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 —
.title2semibold, centered, framed at 320 pt max width with an inlineApplybutton (smallborderedProminent) 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
Statusrow (label + status text + iOSToggle). 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 smalldoc.on.docaccessory that flips tocheckmark.circle.fillon 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 singlewaveform-iconed message ("Setup incomplete — install and trust the BusymateHelper certificate using the readiness card below…"). Icon color is.primaryto match the headline.
- VPN body: single grouped container with a
- 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.
- Leading region (shield icon + headline +
- 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: whenSystemProxyReader.wifiPACStatus(...)returns.set(_, matchesOurs: true)AND VPN is connected, stop the tunnel. Wired intowillEnterForegroundNotification,.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.
Settings (paths + iOS-style links)
- 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.openwith theApp-prefs:private-but-stable scheme — same path the existingsettingsLinkedTexthelper inSetupStepsViewuses 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.appinstalled + 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
HTTPSdirective — 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 honorHTTPS→:8443SNI 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.jsonviaperDevicePortRange). 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 /allocateendpoint (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.jsonso iOS clients holdinghttp://<port>.busymate.netPAC 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) returnsPROXY 9007.busymate.net:9007; DIRECTafter validating the port is actually allocated. Alpha subdomain falls through to the legacyHTTPS+PROXYbody for non-iOS clients (Mac, Chrome, Firefox — they honorHTTPSPAC). - Attribution priority in
index.tsemit()— 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) andstreaming.allocatedPort(Int) added toAppSettings.StreamingConfig. Codable defaults so existingapp_settings.jsonfiles decode cleanly.- New
PortAllocatorservice — modeled onWhitelistRegistrar. POSTs to/allocateon app foreground + every 30 min; persists the returned port to AppSettings. SetupGuideView's PAC URL flips fromhttp://<deviceName-slug>.busymate.nettohttp://<allocatedPort>.busymate.net. Status row reads "Waiting for port…" until/allocatelands; 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-proxyNo 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.)