feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
This commit is contained in:
282
ios/README.md
282
ios/README.md
@@ -16,8 +16,14 @@ small splits) get the compact stack layout; iPad regular width gets a
|
||||
## 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.
|
||||
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
|
||||
@@ -29,7 +35,7 @@ xcodebuild -project WebTerm.xcodeproj -scheme WebTerm \
|
||||
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' test
|
||||
```
|
||||
|
||||
Layout:
|
||||
### Layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
@@ -37,15 +43,130 @@ Layout:
|
||||
| `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/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`) |
|
||||
| `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,
|
||||
@@ -94,8 +215,14 @@ remotely, watch status, and reattach with full scrollback.
|
||||
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.)
|
||||
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.
|
||||
@@ -125,6 +252,72 @@ remotely, watch status, and reattach with full scrollback.
|
||||
- **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.
|
||||
**Known gap (measured, not fixed):** the fixed 52 pt key-bar needs ~108 pt at
|
||||
AX5, so key caps clip — recorded as a `withKnownIssue` in
|
||||
`DynamicTypeLayoutTests` so the test starts complaining once it is fixed.
|
||||
- **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
|
||||
@@ -137,23 +330,70 @@ 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).
|
||||
**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")
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user