BusymateHelper internals

How the iOS app captures DNS + TCP + HTTPS via NEPacketTunnelProvider and streams to Supabase.

A Proxyman-like network debugger built on NEPacketTunnelProvider. Captures system-wide DNS + TCP and decrypts HTTPS for the hosts in the user's SSL-Proxying list. Every captured request streams directly into Supabase Postgres — no local files, no batching queues.

The Xcode project lives at ios/BusymateHelper.xcodeproj and contains three targets:

TargetBundle IDFolderPurpose
BusymateHelpercom.busymatehelper.appios/BusymateHelper/Main SwiftUI app. UI, settings, pairing, Realtime client.
PacketTunnelExtensioncom.busymatehelper.app.tunnelios/Packet​Tunnel​Extension/NEPacketTunnelProvider host. Parses IP packets, runs MITM, writes to Supabase. (Folder name uses U+200B zero-width spaces — historical artifact.)
BusymateActivitycom.busymatehelper.app.BusymateActivityios/BusymateActivity/WidgetKit Live Activity (Lock Screen + Dynamic Island).

App Group group.com.busymatehelper is shared across all three targets; shared sources live in ios/Shared/.

Subsystem map

Tunnel

FileWhat it owns
Packet​Tunnel​Extension/PacketTunnelProvider.swiftNEPacketTunnelProvider lifecycle. Builds 198.18.0.2/24 tunnel address, default route, MTU 1500, configurable DNS (default 8.8.8.8 + 1.1.1.1 via AppSettings.dnsServers). readPackets loop dispatches IPv4 packets to DNSForwarder (UDP/53) and TCPForwarder (TCP). UDP/443 (QUIC) is dropped + counted.
Packet​Tunnel​Extension/DNSForwarder.swiftUDP/53 packet → NWConnection → packet bridge.
Packet​Tunnel​Extension/TCPForwarder.swiftHand-rolled TCP state machine. Tracks flows: [FlowKey: TCPFlow]. On SYN, decides among four paths: direct upstream, external HTTP-CONNECT proxy, deferred-for-SNI MITM, deferred-for-proxy-hostname. SNI extraction in extractSNI(from:) with sniBufferLimit = 8 KB. 30 s sweep timer, 90 s idle eviction.
Packet​Tunnel​Extension/MITMProxy.swiftPer-flow MITM session. Spins a local NWListener with the per-host leaf identity, has the client TLS-handshake against it, dials the real server out (possibly through HTTPConnectProxy). HTTP/1.1 framing only — ALPN advertises only http/1.1.
Packet​Tunnel​Extension/CertificateGenerator.swiftLoads CA from <appGroup>/{ca.der, ca.key}. Generates a per-tunnel RSA-2048 MITM key, mints leaf certs on demand keyed by hostname into an in-memory cache.
Packet​Tunnel​Extension/HTTPConnectProxy.swift + HTTPConnectFramer.swiftWraps direct upstream dials with a CONNECT host:port HTTP/1.1 preamble when AppSettings.externalProxy.isUsable.
BusymateHelper/Services/CertificateManager.swiftMain-app side: generates the CA on first launch, writes it to the app group, computes trust state for the "CA installed and trusted?" readiness check.
Shared/SSLProxyMatcher.swiftWildcard matcher for the SSL-Proxying list.

Capture-to-cloud

Packet​Tunnel​Extension/LogStreamer.swift is the canonical ingest path. No batching, no disk, no local FIFO. Each captured TrafficLogEntry becomes one INSERT INTO entries (request_id, device_uuid, ts, kind:"event", payload) via the hand-rolled SupabaseClient. request_id is a fresh UUID per row; the request_id UNIQUE constraint on the partitioned table makes retries safe. Auth is the device JWT read from DeviceJWTKeychain on every call.

LogStreamer._upsertStatus also writes the device's device_status row event-driven (tunnel start, VPN-state change, graceful stop) with (online, last_heartbeat_at, vpn_state, source: "tunnel"). No periodic heartbeat — online/offline is derived from Realtime Presence.

Pairing

