Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions apps/ios/ADE.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions apps/ios/ADE/ADE.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-appclip-app-identifiers</key>
<array>
<string>$(AppIdentifierPrefix)com.ade.ios.Clip</string>
</array>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
Expand Down
4 changes: 4 additions & 0 deletions apps/ios/ADE/App/ADEApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions apps/ios/ADE/Services/ClipPairingHandoff.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

/// Pairing credentials handed off by the ADE App Clip through the shared App
/// Group container. The clip pairs (PIN-gated) before the full app is
/// installed and writes this blob; the app adopts it on first launch and
/// deletes the file. Field shape is versioned and must stay in sync with
/// `ClipHandoff.Payload` in the ADEClip target.
struct ClipPairingHandoff: Codable, Equatable {
static let appGroupIdentifier = "group.com.ade.ios"
static let handoffFilename = "clip-pairing-handoff.v1.json"
/// Handoffs older than this are ignored (stale scan long before install).
static let maxAgeSeconds: Double = 60 * 60 * 24 * 7

var version: Int = 1
let deviceId: String
let secret: String
let host: String
let port: Int
let hostIdentity: String
let hostName: String
let siteId: String?
let addressCandidates: [String]
let relayCandidates: [String]
let pairedAtEpochSeconds: Double

static func containerURL() -> URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
.appendingPathComponent(handoffFilename, isDirectory: false)
}

/// Reads AND removes the pending handoff (one-shot: the blob holds a
/// secret, so it never outlives its first read, valid or not).
/// `location` is injectable for tests; production callers use the App
/// Group container.
static func consume(at location: URL? = nil, now: Date = Date()) -> ClipPairingHandoff? {
guard let url = location ?? containerURL(),
let data = try? Data(contentsOf: url) else {
return nil
}
try? FileManager.default.removeItem(at: url)
guard let handoff = try? JSONDecoder().decode(ClipPairingHandoff.self, from: data),
handoff.version == 1,
!handoff.deviceId.isEmpty,
!handoff.secret.isEmpty,
now.timeIntervalSince1970 - handoff.pairedAtEpochSeconds < maxAgeSeconds else {
return nil
}
return handoff
}
}
60 changes: 60 additions & 0 deletions apps/ios/ADE/Services/SyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Comment on lines +3518 to +3525

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 51fad04e3 — ClipPairingHandoffTests (7 cases: decode gates for version/deviceId/secret, one-shot delete even on invalid blobs, stale-age boundary via injectable now, malformed JSON, missing file). consume() gained an injectable location/now for testability; ran green on simulator alongside PairingAndDpopTests. Skip-when-already-paired lives in SyncService.adoptClipPairingHandoffIfPresent and reuses savedProfileForPairingQr, which PairingAndDpopTests already exercises.

// 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the handed-off device id for the first full-app hello

When a fresh full app adopts a clip pairing, this saves the clip's deviceId as pairedDeviceId, but hello() still sends peer.deviceId from SyncService.deviceId, which is independently generated from the full app keychain during init. The desktop paired-auth path rejects any hello where auth.deviceId !== hello.peer.deviceId, so the App Clip handoff scenario always reconnects with mismatched ids and gets auth_failed (then the saved pairing can be forgotten) instead of being adopted. Seed/update the full app's device id from the handoff before reconnecting, or otherwise pair the clip under the same id the full app will advertise.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Align the adopted device ID before reconnecting

When a fresh full-app install adopts a clip handoff, this stores the App Clip's paired device id in the profile but leaves SyncService.deviceId as the full app's separately generated id. The paired hello then sends auth.deviceId = handoff.deviceId while currentPeerMetadata() sends peer.deviceId = deviceId, and the host rejects paired hellos when those two ids differ (apps/ade-cli/src/services/sync/syncHostService.ts:4218), so clip-adopted pairings immediately fail to authenticate instead of connecting. Persist the handoff id as the local device id before reconnecting, or make the clip pair with the id the full app will use.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e1e3209 — the adopter now takes over the clip's deviceId as the install's identity (persisted to keychain + legacy defaults) before reconnecting, so auth.deviceId and peer.deviceId agree at the host check. Gated to fresh installs: if any saved machine already exists under the app's own id, the handoff is dropped instead of rewriting identity out from under existing pairings.

