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:
Yaojia Wang
2026-07-30 15:57:41 +02:00
parent 9114630c3a
commit 284cfd193a
70 changed files with 10271 additions and 358 deletions

View File

@@ -4,7 +4,7 @@ A self-hosted, browser-based terminal **and** Claude-Code session/project workbe
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the scrollback replays. The server is a **byte-shuttle** — it ferries raw bytes between the shell and the browser and never parses terminal/ANSI semantics; xterm.js renders, node-pty provides the TTY.
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only by design; the optional `WEBTERM_TOKEN` access token raises the bar but is **not** a substitute for TLS/Tailscale. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
---
@@ -42,7 +42,8 @@ On a large screen (≥ 1024px) the terminal area can split into a grid so severa
### Projects (v0.6)
- **Auto-discovered git repos** — scans configurable roots for `.git`, showing each repo's branch and dirty state. Read-only, cached, with a depth-bounded BFS that skips `node_modules`/dotdirs/symlinks.
- **Per-project launchers** — each card has brand-logo buttons: **Claude** and **Codex** open a new tab running that CLI in the repo; **VS Code** asks the *host* to open the editor on that path. Projects with an active Claude session highlight the Claude button (with a count).
- **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and a **create-worktree** action.
- **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and worktree **create / prune / remove**.
- **Git panel** — ambient git state on the detail page: a **sync band** (`↑ahead ↓behind`, upstream / detached-HEAD / "never fetched" states, green "in sync" only when nothing is pending *and* the last fetch is recent), a **commit log** with the unpushed boundary marked, **PR status** (via the host's `gh`, when installed), and **stage / commit / push / fetch** actions. All writes are Origin-guarded `POST /projects/git/*` routes running `git` through `execFile` (no shell) with timeouts.
### Walk-away workbench (v0.7)
- **Mobile Web Push + lock-screen triage** — the host actively notifies your phone on **needs-input** (high priority, with **Allow / Deny** action buttons) and **done** (low priority). Approve or deny a held tool request straight from the lock screen without opening the app, secured by a per-decision capability token. Optional **ntfy / Pushover** bridge for setups without HTTPS Web Push.
@@ -59,19 +60,35 @@ On a large screen (≥ 1024px) the terminal area can split into a grid so severa
## Clients
The browser is the reference client; two native clients and a remote-access
The browser is the reference client; three native clients and a remote-access
service are also in the repo (all consume the same wire protocol — the server
stays a byte-shuttle).
- **iOS / iPad app** ([`ios/`](ios/), branch `feat/ios-client`) — a native
SwiftUI + SwiftTerm **pure remote client** built for the walk-away loop:
native terminal with scrollback replay, QR/manual pairing (Keychain), the
remote **Approve / Reject** gate + three-way plan gate, **APNs push with
lock-screen Allow / Deny behind Face ID**, deep links, Projects, multi-session
switcher, timeline, diff, quick-reply, and an **adaptive iPhone/iPad split
layout**. Looks like the desktop (amber-gold, dark-first). P0 needs zero server
changes; P1 adds two declared additive touch-points. See
[`ios/README.md`](ios/README.md). *(Not yet merged.)*
- **iOS / iPad app** ([`ios/`](ios/)) — a native SwiftUI + SwiftTerm **pure
remote client** built for the walk-away loop: native terminal with scrollback
replay, QR/manual pairing (Keychain), the remote **Approve / Reject** gate +
three-way plan gate, **APNs push with lock-screen Allow / Deny behind Face ID**,
deep links (including the web's `?join=` share link), Projects with a **git
panel + worktree lifecycle**, `claude --resume` history, multi-session switcher,
timeline, diff, quick-reply, **terminal find bar**, **voice push-to-talk with a
confirm step**, theme (system/dark/light) + Dynamic Type, and an **adaptive
iPhone/iPad split layout**. Supports the optional `WEBTERM_TOKEN` access token
(per-host, in the Keychain). Looks like the desktop (amber-gold, dark-first).
P0 needed zero server changes; P1 added two declared additive touch-points.
**Merged into `develop`.** See [`ios/README.md`](ios/README.md).
- **Android app** ([`android/`](android/)) — a Kotlin/Compose client targeting
functional parity with iOS, module-for-module mirroring the iOS package set
(`:wire-protocol` / `:session-core` / `:api-client` / `:client-tls` /
`:host-registry` / `:terminal-view` / `:app`). Same wire protocol, same
Origin-iff-guarded discipline, same **`WEBTERM_TOKEN`** contract (hand-written
cookie, Keystore-backed storage), plus FCM push with lock-screen Allow / Deny,
Projects, timeline, diff, quick-reply and an adaptive layout. Terminal
rendering wraps the Apache-2.0 **Termux** terminal view. All modules build and
unit-test locally (`./gradlew test :app:assembleDebug koverVerify`) and the app
has been installed and launched on an emulator; **real-device QA is still
pending**. Design in
[`docs/ANDROID_CLIENT_PLAN.md`](docs/ANDROID_CLIENT_PLAN.md), setup notes in
[`android/README.md`](android/README.md).
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
embeds this Node server + node-pty** (all-in-one), for native notifications,
tray, deep links and launch-at-login. Design in
@@ -146,6 +163,7 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
| `MAX_MSGS_PER_SEC` | `2000` | Per-connection WS frame-rate cap; over-limit frames are dropped (not disconnected). |
| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH. |
| `ALLOWED_ORIGINS` | (derived) | Extra allowed WS origins, comma-separated. The base list is derived from the host's NIC IPs + localhost — never from `BIND_HOST`. |
| `WEBTERM_TOKEN` | (unset → auth **disabled**, **secret**) | Optional shared access token gating every HTTP route + the WS upgrade behind a `webterm_auth` cookie (alongside, never instead of, the Origin check). 16512 chars of `[A-Za-z0-9._~+/=-]` or the server refuses to start. Never logged. See [Security & deployment](#security--deployment) for the honest limits. |
| `PERM_TIMEOUT_MS` | `300000` (5 min) | How long a held tool-permission request waits for a remote decision before falling back to Claude's own prompt. Must be > 0. |
| `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. |
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
@@ -191,14 +209,15 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
## Security & deployment
This is a **no-auth, LAN-only** tool by design — it hands a full shell to anyone who can reach the port. The defenses that matter:
This is a **LAN-only** tool by design — with no token set it hands a full shell to anyone who can reach the port, and even with one set it is not an internet-facing service. The defenses that matter:
- **WS Origin validation (cannot be skipped):** the WebSocket handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://<your-lan-ip>:3000`. Only `WS_PATH` is accepted for upgrade.
- **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
- **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree` + `/worktree/prune` + `DELETE /projects/worktree`, `POST /projects/git/{stage,commit,push,fetch}`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
- **Loopback-only hook ingest:** the side-channel ingest endpoints (`/hook`, `/hook/permission`, `/hook/status`) only accept loopback peers — the Claude process always runs on the host.
- **Per-IP rate limits** on push subscribe and lock-screen decision routes; a per-connection WS frame-rate cap.
- **Safe git exec + per-decision capability tokens:** all `git` calls use `execFile` (no shell) with timeouts and path validation; the lock-screen Allow/Deny is authorized by a token bound to that session's current pending request (it expires on resolve/timeout).
- **No authentication yet.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge.
- **Optional shared access token (`WEBTERM_TOKEN`) — a bar-raiser, not authentication.** Unset ⇒ the gate is **disabled** and behaviour is byte-identical to a no-auth server (LAN zero-config preserved). Set (16512 chars of `[A-Za-z0-9._~+/=-]`, else the server refuses to start) ⇒ every HTTP route **and** the WS upgrade require an `HttpOnly; SameSite=Strict` `webterm_auth` cookie, delivered once by `GET /?token=<t>` or `POST /auth`; the loopback hook ingest (`/hook*`) stays exempt so the Claude Code side-channel keeps working. It sits *in front of* the Origin check and never replaces it. **Honest limits:** on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it is one shared secret with no per-user identity and no revocation short of changing the env var and restarting. It meaningfully hardens only the TLS-terminated relay/tunnel path. See `src/http/auth.ts` and [`docs/plans/w5-access-token.md`](docs/plans/w5-access-token.md).
- **Beyond that, there is no authentication.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` — the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge.
---

View File

@@ -37,10 +37,13 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
| `URLSession*Transport` | `:transport-okhttp` | pure Kotlin/JVM (OkHttp) | ✅ built |
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
> impls, JVM) is owned by task **A7** and will be added then. The iOS
> `URLSession*Transport`s consolidate into it (plan §3 framing note).
> `:transport-okhttp` (A7) holds the OkHttp `TermTransport`/`HttpTransport`
> implementations that the two iOS `URLSession*Transport`s consolidate into
> (plan §3 framing note). OkHttp is a plain JVM library, so the module builds and
> unit-tests (MockWebServer) with **no** Android SDK — including the
> access-token cookie on the WS upgrade (`OkHttpAccessTokenTest`).
### Dependency graph (arrows = "depends on")
@@ -121,8 +124,29 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`).
```
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
> small focused files — same discipline as the rest of the repo.
> `:session-core`, `:api-client`, `:client-tls` pure half — the four that apply
> the Kover plugin). TDD, immutable data, small focused files — same discipline as
> the rest of the repo.
### Status & what is NOT verified here
- **Green gate**: `./gradlew test :app:assembleDebug koverVerify` — JVM unit
tests, a debug APK, and the 80 % coverage floor on the four gated modules.
- **Emulator**: the app has been built, installed and launched on an
android-35 arm64 emulator (pairing screen rendered, no crash). See the
`PROGRESS_LOG.md` entry for that run.
- **DEFERRED — real device**: hardware-backed keys (StrongBox falls back to
software keys on an emulator), FCM push delivery and the lock-screen
Allow/Deny walkthrough, and general hand-on-glass QA.
- **DEFERRED — instrumented tests**: everything under `src/androidTest/`
(`TinkAccessTokenStoreTest`, DataStore stores, AndroidKeyStore importer,
hardware-key checks) needs a connected device/emulator and is therefore **not**
part of the `./gradlew test` gate; run them with `connectedAndroidTest` on a
booted emulator.
- **DEFERRED — end-to-end access token**: the `WEBTERM_TOKEN` path is covered by
unit tests (`OkHttpAccessTokenTest`, the `:api-client` route tests, the
`AuthCookie` derivation tests), but no automated leg here starts a real server
with `WEBTERM_TOKEN` set and completes a cookie-gated WS upgrade.
## Android SDK setup (proven working)

View File

@@ -41,8 +41,9 @@ import java.util.UUID
* **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is
* stamped as `Cookie: webterm_auth=<t>` on EVERY route (RO included) inside the same single point that
* stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" —
* an unauthenticated LAN host behaves exactly as before. A **401** from any route becomes the typed
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token.
* an unauthenticated LAN host behaves exactly as before. A **401** becomes the typed
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token — except on the
* routes that define their own 401 ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write family).
*/
public class ApiClient(
public val endpoint: HostEndpoint,
@@ -260,14 +261,23 @@ public class ApiClient(
/**
* The ONE dispatch point: stamp Origin (iff guarded) + the auth cookie (iff a token is stored),
* send, and translate a **401** into the typed [ApiClientError.Unauthorized] BEFORE any per-route
* status mapping — otherwise a guarded git write would degrade a 401 into a generic `Rejected`
* outcome and the UI would never learn to ask for the token (ios-completion §1.1).
* status mapping — otherwise a 401 on a read route would degrade into a generic status error and
* the UI would never learn to ask for the token (ios-completion §1.1).
*
* The one exception is declared AT the route ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write
* family): there a 401 is the server classifying a HOST-side git credential failure
* (`src/http/git-ops.ts:108`), so it must flow on to [gitWrite]'s mapping and reach the user as the
* server's own message. Swallowing it would tell the user to rotate an access token that is fine.
*/
private suspend fun perform(route: ApiRoute): HttpResponse {
val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint))
?: throw ApiClientError.InvalidRequest
val response = http.send(request)
if (response.status == HttpStatus.UNAUTHORIZED) throw ApiClientError.Unauthorized
if (response.status == HttpStatus.UNAUTHORIZED &&
route.unauthorizedPolicy == UnauthorizedPolicy.ACCESS_TOKEN_GATE
) {
throw ApiClientError.Unauthorized
}
return response
}

View File

@@ -21,9 +21,11 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
/**
* **401** from ANY route (RO or guarded): this host has `WEBTERM_TOKEN` set and our request carried
* no cookie / a wrong one (ios-completion §1.1). Distinct from [Forbidden] (that is the Origin
* guard) — the UI must send the user to enter or fix the host's access token.
* **401** from a route whose 401 can only be the access-token gate (RO or guarded): this host has
* `WEBTERM_TOKEN` set and our request carried no cookie / a wrong one (ios-completion §1.1).
* Distinct from [Forbidden] (that is the Origin guard) — the UI must send the user to enter or fix
* the host's access token. The git-write family is excluded (it defines its own 401 — see
* [UnauthorizedPolicy]), so this copy can never be shown for a host-side git credential failure.
*/
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")

View File

@@ -52,6 +52,23 @@ internal enum class OriginPolicy {
GUARDED,
}
/**
* How a **401** on this route must be READ (ios-completion §1.1) — declared at the route so the
* exception is visible instead of hidden in a call site. The Android analogue of iOS
* `UnauthorizedPolicy` (APIClient/Endpoints.swift).
*/
internal enum class UnauthorizedPolicy {
/** Default — on this route a 401 can only be the access-token gate (`src/server.ts:465`). */
ACCESS_TOKEN_GATE,
/**
* The route owns its 401 and it must NOT be read as "your access token is wrong": the git-write
* family, where the server classifies a HOST-side git credential failure as
* `{status:401, error:"Push authentication required on the host."}` (`src/http/git-ops.ts:108`).
*/
ROUTE_DEFINED,
}
/**
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
@@ -65,6 +82,8 @@ internal class ApiRoute(
val percentEncodedQuery: String? = null,
/** Explicit `Accept` when the server's behaviour depends on it (the `/auth` probe). */
val accept: String? = null,
/** See [UnauthorizedPolicy]. Defaults to the gate reading. */
val unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
) {
/**
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the

View File

@@ -179,7 +179,14 @@ internal object Endpoints {
fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
/**
* Build a GUARDED git-write route with a `ModelJson`-encoded JSON body (Origin stamped in
* [ApiRoute]).
*
* Every route built here is [UnauthorizedPolicy.ROUTE_DEFINED]: the server classifies a HOST-side
* git credential failure as **401** with its own message (`src/http/git-ops.ts:108`), which the UI
* must display verbatim instead of the access-token copy (mirrors iOS `gitWriteRoute`).
*/
private fun <T> jsonBodyRoute(
method: HttpMethod,
path: String,
@@ -187,7 +194,13 @@ internal object Endpoints {
value: T,
): ApiRoute {
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
return ApiRoute(
method,
path,
OriginPolicy.GUARDED,
body = body,
unauthorizedPolicy = UnauthorizedPolicy.ROUTE_DEFINED,
)
}
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */

View File

@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.AccessTokenSource
@@ -20,8 +21,10 @@ import java.util.UUID
* - it is **orthogonal to `Origin`**: a RO route carries the cookie and still NO Origin (the
* Origin-iff-guarded 铁律 is untouched);
* - no token configured ⇒ NO `Cookie` header at all (zero-config LAN behaviour is byte-identical);
* - a **401** on any RO/G route is the typed [ApiClientError.Unauthorized], never a generic status
* error, so the UI can route the user to re-enter the token.
* - a **401** on a route whose 401 can only be the gate is the typed [ApiClientError.Unauthorized],
* never a generic status error, so the UI can route the user to re-enter the token — while the
* git-write family, which defines its own 401 (`src/http/git-ops.ts:108`), keeps the server's
* message (E1 fix; see the rewritten push case below).
*/
class AccessTokenCookieTest {
private companion object {
@@ -131,10 +134,50 @@ class AccessTokenCookieTest {
)
}
/**
* E1 · REWRITTEN (this case previously asserted the opposite and pinned a real defect).
*
* `POST /projects/git/push` defines its OWN 401: `src/http/git-ops.ts:108` classifies a HOST-side
* git credential failure ("could not read Username", "permission denied (publickey)", …) as
* `{status:401, error:"Push authentication required on the host."}`. Swallowing that into
* [ApiClientError.Unauthorized] tells the user their *access token* is broken and sends them to
* rotate a secret that is fine, while hiding the real cause. iOS gets this right via
* `unauthorizedPolicy: .routeDefined` (APIClient/GitWrite.swift), and plan §4 item 49 requires the
* distinction — so the route's own message must reach [GitWriteOutcome.Rejected] verbatim.
*/
@Test
fun `a 401 on a guarded git write throws Unauthorized instead of a Rejected outcome`() = runTest {
fun `a 401 on git push is the host's own git-credential failure, surfaced verbatim`() = runTest {
transport.queueSuccess(
method = HttpMethod.POST,
url = "$BASE/projects/git/push",
status = 401,
body = """{"ok":false,"error":"Push authentication required on the host."}""".toByteArray(),
)
val outcome = clientWithToken().gitPush("/repo")
assertEquals(GitWriteOutcome.Rejected(401, "Push authentication required on the host."), outcome)
}
@Test
fun `the route-defined 401 covers the whole git-write family, not just push`() = runTest {
val body = """{"ok":false,"error":"Push authentication required on the host."}""".toByteArray()
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", status = 401, body = body)
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", status = 401, body = body)
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", status = 401, body = body)
val client = clientWithToken()
assertEquals(401, (client.gitStage("/repo", listOf("a.txt"), true) as GitWriteOutcome.Rejected).status)
assertEquals(401, (client.gitCommit("/repo", "msg") as GitWriteOutcome.Rejected).status)
assertEquals(401, (client.createWorktree("/repo", "feat/x") as GitWriteOutcome.Rejected).status)
}
@Test
fun `a git write with no server message still reports the status, never the token copy`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 401)
assertEquals(ApiClientError.Unauthorized, errorOf { clientWithoutToken().gitPush("/repo") })
val outcome = clientWithoutToken().gitPush("/repo")
assertEquals(GitWriteOutcome.Rejected(401, null), outcome)
}
}

View File

@@ -158,4 +158,31 @@ class GitRouteShapeTest {
client.gitPush("/r")
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
}
/**
* E1 · The 401 split declared AT the route (mirrors iOS `UnauthorizedPolicy`): the six git writes
* own their 401 (`src/http/git-ops.ts:108` — a HOST-side git credential failure), everything else
* can only be answered 401 by the access-token gate (`src/server.ts:465`).
*/
@Test
fun `only the git-write family declares the route-defined 401 policy`() {
val gitWrites = listOf(
Endpoints.createWorktree("/r", "b", null),
Endpoints.removeWorktree("/r", "/r/x", false),
Endpoints.pruneWorktrees("/r"),
Endpoints.gitStage("/r", listOf("f"), true),
Endpoints.gitCommit("/r", "m"),
Endpoints.gitPush("/r"),
)
val gateRoutes = listOf(
Endpoints.liveSessions(),
Endpoints.projects(),
Endpoints.projectLog("/r", null),
Endpoints.killSession(UUID.randomUUID()),
Endpoints.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()),
)
assertTrue(gitWrites.all { it.unauthorizedPolicy == UnauthorizedPolicy.ROUTE_DEFINED })
assertTrue(gateRoutes.all { it.unauthorizedPolicy == UnauthorizedPolicy.ACCESS_TOKEN_GATE })
}
}

View File

@@ -2,7 +2,10 @@
> 落地方案文档。目标:给 web-terminal 做一个 **iPhone 原生 App**,把 vibe-coding 的"走开—被叫回—两次手势处理完"闭环装进口袋。
> 拓扑/框架选型:**Phased-Native —— SwiftUI + SwiftTerm4 个纯 SwiftPM 包 + 薄 App 胶水;前台会话单条活 WS其余 HTTP 轮询服务器零改动P0 零触点P1 仅声明的附加触点,见 §0.3**。
> 状态:**规划中2026-07-04未开工**。
> 状态:**已交付并合入 `develop`**P0 + P1 + P2 全部落地;`feat/ios-client` 早已 merge 进 `develop`
> `git merge-base --is-ancestor feat/ios-client develop` 为真,勿再按"分支未合"叙述)。
> 逐任务状态见 **§7 开头的状态总表**2026-07-30 由 ios-completion 收尾波按源码核对重建);
> 真机 / 付费 Apple 账号相关项一律 **DEFERRED** 并在表中标明。
> 本文是「怎么做」的蓝图,配合 [TECH_DOC.md](./TECH_DOC.md)why+ [ARCHITECTURE.md](./ARCHITECTURE.md)how桌面版先例见 [DESKTOP_PLAN.md](./DESKTOP_PLAN.md);远程访问/中继演进见 [PLAN_RELAY_INDEX.md](./PLAN_RELAY_INDEX.md)。
> 完成情况记录在 [PROGRESS_LOG.md](./PROGRESS_LOG.md)。工作流约束见 [CLAUDE.md](../CLAUDE.md)(查 PLAN → 做子任务(TDD) → 验证 → 更新 LOG
> **G1 日志铁律**`PROGRESS_LOG.md` 由 **orchestrator 独写**,不在任何任务的 `Owns:` 里;被派的 subagent **不写 LOG**,而是在最终返回消息末尾附上**可直接粘贴的日志条目**(状态 / 改动文件与函数 / 验证命令+结果 / 决策与偏差 / 阻塞 / 下一步),由主会话统一追加。
@@ -62,6 +65,11 @@
除此之外**服务器 byte-for-byte 零改动**。若实施中发现 server 侧缺陷修复归属对应模块route/session 文件的 owner按 CLAUDE.md 记 `PROGRESS_LOG.md`——iOS 任务不越界改 server。
> **补记ios-completion 收尾波)**:客户端另外开始消费**早已存在**的两处服务器能力,**均无新触点**——
> ① 访问令牌门(`POST /auth` + `webterm_auth` cookiew5-access-token真源 `src/http/auth.ts`
> ② 项目 git 面板/worktree 的 13 个 `/projects*` 路由w4/w6真源 `src/http/projects.ts` + `src/server.ts:507-1320`)。
> 二者都是"服务器先有、iOS 后接",故 §0.3 的"P0 零触点 / P1 两触点"结论不变。
---
## 1. 整体架构 / 进程模型
@@ -156,6 +164,11 @@ web-terminal/
└── LiveServerTests.swift
```
> **树的现状差异2026-07-30 核对,不改本计划的设计意图)**`Packages/` 下现有 **6** 个包——本文的 4 个 gated 包
> + `TestSupport` + **`ClientTLS`**(设备客户端证书/mTLS由 [PLAN_NATIVE_TUNNEL.md](./PLAN_NATIVE_TUNNEL.md) 引入,
> 不属于本计划)。`App/WebTerm/` 也比本文的示意树多出 `DesignSystem/`、`Wiring/`、`Push/` 三个目录
> (分别来自 UX 打磨、iPad 适配/接线、P1 推送)。依赖方向仍严格单向向下,`WireProtocol` 仍是唯一冻结契约。
**构建管线**(本地与 CI 同路径):
1. `brew install xcodegen && cd ios && xcodegen generate``WebTerm.xcodeproj`(不入库)。
2. 各包独立测试:`swift test --package-path ios/Packages/<Pkg>`(纯 mac 侧,无模拟器,秒级)。
@@ -420,13 +433,19 @@ NSCameraUsageDescription: "扫描 web 终端的配对二维码" # P0 必需:T-
```
> P2 前置T-iOS-31语音 PTT开工前需另加 `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`(属于该任务的前置,不属于 P0
> **已落地**ios-completion 收尾波 A1`project.yml` 是单一 owner故一次性加齐两条 usage description +
> `UIBackgroundModes: [remote-notification]`(背景**模式**不是 capability免费 team 不受限;受限的是
> `aps-environment` **entitlement**,故它走 `WEBTERM_PUSH_ENTITLEMENTS` 开关)。**产物层复核仍归 T-iOS-19尚未做。**
- MagicDNS 名(`*.ts.net`)是 FQDN → ATS 全额适用 → 用 100.x IP、加例外域`tailscale serve`https/wss最优
- Local Network 弹窗被拒 → 连接报 POSIX "Network is down";映射到 `PairingError.localNetworkDenied` 并引导去 设置→隐私→本地网络iOS 18 有需重启的已知 bug话术里写明
### 5.3 凭据与本地存储
- **Keychain**host 列表(含未来 authMaterial 占位)——`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`,不进 iCloud 同步。
- **Keychain**host 列表 **+ 每主机访问令牌**`WEBTERM_TOKEN`,原"未来 authMaterial 占位"已由 ios-completion
收尾波填实)——`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`、**无** `kSecAttrSynchronizable`(不进 iCloud 同步、
不随备份离机,`SecItemShim.swift:77-86`)。`AccessToken` 类型在边界校验字符集/长度,`description`/`debugDescription`/
`customMirror` 全部脱敏,且**故意不 `Codable`** —— 插值、`dump()`、反射式崩溃上报都拿不到它;令牌绝不进日志、绝不进 URL query。
- **UserDefaults**仅非机密per-host lastSessionId、UI prefs
- `/hook/decision` 的 capability `token` **只经 push payload 到达、用后即弃**(服务器侧本就单次有效+过期src/server.ts:503-525限频 10/min/IPApp 端绝不落盘。
- App 内无任何硬编码 host/密钥;首个 host 必经配对流程。
@@ -490,15 +509,95 @@ W5 验收(report-only, 并行)
## 7. 任务清单
> 状态图例:`[ ]` TODO · `[~]` 进行中 · `[x]` 完成 · `[!]` 受阻。ID 稳定,永不重编号。
> 状态图例:`[ ]` TODO · `[~]` **部分完成**(下一行必写缺口)· `[x]` 完成 · `[!]` 受阻。ID 稳定,永不重编号。
> **`[x]` 的判据(本文统一约定)**:代码+测试已交付,且**本环境可机器验证的部分全绿**;需要真机 / 付费 Apple 账号
> 才能做的验收项,在该行标 **DEFERRED** 而**不**把任务降级为 `[~]`(否则每条都是"部分",完成度又读不出来)。
> `[~]` 只留给**在本环境本可做却没做**的缺口,或本身就是真机走查的任务。
> 每个任务自带 TDD测试与实现同 agent 同文件组。测试框架Swift Testing`@Test`/`#expect`XCTest 仅 XCUITest。
> 覆盖率验证命令(各包):`swift test --package-path ios/Packages/<Pkg> --enable-code-coverage` + `xcrun llvm-cov report`(阈值 80%,见 §9
> 覆盖率验证命令(各包):`swift test --package-path ios/Packages/<Pkg> --enable-code-coverage` + `xcrun llvm-cov report`(阈值 80%,见 §9
> 实际 CI 用的是修正口径脚本 `ios/IntegrationTests/scripts/coverage-gate.sh <Pkg>`。
>
> **权威状态在下面这张总表**2026-07-30 ios-completion 收尾波按**源码**逐项核对重建)。
> 各任务标题上的方框与总表一致;**Steps 里的 `[ ]` 是原始规格清单,不是状态**——逐条执行记录在
> `PROGRESS_LOG.md` 对应条目里,不在本文重复勾选(此前"任务已交付但满屏 `[ ]`"正是完成度读不出来的原因)。
### 7.0 状态总表(逐项对源码核对)
| ID | 状态 | 证据(文件 / 测试)与缺口 |
|---|---|---|
| T-iOS-1 脚手架 | `[x]` | `ios/project.yml``ios/.gitignore`、6 个 `Package.swift``.github/workflows/ios.yml` |
| T-iOS-2 Day-1 双 spike | `[x]` | 四条自动化断言**已常驻化**`IntegrationTests/OriginGuardTests.swift`(3) + `ReplayTests.swift`(2含 ESC/C0 对抗)。Owns 的临时文件 `OriginSpikeTests`/`SpikeTerminalScreen` 已按计划吸收/删除(树中不存在)。**DEFERRED**:真机键盘/IME/选择 smoke |
| T-iOS-3 WireProtocol 契约 | `[x]` | `Packages/WireProtocol/Sources` 10 文件 + **59** `@Test` |
| T-iOS-4 TestSupport | `[x]` | `FakeTransport`/`FakeClock`/`FakeHTTPTransport` + 3 `@Test` |
| T-iOS-5 Reconnect+Ping | `[x]` | `SessionCore/{ReconnectMachine,PingScheduler}.swift` + 对应 Tests |
| T-iOS-6 Gate+Digest | `[x]` | `SessionCore/{GateState,AwayDigest}.swift` + 对应 Tests |
| T-iOS-7 HostRegistry | `[x]` | 包内 8 源文件(含 `SecItemShim`/`InMemoryHostStore`+ **73** `@Test`;覆盖率 92.49%commit `850531f` |
| T-iOS-8 APIClient + 探针 | `[x]` | `PairingProbe`/`Endpoints`/`Models` 等 + 包内共 **125** `@Test`;覆盖率 92.22% |
| T-iOS-9 URLSessionTermTransport | `[x]` | `SessionCore/URLSessionTermTransport.swift` + Tests`ScriptedWSServer` |
| T-iOS-10 SessionEngine | `[x]` | `SessionCore/{SessionEngine,SessionEvent}.swift` + `SessionEngineTests`;包共 **108** `@Test`,覆盖率 96.74% |
| T-iOS-11 Terminal+KeyBar | `[x]` | `Screens/TerminalScreen.swift``Components/{KeyBar,ReconnectBanner}.swift``SessionCore/KeyByteMap.swift` + `KeyBarTests`/`TerminalViewModelTests` |
| T-iOS-12 Pairing | `[x]` | `Screens/PairingScreen.swift`+`PairingCopy.swift``ViewModels/PairingViewModel.swift` + `PairingViewModelTests`(本波再加令牌态,见 §7.1 |
| T-iOS-13 SessionList | `[x]` | `Screens/SessionListScreen.swift``Components/TelemetryChips.swift``ViewModels/SessionListViewModel.swift` + Tests |
| T-iOS-14 Gate/Digest UI | `[x]` | `Components/{GateBanner,PlanGateSheet,AwayDigestView}.swift``ViewModels/GateViewModel.swift` + `GateViewModelTests` |
| T-iOS-15 App 接线+生命周期 | `[x]` | `Wiring/{RootView,AppCoordinator,PrivacyShade,ColdStartPolicy,TerminalContainerView}.swift` + `PrivacyShadeTests`/`ColdStartPolicyTests` |
| T-iOS-16 集成 CI | `[x]` | `IntegrationTests/**` **10** `@Test` + `scripts/coverage-gate.sh` + `ios.yml` 六个 job含 iPad 单测腿/iPad UI 腿/iOS-17 底线腿)。**未核实**GH Actions 平台侧的运行结果(本环境 `gh` 未登录) |
| T-iOS-17 ntfy 桥验证+文档 | `[x]` | `ios/README.md` ntfy 章节(逐条引 `setup-hooks.mjs` 行号,只读验证,未动用户 hook 配置)。**DEFERRED**:手机端到端 |
| T-iOS-18 F 走查(真机) | `[~]` | 机器可执行项已执行并指认测试名P0 收官条目)。**缺口**F-iOS 的真机项QR 扫码/IME/震动/切换器遮罩目检/ntfy 端到端)仍 DEFERRED手工清单在 LOG |
| T-iOS-19 安全核对 | `[~]` | P0 面已逐条核Origin 单点、G/RO 分界、五段 CIDR + 零 ArbitraryLoads、Keychain 属性)。**缺口**P2 新增的 `NSMicrophoneUsageDescription`/`NSSpeechRecognitionUsageDescription` 尚未在**产物层**复核release ipa 层核对仍未做(免费 team 无分发通道) |
| T-iOS-20 server APNs | `[x]` | `src/push/apns.ts` + `test/push-apns.test.ts`(含本地 h2c 假 APNs 的双 e2e具体测试数以 `npm test` 输出为准LOG 记 65。**DEFERRED**:真机端到端需付费账号 |
| T-iOS-37 server lastOutputAt | `[x]` | `src/types.ts:315` 可选字段 + `src/session/manager.ts:197` 映射 + 测试 |
| T-iOS-38 APIClient P1 契约 | `[x]` | `APIClient/{ApnsToken,Projects,Prefs}.swift` + `ApnsTokenTests`/`ProjectsTests`/`PrefsRoundTripTests` |
| T-iOS-21 PushRegistrar + 锁屏 | `[x]` | `Push/{PushRegistrar,NotificationActionHandler}.swift` + `PushRegistrarTests`/`NotificationActionHandlerTests`/`NotificationActionParseTests`。**DEFERRED**:真机锁屏 Allow + Face ID`aps-environment` 现为 env 开关(免费 team 开不了,见 `ios/README.md` |
| T-iOS-22 DeepLinkRouter | `[x]` | `DeepLinkRouter.swift` + `DeepLinkRouterTests` |
| T-iOS-23 多会话切换器 | `[x]` | `SessionCore/{UnreadLedger,TitleSanitizer}.swift` + `Wiring/UnreadWatermarkStore.swift` + `SessionSwitcherTests`/`UnreadLedgerTests`/`TitleSanitizerTests` |
| T-iOS-24 Timeline sheet | `[x]` | `Screens/TimelineSheet.swift``ViewModels/TimelineViewModel.swift` + `TimelineSheetTests` |
| T-iOS-25 Quick-reply | `[x]` | `Components/{QuickReply,QuickReplyStore}.swift` + `QuickReplyTests` |
| T-iOS-26 Projects | `[x]` | `Screens/{ProjectsScreen,ProjectDetailScreen}.swift``ViewModels/{ProjectsViewModel,ProjectDetailViewModel,ProjectGrouping}.swift` + 3 组 Tests |
| T-iOS-27 Diff 查看器 | `[x]` | `Screens/DiffScreen.swift``ViewModels/DiffViewModel.swift``DiffFetcher.swift` + `DiffViewModelTests`/`DiffFetcherTests` |
| T-iOS-28 会话缩略图 | `[x]` | `Components/{SessionThumbnail,SessionThumbnailRenderer}.swift` + `SessionThumbnailTests` |
| T-iOS-29 new-in-cwd + 退出清理 | `[x]` | `TerminalScreen`/`TerminalContainerView` 增量 + `NewSessionInCwdTests` |
| T-iOS-30 P1 验收+安全复核 | `[x]` | report-only 双 PASS 零 findingsLOG 条目)。**DEFERRED**:真机锁屏/unread 目视等,手工清单在 LOG |
| T-iOS-31 语音 PTT | `[x]` | `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 🎤 键 + `VoicePTTTests` **40** `@Test`。**DEFERRED**:真机口述→确认→注入 |
| T-iOS-32 Worktree + `--resume` 历史 | `[~]` | `Screens/{WorktreeSheet,ResumeHistorySheet}.swift``ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift` + `WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)。**缺口**Accept 的"端到端一次"(对真服务器真建/真删 worktree无自动化腿、本环境也未手工跑 |
| T-iOS-33 终端内搜索 | `[x]` | `Components/TerminalSearchBar.swift` + `TerminalScreen` 绑定 SwiftTerm 搜索 API + `TerminalSearchTests` **18**(含 2 条真 view 高亮断言) |
| T-iOS-34 主题 + Dynamic Type | `[~]` | `DesignSystem/{AppTheme,TerminalPalette}.swift``Screens/SettingsScreen.swift``Tokens/Typography` 浅色档 + `AppThemeTests`(24)/`DynamicTypeLayoutTests`(8)。**缺口(已实测、未修)**AX5 下键栏需 ~108.24pt 而 `KeyBarMetrics.barHeight` 固定 52pt → 键帽裁切,以 `withKnownIssue` 钉住(`KeyBar.swift` 不在该任务 Owns |
| T-iOS-35 web `?join=` 互通 | `[x]` | `DeepLinkRouter.swift``.joinShared` 分支 + `DeepLinkJoinTests` **19**(含改写后的既有拒绝用例) |
| T-iOS-36 P2 验收 | `[~]` | 本波 Wave D 的验收 agent 正在执行;**结果与真实数字以 `PROGRESS_LOG.md` 的该条目为准**,本文不预写结论 |
**说明**:上表的"测试数"是 `@Test` **声明**静态计数(`grep -rh '@Test'`),与 commit `850531f`/`a5fa843` 里实跑数字一致;
参数化用例实跑会更多。App bundle 侧共 **532**`@Test` 声明。
### 7.1 ios-completion 收尾波2026-07-29/30新增能力 —— 无既有任务 ID
审计出的缺口用一波收尾波补齐,这些交付**不属于**上表任何 ID不新编 `T-iOS-*` 号,避免与冻结 ID 冲突):
- **访问令牌(`WEBTERM_TOKEN`)两端接入**iOS`APIClient/AccessToken.swift``HostRegistry/AccessToken.swift`
`SessionCore/AuthCookie.swift``Wiring/URLSessionHTTPTransport.swift`、配对页令牌态)与 Android
`AuthCookie`/`AccessTokenStore`/OkHttp 侧)走**同一冻结契约**`docs/plans/ios-completion.md` §1.1
手写 `Cookie: webterm_auth=<t>``POST /auth` 四态、Keychain/Keystore 存储、WS 401 = 终态不进退避环。
服务端真源 `src/http/auth.ts` / `src/server.ts:394-420`。**诚实边界照抄服务端注释**:抬高门槛≠替代 TLS/Tailscale。
- **项目 git 面板 + worktree 生命周期iOS**`Screens/{GitPanelScreen,GitPanelViews}.swift`
`ViewModels/{GitPanelPresentation,GitPanelViewModel}.swift` + `GitPanelPresentationTests`(19)/`GitPanelViewModelTests`(15)
消费的 13 个 `/projects*` 端点**逐条对齐 `src/server.ts` 实现**core 核对:`/projects/log``/projects/pr`
`/projects/worktree/state``git/{stage,commit,push,fetch}``worktree` 建/删/prune、`GET /sessions`
与 Android 参考实现无不一致。
- **主机移除通路**`PairingViewModel` 的已配对主机管理 + `PushRegistrar.handleHostRemoved` 从死钩子变成真调用点
`Wiring/AppEnvironment.swift:212-231`+ `HostRemovalTests`(6)。
- **ClientTLS 纳入覆盖率门**48→84 测、55.76%→89.49%`coverage-gate.sh` 的门集从 4 包扩到 5 包commit `a5fa843`)。
- **CI 三条死腿修复**app/iPad 腿缺 `npm ci` 导致 `LiveServerSmokeTests` 硬失败iOS-17 腿在缺 runtime 时静默报绿;
另补 iPad UI-test 腿(同 commit
- **签名解锁**`DEVELOPMENT_TEAM` + target 级 `CODE_SIGN_IDENTITY` + `WEBTERM_PUSH_ENTITLEMENTS` env 开关
commit `c4f8b5b`;真机构建在免费 team 上 `BUILD SUCCEEDED`7 天临时 profile
- **已知未闭合缺口(供后续任务领走)**:① AX5 键栏裁切(见 T-iOS-34 行);② **无端到端令牌腿**——
`IntegrationTests` 与模拟器内 live-server smoke 都不带 `WEBTERM_TOKEN` 起服务器grep 无 `WEBTERM_TOKEN`/`webterm_auth`
令牌路径目前只有单元级覆盖;③ T-iOS-19 的产物层复核未覆盖 P2 新增的两条 usage description
④ worktree 建/删的**端到端**一次T-iOS-32 Accept既无自动化腿也未手工跑。
### P0 — 每日可用("口袋里能开终端、能批准")· 合计 ~13 人天
#### W0 · 基础(串行)
#### T-iOS-1 · `ios/` 脚手架 + XcodeGen 工程 `[ ]` · ~0.5 pd
#### T-iOS-1 · `ios/` 脚手架 + XcodeGen 工程 `[x]` · ~0.5 pd
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/project.yml``ios/.gitignore`、5 个 `Package.swift` 空壳4 个 gated 包 + `ios/IntegrationTests`TestSupport 的 manifest 归 T-iOS-4 的 `**` glob`ios/App/WebTerm/WebTermApp.swift`空窗、CI workflow 骨架(`.github/workflows/ios.yml`
- **Depends**: 无 · **Parallel-safe**: 无(必须最先)
- **Steps**:
@@ -508,7 +607,10 @@ W5 验收(report-only, 并行)
- **Accept**: `xcodegen generate && xcodebuild … build` 通过;空 App 在模拟器启动
- **安全注**: ATS 键从本文 §5.2 逐字誊写,不许"先放开回头再收"。
#### T-iOS-2 · Day-1 双 spike评审强制`[ ]` · ~1 pd
#### T-iOS-2 · Day-1 双 spike评审强制`[x]` · ~1 pd
- **落地现状**:结论"URLSessionWebSocketTask 可发自定义 Origin"已定音(不切 Starscream四条自动化断言**已常驻化**为
`IntegrationTests/OriginGuardTests.swift`(3) + `ReplayTests.swift`(2),故 Owns 里的两个临时文件按计划**已删除/吸收**(树中不存在,不是丢失)。
**DEFERRED**:真机 smoke键盘/first-responder、中文 IME、`inputAccessoryView`、文本选择)——无真机。
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/IntegrationTests/OriginSpikeTests.swift``ios/App/WebTerm/Screens/SpikeTerminalScreen.swift`临时文件W4 删)
- **Depends**: T-iOS-1 · **Parallel-safe**: T-iOS-3
- **Steps(测试先行——spike 本身就是测试)**:
@@ -517,7 +619,7 @@ W5 验收(report-only, 并行)
- **Accept**: 4 条自动化断言绿(其中 ③ 的"复现失败"分支用 `withKnownIssue` 记录);真机清单逐项有结论
- **安全注**: 这是对 "URLSessionWebSocketTask 可发自定义 Origin"MED 置信度)的一锤定音;若失败 → `[!] BLOCKED`orchestrator 决策切 Starscream 备胎,**不得自行引入依赖**。
#### T-iOS-3 · `WireProtocol` 契约包(冻结,含共享 I/O 边界类型)`[ ]` · ~1.25 pd
#### T-iOS-3 · `WireProtocol` 契约包(冻结,含共享 I/O 边界类型)`[x]` · ~1.25 pd
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/Packages/WireProtocol/**`Sources + Tests 全部,含 `HostEndpoint/TermTransport/HTTPTransport/TimelineEvent/Tunables`
- **Depends**: T-iOS-1 · **Parallel-safe**: T-iOS-2
- **Steps(测试先行, RED)** — `Tests/WireProtocolTests/CodecRoundtripTests.swift``HostEndpointTests.swift``ServerVectorTests.swift`:
@@ -535,7 +637,7 @@ W5 验收(report-only, 并行)
- **Accept**: `swift test --package-path ios/Packages/WireProtocol` 全绿;覆盖率 ≥ 80%
- **安全注**: 服务器是不可信输入源——decode 对模糊输入永不 crash用随机字节 fuzz 一轮)。
#### T-iOS-4 · TestSupport 测试替身 `[ ]` · ~0.25 pd
#### T-iOS-4 · TestSupport 测试替身 `[x]` · ~0.25 pd
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/Packages/TestSupport/**`(含本包 `Package.swift`,仅声明对 WireProtocol 的依赖——故 W0 即可编译)
- **Depends**: T-iOS-3接口 · **Parallel-safe**: W1 全部
- **Steps**: [ ] `FakeTransport`(实现 WireProtocol 的 `TermTransport`;可手动灌帧 `emit(frame:)`/`emitError`、记录 send/close 调用)[ ] `FakeClock`(手动推进)[ ] `FakeHTTPTransport`(实现 WireProtocol 的 `HTTPTransport`,按 URL 排队响应)[ ] 各带 1 条 smoke 测试(`InMemoryHostStore` 归 T-iOS-7 的 HostRegistry 包——HostStore 协议在 W1 才存在)
@@ -543,7 +645,7 @@ W5 验收(report-only, 并行)
#### W1 · 叶子包(全部并行)
#### T-iOS-5 · `ReconnectMachine` + `PingScheduler` `[ ]` · ~0.5 pd
#### T-iOS-5 · `ReconnectMachine` + `PingScheduler` `[x]` · ~0.5 pd
- **Wave/阶段**: W1 / P0 · **Owns**: `SessionCore/Sources/…/{ReconnectMachine,PingScheduler}.swift``Tests/…/{ReconnectMachineTests,PingSchedulerTests}.swift`
- **Depends**: T-iOS-3、T-iOS-4FakeClock · **Parallel-safe**: T-iOS-6/7/8
- **Steps(测试先行, RED)**:
@@ -555,7 +657,7 @@ W5 验收(report-only, 并行)
- **Steps(实现, GREEN)**: [ ] §3.2 签名 [ ] 常量一律读 `Tunables`WireProtocolT-iOS-3 所有;值见 §3.2.1——需新增常量 → 回 T-iOS-3 改契约,无魔法数字)
- **Accept**: `swift test --package-path ios/Packages/SessionCore --filter Reconnect` 等全绿
#### T-iOS-6 · `GateState` + `AwayDigest` reducer `[ ]` · ~0.5 pd
#### T-iOS-6 · `GateState` + `AwayDigest` reducer `[x]` · ~0.5 pd
- **Wave/阶段**: W1 / P0 · **Owns**: `SessionCore/Sources/…/{GateState,AwayDigest}.swift`、对应 Tests
- **Depends**: T-iOS-3 · **Parallel-safe**: T-iOS-5/7/8
- **Steps(测试先行, RED)**:
@@ -567,7 +669,7 @@ W5 验收(report-only, 并行)
- [ ] `limit` 截断 recent空 events → 全零 digestUI 可据此不渲染)
- **Accept**: 对应 filter 全绿reducer 纯函数、不可变
#### T-iOS-7 · `HostRegistry` 包 `[ ]` · ~0.5 pd
#### T-iOS-7 · `HostRegistry` 包 `[x]` · ~0.5 pd
- **Wave/阶段**: W1 / P0 · **Owns**: `ios/Packages/HostRegistry/**`(含 `SecItemShim.swift` 与测试替身 `InMemoryHostStore.swift`——放 Sources供本包与 App 层 VM 测试 import
- **Depends**: T-iOS-3 · **Parallel-safe**: T-iOS-5/6/8
- **Steps(测试先行, RED)** — 用 InMemory 替身测协议契约Keychain 实现经 `SecItemShim` 缝测:
@@ -580,7 +682,7 @@ W5 验收(report-only, 并行)
- **Accept**: `swift test --package-path ios/Packages/HostRegistry` 全绿(覆盖率计法见 §9KeychainHostStore 以 shim 注入计入)
- **安全注**: Keychain 属性错一个字 = 凭据可被备份带走review 时对照 §5.3。
#### T-iOS-8 · `APIClient` 包 + 配对探针 `[ ]` · ~1 pd
#### T-iOS-8 · `APIClient` 包 + 配对探针 `[x]` · ~1 pd
- **Wave/阶段**: W1 / P0 · **Owns**: `ios/Packages/APIClient/**`
- **Depends**: T-iOS-3、T-iOS-4 · **Parallel-safe**: T-iOS-5/6/7
- **Steps(测试先行, RED)** — `Tests/APIClientTests/{RequestBuilderTests,PairingProbeTests,ModelDecodingTests}.swift`:
@@ -598,7 +700,7 @@ W5 验收(report-only, 并行)
#### W2 · 连接核心
#### T-iOS-9 · `URLSessionTermTransport` `[ ]` · ~1 pd
#### T-iOS-9 · `URLSessionTermTransport` `[x]` · ~1 pd
- **Wave/阶段**: W2 / P0 · **Owns**: `SessionCore/Sources/…/URLSessionTermTransport.swift``Tests/…/URLSessionTermTransportTests.swift`
- **Depends**: T-iOS-2spike 结论、T-iOS-3 · **Parallel-safe**: T-iOS-10接口并行
- **Steps(测试先行, RED)** — 对 in-process 本地 WS echo测试内起 `NWListener` 或复用 IntegrationTests 服务器):
@@ -613,7 +715,7 @@ W5 验收(report-only, 并行)
- **Accept**: filter 全绿T-iOS-16 的真服务器测试是它的最终验收
- **安全注**: Origin 单点取自 `HostEndpoint.originHeader`,本文件出现字符串拼接 origin = review CRITICAL。
#### T-iOS-10 · `SessionEngine` actor `[ ]` · ~1.5 pd
#### T-iOS-10 · `SessionEngine` actor `[x]` · ~1.5 pd
- **Wave/阶段**: W2 / P0 · **Owns**: `SessionCore/Sources/…/{SessionEngine,SessionEvent}.swift``Tests/…/SessionEngineTests.swift`
- **Depends**: T-iOS-3/4/5/6接口、T-iOS-9集成汇合 · **Parallel-safe**: T-iOS-9
- **Steps(测试先行, RED)** — 全部对 FakeTransport
@@ -633,7 +735,7 @@ W5 验收(report-only, 并行)
#### W3 · UI 胶水(全部并行;只依赖 §3 接口 + 替身)
#### T-iOS-11 · `TerminalScreen` + `KeyBar` `[ ]` · ~1 pd
#### T-iOS-11 · `TerminalScreen` + `KeyBar` `[x]` · ~1 pd
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/TerminalScreen.swift``Components/{KeyBar,ReconnectBanner}.swift``ViewModels/TerminalViewModel.swift``SessionCore/Sources/SessionCore/KeyByteMap.swift` + `SessionCore/Tests/…/KeyByteMapTests.swift`(字节表为纯数据,源与测试都在包内——本任务是 W3 唯一持有 SessionCore 文件者T-iOS-12/13/14 不碰 SessionCore、W1/W2 的 SessionCore owner 已完工,并行安全;纯数据+测试计入 SessionCore 覆盖率门,只帮不损)
- **Depends**: T-iOS-10接口 · **Parallel-safe**: T-iOS-12/13/14
- **Steps(测试先行, RED)**:
@@ -645,7 +747,7 @@ W5 验收(report-only, 并行)
- **Steps(实现, GREEN)**: [ ] `UIViewRepresentable``SwiftTerm.TerminalView`delegate `send``engine.send(.input)``sizeChanged``.resize` [ ] KeyBar 为 `inputAccessoryView`,直发 engine绕开 TerminalView 避免软键盘弹出逻辑干扰)[ ] 硬件键盘 `UIKeyCommand` 同映射 [ ] KeyBar 按钮与 `UIKeyCommand` 的标签→字节解析**一律经 `KeyByteMap` 常量**(单一事实源,镜像 `public/keybar.ts`[ ] IME不加自己的 keydown 拦截SwiftTerm 自管 composition
- **Accept**: 字节表测试全绿;模拟器人工冒烟(真机压在 T-iOS-18
#### T-iOS-12 · `PairingScreen`QR + 手输 + 探针 UI`[ ]` · ~0.5 pd
#### T-iOS-12 · `PairingScreen`QR + 手输 + 探针 UI`[x]` · ~0.5 pd
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/PairingScreen.swift``ViewModels/PairingViewModel.swift`
- **Depends**: T-iOS-7/8接口 · **Parallel-safe**: T-iOS-11/13/14
- **Steps(测试先行, RED)** — VM 层(探针逻辑已在 T-iOS-8 测过,这里测状态映射):
@@ -657,7 +759,7 @@ W5 验收(report-only, 并行)
- **Steps(实现, GREEN)**: [ ] `DataScannerViewController`(真机 only模拟器隐藏入口依赖 §5.2 `NSCameraUsageDescription`)读 web UI `qr.ts` 的 origin URL [ ] 手输表单 fallback用户自己输入的 URL 身份已知,可直连探针;复用确认态亦可)[ ] 多 host 切换入口(列表页 header 用)
- **Accept**: VM 测试全绿;模拟器手输路径可配对本机服务器
#### T-iOS-13 · `SessionListScreen`(合并 chooser + dashboard`[ ]` · ~1 pd
#### T-iOS-13 · `SessionListScreen`(合并 chooser + dashboard`[x]` · ~1 pd
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/SessionListScreen.swift``Components/TelemetryChips.swift``ViewModels/SessionListViewModel.swift`
- **Depends**: T-iOS-8接口 · **Parallel-safe**: T-iOS-11/12/14
- **Steps(测试先行, RED)** — VM 对 FakeHTTPTransport:
@@ -670,7 +772,7 @@ W5 验收(report-only, 并行)
- **Steps(实现, GREEN)**: [ ] 下拉刷新 [ ] host 切换 header [ ] 空态(无会话/未配对)
- **Accept**: VM 测试全绿
#### T-iOS-14 · `GateBanner` + `PlanGateSheet` + `AwayDigestView` `[ ]` · ~1 pd
#### T-iOS-14 · `GateBanner` + `PlanGateSheet` + `AwayDigestView` `[x]` · ~1 pd
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Components/{GateBanner,PlanGateSheet,AwayDigestView}.swift``ViewModels/GateViewModel.swift`**独立 VM**`@MainActor @Observable`,消费 SessionEvent 的 `.gate/.digest`——不是 TerminalViewModel 的扩展,与 T-iOS-11 真并行;接入 TerminalScreen 的 wiring 归 T-iOS-15+ 对应 Tests
- **Depends**: T-iOS-6/10接口 · **Parallel-safe**: T-iOS-11/12/13
- **Steps(测试先行, RED)**:
@@ -683,14 +785,14 @@ W5 验收(report-only, 并行)
#### W4 · 集成(汇合点)
#### T-iOS-15 · App 接线 + 生命周期 `[ ]` · ~0.5 pd
#### T-iOS-15 · App 接线 + 生命周期 `[x]` · ~0.5 pd
- **Wave/阶段**: W4 / P0 · **Owns**: `App/WebTerm/WebTermApp.swift`(改)、导航组装、删除 T-iOS-2 的 SpikeTerminalScreen
- **Depends**: T-iOS-1114 全部 · **Parallel-safe**: T-iOS-16/17
- **Steps**: [ ] Pairing→List→Terminal 导航 + 依赖注入(真实现 wiring`GateViewModel`T-iOS-14接入 TerminalScreen[ ] `scenePhase == .active``engine.notifyForegrounded(dims:)`(重连 + 补发 resize**这是"换设备夺回全屏"的关键**[ ] `.background` → 主动 `close()`(干净 detach不留半死 socket[ ] **隐私遮罩**`scenePhase != .active` 时终端覆盖不透明遮罩、`.active` 恢复(**必须用 `!= .active`,不能只判 `.inactive`**——覆盖切换器进入的 .inactive 与快照发生的 .background 两态iOS 后台快照会把终端内容API key/token/源码)写盘并展示在多任务切换器)[ ] 冷启动:有 lastSessionId 的 host → 列表页高亮"继续上次"
- **Accept**: 模拟器全流程手工走查配对→列表→attach→后台→前台重连回放**切后台开切换器 → 卡片显示遮罩而非终端内容**
- **安全注**: 组装点核对一次:所有 G 调用来自 APIClient无绕过、debug ATS 设置未漏进 release scheme。录屏暴露面iOS 无公开 API 把窗口排除出截屏/录屏——只可选做 `UIScreen.isCaptured` 检测(录屏时可选拉黑终端);除此之外记为已接受残余风险(本地信任模型),**不得**计划 isSecureTextEntry 层这类非受支持 hack。
#### T-iOS-16 · 集成 CI对真 Node 服务器)`[ ]` · ~0.5 pd
#### T-iOS-16 · 集成 CI对真 Node 服务器)`[x]` · ~0.5 pd
- **Wave/阶段**: W4 / P0仅依赖 W2——可提前并入第 6 批,见 §8 · **Owns**: `ios/IntegrationTests/**`(含吸收 T-iOS-2 的 OriginSpikeTests`.github/workflows/ios.yml`(改)
- **Depends**: T-iOS-9/10 · **Parallel-safe**: T-iOS-1115/17
- **Steps(测试清单)** — macOS runner`npm ci` → 临时端口 `npm start``ALLOWED_ORIGINS` 注入)→ Swift Testing
@@ -705,7 +807,8 @@ W5 验收(report-only, 并行)
- **Accept**: CI job 绿覆盖率门**演示过一次红**故意把某包压到 80% 下或抬高阈值再回绿这是"客户端复刻的协议契约"的持续防漂移闸门
- **安全注**: 本任务是 §5.1 的自动化化身任何"为了过 CI 放宽 Origin 断言"= CRITICAL
#### T-iOS-17 · ntfy 桥验证 + 文档P0 临时通知,**零新代码**`[ ]` · ~0.1 pd
#### T-iOS-17 · ntfy 桥验证 + 文档P0 临时通知,**零新代码**`[x]` · ~0.1 pd
- **落地现状**`ios/README.md` ntfy 章节逐条引 `scripts/setup-hooks.mjs` 行号只读验证**未触碰用户真实 hook 配置**)。**DEFERRED**手机端到端需用户手机 + 改用户 hook 配置)。
- **Wave/阶段**: W4 / P0 · **Owns**: iOS README ntfy 章节文档**不建任何脚本文件**——桥已随 `npm run setup-hooks` 出货安装逻辑 scripts/setup-hooks.mjs:227-238env :262-264重装时 marker 自清理 §0.3
- **Depends**: App 解耦 · **Parallel-safe**: T-iOS-15/16
- **Steps**: [ ] 验证既有桥 `WEBTERM_NTFY_URL` + `WEBTERM_NTFY_TOPIC`可选 `WEBTERM_NTFY_TOKEN`)→ `npm run setup-hooks` 确认日志 "Installed ntfy bridge (NEEDS-INPUT=high, DONE=low)" [ ] README 写明env 未设即完全无副作用默认关闭已是出货行为topic 生成建议随机串topic 即密码[ ] 记录**STUCK 不在 P0 信号内**服务器 sweepStuck 派生态 hook 事件桥发不出——P1 APNs 经事件总线补上
@@ -714,10 +817,12 @@ W5 验收(report-only, 并行)
#### W5 · 验收report-onlyG4只报告不改码findings 标 owning task 派回)
#### T-iOS-18 · 验收走查 F-iOS-1…13真机`[ ]` · ~0.25 pd
#### T-iOS-18 · 验收走查 F-iOS-1…13真机`[~]` · ~0.25 pd
- **缺口**机器可执行项已执行并指认测试名真机项QR 扫码 / IME / 震动 / 切换器遮罩目检 / ntfy 端到端**DEFERRED**手工清单在 `PROGRESS_LOG.md`
- **Depends**: T-iOS-15/16/17 · **Owns**: 无源码report-only
- **Steps**: §9 验收脚本逐条执行记录结论与录屏
#### T-iOS-19 · 安全核对 `[ ]` · ~0.25 pd
#### T-iOS-19 · 安全核对 `[~]` · ~0.25 pd
- **缺口**P0 面已逐条核**P2 新增的 `NSMicrophoneUsageDescription`/`NSSpeechRecognitionUsageDescription` 未在产物层复核**release ipa 层核对仍缺免费个人 team 无分发通道)。本波 D1 只覆盖令牌路径与 entitlements 开关
- **Depends**: T-iOS-15 · **Owns**: 无源码(report-only)
- **Steps**: [ ] 对照 TECH_DOC §7 + 本文 §5 逐条核Origin 单点派生G/RO 分界ATS release 实况 ipa Info.plist——**五段 CIDR 逐段核对**debug `NSAllowsArbitraryLoads` 会掩盖缺段)、隐私 usage description 与实际使用的 capability **一一对应**相机/本地网络P2 加麦克风/语音识别—— ipa 核对)、Keychain 属性模拟器测试断言 `kSecAttrAccessible`)、ntfy payload 最小化无硬编码 host/密钥警告分层文案在位公网阻断 + RFC1918 明文提示 + Tailscale 豁免)、**真机核切后台开切换器 快照卡片是遮罩非终端内容**。
@@ -729,68 +834,70 @@ W5 验收(report-only, 并行)
> 波次:**W6服务器触点T-iOS-20 ∥ T-iOS-37串行入库各自 repo 流程)与 W7 并行开跑**——W7 里只有 T-iOS-21 依赖 T-iOS-20payload 形状、T-iOS-23 依赖 T-iOS-37lastOutputAt 字段),其余 W7 任务T-iOS-22/24/25/27/28/29不等 W6T-iOS-38APIClient P1 契约增量)在 W7 首发T-iOS-21/26 依赖它 → W8验收。任务粒度与 P0 同规格;此处 Steps(测试) 列关键用例,细化在开工时由任务 owner 补足(不改接口)。
#### T-iOS-20 · server: APNs sender + token 注册端点 `[ ]` · ~2 pd
#### T-iOS-20 · server: APNs sender + token 注册端点 `[x]` · ~2 pd
- **落地现状**`src/push/apns.ts` + `test/push-apns.test.ts`含本地 h2c APNs 的双 e2eenv 三件套缺失即整体 disabled启动不 crash密钥材料零日志。**DEFERRED**对真 APNs 的端到端需付费账号 + `.p8`
- **Wave**: W6 · **Owns**: `src/push/apns.ts`)、`src/server.ts` 增量 route`test/push-apns.test.ts`**服务器触点TypeScript 任务**遵循根仓库 PLAN 工作流
- **Depends**: · **Parallel-safe**: T-iOS-37/38 W7 T-iOS-21 外全部接口先行
- **Steps(测试先行)**: [ ] `.p8` 缺失 功能整体 disabled启动不 crash [ ] hook 事件 APNs payload 形状 `/hook/decision` 用的 capability token + category[ ] token 注册端点G 守卫 403限频 429幂等注册/注销 [ ] 与既有 web-push 并行互不干扰
- **Steps(实现)**: [ ] HTTP/2 `api.push.apple.com``.p8` env 路径无硬编码密钥[ ] 复用 `src/push/` 的事件订阅点
- **安全注**: capability token 语义不变单次过期10/min 限频src/server.ts:503-525APNs payload 不含命令内容
#### T-iOS-37 · server: `LiveSessionInfo.lastOutputAt` 字段 `[ ]` · ~0.25 pd
#### T-iOS-37 · server: `LiveSessionInfo.lastOutputAt` 字段 `[x]` · ~0.25 pd
- **Wave**: W6 · **Owns**: `src/types.ts` `LiveSessionInfo` 增量字段`src/session/manager.ts` `list()` 一行映射并更新其 "omitted by design" 注释)、对应测试增量**声明的服务器触点TypeScript 任务**遵循根仓库 PLAN 工作流 §0.3
- **Depends**: · **Parallel-safe**: T-iOS-20W7 全部
- **Steps(测试先行)**: [ ] `GET /live-sessions` 响应含 `lastOutputAt`服务器已逐 `pty.onData` 维护src/types.ts:211/M3——只是序列化出来[ ] 旧客户端兼容字段为**新增可选**web 前端不受影响
- **Accept**: 根仓库 `npm test` 全绿T-iOS-23 unread 水位有数据源
#### T-iOS-38 · `APIClient` P1 契约增量W7 首发,其余 W7 任务的 APIClient 单一 owner`[ ]` · ~0.5 pd
#### T-iOS-38 · `APIClient` P1 契约增量W7 首发,其余 W7 任务的 APIClient 单一 owner`[x]` · ~0.5 pd
- **Wave**: W7首发 · **Owns**: `ios/Packages/APIClient/**` **全部 P1 增量**APNs token 注册 builder`/projects``/projects/detail?path=``GET/PUT /prefs` builders 与解码 + Tests)——W7 期间 APIClient 文件**只有本任务可改**对齐 T-iOS-3 冻结契约模式避免 T-iOS-21/26 同波踩踏
- **Depends**: T-iOS-8T-iOS-20 token 注册 builder 的端点形状——可接口先行并行编码形状定稿时汇合 · **Parallel-safe**: T-iOS-20/22/23/24/25/27/28/29均不碰 APIClient 文件
- **Steps(测试先行)**: [ ] token 注册 builderG Origin[ ] projects/detail/prefs buildersPUT Origin与解码 [ ] doc comment 端点约束push subscribe body 8 KB、≤5 //IPsrc/server.ts:73,461-466`PUT /prefs` 64 KBsrc/server.ts:278
- **Accept**: `swift test --package-path ios/Packages/APIClient` 全绿覆盖率 80%
#### T-iOS-21 · PushRegistrar + 锁屏 Allow/Deny `[ ]` · ~1.5 pd
#### T-iOS-21 · PushRegistrar + 锁屏 Allow/Deny `[x]` · ~1.5 pd
- **落地现状**`Push/{PushRegistrar,NotificationActionHandler}.swift` + 三组 Tests`handleHostRemoved` 在收尾波接上真调用点`Wiring/AppEnvironment.swift`)。**DEFERRED**真机锁屏 Allow + Face ID 走查`aps-environment` 需付费 team`WEBTERM_PUSH_ENTITLEMENTS` 开关 `ios/README.md`)。
- **Wave**: W7 · **Owns**: `App/WebTerm/Push/{PushRegistrar,NotificationActionHandler}.swift` + 对应 TestsAPIClient token 注册 builder T-iOS-38
- **Depends**: T-iOS-20payload 形状)、T-iOS-38token 注册 builder · **Parallel-safe**: T-iOS-2229
- **Steps(测试先行)**: [ ] `UNNotificationCategory` Allow/Deny 注册形状——**Allow 动作必须带 `UNNotificationActionOptions.authenticationRequired`**锁屏批准 = 授权主机执行命令;旁观者拿到锁屏手机只能 Deny——fail-safe断言注册 category Allow 选项含 `.authenticationRequired`且两动作均**不含** `.foreground`[ ] action handler payload `{sessionId, token}` `POST /hook/decision` Origin)→ **不启动 App UI**Face ID 设备上"两次手势 + 一瞥"闭环[ ] token 用后即弃不落盘 [ ] 决策失败token 过期 403)→ 补一条本地通知提示进 App 处理
- **安全注**: Allow/Deny 由系统**后台拉起主 App**、送达 `UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:)`——** extension 参与**本工程没有 notification extension targetService Extension 只能改写来押通知收不到 action tap)。handler `beginBackgroundTask/endBackgroundTask` 包住 POST请求完成/失败后才调 completion handler失败必须可见本地通知兜底绝不静默吞
#### T-iOS-22 · DeepLinkRouter `[ ]` · ~1 pd
#### T-iOS-22 · DeepLinkRouter `[x]` · ~1 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/DeepLinkRouter.swift` + Tests
- **Steps(测试先行)**: [ ] `webterminal://open?host=<id>&join=<uuid>`UUID v4 校验复用 `Validation`非法 忽略并留日志 [ ] 未知 host id 落到配对页并提示 [ ] /热启动两路径都直达 gated 会话 [ ] push tap 同一路由
- **安全注**: deep link 是外部输入——全字段白名单校验绝不据此直接拼 URL 请求
#### T-iOS-23 · 多会话切换器unread dots + OSC 标题)`[ ]` · ~2 pd
#### T-iOS-23 · 多会话切换器unread dots + OSC 标题)`[x]` · ~2 pd
- **Wave**: W7 · **Owns**: `SessionListScreen` 增强**W7 内该文件唯一 owner** T-iOS-29 移交的列表侧入口)、`SessionCore` unread 记账`UnreadLedger.swift`)、标题净化器`TitleSanitizer.swift`+ Tests
- **Depends**: T-iOS-37`lastOutputAt` 字段 · **Parallel-safe**: T-iOS-21/22/24/25/26/27/28
- **Steps(测试先行)**: [ ] 单活 WS 不变切会话 = close→open回放恢复 [ ] unread 判定`/live-sessions` 快照的 `lastOutputAt`T-iOS-37 新增字段> 本地 last-seen 水位 → unread 点 [ ] OSC 标题经 SwiftTerm `setTerminalTitle` delegate 上浮到列表——**标题是主机/攻击者可控输入,入列表前过净化器**:截断 `Tunables.titleMaxLength`(256);剥 Unicode 双向覆写与零宽字符U+200B200F、U+202A202E、U+20662069——OSC 字符串解析已排除 C0真正的仿冒向量是 bidi/零宽);渲染用 `Text(verbatim:)`(绝不走 LocalizedStringKey/Markdown+ `.lineLimit(1)` 截断 [ ] 敌意标题解码测试超长、U+202E payload、emoji 洪泛 → 断言净化输出 [ ] 切换 <1s 观感回放解析在后台feed main
#### T-iOS-24 · Timeline sheet完整时间线钻取`[ ]` · ~1 pd
#### T-iOS-24 · Timeline sheet完整时间线钻取`[x]` · ~1 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/TimelineSheet.swift` + VM 测试
- **Steps(测试先行)**: [ ] `/live-sessions/:id/events` 全量渲染class 图标/颜色映射[ ] timeline disabled空数组)→ 空态而非错误 [ ] digest "展开"入口进入
#### T-iOS-25 · Quick-reply chips + 常用语面板 `[ ]` · ~1.5 pd
#### T-iOS-25 · Quick-reply chips + 常用语面板 `[x]` · ~1.5 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Components/QuickReply.swift`本地存储UserDefaults+ Tests
- **Steps(测试先行)**: [ ] chip 点击 `input` 文本 + `\r`[ ] 自定义面板增删改序 [ ] waiting 状态才浮出对齐 web `quick-reply.ts` 行为
#### T-iOS-26 · Projects列表 + 详情 + 在仓库起 Claude `[ ]` · ~2 pd
#### T-iOS-26 · Projects列表 + 详情 + 在仓库起 Claude `[x]` · ~2 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/{ProjectsScreen,ProjectDetailScreen}.swift` + VM Testsprojects/detail/prefs APIClient builders T-iOS-38本任务只消费
- **Depends**: T-iOS-38builders · **Parallel-safe**: T-iOS-21/22/23/24/25/27/28/29
- **Steps(测试先行)**: [ ] VM 消费 `/projects``/projects/detail?path=``GET/PUT /prefs`builder 与解码测试在 T-iOS-38[ ] favourites 同步 [ ] "在此仓库开新会话" = `attach(null, cwd)` + 注入 `claude\r` [ ] detail 400/404/500 `{error}` 显式路径
#### T-iOS-27 · Diff 查看器(只读)`[ ]` · ~1.5 pd
#### T-iOS-27 · Diff 查看器(只读)`[x]` · ~1.5 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/DiffScreen.swift` + VM 测试
- **Steps(测试先行)**: [ ] `DiffResult{files,staged,truncated}` 渲染truncated 提示 [ ] staged/unstaged 切换 [ ] path 非法 404 友好错误
#### T-iOS-28 · 会话缩略图offscreen SwiftTerm`[ ]` · ~1.5 pd
#### T-iOS-28 · 会话缩略图offscreen SwiftTerm`[x]` · ~1.5 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Components/SessionThumbnail.swift` + 快照测试
- **Steps(测试先行)**: [ ] `GET /live-sessions/:id/preview``{id,cols,rows,data}`24KB tail)→ 离屏 TerminalView feed 快照图 [ ] 列表滚动不掉帧离屏渲染限并发[ ] 404 占位图
#### T-iOS-29 · 杂项闭环new-in-cwd + 退出会话清理 `[ ]` · ~1 pd
#### T-iOS-29 · 杂项闭环new-in-cwd + 退出会话清理 `[x]` · ~1 pd
- **Wave**: W7 · **Owns**: `TerminalScreen` 的小增量 + Tests**不碰 `SessionListScreen`**——列表侧入口/行项变更移交 T-iOS-23该文件 W7 内单一 owner
- **Depends**: T-iOS-23列表侧入口 · **Parallel-safe**: T-iOS-21/22/24/25/26/27/28
- **Steps(测试先行)**: [ ] "在当前会话 cwd 开新会话"`attach(null, cwd)`[ ] exited 会话点开 回放 + exit 横幅 + "开新会话"动作src/session/manager.ts:145-153 语义
#### T-iOS-30 · P1 验收 + 安全复核 `[ ]` · ~1 pdreport-only
#### T-iOS-30 · P1 验收 + 安全复核 `[x]` · ~1 pdreport-only
- **Steps**: [ ] F-iOS-14/15(§9真机走查 [ ] 安全APNs payload 审计token 生命周期deep link fuzz通知在锁屏的预览泄露面默认隐藏内容验证)、**锁屏 Allow 动作必须 `.authenticationRequired`真机验锁屏 Allow Face ID/通行码Deny 无需解锁**。
**P1 合计 ≈ 17.5 人天**含新增 T-iOS-37 0.25 + T-iOS-38 0.5T-iOS-21/26 相应减负)。
@@ -800,13 +907,25 @@ W5 验收(report-only, 并行)
### P2 — 打磨 · 合计 ~8 人天
> P2 任务此处只给**分派必需元数据**Wave/Owns/Depends/Accept完整 RED 测试清单在开工时由任务 owner 按 P0 规格扩写(不改 §3 接口)。**未扩写前不得按下述描述直接分派**§4 TDD 强制与 §6 Owns 铁律同样适用)。
> **该前置已履行**T-iOS-31/32/33/34/35 的逐条 RED 清单由各 builder 开工第一步写进
> [`docs/plans/ios-completion.md`](./plans/ios-completion.md) §4共 100+ 条,含 T-iOS-32 的 35 条、
> T-iOS-33 的 18 条、T-iOS-31 的 38 条、T-iOS-34 的 29 条、T-iOS-35 的 21 条),再进 GREEN。
- **T-iOS-31** · 语音 PTT + 确认端口匹配器 / 1.5s 撤销 / epoch 防误发`[ ]` ~2.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/VoicePTT.swift` + VM Testsepoch 防误发若需 SessionCore 新接口 T-iOS-6 owner SessionCore 不直改)· **Depends**: T-iOS-6/11**前置**Info.plist `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`(§5.2 )· **Accept**: VM 测试 + 真机口述确认注入 input
- **T-iOS-32** · Worktree 创建`POST /projects/worktree`G+ `claude --resume <id>` 历史`GET /sessions``[ ]` ~1.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Screens/WorktreeSheet.swift` + TestsAPIClient builders T-iOS-38 owner 模式回 APIClient )· **Depends**: T-iOS-26/38 · **Accept**: builder 测试 + 端到端一次
- **T-iOS-33** · 终端内搜索 `[ ]` ~1 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/TerminalSearchBar.swift` + Tests · **Depends**: T-iOS-11 · **Accept**: SwiftTerm search API 命中高亮
- **T-iOS-34** · 主题 + Dynamic Type `[ ]` ~1.5 pd。**Wave**: W9 · **Owns**: 主题/字号小增量与同波任务文件不相交开工时列明文件清单)· **Depends**: T-iOS-11/13 · **Accept**: 亮暗主题 + 最大字号不破版
- **T-iOS-35** · web `?join=` 互通 QR 双向`[ ]` ~0.5 pd。**Wave**: W9 · **Owns**: `DeepLinkRouter.swift` 增量`?join=` 解析)· **Depends**: T-iOS-22 · **Accept**: 手机扫 web 分享 QR 直达同会话
- **T-iOS-36** · P2 验收 `[ ]` ~1 pdreport-only)。**Wave**: W10 · **Owns**: 无源码 · **Depends**: T-iOS-3135
- **T-iOS-31** · 语音 PTT + 确认端口匹配器 / 1.5s 撤销 / epoch 防误发`[x]` ~2.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/VoicePTT.swift` + VM Testsepoch 防误发若需 SessionCore 新接口 T-iOS-6 owner SessionCore 不直改)· **Depends**: T-iOS-6/11**前置**Info.plist `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`(§5.2 )· **Accept**: VM 测试 + 真机口述确认注入 input
- **落地**: `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 末位 🎤 17 键顺序/标签零变化+ `VoicePTTTests` 40 转写清洗 / 匹配器 / 1.5s 撤销窗 / 双道 epoch / 权限失败路径 / 键栏零回归)。注入内容**不以 `\r` 结尾**确认前零注入
- **DEFERRED**: 真机口述确认注入需真机麦克风与语音识别授权
- **T-iOS-32** · Worktree 创建`POST /projects/worktree`G+ `claude --resume <id>` 历史`GET /sessions``[~]` ~1.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Screens/WorktreeSheet.swift` + TestsAPIClient builders T-iOS-38 owner 模式回 APIClient )· **Depends**: T-iOS-26/38 · **Accept**: builder 测试 + 端到端一次
- **落地**: `Screens/{WorktreeSheet,ResumeHistorySheet}.swift` + `ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift`支名 9 条规则客户端先校验非法名零请求)、prune 幂等文案remove 两级确认409显式 force 确认绝不自动重试)、`--resume` id 白名单后才拼命令行`WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)
- **未做**: Accept 里的"端到端一次"对真服务器真建/真删一个 worktree没有自动化腿也未在本环境手工跑过
- **T-iOS-33** · 终端内搜索 `[x]` ~1 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/TerminalSearchBar.swift` + Tests · **Depends**: T-iOS-11 · **Accept**: SwiftTerm search API 命中高亮
- **T-iOS-34** · 主题 + Dynamic Type `[~]` ~1.5 pd。**Wave**: W9 · **Owns**: 主题/字号小增量与同波任务文件不相交开工时列明文件清单)· **Depends**: T-iOS-11/13 · **Accept**: 亮暗主题 + 最大字号不破版
- **落地**: `DesignSystem/{AppTheme,TerminalPalette}.swift` + `Screens/SettingsScreen.swift`齿轮入口在 `ProjectsToolbarItem` stack split 两根视图共享+ Tokens/Typography 浅色档`AppThemeTests`(24)/`DynamicTypeLayoutTests`(8)。默认仍是深色零回归)。
- **缺口已实测未修**: AX5 下键栏两行需 **108.24pt**`KeyBarMetrics.barHeight` 固定 **52pt**44+8)→ 键帽裁切 `withKnownIssue` 钉在 `DynamicTypeLayoutTests`修好后该用例会报 "known issue was not recorded"提醒删标记)。`Components/KeyBar.swift` 不在该任务 Owns需另派
- **T-iOS-35** · web `?join=` 互通分享 QR 双向`[x]` ~0.5 pd。**Wave**: W9 · **Owns**: `DeepLinkRouter.swift` 增量`?join=` 解析)· **Depends**: T-iOS-22 · **Accept**: 手机扫 web 分享 QR 直达同会话
- **落地**: `DeepLinkRouter` `.joinShared` 分支 + `DeepLinkJoinTests` 19 含把原"scheme 不是 webterminal"的拒绝理由改写为"web 形状只许单一 `join` "主机身份只经 `HostStore`+`HostEndpoint.originHeader` 解析未配对 origin 一律走配对页且 hint 不回显链接内容
- **DEFERRED**: 用真手机相机扫 web 分享 QR 的目视走查模拟器无相机)。
- **T-iOS-36** · P2 验收 `[~]` ~1 pdreport-only)。**Wave**: W10 · **Owns**: 无源码 · **Depends**: T-iOS-3135
- **缺口**: ios-completion 收尾波的 Wave D 验收 agent 执行中**真实数字与结论以 `PROGRESS_LOG.md` 的该条目为准**本文不预写"通过"。
**总计P0 13 + P1 17.5 + P2 8 ≈ 38.5 人天。**
@@ -863,7 +982,15 @@ W5 验收(report-only, 并行)
每任务**RED** Steps(测试) 先写失败测试)→ **GREEN**最小实现过测)→ **REFACTOR**对照 §4 清单)。测试命名讲行为`test("未知 UUID attach 后采用服务器新发的 id")`AAA 结构
### 覆盖率门(≥ 80%,只量 4 个包)
### 覆盖率门(≥ 80%;原定 4 个包**现为 5 个**
> **现状2026-07-30**:门集已扩到 **5** 个包——原 4 个 + **`ClientTLS`**ios-completion 收尾波 B4
> 48→84 测、55.76%→**89.49%**,此前是树里唯一未进门的包,却最安全敏感)。
> CI 实际执行的是**修正口径**脚本 `ios/IntegrationTests/scripts/coverage-gate.sh <Pkg>`
> (下面这段裸 `llvm-cov` 命令读的是 export TOTALS会把静态链入的**依赖包源码**一起计进去——
> 这个缺陷由 T-iOS-16 修掉,脚本只保留 `Packages/<P>/Sources/` 并排除 `*Placeholder*`)。
> 最近一次记录在案的 own-sources 数字APIClient 92.22% · HostRegistry 92.49% · SessionCore 96.74% ·
> ClientTLS 89.49% · WireProtocol 100%。
```bash
for p in WireProtocol SessionCore HostRegistry APIClient; do
@@ -931,9 +1058,16 @@ XCUITest 只保一条 happy path配对→attach→输入→gate approve
| 单 WS 设计与多会话切换器P1的张力 | 低 | 切换 = 重放恢复,成本近零;若实测不适再评估观察者 WS |
**待你拍板的开放项**
1. Bundle id / 产品名 / 图标App 叫什么?`webterminal://` scheme 是否可用/要改名?)
2. Apple 付费开发者账号($99/年)何时开——决定 P1 APNs 与 TestFlight 分发起点P0 期间接受自签 sideload
3. 最低系统版本iOS 17可分发下限还是 18/26个人工具availability 噪音最小)?
1. ~~Bundle id / 产品名 / 图标~~ **已定**`com.yaojia.webterm` / WebTerm / "Orbit" 图标(`project.yml`
deep-link scheme `webterminal://` 已注册并在用,另接受 web 的 `http(s)://…/?join=` 形状T-iOS-35
2. ~~Apple 付费开发者账号何时开~~ **现状已定、后果已量化**:用的是**免费个人 team**`DEVELOPMENT_TEAM=C738Z66SRW`)。
真机构建可用7 天临时 profile`-allowProvisioningUpdates`,实测 `BUILD SUCCEEDED`
**Push Notifications capability 免费 team 不支持** —— `aps-environment` 因此做成 `WEBTERM_PUSH_ENTITLEMENTS`
env 开关(默认不挂;挂上则真机构建报
`Personal development teams, including "Yaojia Wang", do not support the Push Notifications capability`)。
**APNs 真机端到端 + TestFlight 仍待付费账号**release 构建还需把 `aps-environment` 翻成 `production`
操作细节见 [`ios/README.md`](../ios/README.md#signing-device-builds-and-the-push-entitlements-switch)。
3. ~~最低系统版本~~ **已定**iOS **17.0**`project.yml` `deploymentTarget`CI 另有一条 iOS-17 底线腿。
4. ~~P0 的 ntfy 桥要不要做成默认关闭~~ **已解决**:桥已随 `npm run setup-hooks` 出货且默认关闭(`WEBTERM_NTFY_URL`/`WEBTERM_NTFY_TOPIC` 未设即完全无副作用——无需新做T-iOS-17 只验证/写文档。
5. ~~Tailscale 场景是否直接推荐 `tailscale serve`~~ **已定**:推荐 `tailscale serve`wss为标准部署话术配对 UI/README 采用;绕开全部 ATS 例外与明文嗅探面,见 §5.4)。

View File

@@ -1,8 +1,13 @@
# PLAN_IOS_IPAD.md — iPad 适配(自适应布局,非分叉)
> 落地方案文档。目标:让已完成的 iPhone 客户端([PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md)P0+P1 已交付,分支 `feat/ios-client`**原生适配 iPad**——大屏分栏、双向布局、指针/硬件键盘,而**不分叉出第二套 UI**。
> 落地方案文档。目标:让已完成的 iPhone 客户端([PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md)P0+P1+P2 已交付,**已合入 `develop`****原生适配 iPad**——大屏分栏、双向布局、指针/硬件键盘,而**不分叉出第二套 UI**。
> 拓扑决策:**单一代码库 + size-class 自适应**——`NavigationSplitView` 在 regular 宽度iPad 全屏/大分屏)给 sidebar+detail在 compact 宽度iPhone、iPad Slide Over/小分屏)**自动退化为现有 stack**。iPhone 行为字节级不变。
> 状态:**规划中2026-07-05未开工**
> 状态:**已交付并合入 `develop`**T-iPad-1…4 全部落地T-iPad-5 验收 PASS_WITH_FINDINGS4/4 findings 已修,真机项 DEFERRED
> 逐任务状态见 §5 各任务标题上的方框;**Steps 里的 `[ ]` 是原始规格清单,不是状态**(执行记录在 `PROGRESS_LOG.md`)。
> 2026-07-30 由 ios-completion 收尾波按源码核对:`Wiring/{AdaptiveRootView,SplitRootView,LayoutMode}.swift`、
> `Components/TerminalContextMenu.swift`、`Screens/ProjectsLayout.swift` 与 `LayoutPolicyTests`/`SidebarSelectionTests`/
> `KeyBarVisibilityTests`/`TerminalContextMenuTests`/`ProjectsLayoutTests`/`ProjectsLayoutUITests` 均在树内;
> `project.yml` 三个 target 的 `TARGETED_DEVICE_FAMILY` 均为 `"1,2"``ios.yml` 有 iPad 单测腿与 iPad UI-test 腿。
> 本文是 iPhone 计划之上的**布局适配层**,不改协议/会话模型/纯逻辑包;沿用 [PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md) 的 §3 契约、§4 工程标准、§5 安全模型、§6 并行规则。冲突以 iPhone 计划为准。
> **G1 日志铁律**`PROGRESS_LOG.md` 由 orchestrator 独写;被派 subagent 不写 LOG在返回消息末尾附可粘贴条目。
@@ -118,7 +123,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
### W0 · 可安装性(串行,先行)
#### T-iPad-1 · device family + iPad plist/方向 `[ ]` · ~0.5 pd
#### T-iPad-1 · device family + iPad plist/方向 `[x]` · ~0.5 pd
- **Owns**: `ios/project.yml`device familyiPad 方向必要 plist)、`.github/workflows/ios.yml` iPad 模拟器测试腿
- **Depends**:
- **Steps(测试先行)**:
@@ -132,7 +137,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
### W1 · 自适应导航壳(核心)
#### T-iPad-2 · AdaptiveRootView + NavigationSplitView + LayoutPolicy `[ ]` · ~2 pd
#### T-iPad-2 · AdaptiveRootView + NavigationSplitView + LayoutPolicy `[x]` · ~2 pd
- **Owns**: `Wiring/{AdaptiveRootView,SplitRootView,LayoutMode}.swift`)、`Wiring/RootView.swift`现有 stack 抽成 `StackRootView` 子视图供 compact 复用)、`Wiring/AppCoordinator.swift`sidebar 选中 路由最小桥)、对应测试
- **Depends**: T-iPad-1
- **Steps(测试先行, RED)** `LayoutPolicyTests` + `SidebarSelectionTests`:
@@ -149,7 +154,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
### W2 · 逐面适配(并行,文件互斥)
#### T-iPad-3 · 终端面板KeyBar 自适应 + 指针上下文菜单 `[ ]` · ~1 pd
#### T-iPad-3 · 终端面板KeyBar 自适应 + 指针上下文菜单 `[x]` · ~1 pd
- **Owns**: `Components/{KeyBar,TerminalContextMenu}.swift``Screens/TerminalScreen.swift`增量)、测试
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-4
- **Steps(测试先行)**:
@@ -159,7 +164,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
- **Accept**: iPad 接键盘时 KeyBar 自动隐去键盘复现右键菜单在 iPad 生效iPhone 无硬件键盘时 KeyBar 行为不变
- **安全注**: 上下文菜单的 kill/开会话是既有 G/RO 通道的又一触发面——测试名标复用 APIClient无绕过
#### T-iPad-4 · Projects/Timeline/sheet 大屏化 `[ ]` · ~1 pd
#### T-iPad-4 · Projects/Timeline/sheet 大屏化 `[x]` · ~1 pd
- **Owns**: `Screens/ProjectsScreen.swift`增量)、Projects/Timeline 呈现方式regular `.sheet` /`.presentationDetents` sidebar section)、测试
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-3
- **Steps(测试先行)**:
@@ -170,7 +175,9 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
### W3 · 验收report-only, G4
#### T-iPad-5 · iPad 验收 + iPhone 回归 + 安全核对 `[ ]` · ~0.5 pd
#### T-iPad-5 · iPad 验收 + iPhone 回归 + 安全核对 `[~]` · ~0.5 pd
- **已执行**模拟器侧 PASS_WITH_FINDINGSiPhone 16 / iPad Pro 11 双套件绿iPhone 零回归硬门守住4 findings2 MED / 2 LOW orchestrator 修完 4/4iPad 分栏 sidebar+detail 截图确认
- **缺口**:① 真机 iPad分栏手势 / 硬件键盘全键位 / 指针 hover 右键 / Stage Manager**DEFERRED**手工清单在 `PROGRESS_LOG.md`;② iPad happy-path XCUITest **接受推迟**split 选中逻辑已由 `SidebarSelectionTests` 覆盖单跑 711 min 且脆);③ **release ipa **核对未做免费个人 team 无分发通道)—— T-iOS-19 同一缺口 P2 新增的麦克风/语音识别 usage description 也应一并核
- **Depends**: T-iPad-2/3/4 · **Owns**: 无源码report-onlyfindings 派回 owner
- **Steps**:
- [ ] **F-iPad 走查**(§6 清单逐条分栏横竖屏Split View/Slide Over/Stage Manager 尺寸切换硬件键盘/指针终端更宽列数遮罩覆盖 detail
@@ -214,10 +221,10 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
| SwiftTerm 在超宽 detail 的性能/选择手感 | | 复用 iPhone 已测路径真机目视 |
| 多窗口诱惑导致 scope 膨胀 | | 本期明确单场景(§0 非目标多窗口另立计划 |
**待你拍板**
1. iPad 最低系统版本iPadOS 17 iPhone 一致还是抬到 18/26
2. 本期是否要指针右键上下文菜单T-iPad-3 后半)——纯锦上添花可砍到后续
3. 多窗口拖会话开新窗并排两终端确认放到下一期
**待你拍板** —— 三项均已落定2026-07-30 按源码核对
1. ~~iPad 最低系统版本~~ **已定**iPadOS **17** iPhone 一致`project.yml` 单一 `deploymentTarget: iOS 17.0`无独立 iPad 下限)。
2. ~~本期是否要指针右键上下文菜单~~ **已做**`Components/TerminalContextMenu.swift`复制选区 / cwd 开新会话 / 结束会话全部路由既有通道kill 仍经 APIClient Origin+ `TerminalContextMenuTests`
3. ~~多窗口~~ **确认推迟**本期单场景(§0 非目标拖会话开新窗并排两终端仍未开工另立计划
**工作量合计**W0 0.5 + W1 2 + W2342 + W3 0.5 **5 人日**并行后墙钟更短)。

View File

@@ -146,4 +146,284 @@ A1 必须一次把**后续所有波次需要的 Info.plist 键**都加好(它
## 4. P2 RED 测试清单(由各 P2 builder 在开工第一步追加到本节)
<!-- C3 / C4 在此追加 T-iOS-31/33/34/35 的 RED 清单 -->
### T-iOS-32 · Worktree 生命周期create/prune/remove+ `claude --resume` 历史 —— C2 追加
真源:`src/http/worktrees.ts` / `src/server.ts:1095-1183``docs/plans/w4-worktree-lifecycle.md`
web 参照实现 `public/projects.ts``validateBranchNameClient` :982、`confirmAndRemoveWorktree` :431、
`confirmAndPruneWorktrees` :455、`renderNewWorktreeForm` :998、`newTabForResume` `public/tabs.ts:918`)。
APIClient 侧 builder/状态码映射已在 B1 测过125 tests故以下全部是 **App 层归约**测试,
文件 `ios/App/WebTermTests/WorktreeViewModelTests.swift` / `ResumeHistoryViewModelTests.swift`
**A. 分支名校验(纯函数 `WorktreeBranchRule.validate`,逐条镜像 web 的 9 条规则)**
1. RED空串 → 非 nil 错误文案(不得放行到网络)。
2. RED长度 > 250 → 报错;== 250 → 通过。
3. RED含空格 / 控制字符(`\u{0}``\u{20}``\u{7f}`)→ 报错。
4. RED`-` 开头 → 报错(否则会变成 git flag
5. RED`..`、以 `.lock` 结尾 → 报错。
6. RED`~ ^ : ? * [ \` 任一 → 报错。
7. RED`@{` → 报错。
8. RED`/` 开头、以 `/` 结尾、含 `//` → 报错。
9. RED合法名`feat/x``v1.2-fix`)→ nil。
**B. 创建 worktree`POST /projects/worktree`G**
10. RED非法分支名 → **不发请求**(记录一次调用计数 == 0只显示校验错误。
11. RED`.ok(CreateWorktreeResult)``phase == .created(path:branch:)`,且把服务器返回的
canonical `path`/`branch` 当真相(不回显本地输入)。
12. RED`base` 留空 → 请求体的 `base` 为 nil服务器读作「从 HEAD 切」),**不得**送 `""`
13. RED`.rejected(status:403, message:"…")` → 原样显示服务器安全文案(不得吞、不得自造)。
14. RED`.rejected(status:500, message:nil)` → 兜底中文文案(非空、可读)。
15. RED`.rateLimited` → 专用「请稍后再试」文案,且**不自动重试**。
16. RED抛出的 `APIClientError.unauthorized` → 引导补令牌的文案(不与 500 混淆)。
17. RED创建进行中 `isBusy == true` 且重复提交只发一次请求。
**C. prune`POST /projects/worktree/prune`G破坏性 → 需确认)**
18. RED未确认`confirmPrune` 未调用)→ 零请求。
19. RED确认后 `.ok(pruned: [])` → 幂等文案「没有可回收的 worktree」非错误态。
20. RED`.ok(pruned: [a,b])` → 文案含数量 2。
21. RED`.rejected(404, msg)` → 显示服务器文案。
**D. remove`DELETE /projects/worktree`G两级确认**
22. RED主 worktree`isMain`)→ UI 不提供 remove`canRemove == false`locked 同样 false。
23. RED第一次确认 → 以 `force: false` 发一次请求。
24. RED`.rejected(409, msg)` → 进入 `.forceConfirming`**不自动**重试),并带上服务器文案。
25. RED`.forceConfirming` 下用户取消 → 零第二次请求。
26. RED`.forceConfirming` 下用户确认 → 第二次请求 `force: true`
27. RED`.rejected(400)`(非 409→ 直接错误态,**不**进入 force 分支。
**E. `claude --resume <id>` 历史(`GET /sessions`**
28. RED`GET /sessions` 成功 → 仅保留 cwd 落在本项目路径内的会话
`cwd == path``cwd.hasPrefix(path + "/")`),其余过滤掉。
29. RED路径前缀不得半路匹配`/repos/web-terminal-old` 不属于 `/repos/web-terminal`
30. RED服务器顺序mtime 新→旧)保持不变,客户端不重排。
31. RED空结果 → `.empty` 而非 `.failed`(服务器 `~/.claude/projects` 缺失时回 `[]`)。
32. RED加载失败 → `.failed(可重试)``load()` 可重试成功。
33. RED**安全web 侧缺失的校验**`ProjectResumeCommand.bootstrapInput(sessionId:)`
对 id 做 `[A-Za-z0-9._-]{1,128}` 白名单 —— `abc; rm -rf ~`、含空格/反引号/`$(`/`\n` 的 id
→ 返回 nil**绝不**拼进 PTY 命令行);合法 UUID stem → `"claude --resume <id>\r"`
结尾必须是 `\r`0x0D不是 `\n`)。
34. RED非法 id 的历史行 `canResume == false`UI 不提供恢复按钮)。
35. REDcwd 非绝对路径的历史条目 → 不可恢复(`Validation.isAbsoluteCwd` 纪律)。
### 附C2 git 面板(非 P2但同批交付RED 清单
`ios/App/WebTermTests/GitPanelPresentationTests.swift` / `GitPanelViewModelTests.swift`
真源 `docs/plans/w6-project-git-panel.md` + `public/projects.ts:615-745 makeSyncBand`
36. RED`sync == nil`(非 git 目录)→ 无同步带nil不发任何 git 请求。
37. RED`↑0 ↓0` + fetch 在 1h 内 → **唯一**允许的绿色「已同步」。
38. RED`↑0 ↓0` + `lastFetchMs` 超过 `FETCH_STALE_MS`(=3600_000) → **不绿**`↓` 带「未核实」。
39. RED`lastFetchMs == nil`(从未 fetch→ 视为 stale不绿。
40. RED`upstream == nil` → 「无上游」,无 `↑↓`**不绿**(最易犯的 bug
41. RED`detached == true` → 「分离 HEAD」`↑↓`fetch 按钮禁用(`canFetch == false`)。
42. RED`ahead == nil`(读不到)→ 显示 `↑ —` 而非 `↑ 0`,且不绿。
43. RED`dirtyCount == nil` → 「未检查」,**不是** clean`0` → clean`3` → 3。
44. REDgit log 未推送边界:`upstream != nil` 时边界画在最后一个 `unpushed == true` 之后,
且只画一次;`unpushed` 只在严格 `true` 时生效。
45. RED`upstream == nil` → 完全不画边界。
46. RED未推送提交排在已推送之下老日期 merge→ 边界仍在最后一个 unpushed 之后Set 为准,非行号)。
47. REDstage/commit/push/fetch 的 `.rejected` 一律把服务器 `error` 原样显示;`nil` message → 兜底文案。
48. REDcommit 成功 → 清空输入框 + 显示短 sha`.rejected(409)`(无暂存)→ 保留输入框内容。
49. REDpush `.rejected(401)` → 「主机上的 git 凭据」文案,**不**与访问令牌 401 混淆
`unauthorizedPolicy == .routeDefined`§1.1)。
50. RED任一写操作进行中 → `isBusy`,重复点击只发一次。
51. RED重命名文件 stage → 请求体同时含 `oldPath``newPath`(镜像 web
### T-iOS-33 · 终端内搜索 —— C3 追加
真源SwiftTerm 1.13.0 已带搜索 API`TerminalViewSearch.swift``findNext(_:options:scrollToResult:)`
`findPrevious(...)``clearSearch()``SearchOptions(caseSensitive:false, regex:false, wholeWord:false)`
默认值与 xterm.js search addon 一致。web 参照 `public/search.ts`Enter=next / Shift+Enter=prev /
Esc=关闭并 `hooks.clear()``public/terminal-session.ts:547-553` 把三个 hook 落到 SearchAddon
`style.css:812` searchbox 固定在**右上**)。文件 `ios/App/WebTermTests/TerminalSearchTests.swift`
**A. 查询提交策略(纯函数 `TerminalSearchQuery.isSubmittable`**
1. RED空串 → 不可提交,且**零引擎调用**(不把空词丢给 SwiftTerm
2. RED单个空格 `" "` → **可**提交(镜像 web`input.value` 原样进 `findNext`,空格是合法搜索词)。
3. RED`" foo "` → 原样传(**不 trim、不改大小写**,逐字符等于用户输入)。
**B. 方向与引擎调用(`TerminalSearchModel` over fake `TerminalSearching`**
4. RED`find(.next)` → 恰一次 `searchNext(term:)`term 逐字符等于 query`searchPrevious`
5. RED`find(.previous)` → 恰一次 `searchPrevious`,零 `searchNext`
6. RED引擎回 true → `outcome == .found`
7. RED引擎回 false → `outcome == .notFound`,且有非空「无匹配」文案。
8. RED连续三次 `find(.next)` → 三次引擎调用(搜索游标由 SwiftTerm 自己推进,客户端不缓存)。
9. RED未 attach 引擎(`searcher == nil`)→ 不崩、`outcome` 保持 `.idle`(预览/测试环境)。
**C. 打开/关闭生命周期**
10. RED初始 `isPresented == false``outcome == .idle`
11. RED`present()`→true`dismiss()`→false。
12. RED`dismiss()` → 恰一次 `searchClear()`(镜像 web `hide() → hooks.clear()`+ 清空 query + `outcome == .idle`
13. RED`present()` **不**调用任何引擎方法(开面板 ≠ 搜索)。
14. RED改 query → `outcome` 复位 `.idle`(旧的「无匹配」不得挂在新词上)。
**D. 不变式:搜索是纯读**
15. RED整个搜索流程后真 `TerminalViewModel.forwardedSendCount == 0`(零 PTY 字节byte-shuttle 不变)。
16. RED终端已 `.exited`read-only时搜索照常可用`find` 仍打引擎)。
**E. SwiftTerm 绑定Accept命中并高亮**
17. RED`KeyCommandTerminalView` feed 一段文本后 `searchNext(term:)` 命中 → 返回 true **且**
`hasActiveSelection == true`= SwiftTerm 选区高亮);搜不存在的词 → false。
18. RED`searchClear()``hasActiveSelection == false`(高亮清掉)。
### T-iOS-31 · 语音 PTT + 确认 —— C3 追加
真源(三件套逐条移植 webplan §7「端口匹配器 / 1.5s 撤销 / epoch 防误发」= port `voice-commands.ts` 的匹配器):
`public/voice.ts`PTT 生命周期、`autoSend:false` 默认 = **不自动回车**)、
`public/voice-commands.ts`(整句精确匹配器 + 否定守卫 + `MIN_APPROVE_CONFIDENCE=0.6`)、
`public/voice-confirm.ts``DEFAULT_WINDOW_MS = 1500` 可撤销窗口)、
`SessionCore/GateState.swift`epoch 防陈旧决策的既有先例 `canDecide(epoch:)`)。
文件 `ios/App/WebTermTests/VoicePTTTests.swift`
**A. 转写清洗(安全边界,纯函数 `VoiceTranscript`**
19. RED普通文本 → 首尾 trim 后原样。
20. RED`\r`0x0D**剥除**(口述绝不自己回车执行命令;等价 web `autoSend:false`)。
21. RED`\n`/`\t`/ESC `\u{1b}`/其它 C0-C1 控制字符 → 全部剥除。
22. RED多行 → 折成单行(换行变空格 + 合并连续空白)。
23. RED`maxLength` → 截断到上限。
24. RED清洗后为空 → `isInjectable == false`(不进确认态)。
**B. 匹配器移植(`VoiceCommandMatcher`,逐条镜像 `voice-commands.ts`**
25. RED无 held gate → 任何话语都是 `.text`(含「确认」)。
26. REDgate 是 `.plan``.text`v1 只作用 tool gate
27. REDtool gate + 「确认」/「批准」/`ok`/`go ahead``.approve`
28. RED归一化小写 + 去标点 + 合并空白):`OK.`/「好的!」命中;`don't``dont`
29. RED**整句精确**:「确认一下这个」→ `.text`(绝不子串匹配,否则顺口一句就批了 shell
30. REDtool gate + 「拒绝」/「取消」/`stop``.reject`
31. RED否定守卫「不批准」→ `.reject`;「不清楚」→ `.text``now` **不**被 `no` 前缀否定 → `.text`
32. RED置信度门`.approve` 且 confidence < 0.6 降级 `.text``.reject` 不受置信度影响
33. RED/纯标点话语 `.text`
**C. 确认 + 1.5s 撤销窗口FakeClock零真睡**
34. RED`pressDown()` `.listening`partial 回调更新 `.listening(partial:)`
35. RED`pressUp()` 得空转写 `.idle` + `lastResolution == .empty`**零注入**。
36. RED`pressUp()` 得有效转写 `.confirming(...)`**零注入**(「确认前绝不注入」)。
37. RED`.confirming` `cancel()` `.idle` + `.discarded` + 零注入
38. RED`confirm()` `.undoWindow`此刻**仍零注入**。
39. RED时钟 +1.4s 仍零注入 1.5s 恰一次注入内容 == 清洗后的转写`lastResolution == .committed(.text(...))`
40. RED`.undoWindow` `undo()` 零注入 + `.undone`再推进 10s **也不**注入任务已取消)。
41. RED注入内容**不以 `\r` 结尾**用户自己按 —— 误听绝不自动执行)。
42. RED `.confirming` 态调 `confirm()` 无副作用
43. RED重复 `confirm()` arm 一次只注入一次
**D. epoch 防误发(两道闸)**
44. RED口述确认之间 epoch 变了 `confirm()` 直接丢弃零注入 + `.staleEpoch`
45. REDepoch **撤销窗口内**confirm 已过1.5s 内切会话)→ 到期仍零注入 + `.staleEpoch`
46. REDepoch 不变 正常注入防止闸门过严的回归保护)。
47. RED口述时 sessionId 还没 adoptnil)、确认时已 adopt 视为变化 零注入
48. RED`VoiceEpochPolicy.invalidates``.reconnecting` true重连后服务器可能给的是新 PTY
客户端分辨不了 走安全方向`.connecting`/`.none` false`VoiceEpochSource` 累加 generation
**E. 权限与失败路径**
49. RED麦克风授权被拒 `.denied(.microphone)`文案指向 设置隐私麦克风零录音零注入
50. RED语音识别授权被拒 `.denied(.speech)`文案指向语音识别开关
51. RED识别器抛错 `.failed(message:)`零注入可再按重试
52. RED终端只读`.exited`)→ `pressDown()` 拒绝`.readOnly`**不启动录音**`startCount == 0`)。
**F. 键栏集成KeyBar 零回归)**
53. RED不提供语音闭包 按钮数 == `KeyBarLayout.buttons.count`17无麦克风键
54. RED提供语音闭包 末尾多一个 🎤/「语音 17 键顺序与 accessibilityLabel **完全不变**
55. RED麦克风键 touchDown `onVoiceDown` 恰一次touchUpInside `onVoiceUp` 恰一次
**绝不** `onKey`麦克风不映射任何 `KeyByteMap` 字节)。
56. RED匹配到 `.approve` 且注入了 gate 走同一 1.5s 窗口到期调 `decide(.approve)` ****注入文本
### T-iOS-34 · 主题(跟随系统/深色/浅色)+ Dynamic Type —— C4 追加
真源`Wiring/RootView.swift:30` 今天**硬锁** `.preferredColorScheme(.dark)`无浅色通路
`DesignSystem/Tokens.swift` 已声明浅色 accent `#C9892F`web `--accent-2`但状态色/时间线色/
`accentSoft`/终端画布仍是**单值深色专用**终端主题只在 `Screens/TerminalScreen.swift:223-235`
`makeUIView` 时套一次web 参照 `public/settings.ts``DEFAULT_SETTINGS.theme='dark'`
`THEMES.light = {background:'#f6f7f9', foreground:'#1a1d24'}`)。
文件 `ios/App/WebTermTests/AppThemeTests.swift` / `DynamicTypeLayoutTests.swift`
**A. 主题模型(纯值 `AppTheme`**
1. RED`.system.colorScheme == nil`= 交给系统`.dark → .dark``.light → .light`
2. RED三档 label / SF Symbol 各自非空且**互不相同**选择器要能区分)。
3. RED`AppTheme.allCases` 顺序 = 跟随系统 深色 浅色选择器顺序即此顺序UI 不重排)。
4. RED`AppTheme(rawValue:)` 白名单`"system"/"dark"/"light"` 命中其它含空串`"Dark"`)→ nil
**B. 持久化(`AppThemeCodec` 纯函数 + `ThemeStore` over fake defaults**
5. RED`decode(nil)`从未设置)→ **`.dark`** —— 默认必须等于今天硬锁的深色零回归)。
6. RED`decode("solarized")` / `decode("")` / `decode("DARK")` `.dark`未知值不崩不落 system)。
7. RED三档 `encode → decode` 往返恒等
8. RED`ThemeStore` defaults `.dark`**不写盘**首次读不产生写)。
9. RED`select(.light)` `theme == .light` defaults `"light"`同一 defaults 新建 store `.light`跨启动)。
10. RED`select` 同一档两次 只写一次不产生多余写)。
11. REDdefaults 里被塞脏值 store 读作 `.dark`****清空用户其它键
**C. 有效配色解析(`AppTheme.resolvedScheme(system:)`**
12. RED`.system` + 系统 `.light` `.light``.system` + 系统 `.dark` `.dark`透传)。
13. RED`.dark`/`.light` 无视系统值 强制自身
**D. 浅色通路的 token 审计(`UIColor.resolvedColor(with:)` 逐档解析)**
14. RED`statusWorking`/`statusWaiting`/`statusStuck`/`timelineTool`/`timelineUser` **light**
**dark** 下解析值**不同**真的有浅色变体不是解锁开关就完事)。
15. RED深色档的值**逐字节不变**#46D07F / #F5B14C / #FF6B6B / #5E9EFF / #AF7BFF)—— 深色零回归
16. RED上述 5 色在**两档**下相对该档 `systemBackground` WCAG 对比度 ** 3:1**
非文本 UI 组件门槛1.4.11)。今天的 `#46D07F`/`#F5B14C`/`#FF6B6B` 在白底只有
1.96/1.60/2.74:1 —— 这是 RED 的核心
17. REDcritical 三色working/waiting/stuck **light** 档仍两两可辨不因变暗而糊成一片)。
18. RED`accentSoft` 两档不同**都仍是 wash**alpha < 0.3不许变成实心块)。
19. RED`onAccent` 两档**相同**金色填充在两档都要深色墨故它是固定值且与 accent 对比 4.5:1
**E. 终端画布跟随主题(`TerminalPalette`**
20. RED`colors(for: .dark).background == #100F0D``.foreground == #ECE9E3`桌面 web `--bg/--text`零回归)。
21. RED`colors(for: .light).background == #F6F7F9``.foreground == #1A1D24`镜像 web `THEMES.light`)。
22. RED两档 fgbg 对比度 7:1终端是正文AAA 门槛)。
23. REDcaret / selection 用该档 accent 解析值不写死深色档的金)。
24. RED`DS.Palette.terminalBackgroundUIColor()` **动态** UIColorlight/dark 解析值不同
今天是单值常量 RED)。
**F. Dynamic Type 不破版(`UIHostingController.sizeThatFits` / UIKit `systemLayoutSizeFitting`AX5**
25. RED`GateBanner` 320pt 宽容器`.accessibility5` 宽度 320无横向溢出)、
高度有限且 > 标准字号高度(= 真的长高而不是被裁)。
26. RED`TelemetryChip``lineLimit(1)` 的等宽数字)在 AX5 下宽度 ≤ 320且**AX2 与 AX5 同高**
`DS.Typography.numericClamp` 夹取生效 —— 表格数字不许炸版;落地时把原计划的
"增幅 ≤ 2.2×"换成了这条更强、更确定的等式断言)。
27. RED`dsMetaText()` 的元信息行在 AX5 下同样受夹取(与 26 同一策略,单一出处)。
28. RED`DSButtonStyle` 在 AX5 下高度 ≥ `DS.Layout.minHitTarget` 且标签可换行不横向溢出。
29. RED**外部件度量,越界只报不改**AX5 下键帽两行所需高度 vs 固定 `barHeight`44+8
实测 **108.24pt vs 52pt → 裁切**footnote 行高 52.51 + caption2 行高 47.73 + 内衬 8
`Components/KeyBar.swift` 不在 C4 Owns故以 `withKnownIssue` 记为已知缺口(修好后用例会
报 "known issue was not recorded",提醒删标记)。
注:不能用 `performAsCurrent { KeyBarView() }` 量 —— `UIFont.preferredFont(forTextStyle:)`
读 App 级 content size category不看 `UITraitCollection.current`,那样量出的是默认字号
38pt的**假绿**;改用 `compatibleWith:` 取真实 AX5 行高。
### T-iOS-35 · web 分享 QR`?join=`)互通 —— C4 追加
真源:`public/share.ts:55` 的 URL 形状 **`${location.origin}/?join=${sessionId}`**
`src/server.ts``GET /?join=<id>``DeepLinkRouter.swift:56-65` 今天只认 `webterminal://open`
http(s) 一律 `.ignore``DeepLinkRouterTests.swift:84` 钉住了它 —— 本任务**有意**改写该用例)。
主机身份仍**只**经 `HostStore` 解析(`HostEndpoint.originHeader` 是冻结的唯一 origin 派生点)。
文件 `ios/App/WebTermTests/DeepLinkRouterTests.swift`(增补 + 有意改写 1 例)。
**A. 接受 web 分享形状**
30. RED`http://192.168.1.5:3000/?join=<v4>``.joinShared(origin:"http://192.168.1.5:3000", sessionId:)`
31. RED`https://mac.ts.net/?join=<v4>` → origin 省略默认端口(`:443` 不出现)。
32. RED`HTTP://192.168.1.5:3000/?join=<v4>`(大写 scheme/host→ origin 归一化为小写。
33. REDpath 为 `""``"/"` 都接受(`location.origin + "/?join="` 产出 `/`)。
**B. 白名单纪律http(s) 是比自定义 scheme 大得多的输入面,故**更严****
34. RED`join` 值非 v4`Validation.isValidSessionId` 判定)→ `.ignore`
35. RED重复 `join` 键 → `.ignore`(歧义输入绝不部分应用)。
36. RED**多余 query 键**`/?join=<v4>&x=1`)→ `.ignore`web 形状精确只有一个键)。
37. RED非空 path`/manage.html?join=<v4>`)→ `.ignore`
38. RED带 fragment`/?join=<v4>#x`)→ `.ignore`
39. RED带 userinfo`http://u:p@host/?join=<v4>`)→ `.ignore`(二维码钓鱼形状)。
40. RED非 http(s) scheme`ftp://``file://``javascript:`)→ `.ignore`
41. RED`?join=` 但**无 host**`http:///?join=<v4>`)→ `.ignore`
42. RED**有意改写既有用例**`https://open?host=<v4>&join=<v4>``.ignore`
—— 但理由从"scheme 不是 webterminal"变成"web 形状只许**单一** `join` 键"。
原用例名/注释同步改写,避免留下已失效的断言语义。
43. RED`webterminal://open?host=&join=` 等既有拒绝路径**逐条不变**(自定义 scheme 分支零回归)。
**C. 解析主机:只认已配对(二维码是不可信输入,绝不静默配对)**
44. REDorigin 命中已配对主机 → `openSession(host, sessionId)` 恰一次,无 hint。
45. REDorigin 未配对 → **零** open、`showPairing` 一次、hint == `DeepLinkCopy.unknownHostHint`
**不得**据链接内容自动新增主机)。
46. REDhint 文案**不回显**链接内容(不含 origin 串、不含 sessionId—— 防钓鱼/防日志注入。
47. REDorigin 比较走 `HostEndpoint.originHeader``http://host:3000``http://HOST:3000/` 视为同一主机;
`http://host:3001`(端口不同)**不是**同一主机。
48. RED`https://host/``http://host/`scheme 不同)**不是**同一主机。
49. RED冷启动 stash 对 `.joinShared` 同样生效ready 前 handle → markReady 后恰应用一次)。
50. RED非法 web 链接 → `ignoredCount + 1`,且**不入 stash**(与既有 `.ignore` 同一通路)。

View File

@@ -54,9 +54,26 @@ struct KeyBarButtonSpec: Equatable, Sendable {
}
}
/// Push-to-talk outlets for the 🎤 key (T-iOS-31). Supplied as a PAIR a mic
/// that can start but not stop would leave the microphone hot.
struct KeyBarVoiceHandlers {
let onDown: @MainActor () -> Void
let onUp: @MainActor () -> Void
}
/// Button order/copy transcribed from `public/keybar.ts` `KEYBAR_BUTTONS`
/// (most-used Claude Code keys first). The 🎤 voice button is P2 (T-iOS-31).
/// (most-used Claude Code keys first), plus the 🎤 push-to-talk key
/// (T-iOS-31) which mirrors `keybar.ts buildMicButton` appended at the END,
/// only when voice handlers are supplied, and deliberately OUTSIDE
/// `buttons`/`KeyByteMap`: it maps to no bytes at all.
enum KeyBarLayout {
/// 🎤 glyph + caption, transcribed from `keybar.ts buildMicButton`.
static let voiceLabel = "🎤"
static let voiceCaption = "语音"
/// Accessibility title. Unlike the web (Chrome ships audio to Google), iOS
/// prefers on-device recognition the copy says what actually happens.
static let voiceTitle = "按住说话 — 转成文字后要你确认才送进终端"
static let buttons: [KeyBarButtonSpec] = [
KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断",
title: "Esc — interrupt Claude / dismiss", isPrimary: true),
@@ -117,10 +134,19 @@ private enum KeyBarMetrics {
final class KeyBarView: UIInputView {
/// Tap outlet. `@MainActor`-typed so the handler can drive the ViewModel.
var onKey: (@MainActor (KeyByteMap.Key) -> Void)?
/// Built buttons in layout order (test-visible).
/// Built buttons in layout order (test-visible). The 🎤 key is NOT in here
/// it sends no bytes, so it stays out of the KeyByteMap-backed list.
private(set) var keyButtons: [UIButton] = []
/// The 🎤 push-to-talk key, nil unless voice handlers were supplied.
private(set) var voiceButton: UIButton?
init() {
private let voice: KeyBarVoiceHandlers?
/// - Parameter voice: press/release outlets for the 🎤 key (T-iOS-31).
/// nil no mic key at all (mirrors the web bar, which only renders it
/// when speech is supported AND a trigger is supplied).
init(voice: KeyBarVoiceHandlers? = nil) {
self.voice = voice
super.init(
frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight),
inputViewStyle: .keyboard
@@ -150,6 +176,11 @@ final class KeyBarView: UIInputView {
keyButtons = keyButtons + [button]
stack.addArrangedSubview(button)
}
if let voice {
let mic = makeVoiceButton(voice)
voiceButton = mic
stack.addArrangedSubview(mic)
}
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
@@ -177,41 +208,67 @@ final class KeyBarView: UIInputView {
}
private func makeButton(for spec: KeyBarButtonSpec) -> UIButton {
// Keycap look: primary (Esc) wears the accent tint, the rest a subtle
// gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is the
// UIKit twin of DS.Palette.textSecondary the DS UIColor surface only
// vends the accent, so native system labels stand in at this boundary.)
var config: UIButton.Configuration = spec.isPrimary ? .tinted() : .gray()
config.cornerStyle = .fixed
config.background.cornerRadius = DS.Radius.sm8
var label = AttributedString(spec.label)
label.font = UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
weight: .semibold
let button = makeKeycap(
label: spec.label, caption: spec.caption,
title: spec.title, isPrimary: spec.isPrimary
)
config.attributedTitle = label
var caption = AttributedString(spec.caption)
caption.font = UIFont.preferredFont(forTextStyle: .caption2)
caption.foregroundColor = .secondaryLabel
config.attributedSubtitle = caption
config.titleAlignment = .center
config.contentInsets = KeyBarMetrics.buttonInsets
let button = UIButton(configuration: config)
if spec.isPrimary {
button.tintColor = DS.Palette.accentUIColor()
}
button.accessibilityLabel = spec.title
// HIG minimum touch target regardless of glyph width.
button.heightAnchor
.constraint(greaterThanOrEqualToConstant: DS.Layout.minHitTarget)
.isActive = true
button.addAction(
UIAction { [weak self] _ in self?.onKey?(spec.key) },
for: .touchUpInside
)
return button
}
/// The 🎤 key: same keycap look, but **press/release** semantics instead of a
/// tap, and it never goes through `onKey` (it maps to no bytes at all).
/// `.touchUpOutside`/`.touchCancel` also release, so sliding a finger off the
/// key can never leave the microphone open.
private func makeVoiceButton(_ voice: KeyBarVoiceHandlers) -> UIButton {
let button = makeKeycap(
label: KeyBarLayout.voiceLabel, caption: KeyBarLayout.voiceCaption,
title: KeyBarLayout.voiceTitle, isPrimary: false
)
button.addAction(UIAction { _ in voice.onDown() }, for: .touchDown)
for event in [UIControl.Event.touchUpInside, .touchUpOutside, .touchCancel] {
button.addAction(UIAction { _ in voice.onUp() }, for: event)
}
return button
}
/// Shared keycap chrome: primary (Esc) wears the accent tint, the rest a
/// subtle gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is
/// the UIKit twin of DS.Palette.textSecondary the DS UIColor surface only
/// vends the accent, so native system labels stand in at this boundary.)
private func makeKeycap(
label: String, caption: String, title: String, isPrimary: Bool
) -> UIButton {
var config: UIButton.Configuration = isPrimary ? .tinted() : .gray()
config.cornerStyle = .fixed
config.background.cornerRadius = DS.Radius.sm8
var attributedLabel = AttributedString(label)
attributedLabel.font = UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
weight: .semibold
)
config.attributedTitle = attributedLabel
var attributedCaption = AttributedString(caption)
attributedCaption.font = UIFont.preferredFont(forTextStyle: .caption2)
attributedCaption.foregroundColor = .secondaryLabel
config.attributedSubtitle = attributedCaption
config.titleAlignment = .center
config.contentInsets = KeyBarMetrics.buttonInsets
let button = UIButton(configuration: config)
if isPrimary {
button.tintColor = DS.Palette.accentUIColor()
}
button.accessibilityLabel = title
// HIG minimum touch target regardless of glyph width.
button.heightAnchor
.constraint(greaterThanOrEqualToConstant: DS.Layout.minHitTarget)
.isActive = true
return button
}
}
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)

View File

@@ -0,0 +1,152 @@
import AVFoundation
import Foundation
import Speech
/// T-iOS-31 · `VoiceDictating` Speech + AVAudioEngine
/// `VoicePTT.swift` 800 plan §4
/// ****`requiresOnDeviceRecognition`
/// Apple
/// web SEC-L2 `VoiceTranscript.sanitize`
///
/// PTT **** 12
@MainActor
final class SpeechDictation: VoiceDictating {
enum DictationError: LocalizedError {
case recognizerUnavailable
case microphoneUnavailable
var errorDescription: String? {
switch self {
case .recognizerUnavailable: "设备上的语音识别当前不可用。"
case .microphoneUnavailable: "拿不到麦克风输入。"
}
}
}
/// tap Apple
private static let tapBufferSize: AVAudioFrameCount = 1024
private lazy var audioEngine = AVAudioEngine()
private lazy var recognizer = SFSpeechRecognizer(locale: .current) ?? SFSpeechRecognizer()
private var request: SFSpeechAudioBufferRecognitionRequest?
private var task: SFSpeechRecognitionTask?
private var onPartial: (@MainActor (String) -> Void)?
private var isTapInstalled = false
private var latest = VoiceDictationResult(transcript: "", confidence: nil)
func requestAuthorization() async -> VoiceAuthorization {
let speech = await Self.requestSpeechAuthorization()
switch speech {
case .authorized: break
case .restricted: return .restricted
case .denied, .notDetermined: return .deniedSpeech
@unknown default: return .deniedSpeech
}
return await Self.requestMicrophoneAuthorization() ? .granted : .deniedMicrophone
}
func start(onPartial: @escaping @MainActor (String) -> Void) async throws {
guard let recognizer, recognizer.isAvailable else {
throw DictationError.recognizerUnavailable
}
self.onPartial = onPartial
latest = VoiceDictationResult(transcript: "", confidence: nil)
let session = AVAudioSession.sharedInstance()
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
try session.setActive(true, options: .notifyOthersOnDeactivation)
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
self.request = request
let input = audioEngine.inputNode
let format = input.outputFormat(forBus: 0)
// installTap ObjC
guard format.sampleRate > 0, format.channelCount > 0 else {
teardown()
throw DictationError.microphoneUnavailable
}
input.installTap(onBus: 0, bufferSize: Self.tapBufferSize, format: format) { buffer, _ in
request.append(buffer)
}
isTapInstalled = true
audioEngine.prepare()
try audioEngine.start()
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
// Sendable result actor
let transcript = result?.bestTranscription.formattedString
let confidence = result.flatMap { Self.averageConfidence(of: $0.bestTranscription) }
let isFinal = result?.isFinal ?? false
let didFail = error != nil
Task { @MainActor in
self?.ingest(
transcript: transcript, confidence: confidence,
isFinal: isFinal, didFail: didFail
)
}
}
}
func stop() async -> VoiceDictationResult {
teardown()
return latest
}
// MARK: Internals
private func ingest(
transcript: String?, confidence: Double?, isFinal: Bool, didFail: Bool
) {
if let transcript, !transcript.isEmpty {
latest = VoiceDictationResult(transcript: transcript, confidence: confidence)
onPartial?(transcript)
}
guard isFinal || didFail else { return }
teardown()
}
/// tap
private func teardown() {
request?.endAudio()
task?.cancel()
task = nil
request = nil
onPartial = nil
if isTapInstalled {
audioEngine.inputNode.removeTap(onBus: 0)
isTapInstalled = false
}
if audioEngine.isRunning {
audioEngine.stop()
}
try? AVAudioSession.sharedInstance()
.setActive(false, options: .notifyOthersOnDeactivation)
}
private static func averageConfidence(of transcription: SFTranscription) -> Double? {
let values = transcription.segments.map { Double($0.confidence) }.filter { $0 > 0 }
guard !values.isEmpty else { return nil }
return values.reduce(0, +) / Double(values.count)
}
private static func requestSpeechAuthorization() async
-> SFSpeechRecognizerAuthorizationStatus {
await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
}
private static func requestMicrophoneAuthorization() async -> Bool {
await withCheckedContinuation { continuation in
AVAudioApplication.requestRecordPermission { granted in
continuation.resume(returning: granted)
}
}
}
}

View File

@@ -0,0 +1,200 @@
import SwiftUI
/// T-iOS-33 · scrollback find bar
///
/// web `public/search.ts` + //×
/// xterm SearchAddon`terminal-session.ts:547-553`iOS
/// **** + ****
/// SwiftTerm 1.13.0 `findNext`/`findPrevious`/`clearSearch`
/// `SearchOptions` caseSensitive:false / regex:false xterm
///
/// **** SwiftTerm / PTY
/// 退read-only
// MARK: - Engine seam
/// `KeyCommandTerminalView`SwiftTerm
/// `AnyObject` `TerminalSearchModel` UIKit
@MainActor
protocol TerminalSearching: AnyObject {
/// true =
@discardableResult func searchNext(term: String) -> Bool
/// true
@discardableResult func searchPrevious(term: String) -> Bool
///
func searchClear()
}
/// webEnter = nextShift+Enter = prev
enum TerminalSearchDirection: String, Equatable, Sendable {
case next
case previous
}
/// `.idle` = /
enum TerminalSearchOutcome: Equatable, Sendable {
case idle
case found
case notFound
}
/// **** web `hooks.find(input.value, )`
/// trim
enum TerminalSearchQuery {
static func isSubmittable(_ raw: String) -> Bool {
!raw.isEmpty
}
}
// MARK: - Model
/// **** `query`
/// `.idle` didSet
@MainActor
@Observable
final class TerminalSearchModel {
/// named constants
enum Copy {
static let title = "搜索回滚缓冲"
static let placeholder = "搜索终端输出…"
static let noMatch = "无匹配"
static let previous = "上一处匹配"
static let next = "下一处匹配"
static let close = "关闭搜索"
static let open = "搜索"
}
/// `outcome` `.idle`
private struct Attempt: Equatable {
let term: String
let didHit: Bool
}
var query: String = ""
private(set) var isPresented = false
private var lastAttempt: Attempt?
/// SwiftTerm UIKit
@ObservationIgnored private weak var searcher: (any TerminalSearching)?
///
var outcome: TerminalSearchOutcome {
guard let lastAttempt, lastAttempt.term == query else { return .idle }
return lastAttempt.didHit ? .found : .notFound
}
///
var statusText: String? {
outcome == .notFound ? Copy.noMatch : nil
}
/// SwiftTerm `makeUIView`
func attach(_ searcher: any TerminalSearching) {
self.searcher = searcher
}
func present() {
isPresented = true
}
/// web `hide()``hooks.clear()`+
func dismiss() {
isPresented = false
query = ""
lastAttempt = nil
searcher?.searchClear()
}
/// /
func find(_ direction: TerminalSearchDirection) {
let term = query
guard TerminalSearchQuery.isSubmittable(term), let searcher else { return }
let didHit: Bool = switch direction {
case .next: searcher.searchNext(term: term)
case .previous: searcher.searchPrevious(term: term)
}
lastAttempt = Attempt(term: term, didHit: didHit)
}
}
// MARK: - View
/// web `#searchbox``position:fixed; top; right`
/// submit = / ×
struct TerminalSearchBar: View {
let model: TerminalSearchModel
/// web `open()` `input.focus()`
@FocusState private var isFieldFocused: Bool
private enum Metrics {
static let fieldMinWidth: CGFloat = 140
static let fieldMaxWidth: CGFloat = 220
}
var body: some View {
HStack(spacing: DS.Space.xs4) {
searchField
if let status = model.statusText {
Text(status)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
.accessibilityIdentifier("terminal.search.status")
}
iconButton(
systemImage: "chevron.up",
title: TerminalSearchModel.Copy.previous,
identifier: "terminal.search.previousButton"
) { model.find(.previous) }
iconButton(
systemImage: "chevron.down",
title: TerminalSearchModel.Copy.next,
identifier: "terminal.search.nextButton"
) { model.find(.next) }
iconButton(
systemImage: "xmark",
title: TerminalSearchModel.Copy.close,
identifier: "terminal.search.closeButton"
) { model.dismiss() }
}
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs4)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
.onAppear { isFieldFocused = true }
.accessibilityLabel(TerminalSearchModel.Copy.title)
}
private var searchField: some View {
TextField(
TerminalSearchModel.Copy.placeholder,
text: Binding(get: { model.query }, set: { model.query = $0 })
)
.textFieldStyle(.plain)
.font(DS.Typography.callout)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.submitLabel(.search)
.focused($isFieldFocused)
.frame(minWidth: Metrics.fieldMinWidth, maxWidth: Metrics.fieldMaxWidth)
.onSubmit { model.find(.next) }
.accessibilityIdentifier("terminal.search.field")
}
private func iconButton(
systemImage: String,
title: String,
identifier: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemImage)
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityIdentifier(identifier)
}
}

View File

@@ -0,0 +1,579 @@
import Foundation
import Observation
/// T-iOS-31 · PTT+ + 1.5s + epoch
///
/// = + + 800
/// `VoicePTTBanner.swift`SwiftUI `SpeechDictation.swift`
///
/// webplan §7 / 1.5s / epoch = port
/// `public/voice-commands.ts`
/// - `public/voice.ts`PTT `autoSend` false **
/// **
/// - `public/voice-commands.ts`**** + + 0.6
/// - `public/voice-confirm.ts``DEFAULT_WINDOW_MS = 1500`
///
///
/// 1. = ****`VoiceTranscript.sanitize`
/// C0/C1 `\r`/ESC PTY
/// 2. ****`.confirming` arm
/// 3. **1.5s **arm PTY
///
/// 4. **epoch ** epoch`confirm()`
/// /
///
/// `VoiceDictating` //epoch
/// `SpeechDictation` DEFERRED
// MARK: - Recognizer seam
/// TCC
enum VoiceAuthorization: Equatable, Sendable {
case granted
case deniedMicrophone
case deniedSpeech
case restricted
var denialReason: VoiceDenialReason? {
switch self {
case .granted: nil
case .deniedMicrophone: .microphone
case .deniedSpeech: .speech
case .restricted: .restricted
}
}
}
///
enum VoiceDenialReason: Equatable, Sendable {
case microphone
case speech
case restricted
var message: String {
switch self {
case .microphone:
"麦克风权限被拒绝。到 设置 → 隐私与安全性 → 麦克风 里打开 WebTerm 才能按住说话。"
case .speech:
"语音识别权限被拒绝。到 设置 → 隐私与安全性 → 语音识别 里打开 WebTerm。"
case .restricted:
"本设备的语音识别被限制(如「屏幕使用时间」策略),无法使用语音输入。"
}
}
}
/// `confidence`
struct VoiceDictationResult: Equatable, Sendable {
let transcript: String
let confidence: Double?
}
/// `SpeechDictation`Speech + AVAudioEngine
@MainActor
protocol VoiceDictating: AnyObject {
/// + TCC
func requestAuthorization() async -> VoiceAuthorization
/// `onPartial`
func start(onPartial: @escaping @MainActor (String) -> Void) async throws
///
func stop() async -> VoiceDictationResult
}
// MARK: - Transcript sanitisation (security boundary)
/// **** PTY
enum VoiceTranscript {
///
static let maxLength = 2000
/// / C0/C1
private static let whitespaceControls: Set<UInt32> = [0x09, 0x0A, 0x0B, 0x0C, 0x0D]
private static func isControl(_ scalar: Unicode.Scalar) -> Bool {
scalar.value < 0x20 || scalar.value == 0x7F || (0x80...0x9F).contains(scalar.value)
}
///
///
/// `\r`0x0D****
/// web `autoSend: false`
static func sanitize(_ raw: String) -> String {
let scalars = raw.unicodeScalars.compactMap { scalar -> Unicode.Scalar? in
if whitespaceControls.contains(scalar.value) { return " " }
return isControl(scalar) ? nil : scalar
}
let collapsed = String(String.UnicodeScalarView(scalars))
.split(whereSeparator: \.isWhitespace)
.joined(separator: " ")
return String(collapsed.prefix(maxLength))
}
///
static func isInjectable(_ sanitized: String) -> Bool {
!sanitized.isEmpty
}
}
// MARK: - Command matcher (port of public/voice-commands.ts)
/// `.text` =
enum VoiceAction: String, Equatable, Sendable {
case approve
case reject
case text
}
/// gate v1 tool gate web
enum VoiceGateKind: String, Equatable, Sendable {
case tool
case plan
}
/// ASR
struct VoiceGateSnapshot: Equatable, Sendable {
let pendingApproval: Bool
let gate: VoiceGateKind?
}
/// web `VoiceMatchContext`
struct VoiceMatchContext: Equatable, Sendable {
let pendingApproval: Bool
let gate: VoiceGateKind?
let confidence: Double?
}
/// ****
/// `public/voice-commands.ts`/
/// shell
enum VoiceCommandMatcher {
/// `.approve` ASR `.reject`
static let minApproveConfidence = 0.6
static let approvePhrases: Set<String> = [
"确认", "批准", "同意", "通过", "允许", "可以", "", "好的", "好吧", "好啊",
"", "是的", "", "", "继续", "回车",
"yes", "yeah", "yep", "ok", "okay", "confirm", "approve", "accept",
"proceed", "go ahead",
]
static let rejectPhrases: Set<String> = [
"拒绝", "取消", "不行", "不要", "不用", "不同意", "不批准", "不可以", "不允许", "不通过",
"中断", "停止", "算了", "", "",
"no", "nope", "reject", "deny", "cancel", "abort", "decline", "stop",
]
static let negationPrefixes: [String] = [
"", "", "", "", "",
"no", "not", "dont", "cannot", "wont", "never",
]
/// web `PUNCTUATION_RE` ASCII + CJK
private static let punctuation = CharacterSet(
charactersIn: "'.,!?;:,。!?;:、「」『』“”‘’()()[]{}【】〈〉《》…—-_/\\|\"`~@#$%^&*+=<>"
)
private static func isASCIIWord(_ scalar: Unicode.Scalar) -> Bool {
("a"..."z").contains(scalar) || ("0"..."9").contains(scalar)
}
/// `trim don'tdont trim` web
static func normalize(_ raw: String) -> String {
let lowered = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let stripped = String(String.UnicodeScalarView(
lowered.unicodeScalars.filter { !punctuation.contains($0) }
))
return stripped.split(whereSeparator: \.isWhitespace).joined(separator: " ")
}
/// ASCII `now`
static func startsWithNegation(_ normalized: String) -> Bool {
negationPrefixes.contains { prefix in
guard normalized.hasPrefix(prefix) else { return false }
guard let head = prefix.unicodeScalars.first, isASCIIWord(head) else { return true }
guard let next = normalized.dropFirst(prefix.count).unicodeScalars.first else {
return true
}
return !isASCIIWord(next)
}
}
/// + gate + `.text`
static func match(transcript: String, context: VoiceMatchContext) -> VoiceAction {
let normalized = normalize(transcript)
guard context.pendingApproval, !normalized.isEmpty else { return .text }
guard context.gate == .tool else { return .text }
if startsWithNegation(normalized) {
return rejectPhrases.contains(normalized) ? .reject : .text
}
if rejectPhrases.contains(normalized) { return .reject }
guard approvePhrases.contains(normalized) else { return .text }
if let confidence = context.confidence, confidence < minApproveConfidence {
return .text
}
return .approve
}
}
// MARK: - Epoch (mis-send guard)
/// `sessionId` id`generation` **
/// **
struct VoiceEpoch: Equatable, Sendable {
let sessionId: UUID?
let generation: Int
}
/// epoch
enum VoiceEpochPolicy {
///
///
/// `.reconnecting` **** PTY
/// IDLE_TTL `attached`
///
static func invalidates(_ banner: TerminalViewModel.ConnectionBanner) -> Bool {
switch banner {
case .reconnecting: true
case .none, .connecting: false
}
}
}
/// epoch `TerminalScreen` `TerminalViewModel` sessionId / banner
/// PTT
@MainActor
@Observable
final class VoiceEpochSource {
private(set) var generation = 0
private(set) var sessionId: UUID?
var epoch: VoiceEpoch {
VoiceEpoch(sessionId: sessionId, generation: generation)
}
func noteSession(_ id: UUID?) {
sessionId = id
}
func noteBanner(_ banner: TerminalViewModel.ConnectionBanner) {
guard VoiceEpochPolicy.invalidates(banner) else { return }
generation += 1
}
}
// MARK: - Pending action / resolution
///
enum VoiceIntent: Equatable, Sendable {
///
case text(String)
/// held tool gate
case decision(VoiceDecision)
}
/// gate approve/reject gate wiring
enum VoiceDecision: String, Equatable, Sendable {
case approve
case reject
}
/// **** epoch
struct VoicePendingAction: Equatable, Sendable {
let intent: VoiceIntent
let epoch: VoiceEpoch
///
let preview: String
}
/// UI
enum VoiceResolution: Equatable, Sendable {
case committed(VoiceIntent)
case undone
case discarded
case staleEpoch
case empty
case readOnly
}
/// gate + ****nil gate
/// 退 web held gate dictation
struct VoiceGateBridge {
let snapshot: @MainActor () -> VoiceGateSnapshot
let decide: @MainActor (VoiceDecision) -> Void
}
// MARK: - ViewModel
/// PTT `Clock`
@MainActor
@Observable
final class VoicePTTViewModel {
/// web `voice-confirm.ts` `DEFAULT_WINDOW_MS = 1500`
static let undoWindow: Duration = .milliseconds(1500)
enum Phase: Equatable {
case idle
case requestingPermission
case listening(partial: String)
case confirming(VoicePendingAction)
case undoWindow(VoicePendingAction)
case denied(VoiceDenialReason)
case failed(message: String)
}
/// named constants
enum Copy {
static let hold = "按住说话"
static let requesting = "正在请求麦克风权限…"
static let listening = "正在听…松手结束"
static let listeningHint = "松手后要你确认才会发送"
static let confirmTitle = "确认发送到终端"
static let approveTitle = "确认批准这次工具调用"
static let rejectTitle = "确认拒绝这次工具调用"
static let send = "发送"
static let cancel = "取消"
static let undo = "撤销"
static let sending = "1.5 秒后发送 —— 可撤销"
static let deciding = "1.5 秒后提交决定 —— 可撤销"
static let recognizerFailed = "语音识别没能启动"
static let noticeUndone = "已撤销,什么都没发送。"
static let noticeDiscarded = "已丢弃这段口述。"
static let noticeStaleEpoch = "会话已切换或重连过,这段口述没有发送(避免打进别的会话)。"
static let noticeEmpty = "没听到内容,什么都没发送。"
static let noticeReadOnly = "这个会话已结束,不能再输入。"
static let dismissNotice = "关闭提示"
}
private(set) var phase: Phase = .idle
private(set) var lastResolution: VoiceResolution?
@ObservationIgnored private let dictation: any VoiceDictating
@ObservationIgnored private let clock: any Clock<Duration>
@ObservationIgnored private let epoch: @MainActor () -> VoiceEpoch
@ObservationIgnored private let isReadOnly: @MainActor () -> Bool
@ObservationIgnored private let inject: @MainActor (String) -> Void
@ObservationIgnored private let gate: VoiceGateBridge?
/// PTT
@ObservationIgnored private var isPressed = false
/// `dictation.start` start
@ObservationIgnored private var isStarting = false
/// epoch `VoicePendingAction`
@ObservationIgnored private var dictationEpoch = VoiceEpoch(sessionId: nil, generation: 0)
@ObservationIgnored private var windowTask: Task<Void, Never>?
init(
dictation: any VoiceDictating,
clock: any Clock<Duration>,
epoch: @escaping @MainActor () -> VoiceEpoch,
isReadOnly: @escaping @MainActor () -> Bool,
inject: @escaping @MainActor (String) -> Void,
gate: VoiceGateBridge? = nil
) {
self.dictation = dictation
self.clock = clock
self.epoch = epoch
self.isReadOnly = isReadOnly
self.inject = inject
self.gate = gate
}
// MARK: Derived UI state
var isUndoWindowOpen: Bool {
if case .undoWindow = phase { return true }
return false
}
var isListening: Bool {
if case .listening = phase { return true }
return false
}
/// nil = 西
var pendingAction: VoicePendingAction? {
switch phase {
case .confirming(let pending), .undoWindow(let pending): pending
default: nil
}
}
/// `.committed`
var noticeText: String? {
switch lastResolution {
case .none, .committed: nil
case .undone: Copy.noticeUndone
case .discarded: Copy.noticeDiscarded
case .staleEpoch: Copy.noticeStaleEpoch
case .empty: Copy.noticeEmpty
case .readOnly: Copy.noticeReadOnly
}
}
func clearNotice() {
lastResolution = nil
}
// MARK: PTT
///
func pressDown() async {
guard pendingAction == nil else { return }
isPressed = true
lastResolution = nil
guard !isReadOnly() else {
isPressed = false
phase = .idle
lastResolution = .readOnly
return
}
dictationEpoch = epoch()
phase = .requestingPermission
let authorization = await dictation.requestAuthorization()
guard authorization == .granted else {
isPressed = false
phase = .denied(authorization.denialReason ?? .restricted)
return
}
//
guard isPressed else {
phase = .idle
return
}
await beginListening()
}
/// `beginListening`
func pressUp() async {
isPressed = false
guard !isStarting, isListening else { return }
await finishListening()
}
private func beginListening() async {
phase = .listening(partial: "")
isStarting = true
do {
try await dictation.start { [weak self] partial in
guard let self, self.isListening else { return }
self.phase = .listening(partial: partial)
}
} catch {
isStarting = false
isPressed = false
phase = .failed(message: Self.failureMessage(error))
return
}
isStarting = false
//
guard isPressed else {
await finishListening()
return
}
}
private func finishListening() async {
let result = await dictation.stop()
let text = VoiceTranscript.sanitize(result.transcript)
guard VoiceTranscript.isInjectable(text) else {
phase = .idle
lastResolution = .empty
return
}
let snapshot = gate?.snapshot() ?? VoiceGateSnapshot(pendingApproval: false, gate: nil)
let context = VoiceMatchContext(
pendingApproval: snapshot.pendingApproval,
gate: snapshot.gate,
confidence: result.confidence
)
// **** web
let intent: VoiceIntent = switch VoiceCommandMatcher.match(
transcript: result.transcript, context: context
) {
case .text: .text(text)
case .approve: .decision(.approve)
case .reject: .decision(.reject)
}
phase = .confirming(
VoicePendingAction(intent: intent, epoch: dictationEpoch, preview: text)
)
}
private static func failureMessage(_ error: any Error) -> String {
guard let described = (error as? any LocalizedError)?.errorDescription,
!described.isEmpty
else { return Copy.recognizerFailed }
return "\(Copy.recognizerFailed)\(described)"
}
// MARK: Confirm / undo window
/// epoch arm
func confirm() {
guard case .confirming(let pending) = phase else { return }
guard epoch() == pending.epoch else {
discard(.staleEpoch)
return
}
phase = .undoWindow(pending)
windowTask = Task { [weak self] in
guard let self else { return }
do {
try await clock.sleep(for: Self.undoWindow, tolerance: nil)
} catch {
return // undo
}
commit()
}
}
///
func cancel() {
guard case .confirming = phase else { return }
phase = .idle
lastResolution = .discarded
}
/// App
func undo() {
guard case .undoWindow = phase else { return }
windowTask?.cancel()
phase = .idle
lastResolution = .undone
}
/// epoch confirm
private func commit() {
guard case .undoWindow(let pending) = phase else { return }
guard epoch() == pending.epoch else {
discard(.staleEpoch)
return
}
switch pending.intent {
case .text(let text):
inject(text)
case .decision(let decision):
guard let gate else {
discard(.discarded) // gate
return
}
gate.decide(decision)
}
phase = .idle
lastResolution = .committed(pending.intent)
}
private func discard(_ resolution: VoiceResolution) {
windowTask?.cancel()
phase = .idle
lastResolution = resolution
}
// MARK: Deterministic test barrier
///
func waitUntilWindowSettled() async {
await windowTask?.value
}
}

View File

@@ -0,0 +1,143 @@
import SwiftUI
/// T-iOS-31 · PTT SwiftUI `VoicePTT.swift`
/// `VoicePTTViewModel` `VoicePTT.swift`
/// 800 plan §4
/// PTT / / / / /
/// nil 西
struct VoicePTTBanner: View {
let model: VoicePTTViewModel
/// `.idle`
private var hasCard: Bool {
if case .idle = model.phase { return model.noticeText != nil }
return true
}
var body: some View {
if hasCard {
card
.padding(DS.Space.md12)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
.accessibilityIdentifier("terminal.voice.card")
}
}
@ViewBuilder private var card: some View {
switch model.phase {
case .requestingPermission:
label(VoicePTTViewModel.Copy.requesting, systemImage: "mic.badge.plus")
case .listening(let partial):
listening(partial: partial)
case .confirming(let pending):
confirming(pending)
case .undoWindow(let pending):
undoWindow(pending)
case .denied(let reason):
notice(reason.message, systemImage: "mic.slash")
case .failed(let message):
notice(message, systemImage: "exclamationmark.triangle")
case .idle:
if let text = model.noticeText {
notice(text, systemImage: "info.circle")
}
}
}
private func label(_ text: String, systemImage: String) -> some View {
Label(text, systemImage: systemImage)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
}
private func listening(partial: String) -> some View {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
label(VoicePTTViewModel.Copy.listening, systemImage: "waveform")
if !partial.isEmpty {
Text(partial)
.font(DS.Typography.mono(.callout))
.foregroundStyle(DS.Palette.textSecondary)
.accessibilityIdentifier("terminal.voice.partial")
}
Text(VoicePTTViewModel.Copy.listeningHint)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textTertiary)
}
}
private func confirming(_ pending: VoicePendingAction) -> some View {
VStack(alignment: .leading, spacing: DS.Space.sm8) {
Text(title(for: pending.intent))
.font(DS.Typography.callout.weight(.semibold))
.foregroundStyle(DS.Palette.textPrimary)
Text(pending.preview)
.font(DS.Typography.mono(.callout))
.foregroundStyle(DS.Palette.textPrimary)
.accessibilityIdentifier("terminal.voice.preview")
HStack(spacing: DS.Space.sm8) {
Button(VoicePTTViewModel.Copy.cancel) { model.cancel() }
.buttonStyle(.bordered)
.accessibilityIdentifier("terminal.voice.cancelButton")
Button(VoicePTTViewModel.Copy.send) { model.confirm() }
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("terminal.voice.confirmButton")
}
.frame(minHeight: DS.Layout.minHitTarget)
}
}
private func undoWindow(_ pending: VoicePendingAction) -> some View {
HStack(spacing: DS.Space.sm8) {
VStack(alignment: .leading, spacing: DS.Space.xs2) {
Text(pending.intent.isDecision
? VoicePTTViewModel.Copy.deciding
: VoicePTTViewModel.Copy.sending)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
Text(pending.preview)
.font(DS.Typography.mono(.callout))
.foregroundStyle(DS.Palette.textPrimary)
}
Spacer(minLength: DS.Space.sm8)
Button(VoicePTTViewModel.Copy.undo) { model.undo() }
.buttonStyle(.borderedProminent)
.frame(minHeight: DS.Layout.minHitTarget)
.accessibilityIdentifier("terminal.voice.undoButton")
}
}
private func notice(_ text: String, systemImage: String) -> some View {
HStack(spacing: DS.Space.sm8) {
label(text, systemImage: systemImage)
Spacer(minLength: DS.Space.sm8)
Button {
model.clearNotice()
} label: {
Image(systemName: "xmark")
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
}
.buttonStyle(.plain)
.accessibilityLabel(VoicePTTViewModel.Copy.dismissNotice)
}
}
private func title(for intent: VoiceIntent) -> String {
switch intent {
case .text: VoicePTTViewModel.Copy.confirmTitle
case .decision(.approve): VoicePTTViewModel.Copy.approveTitle
case .decision(.reject): VoicePTTViewModel.Copy.rejectTitle
}
}
}
extension VoiceIntent {
var isDecision: Bool {
if case .decision = self { return true }
return false
}
}

View File

@@ -4,12 +4,14 @@ import Observation
import OSLog
import WireProtocol
/// T-iOS-22 · Deep-link routing.
/// T-iOS-22 / T-iOS-35 · Deep-link routing.
///
/// Two external entry points share ONE validation surface:
/// Three external entry points share ONE validation surface:
/// - `webterminal://open?host=<uuid>&join=<uuid>` (scheme registered in
/// project.yml `CFBundleURLTypes`; web QR `?join=`
/// T-iOS-35 ), and
/// project.yml `CFBundleURLTypes`),
/// - **the web share link `http(s)://<host>[:<port>]/?join=<uuid>`**
/// (T-iOS-35 the exact shape `public/share.ts:55` puts in the 🔗 QR, i.e.
/// `${location.origin}/?join=${sessionId}`), and
/// - the WEBTERM_GATE push payload `{sessionId}` (T-iOS-21 reuses
/// `route(from:)` same UUID rules, no second parser).
///
@@ -25,6 +27,11 @@ enum DeepLinkRouter {
enum Route: Equatable, Sendable {
/// `webterminal://open` with both ids syntactically valid.
case openSession(hostId: UUID, sessionId: UUID)
/// Web share link (`<origin>/?join=<uuid>`). Carries the NORMALISED
/// origin (`HostEndpoint.originHeader`) instead of a host id the web
/// UI has no idea what UUID this device filed that host under, so
/// resolution is "which paired host serves this origin" (`DeepLinkHandler`).
case joinShared(origin: String, sessionId: UUID)
/// Push payload with a valid `sessionId` (no host id in the payload
/// T-iOS-21's notification handler owns the resolution strategy).
case gateSession(sessionId: UUID)
@@ -40,6 +47,19 @@ enum DeepLinkRouter {
static let emptyPaths: Set<String> = ["", "/"]
}
/// Whitelisted web-share shape (`http(s)://<host>[:<port>]/?join=<uuid>`).
///
/// Deliberately STRICTER than the custom-scheme branch: `webterminal://` can
/// only come from something that knows our private scheme, while an http(s)
/// URL is whatever a QR code or another app decided to hand us. So the query
/// must be EXACTLY one `join` key (no "unknown keys are ignored" leniency
/// here), the path must be empty/`/`, and userinfo/fragment are rejected
/// outright (`http://user:pass@real-host@evil/` is a classic QR phish).
private enum WebShape {
static let schemes: Set<String> = ["http", "https"]
static let queryItemCount = 1
}
/// Whitelisted query keys (exact match; unknown keys are ignored).
private enum QueryKey {
static let host = "host"
@@ -55,8 +75,21 @@ enum DeepLinkRouter {
static func route(url: URL) -> Route {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.scheme?.lowercased() == LinkShape.scheme,
components.host?.lowercased() == LinkShape.action,
let scheme = components.scheme?.lowercased(),
// Credentials/fragment are never part of either whitelisted shape.
components.user == nil, components.password == nil,
components.fragment == nil
else { return .ignore }
if scheme == LinkShape.scheme { return customSchemeRoute(components) }
if WebShape.schemes.contains(scheme) { return webShareRoute(url: url, components: components) }
return .ignore
}
/// `webterminal://open?host=<uuid>&join=<uuid>` unchanged semantics
/// (unknown extra query keys stay tolerated; both ids required).
private static func customSchemeRoute(_ components: URLComponents) -> Route {
guard components.host?.lowercased() == LinkShape.action,
LinkShape.emptyPaths.contains(components.path),
let hostId = uniqueValidatedId(in: components, key: QueryKey.host),
let sessionId = uniqueValidatedId(in: components, key: QueryKey.join)
@@ -64,6 +97,23 @@ enum DeepLinkRouter {
return .openSession(hostId: hostId, sessionId: sessionId)
}
/// `http(s)://<host>[:<port>]/?join=<uuid>` the web 🔗 share QR.
///
/// The origin is derived by `HostEndpoint` (the FROZEN single derivation
/// point, plan §3.1) rather than assembled here: it also does the http(s) +
/// non-empty-host validation and the default-port/lowercasing normalisation
/// the store's own origins were built with, so the comparison in
/// `DeepLinkHandler` is apples to apples.
private static func webShareRoute(url: URL, components: URLComponents) -> Route {
guard LinkShape.emptyPaths.contains(components.path),
let items = components.queryItems, items.count == WebShape.queryItemCount,
let item = items.first, item.name == QueryKey.join,
let raw = item.value, let sessionId = validatedId(raw),
let endpoint = HostEndpoint(baseURL: url)
else { return .ignore }
return .joinShared(origin: endpoint.originHeader, sessionId: sessionId)
}
// MARK: - Push entry (WEBTERM_GATE payload, reused by T-iOS-21)
static func route(from payload: [AnyHashable: Any]) -> Route {
@@ -175,12 +225,33 @@ final class DeepLinkHandler {
// MARK: - Apply (host id resolves ONLY through the store)
private func apply(_ route: DeepLinkRouter.Route) async {
// `.gateSession` never reaches here in P1: it only exists for the
// push path, whose handling (host resolution incl.) is T-iOS-21.
guard case let .openSession(hostId, sessionId) = route else { return }
switch route {
case let .openSession(hostId, sessionId):
// Custom scheme: the link carries OUR host id.
await openSession(sessionId, onHostMatching: { $0.id == hostId })
case let .joinShared(origin, sessionId):
// T-iOS-35 · web share QR: match on the normalised origin. An
// unpaired origin must NEVER be auto-added a QR code is untrusted
// input, so it falls through to the pairing flow (where the user
// sees and confirms the target) exactly like an unknown host id.
await openSession(sessionId, onHostMatching: { $0.endpoint.originHeader == origin })
case .gateSession, .ignore:
// `.gateSession` never reaches here in P1: it only exists for the
// push path, whose handling (host resolution incl.) is T-iOS-21.
return
}
}
/// The ONE host-resolution path (both link shapes funnel through it): store
/// lookup open, or unknown pairing hint. The hint copy is deliberately
/// generic it never echoes the link's origin or session id (a link is
/// untrusted text; quoting it back is a phishing/log-injection surface).
private func openSession(
_ sessionId: UUID, onHostMatching matches: (HostRegistry.Host) -> Bool
) async {
do {
let hosts = try await loadHosts()
guard let host = hosts.first(where: { $0.id == hostId }) else {
guard let host = hosts.first(where: matches) else {
hintMessage = DeepLinkCopy.unknownHostHint
actions.showPairing()
return

View File

@@ -0,0 +1,125 @@
import Observation
import SwiftUI
/// T-iOS-34 · / /
///
/// `RootView` **** `.preferredColorScheme(.dark)`
///
/// ** token **
/// `Tokens.swift` light
/// `AppThemeTests` WCAG
///
/// = `.dark` web `DEFAULT_SETTINGS.theme = 'dark'`
/// `public/settings.ts:33`
enum AppTheme: String, CaseIterable, Equatable, Sendable {
/// iOS //
case system
///
case dark
///
case light
/// SwiftUI `preferredColorScheme` `nil` =
/// ""
/// App
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .dark: return .dark
case .light: return .light
}
}
/// App
var label: String {
switch self {
case .system: return "跟随系统"
case .dark: return "深色"
case .light: return "浅色"
}
}
/// DS
///
var symbolName: String {
switch self {
case .system: return "circle.lefthalf.filled"
case .dark: return "moon.fill"
case .light: return "sun.max.fill"
}
}
/// ****""
/// `.system`
func resolvedScheme(system: ColorScheme) -> ColorScheme {
colorScheme ?? system
}
}
// MARK: -
/// /****/
/// 退 `fallback`
///
enum AppThemeCodec {
/// /
static let fallback = AppTheme.dark
static func decode(_ raw: String?) -> AppTheme {
raw.flatMap(AppTheme.init(rawValue:)) ?? fallback
}
static func encode(_ theme: AppTheme) -> String {
theme.rawValue
}
}
// MARK: -
/// `UserDefaults` `SecItemShim` live
/// ****
/// / Keychain§1.1
protocol ThemeDefaults {
func themeRaw(forKey key: String) -> String?
func setThemeRaw(_ value: String, forKey key: String)
}
/// `UserDefaults.standard`
struct LiveThemeDefaults: ThemeDefaults {
func themeRaw(forKey key: String) -> String? {
UserDefaults.standard.string(forKey: key)
}
func setThemeRaw(_ value: String, forKey key: String) {
UserDefaults.standard.set(value, forKey: key)
}
}
// MARK: - ThemeStore
/// `RootView`
///
/// `select` **** no-op init
/// `theme` `@Observable` UI
@MainActor
@Observable
final class ThemeStore {
/// `UserDefaults` App
static let storageKey = "webterm.appearance.theme"
private(set) var theme: AppTheme
@ObservationIgnored private let defaults: any ThemeDefaults
init(defaults: any ThemeDefaults = LiveThemeDefaults()) {
self.defaults = defaults
self.theme = AppThemeCodec.decode(defaults.themeRaw(forKey: Self.storageKey))
}
/// `@Observable`
func select(_ next: AppTheme) {
guard next != theme else { return }
theme = next
defaults.setThemeRaw(AppThemeCodec.encode(next), forKey: Self.storageKey)
}
}

View File

@@ -72,6 +72,9 @@ struct TelemetryChip: View {
.background(.quaternary, in: Capsule())
.opacity(isStale ? DS.Opacity.stale : 1)
.grayscale(isStale ? 1 : 0)
// T-iOS-34 · a `lineLimit(1)` tabular pill cannot wrap, so past a point
// extra size only truncates it. Same ceiling as `dsMetaText`.
.dynamicTypeSize(...DS.Typography.numericClamp)
}
}
@@ -122,6 +125,12 @@ struct DSButtonStyle: ButtonStyle {
enum Kind { case primary, secondary, destructive }
var kind: Kind = .primary
/// T-iOS-34 · how far a label may shrink before it would rather overflow.
/// A gate's two half-width buttons at AX5 hold a word that cannot hyphenate
/// ("Approve"); a small scale-down plus wrapping keeps it inside the pill
/// instead of bleeding past the rounded edge.
fileprivate static let labelMinScale: CGFloat = 0.75
func makeBody(configuration: Configuration) -> some View {
DSButtonBody(kind: kind, configuration: configuration)
}
@@ -137,6 +146,12 @@ struct DSButtonStyle: ButtonStyle {
var body: some View {
configuration.label
.font(DS.Typography.body.weight(.semibold))
// Dynamic Type: wrap + center + a bounded shrink, then let the
// pill grow taller. `minHeight` (not `height`) is what keeps a
// wrapped AX5 label from being clipped to 44pt.
.multilineTextAlignment(.center)
.minimumScaleFactor(DSButtonStyle.labelMinScale)
.padding(.horizontal, DS.Space.sm8)
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
.foregroundStyle(foreground)
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12))

View File

@@ -0,0 +1,98 @@
import SwiftUI
import UIKit
/// T-iOS-34 · ****
///
/// ""
///
///
/// - = `#100F0D` / `#ECE9E3` web `--bg` / `--text`****
/// - = `#F6F7F9` / `#1A1D24` web `public/settings.ts`
/// `THEMES.light`iOS
///
/// caret / selection **** accent `#E3A64A` /
/// `#C9892F`
///
/// `SwiftTerm.TerminalView` `nativeBackgroundColor` setter
/// UIColor `terminal.backgroundColor``iOSTerminalView.swift:1207`
/// `traitCollectionDidChange` ** UIColor "
/// "** `apply`
/// `colors(for:)` `dynamic*` UIColor
/// trait
enum TerminalPalette {
/// ****
/// 便 SwiftTerm便
struct Colors: Equatable {
let background: UIColor
let foreground: UIColor
let caret: UIColor
let selection: UIColor
}
///
private enum Dark {
static let background: UInt32 = 0x100F_0D
static let foreground: UInt32 = 0xECE9_E3
}
/// web `THEMES.light`
private enum Light {
static let background: UInt32 = 0xF6F7_F9
static let foreground: UInt32 = 0x1A1D_24
}
static func colors(for scheme: ColorScheme) -> Colors {
let style = scheme.userInterfaceStyle
let accent = DS.Palette.accentUIColor()
.resolvedColor(with: UITraitCollection(userInterfaceStyle: style))
let isDark = style == .dark
return Colors(
background: UIColor(hex: isDark ? Dark.background : Light.background),
foreground: UIColor(hex: isDark ? Dark.foreground : Light.foreground),
caret: accent,
selection: accent
)
}
/// SwiftUI/UIKit trait
static func dynamicBackground() -> UIColor {
UIColor { trait in colors(for: trait.colorScheme).background }
}
///
static func dynamicForeground() -> UIColor {
UIColor { trait in colors(for: trait.colorScheme).foreground }
}
}
// MARK: - ColorScheme UIUserInterfaceStyle
extension ColorScheme {
/// SwiftUI UIKit`ColorScheme` light/dark case
var userInterfaceStyle: UIUserInterfaceStyle {
self == .dark ? .dark : .light
}
}
extension UITraitCollection {
/// UIKit SwiftUI`.unspecified` iOS
var colorScheme: ColorScheme {
userInterfaceStyle == .dark ? .dark : .light
}
}
// MARK: - Hex
extension UIColor {
/// `0xRRGGBB` sRGB token /CSS
/// 01
convenience init(hex: UInt32, alpha: CGFloat = 1) {
self.init(
red: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255,
alpha: alpha
)
}
}

View File

@@ -9,6 +9,16 @@ import UIKit
/// (refined native, Apple HIG), dark-mode-first, amber-gold accent (desktop-matched)
/// continuing the web selection color.
///
/// T-iOS-34 · dark-mode-first no longer means dark-mode-only: the app has a real
/// theme setting (`AppTheme` / `ThemeStore`, injected once by `RootView`), so
/// EVERY color token must resolve sensibly in both schemes. Adaptive tokens are
/// built with the private `adaptive(dark:light:)` helper below and their light
/// values are contrast-audited in `AppThemeTests` (WCAG 1.4.11, 3:1 floor for
/// non-text UI). Known accepted gap: `accent` at its frozen light value #C9892F
/// is 2.88:1 on white fine as a FILL (dark ink on top is 6.2:1) but marginal
/// as bare tinted text; the value is pinned by `DesignSystemTests` and left
/// unchanged here (reported to the orchestrator instead of silently re-toned).
///
/// Vocabulary (all under `DS.`):
/// - `Palette` adaptive `accent` (amber gold, matches desktop) · semantic `status*` colors
/// (color is NEVER the only status signal pair with a symbol,
@@ -59,21 +69,36 @@ enum DS {
/// ink for contrast mirrors web --on-accent #1A1305).
static let onAccent = rgb(26, 19, 5)
/// Faint accent wash (selection/soft highlight) web --accent-soft.
static let accentSoft = Color(red: 0xE3 / 255.0, green: 0xA6 / 255.0, blue: 0x4A / 255.0, opacity: 0.15)
/// Adaptive (T-iOS-34): the dark wash is the web value verbatim; on a
/// light background a 15% wash of the BRIGHT gold reads as nothing, so
/// light uses the deeper `--accent-2` gold at a slightly higher alpha.
/// Still a wash in both (alpha < 0.3 never a solid fill).
static let accentSoft = adaptive(
dark: UIColor(hex: 0xE3A6_4A, alpha: 0.15),
light: UIColor(hex: 0xC989_2F, alpha: 0.18)
)
// Semantic status colors (match the desktop/web status palette)
// These are the ONLY status colors. `StatusStyle` pairs each with a
// distinct SF Symbol so status is never conveyed by color alone. Hex
// mirrors the web's warm status set (public/style.css:18-20) so iOS and
// desktop read the same. `waiting` amber #F5B14C stays distinct from the
// gold accent #E3A64A (brighter/less brown), and is only ever a small
// badge fill, never a large accent surface.
/// working #46D07F (web --green).
static let statusWorking = rgb(70, 208, 127)
/// waiting / needs-me #F5B14C (web --amber).
static let statusWaiting = rgb(245, 177, 76)
/// stuck #FF6B6B (web --red).
static let statusStuck = rgb(255, 107, 107)
// distinct SF Symbol so status is never conveyed by color alone. The
// DARK values mirror the web's warm status set (public/style.css:18-20)
// byte for byte so iOS and desktop read the same; `waiting` amber
// #F5B14C stays distinct from the gold accent #E3A64A (brighter/less
// brown), and is only ever a small badge fill, never a large surface.
//
// T-iOS-34 · the LIGHT values are new. The web chrome is dark-only, so
// there was nothing to mirror: each light value is the same hue darkened
// until it clears WCAG 1.4.11 (3:1 for non-text UI) against a white
// background the bright dark-mode values sit at 1.96:1 (#46D07F),
// 1.60:1 (#F5B14C) and 2.74:1 (#FF6B6B) on white, i.e. unreadable.
// `AppThemeTests` pins BOTH the dark bytes and the light contrast floor.
/// working dark #46D07F (web --green) · light #1F8F52 (4.0:1 on white).
static let statusWorking = adaptive(dark: 0x46D0_7F, light: 0x1F8F_52)
/// waiting / needs-me dark #F5B14C (web --amber) · light #C25E00
/// (4.2:1; burnt orange keeps it distinct from the light gold accent).
static let statusWaiting = adaptive(dark: 0xF5B1_4C, light: 0xC25E_00)
/// stuck dark #FF6B6B (web --red) · light #D22B2B (5.1:1).
static let statusStuck = adaptive(dark: 0xFF6B_6B, light: 0xD22B_2B)
/// idle quiet secondary gray (distinguished from `unknown` by shape).
static let statusIdle = Color.secondary
/// unknown / no signal yet gray.
@@ -86,10 +111,12 @@ enum DS {
// and `stuck` reuse the status tokens above (same meaning); `done`
// reuses `statusWorking` (a completed run). `tool`/`user` get their own
// tokens so nothing in the app hardcodes a raw SwiftUI color.
/// tool run indigo, tied to the app accent family (#5E9EFF-ish).
static let timelineTool = rgb(94, 158, 255)
/// user message violet (#AF7BFF), distinct from accent & tool.
static let timelineUser = rgb(175, 123, 255)
/// tool run dark #5E9EFF · light #2C6ED6 (4.8:1 on white; the bright
/// blue is 2.63:1 there).
static let timelineTool = adaptive(dark: 0x5E9E_FF, light: 0x2C6E_D6)
/// user message dark #AF7BFF · light #7A3BD6 (6.1:1; bright violet is
/// 2.81:1). Distinct from accent & tool in both schemes.
static let timelineUser = adaptive(dark: 0xAF7B_FF, light: 0x7A3B_D6)
// Surfaces & text
@@ -106,18 +133,48 @@ enum DS {
/// Tertiary label color (de-emphasized detail).
static let textTertiary = Color(uiColor: .tertiaryLabel)
// Terminal canvas (fixed warm-dark, matches the desktop terminal)
// A terminal reads as dark regardless of app appearance (like the
// desktop). Values mirror the web chrome: --bg #100F0D / --text #ECE9E3.
/// Terminal background warm near-black #100F0D (web --bg).
static let terminalBackground = rgb(16, 15, 13)
/// Terminal foreground warm off-white #ECE9E3 (web --text).
static let terminalForeground = rgb(236, 233, 227)
// Terminal canvas (theme-following as of T-iOS-34)
// Was a FIXED warm-dark canvas ("a terminal reads as dark regardless of
// app appearance"). With a real light theme that stops being true a
// full-screen near-black slab is the loudest thing on a light UI. The
// two schemes now live in `TerminalPalette` (which also vends the
// already-resolved set SwiftTerm needs); these stay as the SwiftUI-side
// tokens so existing call sites keep compiling and become adaptive.
/// Terminal background dark #100F0D (web --bg) · light #F6F7F9.
static let terminalBackground = Color(uiColor: terminalBackgroundUIColor())
/// Terminal foreground dark #ECE9E3 (web --text) · light #1A1D24.
static let terminalForeground = Color(uiColor: terminalForegroundUIColor())
/// Dynamic terminal background. Exposed as `UIColor` because SwiftTerm's
/// `nativeBackgroundColor` takes UIKit colors AND flattens them on set
/// (see `TerminalPalette`) the bridge must not go through SwiftUI.
static func terminalBackgroundUIColor() -> UIColor {
TerminalPalette.dynamicBackground()
}
/// Dynamic terminal foreground (same rationale as the background).
static func terminalForegroundUIColor() -> UIColor {
TerminalPalette.dynamicForeground()
}
/// Build an opaque sRGB color from 0255 components.
private static func rgb(_ r: Double, _ g: Double, _ b: Double) -> Color {
Color(.sRGB, red: r / 255, green: g / 255, blue: b / 255, opacity: 1)
}
/// One scheme-adaptive color from two `0xRRGGBB` values. THE way to add
/// an adaptive token hand-rolling a `UIColor { trait in }` per token
/// is how a scheme branch gets forgotten.
private static func adaptive(dark: UInt32, light: UInt32) -> Color {
adaptive(dark: UIColor(hex: dark), light: UIColor(hex: light))
}
/// Same, for values that need a non-opaque alpha (washes).
private static func adaptive(dark: UIColor, light: UIColor) -> Color {
Color(uiColor: UIColor { trait in
trait.userInterfaceStyle == .dark ? dark : light
})
}
}
// MARK: - Space

View File

@@ -37,6 +37,23 @@ extension DS {
/// The canonical meta-number font: caption-sized mono + tabular.
static let metaMono = mono(.caption)
// Dynamic Type clamp for dense numerics (T-iOS-34)
/// Upper bound applied to **numeric / meta** text only (telemetry chips,
/// `dsMetaText` rows: `ctx 92% · $0.1234 · 161×50`).
///
/// Prose scales all the way to `.accessibility5` that is the point of
/// Dynamic Type and nothing here caps it. Tabular numerics are different:
/// they live in one-line rows next to a status badge, and at AX4/AX5 a
/// single chip row becomes three wrapped lines that push the row's real
/// content off screen. Capping them at `.accessibility2` (already ~2×
/// the default size) keeps the meta line legible AND keeps the row's
/// primary text the session name visible.
///
/// Pinned in `DynamicTypeLayoutTests` both as a policy value and
/// behaviorally (AX2 and AX5 must measure the same height).
static let numericClamp: DynamicTypeSize = .accessibility2
}
}
@@ -44,11 +61,15 @@ extension DS {
/// Secondary + monospaced-tabular styling for a row's numeric meta line
/// ("N · 161×50"). Apply via `.dsMetaText()`.
///
/// T-iOS-34 · carries the `numericClamp` so every meta line in the app gets the
/// same Dynamic Type ceiling from ONE place (per-screen clamps would drift).
struct MetaText: ViewModifier {
func body(content: Content) -> some View {
content
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.textSecondary)
.dynamicTypeSize(...DS.Typography.numericClamp)
}
}

View File

@@ -191,10 +191,18 @@ final class PushRegistrar {
logger.error("remote notification registration failed: \(error)")
}
/// device token**additive hook** App
/// UI `HostStore.remove(id:)`
/// token
/// APNs 410
/// device token
///
/// C1 · ****App UI
/// `HostStore.remove(id:)` APNs token
/// 线 `PairingViewModel.removeHost(id:)`
/// `Probe.unregisterPush` `PushHostDeregistration.run(for:)`
/// `AppEnvironment` `PushAppDelegate` ****device
/// token
///
/// 访
/// 401
/// token APNs 410
func handleHostRemoved(_ host: HostRegistry.Host) async {
registeredHostIds.remove(host.id)
guard let token = currentTokenHex else { return }

View File

@@ -0,0 +1,268 @@
import APIClient
import SwiftUI
import WireProtocol
/// C2 · git web v0.6 `docs/plans/w6-project-git-panel.md`
///
/// web 720px
/// ""绿
///
/// ////PR ****
/// `Text(verbatim:)` LocalizedStringKey/Markdown/PR
/// `PrLink` https+github.com
struct GitPanelScreen: View {
@State private var viewModel: GitPanelViewModel
private enum Metrics {
/// token
static let commitEditorLines = 2...5
/// hash `git log --format=%h`
static let shortHashLength = 7
}
/// DS ProjectDetailScreen
private static let buttonRowInsets = EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.xs4, trailing: DS.Space.lg16
)
init(viewModel: GitPanelViewModel) {
_viewModel = State(initialValue: viewModel)
}
/// endpoint/http/path
init(endpoint: HostEndpoint, http: any HTTPTransport, path: String) {
self.init(viewModel: .forProject(endpoint: endpoint, http: http, path: path))
}
var body: some View {
@Bindable var bindable = viewModel
return List {
stateSection
feedbackSection
changesSection
commitSection(message: $bindable.commitMessage)
pullRequestSection
logSection
}
.listStyle(.insetGrouped)
.navigationTitle(GitPanelCopy.title)
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.load()
await viewModel.loadPullRequest() // gh
}
.refreshable {
await viewModel.load()
await viewModel.loadPullRequest()
}
.overlay {
if !viewModel.isLoaded {
ProgressView()
}
}
}
// MARK: -
@ViewBuilder private var stateSection: some View {
Section(GitPanelCopy.stateSection) {
if let band = viewModel.band {
GitSyncBandView(band: band, branch: viewModel.branch)
Button {
Task { await viewModel.fetchRemote() }
} label: {
Label(GitPanelCopy.fetchButton, systemImage: "arrow.down.circle")
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(!viewModel.canFetch)
.listRowInsets(Self.buttonRowInsets)
.listRowBackground(Color.clear)
.accessibilityHint(GitPanelCopy.fetchHint)
}
if let message = viewModel.stateErrorMessage {
InlineMessage(text: message, tone: .error)
}
}
}
/// /`Text(verbatim:)`
@ViewBuilder private var feedbackSection: some View {
if viewModel.errorMessage != nil || viewModel.noticeMessage != nil {
Section {
if let message = viewModel.errorMessage {
InlineMessage(text: message, tone: .error)
}
if let message = viewModel.noticeMessage {
InlineMessage(text: message, tone: .success)
}
}
}
}
// MARK: - stage / unstage
@ViewBuilder private var changesSection: some View {
Section(GitPanelCopy.changesSection) {
if let message = viewModel.changesErrorMessage {
InlineMessage(text: message, tone: .error)
}
if viewModel.staged.isEmpty && viewModel.unstaged.isEmpty
&& viewModel.changesErrorMessage == nil {
Text(GitPanelCopy.noChanges)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
ForEach(viewModel.staged) { file in
changedFileRow(file, isStaged: true)
}
ForEach(viewModel.unstaged) { file in
changedFileRow(file, isStaged: false)
}
}
}
private func changedFileRow(
_ file: GitPanelViewModel.ChangedFile, isStaged: Bool
) -> some View {
HStack(spacing: DS.Space.sm8) {
VStack(alignment: .leading, spacing: DS.Space.xs2) {
// verbatim +
Text(verbatim: file.displayPath)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Text(GitChangeCopy.summary(file))
.dsMetaText()
}
Spacer(minLength: DS.Space.sm8)
Button(isStaged ? GitPanelCopy.unstageButton : GitPanelCopy.stageButton) {
Task { await viewModel.setStaged(file, staged: !isStaged) }
}
.font(DS.Typography.caption.weight(.semibold))
.foregroundStyle(DS.Palette.accent)
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
.disabled(viewModel.busy != nil)
}
}
// MARK: - /
@ViewBuilder private func commitSection(message: Binding<String>) -> some View {
Section(GitPanelCopy.commitSection) {
TextField(GitPanelCopy.commitPlaceholder, text: message, axis: .vertical)
.lineLimit(Metrics.commitEditorLines)
.font(DS.Typography.body)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
Task { await viewModel.commit() }
} label: {
Label(GitPanelCopy.commitButton, systemImage: "checkmark.seal")
}
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(!viewModel.canCommit)
.listRowInsets(Self.buttonRowInsets)
.listRowBackground(Color.clear)
Button {
Task { await viewModel.push() }
} label: {
Label(GitPanelCopy.pushButton, systemImage: "arrow.up.circle")
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(viewModel.busy != nil)
.listRowInsets(Self.buttonRowInsets)
.listRowBackground(Color.clear)
}
}
// MARK: - PR / CI
@ViewBuilder private var pullRequestSection: some View {
if let pr = viewModel.pr {
Section(GitPanelCopy.prSection) {
PrStatusRow(status: pr)
}
}
}
// MARK: -
@ViewBuilder private var logSection: some View {
Section(GitPanelCopy.logSection) {
if let message = viewModel.logErrorMessage {
InlineMessage(text: message, tone: .error)
}
if let log = viewModel.log {
if log.commits.isEmpty {
Text(GitPanelCopy.noCommits)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
commitRows(log)
if log.truncated {
Text(GitPanelCopy.truncatedLog)
.dsMetaText()
}
}
}
}
@ViewBuilder private func commitRows(_ log: GitLogResult) -> some View {
let boundary = GitLogBoundary.index(commits: log.commits, upstream: log.upstream)
ForEach(Array(log.commits.enumerated()), id: \.element.hash) { index, commit in
VStack(alignment: .leading, spacing: DS.Space.xs4) {
commitRow(commit)
// unpushed w6/G4
if index == boundary, let upstream = log.upstream {
UnpushedBoundary(upstream: upstream)
}
}
}
}
private func commitRow(_ commit: CommitLogEntry) -> some View {
HStack(alignment: .top, spacing: DS.Space.sm8) {
Text(verbatim: String(commit.hash.prefix(Metrics.shortHashLength)))
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
VStack(alignment: .leading, spacing: DS.Space.xs2) {
// verbatim
Text(verbatim: commit.subject)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(2)
Text(GitTimeFormat.relative(fromMs: Double(commit.at), nowMs: Date().timeIntervalSince1970 * 1_000))
.dsMetaText()
}
Spacer(minLength: 0)
if commit.unpushed == true {
Image(systemName: "arrow.up.circle")
.foregroundStyle(DS.Palette.statusWaiting)
.accessibilityLabel(GitChangeCopy.unpushedLabel)
}
}
}
}
// MARK: -
enum GitChangeCopy {
static let unpushedLabel = "未推送"
static func summary(_ file: GitPanelViewModel.ChangedFile) -> String {
"\(statusLabel(file.status)) · +\(file.added) \(file.removed)"
}
static func statusLabel(_ status: DiffFileStatus) -> String {
switch status {
case .modified: return "修改"
case .added: return "新增"
case .deleted: return "删除"
case .renamed: return "重命名"
case .binary: return "二进制"
case .untracked: return "未跟踪"
}
}
}

View File

@@ -0,0 +1,359 @@
import APIClient
import Foundation
import SwiftUI
/// C2 · git `DS` token/
/// `Text(verbatim:)` `DS.Layout.minHitTarget`
// MARK: - w6/G3
/// HEAD/ · · +
///
/// **绿**`statusWorking`
/// // HEAD`statusWaiting`
/// 怀 web
struct GitSyncBandView: View {
let band: GitSyncBand
///
let branch: String?
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.sm8) {
headRow
if case .tracking(_, let ahead, let behind) = band.head {
countsRow(ahead: ahead, behind: behind)
}
if let footnote {
Text(footnote)
.font(DS.Typography.caption)
.foregroundStyle(
band.isInSync ? DS.Palette.textSecondary : DS.Palette.statusWaiting
)
.fixedSize(horizontal: false, vertical: true)
}
dirtyRow
}
.padding(.vertical, DS.Space.xs4)
}
@ViewBuilder private var headRow: some View {
HStack(spacing: DS.Space.sm8) {
if let branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
}
switch band.head {
case .detached:
SyncChip(text: GitPanelCopy.detachedHead, tone: .warning)
case .noUpstream:
SyncChip(text: GitPanelCopy.noUpstream, tone: .warning)
case .tracking(let upstream, _, _):
//
Text(verbatim: upstream)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
if band.isInSync {
SyncChip(text: GitPanelCopy.inSync, tone: .good)
}
}
Spacer(minLength: 0)
}
}
private func countsRow(ahead: Int?, behind: Int?) -> some View {
HStack(spacing: DS.Space.md12) {
countCell(caption: GitPanelCopy.toPushCaption, symbol: "arrow.up", value: ahead)
countCell(caption: GitPanelCopy.toPullCaption, symbol: "arrow.down", value: behind)
if !band.isBehindVerified {
SyncChip(text: GitPanelCopy.unverified, tone: .warning)
}
Spacer(minLength: 0)
}
}
/// nil ``"" 0
private func countCell(caption: String, symbol: String, value: Int?) -> some View {
HStack(spacing: DS.Space.xs4) {
Image(systemName: symbol)
.imageScale(.small)
Text(value.map(String.init) ?? GitPanelCopy.unknownCount)
.font(DS.Typography.mono(.callout))
Text(caption)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
.foregroundStyle(DS.Palette.textPrimary)
.accessibilityElement(children: .combine)
}
@ViewBuilder private var dirtyRow: some View {
switch band.dirty {
case .unknown:
Text(GitPanelCopy.dirtyUnknown)
.dsMetaText()
case .clean:
SyncChip(text: GitPanelCopy.clean, tone: .good)
case .changed(let count):
SyncChip(text: GitPanelCopy.dirtyCount(count), tone: .dirty)
}
}
/// """"
private var footnote: String? {
switch band.head {
case .detached:
return GitPanelCopy.detachedDetail
case .noUpstream:
return GitPanelCopy.noUpstreamDetail
case .tracking:
guard !band.isBehindVerified else { return nil }
return GitPanelCopy.staleFetch
}
}
}
/// ****""
///
/// `GitSyncBand` 绿
/// `nil` `` 0 ``
struct SyncSummaryChips: View {
let band: GitSyncBand
var body: some View {
HStack(spacing: DS.Space.xs4) {
switch band.head {
case .detached:
SyncChip(text: GitPanelCopy.detachedHead, tone: .warning)
case .noUpstream:
SyncChip(text: GitPanelCopy.noUpstream, tone: .warning)
case .tracking(_, let ahead, let behind):
if band.isInSync {
SyncChip(text: GitPanelCopy.inSync, tone: .good)
} else {
trackingChips(ahead: ahead, behind: behind)
}
}
Spacer(minLength: 0)
}
}
@ViewBuilder private func trackingChips(ahead: Int?, behind: Int?) -> some View {
if let ahead, ahead > 0 {
SyncChip(text: "\(ahead)", tone: .neutral)
}
if let behind, behind > 0 {
SyncChip(text: "\(behind)", tone: .neutral)
}
if ahead == nil || behind == nil {
// 0
SyncChip(text: "↑↓ \(GitPanelCopy.unknownCount)", tone: .warning)
}
if !band.isBehindVerified {
SyncChip(text: GitPanelCopy.unverified, tone: .warning)
}
}
}
/// + `DirtyBadge`
struct SyncChip: View {
enum Tone {
case good
case warning
case dirty
case neutral
}
let text: String
var tone: Tone = .neutral
private var color: Color {
switch tone {
case .good: return DS.Palette.statusWorking
case .warning: return DS.Palette.statusWaiting
case .dirty: return DS.Palette.statusWaiting
case .neutral: return DS.Palette.textSecondary
}
}
var body: some View {
Text(text)
.font(DS.Typography.caption.weight(.medium))
.foregroundStyle(color)
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs2)
.background(color.opacity(Self.fillOpacity), in: Capsule())
.lineLimit(1)
}
/// `DirtyBadge`
private static let fillOpacity: Double = 0.18
}
// MARK: - w6/G4
/// `git log` 线 `<upstream>`
struct UnpushedBoundary: View {
let upstream: String
var body: some View {
HStack(spacing: DS.Space.sm8) {
Rectangle()
.fill(DS.Palette.statusWaiting)
.frame(height: DS.Stroke.hairline)
// upstream verbatim
Text(verbatim: GitPanelCopy.unpushedBoundary(upstream))
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
.lineLimit(1)
Rectangle()
.fill(DS.Palette.statusWaiting)
.frame(height: DS.Stroke.hairline)
}
.padding(.vertical, DS.Space.xs2)
}
}
// MARK: -
/// /verbatim
struct InlineMessage: View {
enum Tone {
case error
case success
case info
}
let text: String
var tone: Tone = .info
private var color: Color {
switch tone {
case .error: return DS.Palette.statusStuck
case .success: return DS.Palette.statusWorking
case .info: return DS.Palette.textSecondary
}
}
private var symbol: String {
switch tone {
case .error: return "exclamationmark.triangle"
case .success: return "checkmark.circle"
case .info: return "info.circle"
}
}
var body: some View {
HStack(alignment: .top, spacing: DS.Space.sm8) {
Image(systemName: symbol)
.foregroundStyle(color)
Text(verbatim: text)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
}
.accessibilityElement(children: .combine)
}
}
// MARK: - PR / CI w3-pr-ci-chip
/// PR + CI
///
/// gh // PR// 200 + `availability`
///
struct PrStatusRow: View {
let status: PrStatus
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
HStack(spacing: DS.Space.sm8) {
TelemetryChip(systemImage: "arrow.triangle.pull", text: headline)
if let checks = status.checks, checks.total > 0 {
TelemetryChip(
systemImage: checkSymbol(checks),
text: PrCopy.checks(checks),
isWarning: checks.failing > 0
)
}
Spacer(minLength: 0)
}
if let title = status.title, !title.isEmpty {
// PR GitHub verbatim
Text(verbatim: title)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(2)
}
if let url = PrLink.url(from: status.url) {
Link(destination: url) {
Label(PrCopy.openInBrowser, systemImage: "safari")
.font(DS.Typography.caption)
}
.frame(minHeight: DS.Layout.minHitTarget, alignment: .leading)
}
}
}
private var headline: String {
switch status.availability {
case .ok:
return PrCopy.number(status.number, state: status.state, isDraft: status.isDraft)
case .noPr: return PrCopy.noPr
case .notInstalled: return PrCopy.notInstalled
case .unauthenticated: return PrCopy.unauthenticated
case .disabled: return PrCopy.disabled
case .error: return PrCopy.error
}
}
private func checkSymbol(_ checks: PrCheckSummary) -> String {
if checks.failing > 0 { return "xmark.octagon" }
if checks.pending > 0 { return "clock" }
return "checkmark.seal"
}
}
/// PR URL **** `openURL`
/// App scheme https + github.com
enum PrLink {
private static let allowedScheme = "https"
private static let allowedHostSuffix = "github.com"
static func url(from raw: String?) -> URL? {
guard let raw, let url = URL(string: raw), url.scheme?.lowercased() == allowedScheme,
let host = url.host?.lowercased(),
host == allowedHostSuffix || host.hasSuffix("." + allowedHostSuffix)
else {
return nil
}
return url
}
}
enum PrCopy {
static let noPr = "无 PR"
static let notInstalled = "主机未安装 gh"
static let unauthenticated = "gh 未登录"
static let disabled = "PR 查询已关闭"
static let error = "PR 状态获取失败"
static let openInBrowser = "在浏览器打开"
static let draft = "草稿"
static func number(_ number: Int?, state: String?, isDraft: Bool?) -> String {
var text = number.map { "PR #\($0)" } ?? "PR"
if let state, !state.isEmpty { text += " · \(state)" }
if isDraft == true { text += " · \(draft)" }
return text
}
static func checks(_ checks: PrCheckSummary) -> String {
"\(checks.passing)\(checks.failing)\(checks.pending)"
}
}

View File

@@ -0,0 +1,84 @@
import HostRegistry
/// User-facing pairing copy (plan §3.4 taxonomy actionable wording; §5.2
/// Local-Network guidance including the iOS 18 restart caveat).
///
/// Extracted from `PairingViewModel.swift` by C1: the VM grew the access-token
/// and host-management flows, and copy is presentation, not state machine
/// (plan §4 file-size discipline many small focused files).
///
/// SECURITY (§5.3): no function here ever takes or echoes a token value. The
/// shape-rejection copy is derived from `AccessTokenError`, which deliberately
/// carries a length at most never the rejected secret.
enum PairingCopy {
static let scanRejected =
"二维码不是 http(s) 地址,无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。"
static let manualRejected =
"无法解析这个地址。请输入完整 URL例如 http://192.168.1.5:3000"
static let storeFailed =
"主机已通过验证,但保存到本机失败,请重试。"
static let localNetworkDenied =
"无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关"
+ "iOS 18 存在需要重启手机才生效的已知问题)。"
static let notWebTerminal =
"对方在响应 HTTP但不是 web-terminal——端口对吗"
static let tlsFailure =
"TLS 连接失败:证书无效或不受信任。"
static let timeout =
"连接超时。请确认主机在线、与手机在同一网络后重试。"
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
static let deviceCertRequired =
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
static let clientCertRejected =
"本设备证书无效或已吊销,请重新导入。"
// MARK: - Access token (C1 · ios-completion §1.1)
/// 401 from `POST /auth` the token itself is wrong. Never echoes it.
static let tokenInvalid =
"访问令牌不正确。请到主机上核对 WEBTERM_TOKEN 的值后重新输入。"
/// 429 the server allows 10 `/auth` attempts per minute per IP.
static let tokenRateLimited =
"尝试次数过多(主机限制每分钟 10 次),请稍后再试。"
/// 204 WITHOUT `Set-Cookie`: this host never enabled auth, so there is
/// nothing to authenticate against and nothing gets saved. The pairing
/// failure therefore has another cause almost always `ALLOWED_ORIGINS`.
static let tokenNotRequired =
"该主机没有启用访问令牌(服务器未设置 WEBTERM_TOKEN令牌不会被保存。"
+ "配对失败的原因更可能是主机的 ALLOWED_ORIGINS 不含本 App 拨号的地址。"
/// Defensive: a shape violation that slipped past the field validation.
static let tokenShapeUnknown =
"访问令牌格式不符合要求16512 位,且只能包含 A-Z a-z 0-9 . _ ~ + / = -)。"
static let hostsLoadFailed =
"无法读取已配对的主机列表(钥匙串读取失败)。"
static let hostRemoveFailed =
"移除主机失败(钥匙串写入失败),请重试。"
/// Turn the typed `AccessTokenError` into field-level copy. The length cases
/// report the length only that is not secret, and it is exactly what tells
/// the user whether they pasted a truncated value.
static func tokenShapeRejected(_ error: AccessTokenError) -> String {
switch error {
case .tooShort(let length):
return "访问令牌太短(\(length) 位,至少 \(AccessToken.minLength) 位)。"
case .tooLong(let length):
return "访问令牌太长(\(length) 位,最多 \(AccessToken.maxLength) 位)。"
case .invalidCharacters:
return "访问令牌含有不允许的字符(只能是 A-Z a-z 0-9 . _ ~ + / = -)。"
case .unknownHost:
return hostsLoadFailed
}
}
static func hostUnreachable(_ underlying: String) -> String {
"无法连接主机:\(underlying)"
}
/// §3.4 wording for the ATS cleartext block.
static func atsBlocked(host: String) -> String {
"明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内,"
+ "请改用 https / tailscale serve或反馈该网段。"
}
}

View File

@@ -18,6 +18,12 @@ struct PairingScreen: View {
@State private var manualURLText = ""
@State private var isShowingScanner = false
@State private var scannerError: String?
/// C1 · Typed access token. Lives in `SecureField` view state only for as
/// long as the prompt is up, and is cleared the moment it is submitted or
/// abandoned the persisted copy belongs in the Keychain, nowhere else.
@State private var accessTokenText = ""
/// C1 · Host pending the "" confirmation dialog (nil = none).
@State private var hostPendingRemoval: HostRegistry.Host?
var body: some View {
content
@@ -37,47 +43,162 @@ struct PairingScreen: View {
probingView(pending)
case .failed(let pending, let failure):
FailureView(pending: pending, failure: failure, viewModel: viewModel)
case .awaitingToken(let pending):
tokenPrompt(pending, isValidating: false)
case .validatingToken(let pending):
tokenPrompt(pending, isValidating: true)
case .paired(let host):
pairedView(host)
}
}
private var idleView: some View {
// MARK: - Access-token prompt (C1 · §1.1)
/// The host answered 401. `SecureField` (never a plain `TextField`), no
/// autocorrect/autocapitalisation a token is not prose and the inline
/// rejection comes from the VM, which never echoes the value back.
private func tokenPrompt(
_ pending: PairingViewModel.PendingHost, isValidating: Bool
) -> some View {
ScrollView {
VStack(spacing: DS.Space.xl20) {
VStack(spacing: DS.Space.md12) { // inviting hero
Image(systemName: "desktopcomputer")
.font(DS.Typography.largeTitle)
.foregroundStyle(DS.Palette.accent)
.padding(.top, DS.Space.sm8)
Text(ScreenCopy.heroTitle)
.font(DS.Typography.title)
.foregroundStyle(DS.Palette.textPrimary)
Text(ScreenCopy.heroSubtitle)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
.multilineTextAlignment(.center)
tokenFieldCard(pending, isValidating: isValidating)
if let rejection = viewModel.tokenRejection {
Label(rejection, systemImage: "exclamationmark.circle.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity)
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.manualSectionTitle)
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
.font(DS.Typography.mono())
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.go)
.onSubmit { viewModel.submitManualURL(manualURLText) }
.accessibilityIdentifier("pairing.urlField")
Divider()
Button(ScreenCopy.manualSubmit) {
DS.Haptics.selection()
viewModel.submitManualURL(manualURLText)
}
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.submitButton")
VStack(spacing: DS.Space.md12) {
if isValidating {
ProgressView()
}
Button(ScreenCopy.tokenSubmit) { submitAccessToken() }
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(isValidating || accessTokenText.isEmpty)
.accessibilityIdentifier("pairing.tokenSubmit")
Button(ScreenCopy.cancel) {
accessTokenText = ""
viewModel.cancelAccessTokenEntry()
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(isValidating)
}
}
.padding(DS.Space.lg16)
}
}
private func tokenFieldCard(
_ pending: PairingViewModel.PendingHost, isValidating: Bool
) -> some View {
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.tokenSectionTitle)
Text(pending.displayAddress)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
Text(ScreenCopy.tokenHint)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
Divider()
SecureField(ScreenCopy.tokenPlaceholder, text: $accessTokenText)
.font(DS.Typography.mono())
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.go)
.disabled(isValidating)
.onSubmit { submitAccessToken() }
.accessibilityIdentifier("pairing.tokenField")
}
}
}
/// Hand the token to the VM and drop the view's copy immediately.
private func submitAccessToken() {
let token = accessTokenText
accessTokenText = ""
Task { await viewModel.submitAccessToken(token) }
}
private var idleView: some View {
idleContent
.sheet(isPresented: $isShowingScanner) { scannerSheet }
.task { await viewModel.loadPairedHosts() }
.confirmationDialog(
ScreenCopy.removeConfirmTitle,
isPresented: removalDialogBinding,
titleVisibility: .visible,
presenting: hostPendingRemoval
) { host in
removalDialogActions(host)
} message: { _ in
Text(ScreenCopy.removeConfirmMessage)
}
}
/// Presented iff a host is staged for removal; dismissal clears the staging.
private var removalDialogBinding: Binding<Bool> {
Binding(
get: { hostPendingRemoval != nil },
set: { presented in if !presented { hostPendingRemoval = nil } }
)
}
@ViewBuilder private func removalDialogActions(
_ host: HostRegistry.Host
) -> some View {
Button(ScreenCopy.removeConfirm(host.name), role: .destructive) {
hostPendingRemoval = nil
Task { await viewModel.removeHost(id: host.id) }
}
Button(ScreenCopy.cancel, role: .cancel) { hostPendingRemoval = nil }
}
private var hero: some View {
VStack(spacing: DS.Space.md12) { // inviting hero
Image(systemName: "desktopcomputer")
.font(DS.Typography.largeTitle)
.foregroundStyle(DS.Palette.accent)
.padding(.top, DS.Space.sm8)
Text(ScreenCopy.heroTitle)
.font(DS.Typography.title)
.foregroundStyle(DS.Palette.textPrimary)
Text(ScreenCopy.heroSubtitle)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
private var manualEntryCard: some View {
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.manualSectionTitle)
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
.font(DS.Typography.mono())
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.go)
.onSubmit { viewModel.submitManualURL(manualURLText) }
.accessibilityIdentifier("pairing.urlField")
Divider()
Button(ScreenCopy.manualSubmit) {
DS.Haptics.selection()
viewModel.submitManualURL(manualURLText)
}
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.submitButton")
}
}
}
private var idleContent: some View {
ScrollView {
VStack(spacing: DS.Space.xl20) {
hero
manualEntryCard
if let rejection = viewModel.inputRejection {
Label(rejection, systemImage: "exclamationmark.circle.fill")
.font(DS.Typography.caption)
@@ -97,10 +218,72 @@ struct PairingScreen: View {
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
pairedHostsSection
}
.padding(DS.Space.lg16)
}
.sheet(isPresented: $isShowingScanner) { scannerSheet }
}
// MARK: - Paired hosts (C1 · remove + "re-pair to add/update the token")
/// The app's host-management surface: re-pairing the same address updates
/// that host in place (that is how an existing host gains an access token
/// after the server enabled `WEBTERM_TOKEN`), and deletes the record
/// which takes its stored token with it and de-registers its APNs token.
@ViewBuilder private var pairedHostsSection: some View {
if !viewModel.pairedHosts.isEmpty || viewModel.hostsErrorMessage != nil {
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.pairedSectionTitle)
if let message = viewModel.hostsErrorMessage {
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
}
ForEach(viewModel.pairedHosts) { host in
pairedHostRow(host)
if host.id != viewModel.pairedHosts.last?.id {
Divider()
}
}
Text(ScreenCopy.pairedSectionHint)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
}
}
private func pairedHostRow(_ host: HostRegistry.Host) -> some View {
HStack(spacing: DS.Space.md12) {
VStack(alignment: .leading, spacing: DS.Space.xs2) {
Text(verbatim: host.name)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Text(host.endpoint.originHeader)
.dsMetaText()
.lineLimit(1)
.truncationMode(.middle)
// PRESENCE only a token value never reaches the screen.
if host.accessToken != nil {
Label(ScreenCopy.tokenStored, systemImage: "key.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.accent)
}
}
Spacer(minLength: 0)
Button(role: .destructive) {
hostPendingRemoval = host
} label: {
Label(ScreenCopy.remove, systemImage: "trash")
.labelStyle(.iconOnly)
}
.buttonStyle(.plain)
.foregroundStyle(DS.Palette.statusStuck)
.accessibilityLabel(ScreenCopy.removeAccessibility(host.name))
.accessibilityIdentifier("pairing.removeHost")
}
}
@ViewBuilder private var scannerSheet: some View {
@@ -277,12 +460,22 @@ private struct FailureView: View {
}
VStack(spacing: DS.Space.md12) {
let needsSettings = failure.action == .openLocalNetworkSettings
// C1 · 401 is ambiguous (token OR Origin), so BOTH remedies
// stay on screen: the token prompt leads, retry stays put.
let needsToken = failure.action == .enterAccessToken
if needsSettings {
Button(ScreenCopy.openSettings) { openAppSettings() }
.buttonStyle(DSButtonStyle(kind: .primary))
}
if needsToken {
Button(ScreenCopy.enterToken) { viewModel.beginAccessTokenEntry() }
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.enterTokenButton")
}
Button(ScreenCopy.retry) { Task { await viewModel.retry() } }
.buttonStyle(DSButtonStyle(kind: needsSettings ? .secondary : .primary))
.buttonStyle(DSButtonStyle(
kind: (needsSettings || needsToken) ? .secondary : .primary
))
Button(ScreenCopy.back) { viewModel.cancel() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
@@ -391,6 +584,24 @@ private enum ScreenCopy {
static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
static let publicAcknowledge = "我已了解风险,仍要连接"
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
// C1 · access token + host management
static let enterToken = "输入访问令牌"
static let tokenSectionTitle = "输入访问令牌"
static let tokenPlaceholder = "WEBTERM_TOKEN"
static let tokenHint =
"这台主机设置了 WEBTERM_TOKEN。令牌只会保存在本机钥匙串里绝不出现在网址或日志中。"
static let tokenSubmit = "验证并保存"
static let tokenStored = "已保存访问令牌"
static let pairedSectionTitle = "已配对主机"
static let pairedSectionHint =
"想给已配对的主机补/换访问令牌:在上面重新输入同一个地址配对一次即可(不会重复添加)。"
static let remove = "移除"
static let removeConfirmTitle = "移除这台主机?"
static let removeConfirmMessage =
"会同时删除本机保存的访问令牌,并在该主机上注销本设备的推送。主机上正在跑的会话不受影响。"
static func removeConfirm(_ name: String) -> String { "移除「\(name)" }
static func removeAccessibility(_ name: String) -> String { "移除主机 \(name)" }
static func probing(_ address: String) -> String { "正在验证 \(address)" }
static func paired(_ name: String) -> String { "已配对:\(name)" }

View File

@@ -5,6 +5,14 @@ import WireProtocol
/// T-iOS-26 · sessions/worktrees/CLAUDE.md + diff
/// T-iOS-27 `DiffScreen(endpoint:path:http:)`+ ""
///
/// C2 web v0.6 / Android
/// - ****`GitSyncBand` / / HEAD /
/// git ""
/// - **Git **stage/commit/push/fetch + `git log` + PR/CI
/// - **worktree **T-iOS-32 / /
///
/// - **`claude --resume` **
///
/// ///CLAUDE.md ****
/// `Text(verbatim:)` LocalizedStringKey/Markdown/
struct ProjectDetailScreen: View {
@@ -12,7 +20,30 @@ struct ProjectDetailScreen: View {
private let endpoint: HostEndpoint
private let http: any HTTPTransport
private let onOpenClaude: (String) -> Void
@State private var isDiffPresented = false
private let onResumeClaude: (String, String) -> Void
/// worktree prune / removecreate sheet
///
@State private var worktreeActions: WorktreeViewModel
@State private var route: DetailRoute?
@State private var sheet: DetailSheet?
@State private var worktreePendingRemoval: WorktreeInfo?
@State private var isPruneConfirmPresented = false
/// destination `isPresented:`
private enum DetailRoute: Hashable, Identifiable {
case diff
case gitPanel
var id: Self { self }
}
private enum DetailSheet: Hashable, Identifiable {
case newWorktree
case resumeHistory
var id: Self { self }
}
private enum Metrics {
/// CLAUDE.md token
@@ -23,12 +54,17 @@ struct ProjectDetailScreen: View {
viewModel: ProjectDetailViewModel,
endpoint: HostEndpoint,
http: any HTTPTransport,
onOpenClaude: @escaping (String) -> Void
onOpenClaude: @escaping (String) -> Void,
onResumeClaude: @escaping (String, String) -> Void
) {
_viewModel = State(initialValue: viewModel)
self.endpoint = endpoint
self.http = http
self.onOpenClaude = onOpenClaude
self.onResumeClaude = onResumeClaude
_worktreeActions = State(initialValue: .forProject(
endpoint: endpoint, http: http, path: viewModel.path
))
}
var body: some View {
@@ -36,14 +72,118 @@ struct ProjectDetailScreen: View {
.navigationTitle(ProjectDetailCopy.title)
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load() }
.navigationDestination(isPresented: $isDiffPresented) {
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
.navigationDestination(item: $route) { destination(for: $0) }
.sheet(item: $sheet) { presentedSheet(for: $0) }
}
/// worktree prune remove
/// 409 `content` `body`
/// `body`
@ViewBuilder private var content: some View {
phaseContent
.confirmationDialog(
WorktreeCopy.pruneConfirmTitle,
isPresented: $isPruneConfirmPresented,
titleVisibility: .visible
) {
Button(WorktreeCopy.pruneConfirm, role: .destructive) {
Task {
await worktreeActions.prune()
await viewModel.load()
}
}
} message: {
Text(WorktreeCopy.pruneConfirmMessage)
}
.confirmationDialog(
WorktreeCopy.removeConfirmTitle,
isPresented: removalDialogBinding,
titleVisibility: .visible,
presenting: worktreePendingRemoval
) { worktree in
Button(WorktreeCopy.removeConfirm, role: .destructive) {
remove(worktree)
}
} message: { worktree in
Text(verbatim: WorktreeCopy.removeConfirmMessage(worktree.path))
}
.confirmationDialog(
WorktreeCopy.forceConfirmTitle,
isPresented: forceDialogBinding,
titleVisibility: .visible
) {
// 409****
Button(WorktreeCopy.forceConfirm, role: .destructive) {
Task {
await worktreeActions.confirmForceRemove()
await viewModel.load()
}
}
Button(WorktreeCopy.cancel, role: .cancel) {
worktreeActions.cancelForceRemove()
}
} message: {
Text(WorktreeCopy.forceConfirmMessage)
}
}
@ViewBuilder private func destination(for route: DetailRoute) -> some View {
switch route {
case .diff:
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
case .gitPanel:
GitPanelScreen(endpoint: endpoint, http: http, path: viewModel.path)
}
}
@ViewBuilder private func presentedSheet(for sheet: DetailSheet) -> some View {
switch sheet {
case .newWorktree:
WorktreeSheet(
viewModel: .forProject(endpoint: endpoint, http: http, path: viewModel.path),
onCreated: { Task { await viewModel.load() } },
onOpenSession: onOpenClaude
)
case .resumeHistory:
ResumeHistorySheet(
viewModel: .forProject(endpoint: endpoint, http: http, path: viewModel.path),
onResume: onResumeClaude
)
}
}
/// worktree
private var removalDialogBinding: Binding<Bool> {
Binding(
get: { worktreePendingRemoval != nil },
set: { presented in if !presented { worktreePendingRemoval = nil } }
)
}
/// 409 force
private var forceDialogBinding: Binding<Bool> {
Binding(
get: {
if case .forceConfirming = worktreeActions.removePhase { return true }
return false
},
set: { presented in if !presented { worktreeActions.cancelForceRemove() } }
)
}
private func remove(_ worktree: WorktreeInfo) {
Task {
await worktreeActions.remove(worktreePath: worktree.path)
if case .removed = worktreeActions.removePhase {
DS.Haptics.warning()
await viewModel.load()
}
}
}
// MARK: - Phase switch
@ViewBuilder private var content: some View {
@ViewBuilder private var phaseContent: some View {
switch viewModel.phase {
case .loading:
ProgressView()
@@ -93,8 +233,8 @@ struct ProjectDetailScreen: View {
List {
headerSection(detail)
actionsSection(detail)
sessionsSection(detail.sessions)
worktreesSection(detail.worktrees)
sessionsSection(detail)
worktreesSection(detail)
claudeMdSection(detail)
}
.listStyle(.insetGrouped)
@@ -127,6 +267,13 @@ struct ProjectDetailScreen: View {
DirtyBadge()
}
}
// w6/G3 git
if let band = GitSyncBand.make(
sync: detail.sync, dirtyCount: detail.dirtyCount,
nowMs: Date().timeIntervalSince1970 * 1_000
) {
SyncSummaryChips(band: band)
}
}
}
}
@@ -146,32 +293,53 @@ struct ProjectDetailScreen: View {
))
.listRowBackground(Color.clear)
if detail.isGit {
Button {
isDiffPresented = true
} label: {
Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus")
secondaryAction(GitPanelCopy.entryLabel, symbol: "point.3.filled.connected.trianglepath.dotted") {
route = .gitPanel
}
secondaryAction(ProjectDetailCopy.viewDiff, symbol: "plus.forwardslash.minus") {
route = .diff
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.sm8, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
}
}
}
@ViewBuilder private func sessionsSection(_ sessions: [ProjectSessionRef]) -> some View {
private func secondaryAction(
_ title: String, symbol: String, action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(title, systemImage: symbol)
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.xs4, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
}
@ViewBuilder private func sessionsSection(_ detail: ProjectDetail) -> some View {
Section(ProjectDetailCopy.sessionsHeader) {
if sessions.isEmpty {
if detail.sessions.isEmpty {
Text(ProjectDetailCopy.noSessions)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
} else {
ForEach(sessions, id: \.id) { session in
ForEach(detail.sessions, id: \.id) { session in
sessionRow(session)
}
}
// `claude --resume <id>`T-iOS-32
Button {
sheet = .resumeHistory
} label: {
Label(ResumeCopy.entryLabel, systemImage: "clock.arrow.circlepath")
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.xs4, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
}
}
@@ -199,16 +367,75 @@ struct ProjectDetailScreen: View {
}
}
@ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View {
if !worktrees.isEmpty {
Section(ProjectDetailCopy.worktreesHeader) {
ForEach(worktrees, id: \.path) { worktree in
// MARK: - WorktreesT-iOS-32 + + +
/// git worktree """"
/// w6/G5
@ViewBuilder private func worktreesSection(_ detail: ProjectDetail) -> some View {
if detail.isGit {
Section {
ForEach(detail.worktrees, id: \.path) { worktree in
worktreeRow(worktree)
}
Button {
sheet = .newWorktree
} label: {
Label(WorktreeCopy.sheetTitle, systemImage: "plus.rectangle.on.folder")
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.xs4, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
worktreeFeedback
} header: {
worktreeHeader(detail)
} footer: {
if detail.worktrees.contains(where: WorktreeViewModel.canRemove) {
Text(ProjectDetailCopy.worktreeSwipeHint)
}
}
}
}
private func worktreeHeader(_ detail: ProjectDetail) -> some View {
HStack {
Text(ProjectDetailCopy.worktreesHeader)
Spacer(minLength: DS.Space.sm8)
// prunable
if detail.worktrees.contains(where: { $0.prunable == true }) {
Button(WorktreeCopy.pruneButton) {
isPruneConfirmPresented = true
}
.font(DS.Typography.caption.weight(.semibold))
.foregroundStyle(DS.Palette.accent)
.frame(minHeight: DS.Layout.minHitTarget)
.disabled(worktreeActions.isBusy)
}
}
}
/// prune / remove
@ViewBuilder private var worktreeFeedback: some View {
switch worktreeActions.prunePhase {
case .done(let message):
InlineMessage(text: message, tone: .success)
case .failed(let message):
InlineMessage(text: message, tone: .error)
case .idle, .pruning:
EmptyView()
}
switch worktreeActions.removePhase {
case .removed(let path):
InlineMessage(text: WorktreeCopy.removed(path), tone: .success)
case .failed(let message):
InlineMessage(text: message, tone: .error)
case .idle, .removing, .forceConfirming:
EmptyView()
}
}
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
HStack(spacing: DS.Space.sm8) {
@@ -225,12 +452,28 @@ struct ProjectDetailScreen: View {
if worktree.locked == true {
TagBadge(text: ProjectDetailCopy.worktreeLocked)
}
if worktree.prunable == true {
TagBadge(text: ProjectDetailCopy.worktreePrunable)
}
}
Text(verbatim: worktree.path)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
if worktree.locked == true {
Text(WorktreeCopy.lockedHint)
.dsMetaText()
}
}
.swipeActions(edge: .trailing) {
// worktree 400/409
if WorktreeViewModel.canRemove(worktree) {
Button(WorktreeCopy.removeButton, role: .destructive) {
worktreePendingRemoval = worktree
}
.accessibilityLabel(WorktreeCopy.removeAccessibilityLabel)
}
}
}
@@ -268,7 +511,7 @@ struct DirtyBadge: View {
}
}
/// worktree //accent
/// worktree ///accent
struct TagBadge: View {
let text: String
@@ -297,6 +540,8 @@ enum ProjectDetailCopy {
static let worktreeMain = ""
static let worktreeCurrent = "当前"
static let worktreeLocked = "已锁定"
static let worktreePrunable = "可回收"
static let worktreeSwipeHint = "左滑一行可删除该 worktree主 worktree 与已锁定的不可删)。"
static let detachedHead = "(分离 HEAD"
static let claudeMdHeader = "CLAUDE.md"
static let claudeMdPresent = "本仓库包含 CLAUDE.md。"

View File

@@ -39,7 +39,11 @@ struct ProjectsScreen: View {
viewModel: viewModel.makeDetailViewModel(path: route.path),
endpoint: viewModel.host.endpoint,
http: viewModel.http,
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) }
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) },
// T-iOS-32C2
onResumeClaude: { cwd, sessionId in
viewModel.requestResumeClaude(cwd: cwd, sessionId: sessionId)
}
)
}
// T-iPad-4 · iPhone sheet iPad

View File

@@ -0,0 +1,116 @@
import APIClient
import SwiftUI
import WireProtocol
/// T-iOS-32 · `claude --resume <id>` `GET /sessions`
///
/// cwd cwd
/// resume ** cwd**
/// `claude --resume <id>\r`worktree worktree
///
/// id/cwd/preview `Text(verbatim:)`
/// id `ProjectResumeCommand` ****西
/// PTY
struct ResumeHistorySheet: View {
@State private var viewModel: ResumeHistoryViewModel
/// (cwd, sessionId) ""
let onResume: (String, String) -> Void
@Environment(\.dismiss) private var dismiss
private enum Metrics {
/// 120
static let previewLineLimit = 2
}
init(viewModel: ResumeHistoryViewModel, onResume: @escaping (String, String) -> Void) {
_viewModel = State(initialValue: viewModel)
self.onResume = onResume
}
var body: some View {
NavigationStack {
content
.navigationTitle(ResumeCopy.sheetTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(WorktreeCopy.cancel) { dismiss() }
}
}
.task { await viewModel.load() }
}
}
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .empty:
ContentUnavailableView {
Label(ResumeCopy.emptyTitle, systemImage: "clock.arrow.circlepath")
} description: {
Text(ResumeCopy.emptyDetail)
}
case .failed(let message):
ContentUnavailableView {
Label(ResumeCopy.loadFailed, systemImage: "exclamationmark.triangle")
} description: {
Text(verbatim: message)
} actions: {
Button(ResumeCopy.retry) {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
.tint(DS.Palette.accent)
}
case .loaded(let candidates):
List(candidates) { candidate in
row(candidate)
}
.listStyle(.insetGrouped)
}
}
private func row(_ candidate: ResumeHistoryViewModel.ResumeCandidate) -> some View {
HStack(spacing: DS.Space.sm8) {
VStack(alignment: .leading, spacing: DS.Space.xs2) {
// verbatim
Text(verbatim: candidate.session.preview.isEmpty
? ResumeCopy.noPreview : candidate.session.preview)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(Metrics.previewLineLimit)
Text(verbatim: candidate.cwd)
.font(DS.Typography.mono(.caption2))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
Text(GitTimeFormat.relative(
fromMs: candidate.session.mtimeMs,
nowMs: Date().timeIntervalSince1970 * 1_000
))
.dsMetaText()
if !candidate.canResume {
Text(ResumeCopy.notResumable)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
}
}
Spacer(minLength: DS.Space.sm8)
if candidate.canResume {
Button(ResumeCopy.resume) {
DS.Haptics.selection()
onResume(candidate.cwd, candidate.session.id)
dismiss()
}
.font(DS.Typography.body.weight(.semibold))
.foregroundStyle(DS.Palette.accent)
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
.accessibilityLabel(
ResumeCopy.resumeAccessibilityLabel(candidate.session.project)
)
}
}
}
}

View File

@@ -224,6 +224,17 @@ struct SessionListScreen: View {
} label: {
Label(ScreenCopy.addHost, systemImage: "plus")
}
// C1 · Same sheet as `PairingScreen` is the host
// management surface (access token + ), and it already
// receives the real `HostStore` through its VM. A second entry
// with the management label is what makes those two actions
// discoverable without a second navigation path to maintain.
Button {
onAddHost()
} label: {
Label(ScreenCopy.manageHosts, systemImage: "key")
}
.accessibilityIdentifier("sessions.manageHosts")
Button {
onEnroll()
} label: {
@@ -365,6 +376,8 @@ private enum ScreenCopy {
static let newSession = "新建会话"
static let kill = "结束"
static let addHost = "配对新主机"
/// C1 · Access token + (same sheet as `addHost` see the menu).
static let manageHosts = "管理主机与令牌"
static let deviceCert = "设备证书"
static let enroll = "自动获取证书"
static let hostMenuFallback = "主机"

View File

@@ -0,0 +1,122 @@
import SwiftUI
/// T-iOS-34 · **** / /
///
/// Dynamic Type`TerminalTheme`
/// `.footnote` /
///
///
/// `ThemeStore.select` + `@Observable`
/// `preferredColorScheme`
struct SettingsScreen: View {
/// `RootView`
let themeStore: ThemeStore
/// **** `preferredColorScheme`
///
@Environment(\.colorScheme) private var effectiveScheme
var body: some View {
List {
themeSection
terminalPreviewSection
}
.navigationTitle(SettingsCopy.title)
}
// MARK: -
private var themeSection: some View {
Section {
ForEach(AppTheme.allCases, id: \.self) { theme in
themeRow(theme)
}
} header: {
Text(SettingsCopy.themeHeader)
} footer: {
Text(SettingsCopy.themeFooter)
}
}
private func themeRow(_ theme: AppTheme) -> some View {
Button {
themeStore.select(theme)
DS.Haptics.selection()
} label: {
HStack(spacing: DS.Space.md12) {
Image(systemName: theme.symbolName)
.foregroundStyle(DS.Palette.accent)
.frame(width: DS.Space.xxl24)
Text(theme.label)
.foregroundStyle(DS.Palette.textPrimary)
Spacer(minLength: DS.Space.sm8)
if themeStore.theme == theme {
Image(systemName: "checkmark")
.foregroundStyle(DS.Palette.accent)
.fontWeight(.semibold)
}
}
.frame(minHeight: DS.Layout.minHitTarget)
}
.accessibilityIdentifier("settings.theme.\(theme.rawValue)")
// VoiceOver
.accessibilityAddTraits(themeStore.theme == theme ? [.isSelected] : [])
}
// MARK: -
///
/// // `TerminalPalette`
private var terminalPreviewSection: some View {
Section {
let colors = TerminalPalette.colors(for: effectiveScheme)
HStack(spacing: DS.Space.xs2) {
Text(verbatim: SettingsCopy.terminalSample)
.font(DS.Typography.mono(.footnote))
.foregroundStyle(Color(uiColor: colors.foreground))
Rectangle()
.fill(Color(uiColor: colors.caret))
.frame(width: DS.Space.sm8, height: DS.Space.lg16)
Spacer(minLength: 0)
}
.padding(DS.Space.md12)
.background(
Color(uiColor: colors.background),
in: RoundedRectangle(cornerRadius: DS.Radius.sm8)
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(SettingsCopy.terminalPreviewA11y)
} header: {
Text(SettingsCopy.terminalHeader)
}
}
}
///
enum SettingsCopy {
static let title = "设置"
static let themeHeader = "外观"
static let themeFooter = "默认深色 —— 与网页端一致。选「跟随系统」时随 iOS 的浅色/深色切换。"
static let terminalHeader = "终端预览"
static let terminalSample = "$ claude --resume"
static let terminalPreviewA11y = "终端配色预览"
}
// MARK: - Previews
#Preview("SettingsScreen") {
NavigationStack {
SettingsScreen(themeStore: ThemeStore(defaults: PreviewThemeDefaults()))
}
}
/// defaults `UserDefaults`
private final class PreviewThemeDefaults: ThemeDefaults {
private var values: [String: String] = [:]
func themeRaw(forKey key: String) -> String? { values[key] }
func setThemeRaw(_ value: String, forKey key: String) {
values = values.merging([key: value]) { _, new in new }
}
}

View File

@@ -37,6 +37,13 @@ struct TerminalScreen: View {
/// T-iPad-3 · KeyBar nil =
@State private var keyBarUserOverride: Bool?
/// T-iOS-33 · `makeUIView`
@State private var searchModel = TerminalSearchModel()
/// T-iOS-31 · PTT + epoch `init`
/// PTT //epoch `viewModel`
@State private var voiceEpochSource: VoiceEpochSource
@State private var voiceModel: VoicePTTViewModel
/// DS.Motion.gated
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -46,6 +53,33 @@ struct TerminalScreen: View {
static let hideKeyBar = "隐藏快捷键栏"
}
/// `init` init PTT
/// ViewModel `@State` `viewModel`
/// `sendInput` epoch sessionId/
///
/// SwiftUI **** `@State`
/// `TerminalContainerView` `.id(controller.generation)`
/// `controller.terminalViewModel`
init(
viewModel: TerminalViewModel,
onNewSessionInCwd: (@MainActor () -> Void)? = nil,
onKillSession: (@MainActor () -> Void)? = nil
) {
self.viewModel = viewModel
self.onNewSessionInCwd = onNewSessionInCwd
self.onKillSession = onKillSession
let epochSource = VoiceEpochSource()
_voiceEpochSource = State(initialValue: epochSource)
_voiceModel = State(initialValue: VoicePTTViewModel(
dictation: SpeechDictation(),
clock: ContinuousClock(),
epoch: { epochSource.epoch },
isReadOnly: { viewModel.isReadOnly },
inject: { text in viewModel.sendInput(text) }
))
}
/// KeyBar `KeyBarVisibility`
private var isKeyBarVisible: Bool {
KeyBarVisibility.isVisible(
@@ -58,6 +92,8 @@ struct TerminalScreen: View {
TerminalHostView(
viewModel: viewModel,
keyBarVisible: isKeyBarVisible,
searchModel: searchModel,
voiceModel: voiceModel,
onNewSessionInCwd: onNewSessionInCwd,
onKillSession: onKillSession
)
@@ -70,11 +106,37 @@ struct TerminalScreen: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
}
// T-iOS-33 · web `#searchbox`
// (`position:fixed; top; right`, style.css:812)
.overlay(alignment: .topTrailing) {
if searchModel.isPresented {
TerminalSearchBar(model: searchModel)
.padding(.horizontal, DS.Space.sm8)
.padding(.top, DS.Space.sm8)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
// T-iOS-31 · PTT 🎤
.overlay(alignment: .bottom) {
VoicePTTBanner(model: voiceModel)
.padding(.horizontal, DS.Space.md12)
.padding(.bottom, DS.Space.xl20)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: viewModel.bannerModel
)
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: searchModel.isPresented
)
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: voiceModel.phase
)
.toolbar {
searchToolbarItem
newSessionToolbarItem
keyBarToggleToolbarItem
}
@@ -84,7 +146,34 @@ struct TerminalScreen: View {
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
hasHardwareKeyboard = GCKeyboard.coalesced != nil
}
.onAppear { viewModel.start() }
// T-iOS-31 · epoch
.onChange(of: viewModel.sessionId) { _, id in voiceEpochSource.noteSession(id) }
.onChange(of: viewModel.banner) { _, banner in voiceEpochSource.noteBanner(banner) }
.onAppear {
viewModel.start()
voiceEpochSource.noteSession(viewModel.sessionId)
}
}
/// T-iOS-33 · web toolbar 🔍/
@ToolbarContentBuilder private var searchToolbarItem: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
if searchModel.isPresented {
searchModel.dismiss()
} else {
searchModel.present()
}
} label: {
Label(
searchModel.isPresented
? TerminalSearchModel.Copy.close : TerminalSearchModel.Copy.open,
systemImage: searchModel.isPresented ? "magnifyingglass.circle.fill"
: "magnifyingglass"
)
}
.accessibilityIdentifier("terminal.searchToggleButton")
}
}
/// T-iPad-3 · KeyBar
@@ -155,6 +244,10 @@ private struct TerminalHostView: UIViewRepresentable {
/// T-iPad-3 · KeyBar (`inputAccessoryView`) `KeyBarVisibility`
/// SwiftUI `updateUIView`
let keyBarVisible: Bool
/// T-iOS-33 · `makeUIView` SwiftTerm
let searchModel: TerminalSearchModel
/// T-iOS-31 · PTT 🎤 /
let voiceModel: VoicePTTViewModel
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
var onKillSession: (@MainActor () -> Void)? = nil
@@ -170,7 +263,15 @@ private struct TerminalHostView: UIViewRepresentable {
let viewModel = viewModel
terminal.onKeyCommand = { key in viewModel.send(key: key) }
let keyBar = KeyBarView()
// T-iOS-33 · SwiftTerm PTY
searchModel.attach(terminal)
// T-iOS-31 · 🎤 KeyByteMap
let voiceModel = voiceModel
let keyBar = KeyBarView(voice: KeyBarVoiceHandlers(
onDown: { Task { await voiceModel.pressDown() } },
onUp: { Task { await voiceModel.pressUp() } }
))
keyBar.onKey = { key in viewModel.send(key: key) }
terminal.installKeyBar(keyBar, visible: keyBarVisible)
@@ -323,11 +424,15 @@ final class KeyCommandTerminalView: TerminalView {
addInteraction(UIContextMenuInteraction(delegate: delegate))
}
/// Whether a selection exists reuses SwiftTerm's own `copy` eligibility
/// (`canPerformAction` returns `selection.active`); pure read, no bytes.
var hasActiveSelection: Bool {
canPerformAction(#selector(UIResponderStandardEditActions.copy(_:)), withSender: nil)
}
// NOTE (T-iOS-33/31 root fix): the "does a selection exist" read used to be
// a LOCAL `hasActiveSelection` here, computed via SwiftTerm's own `copy`
// eligibility (`canPerformAction` answers from `selection.active`). SwiftTerm
// v1.14.0 promoted the very same thing to `public var hasActiveSelection`
// (`selection?.active ?? false`) on its own view, which then COLLIDED with
// the local one and pinned the dependency at 1.13.0 (`override` cannot fix a
// `public`-but-not-`open` property). The local copy is therefore gone and
// every caller the pointer context menu, the find-bar tests now reads
// upstream's property, so the pin rides at 1.15.0. Do not reintroduce it.
/// Copy the current selection via SwiftTerm's own `copy(_:)` (selection
/// `UIPasteboard`). Pure UI: it never writes to the PTY, so the byte stream
@@ -336,3 +441,23 @@ final class KeyCommandTerminalView: TerminalView {
copy(nil)
}
}
// MARK: - Terminal search binding (T-iOS-33)
/// The find bar's engine is SwiftTerm's own scrollback search
/// (`TerminalViewSearch.swift`): `findNext`/`findPrevious` select + scroll the
/// match (that selection IS the highlight) and `clearSearch` drops both. Pure
/// read no PTY byte is produced, so search also works on an exited session.
extension KeyCommandTerminalView: TerminalSearching {
func searchNext(term: String) -> Bool {
findNext(term)
}
func searchPrevious(term: String) -> Bool {
findPrevious(term)
}
func searchClear() {
clearSearch()
}
}

View File

@@ -0,0 +1,131 @@
import APIClient
import SwiftUI
import WireProtocol
/// T-iOS-32 · Worktree`POST /projects/worktree`G
///
/// + ref = HEAD
/// canonical /" Worktree "
/// "spin up a worktree, land the winner, delete the rest"
///
/// `WorktreeBranchRule`//
/// `Text(verbatim:)`
struct WorktreeSheet: View {
@State private var viewModel: WorktreeViewModel
/// worktree
let onCreated: () -> Void
/// " worktree " canonical
let onOpenSession: (String) -> Void
@Environment(\.dismiss) private var dismiss
init(
viewModel: WorktreeViewModel,
onCreated: @escaping () -> Void,
onOpenSession: @escaping (String) -> Void
) {
_viewModel = State(initialValue: viewModel)
self.onCreated = onCreated
self.onOpenSession = onOpenSession
}
var body: some View {
@Bindable var bindable = viewModel
return NavigationStack {
Form {
if case .created(let path, let branch) = viewModel.createPhase {
createdSection(path: path, branch: branch)
} else {
formSection(
branch: $bindable.branchText, base: $bindable.baseText
)
}
}
.navigationTitle(WorktreeCopy.sheetTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(WorktreeCopy.cancel) { dismiss() }
}
}
}
}
@ViewBuilder private func formSection(
branch: Binding<String>, base: Binding<String>
) -> some View {
Section {
TextField(WorktreeCopy.branchField, text: branch)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
.onSubmit { submit() }
TextField(WorktreeCopy.baseField, text: base)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} footer: {
if let message = viewModel.branchValidationMessage {
Text(message)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
}
}
Section {
Button {
submit()
} label: {
if viewModel.createPhase == .creating {
ProgressView()
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
} else {
Label(WorktreeCopy.submit, systemImage: "plus.rectangle.on.folder")
}
}
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(!viewModel.canSubmitCreate)
.listRowBackground(Color.clear)
if case .failed(let message) = viewModel.createPhase {
InlineMessage(text: message, tone: .error)
}
}
}
@ViewBuilder private func createdSection(path: String, branch: String) -> some View {
Section(WorktreeCopy.createdTitle) {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
Text(verbatim: branch)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: path)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(2)
.truncationMode(.middle)
}
}
Section {
Button {
DS.Haptics.selection()
onOpenSession(path)
dismiss()
} label: {
Label(WorktreeCopy.openInNewWorktree, systemImage: "terminal.fill")
}
.buttonStyle(DSButtonStyle(kind: .primary))
.disabled(path.isEmpty)
.listRowBackground(Color.clear)
Button(WorktreeCopy.cancel) { dismiss() }
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowBackground(Color.clear)
}
}
private func submit() {
Task {
await viewModel.create()
if case .created = viewModel.createPhase {
DS.Haptics.success()
onCreated() // worktree
}
}
}
}

View File

@@ -0,0 +1,161 @@
import APIClient
import Foundation
// C2 · git **** I/O SwiftUI w6 "
// 绿"
//
// `docs/plans/w6-project-git-panel.md`Design rule that drives everything
// + web `public/projects.ts:615-745 makeSyncBand`iOS web
//
// MARK: - w6/G3
/// /worktree ""
///
/// optional 0`ahead`/`behind` `@{u}`
/// `@{u}` **** fetch
/// - `ahead`
/// - `behind` **** `lastFetchMs` ""
/// - `upstream == nil` ""****"西"
/// - `nil` "" `?? 0` 绿
///
struct GitSyncBand: Equatable {
/// HEAD
enum Head: Equatable {
/// HEAD ahead/behind
case detached
/// worktree
case noUpstream
/// nil = `` 0
case tracking(upstream: String, ahead: Int?, behind: Int?)
}
/// `unknown` dirty `PROJECT_DIRTY_CHECK=0`
/// **** clean ""
enum Dirty: Equatable {
case unknown
case clean
case changed(Int)
}
/// `FETCH_HEAD` `behind` web `FETCH_STALE_MS`
/// `public/projects.ts:615`
static let fetchStaleMs: Double = 60 * 60 * 1000
let head: Head
let dirty: Dirty
/// 绿 && 0 && 0 && fetch
let isInSync: Bool
/// `behind` fetch false ""
let isBehindVerified: Bool
/// HEAD fetch 400
let canFetch: Bool
/// nil = git
static func make(sync: SyncState?, dirtyCount: Int?, nowMs: Double) -> GitSyncBand? {
guard let sync else { return nil }
let isStale = Self.isStale(lastFetchMs: sync.lastFetchMs, nowMs: nowMs)
let head = Self.head(for: sync)
let isTracking: Bool
if case .tracking = head { isTracking = true } else { isTracking = false }
return GitSyncBand(
head: head,
dirty: Self.dirty(for: dirtyCount),
isInSync: isTracking && sync.ahead == 0 && sync.behind == 0 && !isStale,
isBehindVerified: isTracking && !isStale && sync.behind != nil,
canFetch: sync.detached != true
)
}
/// fetchnil " fetch "" fetch"
private static func isStale(lastFetchMs: Double?, nowMs: Double) -> Bool {
guard let lastFetchMs else { return true }
return nowMs - lastFetchMs > fetchStaleMs
}
private static func head(for sync: SyncState) -> Head {
if sync.detached == true { return .detached }
guard let upstream = sync.upstream else { return .noUpstream }
return .tracking(upstream: upstream, ahead: sync.ahead, behind: sync.behind)
}
private static func dirty(for dirtyCount: Int?) -> Dirty {
guard let dirtyCount else { return .unknown }
return dirtyCount > 0 ? .changed(dirtyCount) : .clean
}
}
// MARK: - w6/G4
/// `git log` "/"线
///
/// `unpushed` SHA `@{u}..HEAD`
/// **** merge
/// " unpushed"" N "
enum GitLogBoundary {
/// ****nil =
///
static func index(commits: [CommitLogEntry], upstream: String?) -> Int? {
guard upstream != nil else { return nil }
// `unpushed` true nil "" false
return commits.lastIndex { $0.unpushed == true }
}
}
// MARK: -
/// / fetch
/// 退
enum GitTimeFormat {
private static let msPerSecond: Double = 1_000
private static let secondsPerMinute: Double = 60
private static let secondsPerHour: Double = 60 * 60
private static let secondsPerDay: Double = 24 * 60 * 60
///
private static let justNowSeconds: Double = 60
static func relative(fromMs: Double, nowMs: Double) -> String {
let seconds = max(0, (nowMs - fromMs) / msPerSecond)
if seconds < justNowSeconds { return Copy.justNow }
if seconds < secondsPerHour {
return Copy.minutesAgo(Int(seconds / secondsPerMinute))
}
if seconds < secondsPerDay {
return Copy.hoursAgo(Int(seconds / secondsPerHour))
}
return Copy.daysAgo(Int(seconds / secondsPerDay))
}
private enum Copy {
static let justNow = "刚刚"
static func minutesAgo(_ value: Int) -> String { "\(value) 分钟前" }
static func hoursAgo(_ value: Int) -> String { "\(value) 小时前" }
static func daysAgo(_ value: Int) -> String { "\(value) 天前" }
}
}
// MARK: -
/// git stage/commit/push/fetch/worktree****
///
/// git stderr SEC-M10****
///
/// message
enum GitWriteFeedback {
static func message(status: Int, serverMessage: String?, fallback: String) -> String {
guard let serverMessage, !serverMessage.isEmpty else {
return "\(fallback)HTTP \(status)"
}
return serverMessage
}
/// `APIClientError` 401
/// `localizedDescription`
///
static func message(for error: any Error, fallback: String) -> String {
(error as? APIClientError)?.message ?? fallback
}
/// 429****
static let rateLimited = APIClientError.rateLimited.message
}

View File

@@ -0,0 +1,369 @@
import APIClient
import Foundation
import Observation
import WireProtocol
/// C2 · git `docs/plans/w6-project-git-panel.md`
///
/// **** web
/// + + fetch· stage/unstage · commit · push ·
/// `git log`· PR/CI
///
///
/// 1. ** git **/
/// `ahead`/`behind`/`lastFetchMs` w6
/// push
/// 2. ****PRgh
/// ****""
/// 3. ****SEC-M10 stderr
///
@MainActor
@Observable
final class GitPanelViewModel {
///
///
/// `stagePaths` **** oldPath newPath
/// `git add` web `public/diff.ts`
/// """"
struct ChangedFile: Equatable, Identifiable, Sendable {
let displayPath: String
let stagePaths: [String]
let status: DiffFileStatus
let added: Int
let removed: Int
var id: String { displayPath }
static func make(from file: DiffFile) -> ChangedFile {
let primary = file.newPath.isEmpty ? file.oldPath : file.newPath
let isRename = file.status == .renamed && !file.oldPath.isEmpty
&& file.oldPath != file.newPath
return ChangedFile(
displayPath: isRename ? "\(file.oldPath)\(file.newPath)" : primary,
stagePaths: isRename ? [file.oldPath, file.newPath] : [primary],
status: file.status,
added: file.added,
removed: file.removed
)
}
}
/// + spinner
enum Operation: Equatable {
case fetch
case commit
case push
case stage(String)
case unstage(String)
}
/// `forProject` `APIClient`/`DiffFetcher` fake
struct Dependencies: Sendable {
let detail: @Sendable () async throws -> ProjectDetail
let log: @Sendable () async throws -> GitLogResult
let pr: @Sendable () async throws -> PrStatus
let changes: @Sendable (_ staged: Bool) async throws -> [ChangedFile]
let stage: @Sendable (_ files: [String], _ stage: Bool) async throws
-> GitWriteOutcome<StageResult>
let commit: @Sendable (_ message: String) async throws -> GitWriteOutcome<CommitResult>
let push: @Sendable () async throws -> GitWriteOutcome<PushResult>
let fetchRemote: @Sendable () async throws -> GitWriteOutcome<FetchResult>
/// fetch "" 1h
let nowMs: @Sendable () -> Double
init(
detail: @escaping @Sendable () async throws -> ProjectDetail,
log: @escaping @Sendable () async throws -> GitLogResult,
pr: @escaping @Sendable () async throws -> PrStatus,
changes: @escaping @Sendable (Bool) async throws -> [ChangedFile],
stage: @escaping @Sendable ([String], Bool) async throws -> GitWriteOutcome<StageResult>,
commit: @escaping @Sendable (String) async throws -> GitWriteOutcome<CommitResult>,
push: @escaping @Sendable () async throws -> GitWriteOutcome<PushResult>,
fetchRemote: @escaping @Sendable () async throws -> GitWriteOutcome<FetchResult>,
nowMs: @escaping @Sendable () -> Double = { Date().timeIntervalSince1970 * 1_000 }
) {
self.detail = detail
self.log = log
self.pr = pr
self.changes = changes
self.stage = stage
self.commit = commit
self.push = push
self.fetchRemote = fetchRemote
self.nowMs = nowMs
}
}
let path: String
private(set) var isLoaded = false
private(set) var branch: String?
/// nil = git **** `stateErrorMessage`
private(set) var band: GitSyncBand?
private(set) var stateErrorMessage: String?
private(set) var log: GitLogResult?
private(set) var logErrorMessage: String?
private(set) var pr: PrStatus?
private(set) var unstaged: [ChangedFile] = []
private(set) var staged: [ChangedFile] = []
private(set) var changesErrorMessage: String?
private(set) var busy: Operation?
///
private(set) var errorMessage: String?
///
private(set) var noticeMessage: String?
/// 稿`TextField`
var commitMessage = ""
@ObservationIgnored
private let dependencies: Dependencies
init(path: String, dependencies: Dependencies) {
self.path = path
self.dependencies = dependencies
}
/// ProjectDetailScreen endpoint/http/path
static func forProject(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> GitPanelViewModel {
let client = APIClient(endpoint: endpoint, http: http)
let diff = DiffFetcher(endpoint: endpoint, http: http)
return GitPanelViewModel(path: path, dependencies: Dependencies(
detail: { try await client.projectDetail(path: path) },
log: { try await client.gitLog(path: path) },
pr: { try await client.prStatus(path: path) },
changes: { staged in
try await diff.fetch(path: path, staged: staged).files.map(ChangedFile.make(from:))
},
stage: { files, stage in
try await client.gitStage(path: path, files: files, stage: stage)
},
commit: { message in try await client.gitCommit(path: path, message: message) },
push: { try await client.gitPush(path: path) },
fetchRemote: { try await client.gitFetch(path: path) }
))
}
var canCommit: Bool {
busy == nil && !commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// HEAD fetch 400
var canFetch: Bool { busy == nil && band?.canFetch == true }
// MARK: -
/// +
func load() async {
await loadState()
await loadLog()
await loadChanges()
isLoaded = true
}
/// PR/CI gh
func loadPullRequest() async {
do {
pr = try await dependencies.pr()
} catch {
// 200 + availability "gh "
//
pr = nil
}
}
private func loadState() async {
do {
let detail = try await dependencies.detail()
branch = detail.branch
band = GitSyncBand.make(
sync: detail.sync, dirtyCount: detail.dirtyCount, nowMs: dependencies.nowMs()
)
stateErrorMessage = nil
} catch {
branch = nil
band = nil // ""
stateErrorMessage = GitWriteFeedback.message(
for: error, fallback: GitPanelCopy.stateFailed
)
}
}
private func loadLog() async {
do {
log = try await dependencies.log()
logErrorMessage = nil
} catch {
logErrorMessage = GitWriteFeedback.message(for: error, fallback: GitPanelCopy.logFailed)
}
}
private func loadChanges() async {
do {
unstaged = try await dependencies.changes(false)
staged = try await dependencies.changes(true)
changesErrorMessage = nil
} catch {
changesErrorMessage = GitWriteFeedback.message(
for: error, fallback: GitPanelCopy.changesFailed
)
}
}
// MARK: -
func setStaged(_ file: ChangedFile, staged: Bool) async {
await perform(
staged ? .stage(file.id) : .unstage(file.id),
fallback: staged ? GitPanelCopy.stageFailed : GitPanelCopy.unstageFailed,
write: { try await self.dependencies.stage(file.stagePaths, staged) },
onSuccess: { [weak self] (result: StageResult) in
self?.noticeMessage = staged
? GitPanelCopy.staged(result.count)
: GitPanelCopy.unstaged(result.count)
}
)
}
func commit() async {
let message = commitMessage.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else {
errorMessage = GitPanelCopy.commitMessageRequired
return
}
await perform(
.commit,
fallback: GitPanelCopy.commitFailed,
write: { try await self.dependencies.commit(message) },
onSuccess: { [weak self] (result: CommitResult) in
// 稿409
self?.commitMessage = ""
self?.noticeMessage = GitPanelCopy.committed(result.commit)
}
)
}
func push() async {
await perform(
.push,
fallback: GitPanelCopy.pushFailed,
write: { try await self.dependencies.push() },
onSuccess: { [weak self] (result: PushResult) in
self?.noticeMessage = GitPanelCopy.pushed(
branch: result.branch, remote: result.remote
)
}
)
}
func fetchRemote() async {
await perform(
.fetch,
fallback: GitPanelCopy.fetchFailed,
write: { try await self.dependencies.fetchRemote() },
onSuccess: { [weak self] (_: FetchResult) in
self?.noticeMessage = GitPanelCopy.fetched
}
)
}
/// **
/// **`write`/`onSuccess` MainActor
private func perform<Payload: Sendable & Equatable>(
_ operation: Operation,
fallback: String,
write: () async throws -> GitWriteOutcome<Payload>,
onSuccess: (Payload) -> Void
) async {
guard busy == nil else { return } //
busy = operation
errorMessage = nil
noticeMessage = nil
do {
switch try await write() {
case .ok(let payload):
onSuccess(payload)
busy = nil
await load() // w6 push
return
case .rejected(let status, let message):
errorMessage = GitWriteFeedback.message(
status: status, serverMessage: message, fallback: fallback
)
case .rateLimited:
errorMessage = GitWriteFeedback.rateLimited //
}
} catch {
errorMessage = GitWriteFeedback.message(for: error, fallback: fallback)
}
busy = nil
}
}
// MARK: - plan §4
enum GitPanelCopy {
static let title = "Git 面板"
static let entryLabel = "Git 面板"
static let stateSection = "同步状态"
static let changesSection = "改动"
static let commitSection = "提交"
static let logSection = "最近提交"
static let prSection = "Pull Request"
static let upstreamCaption = "上游"
static let toPushCaption = "待推送"
static let toPullCaption = "待拉取"
static let unknownCount = ""
static let inSync = "已同步"
static let unverified = "未核实"
static let noUpstream = "无上游"
static let detachedHead = "分离 HEAD"
static let dirtyUnknown = "未检查改动"
static let clean = "工作区干净"
static let neverFetched = "从未 fetch —— 待拉取数不可信"
static let staleFetch = "上次 fetch 已久 —— 待拉取数不可信"
static let noUpstreamDetail = "此分支不跟踪任何上游,没有可报告的待推送/待拉取。"
static let detachedDetail = "HEAD 处于分离状态:没有分支,也无法 fetch。"
static let fetchButton = "Fetch"
static let fetchHint = "只刷新远端引用,不 pull、不合并"
static let commitButton = "提交"
static let pushButton = "推送"
static let commitPlaceholder = "提交信息"
static let stageButton = "暂存"
static let unstageButton = "取消暂存"
static let noChanges = "工作区没有待提交的改动。"
static let noStaged = "暂存区为空。"
static let noCommits = "暂无提交记录。"
static let truncatedLog = "仅显示最近若干条提交。"
static let commitMessageRequired = "请先填写提交信息。"
static let stateFailed = "读取 git 状态失败"
static let logFailed = "读取提交记录失败"
static let changesFailed = "读取改动列表失败"
static let stageFailed = "暂存失败"
static let unstageFailed = "取消暂存失败"
static let commitFailed = "提交失败"
static let pushFailed = "推送失败"
static let fetchFailed = "Fetch 失败"
static let fetched = "已刷新远端引用。"
static func staged(_ count: Int) -> String { "已暂存 \(count) 个文件。" }
static func unstaged(_ count: Int) -> String { "已取消暂存 \(count) 个文件。" }
static func committed(_ commit: String) -> String {
commit.isEmpty ? "已提交。" : "已提交 \(commit)"
}
static func pushed(branch: String?, remote: String?) -> String {
guard let branch, let remote else { return "已推送。" }
return "已推送 \(branch)\(remote)"
}
static func unpushedBoundary(_ upstream: String) -> String { "以上未推送到 \(upstream)" }
static func dirtyCount(_ count: Int) -> String { "\(count) 处未提交改动" }
static func lastFetched(_ label: String) -> String { "上次 fetch\(label)" }
}

View File

@@ -42,9 +42,59 @@ import WireProtocol
@MainActor
@Observable
final class PairingViewModel {
/// The probe, injected as a closure so tests can both fake results AND
/// assert non-invocation before the user confirms (task RED list).
typealias Probe = @Sendable (HostEndpoint) async -> Result<HostEndpoint, PairingError>
/// Everything the pairing flow needs from the network/platform, injected as
/// ONE value built by the composition root (`AppEnvironment.probe`).
///
/// Why a struct and not three parameters: `AppEnvironment.probe` is handed to
/// this VM by `AppCoordinator`, so growing the capability set inside the
/// value keeps the composition root the single place they are built adding
/// separately-defaulted parameters instead would silently fall back to
/// defaults in production, i.e. a dead hook.
///
/// Tests fake results AND assert non-invocation before the user confirms
/// (task RED list); `validateToken`/`unregisterPush` default to the
/// zero-config behaviour so existing call sites stay one-liners.
struct Probe: Sendable {
/// Two-step host verification (`runPairingProbe`), carrying an optional
/// candidate access token for a host that gates its surface (§1.1).
let verifyHost: @Sendable (HostEndpoint, AccessToken?) async
-> Result<HostEndpoint, PairingError>
/// One-shot `POST /auth` validation of a candidate token §1.1's four
/// outcomes, or a transport-level failure.
let validateToken: @Sendable (HostEndpoint, AccessToken) async
-> Result<AccessTokenProbeResult, TokenProbeFailure>
/// Side effect of removing a host: de-register THIS device's APNs token
/// on that host (`PushRegistrar.handleHostRemoved`). Failure is the
/// registrar's to log removal must never be blocked by it.
let unregisterPush: @Sendable (HostRegistry.Host) async -> Void
init(
verifyHost: @escaping @Sendable (HostEndpoint, AccessToken?) async
-> Result<HostEndpoint, PairingError>,
/// Unscripted default = "this host has auth disabled", which is the
/// zero-config LAN reality and never fabricates an authenticated state.
validateToken: @escaping @Sendable (HostEndpoint, AccessToken) async
-> Result<AccessTokenProbeResult, TokenProbeFailure> = { _, _ in
.success(.authDisabled)
},
unregisterPush: @escaping @Sendable (HostRegistry.Host) async -> Void = { _ in }
) {
self.verifyHost = verifyHost
self.validateToken = validateToken
self.unregisterPush = unregisterPush
}
}
/// Why a `POST /auth` probe never produced one of the four §1.1 outcomes.
/// Deliberately payload-free about the token itself (§5.3).
enum TokenProbeFailure: Error, Equatable, Sendable {
/// Transport-level failure / unexpected status; carries the network
/// description only (never the token).
case unreachable(String)
/// The candidate violated the frozen shape before any I/O defensive:
/// the VM validates first, so this should be unreachable in practice.
case malformed
}
// MARK: - UI state model
@@ -81,6 +131,11 @@ final class PairingViewModel {
/// iOS Local Network permission was denied deep-link to the app's
/// Settings pane (its toggle lives there).
case openLocalNetworkSettings
/// C1 · The host answered **401**, which is ambiguous by design (Origin
/// or access token same status). Offer the one remedy that can be
/// applied from the phone: type the access token. Retry stays available
/// for the Origin case.
case enterAccessToken
}
struct FailureDisplay: Equatable, Sendable {
@@ -93,6 +148,10 @@ final class PairingViewModel {
case confirming(PendingHost)
case probing(PendingHost)
case failed(PendingHost, FailureDisplay)
/// C1 · Access-token prompt for a host that answered 401.
case awaitingToken(PendingHost)
/// C1 · `POST /auth` in flight for the typed candidate.
case validatingToken(PendingHost)
case paired(HostRegistry.Host)
}
@@ -112,11 +171,24 @@ final class PairingViewModel {
/// Navigate signal: set exactly once when pairing completes (T-iOS-15
/// observes it to move on to the session list).
private(set) var pairedHost: HostRegistry.Host?
/// Inline copy under the token field (wrong token / rate-limited / this host
/// has no auth at all / shape violation). NEVER echoes the token itself.
private(set) var tokenRejection: String?
/// C1 · Already-paired hosts, for the manage section (token update + remove).
/// Loaded explicitly by the screen pairing itself never needs it.
private(set) var pairedHosts: [HostRegistry.Host] = []
/// Explicit store-failure copy for the manage section (never a silent
/// empty list hiding a broken keychain).
private(set) var hostsErrorMessage: String?
// MARK: - Dependencies (not observed)
@ObservationIgnored private let store: any HostStore
@ObservationIgnored private let probe: Probe
/// The token validated by `POST /auth` for the host being paired, held in
/// memory ONLY until the host record is written (Keychain via `HostStore`).
/// nil = no token (LAN default, or the host reported auth disabled).
@ObservationIgnored private var candidateToken: AccessToken?
/// C-iOS-3 · Whether a device client certificate is installed. Used to gate
/// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
/// production reads the keychain. Defaulted so existing call sites compile.
@@ -124,7 +196,7 @@ final class PairingViewModel {
init(
store: any HostStore,
probe: @escaping Probe,
probe: Probe, // C1 · a value now (three capabilities), no longer a closure
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
KeychainClientIdentityStore().hasInstalledIdentity()
}
@@ -170,11 +242,15 @@ final class PairingViewModel {
}
/// Back out of confirm/failed to a clean entry state. Never probes.
/// Drops the in-memory token candidate too an abandoned pairing must not
/// leave a secret behind for the next target.
func cancel() {
phase = .idle
inputRejection = nil
hasAcknowledgedPublicRisk = false
needsPublicRiskAcknowledgement = false
tokenRejection = nil
candidateToken = nil
}
// MARK: - Confirm probe store
@@ -200,7 +276,9 @@ final class PairingViewModel {
switch phase {
case .idle, .confirming, .failed:
return true
case .probing, .paired:
case .probing, .awaitingToken, .validatingToken, .paired:
// Mid-credential-entry counts as busy: a scan landing while the
// token prompt is up must not silently retarget the token.
return false
}
}
@@ -209,6 +287,8 @@ final class PairingViewModel {
inputRejection = nil
hasAcknowledgedPublicRisk = false
needsPublicRiskAcknowledgement = false
tokenRejection = nil
candidateToken = nil // a new target never inherits the previous token
hostName = endpoint.baseURL.host ?? ""
phase = .confirming(PendingHost(
endpoint: endpoint, warning: Self.warning(for: endpoint)
@@ -228,7 +308,10 @@ final class PairingViewModel {
return
}
phase = .probing(pending)
switch await probe(pending.endpoint) {
// The candidate token (if any) travels with BOTH probe legs the HTTP
// one as a `Cookie` header via APIClient, the WS one via the
// probe-scoped transport the composition root builds (§1.1).
switch await probe.verifyHost(pending.endpoint, candidateToken) {
case .failure(let error):
phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
case .success(let endpoint):
@@ -239,13 +322,23 @@ final class PairingViewModel {
/// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED
/// endpoint. A store failure is surfaced explicitly (never swallowed);
/// retry re-runs the whole confirm flow.
///
/// C1 · Re-pairing the SAME origin updates that host in place (its `id` is
/// reused) instead of appending a duplicate. That is what makes "this host
/// just got a WEBTERM_TOKEN" recoverable: pair it again, type the token, and
/// the existing record the one every `lastSessionId`/watermark is keyed by
/// gains the token.
private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
let existing = await existingHost(matching: endpoint)
let host = HostRegistry.Host(
id: UUID(),
name: trimmedName.isEmpty ? fallbackName : trimmedName,
endpoint: endpoint
id: existing?.id ?? UUID(),
name: resolvedName(for: endpoint, existing: existing),
endpoint: endpoint,
// A validated candidate wins; otherwise keep whatever the record
// already had (re-pairing to fix a name must not wipe a working
// credential). `.authDisabled` clears the candidate first, so a
// host that reports no auth can never persist a token here.
accessToken: candidateToken ?? existing?.accessToken
)
do {
_ = try await store.upsert(host)
@@ -255,10 +348,128 @@ final class PairingViewModel {
))
return
}
candidateToken = nil // persisted drop the in-memory copy
pairedHost = host
phase = .paired(host)
}
/// The already-paired host at the same origin, or nil. A store read failure
/// degrades to nil (pair as new) rather than blocking the pairing.
private func existingHost(matching endpoint: HostEndpoint) async -> HostRegistry.Host? {
let hosts = try? await store.loadAll()
return hosts?.first { $0.endpoint.originHeader == endpoint.originHeader }
}
/// User-typed name wins; an untouched field keeps the existing host's name
/// (re-pairing must not rename "MacBook" to "192.168.1.5").
private func resolvedName(
for endpoint: HostEndpoint, existing: HostRegistry.Host?
) -> String {
let trimmed = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
let derived = endpoint.baseURL.host ?? endpoint.originHeader
if trimmed.isEmpty || trimmed == derived {
return existing?.name ?? derived
}
return trimmed
}
// MARK: - Access token (§1.1: POST /auth is a one-shot pairing probe)
/// Failure page token prompt. Only from a 401-shaped failure, which is the
/// only failure whose remedy might be a token.
func beginAccessTokenEntry() {
guard case .failed(let pending, let failure) = phase,
failure.action == .enterAccessToken else { return }
tokenRejection = nil
phase = .awaitingToken(pending)
}
/// Token prompt back to the confirm page (the URL is still fine; the user
/// may prefer to fix `ALLOWED_ORIGINS` instead).
func cancelAccessTokenEntry() {
guard case .awaitingToken(let pending) = phase else { return }
tokenRejection = nil
candidateToken = nil
phase = .confirming(pending)
}
/// Validate a typed token against the host, then re-run the host probe with
/// it. Shape is checked BEFORE any I/O (§1.1 charset/length is the server's
/// own rule, so a shape violation can never be the right token).
///
/// The four §1.1 outcomes are all handled explicitly, and `authDisabled`
/// deliberately does NOT persist anything: a 204 without `Set-Cookie` means
/// the host never enabled auth, so claiming "authenticated" would be a lie
/// and storing the token would gate nothing.
func submitAccessToken(_ raw: String) async {
guard case .awaitingToken(let pending) = phase else { return }
let token: AccessToken
do {
token = try AccessToken(validating: raw)
} catch let error as AccessTokenError {
tokenRejection = PairingCopy.tokenShapeRejected(error)
return
} catch {
tokenRejection = PairingCopy.tokenShapeUnknown
return
}
tokenRejection = nil
phase = .validatingToken(pending)
switch await probe.validateToken(pending.endpoint, token) {
case .success(.valid):
candidateToken = token
await runProbe(for: pending) // re-verify, now authenticated
case .success(.authDisabled):
candidateToken = nil
tokenRejection = PairingCopy.tokenNotRequired
phase = .awaitingToken(pending)
case .success(.invalidToken):
tokenRejection = PairingCopy.tokenInvalid
phase = .awaitingToken(pending)
case .success(.rateLimited):
tokenRejection = PairingCopy.tokenRateLimited
phase = .awaitingToken(pending)
case .failure(.unreachable(let underlying)):
tokenRejection = PairingCopy.hostUnreachable(underlying)
phase = .awaitingToken(pending)
case .failure(.malformed):
tokenRejection = PairingCopy.tokenShapeUnknown
phase = .awaitingToken(pending)
}
}
// MARK: - Paired-host management (C1 · the removal path `handleHostRemoved` lacked)
/// Load the manage section's host list. Explicit error copy on failure.
func loadPairedHosts() async {
do {
pairedHosts = try await store.loadAll()
hostsErrorMessage = nil
} catch {
hostsErrorMessage = PairingCopy.hostsLoadFailed
}
}
/// Remove a paired host: de-register this device's APNs token on it, then
/// delete the record (which takes its access token with it the token is a
/// field of the record, so no orphaned secret can survive).
///
/// ORDER MATTERS: the de-registration POST goes out FIRST, while the record
/// (and therefore the host's access token, which the request needs to get
/// past a token gate) still exists. A failing de-registration never blocks
/// the removal the user asked for the host to be gone, and the server also
/// prunes tokens itself on an APNs 410.
func removeHost(id: UUID) async {
guard let host = pairedHosts.first(where: { $0.id == id }) else { return }
await probe.unregisterPush(host)
do {
pairedHosts = try await store.remove(id: id)
hostsErrorMessage = nil
} catch {
hostsErrorMessage = PairingCopy.hostRemoveFailed
}
}
// MARK: - PairingError copy + action (task RED list, one case each)
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
@@ -289,7 +500,14 @@ final class PairingViewModel {
case .originRejected(let hint):
// The probe already derived the complete actionable copy from
// endpoint.originHeader surface it VERBATIM, never re-derive.
return FailureDisplay(message: hint, action: .retry)
//
// C1 · The action is `.enterAccessToken`, not `.retry`: this case now
// also carries the AMBIGUOUS 401 (Origin *or* token the server
// answers the same status for both, see `unauthorizedPairingHint`),
// and typing a token is the only remedy reachable from the phone.
// The failure view keeps a retry button alongside it, so the pure
// Origin case (the 403 on the guarded kill) loses nothing.
return FailureDisplay(message: hint, action: .enterAccessToken)
case .atsBlocked(let host):
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
case .tlsFailure:
@@ -418,40 +636,3 @@ final class PairingViewModel {
private static let ipv4OctetCount = 4
private static let ipv4OctetRange = 0...255
}
/// User-facing pairing copy (plan §3.4 taxonomy actionable wording; §5.2
/// Local-Network guidance including the iOS 18 restart caveat).
enum PairingCopy {
static let scanRejected =
"二维码不是 http(s) 地址,无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。"
static let manualRejected =
"无法解析这个地址。请输入完整 URL例如 http://192.168.1.5:3000"
static let storeFailed =
"主机已通过验证,但保存到本机失败,请重试。"
static let localNetworkDenied =
"无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关"
+ "iOS 18 存在需要重启手机才生效的已知问题)。"
static let notWebTerminal =
"对方在响应 HTTP但不是 web-terminal——端口对吗"
static let tlsFailure =
"TLS 连接失败:证书无效或不受信任。"
static let timeout =
"连接超时。请确认主机在线、与手机在同一网络后重试。"
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
static let deviceCertRequired =
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
static let clientCertRejected =
"本设备证书无效或已吊销,请重新导入。"
static func hostUnreachable(_ underlying: String) -> String {
"无法连接主机:\(underlying)"
}
/// §3.4 wording for the ATS cleartext block.
static func atsBlocked(host: String) -> String {
"明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内,"
+ "请改用 https / tailscale serve或反馈该网段。"
}
}

View File

@@ -178,6 +178,28 @@ final class ProjectsViewModel {
)
}
/// T-iOS-32C2· **** `attach(null, cwd)`
/// bootstrap `claude --resume <id>\r` web
/// `public/tabs.ts:918 newTabForResume`
///
/// request cwd
/// id `ProjectResumeCommand`
/// PTY web iOS
func requestResumeClaude(cwd: String, sessionId: String) {
guard Validation.isAbsoluteCwd(cwd) else {
openErrorMessage = ProjectsCopy.openClaudeInvalidPath
return
}
guard let bootstrap = ProjectResumeCommand.bootstrapInput(sessionId: sessionId) else {
openErrorMessage = ResumeCopy.notResumable
return
}
openErrorMessage = nil
openRequest = ProjectOpenRequest(
id: UUID(), host: host, cwd: cwd, bootstrapInput: bootstrap
)
}
// MARK: - Detail assembly/
func makeDetailViewModel(path: String) -> ProjectDetailViewModel {

View File

@@ -0,0 +1,146 @@
import APIClient
import Foundation
import Observation
import WireProtocol
/// T-iOS-32 · `claude --resume <id>` `GET /sessions`
///
/// `~/.claude/projects`
/// `src/http/history.ts` `claude --resume`
/// ""`attach(null, cwd)` + attach
/// `claude --resume <id>\r` web `public/tabs.ts:918 newTabForResume`
///
/// ****`id`/`cwd`/`preview`
/// `id` ** PTY ** `ProjectResumeCommand`
/// id`;` `` ` `` `$(`
/// web `claude --resume ${sessionId}\r` iOS
@MainActor
@Observable
final class ResumeHistoryViewModel {
/// `bootstrapInput == nil` id
///
struct ResumeCandidate: Equatable, Identifiable, Sendable {
let session: HistorySession
/// cwdworktree worktree
let cwd: String
let bootstrapInput: String?
var id: String { session.id }
var canResume: Bool { bootstrapInput != nil }
}
enum Phase: Equatable {
case loading
case loaded([ResumeCandidate])
///
case empty
case failed(String)
}
let projectPath: String
private(set) var phase: Phase = .loading
@ObservationIgnored
private let fetch: @Sendable () async throws -> [HistorySession]
init(projectPath: String, fetch: @escaping @Sendable () async throws -> [HistorySession]) {
self.projectPath = projectPath
self.fetch = fetch
}
///
static func forProject(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> ResumeHistoryViewModel {
let client = APIClient(endpoint: endpoint, http: http)
return ResumeHistoryViewModel(projectPath: path, fetch: {
try await client.claudeSessions()
})
}
/// Fetch
func load() async {
phase = .loading
do {
let candidates = Self.candidates(from: try await fetch(), projectPath: projectPath)
phase = candidates.isEmpty ? .empty : .loaded(candidates)
} catch {
phase = .failed(GitWriteFeedback.message(for: error, fallback: ResumeCopy.loadFailed))
}
}
/// + mtime ****
///
/// cwd cwd
/// resume `/` `/repos/web-terminal-old`
/// `/repos/web-terminal`
static func candidates(
from sessions: [HistorySession], projectPath: String
) -> [ResumeCandidate] {
let root = normalized(projectPath)
guard Validation.isAbsoluteCwd(root) else { return [] }
return sessions.compactMap { session in
let cwd = normalized(session.cwd)
guard Validation.isAbsoluteCwd(cwd), isInside(cwd: cwd, root: root) else { return nil }
return ResumeCandidate(
session: session,
cwd: cwd,
bootstrapInput: ProjectResumeCommand.bootstrapInput(sessionId: session.id)
)
}
}
/// `/` `/`
private static func normalized(_ path: String) -> String {
guard path.count > 1, path.hasSuffix("/") else { return path }
return String(path.dropLast())
}
private static func isInside(cwd: String, root: String) -> Bool {
cwd == root || cwd.hasPrefix(root.hasSuffix("/") ? root : root + "/")
}
}
// MARK: -
/// id PTY
///
/// id `.jsonl` UUID
/// `[A-Za-z0-9._-]` `;``|``&``` ` ```$`
/// " id" id
enum ProjectResumeCommand {
/// UUID 36
static let maxIdLength = 128
private static let allowed = Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
/// nil = id `\r`0x0D Enter `\r`
/// `\n`CLAUDE.md Gotchas
static func bootstrapInput(sessionId: String) -> String? {
guard isWellFormed(sessionId) else { return nil }
return "claude --resume \(sessionId)\r"
}
static func isWellFormed(_ sessionId: String) -> Bool {
!sessionId.isEmpty
&& sessionId.count <= maxIdLength
&& sessionId.allSatisfy(allowed.contains)
}
}
// MARK: - plan §4
enum ResumeCopy {
static let entryLabel = "恢复历史会话"
static let sheetTitle = "历史会话"
static let emptyTitle = "暂无历史会话"
static let emptyDetail = "主机在此仓库下还没有 Claude Code 历史记录(`~/.claude/projects`)。"
static let loadFailed = "读取历史会话失败"
static let retry = "重试"
static let resume = "恢复"
static let notResumable = "会话标识不合法,无法恢复。"
static let noPreview = "(无首条提示)"
static func resumeAccessibilityLabel(_ project: String) -> String { "恢复 \(project) 的会话" }
}

View File

@@ -57,6 +57,19 @@ final class TerminalViewModel {
static let replayTooLargeMessage =
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
/// Actionable copy for `.failed(.unauthorized)` (C1 over B3, ios-completion
/// §1.1). The server answers **401 on the WS upgrade for two different
/// reasons** the Origin/CSWSH check runs first, the `webterm_auth` cookie
/// second (src/server.ts:1367-1379) and the status is identical, so the
/// client provably cannot tell them apart. Hence the copy names BOTH
/// remedies instead of guessing one. Retrying is pointless (the engine
/// already treats this as terminal), so the message asks for a credential
/// change, not patience.
static let unauthorizedMessage =
"主机拒绝了连接401访问令牌缺失或不正确也可能是主机的 ALLOWED_ORIGINS 不含本 App 拨号的地址。"
+ "请在会话列表的主机菜单里「管理主机与令牌」重新输入访问令牌;"
+ "若令牌无误,就把主机的 ALLOWED_ORIGINS 设成 App 连接的完整地址后重启 web-terminal。"
/// Last VALID dims forwarded to the engine (SwiftTerm `sizeChanged`).
/// Read by the wiring layer for `notifyForegrounded(dims:)` the frozen
/// §3.2 signature needs real cols/rows and this is their single source.
@@ -271,6 +284,11 @@ final class TerminalViewModel {
case .failed(.replayTooLarge):
banner = .none
phase = .failed(message: Self.replayTooLargeMessage)
case .failed(.unauthorized):
// Same shape as replayTooLarge: a terminal state, so the spinner
// must go and the terminal becomes read-only with actionable copy.
banner = .none
phase = .failed(message: Self.unauthorizedMessage)
}
}

View File

@@ -0,0 +1,327 @@
import APIClient
import Foundation
import Observation
import WireProtocol
/// T-iOS-32 · worktree create / prune / remove
///
/// `src/http/worktrees.ts` + `docs/plans/w4-worktree-lifecycle.md`web
/// `public/projects.ts``validateBranchNameClient``confirmAndRemoveWorktree`
/// `confirmAndPruneWorktrees`
///
///
/// 1. ****
///
/// 2. **** 409** force**
/// UI
/// 3. ****SEC-M10 git stderr
@MainActor
@Observable
final class WorktreeViewModel {
struct Dependencies: Sendable {
let create: @Sendable (_ branch: String, _ base: String?) async throws
-> GitWriteOutcome<CreateWorktreeResult>
let prune: @Sendable () async throws -> GitWriteOutcome<PruneWorktreesResult>
let remove: @Sendable (_ worktreePath: String, _ force: Bool) async throws
-> GitWriteOutcome<RemoveWorktreeResult>
}
enum CreatePhase: Equatable {
case idle
case creating
/// canonical path/branch
case created(path: String, branch: String)
case failed(String)
}
enum PrunePhase: Equatable {
case idle
case pruning
case done(String)
case failed(String)
}
enum RemovePhase: Equatable {
case idle
case removing
/// 409 force`confirmForceRemove`
/// `cancelForceRemove`
case forceConfirming(path: String, message: String)
case removed(path: String)
case failed(String)
}
/// `TextField`
var branchText = ""
/// ref = HEAD ****
var baseText = ""
private(set) var createPhase: CreatePhase = .idle
private(set) var prunePhase: PrunePhase = .idle
private(set) var removePhase: RemovePhase = .idle
@ObservationIgnored
private let dependencies: Dependencies
init(dependencies: Dependencies) {
self.dependencies = dependencies
}
///
static func forProject(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> WorktreeViewModel {
let client = APIClient(endpoint: endpoint, http: http)
return WorktreeViewModel(dependencies: Dependencies(
create: { branch, base in
try await client.createWorktree(path: path, branch: branch, base: base)
},
prune: { try await client.pruneWorktrees(path: path) },
remove: { worktreePath, force in
try await client.removeWorktree(
path: path, worktreePath: worktreePath, force: force
)
}
))
}
///
var branchValidationMessage: String? {
let branch = trimmedBranch
guard !branch.isEmpty else { return nil }
return WorktreeBranchRule.validate(branch)
}
var canSubmitCreate: Bool {
createPhase != .creating && WorktreeBranchRule.validate(trimmedBranch) == nil
}
var isBusy: Bool {
createPhase == .creating || prunePhase == .pruning || removePhase == .removing
}
/// ""worktree worktree 400
/// unlock 409 UI
static func canRemove(_ worktree: WorktreeInfo) -> Bool {
!worktree.isMain && worktree.locked != true
}
private var trimmedBranch: String {
branchText.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedBase: String? {
let base = baseText.trimmingCharacters(in: .whitespacesAndNewlines)
return base.isEmpty ? nil : base
}
// MARK: -
func create() async {
guard createPhase != .creating else { return } //
let branch = trimmedBranch
if let invalid = WorktreeBranchRule.validate(branch) {
createPhase = .failed(invalid) // I/O
return
}
createPhase = .creating
do {
switch try await dependencies.create(branch, trimmedBase) {
case .ok(let result):
createPhase = .created(
path: result.path ?? "", branch: result.branch ?? branch
)
case .rejected(let status, let message):
createPhase = .failed(GitWriteFeedback.message(
status: status, serverMessage: message, fallback: WorktreeCopy.createFailed
))
case .rateLimited:
createPhase = .failed(GitWriteFeedback.rateLimited)
}
} catch {
createPhase = .failed(GitWriteFeedback.message(
for: error, fallback: WorktreeCopy.createFailed
))
}
}
/// sheet
func resetCreate() {
branchText = ""
baseText = ""
createPhase = .idle
}
// MARK: - prune
func prune() async {
guard prunePhase != .pruning else { return }
prunePhase = .pruning
do {
switch try await dependencies.prune() {
case .ok(let result):
// =
prunePhase = .done(
result.pruned.isEmpty
? WorktreeCopy.pruneNothing
: WorktreeCopy.pruneDone(result.pruned.count)
)
case .rejected(let status, let message):
prunePhase = .failed(GitWriteFeedback.message(
status: status, serverMessage: message, fallback: WorktreeCopy.pruneFailed
))
case .rateLimited:
prunePhase = .failed(GitWriteFeedback.rateLimited)
}
} catch {
prunePhase = .failed(GitWriteFeedback.message(
for: error, fallback: WorktreeCopy.pruneFailed
))
}
}
func clearPruneResult() {
prunePhase = .idle
}
// MARK: - remove
/// `force: false`
func remove(worktreePath: String) async {
await runRemove(worktreePath: worktreePath, force: false)
}
/// `.forceConfirming`
func confirmForceRemove() async {
guard case .forceConfirming(let path, _) = removePhase else { return }
await runRemove(worktreePath: path, force: true)
}
func cancelForceRemove() {
guard case .forceConfirming = removePhase else { return }
removePhase = .idle
}
func clearRemoveResult() {
removePhase = .idle
}
private func runRemove(worktreePath: String, force: Bool) async {
guard removePhase != .removing else { return }
removePhase = .removing
do {
switch try await dependencies.remove(worktreePath, force) {
case .ok(let result):
removePhase = .removed(path: result.path ?? worktreePath)
case .rejected(let status, let message):
let text = GitWriteFeedback.message(
status: status, serverMessage: message, fallback: WorktreeCopy.removeFailed
)
// 409 = force
// force 409
removePhase = (status == WorktreeStatus.conflict && !force)
? .forceConfirming(path: worktreePath, message: text)
: .failed(text)
case .rateLimited:
removePhase = .failed(GitWriteFeedback.rateLimited)
}
} catch {
removePhase = .failed(GitWriteFeedback.message(
for: error, fallback: WorktreeCopy.removeFailed
))
}
}
}
/// VM HTTP APIClient internal
private enum WorktreeStatus {
static let conflict = 409
}
// MARK: -
/// web `validateBranchNameClient``public/projects.ts:982`
/// `git check-ref-format`
///
/// "" `execFile` argv shell `--`
/// ****
enum WorktreeBranchRule {
/// web
static let maxLength = 250
static func validate(_ branch: String) -> String? {
if branch.isEmpty { return Message.empty }
if branch.count > maxLength { return Message.tooLong }
if branch.unicodeScalars.contains(where: isForbiddenControlScalar) {
return Message.whitespaceOrControl
}
if branch.hasPrefix("-") { return Message.leadingHyphen }
if branch.contains("..") { return Message.doubleDot }
if branch.hasSuffix(".lock") { return Message.lockSuffix }
if branch.contains(where: forbiddenCharacters.contains) { return Message.forbiddenCharacter }
if branch.contains("@{") { return Message.atBrace }
if branch.hasPrefix("/") || branch.hasSuffix("/") || branch.contains("//") {
return Message.slashUsage
}
return nil
}
/// `[\x00-\x20\x7f]` C0 + DEL
private static func isForbiddenControlScalar(_ scalar: Unicode.Scalar) -> Bool {
scalar.value <= 0x20 || scalar.value == 0x7F
}
private static let forbiddenCharacters: Set<Character> = ["~", "^", ":", "?", "*", "[", "\\"]
private enum Message {
static let empty = "分支名不能为空。"
static let tooLong = "分支名过长(最多 \(maxLength) 个字符)。"
static let whitespaceOrControl = "分支名不能包含空格或控制字符。"
static let leadingHyphen = "分支名不能以 - 开头。"
static let doubleDot = "分支名不能包含 \"..\""
static let lockSuffix = "分支名不能以 \".lock\" 结尾。"
static let forbiddenCharacter = "分支名包含非法字符(~ ^ : ? * [ \\)。"
static let atBrace = "分支名不能包含 \"@{\""
static let slashUsage = "分支名的 / 用法不合法。"
}
}
// MARK: - plan §4
enum WorktreeCopy {
static let sheetTitle = "新建 Worktree"
static let branchField = "新分支名"
static let baseField = "起点(留空 = 当前 HEAD"
static let submit = "创建 Worktree"
static let cancel = "取消"
static let createdTitle = "Worktree 已创建"
static let openInNewWorktree = "在新 Worktree 开会话"
static let createFailed = "创建 worktree 失败"
static let pruneButton = "回收失效 Worktree"
static let pruneConfirmTitle = "回收文件夹已消失的 worktree"
static let pruneConfirmMessage = "只清理 git 中已失效的登记项,不会删除任何仍存在的工作树。"
static let pruneConfirm = "回收"
static let pruneNothing = "没有可回收的 worktree。"
static let pruneFailed = "回收失败"
static let removeButton = "删除"
static let removeAccessibilityLabel = "删除 worktree"
static let removeConfirmTitle = "删除这个 worktree"
static let removeConfirm = "删除"
static let forceConfirmTitle = "有未提交的改动"
static let forceConfirmMessage = "继续将丢失该 worktree 里未提交的改动。"
static let forceConfirm = "强制删除"
static let removeFailed = "删除 worktree 失败"
static let lockedHint = "已锁定:请先在终端里 unlock。"
static func pruneDone(_ count: Int) -> String { "已回收 \(count) 个失效 worktree。" }
static func removeConfirmMessage(_ path: String) -> String {
"将删除工作树目录:\(path)"
}
static func removed(_ path: String) -> String { "已删除 \(path)" }
static func created(path: String, branch: String) -> String {
"已在 \(path) 创建分支 \(branch)"
}
}

View File

@@ -3,6 +3,7 @@ import ClientTLS
import Foundation
import HostRegistry
import SessionCore
import UIKit
import WireProtocol
/// T-iOS-15 · Production dependency graph (composition root). One immutable
@@ -12,12 +13,15 @@ import WireProtocol
/// Assembly security audit (task , verified at this single point):
/// - All `G` (state-changing) HTTP goes through `APIClient` (kill via
/// SessionListViewModel's client, probe's kill round-trip inside
/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own.
/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own
/// EXCEPT the access-token `Cookie` (C1) see its type doc for why that one
/// belongs at the transport and cannot bypass the Origin rule.
/// - Origin is derived solely by `HostEndpoint` (WS: URLSessionTermTransport;
/// HTTP: APIClient's route builder) nothing here hand-assembles one.
/// - Secrets: hosts in `KeychainHostStore`
/// - Secrets: hosts (and their access tokens) in `KeychainHostStore`
/// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it);
/// UserDefaults carries only the non-secret per-host lastSessionId.
/// UserDefaults carries only the non-secret per-host lastSessionId. A token
/// never reaches a log line, a URL query, or an error description.
/// - No debug ATS overrides exist (project.yml declares NO
/// NSAllowsArbitraryLoads in any configuration only the five §5.2 CIDR
/// exceptions), and no isSecureTextEntry-style screenshot hacks are used;
@@ -27,15 +31,38 @@ struct AppEnvironment: Sendable {
let hostStore: any HostStore
let lastSessionStore: any LastSessionStore
let http: any HTTPTransport
/// The WS transport used when no per-host factory is injected: it carries
/// NO access token, so it is correct only for a host that has none. Every
/// terminal goes through `makeTermTransport(for:)`, which prefers
/// `termTransportFactory` production always installs one.
let termTransport: any TermTransport
/// Injected into `PairingViewModel` production is `runPairingProbe`
/// over the real transports (two-step: RO GET, then WS attach + guarded
/// kill; only runs after the user's explicit confirm, T-iOS-12).
/// kill; only runs after the user's explicit confirm, T-iOS-12), plus the
/// `POST /auth` token probe and the host-removal side effect (C1).
let probe: PairingViewModel.Probe
/// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults).
/// `var` + default so the memberwise init stays source-compatible for
/// pre-P1 call sites while tests can inject an in-memory fake.
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
/// E1 · Builds the WS transport for ONE host, so the upgrade can carry that
/// host's access token (§1.1). nil `termTransport` for every host (the
/// App-layer tests' `FakeTransport`); production installs the real factory.
var termTransportFactory: (@Sendable (HostRegistry.Host) -> any TermTransport)?
/// The WS transport a terminal on `host` must dial.
///
/// E1 (HIGH) · This exists because the access-token cookie is PER HOST: the
/// app previously shared one transport whose token was resolved
/// host-independently, which returned nil for the mixed fleet the token
/// feature is for (a tokened tunnel host beside an open LAN host) the
/// upgrade omitted the cookie, the server 401'd, and that failure is
/// terminal with no in-app remedy. Android never had the bug because
/// `OkHttpTermTransport` resolves `tokens.tokenFor(endpoint)`; this is the
/// same rule.
func makeTermTransport(for host: HostRegistry.Host) -> any TermTransport {
termTransportFactory?(host) ?? termTransport
}
static func production() -> AppEnvironment {
// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
@@ -50,24 +77,181 @@ struct AppEnvironment: Sendable {
let identityProvider: @Sendable () -> ClientIdentity? = {
identityStore.loadedIdentityOrNil()
}
let http = URLSessionHTTPTransport(identityProvider: identityProvider)
let termTransport = URLSessionTermTransport(identityProvider: identityProvider)
let hostStore = KeychainHostStore()
// C1 · ONE access-token source for the whole app, reading the same
// Keychain records the pairing flow writes. Both transports consult it
// per request/connect, so a token typed mid-run applies immediately
// no relaunch, and no token snapshot captured at composition.
let tokens = AccessTokenSource(store: hostStore)
let http = URLSessionHTTPTransport(
identityProvider: identityProvider,
tokenForOrigin: { origin in await tokens.token(forOrigin: origin) }
)
// E1 · ONE transport per host: the upgrade's Cookie is this host's token
// and never another's. The provider is re-consulted on every connect
// (rotation applies on the next reconnect, no relaunch).
let termTransportFactory: @Sendable (HostRegistry.Host) -> any TermTransport = { host in
URLSessionTermTransport(
identityProvider: identityProvider,
tokenProvider: { tokens.wsToken(for: host) }
)
}
return AppEnvironment(
hostStore: KeychainHostStore(),
hostStore: hostStore,
lastSessionStore: UserDefaultsLastSessionStore(),
http: http,
termTransport: termTransport,
probe: { endpoint in
await runPairingProbe(endpoint: endpoint, http: http, ws: termTransport)
}
termTransport: URLSessionTermTransport(identityProvider: identityProvider),
probe: PairingViewModel.Probe(
verifyHost: { endpoint, token in
// The candidate token has to reach BOTH probe legs. HTTP
// takes it as a parameter (APIClient stamps the Cookie); the
// WS leg gets a PROBE-SCOPED transport carrying exactly this
// candidate the frozen `TermTransport.connect` has no
// credential parameter, and the host is not paired yet, so
// the shared transport could not resolve it from the store.
let ws = URLSessionTermTransport(
identityProvider: identityProvider,
tokenProvider: { token?.rawValue }
)
return await runPairingProbe(
endpoint: endpoint, http: http, ws: ws,
accessToken: token?.rawValue
)
},
validateToken: { endpoint, token in
await probeAccessToken(endpoint: endpoint, http: http, token: token)
},
unregisterPush: { host in
await PushHostDeregistration.run(for: host)
}
),
termTransportFactory: termTransportFactory
)
}
/// Away-digest source for a session engine: wraps `APIClient.events` per
/// host (the engine never holds an HTTP client plan §3.2).
/// host (the engine never holds an HTTP client plan §3.2). The token rides
/// along at the transport, so this stays token-free.
func makeEventsSource(endpoint: HostEndpoint)
-> @Sendable (UUID) async throws -> [TimelineEvent] {
let client = APIClient(endpoint: endpoint, http: http)
return { id in try await client.events(id: id) }
}
}
// MARK: - Access-token source (C1 · ios-completion §1.1)
/// The app's single read path for per-host access tokens.
///
/// Reads the `HostStore` (Keychain) on demand rather than caching a value at
/// composition: pairing writes a token into the same records, and a snapshot
/// taken at launch would make "type the token, then connect" require a relaunch.
/// A Keychain read is a sub-millisecond `SecItemCopyMatching` + JSON decode, so
/// per-request resolution is affordable at the app's polling cadence.
///
/// SECURITY (§5.3): the token is returned as a `String` for exactly one use a
/// `Cookie` header value. It is never logged, never interpolated into a URL, and
/// the values it returns are `AccessToken`-validated (§1.1 charset), which is
/// what makes header injection impossible.
final class AccessTokenSource: @unchecked Sendable {
private let store: any HostStore
private let lock = NSLock()
/// Origin token, rebuilt from the store on every `token(forOrigin:)`.
/// See `wsToken(for:)`. Guarded by `lock`; `nonisolated` state is the reason
/// this type is `@unchecked Sendable` (an actor cannot serve the WS
/// provider, which is synchronous).
private var snapshot: [String: String] = [:]
init(store: any HostStore) {
self.store = store
}
/// This host's token, matched by ORIGIN (`HostEndpoint.originHeader` the
/// single derivation point, plan §5.1), or nil when the host has none / is
/// unknown / the store read fails. A failed read degrades to "no token"
/// rather than throwing: the request then gets the server's 401, which the
/// UI already explains, instead of the app breaking outright.
func token(forOrigin origin: String) async -> String? {
let hosts = (try? await store.loadAll()) ?? []
updateSnapshot(from: hosts)
return hosts.first { $0.endpoint.originHeader == origin }?.accessToken?.rawValue
}
/// The token a WS upgrade to `host` must present (§1.1) for the one caller
/// that can be given neither an `await` nor a parameter: SessionCore's
/// frozen `tokenProvider: @Sendable () -> String?`, which the per-host
/// transport closes over (`AppEnvironment.makeTermTransport(for:)`).
///
/// Two reads, in this order, and both are THIS host's own value no answer
/// derived from any other host can ever be returned (§5.3):
/// 1. the latest value the store returned for this origin (so a token
/// rotated mid-run applies on the next connect, no relaunch);
/// 2. the `host` record itself, which the coordinator read from the same
/// Keychain store when the session was opened this is what makes the
/// FIRST connect correct even before any HTTP request has warmed the
/// snapshot (a cold-start terminal must not depend on that race), and
/// what keeps a failed store read from silently dropping the cookie.
///
/// Consequence of (2), stated plainly: a token DELETED from the store keeps
/// riding until this terminal is reopened. That is still this host's own
/// value the server just answers 401 if it is no longer valid and it is
/// the price of never dropping the cookie on a cold connect.
func wsToken(for host: HostRegistry.Host) -> String? {
let origin = host.endpoint.originHeader
return lock.withLock { snapshot[origin] } ?? host.accessToken?.rawValue
}
private func updateSnapshot(from hosts: [HostRegistry.Host]) {
let resolved = hosts.reduce(into: [String: String]()) { map, host in
guard let token = host.accessToken?.rawValue else { return }
map[host.endpoint.originHeader] = token
}
lock.withLock { snapshot = resolved }
}
}
/// `POST /auth` (§1.1) as the pairing flow needs it: the four documented
/// outcomes, or a typed transport failure. Lives here (composition root) because
/// it is the seam between `APIClient`'s throwing API and the VM's total switch.
private func probeAccessToken(
endpoint: HostEndpoint,
http: any HTTPTransport,
token: AccessToken
) async -> Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure> {
do {
let client = APIClient(endpoint: endpoint, http: http)
return .success(try await client.probeAccessToken(token.rawValue))
} catch APIClientError.malformedToken {
return .failure(.malformed)
} catch let apiError as APIClientError {
// `.message` is user-facing copy about the STATUS, never about the token.
return .failure(.unreachable(apiError.message))
} catch {
return .failure(.unreachable((error as NSError).localizedDescription))
}
}
// MARK: - Host removal APNs de-registration (C1 · fixes the dead hook)
/// Bridge from "the user removed a host" to `PushRegistrar.handleHostRemoved`.
///
/// The registrar owns the in-memory APNs device token (deliberately never
/// persisted iOS re-delivers it on every registration), so the de-registration
/// can only be done by the LIVE instance. That instance is created and held by
/// `PushAppDelegate` (`makePushWiring`), which the system builds after this
/// composition root hence the resolution happens at CALL time through
/// `UIApplication.shared.delegate`, the platform's own object, rather than any
/// singleton of ours.
///
/// nil delegate / nil registrar (unit tests, XCUITest, a build where push was
/// never wired) is a deliberate no-op: removal must never depend on push.
enum PushHostDeregistration {
@MainActor
static func run(for host: HostRegistry.Host) async {
guard let delegate = UIApplication.shared.delegate as? PushAppDelegate,
let registrar = delegate.registrar else {
return
}
await registrar.handleHostRemoved(host)
}
}

View File

@@ -16,18 +16,28 @@ import SwiftUI
/// `StackRootView` `RootView` body **** compact
/// iPhone
/// `DS.*` token //
/// T-iOS-34 · `ThemeStore` `@main`
/// store `preferredColorScheme`
///
struct RootView: View {
@Bindable var coordinator: AppCoordinator
/// `UserDefaults` `@State` 寿
@State private var themeStore = ThemeStore()
var body: some View {
AdaptiveRootView(coordinator: coordinator)
// DS tint Tokens.swift
// gate amber orange `.tint`
.tint(DS.Palette.accent)
// web DEFAULT_SETTINGS.theme = 'dark'
// --bg #100F0D
// accent/status
.preferredColorScheme(.dark)
// web `DEFAULT_SETTINGS.theme='dark'`
// `.preferredColorScheme(.dark)`
// `colorScheme` nil = iOS
// token
// `Tokens.swift` WCAG 3:1
.preferredColorScheme(themeStore.theme.colorScheme)
// store穿
.environment(themeStore)
}
}
@@ -194,10 +204,16 @@ struct CertRenewalWarningBanner: View {
}
}
// MARK: - Projects toolbar item (shared stack + split, DRY)
// MARK: - Root leading toolbar (shared stack + split, DRY)
/// leading stack split disabled
/// `presentProjects` a11y id tint label accent
/// leading stack split disabled
/// a11y id tint label accent
///
/// +T-iOS-34
/// **** `ProjectsToolbarItem``SplitRootView.swift`
/// C4 Owns iPhone(stack)
/// iPad(split)
/// `RootLeadingToolbar` `SplitRootView.swift`
struct ProjectsToolbarItem: ToolbarContent {
@Bindable var coordinator: AppCoordinator
@@ -211,6 +227,41 @@ struct ProjectsToolbarItem: ToolbarContent {
.disabled(coordinator.sessionList.activeHost == nil)
.accessibilityIdentifier("sessions.projectsButton")
}
ToolbarItem(placement: .topBarLeading) {
SettingsToolbarButton()
}
}
}
/// 齿+ sheet `@State`
/// `AppCoordinator` `isSettingsPresented`
///
///
/// `ThemeStore` ****/ store
/// 齿 crash
struct SettingsToolbarButton: View {
@Environment(ThemeStore.self) private var themeStore: ThemeStore?
@State private var isPresented = false
var body: some View {
if let themeStore {
Button {
isPresented = true
} label: {
Label(RootCopy.settings, systemImage: "gearshape")
}
.accessibilityIdentifier("sessions.settingsButton")
.sheet(isPresented: $isPresented) {
NavigationStack {
SettingsScreen(themeStore: themeStore)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(RootCopy.done) { isPresented = false }
}
}
}
}
}
}
}
@@ -218,6 +269,7 @@ struct ProjectsToolbarItem: ToolbarContent {
enum RootCopy {
static let continueLast = "继续上次会话"
static let projects = "项目"
static let settings = "设置"
static let done = "完成"
/// B3 (HIGH) · Shown when the silent device-cert renewal keeps failing.
static let certRenewalFailing = "设备证书自动续期失败,将在下次前台重试"

View File

@@ -197,7 +197,10 @@ final class TerminalSessionController: Identifiable {
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
) -> Stack {
let engine = SessionEngine(
transport: environment.termTransport,
// E1 · The transport is built for THIS host, so the WS upgrade
// carries this host's access token (§1.1) a shared, host-blind
// transport could not resolve a token for a mixed fleet at all.
transport: environment.makeTermTransport(for: host),
clock: ContinuousClock(),
endpoint: host.endpoint,
eventsSource: environment.makeEventsSource(endpoint: host.endpoint)

View File

@@ -4,12 +4,32 @@ import WireProtocol
/// T-iOS-15 · Production `HTTPTransport` (the WireProtocol seam's doc reserves
/// the URLSession wrapper for the production side; no package owns it, so the
/// assembly layer provides it). Deliberately logic-free: `APIClient` builds
/// every request including the Origin-iff-G rule (plan §3.4 ) and this
/// type only performs the exchange. Adding ANY header/URL logic here would
/// bypass that single audited point (review CRITICAL).
/// assembly layer provides it). Deliberately logic-free about ROUTING:
/// `APIClient` builds every request including the Origin-iff-G rule (plan §3.4
/// ) and this type only performs the exchange. Adding URL or Origin logic
/// here would bypass that single audited point (review CRITICAL).
///
/// C1 · The ONE exception is the access-token `Cookie` (ios-completion §1.1),
/// and it is deliberate:
/// - the token is **unconditional** every request, RO and G alike so it has
/// no interaction with the conditional Origin rule it must never replace;
/// - it is **per host**, resolved from the request's own origin, whereas
/// `APIClient` instances are built ad hoc all over the App layer (list poll,
/// previews, projects, diffs, push registration) with no access to the
/// Keychain. Stamping at the shared transport is what makes "every request
/// carries the token" true by construction instead of per-call-site;
/// - it is resolved LAZILY per request from `tokenForOrigin` the same
/// no-relaunch pattern as the mTLS `identityProvider` below so a token typed
/// mid-run applies to the very next request.
///
/// A request that ALREADY carries a `Cookie` is left untouched: the pairing probe
/// authenticates with a *candidate* token that is not in the store yet, and
/// overwriting it here would silently unauthenticate the probe.
struct URLSessionHTTPTransport: HTTPTransport {
private let session: URLSession
/// Resolves the stored access token for a request's origin (see type doc).
/// `@Sendable`, async: the store is an actor over the Keychain.
private let tokenForOrigin: @Sendable (String) async -> String?
/// Strong reference to the mTLS delegate. URLSession retains its delegate
/// until invalidated, but this ephemeral session is never explicitly
/// invalidated, so holding it here documents the ownership and keeps the
@@ -18,6 +38,7 @@ struct URLSessionHTTPTransport: HTTPTransport {
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
/// provider, so behaviour is identical to capturing the identity directly.
/// No token source no `Cookie` is ever added (LAN zero-config default).
init(identity: ClientIdentity? = nil) {
self.init(identityProvider: { identity })
}
@@ -37,16 +58,20 @@ struct URLSessionHTTPTransport: HTTPTransport {
/// contain printed secrets and `.shared`'s default URLCache writes
/// responses to disk. Ephemeral keeps them memory-only, matching the WS
/// transport and the privacy-shade posture.
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
init(
identityProvider: @escaping @Sendable () -> ClientIdentity?,
tokenForOrigin: @escaping @Sendable (String) async -> String? = { _ in nil }
) {
let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider)
self.tlsDelegate = delegate
self.tokenForOrigin = tokenForOrigin
self.session = URLSession(
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
)
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
let (data, response) = try await session.data(for: await authenticated(request))
guard let httpResponse = response as? HTTPURLResponse else {
// http(s)-only endpoints (HostEndpoint validates) always produce
// an HTTPURLResponse; anything else is a transport-level anomaly.
@@ -54,6 +79,50 @@ struct URLSessionHTTPTransport: HTTPTransport {
}
return (data, httpResponse)
}
/// Stamp `Cookie: webterm_auth=<t>` for the request's own origin (C1).
/// Immutable style: returns a NEW request, never mutates the caller's.
///
/// `internal`, not private: this is the whole behaviour `send` adds, and it
/// is testable with zero network the alternative would be leaving the one
/// line that carries a credential unverified.
func authenticated(_ request: URLRequest) async -> URLRequest {
guard let origin = AccessTokenCookie.origin(of: request),
let token = await tokenForOrigin(origin) else {
return request
}
return AccessTokenCookie.stamped(request, token: token)
}
}
/// The single point where an access token becomes a request header (App layer).
/// Pure and static so the rules are unit-testable without any network.
enum AccessTokenCookie {
/// `AUTH_COOKIE_NAME` (src/http/auth.ts:30) the same literal the packages
/// pin; both of their `AuthCookie` helpers are package-internal, so the App
/// layer needs its own single definition rather than a fourth ad-hoc string.
static let name = "webterm_auth"
static let header = "Cookie"
/// The request's origin in `HostEndpoint.originHeader` form, via the frozen
/// single derivation point (default ports omitted, scheme/host lowercased)
/// never hand-assembled here. nil for a non-http(s) or host-less URL.
static func origin(of request: URLRequest) -> String? {
guard let url = request.url, let endpoint = HostEndpoint(baseURL: url) else {
return nil
}
return endpoint.originHeader
}
/// A copy of `request` carrying the token cookie unless it already carries
/// a `Cookie`, in which case the existing one wins (the pairing probe's
/// candidate token must not be overwritten by the stored one).
static func stamped(_ request: URLRequest, token: String) -> URLRequest {
guard request.value(forHTTPHeaderField: header) == nil else { return request }
var authenticated = request
authenticated.setValue("\(name)=\(token)", forHTTPHeaderField: header)
return authenticated
}
}
/// C-iOS-2 (MEDIUM no-relaunch fix) · Session-level mTLS delegate that resolves

View File

@@ -0,0 +1,346 @@
import Foundation
import HostRegistry
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// C1 · The app's single read path for per-host access tokens, plus the one
/// header-stamping point (`AccessTokenCookie`).
///
/// E1 · The WS cases are the important ones. SessionCore's `tokenProvider` is
/// `() -> String?` no endpoint, no await so C1 answered it with "the token
/// every paired host shares", which is *nil* for the deployment the token
/// exists for (a tokened tunnel host next to an open LAN host): the upgrade then
/// omitted the cookie, the server 401'd, and `.failed(.unauthorized)` is
/// terminal with no in-app remedy, since re-entering the correct token left
/// the fleet just as mixed. The answer is now resolved PER HOST
/// (`wsToken(for:)`), exactly like the HTTP path and like Android's
/// `tokens.tokenFor(endpoint)`.
@Suite("Access-token source")
struct AccessTokenSourceTests {
private static let tokenA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private static let tokenB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
private struct StoreFailure: Error {}
private actor ThrowingHostStore: HostStore {
func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
throw StoreFailure()
}
func remove(id: UUID) async throws -> [HostRegistry.Host] { throw StoreFailure() }
}
private func makeHost(_ base: String, token: String?) throws -> HostRegistry.Host {
let url = try #require(URL(string: base))
return HostRegistry.Host(
id: UUID(),
name: base,
endpoint: try #require(HostEndpoint(baseURL: url)),
accessToken: try token.map { try AccessToken(validating: $0) }
)
}
private func makeStore(_ hosts: [HostRegistry.Host]) async throws -> InMemoryHostStore {
let store = InMemoryHostStore()
for host in hosts {
_ = try await store.upsert(host)
}
return store
}
// MARK: - Per-origin resolution (the HTTP path)
@Test("按 origin 取到该主机的令牌;其它 origin 与无令牌主机都返回 nil")
func resolvesTokenByOrigin() async throws {
let store = try await makeStore([
try makeHost("http://192.168.1.5:3000", token: Self.tokenA),
try makeHost("http://192.168.1.9:3000", token: nil),
])
let source = AccessTokenSource(store: store)
#expect(await source.token(forOrigin: "http://192.168.1.5:3000") == Self.tokenA)
#expect(await source.token(forOrigin: "http://192.168.1.9:3000") == nil)
#expect(await source.token(forOrigin: "http://192.168.1.99:3000") == nil)
}
@Test("https 默认端口按 originHeader 规范化匹配(不写 :443")
func matchesNormalizedHTTPSOrigin() async throws {
let store = try await makeStore([
try makeHost("https://mac.tailnet.ts.net", token: Self.tokenA),
])
let source = AccessTokenSource(store: store)
#expect(await source.token(forOrigin: "https://mac.tailnet.ts.net") == Self.tokenA)
#expect(await source.token(forOrigin: "https://mac.tailnet.ts.net:443") == nil)
}
@Test("存储读取失败 → nil降级成「无令牌」不让 App 崩或卡住)")
func storeFailureDegradesToNoToken() async throws {
let source = AccessTokenSource(store: ThrowingHostStore())
#expect(await source.token(forOrigin: "http://192.168.1.5:3000") == nil)
}
// MARK: - The per-host WS answer (E1 · replaces `hostIndependentToken`)
@Test("混合机群:有令牌的那台拿到自己的令牌,没令牌的那台拿 nil旧解析对两台都给 nil")
func resolvesPerHostTokenInAMixedFleet() async throws {
let tokened = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
let open = try makeHost("http://192.168.1.9:3000", token: nil)
let source = AccessTokenSource(store: try await makeStore([tokened, open]))
_ = await source.token(forOrigin: tokened.endpoint.originHeader) //
#expect(source.wsToken(for: tokened) == Self.tokenA)
#expect(source.wsToken(for: open) == nil)
}
@Test("两台令牌不同 → 各拿各的,绝不把 A 的令牌发给 B")
func neverHandsOneHostsTokenToAnother() async throws {
let hostA = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
let hostB = try makeHost("http://192.168.1.9:3000", token: Self.tokenB)
let source = AccessTokenSource(store: try await makeStore([hostA, hostB]))
_ = await source.token(forOrigin: hostA.endpoint.originHeader)
#expect(source.wsToken(for: hostA) == Self.tokenA)
#expect(source.wsToken(for: hostB) == Self.tokenB)
}
@Test("快照还没热(刚启动就开终端)→ 回落到该主机记录,绝不因竞态漏掉 Cookie")
func fallsBackToTheHostRecordBeforeAnyStoreRead() async throws {
let host = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
let source = AccessTokenSource(store: try await makeStore([host]))
// No `token(forOrigin:)` call yet the snapshot is empty.
#expect(source.wsToken(for: host) == Self.tokenA)
}
@Test("令牌轮换后:下一次连接用存储里的新值,而不是打开终端时的旧值")
func picksUpARotatedTokenOnTheNextConnect() async throws {
let opened = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
let store = try await makeStore([opened])
let source = AccessTokenSource(store: store)
let rotated = HostRegistry.Host(
id: opened.id, name: opened.name, endpoint: opened.endpoint,
accessToken: try AccessToken(validating: Self.tokenB)
)
_ = try await store.upsert(rotated)
_ = await source.token(forOrigin: opened.endpoint.originHeader) // HTTP
#expect(source.wsToken(for: opened) == Self.tokenB)
}
@Test("存储读取失败 → 回落到主机记录(打开时的值),不是别的主机的令牌")
func wsTokenSurvivesAStoreFailure() async throws {
let host = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
let source = AccessTokenSource(store: ThrowingHostStore())
#expect(await source.token(forOrigin: host.endpoint.originHeader) == nil)
#expect(source.wsToken(for: host) == Self.tokenA)
}
@Test("完全没有令牌的机群 → nilLAN 零配置逐字不变,不带 Cookie")
func tokenlessFleetYieldsNoCookie() async throws {
let host = try makeHost("http://192.168.1.5:3000", token: nil)
let source = AccessTokenSource(store: try await makeStore([host]))
_ = await source.token(forOrigin: host.endpoint.originHeader)
#expect(source.wsToken(for: host) == nil)
}
}
/// E1 · The wiring that the per-host answer depends on: a terminal's WS
/// transport is built for THE host it dials, not once for the whole app.
@MainActor
@Suite("Per-host WS transport wiring")
struct PerHostTermTransportTests {
/// `@Sendable`-safe recorder for the hosts the factory was asked about.
private final class HostRecorder: @unchecked Sendable {
private let lock = NSLock()
private var hosts: [HostRegistry.Host] = []
func record(_ host: HostRegistry.Host) {
lock.withLock { hosts.append(host) }
}
var recorded: [HostRegistry.Host] { lock.withLock { hosts } }
}
private func makeHost(_ base: String, token: String?) throws -> HostRegistry.Host {
let url = try #require(URL(string: base))
return HostRegistry.Host(
id: UUID(), name: base,
endpoint: try #require(HostEndpoint(baseURL: url)),
accessToken: try token.map { try AccessToken(validating: $0) }
)
}
private func makeEnvironment(
shared: FakeTransport,
factory: (@Sendable (HostRegistry.Host) -> any TermTransport)? = nil
) throws -> AppEnvironment {
let defaults = try #require(UserDefaults(suiteName: "PerHostTermTransportTests"))
return AppEnvironment(
hostStore: InMemoryHostStore(),
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
http: FakeHTTPTransport(),
termTransport: shared,
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
termTransportFactory: factory
)
}
@Test("未注入工厂 → 回落到共享传输(既有 App 层测试装配零回归)")
func fallsBackToTheSharedTransport() throws {
let shared = FakeTransport()
let environment = try makeEnvironment(shared: shared)
let resolved = environment.makeTermTransport(for: try makeHost("http://192.168.1.5:3000", token: nil))
#expect(resolved as? FakeTransport === shared)
}
@Test("注入工厂 → 每台主机各建一个传输,且工厂拿到的就是那台主机(含其令牌)")
func buildsOneTransportPerHost() throws {
let recorder = HostRecorder()
let environment = try makeEnvironment(
shared: FakeTransport(),
factory: { host in
recorder.record(host)
return FakeTransport()
}
)
let tokened = try makeHost("http://192.168.1.5:3000", token: String(repeating: "a", count: 32))
let open = try makeHost("http://192.168.1.9:3000", token: nil)
_ = environment.makeTermTransport(for: tokened)
_ = environment.makeTermTransport(for: open)
#expect(recorder.recorded.map(\.id) == [tokened.id, open.id])
#expect(recorder.recorded.first?.accessToken == tokened.accessToken)
}
@Test("TerminalSessionController 用自己那台主机建传输(终端 401 的根因就在这里)")
func controllerBuildsItsTransportForItsOwnHost() throws {
let recorder = HostRecorder()
let environment = try makeEnvironment(
shared: FakeTransport(),
factory: { host in
recorder.record(host)
return FakeTransport()
}
)
let host = try makeHost("http://192.168.1.5:3000", token: String(repeating: "b", count: 32))
_ = TerminalSessionController(
host: host, sessionId: nil, environment: environment,
onPendingChanged: { _, _ in }
)
#expect(recorder.recorded.map(\.id) == [host.id])
}
}
/// C1 · The App layer's one place where a token becomes a header.
@Suite("Access-token cookie stamping")
struct AccessTokenCookieTests {
private static let token = "abcdefghijklmnopqrstuvwxyz012345"
private func request(_ url: String) throws -> URLRequest {
URLRequest(url: try #require(URL(string: url)))
}
@Test("请求 URL → originHeader 形式的 origin路径/查询串被忽略)")
func derivesOriginFromRequestURL() throws {
let withPath = try request("http://192.168.1.5:3000/live-sessions/abc/preview?x=1")
#expect(AccessTokenCookie.origin(of: withPath) == "http://192.168.1.5:3000")
}
@Test("https 默认端口不写 :443与服务器的 URL 规范化一致)")
func omitsDefaultHTTPSPort() throws {
let secure = try request("https://mac.tailnet.ts.net/live-sessions")
#expect(AccessTokenCookie.origin(of: secure) == "https://mac.tailnet.ts.net")
}
@Test("非 http(s) / 无 host 的请求 → nil不可能是本 App 的主机)")
func rejectsNonHTTPRequests() throws {
#expect(AccessTokenCookie.origin(of: try request("ftp://192.168.1.5/x")) == nil)
#expect(AccessTokenCookie.origin(of: URLRequest(url: URL(fileURLWithPath: "/tmp"))) == nil)
}
@Test("盖上 Cookie: webterm_auth=<t>,且返回新请求(不原地改调用方的请求)")
func stampsCookieImmutably() throws {
let original = try request("http://192.168.1.5:3000/live-sessions")
let stamped = AccessTokenCookie.stamped(original, token: Self.token)
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
#expect(original.value(forHTTPHeaderField: "Cookie") == nil)
}
@Test("已经带 Cookie 的请求原样通过(配对探针的候选令牌不能被覆盖)")
func neverOverwritesAnExistingCookie() throws {
var candidate = try request("http://192.168.1.5:3000/live-sessions")
candidate.setValue("webterm_auth=candidate-token-value", forHTTPHeaderField: "Cookie")
let stamped = AccessTokenCookie.stamped(candidate, token: Self.token)
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=candidate-token-value")
}
@Test("Cookie 名与服务器 AUTH_COOKIE_NAME 逐字一致")
func cookieNameMatchesTheServer() {
#expect(AccessTokenCookie.name == "webterm_auth")
}
// MARK: - The transport choke point itself (no network involved)
@Test("传输层给每个请求按 origin 盖上令牌 CookieRO GET 也一样)")
func transportStampsEveryRequest() async throws {
let transport = URLSessionHTTPTransport(
identityProvider: { nil },
tokenForOrigin: { origin in
origin == "http://192.168.1.5:3000" ? Self.token : nil
}
)
let readOnly = try request("http://192.168.1.5:3000/live-sessions")
let stamped = await transport.authenticated(readOnly)
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
}
@Test("没有该 origin 的令牌 → 请求逐字不变LAN 零配置主机不受影响)")
func transportLeavesTokenlessOriginsAlone() async throws {
let transport = URLSessionHTTPTransport(
identityProvider: { nil }, tokenForOrigin: { _ in nil }
)
let plain = try request("http://192.168.1.9:3000/live-sessions")
let result = await transport.authenticated(plain)
#expect(result.value(forHTTPHeaderField: "Cookie") == nil)
#expect(result.allHTTPHeaderFields == plain.allHTTPHeaderFields)
}
@Test("请求已带 Cookie配对探针的候选令牌→ 传输层不覆盖")
func transportNeverOverwritesTheProbeCandidate() async throws {
let transport = URLSessionHTTPTransport(
identityProvider: { nil }, tokenForOrigin: { _ in Self.token }
)
var candidate = try request("http://192.168.1.5:3000/live-sessions")
candidate.setValue("webterm_auth=candidate", forHTTPHeaderField: "Cookie")
let result = await transport.authenticated(candidate)
#expect(result.value(forHTTPHeaderField: "Cookie") == "webterm_auth=candidate")
}
}

View File

@@ -0,0 +1,353 @@
import SwiftUI
import Testing
import UIKit
@testable import WebTerm
/// T-iOS-34 · / /
/// ** token **doc §4 AE 124
///
/// " `.preferredColorScheme(.dark)` "
/// **** D WCAG UI
/// 1.4.11 = 3:1 AAA = 7:1****
///
@MainActor
@Suite("AppTheme")
struct AppThemeTests {
// MARK: - A.
@Test("colorScheme.system 交给系统nil.dark/.light 强制自身")
func colorSchemePerCase() {
#expect(AppTheme.system.colorScheme == nil)
#expect(AppTheme.dark.colorScheme == .dark)
#expect(AppTheme.light.colorScheme == .light)
}
@Test("三档 label / SF Symbol 非空且互不相同")
func labelsAndSymbolsAreDistinct() {
let labels = AppTheme.allCases.map(\.label)
let symbols = AppTheme.allCases.map(\.symbolName)
#expect(labels.allSatisfy { !$0.isEmpty })
#expect(symbols.allSatisfy { !$0.isEmpty })
#expect(Set(labels).count == AppTheme.allCases.count)
#expect(Set(symbols).count == AppTheme.allCases.count)
}
@Test("allCases 顺序 = 跟随系统 → 深色 → 浅色(选择器直接用它,不重排)")
func caseOrderIsPickerOrder() {
#expect(AppTheme.allCases == [.system, .dark, .light])
}
@Test("rawValue 白名单:只认三个小写标识")
func rawValueWhitelist() {
#expect(AppTheme(rawValue: "system") == .system)
#expect(AppTheme(rawValue: "dark") == .dark)
#expect(AppTheme(rawValue: "light") == .light)
#expect(AppTheme(rawValue: "") == nil)
#expect(AppTheme(rawValue: "Dark") == nil)
#expect(AppTheme(rawValue: "solarized") == nil)
}
// MARK: - B.
@Test("未设置 → .dark默认必须等于今天硬锁的深色零回归")
func decodeMissingIsDark() {
#expect(AppThemeCodec.decode(nil) == .dark)
#expect(AppThemeCodec.fallback == .dark)
}
@Test("脏值 / 空串 / 大小写不符 → .dark不崩、不落 system")
func decodeGarbageIsDark() {
#expect(AppThemeCodec.decode("solarized") == .dark)
#expect(AppThemeCodec.decode("") == .dark)
#expect(AppThemeCodec.decode("DARK") == .dark)
#expect(AppThemeCodec.decode("\u{0}light") == .dark)
}
@Test("三档 encode → decode 往返恒等")
func codecRoundTrips() {
for theme in AppTheme.allCases {
#expect(AppThemeCodec.decode(AppThemeCodec.encode(theme)) == theme)
}
}
@Test("空 defaults → .dark且首次读不写盘")
func storeStartsDarkWithoutWriting() {
let defaults = FakeThemeDefaults()
let store = ThemeStore(defaults: defaults)
#expect(store.theme == .dark)
#expect(defaults.writeCount == 0)
}
@Test("select(.light) → 落盘 \"light\",同一 defaults 新建 store 读回(跨启动)")
func selectPersistsAcrossStores() {
let defaults = FakeThemeDefaults()
let store = ThemeStore(defaults: defaults)
store.select(.light)
#expect(store.theme == .light)
#expect(defaults.values[ThemeStore.storageKey] == "light")
#expect(ThemeStore(defaults: defaults).theme == .light)
}
@Test("select 同一档两次 → 只写一次")
func repeatedSelectWritesOnce() {
let defaults = FakeThemeDefaults()
let store = ThemeStore(defaults: defaults)
store.select(.light)
store.select(.light)
#expect(defaults.writeCount == 1)
}
@Test("defaults 里是脏值 → 读作 .dark且不动其它键")
func dirtyStoredValueDegradesToDark() {
let defaults = FakeThemeDefaults(values: [
ThemeStore.storageKey: "; rm -rf ~",
"unrelated.key": "keep-me",
])
let store = ThemeStore(defaults: defaults)
#expect(store.theme == .dark)
#expect(defaults.values["unrelated.key"] == "keep-me")
}
// MARK: - C.
@Test("resolvedScheme.system 透传系统值,.dark/.light 无视系统")
func resolvedScheme() {
#expect(AppTheme.system.resolvedScheme(system: .light) == .light)
#expect(AppTheme.system.resolvedScheme(system: .dark) == .dark)
#expect(AppTheme.dark.resolvedScheme(system: .light) == .dark)
#expect(AppTheme.light.resolvedScheme(system: .dark) == .light)
}
// MARK: - D. token
/// 5 + 线
private static let semanticColors: [(name: String, color: Color)] = [
("statusWorking", DS.Palette.statusWorking),
("statusWaiting", DS.Palette.statusWaiting),
("statusStuck", DS.Palette.statusStuck),
("timelineTool", DS.Palette.timelineTool),
("timelineUser", DS.Palette.timelineUser),
]
@Test("5 个语义色在 light 与 dark 下解析值不同(真有浅色变体)")
func semanticColorsAreAdaptive() {
for (name, color) in Self.semanticColors {
let dark = UIColor(color).resolved(.dark)
let light = UIColor(color).resolved(.light)
#expect(!ColorMath.approxEqual(dark, light), "\(name) 在两档下相同 → 没有浅色变体")
}
}
@Test("深色档逐字节不变(#46D07F/#F5B14C/#FF6B6B/#5E9EFF/#AF7BFF")
func darkSchemeValuesUnchanged() {
let expected: [(Color, UInt32)] = [
(DS.Palette.statusWorking, 0x46D0_7F),
(DS.Palette.statusWaiting, 0xF5B1_4C),
(DS.Palette.statusStuck, 0xFF6B_6B),
(DS.Palette.timelineTool, 0x5E9E_FF),
(DS.Palette.timelineUser, 0xAF7B_FF),
]
for (color, hex) in expected {
let resolved = UIColor(color).resolved(.dark)
#expect(
ColorMath.matchesHex(resolved, hex),
"深色档漂移:实际 \(ColorMath.hexString(resolved))"
)
}
}
@Test("两档下语义色对该档 systemBackground 的对比度 ≥ 3:1WCAG 1.4.11")
func semanticColorsMeetNonTextContrast() {
for style in [UIUserInterfaceStyle.dark, .light] {
let background = UIColor.systemBackground.resolved(style)
for (name, color) in Self.semanticColors {
let ratio = ColorMath.contrast(UIColor(color).resolved(style), background)
#expect(ratio >= 3, "\(name)\(style == .dark ? "dark" : "light") 只有 \(ratio):1")
}
}
}
@Test("light 档 working/waiting/stuck 仍两两可辨")
func criticalTrioStaysDistinctInLight() {
let working = UIColor(DS.Palette.statusWorking).resolved(.light)
let waiting = UIColor(DS.Palette.statusWaiting).resolved(.light)
let stuck = UIColor(DS.Palette.statusStuck).resolved(.light)
#expect(!ColorMath.approxEqual(working, waiting))
#expect(!ColorMath.approxEqual(working, stuck))
#expect(!ColorMath.approxEqual(waiting, stuck))
}
@Test("accentSoft 两档不同,且都仍是 washalpha < 0.3")
func accentSoftIsAdaptiveWash() {
let soft = UIColor(DS.Palette.accentSoft)
let dark = soft.resolved(.dark)
let light = soft.resolved(.light)
#expect(!ColorMath.approxEqual(dark, light))
#expect(ColorMath.rgba(dark).a < 0.3)
#expect(ColorMath.rgba(light).a < 0.3)
}
@Test("onAccent 两档相同(金填充恒需深墨),与 accent 对比 ≥ 4.5:1")
func onAccentIsFixedAndReadable() {
let ink = UIColor(DS.Palette.onAccent)
#expect(ColorMath.approxEqual(ink.resolved(.dark), ink.resolved(.light)))
for style in [UIUserInterfaceStyle.dark, .light] {
let ratio = ColorMath.contrast(
ink.resolved(style), DS.Palette.accentUIColor().resolved(style)
)
#expect(ratio >= 4.5, "onAccent/accent 在 \(style.rawValue) 只有 \(ratio):1")
}
}
// MARK: - E.
@Test("dark 档终端 = #100F0D / #ECE9E3桌面 web --bg/--text零回归")
func terminalDarkMatchesDesktop() {
let colors = TerminalPalette.colors(for: .dark)
#expect(ColorMath.matchesHex(colors.background, 0x100F_0D),
"实际 \(ColorMath.hexString(colors.background))")
#expect(ColorMath.matchesHex(colors.foreground, 0xECE9_E3),
"实际 \(ColorMath.hexString(colors.foreground))")
}
@Test("light 档终端 = #F6F7F9 / #1A1D24镜像 web THEMES.light")
func terminalLightMirrorsWeb() {
let colors = TerminalPalette.colors(for: .light)
#expect(ColorMath.matchesHex(colors.background, 0xF6F7_F9),
"实际 \(ColorMath.hexString(colors.background))")
#expect(ColorMath.matchesHex(colors.foreground, 0x1A1D_24),
"实际 \(ColorMath.hexString(colors.foreground))")
}
@Test("两档终端 fg↔bg 对比度 ≥ 7:1终端是正文AAA")
func terminalContrastIsAAA() {
for scheme in [ColorScheme.dark, .light] {
let colors = TerminalPalette.colors(for: scheme)
let ratio = ColorMath.contrast(colors.foreground, colors.background)
#expect(ratio >= 7, "终端 \(scheme) 对比度只有 \(ratio):1")
}
}
@Test("caret / selection 用该档 accent 解析值")
func terminalCaretFollowsAccent() {
for (scheme, style) in [(ColorScheme.dark, UIUserInterfaceStyle.dark),
(ColorScheme.light, UIUserInterfaceStyle.light)] {
let colors = TerminalPalette.colors(for: scheme)
let accent = DS.Palette.accentUIColor().resolved(style)
#expect(ColorMath.approxEqual(colors.caret, accent))
#expect(ColorMath.approxEqual(colors.selection, accent))
}
}
@Test("terminalBackgroundUIColor()/ForegroundUIColor() 是动态 UIColor两档不同")
func terminalTokensAreDynamic() {
for token in [DS.Palette.terminalBackgroundUIColor(),
DS.Palette.terminalForegroundUIColor()] {
#expect(!ColorMath.approxEqual(token.resolved(.dark), token.resolved(.light)))
}
}
@Test("SwiftUI 侧 terminalBackground/Foreground 同样跟随主题(既有调用点不退化)")
func terminalSwiftUITokensStayDynamic() {
for color in [DS.Palette.terminalBackground, DS.Palette.terminalForeground] {
let bridged = UIColor(color)
#expect(!ColorMath.approxEqual(bridged.resolved(.dark), bridged.resolved(.light)))
}
}
}
// MARK: - Fake defaults
/// `ThemeDefaults` """ select "
/// store
final class FakeThemeDefaults: ThemeDefaults {
private(set) var values: [String: String]
private(set) var writeCount = 0
init(values: [String: String] = [:]) {
self.values = values
}
func themeRaw(forKey key: String) -> String? { values[key] }
func setThemeRaw(_ value: String, forKey key: String) {
values = values.merging([key: value]) { _, new in new }
writeCount += 1
}
}
// MARK: - Color math (WCAG)
/// + WCAG /
/// ****"" WCAG 2.1
enum ColorMath {
typealias RGBA = (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat)
static func rgba(_ color: UIColor) -> RGBA {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
color.getRed(&r, green: &g, blue: &b, alpha: &a)
return (r, g, b, a)
}
static func approxEqual(_ lhs: UIColor, _ rhs: UIColor, tolerance: CGFloat = 0.02) -> Bool {
let left = rgba(lhs), right = rgba(rhs)
return abs(left.r - right.r) < tolerance
&& abs(left.g - right.g) < tolerance
&& abs(left.b - right.b) < tolerance
&& abs(left.a - right.a) < tolerance
}
/// WCAG sRGB 线
static func luminance(_ color: UIColor) -> CGFloat {
let components = rgba(color)
func linear(_ channel: CGFloat) -> CGFloat {
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
}
return 0.2126 * linear(components.r)
+ 0.7152 * linear(components.g)
+ 0.0722 * linear(components.b)
}
/// WCAG 1:121:1
static func contrast(_ lhs: UIColor, _ rhs: UIColor) -> CGFloat {
let a = luminance(lhs), b = luminance(rhs)
let (lighter, darker) = a > b ? (a, b) : (b, a)
return (lighter + 0.05) / (darker + 0.05)
}
/// ±1/255 `0xRRGGBB`
static func matchesHex(_ color: UIColor, _ hex: UInt32, tolerance: CGFloat = 0.004) -> Bool {
let components = rgba(color)
let expected = (
r: CGFloat((hex >> 16) & 0xFF) / 255,
g: CGFloat((hex >> 8) & 0xFF) / 255,
b: CGFloat(hex & 0xFF) / 255
)
return abs(components.r - expected.r) < tolerance
&& abs(components.g - expected.g) < tolerance
&& abs(components.b - expected.b) < tolerance
}
/// `#RRGGBB`便
static func hexString(_ color: UIColor) -> String {
let components = rgba(color)
let value = (Int(components.r * 255) << 16)
| (Int(components.g * 255) << 8) | Int(components.b * 255)
return String(format: "#%06X", value)
}
}
extension UIColor {
///
func resolved(_ style: UIUserInterfaceStyle) -> UIColor {
resolvedColor(with: UITraitCollection(userInterfaceStyle: style))
}
}

View File

@@ -0,0 +1,302 @@
import Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-35 · web QR`?join=`doc §4 AC 3050
///
/// web URL `${location.origin}/?join=${sessionId}`
/// `public/share.ts:55` URL ****
/// 西 http(s) scheme ****
/// scheme/host/path/query query `join` fragment
/// userinfo`sessionId` `Validation.isValidSessionId`
///
/// **** `HostStore` origin `HostEndpoint.originHeader`
/// origin ****
@MainActor
@Suite("DeepLinkJoin")
struct DeepLinkJoinTests {
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
private static let v1StyleId = "11111111-2222-1333-8444-555555555555"
private static func url(_ string: String) throws -> URL {
try #require(URL(string: string))
}
// MARK: - A. web
@Test("http://<ip>:3000/?join=<v4> → .joinShared(origin, sessionId)")
func lanShareLinkRoutes() throws {
let route = DeepLinkRouter.route(
url: try Self.url("http://192.168.1.5:3000/?join=\(Self.validSessionId)")
)
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .joinShared(origin: "http://192.168.1.5:3000", sessionId: sessionId))
}
@Test("https 默认端口不出现在 origin 里")
func httpsDefaultPortOmitted() throws {
let route = DeepLinkRouter.route(
url: try Self.url("https://mac.tailnet.ts.net/?join=\(Self.validSessionId)")
)
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .joinShared(origin: "https://mac.tailnet.ts.net", sessionId: sessionId))
}
@Test("大写 scheme/host → origin 归一化为小写")
func originIsNormalizedLowercase() throws {
let route = DeepLinkRouter.route(
url: try Self.url("HTTP://MAC.LOCAL:3000/?join=\(Self.validSessionId)")
)
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .joinShared(origin: "http://mac.local:3000", sessionId: sessionId))
}
@Test("path 为空或 \"/\" 都接受origin + \"/?join=\" 产出 /")
func emptyAndRootPathsAccepted() throws {
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
let expected = DeepLinkRouter.Route.joinShared(
origin: "http://mac.local:3000", sessionId: sessionId
)
#expect(
DeepLinkRouter.route(
url: try Self.url("http://mac.local:3000/?join=\(Self.validSessionId)")
) == expected
)
#expect(
DeepLinkRouter.route(
url: try Self.url("http://mac.local:3000?join=\(Self.validSessionId)")
) == expected
)
}
// MARK: - B.
@Test("join 值非 v4 → .ignore复用 Validation不再造正则")
func nonV4JoinIgnored() throws {
#expect(
DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=\(Self.v1StyleId)"))
== .ignore
)
#expect(
DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=not-a-uuid")) == .ignore
)
#expect(DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=")) == .ignore)
}
@Test("重复 join 键 → .ignore歧义输入绝不部分应用")
func duplicateJoinIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url(
"http://mac.local/?join=\(Self.validSessionId)&join=\(Self.validSessionId)"
)
)
#expect(route == .ignore)
}
@Test("多余 query 键 → .ignoreweb 形状精确只有一个 join")
func extraQueryKeysIgnored() throws {
#expect(
DeepLinkRouter.route(
url: try Self.url("http://mac.local/?join=\(Self.validSessionId)&x=1")
) == .ignore
)
#expect(
DeepLinkRouter.route(
url: try Self.url("http://mac.local/?utm=a&join=\(Self.validSessionId)")
) == .ignore
)
}
@Test("非空 path → .ignore")
func nonEmptyPathIgnored() throws {
#expect(
DeepLinkRouter.route(
url: try Self.url("http://mac.local/manage.html?join=\(Self.validSessionId)")
) == .ignore
)
}
@Test("带 fragment → .ignore")
func fragmentIgnored() throws {
#expect(
DeepLinkRouter.route(
url: try Self.url("http://mac.local/?join=\(Self.validSessionId)#x")
) == .ignore
)
}
@Test("带 userinfo二维码钓鱼形状→ .ignore")
func userInfoIgnored() throws {
#expect(
DeepLinkRouter.route(
url: try Self.url("http://user:pass@mac.local/?join=\(Self.validSessionId)")
) == .ignore
)
}
@Test("无 host → .ignore")
func missingHostIgnored() throws {
#expect(
DeepLinkRouter.route(url: try Self.url("http:///?join=\(Self.validSessionId)"))
== .ignore
)
}
@Test("自定义 scheme 分支零回归webterminal 仍需 host+join 双 v4")
func customSchemeBranchUnchanged() throws {
#expect(
DeepLinkRouter.route(url: try Self.url("webterminal://open?join=\(Self.validSessionId)"))
== .ignore
)
let hostId = UUID()
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(
DeepLinkRouter.route(
url: try Self.url(
"webterminal://open?host=\(hostId.uuidString)&join=\(Self.validSessionId)"
)
) == .openSession(hostId: hostId, sessionId: sessionId)
)
}
}
/// C `DeepLinkHandler` origin
@MainActor
@Suite("DeepLinkJoinHandler")
struct DeepLinkJoinHandlerTests {
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
@MainActor
private final class ActionRecorder {
private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = []
private(set) var pairingShownCount = 0
var actions: DeepLinkHandler.Actions {
DeepLinkHandler.Actions(
openSession: { [weak self] host, sessionId in
self?.opened.append((host, sessionId))
},
showPairing: { [weak self] in self?.pairingShownCount += 1 }
)
}
}
private static func makeHost(_ base: String) throws -> HostRegistry.Host {
let url = try #require(URL(string: base))
let endpoint = try #require(HostEndpoint(baseURL: url))
return HostRegistry.Host(id: UUID(), name: "mac", endpoint: endpoint)
}
private static func shareURL(_ origin: String) throws -> URL {
try #require(URL(string: "\(origin)/?join=\(validSessionId)"))
}
@Test("origin 命中已配对主机 → 恰一次 openSession无 hint")
func knownOriginOpensSession() async throws {
let host = try Self.makeHost("http://192.168.1.5:3000")
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.shareURL("http://192.168.1.5:3000"))
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(recorder.opened.count == 1)
#expect(recorder.opened.first?.host == host)
#expect(recorder.opened.first?.sessionId == sessionId)
#expect(handler.hintMessage == nil)
}
@Test("origin 未配对 → 零 open + 落配对流程(绝不据链接静默新增主机)")
func unknownOriginNeverPairsSilently() async throws {
let paired = try Self.makeHost("http://192.168.1.5:3000")
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [paired] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.shareURL("http://10.0.0.9:3000"))
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 1)
#expect(handler.hintMessage == DeepLinkCopy.unknownHostHint)
}
@Test("提示文案不回显链接内容(防钓鱼/防日志注入)")
func hintNeverEchoesLinkContents() async throws {
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.shareURL("http://evil.example:3000"))
let hint = try #require(handler.hintMessage)
#expect(!hint.contains("evil.example"))
#expect(!hint.contains(Self.validSessionId))
}
@Test("origin 比对经 originHeader大小写/尾斜杠同一主机,端口不同则不是")
func originComparisonUsesOriginHeader() async throws {
let host = try Self.makeHost("http://mac.local:3000")
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.shareURL("http://MAC.LOCAL:3000"))
#expect(recorder.opened.count == 1)
await handler.handle(url: try Self.shareURL("http://mac.local:3001"))
#expect(recorder.opened.count == 1) //
#expect(recorder.pairingShownCount == 1)
}
@Test("scheme 不同 → 不是同一主机")
func schemeMismatchIsDifferentHost() async throws {
let host = try Self.makeHost("http://mac.local")
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.shareURL("https://mac.local"))
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 1)
}
@Test("冷启动 stash 对 .joinShared 同样生效(恰应用一次)")
func coldLaunchStashAppliesJoin() async throws {
let host = try Self.makeHost("http://mac.local:3000")
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.handle(url: try Self.shareURL("http://mac.local:3000"))
#expect(recorder.opened.isEmpty)
await handler.markReady()
#expect(recorder.opened.count == 1)
await handler.markReady()
#expect(recorder.opened.count == 1)
}
@Test("非法 web 链接 → ignoredCount+1 且不入 stash")
func invalidWebLinkCountedNeverStashed() async throws {
let host = try Self.makeHost("http://mac.local:3000")
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.handle(
url: try #require(URL(string: "http://mac.local:3000/?join=nope&x=1"))
)
#expect(handler.ignoredCount == 1)
await handler.markReady()
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 0)
}
}

View File

@@ -78,14 +78,30 @@ struct DeepLinkRouterTests {
// MARK: - URL .ignore
@Test("scheme 非 webterminal → .ignore")
func wrongSchemeIgnored() throws {
/// T-iOS-35 ****"scheme webterminal .ignore"
/// http(s) web QR http(s)
/// `<origin>/?join=<uuid>` `DeepLinkJoinTests`****
/// URL `.ignore` `host=` query
/// web `join` "scheme "
/// scheme http/https/webterminal
@Test("https 但不是 web 分享形状(多余 query 键)→ .ignore")
func httpsWithExtraQueryKeysIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)")
)
#expect(route == .ignore)
}
@Test("白名单外的 schemeftp/file/javascript→ .ignore")
func nonWhitelistedSchemeIgnored() throws {
for scheme in ["ftp", "file", "javascript"] {
let route = DeepLinkRouter.route(
url: try Self.url("\(scheme)://open?join=\(Self.validSessionId)")
)
#expect(route == .ignore, "\(scheme) 不该被接受")
}
}
@Test("action 非 open → .ignore")
func wrongActionIgnored() throws {
let route = DeepLinkRouter.route(

View File

@@ -44,11 +44,21 @@ struct DesignSystemTests {
#expect(!approxEqual(waiting, stuck))
}
@Test("semantic status colors match the desktop/web status palette")
/// T-iOS-34 **** token "" scheme-adaptive
/// = web = `Tokens.swift`
/// `UIColor(color).getRed(...)` ** trait**
/// " web
/// " ** trait**
/// `AppThemeTests`
@Test("semantic status colors match the desktop/web status palette (dark scheme)")
func statusColorsMatchDirection() {
assertColor(StatusStyle.style(for: DisplayStatus.working).color, r: 70, g: 208, b: 127) // #46D07F web --green
assertColor(StatusStyle.style(for: DisplayStatus.waiting).color, r: 245, g: 177, b: 76) // #F5B14C web --amber
assertColor(StatusStyle.style(for: DisplayStatus.stuck).color, r: 255, g: 107, b: 107) // #FF6B6B web --red
let dark = UITraitCollection(userInterfaceStyle: .dark)
assertColor(StatusStyle.style(for: DisplayStatus.working).color, in: dark,
r: 70, g: 208, b: 127) // #46D07F web --green
assertColor(StatusStyle.style(for: DisplayStatus.waiting).color, in: dark,
r: 245, g: 177, b: 76) // #F5B14C web --amber
assertColor(StatusStyle.style(for: DisplayStatus.stuck).color, in: dark,
r: 255, g: 107, b: 107) // #FF6B6B web --red
}
@Test("the wire ClaudeStatus bridge covers all five cases")
@@ -150,6 +160,13 @@ struct DesignSystemTests {
assertRGBA(rgba(color), r: r, g: g, b: b)
}
/// Same, but resolving an adaptive token in an explicit scheme.
private func assertColor(
_ color: Color, in traits: UITraitCollection, r: Int, g: Int, b: Int
) {
assertRGBA(rgba(UIColor(color).resolvedColor(with: traits)), r: r, g: g, b: b)
}
private func assertRGBA(_ value: RGBA, r: Int, g: Int, b: Int, tolerance: CGFloat = 0.02) {
#expect(abs(value.r - CGFloat(r) / 255) < tolerance)
#expect(abs(value.g - CGFloat(g) / 255) < tolerance)

View File

@@ -0,0 +1,177 @@
import SessionCore
import SwiftUI
import Testing
import UIKit
import WireProtocol
@testable import WebTerm
/// T-iOS-34 · Dynamic Type doc §4 F 2529
///
/// ""****""
/// `UIHostingController` 320pt iPhone SE /
/// `sizeThatFits`
/// 1. **** /
/// 2. **** AX >
///
/// `TelemetryChip` / `dsMetaText`****
/// AX5 DS `DS.Typography.numericClamp`
/// "AX2 AX5 "
@MainActor
@Suite("DynamicTypeLayout")
struct DynamicTypeLayoutTests {
// MARK: - F25 · GateBanner/ shell
@Test("GateBanner 在 AX5 下不横向溢出,且相比标准档是长高而非被裁")
func gateBannerSurvivesLargestType() {
let gate = GateState(kind: .tool, detail: "Bash(rm -rf ./build)", epoch: 1)
let banner = GateBanner(gate: gate) { _, _ in }
let standard = LayoutProbe.fit(banner, size: .large)
let largest = LayoutProbe.fit(banner, size: .accessibility5)
#expect(largest.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
#expect(largest.height.isFinite)
#expect(largest.height > standard.height)
}
// MARK: - F26/F27 ·
@Test("numericClamp 策略:封顶在 accessibility2严格小于 accessibility5")
func numericClampPolicy() {
#expect(DS.Typography.numericClamp == .accessibility2)
#expect(DS.Typography.numericClamp < .accessibility5)
}
@Test("TelemetryChipAX2 与 AX5 同高(夹取生效),且不横向溢出")
func telemetryChipIsClamped() {
let chip = TelemetryChip(systemImage: "cpu", text: "ctx 92% · $0.1234")
let clampEdge = LayoutProbe.fit(chip, size: DS.Typography.numericClamp)
let largest = LayoutProbe.fit(chip, size: .accessibility5)
#expect(largest.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
#expect(abs(largest.height - clampEdge.height) < LayoutProbe.epsilon)
}
@Test("TelemetryChip 在夹取上限之前照常放大(不是把字号焊死)")
func telemetryChipStillScalesBelowClamp() {
let chip = TelemetryChip(text: "161×50")
let standard = LayoutProbe.fit(chip, size: .large)
let clampEdge = LayoutProbe.fit(chip, size: DS.Typography.numericClamp)
#expect(clampEdge.height > standard.height)
}
@Test("dsMetaText 元信息行走同一夹取(单一出处,非逐屏各写一遍)")
func metaTextIsClamped() {
let meta = Text(verbatim: "2 台设备在看 · 161×50").dsMetaText()
let clampEdge = LayoutProbe.fit(meta, size: DS.Typography.numericClamp)
let largest = LayoutProbe.fit(meta, size: .accessibility5)
#expect(abs(largest.height - clampEdge.height) < LayoutProbe.epsilon)
}
// MARK: - F28 · DSButtonStylegate
@Test("DSButtonStyle 在 AX5 半宽容器里仍 ≥ 44pt 高且不横向溢出")
func buttonStyleSurvivesLargestType() {
let button = Button(GateChoiceSpec.label(for: .approve)) {}
.buttonStyle(DSButtonStyle(kind: .primary))
let halfWidth = LayoutProbe.phoneWidth / 2
let standard = LayoutProbe.fit(button, width: halfWidth, size: .large)
let largest = LayoutProbe.fit(button, width: halfWidth, size: .accessibility5)
#expect(largest.width <= halfWidth + LayoutProbe.epsilon)
#expect(largest.height >= DS.Layout.minHitTarget)
// `minHeight` `height` 44pt
#expect(largest.height > standard.height)
}
// MARK: - AX5
@Test("SettingsScreen 在 AX5 下可渲染且不横向溢出")
func settingsScreenSurvivesLargestType() {
let screen = SettingsScreen(themeStore: ThemeStore(defaults: FakeThemeDefaults()))
let size = LayoutProbe.fit(NavigationStack { screen }, size: .accessibility5)
#expect(size.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
#expect(size.height.isFinite)
}
// MARK: - F29 · KeyBarUIKit vs
/// iPhone 16 Pro AX5
/// **108.24pt**footnote 52.51 + caption2 47.73 + 4+4
/// `KeyBarMetrics.barHeight` ** 52pt**44 + 8
///
/// `barHeight` Dynamic Type
/// `UIFontMetrics(forTextStyle: .footnote).scaledValue(for: DS.Layout.minHitTarget + DS.Space.sm8)`
/// `intrinsicContentSize`
/// **`Components/KeyBar.swift` C4 Owns **
/// `withKnownIssue`
/// "known issue was not recorded"
@Test("KeyBar 键帽在 AX5 下超出固定条高已知缺口KeyBar.swift 属他人 Owns")
func keyBarKeycapExceedsBarHeightAtLargestType() {
withKnownIssue("KeyBarMetrics.barHeight 固定 52ptAX5 键帽需约 108pt → 裁切") {
let requirement = KeyBarProbe.largestTypeRequirement()
#expect(
requirement.needed <= requirement.barHeight,
"AX5 键帽需 \(requirement.needed)pt条高只有 \(requirement.barHeight)pt"
)
}
}
}
// MARK: - Probes
/// SwiftUI `UIHostingController`
/// `sizeThatFits` `.frame(width:)`
///
@MainActor
enum LayoutProbe {
/// iPhone SE
static let phoneWidth: CGFloat = 320
///
static let epsilon: CGFloat = 0.5
static func fit<V: View>(
_ view: V, width: CGFloat = phoneWidth, size: DynamicTypeSize
) -> CGSize {
let host = UIHostingController(rootView: view.dynamicTypeSize(size))
host.view.layoutIfNeeded()
return host.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
}
}
/// UIKit AX5 ********
///
/// `KeyBarView` AX5 trait `KeyBarView`
/// `UIFont.preferredFont(forTextStyle:)` `compatibleWith:`
/// **App ** `preferredContentSizeCategory`**** `UITraitCollection.current`
/// `performAsCurrent { KeyBarView() }` 38pt
/// ""绿 `compatibleWith:` AX5
/// glyph + caption+
@MainActor
enum KeyBarProbe {
/// AX5
private static let largestCategory = UIContentSizeCategory
.accessibilityExtraExtraExtraLarge
/// - Returns: (, AX5 )
static func largestTypeRequirement() -> (barHeight: CGFloat, needed: CGFloat) {
let traits = UITraitCollection(preferredContentSizeCategory: largestCategory)
let glyph = UIFont.preferredFont(forTextStyle: .footnote, compatibleWith: traits)
let caption = UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits)
// KeyBarMetrics.buttonInsets xs4
let verticalInsets = DS.Space.xs4 * 2
return (
barHeight: KeyBarView().intrinsicContentSize.height,
needed: glyph.lineHeight + caption.lineHeight + verticalInsets
)
}
}

View File

@@ -0,0 +1,224 @@
import APIClient
import Foundation
import Testing
@testable import WebTerm
/// C2 · / / /
///
/// `docs/plans/w6-project-git-panel.md` web
/// `public/projects.ts:615-745 makeSyncBand` "
/// 绿"RED 3647 docs/plans/ios-completion.md §4
@Suite("GitPanelPresentation")
struct GitPanelPresentationTests {
/// ""2026-07-30T12:00:00Z
private static let nowMs: Double = 1_785_412_800_000
private static func minutesAgo(_ minutes: Double) -> Double {
nowMs - minutes * 60 * 1000
}
// MARK: - RED 3643
@Test("非 git 目录sync == nil→ 无同步带")
func nonGitHasNoBand() {
#expect(GitSyncBand.make(sync: nil, dirtyCount: nil, nowMs: Self.nowMs) == nil)
}
@Test("↑0 ↓0 + 新鲜 fetch → 唯一允许的「已同步」绿色态")
func inSyncNeedsFreshFetch() throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(
upstream: "origin/develop", ahead: 0, behind: 0,
lastFetchMs: Self.minutesAgo(5)
),
dirtyCount: 0, nowMs: Self.nowMs
))
#expect(band.isInSync)
#expect(band.isBehindVerified)
#expect(band.canFetch)
#expect(band.head == .tracking(
upstream: "origin/develop", ahead: 0, behind: 0
))
}
@Test("↑0 ↓0 但 fetch 已过期(> FETCH_STALE_MS→ 不绿,↓ 未核实")
func staleFetchIsNeverGreen() throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(
upstream: "origin/develop", ahead: 0, behind: 0,
lastFetchMs: Self.minutesAgo(61)
),
dirtyCount: 0, nowMs: Self.nowMs
))
#expect(!band.isInSync)
#expect(!band.isBehindVerified)
}
@Test("FETCH_STALE_MS 就是 1 小时(镜像 public/projects.ts:615")
func staleThresholdMatchesWeb() {
#expect(GitSyncBand.fetchStaleMs == 60 * 60 * 1000)
}
@Test("从未 fetchlastFetchMs == nil→ 视为过期,不绿")
func neverFetchedIsStale() throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(upstream: "origin/develop", ahead: 0, behind: 0, lastFetchMs: nil),
dirtyCount: 0, nowMs: Self.nowMs
))
#expect(!band.isInSync)
#expect(!band.isBehindVerified)
}
@Test("无上游 → 「无上游」,没有 ↑↓,绝不绿(最易犯的 bug")
func noUpstreamIsNeverGreen() throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(upstream: nil, ahead: nil, behind: nil, lastFetchMs: Self.nowMs),
dirtyCount: 0, nowMs: Self.nowMs
))
#expect(band.head == .noUpstream)
#expect(!band.isInSync)
#expect(band.canFetch)
}
@Test("分离 HEAD → 「分离 HEAD」fetch 禁用,无 ↑↓")
func detachedDisablesFetch() throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(upstream: nil, ahead: nil, behind: nil, lastFetchMs: nil, detached: true),
dirtyCount: nil, nowMs: Self.nowMs
))
#expect(band.head == .detached)
#expect(!band.canFetch)
#expect(!band.isInSync)
}
@Test("ahead 读不到nil→ 保留 nil渲染 ↑ —),且不绿")
func unknownAheadIsNotZero() throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(
upstream: "origin/develop", ahead: nil, behind: 0,
lastFetchMs: Self.minutesAgo(1)
),
dirtyCount: 0, nowMs: Self.nowMs
))
#expect(band.head == .tracking(upstream: "origin/develop", ahead: nil, behind: 0))
#expect(!band.isInSync)
}
@Test(
"dirtyCountnil → 未检查(不是 clean、0 → clean、3 → changed(3)",
arguments: [
(Int?.none, GitSyncBand.Dirty.unknown),
(Int?(0), GitSyncBand.Dirty.clean),
(Int?(3), GitSyncBand.Dirty.changed(3)),
] as [(Int?, GitSyncBand.Dirty)]
)
func dirtyTriState(dirtyCount: Int?, expected: GitSyncBand.Dirty) throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(upstream: "origin/x", ahead: 0, behind: 0, lastFetchMs: Self.nowMs),
dirtyCount: dirtyCount, nowMs: Self.nowMs
))
#expect(band.dirty == expected)
}
// MARK: - RED 4446
@Test("边界画在最后一个 unpushed 之后,且只画一次")
func boundaryAfterLastUnpushed() {
let commits = [
CommitLogEntry(hash: "aaa", at: 3, subject: "c", unpushed: true),
CommitLogEntry(hash: "bbb", at: 2, subject: "b", unpushed: true),
CommitLogEntry(hash: "ccc", at: 1, subject: "a", unpushed: false),
]
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == 1)
}
@Test("unpushed 只在严格 true 时生效nil ≠ false ≠ true")
func nilUnpushedDrawsNoBoundary() {
let commits = [
CommitLogEntry(hash: "aaa", at: 2, subject: "c", unpushed: nil),
CommitLogEntry(hash: "bbb", at: 1, subject: "b", unpushed: false),
]
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == nil)
}
@Test("无上游 → 完全不画边界(没有可比对的东西)")
func noUpstreamDrawsNoBoundary() {
let commits = [CommitLogEntry(hash: "aaa", at: 1, subject: "c", unpushed: true)]
#expect(GitLogBoundary.index(commits: commits, upstream: nil) == nil)
}
@Test("未推送提交排在已推送之下(老日期 merge→ 边界仍在最后一个 unpushed 之后")
func interleavedUnpushedStillBounds() {
let commits = [
CommitLogEntry(hash: "aaa", at: 5, subject: "new pushed", unpushed: false),
CommitLogEntry(hash: "bbb", at: 4, subject: "merge of old branch", unpushed: true),
CommitLogEntry(hash: "ccc", at: 3, subject: "older pushed", unpushed: false),
]
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == 1)
}
// MARK: -
@Test(
"相对时间:秒/分/时/天各档非空且互不相同",
arguments: [0.5, 5, 90, 60 * 26] as [Double]
)
func relativeTimeBuckets(minutes: Double) {
let label = GitTimeFormat.relative(fromMs: Self.minutesAgo(minutes), nowMs: Self.nowMs)
#expect(!label.isEmpty)
}
@Test("未来时间戳(主机时钟漂移)→ 退化为「刚刚」,不出现负数")
func futureTimestampDegrades() {
let label = GitTimeFormat.relative(fromMs: Self.nowMs + 60_000, nowMs: Self.nowMs)
#expect(!label.contains("-"))
}
// MARK: - RED 47
@Test("服务器安全文案原样显示(绝不吞、绝不自造)")
func serverMessageIsSurfacedVerbatim() {
let text = GitWriteFeedback.message(
status: 409, serverMessage: "Nothing staged to commit.", fallback: "兜底"
)
#expect(text.contains("Nothing staged to commit."))
}
@Test("服务器没给 message → 兜底中文文案(非空)")
func missingServerMessageFallsBack() {
let text = GitWriteFeedback.message(status: 500, serverMessage: nil, fallback: "提交失败")
#expect(!text.isEmpty)
#expect(text.contains("提交失败"))
}
@Test("抛出的 APIClientError → 其自带话术(.unauthorized 引导补令牌)")
func thrownAPIErrorUsesItsOwnCopy() {
let text = GitWriteFeedback.message(for: APIClientError.unauthorized, fallback: "兜底")
#expect(text == APIClientError.unauthorized.message)
}
@Test("非 APIClientError传输层→ 兜底文案,不 crash")
func transportErrorFallsBack() {
let text = GitWriteFeedback.message(
for: URLError(.notConnectedToInternet), fallback: "推送失败"
)
#expect(text.contains("推送失败"))
}
}

View File

@@ -0,0 +1,328 @@
import APIClient
import Foundation
import Testing
@testable import WebTerm
/// C2 · git VMRED 4751 +
///
/// ProjectDetailViewModel/DiffViewModel
/// / APIClient VM
///
@MainActor
@Suite("GitPanelViewModel")
struct GitPanelViewModelTests {
private nonisolated static let path = "/repos/web-terminal"
private nonisolated static let nowMs: Double = 1_785_412_800_000
private nonisolated static func detail(
sync: SyncState? = SyncState(
upstream: "origin/develop", ahead: 2, behind: 0, lastFetchMs: 1_785_412_500_000
),
dirtyCount: Int? = 3
) -> ProjectDetail {
ProjectDetail(
name: "web-terminal", path: path, isGit: true, branch: "develop", dirty: true,
worktrees: [], sessions: [], hasClaudeMd: false, claudeMd: nil,
dirtyCount: dirtyCount, sync: sync
)
}
// MARK: -
@Test("load 成功 → 同步带/分支/日志/改动列表就位")
func loadPopulatesEverySection() async {
let vm = Self.makeViewModel(
log: { GitLogResult(commits: [
CommitLogEntry(hash: "abc1234", at: 1_785_412_000_000, subject: "feat: x", unpushed: true),
], truncated: false, upstream: "origin/develop") },
changes: { staged in
staged ? [] : [Self.changedFile("src/a.ts")]
}
)
await vm.load()
#expect(vm.isLoaded)
#expect(vm.branch == "develop")
#expect(vm.band?.head == .tracking(upstream: "origin/develop", ahead: 2, behind: 0))
#expect(vm.band?.dirty == .changed(3))
#expect(vm.log?.commits.count == 1)
#expect(vm.unstaged.map(\.displayPath) == ["src/a.ts"])
#expect(vm.staged.isEmpty)
}
@Test("日志失败不拖垮面板:其余分区照常,日志区显示自己的错误")
func logFailureIsContained() async {
let vm = Self.makeViewModel(log: { throw APIClientError.gitDataUnavailable })
await vm.load()
#expect(vm.isLoaded)
#expect(vm.band != nil)
#expect(vm.logErrorMessage == APIClientError.gitDataUnavailable.message)
}
@Test("详情失败 → 状态区错误 + 无同步带(绝不编造 ↑↓)")
func detailFailureLeavesNoBand() async {
let vm = Self.makeViewModel(detail: { throw APIClientError.projectNotFound })
await vm.load()
#expect(vm.band == nil)
#expect(vm.stateErrorMessage == APIClientError.projectNotFound.message)
}
@Test("PR 单独加载gh 是一次外网调用,不阻塞面板首屏)")
func prLoadsSeparately() async {
let vm = Self.makeViewModel(pr: { PrStatus(availability: .ok, number: 7, title: "t") })
await vm.load()
#expect(vm.pr == nil)
await vm.loadPullRequest()
#expect(vm.pr?.number == 7)
}
@Test("gh 未安装/未登录是 200 + 非 ok availability不是错误")
func degradedGhIsNotAnError() async {
let vm = Self.makeViewModel(pr: { PrStatus(availability: .notInstalled) })
await vm.loadPullRequest()
#expect(vm.pr?.availability == .notInstalled)
#expect(vm.errorMessage == nil)
}
// MARK: - 4749
@Test("stage 被拒 → 服务器安全文案原样显示")
func stageRejectionSurfacesServerMessage() async {
let vm = Self.makeViewModel(
stage: { _, _ in .rejected(status: 403, message: "Git operations are disabled.") }
)
await vm.setStaged(Self.changedFile("src/a.ts"), staged: true)
#expect(vm.errorMessage == "Git operations are disabled.")
}
@Test("commit 成功 → 清空输入框 + 短 sha 提示")
func commitSuccessClearsDraft() async {
let vm = Self.makeViewModel(commit: { _ in .ok(CommitResult(commit: "abc1234")) })
vm.commitMessage = "fix: 修一下"
await vm.commit()
#expect(vm.commitMessage.isEmpty)
#expect(vm.noticeMessage?.contains("abc1234") == true)
#expect(vm.errorMessage == nil)
}
@Test("commit 409无暂存→ 保留输入框内容,显示服务器文案")
func commitConflictKeepsDraft() async {
let vm = Self.makeViewModel(
commit: { _ in .rejected(status: 409, message: "Nothing staged to commit.") }
)
vm.commitMessage = "fix: 修一下"
await vm.commit()
#expect(vm.commitMessage == "fix: 修一下")
#expect(vm.errorMessage == "Nothing staged to commit.")
}
@Test("空提交信息 → 一次请求都不发")
func blankCommitMessageSkipsNetwork() async {
let spy = GitPanelSpy()
let vm = Self.makeViewModel(spy: spy)
vm.commitMessage = " "
await vm.commit()
#expect(await spy.commitCalls == 0)
#expect(vm.errorMessage == GitPanelCopy.commitMessageRequired)
}
@Test("push 401 是主机 git 凭据问题 → 用服务器文案,不与访问令牌 401 混淆")
func pushAuthIsNotTheAccessTokenGate() async {
let vm = Self.makeViewModel(
push: { .rejected(status: 401, message: "Push authentication required on the host.") }
)
await vm.push()
#expect(vm.errorMessage == "Push authentication required on the host.")
#expect(vm.errorMessage != APIClientError.unauthorized.message)
}
@Test("429 → 限流文案,且不自动重试")
func rateLimitedIsNotRetried() async {
let spy = GitPanelSpy()
let vm = Self.makeViewModel(spy: spy, push: { .rateLimited })
await vm.push()
#expect(await spy.pushCalls == 1)
#expect(vm.errorMessage == GitWriteFeedback.rateLimited)
}
@Test("fetch 成功 → 重新读取详情lastFetchMs 由服务器给,客户端绝不自己往前拨)")
func fetchReloadsStateFromServer() async {
let spy = GitPanelSpy()
let vm = Self.makeViewModel(spy: spy, fetchRemote: { .ok(FetchResult(remote: "origin", lastFetchMs: Self.nowMs)) })
await vm.load()
let loadsAfterFirstLoad = await spy.detailCalls
await vm.fetchRemote()
#expect(await spy.detailCalls == loadsAfterFirstLoad + 1)
}
// MARK: - 50
@Test("写操作进行中重复点击 → 只发一次请求")
func busyDeduplicatesWrites() async {
let gate = GitPanelGate()
let spy = GitPanelSpy()
let vm = Self.makeViewModel(
spy: spy,
push: {
await gate.wait()
return .ok(PushResult(branch: "develop", remote: "origin"))
}
)
let first = Task { await vm.push() }
await Self.spin(until: { vm.busy == .push })
await vm.push()
#expect(await spy.pushCalls == 1)
await gate.release()
await first.value
#expect(vm.busy == nil)
}
// MARK: - 51
@Test("重命名 → 同时送 oldPath 与 newPath否则删除侧不会被暂存")
func renameStagesBothPaths() {
let file = DiffFile(
oldPath: "src/old.ts", newPath: "src/new.ts", status: .renamed,
added: 1, removed: 1, binary: false, hunks: []
)
let changed = GitPanelViewModel.ChangedFile.make(from: file)
#expect(changed.stagePaths == ["src/old.ts", "src/new.ts"])
#expect(changed.displayPath.contains("src/new.ts"))
}
@Test("普通修改 → 只送 newPath")
func modifiedStagesOnePath() {
let file = DiffFile(
oldPath: "src/a.ts", newPath: "src/a.ts", status: .modified,
added: 2, removed: 0, binary: false, hunks: []
)
let changed = GitPanelViewModel.ChangedFile.make(from: file)
#expect(changed.stagePaths == ["src/a.ts"])
}
// MARK: - Fixtures
private nonisolated static func changedFile(_ path: String) -> GitPanelViewModel.ChangedFile {
GitPanelViewModel.ChangedFile(
displayPath: path, stagePaths: [path], status: .modified, added: 1, removed: 0
)
}
private static func makeViewModel(
spy: GitPanelSpy = GitPanelSpy(),
detail: (@Sendable () async throws -> ProjectDetail)? = nil,
log: (@Sendable () async throws -> GitLogResult)? = nil,
pr: (@Sendable () async throws -> PrStatus)? = nil,
changes: (@Sendable (Bool) async throws -> [GitPanelViewModel.ChangedFile])? = nil,
stage: (@Sendable ([String], Bool) async throws -> GitWriteOutcome<StageResult>)? = nil,
commit: (@Sendable (String) async throws -> GitWriteOutcome<CommitResult>)? = nil,
push: (@Sendable () async throws -> GitWriteOutcome<PushResult>)? = nil,
fetchRemote: (@Sendable () async throws -> GitWriteOutcome<FetchResult>)? = nil
) -> GitPanelViewModel {
GitPanelViewModel(
path: path,
dependencies: GitPanelViewModel.Dependencies(
detail: {
await spy.recordDetail()
if let detail { return try await detail() }
return Self.detail()
},
log: {
if let log { return try await log() }
return GitLogResult(commits: [], truncated: false, upstream: "origin/develop")
},
pr: {
if let pr { return try await pr() }
return PrStatus(availability: .noPr)
},
changes: { staged in
if let changes { return try await changes(staged) }
return []
},
stage: { files, isStaging in
await spy.recordStage()
if let stage { return try await stage(files, isStaging) }
return .ok(StageResult(staged: isStaging, count: files.count))
},
commit: { message in
await spy.recordCommit()
if let commit { return try await commit(message) }
return .ok(CommitResult(commit: "abc1234"))
},
push: {
await spy.recordPush()
if let push { return try await push() }
return .ok(PushResult(branch: "develop", remote: "origin"))
},
fetchRemote: {
if let fetchRemote { return try await fetchRemote() }
return .ok(FetchResult(remote: "origin", lastFetchMs: nowMs))
},
nowMs: { nowMs }
)
)
}
private static func spin(
until condition: @MainActor () -> Bool, maxYields: Int = 1_000
) async {
for _ in 0..<maxYields where !condition() {
await Task.yield()
}
}
}
private actor GitPanelSpy {
private(set) var detailCalls = 0
private(set) var stageCalls = 0
private(set) var commitCalls = 0
private(set) var pushCalls = 0
func recordDetail() { detailCalls += 1 }
func recordStage() { stageCalls += 1 }
func recordCommit() { commitCalls += 1 }
func recordPush() { pushCalls += 1 }
}
private actor GitPanelGate {
private var waiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
await withCheckedContinuation { waiters.append($0) }
}
func release() {
let pending = waiters
waiters = []
pending.forEach { $0.resume() }
}
}

View File

@@ -0,0 +1,199 @@
import APIClient
import Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// C1 · the path `PushRegistrar.handleHostRemoved` never had.
///
/// Two properties matter beyond "the row disappears":
/// 1. the APNs de-registration actually happens, and happens BEFORE the record
/// is deleted (the request needs the host's endpoint AND its access token,
/// which live in that record);
/// 2. no secret survives: the token is a field of the removed record, so the
/// same write that drops the host drops the token.
@MainActor
@Suite("Host removal")
struct HostRemovalTests {
private static let goodToken = "abcdefghijklmnopqrstuvwxyz012345"
/// Records the interleaving of the push hook and the store write.
private actor Journal {
enum Entry: Equatable {
case unregistered(UUID)
case removed(UUID)
}
private(set) var entries: [Entry] = []
func record(_ entry: Entry) {
entries = entries + [entry]
}
}
/// `InMemoryHostStore` + journalling of `remove`, so ordering is observable.
private actor JournallingStore: HostStore {
private let base = InMemoryHostStore()
private let journal: Journal
private let removeFails: Bool
init(journal: Journal, removeFails: Bool = false) {
self.journal = journal
self.removeFails = removeFails
}
func loadAll() async throws -> [HostRegistry.Host] { try await base.loadAll() }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
try await base.upsert(host)
}
func remove(id: UUID) async throws -> [HostRegistry.Host] {
await journal.record(.removed(id))
if removeFails { throw StoreFailure() }
return try await base.remove(id: id)
}
func accessToken(host id: UUID) async throws -> AccessToken? {
try await base.accessToken(host: id)
}
func setAccessToken(
_ token: AccessToken?, host id: UUID
) async throws -> [HostRegistry.Host] {
try await base.setAccessToken(token, host: id)
}
}
private struct StoreFailure: Error {}
private actor ThrowingHostStore: HostStore {
func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
throw StoreFailure()
}
func remove(id: UUID) async throws -> [HostRegistry.Host] { throw StoreFailure() }
}
private func makeViewModel(store: any HostStore, journal: Journal) -> PairingViewModel {
PairingViewModel(
store: store,
probe: PairingViewModel.Probe(
verifyHost: { endpoint, _ in .success(endpoint) },
unregisterPush: { host in await journal.record(.unregistered(host.id)) }
)
)
}
private func makeHost(
name: String, port: Int, token: String? = nil
) throws -> HostRegistry.Host {
let url = try #require(URL(string: "http://192.168.1.5:\(port)"))
return HostRegistry.Host(
id: UUID(),
name: name,
endpoint: try #require(HostEndpoint(baseURL: url)),
accessToken: try token.map { try AccessToken(validating: $0) }
)
}
// MARK: - The wiring
@Test("移除主机:先注销该主机上的推送 token再删记录顺序是硬要求")
func removalDeregistersPushBeforeDeletingTheRecord() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal)
let host = try makeHost(name: "书房 Mac", port: 3000, token: Self.goodToken)
_ = try await store.upsert(host)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: host.id)
#expect(await journal.entries == [.unregistered(host.id), .removed(host.id)])
#expect(viewModel.pairedHosts.isEmpty)
#expect(try await store.loadAll().isEmpty)
}
@Test("移除主机后本机不留令牌(令牌是记录的一个字段,同一次写入一起消失)")
func removalLeavesNoTokenBehind() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal)
let host = try makeHost(name: "书房 Mac", port: 3000, token: Self.goodToken)
_ = try await store.upsert(host)
#expect(try await store.accessToken(host: host.id)?.rawValue == Self.goodToken)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: host.id)
#expect(try await store.accessToken(host: host.id) == nil)
#expect(try await store.loadAll().allSatisfy { $0.accessToken == nil })
}
@Test("只移除指定主机,其它主机及其令牌不受影响")
func removalIsScopedToOneHost() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal)
let doomed = try makeHost(name: "旧 Mac", port: 3000, token: Self.goodToken)
let kept = try makeHost(name: "新 Mac", port: 3100, token: Self.goodToken)
_ = try await store.upsert(doomed)
_ = try await store.upsert(kept)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: doomed.id)
#expect(viewModel.pairedHosts.map(\.id) == [kept.id])
#expect(try await store.accessToken(host: kept.id)?.rawValue == Self.goodToken)
#expect(await journal.entries == [.unregistered(doomed.id), .removed(doomed.id)])
}
@Test("未知 id → 完全 no-op不注销、不写存储")
func unknownIdIsANoOp() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal)
let host = try makeHost(name: "书房 Mac", port: 3000)
_ = try await store.upsert(host)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: UUID())
#expect(await journal.entries.isEmpty)
#expect(try await store.loadAll().count == 1)
}
@Test("存储写入失败 → 显式报错(不假装移除成功)")
func storeFailureIsSurfaced() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal, removeFails: true)
let host = try makeHost(name: "书房 Mac", port: 3000)
_ = try await store.upsert(host)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: host.id)
#expect(viewModel.hostsErrorMessage == PairingCopy.hostRemoveFailed)
#expect(viewModel.pairedHosts.map(\.id) == [host.id]) // list unchanged
}
// MARK: - Manage-section loading
@Test("加载已配对主机:成功清错,失败给出显式话术")
func loadingPairedHostsSurfacesErrors() async throws {
let journal = Journal()
let failing = makeViewModel(store: ThrowingHostStore(), journal: journal)
await failing.loadPairedHosts()
#expect(failing.pairedHosts.isEmpty)
#expect(failing.hostsErrorMessage == PairingCopy.hostsLoadFailed)
let working = makeViewModel(store: InMemoryHostStore(), journal: journal)
await working.loadPairedHosts()
#expect(working.hostsErrorMessage == nil)
}
}

View File

@@ -55,7 +55,7 @@ struct NewSessionInCwdTests {
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
http: http,
termTransport: transport,
probe: { _ in .failure(.timeout) },
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
unreadStore: unreadStore
)
)

View File

@@ -0,0 +1,176 @@
import APIClient
import Foundation
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// C1 · The pairing probe against a token-gated host, driven through the REAL
/// `runPairingProbe` over `FakeHTTPTransport` + `FakeTransport`.
///
/// Two things are pinned:
/// 1. a 401 is no longer misreported as "Origin rejected" with the token gate
/// live, 401 has TWO causes and one status code, so the copy must name both;
/// 2. the candidate token really travels: `Cookie: webterm_auth=<t>` on every
/// HTTP leg (RO GET and guarded DELETE alike, §1.1 the cookie is orthogonal
/// to `Origin`, which only the DELETE carries).
///
/// These live in the App test target because APIClient's own test suite is B1's
/// file ownership; the probe function under test is the package's public one.
@Suite("Pairing probe · 401 and the token cookie")
struct PairingProbeUnauthorizedTests {
private static let base = "http://192.168.1.5:3000"
private static let token = "abcdefghijklmnopqrstuvwxyz012345"
private static let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
private func endpoint() throws -> HostEndpoint {
let url = try #require(URL(string: Self.base))
return try #require(HostEndpoint(baseURL: url))
}
/// Script the whole happy path: RO list WS attach guarded kill.
private func scriptHappyPath(
http: FakeHTTPTransport, ws: FakeTransport
) async throws {
await http.queueSuccess(
url: try #require(URL(string: "\(Self.base)/live-sessions")),
body: Data("[]".utf8)
)
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(Self.probeSessionId)"}"#)
await http.queueSuccess(
method: "DELETE",
url: try #require(URL(string: "\(Self.base)/live-sessions/\(Self.probeSessionId)")),
status: 204
)
}
// MARK: - 401 both remedies
@Test("RO 探针收到 401 → 不是「不是 web-terminal」而是给出令牌+ALLOWED_ORIGINS 两条补救")
func readOnly401NamesBothRemedies() async throws {
let http = FakeHTTPTransport()
let ws = FakeTransport()
await http.queueSuccess(
url: try #require(URL(string: "\(Self.base)/live-sessions")),
status: 401,
body: Data(#"{"error":"unauthorized"}"#.utf8)
)
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
guard case .failure(.originRejected(let hint)) = result else {
Issue.record("expected originRejected(hint:), got \(result)")
return
}
#expect(hint.contains("401"))
#expect(hint.contains("访问令牌"))
#expect(hint.contains("ALLOWED_ORIGINS=\(Self.base)"))
// The WS leg is never reached, so no PTY is spawned on a host that
// refuses us.
#expect(await ws.connectAttempts.isEmpty)
}
@Test("WS 升级被拒(无法归类)→ 同一条两因两解的话术")
func upgradeRejectionNamesBothRemedies() async throws {
let http = FakeHTTPTransport()
let ws = FakeTransport()
await http.queueSuccess(
url: try #require(URL(string: "\(Self.base)/live-sessions")),
body: Data("[]".utf8)
)
await ws.scriptConnectFailure()
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
guard case .failure(.originRejected(let hint)) = result else {
Issue.record("expected originRejected(hint:), got \(result)")
return
}
#expect(hint.contains("访问令牌"))
#expect(hint.contains("ALLOWED_ORIGINS=\(Self.base)"))
#expect(!hint.contains(":443"))
}
@Test("守卫 DELETE 收到 401而非 403→ 同样是两因两解,不报「主机不可达」")
func guardedDelete401NamesBothRemedies() async throws {
let http = FakeHTTPTransport()
let ws = FakeTransport()
await http.queueSuccess(
url: try #require(URL(string: "\(Self.base)/live-sessions")),
body: Data("[]".utf8)
)
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(Self.probeSessionId)"}"#)
await http.queueSuccess(
method: "DELETE",
url: try #require(URL(string: "\(Self.base)/live-sessions/\(Self.probeSessionId)")),
status: 401
)
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
guard case .failure(.originRejected(let hint)) = result else {
Issue.record("expected originRejected(hint:), got \(result)")
return
}
#expect(hint.contains("访问令牌"))
}
// MARK: - The candidate token actually travels
@Test("带候选令牌的探针:两条 HTTP 腿都带 Cookie: webterm_auth且只读腿仍不带 Origin")
func tokenTravelsOnBothHTTPLegs() async throws {
let http = FakeHTTPTransport()
let ws = FakeTransport()
try await scriptHappyPath(http: http, ws: ws)
let result = await runPairingProbe(
endpoint: try endpoint(), http: http, ws: ws, accessToken: Self.token
)
guard case .success = result else {
Issue.record("expected success, got \(result)")
return
}
let requests = await http.recordedRequests
#expect(requests.count == 2)
for request in requests {
#expect(
request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)",
"every probe leg must carry the token cookie"
)
}
// Origin-iff-G is untouched by the token (§1.1: orthogonal).
let readOnly = try #require(requests.first)
let guarded = try #require(requests.last)
#expect(readOnly.value(forHTTPHeaderField: "Origin") == nil)
#expect(guarded.value(forHTTPHeaderField: "Origin") == Self.base)
}
@Test("没有候选令牌 → 一个 Cookie 头都不加LAN 零配置逐字不变)")
func noTokenMeansNoCookieHeader() async throws {
let http = FakeHTTPTransport()
let ws = FakeTransport()
try await scriptHappyPath(http: http, ws: ws)
_ = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
for request in await http.recordedRequests {
#expect(request.value(forHTTPHeaderField: "Cookie") == nil)
}
}
@Test("令牌不出现在 URL 里(绝不进查询串/日志)")
func tokenNeverAppearsInAURL() async throws {
let http = FakeHTTPTransport()
let ws = FakeTransport()
try await scriptHappyPath(http: http, ws: ws)
_ = await runPairingProbe(
endpoint: try endpoint(), http: http, ws: ws, accessToken: Self.token
)
for request in await http.recordedRequests {
#expect(request.url?.absoluteString.contains(Self.token) == false)
}
}
}

View File

@@ -0,0 +1,430 @@
import APIClient
import Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// C1 · Access-token UX in the pairing flow (ios-completion §1.1).
///
/// Every branch of the frozen contract is pinned here, because three of the four
/// `POST /auth` outcomes are NOT errors and must be told apart:
/// 204+Set-Cookie = save it · 204 without = this host has no auth (save nothing)
/// · 401 = wrong token · 429 = back off.
@MainActor
@Suite("Pairing access token")
struct PairingTokenViewModelTests {
// MARK: - Scripted probe (records what the VM asked for, in order)
private actor ProbeRecorder {
/// `(endpoint, token)` per host-verification call the token argument is
/// what proves the candidate reaches the probe.
private(set) var verifyCalls: [(origin: String, token: String?)] = []
private(set) var validateCalls: [String] = []
private var verifyResults: [Result<HostEndpoint, PairingError>]
private var validateResults:
[Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure>]
init(
verifyResults: [Result<HostEndpoint, PairingError>] = [],
validateResults: [Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure>]
= []
) {
self.verifyResults = verifyResults
self.validateResults = validateResults
}
func verify(
_ endpoint: HostEndpoint, _ token: AccessToken?
) -> Result<HostEndpoint, PairingError> {
verifyCalls = verifyCalls + [(endpoint.originHeader, token?.rawValue)]
guard let next = verifyResults.first else { return .success(endpoint) }
verifyResults = Array(verifyResults.dropFirst())
return next
}
func validate(
_ token: AccessToken
) -> Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure> {
validateCalls = validateCalls + [token.rawValue]
guard let next = validateResults.first else { return .success(.valid) }
validateResults = Array(validateResults.dropFirst())
return next
}
}
private static let base = "http://192.168.1.5:3000"
/// 32 chars from the frozen charset shape-valid, so any rejection in a test
/// comes from the server outcome, not from validation.
private static let goodToken = "abcdefghijklmnopqrstuvwxyz012345"
private func makeViewModel(
store: any HostStore,
recorder: ProbeRecorder
) -> PairingViewModel {
PairingViewModel(
store: store,
probe: PairingViewModel.Probe(
verifyHost: { endpoint, token in await recorder.verify(endpoint, token) },
validateToken: { _, token in await recorder.validate(token) }
)
)
}
/// Drive the VM to the token prompt the way the user gets there: the host
/// answers 401, which the probe reports as `originRejected` with the
/// both-remedies hint.
private func viewModelAtTokenPrompt(
store: any HostStore = InMemoryHostStore(),
recorder: ProbeRecorder
) async -> PairingViewModel {
let viewModel = makeViewModel(store: store, recorder: recorder)
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
viewModel.beginAccessTokenEntry()
// Loud, not silent: a recorder whose first verification does NOT fail
// with a 401 would leave the VM elsewhere and make every assertion below
// vacuous.
if case .awaitingToken = viewModel.phase {} else {
Issue.record("harness expected .awaitingToken, got \(viewModel.phase)")
}
return viewModel
}
// MARK: - 401 prompt
@Test("401 配对失败 → 提供「输入访问令牌」,进入令牌输入态")
func unauthorizedFailureOffersTokenPrompt() async throws {
// The hint text itself is the probe's (pinned in
// PairingProbeUnauthorizedTests against the REAL probe); here it stands
// in verbatim, because the VM must surface it unchanged.
let hint = "主机以 401 拒绝了这次配对……请输入访问令牌后重试;"
+ "或在主机上设置 ALLOWED_ORIGINS=\(Self.base) 后重启 web-terminal。"
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: hint))])
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.action == .enterAccessToken)
// The ambiguity is spelled out: BOTH remedies, since the server answers
// 401 for a bad token and a rejected Origin alike.
#expect(failure.message.contains("访问令牌"))
#expect(failure.message.contains("ALLOWED_ORIGINS=\(Self.base)"))
viewModel.beginAccessTokenEntry()
guard case .awaitingToken = viewModel.phase else {
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
return
}
}
@Test("非 401 失败不给令牌入口beginAccessTokenEntry 无副作用)")
func nonUnauthorizedFailureHasNoTokenEntry() async throws {
let recorder = ProbeRecorder(verifyResults: [.failure(.timeout)])
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
viewModel.beginAccessTokenEntry()
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.action == .retry)
}
// MARK: - Shape validation happens BEFORE any network I/O
@Test("令牌太短 → 本地就拒(不发 /auth话术只带长度不带令牌")
func tooShortTokenIsRejectedLocally() async throws {
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: "401"))])
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
await viewModel.submitAccessToken("short")
#expect(await recorder.validateCalls.isEmpty)
let rejection = try #require(viewModel.tokenRejection)
#expect(rejection.contains("\(5)"))
#expect(!rejection.contains("short")) // never echo the value
guard case .awaitingToken = viewModel.phase else {
Issue.record("expected to stay in .awaitingToken, got \(viewModel.phase)")
return
}
}
@Test("令牌含非法字符 → 本地就拒,不发 /auth")
func invalidCharactersRejectedLocally() async throws {
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: "401"))])
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
await viewModel.submitAccessToken("abcdefghijklmnop qrstuv")
#expect(await recorder.validateCalls.isEmpty)
#expect(viewModel.tokenRejection?.isEmpty == false)
}
// MARK: - The four §1.1 outcomes
@Test("204+Set-Cookievalid→ 带令牌重跑探针,主机连令牌一起入库")
func validTokenIsSavedWithTheHost() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))], // first, unauthenticated
validateResults: [.success(.valid)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
// The re-verification carried the candidate token (2nd verify call).
let verifyCalls = await recorder.verifyCalls
#expect(verifyCalls.count == 2)
#expect(verifyCalls.first?.token == nil)
#expect(verifyCalls.last?.token == Self.goodToken)
// and the persisted record has it (Keychain in production).
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(host.accessToken?.rawValue == Self.goodToken)
let stored = try await store.loadAll()
#expect(stored.count == 1)
#expect(stored.first?.accessToken?.rawValue == Self.goodToken)
#expect(try await store.accessToken(host: host.id)?.rawValue == Self.goodToken)
}
@Test("204 但没有 Set-Cookie该主机未启用鉴权→ 绝不保存令牌,也不声称已认证")
func authDisabledNeverPersistsAToken() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.authDisabled)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
// Still in the prompt, told the truth, and NOTHING was stored.
guard case .awaitingToken = viewModel.phase else {
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
return
}
#expect(viewModel.tokenRejection == PairingCopy.tokenNotRequired)
#expect(try await store.loadAll().isEmpty)
// No second verification either re-probing unauthenticated would just
// reproduce the same failure (the cause is not the token).
#expect(await recorder.verifyCalls.count == 1)
}
@Test("authDisabled 之后即使配对成功也不带令牌入库(候选已被清掉)")
func authDisabledClearsTheCandidate() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.authDisabled)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
// Back to confirm, then a (now succeeding) retry of the whole flow.
viewModel.cancelAccessTokenEntry()
await viewModel.confirmConnect()
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(host.accessToken == nil)
#expect(await recorder.verifyCalls.last?.token == nil)
}
@Test("401令牌错→ 留在输入态给出「令牌不正确」,不入库")
func invalidTokenKeepsThePrompt() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.invalidToken)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
#expect(viewModel.tokenRejection == PairingCopy.tokenInvalid)
#expect(try await store.loadAll().isEmpty)
guard case .awaitingToken = viewModel.phase else {
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
return
}
}
@Test("429 → 「稍后再试」话术,不入库")
func rateLimitedKeepsThePrompt() async throws {
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.rateLimited)]
)
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
#expect(viewModel.tokenRejection == PairingCopy.tokenRateLimited)
}
@Test("/auth 传输失败 → 网络话术(不吞错、不假装令牌错)")
func transportFailureIsSurfaced() async throws {
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.failure(.unreachable("Connection refused"))]
)
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
let rejection = try #require(viewModel.tokenRejection)
#expect(rejection.contains("Connection refused"))
#expect(rejection != PairingCopy.tokenInvalid)
}
// MARK: - Recovering an already-paired host
@Test("对同一 origin 再配对一次 = 原地更新(复用 id、不重复添加、补上令牌")
func repairingSameOriginUpdatesInPlace() async throws {
// Arrange: a host paired before the server enabled WEBTERM_TOKEN.
let store = InMemoryHostStore()
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
let existing = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
_ = try await store.upsert(existing)
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.valid)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
// Act
await viewModel.submitAccessToken(Self.goodToken)
// Assert: ONE host, same id (every lastSessionId/watermark keyed by it
// survives), original name kept, token now present.
let hosts = try await store.loadAll()
#expect(hosts.count == 1)
#expect(hosts.first?.id == existing.id)
#expect(hosts.first?.name == "书房 Mac")
#expect(hosts.first?.accessToken?.rawValue == Self.goodToken)
}
@Test("原地更新不会因为「用户没改名字」而把主机名覆盖成 IP")
func repairingKeepsUserChosenName() async throws {
let store = InMemoryHostStore()
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
_ = try await store.upsert(
HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
)
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
#expect(try await store.loadAll().first?.name == "书房 Mac")
}
@Test("重新配对时用户改了名字 → 采用新名字")
func repairingAdoptsEditedName() async throws {
let store = InMemoryHostStore()
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
_ = try await store.upsert(
HostRegistry.Host(id: UUID(), name: "旧名字", endpoint: endpoint)
)
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
viewModel.submitManualURL(Self.base)
viewModel.hostName = "客厅 Mac"
await viewModel.confirmConnect()
#expect(try await store.loadAll().first?.name == "客厅 Mac")
}
@Test("重新配对(未输新令牌)不会丢掉已保存的令牌")
func repairingPreservesStoredToken() async throws {
let store = InMemoryHostStore()
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
let token = try AccessToken(validating: Self.goodToken)
_ = try await store.upsert(HostRegistry.Host(
id: UUID(), name: "书房 Mac", endpoint: endpoint, accessToken: token
))
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
#expect(try await store.loadAll().first?.accessToken == token)
}
// MARK: - Secret hygiene
@Test("取消令牌输入 → 回到确认页,候选令牌被丢弃")
func cancellingTokenEntryDropsTheCandidate() async throws {
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.valid)]
)
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
viewModel.cancelAccessTokenEntry()
guard case .confirming = viewModel.phase else {
Issue.record("expected .confirming, got \(viewModel.phase)")
return
}
#expect(viewModel.tokenRejection == nil)
await viewModel.confirmConnect()
#expect(await recorder.verifyCalls.last?.token == nil)
}
@Test("换一个配对目标不会继承上一个目标的令牌")
func newTargetDoesNotInheritTheToken() async throws {
let recorder = ProbeRecorder(validateResults: [.success(.valid)])
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect() // succeeds paired
// A fresh VM state is what the sheet gives on re-entry; here we assert the
// reset path itself.
viewModel.cancel()
viewModel.submitManualURL("http://192.168.1.9:3000")
await viewModel.confirmConnect()
#expect(await recorder.verifyCalls.last?.token == nil)
#expect(await recorder.verifyCalls.last?.origin == "http://192.168.1.9:3000")
}
@Test("令牌绝不出现在任何可观察状态里(阶段/话术/主机描述)")
func tokenNeverLeaksIntoObservableState() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.valid)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(!String(describing: viewModel.phase).contains(Self.goodToken))
#expect(!host.description.contains(Self.goodToken))
#expect(host.description.contains("hasAccessToken: true")) // presence only
#expect(viewModel.tokenRejection == nil)
}
}

View File

@@ -42,7 +42,14 @@ struct PairingViewModelTests {
store: any HostStore = InMemoryHostStore(),
script: ProbeScript
) -> PairingViewModel {
PairingViewModel(store: store, probe: { await script.invoke($0) })
// C1 · `Probe` is now a value carrying the three capabilities; the
// token/push ones keep their zero-config defaults for these tests.
PairingViewModel(
store: store,
probe: PairingViewModel.Probe(verifyHost: { endpoint, _ in
await script.invoke(endpoint)
})
)
}
private struct StoreFailure: Error {}
@@ -67,7 +74,11 @@ struct PairingViewModelTests {
let store = InMemoryHostStore()
let viewModel = PairingViewModel(
store: store,
probe: { await runPairingProbe(endpoint: $0, http: http, ws: ws) }
probe: PairingViewModel.Probe(verifyHost: { endpoint, token in
await runPairingProbe(
endpoint: endpoint, http: http, ws: ws, accessToken: token?.rawValue
)
})
)
let base = "http://192.168.1.5:3000"
let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
@@ -270,8 +281,10 @@ struct PairingViewModelTests {
(PairingError.httpOkButNotWebTerminal,
PairingViewModel.RecoveryAction.retry,
["端口"]),
// C1 · 401 is ambiguous (Origin OR access token, same status), so the
// primary offered remedy is now the token prompt; retry stays on screen.
(PairingError.originRejected(hint: "在主机加 ALLOWED_ORIGINS=http://192.168.1.5:3000"),
PairingViewModel.RecoveryAction.retry,
PairingViewModel.RecoveryAction.enterAccessToken,
["ALLOWED_ORIGINS=http://192.168.1.5:3000"]),
(PairingError.atsBlocked(host: "198.18.0.1"),
PairingViewModel.RecoveryAction.retry,

View File

@@ -37,7 +37,7 @@ struct ProjectOpenWiringTests {
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
http: FakeHTTPTransport(),
termTransport: transport,
probe: { _ in .failure(.timeout) }
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) })
)
)
}

View File

@@ -0,0 +1,99 @@
import APIClient
import Foundation
import HostRegistry
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-32C2· ****`ProjectsViewModel.requestResumeClaude`
/// "" `attach(null, cwd)` bootstrap
/// `claude --resume <id>\r` request cwd / id
@MainActor
@Suite("ProjectResumeLaunch")
struct ProjectResumeLaunchTests {
private nonisolated static let base = "http://192.168.1.5:3000"
private func makeViewModel() throws -> ProjectsViewModel {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
return ProjectsViewModel(host: host, http: FakeHTTPTransport())
}
@Test("合法 cwd + 合法 id → OpenRequest 携带 `claude --resume <id>\\r`")
func resumeBuildsBootstrapInput() throws {
let viewModel = try makeViewModel()
let sessionId = "3f2b1c4d-0000-4000-8000-000000000001"
viewModel.requestResumeClaude(cwd: "/repos/web-terminal", sessionId: sessionId)
let request = try #require(viewModel.openRequest)
#expect(request.cwd == "/repos/web-terminal")
#expect(request.bootstrapInput == "claude --resume \(sessionId)\r")
#expect(viewModel.openErrorMessage == nil)
}
@Test("相对 cwd → 拒绝铸造(与 requestOpenClaude 同款纪律)")
func relativeCwdIsRejected() throws {
let viewModel = try makeViewModel()
viewModel.requestResumeClaude(cwd: "repos/web-terminal", sessionId: "abc")
#expect(viewModel.openRequest == nil)
#expect(viewModel.openErrorMessage != nil)
}
@Test("危险会话 id → 拒绝铸造(绝不把它送进 PTY 命令行)")
func injectionIdIsRejected() throws {
let viewModel = try makeViewModel()
viewModel.requestResumeClaude(
cwd: "/repos/web-terminal", sessionId: "abc; rm -rf ~"
)
#expect(viewModel.openRequest == nil)
#expect(viewModel.openErrorMessage == ResumeCopy.notResumable)
}
@Test("普通开会话仍是 `claude\\r`(恢复路径没有改动既有行为)")
func plainOpenIsUnchanged() throws {
let viewModel = try makeViewModel()
viewModel.requestOpenClaude(cwd: "/repos/web-terminal")
#expect(viewModel.openRequest?.bootstrapInput == ProjectLaunch.claudeBootstrapInput)
}
}
/// C2 · PR `PrStatus.url` `openURL`
/// App
@Suite("PrLink")
struct PrLinkTests {
@Test("https + github.com含子域→ 可点")
func githubHttpsIsAllowed() {
#expect(PrLink.url(from: "https://github.com/o/r/pull/7") != nil)
#expect(PrLink.url(from: "https://www.github.com/o/r/pull/7") != nil)
}
@Test(
"其余一律拒绝http、自定义 scheme、非 github 主机、伪造后缀、nil",
arguments: [
"http://github.com/o/r/pull/7",
"javascript:alert(1)",
"webterm://join?id=1",
"https://evil.com/o/r/pull/7",
"https://github.com.evil.com/o/r/pull/7",
"not a url at all",
"",
]
)
func everythingElseIsRejected(raw: String) {
#expect(PrLink.url(from: raw) == nil, "\(raw) 不应被接受")
}
@Test("nil URLgh 降级时没有 url 字段)→ nil")
func nilIsRejected() {
#expect(PrLink.url(from: nil) == nil)
}
}

View File

@@ -0,0 +1,187 @@
import APIClient
import Foundation
import Testing
@testable import WebTerm
/// T-iOS-32 · `claude --resume <id>` `GET /sessions` App
///
/// RED `docs/plans/ios-completion.md` §4E 2835 id
/// **** PTY
/// web `public/tabs.ts:918`
@MainActor
@Suite("ResumeHistoryViewModel")
struct ResumeHistoryViewModelTests {
private static let projectPath = "/repos/web-terminal"
private nonisolated static func session(
id: String = "3f2b1c4d-0000-4000-8000-000000000001",
cwd: String,
mtimeMs: Double = 1_785_390_645_813.5
) -> HistorySession {
HistorySession(
id: id, cwd: cwd, project: "web-terminal", mtimeMs: mtimeMs, preview: "修一下 CJK locale"
)
}
// MARK: - 2830
@Test("只保留 cwd 落在本项目内的会话(含仓库根与子目录/worktree")
func keepsOnlySessionsInsideProject() {
let inside = Self.session(id: "a1", cwd: Self.projectPath)
let nested = Self.session(id: "a2", cwd: Self.projectPath + "/.claude/worktrees/x")
let outside = Self.session(id: "a3", cwd: "/repos/other")
let candidates = ResumeHistoryViewModel.candidates(
from: [inside, nested, outside], projectPath: Self.projectPath
)
#expect(candidates.map(\.id) == ["a1", "a2"])
}
@Test("前缀不得半路匹配:/repos/web-terminal-old 不属于 /repos/web-terminal")
func siblingPrefixIsNotInside() {
let sibling = Self.session(id: "a1", cwd: "/repos/web-terminal-old")
let candidates = ResumeHistoryViewModel.candidates(
from: [sibling], projectPath: Self.projectPath
)
#expect(candidates.isEmpty)
}
@Test("服务器顺序mtime 新→旧)原样保留,客户端不重排")
func serverOrderIsPreserved() {
let older = Self.session(id: "old", cwd: Self.projectPath, mtimeMs: 1_000)
let newer = Self.session(id: "new", cwd: Self.projectPath, mtimeMs: 9_000)
let candidates = ResumeHistoryViewModel.candidates(
from: [newer, older], projectPath: Self.projectPath
)
#expect(candidates.map(\.id) == ["new", "old"])
}
@Test("cwd 非绝对路径 → 不可恢复Validation.isAbsoluteCwd 纪律)")
func relativeCwdIsFilteredOut() {
let relative = Self.session(id: "a1", cwd: "repos/web-terminal")
let candidates = ResumeHistoryViewModel.candidates(
from: [relative], projectPath: "repos/web-terminal"
)
#expect(candidates.isEmpty)
}
// MARK: - phase3132
@Test("空结果 → .empty不是 .failed服务器无历史时正常回 []")
func emptyListIsNotFailure() async {
let vm = ResumeHistoryViewModel(projectPath: Self.projectPath, fetch: { [] })
await vm.load()
#expect(vm.phase == .empty)
}
@Test("过滤后为空(有历史但都不属于本仓库)→ 同样 .empty")
func allFilteredOutIsEmpty() async {
let vm = ResumeHistoryViewModel(
projectPath: Self.projectPath,
fetch: { [Self.session(id: "a1", cwd: "/repos/other")] }
)
await vm.load()
#expect(vm.phase == .empty)
}
@Test("加载失败 → .failed可重试重试成功 → .loaded")
func failureIsRetryable() async {
let flag = ResumeFailOnceFlag()
let payload = Self.session(id: "a1", cwd: Self.projectPath)
let vm = ResumeHistoryViewModel(projectPath: Self.projectPath, fetch: {
if await flag.consumeShouldFail() { throw APIClientError.gitDataUnavailable }
return [payload]
})
await vm.load()
guard case .failed = vm.phase else {
Issue.record("应为 .failed实际 \(vm.phase)")
return
}
await vm.load()
#expect(vm.phase == .loaded(ResumeHistoryViewModel.candidates(
from: [payload], projectPath: Self.projectPath
)))
}
// MARK: - 3334
@Test(
"危险 id 绝不拼进 PTY 命令行(注入白名单)",
arguments: [
"abc; rm -rf ~", "abc `id`", "abc$(id)", "abc\nwhoami", "abc id", "abc'x'",
"abc\"x\"", "abc|x", "abc&x", "abc>x", "../../etc/passwd", "",
]
)
func dangerousIdsAreRejected(sessionId: String) {
#expect(
ProjectResumeCommand.bootstrapInput(sessionId: sessionId) == nil,
"\(sessionId) 不应被接受"
)
}
@Test("超长 id> 128拒绝")
func overlongIdRejected() {
let long = String(repeating: "a", count: 129)
#expect(ProjectResumeCommand.bootstrapInput(sessionId: long) == nil)
}
@Test("合法 id → `claude --resume <id>` 且以 \\r0x0D结尾绝不是 \\n")
func validIdBuildsCarriageReturnCommand() throws {
let id = "3f2b1c4d-0000-4000-8000-000000000001"
let input = try #require(ProjectResumeCommand.bootstrapInput(sessionId: id))
#expect(input == "claude --resume \(id)\r")
#expect(input.hasSuffix("\r"))
#expect(!input.contains("\n"))
}
@Test("非法 id 的历史行仍显示,但 canResume == false不提供恢复按钮")
func rowWithBadIdIsNotResumable() {
let bad = Self.session(id: "abc; rm -rf ~", cwd: Self.projectPath)
let candidates = ResumeHistoryViewModel.candidates(
from: [bad], projectPath: Self.projectPath
)
#expect(candidates.count == 1)
#expect(!candidates[0].canResume)
}
@Test("合法行 canResume == true且携带会话自己的 cwdworktree 会话回到 worktree")
func resumableRowCarriesItsOwnCwd() throws {
let nestedCwd = Self.projectPath + "/.claude/worktrees/x"
let session = Self.session(id: "a1", cwd: nestedCwd)
let candidate = try #require(ResumeHistoryViewModel.candidates(
from: [session], projectPath: Self.projectPath
).first)
#expect(candidate.canResume)
#expect(candidate.cwd == nestedCwd)
}
}
/// @Sendable fetch actor
private actor ResumeFailOnceFlag {
private var shouldFail = true
func consumeShouldFail() -> Bool {
defer { shouldFail = false }
return shouldFail
}
}

View File

@@ -304,7 +304,7 @@ struct SessionSwitcherTests {
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
http: http,
termTransport: transport,
probe: { _ in .failure(.timeout) },
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
unreadStore: unreadStore
)
)

View File

@@ -53,7 +53,7 @@ struct SidebarSelectionTests {
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
http: http,
termTransport: transport,
probe: { _ in .failure(.timeout) },
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
unreadStore: unreadStore
)
)

View File

@@ -0,0 +1,315 @@
import Foundation
import SessionCore
import SwiftTerm
import TestSupport
import Testing
import UIKit
import WireProtocol
@testable import WebTerm
/// T-iOS-33 · ios-completion §4 RED 118
///
/// SwiftTerm 1.13.0 API`findNext`/`findPrevious`/
/// `clearSearch``SearchOptions` xterm.js search addon + web
/// `public/search.ts`Enter=next / Shift+Enter=prev / Esc= clear
///
/// **** PTY byte-shuttle
@MainActor
@Suite("TerminalSearch (T-iOS-33)")
struct TerminalSearchTests {
// MARK: - Fakes
///
@MainActor
private final class FakeSearcher: TerminalSearching {
enum Call: Equatable {
case next(String)
case previous(String)
case clear
}
private(set) var calls: [Call] = []
var hitResult = true
func searchNext(term: String) -> Bool {
calls = calls + [.next(term)]
return hitResult
}
func searchPrevious(term: String) -> Bool {
calls = calls + [.previous(term)]
return hitResult
}
func searchClear() {
calls = calls + [.clear]
}
}
private func model(_ searcher: FakeSearcher) -> TerminalSearchModel {
let model = TerminalSearchModel()
model.attach(searcher)
return model
}
// MARK: - A. 13
@Test("1 · 空串不可提交,且零引擎调用")
func emptyQueryIsNotSubmittable() {
#expect(!TerminalSearchQuery.isSubmittable(""))
let searcher = FakeSearcher()
let model = model(searcher)
model.query = ""
model.find(.next)
#expect(searcher.calls.isEmpty)
#expect(model.outcome == .idle)
}
@Test("2 · 单个空格可提交(镜像 web空格是合法搜索词绝不 trim 掉)")
func singleSpaceIsSubmittable() {
#expect(TerminalSearchQuery.isSubmittable(" "))
let searcher = FakeSearcher()
let model = model(searcher)
model.query = " "
model.find(.next)
#expect(searcher.calls == [.next(" ")])
}
@Test("3 · 查询词逐字符原样下传(不 trim、不改大小写")
func queryIsPassedThroughVerbatim() {
let searcher = FakeSearcher()
let model = model(searcher)
model.query = " Foo "
model.find(.next)
#expect(searcher.calls == [.next(" Foo ")])
}
// MARK: - B. 49
@Test("4 · find(.next) → 恰一次 searchNext零 searchPrevious")
func findNextCallsNextOnly() {
let searcher = FakeSearcher()
let model = model(searcher)
model.query = "claude"
model.find(.next)
#expect(searcher.calls == [.next("claude")])
}
@Test("5 · find(.previous) → 恰一次 searchPrevious零 searchNext")
func findPreviousCallsPreviousOnly() {
let searcher = FakeSearcher()
let model = model(searcher)
model.query = "claude"
model.find(.previous)
#expect(searcher.calls == [.previous("claude")])
}
@Test("6 · 引擎命中 → outcome == .found")
func hitProducesFoundOutcome() {
let searcher = FakeSearcher()
searcher.hitResult = true
let model = model(searcher)
model.query = "claude"
model.find(.next)
#expect(model.outcome == .found)
}
@Test("7 · 引擎未命中 → outcome == .notFound且有非空文案")
func missProducesNotFoundOutcomeWithCopy() {
let searcher = FakeSearcher()
searcher.hitResult = false
let model = model(searcher)
model.query = "zzz"
model.find(.next)
#expect(model.outcome == .notFound)
let status = model.statusText
#expect(status != nil)
#expect(!(status ?? "").isEmpty)
}
@Test("8 · 连续三次 find(.next) → 三次引擎调用(游标由 SwiftTerm 推进)")
func repeatedFindHitsEngineEachTime() {
let searcher = FakeSearcher()
let model = model(searcher)
model.query = "x"
model.find(.next)
model.find(.next)
model.find(.next)
#expect(searcher.calls == [.next("x"), .next("x"), .next("x")])
}
@Test("9 · 未 attach 引擎 → 不崩outcome 保持 .idle")
func noSearcherAttachedIsSafe() {
let model = TerminalSearchModel()
model.query = "claude"
model.find(.next)
#expect(model.outcome == .idle)
#expect(model.statusText == nil)
}
// MARK: - C. /1014
@Test("10 · 初始 isPresented == false 且 outcome == .idle")
func initialStateIsClosedAndIdle() {
let model = TerminalSearchModel()
#expect(!model.isPresented)
#expect(model.outcome == .idle)
}
@Test("11 · present()/dismiss() 切换 isPresented")
func presentAndDismissTogglePresentation() {
let searcher = FakeSearcher()
let model = model(searcher)
model.present()
#expect(model.isPresented)
model.dismiss()
#expect(!model.isPresented)
}
@Test("12 · dismiss() → 恰一次 searchClear + 清空 query + outcome 复位")
func dismissClearsHighlightAndQuery() {
let searcher = FakeSearcher()
searcher.hitResult = false
let model = model(searcher)
model.present()
model.query = "zzz"
model.find(.next)
#expect(model.outcome == .notFound)
model.dismiss()
#expect(searcher.calls == [.next("zzz"), .clear])
#expect(model.query.isEmpty)
#expect(model.outcome == .idle)
}
@Test("13 · present() 不调用任何引擎方法(开面板 ≠ 搜索)")
func presentDoesNotTouchEngine() {
let searcher = FakeSearcher()
let model = model(searcher)
model.present()
#expect(searcher.calls.isEmpty)
}
@Test("14 · 改 query → outcome 复位 .idle旧「无匹配」不挂新词")
func editingQueryResetsOutcome() {
let searcher = FakeSearcher()
searcher.hitResult = false
let model = model(searcher)
model.query = "zzz"
model.find(.next)
#expect(model.outcome == .notFound)
model.query = "zz"
#expect(model.outcome == .idle)
#expect(model.statusText == nil)
}
// MARK: - D. 1516
@Test("15 · 整个搜索流程后零 PTY 字节byte-shuttle 不变式)")
func searchNeverWritesToThePty() async throws {
// Arrange SessionEngine over FakeTransport + TerminalViewModel
let transport = FakeTransport()
let clock = FakeClock()
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let engine = SessionEngine(
transport: transport, clock: clock, endpoint: endpoint,
eventsSource: { _ in [] }
)
let terminalViewModel = TerminalViewModel(engine: engine, events: engine.events)
terminalViewModel.start()
// Act
let searcher = FakeSearcher()
let model = model(searcher)
model.present()
model.query = "claude"
model.find(.next)
model.find(.previous)
model.dismiss()
// Assert PTY
#expect(terminalViewModel.forwardedSendCount == 0)
#expect(terminalViewModel.droppedReadOnlyInputCount == 0)
}
@Test("16 · 终端已退出read-only时搜索照常可用")
func searchWorksOnAnExitedTerminal() async throws {
// Arrange VM .exited
let transport = FakeTransport()
let clock = FakeClock()
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let engine = SessionEngine(
transport: transport, clock: clock, endpoint: endpoint,
eventsSource: { _ in [] }
)
let terminalViewModel = TerminalViewModel(engine: engine, events: engine.events)
terminalViewModel.start()
await engine.open(sessionId: nil, cwd: nil)
await transport.emit(frame: #"{"type":"exit","code":0,"reason":"done"}"#)
await terminalViewModel.waitUntilProcessed(eventCount: 3)
#expect(terminalViewModel.isReadOnly)
// Act
let searcher = FakeSearcher()
let model = model(searcher)
model.query = "claude"
model.find(.next)
// Assert退
#expect(searcher.calls == [.next("claude")])
#expect(model.outcome == .found)
}
// MARK: - E. SwiftTerm 1718Accept
/// `KeyCommandTerminalView` `TerminalSearching`
/// SwiftTerm
@Test("17 · 真 SwiftTerm view命中返回 true 并高亮选区;未命中返回 false")
func realTerminalViewSearchHitsAndHighlights() {
let terminal = KeyCommandTerminalView(
frame: CGRect(x: 0, y: 0, width: 480, height: 480)
)
terminal.feed(text: "claude code cockpit\r\n")
let searcher: any TerminalSearching = terminal
#expect(searcher.searchNext(term: "cockpit"))
#expect(terminal.hasActiveSelection)
#expect(!searcher.searchNext(term: "nonexistent-needle"))
}
@Test("18 · searchClear() 清掉高亮")
func clearRemovesHighlight() {
let terminal = KeyCommandTerminalView(
frame: CGRect(x: 0, y: 0, width: 480, height: 480)
)
terminal.feed(text: "claude code cockpit\r\n")
let searcher: any TerminalSearching = terminal
#expect(searcher.searchNext(term: "cockpit"))
#expect(terminal.hasActiveSelection)
searcher.searchClear()
#expect(!terminal.hasActiveSelection)
}
}

View File

@@ -0,0 +1,95 @@
import Foundation
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// C1 · The 401 terminal state end to end through the VM (B3 added
/// `FailureReason.unauthorized`; this pins how the terminal presents it).
///
/// Driven through a REAL `SessionEngine` over `TestSupport.FakeTransport`, so
/// the assertion covers the whole path a wrong token actually takes:
/// upgrade 401 `TermTransportError.unauthorized` engine terminal state
/// `SessionEvent.connection(.failed(.unauthorized))` VM phase + copy.
@MainActor
@Suite("Terminal unauthorized (401)")
struct TerminalUnauthorizedTests {
@MainActor
private struct Harness {
let transport = FakeTransport()
let clock = FakeClock()
let engine: SessionEngine
let viewModel: TerminalViewModel
init() throws {
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
engine = SessionEngine(
transport: transport, clock: clock, endpoint: endpoint,
eventsSource: { _ in [] }
)
viewModel = TerminalViewModel(engine: engine, events: engine.events)
viewModel.start()
}
/// Upgrade rejected with 401 `.connecting` + `.failed(.unauthorized)`.
func openRejected() async {
await transport.scriptConnectFailure(TermTransportError.unauthorized)
await engine.open(sessionId: nil, cwd: nil)
await viewModel.waitUntilProcessed(eventCount: 2)
}
}
@Test("401 升级被拒 → 终态 failed(不是 reconnecting 转圈)")
func unauthorizedIsTerminalNotRetried() async throws {
let harness = try Harness()
await harness.openRejected()
guard case .failed = harness.viewModel.phase else {
Issue.record("expected .failed, got \(harness.viewModel.phase)")
return
}
#expect(harness.viewModel.banner == .none) // spinner cleared
#expect(harness.viewModel.isReadOnly)
#expect(await harness.transport.connectAttempts.count == 1) // no backoff loop
}
@Test("401 话术同时给出两条补救:令牌与 ALLOWED_ORIGINS服务端两者都回 401")
func unauthorizedCopyOffersBothRemedies() async throws {
let harness = try Harness()
await harness.openRejected()
guard case .failed(let message) = harness.viewModel.phase else {
Issue.record("expected .failed, got \(harness.viewModel.phase)")
return
}
#expect(message.contains("访问令牌"))
#expect(message.contains("ALLOWED_ORIGINS"))
#expect(message == TerminalViewModel.unauthorizedMessage)
}
@Test("401 横幅模型走 failed 分支(供 ReconnectBanner 渲染)")
func unauthorizedRendersFailedBanner() async throws {
let harness = try Harness()
await harness.openRejected()
#expect(
harness.viewModel.bannerModel
== .failed(message: TerminalViewModel.unauthorizedMessage)
)
}
@Test("401 终态下输入被丢弃read-only不再打向已死连接")
func unauthorizedDropsInput() async throws {
let harness = try Harness()
await harness.openRejected()
harness.viewModel.sendInput("ls\r")
#expect(harness.viewModel.droppedReadOnlyInputCount == 1)
}
}

View File

@@ -0,0 +1,635 @@
import Foundation
import SessionCore
import TestSupport
import Testing
import UIKit
@testable import WebTerm
/// T-iOS-31 · PTT + ios-completion §4 RED 1956
///
/// web`public/voice.ts`PTT `autoSend:false`
/// `public/voice-commands.ts` + + 0.6
/// `public/voice-confirm.ts`1500ms epoch 沿
/// `SessionCore/GateState.canDecide(epoch:)`
///
/// DEFERRED
/// ** / / epoch **FakeClock
@MainActor
@Suite("VoicePTT (T-iOS-31)")
struct VoicePTTTests {
// MARK: - Fakes
/// `await`
/// MainActor
@MainActor
private final class Gate {
var isArmed = false
private var didReach = false
private var blocked: CheckedContinuation<Void, Never>?
private var arrival: CheckedContinuation<Void, Never>?
/// armed
func passThrough() async {
guard isArmed else { return }
didReach = true
arrival?.resume()
arrival = nil
await withCheckedContinuation { blocked = $0 }
}
///
func waitUntilReached() async {
guard !didReach else { return }
await withCheckedContinuation { arrival = $0 }
}
func open() {
isArmed = false
let blocked = self.blocked
self.blocked = nil
blocked?.resume()
}
}
///
/// PTT
@MainActor
private final class FakeDictation: VoiceDictating {
var authorization: VoiceAuthorization = .granted
var startError: (any Error)?
var result = VoiceDictationResult(transcript: "", confidence: nil)
private(set) var startCount = 0
private(set) var stopCount = 0
private(set) var partialSink: (@MainActor (String) -> Void)?
let authorizationGate = Gate()
let startGate = Gate()
func requestAuthorization() async -> VoiceAuthorization {
await authorizationGate.passThrough()
return authorization
}
func start(onPartial: @escaping @MainActor (String) -> Void) async throws {
startCount += 1
if let startError { throw startError }
partialSink = onPartial
await startGate.passThrough()
}
func stop() async -> VoiceDictationResult {
stopCount += 1
partialSink = nil
return result
}
}
/// epoch /
@MainActor
private final class EpochBox {
var epoch: VoiceEpoch
init(_ epoch: VoiceEpoch = VoiceEpoch(sessionId: nil, generation: 0)) {
self.epoch = epoch
}
}
/// /
@MainActor
private final class InjectRecorder {
private(set) var texts: [String] = []
private(set) var decisions: [VoiceDecision] = []
func inject(_ text: String) { texts = texts + [text] }
func decide(_ decision: VoiceDecision) { decisions = decisions + [decision] }
}
@MainActor
private struct Harness {
let dictation = FakeDictation()
let clock = FakeClock()
let epochBox = EpochBox()
let recorder = InjectRecorder()
let viewModel: VoicePTTViewModel
init(isReadOnly: Bool = false, gate: VoiceGateSnapshot? = nil) {
let dictation = self.dictation
let clock = self.clock
let epochBox = self.epochBox
let recorder = self.recorder
let bridge: VoiceGateBridge? = gate.map { snapshot in
VoiceGateBridge(
snapshot: { snapshot },
decide: { decision in recorder.decide(decision) }
)
}
viewModel = VoicePTTViewModel(
dictation: dictation,
clock: clock,
epoch: { epochBox.epoch },
isReadOnly: { isReadOnly },
inject: { text in recorder.inject(text) },
gate: bridge
)
}
/// `dictation.result`
func dictate(_ transcript: String, confidence: Double? = nil) async {
dictation.result = VoiceDictationResult(
transcript: transcript, confidence: confidence
)
await viewModel.pressDown()
await viewModel.pressUp()
}
///
func elapseUndoWindow() async {
await clock.waitForSleepers(count: 1)
clock.advance(by: VoicePTTViewModel.undoWindow)
await viewModel.waitUntilWindowSettled()
}
}
private static let sessionA = UUID()
private static let sessionB = UUID()
// MARK: - A. 1924
@Test("19 · 普通文本 → 首尾 trim 后原样")
func sanitizeKeepsPlainText() {
#expect(VoiceTranscript.sanitize(" git status ") == "git status")
}
@Test("20 · \\r0x0D被剥除 —— 口述绝不自己回车执行命令")
func sanitizeStripsCarriageReturn() {
let sanitized = VoiceTranscript.sanitize("rm -rf /\r")
#expect(!sanitized.contains("\r"))
#expect(sanitized == "rm -rf /")
}
@Test("21 · C0/C1 控制字符(\\n \\t ESC BEL DEL全部剥除")
func sanitizeStripsControlCharacters() {
let raw = "ec\u{1b}ho\u{7}\t hi\u{7f}\u{0}\u{9b}"
let sanitized = VoiceTranscript.sanitize(raw)
#expect(sanitized == "echo hi")
}
@Test("22 · 多行折成单行(换行→空格 + 合并连续空白)")
func sanitizeCollapsesToOneLine() {
#expect(VoiceTranscript.sanitize("git\ncommit -m\n\n test") == "git commit -m test")
}
@Test("23 · 超长转写截断到上限")
func sanitizeTruncatesOverlongTranscript() {
let raw = String(repeating: "a", count: VoiceTranscript.maxLength + 500)
#expect(VoiceTranscript.sanitize(raw).count == VoiceTranscript.maxLength)
}
@Test("24 · 清洗后为空 → 不可注入")
func sanitizeEmptyIsNotInjectable() {
#expect(!VoiceTranscript.isInjectable(VoiceTranscript.sanitize("\u{1b}\r\n \t")))
#expect(VoiceTranscript.isInjectable("ok"))
}
// MARK: - B. 2533
private func context(
pendingApproval: Bool, gate: VoiceGateKind?, confidence: Double? = nil
) -> VoiceMatchContext {
VoiceMatchContext(pendingApproval: pendingApproval, gate: gate, confidence: confidence)
}
@Test("25 · 无 held gate → 任何话语都是 .text含「确认」")
func matcherFallsThroughWithoutHeldGate() {
let ctx = context(pendingApproval: false, gate: .tool)
#expect(VoiceCommandMatcher.match(transcript: "确认", context: ctx) == .text)
#expect(VoiceCommandMatcher.match(transcript: "approve", context: ctx) == .text)
}
@Test("26 · gate 是 .plan → .textv1 只作用 tool gate")
func matcherIgnoresPlanGate() {
let ctx = context(pendingApproval: true, gate: .plan)
#expect(VoiceCommandMatcher.match(transcript: "确认", context: ctx) == .text)
}
@Test("27 · tool gate + 肯定短语 → .approve")
func matcherApprovesAffirmativePhrases() {
let ctx = context(pendingApproval: true, gate: .tool)
for phrase in ["确认", "批准", "同意", "好的", "ok", "go ahead", "proceed"] {
#expect(VoiceCommandMatcher.match(transcript: phrase, context: ctx) == .approve,
"\(phrase) 应判 approve")
}
}
@Test("28 · 归一化:大小写 + 去标点 + 合并空白don't → dont")
func matcherNormalizesTranscript() {
let ctx = context(pendingApproval: true, gate: .tool)
#expect(VoiceCommandMatcher.match(transcript: "OK.", context: ctx) == .approve)
#expect(VoiceCommandMatcher.match(transcript: "好的!", context: ctx) == .approve)
#expect(VoiceCommandMatcher.normalize("Don't DO it") == "dont do it")
}
@Test("29 · 整句精确匹配 —— 「确认一下这个」落 .text绝不子串匹配")
func matcherIsWholeUtteranceExact() {
let ctx = context(pendingApproval: true, gate: .tool)
#expect(VoiceCommandMatcher.match(transcript: "确认一下这个", context: ctx) == .text)
#expect(VoiceCommandMatcher.match(transcript: "ok let's ship it", context: ctx) == .text)
}
@Test("30 · tool gate + 否定短语 → .reject")
func matcherRejectsNegativePhrases() {
let ctx = context(pendingApproval: true, gate: .tool)
for phrase in ["拒绝", "取消", "stop", "abort", "no"] {
#expect(VoiceCommandMatcher.match(transcript: phrase, context: ctx) == .reject,
"\(phrase) 应判 reject")
}
}
@Test("31 · 否定守卫:「不批准」→ reject「不清楚」→ textnow 不被 no 否定")
func matcherNegationGuard() {
let ctx = context(pendingApproval: true, gate: .tool)
#expect(VoiceCommandMatcher.match(transcript: "不批准", context: ctx) == .reject)
#expect(VoiceCommandMatcher.match(transcript: "不清楚", context: ctx) == .text)
#expect(VoiceCommandMatcher.match(transcript: "now", context: ctx) == .text)
#expect(VoiceCommandMatcher.startsWithNegation("不批准"))
#expect(!VoiceCommandMatcher.startsWithNegation("now"))
}
@Test("32 · 置信度门approve 低于 0.6 降级 textreject 不受影响")
func matcherConfidenceGateAppliesToApproveOnly() {
let low = context(pendingApproval: true, gate: .tool, confidence: 0.4)
#expect(VoiceCommandMatcher.match(transcript: "确认", context: low) == .text)
#expect(VoiceCommandMatcher.match(transcript: "取消", context: low) == .reject)
let atThreshold = context(
pendingApproval: true, gate: .tool,
confidence: VoiceCommandMatcher.minApproveConfidence
)
#expect(VoiceCommandMatcher.match(transcript: "确认", context: atThreshold) == .approve)
}
@Test("33 · 空/纯标点话语 → .text")
func matcherEmptyUtteranceIsText() {
let ctx = context(pendingApproval: true, gate: .tool)
#expect(VoiceCommandMatcher.match(transcript: "", context: ctx) == .text)
#expect(VoiceCommandMatcher.match(transcript: "……", context: ctx) == .text)
}
// MARK: - C. + 1.5s 3443
@Test("34 · pressDown → .listeningpartial 回调更新 partial 文本")
func pressDownStartsListeningAndSurfacesPartials() async {
let harness = Harness()
await harness.viewModel.pressDown()
#expect(harness.viewModel.phase == .listening(partial: ""))
#expect(harness.dictation.startCount == 1)
harness.dictation.partialSink?("git st")
#expect(harness.viewModel.phase == .listening(partial: "git st"))
}
@Test("35 · 空转写 → .idle + .empty + 零注入")
func emptyTranscriptResolvesEmpty() async {
let harness = Harness()
await harness.dictate(" \r\n ")
#expect(harness.viewModel.phase == .idle)
#expect(harness.viewModel.lastResolution == .empty)
#expect(harness.recorder.texts.isEmpty)
}
@Test("36 · 有效转写 → .confirming且此刻零注入确认前绝不注入")
func validTranscriptWaitsForExplicitConfirm() async {
let harness = Harness()
await harness.dictate("git status")
#expect(harness.viewModel.phase == .confirming(VoicePendingAction(
intent: .text("git status"),
epoch: VoiceEpoch(sessionId: nil, generation: 0),
preview: "git status"
)))
#expect(harness.recorder.texts.isEmpty)
}
@Test("37 · .confirming 下 cancel() → .idle + .discarded + 零注入")
func cancelDiscardsBeforeInjection() async {
let harness = Harness()
await harness.dictate("git status")
harness.viewModel.cancel()
#expect(harness.viewModel.phase == .idle)
#expect(harness.viewModel.lastResolution == .discarded)
#expect(harness.recorder.texts.isEmpty)
}
@Test("38 · confirm() → .undoWindow此刻仍零注入")
func confirmArmsUndoWindowWithoutInjecting() async {
let harness = Harness()
await harness.dictate("git status")
harness.viewModel.confirm()
#expect(harness.viewModel.isUndoWindowOpen)
#expect(harness.recorder.texts.isEmpty)
}
@Test("39 · +1.4s 仍零注入;到 1.5s → 恰一次注入 + .committed")
func injectionLandsOnlyAfterTheFullUndoWindow() async {
let harness = Harness()
await harness.dictate("git status")
harness.viewModel.confirm()
await harness.clock.waitForSleepers(count: 1)
harness.clock.advance(by: .milliseconds(1400))
#expect(harness.recorder.texts.isEmpty)
harness.clock.advance(by: .milliseconds(100))
await harness.viewModel.waitUntilWindowSettled()
#expect(harness.recorder.texts == ["git status"])
#expect(harness.viewModel.lastResolution == .committed(.text("git status")))
#expect(harness.viewModel.phase == .idle)
}
@Test("40 · 撤销窗口内 undo() → 零注入 + .undone再推进 10s 也不注入")
func undoCancelsTheInjectionForGood() async {
let harness = Harness()
await harness.dictate("git status")
harness.viewModel.confirm()
await harness.clock.waitForSleepers(count: 1)
harness.viewModel.undo()
await harness.viewModel.waitUntilWindowSettled()
harness.clock.advance(by: .seconds(10))
await harness.viewModel.waitUntilWindowSettled()
#expect(harness.recorder.texts.isEmpty)
#expect(harness.viewModel.lastResolution == .undone)
#expect(harness.viewModel.phase == .idle)
}
@Test("41 · 注入内容不以 \\r 结尾(用户自己按 ⏎)")
func injectedTextNeverEndsWithCarriageReturn() async {
let harness = Harness()
await harness.dictate("npm test")
harness.viewModel.confirm()
await harness.elapseUndoWindow()
#expect(harness.recorder.texts == ["npm test"])
#expect(!(harness.recorder.texts.first ?? "\r").hasSuffix("\r"))
}
@Test("42 · 非 .confirming 态调 confirm() → 无副作用")
func confirmOutsideConfirmingIsNoop() async {
let harness = Harness()
harness.viewModel.confirm()
#expect(harness.viewModel.phase == .idle)
#expect(harness.viewModel.lastResolution == nil)
#expect(harness.recorder.texts.isEmpty)
}
@Test("43 · 重复 confirm() 只 arm 一次、只注入一次")
func repeatedConfirmArmsOnce() async {
let harness = Harness()
await harness.dictate("git status")
harness.viewModel.confirm()
harness.viewModel.confirm()
harness.viewModel.confirm()
await harness.elapseUndoWindow()
#expect(harness.recorder.texts == ["git status"])
}
// MARK: - D. epoch 4448
@Test("44 · 口述→确认之间 epoch 变了 → 零注入 + .staleEpoch")
func epochChangeBeforeConfirmDiscards() async {
let harness = Harness()
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0)
await harness.dictate("git status")
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionB, generation: 0)
harness.viewModel.confirm()
#expect(harness.recorder.texts.isEmpty)
#expect(harness.viewModel.lastResolution == .staleEpoch)
#expect(harness.viewModel.phase == .idle)
}
@Test("45 · epoch 在撤销窗口内变 → 到期仍零注入 + .staleEpoch第二道闸")
func epochChangeInsideUndoWindowDiscards() async {
let harness = Harness()
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0)
await harness.dictate("git status")
harness.viewModel.confirm()
await harness.clock.waitForSleepers(count: 1)
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 1)
harness.clock.advance(by: VoicePTTViewModel.undoWindow)
await harness.viewModel.waitUntilWindowSettled()
#expect(harness.recorder.texts.isEmpty)
#expect(harness.viewModel.lastResolution == .staleEpoch)
}
@Test("46 · epoch 不变 → 正常注入(防闸门过严的回归保护)")
func stableEpochStillInjects() async {
let harness = Harness()
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 3)
await harness.dictate("ls -la")
harness.viewModel.confirm()
await harness.elapseUndoWindow()
#expect(harness.recorder.texts == ["ls -la"])
}
@Test("47 · 口述时 sessionId 未 adopt、确认时已 adopt → 视为变化 → 零注入")
func dictatingBeforeAdoptionDiscardsAfterAdoption() async {
let harness = Harness()
await harness.dictate("git status")
harness.epochBox.epoch = VoiceEpoch(sessionId: Self.sessionA, generation: 0)
harness.viewModel.confirm()
#expect(harness.recorder.texts.isEmpty)
#expect(harness.viewModel.lastResolution == .staleEpoch)
}
@Test("48 · VoiceEpochPolicy.reconnecting 作废 epoch.connecting/.none 不作废")
func epochPolicyInvalidatesOnReconnect() {
#expect(VoiceEpochPolicy.invalidates(
.reconnecting(attempt: 1, next: .seconds(1))
))
#expect(!VoiceEpochPolicy.invalidates(.connecting))
#expect(!VoiceEpochPolicy.invalidates(.none))
let source = VoiceEpochSource()
#expect(source.epoch == VoiceEpoch(sessionId: nil, generation: 0))
source.noteBanner(.reconnecting(attempt: 1, next: .seconds(1)))
source.noteBanner(.connecting)
source.noteSession(Self.sessionA)
#expect(source.epoch == VoiceEpoch(sessionId: Self.sessionA, generation: 1))
}
// MARK: - E. 4952
@Test("49 · 麦克风授权被拒 → .denied(.microphone),零录音零注入")
func microphoneDenialBlocksDictation() async {
let harness = Harness()
harness.dictation.authorization = .deniedMicrophone
await harness.viewModel.pressDown()
#expect(harness.viewModel.phase == .denied(.microphone))
#expect(harness.dictation.startCount == 0)
#expect(harness.recorder.texts.isEmpty)
#expect(VoiceDenialReason.microphone.message.contains("麦克风"))
}
@Test("50 · 语音识别授权被拒 → .denied(.speech),文案指向语音识别开关")
func speechDenialBlocksDictation() async {
let harness = Harness()
harness.dictation.authorization = .deniedSpeech
await harness.viewModel.pressDown()
#expect(harness.viewModel.phase == .denied(.speech))
#expect(harness.dictation.startCount == 0)
#expect(VoiceDenialReason.speech.message.contains("语音识别"))
}
@Test("51 · 识别器抛错 → .failed(message:),零注入,可再按重试")
func recognizerFailureIsRecoverable() async {
struct Boom: Error {}
let harness = Harness()
harness.dictation.startError = Boom()
await harness.viewModel.pressDown()
guard case .failed(let message) = harness.viewModel.phase else {
Issue.record("期望 .failed实际 \(harness.viewModel.phase)")
return
}
#expect(!message.isEmpty)
#expect(harness.recorder.texts.isEmpty)
harness.dictation.startError = nil
await harness.viewModel.pressDown()
#expect(harness.viewModel.phase == .listening(partial: ""))
}
@Test("52 · 终端只读 → pressDown 拒绝,不启动录音")
func readOnlyTerminalRefusesDictation() async {
let harness = Harness(isReadOnly: true)
await harness.viewModel.pressDown()
#expect(harness.viewModel.phase == .idle)
#expect(harness.viewModel.lastResolution == .readOnly)
#expect(harness.dictation.startCount == 0)
}
@Test("PTT 竞态 A录音启动期间松手 → 启动完立刻收尾(麦克风绝不卡在开着)")
func releaseDuringStartStillFinishes() async {
let harness = Harness()
harness.dictation.startGate.isArmed = true
harness.dictation.result = VoiceDictationResult(
transcript: "git status", confidence: nil
)
let down = Task { await harness.viewModel.pressDown() }
await harness.dictation.startGate.waitUntilReached()
let up = Task { await harness.viewModel.pressUp() }
await up.value
harness.dictation.startGate.open()
await down.value
#expect(harness.dictation.stopCount == 1)
#expect(harness.viewModel.phase == .confirming(VoicePendingAction(
intent: .text("git status"),
epoch: VoiceEpoch(sessionId: nil, generation: 0),
preview: "git status"
)))
}
@Test("PTT 竞态 B授权期间就松手 → 麦克风一次都不开(零 start")
func releaseDuringAuthorizationNeverOpensTheMic() async {
let harness = Harness()
harness.dictation.authorizationGate.isArmed = true
let down = Task { await harness.viewModel.pressDown() }
await harness.dictation.authorizationGate.waitUntilReached()
let up = Task { await harness.viewModel.pressUp() }
await up.value
harness.dictation.authorizationGate.open()
await down.value
#expect(harness.dictation.startCount == 0)
#expect(harness.dictation.stopCount == 0)
#expect(harness.viewModel.phase == .idle)
}
// MARK: - F. + gate 5356
@Test("53 · 不提供语音闭包 → 无麦克风键,按钮数不变(零回归)")
func keyBarWithoutVoiceHandlersIsUnchanged() {
let bar = KeyBarView()
#expect(bar.voiceButton == nil)
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
}
@Test("54 · 提供语音闭包 → 末尾多一个 🎤 键,前 17 键顺序/标签完全不变")
func keyBarAppendsMicWithoutDisturbingExistingKeys() {
let bar = KeyBarView(voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}))
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
#expect(bar.keyButtons.map(\.accessibilityLabel)
== KeyBarLayout.buttons.map(\.title))
let mic = bar.voiceButton
#expect(mic != nil)
#expect(mic?.accessibilityLabel == KeyBarLayout.voiceTitle)
}
@Test("55 · 麦克风键 touchDown/touchUpInside 各触发一次,绝不经 onKey")
func micButtonRoutesPressAndReleaseOnly() throws {
var down = 0
var up = 0
var keys: [KeyByteMap.Key] = []
let bar = KeyBarView(voice: KeyBarVoiceHandlers(
onDown: { down += 1 },
onUp: { up += 1 }
))
bar.onKey = { key in keys = keys + [key] }
let mic = try #require(bar.voiceButton)
mic.sendActions(for: .touchDown)
#expect((down, up) == (1, 0))
mic.sendActions(for: .touchUpInside)
#expect((down, up) == (1, 1))
#expect(keys.isEmpty)
}
@Test("56 · 匹配到 approve → 同一 1.5s 窗口,到期调 decide(.approve) 而不注入文本")
func approveIntentGoesThroughTheSameWindow() async {
let harness = Harness(gate: VoiceGateSnapshot(pendingApproval: true, gate: .tool))
await harness.dictate("确认", confidence: 0.9)
#expect(harness.viewModel.phase == .confirming(VoicePendingAction(
intent: .decision(.approve),
epoch: VoiceEpoch(sessionId: nil, generation: 0),
preview: "确认"
)))
harness.viewModel.confirm()
await harness.elapseUndoWindow()
#expect(harness.recorder.decisions == [.approve])
#expect(harness.recorder.texts.isEmpty)
#expect(harness.viewModel.lastResolution == .committed(.decision(.approve)))
}
}

View File

@@ -0,0 +1,391 @@
import APIClient
import Foundation
import Testing
@testable import WebTerm
/// T-iOS-32 · worktree create / prune / remove App
///
/// RED `docs/plans/ios-completion.md` §4A 19B 1017C 1821D 2227
/// APIClient builder// B1 125 tests
/// VM
@MainActor
@Suite("WorktreeViewModel")
struct WorktreeViewModelTests {
// MARK: - A. public/projects.ts:982 validateBranchNameClient
@Test("空分支名 → 报错")
func emptyBranchRejected() {
#expect(WorktreeBranchRule.validate("") != nil)
}
@Test("长度250 通过、251 报错")
func lengthBoundary() {
let ok = String(repeating: "a", count: 250)
#expect(WorktreeBranchRule.validate(ok) == nil)
#expect(WorktreeBranchRule.validate(ok + "a") != nil)
}
@Test(
"非法形态一律报错(空格/控制字符/前导-/../.lock/特殊字符/@{/斜杠误用)",
arguments: [
"feat x", "feat\tx", "feat\u{0}x", "feat\u{7f}x",
"-feat", "feat..x", "feat.lock",
"feat~x", "feat^x", "feat:x", "feat?x", "feat*x", "feat[x", "feat\\x",
"feat@{1}", "/feat", "feat/", "feat//x",
]
)
func invalidShapesRejected(branch: String) {
#expect(WorktreeBranchRule.validate(branch) != nil, "\(branch) 应被拒绝")
}
@Test("合法分支名通过", arguments: ["feat/x", "v1.2-fix", "worktree-ios_32"])
func validNamesAccepted(branch: String) {
#expect(WorktreeBranchRule.validate(branch) == nil)
}
// MARK: - B.
@Test("非法分支名 → 一次请求都不发(校验在边界,先于网络)")
func invalidBranchSkipsNetwork() async {
let spy = WorktreeCallSpy()
let vm = Self.makeViewModel(spy: spy)
vm.branchText = "-bad"
await vm.create()
#expect(await spy.createCalls == 0)
#expect(vm.createPhase != .creating)
if case .failed(let message) = vm.createPhase {
#expect(!message.isEmpty)
} else {
Issue.record("应为 .failed实际 \(vm.createPhase)")
}
}
@Test("成功 → 采用服务器返回的 canonical path/branch不回显本地输入")
func createAdoptsServerTruth() async {
let spy = WorktreeCallSpy()
let vm = Self.makeViewModel(
spy: spy,
create: { _, _ in
.ok(CreateWorktreeResult(path: "/repos/wt/feat-x", branch: "feat/x"))
}
)
vm.branchText = "feat/x"
await vm.create()
#expect(vm.createPhase == .created(path: "/repos/wt/feat-x", branch: "feat/x"))
}
@Test("base 留空 → 请求体 base 为 nil服务器读作「从 HEAD 切」,绝不送空串)")
func blankBaseBecomesNil() async {
let spy = WorktreeCallSpy()
let vm = Self.makeViewModel(spy: spy)
vm.branchText = "feat/x"
vm.baseText = " "
await vm.create()
#expect(await spy.lastBase == nil)
}
@Test("填了 base → 去空白后原样送出")
func trimmedBaseIsSent() async {
let spy = WorktreeCallSpy()
let vm = Self.makeViewModel(spy: spy)
vm.branchText = "feat/x"
vm.baseText = " develop "
await vm.create()
#expect(await spy.lastBase == "develop")
}
@Test("403kill-switch 或 Origin→ 原样显示服务器安全文案")
func rejectionSurfacesServerMessage() async {
let vm = Self.makeViewModel(
spy: WorktreeCallSpy(),
create: { _, _ in .rejected(status: 403, message: "Worktrees are disabled.") }
)
vm.branchText = "feat/x"
await vm.create()
#expect(vm.createPhase == .failed("Worktrees are disabled."))
}
@Test("500 且服务器没给 message → 非空兜底中文文案")
func rejectionWithoutMessageFallsBack() async {
let vm = Self.makeViewModel(
spy: WorktreeCallSpy(),
create: { _, _ in .rejected(status: 500, message: nil) }
)
vm.branchText = "feat/x"
await vm.create()
if case .failed(let message) = vm.createPhase {
#expect(!message.isEmpty)
#expect(message.contains("500"))
} else {
Issue.record("应为 .failed实际 \(vm.createPhase)")
}
}
@Test("429 → 限流专用文案,且不自动重试(只发一次)")
func rateLimitedIsNotRetried() async {
let spy = WorktreeCallSpy()
let vm = Self.makeViewModel(spy: spy, create: { _, _ in .rateLimited })
vm.branchText = "feat/x"
await vm.create()
#expect(await spy.createCalls == 1)
#expect(vm.createPhase == .failed(GitWriteFeedback.rateLimited))
}
@Test("抛出 .unauthorized → 引导补令牌的话术(与 500 区分)")
func unauthorizedUsesTokenCopy() async {
let vm = Self.makeViewModel(
spy: WorktreeCallSpy(),
create: { _, _ in throw APIClientError.unauthorized }
)
vm.branchText = "feat/x"
await vm.create()
#expect(vm.createPhase == .failed(APIClientError.unauthorized.message))
}
@Test("创建进行中重复提交 → 只发一次请求")
func duplicateCreateSendsOneRequest() async {
let gate = WorktreeGate()
let spy = WorktreeCallSpy()
let vm = Self.makeViewModel(
spy: spy,
create: { _, _ in
await gate.wait()
return .ok(CreateWorktreeResult(path: "/repos/wt", branch: "feat/x"))
}
)
vm.branchText = "feat/x"
let first = Task { await vm.create() }
await Self.spin(until: { vm.createPhase == .creating })
await vm.create() //
#expect(await spy.createCalls == 1)
await gate.release()
await first.value
}
// MARK: - C. prune VM
@Test("pruned 为空 → 幂等文案,非错误态")
func pruneEmptyIsIdempotent() async {
let vm = Self.makeViewModel(spy: WorktreeCallSpy(), prune: { .ok(PruneWorktreesResult(pruned: [])) })
await vm.prune()
#expect(vm.prunePhase == .done(WorktreeCopy.pruneNothing))
}
@Test("pruned 有 2 条 → 文案带数量")
func pruneReportsCount() async {
let vm = Self.makeViewModel(
spy: WorktreeCallSpy(),
prune: { .ok(PruneWorktreesResult(pruned: ["worktrees/a", "worktrees/b"])) }
)
await vm.prune()
#expect(vm.prunePhase == .done(WorktreeCopy.pruneDone(2)))
}
@Test("prune 404 → 显示服务器文案")
func pruneRejectionSurfacesMessage() async {
let vm = Self.makeViewModel(
spy: WorktreeCallSpy(),
prune: { .rejected(status: 404, message: "Not a git repository.") }
)
await vm.prune()
#expect(vm.prunePhase == .failed("Not a git repository."))
}
// MARK: - D. remove
@Test("主 worktree / 已锁定 worktree → 不提供删除入口")
func mainAndLockedAreNotRemovable() {
let main = WorktreeInfo(
path: "/repos/r", branch: "develop", head: "a", isMain: true,
isCurrent: true, locked: nil, prunable: nil
)
let locked = WorktreeInfo(
path: "/repos/wt", branch: "feat/x", head: "b", isMain: false,
isCurrent: false, locked: true, prunable: nil
)
let plain = WorktreeInfo(
path: "/repos/wt2", branch: "feat/y", head: "c", isMain: false,
isCurrent: false, locked: false, prunable: true
)
#expect(!WorktreeViewModel.canRemove(main))
#expect(!WorktreeViewModel.canRemove(locked))
#expect(WorktreeViewModel.canRemove(plain))
}
@Test("第一次确认 → force: false")
func firstRemoveIsNotForced() async {
let spy = WorktreeCallSpy()
let vm = Self.makeViewModel(spy: spy, remove: { _, _ in .ok(RemoveWorktreeResult(path: "/repos/wt")) })
await vm.remove(worktreePath: "/repos/wt")
#expect(await spy.removeForceFlags == [false])
#expect(vm.removePhase == .removed(path: "/repos/wt"))
}
@Test("409脏工作树→ 进入二次确认,绝不自动 force")
func dirtyTreeAsksBeforeForcing() async {
let spy = WorktreeCallSpy()
let vm = Self.makeViewModel(
spy: spy,
remove: { _, force in
force
? .ok(RemoveWorktreeResult(path: "/repos/wt"))
: .rejected(status: 409, message: "Worktree has uncommitted changes — force required.")
}
)
await vm.remove(worktreePath: "/repos/wt")
#expect(await spy.removeForceFlags == [false])
#expect(vm.removePhase == .forceConfirming(
path: "/repos/wt",
message: "Worktree has uncommitted changes — force required."
))
}
@Test("二次确认取消 → 不发第二次请求")
func cancellingForceSendsNothing() async {
let spy = WorktreeCallSpy()
let vm = Self.makeViewModel(
spy: spy,
remove: { _, _ in .rejected(status: 409, message: "dirty") }
)
await vm.remove(worktreePath: "/repos/wt")
vm.cancelForceRemove()
#expect(await spy.removeForceFlags == [false])
#expect(vm.removePhase == .idle)
}
@Test("二次确认通过 → 第二次请求 force: true")
func confirmingForceRetriesWithForce() async {
let spy = WorktreeCallSpy()
let vm = Self.makeViewModel(
spy: spy,
remove: { _, force in
force
? .ok(RemoveWorktreeResult(path: "/repos/wt"))
: .rejected(status: 409, message: "dirty")
}
)
await vm.remove(worktreePath: "/repos/wt")
await vm.confirmForceRemove()
#expect(await spy.removeForceFlags == [false, true])
#expect(vm.removePhase == .removed(path: "/repos/wt"))
}
@Test("400非 409→ 直接失败,不进 force 分支")
func nonConflictRejectionNeverOffersForce() async {
let vm = Self.makeViewModel(
spy: WorktreeCallSpy(),
remove: { _, _ in .rejected(status: 400, message: "Cannot remove the main worktree.") }
)
await vm.remove(worktreePath: "/repos/r")
#expect(vm.removePhase == .failed("Cannot remove the main worktree."))
}
// MARK: - Fixtures
private static func makeViewModel(
spy: WorktreeCallSpy,
create: (@Sendable (String, String?) async throws -> GitWriteOutcome<CreateWorktreeResult>)? = nil,
prune: (@Sendable () async throws -> GitWriteOutcome<PruneWorktreesResult>)? = nil,
remove: (@Sendable (String, Bool) async throws -> GitWriteOutcome<RemoveWorktreeResult>)? = nil
) -> WorktreeViewModel {
WorktreeViewModel(dependencies: WorktreeViewModel.Dependencies(
create: { branch, base in
await spy.recordCreate(base: base)
if let create { return try await create(branch, base) }
return .ok(CreateWorktreeResult(path: "/repos/wt", branch: branch))
},
prune: {
await spy.recordPrune()
if let prune { return try await prune() }
return .ok(PruneWorktreesResult(pruned: []))
},
remove: { path, force in
await spy.recordRemove(force: force)
if let remove { return try await remove(path, force) }
return .ok(RemoveWorktreeResult(path: path))
}
))
}
/// MainActor
private static func spin(
until condition: @MainActor () -> Bool, maxYields: Int = 1_000
) async {
for _ in 0..<maxYields where !condition() {
await Task.yield()
}
}
}
/// @Sendable actor
private actor WorktreeCallSpy {
private(set) var createCalls = 0
private(set) var lastBase: String?
private(set) var pruneCalls = 0
private(set) var removeForceFlags: [Bool] = []
func recordCreate(base: String?) {
createCalls += 1
lastBase = base
}
func recordPrune() {
pruneCalls += 1
}
func recordRemove(force: Bool) {
removeForceFlags.append(force)
}
}
/// 便
private actor WorktreeGate {
private var waiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
await withCheckedContinuation { waiters.append($0) }
}
func release() {
let pending = waiters
waiters = []
pending.forEach { $0.resume() }
}
}

View File

@@ -28,21 +28,33 @@ import WireProtocol
/// deadline (the transport's own timeouts still apply). The production
/// default is a UI-layer decision (T-iOS-12); a shared
/// `Tunables.pairingProbeTimeout` would be a T-iOS-3 contract addition.
/// - accessToken: candidate `WEBTERM_TOKEN` for a host that gates its HTTP
/// surface (C1 over B1, ios-completion §1.1). It is stamped as
/// `Cookie: webterm_auth=` on the probe's TWO HTTP legs by `APIClient`;
/// the WS leg's cookie comes from the transport the caller passes (the
/// pairing flow builds a probe-scoped transport carrying the same
/// candidate), because `TermTransport.connect` is a frozen contract with no
/// credential parameter. `nil` = probe unauthenticated (the LAN default).
func runPairingProbeCore(
endpoint: HostEndpoint,
http: any HTTPTransport,
ws: any TermTransport,
clock: any Clock<Duration>,
timeout: Duration?
timeout: Duration?,
accessToken: String? = nil
) async -> Result<HostEndpoint, PairingError> {
guard let timeout else {
return await performProbe(endpoint: endpoint, http: http, ws: ws)
return await performProbe(
endpoint: endpoint, http: http, ws: ws, accessToken: accessToken
)
}
return await withTaskGroup(
of: Result<HostEndpoint, PairingError>.self
) { group in
group.addTask {
await performProbe(endpoint: endpoint, http: http, ws: ws)
await performProbe(
endpoint: endpoint, http: http, ws: ws, accessToken: accessToken
)
}
group.addTask {
// Cancellation (probe won) also lands here; the value is discarded.
@@ -63,14 +75,16 @@ func runPairingProbeCore(
public func runPairingProbe(
endpoint: HostEndpoint,
http: any HTTPTransport,
ws: any TermTransport
ws: any TermTransport,
accessToken: String? = nil
) async -> Result<HostEndpoint, PairingError> {
await runPairingProbeCore(
endpoint: endpoint,
http: http,
ws: ws,
clock: ContinuousClock(),
timeout: Tunables.pairingProbeTimeout
timeout: Tunables.pairingProbeTimeout,
accessToken: accessToken
)
}
@@ -79,23 +93,31 @@ public func runPairingProbe(
private func performProbe(
endpoint: HostEndpoint,
http: any HTTPTransport,
ws: any TermTransport
ws: any TermTransport,
accessToken: String?
) async -> Result<HostEndpoint, PairingError> {
let api = APIClient(endpoint: endpoint, http: http)
let api = APIClient(endpoint: endpoint, http: http, accessToken: accessToken)
// Reachability + shape. Any HTTP-level answer that isn't the
// /live-sessions array shape means "some other service" ?
do {
_ = try await api.liveSessions()
} catch APIClientError.unauthorized {
// C1 · 401 on the RO leg is NOT "some other service": this host gates
// its HTTP surface and our candidate token was absent or wrong. The
// status alone cannot say which of the two gates rejected us, so the
// copy offers both remedies (see `unauthorizedPairingHint`).
return .failure(.originRejected(hint: unauthorizedPairingHint(for: endpoint)))
} catch is APIClientError {
return .failure(.httpOkButNotWebTerminal)
} catch {
return .failure(PairingError.classify(error, endpoint: endpoint))
}
// WS upgrade the server's ONLY upgrade-reject path is the Origin 401
// (src/server.ts:646-651), so after passed, an unrecognizable connect
// failure is classified as originRejected.
// WS upgrade the server rejects an upgrade with 401 from TWO gates:
// the Origin/CSWSH check and then the `webterm_auth` cookie
// (src/server.ts:1367-1379). After passed, an unrecognizable connect
// failure is one of those two, so the fallback names both.
let connection: TransportConnection
do {
connection = try await ws.connect(to: endpoint)
@@ -103,7 +125,7 @@ private func performProbe(
return .failure(PairingError.classify(
error, endpoint: endpoint,
unrecognizedFallback: .originRejected(
hint: PairingError.originRejectedHint(for: endpoint)
hint: unauthorizedPairingHint(for: endpoint)
)
))
}
@@ -152,9 +174,14 @@ private func killProbeSession(
} catch APIClientError.sessionNotFound {
// Already gone (exited between attach and kill) the goal state.
} catch APIClientError.forbidden {
// 403 is UNAMBIGUOUS: only the guarded-HTTP Origin check answers 403
// (src/server.ts:332-339) the token gate answers 401. So this one
// keeps the pure Origin copy.
return .failure(.originRejected(
hint: PairingError.originRejectedHint(for: endpoint)
))
} catch APIClientError.unauthorized {
return .failure(.originRejected(hint: unauthorizedPairingHint(for: endpoint)))
} catch let apiError as APIClientError {
return .failure(.hostUnreachable(underlying: apiError.message))
} catch {
@@ -162,3 +189,27 @@ private func killProbeSession(
}
return .success(endpoint)
}
// MARK: - The ambiguous 401 (C1 · fixes the pre-token "Origin rejected" verdict)
/// Copy for a **401** met during pairing.
///
/// Before the access token existed, 401 had exactly one cause on this path, so
/// the probe reported `originRejected` with Origin-only copy. With
/// `WEBTERM_TOKEN` live there are TWO causes and one status code: the server
/// checks Origin first and the `webterm_auth` cookie second both write 401
/// (src/server.ts:1367-1379; the RO HTTP gate likewise, src/server.ts:459).
/// A client provably cannot tell them apart, so guessing one remedy sends half
/// the users chasing the wrong knob. Both are named, token first (it is the
/// one the user can fix from the phone).
///
/// The `ALLOWED_ORIGINS=` value is still derived from `endpoint.originHeader`
/// the single point (plan §5.1), never hand-assembled, default ports omitted.
/// The error case stays `originRejected` because `PairingError` is a frozen
/// contract (§3.4) and its payload is exactly "the hint the UI shows verbatim".
func unauthorizedPairingHint(for endpoint: HostEndpoint) -> String {
"主机以 401 拒绝了这次配对,而两种原因会得到同一个状态码:"
+ "① 该主机启用了访问令牌WEBTERM_TOKEN本次配对没带或带错了令牌——请输入访问令牌后重试"
+ "② 主机的来源白名单不含本 App 拨号的地址——请在主机上设置 "
+ "ALLOWED_ORIGINS=\(endpoint.originHeader)(与 App 连接的 URL 完全一致)后重启 web-terminal。"
}

View File

@@ -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=<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,
@@ -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

View File

@@ -52,19 +52,24 @@ packages:
#
# PINNED, not floating (`from:`). The generated .xcodeproj — and with it
# Package.resolved, which lives inside the bundle — is gitignored, so every
# agent/CI run re-resolves from scratch: `from: 1.13.0` silently drifted to
# 1.15.0 and broke the App target, because SwiftTerm v1.14.0 added
# `public var hasActiveSelection` to iOSTerminalView, colliding with the
# same-named property TerminalScreen.swift:328 declares on its subclass
# (`error: overriding non-open property outside of its defining module` —
# unfixable with `override`, since SwiftTerm's is public, not open).
# 1.13.0 is the newest tag whose API the current App source compiles against.
# To raise the pin: first drop TerminalScreen.swift's local
# `hasActiveSelection` and use SwiftTerm's public one (that file is owned by
# the terminal-screen task, not by project.yml).
# agent/CI run re-resolves from scratch, and a floating `from:` would silently
# drift to whatever is newest. An exact pin makes every agent/CI run resolve
# the SAME source (the earlier drift 1.13.0 → 1.15.0 is what broke the App
# target); raising it is a deliberate, verified edit.
#
# History (T-iOS-33/31 root fix): v1.14.0 added `public var hasActiveSelection`
# to iOSTerminalView, which collided with a same-named property the App
# declared on its `TerminalView` subclass — and `override` cannot fix it,
# since upstream's is `public`, not `open`. The fix was to DROP the local
# property and use upstream's (identical semantics: `selection?.active ??
# false`, vs. the local `canPerformAction(copy)` which SwiftTerm answers from
# the same flag). Done in TerminalScreen.swift, so the pin now rides at
# 1.15.0, which also brings `searchMatchSummary` (a "2/14" match counter the
# find bar can adopt later — deliberately not consumed yet, so the pin stays
# revertible).
SwiftTerm:
url: https://github.com/migueldeicaza/SwiftTerm
exactVersion: 1.13.0
exactVersion: 1.15.0
targets:
WebTerm: