Clarify native connection failures and clear recovered refresh errors
8 files changed,
+108
−12
apple/Infinity/AccountStore.swift+6 −3apple/Infinity/OchoRootView.swift+3 −2apple/InfinityMacUITests/NativeChatTests.swift+10 −0apple/project.yml+1 −1docs/infinity/status.md+6 −0mobile/network.go+15 −5mobile/network_test.go+55 −0test/infinity/native-send-proxy.py+12 −1
Intent
diff --git a/apple/Infinity/AccountStore.swift b/apple/Infinity/AccountStore.swift| index 867f875..4311f48 100644 |
| --- a/apple/Infinity/AccountStore.swift |
| +++ b/apple/Infinity/AccountStore.swift |
| @@ -8,6 +8,7 @@ import OchoCore |
| @Published var threads: [OchoCore.Thread] = [] |
| @Published var requests: [Event] = [] |
| @Published var error: String? |
| + @Published var refreshError: String? |
| @Published var busy = false |
| @Published var stage: String? |
| @Published var selected: String? |
| @@ -50,7 +51,7 @@ import OchoCore |
| let view = try await c.refresh() |
| try KeychainIdentity.write(JSONEncoder().encode(id)) |
| if claim != nil { stage = "To infinity!"; try await Task.sleep(for: .milliseconds(800)) } |
| - client = c; identity = id; threads = view.threads; requests = view.requests; try restoreOutgoing() |
| + refreshError = nil; client = c; identity = id; threads = view.threads; requests = view.requests; try restoreOutgoing() |
| } |
| struct Pending: Codable { var identity: Identity; var claim: String } |
| func create(name: String) async { |
| @@ -94,13 +95,15 @@ import OchoCore |
| } |
| func refresh() async throws { |
| guard let client else { return } |
| - let view = try await client.refresh(); threads = view.threads; requests = view.requests; identity = await client.identity; reconcileOutgoing() |
| + let view = try await client.refresh(); threads = view.threads; requests = view.requests; identity = await client.identity; reconcileOutgoing(); refreshError = nil |
| } |
| func poll() async { |
| while !Task.isCancelled { |
| if client != nil && !busy { |
| busy = true |
| - do { try await refresh() } catch { self.error = error.localizedDescription } |
| + do { try await refresh() } catch { |
| + self.refreshError = error.localizedDescription.replacingOccurrences(of: "Please try again.", with: "Retrying…") |
| + } |
| busy = false |
| } |
| try? await Task.sleep(for: .seconds(3)) |
diff --git a/apple/Infinity/OchoRootView.swift b/apple/Infinity/OchoRootView.swift| index d110089..3e7f7de 100644 |
| --- a/apple/Infinity/OchoRootView.swift |
| +++ b/apple/Infinity/OchoRootView.swift |
| @@ -57,9 +57,10 @@ struct OchoRootView: View { |
| } |
| } |
| .safeAreaInset(edge: .bottom) { |
| - if let error = account.error { |
| - HStack { Text(error).font(.callout); Spacer(); Button("Dismiss") { account.error = nil } } |
| + if let error = account.error ?? account.refreshError { |
| + HStack { Text(error).font(.callout); Spacer(); Button("Dismiss") { if account.error != nil { account.error = nil } else { account.refreshError = nil } } } |
| .padding().background(.regularMaterial).accessibilityElement(children: .combine) |
| + .accessibilityIdentifier("account-status") |
| } |
| } |
| .sheet(item: $sheet) { value in |
diff --git a/apple/InfinityMacUITests/NativeChatTests.swift b/apple/InfinityMacUITests/NativeChatTests.swift| index 75966bf..e040dfe 100644 |
| --- a/apple/InfinityMacUITests/NativeChatTests.swift |
| +++ b/apple/InfinityMacUITests/NativeChatTests.swift |
| @@ -55,6 +55,16 @@ import OchoCore |
| field.click(); field.typeText("Composer alignment") |
| let sendFrame = app.buttons["Send message"].frame |
| XCTAssertLessThanOrEqual(abs(field.frame.midY - sendFrame.midY), 2) |
| + fault.url = URL(string: "http://localhost:18873/__test/fail-refresh")! |
| + _ = try await URLSession.shared.data(for: fault) |
| + let banner = app.descendants(matching: .any)["account-status"].firstMatch |
| + XCTAssertTrue(banner.waitForExistence(timeout: 15), app.debugDescription) |
| + XCTAssertTrue(banner.label.contains("Ocho is having a server problem (HTTP 503). Retrying…"), banner.debugDescription) |
| + fault.url = URL(string: "http://localhost:18873/__test/recover-refresh")! |
| + _ = try await URLSession.shared.data(for: fault) |
| + let recovered = expectation(for: NSPredicate { _, _ in !banner.exists }, evaluatedWith: nil) |
| + await fulfillment(of: [recovered], timeout: 15) |
| + XCTAssertEqual(field.value as? String, "Composer alignment") |
| let attachment = XCTAttachment(screenshot: app.screenshot()); attachment.lifetime = .keepAlways; add(attachment) |
| } |
| } |
diff --git a/apple/project.yml b/apple/project.yml| index cd37537..cb9dbcf 100644 |
| --- a/apple/project.yml |
| +++ b/apple/project.yml |
| @@ -8,7 +8,7 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| MARKETING_VERSION: '0.2.0' |
| - CURRENT_PROJECT_VERSION: '9' |
| + CURRENT_PROJECT_VERSION: '10' |
| GENERATE_INFOPLIST_FILE: YES |
| ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon |
| CODE_SIGN_STYLE: Automatic |
diff --git a/docs/infinity/status.md b/docs/infinity/status.md| index d1ca682..9c4675b 100644 |
| --- a/docs/infinity/status.md |
| +++ b/docs/infinity/status.md |
| @@ -1,5 +1,11 @@ |
| # 🤖 Ocho status — 2026-09-08 |
| +## Native connection status — September 8 |
| + |
| +Native transport now distinguishes connection failures, interrupted responses and HTTP server errors from explicit authentication errors. Background refresh has a separate error banner, cleared by successful synchronization without clearing unrelated operation errors. The old account-unavailable banner covered both transport failures and non-JSON HTTP failures and persisted after recovery; available logs did not identify which triggered the reported screenshot. No account restriction was established. |
| + |
| +Mac 0.2.0 build 10 is installed at `/Applications/Ocho.app`, superseding build 9. Signed iPhone build 10 compiled locally; no physical-device installation. Shared Go race tests cover connection refusal, interrupted bodies, HTML/JSON server errors and authentication responses. The actual Mac UI test forces a 503 outage, verifies the retry banner and automatic clearing after recovery, and preserves the draft. Existing sending/keyboard checks and `tools/infinity/check all` pass. These are native changes; hosted releases remain unchanged. |
| + |
| ## Composer alignment — September 8 |
| Home release `64d67031` is live at ocho.chat, verified by its version response after a scoped restart. The personal-account service remains on sending release `3a34741a`. Message fields are white with dark text in both colour schemes. The web field is 46 CSS pixels high; live Chromium and WebKit checks measure zero vertical offset for both the text line and send button. Multiline growth/shrink checks pass. The Mac editor uses its font's actual line height with symmetric padding; the native UI alignment and keyboard/retry tests pass, and its screenshot was inspected. |
diff --git a/mobile/network.go b/mobile/network.go| index 906a3bb..5a585d2 100644 |
| --- a/mobile/network.go |
| +++ b/mobile/network.go |
| @@ -5,6 +5,7 @@ import ( |
| "bytes" |
| "encoding/json" |
| "errors" |
| + "fmt" |
| "io" |
| "net/http" |
| "net/url" |
| @@ -54,12 +55,18 @@ func request(origin, path, auth, method string, body any, out any) error { |
| } |
| res, e := transport.Do(req) |
| if e != nil { |
| - return errors.New("Your account is temporarily unavailable. Please try again.") |
| + return errors.New("Can’t connect to Ocho right now. Please try again.") |
| } |
| defer res.Body.Close() |
| data, e := io.ReadAll(io.LimitReader(res.Body, (16<<20)+1)) |
| - if e != nil || len(data) > 16<<20 { |
| - return errors.New("Your account response is unavailable.") |
| + if e != nil { |
| + return errors.New("The connection to Ocho was interrupted. Please try again.") |
| + } |
| + if len(data) > 16<<20 { |
| + return errors.New("Ocho returned an unexpected response. Please try again.") |
| + } |
| + if res.StatusCode >= 500 { |
| + return fmt.Errorf("Ocho is having a server problem (HTTP %d). Please try again.", res.StatusCode) |
| } |
| if res.StatusCode < 200 || res.StatusCode >= 300 { |
| var failure struct { |
| @@ -69,12 +76,15 @@ func request(origin, path, auth, method string, body any, out any) error { |
| if failure.Error != "" { |
| return errors.New(failure.Error) |
| } |
| - return errors.New("Your account is temporarily unavailable. Please try again.") |
| + return fmt.Errorf("Ocho couldn’t complete the request (HTTP %d). Please try again.", res.StatusCode) |
| } |
| if out == nil { |
| return nil |
| } |
| - return json.Unmarshal(data, out) |
| + if e := json.Unmarshal(data, out); e != nil { |
| + return errors.New("Ocho returned an unexpected response. Please try again.") |
| + } |
| + return nil |
| } |
| // RequestJSON exposes bounded HTTPS/localhost JSON APIs for platform-owned passkey ceremonies. |
diff --git a/mobile/network_test.go b/mobile/network_test.go| new file mode 100644 |
| index 0000000..7cb04a1 |
| --- /dev/null |
| +++ b/mobile/network_test.go |
| @@ -0,0 +1,55 @@ |
| +// 🤖 Transport failures must describe connectivity or service trouble, not account restrictions. |
| +package ocho |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestRequestFailureMessages(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + status int |
| + body, want string |
| + }{ |
| + {"server HTML", 503, "<html>Unavailable</html>", "server problem (HTTP 503)"}, |
| + {"server JSON", 500, `{"error":"internal detail"}`, "server problem (HTTP 500)"}, |
| + {"auth", 401, `{"error":"Please sign in again."}`, "Please sign in again."}, |
| + {"non JSON auth", 403, "Forbidden", "request (HTTP 403)"}, |
| + {"invalid response", 200, "not JSON", "unexpected response"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(tc.status) |
| + _, _ = w.Write([]byte(tc.body)) |
| + })) |
| + defer server.Close() |
| + var out any |
| + err := request(server.URL, "/api/vault", "", "GET", nil, &out) |
| + if err == nil || !strings.Contains(err.Error(), tc.want) { |
| + t.Fatalf("got %v, want %q", err, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| +func TestRequestConnectionFailure(t *testing.T) { |
| + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) |
| + server.Close() |
| + err := request(server.URL, "/api/vault", "", "GET", nil, nil) |
| + if err == nil || !strings.Contains(err.Error(), "Can’t connect to Ocho") { |
| + t.Fatalf("got %v", err) |
| + } |
| +} |
| +func TestRequestInterruptedResponse(t *testing.T) { |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Length", "100") |
| + _, _ = w.Write([]byte("{")) |
| + })) |
| + defer server.Close() |
| + err := request(server.URL, "/api/vault", "", "GET", nil, nil) |
| + if err == nil || !strings.Contains(err.Error(), "connection to Ocho was interrupted") { |
| + t.Fatalf("got %v", err) |
| + } |
| +} |
diff --git a/test/infinity/native-send-proxy.py b/test/infinity/native-send-proxy.py| index 5982d0c..42f1caa 100644 |
| --- a/test/infinity/native-send-proxy.py |
| +++ b/test/infinity/native-send-proxy.py |
| @@ -6,6 +6,7 @@ import threading |
| import time |
| armed = False |
| +refresh_failure = False |
| lock = threading.Lock() |
| class Proxy(http.server.BaseHTTPRequestHandler): |
| def log_message(self, *_): pass |
| @@ -13,11 +14,21 @@ class Proxy(http.server.BaseHTTPRequestHandler): |
| def do_POST(self): self.forward() |
| def do_PUT(self): self.forward() |
| def forward(self): |
| - global armed |
| + global armed, refresh_failure |
| body = self.rfile.read(int(self.headers.get('Content-Length', 0))) |
| if self.path == '/__test/fail-next-send': |
| with lock: armed = True |
| self.send_response(200); self.end_headers(); return |
| + if self.path == '/__test/fail-refresh': |
| + with lock: refresh_failure = True |
| + self.send_response(200); self.end_headers(); return |
| + if self.path == '/__test/recover-refresh': |
| + with lock: refresh_failure = False |
| + self.send_response(200); self.end_headers(); return |
| + with lock: |
| + unavailable = refresh_failure and self.command == 'GET' |
| + if unavailable: |
| + self.send_response(503); self.end_headers(); self.wfile.write(b'Service unavailable'); return |
| with lock: |
| fail = armed and self.path == '/api/send' |
| if fail: armed = False |