Skip to content
Closed
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
12 changes: 7 additions & 5 deletions Apps/iOS/CellTunnelPhoneApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 14 additions & 17 deletions Apps/iOS/Services/PhoneRelayBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions Apps/iOS/Services/PhoneTunnelProvisioningBackend.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// PhoneTunnelProvisioningBackend.swift
// CellTunnelPhone
//
// Created by Alexander Goodkind <alex@goodkind.io> 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
1 change: 1 addition & 0 deletions Apps/iOS/Services/RelayControlBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,5 @@ extension RelayControlBackend {
var usesEgressRoster: Bool {
false
}

}
110 changes: 75 additions & 35 deletions Apps/iOS/Services/RelayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
private var throughput: ThroughputCalculator
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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 {
Expand All @@ -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")
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions Apps/iOS/Views/PreviewRelayBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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