Files
web-terminal/ios
Yaojia Wang d36deb6922 fix(ios): dark-appearance-first + readable dirty badge (gold-on-light contrast)
Amber-gold accent is designed for the desktop's warm-dark background; on light
mode gold text/badges wash out. Match the desktop (dark theme by default):
.preferredColorScheme(.dark) at the root. DirtyBadge becomes a soft filled
capsule (amber on amber-18%) instead of amber-on-quaternary, readable in any mode.
2026-07-06 08:10:34 +02:00
..

WebTerm iOS Client

Native iPhone + iPad client for the web-terminal server in the repository root. It is a pure remote client: it speaks the same WebSocket wire protocol and HTTP endpoints as the web frontend. P0 needs zero server changes; P1 adds exactly two declared, additive server touch-points (an APNs sender + token endpoint, and an optional lastOutputAt field on GET /live-sessions) — nothing else in src/ / public/ changes.

The whole app is a single adaptive codebase: iPhone (and iPad Slide Over / small splits) get the compact stack layout; iPad regular width gets a NavigationSplitView sidebar + detail — no forked UI. Min deployment iOS 17.0; Swift 6 language mode. The look matches the desktop web theme (amber-gold accent #E3A64A on warm-dark, dark-appearance-first).

Build & Run

Requirements: macOS 15, Xcode 16.3 (Swift 6.1), XcodeGen, an iOS 17+ simulator (CI tests on iPhone 16 and iPad Pro 11"). SwiftTerm 1.13 is resolved for the App target only.

cd ios
xcodegen generate          # project.yml → WebTerm.xcodeproj
open WebTerm.xcodeproj     # or run the full test suite headless (both idioms):
xcodebuild -project WebTerm.xcodeproj -scheme WebTerm \
  -destination 'platform=iOS Simulator,name=iPhone 16,arch=arm64' test
xcodebuild -project WebTerm.xcodeproj -scheme WebTerm \
  -destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' test

Layout:

Path What
App/ SwiftUI app glue + WebTermTests unit-test bundle
Packages/WireProtocol Frozen wire contract (frames, validation, tunables)
Packages/SessionCore SessionEngine actor: connect/replay/reconnect/gate
Packages/HostRegistry Paired hosts + last-session persistence
Packages/APIClient HTTP endpoints (guarded routes carry Origin)
Packages/TestSupport FakeTransport / FakeClock / FakeHTTPTransport
IntegrationTests/ Tests against a real Node server (npm start)

Per-package tests: swift test --package-path Packages/<Name>.

The server runs from the repo root: npm install && npm start (see the root README).

Features

The product loop is vibe coding: kick off Claude Code on your Mac, walk away, and check / steer it from your phone — approve or reject permission gates remotely, watch status, and reattach with full scrollback.

P0 — daily-usable pocket cockpit

  • Native terminalSwiftTerm, not a web view. Full ANSI/true-color, native selection, IME and keyboard. On attach it replays the host's whole scrollback ring, then resumes the live stream. The canvas matches the desktop (warm near-black #100F0D, gold caret).
  • Pairing — scan the web UI's connect-device QR, or type the host URL. A confirm step shows the parsed scheme://host:port and makes zero network calls until you confirm (a scanned code is untrusted input). A two-step probe verifies reachability + the Origin handshake before saving the host to the Keychain. Per-§5.4 warning tiers (public host = blocking warning, plaintext LAN = notice, Tailscale = fine).
  • Session list (chooser + dashboard in one) — every running session with a semantic status badge (working / waiting / idle / stuck — colour and shape, never colour alone), live telemetry chips (cost / context / PR, SF Mono, greyed when stale), a preview thumbnail, cols×rows, swipe-to-kill, pull-to-refresh, and multi-host switching.
  • Remote approve / reject — the core steering primitive. A tool gate shows a prominent Approve / Reject card (≥44 pt); a plan gate shows the three-way sheet (Approve+Auto → acceptEdits / Approve+Review → default / Keep Planning → reject, matching the web client exactly). Haptic on arrival.
  • Away digest — on reattach, a summary of what happened while you were gone ("ran N tools · waiting · done"), expandable into the full timeline.
  • Sessions survive everything — kill the app / background / lose the network → foreground reattaches and replays. One live WS for the foreground session; the rest are HTTP-polled. Reconnect uses 1s→30s backoff; a 25 s ping keeps the socket honest.
  • Mobile key-bar — Esc / Esc·Esc / ⇧Tab / arrows / Enter (\r) / ^C / ^R / ^O / ^L / ^T / ^B / ^D / Tab / /, byte-for-byte from the web key-bar, sent raw (no soft-keyboard pop). Hardware-keyboard chords via UIKeyCommand.
  • Privacy shade — the terminal is covered whenever the app isn't active (scenePhase != .active), so the multitasking snapshot never leaks terminal bytes.
  • Notifications (P0) — reuses the server's existing ntfy bridge (see below), zero new code, default-off.

P1 — walk-away complete

  • APNs push + lock-screen Allow / Deny — the host notifies your phone on a held gate; from the lock screen you Allow (behind Face ID / passcode, .authenticationRequired) or Deny without opening the app. The decision is a single-use capability token; it's validated then discarded, never persisted, never logged. (Needs a paid Apple account + .p8 on the server — see PLAN_IOS_CLIENT.md §T-iOS-20.)
  • Deep linkswebterminal://open?host=…&join=… (strict whitelist, UUID- validated) opens straight to a gated session, cold or warm; push taps use the same router.
  • Projects — auto-discovered git repos grouped by namespace, favourites + collapsed state synced through the server's /prefs (unknown keys preserved so iOS never clobbers web-written prefs), dirty badge, detail page (sessions / worktrees / CLAUDE.md), and "open Claude in this repo" (attach(cwd) + inject claude\r).
  • Multi-session switcher — unread dots (from lastOutputAt) and sanitized OSC titles (bidi / zero-width stripped, length-capped — titles are attacker-influenced).
  • Activity timeline — the full /events drill-down, class → icon + colour.
  • Diff viewer — read-only git diff, staged/unstaged, per-file hunks.
  • Quick-reply chips — tap-to-send snippets + an editable palette (floats while a gate is waiting).
  • Session thumbnails — off-screen SwiftTerm renders, concurrency-capped and cached by (sessionId, lastOutputAt).

iPad

  • Adaptive split view — regular width shows a sidebar (session list + projects, continue-last banner) beside the terminal detail; compact width (incl. Slide Over) collapses to the iPhone stack. One code path (LayoutPolicy), the privacy shade covers both.
  • Hardware keyboard — the on-screen key-bar auto-hides when a hardware keyboard is attached.
  • Pointer — right-click / long-press context menu on the terminal (open in cwd / kill / copy), routed through the same guarded endpoints.

Look & feel

A single frozen design system (App/WebTerm/DesignSystem/): one amber-gold accent token (#E3A64A dark / #C9892F light) matching the desktop --accent, semantic status colours from the web palette, an 8-pt spacing scale, SF Mono tabular numbers, reduce-motion-gated animation and haptics, and reusable primitives (StatusBadge / TelemetryChip / Card / …). The app is dark-appearance-first to match the desktop's dark theme (gold reads best on warm-dark). Every view consumes tokens — no hardcoded colours.

Status & what's verified

Built and green: 290 app tests (iPhone 16 + iPad Pro 11"), 261 package tests, 10 integration tests against a real Node server, plus one XCUITest happy-path (pair → attach → type → approve). Coverage ≥ 80 % on the four logic packages. Not yet done / needs hardware: on-device gesture / IME / camera-QR / haptics / lock-screen-Allow walkthroughs are DEFERRED to a real device; APNs end-to-end needs a paid Apple account. The client is on the feat/ios-client branch (not yet merged). Design & task detail live in PLAN_IOS_CLIENT.md and PLAN_IOS_IPAD.md; progress in PROGRESS_LOG.md.

ntfy notification bridge (P0 "host finds the phone")

Until APNs push lands (P1, T-iOS-20/21), the phone is notified via the existing ntfy bridge that already ships with npm run setup-hooks. No new code — this chapter documents and verifies the shipped behavior.

What it does

When Claude Code (running inside a web-terminal session on the host) fires a hook event, an extra curl posts to your ntfy topic:

Claude Code hook event ntfy Priority header Meaning
PermissionRequest (held gate) high NEEDS-INPUT — Claude is waiting for approval
Stop / SessionEnd low DONE — the task finished / session ended

Install logic: scripts/setup-hooks.mjs:229-238 (high on PermissionRequest at :231-233, low on Stop/SessionEnd at :235-237). The command itself is built by buildNtfyCommand (scripts/setup-hooks.mjs:91-101).

Default-off: env unset ⇒ zero side effects

The bridge is opt-in at install time: npm run setup-hooks only adds the ntfy hooks when both WEBTERM_NTFY_URL and WEBTERM_NTFY_TOPIC are set in the environment (gate at scripts/setup-hooks.mjs:262-264, guarded install at :229). With the vars unset, the written settings.json contains no ntfy entry at all (verified: fresh install without env → grep -c WEBTERM_NTFY settings.json = 0, and the install log has no ntfy line).

Two further layers keep it inert outside web-terminal:

  • Every installed ntfy command starts with [ -n "$WEBTERM_HOOK_URL" ] && … (scripts/setup-hooks.mjs:93) — in a normal terminal that var is unset, so the hook is a no-op.
  • The session env is the only carrier: the server forwards WEBTERM_NTFY_* into each spawned session via the ...process.env spread (src/session/session.ts:95-102, comment at :99-100). Nothing is hardcoded server-side.

Setup

  1. Install the ntfy app on the iPhone (App Store) and allow notifications.

  2. Generate a random topic and subscribe to it. On the public ntfy.sh server, the topic name is the password: anyone who knows it can read your notifications and publish to the channel. Use a random string, e.g. openssl rand -hex 12.

  3. Export the env vars, then install the hooks and start the server from that same shell (the install gate reads env at setup-hooks.mjs:262-264; at runtime the curl expands $WEBTERM_NTFY_* from the session env, which inherits the server's env — src/session/session.ts:95-102):

    export WEBTERM_NTFY_URL=https://ntfy.sh     # or your self-hosted ntfy
    export WEBTERM_NTFY_TOPIC=<your-random-topic>
    # optional, self-hosted/paid access control only:
    export WEBTERM_NTFY_TOKEN=tk_...            # env only — never a literal
    npm run setup-hooks
    # expect: Installed ntfy bridge (NEEDS-INPUT=high, DONE=low) → https://ntfy.sh/<topic>
    npm start
    

    Confirmation log line: scripts/setup-hooks.mjs:296-298.

  4. Restart any running claude sessions (the installer prints this reminder).

What the notification contains (payload minimization)

Verified against the shipped command (scripts/setup-hooks.mjs:91-101): the curl sends an empty POST body — there is no -d/--data flag at all. The only information that leaves the host is:

  • which topic was posted to (your private random channel), and
  • the Priority header (high = needs input, low = done).

No session id, no cwd, no command content, no tool names — strictly less than even a "sessionId prefix + status word" payload. In the ntfy app both signals show ntfy's default message text; you tell them apart by the priority rendering (high-priority notifications are visually marked and can bypass quiet delivery).

The token is referenced as Bearer $WEBTERM_NTFY_TOKEN (scripts/setup-hooks.mjs:95) — a shell variable expanded at hook-fire time. It is never written into settings.json or any command literal (SEC-C6; regression-tested by test/setup-hooks.test.ts:410-421, re-run for this verification: 49/49 passed).

What it does NOT cover: STUCK

stuck is a server-derived state: manager.sweepStuck (src/session/manager.ts:268) infers it from output silence while a session looks busy. No Claude Code hook event fires for it, so a hook-side bridge physically cannot send a STUCK notification. This gap is closed in P1 by APNs (T-iOS-20), which pushes from the server's own event bus.

Turning it off / P1 supersession

Once P1 APNs lands (T-iOS-20/21), this bridge is superseded and can be disabled. Any of:

  • Re-run npm run setup-hooks without the WEBTERM_NTFY_* env vars — the reinstall pass strips previously installed ntfy groups (marker cleanup, scripts/setup-hooks.mjs:185-205; the ntfy command contains the WEBTERM_HOOK_URL marker via its guard at :93, verified: reinstall without env → 0 ntfy entries remain).
  • npm run setup-hooks -- --remove — removes all web-terminal hooks.

End-to-end phone check — DEFERRED(需真机/用户操作)

Not runnable in this environment (requires the user's phone and mutating the user's live Claude Code hook config). Exact manual steps:

  1. iPhone: install ntfy, subscribe to <your-random-topic> on https://ntfy.sh, allow notifications.
  2. Mac: export WEBTERM_NTFY_URL=https://ntfy.sh WEBTERM_NTFY_TOPIC=<topic>, run npm run setup-hooks, confirm the log line Installed ntfy bridge (NEEDS-INPUT=high, DONE=low), then npm start from the same shell.
  3. Open the web terminal, start a session, run claude, and give it a task that triggers a permission gate (e.g. a Bash command that is not pre-allowed).
  4. When the gate is held (tab badge shows waiting-for-approval), the iPhone should receive a high-priority ntfy notification within seconds.
  5. Approve and let the task finish — a low-priority notification arrives on Stop/SessionEnd.