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
96 changes: 96 additions & 0 deletions Docs/connection.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions Docs/embedded-tunnel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Docs/runtime-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion Runtime/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
229 changes: 229 additions & 0 deletions Runtime/Sources/CellUseRuntime/CellUseConnection.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?
private var events: Task<Void, Never>?
private var frames: Task<Void, Never>?
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
26 changes: 26 additions & 0 deletions Runtime/Sources/CellUseRuntime/ConnectionDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading