How a PAC-mode iPhone shows as Online on the dashboard even while the main app is backgrounded or suspended. Companion to Per-device attribution — that doc covers which device a request belongs to, this one covers how a device gets surfaced as alive.
The challenge
PAC mode routes the iOS Wi-Fi proxy at <port>.busymate.net, where capture happens on the VPS (web/proxy-server). The main app doesn't see this traffic — it sees no events at all once the user backgrounds it. iOS suspends the process within ~30 s of backgrounding, killing the foregrounded RealtimeSubscriber socket. If "Online" was derived from "does the iOS app have a live Realtime connection?", every PAC user would flip to Offline a few seconds after locking their phone.
The fix is to publish presence server-side, from the proxy-server, on behalf of each device whose port pool is receiving traffic.
Three presence publishers
The dashboard reads device_status.online (synced from Realtime Presence) and shows the device as Online if any of these publishes a presence event:
| Publisher | Where | When |
|---|---|---|
Main app RealtimeSubscriber | ios/BusymateHelper/Services/RealtimeSubscriber.swift | iOS app foregrounded. Tracks on devices:all with source: "main". Skipped while VPN is active. |
Tunnel extension TunnelPresence | ios/PacketTunnelExtension/TunnelPresence.swift | VPN tunnel up. Tracks on devices:all with source: "tunnel". Survives main-app backgrounding because iOS keeps the extension alive while NEPacketTunnelProvider is active. |
Proxy-server PerDevicePresence | web/proxy-server/src/perDevicePresence.ts | PAC mode, any time the device's port-pool listener has had traffic recently. Tracks on devices:all with source: "pac". |
The dashboard's device_status:all Realtime subscription consumes all three; the per-row UI shows a source pill that says which mechanism is currently keeping the device lit.
PerDevicePresence
web/proxy-server/src/perDevicePresence.ts manages one supabase-js Realtime client + channel per active iOS device UUID:
- Trigger — every successful TCP accept on a port-pool listener (
web/proxy-server/src/portPool.ts) callsperDevicePresence.touch(uuid). The first touch for an unknown UUID spins up a fresh client withpresence.key = uuidandsource: "pac", subscribes todevices:all, and presence-tracks. - Renewal — subsequent touches reset an internal
IDLE_TIMEOUT_MS = 90_000timer. As long as some PAC traffic flows once every 90 s, the presence stays up. - Teardown — when the idle timer fires (90 s with no traffic), the per-device client is untracked + closed. The dashboard sees the leave event and flips the device to Offline.
The 90 s window is chosen to bridge the user's "open Safari → tap a link → switch back to home screen" pattern without flapping the indicator.
Foreground-side support
While the main app is foregrounded, two iOS-side mechanisms keep the proxy-server happy:
WhitelistRegistrar (BusymateHelper/Services/WhitelistRegistrar.swift)
registerNow()POSTs the device's apparent IP toproxy.busymate.net/whitelist/register(cert-bundle-auth-gated).startPeriodicRefresh(every: 5 minutes)keeps the whitelist row alive — proxy-server'sIpWhitelistTTL is 24 h, so this is belt-and-suspenders.- Foreground hook in
NetworkMonitorViewModel.willEnterForeground:whitelist.registerNow()+portAllocator.allocateNow()+realtimeSub.refreshStatus()+realtimeSub.retrackForForeground()all fire together.
PortAllocator (BusymateHelper/Services/PortAllocator.swift)
allocateNow()POSTsproxy.busymate.net/allocatewith{uuid, deviceName, country: Locale.current.region}. Server returns{port}, which gets written toAppSettings.streaming.allocatedPort.startPeriodicRefresh(every: 30 minutes)re-allocates idempotently. Server-side this is a no-op for already-claimed UUIDs.
BackgroundRefreshScheduler (Shared/BackgroundRefreshScheduler.swift)
When the app is backgrounded but the device is on Wi-Fi, iOS occasionally fires the registered BGAppRefreshTask com.busymatehelper.app.refresh-whitelist. The handler:
- Calls
WhitelistRegistrar.registerNow()so the IP doesn't drift out of the whitelist after 24 h. - Reschedules itself.
iOS doesn't promise when (or if) it'll fire this; treat it as a probabilistic extension of the foreground 5 min cycle, not a reliable wakeup.
Combined behaviour
| State | Mechanism that keeps "Online" lit |
|---|---|
| App foregrounded | RealtimeSubscriber (source: "main") |
| VPN tunnel up | TunnelPresence (source: "tunnel") — survives app backgrounding because the extension stays alive |
| PAC mode, app backgrounded, traffic flowing | PerDevicePresence (source: "pac") |
| PAC mode, app backgrounded, no traffic for >90 s | Offline. Next request that hits the port-pool listener re-pubishes presence. |
| All paths down for >90 s | Offline (the publishers' Realtime leave event is consumed by the dashboard) |
Files
| File | Role |
|---|---|
web/proxy-server/src/perDevicePresence.ts | The actual mechanism. One client per active device UUID. |
web/proxy-server/src/portPool.ts | Calls perDevicePresence.touch(uuid) on every TCP accept. |
web/proxy-server/src/proxy.ts | :8888 whitelist gate, :8443 SNI bypass, and the /whitelist/register + /allocate endpoints. |
web/proxy-server/src/whitelist.ts | In-memory IP whitelist (24 h TTL, sweep cleaner). |
ios/BusymateHelper/Services/WhitelistRegistrar.swift | 5 min IP re-register loop. |
ios/BusymateHelper/Services/PortAllocator.swift | 30 min port re-allocation loop. |
ios/Shared/BackgroundRefreshScheduler.swift | BGAppRefreshTask registration + scheduling. |
ios/BusymateHelper/ViewModels/NetworkMonitorViewModel.swift | willEnterForeground orchestrates all four. |
ios/BusymateHelper.xcodeproj/project.pbxproj | UIBackgroundModes = ["fetch", "processing", "remote-notification"], BGTaskSchedulerPermittedIdentifiers = [refresh-whitelist, settings-session]. |
Diagnostics
# What's in the per-device port map (proxy-server's view of who claimed which port)
curl -u busymate-cert:<password> https://proxy.busymate.net/portmap | jq
# What's in the IP whitelist
curl -u busymate-cert:<password> https://proxy.busymate.net/whitelist | jq
# Realtime presence on devices:all (from the dashboard's perspective)
# — observable in DashboardClient.tsx's deviceStatuses Mapsource pill values you might see in the dashboard:
main— main app foregroundedtunnel— VPN tunnel up (extension publishing)pac— proxy-server publishing on behalf of a PAC-mode deviceproxy-server— the proxy-server itself (its own row, not an iOS device)
Multiple sources can be lit at once if e.g. the user has VPN up AND the main app is foregrounded; presence sync deduplicates by device_uuid.
Related
- Per-device attribution — port pool → SNI → IP whitelist → pinned name. The publishing mechanism here is what surfaces those attributed devices as alive.
- Proxy server — file map, boot sequence, control plane.
- BusymateHelper iOS — main-app + tunnel-extension Realtime subscribers.
SECURITY.md— proxy auth model, cert bundle rotation.