Files
web-terminal/ios/README.md
Yaojia Wang 5cc755b0b6 fix(ios,android): close the acceptance gaps the review found, add Android CI
- T-iOS-34's stated acceptance is "最大字号不破版" and it was failing: the key bar
  froze its height at 52pt while an AX5 keycap needs 108.24pt, so caps clipped —
  recorded as a withKnownIssue rather than fixed. Height now derives from the
  content size category (and tracks live changes via registerForTraitChanges);
  the known-issue marker is gone, replaced by positive assertions including a
  12-category no-clip sweep and a guard that stays red if anyone writes the
  constant back. Honest tradeoff: the keycap font is clamped at .accessibility2,
  the same policy the design system already applies to dense content, because an
  unclamped AX5 bar would eat the terminal. A test pins the clamp so the two
  cannot drift.

- Thumbnails silently 401'd on a token-gated host: the pipeline built its own
  transport with no token source, and by design never throws, so every preview
  degraded to a placeholder with no signal. Assembled from AppEnvironment now.

- Android had zero CI while the token wave shipped 24 files of secret-handling
  code. The instrumented leg is workflow_dispatch-only and says why in the file:
  no one has ever seen it green on a runner, and a required leg nobody trusts
  just produces a false green.

- Android persisted a validated token before the host probe succeeded, stranding
  a secret for a host that never paired.

App bundle 534 -> 550 on both simulators, zero known issues. Android 687 -> 691.
2026-07-30 16:46:20 +02:00

