Files
web-terminal/ios/README.md
Yaojia Wang a887638557 docs: document native clients — full iOS/iPad feature README + root Clients section
ios/README: add a comprehensive Features section (P0 pocket cockpit, P1 walk-away
complete, iPad adaptive split, design system, verification status); note min iOS
17, both-idiom test commands, P1 server touch-points.
root README: add a Clients section covering the iOS/iPad app, desktop Electron
shell, and the pre-production relay stack (with Tailscale as the remote-access
path today).
2026-07-06 08:10:33 +02:00

275 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](https://github.com/yonaskolb/XcodeGen),
an iOS 17+ simulator (CI tests on iPhone 16 **and** iPad Pro 11"). SwiftTerm 1.13
is resolved for the App target only.
```bash
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](../README.md)).
## 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 terminal** — [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm),
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`](../docs/PLAN_IOS_CLIENT.md) §T-iOS-20.)
- **Deep links** — `webterminal://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`](../docs/PLAN_IOS_CLIENT.md) and
[`PLAN_IOS_IPAD.md`](../docs/PLAN_IOS_IPAD.md); progress in
[`PROGRESS_LOG.md`](../docs/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](https://ntfy.sh) 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`):
```bash
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`.