- 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.
519 lines
30 KiB
Markdown
519 lines
30 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 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=<16–512 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 AX3–AX5 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 XS–XL.
|
||
- **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`.
|