519 lines
30 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 is
the only third-party dependency, attached to the App target only, and **pinned to
an exact version — `exactVersion: 1.15.0`** (`project.yml`). The pin is exact, not
`from:`, because the generated `.xcodeproj` — and with it `Package.resolved`
is gitignored, so every checkout/CI run re-resolves from scratch and a floating
requirement silently drifted (1.13.0 → 1.15.0 once broke the App target on a
`hasActiveSelection` name collision; the local property was dropped in favour of
upstream's, so the tree now rides 1.15.0 deliberately).
```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`, all routes carry the token `Cookie`) |
| `Packages/ClientTLS` | Device client-certificate (mTLS) identity: Secure-Enclave key, CSR, enrolment, rotation |
| `Packages/TestSupport` | `FakeTransport` / `FakeClock` / `FakeHTTPTransport` |
| `IntegrationTests/` | Tests against a real Node server (`npm start`) + `scripts/coverage-gate.sh` |
Per-package tests: `swift test --package-path Packages/<Name>`.
Coverage gate (≥ 80 % own-sources, one package per invocation):
`ios/IntegrationTests/scripts/coverage-gate.sh <Package>` — gated packages are
`WireProtocol`, `SessionCore`, `HostRegistry`, `APIClient` and (since this wave)
`ClientTLS`.
The server runs from the repo root: `npm install && npm start` (see the root
[README](../README.md)).
### Signing, device builds, and the push-entitlements switch
The signing account is a **free personal Apple team**: `DEVELOPMENT_TEAM =
C738Z66SRW` is declared in `project.yml` (a Team ID is not a secret — it is
public in every signed ipa), and without it any device build stops at *"Signing
for 'WebTerm' requires a development team"*. `CODE_SIGN_IDENTITY` is set to the
modern `Apple Development` at **target** level, because XcodeGen's
iOS-application preset injects the deprecated pre-Xcode-12 `iPhone Developer`
string at target level, which outranks anything set project-wide.
**Device build works on the free team** (verified in this wave, commit
`c4f8b5b`): with automatic signing plus `-allowProvisioningUpdates`, Xcode mints
an on-the-fly **7-day** free-team provisioning profile and the real-device build
reports `BUILD SUCCEEDED`. A 7-day profile expires — re-run the build to re-mint.
**Push entitlements are opt-in, via one frozen env switch.** `aps-environment`
lives in `App/WebTerm/WebTerm.entitlements`, which is **not attached by
default**:
```bash
cd ios && WEBTERM_PUSH_ENTITLEMENTS=App/WebTerm/WebTerm.entitlements xcodegen generate # push ON
cd ios && xcodegen generate # push OFF (default)
```
Plainly: **on a free personal team, switching push ON makes the device build
fail** — verbatim:
> `Personal development teams, including "Yaojia Wang", do not support the Push Notifications capability`
So the switch is usable only on a **paid** team with the Push Notifications
capability enabled on the App ID, and a **release** build additionally needs
`aps-environment` flipped from `development` to `production` (that flip belongs
to the release pipeline, not to the checked-in file). With the variable unset,
`CODE_SIGN_ENTITLEMENTS` resolves against an empty project-level default, so no
entitlements file is signed in at all — which is exactly why the default build
works on the free team.
The switch is read by XcodeGen at **generate** time and its default is declared
as a project-level build setting (which outranks the build-time process
environment), so an exported shell variable can never silently attach
entitlements to a project that was generated without it.
`UIBackgroundModes: [remote-notification]` is unconditional: a background
*mode* is not an Apple capability, so it is safe on a free team — unlike the
`aps-environment` *entitlement*.
## Access token (`WEBTERM_TOKEN`) — how this client authenticates
A server started with `WEBTERM_TOKEN=<16512 chars of [A-Za-z0-9._~+/=-]>` gates
**every** HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie
(`src/http/auth.ts`). A host with **no** token configured is byte-identical to
before the feature: nothing is stored, so no `Cookie` header is ever sent and LAN
zero-config still works.
**Honest boundary, mirroring `src/http/auth.ts`: this is a bar-raiser, NOT a
TLS/Tailscale substitute.** On a bare-LAN `ws://` / `http://` deployment the
cookie and the token travel in **cleartext** — anyone sniffing the LAN sees the
token and can **replay** it (no per-request nonce, no channel binding). It is a
single shared secret: no per-user identity, no revocation except changing the env
var and restarting. It only meaningfully hardens the relay/tunnel path where the
edge terminates TLS. It does not make the server safe to port-forward.
How the native client speaks it:
- **The client hand-writes `Cookie: webterm_auth=<t>` itself** — no
`HTTPCookieStorage`, no `Set-Cookie` parsing (`httpShouldSetCookies = false`,
`httpCookieAcceptPolicy = .never`, `httpCookieStorage = nil` on the WS session).
Three stamping points, all through the same derivation helper: the `APIClient`
route builder (`Endpoints.swift`, the same choke point that enforces
Origin-iff-guarded — and it stamps the cookie **unconditionally**, read-only
GETs included), the WS-upgrade request
(`SessionCore/URLSessionTermTransport.swift`), and the shared production HTTP
transport (`Wiring/URLSessionHTTPTransport.swift`), which resolves the token
**lazily per request from the request's own origin** and therefore covers the
hand-built requests that don't go through `APIClient` at all (e.g. the
app-layer `GET /projects/diff` fetcher) — "every request carries the token" is
true by construction rather than per call site. A request that *already* carries
a `Cookie` is left untouched, because the pairing probe authenticates with a
**candidate** token that is not in the Keychain yet.
- **The `Cookie` is orthogonal to `Origin`**, never a replacement: the server
checks Origin first, then the cookie, so guarded routes still carry `Origin`
and read-only routes still carry none.
- **`POST /auth` is used once, at pairing time**, as a validation probe with the
four outcomes the server actually produces: `204` **with** `Set-Cookie` = the
token is right (store it) · `204` **without** `Set-Cookie` = that host has auth
disabled (store nothing, and do *not* treat it as authenticated) · `401` =
wrong token · `429` = rate-limited (10/min/IP). The request sends
`{"token":"…"}` with `Accept: application/json` — an `Accept` containing
`text/html` makes the server answer a form-style `302` instead of `204`/`401`.
- **Storage**: per host, in the **Keychain**, under the existing `SecItemShim`
conventions — `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, no
`kSecAttrSynchronizable` (never iCloud-synced, never in a device backup that
leaves the phone). The `AccessToken` type validates charset/length at the
keyboard (the same regex the server enforces at config load), redacts
`description`/`debugDescription`/`customMirror`, and is deliberately **not**
`Codable` — so no interpolation, `dump()`, or reflection-based crash reporter
can serialise it by accident. It is never logged and never placed in a URL.
- **401 handling is typed, not generic**: a REST 401 throws
`APIClientError.unauthorized`, and a **401 on the WS upgrade is terminal**
(`TermTransportError.unauthorized``connection(.failed(.unauthorized))`) — it
never enters the reconnect back-off loop, because re-dialling the handshake with
one wrong shared token is a brute-force loop with no possible upside. Same tier
as `.replayTooLarge`: a terminal state with actionable copy, not a retry.
- **UI**: a failure whose remedy *might* be a token offers an inline
token prompt on the pairing screen (wrong token / rate-limited / "this host has
no auth at all" / shape violation each get their own copy, and none of them
ever echoes the token). Already-paired hosts can have their token updated or be
removed outright from the same screen; removing a host also de-registers this
device's APNs token for it.
## 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. The client and server halves are built and unit-tested, but the
**end-to-end path needs a paid Apple account**: a `.p8` on the server *and* the
Push Notifications capability, which the free personal team cannot grant — so
the `aps-environment` entitlement ships behind the
`WEBTERM_PUSH_ENTITLEMENTS` switch (see
[Signing, device builds, and the push-entitlements switch](#signing-device-builds-and-the-push-entitlements-switch))
and lock-screen Allow/Deny has never been exercised on hardware here. Server
side: [`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.
### P2 — polish (this wave)
- **Terminal find bar** — a magnifier toolbar toggle opens a search field pinned
**top-right** (mirroring the web `#searchbox`); Enter or `chevron.down` = next
match, `chevron.up` = previous, `xmark` closes and clears (mirroring the web's
Enter / Shift+Enter / Esc). The engine is SwiftTerm's **own** scrollback
search (`findNext`/`findPrevious`/`clearSearch`) — the selection *is* the
highlight. Search is **pure read**: it produces zero PTY bytes, so it also
works on an exited (read-only) session. The query is passed through
byte-for-byte (a single space is a legal search term; nothing is trimmed).
- **Voice push-to-talk** — an extra 🎤 key appended **after** the 17 existing
key-bar keys (their order and accessibility labels are unchanged, and the mic
key deliberately maps to no bytes at all). Press-and-hold dictates; on release
the transcript is **sanitised** (`\r`, `\n`, `\t`, ESC and every other C0/C1
control character stripped, multi-line folded to one line, length-capped) and
put in front of you for **confirmation** — nothing is ever injected before you
confirm, and what is injected does **not** end with `\r`, so a misheard command
can never execute itself. Confirming opens a **1.5 s undo window** before the
injection lands. A whole-utterance matcher (ported from the web
`voice-commands.ts`, with the same negation guard and 0.6 confidence floor) can
resolve a **held tool gate** by voice — exact-sentence match only, so "确认一下这个"
is text, not an approval. Two **epoch** gates discard a decision if the session
or connection changed between dictating and confirming. Microphone / speech
permission denial and recogniser failure are distinct, actionable states, and a
read-only terminal refuses to start recording at all.
- **Theme + Dynamic Type** — a Settings sheet (gear in the sessions toolbar, on
both the stack and the split layouts) offers **follow system / dark / light**.
The default is **dark**, byte-identical to the previously hard-locked
appearance; unknown persisted values fall back to dark. Light mode is a real
code path, not just an unlock: status / timeline / accent tokens have light
variants that clear **WCAG 1.4.11 (≥ 3:1)** against their background, and the
terminal canvas has its own per-scheme palette (dark `#100F0D`/`#ECE9E3`,
light `#F6F7F9`/`#1A1D24` — mirroring the web `THEMES.light`) with ≥ 7:1
foreground contrast. Dynamic Type up to **AX5** is measured, not assumed:
gate banner, telemetry chips and meta rows are asserted not to overflow 320 pt,
with a numeric clamp keeping tabular figures from blowing up the layout.
The key-bar height is derived from the content size category rather than
frozen at 52 pt, so nothing clips at any size. Its keycap **font** is clamped
at `.accessibilityLarge` — the same dense-content policy the design system
already applies to telemetry chips — because an unclamped AX5 bar wants
~108 pt of the 17-key strip and would eat the terminal. So AX3AX5 render
keycaps at AX2 size; a test pins the clamp to the design-system constant so
the two cannot drift, and another keeps the bar at exactly 52 pt for XSXL.
- **Project git panel** — the ambient git state the server has served since w6,
now on the phone: a **sync band** (`↑ahead ↓behind`, upstream, detached-HEAD
and fetch-staleness states — green "in sync" *only* when `↑0 ↓0` **and** the
last fetch is under an hour old), a **log** with the unpushed-commit boundary
drawn after the last unpushed commit, **PR status**, and **stage / commit /
push / fetch** writes. Every write shows the server's own error text verbatim
when it rejects; a git-credential `401` from `push` is deliberately *not*
confused with an access-token `401`.
- **Worktree lifecycle** — create (with the web's 9 branch-name rules enforced
client-side, so an invalid name never reaches the network), **prune**, and
**remove** behind two-step confirmation: a `409` promotes to an explicit
force-confirm rather than auto-retrying, and the main/locked worktrees offer no
remove action at all.
- **`claude --resume` history** — `GET /sessions` filtered to the project's own
subtree (no half-path prefix matches), server order preserved. The session id
is whitelisted (`[A-Za-z0-9._-]{1,128}`) **before** it is composed into the
bootstrap command line, and a row whose id fails that check offers no resume
button.
- **`?join=` interop with the web share QR** — the web 🔗 share link
(`http(s)://<host>[:<port>]/?join=<uuid>`) now opens the same session in the
app. Because an http(s) URL is a far bigger input surface than the private
`webterminal://` scheme, the whitelist is stricter: exactly one `join` key, a
valid v4 UUID, empty-or-`/` path, no fragment, no userinfo, no extra query
keys — anything else is ignored. The host is resolved **only** through the
already-paired host list (`HostEndpoint.originHeader`); an unknown origin opens
pairing with a hint that never echoes the scanned link back.
### 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
**Merged.** The client is not on a side branch any more: `feat/ios-client` was
merged into **`develop`** long ago (`git merge-base --is-ancestor
feat/ios-client develop` → true) and iOS work continues there. P0, P1, the iPad
adaptation and P2 have all shipped.
Test suites, and where each number comes from:
| Layer | Size | Source of the number |
|---|---|---|
| Logic packages | WireProtocol 59 · SessionCore 108 · HostRegistry 73 · APIClient 125 · ClientTLS 84 (**449**), plus 3 in TestSupport | static count of `@Test` declarations (`grep -rh '@Test' ios/Packages/*/Tests`); for the four packages this wave touched, that count equals the executed counts quoted in commits `850531f` / `a5fa843` |
| App bundle (`WebTermTests`) | **532** `@Test` declarations | static count (`grep -rh '@Test' ios/App/WebTermTests`) — a declaration count, not an executed count (parameterised cases expand) |
| Integration (real Node server) | **10** | `grep '@Test' ios/IntegrationTests/*.swift`; the harness boots `tsx src/server.ts` itself |
| XCUITest | 1 scripted happy path (pair → attach → type → approve) + an iPad `ProjectsLayout` suite | `ios/App/WebTermUITests/` |
Own-sources coverage, from the runs recorded in the wave commits: APIClient
**92.22 %**, HostRegistry **92.49 %**, SessionCore **96.74 %**, ClientTLS
**89.49 %** (up from 55.76 %, and newly **inside** the gate), WireProtocol 100 %
(P0 close-out). The gate script is
`ios/IntegrationTests/scripts/coverage-gate.sh`.
The last *executed* full app-suite figure this README can point at is **290 on
iPhone 16 + 290 on iPad Pro 11"** from the 2026-07-05 design-polish pass
(`PROGRESS_LOG.md`). This wave's authoritative run is the Wave-D acceptance pass —
read its entry in [`PROGRESS_LOG.md`](../docs/PROGRESS_LOG.md) for the numbers
actually measured, and trust that over any count in this file.
**DEFERRED — cannot be verified in this environment:**
- **Real hardware**: on-device gestures / IME / camera QR / haptics, the
lock-screen Allow walkthrough (Face ID), Stage Manager and iPad
pointer/hardware-keyboard passes. Manual step lists live in the T-iOS-18 /
T-iOS-30 / T-iPad-5 report entries in `PROGRESS_LOG.md`.
- **Paid Apple account**: APNs end-to-end, TestFlight, and the push-entitlements
switch (see above — turning it on breaks the free-team device build by design).
- **No end-to-end access-token leg**: the token path is covered at unit level
(`AccessToken` / probe / cookie-stamping / 401-terminal tests on both the
package and app layers), but neither `ios/IntegrationTests` nor the in-simulator
live-server smoke starts the server with `WEBTERM_TOKEN` set, so the
cookie-gated handshake has never been exercised against a real gated server
here. Verified by grep: no `WEBTERM_TOKEN` / `webterm_auth` in either harness.
- **GitHub Actions**: `.github/workflows/ios.yml` defines six jobs
(`package-tests` matrix + coverage gate, `testsupport-tests`, `app-tests` on
iPhone *and* iPad, `integration-tests`, `ui-test` on both idioms,
`ios17-floor-tests`). Three previously-dead legs were fixed
in `a5fa843` (the app/iPad legs hard-failed on a missing `npm ci`; the iOS-17
leg could silently report green with no runtime installed). Whether a run has
since gone green on GitHub was **not** checked here (`gh` is unauthenticated in
this environment).
Design & task detail live in [`PLAN_IOS_CLIENT.md`](../docs/PLAN_IOS_CLIENT.md)
and [`PLAN_IOS_IPAD.md`](../docs/PLAN_IOS_IPAD.md) (§7 / §5 carry a per-task
status table); progress in [`PROGRESS_LOG.md`](../docs/PROGRESS_LOG.md).
## ntfy notification bridge (P0 "host finds the phone")
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.
> **Still the working channel today.** The APNs path (P1, T-iOS-20/21) is built
> and unit-tested on both sides, but it cannot be used end-to-end on the current
> **free** Apple team (no Push Notifications capability — see
> [the entitlements switch](#signing-device-builds-and-the-push-entitlements-switch)),
> so until a paid account exists this bridge is how the host reaches the phone.
### 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`.