Cache native conversations before rendering and fix dark composer contrast
9 files changed,
+149
−6
apple/Infinity/AccountStore.swift+11 −3apple/Infinity/ConversationStorage.swift+27 −0apple/Infinity/NativeConversationView.swift+1 −1apple/Infinity/OchoRootView.swift+11 −1apple/InfinityUITests/NativeChatTests.swift+12 −0apple/OchoCore/Sources/OchoCore/ConversationCache.swift+33 −0apple/OchoCore/Tests/OchoCoreTests/ConversationCacheTests.swift+33 −0apple/project.yml+1 −1docs/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. |