eleven / ocho Public

Cache native conversations before rendering and fix dark composer contrast

Paul Campbell pushed by codex agent, operated by paul@keymail.dev 20b5f3d127cde0fc5d0b5040410e24725d57ef0b parent e4f167d
9 files changed, +149 −6
  • apple/Infinity/AccountStore.swift +11 −3
  • apple/Infinity/ConversationStorage.swift +27 −0
  • apple/Infinity/NativeConversationView.swift +1 −1
  • apple/Infinity/OchoRootView.swift +11 −1
  • apple/InfinityUITests/NativeChatTests.swift +12 −0
  • apple/OchoCore/Sources/OchoCore/ConversationCache.swift +33 −0
  • apple/OchoCore/Tests/OchoCoreTests/ConversationCacheTests.swift +33 −0
  • apple/project.yml +1 −1
  • docs/infinity/status.md +20 −0
diff --git a/apple/Infinity/AccountStore.swift b/apple/Infinity/AccountStore.swift
index 4311f48..f537128 100644
--- a/apple/Infinity/AccountStore.swift
+++ b/apple/Infinity/AccountStore.swift
@@ -6,6 +6,7 @@ import OchoCore
@MainActor final class AccountStore: ObservableObject {
@Published var identity: Identity?
@Published var threads: [OchoCore.Thread] = []
+ @Published var hasLoadedConversations = false
@Published var requests: [Event] = []
@Published var error: String?
@Published var refreshError: String?
@@ -22,7 +23,7 @@ import OchoCore
if let encoded = ProcessInfo.processInfo.environment["OCHO_UI_IDENTITY"],
let data = try? Data(base64URL: encoded), let id = try? JSONDecoder().decode(Identity.self, from: data),
(try? id.validate()) != nil, URL(string: id.origin)?.host == "localhost" {
- identity = id; client = try? OchoClient(identity: id); return
+ identity = id; client = try? OchoClient(identity: id); restoreConversations(); return
}
#endif
do {
@@ -37,6 +38,7 @@ import OchoCore
try KeychainIdentity.write(JSONEncoder().encode(id)); identity = id; client = try OchoClient(identity: id)
}
}
+ restoreConversations()
try restoreOutgoing()
} catch { self.error = error.localizedDescription }
}
@@ -51,7 +53,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)) }
- refreshError = nil; client = c; identity = id; threads = view.threads; requests = view.requests; try restoreOutgoing()
+ refreshError = nil; client = c; identity = id; threads = view.threads; requests = view.requests; hasLoadedConversations = true; saveConversations(view, identity: id); try restoreOutgoing()
}
struct Pending: Codable { var identity: Identity; var claim: String }
func create(name: String) async {
@@ -95,9 +97,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(); refreshError = nil
+ let view = try await client.refresh(); threads = view.threads; requests = view.requests; identity = await client.identity; hasLoadedConversations = true; if let identity { saveConversations(view, identity: identity) }; reconcileOutgoing(); refreshError = nil
}
func poll() async {
+ #if DEBUG
+ if ProcessInfo.processInfo.environment["OCHO_UI_IDENTITY"] != nil,
+ let delay = ProcessInfo.processInfo.environment["OCHO_UI_REFRESH_DELAY"].flatMap(Double.init) {
+ try? await Task.sleep(for: .seconds(min(max(delay, 0), 30)))
+ }
+ #endif
while !Task.isCancelled {
if client != nil && !busy {
busy = true
diff --git a/apple/Infinity/ConversationStorage.swift b/apple/Infinity/ConversationStorage.swift
new file mode 100644
index 0000000..d71fc42
--- /dev/null
+++ b/apple/Infinity/ConversationStorage.swift
@@ -0,0 +1,27 @@
+// 🤖 Restore cached rows before SwiftUI first renders; refresh never clears them.
+import Foundation
+import OchoCore
+
+extension AccountStore {
+ private var conversationCache: ConversationCache {
+ let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ return ConversationCache(directory: root.appendingPathComponent(Bundle.main.bundleIdentifier ?? "chat.ocho.app").appendingPathComponent("Conversations"))
+ }
+ func restoreConversations() {
+ guard let identity else { return }
+ do {
+ if let view = try conversationCache.load(identity: identity) {
+ threads = view.threads; requests = view.requests
+ // An empty snapshot may be stale; confirm it with the server.
+ hasLoadedConversations = !threads.isEmpty || !requests.isEmpty
+ }
+ } catch {
+ // Unreadable cache is not evidence of an empty account. Refresh repairs it.
+ hasLoadedConversations = false
+ }
+ }
+ func saveConversations(_ view: Projection, identity: Identity) {
+ do { try conversationCache.save(view, identity: identity) }
+ catch { self.error = "Could not save conversations on this device. They remain available while Ocho is open." }
+ }
+}
diff --git a/apple/Infinity/NativeConversationView.swift b/apple/Infinity/NativeConversationView.swift
index 628a785..e030365 100644
--- a/apple/Infinity/NativeConversationView.swift
+++ b/apple/Infinity/NativeConversationView.swift
@@ -76,7 +76,7 @@ struct NativeConversationView: View {
.overlay(alignment: .leading) { if draft.isEmpty { Text("Message").foregroundStyle(Color.gray).allowsHitTesting(false) } }
.padding(.horizontal, 14).padding(.vertical, 9).background(Color.white, in: RoundedRectangle(cornerRadius: 18)).overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color.gray.opacity(0.25), lineWidth: 1))
#else
- TextField("Message", text: $draft, axis: .vertical).foregroundStyle(Color.black).lineLimit(1...6).textFieldStyle(.plain).padding(.horizontal, 14).padding(.vertical, 9).background(Color.white, in: RoundedRectangle(cornerRadius: 18)).overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color.gray.opacity(0.25), lineWidth: 1)).focused($composerFocused)
+ TextField("Message", text: $draft, prompt: Text("Message").foregroundStyle(Color.gray), axis: .vertical).foregroundStyle(Color.black).lineLimit(1...6).textFieldStyle(.plain).padding(.horizontal, 14).padding(.vertical, 9).background(Color.white, in: RoundedRectangle(cornerRadius: 18)).overlay(RoundedRectangle(cornerRadius: 18).strokeBorder(Color.gray.opacity(0.25), lineWidth: 1)).focused($composerFocused)
#endif
if !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Button(action: send) { Image(systemName: "arrow.up.circle.fill").font(.system(size: 32)) }
diff --git a/apple/Infinity/OchoRootView.swift b/apple/Infinity/OchoRootView.swift
index 3e7f7de..beaab46 100644
--- a/apple/Infinity/OchoRootView.swift
+++ b/apple/Infinity/OchoRootView.swift
@@ -38,7 +38,17 @@ struct OchoRootView: View {
.navigationSplitViewColumnWidth(min: 260, ideal: 320, max: 420)
.navigationTitle("Ocho")
.searchable(text: $search, prompt: "Search chats")
- .overlay { if account.conversationThreads.isEmpty && account.requests.isEmpty { ContentUnavailableView("Your people, right here", systemImage: "bubble.left.and.bubble.right", description: Text("Share an invitation to start a conversation.")) } }
+ .overlay {
+ if account.conversationThreads.isEmpty && account.requests.isEmpty {
+ if account.hasLoadedConversations {
+ ContentUnavailableView("Your people, right here", systemImage: "bubble.left.and.bubble.right", description: Text("Share an invitation to start a conversation."))
+ } else if account.refreshError != nil {
+ ContentUnavailableView("Couldn’t load conversations", systemImage: "wifi.exclamationmark", description: Text("Your conversations will appear when Ocho reconnects."))
+ } else {
+ ProgressView("Loading conversations…").accessibilityIdentifier("conversations-loading")
+ }
+ }
+ }
.toolbar {
ToolbarItem { Menu {
Button("Invite someone", systemImage: "square.and.arrow.up") { Task { await account.perform { shareURL = try await account.invite() } } }
diff --git a/apple/InfinityUITests/NativeChatTests.swift b/apple/InfinityUITests/NativeChatTests.swift
index 9f32a12..bd76ee1 100644
--- a/apple/InfinityUITests/NativeChatTests.swift
+++ b/apple/InfinityUITests/NativeChatTests.swift
@@ -13,7 +13,11 @@ import OchoCore
try await b.message(thread: thread, text: "Hello from the other native client")
let app = XCUIApplication()
app.launchEnvironment["OCHO_UI_IDENTITY"] = try JSONEncoder().encode(me).base64URL
+ app.launchEnvironment["OCHO_UI_REFRESH_DELAY"] = "15"
+ app.launchArguments += ["-AppleInterfaceStyle", "Dark"]
app.launch()
+ XCTAssertTrue(app.descendants(matching: .any)["conversations-loading"].firstMatch.waitForExistence(timeout: 3))
+ XCTAssertFalse(app.staticTexts["Your people, right here"].exists)
XCTAssertTrue(app.staticTexts["Native UI Bob"].firstMatch.waitForExistence(timeout: 20))
app.staticTexts["Native UI Bob"].firstMatch.tap()
XCTAssertTrue(app.staticTexts["Hello from the other native client"].waitForExistence(timeout: 10))
@@ -25,6 +29,14 @@ import OchoCore
let received = try await b.refresh()
XCTAssertEqual(received.threads.first?.messages.last?.text, "Sent from the native SwiftUI composer")
XCTAssertEqual(app.webViews.count, 0)
+ app.terminate()
+ app.launchEnvironment["OCHO_UI_REFRESH_DELAY"] = "30"
+ app.launch()
+ XCTAssertTrue(app.staticTexts["Native UI Bob"].firstMatch.waitForExistence(timeout: 5))
+ XCTAssertFalse(app.descendants(matching: .any)["conversations-loading"].firstMatch.exists)
+ XCTAssertFalse(app.staticTexts["Your people, right here"].exists)
+ app.staticTexts["Native UI Bob"].firstMatch.tap()
+ XCTAssertTrue(app.staticTexts["Sent from the native SwiftUI composer"].waitForExistence(timeout: 5))
let attachment = XCTAttachment(screenshot: app.screenshot()); attachment.lifetime = .keepAlways; add(attachment)
}
}
diff --git a/apple/OchoCore/Sources/OchoCore/ConversationCache.swift b/apple/OchoCore/Sources/OchoCore/ConversationCache.swift
new file mode 100644
index 0000000..b131372
--- /dev/null
+++ b/apple/OchoCore/Sources/OchoCore/ConversationCache.swift
@@ -0,0 +1,33 @@
+// 🤖 Local-only encrypted projection. Identity and usable keys remain in Keychain.
+import Foundation
+
+public struct ConversationCache {
+ public let directory: URL
+ public init(directory: URL) { self.directory = directory }
+ private struct Snapshot: Codable {
+ var purpose = "ocho-conversations-v1"
+ var origin: String
+ var signing: String
+ var view: Projection
+ }
+ private func file(_ identity: Identity) -> URL {
+ directory.appendingPathComponent(Data(identity.signing.utf8).base64URL + ".cache")
+ }
+ public func load(identity: Identity) throws -> Projection? {
+ let url = file(identity)
+ guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+ let text = String(decoding: try Data(contentsOf: url), as: UTF8.self)
+ let snapshot = try Crypto.unlock(text, key: identity.vaultKey, as: Snapshot.self)
+ guard snapshot.purpose == "ocho-conversations-v1", snapshot.origin == identity.origin,
+ snapshot.signing == identity.signing else { throw OchoError.invalid("Saved conversations belong to another account.") }
+ return snapshot.view
+ }
+ public func save(_ view: Projection, identity: Identity) throws {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ var directory = directory
+ var values = URLResourceValues(); values.isExcludedFromBackup = true
+ try directory.setResourceValues(values)
+ let text = try Crypto.lock(Snapshot(origin: identity.origin, signing: identity.signing, view: view), key: identity.vaultKey)
+ try Data(text.utf8).write(to: file(identity), options: .atomic)
+ }
+}
diff --git a/apple/OchoCore/Tests/OchoCoreTests/ConversationCacheTests.swift b/apple/OchoCore/Tests/OchoCoreTests/ConversationCacheTests.swift
new file mode 100644
index 0000000..bb18d85
--- /dev/null
+++ b/apple/OchoCore/Tests/OchoCoreTests/ConversationCacheTests.swift
@@ -0,0 +1,33 @@
+// 🤖 Relaunch, true-empty, account separation and ciphertext-only cache checks.
+import XCTest
+@testable import OchoCore
+
+final class ConversationCacheTests: XCTestCase {
+ func testRelaunchAndAccountSeparation() throws {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let identity = try Identity.create(origin: "https://cache.example", name: "Cache test")
+ let cache = ConversationCache(directory: directory)
+ XCTAssertNil(try cache.load(identity: identity))
+ var view = try JSONDecoder().decode(Projection.self, from: Data("{\"threads\":[],\"requests\":[{\"kind\":\"invite\",\"thread\":\"private-cache-thread\",\"at\":123,\"text\":\"private cache message\"}]}".utf8))
+ view.threads = [Thread(id: "cached-thread", name: "Cached peer", members: [], pending: false,
+ messages: [Message(id: "cached-message", text: "private cached message", at: 123, mine: true, name: "Me", signer: identity.signing)])]
+ try cache.save(view, identity: identity)
+ let relaunched = ConversationCache(directory: directory)
+ XCTAssertEqual(try relaunched.load(identity: identity)?.requests.first?.text, "private cache message")
+ XCTAssertEqual(try relaunched.load(identity: identity)?.threads.first?.messages.first?.text, "private cached message")
+ let file = try XCTUnwrap(FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).first)
+ let bytes = try Data(contentsOf: file)
+ XCTAssertFalse(String(decoding: bytes, as: UTF8.self).contains("private cache"))
+ let other = try Identity.create(origin: "https://other.example", name: "Other")
+ XCTAssertNil(try relaunched.load(identity: other))
+ var wrongKey = identity; wrongKey.vaultKey = Crypto.random()
+ XCTAssertThrowsError(try relaunched.load(identity: wrongKey))
+ var wrongOrigin = identity; wrongOrigin.origin = "https://other.example"
+ XCTAssertThrowsError(try relaunched.load(identity: wrongOrigin))
+ try Data("corrupt".utf8).write(to: file)
+ XCTAssertThrowsError(try relaunched.load(identity: identity))
+ try cache.save(Projection(), identity: identity)
+ XCTAssertEqual(try relaunched.load(identity: identity)?.threads.count, 0)
+ }
+}
diff --git a/apple/project.yml b/apple/project.yml
index 350fe61..ecffa59 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: '11'
+ CURRENT_PROJECT_VERSION: '12'
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 1e6f350..b24a532 100644
--- a/docs/infinity/status.md
+++ b/docs/infinity/status.md
@@ -147,3 +147,23 @@ Apple also warned that the Go engine framework lacks a matching dSYM.
The existing `ios/Infinity.ipa` and its checksum describe the earlier export before
the orientation correction. The corrected `Infinity.xcarchive` was exported directly
to Apple; that upload did not save a replacement local IPA.
+
+## Native startup and composer correction (Codex)
+
+The conversation projection is cached locally as authenticated ciphertext using
+the account vault key, scoped to signing identity and origin. No plaintext cache
+or new server metadata is introduced. Cached threads load before the first UI
+render; only a successful server refresh establishes an empty account. Missing
+or unreadable cache shows loading (or a connection failure after a failed fetch),
+never the empty invitation screen. Existing threads remain visible during refresh.
+iPhone composer keeps its white background and black input text; its placeholder
+gets an explicit gray color to remain readable in dark mode.
+
+Build 12's signed iPhone archive and TestFlight upload succeeded. The shared Mac
+app compiles. Full server/browser checks pass, as do encrypted-cache tests and
+Go/Web Crypto/Swift interoperability (the standalone Swift run skips its optional
+real-process integration test). Actual iPhone UI automation passes uncached loading,
+no false empty state, sending and cached thread/message restoration on relaunch
+while refresh is delayed. System-dark visual verification is being completed
+separately because the initial XCTest launch argument did not change appearance.
+Apple processing and beta distribution for build 12 remain pending.