The entire UI is gated by BusymateHelper/Views/DevicePairingSheet.swift until a 365-day device JWT lands. BusymateHelper/Services/DevicePairingService.swift runs the handshake: POST ${api}/functions/v1/device-pair with {action: "claim", code, device_uuid, name, model, os_version, setup} → receives {token, jti, expires_at} → writes via Shared/SupabaseClient.swift's DeviceJWTKeychain.write(). The keychain entry is mirrored to <appGroup>/device-jwt.json so the tunnel extension (separate keychain access group) can read it.

The minted JWT carries app_metadata.{user_type: "device", device_uuid: <uuid>} claims. Server-side, the public.device_uuid() SQL helper extracts the claim and RLS policies match it against row ownership.

Realtime

BusymateHelper/Services/RealtimeSubscriber.swift is the main-app Realtime client (supabase-swift RealtimeClientV2, vsn pinned to .v1 for cross-SDK interop with the supabase-js dashboard). Three channels:

  • device:<uuid> (private) — settings_device postgres_changes, device-row rename broadcast, resend-request / breakpoint-continue / settings-updated / unpair / vpn-on / vpn-off / open-sheet broadcasts (the last carries a settings | cert | pac key for RemoteSheetController). breakpoint-continue is a main-app no-op — it's handled in the tunnel extension (see Breakpoints), which keeps its own device:<uuid> subscription so a held MITM request/response resolves even while the main app is backgrounded.
  • settings:global (private) — settings_global postgres_changes + settings-invalidated broadcast.
  • devices:all (private) — presence-track this device's row with source: "main". Skipped while vpnActive so the tunnel-extension's source: "tunnel" track stays canonical.

The tunnel extension publishes its own presence via Packet​Tunnel​Extension/TunnelPresence.swift on the same devices:all channel.

Breakpoints

