iOS App Clip: scan the pairing QR, pair instantly, hand off to the full app#706
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
@copilot review |
📝 WalkthroughWalkthroughThis PR adds an App Clip pairing flow, shared handoff storage between the App Clip and the full app, bootstrap adoption in the main app, and web metadata and routing for App Clip invocation. ChangesApp Clip pairing and handoff
Estimated code review effort: 4 (Complex) | ~60 minutes Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
apps/ios/ADEClip/ClipPairingClient.swift (1)
38-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueInstance-level
session/taskare only safe due to caller's re-entrancy guard.Storing
session/taskas class properties (rather than locals withinpairOnce) means a concurrentpair()invocation on the sameClipPairingClientinstance would race on these fields, with thedeferblock nil-ing out state that may belong to a different in-flight call. This is currently mitigated byClipPairingView.submitPin()'sphase == .enterPinguard, but it's fragile against future refactors that might reuse the client differently. Consider scoping these as local variables insidepairOnce.Also applies to: 78-85
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/ios/ADEClip/ClipPairingClient.swift` around lines 38 - 40, The shared instance properties session and task in ClipPairingClient make pairOnce() stateful and race-prone if pair() is ever called concurrently on the same instance. Move these websocket/session variables into pairOnce() as local variables, and update the cleanup/defer logic to clear only the local references so each invocation owns its own connection state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/ios/ADEClip/ClipPairingClient.swift`:
- Around line 69-152: The receive loop in ClipPairingClient.pairOnce can still
wait indefinitely because timeoutIntervalForRequest only applies to opening the
websocket, not to task.receive(). Add an explicit time-based deadline or cancel
the URLSessionWebSocketTask after a fixed number of seconds while iterating for
the pairing_result, and make sure the timeout path throws a pairing failure
instead of leaving the caller stuck in .pairing.
In `@apps/ios/ADEClip/Info.plist`:
- Around line 16-21: The App Clip’s pairing flow uses
`ClipPairingClient.socketURL` to connect with `ws://` local-network candidates,
but `Info.plist` is missing the required App Transport Security exception. Add
an `NSAppTransportSecurity` entry with `NSAllowsLocalNetworking` enabled in the
same plist so the local WebSocket pairing path can run at runtime.
---
Nitpick comments:
In `@apps/ios/ADEClip/ClipPairingClient.swift`:
- Around line 38-40: The shared instance properties session and task in
ClipPairingClient make pairOnce() stateful and race-prone if pair() is ever
called concurrently on the same instance. Move these websocket/session variables
into pairOnce() as local variables, and update the cleanup/defer logic to clear
only the local references so each invocation owns its own connection state.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: c4cff202-36e3-40b9-8d9c-72bd4d8fe421
⛔ Files ignored due to path filters (1)
apps/ios/ADE.xcodeproj/project.pbxprojis excluded by!**/*.xcodeproj/project.pbxproj
📒 Files selected for processing (12)
apps/ios/ADE/App/ADEApp.swiftapps/ios/ADE/Services/ClipPairingHandoff.swiftapps/ios/ADE/Services/SyncService.swiftapps/ios/ADEClip/ADEClip.entitlementsapps/ios/ADEClip/ADEClipApp.swiftapps/ios/ADEClip/ClipHandoff.swiftapps/ios/ADEClip/ClipPairingClient.swiftapps/ios/ADEClip/ClipPairingView.swiftapps/ios/ADEClip/Info.plistapps/web/public/.well-known/apple-app-site-associationapps/web/public/pair/index.htmlapps/web/vercel.json
|
@codex review |
There was a problem hiding this comment.
Pull request overview
Adds an iOS App Clip (“ADEClip”) to enable QR-driven instant pairing, plus a web landing page/AASA plumbing for App Clip invocation and a one-shot App Group handoff that the full iOS app adopts on first launch.
Changes:
- Introduces the
ADECliptarget with a minimal WebSocket pairing client + SwiftUI PIN flow and an App Group handoff writer. - Adds full-app adoption of the clip handoff at bootstrap (persist profile + keychain token, then reconnect).
- Adds web assets for App Clip invocation (
/.well-known/apple-app-site-association,/pairlanding) and Vercel headers.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/web/vercel.json | Adds JSON content-type header for AASA (but currently lacks /pair rewrite protection from SPA fallback). |
| apps/web/public/pair/index.html | Adds a static /pair landing page with App Clip smart banner metadata and pairing instructions. |
| apps/web/public/.well-known/apple-app-site-association | Adds App Clip association payload for ade-app.dev. |
| apps/ios/ADEClip/Info.plist | App Clip Info.plist (local networking ATS posture + local network usage text). |
| apps/ios/ADEClip/ClipPairingView.swift | SwiftUI state machine + PIN entry UI + App Store overlay presentation. |
| apps/ios/ADEClip/ClipPairingClient.swift | Minimal v1 sync-envelope WebSocket pairing client with candidate walking and handshake timeout. |
| apps/ios/ADEClip/ClipHandoff.swift | Writes one-shot credential blob to shared App Group with .completeFileProtection. |
| apps/ios/ADEClip/ADEClipApp.swift | App Clip entrypoint handling NSUserActivityTypeBrowsingWeb invocation URLs. |
| apps/ios/ADEClip/ADEClip.entitlements | App Clip entitlements for App Groups + Associated Domains. |
| apps/ios/ADE/Services/SyncService.swift | Adds adoptClipPairingHandoffIfPresent() to persist adopted pairing and reconnect. |
| apps/ios/ADE/Services/ClipPairingHandoff.swift | Adds handoff blob reader/deleter with version + age validation. |
| apps/ios/ADE/App/ADEApp.swift | Calls handoff adoption before first foreground sync connect. |
| apps/ios/ADE.xcodeproj/project.pbxproj | Adds the ADEClip target, sources, embed phase, and build settings wiring. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| { | ||
| "source": "/.well-known/apple-app-site-association", | ||
| "headers": [ | ||
| { | ||
| "key": "Content-Type", | ||
| "value": "application/json" | ||
| } | ||
| ] | ||
| } |
There was a problem hiding this comment.
Fixed in 51fad04 — /pair and /pair/ now rewrite to /pair/index.html ahead of the SPA catch-all, so the clip-banner landing always serves.
| let code = pin.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard code.count >= 4 else { return } |
There was a problem hiding this comment.
Fixed in 51fad04 — submit now requires exactly 6 digits (pinIsComplete), matching the desktop generator.
| } | ||
| .buttonStyle(.borderedProminent) | ||
| .buttonBorderShape(.roundedRectangle(radius: 14)) | ||
| .disabled(model.phase == .pairing || model.pin.trimmingCharacters(in: .whitespaces).count < 4) |
There was a problem hiding this comment.
Fixed in 51fad04 — the Pair button disables until pinIsComplete (6 digits), same gate as submit.
| <body> | ||
| <div class="card"> | ||
| <h1>Pair your iPhone with ADE</h1> | ||
| <p id="lead">Scan this page's QR code with your iPhone camera to pair instantly.</p> |
There was a problem hiding this comment.
Fixed in 51fad04 — lead copy now says to scan the pairing code shown in ADE on the Mac; no claim that this page displays a QR.
| /// Adopts pairing credentials handed off by the ADE App Clip (scan QR → | ||
| /// pair before the full app is installed). The clip writes a one-shot blob | ||
| /// into the shared App Group container; this reads it, persists the machine | ||
| /// exactly like a successful in-app pairing (saved profile + keychain | ||
| /// tokens), and connects. Returns true when a handoff was adopted. | ||
| @discardableResult | ||
| func adoptClipPairingHandoffIfPresent() async -> Bool { | ||
| guard let handoff = ClipPairingHandoff.consume() else { return false } |
There was a problem hiding this comment.
Added in 51fad04 — ClipPairingHandoffTests (7 cases: decode gates for version/deviceId/secret, one-shot delete even on invalid blobs, stale-age boundary via injectable now, malformed JSON, missing file). consume() gained an injectable location/now for testability; ran green on simulator alongside PairingAndDpopTests. Skip-when-already-paired lives in SyncService.adoptClipPairingHandoffIfPresent and reuses savedProfileForPairingQr, which PairingAndDpopTests already exercises.
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
@codex review |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
apps/web/vercel.json (1)
45-52: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueCorrectly ordered before the SPA fallback.
Placing the
/pairand/pair/rewrites ahead of the catch-all/((?!.*\\.).*)rule (line 53) ensures they aren't swallowed by the SPA fallback, matching the stated intent to serve the static landing page before the SPA takes over.Optionally, the two rules could be consolidated into one using an optional trailing slash pattern, since Vercel's rewrite
sourcesupports path-to-regexp syntax:♻️ Optional consolidation
{ - "source": "/pair", - "destination": "/pair/index.html" - }, - { - "source": "/pair/", + "source": "/pair/?", "destination": "/pair/index.html" },🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/vercel.json` around lines 45 - 52, Move the /pair and /pair/ rewrites in vercel.json so they appear before the catch-all SPA fallback rewrite, using the existing rewrite entries in the Vercel config to preserve the intended static landing page behavior. If you want to simplify the config, you can also consolidate the two pair routes into one optional trailing-slash rule, but the key fix is ensuring the /pair mapping is ordered ahead of the /((?!.*\\.).*) rule.apps/ios/ADEClip/ClipPairingClient.swift (1)
88-116: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueDrop the unused
session/taskinstance stateself.sessionandself.taskaren’t read anywhere else; the localsession/taskvalues already cover lifetime and cancellation, so these properties can be removed to simplify the client.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/ios/ADEClip/ClipPairingClient.swift` around lines 88 - 116, The `ClipPairingClient.pairOnceInner(host:port:pin:)` method is storing `session` and `task` into unused instance properties via `self.session` and `self.task`, but those values are never read elsewhere. Remove the `session`/`task` state from the class and keep the existing local `URLSession` and web socket task lifecycle management inside `pairOnceInner`, including the `defer` cleanup and cancellation handler.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@apps/ios/ADEClip/ClipPairingClient.swift`:
- Around line 88-116: The `ClipPairingClient.pairOnceInner(host:port:pin:)`
method is storing `session` and `task` into unused instance properties via
`self.session` and `self.task`, but those values are never read elsewhere.
Remove the `session`/`task` state from the class and keep the existing local
`URLSession` and web socket task lifecycle management inside `pairOnceInner`,
including the `defer` cleanup and cancellation handler.
In `@apps/web/vercel.json`:
- Around line 45-52: Move the /pair and /pair/ rewrites in vercel.json so they
appear before the catch-all SPA fallback rewrite, using the existing rewrite
entries in the Vercel config to preserve the intended static landing page
behavior. If you want to simplify the config, you can also consolidate the two
pair routes into one optional trailing-slash rule, but the key fix is ensuring
the /pair mapping is ordered ahead of the /((?!.*\\.).*) rule.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 9b7c7455-0c23-45ad-b021-c9aa4292818e
⛔ Files ignored due to path filters (1)
apps/ios/ADE.xcodeproj/project.pbxprojis excluded by!**/*.xcodeproj/project.pbxproj
📒 Files selected for processing (7)
apps/ios/ADE/Services/ClipPairingHandoff.swiftapps/ios/ADEClip/ClipPairingClient.swiftapps/ios/ADEClip/ClipPairingView.swiftapps/ios/ADEClip/Info.plistapps/ios/ADETests/ClipPairingHandoffTests.swiftapps/web/public/pair/index.htmlapps/web/vercel.json
🚧 Files skipped from review as they are similar to previous changes (4)
- apps/web/public/pair/index.html
- apps/ios/ADEClip/Info.plist
- apps/ios/ADE/Services/ClipPairingHandoff.swift
- apps/ios/ADEClip/ClipPairingView.swift
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 51fad04e34
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| siteId: syncNonEmpty(handoff.siteId), | ||
| port: handoff.port, | ||
| authKind: "paired", | ||
| pairedDeviceId: handoff.deviceId, |
There was a problem hiding this comment.
Use the handed-off device id for the first full-app hello
When a fresh full app adopts a clip pairing, this saves the clip's deviceId as pairedDeviceId, but hello() still sends peer.deviceId from SyncService.deviceId, which is independently generated from the full app keychain during init. The desktop paired-auth path rejects any hello where auth.deviceId !== hello.peer.deviceId, so the App Clip handoff scenario always reconnects with mismatched ids and gets auth_failed (then the saved pairing can be forgotten) instead of being adopted. Seed/update the full app's device id from the handoff before reconnecting, or otherwise pair the clip under the same id the full app will advertise.
Useful? React with 👍 / 👎.
New ADEClip target (1.4MB, on-demand-install product embedded in ADE.app/AppClips): slim pairing-only WS client speaking the v1 sync envelope (pairing_request/ pairing_result), PIN card UI, one-shot credential handoff through the shared App Group; the full app adopts it on first launch (SyncService .adoptClipPairingHandoffIfPresent) and connects without re-pairing. Clip pairs without a DPoP key on purpose — the host's TOFU upgrade adopts the app's enclave key on its first hello. Web: /.well-known/apple-app-site-association (appclips) + /pair smart landing with the apple-itunes-app clip banner; AASA served as application/json. ASC: registered com.ade.ios.Clip (97ZL5TPJB8) with APP_GROUPS + ASSOCIATED_DOMAINS via asc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rking Review round 1 (CodeRabbit): a host that accepts the socket but never answers pairing_request no longer pins the clip in .pairing — 15s hard deadline racing the handshake, with the pending receive() unblocked via socket cancel; and the clip now carries the same NSAllowsLocalNetworking ATS posture as the full app so ws:// LAN pairing works at runtime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ests Review round 2 (Copilot): PIN submit/button now require exactly 6 digits (matching the desktop generator) instead of >=4; /pair and /pair/ rewrite to the static landing ahead of the extensionless SPA catch-all so the App Clip banner page always serves; landing copy no longer claims the page shows a QR; ClipPairingHandoff.consume gains an injectable location/now and a 7-case XCTest suite (decode gates, one-shot delete, stale rejection). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
51fad04 to
4982fc8
Compare
Greptile P1: the clip advertised Tailscale direct candidates but its ATS only allowed local networking, so a tailnet-only pairing route (ws://100.x.x.x or *.ts.net) would be blocked at runtime. Copies the app's exception domains (100.64.0.0/10, fd7a:115c:a1e0::/48, ade-sync, ts.net) verbatim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0623e1faab
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| siteId: syncNonEmpty(handoff.siteId), | ||
| port: handoff.port, | ||
| authKind: "paired", | ||
| pairedDeviceId: handoff.deviceId, |
There was a problem hiding this comment.
Align the adopted device ID before reconnecting
When a fresh full-app install adopts a clip handoff, this stores the App Clip's paired device id in the profile but leaves SyncService.deviceId as the full app's separately generated id. The paired hello then sends auth.deviceId = handoff.deviceId while currentPeerMetadata() sends peer.deviceId = deviceId, and the host rejects paired hellos when those two ids differ (apps/ade-cli/src/services/sync/syncHostService.ts:4218), so clip-adopted pairings immediately fail to authenticate instead of connecting. Persist the handoff id as the local device id before reconnecting, or make the clip pair with the id the full app will use.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in e1e3209 — the adopter now takes over the clip's deviceId as the install's identity (persisted to keychain + legacy defaults) before reconnecting, so auth.deviceId and peer.deviceId agree at the host check. Gated to fresh installs: if any saved machine already exists under the app's own id, the handoff is dropped instead of rewriting identity out from under existing pairings.
| "@executable_path/Frameworks", | ||
| ); | ||
| MARKETING_VERSION = 1.0; | ||
| PRODUCT_BUNDLE_IDENTIFIER = com.ade.ios.Clip; |
There was a problem hiding this comment.
Map the App Clip bundle in manual exports
When exporting with the existing apps/ios/ExportOptions.plist, signing is manual and the provisioning profile map still covers only com.ade.ios and com.ade.ios.widgets. Adding this embedded com.ade.ios.Clip product without adding its App Store provisioning profile means any release/export path that uses that plist will fail at archive export when Xcode tries to sign the clip. Add the clip bundle id to that profile map or use the automatic export plist for App Clip builds.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in e1e3209 — ExportOptions.plist now maps com.ade.ios.Clip → 'ADE App Clip App Store' alongside the app/widgets entries (profile to be minted in the signing setup pass; the auto plist needs no change).
| DE00988229EE6003DBF8D8FA /* Frameworks */, | ||
| 7A00A99FE2965CB60C5BE5BF /* Resources */, | ||
| CC0000000000000000000001 /* Embed Foundation Extensions */, | ||
| AC5000000000000000000001 /* Embed App Clips */, |
There was a problem hiding this comment.
Add the parent-side App Clip entitlement
Once the ADE target embeds an App Clip, the containing app also needs the associated App Clip identifier entitlement for $(AppIdentifierPrefix)com.ade.ios.Clip; this change only adds the child target's parent-application-identifiers, while ADE/ADE.entitlements still lacks com.apple.developer.associated-appclip-app-identifiers. TestFlight/App Store validation can reject or warn on the containing app/clip relationship without that parent-side entitlement, so add it alongside this embed.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in e1e3209 — ADE/ADE.entitlements now declares com.apple.developer.associated-appclip-app-identifiers = [$(AppIdentifierPrefix)com.ade.ios.Clip].
…lumbing Codex round: (P1) the host binds the pairing secret to the clip's deviceId and rejects paired hellos when auth.deviceId != peer.deviceId — the adopter now takes over the clip's device id as the install's identity, gated to fresh installs (any existing saved machine keeps its identity and the handoff is dropped instead). (P2) manual ExportOptions.plist maps com.ade.ios.Clip to an App Store profile so archive export doesn't fail on the embedded clip; (P2) the containing app declares com.apple.developer.associated-appclip-app-identifiers for TestFlight/App Store validation of the app↔clip relationship. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e1e3209167
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| CODE_SIGN_STYLE = Automatic; | ||
| CURRENT_PROJECT_VERSION = 1; | ||
| DEVELOPMENT_TEAM = VQ372F39G6; | ||
| GENERATE_INFOPLIST_FILE = NO; |
There was a problem hiding this comment.
Generate or define the App Clip bundle metadata
With GENERATE_INFOPLIST_FILE = NO, the ADEClip target relies entirely on ADEClip/Info.plist; that new plist only defines the display name/App Clip settings and never supplies required bundle keys such as CFBundleIdentifier, CFBundleExecutable, CFBundlePackageType, CFBundleShortVersionString, or CFBundleVersion. The main app and widget plists define these explicitly, so an App Clip built or archived from this target is missing required metadata and will fail install/archive validation; add the standard CFBundle keys or enable generated Info.plist while preserving the App Clip/ATS entries.
Useful? React with 👍 / 👎.
The ADE app embeds an App Clip (com.ade.ios.Clip, PR #706), so a manual-signing export must provision three bundles, not two. Document the minted 'ADE App Clip App Store' profile (+ re-mint command) and the auto-plist escape hatch in both AGENTS.md and the release skill's 7c gotchas, so a context-free release agent doesn't hit a surprise signing failure on the clip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ADE app embeds an App Clip (com.ade.ios.Clip, PR #706), so a manual-signing export must provision three bundles, not two. Document the minted 'ADE App Clip App Store' profile (+ re-mint command) and the auto-plist escape hatch in both AGENTS.md and the release skill's 7c gotchas, so a context-free release agent doesn't hit a surprise signing failure on the clip. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
What
A native iOS App Clip (
ADEClip, 1.4 MB) that makes ADE pairing instant for someone who doesn't have the app yet: scan the desktop's pairing QR with the iPhone camera → the clip card slides up → enter the PIN → paired. When the full app is installed (the clip promotes it via the App Store overlay), it adopts the pairing automatically on first launch — no re-pair, no PIN re-entry.How
apps/ios/ADEClip/): pairing-only WebSocket client speaking the v1 sync envelope (pairing_request→pairing_result), walking the QR's direct candidates then relay URLs, mirroring the full app's preference order. CompilesPairingQrPayload.swift(same v3 codec, fragment-carried payload, PIN never in the QR)..completeFileProtection); the full app's newSyncService.adoptClipPairingHandoffIfPresent()consumes it at bootstrap, persists the machine exactly like an in-app pairing (saved profile + keychain tokens), and connects. Stale blobs (>7 days) are discarded; the blob never survives its first read.apps/web):/.well-known/apple-app-site-association(appclips→VQ372F39G6.com.ade.ios.Clip, served asapplication/jsonvia vercel.json) + a/pairsmart landing with theapple-itunes-appclip banner.asc): registeredcom.ade.ios.Clip(97ZL5TPJB8) with App Groups + Associated Domains.Verification
ADEClipandADEschemes both build for simulator; clip embeds atADE.app/AppClips/ADEClip.app(1.4 MB, budget 10 MB).Follow-ups (not in this PR)
🤖 Generated with Claude Code
Summary by CodeRabbit
Greptile Summary
This PR adds an iOS App Clip flow for pairing ADE from a scanned QR code. The main changes are:
ADECliptarget with QR invocation handling, PIN entry, WebSocket pairing, and App Store overlay promotion./pairlanding page, Apple App Site Association file, and Vercel routing/header updates for App Clip invocation.Confidence Score: 5/5
Safe to merge with low risk.
The changed code follows existing pairing and profile persistence patterns, validates and deletes the handoff blob on first read, bounds clip WebSocket handshakes, and includes focused tests for the handoff decoder.
No files require special attention.
What T-Rex did
Important Files Changed
/pairroutes to the landing page.Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Camera as iPhone Camera/Safari participant Clip as ADEClip participant Host as ADE desktop host participant Group as Shared App Group file participant App as Full ADE app participant Keychain as iOS Keychain Camera->>Clip: "Open https://ade-app.dev/pair#payload" Clip->>Clip: Parse PairingQrPayload and collect candidates Clip->>Host: WebSocket pairing_request + PIN + clip peer id Host-->>Clip: pairing_result(secret, deviceId) Clip->>Group: Write clip-pairing-handoff.v1.json Clip->>Camera: Show App Store overlay App->>Group: consume() handoff on first launch Group-->>App: Return payload and delete file App->>Keychain: Save device id and pairing token App->>App: Save HostConnectionProfile App->>Host: Reconnect as paired device%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Camera as iPhone Camera/Safari participant Clip as ADEClip participant Host as ADE desktop host participant Group as Shared App Group file participant App as Full ADE app participant Keychain as iOS Keychain Camera->>Clip: "Open https://ade-app.dev/pair#payload" Clip->>Clip: Parse PairingQrPayload and collect candidates Clip->>Host: WebSocket pairing_request + PIN + clip peer id Host-->>Clip: pairing_result(secret, deviceId) Clip->>Group: Write clip-pairing-handoff.v1.json Clip->>Camera: Show App Store overlay App->>Group: consume() handoff on first launch Group-->>App: Return payload and delete file App->>Keychain: Save device id and pairing token App->>App: Save HostConnectionProfile App->>Host: Reconnect as paired deviceComments Outside Diff (1)
General comment
http://127.0.0.1:4180/pair/#samplepairpayloadreturns the React app's not-found page instead ofpublic/pair/index.html. The executed Playwright proof captured titleADE — Not found, body text404 Page not found, andPAIR_META_APPLE_ITUNES_APP: <missing>, so iPhone users would not see the intended App Clip smart banner on/pair.apps/web/public/pair/index.html, but the app/Vite routing being exercised resolves/pair/through the SPA entry and not the nested staticindex.html, so the catch-all app route renders its 404 page before the static App Clip landing content is used./pairand/pair/are served directly from the static landing page before the SPA fallback. For example, add an explicit Vercel rewrite/route from/pairand/pair/to/pair/index.html, or move/generate the landing asset at a path that the hosting layer serves before the React catch-all.Reviews (6): Last reviewed commit: "clip: adopt handoff deviceId on fresh in..." | Re-trigger Greptile