diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 5764c0595..28d81d73d 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -35,7 +35,15 @@ 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 */; }; + 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 +223,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 +246,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 = ""; }; @@ -247,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 = ""; }; @@ -446,6 +470,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AC0000000000000000000011 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -695,6 +726,7 @@ AE00000000000000000000A3 /* LiveActivityService.swift */, AF00000000000000000000A1 /* DpopKeyService.swift */, AF00000000000000000000A2 /* PairingQrPayload.swift */, + AC1000000000000000000006 /* ClipPairingHandoff.swift */, B34C6C526E3BBB1E720C8D7C /* Dictation */, ); name = Services; @@ -739,6 +771,7 @@ 6AC81F4E8475EA47F592B212 /* Frameworks */, 7A6120AF79287BB301D63A58 /* ADE */, AA3000000000000000000001 /* ADEWidgets */, + AC3000000000000000000001 /* ADEClip */, C77DE76E58CC8D681F4D4619 /* ADETests */, ); sourceTree = ""; @@ -767,6 +800,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 +843,7 @@ E3A5721EB84321D201716BC3 /* ADETests.xctest */, CCAB2414C359E971B780BF99 /* PreviewHost.app */, AA0000000000000000000002 /* ADEWidgets.appex */, + AC0000000000000000000002 /* ADEClip.app */, ); name = Products; sourceTree = ""; @@ -824,6 +871,7 @@ children = ( 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */, AF00000000000000000000A4 /* PairingAndDpopTests.swift */, + AC1000000000000000000008 /* ClipPairingHandoffTests.swift */, D30000000000000000000005 /* AttentionDrawerModelTests.swift */, D30000000000000000000006 /* SyncEnvelopeChunkAssemblerTests.swift */, D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */, @@ -862,11 +910,13 @@ DE00988229EE6003DBF8D8FA /* Frameworks */, 7A00A99FE2965CB60C5BE5BF /* Resources */, CC0000000000000000000001 /* Embed Foundation Extensions */, + AC5000000000000000000001 /* Embed App Clips */, ); buildRules = ( ); dependencies = ( AA4400000000000000000002 /* PBXTargetDependency */, + AC4400000000000000000002 /* PBXTargetDependency */, ); name = ADE; packageProductDependencies = ( @@ -893,6 +943,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 +974,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 +1022,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 +1058,7 @@ targets = ( 928432ECB10B6E8870725B02 /* ADE */, AA0000000000000000000001 /* ADEWidgets */, + AC0000000000000000000001 /* ADEClip */, 62C217CE2C1C31B3127D1ACF /* ADETests */, ); }; @@ -994,6 +1089,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AC0000000000000000000012 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -1173,6 +1275,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; @@ -1183,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 */, @@ -1206,6 +1310,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 +1337,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 +1573,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 +1698,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/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/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..f80de5d93 --- /dev/null +++ b/apps/ios/ADE/Services/ClipPairingHandoff.swift @@ -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 + } +} diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 2de15823e..dccfa9539 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -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, + 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..d79ba0b0e --- /dev/null +++ b/apps/ios/ADEClip/ClipPairingClient.swift @@ -0,0 +1,217 @@ +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 + } + + /// 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 + } + 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() + + // 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(), + "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..33f3d6038 --- /dev/null +++ b/apps/ios/ADEClip/ClipPairingView.swift @@ -0,0 +1,221 @@ +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 + } + + /// 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 pinIsComplete 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.pinIsComplete) + } + 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..b8aacfb6f --- /dev/null +++ b/apps/ios/ADEClip/Info.plist @@ -0,0 +1,58 @@ + + + + + CFBundleDisplayName + ADE + NSAppClip + + NSAppClipRequestEphemeralUserNotification + + NSAppClipRequestLocationConfirmation + + + UILaunchScreen + + + NSAppTransportSecurity + + 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. + NSBonjourServices + + _ade-sync._tcp + + + 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/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 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..29d07e27f --- /dev/null +++ b/apps/web/public/pair/index.html @@ -0,0 +1,78 @@ + + + + + + Pair with ADE + + + + + +
+

Pair your iPhone with ADE

+

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. +
  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..7e3f4d362 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" + } ] } ], @@ -27,6 +42,14 @@ "source": "/open", "destination": "/api/open" }, + { + "source": "/pair", + "destination": "/pair/index.html" + }, + { + "source": "/pair/", + "destination": "/pair/index.html" + }, { "source": "/((?!.*\\.).*)", "destination": "/index.html"