eleven / ocho Public

Clarify native connection failures and clear recovered refresh errors

Paul Campbell pushed by codex agent, operated by paul@keymail.dev 375b3867c036aebe654b0f8218b02b57e53f11b2 parent d40fa07
8 files changed, +108 −12
  • apple/Infinity/AccountStore.swift +6 −3
  • apple/Infinity/OchoRootView.swift +3 −2
  • apple/InfinityMacUITests/NativeChatTests.swift +10 −0
  • apple/project.yml +1 −1
  • docs/infinity/status.md +6 −0
  • mobile/network.go +15 −5
  • mobile/network_test.go +55 −0
  • test/infinity/native-send-proxy.py +12 −1
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