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
TabViewand no bottom tab bar. Two toolbar buttons in the top-right corner (antenna.radiowaves.left.and.rightfor Network,gearshape.fillfor Settings) present those views as iOS-native modal sheets, matching the Music/Maps/Files convention. - Top centered VPN power toggle. Large
power.circle.fillglyph 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.dimInactiveLayerswhenever 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
DisclosureGroupand 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
SecTrustEvaluateWithErrorwithBasicX509policy 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, onUIApplication.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.filltoolbar 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 —
StatisticsViewinDashboardView.swift→ renamed toStatisticsView.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 with8.8.8.8and1.1.1.1. Add/edit/delete + drag-to-reorder. The packet-tunnel extension reads the list atapplySettingstime. - 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
TrustInstructionsViewmodal were redundant and got deleted. - Settings header reads
Info.plist. App name, version, and build come fromCFBundleDisplayName/CFBundleShortVersionString/CFBundleVersioninstead 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 launchwere 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 ID7EE28F79-506A-546A-BBC5-93B146E47E08, libimobiledevice UDID00008150-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 isxcrun 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
applySettingstime. 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.yellowtext onColor.yellow.opacity(0.2)fill. Readable but not great. If accessibility flags it, switch to.orangefor 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 bareApp-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:29842with 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 passestruewhenever the profile is already installed (the "Re-download" label state). On tap: delete on-diskca.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. PreviouslydeleteCertificate()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-serverAuthon leaf certs. Added defensively toPacketTunnelExtension/CertificateGenerator.swift'sbuildHostCertificate(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 existingNetworkMonitorViewModel.loadNewEntries()250 ms file-poll path, forwards every capturedTrafficLogEntryover aURLSessionWebSocketTask. Exponential reconnect (1 s → 30 s), 500-entry buffer for offline drops,@Observablestate surfaces (disabled / idle / connecting / connected / failed) for the Settings UI to render. URL composition tolerateshttp(s)://orws(s)://input and normalizes tows(s)://<host>/ingest?device=<name>. New Settings → Web Streaming subview owns the toggle + server URL + device name, persisted asAppSettings.streaming: StreamingConfig. web/ws-server/— Deno service. Singlemain.ts. Two WebSocket endpoints (/ingest?device=<name>for devices,/dashboardfor 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-kvflag 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 viaNEXT_PUBLIC_WS_URL. Reconnect-on-drop with exponential backoff. Source isapp/DashboardClient.tsx(client component owning the WebSocket);app/page.tsxis 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 inios/Shared/TrafficLogEntry.swift(source of truth) — kept in sync by hand inweb/ws-server/main.tsandweb/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
- If anything in the new chrome feels off (sheet vs nav, button density on iPhone 17 vs iPad), iterate from
SetupGuideView.swiftandRequestListView.swift. Both are the surfaces that took the most change. - 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 betweenCertificateManagerandCertificateGeneratorare the things to re-check first. - Untouched but worth cleanup: the
vpnBarinsideRequestListViewstill uses the granularvpnManager.statusText— fine in that view, but worth noting if we later want all surfaces binary like Setup. - 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.