Skip to content

Commit 57ece2f

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 3740ebd commit 57ece2f

23 files changed

Lines changed: 1538 additions & 79 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/43-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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,20 @@ private struct ConversationsSheetModifier: ViewModifier {
406406
.padding(.top, DesignConstants.Spacing.step5x)
407407
}
408408
)
409+
.selfSizingSheet(
410+
item: $viewModel.foundDevicePairingPrompt,
411+
onDismiss: {
412+
viewModel.onFoundDevicePromptDismissed()
413+
},
414+
content: { prompt in
415+
PairFoundDeviceInfoSheet(
416+
deviceName: prompt.deviceName,
417+
onPair: { viewModel.pairWithFoundDevice() },
418+
onSkip: { viewModel.skipFoundDevicePairing() }
419+
)
420+
.padding(.top, DesignConstants.Spacing.step5x)
421+
}
422+
)
409423
.selfSizingSheet(isPresented: $viewModel.presentingExplodeInfo) {
410424
ExplodeInfoView()
411425
}

Convos/Conversations List/ConversationsViewModel.swift

Lines changed: 337 additions & 49 deletions
Large diffs are not rendered by default.

Convos/ConvosApp.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ struct ConvosApp: App {
5454
let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
5555
Log.info("Launch: version=\(appVersion) build=\(appBuild) commit=\(Secrets.GIT_COMMIT_SHA) environment=\(environment.name)")
5656
QAEvent.emit(.app, "launched", ["environment": environment.name])
57+
QALaunchHooks.run(environment: environment)
5758

5859
// Firebase must be configured before ConvosClient is created so AppCheck is ready when auth begins
5960
switch environment {

Convos/Devices/JoinerPairingSheetView.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,10 @@ struct JoinerPairingSheetView: View {
102102
}
103103

104104
private var connectingContent: some View {
105-
VStack(spacing: DesignConstants.Spacing.step4x) {
106-
Text("\"\(viewModel.initiatorDeviceName)\" is requesting to pair. Paired devices sync all conversations.")
105+
let message: String = viewModel.connectingMessage
106+
?? "\"\(viewModel.initiatorDeviceName)\" is requesting to pair. Paired devices sync all conversations."
107+
return VStack(spacing: DesignConstants.Spacing.step4x) {
108+
Text(message)
107109
.font(.subheadline)
108110
.foregroundStyle(.colorTextPrimary)
109111
.frame(maxWidth: .infinity, alignment: .leading)

Convos/Devices/JoinerPairingSheetViewModel.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,20 @@ final class JoinerPairingSheetViewModel: Identifiable {
3030
private let timeoutInterval: TimeInterval
3131
private let pairingService: any PairingServiceProtocol
3232
private let initiatorName: String?
33+
/// Copy shown while `.connecting`; nil falls back to the default
34+
/// "<device> is requesting to pair" text. The iCloud-discovery entry
35+
/// point overrides it with instructions to open the pairing screen on
36+
/// the other device.
37+
let connectingMessage: String?
38+
/// When set, the join request is re-sent on this cadence while the
39+
/// flow stays in `.connecting`. The QR flow never needs this - the
40+
/// initiator is already streaming when its QR is scanned - but the
41+
/// iCloud-discovery flow sends the first request before the user has
42+
/// opened the pairing screen on the other device, and the initiator
43+
/// only sees requests that arrive while it streams.
44+
private let resendJoinRequestInterval: TimeInterval?
3345
private var countdownTask: Task<Void, Never>?
46+
private var resendTask: Task<Void, Never>?
3447
@ObservationIgnored
3548
private let observers: PairingNotificationObservers
3649
private var initiatorInboxId: String?
@@ -52,6 +65,8 @@ final class JoinerPairingSheetViewModel: Identifiable {
5265
expiresAt: Date? = nil,
5366
initiatorName: String? = nil,
5467
timeoutInterval: TimeInterval = 60,
68+
connectingMessage: String? = nil,
69+
resendJoinRequestInterval: TimeInterval? = nil,
5570
pairingService: any PairingServiceProtocol,
5671
onPairingAdopted: (@MainActor () async -> Void)? = nil,
5772
onApplyAdoptedProfile: (@MainActor (_ displayName: String?, _ imageAssetIdentifier: String?) async -> Void)? = nil,
@@ -62,6 +77,8 @@ final class JoinerPairingSheetViewModel: Identifiable {
6277
self.pairingId = pairingId
6378
self.timeoutInterval = timeoutInterval
6479
self.initiatorName = initiatorName
80+
self.connectingMessage = connectingMessage
81+
self.resendJoinRequestInterval = resendJoinRequestInterval
6582
self.pairingService = pairingService
6683
self.onPairingAdopted = onPairingAdopted
6784
self.onApplyAdoptedProfile = onApplyAdoptedProfile
@@ -140,11 +157,38 @@ final class JoinerPairingSheetViewModel: Identifiable {
140157
slug: pairingId,
141158
deviceName: DeviceInfo.deviceName
142159
)
160+
startResendLoopIfNeeded()
143161
} catch {
144162
flowState = .failed(error.localizedDescription)
145163
}
146164
}
147165

166+
/// Re-sends the join request on a fixed cadence while the flow stays
167+
/// in `.connecting`. Duplicate requests are harmless on the initiator:
168+
/// before its pairing screen opens nothing is listening, and once a
169+
/// handshake is underway `PairingCoordinator.receivedJoinRequest`
170+
/// ignores repeats. Send failures are logged and retried - the loop
171+
/// only stops when the flow leaves `.connecting`.
172+
private func startResendLoopIfNeeded() {
173+
guard let interval = resendJoinRequestInterval else { return }
174+
resendTask?.cancel()
175+
resendTask = Task { [weak self] in
176+
while !Task.isCancelled {
177+
try? await Task.sleep(for: .seconds(interval))
178+
guard !Task.isCancelled, let self else { return }
179+
guard self.flowState == .connecting else { return }
180+
do {
181+
try await self.pairingService.sendPairingJoinRequest(
182+
slug: self.pairingId,
183+
deviceName: DeviceInfo.deviceName
184+
)
185+
} catch {
186+
Log.warning("Pairing: join request resend failed: \(error)")
187+
}
188+
}
189+
}
190+
}
191+
148192
func confirmDeleteAndPair() async {
149193
guard let onDeleteExistingData else {
150194
flowState = .failed("This device can't be reset from here.")
@@ -209,6 +253,7 @@ final class JoinerPairingSheetViewModel: Identifiable {
209253
}
210254

211255
private func onPinReceived(_ pin: String, from senderInboxId: String) {
256+
resendTask?.cancel()
212257
initiatorInboxId = senderInboxId
213258
// Rebase the countdown when the PIN arrives. The initial window
214259
// is the slug's expiresAt — by the time the PIN lands the user
@@ -226,6 +271,7 @@ final class JoinerPairingSheetViewModel: Identifiable {
226271
guard !didComplete else { return }
227272
didComplete = true
228273
countdownTask?.cancel()
274+
resendTask?.cancel()
229275
title = "Adopting identity"
230276
flowState = .syncing
231277
canDismiss = false
@@ -242,12 +288,14 @@ final class JoinerPairingSheetViewModel: Identifiable {
242288
}
243289

244290
func onPairingFailed(_ message: String) {
291+
resendTask?.cancel()
245292
flowState = .failed(message)
246293
canDismiss = true
247294
}
248295

249296
func cancel() {
250297
countdownTask?.cancel()
298+
resendTask?.cancel()
251299
Task {
252300
await pairingService.stop()
253301
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import SwiftUI
2+
3+
// First-install pairing prompt. When a fresh install finds another
4+
// device's identity in the iCloud-synced keychain backup slot,
5+
// ConversationsViewModel surfaces this sheet offering to pair with that
6+
// device instead of continuing with the placeholder account. "Pair" runs
7+
// the standard joiner pairing flow targeting the found device as the main
8+
// device; "Skip" declines persistently.
9+
10+
/// Drives presentation of `PairFoundDeviceInfoSheet`: one backed-up
11+
/// device identity discovered in iCloud, identified by the inboxId this
12+
/// install would adopt on pair.
13+
struct FoundDevicePairingPrompt: Identifiable, Equatable {
14+
let inboxId: String
15+
let deviceName: String?
16+
17+
var id: String { inboxId }
18+
}
19+
20+
struct PairFoundDeviceInfoSheet: View {
21+
let deviceName: String?
22+
let onPair: () -> Void
23+
let onSkip: () -> Void
24+
25+
private var title: String {
26+
guard let deviceName else { return "Pair your other device?" }
27+
return "Pair \(deviceName)?"
28+
}
29+
30+
private var subtitle: String {
31+
guard let deviceName else {
32+
return "Another device was found in iCloud, if you have it nearby, you can pair it now"
33+
}
34+
return "Your \(deviceName) was found in iCloud, if you have it nearby, you can pair it now"
35+
}
36+
37+
private var primaryButtonTitle: String {
38+
guard let deviceName else { return "Pair device" }
39+
return "Pair \(deviceName)"
40+
}
41+
42+
var body: some View {
43+
// No container-level accessibilityIdentifier: applied to the sheet
44+
// as a whole it propagates to every child element and clobbers the
45+
// two button identifiers below. Automation anchors on
46+
// pair-found-device-button instead.
47+
FeatureInfoSheet(
48+
title: title,
49+
subtitle: subtitle,
50+
primaryButtonTitle: primaryButtonTitle,
51+
primaryButtonAction: onPair,
52+
primaryButtonAccessibilityIdentifier: "pair-found-device-button",
53+
secondaryButtonTitle: "Skip",
54+
secondaryButtonAction: onSkip,
55+
secondaryButtonAccessibilityIdentifier: "skip-found-device-pairing-button"
56+
)
57+
}
58+
}
59+
60+
#Preview("Named device") {
61+
@Previewable @State var isPresented: Bool = true
62+
VStack { Button { isPresented.toggle() } label: { Text("Show") } }
63+
.selfSizingSheet(isPresented: $isPresented) {
64+
PairFoundDeviceInfoSheet(deviceName: "Jarod's iPhone", onPair: {}, onSkip: {})
65+
.padding(.top, 20)
66+
}
67+
}
68+
69+
#Preview("Unnamed device") {
70+
@Previewable @State var isPresented: Bool = true
71+
VStack { Button { isPresented.toggle() } label: { Text("Show") } }
72+
.selfSizingSheet(isPresented: $isPresented) {
73+
PairFoundDeviceInfoSheet(deviceName: nil, onPair: {}, onSkip: {})
74+
.padding(.top, 20)
75+
}
76+
}

Convos/Devices/PairingSheetViewModel.swift

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,27 @@ enum PairingFlowState: Equatable {
1212
case expired
1313
}
1414

15+
/// How the initiator pairing sheet was entered.
16+
enum PairingSheetMode: Equatable {
17+
/// Settings > Devices > Add new device: create an invite and show
18+
/// the QR for a joiner to scan.
19+
case createInvite
20+
/// A verified join request already arrived via the main message
21+
/// stream (iCloud-discovery joiner); respond with a PIN immediately,
22+
/// no QR step.
23+
case respondToJoinRequest(joinerInboxId: String, deviceName: String)
24+
}
25+
1526
@Observable
1627
@MainActor
17-
final class PairingSheetViewModel {
28+
final class PairingSheetViewModel: Identifiable {
29+
/// The initiator sheet currently mid-flow, if any. Weak so dismissal
30+
/// (either host nils its reference) clears it automatically. The
31+
/// auto-surface path checks this to avoid presenting a second
32+
/// initiator flow - two coordinators would race PIN generation for
33+
/// the same joiner and the handshake would fail on a stale PIN.
34+
private(set) static weak var active: PairingSheetViewModel?
35+
1836
var flowState: PairingFlowState = .qrCode(url: "")
1937
var canDismiss: Bool = true
2038
var title: String = "Pair new device"
@@ -23,6 +41,7 @@ final class PairingSheetViewModel {
2341
private let pairingService: any PairingServiceProtocol
2442
private let appGroupIdentifier: String?
2543
private let timeoutInterval: TimeInterval
44+
private let mode: PairingSheetMode
2645
private(set) var coordinator: PairingCoordinator?
2746
private var joinerDeviceName: String = "New Device"
2847
private var joinerInboxId: String?
@@ -34,11 +53,13 @@ final class PairingSheetViewModel {
3453
init(
3554
pairingService: any PairingServiceProtocol,
3655
timeoutInterval: TimeInterval = 120,
56+
mode: PairingSheetMode = .createInvite,
3757
appGroupIdentifier: String? = nil
3858
) {
3959
self.pairingService = pairingService
4060
self.appGroupIdentifier = appGroupIdentifier
4161
self.timeoutInterval = timeoutInterval
62+
self.mode = mode
4263
self.secondsRemaining = Int(timeoutInterval)
4364
self.observers = PairingNotificationObservers()
4465
observeNotifications()
@@ -75,6 +96,16 @@ final class PairingSheetViewModel {
7596
}
7697

7798
func startPairing() async {
99+
Self.active = self
100+
switch mode {
101+
case .createInvite:
102+
await startInviteFlow()
103+
case let .respondToJoinRequest(joinerInboxId, deviceName):
104+
await startRespondFlow(joinerInboxId: joinerInboxId, deviceName: deviceName)
105+
}
106+
}
107+
108+
private func startInviteFlow() async {
78109
let coordinator = PairingCoordinator(pairingService: pairingService, timeoutInterval: timeoutInterval)
79110
self.coordinator = coordinator
80111

@@ -99,6 +130,47 @@ final class PairingSheetViewModel {
99130
startCountdown()
100131
} catch {
101132
Log.error("Pairing start failed: \(error)")
133+
// The service may have started before the failing call; stop
134+
// it so its stream doesn't outlive the failed flow (mirrors
135+
// confirmEmoji's failure handling).
136+
await pairingService.stop()
137+
flowState = .failed(error.localizedDescription)
138+
}
139+
}
140+
141+
/// Mirrors the back half of `onJoinRequestReceived`: the join request
142+
/// already arrived (verified by the stream layer), so the coordinator
143+
/// starts directly in `.showingPin` and the PIN goes straight to the
144+
/// joiner.
145+
private func startRespondFlow(joinerInboxId: String, deviceName: String) async {
146+
let coordinator = PairingCoordinator(pairingService: pairingService, timeoutInterval: timeoutInterval)
147+
self.coordinator = coordinator
148+
149+
do {
150+
try await pairingService.start()
151+
guard let initiatorInboxId = await pairingService.pairingInboxId() else {
152+
await pairingService.stop()
153+
flowState = .failed("Pairing service is not ready")
154+
return
155+
}
156+
let pin = try await coordinator.startPairing(
157+
respondingToJoinerInboxId: joinerInboxId,
158+
deviceName: deviceName,
159+
initiatorInboxId: initiatorInboxId
160+
)
161+
joinerDeviceName = deviceName
162+
self.joinerInboxId = joinerInboxId
163+
expiresAt = Date().addingTimeInterval(timeoutInterval)
164+
secondsRemaining = Int(timeoutInterval)
165+
try await pairingService.sendPinToJoiner(pin, joinerInboxId: joinerInboxId)
166+
QAEvent.emit(.pairing, "responding_to_join_request", ["joinerInboxId": joinerInboxId])
167+
flowState = .showingPin(pin: pin, deviceName: deviceName)
168+
startCountdown()
169+
} catch {
170+
Log.error("Pairing respond flow failed: \(error)")
171+
// Mirrors confirmEmoji's failure handling: the service was
172+
// started above, so stop its stream before parking in .failed.
173+
await pairingService.stop()
102174
flowState = .failed(error.localizedDescription)
103175
}
104176
}

0 commit comments

Comments
 (0)