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
| Mode | What it does | Where capture runs |
|---|---|---|
| VPN | NEPacketTunnelProvider active; system-wide DNS + TCP intercept; in-process MITM. | On the device. LogStreamer writes entries directly to Supabase. |
| PAC | Wi-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:
The animated app icon is the primary connection indicator (SetupGuideView.swift — AnimatedAppIcon 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:
// 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:
.onChange(of: NEVPNStatus)— VPN flipped via the toggle..onChange(of: scenePhase == .active)— user came back from Settings → Wi-Fi.- A 1.5 s
Timer.publishonSetupGuideView— 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):
| Source | Column | Meaning |
|---|---|---|
| Per-device override | settings_device.connection_type (nullable) | null = inherit the user default. |
| Per-user default | profiles.connection_type (nullable) | The device owner's account default. null = inherit global. |
| Global default | settings_global.connection_type | Project-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":
- 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.
- 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
| File | Role |
|---|---|
BusymateHelper/Views/SetupGuideView.swift | Main 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.swift | The detailed steps the readiness card opens (cert install, PAC setup, etc.). |
BusymateHelper/Views/PACSetupSheet.swift | "Configure → Wi-Fi → Proxy → Automatic" walkthrough. |
BusymateHelper/Services/RemoteSheetController.swift | Handles the open-sheet broadcast; presents the settings / cert / pac sheet on demand. |
BusymateHelper/Views/CertSetupSheet.swift | CA download + Settings → General → About → Certificate Trust walkthrough. |
BusymateHelper/ViewModels/NetworkMonitorViewModel.swift | selectedMode, pacExpectedHost, reconcileVPNvsPAC, owns RealtimeSubscriber / WhitelistRegistrar / PortAllocator / remoteSettings / VPNManager. |
BusymateHelper/Services/SystemProxyReader.swift | Single live CFNetworkCopySystemProxySettings() read. |
BusymateHelper/Services/PortAllocator.swift | Periodic /allocate round-trip; sets AppSettings.streaming.allocatedPort. |
Verification scenarios
- First launch, never paired →
DevicePairingSheetcovers 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.
Related
- 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.