From 57368768f48889b101405830771da446d14e2af3 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:22:40 -0400 Subject: [PATCH 1/5] =?UTF-8?q?iOS=20App=20Clip:=20scan=20pairing=20QR=20?= =?UTF-8?q?=E2=86=92=20instant=20pair=20=E2=86=92=20full-app=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/ios/ADE.xcodeproj/project.pbxproj | 192 ++++++++++++++++ apps/ios/ADE/App/ADEApp.swift | 4 + .../ios/ADE/Services/ClipPairingHandoff.swift | 49 ++++ apps/ios/ADE/Services/SyncService.swift | 48 ++++ apps/ios/ADEClip/ADEClip.entitlements | 18 ++ apps/ios/ADEClip/ADEClipApp.swift | 24 ++ apps/ios/ADEClip/ClipHandoff.swift | 94 ++++++++ apps/ios/ADEClip/ClipPairingClient.swift | 181 +++++++++++++++ apps/ios/ADEClip/ClipPairingView.swift | 211 ++++++++++++++++++ apps/ios/ADEClip/Info.plist | 23 ++ .../.well-known/apple-app-site-association | 5 + apps/web/public/pair/index.html | 78 +++++++ apps/web/vercel.json | 19 +- 13 files changed, 944 insertions(+), 2 deletions(-) create mode 100644 apps/ios/ADE/Services/ClipPairingHandoff.swift create mode 100644 apps/ios/ADEClip/ADEClip.entitlements create mode 100644 apps/ios/ADEClip/ADEClipApp.swift create mode 100644 apps/ios/ADEClip/ClipHandoff.swift create mode 100644 apps/ios/ADEClip/ClipPairingClient.swift create mode 100644 apps/ios/ADEClip/ClipPairingView.swift create mode 100644 apps/ios/ADEClip/Info.plist create mode 100644 apps/web/public/.well-known/apple-app-site-association create mode 100644 apps/web/public/pair/index.html diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 5764c0595..6c190488e 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -36,6 +36,13 @@ AF00000000000000000000B3 /* SettingsPairingScannerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF00000000000000000000A3 /* SettingsPairingScannerSheet.swift */; }; AF00000000000000000000C4 /* PairingAndDpopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF00000000000000000000A4 /* PairingAndDpopTests.swift */; }; AA2200000000000000000001 /* ADEWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000002 /* ADEWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + AC1100000000000000000001 /* ADEClipApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1000000000000000000001 /* ADEClipApp.swift */; }; + AC1100000000000000000002 /* ClipPairingClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1000000000000000000002 /* ClipPairingClient.swift */; }; + AC1100000000000000000003 /* ClipHandoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1000000000000000000003 /* ClipHandoff.swift */; }; + AC1100000000000000000004 /* ClipPairingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1000000000000000000004 /* ClipPairingView.swift */; }; + AC1100000000000000000005 /* PairingQrPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF00000000000000000000A2 /* PairingQrPayload.swift */; }; + AC1100000000000000000006 /* ClipPairingHandoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1000000000000000000006 /* ClipPairingHandoff.swift */; }; + AC2200000000000000000001 /* ADEClip.app in Embed App Clips */ = {isa = PBXBuildFile; fileRef = AC0000000000000000000002 /* ADEClip.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 0375D32BA5870617FA1758C6 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D5B5B87564C73F2FF34B0D /* KeychainService.swift */; }; 0A1E077A24A5367ED58900F9 /* ADEDesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9E6135ED52117E41DE95F7 /* ADEDesignSystem.swift */; }; 1558E44876BBE931C02D5640 /* ADEApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9411193AF56B236BA32EFF5 /* ADEApp.swift */; }; @@ -215,6 +222,13 @@ remoteGlobalIDString = 928432ECB10B6E8870725B02; remoteInfo = ADE; }; + AC4400000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9CBC925352322D208431EFAA /* Project object */; + proxyType = 1; + remoteGlobalIDString = AC0000000000000000000001; + remoteInfo = ADEClip; + }; AA4400000000000000000001 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 9CBC925352322D208431EFAA /* Project object */; @@ -231,6 +245,14 @@ AA0000000000000000000002 /* ADEWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ADEWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; AA5000000000000000000001 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ADEWidgets/Info.plist; sourceTree = ""; }; AA5000000000000000000002 /* ADEWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = ADEWidgets.entitlements; path = ADEWidgets/ADEWidgets.entitlements; sourceTree = ""; }; + AC0000000000000000000002 /* ADEClip.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ADEClip.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AC1000000000000000000001 /* ADEClipApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEClipApp.swift; path = ADEClip/ADEClipApp.swift; sourceTree = ""; }; + AC1000000000000000000002 /* ClipPairingClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ClipPairingClient.swift; path = ADEClip/ClipPairingClient.swift; sourceTree = ""; }; + AC1000000000000000000003 /* ClipHandoff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ClipHandoff.swift; path = ADEClip/ClipHandoff.swift; sourceTree = ""; }; + AC1000000000000000000004 /* ClipPairingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ClipPairingView.swift; path = ADEClip/ClipPairingView.swift; sourceTree = ""; }; + AC1000000000000000000005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ADEClip/Info.plist; sourceTree = ""; }; + AC1000000000000000000006 /* ClipPairingHandoff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ClipPairingHandoff.swift; path = ADE/Services/ClipPairingHandoff.swift; sourceTree = ""; }; + AC1000000000000000000007 /* ADEClip.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = ADEClip.entitlements; path = ADEClip/ADEClip.entitlements; sourceTree = ""; }; AA5100000000000000000004 /* AttentionActionIntents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionActionIntents.swift; path = ADE/Shared/AttentionActionIntents.swift; sourceTree = ""; }; AA5300000000000000000012 /* DeepLinkRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DeepLinkRouter.swift; path = ADE/App/DeepLinkRouter.swift; sourceTree = ""; }; K20000000000000000000001 /* SendToMacCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SendToMacCard.swift; path = ADE/Views/Deeplinks/SendToMacCard.swift; sourceTree = ""; }; @@ -446,6 +468,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AC0000000000000000000011 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -695,6 +724,7 @@ AE00000000000000000000A3 /* LiveActivityService.swift */, AF00000000000000000000A1 /* DpopKeyService.swift */, AF00000000000000000000A2 /* PairingQrPayload.swift */, + AC1000000000000000000006 /* ClipPairingHandoff.swift */, B34C6C526E3BBB1E720C8D7C /* Dictation */, ); name = Services; @@ -739,6 +769,7 @@ 6AC81F4E8475EA47F592B212 /* Frameworks */, 7A6120AF79287BB301D63A58 /* ADE */, AA3000000000000000000001 /* ADEWidgets */, + AC3000000000000000000001 /* ADEClip */, C77DE76E58CC8D681F4D4619 /* ADETests */, ); sourceTree = ""; @@ -767,6 +798,19 @@ name = ADEWidgets; sourceTree = ""; }; + AC3000000000000000000001 /* ADEClip */ = { + isa = PBXGroup; + children = ( + AC1000000000000000000005 /* Info.plist */, + AC1000000000000000000007 /* ADEClip.entitlements */, + AC1000000000000000000001 /* ADEClipApp.swift */, + AC1000000000000000000002 /* ClipPairingClient.swift */, + AC1000000000000000000003 /* ClipHandoff.swift */, + AC1000000000000000000004 /* ClipPairingView.swift */, + ); + name = ADEClip; + sourceTree = ""; + }; 6AC81F4E8475EA47F592B212 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -797,6 +841,7 @@ E3A5721EB84321D201716BC3 /* ADETests.xctest */, CCAB2414C359E971B780BF99 /* PreviewHost.app */, AA0000000000000000000002 /* ADEWidgets.appex */, + AC0000000000000000000002 /* ADEClip.app */, ); name = Products; sourceTree = ""; @@ -862,11 +907,13 @@ DE00988229EE6003DBF8D8FA /* Frameworks */, 7A00A99FE2965CB60C5BE5BF /* Resources */, CC0000000000000000000001 /* Embed Foundation Extensions */, + AC5000000000000000000001 /* Embed App Clips */, ); buildRules = ( ); dependencies = ( AA4400000000000000000002 /* PBXTargetDependency */, + AC4400000000000000000002 /* PBXTargetDependency */, ); name = ADE; packageProductDependencies = ( @@ -893,6 +940,23 @@ productReference = AA0000000000000000000002 /* ADEWidgets.appex */; productType = "com.apple.product-type.app-extension"; }; + AC0000000000000000000001 /* ADEClip */ = { + isa = PBXNativeTarget; + buildConfigurationList = AC6000000000000000000001 /* Build configuration list for PBXNativeTarget "ADEClip" */; + buildPhases = ( + AC0000000000000000000010 /* Sources */, + AC0000000000000000000011 /* Frameworks */, + AC0000000000000000000012 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ADEClip; + productName = ADEClip; + productReference = AC0000000000000000000002 /* ADEClip.app */; + productType = "com.apple.product-type.application.on-demand-install-capable"; + }; /* End PBXNativeTarget section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -907,6 +971,17 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + AC5000000000000000000001 /* Embed App Clips */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/AppClips"; + dstSubfolderSpec = 16; + files = ( + AC2200000000000000000001 /* ADEClip.app in Embed App Clips */, + ); + name = "Embed App Clips"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXProject section */ @@ -944,6 +1019,22 @@ }; }; }; + AC0000000000000000000001 = { + CreatedOnToolsVersion = 16.0; + DevelopmentTeam = VQ372F39G6; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.AssociatedDomains = { + enabled = 1; + }; + com.apple.OnDemandInstallCapable = { + enabled = 1; + }; + }; + }; }; }; buildConfigurationList = 966E640F465400271B948B96 /* Build configuration list for PBXProject "ADE" */; @@ -964,6 +1055,7 @@ targets = ( 928432ECB10B6E8870725B02 /* ADE */, AA0000000000000000000001 /* ADEWidgets */, + AC0000000000000000000001 /* ADEClip */, 62C217CE2C1C31B3127D1ACF /* ADETests */, ); }; @@ -994,6 +1086,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AC0000000000000000000012 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -1173,6 +1272,7 @@ AE00000000000000000000B5 /* ADEAgentActivityAttributes.swift in Sources */, AF00000000000000000000B1 /* DpopKeyService.swift in Sources */, AF00000000000000000000B2 /* PairingQrPayload.swift in Sources */, + AC1100000000000000000006 /* ClipPairingHandoff.swift in Sources */, AF00000000000000000000B3 /* SettingsPairingScannerSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1206,6 +1306,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AC0000000000000000000010 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AC1100000000000000000001 /* ADEClipApp.swift in Sources */, + AC1100000000000000000002 /* ClipPairingClient.swift in Sources */, + AC1100000000000000000003 /* ClipHandoff.swift in Sources */, + AC1100000000000000000004 /* ClipPairingView.swift in Sources */, + AC1100000000000000000005 /* PairingQrPayload.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -1221,6 +1333,12 @@ target = AA0000000000000000000001 /* ADEWidgets */; targetProxy = AA4400000000000000000001 /* PBXContainerItemProxy */; }; + AC4400000000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = ADEClip; + target = AC0000000000000000000001 /* ADEClip */; + targetProxy = AC4400000000000000000001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1451,6 +1569,71 @@ }; name = Debug; }; + AC6000000000000000000010 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGN_ENTITLEMENTS = ADEClip/ADEClip.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VQ372F39G6; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ADEClip/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ade.ios.Clip; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1"; + }; + name = Debug; + }; + AC6000000000000000000011 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGN_ENTITLEMENTS = ADEClip/ADEClip.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = VQ372F39G6; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ADEClip/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ade.ios.Clip; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; AA6000000000000000000011 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1511,6 +1694,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + AC6000000000000000000001 /* Build configuration list for PBXNativeTarget "ADEClip" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AC6000000000000000000010 /* Debug */, + AC6000000000000000000011 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; AA6000000000000000000001 /* Build configuration list for PBXNativeTarget "ADEWidgets" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index 378fc7659..ceb87b125 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -30,6 +30,10 @@ struct ADEApp: App { didBootstrapSync = true lastActivationSyncAt = Date() await PushNotificationService.shared.clearAppBadge() + // App Clip → full app handoff: adopt clip-scanned pairing + // credentials before the first connect so a fresh install lands + // already paired. + await syncService.adoptClipPairingHandoffIfPresent() await syncService.handleForegroundTransition() } .onChange(of: scenePhase) { _, newPhase in diff --git a/apps/ios/ADE/Services/ClipPairingHandoff.swift b/apps/ios/ADE/Services/ClipPairingHandoff.swift new file mode 100644 index 000000000..3872d8507 --- /dev/null +++ b/apps/ios/ADE/Services/ClipPairingHandoff.swift @@ -0,0 +1,49 @@ +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). + static func consume() -> ClipPairingHandoff? { + guard let url = 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, + Date().timeIntervalSince1970 - handoff.pairedAtEpochSeconds < maxAgeSeconds else { + return nil + } + return handoff + } +} diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 2de15823e..79b84de96 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3515,6 +3515,54 @@ 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 + } + 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, + 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() diff --git a/apps/ios/ADEClip/ADEClip.entitlements b/apps/ios/ADEClip/ADEClip.entitlements new file mode 100644 index 000000000..afda7bd10 --- /dev/null +++ b/apps/ios/ADEClip/ADEClip.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.developer.parent-application-identifiers + + $(AppIdentifierPrefix)com.ade.ios + + com.apple.developer.associated-domains + + appclips:ade-app.dev + + com.apple.security.application-groups + + group.com.ade.ios + + + diff --git a/apps/ios/ADEClip/ADEClipApp.swift b/apps/ios/ADEClip/ADEClipApp.swift new file mode 100644 index 000000000..bde40ac19 --- /dev/null +++ b/apps/ios/ADEClip/ADEClipApp.swift @@ -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#`); 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) + } + } + } +} diff --git a/apps/ios/ADEClip/ClipHandoff.swift b/apps/ios/ADEClip/ClipHandoff.swift new file mode 100644 index 000000000..e1279012f --- /dev/null +++ b/apps/ios/ADEClip/ClipHandoff.swift @@ -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 + } +} diff --git a/apps/ios/ADEClip/ClipPairingClient.swift b/apps/ios/ADEClip/ClipPairingClient.swift new file mode 100644 index 000000000..773386457 --- /dev/null +++ b/apps/ios/ADEClip/ClipPairingClient.swift @@ -0,0 +1,181 @@ +import Foundation + +/// Result of a successful App Clip pairing handshake. Mirrors what the full +/// app persists after `pairAndConnect`, minus the sync-database state the +/// clip does not have. +struct ClipPairingSuccess: Equatable { + let deviceId: String + let secret: String + let host: String + let port: Int +} + +enum ClipPairingError: LocalizedError, Equatable { + case unreachable + case pinNotSet + case invalidPin + case failed(String) + + var errorDescription: String? { + switch self { + case .unreachable: + return "Couldn't reach the machine. Make sure your iPhone is on the same network." + case .pinNotSet: + return "No pairing PIN is set on the computer. Open ADE on your Mac and set one first." + case .invalidPin: + return "That PIN doesn't match. Check the code shown on your Mac." + case .failed(let message): + return message + } + } +} + +/// Minimal pairing-only sync client. Speaks just enough of the ADE sync +/// envelope protocol (version 1, uncompressed JSON payloads) to walk the QR's +/// address candidates, deliver one `pairing_request`, and read the +/// `pairing_result`. Deliberately self-contained: the clip must stay far under +/// the App Clip size budget, so it does not link the full `SyncService`. +final class ClipPairingClient: NSObject { + private var session: URLSession? + private var task: URLSessionWebSocketTask? + + /// Walks direct candidates first (LAN / Tailscale / loopback), then relay + /// URLs, mirroring the full app's preference order. Returns the first + /// address that completes the handshake. + func pair(payload: PairingQrPayload, pin: String) async throws -> ClipPairingSuccess { + var lastError: ClipPairingError = .unreachable + let candidates = payload.directCandidateHosts.map { (host: $0, isRelay: false) } + + payload.relayCandidateHosts.map { (host: $0, isRelay: true) } + for candidate in candidates { + do { + return try await pairOnce( + host: candidate.host, + port: payload.port, + pin: pin + ) + } catch let error as ClipPairingError { + // PIN/host errors are terminal — the host was reached and answered. + switch error { + case .pinNotSet, .invalidPin, .failed: + throw error + case .unreachable: + lastError = error + } + } + } + throw lastError + } + + private func pairOnce(host: String, port: Int, pin: String) async throws -> ClipPairingSuccess { + guard let url = ClipPairingClient.socketURL(host: host, defaultPort: port) else { + throw ClipPairingError.unreachable + } + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 10 + let session = URLSession(configuration: configuration) + let task = session.webSocketTask(with: url) + task.maximumMessageSize = 8 * 1024 * 1024 + self.session = session + self.task = task + defer { + task.cancel(with: .normalClosure, reason: nil) + session.invalidateAndCancel() + self.task = nil + self.session = nil + } + task.resume() + + let requestId = UUID().uuidString.lowercased() + let peer: [String: Any] = [ + "deviceId": ClipHandoff.clipDeviceId(), + "deviceName": ClipHandoff.deviceDisplayName(), + "platform": "ios", + "deviceType": "phone", + // The clip has no sync database; the full app hellos with its real + // site id and the host upserts peer metadata then. A stable random + // placeholder satisfies the pairing payload contract. + "siteId": ClipHandoff.clipSiteId(), + "dbVersion": 0, + ] + let envelope: [String: Any] = [ + "version": 1, + "type": "pairing_request", + "compression": "none", + "payloadEncoding": "json", + "requestId": requestId, + "payload": [ + "code": pin.trimmingCharacters(in: .whitespacesAndNewlines).uppercased(), + "peer": peer, + ], + ] + guard let data = try? JSONSerialization.data(withJSONObject: envelope), + let text = String(data: data, encoding: .utf8) else { + throw ClipPairingError.failed("Couldn't encode the pairing request.") + } + do { + try await task.send(.string(text)) + } catch { + throw ClipPairingError.unreachable + } + + // Read frames until the matching pairing_result arrives (the host may + // interleave unrelated frames); bail after a bounded number of frames. + for _ in 0..<32 { + let message: URLSessionWebSocketTask.Message + do { + message = try await task.receive() + } catch { + throw ClipPairingError.unreachable + } + guard let object = ClipPairingClient.decodeEnvelope(message) else { continue } + guard (object["type"] as? String) == "pairing_result" else { continue } + if let envelopeRequestId = object["requestId"] as? String, + !envelopeRequestId.isEmpty, envelopeRequestId != requestId { + continue + } + let payload = object["payload"] as? [String: Any] ?? [:] + if (payload["ok"] as? Bool) == true, + let secret = payload["secret"] as? String, + let deviceId = payload["deviceId"] as? String { + return ClipPairingSuccess(deviceId: deviceId, secret: secret, host: host, port: port) + } + let error = payload["error"] as? [String: Any] + let code = error?["code"] as? String + let errorMessage = error?["message"] as? String + switch code { + case "pin_not_set": throw ClipPairingError.pinNotSet + case "invalid_pin": throw ClipPairingError.invalidPin + default: throw ClipPairingError.failed(errorMessage ?? "Pairing failed. Try scanning the code again.") + } + } + throw ClipPairingError.failed("The machine didn't answer the pairing request.") + } + + /// Mirrors `syncWebSocketURLString`: full ws/wss relay URLs pass verbatim + /// (they carry `/connect/` paths); bare hosts become + /// `ws://host:port` with IPv6 literals bracketed. + static func socketURL(host rawHost: String, defaultPort: Int) -> URL? { + let trimmed = rawHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.lowercased().hasPrefix("ws://") || trimmed.lowercased().hasPrefix("wss://") { + return URL(string: trimmed) + } + let host = trimmed.contains(":") && !trimmed.hasPrefix("[") ? "[\(trimmed)]" : trimmed + guard defaultPort > 0, defaultPort <= 65_535 else { return nil } + return URL(string: "ws://\(host):\(defaultPort)") + } + + private static func decodeEnvelope(_ message: URLSessionWebSocketTask.Message) -> [String: Any]? { + let data: Data + switch message { + case .string(let text): + guard let encoded = text.data(using: .utf8) else { return nil } + data = encoded + case .data(let raw): + data = raw + @unknown default: + return nil + } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } +} diff --git a/apps/ios/ADEClip/ClipPairingView.swift b/apps/ios/ADEClip/ClipPairingView.swift new file mode 100644 index 000000000..2abb37e0b --- /dev/null +++ b/apps/ios/ADEClip/ClipPairingView.swift @@ -0,0 +1,211 @@ +import StoreKit +import SwiftUI + +/// State machine for the clip: parse invocation → PIN entry → pairing → +/// success (hand off + promote the full app) or a retryable error. +@MainActor +final class ClipPairingModel: ObservableObject { + enum Phase: Equatable { + case waitingForInvocation + case invalidInvocation + case enterPin + case pairing + case paired + case storeFailed + } + + @Published var phase: Phase = .waitingForInvocation + @Published var pin: String = "" + @Published var errorText: String? + @Published private(set) var payload: PairingQrPayload? + + private let client = ClipPairingClient() + + var hostName: String { + payload?.hostIdentity.name ?? "your Mac" + } + + func handleInvocation(url: URL) { + guard phase == .waitingForInvocation || phase == .invalidInvocation else { return } + guard let parsed = PairingQrPayload.parse(url.absoluteString) else { + phase = .invalidInvocation + return + } + payload = parsed + phase = .enterPin + } + + func submitPin() { + guard let payload, phase == .enterPin || errorText != nil else { return } + let code = pin.trimmingCharacters(in: .whitespacesAndNewlines) + guard code.count >= 4 else { return } + errorText = nil + phase = .pairing + Task { + do { + let success = try await client.pair(payload: payload, pin: code) + if ClipHandoff.store(success: success, payload: payload) { + phase = .paired + } else { + phase = .storeFailed + } + } catch { + errorText = (error as? ClipPairingError)?.errorDescription + ?? "Pairing failed. Try scanning the code again." + phase = .enterPin + } + } + } +} + +struct ClipPairingView: View { + @ObservedObject var model: ClipPairingModel + @FocusState private var pinFocused: Bool + @State private var showAppStoreOverlay = false + + var body: some View { + VStack(spacing: 0) { + Spacer(minLength: 0) + card + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea()) + .appStoreOverlay(isPresented: $showAppStoreOverlay) { + SKOverlay.AppClipConfiguration(position: .bottom) + } + .onChange(of: model.phase) { _, phase in + if phase == .paired { + showAppStoreOverlay = true + } + } + } + + private var card: some View { + VStack(spacing: 20) { + Image(systemName: symbolName) + .font(.system(size: 40, weight: .medium)) + .foregroundStyle(symbolTint) + .frame(height: 48) + + VStack(spacing: 6) { + Text(title) + .font(.title2.weight(.semibold)) + .multilineTextAlignment(.center) + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + content + } + .padding(28) + .frame(maxWidth: 420) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground)) + ) + .padding(.horizontal, 20) + } + + @ViewBuilder + private var content: some View { + switch model.phase { + case .waitingForInvocation: + ProgressView() + .padding(.top, 4) + case .invalidInvocation: + Text("Open ADE on your Mac and scan the pairing code it shows in Settings.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + case .enterPin, .pairing: + VStack(spacing: 14) { + TextField("6-digit PIN", text: $model.pin) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .multilineTextAlignment(.center) + .font(.system(.title2, design: .monospaced).weight(.medium)) + .kerning(6) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(uiColor: .tertiarySystemFill)) + ) + .focused($pinFocused) + .disabled(model.phase == .pairing) + .onAppear { pinFocused = true } + + if let errorText = model.errorText { + Text(errorText) + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + Button(action: { model.submitPin() }) { + Group { + if model.phase == .pairing { + ProgressView() + .tint(.white) + } else { + Text("Pair") + .font(.headline) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 14)) + .disabled(model.phase == .pairing || model.pin.trimmingCharacters(in: .whitespaces).count < 4) + } + case .paired: + Text("Get the ADE app to start working with your agents.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + case .storeFailed: + Text("Paired, but the handoff couldn't be saved. Install the ADE app and pair again from Settings.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + + private var symbolName: String { + switch model.phase { + case .paired: return "checkmark.circle.fill" + case .invalidInvocation, .storeFailed: return "qrcode.viewfinder" + default: return "laptopcomputer.and.iphone" + } + } + + private var symbolTint: Color { + model.phase == .paired ? .green : .accentColor + } + + private var title: String { + switch model.phase { + case .waitingForInvocation: return "Opening pairing code…" + case .invalidInvocation: return "Scan an ADE pairing code" + case .enterPin, .pairing: return "Pair with \(model.hostName)" + case .paired: return "Paired with \(model.hostName)" + case .storeFailed: return "Almost there" + } + } + + private var subtitle: String { + switch model.phase { + case .enterPin, .pairing: + return "Enter the PIN shown in ADE on your Mac." + case .paired: + return "Your iPhone is trusted. ADE picks this pairing up automatically." + default: + return "ADE pairs your iPhone with your Mac to control agents from anywhere." + } + } +} diff --git a/apps/ios/ADEClip/Info.plist b/apps/ios/ADEClip/Info.plist new file mode 100644 index 000000000..a29b0ed37 --- /dev/null +++ b/apps/ios/ADEClip/Info.plist @@ -0,0 +1,23 @@ + + + + + CFBundleDisplayName + ADE + NSAppClip + + NSAppClipRequestEphemeralUserNotification + + NSAppClipRequestLocationConfirmation + + + UILaunchScreen + + NSLocalNetworkUsageDescription + ADE connects to your Mac on the local network to pair this device. + NSBonjourServices + + _ade-sync._tcp + + + diff --git a/apps/web/public/.well-known/apple-app-site-association b/apps/web/public/.well-known/apple-app-site-association new file mode 100644 index 000000000..d2be4108f --- /dev/null +++ b/apps/web/public/.well-known/apple-app-site-association @@ -0,0 +1,5 @@ +{ + "appclips": { + "apps": ["VQ372F39G6.com.ade.ios.Clip"] + } +} diff --git a/apps/web/public/pair/index.html b/apps/web/public/pair/index.html new file mode 100644 index 000000000..ae2cf465f --- /dev/null +++ b/apps/web/public/pair/index.html @@ -0,0 +1,78 @@ + + + + + + Pair with ADE + + + + + +
+

Pair your iPhone with ADE

+

Scan this page's QR code with your iPhone camera to pair instantly.

+
    +
  1. Open the Camera app on your iPhone
  2. +
  3. Scan the pairing QR shown in ADE on your Mac
  4. +
  5. Enter the PIN your Mac displays
  6. +
+

Have the ADE app already? Scanning the same code inside the app's Settings works too.

+
+ + + diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 1d3dd17c0..b1cc59563 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -4,13 +4,28 @@ { "source": "/videos/(.*)", "headers": [ - { "key": "Cache-Control", "value": "public, max-age=0, s-maxage=31536000, must-revalidate" } + { + "key": "Cache-Control", + "value": "public, max-age=0, s-maxage=31536000, must-revalidate" + } ] }, { "source": "/images/(.*)", "headers": [ - { "key": "Cache-Control", "value": "public, max-age=0, s-maxage=31536000, must-revalidate" } + { + "key": "Cache-Control", + "value": "public, max-age=0, s-maxage=31536000, must-revalidate" + } + ] + }, + { + "source": "/.well-known/apple-app-site-association", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } ] } ], From 4cfe1dba6be9085d870450a3353d92bcf617cc52 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:34:21 -0400 Subject: [PATCH 2/5] clip: handshake deadline + cancellation-aware socket; ATS local networking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/ios/ADEClip/ClipPairingClient.swift | 36 ++++++++++++++++++++++++ apps/ios/ADEClip/Info.plist | 10 +++++++ 2 files changed, 46 insertions(+) diff --git a/apps/ios/ADEClip/ClipPairingClient.swift b/apps/ios/ADEClip/ClipPairingClient.swift index 773386457..d79ba0b0e 100644 --- a/apps/ios/ADEClip/ClipPairingClient.swift +++ b/apps/ios/ADEClip/ClipPairingClient.swift @@ -66,7 +66,26 @@ final class ClipPairingClient: NSObject { throw lastError } + /// Hard ceiling on one candidate's full handshake (connect + send + + /// receive). `timeoutIntervalForRequest` only covers opening the socket; + /// without this a host that accepts the connection but never answers the + /// pairing_request would pin the clip in `.pairing` forever. + private static let handshakeTimeoutSeconds: UInt64 = 15 + private func pairOnce(host: String, port: Int, pin: String) async throws -> ClipPairingSuccess { + try await withThrowingTaskGroup(of: ClipPairingSuccess.self) { group in + group.addTask { try await self.pairOnceInner(host: host, port: port, pin: pin) } + group.addTask { + try await Task.sleep(nanoseconds: Self.handshakeTimeoutSeconds * 1_000_000_000) + throw ClipPairingError.unreachable + } + defer { group.cancelAll() } + guard let first = try await group.next() else { throw ClipPairingError.unreachable } + return first + } + } + + private func pairOnceInner(host: String, port: Int, pin: String) async throws -> ClipPairingSuccess { guard let url = ClipPairingClient.socketURL(host: host, defaultPort: port) else { throw ClipPairingError.unreachable } @@ -85,6 +104,23 @@ final class ClipPairingClient: NSObject { } task.resume() + // A pending `receive()` does not respond to Swift task cancellation on + // its own; killing the socket on cancel errors it out so the timeout + // sibling in `pairOnce` can actually unwind this child. + return try await withTaskCancellationHandler { + try await performHandshake(task: task, host: host, port: port, pin: pin) + } onCancel: { + task.cancel(with: .goingAway, reason: nil) + session.invalidateAndCancel() + } + } + + private func performHandshake( + task: URLSessionWebSocketTask, + host: String, + port: Int, + pin: String + ) async throws -> ClipPairingSuccess { let requestId = UUID().uuidString.lowercased() let peer: [String: Any] = [ "deviceId": ClipHandoff.clipDeviceId(), diff --git a/apps/ios/ADEClip/Info.plist b/apps/ios/ADEClip/Info.plist index a29b0ed37..87d93051b 100644 --- a/apps/ios/ADEClip/Info.plist +++ b/apps/ios/ADEClip/Info.plist @@ -13,6 +13,16 @@ UILaunchScreen + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSLocalNetworkUsageDescription ADE connects to your Mac on the local network to pair this device. NSBonjourServices From 4982fc8d61339da58fe167e8570dcbb6712a0df2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:37:22 -0400 Subject: [PATCH 3/5] clip: 6-digit PIN gate, /pair rewrite before SPA catch-all, handoff tests 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 --- apps/ios/ADE.xcodeproj/project.pbxproj | 4 + .../ios/ADE/Services/ClipPairingHandoff.swift | 8 +- apps/ios/ADEClip/ClipPairingView.swift | 14 ++- .../ADETests/ClipPairingHandoffTests.swift | 96 +++++++++++++++++++ apps/web/public/pair/index.html | 2 +- apps/web/vercel.json | 8 ++ 6 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 apps/ios/ADETests/ClipPairingHandoffTests.swift diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 6c190488e..28d81d73d 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ AF00000000000000000000B2 /* PairingQrPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF00000000000000000000A2 /* PairingQrPayload.swift */; }; AF00000000000000000000B3 /* SettingsPairingScannerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF00000000000000000000A3 /* SettingsPairingScannerSheet.swift */; }; AF00000000000000000000C4 /* PairingAndDpopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF00000000000000000000A4 /* PairingAndDpopTests.swift */; }; + AC1100000000000000000008 /* ClipPairingHandoffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1000000000000000000008 /* ClipPairingHandoffTests.swift */; }; AA2200000000000000000001 /* ADEWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000002 /* ADEWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AC1100000000000000000001 /* ADEClipApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1000000000000000000001 /* ADEClipApp.swift */; }; AC1100000000000000000002 /* ClipPairingClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC1000000000000000000002 /* ClipPairingClient.swift */; }; @@ -269,6 +270,7 @@ AF00000000000000000000A2 /* PairingQrPayload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PairingQrPayload.swift; path = ADE/Services/PairingQrPayload.swift; sourceTree = ""; }; AF00000000000000000000A3 /* SettingsPairingScannerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SettingsPairingScannerSheet.swift; path = ADE/Views/Settings/SettingsPairingScannerSheet.swift; sourceTree = ""; }; AF00000000000000000000A4 /* PairingAndDpopTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PairingAndDpopTests.swift; path = ADETests/PairingAndDpopTests.swift; sourceTree = ""; }; + AC1000000000000000000008 /* ClipPairingHandoffTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ClipPairingHandoffTests.swift; path = ADETests/ClipPairingHandoffTests.swift; sourceTree = ""; }; D1C7A70000000000000001A1 /* DictationController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DictationController.swift; path = ADE/Services/Dictation/DictationController.swift; sourceTree = ""; }; CC5000000000000000000001 /* ADE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = ADE.entitlements; path = ADE/ADE.entitlements; sourceTree = ""; }; 0C6ECFA9D57E70E57A60E8AB /* WorkTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkTabView.swift; path = ADE/Views/WorkTabView.swift; sourceTree = ""; }; @@ -869,6 +871,7 @@ children = ( 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */, AF00000000000000000000A4 /* PairingAndDpopTests.swift */, + AC1000000000000000000008 /* ClipPairingHandoffTests.swift */, D30000000000000000000005 /* AttentionDrawerModelTests.swift */, D30000000000000000000006 /* SyncEnvelopeChunkAssemblerTests.swift */, D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */, @@ -1283,6 +1286,7 @@ files = ( 7B70BE6839672E5D2D006B28 /* ADETests.swift in Sources */, AF00000000000000000000C4 /* PairingAndDpopTests.swift in Sources */, + AC1100000000000000000008 /* ClipPairingHandoffTests.swift in Sources */, D30000000000000000000015 /* AttentionDrawerModelTests.swift in Sources */, D30000000000000000000016 /* SyncEnvelopeChunkAssemblerTests.swift in Sources */, D30000000000000000000017 /* WorkMarkdownStreamingParsingTests.swift in Sources */, diff --git a/apps/ios/ADE/Services/ClipPairingHandoff.swift b/apps/ios/ADE/Services/ClipPairingHandoff.swift index 3872d8507..f80de5d93 100644 --- a/apps/ios/ADE/Services/ClipPairingHandoff.swift +++ b/apps/ios/ADE/Services/ClipPairingHandoff.swift @@ -31,8 +31,10 @@ struct ClipPairingHandoff: Codable, Equatable { /// Reads AND removes the pending handoff (one-shot: the blob holds a /// secret, so it never outlives its first read, valid or not). - static func consume() -> ClipPairingHandoff? { - guard let url = containerURL(), + /// `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 } @@ -41,7 +43,7 @@ struct ClipPairingHandoff: Codable, Equatable { handoff.version == 1, !handoff.deviceId.isEmpty, !handoff.secret.isEmpty, - Date().timeIntervalSince1970 - handoff.pairedAtEpochSeconds < maxAgeSeconds else { + now.timeIntervalSince1970 - handoff.pairedAtEpochSeconds < maxAgeSeconds else { return nil } return handoff diff --git a/apps/ios/ADEClip/ClipPairingView.swift b/apps/ios/ADEClip/ClipPairingView.swift index 2abb37e0b..33f3d6038 100644 --- a/apps/ios/ADEClip/ClipPairingView.swift +++ b/apps/ios/ADEClip/ClipPairingView.swift @@ -35,10 +35,20 @@ final class ClipPairingModel: ObservableObject { phase = .enterPin } + /// Pairing PINs are exactly 6 digits everywhere (desktop generator, full + /// app); gate the clip the same way so the button can't submit a + /// guaranteed-to-fail code. + static let pinLength = 6 + + var pinIsComplete: Bool { + let code = pin.trimmingCharacters(in: .whitespacesAndNewlines) + return code.count == Self.pinLength && code.allSatisfy(\.isNumber) + } + func submitPin() { guard let payload, phase == .enterPin || errorText != nil else { return } let code = pin.trimmingCharacters(in: .whitespacesAndNewlines) - guard code.count >= 4 else { return } + guard pinIsComplete else { return } errorText = nil phase = .pairing Task { @@ -161,7 +171,7 @@ struct ClipPairingView: View { } .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 14)) - .disabled(model.phase == .pairing || model.pin.trimmingCharacters(in: .whitespaces).count < 4) + .disabled(model.phase == .pairing || !model.pinIsComplete) } case .paired: Text("Get the ADE app to start working with your agents.") diff --git a/apps/ios/ADETests/ClipPairingHandoffTests.swift b/apps/ios/ADETests/ClipPairingHandoffTests.swift new file mode 100644 index 000000000..80f003730 --- /dev/null +++ b/apps/ios/ADETests/ClipPairingHandoffTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import ADE + +/// Covers the App Clip → full app pairing handoff blob: decode gates +/// (version / required fields / age) and the one-shot consume semantics the +/// adoption path in `SyncService.adoptClipPairingHandoffIfPresent` relies on. +/// Field shape must stay in sync with `ClipHandoff.Payload` in the ADEClip +/// target. +final class ClipPairingHandoffTests: XCTestCase { + private var tempURL: URL! + + override func setUpWithError() throws { + tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("clip-handoff-tests-\(UUID().uuidString).json") + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempURL) + } + + private func writeBlob( + version: Int = 1, + deviceId: String = "device-1", + secret: String = "secret-1", + pairedAt: Double = Date().timeIntervalSince1970 + ) throws { + let blob: [String: Any] = [ + "version": version, + "deviceId": deviceId, + "secret": secret, + "host": "192.168.1.42", + "port": 8787, + "hostIdentity": "dev-abc123", + "hostName": "Arul MacBook", + "siteId": "site-xyz", + "addressCandidates": ["192.168.1.42", "100.101.102.103"], + "relayCandidates": ["wss://relay.ade-app.dev/connect/machinekey123"], + "pairedAtEpochSeconds": pairedAt, + ] + let data = try JSONSerialization.data(withJSONObject: blob) + try data.write(to: tempURL) + } + + func testConsumesValidBlobAndDeletesFile() throws { + try writeBlob() + let handoff = try XCTUnwrap(ClipPairingHandoff.consume(at: tempURL)) + XCTAssertEqual(handoff.deviceId, "device-1") + XCTAssertEqual(handoff.secret, "secret-1") + XCTAssertEqual(handoff.host, "192.168.1.42") + XCTAssertEqual(handoff.port, 8787) + XCTAssertEqual(handoff.hostIdentity, "dev-abc123") + XCTAssertEqual(handoff.addressCandidates, ["192.168.1.42", "100.101.102.103"]) + XCTAssertEqual(handoff.relayCandidates, ["wss://relay.ade-app.dev/connect/machinekey123"]) + // One-shot: the blob holds a secret and must not survive its first read. + XCTAssertFalse(FileManager.default.fileExists(atPath: tempURL.path)) + } + + func testConsumeIsOneShotEvenWhenValid() throws { + try writeBlob() + XCTAssertNotNil(ClipPairingHandoff.consume(at: tempURL)) + XCTAssertNil(ClipPairingHandoff.consume(at: tempURL)) + } + + func testRejectsUnknownVersionButStillDeletes() throws { + try writeBlob(version: 2) + XCTAssertNil(ClipPairingHandoff.consume(at: tempURL)) + XCTAssertFalse(FileManager.default.fileExists(atPath: tempURL.path)) + } + + func testRejectsEmptyDeviceIdOrSecret() throws { + try writeBlob(deviceId: "") + XCTAssertNil(ClipPairingHandoff.consume(at: tempURL)) + try writeBlob(secret: "") + XCTAssertNil(ClipPairingHandoff.consume(at: tempURL)) + } + + func testRejectsStaleHandoff() throws { + let paired = Date().timeIntervalSince1970 + try writeBlob(pairedAt: paired) + let justInside = Date(timeIntervalSince1970: paired + ClipPairingHandoff.maxAgeSeconds - 60) + let justOutside = Date(timeIntervalSince1970: paired + ClipPairingHandoff.maxAgeSeconds + 60) + XCTAssertNotNil(ClipPairingHandoff.consume(at: tempURL, now: justInside)) + try writeBlob(pairedAt: paired) + XCTAssertNil(ClipPairingHandoff.consume(at: tempURL, now: justOutside)) + } + + func testRejectsMalformedJsonAndDeletes() throws { + try Data("not json".utf8).write(to: tempURL) + XCTAssertNil(ClipPairingHandoff.consume(at: tempURL)) + XCTAssertFalse(FileManager.default.fileExists(atPath: tempURL.path)) + } + + func testMissingFileReturnsNil() { + XCTAssertNil(ClipPairingHandoff.consume(at: tempURL)) + } +} diff --git a/apps/web/public/pair/index.html b/apps/web/public/pair/index.html index ae2cf465f..29d07e27f 100644 --- a/apps/web/public/pair/index.html +++ b/apps/web/public/pair/index.html @@ -58,7 +58,7 @@

Pair your iPhone with ADE

-

Scan this page's QR code with your iPhone camera to pair instantly.

+

Scan the pairing code shown in ADE on your Mac with your iPhone camera to pair instantly.

  1. Open the Camera app on your iPhone
  2. Scan the pairing QR shown in ADE on your Mac
  3. diff --git a/apps/web/vercel.json b/apps/web/vercel.json index b1cc59563..7e3f4d362 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -42,6 +42,14 @@ "source": "/open", "destination": "/api/open" }, + { + "source": "/pair", + "destination": "/pair/index.html" + }, + { + "source": "/pair/", + "destination": "/pair/index.html" + }, { "source": "/((?!.*\\.).*)", "destination": "/index.html" From 0623e1faab9dd76973f80fdb6e3df1ec220fec1a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:50:22 -0400 Subject: [PATCH 4/5] clip: mirror the full app's Tailscale ATS exceptions 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 --- apps/ios/ADEClip/Info.plist | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/ios/ADEClip/Info.plist b/apps/ios/ADEClip/Info.plist index 87d93051b..b8aacfb6f 100644 --- a/apps/ios/ADEClip/Info.plist +++ b/apps/ios/ADEClip/Info.plist @@ -22,6 +22,31 @@ NSAllowsLocalNetworking + NSExceptionDomains + + 100.64.0.0/10 + + NSExceptionAllowsInsecureHTTPLoads + + + fd7a:115c:a1e0::/48 + + NSExceptionAllowsInsecureHTTPLoads + + + ade-sync + + NSExceptionAllowsInsecureHTTPLoads + + + ts.net + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + NSLocalNetworkUsageDescription ADE connects to your Mac on the local network to pair this device. From e1e3209167ce396513122f617a7ec44562167626 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:07:29 -0400 Subject: [PATCH 5/5] clip: adopt handoff deviceId on fresh installs; clip signing/export plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/ios/ADE/ADE.entitlements | 4 ++++ apps/ios/ADE/Services/SyncService.swift | 12 ++++++++++++ apps/ios/ExportOptions.plist | 2 ++ 3 files changed, 18 insertions(+) diff --git a/apps/ios/ADE/ADE.entitlements b/apps/ios/ADE/ADE.entitlements index 2d0ba0c46..fe61d9696 100644 --- a/apps/ios/ADE/ADE.entitlements +++ b/apps/ios/ADE/ADE.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.associated-appclip-app-identifiers + + $(AppIdentifierPrefix)com.ade.ios.Clip + aps-environment development com.apple.security.application-groups diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 79b84de96..dccfa9539 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3529,6 +3529,18 @@ final class SyncService: ObservableObject { 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) } ) diff --git a/apps/ios/ExportOptions.plist b/apps/ios/ExportOptions.plist index 8d351c738..61418e21f 100644 --- a/apps/ios/ExportOptions.plist +++ b/apps/ios/ExportOptions.plist @@ -16,6 +16,8 @@ ADE App Store com.ade.ios.widgets ADE Widgets App Store + com.ade.ios.Clip + ADE App Clip App Store destination export