diff --git a/Apps/iOS/CellTunnelPhoneApp.swift b/Apps/iOS/CellTunnelPhoneApp.swift index 227a688..2cbc21f 100644 --- a/Apps/iOS/CellTunnelPhoneApp.swift +++ b/Apps/iOS/CellTunnelPhoneApp.swift @@ -44,17 +44,19 @@ struct CellTunnelPhoneApp: App { ) } - private static func makeBackend() -> any RelayControlBackend { - #if targetEnvironment(macCatalyst) + #if targetEnvironment(macCatalyst) + private static func makeBackend() -> AgentRelayBackend { logger.notice("phone app selecting Mac agent backend") return AgentRelayBackend() - #else + } + #else + private static func makeBackend() -> PhoneRelayBackend { // The iPhone backend drives the on-device packet tunnel and delegates to // the in-process relay runtime host in the simulator. logger.notice("phone app selecting iPhone relay backend") return PhoneRelayBackend() - #endif - } + } + #endif var body: some Scene { WindowGroup { diff --git a/Apps/iOS/Services/PhoneRelayBackend.swift b/Apps/iOS/Services/PhoneRelayBackend.swift index 88cdef4..268dbef 100644 --- a/Apps/iOS/Services/PhoneRelayBackend.swift +++ b/Apps/iOS/Services/PhoneRelayBackend.swift @@ -32,7 +32,7 @@ /// extension. The data plane lives in the extension, so this type owns no /// forwarder; it reflects the polled snapshot into a `RelayStatusSample`. @MainActor - final class PhoneRelayBackend: RelayControlBackend { + final class PhoneRelayBackend: RelayControlBackend, PhoneTunnelProvisioningBackend { private var manager: NETunnelProviderManager? private var lastSample: RelayStatusSample? private var configurationChangeObserver: NSObjectProtocol? @@ -114,6 +114,19 @@ } } + // MARK: - Tunnel install + + // The iPhone tunnel carries no WireGuard config, so installing it saves and + // starts the provider manager through the existing start path. + func installTunnel(configURL _: URL) async { + if isSimulator { + await simulatorProbe.installTunnel(configURL: URL(fileURLWithPath: "/")) + return + } + logger.notice("phone relay backend install tunnel: starting session") + await start() + } + private func makeSample( snapshot: TunnelDaemonStatusSnapshot, connectionStatus: NEVPNStatus ) -> RelayStatusSample { @@ -294,24 +307,8 @@ await Task.yield() } - // MARK: - Tunnel install - - // The iPhone tunnel carries no WireGuard config, so installing it saves and - // starts the provider manager through the existing start path. - func installTunnel(configURL _: URL) async { - if isSimulator { - await simulatorProbe.installTunnel(configURL: URL(fileURLWithPath: "/")) - return - } - logger.notice("phone relay backend install tunnel: starting session") - await start() - } - // MARK: - Provider messaging - // The iPhone hosts no config library, so it takes the shared no-op config-op defaults - // from RelayControlBackend; its tunnel carries no WireGuard config. - private func sendStatusRequest( on session: NETunnelProviderSession ) async throws -> ProviderControlResponse { diff --git a/Apps/iOS/Services/PhoneTunnelProvisioningBackend.swift b/Apps/iOS/Services/PhoneTunnelProvisioningBackend.swift new file mode 100644 index 0000000..b044e84 --- /dev/null +++ b/Apps/iOS/Services/PhoneTunnelProvisioningBackend.swift @@ -0,0 +1,19 @@ +// +// PhoneTunnelProvisioningBackend.swift +// CellTunnelPhone +// +// Created by Alexander Goodkind on 2026-07-21. +// Copyright © 2026, all rights reserved. +// + +#if !targetEnvironment(macCatalyst) + // MARK: - PhoneTunnelProvisioningBackend + + /// The iPhone-only read of its one-time VPN approval state. It does not expose + /// library operations or configuration mutation. + @MainActor + protocol PhoneTunnelProvisioningBackend { + /// Returns true when the iPhone's own VPN configuration is already approved. + func tunnelProvisioned() async -> Bool + } +#endif diff --git a/Apps/iOS/Services/RelayControlBackend.swift b/Apps/iOS/Services/RelayControlBackend.swift index 311e3d5..d901f85 100644 --- a/Apps/iOS/Services/RelayControlBackend.swift +++ b/Apps/iOS/Services/RelayControlBackend.swift @@ -75,4 +75,5 @@ extension RelayControlBackend { var usesEgressRoster: Bool { false } + } diff --git a/Apps/iOS/Services/RelayController.swift b/Apps/iOS/Services/RelayController.swift index b29a735..310bcb4 100644 --- a/Apps/iOS/Services/RelayController.swift +++ b/Apps/iOS/Services/RelayController.swift @@ -141,7 +141,12 @@ struct RelayStatusSample: Sendable { @Observable final class RelayController { let backend: any RelayControlBackend - private let installState: InstallationState + let installState: InstallationState + + #if !targetEnvironment(macCatalyst) + private let phoneProvisioningBackend: any PhoneTunnelProvisioningBackend + #endif + private let deviceProbe: DeviceEgressProbe? private var pollTask: Task? private var throughput: ThroughputCalculator @@ -226,6 +231,7 @@ final class RelayController { var relayHost: String? var relayServerIPv4Address: String? var relayServerIPv6Address: String? + /// The agent's config library mirrored from the status poll, the rows the Configs /// card lists, so the card reads the same source as the Relay tile and the two /// never diverge. Empty on the iPhone, which hosts no library. @@ -234,19 +240,35 @@ final class RelayController { /// active and the running tunnel uses. var activeConfigID: UUID? - init( - backend: any RelayControlBackend, - throughput: ThroughputCalculator, - lifetimeStore: LifetimeDataStore, - installState: InstallationState = InstallationState(), - deviceProbe: DeviceEgressProbe? = nil - ) { - self.backend = backend - self.throughput = throughput - self.lifetimeStore = lifetimeStore - self.installState = installState - self.deviceProbe = deviceProbe - } + #if targetEnvironment(macCatalyst) + init( + backend: any RelayControlBackend, + throughput: ThroughputCalculator, + lifetimeStore: LifetimeDataStore, + installState: InstallationState = InstallationState(), + deviceProbe: DeviceEgressProbe? = nil + ) { + self.backend = backend + self.throughput = throughput + self.lifetimeStore = lifetimeStore + self.installState = installState + self.deviceProbe = deviceProbe + } + #else + init( + backend: some RelayControlBackend & PhoneTunnelProvisioningBackend, + throughput: ThroughputCalculator, + lifetimeStore: LifetimeDataStore, + deviceProbe: DeviceEgressProbe? = nil + ) { + self.backend = backend + phoneProvisioningBackend = backend + self.throughput = throughput + self.lifetimeStore = lifetimeStore + installState = InstallationState() + self.deviceProbe = deviceProbe + } + #endif // MARK: - Lifecycle @@ -259,10 +281,10 @@ final class RelayController { startPolling() } - /// Starts the relay only when a saved tunnel configuration is already approved. + /// Starts the relay only when the platform's own required setup is complete. func prepare() async { logger.notice("relay controller prepare requested") - let provisioned = await backend.tunnelProvisioned() + let provisioned = await platformTunnelProvisioned() if provisioned { await start() } else { @@ -274,7 +296,7 @@ final class RelayController { /// Refreshes saved tunnel presence and starts the relay when provisioned and idle. func refreshProvisioned() async { logger.notice("relay controller provisioned refresh requested") - let provisioned = await backend.tunnelProvisioned() + let provisioned = await platformTunnelProvisioned() if !provisioned { isTunnelInstalled = false logger.notice("relay controller provisioned refresh found no saved tunnel") @@ -285,6 +307,14 @@ final class RelayController { } } + private func platformTunnelProvisioned() async -> Bool { + #if targetEnvironment(macCatalyst) + return await backend.tunnelProvisioned() + #else + return await phoneProvisioningBackend.tunnelProvisioned() + #endif + } + // Wires the app's egress probe to the device-value recompute and starts it for // the app lifetime, so the `Device` rows show the app's own egress before the // relay runs and whenever the relay does not carry the device's traffic. @@ -352,9 +382,13 @@ final class RelayController { } if let sample = await backend.sample() { apply(sample) - await refreshInstallState(agentReachable: true) + #if targetEnvironment(macCatalyst) + await refreshInstallState(agentReachable: true) + #endif } else { - await refreshInstallState(agentReachable: false) + #if targetEnvironment(macCatalyst) + await refreshInstallState(agentReachable: false) + #endif } guard !Task.isCancelled else { return @@ -428,26 +462,32 @@ final class RelayController { } } - // Refreshes the agent install state each poll, so the install-agent setup tier - // appears on a Mac with no agent and clears once the agent answers or is enabled. - // The install read runs off the main actor, so the poll awaits it and the main - // thread stays free to present modals mid-poll. - private func refreshInstallState(agentReachable: Bool) async { - await installState.refresh(agentReachable: agentReachable) - isAgentInstalled = installState.isAgentInstalled - isAgentApprovalPending = installState.isApprovalPending - } + #if targetEnvironment(macCatalyst) + // Refreshes the agent install state each poll, so the install-agent setup tier + // appears on a Mac with no agent and clears once the agent answers or is enabled. + // The install read runs off the main actor, so the poll awaits it and the main + // thread stays free to present modals mid-poll. + private func refreshInstallState(agentReachable: Bool) async { + await installState.refresh(agentReachable: agentReachable) + isAgentInstalled = installState.isAgentInstalled + isAgentApprovalPending = installState.isApprovalPending + } + #endif + +} + +// MARK: - Routing control - // MARK: - Routing control +extension RelayController { - /// Whether an active config exists to relay through, the gate that decides a - /// connected peer can route at all. The Mac reads the agent's active config id; - /// the iPhone, whose tunnel carries its own config, mirrors its saved-tunnel flag. + /// Whether the current platform has an active relay configuration. Mac Catalyst + /// reads the agent library selection, while the iPhone reads its approved VPN state. var hasActiveConfig: Bool { - if usesEgressRoster { + #if targetEnvironment(macCatalyst) return activeConfigID != nil - } - return isTunnelInstalled + #else + return isTunnelInstalled + #endif } /// The derived state of the single Route traffic switch, computed once from the diff --git a/Apps/iOS/Views/PreviewRelayBackend.swift b/Apps/iOS/Views/PreviewRelayBackend.swift index cc37a1a..f6ab759 100644 --- a/Apps/iOS/Views/PreviewRelayBackend.swift +++ b/Apps/iOS/Views/PreviewRelayBackend.swift @@ -46,7 +46,8 @@ final class PreviewRelayBackend: RelayControlBackend { func installTunnel(configURL _: URL) async { await Task.yield() } - - // The preview backend hosts no config library, so it takes the shared no-op config-op - // defaults from RelayControlBackend. } + +#if !targetEnvironment(macCatalyst) + extension PreviewRelayBackend: PhoneTunnelProvisioningBackend {} +#endif