Add first-install iCloud pairing prompt with main-device auto-surface#983
Conversation
How to use the Graphite Merge QueueAdd the label merge-queue to this PR to add it to the merge queue. You must have a Graphite account in order to use the merge queue. Sign up using this link. An organization admin has enabled the Graphite Merge Queue in this repository. Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue. This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
Claude finished @yewreeka's task —— View job Code Review Complete ✅I've completed a thorough review of PR #983, examining ~2500 lines of code across 31 files. This PR introduces a significant new feature enabling iCloud-based device pairing. Here's my assessment: Summary AssessmentOverall Quality: Strong ✅ The PR demonstrates:
The feature has already undergone extensive review (Macroscope + author responses through 6 commits), and most critical issues have been addressed. Detailed Findings✅ Security - Well DesignedStrengths:
Minor Concern: ✅ Code Quality & Best PracticesAdherence to CLAUDE.md conventions:
Architecture:
Specific observations:
|
ApprovabilityVerdict: Needs human review This PR introduces a new iCloud-based pairing feature with significant new user-facing workflow, security-sensitive keychain/identity handling, signature verification logic, and push notification changes. The scope and security implications warrant careful human review. You can customize Macroscope's approvability policy. Learn more. |
7fcfc8f to
582df55
Compare
|
Pushed 🟡 Macroscope Medium — pairing service left running after a failed flow ( 🟡 Macroscope (second location) / Claude #2 —
Claude #1 (Critical) — "NotificationCenter post without foreground check" Claude #3 — reviewer self-resolved (timer restart is correct as written). #7 — no force-unwrap exists; Verified: SwiftLint clean, app+NSE build green, pairing unit suites pass (32 tests). #984 restacked on the amended commit with no conflicts; CI re-running on both PRs. |
582df55 to
edd7d72
Compare
edd7d72 to
57ece2f
Compare
|
Pushed 🟡 Double-tap race in 🟡 Swipe-dismiss skips Verified: SwiftLint clean, app+NSE build green, pairing unit suites pass (32 tests). #984 restacked cleanly; CI re-running on both PRs. |
57ece2f to
164c4c8
Compare
|
Pushed
Deferred as a follow-up: multi-installation fan-out — with 3+ paired devices, every paired installation auto-surfaces the PIN sheet and the non-matching ones end in "code does not match". Needs a cross-installation signal; tracked separately rather than bolted on here. Verified: lint clean, app+NSE build green, 37 pairing unit tests pass (incl. the new ledger suite). Note: local Docker is mid-migration to OrbStack on this machine, so the Docker-backed integration suite runs in CI for this push (Unit + Integration both monitored). |
164c4c8 to
ce3928e
Compare
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>
) A pairing join request now reaches the user even when the app isn't running: the NSE detects PairingJoinRequestContent in both push paths (the welcome push that delivers the brand-new pairing DM, and encrypted message pushes for resends), verifies the slug against this device's own identity key, and shows "<device> is requesting to pair". The verified request is stashed in app-group defaults (PendingPairRequestStore) and the app presents the initiator PIN sheet from the stash on its next activation; the stash doubles as the NSE's dedupe record against the joiner's 5s resend cadence. Verification logic is extracted into PairingJoinRequestDetector, shared with the main stream's fast path, with a pure core covered by unit tests (self-signed slug accepted; foreign-key, expired, self-sender, wrong inbox, and garbage slugs rejected). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Persist the pairing nonce ledger to app-group storage and apply the replay guard in the NSE welcome path, which is a fresh process per push and was blind to in-memory bindings - Close the pre-presentation race window: a freshly created pairing sheet VM blocks a second flow for a short grace period before it claims PairingSheetViewModel.active - Recover dropped sheet presentations on the activation path too, so a stale never-presented sheet can't strand the NSE stash - Return no pairable backups when the current identity is unknown, instead of offering a self-pair - Decode the pending pair request before clearing it so schema drift can't silently destroy it - Document why the adopted profile's PhotoKit identifier is deliberately not persisted on the joiner Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A welcome push that carries both a pairing join request and a join result / new group used to return the pairing notification before the other cases' state writes ran; a later push would no longer see either event as new, losing them entirely. The join-result / new-group handling is factored into welcomeOutcomeNotification and always runs; only the banner is superseded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
69127c6 to
4e3e405
Compare
- Persist nonce-ledger bindings under per-nonce keys so concurrent app/NSE writes can't clobber each other's replay-guard entries - Un-latch the found-device check when it yields to an in-flight deep-link flow, so the prompt can re-offer once that flow ends - Found-device promotion yields to a deep-link pairing sheet that claimed the slot mid-mint instead of replacing the user's session - Cap the stashed-request surfacing window at the shortest invite lifetime, and ignore duplicate join-request resends once the PIN is showing so the VM countdown can't drift past the coordinator's expiry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopting a profile-less identity (initiator never set up a profile) used to mark onboarding complete anyway, stranding the joiner as "Somebody" with the prompt permanently suppressed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

What
When the app is installed on a new device sharing the user's iCloud account, it now finds the other device's identity in the iCloud-synced keychain backup and offers to pair with it:
StreamProcessorverifies the join request's embedded slug against its own identity key (only a device holding the key - i.e. sharing the iCloud keychain - can mint a valid one, so strangers can't trigger this) and presents the initiator PIN sheet directly when foregrounded, or posts a local notification ("<device> is requesting to pair") otherwise.How it stays safe
Notes
hasAnyUsedConversationsand the conversation counts (see the comment incheckForPairableDeviceIfNeeded). That pre-existing clean-install bug (visible "New Convo" rows on fresh installs) deserves its own ticket; it also makes the erase guard fire for effectively-fresh devices (affects test 38 too).QA
CONVOS_QA_WIPE_PRIMARY_IDENTITYlaunch hook (deletes only the device-local identity slot; the synced backup item survives).🤖 Generated with Claude Code
Note
Add first-install iCloud pairing prompt with automatic main-device detection and no-QR pairing flow
PairFoundDeviceInfoSheet) that surfaces when another device's identity is found in the iCloud-synced keychain backup, allowing the user to pair or skip without scanning a QR code.PairingNonceLedgerfor nonce-to-joiner binding and replay prevention, andPendingPairRequestStorefor cross-process handoff of verified join requests from the Notification Service Extension to the main app.StreamProcessornow fast-paths incomingPairingJoinRequestContentmessages: verified requests post a.pairingDidReceiveVerifiedJoinRequestnotification and bypass normal conversation processing.MessagingServicepush handling detects verified pairing join requests from welcome pushes, constructs deduplicated pairing notifications with nonce-replay protection, and stashes requests for the main app to present.PairingSheetViewModelgains arespondToJoinRequestmode that skips QR display and sends the PIN directly to the joiner; a single-active-flow gate prevents concurrent initiator sessions.JoinerPairingSheetViewModelgains a periodic join-request resend loop while in.connecting, stopping when a PIN arrives or the flow ends.UserDefaultsapp-group storage; if the app group suite is unavailable, the ledger falls back to in-memory only and the store falls back to standard defaults with a warning, which could cause missed deduplication across processes.Macroscope summarized 91ba6d5.