iOS is a self-capturing device (the MITM runs inside the packet-tunnel extension, no proxy in its path), so it drives the dashboard breakpoint contract itself — exactly like the cdp-connector, and mirroring proxy-server. There are no dashboard changes: iOS reuses the same tables, channels, events, and field names.

  • PatternsbreakpointRequestPatterns / breakpointResponsePatterns come from effective_settings_for_device (the request list also folds in the legacy top-level breakpoint_patterns column, same precedence as proxy-server + cdp). RemoteSettings + applyRemoteSettings persist them into AppSettings (App Group) so the tunnel extension reads them; the live settings push refreshes them.
  • MatcherShared/URLPatternMatcher.swift is the Swift port of web/dashboard/app/lib/urlPatternMatch.ts (host = exact or *.x.com subdomain via SSLProxyMatcher; path = *-anywhere glob; leading scheme stripped; a non-*-terminated pattern tolerates a trailing slash/?query/#frag).
  • Gated relayMITMProxy.swift: when any breakpoint pattern is set, a session switches from transparent byte-streaming to per-message gated forwarding (HTTP1MessageParser retains exact raw bytes; complete messages forward through the gate). A matched request is held BEFORE going upstream; a matched response is held BEFORE delivery to the client. Per-direction serialization preserves keep-alive ordering across a pause. No patterns set → the fast streaming path runs untouched (zero overhead).
  • BreakpointController (extension) — process-wide registry: pause() INSERTs breakpoint_events {kind, request_id, raw_http_initial} (the broadcast_breakpoint trigger fans it to device:<uuid> + breakpoint_events:all so the dashboard's paused list shows it), then awaits the dashboard via a continuation. The dashboard's POST /api/breakpoints/:id/continue publishes breakpoint-continue {requestId, rawHttp?, abort?} on device:<uuid>; the controller resolves the held relay (continue / edited rawHttp / abort) and UPDATEs breakpoint_events {resumed_at, outcome, raw_http_final}. 60 s timeout → forward original; first-resolve-wins per requestId. RLS: breakpoint_events_self_insert / _self_update (device-JWT-scoped) — already present for the cdp path.
  • Tradeoff — in gated mode, connection-close-delimited (HTTP/1.0-style eofBody) responses don't stream incrementally (they complete on close), matching proxy-server's full-buffer-then-decide behavior. Only active when patterns are set (a deliberate debugging mode).

Build updates

BusymateHelper/Services/BuildUpdateChecker.swift converges three paths to a single applyServerBuild(_:):

  1. Polling — fetches https://dash.busymate.net/api/version on launch + every scenePhase == .active.
  2. Realtime broadcast — subscribes to the public channel ios:build for build-available events fired by the notify-ios-build Edge Function. See Shipping pipeline.
  3. APNs alert push — same Edge Function fans alert pushes with command: "build-available". BusymateAppDelegate.userNotificationCenter(_:willPresent:) and (_:didReceive:) dispatch the payload as .buildAvailablePushReceived NotificationCenter events; the Checker observes and applies the build directly.

When BuildUpdateChecker.isOutdated (server build > local), the home screen shows a friendly inline update banner above the readiness card (SetupGuideView.updateBanner) — not a modal sheet. Tapping it opens https://ios.busymate.net, which the VPS 302-redirects to the current TestFlight ?build=N deep-link. The banner is non-blocking and disappears once the local build catches up.

Gated to TestFlight/dev installs only. components.ios.build (what the checker compares against) bumps on every fastlane beta, so it's almost always ahead of the build Apple has approved for public release. Showing the banner to App Store users would nag them about a build they can't get, and its CTA deep-links into TestFlight (useless for them). So isOutdated returns false on App Store production installs (Bundle.main.appStoreReceiptURL named receipt); TestFlight (sandboxReceipt) + dev/simulator (no receipt) still show it. On the App Store, iOS handles updates natively.

Push tokens

BusymateHelper/Services/PushTokenRegistrar.swift runs on every app launch. Requests UNUserNotificationCenter authorization, then hands the APNs device token to a Postgres RPC set_device_push_token(p_device_uuid, p_token, p_env, p_bundle_id). The env (sandbox / production) is derived from #if DEBUG — Release builds (fastlane / TF) always tag as production. Tokens land in device_push_tokens with kind = "device"; the Live Activity controller writes a parallel kind = "liveactivity" row.

BusymateHelper/Services/SilentPushHandler.swift routes content-available silent pushes with command: "vpn-on" | "vpn-off" | "vpn-toggle" to VPNManager.

Live Activity

ios/BusymateActivity/ is a separate target hosting a WidgetKit Live Activity widget. Attributes live in ios/Shared/BusymateActivityAttributes.swift: immutable deviceUuid + deviceName, mutable ContentState{mode: .off|.vpn|.pac|.connecting|.disconnecting, requestsPerSec, note}.

BusymateHelper/Services/LiveActivityController.swift owns lifecycle:

  • bootstrap() on .active scene-phase: ends any reinstall-survivor activities, mints a fresh one with pushType: .token.
  • attachTokenObserver drains activity.pushTokenUpdates into a device_push_tokens upsert with kind = "liveactivity".
  • resyncFromSystem reads NEVPNStatus + SystemProxyReader on FG entry so the LA pill matches reality if the user toggled VPN while backgrounded.
  • refreshDeviceName ends + remints the activity (its attributes are immutable) when settings push reports a rename.

Connection modes

VPN tunnel vs PAC mode (Wi-Fi automatic-proxy URL). See the dedicated iOS connection modes doc — covers the swap logic, the port-derived pacExpectedHost, and the readiness card UI in BusymateHelper/Views/SetupGuideView.swift.

Connection-type preference (child sheet)

The home screen carries a Connection row that opens a child sheet (BusymateHelper/Views/ConnectionSheet.swift) hosting a VPN / PAC selector (default VPN). Selecting a mode shows only that mode's controls — the VPN power toggle, or the PAC URL + setup walkthrough — and is a durable preference, persisted, not a capture action: picking a mode does not itself start or stop capture (the VPN toggle / Wi-Fi PAC config still do that).

The preference resolves through a two-tier model, identical to the rest of the settings stack:

  • settings_global.connection_type — the global default (set from the dashboard's global Settings).
  • settings_device.connection_type — a nullable per-device override; null means inherit the global default.

The effective_settings_for_device(target_uuid) RPC resolves them in order device-override → user-default (profiles.connection_type) → global-default → 'vpn', so the value arrives pre-merged in RemoteSettingsClient's effective-settings blob alongside ssl_proxy_domains. All three levels push live over Realtime — per-device + global via their own channels, the per-user default via the device's profile:<owner> channel (the UPDATE broadcast triggers a settings refetch) — so a dashboard edit at any level reflects on the device without a reload.

The dashboard can also remotely open this sheet on the device: an open-sheet broadcast on the device:<uuid> channel carries a sheet key, and RemoteSheetController (BusymateHelper/Services/RemoteSheetController.swift) presents the matching sheet — settings · cert · pac · account · busybro.

Deep linking (#92)

Any source (dashboard, Telegram, a BusyBro answer, an APNs/Live-Activity tap) opens the app at an EXACT view/sheet, keyed by ID. Two transports normalize to ONE route table: the custom scheme busymate://<route> (registered via CFBundleURLTypes in Info.plist; the in-process / Live-Activity / APNs form) and Universal Links https://<host>/app/<route> (the hand-out form — a real hyperlink that falls back to the website; requires the com.apple.developer.associated-domains entitlement applinks:busymate.net + applinks:dash.busymate.net, validated against the AASA served at both hosts; the dev/TF build uses the ?mode=developer suffix to bypass Apple's AASA CDN cache). The pure Shared/DeepLinkRoute.swift parses both into a DeepLinkRoute enum (home/setup/setupCert/settings/pac/account/device(uuid)/network/request(entryId)/busybro(threadId?)); Services/DeepLinkRouter.swift (@MainActor) gates the route through consent→pair→session (a deferred route replays once the gate opens) then dispatches via RemoteSheetController (sheet routes) + a path-driven Settings NavigationStack (network/request). Wired via .onOpenURL + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) on ContentView. Security: a link only NAVIGATES — there is no destructive/action route, the auth/pair gate still applies, and the underlying reads stay RLS-scoped (a foreign device/entry id shows the OWN empty state, never another tenant's data); an unknown/malformed/hostile URL → home (never a crash, never a non-home action).

Geo-IP

BusymateHelper/Services/GeoIPReporter.swift POSTs ${api}/functions/v1/device-geoip with the device JWT bearer + {country: Locale.current.region} (ISO 3166-1 alpha-2). The Edge Function trusts the device-reported country and UPSERTs device_status.country. Falls back to a server-side IP lookup for legacy callers that don't send the field. Throttled to once per hour; fired on cold launch and on .devicePaired notification.

Settings sync

BusymateHelper/Services/RemoteSettingsClient.swift calls the effective_settings_for_device(target_uuid) RPC — server-side merge of settings_global + settings_device plus pre-expanded ssl_proxy_domains from the applied service groups. Result is written into Shared/AppSettings.swift's persisted blob (server-wins on present keys, untouched keys preserved); SSL-Proxying domain set diff routes through TrafficLogReader.add/removeSSLProxyDomain to update the app-group JSON the tunnel extension reads.

Shared/SettingsDarwinBridge.swift posts a Darwin notification com.busymatehelper.settingsDidChange so the tunnel extension hot-reloads AppSettings without a tunnel restart.

Foreground / background tasks

Shared/BackgroundRefreshScheduler.swift registers BGAppRefreshTask com.busymatehelper.app.refresh-whitelist for periodic whitelist re-registration while the app is backgrounded — see PAC presence. Shared/SettingsSessionTask.swift registers iOS 26-only BGContinuedProcessingTask com.busymatehelper.app.settings-session to keep the Settings sheet alive during long-running operations.

Both are scheduled in BusymateHelperApp.body's .onChange(of: scenePhase).

Networking helper

Shared/SupabaseClient.swift is the hand-rolled Supabase HTTP client (no supabase-swift for REST — only its Realtime module is used). One singleton per SupabaseConfig; reads the device JWT from keychain on every request. PostgREST verbs are from(table).insert(rows), from(table).select(...), rpc(name, params). Errors carry the Supabase code so the Pairing service can react to a revoked JWT (401 → re-pair).

Bundle + entitlements

ConcernFile
BusymateHelper/BusymateHelper.entitlementsApp Groups, Keychain Sharing, NetworkExtensions entitlement.
BusymateHelper/Info.plistUIBackgroundModes = ["fetch", "processing", "remote-notification"], BGTaskSchedulerPermittedIdentifiers = ["com.busymatehelper.app.refresh-whitelist", "com.busymatehelper.app.settings-session"].
BusymateActivity/Info.plistNSSupportsLiveActivities, NSSupportsLiveActivitiesFrequentUpdates.
Packet​Tunnel​Extension/Info.plistNEProviderClasses, NEMachServiceName.

Where to look next