2026-05-01

End-of-day snapshot. The app's chrome was rewritten from "four tabs" to "one home screen with two toolbar sheets." Verified end-to-end on the iPad (CoreDevice B193DBAA-3334-5381-B514-5E18BFC11682) and also on the iPhone 17 (7EE28F79-506A-546A-BBC5-93B146E47E08, libimobiledevice UDID 00008150-0008584C1404401C) — first time this session that a build was deployed and verified on the iPhone 17 alongside the iPad.

Status: working as expected

  • Single home: Setup view. App launches directly into the Setup view with no TabView and no bottom tab bar. Two toolbar buttons in the top-right corner (antenna.radiowaves.left.and.right for Network, gearshape.fill for Settings) present those views as iOS-native modal sheets, matching the Music/Maps/Files convention.
  • Top centered VPN power toggle. Large power.circle.fill glyph at the top of Setup, color-coded by state (green = connected, gray = disconnected), with a binary "Connected" / "Disconnected" caption underneath. Disabled while transitioning. The Network toolbar icon animates with .variableColor.iterative.dimInactiveLayers whenever capture is active — replaces the badge that used to live on the Network tab.
  • 3-step onboarding (Download → Install → Trust). Renamed and consolidated from the previous 4 steps; the old Step 4 (Start VPN) became the top toggle. Each step renders as a DisclosureGroup and auto-expands the next undone one, collapsing the rest. Manual expand/collapse is preserved across step-completion events.
  • Live trust-state polling. Step 2 ("Install") flips green when SecTrustEvaluateWithError with BasicX509 policy passes (profile in iOS keychain); step 3 ("Trust") flips green only when the SSL policy passes (Enable Full Trust toggled). Refresh runs on view appear, on UIApplication.willEnterForegroundNotification, and via a 1.5 s timer to cover iPadOS Stage Manager / split view where opening Settings doesn't actually background the app.
  • Yellow-tinted incomplete badges, no "Done" caption. Step number circles render in yellow (yellow on light yellow) for incomplete steps, green with white check for complete. Right-side "Done" text was redundant alongside the badge — removed.
  • Settings is a sheet, not a tab. Hosted by a gearshape.fill toolbar button. Settings sheet contains:
    • External Proxy subview (existing).
    • SSL Proxying subview (manage MITM domain list — separate dedicated view, no longer inline).
    • Statistics subview (formerly Dashboard tab — StatisticsView in DashboardView.swift → renamed to StatisticsView.swift; the redundant VPN start/stop card was dropped from this view since the toggle is now in Setup).
    • DNS Servers subview — new. Editable list of resolvers, persisted to AppSettings.dnsServers (App Group container), default-prefilled with 8.8.8.8 and 1.1.1.1. Add/edit/delete + drag-to-reorder. The packet-tunnel extension reads the list at applySettings time.
    • Capture Mode toggle (existing).
    • About info rows.
    • Data → Clear All Captured Data (existing).
  • Certificate section removed from Settings. The cert install/trust flow lives entirely in Setup now (Download / Install / Trust), so the Settings Certificate section and the TrustInstructionsView modal were redundant and got deleted.
  • Settings header reads Info.plist. App name, version, and build come from CFBundleDisplayName / CFBundleShortVersionString / CFBundleVersion instead of the hardcoded "BusymateHelper Proxy" / "Version 1.0.0 (Build 1)" that drifted out of sync.
  • Cert renamed. "BusymateHelper CA" → "BusymateHelper" in the cert CN, the .mobileconfig PayloadDisplayName, and the runtime leaf-cert generator's hardcoded issuer DN. Both ends had to change in lock-step — leaf certs minted at runtime must carry an issuer DN that bytewise matches the trusted root's subject DN, otherwise iOS rejects the chain.
  • Stable mobileconfig PayloadIdentifier. com.busymatehelper.profile / com.busymatehelper.cert (no random UUID). Re-installing the cert now triggers the iOS "Replace existing profile?" prompt instead of stacking a new entry every time.
  • iPhone 17 deployment verified. First-time-this-session that xcodebuild + devicectl install + process launch were exercised against the iPhone 17 — build, install, and launch succeeded with the same Apple Development signing identity that signs for the iPad. The iPhone 17 is paired and has the developer disk image loaded; CoreDevice ID 7EE28F79-506A-546A-BBC5-93B146E47E08, libimobiledevice UDID 00008150-0008584C1404401C. Trust state to verify on next session: whether the dev cert needs re-trusting between rebuilds (BMH1 does; iPad doesn't; iPhone 17 was clean today).

Known follow-ups / things to remember

  • Network sheet's nested-sheet pattern. Network is now presented as a sheet from the Setup toolbar. Its earlier internal "open Settings sheet from Network's own toolbar" was removed to avoid sheet-on-sheet. If we later want to reach Settings while viewing Network, the user has to dismiss Network first.
  • No certificate delete affordance. Was previously in Settings → Certificate. With that section removed, there's no in-app way to nuke ca.der. Day-to-day this isn't blocking (re-install replaces the profile thanks to the stable identifier), but if the local key ever needs to be regenerated for a clean reset, the only path is xcrun devicectl device copy from --domain-type appGroupDataContainer … on the host. Worth surfacing as a "Reset" affordance somewhere if the need comes up.
  • DNS servers don't hot-reload. Like every other tunnel-extension setting, the new dnsServers list is read at applySettings time. The user has to restart the VPN for changes to take effect; the DNS Servers view footer says so.
  • Yellow-on-yellow contrast in light mode. The incomplete-step badge uses solid Color.yellow text on Color.yellow.opacity(0.2) fill. Readable but not great. If accessibility flags it, switch to .orange for the text or darken the foreground.
  • iOS Settings deep-link still goes to root. The App-prefs: URL scheme's path component (e.g. root=General&path=ManagedConfigurationList) stopped being honored on iPadOS 17+ — apps land on Settings → Apps. We use the bare App-prefs: scheme so the user lands on the Settings root where the "Profile Downloaded" banner is visible.

Configuration verified this session

  • Test devices:
    • Serebano's iPad (iPad Pro 11" M4) — primary test device, dev cert auto-trusted.
    • Serebano's iPhone (iPhone 17) — verified deployable today; libimobiledevice UDID 00008150-0008584C1404401C.
  • App Group: group.com.busymatehelper (unchanged).
  • iPad App Group container UUID: C0C929C0-F716-43EA-BE47-2141417D1EE3.
  • External Proxy used during MITM testing: 107.166.124.52:29842 with auth.

Late-day fix: iPhone 17 MITM ("Not Trusted")

After the initial push, MITM still failed on the iPhone 17 even with all three Setup steps green. Root cause turned out to be stale ca.der in the App Group container from a previous code path with a different CN — generateIfNeeded() is a no-op when the file exists, so the iPhone kept reusing the outdated CA. The runtime leaf-cert generator hardcoded the new "BusymateHelper" issuer DN, but the trusted root on device still carried the old CN, so chain validation failed silently. Uninstalling the app on iPhone (which nukes the App Group container) was the immediate workaround the user found.

The proper fix bundled two changes:

  • Re-download regenerates. CertificateManager.installCertificate(forceRegenerate:) now accepts a flag; the Setup view's step 1 button passes true whenever the profile is already installed (the "Re-download" label state). On tap: delete on-disk ca.der + ca.key, then run the normal generate-and-install flow. First-time Download is unchanged. Without this, future CN/extension changes would silently break MITM on devices that already have a previous build's cert installed, with no in-app recovery affordance.
  • Decoupled deleteCertificate() from SSL Proxying domains. Previously deleteCertificate() cleared the user's SSL Proxying domain list as a side effect — fine for the old explicit "Delete Certificate" button, wrong for the Re-download flow. The two are orthogonal state and now stay separate.
  • EKU id-kp-serverAuth on leaf certs. Added defensively to Packet​Tunnel​Extension/CertificateGenerator.swift's buildHostCertificate(for:). RFC 5280 §4.2.1.12 + Apple's modern cert requirements expect TLS server certs to carry this extension; some iOS surfaces tolerate its absence, others don't. Not the literal cause of this round's bug, but worth landing — protects against future iOS strictness.

Verified: iPhone 17 round-trips MITM through https://busymate.io after Re-download → Install → Trust.

Late-late-day: web streaming dashboard (live network logs in a browser)

End-to-end pipe from iOS BusymateHelper → Deno ws-server → Next.js dashboard. Three new subprojects shipped together; verified live on the iPhone 17 with Setup → Web Streaming pointed at a Mac running both servers locally on 192.168.100.76:8000 (ws-server) + :3838 (dashboard).

Architecture lives in docs/architecture/web-streaming.md. High points:

  • iOS — LogStreamer. New service in the main app. Hooks into the existing NetworkMonitorViewModel.loadNewEntries() 250 ms file-poll path, forwards every captured TrafficLogEntry over a URLSessionWebSocketTask. Exponential reconnect (1 s → 30 s), 500-entry buffer for offline drops, @Observable state surfaces (disabled / idle / connecting / connected / failed) for the Settings UI to render. URL composition tolerates http(s):// or ws(s):// input and normalizes to ws(s)://<host>/ingest?device=<name>. New Settings → Web Streaming subview owns the toggle + server URL + device name, persisted as AppSettings.streaming: StreamingConfig.
  • web/ws-server/ — Deno service. Single main.ts. Two WebSocket endpoints (/ingest?device=<name> for devices, /dashboard for browsers). Each incoming entry is persisted to Deno KV (["entries", deviceName, timestampMs, idempotencyUUID]) and broadcast to active dashboard subscribers. Bodies > 64 KiB get truncated with a marker before KV write to fit within Deno's per-value cap; the live broadcast is not truncated. HTTP fallback (/api/devices, /api/devices/:name/entries) for history scrollback. CORS-open. No auth — personal-network debug tool by design. Designed to deploy to Deno Deploy with KV provisioned per-project; --unstable-kv flag baked into the start task.
  • web/dashboard/ — Next.js + Turbopack. Three-column layout: device sidebar / live entry list (capped at 1000 in memory) / detail pane (headers + bodies). Connects to ws-server via NEXT_PUBLIC_WS_URL. Reconnect-on-drop with exponential backoff. Source is app/DashboardClient.tsx (client component owning the WebSocket); app/page.tsx is a thin SSR wrapper that just reads the env var.
  • Wire shape. JSON envelopes — iOS sends {type:"entry",entry:TrafficLogEntry}, ws-server broadcasts {type:"entry",device:"<name>",entry:TrafficLogEntry}. The TS interface mirrors the Swift Codable struct in ios/Shared/TrafficLogEntry.swift (source of truth) — kept in sync by hand in web/ws-server/main.ts and web/dashboard/types.ts.

Verified end-to-end: with the iPhone 17 streaming and the dashboard open, every Safari request to a domain in the SSL Proxying list shows up in the dashboard within a second, click-to-expand reveals headers + decoded bodies, and the ws-server's /api/devices/:name/entries returns a JSON history of what was sent.

Where to start next session

  1. If anything in the new chrome feels off (sheet vs nav, button density on iPhone 17 vs iPad), iterate from SetupGuideView.swift and RequestListView.swift. Both are the surfaces that took the most change.
  2. If MITM regresses, the four bugs that the 2026-04-30 changelog fixed (NWParameters composition, framer start()-send, FIFO config queue, RST-on-fail) plus the cert CN match between CertificateManager and CertificateGenerator are the things to re-check first.
  3. Untouched but worth cleanup: the vpnBar inside RequestListView still uses the granular vpnManager.statusText — fine in that view, but worth noting if we later want all surfaces binary like Setup.
  4. The QUIC drop counter in PacketTunnelProvider is still in place from the 2026-04-30 round; UDP/443 is still silently dropped. If a real app surfaces a hang, that's the first thing to check via BMHDIAG: syslog filter.