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).
275 lines
14 KiB
Markdown
275 lines
14 KiB
Markdown
# 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`.
|