2026-05-06

End-of-day snapshot for build 5 of BusymateHelper.app, the version we're tagging as TestFlight RC1. Today's work was a full rebuild of the iOS main view around the two equal connection paths (VPN tunnel + Wi-Fi PAC), plus polish across the readiness flow and Settings sheet.

What changed today

Main view (SetupGuideView)

  • Animated app icon at the top, replacing the old centered VPN power button. 140-pt circle with a 152-pt arc reservation; arc is the canonical "what's the connection up to" indicator — three modes (hidden / dim(Color) / steady(Color) / spinning(Color)) driven by VPN + PAC state:
    • Steady green ring with a sine-driven breathing pulse (opacity 0.45 → 1.0, scale 1.0 → 1.025, 1.8 s period) when VPN is connected OR PAC is active for our slug.
    • Spinning partial arc (TimelineView, 0.9 s rotation) for transitions — yellow during the pre-connect remote-settings sync, blue while connecting / reasserting, orange while disconnecting.
    • Static dim gray ring (Color.secondary @ 25 %, 5-pt stroke) at idle. Same dim shade is used as the track behind the spinning arc, so the ring is visually continuous across state transitions (no pop-in).
    • Connection-type badge capsule overlaid on the bottom of the icon, vertically centered on the arc line via offset(y: badgeHeight / 2). Reads "PAC" or "VPN" in white-on-color, color matches the arc.
  • Editable device name below the icon — .title2 semibold, centered, framed at 320 pt max width with an inline Apply button (small borderedProminent) that fades in only when the draft differs from the saved value. .onSubmit (Done key) auto-saves. Single source of truth: viewModel.saveDeviceName(_:).
  • Segmented Picker [ PAC | VPN ] for mode chooser, no card chrome. Auto-selects the active mode on first appear; user-controlled afterwards (renames don't yank the tab).
  • Mode bodies in iOS-Settings-style pill rows on secondarySystemGroupedBackground:
    • VPN body: single grouped container with a Status row (label + status text + iOS Toggle). Toggle is disabled while transitioning OR while PAC is active. Caption beneath adapts to "Toggle on to capture …" / "VPN is disabled while Wi-Fi PAC is set up …".
    • PAC body: single grouped container with two rows (Status / URL) sharing one rounded background, separated by an inset 16-pt divider. URL row has the monospaced PAC URL + a small doc.on.doc accessory that flips to checkmark.circle.fill on copy. Caption adapts: "PAC detection is unavailable while VPN is on …", or the Settings link copy when nothing's set, or the share-this-identity copy when active.
    • Setup-incomplete placeholder: when !isReady (cert not generated / installed / Full-Trust), both tabs collapse to a single waveform-iconed message ("Setup incomplete — install and trust the BusymateHelper certificate using the readiness card below…"). Icon color is .primary to match the headline.
  • Readiness card at the bottom has two side-by-side tap targets sharing one rounded background:
    • Leading region (shield icon + headline + "Tap here to setup" / "Certificate installed and trusted") opens the Setup Steps sheet.
    • Trailing 22-pt gear icon opens the Settings sheet.
  • Empty top toolbar: Network Log / Tunnel Log / Settings have all moved off the toolbar. Network Log + Tunnel Log are now NavigationLinks inside Settings → Tools (pushed onto the Settings nav stack); Settings itself lives in the readiness card's trailing gear.

Mutual exclusion (VPN ↔ PAC)

  • reconcileVPNvsPAC() in the view model: when SystemProxyReader.wifiPACStatus(...) returns .set(_, matchesOurs: true) AND VPN is connected, stop the tunnel. Wired into willEnterForegroundNotification, .NEVPNStatusDidChange, and .onChange(of: pacStatus) so it fires whether the user backgrounds-and-returns to the app or sets the PAC URL while still in-app.
  • The .onChange(of: selectedMode) auto-disconnect that previously fired on tab-tap was removed — flipping segments is pure UI and never kills the tunnel. Symmetric "Disabled — PAC active" / "Unavailable — VPN active" wording surfaces the constraint without surprise behavior.
  • Every iOS-Settings path the user reads (PAC active caption, PAC notSet link, Setup Steps Install/Trust steps) starts with the word Settings rendered as a tappable accent-color link. The link routes through UIApplication.shared.open with the App-prefs: private-but-stable scheme — same path the existing settingsLinkedText helper in SetupStepsView uses to land at the iOS Settings root.
  • Readiness card's plain-text descriptions were normalized to drop the "iOS " prefix and start with Settings → … (no link, since the card is itself one big tap target).
  • Setup Steps sheet's intro paragraph row got tighter listRowInsets (4-pt top + 4-pt bottom) so the first step doesn't sit two screens below.

AppIconImage asset

A dedicated Assets.xcassets/AppIconImage.imageset/ carries the full 1024×1024 source PNG so Image("AppIconImage") renders the icon at 140 pt without upscaling the home-screen 60-pt variant. Avoids the "blurry icon" trap with UIImage(named: "AppIcon60x60").

Build number

CURRENT_PROJECT_VERSION bumped 4 → 5 across both targets and both Debug/Release configurations. MARKETING_VERSION unchanged.

Verified state

  • BusymateHelper.app installed + launched on Serebano's iPhone 17 (7EE28F79-…) and BMH1 (iPhone SE, 523BC3D6-…). Both render the new layout correctly.
  • Mutual exclusion: VPN connected → set Wi-Fi PAC URL in iOS Settings → return to app → tunnel auto-stops. Verified.
  • PAC body's pill rows + arc+badge + "Active" green pulse: verified visually with PAC URL pasted into iOS Wi-Fi.
  • Setup-incomplete placeholder: verified by deleting + reinstalling the app to clear the cert state.
  • Status-colored arc: verified across yellow (sync) → blue (connecting) → green (steady) and disconnect orange transitions.

TestFlight RC1 — what's in this build

This is the first iOS build packaged as a release candidate for TestFlight distribution:

  • App icon + name + bundle ID + entitlements unchanged from build 4.
  • Build 5 is purely visual / UX evolution + the mutual-exclusion + settings-link work above.
  • Server-side stack (proxy-server, ws-server, dashboard) unchanged from earlier this week — already deployed.

Known minor caveat from earlier in the week (still applies):

  • iOS Wi-Fi PAC parser silently drops the HTTPS directive — all PAC traffic from iOS clients lands on plain :8888, gated by the 24-h IP whitelist. Mac / Chrome / Firefox via the same PAC URL honor HTTPS:8443 SNI path. Documented in PAC presence.

Per-device port pool — CGNAT-correct iOS attribution

Late-day continuation: closes the CGNAT collision the SNI path can't fix on iOS (since iOS drops HTTPS PAC). Two iPhones on one carrier-NAT IP using PAC URLs were both attributed to whoever last hit /whitelist/register — last-write-wins, dashboard collapsed them onto one row. Tested first-hand with BMH1 + Serebano on the same Wi-Fi; the bug manifested as BMH1's traffic showing under the Serebano row.

What's new

  • Per-device port pool on the proxy-server. Default range 9000–9099 (capacity 100 devices — plenty for personal use, configurable in ~/.busymate-proxy/config.json via perDevicePortRange). Each device gets its own port; the port itself is the device tag, so attribution survives any number of devices behind a shared NAT.
  • POST /allocate endpoint (cert-bundle auth). Body { uuid, deviceName? }{ port, baseDomain }. Idempotent — same UUID gets the same port across restarts. Allocations are JSON-backed at ~/.busymate-proxy/port-map.json so iOS clients holding http://<port>.busymate.net PAC URLs don't go stale across proxy restarts.
  • GET /portmap (cert-bundle auth) — debug listing of UUID → port assignments.
  • PAC handler routes by subdomain shape: numeric subdomain (e.g. 9007.busymate.net) returns PROXY 9007.busymate.net:9007; DIRECT after validating the port is actually allocated. Alpha subdomain falls through to the legacy HTTPS+PROXY body for non-iOS clients (Mac, Chrome, Firefox — they honor HTTPS PAC).
  • Attribution priority in index.ts emit() — port-derived deviceName now wins over SNI-derived, which still wins over IP-whitelist (port 1, SNI 2, whitelist 3, pinned name 4).
  • Both port-pool sockets and SNI sockets bypass the IP whitelist gate — port ownership / TLS handshake are themselves the gate.

iOS side

  • streaming.deviceID (UUID, generated once on first launch / first launch after upgrade) and streaming.allocatedPort (Int) added to AppSettings.StreamingConfig. Codable defaults so existing app_settings.json files decode cleanly.
  • New PortAllocator service — modeled on WhitelistRegistrar. POSTs to /allocate on app foreground + every 30 min; persists the returned port to AppSettings.
  • SetupGuideView's PAC URL flips from http://<deviceName-slug>.busymate.net to http://<allocatedPort>.busymate.net. Status row reads "Waiting for port…" until /allocate lands; copy button is disabled in that state.
  • PAC URL is now stable across renames. Previously, renaming the device changed the slug, which changed the PAC URL — operators had to repaste on every other client. Now the port is keyed off deviceID (UUID) which doesn't change on rename. The deviceName is just a display label.

Architecture details

Per-device attribution gained a new top-level section describing the boot-time pieces (portMap.ts, portPool.ts, index.ts wiring), the allocation flow, the new PAC body shape, and the updated attribution priority.

Operator one-time setup (VPS)

Range needs to be opened in the firewall once, then the proxy-server unit gets restarted with the new code:

sudo ufw allow 9000:9099/tcp
cd /opt/busymate-proxy && git pull && npm --prefix web/proxy-server install && npm --prefix web/proxy-server run build
sudo systemctl restart busymate-proxy

No nginx changes needed — the wildcard vhost at *.busymate.net:80 already forwards /proxy.pac (and /, /wpad.dat) to proxy-server /pac, which handles the new numeric-subdomain branch on its own.

Commits

3281458 iOS main view: device-name header + segmented PAC/VPN modes + mutual exclusion
fdc5f83 generateDeviceName: emit human-readable "Color Bird" label

(Plus today's pending commit covering the iOS-Settings-style rows, animated icon + arc + badge, layout polish, build-5 bump — see compare URL in this commit's push message.)