TunnelVision

UX — Onboarding and consent

The two hardest UX moments are (1) approving the local VPN and (2) installing and trusting the CA for HTTPS inspection. Both involve leaving the app for a system prompt or Settings. Done well, a non-technical user gets through them without fear; done badly, they bounce. This is the most important UX work in the app (milestone M10).

First run

A short, skippable 3-card intro, then land on the Dashboard in its empty state.

  1. What it does — “See which apps and servers your device talks to. Everything stays on your device.” One sentence, one illustration.
  2. How it works — “TunnelVision watches traffic through a private, on-device VPN. iOS will show a VPN icon while it’s on.” Sets the expectation before the system prompt.
  3. Your privacy — “No account. No cloud. Nothing is uploaded. You can stop anytime.” Then a single primary button: Start monitoring.

No account creation, no sign-in, ever.

As implemented (M10): the copy, the order and both buttons of each card are a pure value, IntroPresentation (TunnelVision/Models), driven by IntroViewModel and drawn by IntroView — the intro covers the whole app (fullScreenCover on RootView) rather than a tab, so nothing behind it can be tapped while it explains what this is. Four decisions the section above does not settle:

When the store cannot be read or written, the intro is shown and the app carries on: a new install cannot be told apart from an unreadable store, and of the two possible mistakes only one is proportionate — showing three cards to someone who has seen them is an annoyance, never explaining the VPN to someone who has not leaves them unprepared for the system prompt. Not being able to record it does not trap the user in the intro either; the fact surfaces in Settings, next to the button that brings the intro back, and not mid-onboarding where it would be an incomprehensible failure about something the user does not yet know exists.

Skippable without sight (M11). “Short and skippable” was only half true with VoiceOver. The deck is walked by swiping it, and the card is a single accessibility element, so the page gesture never reaches it: all that survived of the intro’s navigation was what sits in buttons — Continue, one card at a time and forward only, and Skip, which leaves the intro without ever offering the start. That left two gaps, and neither is cosmetic: there was no way back (no button on the screen retreats, so a card someone wanted to re-read was only recoverable by swiping), and there was no way to the end — the last card is the one that asks to start, and the only route to it was reading all three. The fix is the same shape as the Timeline’s axis: the card becomes adjustable (one card per swipe, clamped at both ends — the intro announces “3 of 3”, and wrapping round to the first would contradict what it just said), plus named actions for what a finger does at a glance. All of it is decided in IntroAccessibility (pure), and both routes end in the same show(_:), so what a finger applies and what VoiceOver applies cannot drift apart. Three rules worth naming:

Not yet heard. The rules are asserted in tests (Views/ stays out of the test bundle), but nobody has run VoiceOver over the intro: whether the phrases read well one after another, and whether the paged TabView keeps its off-screen cards out of the way, needs the Accessibility Inspector or a device.

Enabling the tunnel (VPN permission)

Priming → system prompt → result, never a cold prompt.

[ In-app priming sheet ]
  "iOS will now ask permission to add a VPN configuration.
   This VPN is local — your traffic is not sent to us or anyone else.
   It's what lets TunnelVision watch your connections on-device."
        [ Not now ]                 [ Continue ]
                                        │
                                        ▼
        iOS system VPN prompt  ──▶  Allow / Don't Allow
                                        │
             ┌──────────────────────────┴───────────────────┐
        Allowed                                          Denied
   start tunnel, dashboard goes live         explain + offer retry, no dead end

Enabling HTTPS inspection (the CA flow)

Off by default. Only reached from Settings → Look inside secure traffic, gated behind an explanation of the trade-off. This flow has several system round-trips; guide each one and show live status of where the user is.

Step 0 — Explain the trade-off

“Turning this on lets TunnelVision read your own secure (HTTPS) traffic so you can see inside it. To do that, you’ll install a certificate you create here. Apps that use certificate pinning will stay private and can’t be read — that’s normal and expected.”

Step 1 — Generate the certificate

One tap creates the local CA (private key stays on device, in the Keychain). Show that it was created; offer Learn what this means.

Step 2 — Install the profile

iOS requires installing the certificate as a configuration profile. Provide the profile and step-by-step, screenshot-guided instructions, deep-linking where possible:

Step 3 — Enable full trust

A separate, deliberate iOS step (Apple designed this friction on purpose; embrace it):

Step 4 — Confirm

Back in the app, verify trust and show a clear Inspection on state, listing exactly what becomes visible and what stays private (pinned apps).

As implemented so far (M10): the app can now ask — CertificateStatusReader (../spec/app-services.md) answers whether a CA exists (the shared Keychain, where LocalCA keeps the root key) and whether the system trusts its root (SecTrust, asked in the present tense, never cached). That is what feeds Settings’ availability, and it is what steps 1–4 will be built on. Two constraints it settles, both of which shape the screens still to come:

As implemented (M10, the flow under the screen): steps 0–4 are a pure value (CertificateSetupStage + CertificateSetupPresentation + CertificateSetupPolicy, in TunnelVision/Models), driven by CertificateSetupViewModel over CertificateStatusReader, LocalCA and a new CertificateProfile/CertificateProfileExporter (../spec/app-services.md). Five decisions this section does not settle:

As implemented (M10, the screen): CertificateSetupView (TunnelVision/Views/Settings) draws the stage, its guidance and its buttons; SetupStepList draws one numbered instruction; and CertificateProfileSheet stands between the Install certificate button and the system share sheet. The screen decides nothing — every string, every button and every rule about what may be offered comes from the pure core above. Four decisions that belong to the drawing:

Turning it off / removing trust

As implemented (M10): removing is part of the flow and not an extra. It turns inspection off, then deletes the root key from the Keychain (LocalCA.removeFromKeychain), and only then shows the profile-removal steps — before that there is nothing to withdraw, and the guidance would be telling the user to undo something still in place. It also returns the flow to its explanation: turning it back on is the same decision being taken again. What is not touched is said out loud in the confirmation — history and captures stay.

States to design for every step

not started · in progress (waiting on system) · succeeded · denied/failed (with retry) · partially done (e.g. profile installed but trust not enabled — detect and guide to the exact missing step).