PAC presence

How a backgrounded iOS device in PAC mode stays Online on the dashboard.

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:

PublisherWhereWhen
Main app RealtimeSubscriberios/BusymateHelper/Services/RealtimeSubscriber.swiftiOS app foregrounded. Tracks on devices:all with source: "main". Skipped while VPN is active.
Tunnel extension TunnelPresenceios/Packet​Tunnel​Extension/TunnelPresence.swiftVPN 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 PerDevicePresenceweb/proxy-server/src/perDevicePresence.tsPAC 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:

  1. Trigger — every successful TCP accept on a port-pool listener (web/proxy-server/src/portPool.ts) calls perDevicePresence.touch(uuid). The first touch for an unknown UUID spins up a fresh client with presence.key = uuid and source: "pac", subscribes to devices:all, and presence-tracks.
  2. Renewal — subsequent touches reset an internal IDLE_TIMEOUT_MS = 90_000 timer. As long as some PAC traffic flows once every 90 s, the presence stays up.
  3. 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 to proxy.busymate.net/whitelist/register (cert-bundle-auth-gated).
  • startPeriodicRefresh(every: 5 minutes) keeps the whitelist row alive — proxy-server's IpWhitelist TTL 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() POSTs proxy.busymate.net/allocate with {uuid, deviceName, country: Locale.current.region}. Server returns {port}, which gets written to AppSettings.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:

  1. Calls WhitelistRegistrar.registerNow() so the IP doesn't drift out of the whitelist after 24 h.
  2. 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

StateMechanism that keeps "Online" lit
App foregroundedRealtimeSubscriber (source: "main")
VPN tunnel upTunnelPresence (source: "tunnel") — survives app backgrounding because the extension stays alive
PAC mode, app backgrounded, traffic flowingPerDevicePresence (source: "pac")
PAC mode, app backgrounded, no traffic for >90 sOffline. Next request that hits the port-pool listener re-pubishes presence.
All paths down for >90 sOffline (the publishers' Realtime leave event is consumed by the dashboard)

Files

FileRole
web/proxy-server/src/perDevicePresence.tsThe actual mechanism. One client per active device UUID.
web/proxy-server/src/portPool.tsCalls 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.tsIn-memory IP whitelist (24 h TTL, sweep cleaner).
ios/BusymateHelper/Services/WhitelistRegistrar.swift5 min IP re-register loop.
ios/BusymateHelper/Services/PortAllocator.swift30 min port re-allocation loop.
ios/Shared/BackgroundRefreshScheduler.swiftBGAppRefreshTask registration + scheduling.
ios/BusymateHelper/ViewModels/NetworkMonitorViewModel.swiftwillEnterForeground orchestrates all four.
ios/BusymateHelper.xcodeproj/project.pbxprojUIBackgroundModes = ["fetch", "processing", "remote-notification"], BGTaskSchedulerPermittedIdentifiers = [refresh-whitelist, settings-session].

Diagnostics

bash
# 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 Map

source pill values you might see in the dashboard:

  • main — main app foregrounded
  • tunnel — VPN tunnel up (extension publishing)
  • pac — proxy-server publishing on behalf of a PAC-mode device
  • proxy-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.

  • 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.