Skip to content

Commit ce3928e

Browse files
yewreekaclaude
andcommitted
Add first-install iCloud pairing prompt with main-device auto-surface
When a fresh install finds another device's identity in the iCloud-synced keychain backup, prompt "Pair <device>?" and run the standard joiner pairing handshake against it - the found inbox id becomes this device's identity. The invite is minted locally, signed by the backed-up key, so the joiner's slug and identity-share verification hold unchanged and no QR is scanned. The main device now auto-surfaces incoming requests: StreamProcessor verifies the join request's slug against its own identity key (only a device sharing the iCloud keychain can mint one) and presents the initiator PIN sheet directly when foregrounded, or posts a local "<device> is requesting to pair" notification otherwise. Includes QA test 42 (two-simulator clone seeding via the CONVOS_QA_WIPE_PRIMARY_IDENTITY launch hook), accessibility ids and QA events for automation, and unit tests for invite signing, backup filtering, and the coordinator's respond entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 687f22c commit ce3928e

25 files changed

Lines changed: 1722 additions & 84 deletions

Convos/Config/QALaunchHooks.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import ConvosCore
2+
import Foundation
3+
import Security
4+
5+
/// Launch-time hooks for QA automation. Every hook is double-gated: it
6+
/// only runs in non-production environments and only when its environment
7+
/// variable is set (pass to a simulator app by exporting the
8+
/// SIMCTL_CHILD_-prefixed name before `xcrun simctl launch`).
9+
enum QALaunchHooks {
10+
static func run(environment: AppEnvironment) {
11+
guard !environment.isProduction else { return }
12+
wipePrimaryIdentityIfRequested(environment: environment)
13+
}
14+
15+
/// CONVOS_QA_WIPE_PRIMARY_IDENTITY=1 deletes the device-local identity
16+
/// slot while leaving the iCloud-synced backup slot intact. iCloud
17+
/// Keychain doesn't sync between simulators, so two-simulator QA seeds
18+
/// the "brand-new device on the same iCloud account" state by cloning
19+
/// the onboarded simulator (clones copy the whole keychain) and wiping
20+
/// the cloned primary slot with this hook on first launch. The app then
21+
/// registers a fresh placeholder identity and finds the original
22+
/// device's synced backup, which drives the first-install
23+
/// "Pair <device>?" prompt. See qa/tests/44-icloud-pairing-prompt.md.
24+
private static func wipePrimaryIdentityIfRequested(environment: AppEnvironment) {
25+
guard ProcessInfo.processInfo.environment["CONVOS_QA_WIPE_PRIMARY_IDENTITY"] == "1" else { return }
26+
let query: [String: Any] = [
27+
kSecClass as String: kSecClassGenericPassword,
28+
kSecAttrService as String: KeychainIdentityStore.defaultService,
29+
kSecAttrAccount as String: KeychainIdentityStore.identityAccount,
30+
kSecAttrAccessGroup as String: environment.keychainAccessGroup,
31+
kSecAttrSynchronizable as String: false
32+
]
33+
let status = SecItemDelete(query as CFDictionary)
34+
Log.info("QALaunchHooks: wiped primary identity slot (status=\(status))")
35+
QAEvent.emit(.app, "qa_wiped_primary_identity", ["status": "\(status)"])
36+
}
37+
}

Convos/Conversations List/ConversationsView.swift

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,26 @@ private struct ConversationsSheetModifier: ViewModifier {
310310
)
311311
}
312312

313+
/// Routes every dismissal of the joiner pairing sheet through
314+
/// `dismissJoinerPairing()` so the ephemeral pairing client is torn
315+
/// down before the reference drops. Interactive (swipe) dismissal
316+
/// nils a plain item binding before `onDismiss` runs, which would
317+
/// orphan the joiner's streaming task and temp database - the same
318+
/// gap `MainTabSheetsModifier.incomingPairingBinding` closes for the
319+
/// initiator sheet.
320+
private var joinerPairingBinding: Binding<JoinerPairingSheetViewModel?> {
321+
Binding(
322+
get: { viewModel.pendingJoinerPairing },
323+
set: { newValue in
324+
if newValue == nil {
325+
viewModel.dismissJoinerPairing()
326+
} else {
327+
viewModel.pendingJoinerPairing = newValue
328+
}
329+
}
330+
)
331+
}
332+
313333
func body(content: Content) -> some View {
314334
content
315335
// The `NewConversationView` and `AgentBuilderView` sheets
@@ -325,16 +345,26 @@ private struct ConversationsSheetModifier: ViewModifier {
325345
.presentationDetents([.medium])
326346
}
327347
.selfSizingSheet(
328-
item: $viewModel.pendingJoinerPairing,
329-
onDismiss: {
330-
viewModel.pendingPairDevice = nil
331-
viewModel.pendingJoinerPairing = nil
332-
},
348+
item: joinerPairingBinding,
333349
content: { pairingVM in
334350
JoinerPairingSheetView(viewModel: pairingVM)
335351
.padding(.top, DesignConstants.Spacing.step5x)
336352
}
337353
)
354+
.selfSizingSheet(
355+
item: $viewModel.foundDevicePairingPrompt,
356+
onDismiss: {
357+
viewModel.onFoundDevicePromptDismissed()
358+
},
359+
content: { prompt in
360+
PairFoundDeviceInfoSheet(
361+
deviceName: prompt.deviceName,
362+
onPair: { viewModel.pairWithFoundDevice() },
363+
onSkip: { viewModel.skipFoundDevicePairing() }
364+
)
365+
.padding(.top, DesignConstants.Spacing.step5x)
366+
}
367+
)
338368
.selfSizingSheet(isPresented: $viewModel.presentingExplodeInfo) {
339369
ExplodeInfoView()
340370
}

0 commit comments

Comments
 (0)