Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
108 changes: 86 additions & 22 deletions apps/ios/Sources/Litter/Models/AlleycatCredentialStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,37 @@ 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 }

var item: CFTypeRef?
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
Expand All @@ -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<String> = []

/// 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)
}
Expand Down
2 changes: 2 additions & 0 deletions apps/ios/Sources/Litter/Models/CloudKVSBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ final class CloudKVSBridge {
// initial sync.
store.synchronize()
applyEnvelopeFromStore()
SavedServerStore.syncWithCloud()
scheduleExport()
}

Expand Down Expand Up @@ -121,6 +122,7 @@ final class CloudKVSBridge {
)
applyWritebacks(writebacks)
lastAppliedEnvelopeHash = hash
SavedServerStore.syncWithCloud()
NotificationCenter.default.post(name: .litterThreadPreferencesDidChange, object: nil)
} catch {
LLog.warn(
Expand Down
88 changes: 88 additions & 0 deletions apps/ios/Sources/Litter/Models/SavedServerStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,101 @@ 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
cachedServers = servers
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
Expand Down
8 changes: 7 additions & 1 deletion apps/ios/Sources/Litter/Views/AlleycatAddServerSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
23 changes: 3 additions & 20 deletions apps/ios/Sources/Litter/Views/OnboardingCoachmarks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CGRect>]

Expand All @@ -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)
Expand All @@ -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")
Expand Down
Loading
Loading