lastRemoteDbVersion: 0,
lastHostDeviceId: nil,
lastSuccessfulAddress: handoff.host,
savedAddressCandidates: directHosts,
discoveredLanAddresses: directHosts.filter {
!$0.contains(":") && $0 != "127.0.0.1" && !syncIsTailscaleRoute($0)
},
tailscaleAddress: directHosts.first(where: syncIsTailscaleRoute),
savedRelayCandidates: relayHosts.isEmpty ? nil : relayHosts
)
profile.updatedAt = ISO8601DateFormatter().string(from: Date())
keychain.saveToken(handoff.secret)
if let key = profileStorageKey(profile) {
keychain.saveToken(handoff.secret, hostKey: key)
var profiles = loadSavedProfilesRaw()
profiles[key] = profile
saveSavedProfiles(profiles)
}
saveProfile(profile)
await reconnectIfPossible(userInitiated: true)
return true
}

func reconnectIfPossible(userInitiated: Bool = false, preferTailnet: Bool = false) async {
do {
try ensureDatabaseReady()
Expand Down
18 changes: 18 additions & 0 deletions apps/ios/ADEClip/ADEClip.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.parent-application-identifiers</key>
<array>
<string>$(AppIdentifierPrefix)com.ade.ios</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>appclips:ade-app.dev</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.ade.ios</string>
</array>
</dict>
</plist>
24 changes: 24 additions & 0 deletions apps/ios/ADEClip/ADEClipApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import SwiftUI

/// ADE App Clip — instant pairing from a scanned QR.
///
/// The clip is invoked with the smart pairing URL
/// (`https://ade-app.dev/pair#<base64url(JSON)>`); the payload rides the URL
/// fragment so it never reaches the web server. The clip parses the payload
/// with the same `PairingQrPayload` codec as the full app, performs the
/// PIN-gated pairing handshake, and stores the resulting credentials in the
/// shared App Group container for the full app to adopt on first launch.
@main
struct ADEClipApp: App {
@StateObject private var model = ClipPairingModel()

var body: some Scene {
WindowGroup {
ClipPairingView(model: model)
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
guard let url = activity.webpageURL else { return }
model.handleInvocation(url: url)
}
}
}
}
94 changes: 94 additions & 0 deletions apps/ios/ADEClip/ClipHandoff.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import Foundation
import UIKit

/// Hands pairing credentials from the App Clip to the full app through the
/// shared App Group container. App Clips cannot share a keychain with their
/// full app, so the blob lives in the group container protected with
/// `.completeFileProtection`; the full app migrates it into the Keychain and
/// deletes the file on first launch (see `ClipHandoffAdopter` in the app).
enum ClipHandoff {
static let appGroupIdentifier = "group.com.ade.ios"
static let handoffFilename = "clip-pairing-handoff.v1.json"
private static let defaultsKey = "clip.pairing.identity.v1"

struct Payload: Codable, Equatable {
var version: Int = 1
let deviceId: String
let secret: String
let host: String
let port: Int
let hostIdentity: String
let hostName: String
let siteId: String?
let addressCandidates: [String]
let relayCandidates: [String]
let pairedAtEpochSeconds: Double
}

/// Stable per-install identity for the clip's pairing request. Persisted in
/// the shared group defaults so a re-scan before the full app installs
/// reuses the same deviceId instead of piling up registrations on the host.
static func clipDeviceId() -> String {
identity().deviceId
}

static func clipSiteId() -> String {
identity().siteId
}

static func deviceDisplayName() -> String {
UIDevice.current.name
}

static func store(success: ClipPairingSuccess, payload: PairingQrPayload) -> Bool {
let blob = Payload(
deviceId: success.deviceId,
secret: success.secret,
host: success.host,
port: success.port,
hostIdentity: payload.hostIdentity.deviceId,
hostName: payload.hostIdentity.name,
siteId: payload.hostIdentity.siteId,
addressCandidates: payload.directCandidateHosts,
relayCandidates: payload.relayCandidateHosts,
pairedAtEpochSeconds: Date().timeIntervalSince1970
)
guard let url = handoffURL(),
let data = try? JSONEncoder().encode(blob) else {
return false
}
do {
try data.write(to: url, options: [.atomic, .completeFileProtection])
return true
} catch {
return false
}
}

static func handoffURL() -> URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
.appendingPathComponent(handoffFilename, isDirectory: false)
}

private struct Identity: Codable {
let deviceId: String
let siteId: String
}

private static func identity() -> Identity {
let defaults = UserDefaults(suiteName: appGroupIdentifier) ?? .standard
if let data = defaults.data(forKey: defaultsKey),
let stored = try? JSONDecoder().decode(Identity.self, from: data) {
return stored
}
let fresh = Identity(
deviceId: UUID().uuidString.lowercased(),
siteId: UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
)
if let data = try? JSONEncoder().encode(fresh) {
defaults.set(data, forKey: defaultsKey)
}
return fresh
}
}
Loading