-
Notifications
You must be signed in to change notification settings - Fork 12
iOS App Clip: scan the pairing QR, pair instantly, hand off to the full app #706
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5736876
4cfe1db
4982fc8
0623e1f
e1e3209
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import Foundation | ||
|
|
||
| /// Pairing credentials handed off by the ADE App Clip through the shared App | ||
| /// Group container. The clip pairs (PIN-gated) before the full app is | ||
| /// installed and writes this blob; the app adopts it on first launch and | ||
| /// deletes the file. Field shape is versioned and must stay in sync with | ||
| /// `ClipHandoff.Payload` in the ADEClip target. | ||
| struct ClipPairingHandoff: Codable, Equatable { | ||
| static let appGroupIdentifier = "group.com.ade.ios" | ||
| static let handoffFilename = "clip-pairing-handoff.v1.json" | ||
| /// Handoffs older than this are ignored (stale scan long before install). | ||
| static let maxAgeSeconds: Double = 60 * 60 * 24 * 7 | ||
|
|
||
| var version: Int = 1 | ||
| let deviceId: String | ||
| let secret: String | ||
| let host: String | ||
| let port: Int | ||
| let hostIdentity: String | ||
| let hostName: String | ||
| let siteId: String? | ||
| let addressCandidates: [String] | ||
| let relayCandidates: [String] | ||
| let pairedAtEpochSeconds: Double | ||
|
|
||
| static func containerURL() -> URL? { | ||
| FileManager.default | ||
| .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)? | ||
| .appendingPathComponent(handoffFilename, isDirectory: false) | ||
| } | ||
|
|
||
| /// Reads AND removes the pending handoff (one-shot: the blob holds a | ||
| /// secret, so it never outlives its first read, valid or not). | ||
| /// `location` is injectable for tests; production callers use the App | ||
| /// Group container. | ||
| static func consume(at location: URL? = nil, now: Date = Date()) -> ClipPairingHandoff? { | ||
| guard let url = location ?? containerURL(), | ||
| let data = try? Data(contentsOf: url) else { | ||
| return nil | ||
| } | ||
| try? FileManager.default.removeItem(at: url) | ||
| guard let handoff = try? JSONDecoder().decode(ClipPairingHandoff.self, from: data), | ||
| handoff.version == 1, | ||
| !handoff.deviceId.isEmpty, | ||
| !handoff.secret.isEmpty, | ||
| now.timeIntervalSince1970 - handoff.pairedAtEpochSeconds < maxAgeSeconds else { | ||
| return nil | ||
| } | ||
| return handoff | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3515,6 +3515,66 @@ final class SyncService: ObservableObject { | |
| return true | ||
| } | ||
|
|
||
| /// 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 } | ||
| // Already credentialed for this machine (e.g. the user paired in-app | ||
| // before first launching after a clip scan) — keep the newer in-app | ||
| // pairing and drop the handoff. | ||
| if savedProfileForPairingQr(hostIdentity: handoff.hostIdentity) != nil { | ||
| return false | ||
| } | ||
| // The host binds the pairing secret to the CLIP's deviceId, and paired | ||
| // hellos are rejected when auth.deviceId != peer.deviceId. Adopt the | ||
| // clip's id as this install's device id — but only on a fresh install | ||
| // (no other saved machines); rewriting the id under existing pairings | ||
| // would break their credentials, so in that rare case drop the handoff | ||
| // and let the user pair in-app. | ||
| if handoff.deviceId != deviceId { | ||
| guard loadSavedProfilesRaw().isEmpty else { return false } | ||
| deviceId = handoff.deviceId | ||
| UserDefaults.standard.set(handoff.deviceId, forKey: legacyDeviceIdKey) | ||
| keychain.saveDeviceId(handoff.deviceId) | ||
| } | ||
| let directHosts = deduplicatedAddresses( | ||
| ([handoff.host] + handoff.addressCandidates).compactMap { syncEndpointHost($0) } | ||
| ) | ||
| let relayHosts = deduplicatedAddresses(handoff.relayCandidates.filter(syncIsFullWebSocketRoute)) | ||
| var profile = HostConnectionProfile( | ||
| hostIdentity: handoff.hostIdentity, | ||
| hostName: handoff.hostName, | ||
| siteId: syncNonEmpty(handoff.siteId), | ||
| port: handoff.port, | ||
| authKind: "paired", | ||
| pairedDeviceId: handoff.deviceId, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a fresh full app adopts a clip pairing, this saves the clip's Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a fresh full-app install adopts a clip handoff, this stores the App Clip's paired device id in the profile but leaves Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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. |
||
| lastRemoteDbVersion: 0, | ||
| lastHostDeviceId: nil, | ||
| lastSuccessfulAddress: handoff.host, | ||
| savedAddressCandidates: directHosts, | ||
| discoveredLanAddresses: directHosts.filter { | ||
| !$0.contains(":") && $0 != "127.0.0.1" && !syncIsTailscaleRoute($0) | ||
| }, | ||
| tailscaleAddress: directHosts.first(where: syncIsTailscaleRoute), | ||
| savedRelayCandidates: relayHosts.isEmpty ? nil : relayHosts | ||
| ) | ||
| profile.updatedAt = ISO8601DateFormatter().string(from: Date()) | ||
| keychain.saveToken(handoff.secret) | ||
| if let key = profileStorageKey(profile) { | ||
| keychain.saveToken(handoff.secret, hostKey: key) | ||
| var profiles = loadSavedProfilesRaw() | ||
| profiles[key] = profile | ||
| saveSavedProfiles(profiles) | ||
| } | ||
| saveProfile(profile) | ||
| await reconnectIfPossible(userInitiated: true) | ||
| return true | ||
| } | ||
|
|
||
| func reconnectIfPossible(userInitiated: Bool = false, preferTailnet: Bool = false) async { | ||
| do { | ||
| try ensureDatabaseReady() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| <plist version="1.0"> | ||
| <dict> | ||
| <key>com.apple.developer.parent-application-identifiers</key> | ||
| <array> | ||
| <string>$(AppIdentifierPrefix)com.ade.ios</string> | ||
| </array> | ||
| <key>com.apple.developer.associated-domains</key> | ||
| <array> | ||
| <string>appclips:ade-app.dev</string> | ||
| </array> | ||
| <key>com.apple.security.application-groups</key> | ||
| <array> | ||
| <string>group.com.ade.ios</string> | ||
| </array> | ||
| </dict> | ||
| </plist> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import SwiftUI | ||
|
|
||
| /// ADE App Clip — instant pairing from a scanned QR. | ||
| /// | ||
| /// The clip is invoked with the smart pairing URL | ||
| /// (`https://ade-app.dev/pair#<base64url(JSON)>`); the payload rides the URL | ||
| /// fragment so it never reaches the web server. The clip parses the payload | ||
| /// with the same `PairingQrPayload` codec as the full app, performs the | ||
| /// PIN-gated pairing handshake, and stores the resulting credentials in the | ||
| /// shared App Group container for the full app to adopt on first launch. | ||
| @main | ||
| struct ADEClipApp: App { | ||
| @StateObject private var model = ClipPairingModel() | ||
|
|
||
| var body: some Scene { | ||
| WindowGroup { | ||
| ClipPairingView(model: model) | ||
| .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in | ||
| guard let url = activity.webpageURL else { return } | ||
| model.handleInvocation(url: url) | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import Foundation | ||
| import UIKit | ||
|
|
||
| /// Hands pairing credentials from the App Clip to the full app through the | ||
| /// shared App Group container. App Clips cannot share a keychain with their | ||
| /// full app, so the blob lives in the group container protected with | ||
| /// `.completeFileProtection`; the full app migrates it into the Keychain and | ||
| /// deletes the file on first launch (see `ClipHandoffAdopter` in the app). | ||
| enum ClipHandoff { | ||
| static let appGroupIdentifier = "group.com.ade.ios" | ||
| static let handoffFilename = "clip-pairing-handoff.v1.json" | ||
| private static let defaultsKey = "clip.pairing.identity.v1" | ||
|
|
||
| struct Payload: Codable, Equatable { | ||
| var version: Int = 1 | ||
| let deviceId: String | ||
| let secret: String | ||
| let host: String | ||
| let port: Int | ||
| let hostIdentity: String | ||
| let hostName: String | ||
| let siteId: String? | ||
| let addressCandidates: [String] | ||
| let relayCandidates: [String] | ||
| let pairedAtEpochSeconds: Double | ||
| } | ||
|
|
||
| /// Stable per-install identity for the clip's pairing request. Persisted in | ||
| /// the shared group defaults so a re-scan before the full app installs | ||
| /// reuses the same deviceId instead of piling up registrations on the host. | ||
| static func clipDeviceId() -> String { | ||
| identity().deviceId | ||
| } | ||
|
|
||
| static func clipSiteId() -> String { | ||
| identity().siteId | ||
| } | ||
|
|
||
| static func deviceDisplayName() -> String { | ||
| UIDevice.current.name | ||
| } | ||
|
|
||
| static func store(success: ClipPairingSuccess, payload: PairingQrPayload) -> Bool { | ||
| let blob = Payload( | ||
| deviceId: success.deviceId, | ||
| secret: success.secret, | ||
| host: success.host, | ||
| port: success.port, | ||
| hostIdentity: payload.hostIdentity.deviceId, | ||
| hostName: payload.hostIdentity.name, | ||
| siteId: payload.hostIdentity.siteId, | ||
| addressCandidates: payload.directCandidateHosts, | ||
| relayCandidates: payload.relayCandidateHosts, | ||
| pairedAtEpochSeconds: Date().timeIntervalSince1970 | ||
| ) | ||
| guard let url = handoffURL(), | ||
| let data = try? JSONEncoder().encode(blob) else { | ||
| return false | ||
| } | ||
| do { | ||
| try data.write(to: url, options: [.atomic, .completeFileProtection]) | ||
| return true | ||
| } catch { | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| static func handoffURL() -> URL? { | ||
| FileManager.default | ||
| .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)? | ||
| .appendingPathComponent(handoffFilename, isDirectory: false) | ||
| } | ||
|
|
||
| private struct Identity: Codable { | ||
| let deviceId: String | ||
| let siteId: String | ||
| } | ||
|
|
||
| private static func identity() -> Identity { | ||
| let defaults = UserDefaults(suiteName: appGroupIdentifier) ?? .standard | ||
| if let data = defaults.data(forKey: defaultsKey), | ||
| let stored = try? JSONDecoder().decode(Identity.self, from: data) { | ||
| return stored | ||
| } | ||
| let fresh = Identity( | ||
| deviceId: UUID().uuidString.lowercased(), | ||
| siteId: UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() | ||
| ) | ||
| if let data = try? JSONEncoder().encode(fresh) { | ||
| defaults.set(data, forKey: defaultsKey) | ||
| } | ||
| return fresh | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added in 51fad04e3 — 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.