T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
161 lines
7.3 KiB
Markdown
161 lines
7.3 KiB
Markdown
# WebTerm iOS Client
|
|
|
|
Native iOS 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 and requires **zero server changes** in P0.
|
|
|
|
## Build & Run
|
|
|
|
Requirements: macOS 15, Xcode 16.3 (Swift 6.1), [XcodeGen](https://github.com/yonaskolb/XcodeGen),
|
|
iOS 18.4 simulator (iPhone 16). 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:
|
|
xcodebuild -project WebTerm.xcodeproj -scheme WebTerm \
|
|
-destination 'platform=iOS Simulator,name=iPhone 16,arch=arm64' 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)).
|
|
|
|
## 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`.
|