Files
web-terminal/ios
Yaojia Wang aa956fcbb4 feat(ios): W5 acceptance + finding fixes — XCUITest happy path, CI legs, privacy/device-family hardening
T-iOS-18 (F-iOS-1..13 walkthrough) + T-iOS-19 (security audit vs built artifacts): both
PASS_WITH_FINDINGS, 0 CRITICAL/HIGH; real-device items DEFERRED with manual checklists.
Fixes (1 MED + 3 LOW, all closed):
- WebTermUITests: the single scripted happy path (manual-entry pair → list → attach →
  KeyBar ^L → injected held gate via POST /hook/permission → tap Approve → server-side
  behavior==allow assertion). Green twice locally (fresh + already-paired branches).
- ios.yml: ui-test leg (server boot + TEST_RUNNER_ env as PROCESS env) + iOS17-floor leg
  (loud-skip if runtime absent, hard-fail if present and red); dropped CODE_SIGNING_ALLOWED=NO
  from app-tests leg (unsigned hosts get -34018 from the real keychain — measured)
- URLSessionHTTPTransport defaults to .ephemeral (no disk cache of preview terminal bytes)
- TARGETED_DEVICE_FAMILY moved to target level (built plist now UIDeviceFamily [1] only)
Final: 306 automated checks green (214 pkg + 76 app + 10 integration + XCUITest); server src/
untouched; P0 complete (19/19 tasks)
2026-07-05 02:16:52 +02:00
..

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, iOS 18.4 simulator (iPhone 16). SwiftTerm 1.13 is resolved for the App target only.

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).

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 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):

    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.