diff --git a/Docs/connection.md b/Docs/connection.md new file mode 100644 index 0000000..a362a0c --- /dev/null +++ b/Docs/connection.md @@ -0,0 +1,96 @@ +# Connect from your app + +`CellUseConnection` handles the embedded tunnel, Keychain-backed pairings, +device discovery and session streams. Keep one instance in your app's model. +Your app supplies its UI, agent and background execution window. + +First add the [embedded tunnel extension](embedded-tunnel.md) and +[native runtime targets](runtime-integration.md). The supplied native runtime +targets iOS 27. The app and tunnel extension use your Developer Program signing +team; users approve the VPN configuration on their phone. + +## Create the connection + +```swift +import CellUse +import CellUseRuntime +import DeviceHubLive +import DeviceHubTransport + +// Create on MainActor and retain in your app model. +let connection = try CellUseConnection( + configuration: .init( + appName: "My App", + providerBundleIdentifier: "com.example.myapp.tunnel", + pairingService: "com.example.myapp.pairing" + ), + nativeSessions: try .deviceHubLive(probeScreenshots: true) +) + +connection.onStateChange = { state in + // Update your app's UI. See the states below. +} +connection.onFrame = { frame in + // Optional preview. Frame pixels stay here unless your app sends them. +} + +// From your app's Connect button: +connection.connect() +``` + +Keep the pairing service identifier stable between app versions. Credentials +remain in the native Keychain vault. A saved pairing is reused on subsequent +connections; the controller does not delete pairings when a connection fails. + +The default diagnostics recorder keeps a bounded buffer in memory and has no +file storage or upload destination. Pass your own `DiagnosticRecorder` to the +initializer if your app manages diagnostic retention. + +## Show the next action in your UI + +| State | Host UI | +| --- | --- | +| `startingTunnel` | Show connection progress; iOS handles VPN consent. | +| `pairingRequired` | Show a Pair button calling `connection.pair()`. | +| `pairing(code:)` | Direct the user to Settings > Privacy & Security > Developer Mode and your app's advertised name. Display the temporary code when supplied. | +| `chooseDevice(devices)` | Let the user select a saved device, then call `connection.connect(deviceID:)`. | +| `connecting` | The controller is locating the paired device and opening its session. | +| `checkingReadiness` | Wait for a fresh portrait screenshot and input readiness. | +| `ready` | Enable your app's agent-run action. | +| `needsAction(issue)` | Show `issue.message` and a retry or pairing action as appropriate. | +| `stopping`, `idle` | Stop progress UI; offer Connect when idle. | + +Pairing continues into connection automatically when it succeeds. The library +holds a system background lease during the pairing visit to Settings. If your +UI presents the temporary code through a notification or Live Activity, handle +that presentation and permission in the host. Do not store the code in logs. + +Developer Mode, local-network permission and prepared developer services are +checked through the native connection. When they require user action, setup +returns a specific issue. The controller does not change system settings. + +## Run an agent + +```swift +// On MainActor, after connection.state.isReady: +let runtime = try connection.makeRuntime(agent: myAgent) +runtime.onUpdate = { snapshot in /* update your run UI */ } +runtime.onEnded = { /* finish your app's background work */ } + +// Start when your app's execution window begins. +runtime.start() + +// From the user's Stop action or the OS expiration handler: +runtime.cancel("hostStopped") +await connection.disconnect() +``` + +The connection feeds the runtime from its single frame/event consumers. Do not +also forward frames manually. `makeRuntime` rejects a second active runtime on +the same connection. Loss of fresh frames or input readiness clears `ready`; +stream failure closes the session and exposes a reconnection action. Reconnect +creates a new session and requires a new runtime. + +Explicitly disconnect when the host no longer needs the connection. A completed +agent run can leave the connection available for another user-initiated run. +The host still obtains and ends its own OS background execution allowance. diff --git a/Docs/embedded-tunnel.md b/Docs/embedded-tunnel.md index 8d645cc..7e1e11f 100644 --- a/Docs/embedded-tunnel.md +++ b/Docs/embedded-tunnel.md @@ -31,6 +31,10 @@ See Apple's [Network Extension configuration](https://developer.apple.com/docume ## Start from your host app +For tunnel startup through to a ready agent session, use +[CellUseConnection](connection.md). It reuses saved pairings and provides setup +states for your app's UI. The controller below is the lower-level tunnel API. + Retain a controller for the lifetime of your connection: ```swift diff --git a/Docs/runtime-integration.md b/Docs/runtime-integration.md index 5b2ebeb..210d83b 100644 --- a/Docs/runtime-integration.md +++ b/Docs/runtime-integration.md @@ -27,6 +27,14 @@ required DeviceHubLive, private-media and native-framework targets. Use this sou ## What your app manages +For an embedded tunnel, use [CellUseConnection](connection.md) to manage tunnel +startup, saved pairing, discovery, readiness and the session streams from your +app. It supplies frames and input readiness to the runtime automatically. + +### Connecting an existing session directly + +The lower-level adapter below is useful when your app already manages a session. + The host creates and owns the authenticated DeviceSession, local tunnel route, pairing records, developer-service preparation, discovery, event/frame streams, and OS background task. The runtime takes a session and an agent; it does not diff --git a/Runtime/Package.swift b/Runtime/Package.swift index f31ca74..1c198fc 100644 --- a/Runtime/Package.swift +++ b/Runtime/Package.swift @@ -39,9 +39,14 @@ package.dependencies.append(.package(name: "CellUse", path: "..")) package.products.append(.library(name: "CellUseRuntime", targets: ["CellUseRuntime"])) package.targets.append(.target(name: "CellUseRuntime", dependencies: [ .product(name: "CellUse", package: "CellUse"), + .product(name: "CellUseTunnel", package: "CellUse"), + "PhoneProbeRouting", "PhoneProbeCore", .product(name: "DeviceHubClient", package: "DeviceHubKit"), .product(name: "DeviceHubCore", package: "DeviceHubKit"), - .product(name: "DeviceHubMedia", package: "DeviceHubKit") + .product(name: "DeviceHubMedia", package: "DeviceHubKit"), + .product(name: "DeviceHubTransport", package: "DeviceHubKit"), + .product(name: "DeviceHubPersistence", package: "DeviceHubKit"), + .product(name: "DeviceHubDiagnostics", package: "DeviceHubKit") ], path: "Sources/CellUseRuntime")) package.targets.append(.testTarget(name: "CellUseRuntimeTests", dependencies: [ "CellUseRuntime", .product(name: "CellUse", package: "CellUse"), diff --git a/Runtime/Sources/CellUseRuntime/CellUseConnection.swift b/Runtime/Sources/CellUseRuntime/CellUseConnection.swift new file mode 100644 index 0000000..c4e7887 --- /dev/null +++ b/Runtime/Sources/CellUseRuntime/CellUseConnection.swift @@ -0,0 +1,229 @@ +#if os(iOS) +import CellUse +import CellUseTunnel +import DeviceHubClient +import DeviceHubCore +import DeviceHubDiagnostics +import DeviceHubMedia +import DeviceHubPersistence +import DeviceHubTransport +import Foundation +import PhoneProbeCore +import PhoneProbeRouting +import UIKit + +/// Owns the embedded tunnel, saved pairing, discovery and native session streams. +/// Retain in your host app. The host owns UI and agent background execution time. +@MainActor public final class CellUseConnection { + public struct Configuration: Sendable { + public let appName: String + public let providerBundleIdentifier: String + public let pairingService: String + public let tunnel: TunnelConfiguration + + /// Keep pairingService stable between releases to reuse the Keychain vault. + public init(appName: String, providerBundleIdentifier: String, + pairingService: String, tunnel: TunnelConfiguration = .standard) { + self.appName = appName; self.providerBundleIdentifier = providerBundleIdentifier + self.pairingService = pairingService; self.tunnel = tunnel + } + } + + private let driver: NativeConnectionDriver + private let setup: PhoneConnectionSetup + public var state: PhoneConnectionState { setup.state } + public var onStateChange: ((PhoneConnectionState) -> Void)? { + get { setup.onChange } + set { setup.onChange = newValue } + } + /// Runs on MainActor. Do not create another consumer of the session's streams. + public var onFrame: ((RemoteDisplayFrame) -> Void)? { + get { driver.onFrame } + set { driver.onFrame = newValue } + } + + /// Supply NativeSessionClient.deviceHubLive(probeScreenshots: true) from the + /// app's DeviceHubLive target. All remaining connection composition lives here. + public init(configuration: Configuration, nativeSessions: NativeSessionClient, + diagnostics: DiagnosticRecorder? = nil) throws { + guard !configuration.appName.isEmpty, !configuration.pairingService.isEmpty, + !configuration.providerBundleIdentifier.isEmpty else { throw PhoneSetupIssue.failed } + let route = try ConnectionRoute(mode: .localVPN, peer: configuration.tunnel.deviceAddress) + let recorder = try diagnostics ?? ConnectionDiagnostics.makeRecorder() + let native = RoutedNativeClient.wrap(nativeSessions, route: route, + diagnostics: RouteDiagnostics(mode: route.mode)) + let transport = try DeviceHubTransportConfiguration(controllerDisplayName: configuration.appName, + controllerModel: "Mac17,7", remoteTargetPolicy: .authenticatedDevices) + let persistence = PairingPersistenceClient.live(descriptor: .pairingVault(service: configuration.pairingService)) + let client = DeviceHubClient.live(nativeSessions: native, configuration: transport, + diagnostics: recorder, pairingPersistence: persistence) + driver = NativeConnectionDriver(client: client, + tunnel: CellUseTunnelController(providerBundleIdentifier: configuration.providerBundleIdentifier), + configuration: configuration.tunnel, displayName: configuration.appName) + setup = PhoneConnectionSetup(driver: driver) + } + + public func connect(deviceID: String? = nil) { setup.connect(deviceID: deviceID) } + public func pair() { setup.pair() } + public func disconnect() async { await setup.disconnect() } + + /// Creates a runtime fed by this connection's existing frame/event consumers. + /// Start it only after the host has obtained the required execution window. + public func makeRuntime(agent: any PhoneAgent, + configuration: PhoneActionRunner.Configuration = .init()) throws -> CellUseRuntime { + guard state.isReady, let session = driver.session else { throw PhoneSetupIssue.inputUnavailable } + guard driver.runtime?.active != true else { throw PhoneSetupIssue.anotherSession } + let runtime = CellUseRuntime(runID: session.id.rawValue, session: session, agent: agent, configuration: configuration) + driver.runtime = runtime + return runtime + } +} + +@MainActor private final class NativeConnectionDriver: PhoneConnectionDriver { + var onEvent: ((PhoneConnectionEvent) -> Void)? + var onFrame: ((RemoteDisplayFrame) -> Void)? + private(set) var session: DeviceSession? + var runtime: CellUseRuntime? + private let client: DeviceHubClient + private let tunnel: CellUseTunnelController + private let configuration: TunnelConfiguration + private let displayName: String + private var discovery: Task? + private var events: Task? + private var frames: Task? + private var available: [DeviceSummary] = [] + private var token = UUID() + private var inputReady = false + + init(client: DeviceHubClient, tunnel: CellUseTunnelController, + configuration: TunnelConfiguration, displayName: String) { + self.client = client; self.tunnel = tunnel + self.configuration = configuration; self.displayName = displayName + } + + func startTunnel() async throws { + do { try await tunnel.connect(configuration: configuration, displayName: displayName) } + catch is CancellationError { throw CancellationError() } + catch { throw PhoneSetupIssue.tunnel } + let generation = token + discovery = Task { [weak self, client] in + for await devices in client.availability() { + guard !Task.isCancelled, let self, self.token == generation else { return } + self.available = devices + } + } + } + + func savedDevices() async throws -> [PhonePairedDevice] { + do { return try await client.pairedDevices().map { PhonePairedDevice(id: $0.id.rawValue, name: $0.name) } } + catch { throw Self.issue(error) } + } + + func pair(onCode: @escaping @MainActor (String) -> Void) async throws -> PhonePairedDevice { + // Pairing requires a visit to Settings. Hold only the system-granted lease + // for that explicit operation; agent runs have a separate host-owned lease. + let lifetime = PairingLifetime() + let pairing = Task { @MainActor [client] () throws -> PhonePairedDevice in + do { + for try await event in client.pair(PairingRequest()) { + try Task.checkCancellation() + switch event { + case .waitingForCodeEntry(let code): onCode(code.displayValue) + case .paired(let device): return PhonePairedDevice(id: device.id.rawValue, name: device.name) + default: break + } + } + throw PhoneSetupIssue.pairing + } catch is CancellationError { throw CancellationError() } + catch { throw Self.issue(error) } + } + let lease = UIApplication.shared.beginBackgroundTask(withName: "cell-use pairing") { + pairing.cancel() + Task { @MainActor in lifetime.expired = true } + } + defer { if lease != .invalid { UIApplication.shared.endBackgroundTask(lease) } } + let device = try await withTaskCancellationHandler(operation: { try await pairing.value }, onCancel: { pairing.cancel() }) + try Task.checkCancellation() + guard !lifetime.expired, !pairing.isCancelled else { throw PhoneSetupIssue.pairing } + return device + } + + func open(deviceID: String) async throws { + let generation = token + let deadline = ContinuousClock.now.advanced(by: .seconds(20)) + while !available.contains(where: { $0.id.rawValue == deviceID && $0.reachability == .reachable }) { + try Task.checkCancellation() + guard ContinuousClock.now < deadline else { throw PhoneSetupIssue.unreachable } + try await Task.sleep(for: .milliseconds(150)) + } + let opened: DeviceSession + do { opened = try await client.connect(DeviceID(rawValue: deviceID)) } + catch { throw Self.issue(error) } + guard !Task.isCancelled, token == generation else { await opened.disconnect(); throw CancellationError() } + session = opened + let emit = onEvent + events = Task { [weak self] in + do { + for try await update in opened.events { + guard !Task.isCancelled, let self, self.token == generation else { return } + switch update.event { + case .hidReadinessChanged(let readiness): + self.inputReady = readiness == .ready + self.runtime?.updateInputReadiness(self.inputReady) + emit?(.inputReady(self.inputReady)) + case .ended(let error): + emit?(.ended(error.map { Self.issue($0) } ?? .connectionLost)); return + default: break + } + } + if !Task.isCancelled { emit?(.ended(.connectionLost)) } + } catch { if !Task.isCancelled { emit?(.ended(Self.issue(error))) } } + } + frames = Task { [weak self] in + for await frame in opened.frames { + guard !Task.isCancelled, let self, self.token == generation else { return } + guard frame.metadata.generation.rawValue == opened.id.rawValue else { continue } + let capturedAt = ProcessInfo.processInfo.systemUptime - Date().timeIntervalSince(frame.metadata.receivedAt) + emit?(.frame(capturedAt: capturedAt, portrait: frame.metadata.orientation == .portrait)) + self.runtime?.receive(frame, inputReady: self.inputReady) + self.onFrame?(frame) + } + if !Task.isCancelled { emit?(.ended(.connectionLost)) } + } + } + + func close() async { + token = UUID() + runtime?.cancel("connectionClosed"); runtime = nil + events?.cancel(); frames?.cancel(); discovery?.cancel() + events = nil; frames = nil; discovery = nil + available = []; inputReady = false + let old = session; session = nil + if let old { await old.disconnect() } + tunnel.disconnect() + } + + static func issue(_ error: Error) -> PhoneSetupIssue { + if let issue = error as? PhoneSetupIssue { return issue } + guard let failure = error as? DeviceHubError else { return .failed } + switch failure { + case .localNetworkDenied: return .localNetwork + case .needsPairing, .pairingTimedOut, .pairingRejected, .incorrectPairingCode, + .corruptPairingRecord, .peerAuthenticationFailed: return .pairing + case .developerModeDisabled: return .developerMode + case .deviceLocked: return .unlockPhone + case .deviceBusy: return .anotherSession + case .developerImageUnavailable, .developerImageIncompatible: return .prepareServices + case .unsupportedProtocolVersion: return .incompatibleRuntime + case .deviceOffline: return .unreachable + case .decoderFailed, .mediaStalled: return .screenUnavailable + case .connectionLost, .secureConnectionFailed: return .connectionLost + case .malformedDeviceAnnouncement: return .failed + } + } +} + +@MainActor private final class PairingLifetime { + var expired = false +} +#endif diff --git a/Runtime/Sources/CellUseRuntime/ConnectionDiagnostics.swift b/Runtime/Sources/CellUseRuntime/ConnectionDiagnostics.swift new file mode 100644 index 0000000..f09aefc --- /dev/null +++ b/Runtime/Sources/CellUseRuntime/ConnectionDiagnostics.swift @@ -0,0 +1,26 @@ +import DeviceHubDiagnostics +import Foundation + +enum ConnectionDiagnostics { + /// Connection-scoped, bounded diagnostics. No files or upload destination. + /// A host needing durable diagnostics can supply its own recorder instead. + static func makeRecorder() throws -> DiagnosticRecorder { + let installationID = UUID(), sessionID = UUID() + let context: DiagnosticWireContext + do { + context = try DiagnosticWireContext(installationID: installationID, sessionID: sessionID, + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0", + buildNumber: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0") + } catch { + context = try DiagnosticWireContext(installationID: installationID, sessionID: sessionID, + appVersion: "0.0", buildNumber: "0") + } + return try DiagnosticRecorder(context: context, + policy: DiagnosticRetentionPolicy(maximumEventCount: 128, maximumEncodedByteCount: 65_536), + persistence: DiagnosticPersistenceClient(load: { nil }, save: { _ in }, clear: {}), + uploader: DiagnosticUploadClient(upload: { (_: Data) async throws(DiagnosticUploadFailure) in + throw .invalidConfiguration + }), + now: Date.init) + } +} diff --git a/Runtime/Sources/PhoneProbeRouting/RoutedNativeClient.swift b/Runtime/Sources/PhoneProbeRouting/RoutedNativeClient.swift index 9e38942..4624790 100644 --- a/Runtime/Sources/PhoneProbeRouting/RoutedNativeClient.swift +++ b/Runtime/Sources/PhoneProbeRouting/RoutedNativeClient.swift @@ -2,7 +2,7 @@ import DeviceHubTransport import Foundation import PhoneProbeCore -actor RouteDiagnostics { +public actor RouteDiagnostics { struct Event: Encodable, Sendable { let sequence: Int let operation: String @@ -19,7 +19,7 @@ actor RouteDiagnostics { } private var value: Snapshot private var sequence = 0 - init(mode: ConnectionRoute.Mode) { value = Snapshot(mode: mode) } + public init(mode: ConnectionRoute.Mode) { value = Snapshot(mode: mode) } func snapshot() -> Snapshot { value } func path(_ path: VPNRouteStatus) { value.path = path } func record(_ operation: String, _ outcome: String, failure: NativeSessionFailure? = nil) { @@ -32,7 +32,7 @@ actor RouteDiagnostics { } } -enum RoutedNativeClient { +public enum RoutedNativeClient { /// Change only the destination. Keep Bonjour identity, authentication tags, /// keys, target, and generation intact. Never fall back to unauthenticated I/O. static func request(_ request: NativeRemoteSessionRequest, route: ConnectionRoute) @@ -50,7 +50,7 @@ enum RoutedNativeClient { } } - static func wrap(_ native: NativeSessionClient, route: ConnectionRoute, + public static func wrap(_ native: NativeSessionClient, route: ConnectionRoute, diagnostics: RouteDiagnostics, check: @escaping @Sendable (ConnectionRoute) -> VPNRouteStatus = VPNRouteCheck.status ) -> NativeSessionClient { diff --git a/Runtime/Sources/PhoneProbeRouting/VPNRouteCheck.swift b/Runtime/Sources/PhoneProbeRouting/VPNRouteCheck.swift index 443efda..1510c97 100644 --- a/Runtime/Sources/PhoneProbeRouting/VPNRouteCheck.swift +++ b/Runtime/Sources/PhoneProbeRouting/VPNRouteCheck.swift @@ -2,14 +2,14 @@ import Darwin import Foundation import PhoneProbeCore -enum VPNRouteStatus: String, Codable, Sendable { +public enum VPNRouteStatus: String, Codable, Sendable { case direct, tunnel, localAddress, otherInterface, unavailable } /// UDP connect chooses a source through the routing table without sending data. /// A utun existing somewhere is insufficient: this destination must use it. -enum VPNRouteCheck { - static func status(for route: ConnectionRoute) -> VPNRouteStatus { +public enum VPNRouteCheck { + public static func status(for route: ConnectionRoute) -> VPNRouteStatus { guard route.mode != .direct else { return .direct } let descriptor = socket(AF_INET, SOCK_DGRAM, 0) guard descriptor >= 0 else { return .unavailable } diff --git a/Sources/CellUse/PhoneConnectionSetup.swift b/Sources/CellUse/PhoneConnectionSetup.swift new file mode 100644 index 0000000..f001a29 --- /dev/null +++ b/Sources/CellUse/PhoneConnectionSetup.swift @@ -0,0 +1,244 @@ +import Foundation + +public struct PhonePairedDevice: Equatable, Identifiable, Sendable { + public let id: String + public let name: String + public init(id: String, name: String) { self.id = id; self.name = name } +} + +public enum PhoneSetupIssue: Error, Equatable, Sendable { + case tunnel, pairing, developerMode, unlockPhone, prepareServices, localNetwork + case unreachable, anotherSession, incompatibleRuntime, screenUnavailable + case inputUnavailable, portraitRequired, connectionLost, invalidSelection, failed + + public var message: String { + switch self { + case .tunnel: "Approve the app's VPN connection and try again. Check the embedded extension's signing if it cannot start." + case .pairing: "Pair this iPhone in Settings, then return to the app." + case .developerMode: "Enable Developer Mode in Settings > Privacy & Security and complete the restart prompts." + case .unlockPhone: "Unlock the iPhone and try again." + case .prepareServices: "Prepare this iPhone's developer services with a compatible desktop tool, then reconnect." + case .localNetwork: "Allow local-network access for this app in Settings." + case .unreachable: "Keep the iPhone awake with Wi-Fi enabled and reconnect the local tunnel." + case .anotherSession: "Close the other device-control session and try again." + case .incompatibleRuntime: "Use a runtime build compatible with this iOS version." + case .screenUnavailable: "A fresh screenshot is not available. Reconnect with the phone awake." + case .inputUnavailable: "The screen is connected, but input is not ready. Reconnect the session." + case .portraitRequired: "Keep the iPhone in portrait orientation." + case .connectionLost: "The session ended. Connect again to start a new session." + case .invalidSelection: "Select a device from this app's saved pairings." + case .failed: "Connection setup failed. Try connecting again." + } + } +} + +public enum PhoneConnectionState: Equatable, Sendable { + case idle, startingTunnel, pairingRequired, pairing(code: String?) + case chooseDevice([PhonePairedDevice]), connecting, checkingReadiness, ready + case needsAction(PhoneSetupIssue), stopping + + public var isReady: Bool { self == .ready } +} + +public enum PhoneConnectionEvent: Sendable { + case inputReady(Bool) + /// Monotonic capture time in the same clock domain as systemUptime. + case frame(capturedAt: Double, portrait: Bool) + case ended(PhoneSetupIssue) +} + +/// Boundary for connection backends. Implementations own their streams and close +/// resources before returning from close(). Pairing must respond to cancellation. +@MainActor public protocol PhoneConnectionDriver: AnyObject { + var onEvent: ((PhoneConnectionEvent) -> Void)? { get set } + func startTunnel() async throws + func savedDevices() async throws -> [PhonePairedDevice] + func pair(onCode: @escaping @MainActor (String) -> Void) async throws -> PhonePairedDevice + func open(deviceID: String) async throws + func close() async +} + +/// UI-independent setup flow. Retain it in the host and explicitly disconnect +/// when its connection is no longer needed. It never forgets saved pairings. +@MainActor public final class PhoneConnectionSetup { + public private(set) var state: PhoneConnectionState = .idle + public var onChange: ((PhoneConnectionState) -> Void)? + private let driver: any PhoneConnectionDriver + private let now: () -> Double + private let maximumFrameAge: Double + private let readinessTimeout: Double + private var operation: Task? + private var monitor: Task? + private var cleanup: Task? + private var disconnection: Task? + private var generation = UUID() + private var stopping = false + private var preparing = false + private var observing = false + private var inputReady = false + private var frame: (time: Double, portrait: Bool)? + private var waitingSince: Double? + + public init(driver: any PhoneConnectionDriver, maximumFrameAge: Double = 2, + readinessTimeout: Double = 30) { + precondition(maximumFrameAge.isFinite && maximumFrameAge > 0) + precondition(readinessTimeout.isFinite && readinessTimeout > 0) + self.driver = driver + self.maximumFrameAge = maximumFrameAge + self.readinessTimeout = readinessTimeout + self.now = { ProcessInfo.processInfo.systemUptime } + } + + // Deterministic clock for testing readiness transitions without sleeping. + init(driver: any PhoneConnectionDriver, now: @escaping () -> Double, + maximumFrameAge: Double = 2, readinessTimeout: Double = 30) { + self.driver = driver; self.now = now + self.maximumFrameAge = maximumFrameAge; self.readinessTimeout = readinessTimeout + } + + /// Starts the tunnel and reuses an existing pairing. Multiple pairings require + /// an explicit selection; a missing pairing is surfaced without deleting data. + public func connect(deviceID: String? = nil) { begin(pairing: false, deviceID: deviceID) } + + /// Call from the user's Pair action. Successful pairing continues to connection. + public func pair() { begin(pairing: true, deviceID: nil) } + + public func disconnect() async { + if let disconnection { await disconnection.value; return } + let task = Task { await self.closeConnection() } + disconnection = task + await task.value + disconnection = nil + } + + private func closeConnection() async { + if let cleanup { await cleanup.value } + guard !stopping else { return } + stopping = true; generation = UUID(); observing = false + publish(.stopping) + monitor?.cancel(); monitor = nil + let old = operation; old?.cancel() + await old?.value + operation = nil + driver.onEvent = nil + await driver.close() + frame = nil; inputReady = false; waitingSince = nil + stopping = false; publish(.idle) + } + + private func begin(pairing: Bool, deviceID: String?) { + guard disconnection == nil, !preparing, operation == nil, !stopping, !observing else { return } + preparing = true + let token = UUID(); generation = token + frame = nil; inputReady = false; waitingSince = nil + publish(.startingTunnel) + operation = Task { [weak self] in + guard let self else { return } + defer { self.operation = nil; self.preparing = false } + do { + await self.driver.close() + try self.validate(token) + self.driver.onEvent = { [weak self] event in + guard let self, self.generation == token, !self.stopping else { return } + self.receive(event) + } + try await self.driver.startTunnel() + try self.validate(token) + let selected: PhonePairedDevice + if pairing { + self.publish(.pairing(code: nil)) + selected = try await self.driver.pair { [weak self] code in + guard let self, self.generation == token, !self.stopping else { return } + self.publish(.pairing(code: code)) + } + } else { + let devices = try await self.driver.savedDevices() + try self.validate(token) + if let deviceID { + guard let match = devices.first(where: { $0.id == deviceID }) else { throw PhoneSetupIssue.invalidSelection } + selected = match + } else if devices.count == 1 { + selected = devices[0] + } else { + self.publish(devices.isEmpty ? .pairingRequired : .chooseDevice(devices)) + return + } + } + try self.validate(token) + self.publish(.connecting) + try await self.driver.open(deviceID: selected.id) + try self.validate(token) + self.observing = true + self.waitingSince = self.now() + self.tick() + self.monitor = Task { [weak self] in + while !Task.isCancelled { + do { try await Task.sleep(for: .milliseconds(250)) } catch { return } + guard let self, self.generation == token, self.observing else { return } + self.tick() + } + } + } catch { + self.observing = false + await self.driver.close() + guard self.generation == token, !self.stopping else { return } + self.publish(error is CancellationError ? .idle : .needsAction(error as? PhoneSetupIssue ?? .failed)) + } + } + } + + private func validate(_ token: UUID) throws { + try Task.checkCancellation() + guard generation == token, !stopping else { throw CancellationError() } + } + + private func receive(_ event: PhoneConnectionEvent) { + switch event { + case .inputReady(let ready): inputReady = ready + case .frame(let time, let portrait): frame = (time, portrait) + case .ended(let issue): + // Native open can emit an end before returning. Cancel that operation + // too, so it cannot later overwrite the terminal state with ready. + fail(issue); return + } + if observing { tick() } + } + + func tick() { + guard observing else { return } + let fresh = frame.map { (0...maximumFrameAge).contains(now() - $0.time) } ?? false + if fresh, frame?.portrait == true, inputReady { + waitingSince = nil; publish(.ready) + } else { + if waitingSince == nil { waitingSince = now() } + publish(.checkingReadiness) + if now() - (waitingSince ?? now()) >= readinessTimeout { + fail(!fresh ? .screenUnavailable : frame?.portrait == false ? .portraitRequired : .inputUnavailable) + } + } + } + + private func fail(_ issue: PhoneSetupIssue) { + guard !stopping else { return } + generation = UUID(); observing = false + monitor?.cancel(); monitor = nil + operation?.cancel() + // Block reconnect until teardown has finished. + stopping = true + let pending = operation + cleanup = Task { [weak self] in + await pending?.value + guard let self else { return } + self.driver.onEvent = nil + await self.driver.close() + self.cleanup = nil; self.stopping = false + self.publish(.needsAction(issue)) + } + publish(.needsAction(issue)) + } + + private func publish(_ next: PhoneConnectionState) { + guard state != next else { return } + state = next; onChange?(next) + } +} diff --git a/Sources/CellUseTunnel/CellUseTunnelController.swift b/Sources/CellUseTunnel/CellUseTunnelController.swift index 1fa9ecf..a69f7ef 100644 --- a/Sources/CellUseTunnel/CellUseTunnelController.swift +++ b/Sources/CellUseTunnel/CellUseTunnelController.swift @@ -38,6 +38,16 @@ public final class CellUseTunnelController { self.manager = manager let existing = manager.protocolConfiguration as? NETunnelProviderProtocol let existingConfiguration = try? TunnelConfiguration(providerValues: existing?.providerConfiguration) + // A previous connection can still be stopping when the host retries. + // Wait for that same profile rather than asking the user to retry again. + if manager.connection.status == .disconnecting { + let deadline = ContinuousClock.now.advanced(by: .seconds(15)) + while manager.connection.status == .disconnecting { + try checkCancellation() + guard ContinuousClock.now < deadline else { throw TunnelError.connectionTimedOut } + try await Task.sleep(for: .milliseconds(150)) + } + } let state = manager.connection.status if state == .connected || state == .connecting || state == .reasserting { guard existingConfiguration == configuration else { throw TunnelError.configurationInUse } diff --git a/Tests/CellUseTests/PhoneConnectionSetupTests.swift b/Tests/CellUseTests/PhoneConnectionSetupTests.swift new file mode 100644 index 0000000..02dcf64 --- /dev/null +++ b/Tests/CellUseTests/PhoneConnectionSetupTests.swift @@ -0,0 +1,193 @@ +import Foundation +import Testing +@testable import CellUse + +@MainActor private final class SetupDriver: PhoneConnectionDriver { + var onEvent: ((PhoneConnectionEvent) -> Void)? + var devices = [PhonePairedDevice(id: "phone", name: "My iPhone")] + var calls: [String] = [] + var openIssue: PhoneSetupIssue? + var tunnelIssue: PhoneSetupIssue? + var onOpen: (() -> Void)? + var delayedOpen = false + var delayedClose = false + var suspended: CheckedContinuation? + var closing: CheckedContinuation? + func startTunnel() async throws { calls.append("tunnel"); if let tunnelIssue { throw tunnelIssue } } + func savedDevices() async throws -> [PhonePairedDevice] { calls.append("saved"); return devices } + func pair(onCode: @escaping @MainActor (String) -> Void) async throws -> PhonePairedDevice { + calls.append("pair"); onCode("123456") + return PhonePairedDevice(id: "new", name: "This iPhone") + } + func open(deviceID: String) async throws { + calls.append("open:" + deviceID) + if let openIssue { throw openIssue } + if delayedOpen { await withCheckedContinuation { suspended = $0 } } + onOpen?() + } + func close() async { + calls.append("close") + if delayedClose { await withCheckedContinuation { closing = $0 } } + } +} + +@MainActor private func settle(_ condition: @escaping @MainActor () -> Bool) async throws { + for _ in 0..<1000 { + if condition() { return } + await Task.yield() + } + throw WaitFailure.timedOut +} +private enum WaitFailure: Error { case timedOut } + +@Test @MainActor func setupReusesPairingAndRequiresFreshPortraitAndInput() async throws { + let driver = SetupDriver() + var now = 10.0 + let setup = PhoneConnectionSetup(driver: driver, now: { now }) + setup.connect(); setup.connect() + try await settle { setup.state == .checkingReadiness } + #expect(driver.calls.filter { $0 == "tunnel" }.count == 1) + #expect(!driver.calls.contains("pair")) + driver.onEvent?(.inputReady(true)) + #expect(!setup.state.isReady) + driver.onEvent?(.frame(capturedAt: 1, portrait: true)) + #expect(!setup.state.isReady) + driver.onEvent?(.frame(capturedAt: 10, portrait: false)) + #expect(!setup.state.isReady) + driver.onEvent?(.frame(capturedAt: 10, portrait: true)) + #expect(setup.state == .ready) + now = 13; setup.tick() + #expect(setup.state == .checkingReadiness) + driver.onEvent?(.frame(capturedAt: 13, portrait: true)) + #expect(setup.state == .ready) + driver.onEvent?(.inputReady(false)) + #expect(!setup.state.isReady) + await setup.disconnect() + #expect(setup.state == .idle) +} + +@Test @MainActor func setupPairingContinuesWithoutASecondConnectAction() async throws { + let driver = SetupDriver(); driver.devices = [] + let setup = PhoneConnectionSetup(driver: driver) + var states: [PhoneConnectionState] = [] + setup.onChange = { states.append($0) } + setup.connect() + try await settle { setup.state == .pairingRequired } + #expect(!driver.calls.contains("pair")) + // Wait for the setup operation's defer after its final state callback. + await Task.yield() + setup.pair() + try await settle { setup.state == .checkingReadiness } + #expect(states.contains(.pairing(code: "123456"))) + #expect(driver.calls.contains("open:new")) + #expect(!String(describing: setup.state).contains("123456")) + await setup.disconnect() +} + +@Test @MainActor func setupNeverGuessesBetweenSavedDevices() async throws { + let driver = SetupDriver() + driver.devices.append(PhonePairedDevice(id: "other", name: "Other iPhone")) + let setup = PhoneConnectionSetup(driver: driver) + setup.connect() + try await settle { setup.state == .chooseDevice(driver.devices) } + #expect(!driver.calls.contains(where: { $0.hasPrefix("open:") })) + await Task.yield() + setup.connect(deviceID: "other") + try await settle { setup.state == .checkingReadiness } + #expect(driver.calls.contains("open:other")) + await setup.disconnect() +} + +@Test @MainActor func invalidSelectionAndServiceFailureRemainActionable() async throws { + let driver = SetupDriver(), setup = PhoneConnectionSetup(driver: SetupDriver()) + setup.connect(deviceID: "unknown") + try await settle { setup.state == .needsAction(.invalidSelection) } + await setup.disconnect() + driver.openIssue = .prepareServices + let failed = PhoneConnectionSetup(driver: driver) + failed.connect() + try await settle { failed.state == .needsAction(.prepareServices) } + #expect(driver.calls.last == "close") + await failed.disconnect() +} + +@Test @MainActor func oldSessionCallbacksCannotRestoreReadiness() async throws { + let driver = SetupDriver() + let connection = PhoneConnectionSetup(driver: driver, now: { 10 }) + connection.connect() + try await settle { connection.state == .checkingReadiness } + let old = driver.onEvent + await connection.disconnect() + connection.connect() + try await settle { connection.state == .checkingReadiness } + old?(.inputReady(true)); old?(.frame(capturedAt: 10, portrait: true)) + old?(.ended(.pairing)) + #expect(connection.state == .checkingReadiness) + await connection.disconnect() +} + +@Test @MainActor func cancellationClosesLateOpenBeforeReconnect() async throws { + let driver = SetupDriver(); driver.delayedOpen = true + let setup = PhoneConnectionSetup(driver: driver) + setup.connect() + try await settle { driver.suspended != nil } + let stop = Task { await setup.disconnect() } + try await settle { setup.state == .stopping } + setup.connect() + #expect(driver.calls.filter { $0 == "tunnel" }.count == 1) + driver.suspended?.resume(); driver.suspended = nil + await stop.value + #expect(setup.state == .idle) + #expect(driver.calls.last == "close") +} + +@Test @MainActor func endedDuringOpenCannotBecomeReady() async throws { + let driver = SetupDriver() + driver.onOpen = { [weak driver] in driver?.onEvent?(.ended(.connectionLost)) } + let setup = PhoneConnectionSetup(driver: driver) + setup.connect() + try await settle { setup.state == .needsAction(.connectionLost) } + await setup.disconnect() + #expect(setup.state == .idle) + #expect(driver.calls.last == "close") +} + +@Test @MainActor func readinessTimeoutClosesSessionAndReportsMissingInput() async throws { + let driver = SetupDriver() + var now = 10.0 + let setup = PhoneConnectionSetup(driver: driver, now: { now }, readinessTimeout: 5) + setup.connect() + try await settle { setup.state == .checkingReadiness } + now = 16 + driver.onEvent?(.frame(capturedAt: now, portrait: true)) + try await settle { setup.state == .needsAction(.inputUnavailable) } + await setup.disconnect() + #expect(setup.state == .idle) +} + +@Test @MainActor func failedTunnelNeverAttemptsPairingOrNativeConnection() async throws { + let driver = SetupDriver(); driver.tunnelIssue = .tunnel + let setup = PhoneConnectionSetup(driver: driver) + setup.connect() + try await settle { setup.state == .needsAction(.tunnel) } + #expect(driver.calls == ["close", "tunnel", "close"]) + await setup.disconnect() +} + +@Test @MainActor func concurrentDisconnectsBothWaitForTeardown() async throws { + let driver = SetupDriver() + let connection = PhoneConnectionSetup(driver: driver) + connection.connect() + try await settle { connection.state == .checkingReadiness } + driver.delayedClose = true + let first = Task { await connection.disconnect() } + try await settle { driver.closing != nil } + var secondFinished = false + let second = Task { await connection.disconnect(); secondFinished = true } + for _ in 0..<10 { await Task.yield() } + #expect(!secondFinished) + driver.delayedClose = false; driver.closing?.resume(); driver.closing = nil + await first.value; await second.value + #expect(connection.state == .idle) + #expect(driver.calls.filter { $0 == "close" }.count == 2) +}