From 4114871c272754fe9d247010b637e1b9be9b24e3 Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Tue, 29 Sep 2026 12:43:16 -0400 Subject: [PATCH] mobile: sync saved computers across devices; faster reconnect - Saved Kittylitter and direct-URL computers sync through iCloud KVS. The merge is a last-write-wins element set in Rust (cloud_sync::computers), so concurrent adds survive and removals do not come back. Local Studio, SSH and ChatGPT-connected computers stay on the device. - Kittylitter tokens for synced computers move to iCloud Keychain; every other token stays device-only. Token reads are cached in memory instead of hitting (and rewriting) the Keychain on every reconnect. - Reconnect: concurrent triggers wait for the in-flight pass instead of returning empty, and foreground account probes run detached with a 10s deadline so they no longer hold up the reconnect result. - Remove the add-server first-run hint on iOS and Android. - Build: cap the shared Cargo target (default 60GB) before Rust builds and keep host dev builds to line-table debug info. Co-Authored-By: Claude Opus 5.5 (1M context) --- Makefile | 9 +- .../android/ui/home/OnboardingCoachmarks.kt | 11 - .../Models/AlleycatCredentialStore.swift | 108 +++++-- .../Litter/Models/CloudKVSBridge.swift | 2 + .../Litter/Models/SavedServerStore.swift | 88 ++++++ .../Litter/Views/AlleycatAddServerSheet.swift | 8 +- .../Litter/Views/OnboardingCoachmarks.swift | 23 +- .../LitterTests/SavedServerStoreTests.swift | 43 +++ shared/rust-bridge/Cargo.lock | 1 - shared/rust-bridge/Cargo.toml | 5 + .../src/cloud_sync/computers.rs | 270 ++++++++++++++++++ .../codex-mobile-client/src/cloud_sync/mod.rs | 5 + .../codex-mobile-client/src/ffi/reconnect.rs | 109 +++---- tools/scripts/prune-rust-target.sh | 38 +++ 14 files changed, 615 insertions(+), 105 deletions(-) create mode 100644 shared/rust-bridge/codex-mobile-client/src/cloud_sync/computers.rs create mode 100755 tools/scripts/prune-rust-target.sh diff --git a/Makefile b/Makefile index 996f0c7e0..553916b60 100644 --- a/Makefile +++ b/Makefile @@ -219,7 +219,7 @@ ANDROID_RUST_SOURCES := $(shell find $(RUST_DIR) \ $(shell mkdir -p $(STAMPS)) -.PHONY: all ios ios-sim ios-sim-fast ios-sim-run ios-sim-launch ios-device ios-device-fast ios-device-run ios-device-launch ios-device-stop ios-run verify-ios-project catalyst catalyst-run catalyst-fast catalyst-fast-run mac-direct mac-direct-run mac-direct-fast mac-direct-fast-run \ +.PHONY: rust-target-prune all ios ios-sim ios-sim-fast ios-sim-run ios-sim-launch ios-device ios-device-fast ios-device-run ios-device-launch ios-device-stop ios-run verify-ios-project catalyst catalyst-run catalyst-fast catalyst-fast-run mac-direct mac-direct-run mac-direct-fast mac-direct-fast-run \ android android-fast android-emulator-fast android-emulator-run android-device-run android-release android-debug android-install android-emulator-install \ rust-ios rust-ios-package rust-ios-device-release rust-mac-release rust-ios-device-fast rust-ios-sim-fast rust-ios-macabi-fast rust-android rust-check rust-test rust-host-dev \ ios-xcode-sim ios-xcode-sim-fast ios-xcode-device ios-xcode-device-fast \ @@ -440,7 +440,12 @@ android-release: $(MATERIAL_SCHEMES_OUTPUT) android-alpine-fs proot-android rust-ios: rust-ios-package -alleycat-main: +# Every Rust build depends on alleycat-main, so the target size cap runs +# before each one. Override with LITTER_RUST_TARGET_MAX_GB (0 disables). +rust-target-prune: + @$(ROOT)/tools/scripts/prune-rust-target.sh "$(RUST_TARGET)" + +alleycat-main: rust-target-prune @$(UPDATE_ALLEYCAT_MAIN) --all rust-ios-package: alleycat-main $(STAMP_SYNC) $(STAMP_GHOSTTY_IOS) diff --git a/apps/android/app/src/main/java/com/litter/android/ui/home/OnboardingCoachmarks.kt b/apps/android/app/src/main/java/com/litter/android/ui/home/OnboardingCoachmarks.kt index 25da16be9..2facaf436 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/home/OnboardingCoachmarks.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/home/OnboardingCoachmarks.kt @@ -72,17 +72,6 @@ private data class CoachmarkItem( ) private val coachmarkItems = listOf( - CoachmarkItem( - id = CoachmarkTarget.AddServer, - primary = "add a remote computer", - secondary = "if you have one", - positionX = 0.55f, - positionY = 0.20f, - labelWidth = 200.dp, - labelAlignment = CoachmarkLabelAlignment.Center, - style = CoachmarkLineStyle.SmoothCurve, - isPrimary = false, - ), CoachmarkItem( id = CoachmarkTarget.Search, primary = "see all\nyour threads", diff --git a/apps/ios/Sources/Litter/Models/AlleycatCredentialStore.swift b/apps/ios/Sources/Litter/Models/AlleycatCredentialStore.swift index 452c02112..ff901413c 100644 --- a/apps/ios/Sources/Litter/Models/AlleycatCredentialStore.swift +++ b/apps/ios/Sources/Litter/Models/AlleycatCredentialStore.swift @@ -25,9 +25,22 @@ final class AlleycatCredentialStore { private init() {} + /// Tokens are read on every reconnect for every Kittylitter computer; + /// keep them in memory after the first Keychain hit. + private let cacheLock = NSLock() + private var tokenCache: [String: String] = [:] + func loadToken(nodeId: String) throws -> String? { + let key = normalizedNodeId(nodeId) + cacheLock.lock() + let cached = tokenCache[key] + cacheLock.unlock() + if let cached { return cached } + let query = baseQuery(nodeId: nodeId).merging([ + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, kSecReturnData as String: true, + kSecReturnAttributes as String: true, kSecMatchLimit as String: kSecMatchLimitOne ]) { _, new in new } @@ -35,12 +48,14 @@ final class AlleycatCredentialStore { let status = SecItemCopyMatching(query as CFDictionary, &item) switch status { case errSecSuccess: - guard let data = item as? Data else { throw AlleycatCredentialStoreError.decodingFailed } - guard let token = String(data: data, encoding: .utf8), !token.isEmpty else { + guard let attributes = item as? [String: Any], + let data = attributes[kSecValueData as String] as? Data, + let token = String(data: data, encoding: .utf8), !token.isEmpty else { throw AlleycatCredentialStoreError.decodingFailed } - let updates = [kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly] - _ = SecItemUpdate(baseQuery(nodeId: nodeId) as CFDictionary, updates as CFDictionary) + cacheLock.lock() + tokenCache[key] = token + cacheLock.unlock() return token case errSecItemNotFound: return nil @@ -49,37 +64,86 @@ final class AlleycatCredentialStore { } } - func saveToken(_ token: String, nodeId: String) throws { + /// Saves the token device-only by default. Pass `synchronizable: true` + /// only for computers that sync across devices (Kittylitter hosts, which + /// authenticate by token alone): the token then goes to iCloud Keychain, + /// which is end-to-end encrypted, so the user's other devices can connect. + func saveToken(_ token: String, nodeId: String, synchronizable: Bool = false) throws { guard let data = token.data(using: .utf8) else { throw AlleycatCredentialStoreError.encodingFailed } - let query = baseQuery(nodeId: nodeId) - let attributes = query.merging([ - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + try deleteKeychainItems(nodeId: nodeId) + let attributes = baseQuery(nodeId: nodeId).merging([ + kSecAttrSynchronizable as String: synchronizable, + kSecAttrAccessible as String: synchronizable + ? kSecAttrAccessibleAfterFirstUnlock + : kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, kSecValueData as String: data ]) { _, new in new } - let status = SecItemAdd(attributes as CFDictionary, nil) - if status == errSecDuplicateItem { - let updates: [String: Any] = [ - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - ] - let updateStatus = SecItemUpdate(query as CFDictionary, updates as CFDictionary) - guard updateStatus == errSecSuccess else { - throw AlleycatCredentialStoreError.keychain(updateStatus) - } - return - } - guard status == errSecSuccess else { throw AlleycatCredentialStoreError.keychain(status) } + cacheLock.lock() + tokenCache[normalizedNodeId(nodeId)] = token + cacheLock.unlock() + } + + private var synchronizedNodeIds: Set = [] + + /// Moves a device-only token (saved before iCloud sync) to iCloud + /// Keychain. Called for computers that sync, so a computer that appears + /// on another device also brings its credential. No-op once done. + func makeSynchronizable(nodeId: String) { + let key = normalizedNodeId(nodeId) + cacheLock.lock() + let done = synchronizedNodeIds.contains(key) + cacheLock.unlock() + guard !done else { return } + + let query = baseQuery(nodeId: nodeId).merging([ + kSecAttrSynchronizable as String: false, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ]) { _, new in new } + var item: CFTypeRef? + if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let token = String(data: data, encoding: .utf8), !token.isEmpty { + do { + try saveToken(token, nodeId: nodeId, synchronizable: true) + } catch { + LLog.error("alleycat", "moving token to iCloud Keychain failed", error: error) + return + } + } + cacheLock.lock() + synchronizedNodeIds.insert(key) + cacheLock.unlock() + } + + /// Drop cached tokens so the next read sees iCloud Keychain changes made + /// on another device (for example a re-pair with a new token). + func clearCache() { + cacheLock.lock() + tokenCache.removeAll() + cacheLock.unlock() } func deleteToken(nodeId: String) throws { - let status = SecItemDelete(baseQuery(nodeId: nodeId) as CFDictionary) + cacheLock.lock() + tokenCache[normalizedNodeId(nodeId)] = nil + cacheLock.unlock() + try deleteKeychainItems(nodeId: nodeId) + } + + /// Deletes both the device-only and the iCloud copy. + private func deleteKeychainItems(nodeId: String) throws { + let query = baseQuery(nodeId: nodeId).merging([ + kSecAttrSynchronizable as String: kSecAttrSynchronizableAny + ]) { _, new in new } + let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw AlleycatCredentialStoreError.keychain(status) } diff --git a/apps/ios/Sources/Litter/Models/CloudKVSBridge.swift b/apps/ios/Sources/Litter/Models/CloudKVSBridge.swift index 7dbc3e273..d1efd44c2 100644 --- a/apps/ios/Sources/Litter/Models/CloudKVSBridge.swift +++ b/apps/ios/Sources/Litter/Models/CloudKVSBridge.swift @@ -78,6 +78,7 @@ final class CloudKVSBridge { // initial sync. store.synchronize() applyEnvelopeFromStore() + SavedServerStore.syncWithCloud() scheduleExport() } @@ -121,6 +122,7 @@ final class CloudKVSBridge { ) applyWritebacks(writebacks) lastAppliedEnvelopeHash = hash + SavedServerStore.syncWithCloud() NotificationCenter.default.post(name: .litterThreadPreferencesDidChange, object: nil) } catch { LLog.warn( diff --git a/apps/ios/Sources/Litter/Models/SavedServerStore.swift b/apps/ios/Sources/Litter/Models/SavedServerStore.swift index 42d3c8c58..6d87ee1a2 100644 --- a/apps/ios/Sources/Litter/Models/SavedServerStore.swift +++ b/apps/ios/Sources/Litter/Models/SavedServerStore.swift @@ -17,6 +17,10 @@ enum SavedServerStore { private static var cachedServers: [SavedServer] = [] static func save(_ servers: [SavedServer]) { + persist(reconcileWithCloud(servers)) + } + + private static func persist(_ servers: [SavedServer]) { guard let data = try? JSONEncoder().encode(servers) else { return } UserDefaults.standard.set(data, forKey: savedServersKey) cachedRaw = data @@ -24,6 +28,90 @@ enum SavedServerStore { NotificationCenter.default.post(name: .litterSavedServersDidChange, object: nil) } + // MARK: - iCloud sync + + /// Ledger shared with the user's other devices through `CloudKVSBridge`. + private static let syncedComputersKey = "litter.syncedComputers" + /// This device's copy of the ledger. Not synced. + private static let computerLedgerKey = "litter.syncedComputers.ledger" + + /// Computers that any of the user's devices can reach with what syncs: + /// Kittylitter hosts (token-only auth; tokens sync via iCloud Keychain) + /// and direct Codex URLs. Local Studio grants are bound to one device's + /// key, SSH keys never leave the device, and ChatGPT-connected computers + /// need that device's own sign-in, so those stay local. + static func isCloudSyncable(_ server: SavedServer) -> Bool { + guard server.rememberedByUser, server.source != .local, server.source != .ssh else { return false } + guard !server.id.hasPrefix("slingshot-") else { return false } + guard server.preferredConnectionMode != .ssh, server.alleycatAgentWire != "ssh-bridge" else { return false } + if server.alleycatNodeId != nil { + return !server.id.hasPrefix("alleycat:local-studio:") + } + return server.alleycatHost == nil + } + + /// Re-merge after another device changed the shared ledger. + static func syncWithCloud() { + AlleycatCredentialStore.shared.clearCache() + let current = load() + let merged = reconcileWithCloud(current) + if merged != current { + persist(merged) + } + } + + /// Records this device's syncable computers in the ledger, merges in the + /// ledger from other devices, and returns the saved list the merge + /// implies. The merge policy lives in Rust (`cloud_sync::computers`). + private static func reconcileWithCloud(_ servers: [SavedServer]) -> [SavedServer] { + let defaults = UserDefaults.standard + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + for server in servers where isCloudSyncable(server) { + if let nodeId = server.alleycatNodeId { + AlleycatCredentialStore.shared.makeSynchronizable(nodeId: nodeId) + } + } + let local = servers.filter(isCloudSyncable).compactMap { server -> SyncedComputer? in + guard let data = try? encoder.encode(server), + let json = String(data: data, encoding: .utf8) else { return nil } + return SyncedComputer(id: server.id, payloadJson: json) + } + let remote = defaults.string(forKey: syncedComputersKey) + let result = cloudSyncReconcileComputers( + ledgerJson: defaults.string(forKey: computerLedgerKey), + local: local, + remoteJson: remote, + nowMs: Int64(Date().timeIntervalSince1970 * 1000) + ) + defaults.set(result.ledgerJson, forKey: computerLedgerKey) + if remote != result.ledgerJson { + // Observed by CloudKVSBridge, which pushes it to other devices. + defaults.set(result.ledgerJson, forKey: syncedComputersKey) + } + + let decoder = JSONDecoder() + var synced: [String: SavedServer] = [:] + for computer in result.computers { + guard let data = computer.payloadJson.data(using: .utf8), + let server = try? decoder.decode(SavedServer.self, from: data) else { continue } + synced[computer.id] = server + } + // Keep this device's order; computers added elsewhere go last. + var merged: [SavedServer] = [] + for server in servers { + if isCloudSyncable(server) { + if let updated = synced.removeValue(forKey: server.id) { + merged.append(updated) + } + } else { + merged.append(server) + } + } + merged.append(contentsOf: synced.values.sorted { $0.name < $1.name }) + return merged + } + static func load() -> [SavedServer] { guard let data = UserDefaults.standard.data(forKey: savedServersKey) else { cachedRaw = nil diff --git a/apps/ios/Sources/Litter/Views/AlleycatAddServerSheet.swift b/apps/ios/Sources/Litter/Views/AlleycatAddServerSheet.swift index b24ea7417..65ea1d17e 100644 --- a/apps/ios/Sources/Litter/Views/AlleycatAddServerSheet.swift +++ b/apps/ios/Sources/Litter/Views/AlleycatAddServerSheet.swift @@ -494,7 +494,13 @@ struct AlleycatAddServerSheet: View { // saved-server record. Reconnect re-reads the token and surfaces // a re-pair prompt if it is genuinely missing. do { - try AlleycatCredentialStore.shared.saveToken(params.token, nodeId: params.nodeId) + // Kittylitter computers sync to the user's other devices; + // Local Studio grants are bound to this device's key. + try AlleycatCredentialStore.shared.saveToken( + params.token, + nodeId: params.nodeId, + synchronizable: pairingMode == .kittylitter + ) } catch { LLog.error("alleycat", "keychain save failed after successful pair", error: error) } diff --git a/apps/ios/Sources/Litter/Views/OnboardingCoachmarks.swift b/apps/ios/Sources/Litter/Views/OnboardingCoachmarks.swift index 32a9e97ef..bfa1f6cec 100644 --- a/apps/ios/Sources/Litter/Views/OnboardingCoachmarks.swift +++ b/apps/ios/Sources/Litter/Views/OnboardingCoachmarks.swift @@ -28,11 +28,9 @@ extension View { /// Quiet first-run hints for the empty home screen. /// /// Design rules: mono footnote (follows Dynamic Type), gray, lowercase, no -/// accent colour. The add-server hint hangs directly below the server pill -/// with a thin straight hairline; the bottom-bar hints are one left-aligned -/// block anchored above the bottom buttons. Every hint wraps within the -/// container's 16pt gutters, so nothing clips or overlaps at any iPhone -/// width or text size. +/// accent colour. The bottom-bar hints are one left-aligned block anchored +/// above the bottom buttons, wrapping within the container's 16pt gutters so +/// nothing clips or overlaps at any iPhone width or text size. struct OnboardingCoachmarksView: View { let anchors: [CoachmarkTarget: Anchor] @@ -42,9 +40,6 @@ struct OnboardingCoachmarksView: View { GeometryReader { proxy in let size = proxy.size ZStack(alignment: .topLeading) { - if let anchor = anchors[.addServer] { - addServerHint(target: proxy[anchor], container: size) - } bottomHints(container: size, bottomTop: bottomBarTop(in: proxy)) } .frame(width: size.width, height: size.height, alignment: .topLeading) @@ -60,18 +55,6 @@ struct OnboardingCoachmarksView: View { return tops.min() ?? (proxy.size.height - 72) } - private func addServerHint(target: CGRect, container: CGSize) -> some View { - let x = max(gutter, target.minX + 12) - return VStack(alignment: .leading, spacing: 6) { - Rectangle() - .fill(LitterTheme.textMuted.opacity(0.5)) - .frame(width: 1, height: 18) - hint("add a remote computer, if you have one") - } - .frame(width: max(0, container.width - x - gutter), alignment: .leading) - .offset(x: x, y: target.maxY + 6) - } - private func bottomHints(container: CGSize, bottomTop: CGFloat) -> some View { VStack(alignment: .leading, spacing: 6) { hint("+ start a thread, or just type") diff --git a/apps/ios/Tests/LitterTests/SavedServerStoreTests.swift b/apps/ios/Tests/LitterTests/SavedServerStoreTests.swift index 727588b50..6ed25c91c 100644 --- a/apps/ios/Tests/LitterTests/SavedServerStoreTests.swift +++ b/apps/ios/Tests/LitterTests/SavedServerStoreTests.swift @@ -24,6 +24,49 @@ final class SavedServerStoreTests: XCTestCase { ) } + func testOnlyKittylitterAndDirectURLComputersSyncAcrossDevices() { + XCTAssertTrue(SavedServerStore.isCloudSyncable(alleycatServer(id: "alleycat:node-a", nodeId: "node-a"))) + XCTAssertTrue(SavedServerStore.isCloudSyncable(server(id: "manual-ws", websocketURL: "wss://box.example:8390"))) + + // Local Studio grants are bound to one device's key. + XCTAssertFalse(SavedServerStore.isCloudSyncable( + alleycatServer(id: "alleycat:local-studio:node-a", nodeId: "node-a") + )) + // SSH keys never leave the device. + XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "ssh-box", source: .ssh))) + XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "manual-ssh-box:22", mode: .ssh))) + // ChatGPT-connected computers need that device's own sign-in. + XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "slingshot-env", websocketURL: "wss://chatgpt.com/x"))) + // This device, and computers the user never chose to keep. + XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "local", source: .local))) + XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "manual-ws", remembered: false))) + } + + private func server( + id: String, + source: ServerSource = .manual, + mode: PreferredConnectionMode? = .directCodex, + websocketURL: String? = nil, + remembered: Bool = true + ) -> SavedServer { + SavedServer( + id: id, + name: id, + hostname: "box.example", + port: 8390, + codexPorts: [], + sshPort: nil, + source: source, + hasCodexServer: true, + wakeMAC: nil, + preferredConnectionMode: mode, + preferredCodexPort: nil, + sshPortForwardingEnabled: nil, + websocketURL: websocketURL, + rememberedByUser: remembered + ) + } + private func alleycatServer(id: String, nodeId: String) -> SavedServer { SavedServer( id: id, diff --git a/shared/rust-bridge/Cargo.lock b/shared/rust-bridge/Cargo.lock index e41d96f9d..f563afa67 100644 --- a/shared/rust-bridge/Cargo.lock +++ b/shared/rust-bridge/Cargo.lock @@ -3927,7 +3927,6 @@ dependencies = [ name = "codex-slingshot" version = "0.1.0" dependencies = [ - "async-trait", "base64 0.22.1", "chrono", "codex-app-server-client", diff --git a/shared/rust-bridge/Cargo.toml b/shared/rust-bridge/Cargo.toml index 575497ce5..9963b394e 100644 --- a/shared/rust-bridge/Cargo.toml +++ b/shared/rust-bridge/Cargo.toml @@ -52,6 +52,11 @@ reqwest = { version = "0.12", default-features = false, features = ["http2", "js tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] } shlex = "1" +# Host dev builds (cargo check/test, rust-analyzer): line tables only. Full +# per-variable DWARF made target/debug the largest thing on disk. +[profile.dev] +debug = "line-tables-only" + [profile.release] strip = true lto = "fat" diff --git a/shared/rust-bridge/codex-mobile-client/src/cloud_sync/computers.rs b/shared/rust-bridge/codex-mobile-client/src/cloud_sync/computers.rs new file mode 100644 index 000000000..042ee7f3d --- /dev/null +++ b/shared/rust-bridge/codex-mobile-client/src/cloud_sync/computers.rs @@ -0,0 +1,270 @@ +//! Cross-device sync for saved computers. +//! +//! The platform owns the saved-server schema, so each computer travels as an +//! opaque JSON payload keyed by its server id. This module owns the merge: +//! a last-write-wins element set. Every id carries the time it was last +//! written and, if removed, the time it was removed; the later of the two +//! decides whether it is live. Two devices that write concurrently each keep +//! the other's entries after merging, and removals propagate instead of being +//! resurrected by a device that has not seen them yet. +//! +//! The ledger is serialized as canonical JSON (sorted keys) so the platform +//! can skip a publish when the merged ledger equals what it just received. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Tombstones older than this are dropped. A device offline for longer than +/// this could resurrect a removed computer, which is an acceptable trade for +/// keeping the ledger small. +const TOMBSTONE_RETENTION_MS: i64 = 90 * 24 * 60 * 60 * 1000; + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct SyncedComputer { + pub id: String, + /// Platform-encoded saved-server JSON. Compared byte-for-byte, so the + /// platform must encode deterministically (sorted keys). + pub payload_json: String, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct ComputerSyncResult { + /// Canonical ledger JSON to persist locally and publish to other devices. + pub ledger_json: String, + /// Computers that are live after the merge, sorted by id. + pub computers: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +struct Ledger { + #[serde(default)] + entries: BTreeMap, + #[serde(default)] + removed: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct Entry { + payload: String, + at: i64, +} + +impl Ledger { + fn parse(json: Option<&str>) -> Self { + json.filter(|s| !s.trim().is_empty()) + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default() + } + + fn is_live(&self, id: &str) -> bool { + match (self.entries.get(id), self.removed.get(id)) { + (Some(entry), Some(removed_at)) => entry.at > *removed_at, + (Some(_), None) => true, + _ => false, + } + } + + /// Record the device's current computers: new or changed payloads are + /// stamped `now`, and live entries missing from `local` are removed. + fn record_local(&mut self, local: &[SyncedComputer], now_ms: i64) { + for computer in local { + let unchanged = self.is_live(&computer.id) + && self + .entries + .get(&computer.id) + .is_some_and(|entry| entry.payload == computer.payload_json); + if !unchanged { + self.entries.insert( + computer.id.clone(), + Entry { + payload: computer.payload_json.clone(), + at: now_ms, + }, + ); + } + } + let live_ids: Vec = self + .entries + .keys() + .filter(|id| self.is_live(id)) + .cloned() + .collect(); + for id in live_ids { + if !local.iter().any(|computer| computer.id == id) { + self.removed.insert(id, now_ms); + } + } + } + + fn merge(&mut self, other: Ledger) { + for (id, entry) in other.entries { + let newer = self.entries.get(&id).is_none_or(|mine| entry.at > mine.at); + if newer { + self.entries.insert(id, entry); + } + } + for (id, removed_at) in other.removed { + let newer = self.removed.get(&id).is_none_or(|mine| removed_at > *mine); + if newer { + self.removed.insert(id, removed_at); + } + } + } + + fn compact(&mut self, now_ms: i64) { + let dead: Vec = self + .entries + .keys() + .filter(|id| !self.is_live(id)) + .cloned() + .collect(); + for id in dead { + self.entries.remove(&id); + } + self.removed + .retain(|_, removed_at| now_ms - *removed_at < TOMBSTONE_RETENTION_MS); + } + + fn live(&self) -> Vec { + self.entries + .iter() + .filter(|(id, _)| self.is_live(id)) + .map(|(id, entry)| SyncedComputer { + id: id.clone(), + payload_json: entry.payload.clone(), + }) + .collect() + } +} + +pub fn reconcile( + ledger_json: Option<&str>, + local: &[SyncedComputer], + remote_json: Option<&str>, + now_ms: i64, +) -> ComputerSyncResult { + let mut ledger = Ledger::parse(ledger_json); + ledger.record_local(local, now_ms); + if remote_json.is_some() { + ledger.merge(Ledger::parse(remote_json)); + } + ledger.compact(now_ms); + ComputerSyncResult { + ledger_json: serde_json::to_string(&ledger).unwrap_or_default(), + computers: ledger.live(), + } +} + +/// Merge this device's saved computers with the ledger from other devices. +/// +/// * `ledger_json`: this device's last ledger (None on first run). +/// * `local`: the computers currently saved on this device. +/// * `remote_json`: the ledger received from other devices, if any. +/// +/// Returns the merged ledger and the computers that should now be saved. +#[uniffi::export] +pub fn cloud_sync_reconcile_computers( + ledger_json: Option, + local: Vec, + remote_json: Option, + now_ms: i64, +) -> ComputerSyncResult { + reconcile( + ledger_json.as_deref(), + &local, + remote_json.as_deref(), + now_ms, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn computer(id: &str, payload: &str) -> SyncedComputer { + SyncedComputer { + id: id.to_string(), + payload_json: payload.to_string(), + } + } + + fn ids(result: &ComputerSyncResult) -> Vec<&str> { + result.computers.iter().map(|c| c.id.as_str()).collect() + } + + #[test] + fn first_run_keeps_local_computers() { + let result = reconcile(None, &[computer("a", "1")], None, 10); + assert_eq!(ids(&result), vec!["a"]); + } + + #[test] + fn concurrent_adds_on_two_devices_both_survive() { + let a = reconcile(None, &[computer("mac", "m")], None, 10); + let b = reconcile(None, &[computer("pc", "p")], None, 11); + // Device B receives A's ledger after writing its own. + let merged = reconcile( + Some(&b.ledger_json), + &b.computers, + Some(&a.ledger_json), + 12, + ); + assert_eq!(ids(&merged), vec!["mac", "pc"]); + } + + #[test] + fn late_joining_device_does_not_wipe_existing_computers() { + let phone = reconcile(None, &[computer("mac", "m")], None, 10); + // A new iPad with nothing saved publishes first, then sees the phone. + let ipad = reconcile(None, &[], None, 20); + let ipad = reconcile(Some(&ipad.ledger_json), &[], Some(&phone.ledger_json), 21); + assert_eq!(ids(&ipad), vec!["mac"]); + } + + #[test] + fn removal_propagates_and_is_not_resurrected() { + let a = reconcile(None, &[computer("mac", "m")], None, 10); + let b = reconcile(None, &[], Some(&a.ledger_json), 11); + assert_eq!(ids(&b), vec!["mac"]); + // A removes the computer. + let a = reconcile(Some(&a.ledger_json), &[], None, 20); + assert!(a.computers.is_empty()); + // B still has it saved locally but must drop it after merging. + let b = reconcile(Some(&b.ledger_json), &b.computers, Some(&a.ledger_json), 21); + assert!(b.computers.is_empty()); + } + + #[test] + fn re_adding_after_removal_wins() { + let a = reconcile(None, &[computer("mac", "m")], None, 10); + let a = reconcile(Some(&a.ledger_json), &[], None, 20); + let a = reconcile(Some(&a.ledger_json), &[computer("mac", "m2")], None, 30); + assert_eq!(ids(&a), vec!["mac"]); + assert_eq!(a.computers[0].payload_json, "m2"); + } + + #[test] + fn rename_on_one_device_reaches_the_other() { + let a = reconcile(None, &[computer("mac", "old")], None, 10); + let b = reconcile(None, &[], Some(&a.ledger_json), 11); + let a = reconcile(Some(&a.ledger_json), &[computer("mac", "new")], None, 20); + let b = reconcile(Some(&b.ledger_json), &b.computers, Some(&a.ledger_json), 21); + assert_eq!(b.computers[0].payload_json, "new"); + } + + #[test] + fn unchanged_state_produces_identical_ledger() { + let a = reconcile(None, &[computer("mac", "m")], None, 10); + let again = reconcile(Some(&a.ledger_json), &a.computers, Some(&a.ledger_json), 50); + assert_eq!(a.ledger_json, again.ledger_json); + } + + #[test] + fn old_tombstones_are_compacted() { + let a = reconcile(None, &[computer("mac", "m")], None, 0); + let a = reconcile(Some(&a.ledger_json), &[], None, 1); + let later = reconcile(Some(&a.ledger_json), &[], None, TOMBSTONE_RETENTION_MS + 10); + assert_eq!(later.ledger_json, r#"{"entries":{},"removed":{}}"#); + } +} diff --git a/shared/rust-bridge/codex-mobile-client/src/cloud_sync/mod.rs b/shared/rust-bridge/codex-mobile-client/src/cloud_sync/mod.rs index b4c7c8cdc..840bcb73d 100644 --- a/shared/rust-bridge/codex-mobile-client/src/cloud_sync/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/cloud_sync/mod.rs @@ -26,6 +26,8 @@ //! `PLATFORM_KEYS`). Anything not in that set is round-tripped through the //! Rust-owned `mobile_prefs.json` side of the envelope. +pub mod computers; + use std::collections::HashMap; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; @@ -58,6 +60,9 @@ const PLATFORM_KEYS: &[&str] = &[ "collapseTurns", "litter.debugSettings", "litter.experimentalFeatures", + // Saved-computer ledger (see `computers.rs`). A string holding canonical + // ledger JSON; the platform merges it rather than taking it verbatim. + "litter.syncedComputers", ]; #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs b/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs index 05e74aef6..1417a456f 100644 --- a/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs +++ b/shared/rust-bridge/codex-mobile-client/src/ffi/reconnect.rs @@ -20,6 +20,8 @@ use tracing::{info, warn}; /// Max concurrent account probes when the app becomes active. const ACCOUNT_PROBE_CONCURRENCY: usize = 4; +/// Deadline for a single account probe. +const ACCOUNT_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); fn normalized_local_display_name(value: &str) -> Option { let trimmed = value.trim(); @@ -254,51 +256,7 @@ impl ReconnectController { // foreign async executor. let _ = self .rt - .spawn(async move { - let snapshot = inner.app_snapshot(); - let remote_connected: Vec = snapshot - .servers - .values() - .filter(|s| { - !s.is_local - && s.health == ServerHealthSnapshot::Connected - && server_supports_account_probe(s) - }) - .map(|s| s.server_id.clone()) - .collect(); - - futures::stream::iter(remote_connected) - .map(|server_id| { - let inner = Arc::clone(&inner); - async move { - let request = upstream::ClientRequest::GetAccount { - request_id: upstream::RequestId::Integer(next_request_id()), - params: upstream::GetAccountParams { - refresh_token: false, - }, - }; - match inner - .request_typed_for_server::( - &server_id, request, - ) - .await - { - Ok(response) => { - inner.apply_account_response(&server_id, &response); - } - Err(e) => { - warn!( - "ReconnectController: probe failed server_id={} error={}", - server_id, e - ); - } - } - } - }) - .buffer_unordered(ACCOUNT_PROBE_CONCURRENCY) - .collect::>() - .await; - }) + .spawn(probe_active_remote_servers_inner(inner)) .await .inspect_err(|error| { warn!("ReconnectController: probe_active_remote_servers task failed: {error}"); @@ -314,7 +272,10 @@ impl ReconnectController { // run for transports that can't recover on their own. self.notify_network_change().await; let results = self.reconnect_saved_servers().await; - self.probe_active_remote_servers().await; + // Account probes only refresh account state; they must not hold up + // the reconnect result the UI waits on. + let inner = Arc::clone(&self.inner); + self.rt.spawn(probe_active_remote_servers_inner(inner)); results } @@ -387,6 +348,53 @@ impl ReconnectController { } } +/// Refresh account state on every connected remote. Each probe has a +/// deadline because `account/read` has none on the wire, and one hung +/// remote would otherwise hold a probe slot forever. +async fn probe_active_remote_servers_inner(inner: Arc) { + let snapshot = inner.app_snapshot(); + let remote_connected: Vec = snapshot + .servers + .values() + .filter(|s| { + !s.is_local + && s.health == ServerHealthSnapshot::Connected + && server_supports_account_probe(s) + }) + .map(|s| s.server_id.clone()) + .collect(); + + futures::stream::iter(remote_connected) + .map(|server_id| { + let inner = Arc::clone(&inner); + async move { + let request = upstream::ClientRequest::GetAccount { + request_id: upstream::RequestId::Integer(next_request_id()), + params: upstream::GetAccountParams { + refresh_token: false, + }, + }; + let probe = inner.request_typed_for_server::( + &server_id, request, + ); + match tokio::time::timeout(ACCOUNT_PROBE_TIMEOUT, probe).await { + Ok(Ok(response)) => inner.apply_account_response(&server_id, &response), + Ok(Err(e)) => warn!( + "ReconnectController: probe failed server_id={} error={}", + server_id, e + ), + Err(_) => warn!( + "ReconnectController: probe timed out server_id={}", + server_id + ), + } + } + }) + .buffer_unordered(ACCOUNT_PROBE_CONCURRENCY) + .collect::>() + .await; +} + async fn reconnect_saved_servers_inner( inner: Arc, saved_servers: Arc>>, @@ -397,11 +405,16 @@ async fn reconnect_saved_servers_inner( multi_clanker_and_quic_enabled: bool, reconnect_guard: Arc>, ) -> Vec { + // Launch fires several triggers at once (first task, scene phase, + // reachability). Wait for an in-flight pass instead of returning an + // empty result: an empty result made callers clear their "connecting" + // state while the first pass was still dialing. The follow-up pass is + // cheap because it skips servers that are already connected. let guard = match reconnect_guard.try_lock() { Ok(guard) => guard, Err(_) => { - info!("ReconnectController: reconnect already in progress; skipping"); - return Vec::new(); + info!("ReconnectController: reconnect already in progress; waiting for it"); + reconnect_guard.lock().await } }; diff --git a/tools/scripts/prune-rust-target.sh b/tools/scripts/prune-rust-target.sh new file mode 100755 index 000000000..baaad01a5 --- /dev/null +++ b/tools/scripts/prune-rust-target.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Keep the shared Cargo target under a size cap. Cargo never garbage-collects +# incremental session dirs or stale dependency builds, so without this the +# target grows by tens of GB per week of normal iteration. +# +# Stages, stopping as soon as the target fits under the cap: +# 1. delete every `incremental/` cache (rebuilt on the next build) +# 2. delete the host `debug/` profile (cargo check/test, rust-analyzer) +# 3. delete the whole target (next build is a full rebuild) +# +# Usage: prune-rust-target.sh +# Env: LITTER_RUST_TARGET_MAX_GB (default 60; 0 disables pruning) +set -euo pipefail + +target="${1:?usage: prune-rust-target.sh }" +max_gb="${LITTER_RUST_TARGET_MAX_GB:-60}" + +[ "$max_gb" = "0" ] && exit 0 +[ -d "$target" ] || exit 0 + +size_gb() { du -sk "$target" 2>/dev/null | awk '{print int($1 / 1048576)}'; } + +size="$(size_gb)" +[ "$size" -le "$max_gb" ] && exit 0 +echo "==> Rust target is ${size}GB (cap ${max_gb}GB); pruning $target" + +find "$target" -maxdepth 3 -type d -name incremental -prune -exec rm -rf {} + +size="$(size_gb)" +echo " after dropping incremental caches: ${size}GB" +[ "$size" -le "$max_gb" ] && exit 0 + +rm -rf "$target/debug" +size="$(size_gb)" +echo " after dropping host debug profile: ${size}GB" +[ "$size" -le "$max_gb" ] && exit 0 + +echo " still over cap; removing the whole target (next build is a full rebuild)" +rm -rf "$target"