iOS connection modes

VPN tunnel mode vs. PAC mode — when each applies and how the app switches between them.

BusymateHelper exposes two capture paths as mutually-exclusive modes in a single segmented control: VPN tunnel and Wi-Fi PAC. This doc covers how the swap is reconciled, where the readiness card lives, and why an empty pacExpectedHost silently disables PAC detection.

TL;DR

ModeWhat it doesWhere capture runs
VPNNEPacketTunnelProvider active; system-wide DNS + TCP intercept; in-process MITM.On the device. LogStreamer writes entries directly to Supabase.
PACWi-Fi profile points the device's Wi-Fi proxy at <allocatedPort>.busymate.net.On the VPS. web/proxy-server accepts the TCP via the port pool listener.

Both modes can be configured simultaneously, but iOS's PAC config is invisible to the app while VPN is up — so the UI treats them as a single picker and reconciles which one is "live" at any moment.

UI shape

BusymateHelper/Views/SetupGuideView.swift is the central iOS screen — replaces the old "Network log + Tunnel log + Settings" toolbar. The toolbar is intentionally bare; everything lives in the body:

Jade CardinalPAC · port 9012 · readyPACVPNReadiness✓ Paired✓ CA installed and trusted✓ PAC URL configured✓ Port allocated

The animated app icon is the primary connection indicator (SetupGuideView.swiftAnimatedAppIcon with arcMode + connectionBadge). Tapping the readiness card opens the setup-steps sheet; the gear button on the card opens Settings.

The whole screen is gated by DevicePairingSheet until a 365-day device JWT lands — ContentView presents the pairing sheet over SetupGuideView while DeviceJWTKeychain.read() returns nil.

Mode picker semantics

SetupGuideView exposes a Picker with two cases — pac, vpn. The picker state lives in NetworkMonitorViewModel.selectedMode. Two reconciliation rules:

swift
// NetworkMonitorViewModel.swift, reconcileVPNvsPAC
if vpnConnected && systemProxyMatchesPACExpectedHost {
    // Both look "on" — VPN wins; ignore PAC config.
    selectedMode = .vpn
} else if vpnConnected {
    selectedMode = .vpn
} else if systemProxyMatchesPACExpectedHost {
    selectedMode = .pac
}

Fired from three places:

  1. .onChange(of: NEVPNStatus) — VPN flipped via the toggle.
  2. .onChange(of: scenePhase == .active) — user came back from Settings → Wi-Fi.
  3. A 1.5 s Timer.publish on SetupGuideView — covers the case where the user toggled iOS Settings → Wi-Fi → Proxy while the app stayed foregrounded.

pacExpectedHost is port-derived: the expected Wi-Fi proxy host is <allocatedPort>.busymate.net, where allocatedPort lives in AppSettings.streaming.allocatedPort. Until PortAllocator succeeds against the proxy-server /allocate endpoint, allocatedPort is nil, pacExpectedHost is empty, and PAC detection silently returns .notSet regardless of what the user pastes into Wi-Fi → Proxy. Without an /allocate round-trip, PAC mode cannot self-detect.

SystemProxyReader (BusymateHelper/Services/SystemProxyReader.swift) wraps CFNetworkCopySystemProxySettings() and returns a .matches | .different | .notSet outcome.

Connection-type preference (vs. the live mode)

The picker above tracks what is actually live. Separately, the home screen carries a Connection row that opens a child sheet (BusymateHelper/Views/ConnectionSheet.swift) with its own VPN / PAC selector — this is a durable preference (default VPN), not a capture toggle. Picking a mode here only chooses which controls the sheet shows (VPN power toggle, or PAC URL + walkthrough); it does not start or stop capture.

The preference is a three-tier value resolved server-side (most specific wins):

SourceColumnMeaning
Per-device overridesettings_device.connection_type (nullable)null = inherit the user default.
Per-user defaultprofiles.connection_type (nullable)The device owner's account default. null = inherit global.
Global defaultsettings_global.connection_typeProject-wide default.
Hardcoded fallback'vpn' when none is set.

effective_settings_for_device(target_uuid) merges them device-override → user-default → global-default → 'vpn' and returns the result in the same effective-settings blob RemoteSettingsClient already consumes. The user level is read via the device_owner_connection_type(uuid) security-definer helper (a device JWT can't read its owner's profile under RLS). All three levels push over Realtime — a dashboard edit (per-device switch in the device iOS App tab, Your default connection type or Default connection type in global Settings) reflects on the device live; the per-user change rides the device's profile:<owner> channel (UPDATE broadcast → settings refetch). The home screen shows only the selected mode's body inline (SetupGuideView.modeBody), no sheet.

Mutual-exclusion rationale

There are two paths to "VPN and PAC both look on":

  1. Workaround 1 (rejected): inspect both states and refuse to render the PAC tab while VPN is up. Too brittle — the user is allowed to toggle PAC at any time.
  2. Workaround 2 (rejected): tear down VPN on entering Settings → Wi-Fi. Surprising and lossy.

What the code actually does: treat the two as a single segmented control, let either one be "selected", and trust the reconciliation rules above. When PAC is detected to match this device's allocated-port subdomain while VPN is up, the picker stays on .vpn — VPN takes priority because the user actively configured PAC but the OS isn't actually routing through it.

Files

FileRole
BusymateHelper/Views/SetupGuideView.swiftMain screen. Animated icon, name field, readiness card, and modeBody — the selected connection mode's controls shown inline (the dashboard switches the mode by writing settings_device.connection_type; no sheet).
BusymateHelper/Views/SetupStepsSheet.swiftThe detailed steps the readiness card opens (cert install, PAC setup, etc.).
BusymateHelper/Views/PACSetupSheet.swift"Configure → Wi-Fi → Proxy → Automatic" walkthrough.
BusymateHelper/Services/RemoteSheetController.swiftHandles the open-sheet broadcast; presents the settings / cert / pac sheet on demand.
BusymateHelper/Views/CertSetupSheet.swiftCA download + Settings → General → About → Certificate Trust walkthrough.
BusymateHelper/ViewModels/NetworkMonitorViewModel.swiftselectedMode, pacExpectedHost, reconcileVPNvsPAC, owns RealtimeSubscriber / WhitelistRegistrar / PortAllocator / remoteSettings / VPNManager.
BusymateHelper/Services/SystemProxyReader.swiftSingle live CFNetworkCopySystemProxySettings() read.
BusymateHelper/Services/PortAllocator.swiftPeriodic /allocate round-trip; sets AppSettings.streaming.allocatedPort.

Verification scenarios

  • First launch, never pairedDevicePairingSheet covers everything. Picker disabled.
  • Paired, CA not trusted → Readiness card shows ✗ CA. Tapping the row opens the cert install flow.
  • Paired, CA trusted, port allocated, Wi-Fi PAC not set → Picker shows PAC, readiness card row "Configure Wi-Fi proxy" with a button.
  • Paired, CA trusted, Wi-Fi PAC matches <port>.busymate.net → Picker auto-selects PAC. VPN off.
  • Paired, VPN toggled on → Picker auto-selects VPN. PAC state ignored regardless.
  • Both on (user manually configured Wi-Fi PAC while VPN was already up) → Picker stays on VPN. Reconciliation suppresses the PAC detection.
  • Per-device attribution — how the proxy-server figures out which device a PAC-mode TCP connection belongs to.
  • PAC presence — how a backgrounded iOS device in PAC mode keeps appearing Online on the dashboard.
  • BusymateHelper iOS — overall iOS architecture; VPN tunnel + LogStreamer specifics.