diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..21978bc --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,184 @@ +# Android CI (F2). Until this file existed the Android client had ZERO CI — 10 modules, +# 691 JVM tests and the whole `WEBTERM_TOKEN` secret-handling wave (Tink AEAD under an +# AndroidKeystore master key, the POST /auth probe, cookie stamping on every route and on +# the WS upgrade) were gated by nothing at all. Modelled on ios.yml: same `on:` shape +# (push + pull_request, path-filtered), same "one job per verification layer" structure, +# and — like ios.yml — deliberately NO `concurrency:` group, so the two workflows behave +# the same way on rapid pushes. +# +# ── HONESTY NOTE (read before trusting a green badge) ──────────────────────────────────── +# This repository's only remote is a self-hosted Gitea instance, so GitHub Actions has +# NEVER run here and this file has never been executed by the platform. What IS verified: +# every command below was run locally on macOS against the same Gradle build +# (`./gradlew test :app:assembleDebug koverVerify koverLog` + the androidTest compile), +# and the YAML parses. What is NOT verified: the runner-side pieces — action resolution, +# the preinstalled Android SDK layout on `ubuntu-latest`, and the emulator leg. +# +# ── Why no `src/**` path trigger (ios.yml HAS one) ─────────────────────────────────────── +# ios.yml watches `src/**` because it owns `integration-tests`, a Swift layer that boots the +# real Node server and would catch client↔server protocol drift. Android has no equivalent +# leg (android/README.md, "DEFERRED — end-to-end access token": nothing here starts a server +# with WEBTERM_TOKEN set and completes a cookie-gated WS upgrade). Triggering on `src/**` +# would therefore burn runner minutes without checking a single contract. When an Android +# server-contract leg is added, add `src/**` here at the same time. +name: android + +on: + push: + paths: + - "android/**" + - ".github/workflows/android.yml" + pull_request: + paths: + - "android/**" + - ".github/workflows/android.yml" + # Manual trigger for the instrumented (device) leg below, which is dispatch-only. + workflow_dispatch: + +jobs: + # Layer 1 — everything that runs without a device. This is exactly the green gate + # android/README.md documents ("./gradlew test :app:assembleDebug koverVerify"), plus a + # compile-only pass over the instrumented sources so they cannot silently rot. + build-and-test: + runs-on: ubuntu-latest + timeout-minutes: 45 + defaults: + run: + working-directory: android + steps: + - uses: actions/checkout@v4 + + # jvmToolchain(17) is declared by every module; Gradle resolves it from the + # installed JDKs, so 17 must actually be present (the runner image's default may + # be a different major). + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: gradle + + # :app/:terminal-view/:host-registry/:client-tls-android compile against SDK 36 with + # build-tools 36.0.0 (android/README.md "Android SDK setup"). The runner image ships an + # Android SDK, but which platforms it holds drifts image to image — so install what the + # build actually needs instead of assuming, and FAIL LOUDLY if there is no sdkmanager. + - name: Install the Android SDK packages this build compiles against + run: | + # `set -eu` WITHOUT pipefail on purpose: `yes | sdkmanager --licenses` kills `yes` + # with SIGPIPE (141) as soon as the prompts stop, and pipefail would report that + # normal ending as a failed step. + set -eu + sdkmanager_bin="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + if [ ! -x "$sdkmanager_bin" ]; then + sdkmanager_bin="$(ls -1 "$ANDROID_HOME"/cmdline-tools/*/bin/sdkmanager 2>/dev/null | tail -n 1 || true)" + fi + if [ ! -x "$sdkmanager_bin" ]; then + echo "::error title=No sdkmanager::none found under $ANDROID_HOME/cmdline-tools — the Android SDK is not usable on this runner" >&2 + exit 1 + fi + yes | "$sdkmanager_bin" --licenses > /dev/null + "$sdkmanager_bin" "platforms;android-36" "build-tools;36.0.0" "platform-tools" + + # 10 modules of JVM unit tests (JUnit 5 + coroutines-test + Turbine + MockK). Includes + # the Android-library modules' testDebugUnitTest and :app's — no device needed. + - name: JVM unit tests (all modules) + run: ./gradlew test + + # The APK build is its own step: a Kotlin/KSP/Hilt/Compose breakage that unit tests do + # not reach (resources, manifest merge, DI graph assembly) must not hide behind them. + - name: Debug APK builds + run: ./gradlew :app:assembleDebug + + # Coverage floor: >=80% line coverage on the four modules that apply the Kover plugin + # (:wire-protocol, :session-core, :api-client, :client-tls). koverLog prints the actual + # percentages into the run log so a near-miss is visible before it becomes a failure. + # The Android-framework modules are NOT gated (no Kover on them) — a known gap. + - name: Coverage gate (Kover, >= 80% on the four gated modules) + run: ./gradlew koverVerify koverLog + + # The instrumented sources cannot RUN here, but they must keep COMPILING. Three modules + # carry them, 119 @Test in total: `:app` (67 — the device-blocker suite: key routing, + # resize, alternate-screen scroll, autofill, driven through a custom HiltTestRunner), + # `:host-registry` (31 — incl. TinkAccessTokenStoreTest, the only assertion anywhere that + # the access token is ciphertext at rest) and `:client-tls-android` (21 — AndroidKeyStore + # import). A silent compile break would delete all of that without anyone noticing. + - name: Instrumented test sources still compile + run: ./gradlew :app:assembleDebugAndroidTest :host-registry:assembleDebugAndroidTest :client-tls-android:assembleDebugAndroidTest + + - name: Upload test + coverage reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: android-reports + path: | + android/*/build/reports/tests/** + android/*/build/test-results/** + android/*/build/reports/kover/** + retention-days: 7 + if-no-files-found: ignore + + # Layer 2 — instrumented (device) tests. DISPATCH-ONLY, on purpose. + # + # What is at stake: `TinkAccessTokenStoreTest` is the ONLY test anywhere that proves the + # frozen at-rest properties of the access token (ios-completion §1.1) — that a fresh store + # instance decrypts it after a cold start, and that the bytes on disk are ciphertext rather + # than the token in the clear. Tink's AEAD + the AndroidKeystore-wrapped master key have no + # JVM double, so this can only run on a device or emulator. + # + # The suite itself is NOT unproven: the android-blocker-fixes session ran `:app`'s 67 + # instrumented tests green on real hardware. What has never been proven is this leg — the + # CI *emulator* path. Those are different claims and only the second one gates this `if:`. + # + # Why it is not on push: this workflow has never executed on any runner (see the honesty + # note at the top), and an emulator leg is the single most fragile thing in Android CI — + # it needs KVM, a system image download, and an AVD boot. Wiring an unvalidated, ~15-minute, + # KVM-dependent job into every push would either block the repo on a leg nobody has seen + # pass, or push people to make it non-blocking — and a leg that cannot fail is a false green + # (the same trap ios.yml's `ios17-floor-tests` comment calls out). + # + # THEREFORE: run it by hand from the Actions tab (workflow_dispatch) whenever the token + # store, the DataStore stores, the AndroidKeyStore importer, or the terminal view/input + # path change. Once it has been seen green on a real runner, promote it by deleting the + # `if:` below — the suite has already earned it on hardware. + # + # MANUAL EQUIVALENT (the path that has actually been walked — DEVICE_QA_CHECKLIST.md): + # with a device/emulator attached, + # cd android && ANDROID_HOME= ./gradlew :app:connectedDebugAndroidTest \ + # :host-registry:connectedDebugAndroidTest \ + # :client-tls-android:connectedDebugAndroidTest + # (StrongBox falls back to software keys on an emulator, so hardware-backed key claims still + # need real hardware.) + instrumented-tests: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: gradle + - name: Enable KVM for the emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + # API 29 == the app's minSdk: the deployment floor is where at-rest crypto behaviour is + # most likely to differ (mirrors ios.yml's iOS 17 floor leg). + - name: connectedDebugAndroidTest on an API 29 emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 29 + target: google_apis + arch: x86_64 + working-directory: android + script: ./gradlew :app:connectedDebugAndroidTest :host-registry:connectedDebugAndroidTest :client-tls-android:connectedDebugAndroidTest + - name: Upload instrumented reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-instrumented-reports + path: android/*/build/reports/androidTests/** + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index e6c3090..3593edf 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -6,8 +6,9 @@ # the test binary (a known flaw, fix assigned to T-iOS-16). The corrected # per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh # (jq keeps only Packages/

/Sources/, excluding *Placeholder*). -# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle, -# iPhone 16 simulator): ViewModels/components of the app glue layer. +# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle) on +# an iPhone AND an iPad simulator: ViewModels/components of the app glue +# layer, on both the compact and the regular size class. # 3. integration-tests — Swift Testing against the REAL Node server. The # ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral # loopback port (127.0.0.1: is always in the derived Origin @@ -35,14 +36,15 @@ on: jobs: # Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate. - # The gate covers exactly the 4 gated packages (plan §9); TestSupport runs - # tests below without a gate (test doubles are excluded from the gate). + # The gate covers the 4 packages of plan §9 PLUS ClientTLS (added by B4 — the + # mTLS identity/keychain package, previously the only ungated one at 55.76%). + # TestSupport runs tests below without a gate (test doubles are excluded). package-tests: runs-on: macos-15 strategy: fail-fast: false matrix: - package: [WireProtocol, SessionCore, HostRegistry, APIClient] + package: [WireProtocol, SessionCore, HostRegistry, APIClient, ClientTLS] steps: - uses: actions/checkout@v4 - name: Select Xcode 16.3 @@ -56,47 +58,66 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode 16.3 run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer - - name: swift test (TestSupport — no coverage gate, plan §9 gates 4 packages) + - name: swift test (TestSupport — no coverage gate; it IS the test doubles) run: swift test --package-path ios/Packages/TestSupport - # Layer 2: app-target unit tests (WebTermTests, hosted by the app). + # Layer 2: app-target unit tests (WebTermTests, hosted by the app), on the + # compact iPhone path AND the regular iPad path (T-iPad-1: the adaptive + # NavigationSplitView layout of T-iPad-2 must be exercised in CI, not only + # compact). One matrix instead of two near-identical jobs. + # + # THE NODE DEPENDENCY (B4 fix): the WebTermTests bundle contains + # LiveServerSmokeTests, whose SimServerHarness spawns + # `node_modules/.bin/tsx src/server.ts`. On a bare checkout there is no + # node_modules, so the harness throws + # setup: 找不到 …/node_modules/.bin/tsx — 先在 repo 根目录跑 npm install/npm ci + # and the test HARD-FAILS (it is not a skip) — i.e. both legs were red on every + # run. Fix: `npm ci` on the canonical iPhone leg, which is the one that owns the + # live-server smoke; the iPad leg SKIPS that suite instead of paying a second + # node-pty native build. Rationale: the iPad leg exists to exercise the app's + # regular-width layout, not to re-verify the client↔server contract, which is + # already covered three times over (this leg, integration-tests, ui-test). + # `-skip-testing` (rather than a test plan) keeps the change inside this file: + # test plans live in ios/project.yml, owned by another task. app-tests: runs-on: macos-15 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - leg: iphone + device: iPhone 16 + needsNode: true + extraArgs: "" + - leg: ipad + device: iPad Pro 11-inch (M4) + needsNode: false + extraArgs: "-skip-testing:WebTermTests/LiveServerSmokeTests" steps: - uses: actions/checkout@v4 - name: Select Xcode 16.3 run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer + - uses: actions/setup-node@v4 + if: matrix.needsNode + with: + node-version: 22 + - name: npm ci (LiveServerSmokeTests spawns node_modules/.bin/tsx) + if: matrix.needsNode + run: npm ci - name: Install XcodeGen run: brew install xcodegen - name: Generate project run: cd ios && xcodegen generate - - name: xcodebuild test (WebTermTests, iPhone 16 simulator) + - name: xcodebuild test (WebTermTests, ${{ matrix.device }} simulator) # No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the # real data-protection keychain, which returns -34018 for UNSIGNED test # hosts; simulator ad-hoc signing needs no certificates. (W5-fix # handoff finding, verified locally in the ui-test leg runs.) run: | xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ - test - - # iPad adaptation (T-iPad-1): run the same app suite on an iPad simulator so - # the adaptive layout (regular size class / NavigationSplitView, T-iPad-2) is - # exercised in CI, not only the compact iPhone path. - ipad-tests: - runs-on: macos-15 - steps: - - uses: actions/checkout@v4 - - name: Select Xcode 16.3 - run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer - - name: Install XcodeGen - run: brew install xcodegen - - name: Generate project - run: cd ios && xcodegen generate - - name: xcodebuild test (WebTermTests, iPad Pro 11-inch simulator) - run: | - xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \ - -destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' \ + -destination 'platform=iOS Simulator,name=${{ matrix.device }}' \ + ${{ matrix.extraArgs }} \ test # Layer 3: contract tests against the real Node server (T-iOS-16 test list). @@ -121,9 +142,27 @@ jobs: # runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's # TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the # server (/live-sessions, /live-sessions/:id/preview, held /hook/permission). + # + # iPad leg (B4 fix): ProjectsLayoutUITests has an explicit `isPad` branch + # (landscape + NavigationSplitView sidebar reveal, T-iPad-4) that NEVER ran — + # the only UI leg was iPhone. It now runs on an iPad simulator too. Only THAT + # suite: HappyPathUITests has no regular-width branch at all (no sidebar + # reveal), so running it on iPad would fail for a missing-feature reason rather + # than a regression — making the happy path iPad-adaptive is app-layer work, + # tracked separately. ui-test: runs-on: macos-15 timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - leg: iphone + device: iPhone 16 + suites: "" + - leg: ipad + device: iPad Pro 11-inch (M4) + suites: "-only-testing:WebTermUITests/ProjectsLayoutUITests" steps: - uses: actions/checkout@v4 - name: Select Xcode 16.3 @@ -159,7 +198,7 @@ jobs: # inputAccessoryView — it only exists while the SOFT keyboard is up. - name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard) run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false - - name: xcodebuild test (WebTermUITests, iPhone 16 simulator) + - name: xcodebuild test (WebTermUITests, ${{ matrix.device }} simulator) # TEST_RUNNER_ must be an ENV VAR of the xcodebuild process (it # strips the prefix and injects into the test-runner process); # passing it as a KEY=VALUE argument makes it a build setting, which @@ -172,7 +211,8 @@ jobs: TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217 run: | xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \ - -destination 'platform=iOS Simulator,name=iPhone 16' test + -destination 'platform=iOS Simulator,name=${{ matrix.device }}' \ + ${{ matrix.suites }} test - name: Server log + shutdown if: always() run: | @@ -181,15 +221,21 @@ jobs: # Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the # WebTermTests unit bundle on an iOS 17.x simulator runtime. - # CI-ONLY LEG: GitHub macOS runner images ship older Xcode versions whose - # iOS 17.x simulator runtime is registered system-wide with CoreSimulator, so - # Xcode 16.x can build against it. Local machines are NOT expected to - # download the ~8 GB runtime. If the runner image drops the 17.x runtime, - # the leg skips WITH A LOUD NOTICE; if the runtime exists, test failures - # fail the job (no silent pass). + # + # CI-ONLY LEG: local machines are NOT expected to hold the ~7 GB iOS 17 + # runtime, so this leg is never reproducible on a dev box. + # + # NO SILENT SKIP (B4 fix): this step used to `exit 0` with a ::notice when the + # runner image had no iOS 17.x runtime, and the test step was gated on + # `if: steps.sim17.outputs.runtime != ''` — so on image drift the whole + # deployment-floor round vanished and the job still reported GREEN. A leg that + # can silently stop testing is worse than no leg. Now: if the runtime is + # missing, it is DOWNLOADED (`xcodebuild -downloadPlatform iOS + # -buildVersion`); if the download does not produce a usable runtime, the job + # FAILS. The test step is unconditional. ios17-floor-tests: runs-on: macos-15 - timeout-minutes: 45 + timeout-minutes: 90 # the runtime download alone can take ~15 min steps: - uses: actions/checkout@v4 - name: Select Xcode 16.3 (build toolchain) @@ -198,23 +244,40 @@ jobs: run: brew install xcodegen - name: Generate project run: cd ios && xcodegen generate - - name: Create iOS 17.x simulator (skip-with-notice if runtime absent) + - name: Ensure an iOS 17.x simulator runtime (download if the image lacks it) id: sim17 + env: + IOS17_FALLBACK_VERSION: "17.5" run: | - runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || true)" + find_runtime() { + xcrun simctl list runtimes \ + | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' \ + | tail -n 1 + } + runtime="$(find_runtime || true)" if [ -z "$runtime" ]; then - echo "::notice title=iOS 17 floor leg skipped::no iOS 17.x simulator runtime on this runner image (image drift) — the deployment-floor round did NOT run" - echo "runtime=" >> "$GITHUB_OUTPUT" - exit 0 + echo "::warning title=iOS 17 runtime absent::downloading iOS ${IOS17_FALLBACK_VERSION} simulator runtime (runner image drift)" + sudo xcodebuild -downloadPlatform iOS -buildVersion "$IOS17_FALLBACK_VERSION" || true + runtime="$(find_runtime || true)" + fi + if [ -z "$runtime" ]; then + echo "::error title=iOS 17 floor leg cannot run::no iOS 17.x simulator runtime and the download failed — the deployment-floor round did NOT run, so this job fails instead of reporting a false green" >&2 + xcrun simctl list runtimes >&2 + exit 1 fi udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")" echo "created $udid with $runtime" - echo "runtime=$runtime" >> "$GITHUB_OUTPUT" echo "udid=$udid" >> "$GITHUB_OUTPUT" # No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed # (ad-hoc, certificate-free on simulator) app host — unsigned = -34018. + # + # -skip-testing LiveServerSmokeTests: same reason as the iPad leg — that + # suite spawns node_modules/.bin/tsx and would hard-fail without `npm ci`. + # This leg's job is the DEPLOYMENT FLOOR (does the app build and behave on + # iOS 17), not the server contract, so it stays Node-free. - name: xcodebuild test (WebTermTests on the iOS 17.x floor) - if: steps.sim17.outputs.runtime != '' run: | xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \ - -destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" test + -destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" \ + -skip-testing:WebTermTests/LiveServerSmokeTests \ + test diff --git a/README.md b/README.md index f308c69..f077aa6 100644 --- a/README.md +++ b/README.md @@ -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). 16–512 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://: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 (16–512 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=` 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. --- diff --git a/android/DEVICE_QA_CHECKLIST.md b/android/DEVICE_QA_CHECKLIST.md index 1814c66..8432ff5 100644 --- a/android/DEVICE_QA_CHECKLIST.md +++ b/android/DEVICE_QA_CHECKLIST.md @@ -181,6 +181,30 @@ The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumen expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY. - [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited. +## Access token — `WEBTERM_TOKEN` (B5 / ios-completion §1.1) — needs a token-enabled host +Run the host as `WEBTERM_TOKEN=<16–512 chars> npm start`. The pure logic is JVM-tested (`AuthProbeTest`, +`AccessTokenCookieTest`, `PairingProbeTokenTest`, `OkHttpAccessTokenTest`, `SessionEngineUnauthorizedTest`, +`InMemoryAccessTokenStoreTest`); everything below needs real hardware + a real server. +- [ ] `TinkAccessTokenStore` instrumented suite on a device: + `./gradlew :host-registry:connectedDebugAndroidTest` (real AndroidKeyStore + Tink). +- [ ] Pair a token-enabled host with the token typed → paired; the terminal attaches (the **WS upgrade** + carries `Cookie: webterm_auth=…`), the session list, projects, diff and thumbnails all load. +- [ ] Pair the same host with **no** token typed → the probe 401s → the confirm step comes back asking for + the token ("该主机启用了访问令牌"); typing it and retrying pairs. +- [ ] Wrong token → "访问令牌不正确" **under the field** (still on the confirm step, nothing stored). +- [ ] 11 wrong tries within a minute → "尝试过多" (the server's 10/min/IP `/auth` limiter). +- [ ] Pair a host with `WEBTERM_TOKEN` **unset** while typing a token → pairs, and NOTHING is stored + (204-without-`Set-Cookie`); the app must not claim to be "authenticated". +- [ ] Restart the app → the terminal re-attaches with no re-prompt (the token decrypts from the + Keystore-backed store on a cold start). +- [ ] Rotate `WEBTERM_TOKEN` on the host and restart it → the WS upgrade 401s → the terminal banner shows + the 401 copy, offers **no** "新会话", and there is **no reconnect back-off storm** (verify with + `adb logcat` / the server log: exactly one upgrade attempt). +- [ ] `adb logcat` during all of the above: the token string appears **nowhere**; no request URL contains + it (check the server's access log too). +- [ ] `adb backup` / device-to-device transfer excludes the token blob (`allowBackup=false` + + `data_extraction_rules.xml`). + ## Nav / deep links / adaptive (A32/A26/A29) - [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the right destination; invalid UUID ignored. (The App Link half needs the release-signed APK and diff --git a/android/README.md b/android/README.md index 67e1db1..bd2f21b 100644 --- a/android/README.md +++ b/android/README.md @@ -46,8 +46,16 @@ 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 | | — (no iOS counterpart) | `:macrobenchmark` | `com.android.test` harness | ⬜ scaffolded, no sources | +> `: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`) and the +> public-host cleartext refusal (`CleartextGuardTest`). + `:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a `com.android.test` module — a **separate APK** that drives `:app` out of process via UiAutomator, which is the only way startup/frame timings are real. It therefore cannot @@ -76,8 +84,45 @@ written yet. `:wire-protocol` is the **frozen shared contract** (Android analogue of `src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`, `Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation), -and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary -interfaces. New wire types are added **only** here (a coordination point). +`AuthCookie`/`AccessTokenRule`/`AccessTokenSource` (the single access-token-cookie +derivation), and the `TermTransport` / `HttpTransport` / `PingableTermTransport` +boundary interfaces. New wire types are added **only** here (a coordination point). + +## Access token (`WEBTERM_TOKEN`) — how the Android client authenticates + +A server started with `WEBTERM_TOKEN=<16–512 chars of [A-Za-z0-9._~+/=-]>` gates **every** +HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie. The Android +client therefore: + +- **hand-writes `Cookie: webterm_auth=` itself** for a token it already holds — one + derivation point (`AuthCookie`), stamped in exactly three places: `:api-client`'s + `ApiRoute.toHttpRequest` (all 12+ routes, RO GETs included), the pairing probe's two + hand-built legs, and `:transport-okhttp`'s WS upgrade. The hand-built + `GET /projects/diff` fetcher in `:app` stamps it too. +- **also installs one `AuthCookieJar`** on the single shared `OkHttpClient`, which captures + the `Set-Cookie` a live pairing (`POST /auth`) hands back and replays it on REST *and* on + the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls). The two are + orthogonal: the hand-written header covers a token restored from the Keystore store, the + jar covers a cookie the server just issued. A host with neither sends no cookie at all. +- keeps the header **orthogonal to `Origin`**: the token never replaces the CSWSH + Origin check (the server tests Origin first, then the cookie), and read-only routes + still carry no Origin. +- validates a typed token ONCE at pairing time with `POST /auth` + (`{"token":"…"}`, `Accept: application/json` — an `Accept` containing `text/html` + makes the server answer 302 instead of 204/401). Four outcomes: 204+`Set-Cookie` = + correct → store it; 204 **without** `Set-Cookie` = that host has no auth → store + nothing; 401 = wrong token; 429 = rate-limited (10/min/IP). +- stores it per host (keyed by the canonical origin) in `TinkAccessTokenStore`: + Tink AEAD under an **AndroidKeystore-wrapped, non-exportable** master key, in an + app-private `SharedPreferences` file, with `android:allowBackup="false"` + + `data_extraction_rules.xml` excluding cloud backup and device transfer. The token is + **never logged, never in a URL, never in app UI state**. +- treats a **401 on the WS upgrade as terminal** (`FailureReason.UNAUTHORIZED`): no + reconnect back-off loop, and the banner tells the user to re-enter the token. A 401 on + any REST route is the typed `ApiClientError.Unauthorized`. + +A host with no `WEBTERM_TOKEN` is byte-identical to before the feature: no token stored ⇒ +no `Cookie` header at all (LAN zero-config preserved). ## Toolchain @@ -111,8 +156,9 @@ 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 modules that +> apply the Kover plugin, and therefore the only ones `koverVerify` gates). TDD, +> immutable data, small focused files — same discipline as the rest of the repo. > > **Do not use `--rerun-tasks` in a release gate.** It trips an AGP lint/K2 internal bug > (`FirDeclaration was not found for class KtProperty, fir is null`, on @@ -199,8 +245,7 @@ Before adding a rule, read the merged configuration R8 actually used: `hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to `wang.yaojia.webterm.HiltTestRunner`. -⚠️ **That runner class does not exist yet** — it is the first file the A34 author must write, -into `app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt`: +`app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt` supplies it: ```kotlin class HiltTestRunner : AndroidJUnitRunner() { @@ -215,6 +260,24 @@ replace the app-under-test's `Application` with the generated `HiltTestApplicati *test* APK, not into the app under test. `assembleDebugAndroidTest` builds fine without the class (the runner name is just a manifest value); an actual instrumentation *run* needs it. +The suite has been **run green on real hardware** (67/0 instrumented tests against this +repo's own server), so `src/androidTest/**` is verified, not aspirational — but it still +needs a connected device/emulator and is therefore **not** part of the `./gradlew test` +gate; run it with `connectedAndroidTest`. An emulator additionally needs +`ALLOWED_ORIGINS=http://10.0.2.2:` on the server, because the server derives its +allowed origins from NIC IPs and `10.0.2.2` (the emulator's alias for the host) is not one. + +### What is still NOT verified here + +- **DEFERRED — end-to-end access token**: the `WEBTERM_TOKEN` path is covered by + unit tests (`OkHttpAccessTokenTest`, `AuthProbeTest`, `AccessTokenCookieTest`, the + `:api-client` route tests, the `AuthCookie` derivation tests) and by + `TinkAccessTokenStoreTest` on device, but **no automated leg on the Android side starts + a real server with `WEBTERM_TOKEN` set and completes a cookie-gated WS upgrade** — that + end-to-end coverage exists only on the iOS side (`ios/IntegrationTests`). +- **DEFERRED — the rest of `DEVICE_QA_CHECKLIST.md`**: FCM push delivery under Doze on two + physical handsets (S2) and the lock-screen Allow/Deny walkthrough have no automated cover. + ## Android SDK setup (proven working) The pure JVM modules need only a JDK + Gradle. The **Android-framework** modules diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/AuthProbe.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/AuthProbe.kt new file mode 100644 index 0000000..3d8e2e6 --- /dev/null +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/AuthProbe.kt @@ -0,0 +1,92 @@ +package wang.yaojia.webterm.api.pairing + +import wang.yaojia.webterm.api.routes.Endpoints +import wang.yaojia.webterm.wire.AccessTokenRule +import wang.yaojia.webterm.wire.AuthCookie +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpResponse +import wang.yaojia.webterm.wire.HttpTransport + +/** + * The outcome of the pairing-time access-token validation probe (`POST /auth`). The first four cases + * are the FROZEN four the server distinguishes (ios-completion §1.1); [Malformed] is a client-side + * pre-check and [Unexpected] keeps an unknown status from being mistaken for success. + */ +public sealed interface AuthProbeResult { + /** **204 + `Set-Cookie: webterm_auth=…`** — the token is correct. Persist it for this host. */ + public data object Accepted : AuthProbeResult + + /** + * **204 with NO `Set-Cookie`** — this host has `WEBTERM_TOKEN` unset, so there is nothing to + * authenticate. The token MUST NOT be persisted, and this MUST NOT be read as "authenticated" + * (frozen §1.1): the host is simply open, exactly as a zero-config LAN deploy. + */ + public data object AuthDisabled : AuthProbeResult + + /** **401** — wrong token. UI: "访问令牌不正确". */ + public data object InvalidToken : AuthProbeResult + + /** **429** — the server's `/auth` limiter (10/min/IP) tripped. UI: "尝试过多,稍后再试". */ + public data object RateLimited : AuthProbeResult + + /** The token cannot be a valid server token ([AccessTokenRule]) — rejected before any network I/O. */ + public data object Malformed : AuthProbeResult + + /** Any other status (e.g. a captive portal's 302). Never treated as success. */ + public data class Unexpected(val status: Int) : AuthProbeResult +} + +/** + * Validate [token] against [endpoint] via **`POST /auth`** — a one-shot pairing-time probe, NOT a + * session-establishing login: the native client keeps stamping its own `Cookie` header afterwards and + * ignores the `Set-Cookie` value entirely (frozen §1.1). The response's `Set-Cookie` is used only as a + * BOOLEAN signal (was the token accepted, or is auth disabled on this host?). + * + * Transport-level failures propagate unwrapped so the caller can classify them with + * [PairingError.classify] (same convention as [runPairingProbe]). + * + * Never logs the token; the token appears only in the JSON body, never in the URL. + */ +public suspend fun probeAccessToken( + endpoint: HostEndpoint, + http: HttpTransport, + token: String, +): AuthProbeResult { + // Boundary validation first: a token outside the server's charset/length can never be correct, and + // there is no point spending a rate-limit slot (or shipping it over the wire) to find that out. + val normalized = AccessTokenRule.normalize(token) ?: return AuthProbeResult.Malformed + val request = Endpoints.auth(normalized).toHttpRequest(endpoint) + ?: return AuthProbeResult.Unexpected(UNBUILDABLE_REQUEST) + val response = http.send(request) + return classifyAuthResponse(response) +} + +private fun classifyAuthResponse(response: HttpResponse): AuthProbeResult = when (response.status) { + HTTP_NO_CONTENT -> + // The ONE discriminator between "token correct" and "this host has no auth at all". + if (response.carriesAuthCookie()) AuthProbeResult.Accepted else AuthProbeResult.AuthDisabled + HTTP_UNAUTHORIZED -> AuthProbeResult.InvalidToken + HTTP_TOO_MANY_REQUESTS -> AuthProbeResult.RateLimited + else -> AuthProbeResult.Unexpected(response.status) +} + +/** + * True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the + * value must name [AuthCookie.NAME] — some other service's `Set-Cookie` (a proxy's session id) proves + * nothing about our token. + */ +private fun HttpResponse.carriesAuthCookie(): Boolean = + headers.entries.any { (name, value) -> + name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=") + } + +/** + * Sentinel status for "the request could not even be built" — unreachable for a validated + * [HostEndpoint], surfaced instead of crashing (never mistaken for a real HTTP status). + */ +private const val UNBUILDABLE_REQUEST = 0 + +private const val SET_COOKIE_HEADER = "Set-Cookie" +private const val HTTP_NO_CONTENT = 204 +private const val HTTP_UNAUTHORIZED = 401 +private const val HTTP_TOO_MANY_REQUESTS = 429 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingError.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingError.kt index 5fdb730..8705fc1 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingError.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingError.kt @@ -52,6 +52,17 @@ public sealed interface PairingError { /** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */ public data object TlsFailure : PairingError + /** + * The host answered **401** to the probe's HTTP legs — it has `WEBTERM_TOKEN` set and we presented + * no cookie / a wrong one (ios-completion §1.1). Distinct from [OriginRejected]: this is fixable by + * entering the host's access token, not by editing `ALLOWED_ORIGINS`. + * + * NOTE the probe's ORDERING is what makes the two distinguishable: probe ① authenticates over HTTP + * first, so a 401 there is the token; once ① has passed with our cookie, a 401 on the later WS + * upgrade can only be the Origin allow-list (the server writes the same bare 401 for both). + */ + public data object AccessTokenRequired : PairingError + /** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */ public data object Timeout : PairingError diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingProbe.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingProbe.kt index a04320f..df5eb01 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingProbe.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/pairing/PairingProbe.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie import wang.yaojia.webterm.wire.ClientMessage import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpMethod @@ -50,8 +52,9 @@ public suspend fun runPairingProbe( endpoint: HostEndpoint, http: HttpTransport, ws: TermTransport, + tokens: AccessTokenSource = AccessTokenSource.NONE, ): PairingProbeResult = - runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT) + runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT, tokens = tokens) /** * Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts @@ -63,9 +66,10 @@ internal suspend fun runPairingProbeCore( http: HttpTransport, ws: TermTransport, timeout: Duration?, + tokens: AccessTokenSource = AccessTokenSource.NONE, ): PairingProbeResult { - if (timeout == null) return performProbe(endpoint, http, ws) - return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) } + if (timeout == null) return performProbe(endpoint, http, ws, tokens) + return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) } ?: PairingProbeResult.Failure(PairingError.Timeout) } @@ -75,10 +79,16 @@ private suspend fun performProbe( endpoint: HostEndpoint, http: HttpTransport, ws: TermTransport, + tokens: AccessTokenSource, ): PairingProbeResult { + // The access token (if any) rides BOTH HTTP legs as the hand-written auth cookie. The WS leg gets + // it from the transport's own AccessTokenSource (the same store), so all three legs authenticate. + val cookieHeaders = authHeaders(tokens.tokenFor(endpoint)) + // ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape - // means "some other service" → httpOkButNotWebTerminal ("端口对吗?"). - probeReachability(endpoint, http)?.let { return it } + // means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host + // wants an access token we do not have. + probeReachability(endpoint, http, cookieHeaders)?.let { return it } // ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an // unrecognizable connect failure is classified as originRejected. @@ -105,7 +115,7 @@ private suspend fun performProbe( return try { when (val adoption = adoptAttachedSession(connection)) { is Adoption.Failure -> PairingProbeResult.Failure(adoption.error) - is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http) + is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http, cookieHeaders) } } finally { withContext(NonCancellable) { runCatching { connection.close() } } @@ -120,14 +130,20 @@ private suspend fun performProbe( private suspend fun probeReachability( endpoint: HostEndpoint, http: HttpTransport, + authHeaders: Map, ): PairingProbeResult.Failure? { val response = try { - http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint))) + http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint), headers = authHeaders)) } catch (cancel: CancellationException) { throw cancel } catch (error: Throwable) { return PairingProbeResult.Failure(PairingError.classify(error, endpoint)) } + // Checked BEFORE the shape check: the 401 body is the server's auth JSON, not a session array, and + // reporting "端口对吗?" for a token problem would send the user chasing the wrong thing. + if (response.status == HTTP_UNAUTHORIZED) { + return PairingProbeResult.Failure(PairingError.AccessTokenRequired) + } if (response.status != HTTP_OK || !isJsonArray(response.body)) { return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) } @@ -162,13 +178,14 @@ private suspend fun killProbeSession( sessionId: String, endpoint: HostEndpoint, http: HttpTransport, + authHeaders: Map, ): PairingProbeResult { val response = try { http.send( HttpRequest( method = HttpMethod.DELETE, url = killUrl(endpoint, sessionId), - headers = mapOf(ORIGIN_HEADER to endpoint.originHeader), + headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader), ), ) } catch (cancel: CancellationException) { @@ -178,6 +195,7 @@ private suspend fun killProbeSession( } return when (response.status) { HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint) + HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired) HTTP_FORBIDDEN -> PairingProbeResult.Failure( PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)), ) @@ -219,9 +237,17 @@ private fun httpBaseUrl(endpoint: HostEndpoint): String { return "$scheme://$host$portPart" } +/** + * `Cookie: webterm_auth=` for the probe's HTTP legs, or NO header when the host has no token — + * derived from the single point ([AuthCookie]), never hand-assembled. + */ +private fun authHeaders(token: String?): Map = + if (token == null) emptyMap() else mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token)) + private const val LIVE_SESSIONS_PATH = "/live-sessions" private const val ORIGIN_HEADER = "Origin" private const val HTTP_OK = 200 +private const val HTTP_UNAUTHORIZED = 401 private const val HTTP_NO_CONTENT = 204 private const val HTTP_FORBIDDEN = 403 private const val HTTP_NOT_FOUND = 404 diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt index e4e6035..5ae04dc 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClient.kt @@ -24,6 +24,7 @@ import wang.yaojia.webterm.api.models.UiPrefs import wang.yaojia.webterm.api.models.WorktreeState import wang.yaojia.webterm.api.models.decodeGitError import wang.yaojia.webterm.api.models.decodeGitPayload +import wang.yaojia.webterm.wire.AccessTokenSource import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpResponse import wang.yaojia.webterm.wire.HttpTransport @@ -41,10 +42,18 @@ import java.util.UUID * * The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped), * statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input. + * + * **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is + * stamped as `Cookie: webterm_auth=` 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** 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, private val http: HttpTransport, + private val tokens: AccessTokenSource = AccessTokenSource.NONE, ) { // ── RO (read-only — NO Origin header) ────────────────────────────────────────────────── @@ -343,9 +352,27 @@ public class ApiClient( // ── Internals ────────────────────────────────────────────────────────────────────────── + /** + * 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 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) ?: throw ApiClientError.InvalidRequest - return http.send(request) + val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint)) + ?: throw ApiClientError.InvalidRequest + val response = http.send(request) + if (response.status == HttpStatus.UNAUTHORIZED && + route.unauthorizedPolicy == UnauthorizedPolicy.ACCESS_TOKEN_GATE + ) { + throw ApiClientError.Unauthorized + } + return response } /** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */ diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt index 2104c86..d809149 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiClientError.kt @@ -20,6 +20,15 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u /** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */ public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。") + /** + * **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("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。") + /** 403 from a G route's Origin guard (CSWSH defence). */ public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。") diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt index 9c795bb..d94d97f 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/ApiRoute.kt @@ -1,5 +1,6 @@ package wang.yaojia.webterm.api.routes +import wang.yaojia.webterm.wire.AuthCookie import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpRequest @@ -10,6 +11,7 @@ internal object HttpStatus { const val OK = 200 const val NO_CONTENT = 204 const val BAD_REQUEST = 400 + const val UNAUTHORIZED = 401 const val FORBIDDEN = 403 const val NOT_FOUND = 404 const val CONFLICT = 409 @@ -27,6 +29,12 @@ internal object HttpStatus { internal object HeaderName { const val ORIGIN = "Origin" const val CONTENT_TYPE = "Content-Type" + + /** + * Explicit on the `/auth` probe: the server treats an `Accept` containing `text/html` as a browser + * form submit and answers **302** instead of 204/401 (`src/server.ts` `acceptsHtml`). + */ + const val ACCEPT = "Accept" } internal object ContentType { @@ -47,6 +55,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 @@ -58,19 +83,37 @@ internal class ApiRoute( val originPolicy: OriginPolicy, val body: ByteArray? = null, 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 * query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are * dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base * URL cannot be parsed (surfaced by the client as `InvalidRequest`). + * + * Two headers are stamped HERE and only here, and they are ORTHOGONAL (ios-completion §1.1): + * - `Origin` **iff** the route is [OriginPolicy.GUARDED] (the CSWSH 铁律, unchanged); + * - `Cookie: webterm_auth=` whenever [accessToken] is non-null — on EVERY route, read-only + * ones included, because the server's access-token gate sits in front of the whole app. A null + * token adds no header at all, keeping an unauthenticated host byte-identical to before. + * The token is secret material: it is only ever placed in this header — never in [path], never in + * [percentEncodedQuery], never logged. */ - fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? { + fun toHttpRequest(endpoint: HostEndpoint, accessToken: String? = null): HttpRequest? { val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null val headers = LinkedHashMap() if (originPolicy == OriginPolicy.GUARDED) { headers[HeaderName.ORIGIN] = endpoint.originHeader } + if (accessToken != null) { + headers[AuthCookie.HEADER_NAME] = AuthCookie.headerValue(accessToken) + } + if (accept != null) { + headers[HeaderName.ACCEPT] = accept + } if (body != null) { headers[HeaderName.CONTENT_TYPE] = ContentType.JSON } diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt index 4fdbfca..59d7c8b 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt @@ -134,7 +134,40 @@ internal object Endpoints { private const val FCM_TOKEN_PATH = "/push/fcm-token" + // ── G: access-token validation probe (`POST /auth`, ios-completion §1.1) ─────────────── + + /** + * `POST /auth` — the pairing-time token-validation probe. Body `{"token":""}`. + * + * Two non-obvious requirements, both server-driven (`src/server.ts`): + * - **`Accept` must not contain `text/html`.** The route is dual-mode: an HTML-accepting request + * is treated as a browser form and answered with a **302** redirect instead of 204/401. + * - the route sits BEFORE the auth gate, so it is the one request that legitimately carries no + * auth cookie (the token is in the body — this IS the login). + * + * Guarded (a state-changing POST) so it stamps `Origin` like every other write; the server does not + * Origin-check `/auth`, but keeping the Origin-iff-guarded rule uniform avoids a special case. + */ + fun auth(token: String): ApiRoute { + val body = ModelJson.encodeToString(AuthBody.serializer(), AuthBody(token)).encodeToByteArray() + return ApiRoute( + HttpMethod.POST, + AUTH_PATH, + OriginPolicy.GUARDED, + body = body, + accept = ContentType.JSON, + ) + } + + private const val AUTH_PATH = "/auth" + // ── G: worktree write (create / remove / prune) ──────────────────────────────────────── + // + // Guarded writes, but NOT [UnauthorizedPolicy.ROUTE_DEFINED] (F5): these land in + // `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500), and + // `src/server.ts:1182-1260` adds none. The only 401 they can ever see is the access-token gate, so + // they take the DEFAULT gate reading — pinning them route-defined made a gated host's 401 surface + // as a worktree failure instead of the token flow. /** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */ fun createWorktree(path: String, branch: String, base: String?): ApiRoute = @@ -162,11 +195,11 @@ internal object Endpoints { /** `POST /projects/git/stage` — `{ path, files, stage }`. */ fun gitStage(path: String, files: List, stage: Boolean): ApiRoute = - jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage)) + gitWriteRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage)) /** `POST /projects/git/commit` — `{ path, message }`. */ fun gitCommit(path: String, message: String): ApiRoute = - jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message)) + gitWriteRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message)) /** * `POST /projects/git/fetch` — `{ path }` (w6/G2). GUARDED even though it only updates @@ -176,11 +209,11 @@ internal object Endpoints { * It is NOT a pull: no working tree, no index, no merge. */ fun gitFetch(path: String): ApiRoute = - jsonBodyRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path)) + gitWriteRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path)) /** `POST /projects/git/push` — `{ path }`. */ fun gitPush(path: String): ApiRoute = - jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path)) + gitWriteRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path)) // ── G: W2 prompt queue (enqueue / cancel-all) ────────────────────────────────────────── @@ -212,17 +245,48 @@ internal object Endpoints { private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue" - /** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */ + /** + * Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). + * + * The 401 reading defaults to [UnauthorizedPolicy.ACCESS_TOKEN_GATE] — correct for every route + * whose handler never writes a 401 of its own (the W2 queue: 400/404/429/503 only; the worktree + * trio: 403/400/404/500 only). Only the git-ops writes opt out, via [gitWriteRoute]. + */ private fun jsonBodyRoute( method: HttpMethod, path: String, serializer: kotlinx.serialization.KSerializer, value: T, + unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE, ): 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, + ) } + /** + * Build a GUARDED **git-ops** 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 `gitOpsRoute`). + * + * Scope is exactly the four routes that reach that classifier — `stage`, `commit`, `push`, `fetch`. + * The worktree trio must NOT be built here (F5): its handler has no 401 of its own. + */ + private fun gitWriteRoute( + method: HttpMethod, + path: String, + serializer: kotlinx.serialization.KSerializer, + value: T, + ): ApiRoute = jsonBodyRoute(method, path, serializer, value, UnauthorizedPolicy.ROUTE_DEFINED) + /** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */ private const val GIT_LOG_MAX = 50 @@ -259,6 +323,10 @@ internal object Endpoints { @Serializable private data class FcmTokenBody(val token: String) + /** `POST /auth` body. Holds secret material — never log a request built from it. */ + @Serializable + private data class AuthBody(val token: String) + @Serializable private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null) diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/AuthProbeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/AuthProbeTest.kt new file mode 100644 index 0000000..27b4b5a --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/AuthProbeTest.kt @@ -0,0 +1,159 @@ +package wang.yaojia.webterm.api.pairing + +import kotlinx.coroutines.test.runTest +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.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.AuthCookie +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import java.io.IOException + +/** + * B5 · `POST /auth` as the pairing-time validation probe (FROZEN contract, ios-completion §1.1). + * + * The four distinct server outcomes must stay distinguishable: + * | 204 **with** `Set-Cookie` | token correct → persist it | + * | 204 **without** `Set-Cookie` | the host has NO auth configured → do NOT persist, and do NOT treat as authenticated | + * | 401 | the token is wrong → UI says "令牌不正确" | + * | 429 | rate-limited (10/min/IP) → UI says "尝试过多" | + * + * Plus the request shape the server needs: JSON body `{"token":""}` and an `Accept` that does NOT + * contain `text/html` (an HTML-accepting request is treated as a browser form and answered with a 302 + * instead of 204/401). + */ +class AuthProbeTest { + private companion object { + const val BASE = "http://192.168.1.5:3000" + const val TOKEN = "0123456789abcdefTOKEN" + } + + private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE)) + private val transport = FakeHttpTransport() + + private fun queueAuth(status: Int, headers: Map = emptyMap()) { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/auth", status = status, headers = headers) + } + + private val setCookieHeader = mapOf( + "Set-Cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict", + ) + + // ── the four frozen outcomes ──────────────────────────────────────────────────────────────── + + @Test + fun `204 with Set-Cookie means the token is correct`() = runTest { + queueAuth(204, setCookieHeader) + + assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN)) + } + + @Test + fun `204 without Set-Cookie means the host has no auth configured`() = runTest { + queueAuth(204) + + assertEquals( + AuthProbeResult.AuthDisabled, + probeAccessToken(endpoint, transport, TOKEN), + "204-no-cookie must NEVER be read as 'authenticated' (frozen §1.1)", + ) + } + + @Test + fun `401 means the token is wrong`() = runTest { + queueAuth(401) + + assertEquals(AuthProbeResult.InvalidToken, probeAccessToken(endpoint, transport, TOKEN)) + } + + @Test + fun `429 means rate-limited`() = runTest { + queueAuth(429) + + assertEquals(AuthProbeResult.RateLimited, probeAccessToken(endpoint, transport, TOKEN)) + } + + @Test + fun `any other status is surfaced as Unexpected with the code`() = runTest { + queueAuth(302) // e.g. a proxy/login redirect — never silently treated as success + + assertEquals(AuthProbeResult.Unexpected(302), probeAccessToken(endpoint, transport, TOKEN)) + } + + // ── request shape ─────────────────────────────────────────────────────────────────────────── + + @Test + fun `the probe posts the token as JSON with an Accept that excludes text-html`() = runTest { + queueAuth(204, setCookieHeader) + + probeAccessToken(endpoint, transport, TOKEN) + + val request = transport.recordedRequests.single() + assertEquals(HttpMethod.POST, request.method) + assertEquals("$BASE/auth", request.url) + assertEquals("""{"token":"$TOKEN"}""", request.body?.decodeToString()) + assertEquals("application/json", request.headers["Content-Type"]) + val accept = request.headers["Accept"].orEmpty() + assertTrue(accept.isNotEmpty(), "Accept must be explicit, not left to the HTTP stack") + assertFalse(accept.contains("text/html"), "text/html would make the server answer 302, not 204/401") + } + + @Test + fun `the token never appears in the URL`() = runTest { + queueAuth(401) + + probeAccessToken(endpoint, transport, TOKEN) + + assertFalse( + transport.recordedRequests.single().url.contains(TOKEN), + "a secret must never travel in a URL (query strings land in logs/history)", + ) + } + + // ── boundary validation + transport failure ───────────────────────────────────────────────── + + @Test + fun `a malformed token is rejected before any network IO`() = runTest { + assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "short")) + assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "has spaces in it here")) + + assertTrue(transport.recordedRequests.isEmpty(), "a malformed token must not hit the network") + } + + @Test + fun `a whitespace-padded token is trimmed once and accepted`() = runTest { + queueAuth(204, setCookieHeader) + + assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, " $TOKEN\n")) + assertEquals("""{"token":"$TOKEN"}""", transport.recordedRequests.single().body?.decodeToString()) + } + + @Test + fun `a transport failure propagates so the caller can classify it`() = runTest { + transport.queueFailure(method = HttpMethod.POST, url = "$BASE/auth", error = IOException("refused")) + + val thrown = runCatching { probeAccessToken(endpoint, transport, TOKEN) }.exceptionOrNull() + + assertTrue(thrown is IOException, "network classification stays with PairingError.classify") + } + + @Test + fun `a Set-Cookie header is recognised case-insensitively`() = runTest { + queueAuth(204, mapOf("set-cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/")) + + assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN)) + } + + @Test + fun `a Set-Cookie for some other cookie does not count as accepted`() = runTest { + queueAuth(204, mapOf("Set-Cookie" to "sessionid=abc; Path=/")) + + assertEquals( + AuthProbeResult.AuthDisabled, + probeAccessToken(endpoint, transport, TOKEN), + "only a webterm_auth cookie proves the token was accepted", + ) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingProbeTokenTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingProbeTokenTest.kt new file mode 100644 index 0000000..a8b0df6 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/pairing/PairingProbeTokenTest.kt @@ -0,0 +1,90 @@ +package wang.yaojia.webterm.api.pairing + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.testsupport.FakeTermTransport +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod + +/** + * B5 · the two-step pairing probe under an access-token-protected host: + * - both HTTP legs (`GET /live-sessions` and the guarded `DELETE /live-sessions/:id`) carry the + * hand-written `Cookie: webterm_auth=`; + * - a **401** on the reachability leg is [PairingError.AccessTokenRequired] — NOT + * "端口对吗?" ([PairingError.HttpOkButNotWebTerminal]) and NOT an Origin problem, so the UI can ask + * for the token instead of sending the user on a wild goose chase; + * - with no token configured nothing changes (no `Cookie` header at all). + */ +class PairingProbeTokenTest { + private companion object { + const val BASE = "http://192.168.1.5:3000" + const val TOKEN = "0123456789abcdefTOKEN" + const val SERVER_ID = "11111111-1111-4111-8111-111111111111" + } + + private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE)) + private val http = FakeHttpTransport() + private val ws = FakeTermTransport() + + private fun scriptHappyPath() { + http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 204) + ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""") + } + + private suspend fun probe(tokens: AccessTokenSource): PairingProbeResult = + runPairingProbeCore(endpoint, http, ws, timeout = null, tokens = tokens) + + @Test + fun `both HTTP legs of the probe carry the auth cookie`() = runTest { + scriptHappyPath() + + val result = probe(AccessTokenSource { TOKEN }) + + assertEquals(PairingProbeResult.Success(endpoint), result) + assertEquals(2, http.recordedRequests.size, "reachability GET + guarded kill DELETE") + http.recordedRequests.forEach { request -> + assertEquals( + "${AuthCookie.NAME}=$TOKEN", + request.headers[AuthCookie.HEADER_NAME], + "every probe request must present the token", + ) + } + } + + @Test + fun `with no token the probe requests are byte-identical to before the feature`() = runTest { + scriptHappyPath() + + probe(AccessTokenSource.NONE) + + http.recordedRequests.forEach { request -> + assertFalse(request.headers.containsKey(AuthCookie.HEADER_NAME)) + } + } + + @Test + fun `a 401 on the reachability leg asks for an access token`() = runTest { + http.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray()) + + val result = probe(AccessTokenSource.NONE) + + assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result) + } + + @Test + fun `a 401 on the guarded kill leg also asks for an access token`() = runTest { + http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 401) + ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""") + + val result = probe(AccessTokenSource.NONE) + + assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/AccessTokenCookieTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/AccessTokenCookieTest.kt new file mode 100644 index 0000000..84009d9 --- /dev/null +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/AccessTokenCookieTest.kt @@ -0,0 +1,207 @@ +package wang.yaojia.webterm.api.routes + +import kotlinx.coroutines.test.runTest +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 +import wang.yaojia.webterm.wire.AuthCookie +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import java.util.UUID + +/** + * B5 · the access-token cookie on the REST surface (FROZEN contract, ios-completion §1.1): + * - `Cookie: webterm_auth=` is stamped on **EVERY** route — read-only GETs included — because the + * server's auth gate runs in front of the whole app, not just the guarded routes; + * - 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 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 { + const val BASE = "http://192.168.1.5:3000" + const val TOKEN = "0123456789abcdefTOKEN" + val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") + const val ID_STR = "11111111-2222-4333-8444-555555555555" + } + + private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE)) + private val transport = FakeHttpTransport() + + private fun clientWithToken(): ApiClient = + ApiClient(endpoint, transport, AccessTokenSource { TOKEN }) + + private fun clientWithoutToken(): ApiClient = ApiClient(endpoint, transport) + + private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull() + + private fun assertCookie(index: Int) { + val headers = transport.recordedRequests[index].headers + assertEquals( + "${AuthCookie.NAME}=$TOKEN", + headers[AuthCookie.HEADER_NAME], + "every request must carry the hand-written auth cookie", + ) + } + + @Test + fun `a read-only GET carries the cookie and still no Origin`() = runTest { + transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + + clientWithToken().liveSessions() + + assertCookie(0) + assertFalse( + transport.recordedRequests[0].headers.containsKey(HeaderName.ORIGIN), + "the cookie is additive — a RO route must still never carry Origin", + ) + } + + @Test + fun `a guarded write carries BOTH the byte-equal Origin and the cookie`() = runTest { + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204) + + clientWithToken().hookDecision(ID, HookDecision.ALLOW, "cap-tok-123") + + assertCookie(0) + assertEquals(endpoint.originHeader, transport.recordedRequests[0].headers[HeaderName.ORIGIN]) + } + + @Test + fun `the cookie rides every route shape - RO query, guarded DELETE and guarded PUT alike`() = runTest { + transport.queueSuccess( + url = "$BASE/projects/detail?path=%2Frepo", + body = """{"name":"repo","path":"/repo","isGit":true}""".toByteArray(), + ) + transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204) + transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = "{}".toByteArray()) + val client = clientWithToken() + + client.projectDetail("/repo") + client.killSession(ID) + client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) + + assertEquals(3, transport.recordedRequests.size) + transport.recordedRequests.indices.forEach { assertCookie(it) } + } + + @Test + fun `no configured token means no Cookie header at all`() = runTest { + transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + + clientWithoutToken().liveSessions() + + assertFalse( + transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME), + "an unauthenticated host must see a byte-identical request to before the feature", + ) + } + + @Test + fun `the token is re-read per request so a token stored later takes effect`() = runTest { + transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray()) + var stored: String? = null + val client = ApiClient(endpoint, transport, AccessTokenSource { stored }) + + client.liveSessions() + stored = TOKEN + client.liveSessions() + + assertFalse(transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME)) + assertCookie(1) + } + + @Test + fun `a 401 on a read-only route throws the typed Unauthorized error`() = runTest { + transport.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray()) + + val error = errorOf { clientWithoutToken().liveSessions() } + + assertEquals(ApiClientError.Unauthorized, error) + assertTrue( + ApiClientError.Unauthorized.userMessage.isNotBlank(), + "the UI needs actionable copy for a missing token", + ) + } + + /** + * 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 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-ops 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/git/fetch", 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.gitFetch("/repo") as GitWriteOutcome.Rejected).status) + } + + /** + * F5 · the worktree trio is NOT part of that family. `src/http/worktrees.ts` emits no 401 (403 + * kill-switch / 400 / 404 / 500 only) and `src/server.ts:1182-1260` adds none, so the ONLY 401 those + * routes can ever see is the access-token gate. Reading it as a git rejection stranded the user on a + * "worktree failed" message instead of the token flow. + */ + @Test + fun `a 401 on a worktree write is the access-token gate, not a git rejection`() = runTest { + val gateBody = """{"error":"authentication required"}""".toByteArray() + transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", status = 401, body = gateBody) + transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", status = 401, body = gateBody) + transport.queueSuccess( + method = HttpMethod.POST, + url = "$BASE/projects/worktree/prune", + status = 401, + body = gateBody, + ) + val client = clientWithToken() + + assertEquals(ApiClientError.Unauthorized, errorOf { client.createWorktree("/repo", "feat/x") }) + assertEquals(ApiClientError.Unauthorized, errorOf { client.removeWorktree("/repo", "/repo/wt", false) }) + assertEquals(ApiClientError.Unauthorized, errorOf { client.pruneWorktrees("/repo") }) + } + + @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) + + val outcome = clientWithoutToken().gitPush("/repo") + + assertEquals(GitWriteOutcome.Rejected(401, null), outcome) + } +} diff --git a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/GitRouteShapeTest.kt b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/GitRouteShapeTest.kt index c9ae1f5..0f6054d 100644 --- a/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/GitRouteShapeTest.kt +++ b/android/api-client/src/test/kotlin/wang/yaojia/webterm/api/routes/GitRouteShapeTest.kt @@ -158,4 +158,50 @@ class GitRouteShapeTest { client.gitPush("/r") assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) }) } + + /** + * E1 (narrowed, F5) · The 401 split declared AT the route (mirrors iOS `UnauthorizedPolicy`). + * + * **Route-defined 401 = exactly the four `/projects/git/…` writes, and only because of the ONE + * server line that can produce it:** `src/http/git-ops.ts:108` turns a HOST-side git credential + * failure into `{status:401, error:"Push authentication required on the host."}`. That classifier is + * reached only from `stageFiles`/`commit`/`push`/`fetch`. + * + * **The worktree trio is NOT route-defined.** `createWorktree`/`removeWorktree`/`pruneWorktrees` + * land in `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500), + * and `src/server.ts:1182-1260` adds none. Pinning them ROUTE_DEFINED meant that on a token-gated + * host the GATE's 401 surfaced as a git rejection instead of the token flow. + * + * Every other 401 the server can emit belongs to the gate: `src/server.ts:425` (`/auth` invalid), + * `:472` (the gate itself) and `:1453`/`:1463` (the WS upgrade) — none of which is a route here. + */ + @Test + fun `route-defined 401 is exactly the four git-ops writes, everything else is the gate`() { + val gitOpsWrites = listOf( + Endpoints.gitStage("/r", listOf("f"), true), + Endpoints.gitCommit("/r", "m"), + Endpoints.gitPush("/r"), + Endpoints.gitFetch("/r"), + ) + val gateRoutes = listOf( + // The worktree trio: guarded writes, but the server has no 401 of its own for them. + Endpoints.createWorktree("/r", "b", null), + Endpoints.removeWorktree("/r", "/r/x", false), + Endpoints.pruneWorktrees("/r"), + Endpoints.liveSessions(), + Endpoints.projects(), + Endpoints.projectLog("/r", null), + Endpoints.killSession(UUID.randomUUID()), + Endpoints.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()), + ) + + assertTrue(gitOpsWrites.all { it.unauthorizedPolicy == UnauthorizedPolicy.ROUTE_DEFINED }) + gateRoutes.forEach { + assertEquals( + UnauthorizedPolicy.ACCESS_TOKEN_GATE, + it.unauthorizedPolicy, + "${it.method} ${it.path} has no server-side 401 of its own — a 401 there IS the token gate", + ) + } + } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7e3733d..f5a6d2c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,17 +23,20 @@ never in backups/cloud"). Auto Backup defaults to ON and would sweep up EVERY app-private file: the `webterm` Preferences DataStore (which holds the sealed `webterm_auth` cookie — a full-shell credential the server keeps alive for 30 days) and the Tink keyset/ciphertext pref - files. `dataExtractionRules`/`fullBackupContent` were rejected because they are allow-by- - default: every store added later is backed up until someone remembers to exclude it, and a - silent miss here is an off-device credential leak. Nothing in this app has restore value that - justifies that risk — re-pairing a host is a QR scan, and the hosts are LAN-local anyway. - NOTE: this attribute is Auto Backup (cloud). Android 12+ DEVICE-TO-DEVICE transfer is governed - by a `dataExtractionRules` block, which needs an @xml resource (tracked - separately — see the orchestrator note). Do not set this back to true. --> + files. Nothing in this app has restore value that justifies that risk — re-pairing a host is + a QR scan, and the hosts are LAN-local anyway. + `allowBackup` alone only covers Auto Backup (cloud) / adb backup. Android 12+ DEVICE-TO-DEVICE + transfer is governed by a `dataExtractionRules` block, which needs an @xml + resource — @xml/data_extraction_rules now supplies it, and it is deny-everything in BOTH + sections rather than an exclusion list, so a store added later is excluded by default instead + of leaking until someone remembers it. `fullBackupContent="false"` states the same for the + pre-31 path. Do not set any of these back to true. --> when (model.reason) { FailureReason.REPLAY_TOO_LARGE -> "回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。" + FailureReason.UNAUTHORIZED -> + "服务器拒绝了连接(401)。请在“配对主机”里重新填写访问令牌;若已填写,请检查主机的 ALLOWED_ORIGINS。" } is BannerModel.Exited -> if (model.isSpawnFailure) { diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/AuthModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/AuthModule.kt new file mode 100644 index 0000000..ccd680a --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/AuthModule.kt @@ -0,0 +1,42 @@ +package wang.yaojia.webterm.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import wang.yaojia.webterm.hostregistry.AccessTokenStore +import wang.yaojia.webterm.hostregistry.TinkAccessTokenStore +import wang.yaojia.webterm.wire.AccessTokenSource +import javax.inject.Singleton + +/** + * Access-token boundary (B5 / ios-completion §1.1): the ONE per-host access-token store, exposed both as + * the mutable [AccessTokenStore] (the pairing screen writes it) and as the read-only + * [AccessTokenSource] the transports consume. + * + * It is its own Hilt module — not part of [StorageModule] — because the token is SECRET material: + * [StorageModule]'s DataStore holds only non-secret UI state, while this store is + * Tink-AEAD-encrypted under an AndroidKeystore-wrapped master key (the same posture as [TlsModule]'s + * cert store, with its own keyset so the two secrets never share a key). + * + * Both providers resolve to the SAME singleton, so a token stored during pairing is immediately visible + * to the WS upgrade and to every REST route — no cache to invalidate. The store is constructor-I/O-free + * (Tink/Keystore work is lazy and hops to `Dispatchers.IO` inside), so injecting it on `Main` is cheap. + */ +@Module +@InstallIn(SingletonComponent::class) +public object AuthModule { + + @Provides + @Singleton + public fun provideAccessTokenStore( + @ApplicationContext context: Context, + ): AccessTokenStore = TinkAccessTokenStore(context) + + /** The transports' read seam — the SAME instance the pairing screen wrote to. */ + @Provides + @Singleton + public fun provideAccessTokenSource(store: AccessTokenStore): AccessTokenSource = store +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt index ea4cbd6..58fb190 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt @@ -13,6 +13,7 @@ import wang.yaojia.webterm.transport.OkHttpClientFactory import wang.yaojia.webterm.transport.OkHttpHttpTransport import wang.yaojia.webterm.transport.OkHttpTermTransport import wang.yaojia.webterm.tlsandroid.IdentityRepository +import wang.yaojia.webterm.wire.AccessTokenSource import wang.yaojia.webterm.wire.HttpTransport import wang.yaojia.webterm.wire.TermTransport import javax.inject.Singleton @@ -81,10 +82,18 @@ public object NetworkModule { .connectionPool(connectionPool) .build() - /** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */ + /** + * WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). + * + * [tokens] ([AuthModule]) is what makes the **WS upgrade** carry `Cookie: webterm_auth=` — the + * REST half gets its cookie from `:api-client`'s single stamping point instead (B5 / §1.1). + */ @Provides @Singleton - public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client) + public fun provideTermTransport( + client: OkHttpClient, + tokens: AccessTokenSource, + ): TermTransport = OkHttpTermTransport(client, tokens) /** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */ @Provides diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt index 6ebbb5d..d6a7fa6 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt @@ -58,6 +58,10 @@ public fun PairingPane( runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false) } }, + // B5 · the access-token half: validated with POST /auth, then kept in the Keystore-backed store + // the transports read from. + tokenStore = env.accessTokenStore, + authProber = env.buildAccessTokenProber(), ) } LaunchedEffect(viewModel) { viewModel.bind(this) } @@ -189,7 +193,8 @@ public fun DiffPane( } val viewModel = remember(resolved, path) { DiffViewModel( - fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), + // The hand-built diff route needs the access-token cookie too (B5 / §1.1). + fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport, env.accessTokenStore), path = path, // Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security). writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)), diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt index 32069cd..9b61afe 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat @@ -70,6 +71,12 @@ import wang.yaojia.webterm.viewmodels.needsExplicitAck * requires an explicit acknowledge, and a tunnel host routes to the device-cert screen when the probe is * cert-gated. Every displayed URL/host is INERT [Text] (no autolink/markdown, plan §8). * + * ### Access token (B5 / ios-completion §1.1) + * The confirm step carries an OPTIONAL access-token field (masked, with a reveal toggle). The typed token + * never enters the ViewModel's UI state — it is passed straight into `confirm()/retry()`, validated by + * `POST /auth`, and (only when the server accepts it) stored in the Keystore-backed store. A wrong token / + * rate-limit / "this host requires a token" comes back as an inert [TokenError] under the field. + * * ### Permissions * - **CAMERA** (QR only) is requested at runtime with a rationale; a denial degrades gracefully to * manual entry (the QR path is never mandatory). @@ -113,7 +120,7 @@ public fun PairingScreen( is PairingUiState.Confirming -> ConfirmStep( state = s, onName = viewModel::setName, - onConfirm = viewModel::confirm, + onConfirm = { ack, token -> viewModel.confirm(acknowledgedPublicRisk = ack, accessToken = token) }, onCancel = viewModel::reset, ) is PairingUiState.Probing -> ProbingStep(host = s.name) @@ -247,10 +254,16 @@ private fun CameraPreview(onScanned: (String) -> Unit) { private fun ConfirmStep( state: PairingUiState.Confirming, onName: (String) -> Unit, - onConfirm: (Boolean) -> Unit, + onConfirm: (Boolean, String) -> Unit, onCancel: () -> Unit, ) { var acknowledged by remember(state.endpoint) { mutableStateOf(false) } + // The token lives ONLY here (and in the encrypted store once accepted) — never in the ViewModel's + // UI state, so it cannot leak through a state snapshot / crash report (§1.1). Keyed on the endpoint + // so a token typed for one host is dropped when the candidate changes, but survives a re-render + // after a wrong-token error (the field keeps what the user typed). + var token by remember(state.endpoint) { mutableStateOf("") } + var tokenVisible by remember(state.endpoint) { mutableStateOf(false) } TierWarningCard(tier = state.tier, highlight = state.awaitingAck && !acknowledged) // The dialed URL is validated but user/QR-sourced — render INERT (plan §8). @@ -268,6 +281,14 @@ private fun ConfirmStep( modifier = Modifier.fillMaxWidth(), ) + AccessTokenField( + token = token, + onToken = { token = it }, + visible = tokenVisible, + onToggleVisible = { tokenVisible = !tokenVisible }, + error = state.tokenError, + ) + if (state.tier.needsExplicitAck) { Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it }) @@ -278,13 +299,58 @@ private fun ConfirmStep( Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") } Button( - onClick = { onConfirm(acknowledged) }, + onClick = { onConfirm(acknowledged, token) }, enabled = !state.tier.needsExplicitAck || acknowledged, modifier = Modifier.weight(1f), ) { Text("连接") } } } +/** + * The optional access-token field (B5 / ios-completion §1.1). Masked by default with an explicit + * reveal toggle (a token is often typed from another screen and mistypes are the #1 failure), and + * `KeyboardType.Password` + `autoCorrectEnabled = false` so the IME never learns or suggests it. + * [error] renders the inert, app-authored copy for the typed [TokenError]. + */ +@Composable +private fun AccessTokenField( + token: String, + onToken: (String) -> Unit, + visible: Boolean, + onToggleVisible: () -> Unit, + error: TokenError?, +) { + OutlinedTextField( + value = token, + onValueChange = onToken, + label = { Text("访问令牌(可选,WEBTERM_TOKEN)") }, + singleLine = true, + isError = error != null, + visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + ), + trailingIcon = { + TextButton(onClick = onToggleVisible) { Text(if (visible) "隐藏" else "显示") } + }, + modifier = Modifier.fillMaxWidth(), + ) + if (error != null) { + Text( + text = PairingCopy.describeToken(error), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } else { + Text( + text = "主机未设置 WEBTERM_TOKEN 时留空即可。", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } +} + @Composable private fun TierWarningCard(tier: WarningTier, highlight: Boolean) { val (title, body) = tierCopy(tier) diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt index b7b88ed..1d8e4cf 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt @@ -18,6 +18,8 @@ import wang.yaojia.webterm.api.models.GitWriteOutcome import wang.yaojia.webterm.api.models.PushResult import wang.yaojia.webterm.api.models.StageResult import wang.yaojia.webterm.api.routes.ApiClient +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpRequest @@ -439,14 +441,23 @@ public class DiffUnavailable(public val status: Int) : Exception("diff unavailab /** * Real [DiffFetcher]: builds the RO `GET /projects/diff` against [endpoint] and decodes tolerantly. * Read-only, so it stamps NO `Origin` header (the CSWSH 铁律 — only guarded routes carry it, plan §4.3). + * + * This route is hand-built (it is not one of `:api-client`'s 12), so the access-token cookie must be + * stamped HERE too: the server's auth gate covers EVERY route, and a missing cookie 401s (B5 / + * ios-completion §1.1). The value comes from the single derivation point ([AuthCookie]); [tokens] is + * the same store the transports read, and a host with no token adds no header at all. */ public class HttpDiffFetcher( private val endpoint: HostEndpoint, private val http: HttpTransport, + private val tokens: AccessTokenSource = AccessTokenSource.NONE, ) : DiffFetcher { override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult { val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST) - val response = http.send(HttpRequest(method = HttpMethod.GET, url = url)) + val headers = tokens.tokenFor(endpoint) + ?.let { mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(it)) } + ?: emptyMap() + val response = http.send(HttpRequest(method = HttpMethod.GET, url = url, headers = headers)) if (response.status != HTTP_OK) throw DiffUnavailable(response.status) return decodeDiffResult(response.body) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt index 5fedf1b..feea73c 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt @@ -1,18 +1,28 @@ package wang.yaojia.webterm.viewmodels +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import wang.yaojia.webterm.api.pairing.AuthProbeResult import wang.yaojia.webterm.api.pairing.PairingError import wang.yaojia.webterm.api.pairing.PairingProbeResult +import wang.yaojia.webterm.api.pairing.probeAccessToken +import wang.yaojia.webterm.api.pairing.runPairingProbe +import wang.yaojia.webterm.hostregistry.AccessTokenStore import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.HostStore +import wang.yaojia.webterm.wire.AccessTokenSource import wang.yaojia.webterm.wire.HostClassifier import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostNetworkTier +import wang.yaojia.webterm.wire.HttpTransport +import wang.yaojia.webterm.wire.TermTransport import java.util.UUID /** @@ -40,24 +50,56 @@ import java.util.UUID * * On probe success the validated host is persisted via [HostStore.upsert]. * - * ### The `WEBTERM_TOKEN` gate (server `src/http/auth.ts`) - * A token-gated host answers the probe's unauthenticated `GET /live-sessions` with **401**, which the - * frozen probe taxonomy can only report as [PairingError.HttpOkButNotWebTerminal] ("端口对吗?") — so - * without this flow such a host is impossible to pair AND the copy is misleading. On that ambiguous - * failure the ViewModel asks [PairingProber.requiresAccessToken]; a gated host routes to - * [PairingUiState.TokenRequired], and [submitAccessToken] posts the token (`POST /auth`) so the shared - * cookie jar picks up the `webterm_auth` cookie, then resumes pairing through the same choke point. - * The token is a SHELL credential: it is a parameter, never a field and never part of any UI state. + * ### The `WEBTERM_TOKEN` gate has TWO ways in, and both end in the same place + * A token-gated host can be met either way round, so both are supported: + * - **Typed up front.** The confirm step has an optional token field; [confirm]/[retry] carry it into + * RULE 5 below (validate with `POST /auth`, store, then probe). + * - **Discovered by the probe.** A host the user did not know was gated answers the probe's + * unauthenticated `GET /live-sessions` with **401**. When the probe classifies that as + * [PairingError.AccessTokenRequired] the user goes back to the confirm step with + * [TokenError.REQUIRED] under the field. An older/ambiguous host instead collapses the 401 into + * [PairingError.HttpOkButNotWebTerminal] ("端口对吗?"), which is actively misleading — so that ONE + * case is re-checked against [PairingProber.requiresAccessToken] and, if the host really is gated, + * routed to the dedicated [PairingUiState.TokenRequired] prompt. [submitAccessToken] posts the token + * (`POST /auth`), which lands the `webterm_auth` cookie in the shared cookie jar, keeps the token in + * [tokenStore] so the WS upgrade's own `Cookie` header can carry it too, then resumes pairing through + * the same choke point. + * Either way the token is a SHELL credential: it is a parameter, never a field and never part of any UI + * state. + * + * ### RULE 5 — the access token is validated and stored BEFORE the probe (B5, ios-completion §1.1) + * When the user typed a token, [confirm]/[retry] first validate it with `POST /auth` ([authProber]) and, + * only if the server ACCEPTED it (204 **with** `Set-Cookie`), persist it to the Keystore-backed + * [tokenStore]. That ordering is load-bearing: the two-step probe's own HTTP legs AND its WS upgrade + * read the token back out of that same store, so they can only authenticate if the token is already in + * it. A 204 **without** `Set-Cookie` means the host has no auth at all — nothing is stored and pairing + * continues (never treated as "authenticated"). A wrong token / rate-limit / malformed token keeps the + * user on the confirm step with a typed [TokenError] and NEVER reaches the network probe. + * + * ### RULE 6 — a pairing that does not end in [PairingUiState.Paired] strands no secret (F2) + * Storing before probing means the store can be left holding a token for a host that never paired (wrong + * port, unreachable, user backs out). So every non-paired exit — probe failure, a throw, and cancellation + * by [reset] or by a superseding attempt — rolls the store back to exactly what it held before this + * attempt ([TokenAttempt]): the previous token for a re-pair, nothing at all for a first pairing. The + * rollback runs under [NonCancellable] so an abandoned attempt still cleans up, and the next attempt + * `join`s the one it supersedes so the two never race on the same key. + * + * The token itself never enters [PairingUiState] — it lives in the screen's own field and is passed in + * per call, so no state snapshot, log or crash report can carry it. * * @param hostStore where a successfully-paired [Host] is saved. * @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests. * @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO). + * @param tokenStore per-host access-token storage (production: the Tink/AndroidKeystore store). + * @param authProber the `POST /auth` validation seam ([accessTokenProber] in production). * @param newId host-id allocator (default random UUID; deterministic in tests). */ public class PairingViewModel( private val hostStore: HostStore, private val prober: PairingProber, private val hasDeviceCert: suspend () -> Boolean, + private val tokenStore: AccessTokenStore, + private val authProber: AccessTokenProber, private val newId: () -> String = { UUID.randomUUID().toString() }, ) { private val _uiState = MutableStateFlow(PairingUiState.Entry()) @@ -118,30 +160,34 @@ public class PairingViewModel( /** * Confirm the on-screen candidate and probe it. For a public host, [acknowledgedPublicRisk] MUST be * true or this re-arms the acknowledge reminder without touching the network (RULE 3). + * [accessToken] is the (optional) `WEBTERM_TOKEN` the user typed — blank means "this host has none". */ - public fun confirm(acknowledgedPublicRisk: Boolean = false) { + public fun confirm(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") { val candidate = _uiState.value.candidate() ?: return - runProbe(candidate, acknowledgedPublicRisk) + runProbe(candidate, acknowledgedPublicRisk, accessToken) } /** * Retry after a failure or a cert-gate refusal. Funnels through the SAME [runProbe] choke point as * [confirm] — so the tunnel cert-gate (RULE 4) cannot be bypassed by retrying. */ - public fun retry(acknowledgedPublicRisk: Boolean = false) { + public fun retry(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") { val candidate = _uiState.value.candidate() ?: return - runProbe(candidate, acknowledgedPublicRisk) + runProbe(candidate, acknowledgedPublicRisk, accessToken) } /** - * Submit the host's shared access token (`WEBTERM_TOKEN`). Only meaningful from - * [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never post a - * credential to a host the user has moved on from. + * Submit the host's shared access token (`WEBTERM_TOKEN`) from the token prompt. Only meaningful + * from [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never + * post a credential to a host the user has moved on from. * - * [token] is passed straight through to the prober and is NEVER stored: no field, no UI state, no - * log. On acceptance the shared cookie jar holds `webterm_auth` and pairing resumes through the same - * choke point as [confirm]/[retry] (so RULE 4's cert-gate still applies); every other outcome - * re-arms the prompt with a distinguishable reason and NEVER auto-retries (429 included). + * This is the DISCOVERED half of the gate; the typed-up-front half is [confirm]'s `accessToken` + * (RULE 5). [token] goes straight to the prober's `POST /auth` and is never held in a field or in + * UI state. On acceptance the shared cookie jar holds `webterm_auth` AND the token is kept in + * [tokenStore] — so the WS upgrade's own `Cookie` header authenticates exactly as it would for a + * typed token — and pairing resumes through the same [performProbe] choke point as [confirm]/[retry] + * (so RULE 4's cert-gate still applies, under RULE 6's rollback). Every other outcome re-arms the + * prompt with a distinguishable reason and NEVER auto-retries (429 included). */ public fun submitAccessToken(token: String) { val state = _uiState.value @@ -149,14 +195,22 @@ public class PairingViewModel( if (token.isBlank()) return val candidate = Candidate(state.endpoint, state.name, state.tier) val scope = scope ?: return - probeJob?.cancel() + val superseded = probeJob + superseded?.cancel() probeJob = scope.launch { + superseded?.join() // same reason as runProbe: never race a superseded attempt's rollback. _uiState.value = PairingUiState.TokenSubmitting(candidate.endpoint, candidate.name, candidate.tier) when (prober.submitAccessToken(candidate.endpoint, token)) { // Straight into the probe body: RULE 3 is already satisfied by construction (reaching the // prompt required a probe, which for a public host required the explicit ack — and an ack // cannot be un-given), while RULE 4's cert-gate lives INSIDE performProbe and re-applies. - TokenSubmission.Accepted -> performProbe(candidate) + // This submit WAS the `POST /auth` validation, so RULE 5 only has to KEEP the token. + TokenSubmission.Accepted -> performProbe(candidate) { + writeToken(candidate, token) { error -> promptForToken(candidate, error); null } + } + // 204 with no Set-Cookie: the host has no auth at all (FROZEN §1.1). Store NOTHING and + // never read it as "authenticated" — same rule as `establishToken`'s AuthDisabled. + TokenSubmission.AuthDisabled -> performProbe(candidate) { TokenAttempt.NONE } TokenSubmission.Rejected -> promptForToken(candidate, TokenError.INVALID) TokenSubmission.RateLimited -> promptForToken(candidate, TokenError.RATE_LIMITED) is TokenSubmission.Unreachable -> promptForToken(candidate, TokenError.UNREACHABLE) @@ -165,7 +219,7 @@ public class PairingViewModel( } } - private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) { + private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean, accessToken: String) { // RULE 3 — public host: no probe until the risk is explicitly acknowledged. This is the // pre-network UI gate, so it lives here (the entry point the user drives), not in performProbe. if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) { @@ -178,17 +232,31 @@ public class PairingViewModel( return } val scope = scope ?: return - probeJob?.cancel() - probeJob = scope.launch { performProbe(candidate) } + val superseded = probeJob + superseded?.cancel() + probeJob = scope.launch { + // Wait out the attempt this one replaces: its RULE 6 rollback must finish before we read or + // write the same store key, or the two attempts race and the loser's undo wins. + superseded?.join() + // RULE 5 — validate + store the token BEFORE probing (the probe authenticates from the store). + performProbe(candidate) { + if (accessToken.isBlank()) TokenAttempt.NONE else establishToken(candidate, accessToken) + } + } } /** * The ONE probe body — reached from [confirm]/[retry] via [runProbe] and from an accepted access - * token. Holds RULE 4 (the cert-gate) so BOTH entry points hit it; RULE 3 (the public-risk ack) is a + * token ([submitAccessToken]). Holds RULE 4 (the cert-gate) so BOTH entry points hit it BEFORE any + * network I/O, then RULE 5's [tokenStep] and RULE 6's rollback; RULE 3 (the public-risk ack) is a * pre-network UI gate and stays in [runProbe]. Runs inside the caller's job (never re-assigns * [probeJob], which would cancel the coroutine it is called from). + * + * @param tokenStep the RULE 5 step for this entry point — validate+store a typed token, or merely + * store one the prompt already validated. Returns the [TokenAttempt] to undo, or **null** after + * publishing why pairing stops (meaning "do not probe"). */ - private suspend fun performProbe(candidate: Candidate) { + private suspend fun performProbe(candidate: Candidate, tokenStep: suspend () -> TokenAttempt?) { val tier = candidate.tier // RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both // re-hit this same guard. @@ -197,18 +265,122 @@ public class PairingViewModel( return } _uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier) - when (val result = prober.probe(candidate.endpoint)) { - is PairingProbeResult.Success -> onProbeSuccess(candidate) - is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error) + val attempt = tokenStep() ?: return + // RULE 6 — anything short of Paired puts the store back the way this attempt found it. + val paired = try { + when (val result = prober.probe(candidate.endpoint)) { + is PairingProbeResult.Success -> onProbeSuccess(candidate) + is PairingProbeResult.Failure -> { + onProbeFailure(candidate, result.error) + false + } + } + } catch (error: Throwable) { + // Cancelled (reset / a newer attempt) or a transport throw — the rule is the same, and + // NonCancellable is what lets an already-cancelled attempt still clean up after itself. + withContext(NonCancellable) { rollBackToken(candidate, attempt) } + throw error + } + if (!paired) rollBackToken(candidate, attempt) + } + + /** + * Validate [token] with `POST /auth` and, if the server accepted it, persist it for this host. + * Returns the [TokenAttempt] to undo should pairing not complete (RULE 6) — [TokenAttempt.NONE] when + * the host turned out to have no auth at all — or **null** after publishing the matching + * [TokenError] / failure state, meaning "stop, do not probe". + */ + private suspend fun establishToken(candidate: Candidate, token: String): TokenAttempt? { + val outcome = try { + authProber.validate(candidate.endpoint, token) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + // A network-level failure is not a token problem — classify it like any other probe error. + onProbeFailure(candidate, PairingError.classify(error, candidate.endpoint)) + return null + } + return when (outcome) { + AuthProbeResult.Accepted -> writeToken(candidate, token) { failToken(candidate, it) } + // 204 with no Set-Cookie: the host has no auth. Store NOTHING (never "assume authenticated") + // and carry on — an open host pairs exactly as it did before this feature. + AuthProbeResult.AuthDisabled -> TokenAttempt.NONE + AuthProbeResult.InvalidToken -> failToken(candidate, TokenError.INVALID) + AuthProbeResult.RateLimited -> failToken(candidate, TokenError.RATE_LIMITED) + AuthProbeResult.Malformed -> failToken(candidate, TokenError.MALFORMED) + is AuthProbeResult.Unexpected -> failToken(candidate, TokenError.UNVERIFIABLE) } } /** - * A failed probe. [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a - * `WEBTERM_TOKEN`-gated host's 401 collapses into — so that one case is re-checked against the token - * gate before the misleading "wrong port?" copy is shown. Every other error is reported verbatim. + * Persist [token], remembering what the store held before so RULE 6 can put it back. [onFailure] + * publishes the reason in whichever step the caller is showing (the confirm field, or the token + * prompt) and returns null, so a token we could not keep never reaches the probe. + */ + private suspend fun writeToken( + candidate: Candidate, + token: String, + onFailure: (TokenError) -> TokenAttempt?, + ): TokenAttempt? { + return try { + val previous = tokenStore.tokenFor(candidate.endpoint) + // put() == false ⇒ the token failed the client-side rule (unreachable after a server ACCEPT). + if (!tokenStore.put(candidate.endpoint, token)) onFailure(TokenError.MALFORMED) + else TokenAttempt(stored = true, previous = previous) + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + // A throw ⇒ encrypted storage failed; never continue with a token we could not keep. + onFailure(TokenError.STORE_FAILED) + } + } + + /** + * Undo [attempt]'s write: restore the previous token (a failed re-pair must not cost the user the + * one that was working) or drop the key entirely when this host had none. Never publishes state — + * the caller has already published the reason pairing stopped. + */ + private suspend fun rollBackToken(candidate: Candidate, attempt: TokenAttempt) { + if (!attempt.stored) return + try { + val previous = attempt.previous + if (previous == null) tokenStore.remove(candidate.endpoint) + else tokenStore.put(candidate.endpoint, previous) + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + // Best effort: encrypted storage just failed on the undo. There is no recovery beyond this, + // and surfacing it would replace the pairing error the user actually needs to read. + } + } + + /** Publish a token-specific error, keeping the user ON the confirm step (the field is there). */ + private fun failToken(candidate: Candidate, error: TokenError): TokenAttempt? { + _uiState.value = PairingUiState.Confirming( + endpoint = candidate.endpoint, + name = candidate.name, + tier = candidate.tier, + tokenError = error, + ) + return null + } + + /** + * A failed probe, with TWO token-gate readings: + * - [PairingError.AccessTokenRequired] — the probe saw the gate's own 401, so the user goes back to + * the confirm step with the token field and [TokenError.REQUIRED]; + * - [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a `WEBTERM_TOKEN`-gated + * host's 401 collapses into on a host the taxonomy cannot classify — so that one case is + * re-checked against [PairingProber.requiresAccessToken] and, when the host really is gated, + * routed to the dedicated prompt instead of the misleading "wrong port?" copy. + * Every other error is reported verbatim. */ private suspend fun onProbeFailure(candidate: Candidate, error: PairingError) { + // The host demands a token we do not have → back to the confirm step to enter one. + if (error == PairingError.AccessTokenRequired) { + failToken(candidate, TokenError.REQUIRED) + return + } if (error == PairingError.HttpOkButNotWebTerminal && prober.requiresAccessToken(candidate.endpoint)) { promptForToken(candidate, error = null) return @@ -222,6 +394,7 @@ public class PairingViewModel( ) } + /** Publish the dedicated token prompt (the discovered-gate step), optionally with a reason. */ private fun promptForToken(candidate: Candidate, error: TokenError?) { _uiState.value = PairingUiState.TokenRequired( endpoint = candidate.endpoint, @@ -231,7 +404,8 @@ public class PairingViewModel( ) } - private suspend fun onProbeSuccess(candidate: Candidate) { + /** Persist the paired host. Returns false when it could not be paired (so RULE 6 undoes the token). */ + private suspend fun onProbeSuccess(candidate: Candidate): Boolean { val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl) if (host == null) { // Unreachable in practice (the endpoint already validated), but never persist a null host. @@ -242,10 +416,11 @@ public class PairingViewModel( error = PairingError.HttpOkButNotWebTerminal, message = PairingCopy.describe(PairingError.HttpOkButNotWebTerminal, candidate.tier), ) - return + return false } hostStore.upsert(host) _uiState.value = PairingUiState.Paired(host) + return true } private fun defaultName(endpoint: HostEndpoint): String = @@ -258,11 +433,13 @@ public class PairingViewModel( * `AppEnvironment.buildPairingProber()` over the ONE shared `OkHttpClient` (so the token cookie the * submit obtains is the same jar every later request and the WS upgrade read from). * - * The two token operations are DEFAULTED so a probe-only double still satisfies the interface. Their - * defaults are deliberately the honest "this seam cannot reach the token gate" answers — `false` and - * [TokenSubmission.Unavailable] — never a silent success. + * The two token operations are DEFAULTED so a probe-only double still satisfies the interface — which + * also leaves exactly one abstract member, so this stays a `fun interface` and a test/production + * binding can be written as `PairingProber { endpoint -> … }`. Their defaults are deliberately the + * honest "this seam cannot reach the token gate" answers — `false` and [TokenSubmission.Unavailable] — + * never a silent success. */ -public interface PairingProber { +public fun interface PairingProber { public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult /** @@ -295,6 +472,15 @@ public sealed interface TokenSubmission { /** 2xx (the server answers `204` to a non-HTML request) — `Set-Cookie: webterm_auth=…` was returned. */ public data object Accepted : TokenSubmission + /** + * **2xx with NO `Set-Cookie: webterm_auth=…`** — this host has `WEBTERM_TOKEN` unset, so there is + * nothing to authenticate (FROZEN §1.1; the same discriminator as [AuthProbeResult.AuthDisabled] on + * the typed-token path). The submitted token MUST NOT be persisted and this MUST NOT be read as + * "authenticated": the host is simply open, and pairing continues exactly as for a zero-config LAN + * deploy. + */ + public data object AuthDisabled : TokenSubmission + /** `401` — the token does not match the host's `WEBTERM_TOKEN`. */ public data object Rejected : TokenSubmission @@ -312,21 +498,35 @@ public sealed interface TokenSubmission { public data object Unavailable : TokenSubmission } -/** Why the access-token prompt is showing an error. Maps to inert, app-authored copy in [PairingCopy]. */ -public enum class TokenError { - /** The submitted token was rejected by the host (`401`). */ - INVALID, - - /** The host is rate-limiting auth attempts (`429`) — wait, do not hammer it. */ - RATE_LIMITED, - - /** The host did not answer the submit (or answered unintelligibly). */ - UNREACHABLE, - - /** The app cannot submit a token at all on this seam. */ - UNAVAILABLE, +/** + * The `POST /auth` access-token validation seam ([probeAccessToken]); faked in tests. Kept separate from + * [PairingProber] so the ORDER (validate+store, then probe) is observable in a unit test. + */ +public fun interface AccessTokenProber { + public suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult } +/** + * Production [AccessTokenProber]: the frozen one-shot `POST /auth` probe over the ONE shared + * `OkHttpClient`'s [HttpTransport]. The token travels in the JSON body only — never a URL, never a log. + */ +public fun accessTokenProber(http: HttpTransport): AccessTokenProber = + AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, http, token) } + +/** + * Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared + * `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav + * layer resolves both transports off the DI graph and hands the resulting prober to the ViewModel — + * `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink + * I/O on a UI thread. + */ +public fun pairingProber( + http: HttpTransport, + ws: TermTransport, + tokens: AccessTokenSource = AccessTokenSource.NONE, +): PairingProber = + PairingProber { endpoint -> runPairingProbe(endpoint, http, ws, tokens) } + // ── Warning tiers (plan §5.4) ─────────────────────────────────────────────────────────────────────── /** @@ -396,6 +596,11 @@ public sealed interface PairingUiState { val name: String, val tier: WarningTier, val awaitingAck: Boolean = false, + /** + * Set when the access-token step failed (or the host demands one). The token VALUE is never + * carried here — only why it went wrong (§1.1: a secret never enters app state). + */ + val tokenError: TokenError? = null, ) : PairingUiState /** The two-step probe is running against [endpoint]. */ @@ -447,9 +652,57 @@ public enum class EntryError { INVALID_URL, } +/** + * Why the access-token step failed. Rendered next to the token field on the confirm step (the typed-token + * path) or under the dedicated [PairingUiState.TokenRequired] prompt (the discovered-gate path), so the + * user can fix it in place (B5 / ios-completion §1.1). + */ +public enum class TokenError { + /** The host answered 401 to the probe: it HAS a token configured and we presented none/a wrong one. */ + REQUIRED, + + /** `POST /auth` → 401: the typed/submitted token is wrong. */ + INVALID, + + /** + * `POST /auth` → **429**: the server's 10/min/IP limiter tripped. ONE case for both entry points — + * the typed-token path and the prompt's submit path hit the same limiter on the same route, so two + * enum constants with two different strings were the merge keeping both branches' names for one + * server answer. Surfaced as-is; the app NEVER auto-retries. + */ + RATE_LIMITED, + + /** The typed token cannot be a server token at all (charset / 16–512 length). No request was sent. */ + MALFORMED, + + /** `POST /auth` answered something unexpected — the token could not be verified either way. */ + UNVERIFIABLE, + + /** The host did not answer the submit (or answered unintelligibly) — distinct from a wrong token. */ + UNREACHABLE, + + /** The app cannot submit a token at all on this seam (a probe-only prober). */ + UNAVAILABLE, + + /** The token was correct but encrypted on-device storage refused to keep it. */ + STORE_FAILED, +} + /** The endpoint+name+tier a probe runs against, recovered from whatever state currently holds one. */ private data class Candidate(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) +/** + * What one pairing attempt wrote to the access-token store, and therefore what has to be put back if the + * attempt does not end in [PairingUiState.Paired] (RULE 6). [previous] is what the store held for this + * host beforehand — non-null only for a re-pair, whose working token a failed attempt must not cost. + */ +private data class TokenAttempt(val stored: Boolean, val previous: String?) { + companion object { + /** No token was written (none typed, or the host has no auth) — nothing to undo. */ + val NONE: TokenAttempt = TokenAttempt(stored = false, previous = null) + } +} + private fun PairingUiState.candidate(): Candidate? = when (this) { is PairingUiState.Confirming -> Candidate(endpoint, name, tier) is PairingUiState.Probing -> Candidate(endpoint, name, tier) @@ -462,6 +715,32 @@ private fun PairingUiState.candidate(): Candidate? = when (this) { /** Maps the [PairingError] taxonomy to inert, app-authored Chinese copy (never a server string, §8). */ internal object PairingCopy { + /** + * Inert, app-authored copy for each [TokenError]. Never echoes the token itself. + * + * A wrong token, an unreachable host and a rate-limit read differently on purpose — collapsing them + * would tell the user to re-type a token that was fine — and the rate-limit line tells them to WAIT, + * because the app never retries by itself. + */ + fun describeToken(error: TokenError): String = when (error) { + TokenError.REQUIRED -> + "该主机启用了访问令牌(WEBTERM_TOKEN)。请填写访问令牌后再连接。" + TokenError.INVALID -> + "访问令牌不正确。请核对主机上的 WEBTERM_TOKEN 后重试(区分大小写)。" + TokenError.RATE_LIMITED -> + "尝试次数过多,主机已暂时限流(每分钟最多 10 次)。请等待约一分钟再试——App 不会自动重试。" + TokenError.MALFORMED -> + "访问令牌格式不合法:需 16–512 个字符,且只能包含 A–Z a–z 0–9 . _ ~ + / = -。" + TokenError.UNVERIFIABLE -> + "无法验证访问令牌:服务器返回了意外响应。请确认地址和端口指向 web-terminal。" + TokenError.UNREACHABLE -> + "提交访问令牌时主机没有正常响应。请检查网络与地址后重试。" + TokenError.UNAVAILABLE -> + "当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。" + TokenError.STORE_FAILED -> + "无法在本机安全保存访问令牌(加密存储写入失败)。请重试。" + } + fun describe(error: PairingError, tier: WarningTier): String = when (error) { is PairingError.HostUnreachable -> "无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。" @@ -489,22 +768,8 @@ internal object PairingCopy { // is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked"). if (tier == WarningTier.TUNNEL) "客户端证书无效或已被吊销。请在“设备证书”里重新导入后再试。" else "TLS 握手失败:无法验证服务器证书。" + PairingError.AccessTokenRequired -> describeToken(TokenError.REQUIRED) PairingError.Timeout -> "连接超时。主机可能不可达,或响应过慢。" } - - /** - * Copy for the access-token prompt. A wrong token and an unreachable host read differently on - * purpose, and the rate-limit line tells the user to WAIT — the app never retries by itself. - */ - fun describeToken(error: TokenError): String = when (error) { - TokenError.INVALID -> - "访问令牌不正确。请核对主机上 WEBTERM_TOKEN 的值(区分大小写)后重试。" - TokenError.RATE_LIMITED -> - "尝试次数过多,主机已暂时限流(每分钟最多 10 次)。请等待约一分钟再试——App 不会自动重试。" - TokenError.UNREACHABLE -> - "提交访问令牌时主机没有正常响应。请检查网络与地址后重试。" - TokenError.UNAVAILABLE -> - "当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。" - } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/ApiClientFactory.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/ApiClientFactory.kt index c50c856..8583c38 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/ApiClientFactory.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/ApiClientFactory.kt @@ -2,6 +2,7 @@ package wang.yaojia.webterm.wiring import dagger.Lazy import wang.yaojia.webterm.api.routes.ApiClient +import wang.yaojia.webterm.wire.AccessTokenSource import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpTransport import javax.inject.Inject @@ -23,6 +24,7 @@ import javax.inject.Singleton @Singleton public class ApiClientFactory @Inject constructor( private val http: Lazy, + private val tokens: AccessTokenSource, ) { /** * Resolve the shared [HttpTransport] (→ builds the shared `OkHttpClient`) so the slow mTLS I/O runs @@ -32,6 +34,14 @@ public class ApiClientFactory @Inject constructor( http.get() } - /** Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read. */ - public fun create(endpoint: HostEndpoint): ApiClient = ApiClient(endpoint = endpoint, http = http.get()) + /** + * Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read. + * + * Every client is handed the shared [AccessTokenSource], so EVERY REST route — including the + * read-only GETs and the push-decision POSTs — presents this host's access token when one is stored + * (B5 / ios-completion §1.1). The token is read per request, so pairing a host mid-run takes effect + * without rebuilding anything. + */ + public fun create(endpoint: HostEndpoint): ApiClient = + ApiClient(endpoint = endpoint, http = http.get(), tokens = tokens) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt index e1fdbd9..981d145 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt @@ -14,7 +14,9 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import wang.yaojia.webterm.api.models.UiConfig import wang.yaojia.webterm.api.pairing.PairingProbeResult +import wang.yaojia.webterm.api.pairing.probeAccessToken import wang.yaojia.webterm.api.pairing.runPairingProbe +import wang.yaojia.webterm.hostregistry.AccessTokenStore import wang.yaojia.webterm.hostregistry.AuthCookieRecord import wang.yaojia.webterm.hostregistry.AuthCookieStore import wang.yaojia.webterm.hostregistry.Host @@ -25,6 +27,7 @@ import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.transport.AuthCookieJar import wang.yaojia.webterm.transport.AuthCookieSnapshot import wang.yaojia.webterm.transport.authCookieHostKey +import wang.yaojia.webterm.viewmodels.AccessTokenProber import wang.yaojia.webterm.viewmodels.ApiClientQueueGateway import wang.yaojia.webterm.viewmodels.HostRemovalGateway import wang.yaojia.webterm.viewmodels.PairingProber @@ -32,9 +35,12 @@ import wang.yaojia.webterm.viewmodels.PersistedUnreadWatermarkStore import wang.yaojia.webterm.viewmodels.QueueGateway import wang.yaojia.webterm.viewmodels.TokenSubmission import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpRequest +import wang.yaojia.webterm.wire.HttpResponse import wang.yaojia.webterm.wire.HttpTransport import wang.yaojia.webterm.wire.TermTransport import java.net.URI @@ -81,6 +87,11 @@ import javax.inject.Singleton public class AppEnvironment @Inject constructor( public val hostStore: HostStore, public val lastSessionStore: LastSessionStore, + /** + * B5 · per-host access tokens (`WEBTERM_TOKEN`), Tink/AndroidKeystore-encrypted. The pairing screen + * WRITES it; the transports read the same instance through [AccessTokenSource] (see `AuthModule`). + */ + public val accessTokenStore: AccessTokenStore, public val apiClientFactory: ApiClientFactory, public val sessionEngineFactory: SessionEngineFactory, public val coldStartPolicy: ColdStartPolicy, @@ -147,12 +158,16 @@ public class AppEnvironment @Inject constructor( * The submit rides the SAME client as everything else, which is the whole point: the `webterm_auth` * cookie it earns lands in the shared [AuthCookieJar] and is therefore replayed on every later REST * call AND on the WS upgrade. + * + * The probe also authenticates from [accessTokenStore] (B5 / §1.1) — its two HTTP legs AND its WS + * upgrade read the token the pairing flow may have just stored, so both halves of the gate work: + * a token typed up-front (stored, then probed) and one submitted at the prompt (cookie jar). */ public fun buildPairingProber(): PairingProber = object : PairingProber { private val gateway = AccessTokenGateway { httpTransportLazy.get() } override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult = - runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) + runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get(), accessTokenStore) override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean = gateway.requiresAccessToken(endpoint) @@ -161,6 +176,14 @@ public class AppEnvironment @Inject constructor( gateway.submit(endpoint, token) } + /** + * Build the `POST /auth` access-token validation prober (B5). Like [buildPairingProber], the shared + * transport is resolved lazily INSIDE the call so building this on the UI thread does no mTLS/OkHttp + * work. + */ + public fun buildAccessTokenProber(): AccessTokenProber = + AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, httpTransportLazy.get(), token) } + /** * Build the session-list / manage-page thumbnail pipeline for one host (§6.7). Per-host because its * preview fetch is bound to that host's [ApiClient][wang.yaojia.webterm.api.routes.ApiClient] @@ -225,6 +248,7 @@ public class AppEnvironment @Inject constructor( hostStore = hostStore, lastSessionStore = lastSessionStore, authCookieStore = authCookieStoreLazy.get(), + accessTokenStore = accessTokenStore, watermarkStore = sessionWatermarkStore, pushUnregister = pushUnregister, currentPushToken = { pushTokenSource.currentToken() }, @@ -390,9 +414,11 @@ public fun interface PushTokenSource { * pushing notifications to the device forever. It is **best-effort**: a host that is switched off * must still be removable, and the local state is what the user asked us to forget. * 2. **Then the local state**, in the order that keeps a failure retryable: the session watermarks, the - * last-session pointer, the persisted + live `webterm_auth` cookie, and the paired record LAST. Any - * throw propagates with the host still paired, so the user can simply try again — the UI treats that - * as "nothing was removed" ([HostRemovalGateway]). + * last-session pointer, the persisted + live `webterm_auth` cookie, **the Keystore-held access + * token** ([AccessTokenStore] — the same credential's second durable home; the cookie's value IS + * the token, `src/http/auth.ts`), and the paired record LAST. Any throw propagates with the host + * still paired, so the user can simply try again — the UI treats that as "nothing was removed" + * ([HostRemovalGateway]). * * ### What is deliberately NOT removed * - **The device certificate / AndroidKeyStore identity** — it is ONE app-wide mTLS identity shared by @@ -409,6 +435,14 @@ public class HostRemover( private val hostStore: HostStore, private val lastSessionStore: LastSessionStore, private val authCookieStore: AuthCookieStore, + /** + * The OTHER durable home of the same shell credential (B5). `authCookieStore` alone is not + * "every trace": the token is what the cookie's value IS (`src/http/auth.ts` builds + * `webterm_auth=`), and [AccessTokenStore] is keyed by + * [wang.yaojia.webterm.wire.HostEndpoint.originHeader] — NOT by the paired-host record — so a + * retained token silently re-authenticates the moment the same URL is added again. + */ + private val accessTokenStore: AccessTokenStore, private val watermarkStore: SessionWatermarkStore, private val pushUnregister: HostPushUnregister, private val currentPushToken: suspend () -> String?, @@ -433,6 +467,9 @@ public class HostRemover( authCookieStore.removeHost(hostKey) clearLiveCookies(hostKey) // the in-memory jar too, or the credential rides the next dial } + // The SAME credential's other durable home (B5). Keyed by origin, not by host id, so it + // survives the record and silently re-authenticates a re-added URL unless dropped here. + accessTokenStore.remove(host.endpoint) hostStore.remove(hostId) } @@ -504,6 +541,11 @@ public class AccessTokenGateway(private val http: () -> HttpTransport) { /** * `POST /auth` with [token] in an urlencoded form body. * + * A 2xx is NOT on its own an acceptance: the presence of `Set-Cookie: webterm_auth=…` is the frozen + * §1.1 discriminator between "the token is correct" ([TokenSubmission.Accepted]) and "this host has + * no auth configured" ([TokenSubmission.AuthDisabled]) — and the caller persists a secret on the + * first but must not on the second. + * * [token] appears in the BODY only — never the URL, never a header, never the returned outcome, never * a log line — and nothing here retains it. */ @@ -526,18 +568,34 @@ public class AccessTokenGateway(private val http: () -> HttpTransport) { return TokenSubmission.Unreachable(reason = error::class.simpleName ?: UNKNOWN_ERROR) } return when { - response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES -> TokenSubmission.Accepted + // 2xx splits in TWO (FROZEN §1.1): only a `Set-Cookie: webterm_auth=…` means the token was + // ACCEPTED. A bare 204 means the host has `WEBTERM_TOKEN` unset — nothing to authenticate, so + // the caller must persist nothing. Collapsing them puts a shell credential in the Keystore + // for an open host and makes the client believe it is authenticated. + response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES -> + if (response.carriesAuthCookie()) TokenSubmission.Accepted else TokenSubmission.AuthDisabled response.status == HTTP_UNAUTHORIZED -> TokenSubmission.Rejected response.status == HTTP_TOO_MANY_REQUESTS -> TokenSubmission.RateLimited else -> TokenSubmission.Unreachable(reason = "HTTP ${response.status}") } } + /** + * True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the + * value must name [AuthCookie.NAME] — a proxy's own `Set-Cookie` proves nothing about the gate. Same + * rule as `:api-client`'s `AuthProbe.carriesAuthCookie`; never logs the value. + */ + private fun HttpResponse.carriesAuthCookie(): Boolean = + headers.entries.any { (name, value) -> + name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=") + } + private companion object { const val LIVE_SESSIONS_PATH = "/live-sessions" const val AUTH_PATH = "/auth" const val TOKEN_FIELD = "token" const val CONTENT_TYPE_HEADER = "Content-Type" + const val SET_COOKIE_HEADER = "Set-Cookie" const val FORM_CONTENT_TYPE = "application/x-www-form-urlencoded" const val HTTP_OK = 200 const val HTTP_MULTIPLE_CHOICES = 300 diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..5dda001 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/test/java/wang/yaojia/webterm/components/ReconnectBannerTest.kt b/android/app/src/test/java/wang/yaojia/webterm/components/ReconnectBannerTest.kt index d108436..986f898 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/components/ReconnectBannerTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/components/ReconnectBannerTest.kt @@ -109,4 +109,27 @@ class ReconnectBannerTest { val connecting = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Connecting)) assertEquals(connecting, bannerModel(connecting, Output("some bytes"))) } + + // ── 5. B5 · a 401 upgrade is terminal AND offers no new session ─────────────── + + @Test + fun `an unauthorized failure is terminal with no spinner and no new-session action`() { + val model = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED))) + + val failed = model as BannerModel.Failed + assertEquals(FailureReason.UNAUTHORIZED, failed.reason) + assertFalse(failed.showsSpinner, "a 401 is permanent — never show a retry spinner") + assertFalse( + failed.offersNewSession, + "a new session would 401 too; the fix is the access token, not another session", + ) + } + + @Test + fun `an unauthorized failure outranks later transient events`() { + val failed = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED))) + + assertEquals(failed, bannerModel(failed, Connection(ConnectionState.Connecting))) + assertEquals(failed, bannerModel(failed, Connection(ConnectionState.Reconnecting(1, 2.seconds)))) + } } diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt index f800be2..78943c3 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt @@ -14,6 +14,9 @@ import wang.yaojia.webterm.api.models.CommitResult import wang.yaojia.webterm.api.models.GitWriteOutcome import wang.yaojia.webterm.api.models.PushResult import wang.yaojia.webterm.api.models.StageResult +import wang.yaojia.webterm.testsupport.FakeHttpTransport +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.HostEndpoint /** * A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag @@ -291,4 +294,35 @@ class DiffViewModelTest { assertTrue(DiffUiState(canWrite = true).writeEnabled) assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled) } + + // ── B5 · the hand-built diff route carries the access-token cookie ─────────────────────────── + + @Test + fun `the real diff fetcher stamps the auth cookie and never an Origin`() = runTest { + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")) + val http = FakeHttpTransport() + val url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0" + http.queueSuccess(url = url, body = """{"files":[]}""".toByteArray()) + val fetcher = HttpDiffFetcher(endpoint, http, AccessTokenSource { "0123456789abcdefTOKEN" }) + + fetcher.fetch("/repo", staged = false, base = null) + + val request = http.recordedRequests.single() + assertEquals("webterm_auth=0123456789abcdefTOKEN", request.headers["Cookie"]) + assertFalse(request.headers.containsKey("Origin"), "the diff route is read-only") + } + + @Test + fun `the real diff fetcher sends no cookie for a host without a token`() = runTest { + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")) + val http = FakeHttpTransport() + http.queueSuccess( + url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0", + body = """{"files":[]}""".toByteArray(), + ) + + HttpDiffFetcher(endpoint, http).fetch("/repo", staged = false, base = null) + + assertFalse(http.recordedRequests.single().headers.containsKey("Cookie")) + } } diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingTokenViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingTokenViewModelTest.kt new file mode 100644 index 0000000..466b063 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingTokenViewModelTest.kt @@ -0,0 +1,374 @@ +package wang.yaojia.webterm.viewmodels + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.api.pairing.AuthProbeResult +import wang.yaojia.webterm.api.pairing.PairingError +import wang.yaojia.webterm.api.pairing.PairingProbeResult +import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore +import wang.yaojia.webterm.hostregistry.InMemoryHostStore +import wang.yaojia.webterm.wire.HostEndpoint +import java.io.IOException + +/** + * B5 · the access-token half of pairing (FROZEN contract, ios-completion §1.1). The pairing screen is + * where a token is entered, validated with `POST /auth`, and stored — so this pins: + * - **order**: the token is validated and stored BEFORE the two-step probe runs, because the probe's + * own HTTP+WS legs must already be able to authenticate; + * - the four `/auth` outcomes → the right UI state (and, for 204-no-Set-Cookie, **no** token stored); + * - a wrong token / rate-limit keeps the user ON the confirm step with a typed [TokenError] (the field + * is right there) instead of dropping them into a generic failure; + * - a host that answers 401 to the probe asks for a token ([TokenError.REQUIRED]); + * - **no stranded secret** (F2): the flip side of storing before probing is that a pairing which does + * not reach [PairingUiState.Paired] — failed probe, or abandoned mid-probe — must roll the store back + * to exactly what it held before, restoring a re-paired host's previous token rather than clobbering it; + * - the token NEVER appears in the UI state (no accidental leak into a state dump / crash report). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PairingTokenViewModelTest { + + private companion object { + const val LAN = "http://10.0.0.5:3000" + const val TOKEN = "0123456789abcdefTOKEN" + + /** The token an already-paired host holds, so a failed re-pair can be shown not to clobber it. */ + const val PREVIOUS_TOKEN = "0123456789abcdefOLD1" + + /** A cert-gated native-tunnel host (`WarningTier.TUNNEL`). */ + const val TUNNEL = "https://star.terminal.yaojia.wang" + } + + /** Records every `/auth` validation so ordering vs the pairing probe is directly assertable. */ + private class FakeAuthProber(var result: (String) -> AuthProbeResult) : AccessTokenProber { + val calls = mutableListOf() + override suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult { + calls += token + return result(token) + } + } + + private class FakeProber(var result: (HostEndpoint) -> PairingProbeResult) : PairingProber { + val calls = mutableListOf() + override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult { + calls += endpoint + return result(endpoint) + } + } + + private fun endpoint(url: String = LAN): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl(url)) + + private fun newVm( + prober: PairingProber, + authProber: AccessTokenProber, + tokenStore: InMemoryAccessTokenStore = InMemoryAccessTokenStore(), + store: InMemoryHostStore = InMemoryHostStore(), + ) = PairingViewModel( + hostStore = store, + prober = prober, + hasDeviceCert = { false }, + tokenStore = tokenStore, + authProber = authProber, + newId = { "fixed-id" }, + ) + + // ── happy path: validate → store → probe ──────────────────────────────────────────────────── + + @Test + fun `a correct token is validated, stored, and only THEN is the host probed`() = + runTest(UnconfinedTestDispatcher()) { + val order = mutableListOf() + val authProber = FakeAuthProber { order += "auth"; AuthProbeResult.Accepted } + val prober = FakeProber { order += "probe"; PairingProbeResult.Success(it) } + val tokens = InMemoryAccessTokenStore() + val hosts = InMemoryHostStore() + val vm = newVm(prober, authProber, tokens, hosts) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + assertEquals(listOf("auth", "probe"), order, "the probe must be able to authenticate itself") + assertEquals(TOKEN, tokens.tokenFor(endpoint()), "an accepted token is persisted per host") + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + assertEquals(1, hosts.loadAll().size) + } + + @Test + fun `a blank token skips the auth probe entirely so an open LAN host pairs as before`() = + runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { AuthProbeResult.Accepted } + val prober = FakeProber { PairingProbeResult.Success(it) } + val tokens = InMemoryAccessTokenStore() + val vm = newVm(prober, authProber, tokens) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + assertTrue(authProber.calls.isEmpty(), "no token typed ⇒ never call POST /auth") + assertEquals(1, prober.calls.size) + assertNull(tokens.tokenFor(endpoint())) + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + } + + // ── the four frozen /auth outcomes ────────────────────────────────────────────────────────── + + @Test + fun `204 without Set-Cookie means auth is disabled - nothing is stored but pairing continues`() = + runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { AuthProbeResult.AuthDisabled } + val prober = FakeProber { PairingProbeResult.Success(it) } + val tokens = InMemoryAccessTokenStore() + val vm = newVm(prober, authProber, tokens) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + assertNull(tokens.tokenFor(endpoint()), "a host without auth must not get a stored token") + assertEquals(1, prober.calls.size, "the host is open — pairing proceeds") + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + } + + @Test + fun `a wrong token keeps the user on the confirm step with an INVALID token error`() = + runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { AuthProbeResult.InvalidToken } + val prober = FakeProber { PairingProbeResult.Success(it) } + val tokens = InMemoryAccessTokenStore() + val vm = newVm(prober, authProber, tokens) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + assertEquals(TokenError.INVALID, state.tokenError) + assertTrue(prober.calls.isEmpty(), "a wrong token must not proceed to the probe") + assertNull(tokens.tokenFor(endpoint()), "a rejected token is never stored") + } + + @Test + fun `a rate-limited auth attempt surfaces RATE_LIMITED`() = runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { AuthProbeResult.RateLimited } + val vm = newVm(FakeProber { PairingProbeResult.Success(it) }, authProber) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + // ONE constant for the server's ONE 429 — both entry points hit the same /auth limiter. + assertEquals(TokenError.RATE_LIMITED, state.tokenError) + } + + @Test + fun `a malformed token is reported without any network call`() = runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { AuthProbeResult.Malformed } + val prober = FakeProber { PairingProbeResult.Success(it) } + val vm = newVm(prober, authProber) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = "short") + + val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + assertEquals(TokenError.MALFORMED, state.tokenError) + assertTrue(prober.calls.isEmpty()) + } + + @Test + fun `an unexpected auth status never counts as success`() = runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { AuthProbeResult.Unexpected(302) } + val prober = FakeProber { PairingProbeResult.Success(it) } + val vm = newVm(prober, authProber) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + assertEquals(TokenError.UNVERIFIABLE, state.tokenError) + assertTrue(prober.calls.isEmpty()) + } + + @Test + fun `a transport failure during auth validation maps to the normal pairing failure copy`() = + runTest(UnconfinedTestDispatcher()) { + val authProber = FakeAuthProber { throw IOException("connection refused") } + val vm = newVm(FakeProber { PairingProbeResult.Success(it) }, authProber) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + val state = assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value) + assertTrue(state.message.isNotBlank()) + } + + // ── discovery: the host demands a token we do not have ────────────────────────────────────── + + @Test + fun `a probe that 401s asks for an access token`() = runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber { PairingProbeResult.Failure(PairingError.AccessTokenRequired) } + val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + + val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value) + assertEquals(TokenError.REQUIRED, state.tokenError) + } + + @Test + fun `retrying with the token now typed pairs successfully`() = runTest(UnconfinedTestDispatcher()) { + var demandToken = true + val prober = FakeProber { + if (demandToken) PairingProbeResult.Failure(PairingError.AccessTokenRequired) + else PairingProbeResult.Success(it) + } + val tokens = InMemoryAccessTokenStore() + val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + demandToken = false + vm.retry(accessToken = TOKEN) + + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + assertEquals(TOKEN, tokens.tokenFor(endpoint())) + } + + // ── a pairing that does not succeed must not strand the secret (F2) ───────────────────────── + // + // The token is stored BEFORE the probe on purpose (the probe's own HTTP+WS legs read it back out + // of the store to authenticate). The flip side is that every path which does NOT end in `Paired` + // has to put the store back the way it found it — otherwise a mistyped port leaves a live token + // on disk for a host that was never paired, and nothing ever removes it. + + @Test + fun `a probe failure after the token was accepted leaves no stranded token`() = + runTest(UnconfinedTestDispatcher()) { + val tokens = InMemoryAccessTokenStore() + var tokenVisibleToProbe: String? = null + val prober = PairingProber { endpoint -> + tokenVisibleToProbe = tokens.tokenFor(endpoint) + PairingProbeResult.Failure(PairingError.HostUnreachable("connect refused")) + } + val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + assertEquals(TOKEN, tokenVisibleToProbe, "the probe's legs must still authenticate from the store") + assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value) + assertNull(tokens.tokenFor(endpoint()), "a host that never paired must not keep a stored token") + } + + @Test + fun `a failed re-pair restores the token the host was already paired with`() = + runTest(UnconfinedTestDispatcher()) { + val tokens = InMemoryAccessTokenStore() + tokens.put(endpoint(), PREVIOUS_TOKEN) + val prober = FakeProber { PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) } + val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + assertEquals( + PREVIOUS_TOKEN, + tokens.tokenFor(endpoint()), + "a failed re-pair rolls back to the token that was already working, it does not clobber it", + ) + } + + /** + * The one test that needs a REAL suspension point mid-probe, so it runs on the default + * [kotlinx.coroutines.test.StandardTestDispatcher] rather than the unconfined dispatcher the rest of + * the file uses. Note `runCurrent()`, not `advanceUntilIdle()`: the probe runs in `backgroundScope`, + * and `advanceUntilIdle` stops as soon as no FOREGROUND work is left — it would never run the probe + * at all (verified: the coroutine did not start). + */ + @Test + fun `abandoning an in-flight probe rolls the freshly stored token back`() = runTest { + val probeReached = CompletableDeferred() + val neverAnswers = CompletableDeferred() + val tokens = InMemoryAccessTokenStore() + val vm = newVm( + prober = { probeReached.complete(Unit); neverAnswers.await() }, + authProber = FakeAuthProber { AuthProbeResult.Accepted }, + tokenStore = tokens, + ) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + runCurrent() + + assertTrue(probeReached.isCompleted, "the probe must be in flight for this to test anything") + assertEquals(TOKEN, tokens.tokenFor(endpoint()), "the in-flight probe reads the candidate token") + + vm.reset() // the user backs out while the probe hangs + runCurrent() + + assertNull(tokens.tokenFor(endpoint()), "an abandoned pairing must not leave the secret behind") + } + + @Test + fun `a cert-gated tunnel host is refused before the token is validated or stored`() = + runTest(UnconfinedTestDispatcher()) { + val tokens = InMemoryAccessTokenStore() + val authProber = FakeAuthProber { AuthProbeResult.Accepted } + val prober = FakeProber { PairingProbeResult.Success(it) } + val vm = newVm(prober, authProber, tokens) // hasDeviceCert = false + + vm.bind(backgroundScope) + + vm.onManualEntry(TUNNEL) + vm.confirm(accessToken = TOKEN) + + assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value) + assertTrue(authProber.calls.isEmpty(), "the cert-gate refuses BEFORE any network I/O") + assertNull(tokens.tokenFor(endpoint(TUNNEL)), "nothing is stored for a host that was never probed") + } + + // ── secrecy ───────────────────────────────────────────────────────────────────────────────── + + @Test + fun `the token never appears in the UI state`() = runTest(UnconfinedTestDispatcher()) { + val vm = newVm( + FakeProber { PairingProbeResult.Failure(PairingError.AccessTokenRequired) }, + FakeAuthProber { AuthProbeResult.Accepted }, + ) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm(accessToken = TOKEN) + + assertTrue( + !vm.uiState.value.toString().contains(TOKEN), + "a secret must not be reachable through a state snapshot / toString", + ) + } + + @Test + fun `every token error has actionable copy`() { + TokenError.entries.forEach { error -> + assertTrue(PairingCopy.describeToken(error).isNotBlank(), "copy for $error") + } + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingViewModelTest.kt index cfc4ea1..7870a8c 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingViewModelTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingViewModelTest.kt @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import wang.yaojia.webterm.api.pairing.PairingError import wang.yaojia.webterm.api.pairing.PairingProbeResult +import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore import wang.yaojia.webterm.hostregistry.InMemoryHostStore import wang.yaojia.webterm.wire.HostEndpoint @@ -44,6 +45,11 @@ class PairingViewModelTest { hostStore = store, prober = prober, hasDeviceCert = { hasCert }, + // B5: these tests cover the NO-token paths — an empty store plus a prober that must never be + // called (a blank token skips `POST /auth` entirely). The token paths live in + // PairingTokenViewModelTest. + tokenStore = InMemoryAccessTokenStore(), + authProber = { _, _ -> error("POST /auth must not be called when no token was typed") }, newId = { "fixed-id" }, ) diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt index 91ac004..40d6bf1 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/AccessTokenGatewayTest.kt @@ -55,6 +55,15 @@ class AccessTokenGatewayTest { private fun status(code: Int): (HttpRequest) -> HttpResponse = { HttpResponse(status = code, body = ByteArray(0)) } + /** 204 **with** the gate's `Set-Cookie: webterm_auth=…` — the "token accepted" answer. */ + private fun acceptedWithCookie(): (HttpRequest) -> HttpResponse = { + HttpResponse( + status = 204, + body = ByteArray(0), + headers = mapOf("Set-Cookie" to "webterm_auth=$TOKEN; Path=/; HttpOnly; SameSite=Strict"), + ) + } + private fun gateway(transport: HttpTransport) = AccessTokenGateway { transport } // ── Gate detection: is this host token-gated, or is it just not a web-terminal? ────────────────── @@ -95,8 +104,37 @@ class AccessTokenGatewayTest { // ── POST /auth status mapping ──────────────────────────────────────────────────────────────────── @Test - fun `204 accepts the token`() = runTest { - assertEquals(TokenSubmission.Accepted, gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN)) + fun `204 with the auth cookie accepts the token`() = runTest { + assertEquals( + TokenSubmission.Accepted, + gateway(RecordingTransport(acceptedWithCookie())).submit(endpoint(), TOKEN), + ) + } + + /** + * F4 · the FROZEN §1.1 discriminator, on the submit path too: **204 with no `Set-Cookie` means the + * host has `WEBTERM_TOKEN` unset**, so there is nothing to authenticate. Before the merge this only + * mattered to the jar; now `PairingViewModel.submitAccessToken` writes durable Keystore storage on + * `Accepted`, so collapsing the two readings would persist a shell credential for a host that has no + * auth at all — and let the client believe it is authenticated. Mirrors `AuthProbeResult.AuthDisabled` + * on the proactive path (`AuthProbe.kt:19-24`). + */ + @Test + fun `204 without Set-Cookie means the host has auth disabled, not an accepted token`() = runTest { + assertEquals( + TokenSubmission.AuthDisabled, + gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN), + ) + } + + @Test + fun `only OUR cookie counts as an acceptance`() = runTest { + // A proxy's own Set-Cookie proves nothing about the WEBTERM_TOKEN gate. + val foreign: (HttpRequest) -> HttpResponse = { + HttpResponse(204, ByteArray(0), mapOf("set-cookie" to "proxy_session=abc123; Path=/")) + } + + assertEquals(TokenSubmission.AuthDisabled, gateway(RecordingTransport(foreign)).submit(endpoint(), TOKEN)) } @Test diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt index 9843f2e..8be6064 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/HostRemoverTest.kt @@ -10,9 +10,11 @@ import org.junit.jupiter.api.Test import wang.yaojia.webterm.hostregistry.AuthCookieRecord import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.hostregistry.HostStore +import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore import wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore +import wang.yaojia.webterm.wire.HostEndpoint import java.util.UUID /** @@ -32,10 +34,15 @@ class HostRemoverTest { private companion object { const val BASE = "http://laptop.local:3000" + + /** A real shell credential shape (`AccessTokenRule`: 16–512 of `[A-Za-z0-9._~+/=-]`). */ + const val ACCESS_TOKEN = "0123456789abcdefTOKEN" val SESSION_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555") val SESSION_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666") fun host(id: String = "h1"): Host = Host.create(id = id, name = "laptop", baseUrl = BASE)!! + + fun endpoint(baseUrl: String = BASE): HostEndpoint = HostEndpoint.fromBaseUrl(baseUrl)!! } private class RecordingHostStore(initial: List) : HostStore { @@ -71,7 +78,9 @@ class HostRemoverTest { ): Triple { val lastSession = InMemoryLastSessionStore() val cookies = InMemoryAuthCookieStore() + val tokens = InMemoryAccessTokenStore() val watermarks = InMemorySessionWatermarkStore() + tokens.put(endpoint(), ACCESS_TOKEN) lastSession.setLastSessionId(SESSION_1.toString(), "h1") cookies.upsert( AuthCookieRecord( @@ -91,19 +100,21 @@ class HostRemoverTest { hostStore = hosts, lastSessionStore = lastSession, authCookieStore = cookies, + accessTokenStore = tokens, watermarkStore = watermarks, pushUnregister = push, currentPushToken = { token }, clearLiveCookies = { key -> cookieJarClears += key }, log = { }, // android.util.Log is not mocked under JVM unit tests ) - return Triple(remover, push, Quad(hosts, lastSession, cookies, watermarks)) + return Triple(remover, push, Quad(hosts, lastSession, cookies, tokens, watermarks)) } private data class Quad( val hosts: RecordingHostStore, val lastSession: InMemoryLastSessionStore, val cookies: InMemoryAuthCookieStore, + val tokens: InMemoryAccessTokenStore, val watermarks: InMemorySessionWatermarkStore, ) @@ -122,6 +133,41 @@ class HostRemoverTest { assertTrue(stores.watermarks.loadAll().isEmpty()) } + /** + * F1 · the merge composed a SECOND durable home for the same shell credential + * ([wang.yaojia.webterm.hostregistry.AccessTokenStore], Tink/AndroidKeystore) and left the un-pair + * path pointed at only the first one. The token IS the cookie's value (`src/http/auth.ts` builds + * `webterm_auth=`), so keeping it is keeping a full-shell credential for a host the user + * asked us to forget — and it is LIVE, because `AccessTokenSource.tokenFor` keys off the endpoint's + * origin, not the paired-host record: re-adding the same URL with the token field left blank would + * silently re-authenticate. Mirrors iOS `HostRemovalTests.removalLeavesNoTokenBehind`. + */ + @Test + fun `un-pairing leaves no access token behind`() = runTest { + val (remover, _, stores) = fixture() + assertEquals(ACCESS_TOKEN, stores.tokens.tokenFor(endpoint())) // precondition: it really is there + + remover.removeHost("h1", listOf(SESSION_1.toString())) + + assertNull( + stores.tokens.tokenFor(endpoint()), + "the access token is the cookie's own value — un-pairing must not keep a shell credential", + ) + } + + @Test + fun `un-pairing one host leaves another host's token alone`() = runTest { + val other = Host.create(id = "h2", name = "other", baseUrl = "http://desk.local:3000")!! + val hosts = RecordingHostStore(listOf(host(), other)) + val (remover, _, stores) = fixture(hosts = hosts) + stores.tokens.put(other.endpoint, ACCESS_TOKEN) + + remover.removeHost("h1", listOf(SESSION_1.toString())) + + assertNull(stores.tokens.tokenFor(endpoint())) + assertEquals(ACCESS_TOKEN, stores.tokens.tokenFor(other.endpoint), "token removal is host-scoped") + } + @Test fun `the remote DELETE runs while the host is still paired`() = runTest { val hosts = RecordingHostStore(listOf(host())) diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt index 7efb297..884c9a0 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/PairingTokenFlowTest.kt @@ -11,6 +11,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import wang.yaojia.webterm.api.pairing.PairingError import wang.yaojia.webterm.api.pairing.PairingProbeResult +import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore import wang.yaojia.webterm.hostregistry.InMemoryHostStore import wang.yaojia.webterm.viewmodels.PairingProber import wang.yaojia.webterm.viewmodels.PairingUiState @@ -78,10 +79,16 @@ class PairingTokenFlowTest { prober: PairingProber, hasCert: () -> Boolean = { false }, store: InMemoryHostStore = InMemoryHostStore(), + tokenStore: InMemoryAccessTokenStore = InMemoryAccessTokenStore(), ) = PairingViewModel( hostStore = store, prober = prober, hasDeviceCert = { hasCert() }, + // This file drives the DISCOVERED half of the gate (prompt → submit), so the token never comes + // in through `confirm(accessToken = …)` and `POST /auth` is the prober's `submitAccessToken`, + // never this seam. An accepted submit is still KEPT in the store, hence a real one here. + tokenStore = tokenStore, + authProber = { _, _ -> error("the typed-token POST /auth path is PairingTokenViewModelTest's") }, newId = { "fixed-id" }, ) @@ -158,6 +165,52 @@ class PairingTokenFlowTest { assertEquals(LAN, store.loadAll().single().endpoint.baseUrl) } + /** + * F4 · the frozen §1.1 rule, on the reactive (prompt) path. A **204 without `Set-Cookie`** means the + * host has no auth configured at all: the submitted token must NOT reach durable storage and the + * client must NOT consider itself authenticated. Pairing still continues — an open host pairs exactly + * as it did before the feature existed (same as `AuthProbeResult.AuthDisabled` on the typed path). + */ + @Test + fun `a host with auth disabled pairs without ever persisting the submitted token`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { prober.gated = false; TokenSubmission.AuthDisabled } + val tokenStore = InMemoryAccessTokenStore() + val vm = newVm(prober, tokenStore = tokenStore) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(TOKEN) + + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + assertEquals( + null, + tokenStore.tokenFor(endpoint(LAN)), + "204 without Set-Cookie = auth disabled — a secret must never be persisted for it", + ) + } + + @Test + fun `an accepted token IS persisted, so the WS upgrade can authenticate`() = + runTest(UnconfinedTestDispatcher()) { + val prober = FakeProber(gated = true) + prober.probeResult = gatedThenOpen(prober) + prober.submission = { prober.gated = false; TokenSubmission.Accepted } + val tokenStore = InMemoryAccessTokenStore() + val vm = newVm(prober, tokenStore = tokenStore) + vm.bind(backgroundScope) + + vm.onManualEntry(LAN) + vm.confirm() + vm.submitAccessToken(TOKEN) + + assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value) + assertEquals(TOKEN, tokenStore.tokenFor(endpoint(LAN))) + } + @Test fun `a wrong token re-arms the prompt with an invalid-token error and does not re-probe`() = runTest(UnconfinedTestDispatcher()) { diff --git a/android/host-registry/build.gradle.kts b/android/host-registry/build.gradle.kts index 2235b72..3ea8140 100644 --- a/android/host-registry/build.gradle.kts +++ b/android/host-registry/build.gradle.kts @@ -45,6 +45,10 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.core) implementation(libs.androidx.datastore.preferences) + // Tink AEAD encrypts the per-host access-token blob at rest under an AndroidKeystore-wrapped + // master key (B5 / ios-completion §1.1). A SEPARATE keyset from :client-tls-android's cert store — + // different secrets, different lifecycles, and no module edge to the TLS half. + implementation(libs.tink.android) testImplementation(libs.bundles.unit.test) testRuntimeOnly(libs.junit.platform.launcher) diff --git a/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStoreTest.kt b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStoreTest.kt new file mode 100644 index 0000000..668065a --- /dev/null +++ b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStoreTest.kt @@ -0,0 +1,107 @@ +package wang.yaojia.webterm.hostregistry + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.HostEndpoint +import java.util.UUID + +/** + * Instrumented (device) contract tests for [TinkAccessTokenStore] — Tink's AEAD + the + * AndroidKeystore-wrapped master key need a real device Keystore, so these run on hardware (no + * emulator in the build env → compiled here, executed in device QA, plan §7 / DEVICE_QA_CHECKLIST). + * + * They pin the same contract the JVM [InMemoryAccessTokenStoreTest] pins for the double, PLUS the two + * things only the real store can prove: the token **survives a restart** (a fresh store instance + * decrypts the blob) and the **at-rest bytes are ciphertext**, never the token in the clear. + */ +@RunWith(AndroidJUnit4::class) +class TinkAccessTokenStoreTest { + + private companion object { + const val TOKEN = "0123456789abcdefTOKEN" + } + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + /** A store on a per-test keyset/pref-file so tests never share state. */ + private fun newStore(suffix: String): TinkAccessTokenStore = TinkAccessTokenStore( + context = context, + keysetName = "test_token_keyset_$suffix", + prefFileName = "test_token_prefs_$suffix", + masterKeyUri = "android-keystore://test_token_master_$suffix", + ) + + private fun endpoint(url: String = "http://10.0.0.5:3000"): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl(url)) + + @Test + fun put_then_tokenFor_returns_the_token() = runBlocking { + val store = newStore(UUID.randomUUID().toString()) + + assertTrue(store.put(endpoint(), TOKEN)) + + assertEquals(TOKEN, store.tokenFor(endpoint())) + } + + @Test + fun a_fresh_store_instance_decrypts_the_persisted_token() = runBlocking { + val suffix = UUID.randomUUID().toString() + newStore(suffix).put(endpoint(), TOKEN) + + // A new instance = a cold app start: no in-memory cache, must decrypt from disk. + assertEquals(TOKEN, newStore(suffix).tokenFor(endpoint())) + } + + @Test + fun the_token_is_never_stored_in_the_clear() = runBlocking { + val suffix = UUID.randomUUID().toString() + newStore(suffix).put(endpoint(), TOKEN) + + val raw = context + .getSharedPreferences("test_token_prefs_$suffix", Context.MODE_PRIVATE) + .all + .values + .joinToString(" ") { it.toString() } + + assertFalse("the at-rest blob must be ciphertext", raw.contains(TOKEN)) + } + + @Test + fun remove_drops_the_token_durably() = runBlocking { + val suffix = UUID.randomUUID().toString() + val store = newStore(suffix) + store.put(endpoint(), TOKEN) + + store.remove(endpoint()) + + assertNull(store.tokenFor(endpoint())) + assertNull("a removed token must not come back on a cold start", newStore(suffix).tokenFor(endpoint())) + } + + @Test + fun a_malformed_token_is_rejected_without_touching_storage() = runBlocking { + val suffix = UUID.randomUUID().toString() + val store = newStore(suffix) + + assertFalse(store.put(endpoint(), "short")) + + assertNull(store.tokenFor(endpoint())) + } + + @Test + fun tokens_are_keyed_per_host() = runBlocking { + val store = newStore(UUID.randomUUID().toString()) + store.put(endpoint("http://10.0.0.5:3000"), TOKEN) + + assertNull(store.tokenFor(endpoint("http://10.0.0.9:3000"))) + assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/"))) + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AccessTokenStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AccessTokenStore.kt new file mode 100644 index 0000000..d042aac --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/AccessTokenStore.kt @@ -0,0 +1,70 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import wang.yaojia.webterm.wire.AccessTokenRule +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * Per-host storage for the optional shared access token (`WEBTERM_TOKEN`, ios-completion §1.1). + * + * It IS an [AccessTokenSource], so the very same instance that the pairing screen writes to is what + * `:api-client` (every REST route) and `:transport-okhttp` (the WS upgrade) read from — one store, no + * adapter, no cache to fall out of sync. + * + * ### Keying: the canonical origin, not the raw base URL + * The key is [HostEndpoint.originHeader] — already single-point-derived, lower-cased, default-port + * normalized. So `http://box:3000`, `HTTP://box:3000/` and `http://box:3000/some/path` are ONE host + * (they are literally the same server, and the token is the server's), while a different port or host + * is a different key. Storing under the raw `baseUrl` would silently lose the token when the same + * server was re-paired from a URL with a trailing slash. + * + * ### Security contract (non-negotiable) + * Implementations MUST keep the token device-only (Android-Keystore-wrapped, excluded from backup), + * MUST validate through [AccessTokenRule] before persisting, and MUST NEVER log it. + */ +public interface AccessTokenStore : AccessTokenSource { + /** The token stored for [endpoint]'s canonical origin, or null. Never logs. */ + override suspend fun tokenFor(endpoint: HostEndpoint): String? + + /** + * Store [token] for [endpoint] (replacing any previous one). Returns **false** and stores nothing + * when the token fails [AccessTokenRule] — a value the server could never accept must not be + * persisted (nor round-tripped to disk) at all. + */ + public suspend fun put(endpoint: HostEndpoint, token: String): Boolean + + /** Drop [endpoint]'s token. Idempotent — removing an unknown host is a no-op. */ + public suspend fun remove(endpoint: HostEndpoint) +} + +/** + * In-memory [AccessTokenStore]. Lives in `main` (not `test`) on purpose — it doubles for the encrypted + * store in this module's contract tests AND in the `:app` pairing-ViewModel tests (mirrors + * [InMemoryHostStore]). + * + * A [Mutex] serializes the read-modify-write; the state is an immutable map replaced wholesale on every + * change (never mutated in place). + */ +public class InMemoryAccessTokenStore( + initial: Map = emptyMap(), +) : AccessTokenStore { + private val mutex = Mutex() + + // Defensive copy so a mutable map handed to the ctor cannot alias our state. + private var tokensByOrigin: Map = initial.toMap() + + override suspend fun tokenFor(endpoint: HostEndpoint): String? = + mutex.withLock { tokensByOrigin[endpoint.originHeader] } + + override suspend fun put(endpoint: HostEndpoint, token: String): Boolean { + val normalized = AccessTokenRule.normalize(token) ?: return false + mutex.withLock { tokensByOrigin = tokensByOrigin + (endpoint.originHeader to normalized) } + return true + } + + override suspend fun remove(endpoint: HostEndpoint) { + mutex.withLock { tokensByOrigin = tokensByOrigin - endpoint.originHeader } + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStore.kt new file mode 100644 index 0000000..839df98 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/TinkAccessTokenStore.kt @@ -0,0 +1,153 @@ +package wang.yaojia.webterm.hostregistry + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeyTemplates +import com.google.crypto.tink.RegistryConfiguration +import com.google.crypto.tink.aead.AeadConfig +import com.google.crypto.tink.integration.android.AndroidKeysetManager +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import wang.yaojia.webterm.wire.AccessTokenRule +import wang.yaojia.webterm.wire.HostEndpoint +import java.io.IOException + +/** + * The production [AccessTokenStore]: per-host access tokens AEAD-encrypted with Tink under an + * **AndroidKeystore-wrapped master key** (ios-completion §1.1 storage rule — the Android analogue of + * iOS Keychain `…AfterFirstUnlockThisDeviceOnly` + no `kSecAttrSynchronizable`). + * + * What that buys, concretely: + * - the master key lives in the Keystore (`android-keystore://…`) and is **non-exportable**, so the + * on-disk ciphertext is useless on any other device — device-only, exactly like the cert store; + * - the blob sits in an app-private `SharedPreferences` file (uninstall-wiped) and the app manifest + * sets `android:allowBackup="false"`, so it is **never** in a cloud/adb backup; + * - nothing here logs, and the token never enters a URL (see `AuthCookie`). + * + * Deliberately a SEPARATE keyset/pref-file/master-key from `:client-tls-android`'s `TinkCertStore`: + * different secrets with different lifecycles must not share an AEAD keyset (and this module must not + * gain a dependency on the TLS module). The ~20 lines of Tink setup are the price of that isolation. + * + * All disk + crypto work hops to [io] (never `Main`); the decrypted map is cached in memory after the + * first read so the per-request [tokenFor] on the WS-dial / REST path is a cheap map lookup, and a + * [Mutex] serializes the read-modify-write of a mutation. A blob that fails to decrypt/decode (tamper, + * version skew, a wiped Keystore key after a device restore) degrades to "no tokens" — the user + * re-enters the token — rather than throwing on every request. + */ +public class TinkAccessTokenStore( + context: Context, + private val io: CoroutineDispatcher = Dispatchers.IO, + private val keysetName: String = DEFAULT_KEYSET_NAME, + private val prefFileName: String = DEFAULT_PREF_FILE, + private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI, +) : AccessTokenStore { + private val appContext: Context = context.applicationContext + private val mutex = Mutex() + + /** Decrypted `origin → token` map; null until the first read. Replaced wholesale, never mutated. */ + private var cache: Map? = null + + // Built on first use (registers AEAD, loads-or-creates the Keystore-wrapped keyset) — slow, so it + // only ever happens inside a withContext(io) block. + private val aead: Aead by lazy { buildAead() } + + override suspend fun tokenFor(endpoint: HostEndpoint): String? = + loadTokens()[endpoint.originHeader] + + /** + * Validate → encrypt → durably persist → publish to the cache, in that order: a token the server + * could never accept returns false without touching disk, and the in-memory view is only updated + * after the write is on disk (so a failed write can never leave a "stored" token that vanishes on + * the next launch). + * + * @throws IOException when the durable write fails (infrastructure failure, distinct from `false`, + * which means the token itself was malformed). + */ + override suspend fun put(endpoint: HostEndpoint, token: String): Boolean { + val normalized = AccessTokenRule.normalize(token) ?: return false + mutex.withLock { + val updated = readThrough() + (endpoint.originHeader to normalized) + persist(updated) + cache = updated + } + return true + } + + override suspend fun remove(endpoint: HostEndpoint) { + mutex.withLock { + val current = readThrough() + if (!current.containsKey(endpoint.originHeader)) return // idempotent, no needless write + val updated = current - endpoint.originHeader + persist(updated) + cache = updated + } + } + + private suspend fun loadTokens(): Map = + cache ?: mutex.withLock { + val loaded = readThrough() + cache = loaded + loaded + } + + /** Decrypt the stored blob (or the cache when warm). MUST be called with [mutex] held. */ + private suspend fun readThrough(): Map = + cache ?: withContext(io) { decodeBlob(prefs().getString(BLOB_KEY, null)) } + + private suspend fun persist(tokens: Map) { + withContext(io) { + val plaintext = JSON.encodeToString(TOKEN_MAP_SERIALIZER, tokens).encodeToByteArray() + val ciphertext = aead.encrypt(plaintext, ASSOCIATED_DATA) + val committed = prefs().edit() + .putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP)) + .commit() // synchronous + reports failure (as TinkCertStore); apply() would hide it + if (!committed) throw IOException("failed to durably persist the access-token blob") + } + } + + /** Any decrypt/decode failure ⇒ "no tokens" (never crash a request path on a corrupt blob). */ + private fun decodeBlob(encoded: String?): Map { + if (encoded == null) return emptyMap() + return runCatching { + val ciphertext = Base64.decode(encoded, Base64.NO_WRAP) + val plaintext = aead.decrypt(ciphertext, ASSOCIATED_DATA) + JSON.decodeFromString(TOKEN_MAP_SERIALIZER, plaintext.decodeToString()) + }.getOrDefault(emptyMap()) + } + + private fun buildAead(): Aead { + AeadConfig.register() + val keysetHandle = AndroidKeysetManager.Builder() + .withSharedPref(appContext, keysetName, prefFileName) + .withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE)) + .withMasterKeyUri(masterKeyUri) + .build() + .keysetHandle + return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) + } + + private fun prefs(): SharedPreferences = + appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE) + + public companion object { + private const val DEFAULT_KEYSET_NAME = "webterm_access_token_keyset" + private const val DEFAULT_PREF_FILE = "webterm_access_token_prefs" + private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_access_token_master_key" + private const val AEAD_KEY_TEMPLATE = "AES256_GCM" + private const val BLOB_KEY = "access_tokens_blob" + + /** AEAD associated data — binds the ciphertext to this context so a lifted blob won't decrypt. */ + private val ASSOCIATED_DATA: ByteArray = "webterm.host-registry.access-tokens".toByteArray() + + private val JSON = Json { ignoreUnknownKeys = true } + private val TOKEN_MAP_SERIALIZER = MapSerializer(String.serializer(), String.serializer()) + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAccessTokenStoreTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAccessTokenStoreTest.kt new file mode 100644 index 0000000..c5e7b16 --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryAccessTokenStoreTest.kt @@ -0,0 +1,118 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * B5 · the per-host access-token store contract (ios-completion §1.1), exercised through the pure + * in-memory double (the Tink/AndroidKeyStore-backed implementation is instrumented → device QA): + * - a token is keyed by the endpoint's CANONICAL origin, so the same server dialed with a trailing + * slash / different case / a path resolves to the SAME token, and a different host never does; + * - a malformed token is REJECTED at the boundary and never stored; + * - `remove` is idempotent, and the store doubles as the `AccessTokenSource` the transports consume. + */ +class InMemoryAccessTokenStoreTest { + private companion object { + const val TOKEN = "0123456789abcdefTOKEN" + const val OTHER_TOKEN = "fedcba9876543210OTHER" + } + + private fun endpoint(url: String): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" } + + @Test + fun `a stored token is returned for the same host`() = runTest { + val store = InMemoryAccessTokenStore() + val host = endpoint("http://10.0.0.5:3000") + + assertTrue(store.put(host, TOKEN)) + + assertEquals(TOKEN, store.tokenFor(host)) + } + + @Test + fun `an unknown host has no token`() = runTest { + val store = InMemoryAccessTokenStore() + store.put(endpoint("http://10.0.0.5:3000"), TOKEN) + + assertNull(store.tokenFor(endpoint("http://10.0.0.6:3000"))) + } + + @Test + fun `the key is the canonical origin so the same server matches however it was dialed`() = runTest { + val store = InMemoryAccessTokenStore() + store.put(endpoint("http://10.0.0.5:3000"), TOKEN) + + assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/"))) + assertEquals(TOKEN, store.tokenFor(endpoint("HTTP://10.0.0.5:3000"))) + } + + @Test + fun `a different port is a different host`() = runTest { + val store = InMemoryAccessTokenStore() + store.put(endpoint("http://10.0.0.5:3000"), TOKEN) + + assertNull(store.tokenFor(endpoint("http://10.0.0.5:3001"))) + } + + @Test + fun `putting again replaces the token for that host only`() = runTest { + val store = InMemoryAccessTokenStore() + val a = endpoint("http://10.0.0.5:3000") + val b = endpoint("http://10.0.0.6:3000") + store.put(a, TOKEN) + store.put(b, OTHER_TOKEN) + + store.put(a, OTHER_TOKEN) + + assertEquals(OTHER_TOKEN, store.tokenFor(a)) + assertEquals(OTHER_TOKEN, store.tokenFor(b)) + } + + @Test + fun `a malformed token is rejected and never stored`() = runTest { + val store = InMemoryAccessTokenStore() + val host = endpoint("http://10.0.0.5:3000") + + assertFalse(store.put(host, "short"), "below the server's 16-char minimum") + assertFalse(store.put(host, "has spaces in it here"), "outside the server charset") + + assertNull(store.tokenFor(host), "a rejected token must not reach storage") + } + + @Test + fun `a whitespace-padded token is trimmed once on the way in`() = runTest { + val store = InMemoryAccessTokenStore() + val host = endpoint("http://10.0.0.5:3000") + + store.put(host, " $TOKEN\n") + + assertEquals(TOKEN, store.tokenFor(host)) + } + + @Test + fun `remove drops the token and is idempotent`() = runTest { + val store = InMemoryAccessTokenStore() + val host = endpoint("http://10.0.0.5:3000") + store.put(host, TOKEN) + + store.remove(host) + store.remove(host) + + assertNull(store.tokenFor(host)) + } + + @Test + fun `the store is usable directly as the transports' AccessTokenSource`() = runTest { + val store: wang.yaojia.webterm.wire.AccessTokenSource = InMemoryAccessTokenStore( + initial = mapOf(endpoint("http://10.0.0.5:3000").originHeader to TOKEN), + ) + + assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000"))) + } +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt index ee68997..047d033 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt @@ -20,6 +20,7 @@ import wang.yaojia.webterm.wire.TermTransport import wang.yaojia.webterm.wire.TimelineEvent import wang.yaojia.webterm.wire.TransportConnection import wang.yaojia.webterm.wire.Tunables +import wang.yaojia.webterm.wire.UnauthorizedUpgradeException /** * The session lifecycle state machine (A14, plan §5 / §6.6). The Android analogue of the iOS @@ -51,6 +52,9 @@ import wang.yaojia.webterm.wire.Tunables * frame would be dropped rather than emitted onto a newer generation. * - **oversized replay is terminal:** a frame past [maxFrameBytes] is the non-retryable * [FailureReason.REPLAY_TOO_LARGE] — distinct from a retryable disconnect (never fed to backoff). + * - **a 401 upgrade is terminal:** an [UnauthorizedUpgradeException] out of the dial (missing/invalid + * access token, or a foreign Origin) is the non-retryable [FailureReason.UNAUTHORIZED] — it is + * permanent until the user fixes configuration, so it NEVER enters the back-off ladder. */ public class SessionEngine( private val transport: TermTransport, @@ -76,6 +80,19 @@ public class SessionEngine( /** One live connection plus its optional ping capability (null = plain [TermTransport]). */ private class OpenConn(val connection: TransportConnection, val pinger: ConnectionPinger?) + /** + * Outcome of one dial. A dial failure is retryable ([Retryable]) EXCEPT an upgrade 401 + * ([Unauthorized]), which is permanent until the user fixes the access token / Origin allow-list — + * so it must never be fed to the back-off ladder (see [FailureReason.UNAUTHORIZED]). + */ + private sealed interface DialResult { + class Open(val conn: OpenConn) : DialResult + + data object Retryable : DialResult + + data object Unauthorized : DialResult + } + private val eventsChannel = Channel(Channel.UNLIMITED) /** Engine→UI event stream (single consumer; the App's EventBus fans out per R10). */ @@ -157,7 +174,14 @@ public class SessionEngine( } private suspend fun runOneConnection(gen: Int): EndCause { - val open = dialOrNull() ?: return EndCause.Disconnected + val open = when (val dial = dial()) { + DialResult.Retryable -> return EndCause.Disconnected + DialResult.Unauthorized -> { + emit(Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED))) + return EndCause.Terminal // no back-off: a 401 is permanent until reconfigured + } + is DialResult.Open -> dial.conn + } if (closed) return abandon(open) // close() landed during dial → detach, never publish if (!attachFirst(open)) return EndCause.Disconnected if (closed) return abandon(open) // close() landed during attach → detach, never publish @@ -184,20 +208,37 @@ public class SessionEngine( return EndCause.Terminal } - private suspend fun dialOrNull(): OpenConn? = + private suspend fun dial(): DialResult = try { if (transport is PingableTermTransport) { val pingable = transport.connectPingable(endpoint) - OpenConn(pingable.connection, pingable.pinger) + DialResult.Open(OpenConn(pingable.connection, pingable.pinger)) } else { - OpenConn(transport.connect(endpoint), null) + DialResult.Open(OpenConn(transport.connect(endpoint), null)) } } catch (e: CancellationException) { throw e - } catch (_: Throwable) { - null // connect-time failure → retryable + } catch (error: Throwable) { + // A 401 upgrade is the ONE non-retryable dial failure; everything else backs off. + if (isUnauthorized(error)) DialResult.Unauthorized else DialResult.Retryable } + /** + * True iff [error] (or a bounded, cycle-guarded walk of its causes) is the typed 401-upgrade + * rejection. The cause walk matters because a transport may wrap it in its own `IOException`. + */ + private fun isUnauthorized(error: Throwable): Boolean { + var current: Throwable? = error + var depth = 0 + while (current != null && depth < MAX_CAUSE_DEPTH) { + if (current is UnauthorizedUpgradeException) return true + if (current === current.cause) return false // defensive: never loop on a self-cause + current = current.cause + depth++ + } + return false + } + /** attach-first (+ replay [lastDims]) BEFORE publishing the connection. */ private suspend fun attachFirst(open: OpenConn): Boolean = try { @@ -412,5 +453,8 @@ public class SessionEngine( public companion object { /** Max [AwayDigest.recent] entries carried in the once-per-reconnect digest. */ public const val DEFAULT_DIGEST_LIMIT: Int = 20 + + /** Bound on the `cause` walk in [isUnauthorized] (never trust an error graph not to cycle). */ + private const val MAX_CAUSE_DEPTH: Int = 8 } } diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent.kt index ca01027..dc340e9 100644 --- a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent.kt +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEvent.kt @@ -68,4 +68,14 @@ public enum class FailureReason { * back-off. UI copy: lower the server's SCROLLBACK_BYTES or raise the client cap. */ REPLAY_TOO_LARGE, + + /** + * The server answered the WS upgrade with **HTTP 401** + * ([wang.yaojia.webterm.wire.UnauthorizedUpgradeException]) — the host's access token + * (`WEBTERM_TOKEN`) is missing/wrong, or our `Origin` is not on its allow-list. Both are + * permanent until the user fixes configuration, so — exactly like [REPLAY_TOO_LARGE] — the + * engine goes terminal and NEVER enters the back-off ladder (a retry storm against a 401 is + * pointless and looks like an attack). UI copy: go fix / re-enter the access token. + */ + UNAUTHORIZED, } diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineUnauthorizedTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineUnauthorizedTest.kt new file mode 100644 index 0000000..8d88e77 --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineUnauthorizedTest.kt @@ -0,0 +1,110 @@ +package wang.yaojia.webterm.session + +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakeTermTransport +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.UnauthorizedUpgradeException +import java.io.IOException +import kotlin.time.Duration.Companion.seconds + +/** + * B5 · a **401 on the WS upgrade is TERMINAL** (ios-completion §1.1): the engine emits + * `Failed(UNAUTHORIZED)` and stops — it must NEVER enter the reconnect back-off ladder, because a + * missing/invalid access token (or a foreign Origin) 401s deterministically forever. Same treatment as + * `REPLAY_TOO_LARGE`. + * + * Driven with the frozen [FakeTermTransport] double under `runTest` virtual time (`runCurrent` / + * `advanceTimeBy`, matching `SessionEngineTest`) — no OkHttp, no host, no wall-clock waits. + */ +class SessionEngineUnauthorizedTest { + + private fun endpoint(): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")) + + private fun TestScope.newEngine(transport: FakeTermTransport): SessionEngine = + SessionEngine(transport = transport, endpoint = endpoint(), scope = backgroundScope) + + /** Live-updating sink of everything the engine emits (single consumer, as in SessionEngineTest). */ + private fun TestScope.collectEvents(engine: SessionEngine): List { + val out = mutableListOf() + backgroundScope.launch { engine.events.collect { out += it } } + return out + } + + private fun List.connectionStates(): List = + filterIsInstance().map { it.state } + + @Test + fun `a 401 upgrade ends the engine terminally with UNAUTHORIZED and never dials again`() = runTest { + // Arrange: every dial would be rejected with the typed 401 (the server 401s until a token is stored). + val transport = FakeTermTransport() + repeat(3) { transport.scriptConnectFailure(UnauthorizedUpgradeException()) } + val engine = newEngine(transport) + val events = collectEvents(engine) + + // Act + engine.start() + testScheduler.runCurrent() + testScheduler.advanceTimeBy(60.seconds) // a back-off ladder, if entered, would have fired by now + testScheduler.runCurrent() + + // Assert: exactly ONE dial, a terminal Failed(UNAUTHORIZED), and no Reconnecting event at all. + assertEquals(1, transport.connectAttempts.size, "a 401 must not be retried") + assertEquals( + listOf(ConnectionState.Connecting, ConnectionState.Failed(FailureReason.UNAUTHORIZED)), + events.connectionStates(), + "a terminal 401 must never enter the back-off ladder", + ) + } + + @Test + fun `an ordinary transport failure still reconnects with back-off`() = runTest { + // Regression lock: only the typed 401 is terminal — a plain IOException stays retryable. + val transport = FakeTermTransport() + transport.scriptConnectFailure(IOException("connection refused")) + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + testScheduler.advanceTimeBy(1.seconds) // first rung of the ladder + testScheduler.runCurrent() + + assertEquals(2, transport.connectAttempts.size, "a retryable failure must redial") + assertTrue( + events.connectionStates().any { it is ConnectionState.Reconnecting }, + "a retryable failure must announce the back-off", + ) + assertTrue( + events.connectionStates().none { it is ConnectionState.Failed }, + "a retryable failure must not be reported as a terminal failure", + ) + engine.close() + testScheduler.runCurrent() + } + + @Test + fun `a 401 wrapped as a cause of the transport error is still terminal`() = runTest { + // OkHttp hands back its own IOException; the transport wraps the 401 so the CAUSE carries it. + val transport = FakeTermTransport() + transport.scriptConnectFailure(IOException("upgrade failed", UnauthorizedUpgradeException())) + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + testScheduler.advanceTimeBy(60.seconds) + testScheduler.runCurrent() + + assertEquals(1, transport.connectAttempts.size) + assertEquals( + FailureReason.UNAUTHORIZED, + events.connectionStates().filterIsInstance().single().reason, + ) + } +} diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieJar.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieJar.kt index 2ea293c..b3c8336 100644 --- a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieJar.kt +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieJar.kt @@ -3,6 +3,7 @@ package wang.yaojia.webterm.transport import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl +import wang.yaojia.webterm.wire.AuthCookie import java.util.concurrent.ConcurrentHashMap /** @@ -15,11 +16,29 @@ import java.util.concurrent.ConcurrentHashMap * `BridgeInterceptor` runs for WebSocket calls too. * * ── Isolation (security-load-bearing) ──────────────────────────────────────────────────────────── - * The cookie is a **shell credential**. Two independent layers keep it on its own host: + * The cookie is a **shell credential**. Three independent layers keep it on its own host, and keep + * everything else out: * 1. storage is partitioned by [authCookieHostKey] (`scheme://host:port`), so another host's key * simply has no entry to read; * 2. what survives that lookup is still filtered through OkHttp's own `Cookie.matches(url)`, which - * applies the RFC domain/path rules and refuses to put a `Secure` cookie on cleartext. + * applies the RFC domain/path rules and refuses to put a `Secure` cookie on cleartext; + * 3. **only [AuthCookie.NAME] is ever kept or returned** — see below. + * + * ── Why the name filter is load-bearing, not tidiness (F2) ─────────────────────────────────────── + * The app carries TWO mechanisms for the same cookie: this jar, and the hand-written + * `Cookie: webterm_auth=` that `:api-client` stamps on every REST route and [OkHttpTermTransport] + * stamps on the WS upgrade (the FROZEN §1.1 contract). OkHttp's `BridgeInterceptor` (4.12.0) reconciles + * them with: + * ``` + * val cookies = cookieJar.loadForRequest(userRequest.url) + * if (cookies.isNotEmpty()) requestBuilder.header("Cookie", cookieHeader(cookies)) + * ``` + * — the guard is "the jar returned ANYTHING", not "the jar has a `webterm_auth` for this host", and + * `header(...)` REPLACES the whole header. So storing a foreign cookie meant any TLS-terminating + * intermediary that sets one (Cloudflare Access, a captive portal, a future auth edge) silently + * stripped the token off every REST call **and off the WS upgrade**, where a 401 is terminal. Keeping + * the jar to exactly one name makes that impossible instead of merely unlikely, and stops a chatty + * host from evicting the credential through [MAX_COOKIES_PER_HOST]. * * Expiry is enforced on BOTH sides of the boundary: expired entries are pruned when read (and the * pruning is pushed to storage) and dropped when restored, so a stale credential is never @@ -48,28 +67,32 @@ public class AuthCookieJar( val hostKey = authCookieHostKey(url) val stored = byHostKey[hostKey] ?: return emptyList() val live = pruneExpired(hostKey, stored) - // Second layer: RFC domain/path match + the Secure-over-cleartext refusal. - return live.filter { it.matches(url) } + // Second layer: RFC domain/path match + the Secure-over-cleartext refusal. Third layer: our + // name only — a non-empty return here REPLACES the hand-written `Cookie` header (see the + // BridgeInterceptor note above), so nothing foreign may ever be what it replaces it with. + return live.filter { it.isOurs() && it.matches(url) } } override fun saveFromResponse(url: HttpUrl, cookies: List) { - if (cookies.isEmpty()) return + val ours = cookies.filter { it.isOurs() } + if (ours.isEmpty()) return val hostKey = authCookieHostKey(url) val now = clock() synchronized(writeLock) { - store(hostKey, byHostKey[hostKey].orEmpty().merged(cookies, now)) + store(hostKey, byHostKey[hostKey].orEmpty().merged(ours, now)) } } /** - * Rehydrate one host's cookies at cold start (from `:host-registry`). Already-expired snapshots - * and structurally invalid ones are dropped. This is a hydration path, not a change, so the + * Rehydrate one host's cookies at cold start (from `:host-registry`). Already-expired snapshots, + * structurally invalid ones and anything not named [AuthCookie.NAME] are dropped — records at rest + * are untrusted, and may predate the name filter. This is a hydration path, not a change, so the * [AuthCookiePersister] is deliberately NOT notified. */ public fun restore(hostKey: String, cookies: List) { val now = clock() val live = cookies - .filter { it.expiresAtEpochMillis > now } + .filter { it.name == AuthCookie.NAME && it.expiresAtEpochMillis > now } .mapNotNull { it.toCookieOrNull() } .capped() synchronized(writeLock) { @@ -138,6 +161,13 @@ private fun List.upserting(cookie: Cookie): List = private fun Cookie.sameIdentity(other: Cookie): Boolean = name == other.name && domain == other.domain && path == other.path +/** + * The name filter (F2). [AuthCookie.NAME] is the single point of derivation in `:wire-protocol` — the + * same constant the hand-written header is built from — so the two mechanisms can never drift apart on + * what "our cookie" means. + */ +private fun Cookie.isOurs(): Boolean = name == AuthCookie.NAME + private fun Cookie.toSnapshot(): AuthCookieSnapshot = AuthCookieSnapshot( name = name, diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieSnapshot.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieSnapshot.kt index 350e779..7783466 100644 --- a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieSnapshot.kt +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/AuthCookieSnapshot.kt @@ -2,18 +2,21 @@ package wang.yaojia.webterm.transport import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import wang.yaojia.webterm.wire.AuthCookie /** * The name of the server's shared-access-token cookie (`src/http/auth.ts` `AUTH_COOKIE_NAME`). - * Exposed as a constant so nothing has to hard-code the string; the jar itself is name-agnostic - * (it stores whatever the paired host sets, scoped to that host). + * + * An ALIAS of [AuthCookie.NAME], never a second literal: `:wire-protocol` owns the one derivation the + * hand-written `Cookie` header is also built from, and [AuthCookieJar] now keeps/returns this name and + * nothing else (F2), so a drift between the two spellings would be a security bug. */ -public const val AUTH_COOKIE_NAME: String = "webterm_auth" +public const val AUTH_COOKIE_NAME: String = AuthCookie.NAME /** * Upper bound on how many cookies are retained per host. The paired server sets exactly one - * ([AUTH_COOKIE_NAME]); the cap is a boundary guard so a broken or hostile host cannot turn the - * client into an unbounded credential sink. Oldest entries are evicted first. + * ([AUTH_COOKIE_NAME]) and the jar keeps no other name, so this is a residual boundary guard against a + * host that varies domain/path to mint many entries under that one name. Oldest evicted first. */ internal const val MAX_COOKIES_PER_HOST: Int = 16 diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt index 937e739..4c3f0cb 100644 --- a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt @@ -2,6 +2,7 @@ package wang.yaojia.webterm.transport import okhttp3.CookieJar import okhttp3.OkHttpClient +import wang.yaojia.webterm.wire.AccessTokenSource import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager @@ -107,13 +108,25 @@ public class OkHttpTransports private constructor( public val client: OkHttpClient, ) { public companion object { + /** + * @param identityProvider optional mTLS material (see [OkHttpClientFactory.create]). + * @param cookieJar the shared session-cookie store (see [OkHttpClientFactory.create]). A + * token-gated host's `Set-Cookie: webterm_auth=…` lands here and is replayed on REST calls + * AND on the WS upgrade, because both transports share this one client. + * @param tokens the access-token seam for the WS upgrade's `Cookie` header (ios-completion §1.1). + * REST requests get their cookie from `:api-client`'s single stamping point instead, so this + * only wires the WS half. Orthogonal to [cookieJar]: the hand-written header covers a token + * the app already holds (Keystore-restored, never `Set-Cookie`-observed), the jar covers the + * cookie a live pairing handed back. + */ public fun create( identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE, cookieJar: CookieJar = CookieJar.NO_COOKIES, + tokens: AccessTokenSource = AccessTokenSource.NONE, ): OkHttpTransports { val client = OkHttpClientFactory.create(identityProvider, cookieJar) return OkHttpTransports( - term = OkHttpTermTransport(client), + term = OkHttpTermTransport(client, tokens), http = OkHttpHttpTransport(client), client = client, ) diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransport.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransport.kt index 2c6a537..4b2650f 100644 --- a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransport.kt +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransport.kt @@ -2,6 +2,8 @@ package wang.yaojia.webterm.transport import okhttp3.OkHttpClient import okhttp3.Request +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.PingableConnection import wang.yaojia.webterm.wire.PingableTermTransport @@ -12,6 +14,9 @@ import java.util.concurrent.TimeUnit /** The `Origin` header name — THE CSWSH defence; stamped byte-equal from [HostEndpoint.originHeader]. */ internal const val HEADER_ORIGIN: String = "Origin" +/** HTTP status the server answers an upgrade with when it rejects Origin OR the access-token cookie. */ +internal const val HTTP_UNAUTHORIZED: Int = 401 + /** * The only concrete WS transport (A7): implements [PingableTermTransport] (and thus `TermTransport`) * over OkHttp. `SessionEngine` (A14) cannot tell it apart from the `FakeTermTransport` double. @@ -29,12 +34,25 @@ internal const val HEADER_ORIGIN: String = "Origin" * is cancellation-safe: if the caller's coroutine is cancelled (or the handshake fails) while it is * in flight, the just-created WebSocket is torn down before rethrowing, so no socket is leaked. * + * ### Access token (ios-completion §1.1) + * When [tokens] has a token for the host, the upgrade ALSO carries a hand-written + * `Cookie: webterm_auth=` — no OkHttp `CookieJar`, no `Set-Cookie` parsing (a jar's behaviour on a + * WS upgrade is stack-specific and hard to test; the hand-written header is the same pattern as + * `Origin` and is asserted byte-equal by a MockWebServer test). The two headers are orthogonal: the + * server checks Origin first, then the cookie. No token ⇒ no header at all. + * + * An upgrade answered **401** (rejected Origin OR rejected/missing token — the server writes the same + * bare 401 for both) is surfaced as the typed `UnauthorizedUpgradeException`, which `SessionEngine` + * treats as TERMINAL. The original error is kept as its `cause` so `PairingError.classify`'s cause-walk + * is unaffected. + * * Inbound frames flow up UNMODIFIED — there is deliberately NO transport-level frame-size cap here. * `SessionEngine` (A14) self-measures each inbound frame's UTF-8 size and is the single authoritative * classifier of an oversized ring-buffer replay (the non-retryable `REPLAY_TOO_LARGE` failure). */ public class OkHttpTermTransport( sharedClient: OkHttpClient, + private val tokens: AccessTokenSource = AccessTokenSource.NONE, ) : PingableTermTransport { private val wsClient: OkHttpClient = sharedClient.newBuilder() @@ -51,10 +69,14 @@ public class OkHttpTermTransport( } private suspend fun openConnection(endpoint: HostEndpoint): OkHttpWebSocketConnection { - val request = Request.Builder() + val builder = Request.Builder() .url(endpoint.wsUrl) // OkHttp maps ws(s):// → http(s):// internally .header(HEADER_ORIGIN, endpoint.originHeader) - .build() + // Additive and orthogonal to Origin; absent entirely when the host has no token. + tokens.tokenFor(endpoint)?.let { token -> + builder.header(AuthCookie.HEADER_NAME, AuthCookie.headerValue(token)) + } + val request = builder.build() val connection = OkHttpWebSocketConnection(wsClient, request) try { connection.awaitOpen() diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpWebSocketConnection.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpWebSocketConnection.kt index fcb0a5f..cf356f1 100644 --- a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpWebSocketConnection.kt +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpWebSocketConnection.kt @@ -15,6 +15,7 @@ import okhttp3.WebSocketListener import okio.ByteString import wang.yaojia.webterm.wire.ConnectionPinger import wang.yaojia.webterm.wire.TransportConnection +import wang.yaojia.webterm.wire.UnauthorizedUpgradeException import java.io.IOException /** RFC 6455 normal-closure status code, used for a client-initiated detach. */ @@ -145,9 +146,15 @@ internal class OkHttpWebSocketConnection( } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - finish(t) - // If we never opened, unblock connect() with the verbatim cause; no-op once opened. - opened.completeExceptionally(t) + // A 401 handshake answer is the server's ONE upgrade rejection (foreign Origin OR a + // missing/invalid access-token cookie) and is permanent until reconfigured, so it is TYPED + // for SessionEngine to end terminally on. `t` is kept as the cause, so the verbatim + // transport error is still available to PairingError.classify's cause-walk. Every other + // failure propagates untouched (frozen "rethrow verbatim" contract). + val error = if (response?.code == HTTP_UNAUTHORIZED) UnauthorizedUpgradeException(t) else t + finish(error) + // If we never opened, unblock connect() with the cause; no-op once opened. + opened.completeExceptionally(error) } } } diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieJarTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieJarTest.kt index f1dea54..48cf39a 100644 --- a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieJarTest.kt +++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieJarTest.kt @@ -271,9 +271,13 @@ class AuthCookieJarTest { @Test fun capsTheNumberOfCookiesStoredPerHost() { - // Arrange: a hostile/broken server tries to make the client an unbounded cookie sink. + // Arrange: a hostile/broken server tries to make the client an unbounded cookie sink. Since the + // jar keeps only AUTH_COOKIE_NAME (F2), the sole remaining way to mint many entries is to vary + // (domain, path) under that one name — so that is what the cap has to survive. val response = MockResponse().setResponseCode(200).setBody("{}") - repeat(MAX_COOKIES_PER_HOST * 2) { i -> response.addHeader("Set-Cookie", "c$i=v$i; Path=/; Max-Age=$MAX_AGE_SEC") } + repeat(MAX_COOKIES_PER_HOST * 2) { i -> + response.addHeader("Set-Cookie", "$AUTH_COOKIE_NAME=v$i; Path=/p$i; Max-Age=$MAX_AGE_SEC") + } server.enqueue(response) // Act @@ -283,6 +287,25 @@ class AuthCookieJarTest { assertEquals(MAX_COOKIES_PER_HOST, persister.latestFor(hostKey())?.size) } + @Test + fun neverStoresACookieThatIsNotOurs() { + // Arrange: a TLS-terminating intermediary (Cloudflare Access, a captive portal) sets its own. + val response = MockResponse().setResponseCode(200).setBody("{}") + repeat(MAX_COOKIES_PER_HOST * 2) { i -> response.addHeader("Set-Cookie", "c$i=v$i; Path=/; Max-Age=$MAX_AGE_SEC") } + server.enqueue(response) + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + server.get("/") + server.get("/live-sessions") + + // Assert: nothing was kept, so nothing rides the next request — a non-empty jar would REPLACE + // the hand-written `Cookie: webterm_auth=…` header outright (OkHttp BridgeInterceptor). + assertNull(persister.latestFor(hostKey())) + server.takeRequest() + assertNull(server.takeRequest().getHeader("Cookie")) + } + // ── 4. the credential never reaches a log/toString path ────────────────────── @Test diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieTokenCoexistenceTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieTokenCoexistenceTest.kt new file mode 100644 index 0000000..406f3b9 --- /dev/null +++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/AuthCookieTokenCoexistenceTest.kt @@ -0,0 +1,203 @@ +package wang.yaojia.webterm.transport + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +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.BeforeEach +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest + +/** + * F2 · the TWO mechanisms that carry `webterm_auth`, wired the way production wires them + * (`di/NetworkModule` line 80 installs the jar on the one shared client; line 96 hands the same + * client the [AccessTokenSource]) — which is the combination NOTHING covered before: the jar's own + * suite builds it with `AccessTokenSource.NONE`, and the token suite builds the client with + * `CookieJar.NO_COOKIES`. + * + * ### Why both live at once cannot be left to luck + * OkHttp's `BridgeInterceptor` (4.12.0) does: + * ``` + * val cookies = cookieJar.loadForRequest(userRequest.url) + * if (cookies.isNotEmpty()) requestBuilder.header("Cookie", cookieHeader(cookies)) + * ``` + * The guard is **"the jar returned anything at all"**, NOT "the jar has a `webterm_auth` for this + * host", and `header(...)` REPLACES the whole header rather than merging. So an unrelated cookie from + * any TLS-terminating intermediary (Cloudflare Access, a captive portal, a future auth edge) used to + * be enough to strip the hand-written token off every REST call **and off the WS upgrade** — where a + * 401 is terminal with no reconnect. + * + * The fix is in [AuthCookieJar]: it now keeps and returns [AuthCookie.NAME] and nothing else, so a + * foreign cookie can neither displace the token nor consume the per-host cap. + */ +class AuthCookieTokenCoexistenceTest { + + private lateinit var server: MockWebServer + private val persister = RecordingCoexistencePersister() + private val jar = AuthCookieJar(persister = persister) + private lateinit var client: OkHttpClient + + @BeforeEach + fun setUp() { + server = MockWebServer().apply { start() } + // EXACTLY the production graph: one client, the jar installed on it, tokens on the WS transport. + client = OkHttpClientFactory.create(cookieJar = jar) + } + + @AfterEach + fun tearDown() { + client.dispatcher.cancelAll() + client.connectionPool.evictAll() + runCatching { server.shutdown() } + client.dispatcher.executorService.shutdown() + } + + private fun endpoint(): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl("http://${server.hostName}:${server.port}")) + + /** The REST half exactly as `:api-client` stamps it (`ApiRoute.toHttpRequest`). */ + private fun getWithToken(path: String, token: String = TOKEN) = runBlocking { + OkHttpHttpTransport(client).send( + HttpRequest( + method = HttpMethod.GET, + url = server.url(path).toString(), + headers = mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token)), + ), + ) + } + + /** Seed the live jar from a real `Set-Cookie`, i.e. the way a proxy would do it. */ + private fun seedJarWith(vararg setCookie: String) { + val response = MockResponse().setResponseCode(200).setBody("{}") + setCookie.forEach { response.addHeader("Set-Cookie", it) } + server.enqueue(response) + runBlocking { + OkHttpHttpTransport(client).send(HttpRequest(HttpMethod.GET, server.url("/live-sessions").toString())) + } + server.takeRequest() + } + + // ── 1. an unrelated host cookie must not strip the token ───────────────────────── + + @Test + fun `a foreign cookie in the jar never strips the hand-written token from a REST call`() { + // Arrange: an intermediary sets its own session cookie on this host. + seedJarWith("proxy_session=abc123; Path=/; Max-Age=$MAX_AGE_SEC") + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + getWithToken("/live-sessions") + + // Assert: the shell credential is still on the wire. + val sent = server.takeRequest().getHeader(AuthCookie.HEADER_NAME) + assertTrue( + sent.orEmpty().contains("${AuthCookie.NAME}=$TOKEN"), + "a proxy's cookie must never displace the access token, got: $sent", + ) + } + + @Test + fun `a foreign cookie in the jar never strips the token from the WS upgrade`() = runBlocking { + // Arrange: the upgrade is the worst place to lose it — a 401 there is terminal, no reconnect. + seedJarWith("proxy_session=abc123; Path=/; Max-Age=$MAX_AGE_SEC") + server.enqueue(MockResponse().withWebSocketUpgrade(SilentCoexistenceWebSocket())) + + // Act + val endpoint = endpoint() + val connection = withTimeout(TIMEOUT_MS) { + OkHttpTermTransport(client, AccessTokenSource { TOKEN }).connect(endpoint) + } + + // Assert: cookie intact AND the CSWSH defence untouched. + val upgrade = server.takeRequest() + assertTrue( + upgrade.getHeader(AuthCookie.HEADER_NAME).orEmpty().contains("${AuthCookie.NAME}=$TOKEN"), + "the WS upgrade lost the token to a foreign cookie, got: ${upgrade.getHeader(AuthCookie.HEADER_NAME)}", + ) + assertEquals(endpoint.originHeader, upgrade.getHeader("Origin"), "Origin must be untouched") + connection.close() + } + + @Test + fun `a chatty host cannot evict the token by flooding foreign cookies`() { + // Arrange: more foreign cookies than the per-host cap (the eviction path). + val flood = (0 until MAX_COOKIES_PER_HOST * 2) + .map { "junk$it=v$it; Path=/; Max-Age=$MAX_AGE_SEC" } + .toTypedArray() + seedJarWith(*flood) + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + getWithToken("/live-sessions") + + // Assert: nothing foreign was ever kept, so nothing could be evicted OR sent. + val sent = server.takeRequest().getHeader(AuthCookie.HEADER_NAME) + assertEquals("${AuthCookie.NAME}=$TOKEN", sent, "only our own cookie may ever reach the wire") + assertTrue( + persister.latestFor(hostKey()).orEmpty().none { it.name != AuthCookie.NAME }, + "the jar must never become a sink for another service's cookies", + ) + } + + // ── 2. the stale-jar-value case ────────────────────────────────────────────────── + + /** + * The jar and the token store hold the SAME cookie name, so when both are populated OkHttp's + * bridge decides: `loadForRequest` is consulted last and `header(...)` replaces, therefore **the + * jar's live cookie wins over the hand-written one**. Pinned rather than left implicit. + * + * Against this server the two cannot actually diverge: `POST /auth` is the ONLY writer of either + * side, it runs on this same shared client, and its `Set-Cookie` refreshes the jar in the very + * exchange whose success gates the token-store write (`PairingViewModel` RULE 5). The value that + * arrives is therefore always a single, well-formed `webterm_auth` — never a merge of two. + */ + @Test + fun `with both mechanisms populated exactly one webterm_auth value is sent - the jar's`() { + // Arrange: the jar was refreshed by POST /auth; the store still holds an older typed token. + seedJarWith("${AuthCookie.NAME}=$JAR_TOKEN; Path=/; Max-Age=$MAX_AGE_SEC") + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + getWithToken("/live-sessions", token = TOKEN) + + // Assert: ONE value, deterministic, not a concatenation of both. + val sent = server.takeRequest().getHeader(AuthCookie.HEADER_NAME) + assertEquals("${AuthCookie.NAME}=$JAR_TOKEN", sent) + assertFalse(sent.orEmpty().contains(TOKEN), "the two must never be merged into one header") + } + + private fun hostKey(): String = requireNotNull(authCookieHostKey(server.url("/").toString())) + + private companion object { + const val TOKEN = "0123456789abcdefTOKEN" + const val JAR_TOKEN = "0123456789abcdefJARVAL" + const val MAX_AGE_SEC = 60L + const val TIMEOUT_MS = 5_000L + } +} + +private class RecordingCoexistencePersister : AuthCookiePersister { + private val latest = mutableMapOf>() + + override fun persist(hostKey: String, cookies: List) { + latest[hostKey] = cookies + } + + fun latestFor(hostKey: String): List? = latest[hostKey] +} + +private class SilentCoexistenceWebSocket : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) = Unit +} diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpAccessTokenTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpAccessTokenTest.kt new file mode 100644 index 0000000..a670fc4 --- /dev/null +++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpAccessTokenTest.kt @@ -0,0 +1,145 @@ +package wang.yaojia.webterm.transport + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.AccessTokenSource +import wang.yaojia.webterm.wire.AuthCookie +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.UnauthorizedUpgradeException +import java.io.IOException + +/** + * B5 · the access token on the **WS upgrade** (FROZEN contract, ios-completion §1.1), over + * MockWebServer: + * - the upgrade request carries the hand-written `Cookie: webterm_auth=` ALONGSIDE `Origin` + * (the two are orthogonal — the server checks Origin first, then the cookie); + * - no token ⇒ no `Cookie` header at all (an unauthenticated LAN host is unaffected); + * - an upgrade answered with **401** throws the typed [UnauthorizedUpgradeException] so + * `SessionEngine` can go terminal instead of back-off-looping against a permanent rejection; + * - any OTHER upgrade failure still surfaces verbatim (untyped) — only 401 is special. + */ +class OkHttpAccessTokenTest { + private companion object { + const val TIMEOUT_MS = 5_000L + const val TOKEN = "0123456789abcdefTOKEN" + } + + private lateinit var server: MockWebServer + private val client: OkHttpClient = OkHttpClientFactory.create() + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + } + + @AfterEach + fun tearDown() { + client.dispatcher.cancelAll() + client.connectionPool.evictAll() + runCatching { server.shutdown() } + client.dispatcher.executorService.shutdown() + } + + private fun endpoint(): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl("http://${server.hostName}:${server.port}")) + + private fun transport(tokens: AccessTokenSource): OkHttpTermTransport = + OkHttpTermTransport(client, tokens) + + @Test + fun stampsTheAuthCookieAlongsideOriginOnTheWsUpgrade() = runBlocking { + // Arrange + server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket())) + val endpoint = endpoint() + + // Act + val connection = withTimeout(TIMEOUT_MS) { transport(AccessTokenSource { TOKEN }).connect(endpoint) } + + // Assert + val upgrade = server.takeRequest() + assertEquals("${AuthCookie.NAME}=$TOKEN", upgrade.getHeader(AuthCookie.HEADER_NAME)) + assertEquals(endpoint.originHeader, upgrade.getHeader("Origin"), "Origin must be untouched") + connection.close() + } + + @Test + fun sendsNoCookieHeaderWhenTheHostHasNoToken() = runBlocking { + server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket())) + + val connection = withTimeout(TIMEOUT_MS) { transport(AccessTokenSource.NONE).connect(endpoint()) } + + assertNull(server.takeRequest().getHeader(AuthCookie.HEADER_NAME)) + connection.close() + } + + @Test + fun theTokenNeverAppearsInTheUpgradeUrl() = runBlocking { + server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket())) + + val connection = withTimeout(TIMEOUT_MS) { transport(AccessTokenSource { TOKEN }).connect(endpoint()) } + + assertFalse(server.takeRequest().path.orEmpty().contains(TOKEN), "a secret must never be in a URL") + connection.close() + } + + @Test + fun aPingableDialAlsoCarriesTheAuthCookie() = runBlocking { + server.enqueue(MockResponse().withWebSocketUpgrade(SilentServerWebSocket())) + + val pingable = withTimeout(TIMEOUT_MS) { + transport(AccessTokenSource { TOKEN }).connectPingable(endpoint()) + } + + assertEquals("${AuthCookie.NAME}=$TOKEN", server.takeRequest().getHeader(AuthCookie.HEADER_NAME)) + pingable.connection.close() + } + + @Test + fun a401UpgradeThrowsTheTypedUnauthorizedError() = runBlocking { + // Arrange: the server's bare `HTTP/1.1 401 Unauthorized` upgrade rejection. + server.enqueue(MockResponse().setResponseCode(401)) + + // Act + val thrown = runCatching { + withTimeout(TIMEOUT_MS) { transport(AccessTokenSource.NONE).connect(endpoint()) } + }.exceptionOrNull() + + // Assert: typed, and the verbatim transport cause is retained for PairingError.classify. + assertTrue( + thrown is UnauthorizedUpgradeException, + "a 401 upgrade must be typed so the engine can end terminally, got $thrown", + ) + assertTrue((thrown as UnauthorizedUpgradeException).cause is Throwable) + } + + @Test + fun aNon401UpgradeFailureStillSurfacesVerbatim() = runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + + val thrown = runCatching { + withTimeout(TIMEOUT_MS) { transport(AccessTokenSource.NONE).connect(endpoint()) } + }.exceptionOrNull() + + assertFalse(thrown is UnauthorizedUpgradeException, "only 401 is the auth rejection") + assertTrue(thrown is IOException || thrown is IllegalStateException, "verbatim transport error, got $thrown") + } +} + +/** Server side that accepts the upgrade and says nothing. */ +private class SilentServerWebSocket : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) = Unit +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/AccessToken.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/AccessToken.kt new file mode 100644 index 0000000..5f0db31 --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/AccessToken.kt @@ -0,0 +1,92 @@ +package wang.yaojia.webterm.wire + +/** + * The optional shared access token (`WEBTERM_TOKEN`) contract — the Android half of the FROZEN + * cross-client contract (ios-completion §1.1; server: `src/http/auth.ts`, `src/server.ts`). + * + * ### Why a hand-written header (and NOT an OkHttp `CookieJar`) + * A native client already KNOWS its token, so it stamps `Cookie: webterm_auth=` itself and never + * parses `Set-Cookie` / relies on a cookie store. Reason (frozen): a cookie jar's behaviour on a **WS + * upgrade** differs between HTTP stacks and is hard to test, whereas a hand-written header is the same + * pattern as the existing `Origin` header ([HostEndpoint.originHeader]) and can be pinned by pure unit + * tests. [AuthCookie] is therefore the SINGLE point of derivation — assembling the header string + * anywhere else is a review CRITICAL, exactly like hand-assembling an Origin. + * + * ### Orthogonal to Origin + * The token does NOT replace the Origin/CSWSH check: the server runs Origin first, then the cookie + * (`src/server.ts` upgrade handler), so a request carries BOTH (Origin only on guarded routes, per the + * Origin-iff-guarded rule; the cookie on EVERY request plus the WS upgrade). + * + * ### Secret handling (non-negotiable, §1.1) + * The token is secret material: it lives only in Android-Keystore-backed storage, is NEVER logged, + * NEVER placed in a URL query, and NEVER put into a crash report. Nothing in this file logs. + */ +public object AuthCookie { + /** The server's auth cookie name (`src/http/auth.ts` `AUTH_COOKIE_NAME`). */ + public const val NAME: String = "webterm_auth" + + /** The request header the value is stamped on. */ + public const val HEADER_NAME: String = "Cookie" + + /** + * `webterm_auth=` — the exact header value the server's `parseCookieHeader` expects. No + * attributes, no URL-encoding: the token charset ([AccessTokenRule]) is already cookie-safe, and + * the server reads the value verbatim. + */ + public fun headerValue(token: String): String = "$NAME=$token" +} + +/** + * Client-side access-token validator (validate at the boundary). Mirrors the server's config check: + * `[A-Za-z0-9._~+/=-]`, 16–512 chars — a server that fails this refuses to start, so a token outside + * it can never be correct and must be rejected before any network I/O (and before it reaches storage). + * + * The charset also guarantees the token cannot forge cookie syntax (no `;`, `,`, `=`-prefix, space or + * quote injection into the `Cookie` header). + * + * [normalize] trims ONCE here (a pasted/QR-sourced token often carries stray whitespace) and returns + * the trimmed token, or `null` when it is not a possible server token. + */ +public object AccessTokenRule { + /** Server-side minimum (CLAUDE.md / config validation). */ + public const val MIN_LENGTH: Int = 16 + + /** Server-side maximum. */ + public const val MAX_LENGTH: Int = 512 + + private val ALLOWED: Set = + (('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('.', '_', '~', '+', '/', '=', '-')).toSet() + + /** The trimmed token when it could be a valid server token, else `null`. Never logs its input. */ + public fun normalize(raw: String): String? { + val trimmed = raw.trim() + if (trimmed.length < MIN_LENGTH || trimmed.length > MAX_LENGTH) return null + if (!trimmed.all { it in ALLOWED }) return null + return trimmed + } +} + +/** + * The access-token injection seam: "what token, if any, should be presented to THIS host?". + * + * Defined here (top of the dependency graph) so all three consumers share ONE seam without new module + * edges: `:api-client` (every REST route + the pairing probe), `:transport-okhttp` (the WS upgrade), + * and `:app` (the Keystore-backed store that implements it). It is the token analogue of + * `:transport-okhttp`'s `ClientIdentityProvider` mTLS seam. + * + * `suspend` because the production implementation reads encrypted at-rest storage (Tink + + * AndroidKeyStore) and must be able to hop off the caller's thread; it is consulted per request so a + * token added, changed or removed later takes effect with no client rebuild. + * + * Returning `null` means "this host has no token" — the request then carries no `Cookie` header at + * all, which is exactly the zero-config LAN behaviour of a server with `WEBTERM_TOKEN` unset. + */ +public fun interface AccessTokenSource { + /** The access token for [endpoint], or `null` when none is stored. MUST NOT log the token. */ + public suspend fun tokenFor(endpoint: HostEndpoint): String? + + public companion object { + /** No token for any host — the default everywhere, so an unauthenticated host is unaffected. */ + public val NONE: AccessTokenSource = AccessTokenSource { null } + } +} diff --git a/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/UnauthorizedUpgradeException.kt b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/UnauthorizedUpgradeException.kt new file mode 100644 index 0000000..adb1ae8 --- /dev/null +++ b/android/wire-protocol/src/main/kotlin/wang/yaojia/webterm/wire/UnauthorizedUpgradeException.kt @@ -0,0 +1,31 @@ +package wang.yaojia.webterm.wire + +import java.io.IOException + +/** + * The server answered the WebSocket upgrade with **HTTP 401** instead of 101 — a NON-retryable + * rejection (ios-completion §1.1). + * + * Declared in `:wire-protocol` so the three modules that must agree on it have no new dependency + * edges: `:transport-okhttp` throws it out of `TermTransport.connect`, `:session-core`'s + * `SessionEngine` maps it to a TERMINAL failure (never into the reconnect back-off ladder — retrying + * would 401 forever), and `:api-client`'s pairing probe can classify it. + * + * The server writes a bare `HTTP/1.1 401 Unauthorized` for BOTH of its two upgrade rejections — a + * foreign `Origin` and a missing/invalid access-token cookie (`src/server.ts` upgrade handler) — so + * this type deliberately does NOT claim which one it was. Both are permanent-until-reconfigured, so + * the engine's treatment (terminal, no back-off) is correct either way; the pairing probe disambiguates + * by ordering (it authenticates over HTTP first, so a 401 there is the token and a 401 on the later + * upgrade is the Origin). + * + * [cause] carries the transport's verbatim error so existing cause-chain classification + * (`PairingError.classify`) still sees the original type. + */ +public class UnauthorizedUpgradeException( + cause: Throwable? = null, +) : IOException(MESSAGE, cause) { + private companion object { + /** Fixed copy — never interpolate the token or any header value into an exception message. */ + const val MESSAGE = "the server rejected the WebSocket upgrade with HTTP 401" + } +} diff --git a/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/AccessTokenTest.kt b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/AccessTokenTest.kt new file mode 100644 index 0000000..23749a6 --- /dev/null +++ b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/AccessTokenTest.kt @@ -0,0 +1,109 @@ +package wang.yaojia.webterm.wire + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * B5 · the FROZEN access-token contract (ios-completion §1.1) as pure functions: + * - [AuthCookie] is the SINGLE point of `Cookie: webterm_auth=` derivation (the token analogue of + * [HostEndpoint.originHeader] — never hand-assembled at a call site); + * - [AccessTokenRule] mirrors the server's config validator (`[A-Za-z0-9._~+/=-]`, 16–512) so a + * malformed token is rejected BEFORE any network I/O; + * - [AccessTokenSource.NONE] is the "no token configured" default (LAN zero-config unchanged). + */ +class AccessTokenTest { + + // ── AuthCookie: the one derivation point ──────────────────────────────────────────────────── + + @Test + fun `cookie name and header name match the server contract`() { + assertEquals("webterm_auth", AuthCookie.NAME) + assertEquals("Cookie", AuthCookie.HEADER_NAME) + } + + @Test + fun `header value is name equals token with no extra whitespace or attributes`() { + assertEquals("webterm_auth=abcdefghijklmnop", AuthCookie.headerValue("abcdefghijklmnop")) + } + + // ── AccessTokenRule: charset + length, trimmed once ───────────────────────────────────────── + + @Test + fun `a valid token in the server charset normalizes to itself`() { + val token = "Aa0._~+/=-Aa0._~+/=-" + assertEquals(token, AccessTokenRule.normalize(token)) + } + + @Test + fun `surrounding whitespace is trimmed once at the boundary`() { + assertEquals("0123456789abcdef", AccessTokenRule.normalize(" 0123456789abcdef\n")) + } + + @Test + fun `a token shorter than the minimum or longer than the maximum is rejected`() { + assertEquals(16, AccessTokenRule.MIN_LENGTH) + assertEquals(512, AccessTokenRule.MAX_LENGTH) + assertNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MIN_LENGTH - 1))) + assertNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MAX_LENGTH + 1))) + assertNotNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MIN_LENGTH))) + assertNotNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MAX_LENGTH))) + } + + @Test + fun `an empty or blank token is rejected`() { + assertNull(AccessTokenRule.normalize("")) + assertNull(AccessTokenRule.normalize(" ")) + } + + @Test + fun `characters outside the server charset are rejected`() { + // A `;` would forge a second cookie attribute; a space/quote/CJK char is simply not in the set. + listOf( + "abcdefghijklmno;x", + "abcdefghijklmno x", + "abcdefghijklmno\"x", + "abcdefghijklmno\nx", + "令牌令牌令牌令牌令牌令牌令牌令牌", + ).forEach { raw -> + assertNull(AccessTokenRule.normalize(raw), "must reject: $raw") + } + } + + // ── AccessTokenSource ─────────────────────────────────────────────────────────────────────── + + @Test + fun `NONE yields no token so an unauthenticated LAN host keeps working`() = runTest { + val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")) + assertNull(AccessTokenSource.NONE.tokenFor(endpoint)) + } + + @Test + fun `a source can be a lambda keyed by the endpoint`() = runTest { + val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")) + val other = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.6:3000")) + val source = AccessTokenSource { endpoint -> + if (endpoint == lan) "0123456789abcdef" else null + } + + assertEquals("0123456789abcdef", source.tokenFor(lan)) + assertNull(source.tokenFor(other)) + } + + // ── UnauthorizedUpgradeException ──────────────────────────────────────────────────────────── + + @Test + fun `the 401 upgrade error is an IOException keeping the verbatim cause`() { + val cause = IllegalStateException("expected 101, got 401") + val error: java.io.IOException = UnauthorizedUpgradeException(cause) + + assertEquals(cause, error.cause, "the verbatim transport cause must survive for classify()") + assertEquals( + "the server rejected the WebSocket upgrade with HTTP 401", + error.message, + "fixed copy — no header/token value may ever be interpolated into the message", + ) + } +} diff --git a/docs/PLAN_IOS_CLIENT.md b/docs/PLAN_IOS_CLIENT.md index 278b292..12cfa30 100644 --- a/docs/PLAN_IOS_CLIENT.md +++ b/docs/PLAN_IOS_CLIENT.md @@ -2,7 +2,10 @@ > 落地方案文档。目标:给 web-terminal 做一个 **iPhone 原生 App**,把 vibe-coding 的"走开—被叫回—两次手势处理完"闭环装进口袋。 > 拓扑/框架选型:**Phased-Native —— SwiftUI + SwiftTerm,4 个纯 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` cookie,w5-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/`(纯 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/IP);App 端绝不落盘。 - App 内无任何硬编码 host/密钥;首个 host 必经配对流程。 @@ -490,15 +509,100 @@ 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/ --enable-code-coverage` + `xcrun llvm-cov report`(阈值 80%,见 §9)。 +> 覆盖率验证命令(各包):`swift test --package-path ios/Packages/ --enable-code-coverage` + `xcrun llvm-cov report`(阈值 80%,见 §9); +> 实际 CI 用的是修正口径脚本 `ios/IntegrationTests/scripts/coverage-gate.sh `。 +> +> **权威状态在下面这张总表**(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/**` **32** `@Test`(10 基线 + 8 令牌端到端 + 8 令牌策略漂移守卫 + 6 worktree 生命周期端到端)+ `scripts/coverage-gate.sh`(现门 **5** 个包,含 ClientTLS)+ `ios.yml` 六个 job(含 iPad 单测腿/iPad UI 腿/iOS-17 底线腿)+ 新增 `android.yml`。`ServerHarness.locateTsx` 改为逐级向上解析,故 worktree 内无 `node_modules` 也能自举。**未核实**:GH Actions 平台侧的运行结果——本仓库唯一 remote 是自建 Gitea,两个 workflow **从未在任何 runner 上跑过**;本地已逐条复跑其命令 | +| 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` **已在产物层复核**(真机构建产物的 Info.plist 实测有这两键)。**剩余缺口**: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 零 findings(LOG 条目)。**DEFERRED**:真机锁屏/unread 目视等,手工清单在 LOG | +| T-iOS-31 语音 PTT | `[x]` | `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 🎤 键 + `VoicePTTTests` **40** `@Test`。**DEFERRED**:真机口述→确认→注入 | +| T-iOS-32 Worktree + `--resume` 历史 | `[x]` | `Screens/{WorktreeSheet,ResumeHistorySheet}.swift`、`ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift` + `WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)。**Accept 的"端到端一次"已闭合**(收尾波):`IntegrationTests/WorktreeLifecycleTests.swift` **6 例**在临时 git fixture 仓上对真服务器真建/真删/真 prune——断言的是服务端**推导**出的路径与分支(`feature/e2e-worktree` → 目录 `feature-e2e-worktree`,原始分支名绝不出现在路径里),并用 `git worktree list --porcelain` 与 `.git/worktrees/` 双向核对;含 G 路由纪律的差分腿(无 `Origin` → 403 且零副作用,同一请求加 `Origin` → 200)与非法分支名不创建;`GET /sessions` 用 **HOME 隔离**的专用服务器逐字段解码(否则会打到开发者真实 Claude 历史,CI 上又退化成 `[]` 什么都证明不了) | +| T-iOS-33 终端内搜索 | `[x]` | `Components/TerminalSearchBar.swift` + `TerminalScreen` 绑定 SwiftTerm 搜索 API + `TerminalSearchTests` **18**(含 2 条真 view 高亮断言) | +| T-iOS-34 主题 + Dynamic Type | `[x]` | `DesignSystem/{AppTheme,TerminalPalette}.swift`、`Screens/SettingsScreen.swift`、`Tokens/Typography` 浅色档 + `AppThemeTests`(24)/`DynamicTypeLayoutTests`(**14**)/`KeyBarTests`(**12**)。**缺口已闭合**(收尾波):`KeyBarMetrics.barHeight` 由常量 52pt 改为按字号档推导,`withKnownIssue` 已删、换成正向断言(AX5 不裁切 · 全 12 档不裁切且 ≥44pt · XS–XL 逐点仍 52pt 零回归 · 未封顶 AX5 溢出旧固定高的护栏)。**取舍**:键帽字体封顶在 `DS.Typography.numericClamp`(.accessibility2),故 AX3–AX5 的字号不再增长(不封顶时 AX5 要 108.24pt,会吃掉终端);该封顶由测试钉死不得漂移 | +| T-iOS-35 web `?join=` 互通 | `[x]` | `DeepLinkRouter.swift` 的 `.joinShared` 分支 + `DeepLinkJoinTests` **19**(含改写后的既有拒绝用例) | +| T-iOS-36 P2 验收 | `[x]` | 两轮独立复验(Wave D + 收尾波)均已跑完,**真实数字以 `PROGRESS_LOG.md` 的该条目为准**:452 包测 / 550 App 测(iPhone 16 Pro 与 iPad Pro 11" M4 各一遍,**0 known issue**)/ 26 集成测 / 覆盖率门 5/5 / 真机构建 `BUILD SUCCEEDED` 且产物无 `aps-environment`。P2 五项逐条走查见本节上方各行 | + +**说明**:上表的"测试数"是 `@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=`、`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)。 +- **收尾波(2026-07-30)把四条全部闭合**:④ worktree 端到端 → 见 T-iOS-32 行;① AX5 键栏裁切 → 已修(见 T-iOS-34 行); + ② 端到端令牌腿 → `IntegrationTests/AccessTokenGateTests.swift` **8 例**对真服务器(带 `WEBTERM_TOKEN` 自举) + 跑通:对令牌放行 / 错令牌 401 终态且 **connect 计数 == 1**(另有可重试失败的对照组证明"零重试"不是计时假象)/ + 令牌不替代 Origin(合法 cookie + 外域 Origin 仍拒)/ `POST /auth` 的 204+Set-Cookie、401、204-无-Set-Cookie 三态; + 另加 `TokenPolicyDriftTests.swift` **8 例**漂移守卫(运行期读 `src/config.ts` 与 `src/http/auth.ts` 的真字面量, + 与三份 Swift 谓词逐条比对,含变异测试证明守卫自身够响);③ 两条 usage description 已在产物层复核(见 T-iOS-19 行)。 +- **剩余未闭合**:release ipa 层核对(免费 team 无分发通道);真机人工腿(口述 / 扫码 / 锁屏 Face ID / IME); + Android instrumented 腿(`TinkAccessTokenStoreTest` 是令牌静态加密的唯一证明,需真机或模拟器,CI 上暂设为 + `workflow_dispatch` 触发——理由写在 `android.yml` 文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿)。 ### 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 +612,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 +624,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 +642,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 +650,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-4(FakeClock) · **Parallel-safe**: T-iOS-6/7/8 - **Steps(测试先行, RED)**: @@ -555,7 +662,7 @@ W5 验收(report-only, 并行) - **Steps(实现, GREEN)**: [ ] §3.2 签名 [ ] 常量一律读 `Tunables`(WireProtocol,T-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 +674,7 @@ W5 验收(report-only, 并行) - [ ] `limit` 截断 recent;空 events → 全零 digest(UI 可据此不渲染) - **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 +687,7 @@ W5 验收(report-only, 并行) - **Accept**: `swift test --package-path ios/Packages/HostRegistry` 全绿(覆盖率计法见 §9:KeychainHostStore 以 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 +705,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-2(spike 结论)、T-iOS-3 · **Parallel-safe**: T-iOS-10(接口并行) - **Steps(测试先行, RED)** — 对 in-process 本地 WS echo(测试内起 `NWListener` 或复用 IntegrationTests 服务器): @@ -613,7 +720,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 +740,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 +752,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 +764,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 +777,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 +790,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-11–14 全部 · **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-11–15/17 - **Steps(测试清单)** — macOS runner:`npm ci` → 临时端口 `npm start`(`ALLOWED_ORIGINS` 注入)→ Swift Testing: @@ -705,7 +812,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-238、env 门 :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 +822,12 @@ W5 验收(report-only, 并行) #### W5 · 验收(report-only,G4:只报告不改码,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 +839,70 @@ W5 验收(report-only, 并行) > 波次:**W6(服务器触点:T-iOS-20 ∥ T-iOS-37,串行入库各自 repo 流程)与 W7 并行开跑**——W7 里只有 T-iOS-21 依赖 T-iOS-20(payload 形状)、T-iOS-23 依赖 T-iOS-37(lastOutputAt 字段),其余 W7 任务(T-iOS-22/24/25/27/28/29)不等 W6;T-iOS-38(APIClient 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 的双 e2e);env 三件套缺失即整体 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-525);APNs 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-20、W7 全部 - **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-8;T-iOS-20(仅 token 注册 builder 的端点形状——可接口先行并行编码,形状定稿时汇合) · **Parallel-safe**: T-iOS-20/22/23/24/25/27/28/29(均不碰 APIClient 文件) - **Steps(测试先行)**: [ ] token 注册 builder(G,带 Origin)[ ] projects/detail/prefs builders(PUT 带 Origin)与解码 [ ] doc comment 端点约束:push subscribe body ≤ 8 KB、≤5 次/分/IP(src/server.ts:73,461-466);`PUT /prefs` ≤ 64 KB(src/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` + 对应 Tests(APIClient 的 token 注册 builder 归 T-iOS-38) - **Depends**: T-iOS-20(payload 形状)、T-iOS-38(token 注册 builder) · **Parallel-safe**: T-iOS-22–29 - **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 target;Service 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=&join=`: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+200B–200F、U+202A–202E、U+2066–2069——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 Tests(projects/detail/prefs 的 APIClient builders 归 T-iOS-38,本任务只消费) - **Depends**: T-iOS-38(builders) · **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 pd(report-only) +#### T-iOS-30 · P1 验收 + 安全复核 `[x]` · ~1 pd(report-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.5;T-iOS-21/26 相应减负)。 @@ -800,13 +912,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 Tests(epoch 防误发若需 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 ` 历史(`GET /sessions`)`[ ]` ~1.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Screens/WorktreeSheet.swift` + Tests(APIClient 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 pd(report-only)。**Wave**: W10 · **Owns**: 无源码 · **Depends**: T-iOS-31–35。 +- **T-iOS-31** · 语音 PTT + 确认(端口匹配器 / 1.5s 撤销 / epoch 防误发)`[x]` ~2.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/VoicePTT.swift` + VM Tests(epoch 防误发若需 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 ` 历史(`GET /sessions`)`[~]` ~1.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Screens/WorktreeSheet.swift` + Tests(APIClient 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)。默认仍是深色(零回归)。 + - **缺口已闭合(收尾波,2026-07-30)**: `KeyBarMetrics.barHeight` 从常量 52pt 改为 `max(minHitTarget, keycapHeight(category)) + sm8`,`intrinsicContentSize`/init frame/`apply(contentSizeCategory:)` 三处同源,并经 iOS 17 `registerForTraitChanges` 支持运行期改档。`withKnownIssue` 已删。**取舍**:键帽字体封顶 `.accessibility2`(不封顶时 AX5 需 108.24pt,会把终端吃掉),沿用 DS 对密集内容的既有策略;实测条高 XS–XL 52 / XXL 55 / XXXL 59 / AccM 68 / AccL–AX5 77。 +- **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 pd(report-only)。**Wave**: W10 · **Owns**: 无源码 · **Depends**: T-iOS-31–35。 + - **缺口**: 由 ios-completion 收尾波的 Wave D 验收 agent 执行中;**真实数字与结论以 `PROGRESS_LOG.md` 的该条目为准**,本文不预写"通过"。 **总计:P0 13 + P1 17.5 + P2 8 ≈ 38.5 人天。** @@ -863,7 +987,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 ` +> (下面这段裸 `llvm-cov` 命令读的是 export TOTALS,会把静态链入的**依赖包源码**一起计进去—— +> 这个缺陷由 T-iOS-16 修掉,脚本只保留 `Packages/

/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 +1063,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)。 diff --git a/docs/PLAN_IOS_IPAD.md b/docs/PLAN_IOS_IPAD.md index ed4f3cb..95a8bae 100644 --- a/docs/PLAN_IOS_IPAD.md +++ b/docs/PLAN_IOS_IPAD.md @@ -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_FINDINGS,4/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 family、iPad 方向、必要 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_FINDINGS(iPhone 16 / iPad Pro 11 双套件绿,iPhone 零回归硬门守住),4 个 findings(2 MED / 2 LOW)由 orchestrator 修完 4/4;iPad 分栏 sidebar+detail 截图确认。 +- **缺口**:① 真机 iPad(分栏手势 / 硬件键盘全键位 / 指针 hover 右键 / Stage Manager)**DEFERRED**,手工清单在 `PROGRESS_LOG.md`;② iPad happy-path XCUITest **接受推迟**(split 选中逻辑已由 `SidebarSelectionTests` 覆盖,单跑 7–11 min 且脆);③ **release ipa 层**核对未做(免费个人 team 无分发通道)——与 T-iOS-19 同一缺口,且 P2 新增的麦克风/语音识别 usage description 也应一并核。 - **Depends**: T-iPad-2/3/4 · **Owns**: 无源码(report-only,findings 派回 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 + W2(3∥4)2 + W3 0.5 ≈ **5 人日**(并行后墙钟更短)。 diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 7626f7e..6e4ffc2 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,6 +24,42 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 +### 📱 [x] iOS 收尾波 — 6 项整改 + P2 全波 + 两端令牌(2026-07-30,worktree `ios-completion`) + +- **起因**: 先做了一次 16-agent 的 iOS 完成度审计(2026-07-29),结论是"代码层面基本完工、交付层面卡在真机门口":43 个计划任务 27 DONE / 11 PARTIAL / 5 MISSING,**从未在任何真机上跑过一次**,且已落后服务端两个月的新功能。用户要求"全部实现",并定了三个范围问题:P2 全 5 项在内、Apple 账号是**免费个人 team**、Android 令牌一起补。 +- **编排**: 契约先冻结在 [`docs/plans/ios-completion.md`](./plans/ios-completion.md) §1(orchestrator 亲自核服务端源码),再派 3 个 Workflow 共 22 个 agent。**A 串行 → B 并行 5 → C 串行 4 → D 并行 3 → E 条件 → 收尾波 4**。C 波刻意串行:新增 `.swift` 必须 `xcodegen generate` 重写**共享的** `.xcodeproj`,并行必互踩。 + - 中途整个 C+D 波(7 个 agent)被 provider 侧 `529 Overloaded`/`ECONNRESET` 打挂;`resumeFromRunId` 把 A/B 从缓存回放、只重跑失败的,零返工。 +- **冻结契约(§1.1,踩过的坑都在里面)**: cookie 名 `webterm_auth`;`POST /auth` 体 `{"token":"…"}`;**`Accept` 头不能含 `text/html`**——含了会被当表单走 302 而不是 204/401;**204 但无 `Set-Cookie` = 服务端根本没开鉴权**,绝不能据此认为"已认证";原生客户端**自己手写 `Cookie` 头**,不解析 `Set-Cookie`、不用系统 cookie jar(URLSession/OkHttp 的 jar 在 WS upgrade 上行为不一致且难测);**令牌不替代 Origin**,两者都要带。 +- **真机构建解锁(最高杠杆,一次关掉 7 项验收阻塞)**: `DEVELOPMENT_TEAM=C738Z66SRW` + **target 级** `CODE_SIGN_IDENTITY`(废弃串 `"iPhone Developer"` 是 XcodeGen 的 product-type preset 在 target 层注入的,project 级怎么写都盖不住)→ 免费个人 team 上 `** BUILD SUCCEEDED **`,`-allowProvisioningUpdates` 现场签发 7 天 profile。**`aps-environment` 做成 env 开关且默认不挂**(`WEBTERM_PUSH_ENTITLEMENTS`),并**反向验证过必要性**:打开后真机构建报 `Personal development teams … do not support the Push Notifications capability`。 +- **顺手挖出一个会卡死全波的坑**: SwiftTerm 用浮动版本 `from: 1.13.0`,而 `.xcodeproj` 是 gitignore 的 → **任何人一 `xcodegen generate` 就解析到 1.15.0**,它在 1.14.0 新增的 `public var hasActiveSelection` 与 `TerminalScreen.swift` 本地同名属性冲突,且**加 `override` 修不了**(上游是 `public` 不是 `open`)。A1 先钉死版本止血,C3 删掉本地属性改用上游的、再抬到 1.15.0 根治。 +- **交付**(全部实测,非引用): + + | | 之前 | 之后 | + |---|---|---| + | 包测试 | 310 | **452** | + | App 测试 | 296(1 失败) | **550**(iPhone + iPad 各一遍,**0 known issue**) | + | 集成测试 | 10 | **32** | + | Android 测试 | 687 | **691** | + | ClientTLS 覆盖率 | 55.76%(**不在门内**) | **89.49%**(已入门) | + | 覆盖率门 | 4 个包 | **5 个包全过**(最低余量 +9.49pp) | + | 真机构建 | `BUILD FAILED` | **`BUILD SUCCEEDED`** | + + - **令牌**: 两端全链路(APIClient `/auth` 探针 + Keychain/Keystore 存储 + WS upgrade 带 Cookie + 401 终态不进退避环)。 + - **git 面板 parity**: iOS 补齐 `/projects/{log,pr,git/*,worktree*}` + `/sessions` + 队列——这是与 Android 和 web 差了两个月的那一块。 + - **P2 全 5 项**: 语音 PTT(epoch 防误发)、终端内搜索、worktree 生命周期、主题 + Dynamic Type、web `?join=` 互通。 + - **CI**: 修好 iOS 三条腿(缺 `npm ci` 会**硬失败**不是 skip / 缺 iPad UI 腿 / iOS 17 缺 runtime 时**静默报绿**),新建 `android.yml`(此前 Android 零 CI)。 +- **复核抓到并已修的 2 个 HIGH**: ① iOS 的 WS 令牌是**与主机无关**地解析的,混合机群(一台开令牌一台没开)下令牌门主机永远开不了终端,且 App 内无解 → 改为每主机一个 transport;② Android 把主机自身的 git 凭据 401(`src/http/git-ops.ts:108`)误报成"你的访问令牌错了",因为通用 401 映射跑在路由映射前面 → git 写路由标 `ROUTE_DEFINED`,保住服务端原文案。 +- **orchestrator 的两个裁定**: ① 令牌策略在 iOS 有 3 份、Android 1 份,**不解冻 `WireProtocol` 去收敛**(它是冻结的跨语言契约,共享密钥的 helper 不该进去),改为在 `IntegrationTests` 加漂移守卫——它是全树唯一能同时依赖三个包的目标;② `PairingProbe.swift` 越界改动是我特批的(B1 已收工释放该包),复核把它记为 LOW,已知悉。 +- **诚实的未闭合项**: release ipa 层核对(免费 team 无分发通道);真机人工腿(口述 / 扫码 / 锁屏 Face ID / IME);APNs 端到端(需付费账号);**两个 workflow 从未在任何 runner 上跑过**——本仓库唯一 remote 是自建 Gitea,GH Actions 从来没执行过,本地已逐条复跑其命令;Android instrumented 腿(令牌静态加密的唯一证明)设为 `workflow_dispatch` 触发,理由写在文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿。 +- **一个诚实的取舍**: 键栏在 AX3–AX5 的**字号**封顶在 `.accessibility2`。T-iOS-34 的验收原文是"最大字号不破版",现在任何档都不裁切;但"AX5 键帽以 AX5 字号渲染"是假的——不封顶时 AX5 需 108.24pt,会把终端吃掉。封顶值由测试钉死不得漂移。 +- **合并回 develop 时撞上了 `android-blocker-fixes`**(同期另一会话,见下一条)。**两边各自独立实现了 `WEBTERM_TOKEN` 闸**:它们走响应式(探针失败→提示→提交),本波走前置式(确认页填→`POST /auth`→Keystore)。合并是**组合**不是二选一——两条入口汇到同一个 `performProbe` 收口,两条路径都把令牌落到 WS upgrade 会读的那个 store。 + - **测试全绿不等于合并正确**:1224 个 Android 测试 0 失败的情况下,单独跑的语义复核仍抓出 **3 个真缺陷,其中 2 个是合并引入的**—— + ① **解除配对不再擦除凭据**(🔴 安全):`HostRemover` 擦 cookie 但不擦新加的 `AccessTokenStore`。令牌就是 cookie 的值(整个 shell 的凭据),于是解绑后仍留在磁盘**且是活的**——`AccessTokenSource` 按 origin 取而不是按配对记录取,同一 URL 重新添加、令牌框留空会**静默用残留令牌认证**。两个分支单独都没这个洞。 + ② **"两套 cookie 机制能安全共存"是错的**(🟠):复核 agent 反编译了本仓库钉死的 OkHttp 4.12.0 的 `BridgeInterceptor` 并用真 `MockWebServer` 复现——判据是"jar 非空"而**不是**"jar 里有这台主机的 `webterm_auth`",且是 `header()` **整头替换**。实测:jar 里有个无关 cookie(代理下发的)→ 令牌被从 REST **和 WS upgrade** 上整个抹掉;jar 里有陈旧 `webterm_auth` → 发出去的是陈旧值。修法是给 jar 按名过滤(读/写/水合三处),并补上**production 接线**下的回归测试——原有两个测试一个用 `NO_COOKIES` 建 client、一个用 `AccessTokenSource.NONE` 建 jar,**从来没有两者同时活着**,这正是缺陷能溜过去的原因。 + ③ **索引里的 AndroidManifest 是非法 XML**(🔴):git 把两边的 `android:allowBackup="false"` 自动合成了**重复属性**,无冲突标记。工作树被修好了但没 `git add`,照常 commit 会提交坏的那份、Android 构建在 manifest 解析就挂。 + - 另修 2 项:`204 无 Set-Cookie`(=服务端没开鉴权)在响应式路径上被错误持久化,违反 §1.1 冻结规则;`ROUTE_DEFINED` 范围过宽——服务端唯一路由自有的 401 是 `src/http/git-ops.ts:108`,只有 stage/commit/push/fetch 能到达,worktree 三条根本产生不了,却被钉成 ROUTE_DEFINED(令牌门主机上会把 gate-401 显示成 git 拒绝)。iOS 侧同样的过宽 pinning 一并修。 + - **合并后实测**: Android **1236** 测试 0 失败(10 模块)、kover 四个受门模块 92–96%、iOS 452 包测 + **32** 集成测(对**合并后**的真服务器,含 develop 新加的孤儿 tmux 那套)+ 550 App 测 0 known issue + 覆盖率门 5/5。`src/` 与 develop HEAD 逐字节相同。 + - **遗留(非缺陷,是设计取舍)**: 两套 cookie 机制仍并存,按名过滤后不会互相抹掉,但 jar 仍优先于手写头。要让 store 无条件胜出得二选一(收敛成单机制 = 重写一边的测试套,或加 app→network interceptor 传递),那是带真实设计决策的后续项,不是 merge 能顺手做的。已用一条测试把当前优先级钉死。 ### 🤖 [x] Android 客户端"能装但不能用"修复 — 7 个真实缺陷已修,设备上 67 个 instrumented 测试全绿(2026-07-30,worktree `android-blocker-fixes`) - **起因**: 例行问"android 客户端完成状况如何"。`android/PROGRESS_ANDROID.md` 写着 **"✅ ANDROID CLIENT COMPLETE — all 36 plan tasks landed"**,617 个 JVM 测试全绿,APK 也出得来。**这个"完成"只在编译层面成立** —— 从来没在任何真机或模拟器上跑过,而一旦跑,第一个动作就崩。审计(45 agent,含逐条对抗验证)+ 修复(多波 agent)已把 blocker 清掉。 diff --git a/docs/plans/ios-completion.md b/docs/plans/ios-completion.md new file mode 100644 index 0000000..24c006a --- /dev/null +++ b/docs/plans/ios-completion.md @@ -0,0 +1,429 @@ +# ios-completion — 收尾波:6 项整改 + P2 全波 + Android 令牌对齐 + +> 编排 doc。**orchestrator 冻结的跨 agent 契约在 §1,任何 builder 不得偏离**; +> 任务/Owns 表在 §2;验收在 §3。进度记录仍归 `docs/PROGRESS_LOG.md`(orchestrator 独写)。 + +审计依据:2026-07-29 的 16-agent iOS 完成度审计(43 计划任务 27 DONE / 11 PARTIAL / 5 MISSING)。 + +用户已定的三个范围问题: +1. **P2 全 5 项(T-iOS-31…35)在范围内** —— 计划 §7 要求"未按 P0 规格扩写 RED 清单前不得分派", + 故每个 P2 builder 的**第一步就是扩写自己那一项的 RED 测试清单**(写进本 doc §4 追加区),再进入 GREEN。 +2. **Apple 账号 = 免费个人 team**(Team ID `C738Z66SRW`,O=`Yaojia Wang`)。 + ⇒ **Push Notifications capability 不可用**:`aps-environment` entitlement 若默认挂上,真机构建会直接失败。 + entitlements 必须做成**默认不挂、env 开关**(见 A1)。 +3. **Android 的 `WEBTERM_TOKEN` 支持一起补**(与 iOS 侧文件完全不相交,可并行)。 + +--- + +## 1. 冻结契约(FROZEN — orchestrator 已核对服务端源码,builder 只许实现不许改) + +### 1.1 访问令牌(`WEBTERM_TOKEN`)—— 原生客户端接入方式 + +服务端事实(`src/http/auth.ts`、`src/server.ts:340-430,1353-1385`): + +| 项 | 值 | 出处 | +|---|---|---| +| Cookie 名 | **`webterm_auth`** | `auth.ts:30` `AUTH_COOKIE_NAME` | +| Cookie TTL | 2592000 秒(30 天) | `auth.ts:34` | +| 登录端点 | **`POST /auth`** | `server.ts:393` | +| 请求体 | `{"token":""}`,`Content-Type: application/json` | `server.ts:396,408-409` | +| **`Accept` 头** | **必须不含 `text/html`** | `server.ts:366-369,410` —— 含 `text/html` 会被当成表单走 302 重定向,而不是 204/401 | +| 成功 | **204**,带 `Set-Cookie: webterm_auth=…` | `server.ts:412-414` | +| 令牌错误 | **401** + `{"error":"invalid token"}` | `server.ts:418` | +| 限流 | **429**,10 次/分钟/IP | `server.ts:100,399-402` | +| **服务端未启用鉴权** | **204 但没有 `Set-Cookie`** | `server.ts:404-407` | +| 鉴权后 | 每个 HTTP 请求 + **WS upgrade** 都要带 `Cookie: webterm_auth=` | `server.ts:1375` | +| 令牌字符集 | `[A-Za-z0-9._~+/=-]`,长度 16–512 | CLAUDE.md / config 校验 | + +**实现决定(冻结)**: + +- 原生客户端**自己知道令牌**,因此 **直接手写 `Cookie: webterm_auth=` 请求头**即可, + **不解析 `Set-Cookie`**、不依赖系统 cookie 存储。理由:URLSession/OkHttp 的 cookie jar 在 + WS upgrade 上的行为不一致且难测;手写头与既有 `Origin` 手写头是同一个模式(`Endpoints.swift:81`), + 可被纯函数单测钉死。 +- `POST /auth` 只用作**配对期的一次性校验探针**: + - 204 **有** `Set-Cookie` ⇒ 令牌正确,保存; + - 204 **无** `Set-Cookie` ⇒ 该服务器**没开鉴权**,令牌不必保存(**不得**据此认为"已认证"); + - 401 ⇒ 令牌错,UI 报"令牌不正确";429 ⇒ UI 报"尝试过多,稍后再试"。 +- **`Cookie` 头与 `Origin` 头正交**:令牌**不替代** Origin 检查,两者都要带(`server.ts:1363-1379` 是先 Origin 再 cookie)。 +- **令牌是密级材料**:存 Keychain / Android Keystore-backed 存储, + `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`、**禁 `kSecAttrSynchronizable`**(沿用 `SecItemShim.swift` 既有约定); + **绝不写日志、绝不进 URL query、绝不进崩溃报告**。 +- **401 语义**:任何 RO/G 请求收到 401 ⇒ 抛类型化 `.unauthorized`(不是通用网络错), + UI 引导去补令牌。WS upgrade 收 401 ⇒ **终态,不进退避环**(与 `.replayTooLarge` 同级处理)。 + +### 1.2 git 面板端点(照 Android 已实现的形状;服务端为唯一真源) + +Origin-iff-G 规则(`Endpoints.swift:81`)照旧:**写操作必带 `Origin`,只读绝不带**。 + +| 端点 | 方法 | R/W | Android 参考实现 | +|---|---|---|---| +| `/projects/log` | GET | RO | `api-client/.../models/GitLog.kt` | +| `/projects/pr` | GET | RO | 同上 | +| `/projects/worktree/state` | GET | RO | — | +| `/projects/git/stage` | POST | **G** | `models/GitWrite.kt` | +| `/projects/git/commit` | POST | **G** | 同上 | +| `/projects/git/push` | POST | **G** | 同上 | +| `/projects/git/fetch` | POST | **G** | — | +| `/projects/worktree` | POST | **G** | `routes/GitRouteShapeTest.kt` | +| `/projects/worktree/prune` | POST | **G** | 同上 | +| `/sessions` | GET | RO | —(`claude --resume` 历史,T-iOS-32) | +| `/live-sessions/:id/queue` | POST | **G** | —(w2 pty 注入队列) | + +**真源是 `src/` 的实现**(`src/http/projects.ts` / `src/server.ts`),Android 只作交叉校验; +两者若不一致,**以 `src/` 为准并在返回条目里报告该不一致**。 + +### 1.3 免费 team 的签名与 entitlements(冻结) + +- `DEVELOPMENT_TEAM = C738Z66SRW`。 +- `CODE_SIGN_IDENTITY` 的历史值 `"iPhone Developer"` 是**已废弃**串,改 `"Apple Development"`(或删掉让 Automatic 自己选)。 +- `WebTerm.entitlements`(含 `aps-environment`)**默认不挂**。挂载由 **env 开关**控制, + 未设时 `CODE_SIGN_ENTITLEMENTS` 必须为空 —— 免费 team 挂上就真机构建失败。 + 开关名冻结为 **`WEBTERM_PUSH_ENTITLEMENTS`**(值=entitlements 相对路径),并写进 `ios/README.md`。 +- `UIBackgroundModes: [remote-notification]` 可以无条件加(背景模式不是 capability,免费 team 不受限)。 + +--- + +## 2. 任务表(Owns 铁律:不得编辑本任务 Owns 之外的文件) + +### Wave A(串行,1 agent)—— 全员依赖的工程根 + +| ID | 任务 | Owns | +|---|---|---| +| **A1** | 签名解锁 + Info.plist 全量键 + 可选 entitlements | `ios/project.yml`、`ios/App/WebTerm/WebTerm.entitlements`(新) | + +A1 必须一次把**后续所有波次需要的 Info.plist 键**都加好(它是 project.yml 的唯一 owner): +`NSMicrophoneUsageDescription`、`NSSpeechRecognitionUsageDescription`(T-iOS-31)、 +`UIBackgroundModes: [remote-notification]`(T-iOS-21)。 + +### Wave B(并行 5 agent,目录互斥)—— 包层 + CI + Android + +| ID | 任务 | Owns | +|---|---|---| +| **B1** | APIClient 全部新端点(§1.1 `/auth` + §1.2 全表)+ 模型 + 测试,覆盖率≥80% | `ios/Packages/APIClient/**` | +| **B2** | HostRegistry:按主机存访问令牌(Keychain,§1.1 存储约定)+ 迁移安全 | `ios/Packages/HostRegistry/**` | +| **B3** | SessionCore:WS upgrade 带 `Cookie`、401→终态 `.unauthorized`、不进退避环 | `ios/Packages/SessionCore/**` | +| **B4** | ClientTLS 覆盖率 55.76%→≥80% + 纳入覆盖率门 + 修 CI 三条腿 | `ios/Packages/ClientTLS/Tests/**`、`ios/IntegrationTests/scripts/coverage-gate.sh`、`.github/workflows/ios.yml` | +| **B5** | Android `WEBTERM_TOKEN` 支持(§1.1 同一契约,OkHttp 侧) | `android/**` | + +### Wave C(**串行** 4 agent)—— App 层 + +串行原因:新增 `.swift` 文件必须 `xcodegen generate` 重写**共享的** `.xcodeproj`,并行会互相踩; +串行后无文件归属冲突,每个 agent 可自由跑 `xcodegen + xcodebuild` 全量验证。 + +| ID | 任务 | 内容 | +|---|---|---| +| **C1** | 令牌 UI + 传输接线;移除主机 UI(顺带修 `PushRegistrar.handleHostRemoved` 死钩子) | +| **C2** | git 面板 UI + worktree 表(**含 T-iOS-32**)+ `claude --resume` 历史 | +| **C3** | 终端内搜索(**T-iOS-33**)+ 语音 PTT(**T-iOS-31**) | +| **C4** | 主题 + Dynamic Type(**T-iOS-34**)+ web `?join=` 互通(**T-iOS-35**) | + +### Wave D(并行 3 agent,report-only / 文档) + +| ID | 任务 | +|---|---| +| **D1** | 安全复核:令牌路径(两端)+ entitlements + 无令牌泄漏(日志/URL/崩溃) | +| **D2** | 全量验收(**T-iOS-36** + T-iOS-18/19/30 的机器可执行部分):包测/App 测/集成/覆盖率门/模拟器构建/真机构建尝试,只报真实数字 | +| **D3** | 文档:`README.md`、`ios/README.md`、`docs/PLAN_IOS_CLIENT.md`、`docs/PLAN_IOS_IPAD.md` 勾选与过期表述 | + +### Wave E(串行,条件触发) + +| ID | 任务 | +|---|---| +| **E1** | 修 D1/D2 报出的 CRITICAL/HIGH(无则跳过) | + +--- + +## 3. 验收门(每个 builder 自查,D2 复核) + +- TDD:先 RED 再 GREEN(§4 的 RED 清单是 P2 任务的前置交付物)。 +- `swift build` + `swift test` 本包全绿;改到 App 层则 `xcodegen generate` + iPhone 16 Pro 模拟器 `xcodebuild ... test` 全绿。 +- 受门包覆盖率 ≥80%(B4 后 ClientTLS 也进门)。 +- 零 `TODO`/`FIXME`/占位实现;零硬编码密钥;令牌零日志。 +- **诚实报告**:跑不了的(真机/付费账号/CI 平台)写 DEFERRED 并给出手工步骤,**不得报"通过"**。 + +--- + +## 4. P2 RED 测试清单(由各 P2 builder 在开工第一步追加到本节) + +### 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 ` 历史(`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 \r"`, + 结尾必须是 `\r`(0x0D,不是 `\n`)。 +34. RED:非法 id 的历史行 `canResume == false`(UI 不提供恢复按钮)。 +35. RED:cwd 非绝对路径的历史条目 → 不可恢复(`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. RED:git log 未推送边界:`upstream != nil` 时边界画在最后一个 `unpushed == true` 之后, + 且只画一次;`unpushed` 只在严格 `true` 时生效。 +45. RED:`upstream == nil` → 完全不画边界。 +46. RED:未推送提交排在已推送之下(老日期 merge)→ 边界仍在最后一个 unpushed 之后(Set 为准,非行号)。 +47. RED:stage/commit/push/fetch 的 `.rejected` 一律把服务器 `error` 原样显示;`nil` message → 兜底文案。 +48. RED:commit 成功 → 清空输入框 + 显示短 sha;`.rejected(409)`(无暂存)→ 保留输入框内容。 +49. RED:push `.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 追加 + +真源(三件套逐条移植 web,plan §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. RED:gate 是 `.plan` → `.text`(v1 只作用 tool gate)。 +27. RED:tool gate + 「确认」/「批准」/`ok`/`go ahead` → `.approve`。 +28. RED:归一化(小写 + 去标点 + 合并空白):`OK.`/「好的!」命中;`don't` → `dont`。 +29. RED:**整句精确**:「确认一下这个」→ `.text`(绝不子串匹配,否则顺口一句就批了 shell)。 +30. RED:tool 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. RED:epoch 在**撤销窗口内**变(confirm 已过、1.5s 内切会话)→ 到期仍零注入 + `.staleEpoch`。 +46. RED:epoch 不变 → 正常注入(防止闸门过严的回归保护)。 +47. RED:口述时 sessionId 还没 adopt(nil)、确认时已 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. RED:defaults 里被塞脏值 → 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. RED:critical 三色(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:两档 fg↔bg 对比度 ≥ 7:1(终端是正文,AAA 门槛)。 +23. RED:caret / selection 用该档 accent 解析值(不写死深色档的金)。 +24. RED:`DS.Palette.terminalBackgroundUIColor()` 是**动态** UIColor:light/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=`;`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=` → `.joinShared(origin:"http://192.168.1.5:3000", sessionId:)`。 +31. RED:`https://mac.ts.net/?join=` → origin 省略默认端口(`:443` 不出现)。 +32. RED:`HTTP://192.168.1.5:3000/?join=`(大写 scheme/host)→ origin 归一化为小写。 +33. RED:path 为 `""` 与 `"/"` 都接受(`location.origin + "/?join="` 产出 `/`)。 + +**B. 白名单纪律(http(s) 是比自定义 scheme 大得多的输入面,故**更严**)** +34. RED:`join` 值非 v4(`Validation.isValidSessionId` 判定)→ `.ignore`。 +35. RED:重复 `join` 键 → `.ignore`(歧义输入绝不部分应用)。 +36. RED:**多余 query 键**(`/?join=&x=1`)→ `.ignore`(web 形状精确只有一个键)。 +37. RED:非空 path(`/manage.html?join=`)→ `.ignore`。 +38. RED:带 fragment(`/?join=#x`)→ `.ignore`。 +39. RED:带 userinfo(`http://u:p@host/?join=`)→ `.ignore`(二维码钓鱼形状)。 +40. RED:非 http(s) scheme(`ftp://`、`file://`、`javascript:`)→ `.ignore`。 +41. RED:`?join=` 但**无 host**(`http:///?join=`)→ `.ignore`。 +42. RED(**有意改写既有用例**):`https://open?host=&join=` 仍 `.ignore` + —— 但理由从"scheme 不是 webterminal"变成"web 形状只许**单一** `join` 键"。 + 原用例名/注释同步改写,避免留下已失效的断言语义。 +43. RED:`webterminal://open?host=&join=` 等既有拒绝路径**逐条不变**(自定义 scheme 分支零回归)。 + +**C. 解析主机:只认已配对(二维码是不可信输入,绝不静默配对)** +44. RED:origin 命中已配对主机 → `openSession(host, sessionId)` 恰一次,无 hint。 +45. RED:origin 未配对 → **零** open、`showPairing` 一次、hint == `DeepLinkCopy.unknownHostHint` + (**不得**据链接内容自动新增主机)。 +46. RED:hint 文案**不回显**链接内容(不含 origin 串、不含 sessionId)—— 防钓鱼/防日志注入。 +47. RED:origin 比较走 `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` 同一通路)。 diff --git a/ios/App/WebTerm/Components/KeyBar.swift b/ios/App/WebTerm/Components/KeyBar.swift index c803476..3cf3a75 100644 --- a/ios/App/WebTerm/Components/KeyBar.swift +++ b/ios/App/WebTerm/Components/KeyBar.swift @@ -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), @@ -98,15 +115,89 @@ enum KeyBarLayout { /// Layout constants — all composed from the frozen `DS` scale (no literals). /// UIKit reads the same CGFloat tokens the SwiftUI chrome uses, so the key bar /// stays on-grid with the rest of the app. -private enum KeyBarMetrics { - /// A hit-target-tall row plus a little breathing room (44 + 8). - static let barHeight: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8 +/// +/// T-iOS-34 acceptance ("亮暗主题 + **最大字号不破版**") · The bar height used +/// to be the CONSTANT 44 + 8 = 52pt while the keycap it draws is two lines of +/// Dynamic Type text — at AX5 that keycap needs ~108pt, so more than half of it +/// was clipped. The height is now DERIVED from the keycap at the user's text +/// size, with two bounds that pull in opposite directions and are both required: +/// - a 44pt FLOOR, so the small sizes keep exactly today's 52pt bar and the HIG +/// hit target survives (zero regression); +/// - a CEILING on the text itself (`typeCeiling`), so the bar cannot grow +/// without limit. This bar is an `inputAccessoryView` riding on top of the +/// soft keyboard: an unclamped AX5 keycap turns a 52pt strip into a ~108pt one +/// and, with the keyboard already up, eats the terminal it exists to drive. +/// Clamping the FONT (not the frame) is what keeps that honest — the height +/// below is computed from the SAME clamped fonts the keycaps render with, so +/// nothing is ever clipped at any size. +/// +/// `internal`, not `private`: the acceptance is a MEASUREMENT +/// (`DynamicTypeLayoutTests` compares these against the real UIKit fonts and +/// against a real `KeyBarView`), and a private constant could only be +/// re-implemented in the test — i.e. not tested at all. +enum KeyBarMetrics { static let buttonSpacing: CGFloat = DS.Space.xs4 static let contentInset: CGFloat = DS.Space.sm8 static let buttonInsets = NSDirectionalEdgeInsets( top: DS.Space.xs4, leading: DS.Space.md12, bottom: DS.Space.xs4, trailing: DS.Space.md12 ) + + /// Dynamic Type ceiling for the keycap text — the UIKit currency of the DS's + /// dense-content policy `DS.Typography.numericClamp` (`.accessibility2`, + /// whose `UIContentSizeCategory` twin is `.accessibilityLarge`). The two are + /// pinned equal by a test so they cannot drift apart. + /// + /// Prose in this app scales all the way to AX5 and nothing caps it; a + /// 17-keycap control strip docked to the keyboard is the same dense case as + /// the telemetry chips — past this point extra size only costs terminal rows. + static let typeCeiling: UIContentSizeCategory = .accessibilityLarge + + /// The category the keycaps actually render at: the user's, capped at + /// `typeCeiling`. + static func effectiveCategory( + _ category: UIContentSizeCategory + ) -> UIContentSizeCategory { + category > typeCeiling ? typeCeiling : category + } + + /// Traits carrying the effective category — the `compatibleWith:` argument + /// for every font below. `UIFont.preferredFont(forTextStyle:)` WITHOUT it + /// reads the app-wide category and would ignore the clamp entirely. + static func traits(_ category: UIContentSizeCategory) -> UITraitCollection { + UITraitCollection(preferredContentSizeCategory: effectiveCategory(category)) + } + + /// Glyph line ("Esc", "^C", "⇧Tab"): monospaced footnote, so the glyphs stay + /// on a common width grid. + static func glyphFont(_ category: UIContentSizeCategory) -> UIFont { + UIFont.monospacedSystemFont( + ofSize: UIFont.preferredFont( + forTextStyle: .footnote, compatibleWith: traits(category) + ).pointSize, + weight: .semibold + ) + } + + /// Caption line ("中断", "模式"): caption2, secondary label colour. + static func captionFont(_ category: UIContentSizeCategory) -> UIFont { + UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits(category)) + } + + /// What ONE keycap needs at `category`: its two text lines plus the button's + /// own vertical content insets. Rounded up — a fractional shortfall is still + /// a clipped descender. + static func keycapHeight(_ category: UIContentSizeCategory) -> CGFloat { + (glyphFont(category).lineHeight + captionFont(category).lineHeight + + buttonInsets.top + buttonInsets.bottom).rounded(.up) + } + + /// The bar's height at `category`: whatever the keycap needs, never below the + /// 44pt hit target, plus `sm8` of breathing room. At the standard sizes the + /// keycap is smaller than 44 ⇒ 44 + 8 = 52pt, identical to the old constant. + static func barHeight(_ category: UIContentSizeCategory) -> CGFloat { + max(DS.Layout.minHitTarget, keycapHeight(category)) + DS.Space.sm8 + } } // MARK: - KeyBarView (inputAccessoryView) @@ -117,16 +208,53 @@ 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() { + /// The Dynamic Type category the bar is currently laid out for (T-iOS-34). + /// Test-visible: the acceptance is "the keycap the bar DRAWS fits the bar", + /// which is only measurable if the category the bar drew at is knowable. + private(set) var contentSizeCategory: UIContentSizeCategory + + private let voice: KeyBarVoiceHandlers? + + /// - Parameters: + /// - 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). + /// - contentSizeCategory: the text size to lay out for. Defaults to the + /// app's own — the same value `UIFont.preferredFont(forTextStyle:)` + /// reads — and is re-read from the traits whenever the user changes it. + /// Injectable so the AX5 acceptance can be measured without driving + /// Settings (a `UITraitCollection.performAsCurrent` wrapper would NOT + /// work: the un-`compatibleWith:` font API ignores it, which is exactly + /// the trap the old measurement fell into). + init( + voice: KeyBarVoiceHandlers? = nil, + contentSizeCategory: UIContentSizeCategory = + UIApplication.shared.preferredContentSizeCategory + ) { + self.voice = voice + self.contentSizeCategory = contentSizeCategory super.init( - frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight), + frame: CGRect( + x: 0, y: 0, width: 0, + height: KeyBarMetrics.barHeight(contentSizeCategory) + ), inputViewStyle: .keyboard ) allowsSelfSizing = true buildBar() + // Live text-size changes: re-render the keycaps at the new size and let + // `allowsSelfSizing` re-measure the bar. Without this the bar would keep + // whatever height it was born with until the terminal is reopened. + registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { + (bar: KeyBarView, _: UITraitCollection) in + bar.apply(contentSizeCategory: bar.traitCollection.preferredContentSizeCategory) + } } @available(*, unavailable, message: "KeyBarView is code-built only") @@ -135,7 +263,29 @@ final class KeyBarView: UIInputView { } override var intrinsicContentSize: CGSize { - CGSize(width: UIView.noIntrinsicMetric, height: KeyBarMetrics.barHeight) + CGSize( + width: UIView.noIntrinsicMetric, + height: KeyBarMetrics.barHeight(contentSizeCategory) + ) + } + + /// Re-render every keycap at `category` and re-measure the bar. A no-op when + /// the category did not actually change (trait callbacks fire for unrelated + /// trait changes too). + func apply(contentSizeCategory category: UIContentSizeCategory) { + guard category != contentSizeCategory else { return } + contentSizeCategory = category + for (button, spec) in zip(keyButtons, KeyBarLayout.buttons) { + button.configuration = keycapConfiguration( + label: spec.label, caption: spec.caption, isPrimary: spec.isPrimary + ) + } + voiceButton?.configuration = keycapConfiguration( + label: KeyBarLayout.voiceLabel, caption: KeyBarLayout.voiceCaption, + isPrimary: false + ) + frame.size.height = KeyBarMetrics.barHeight(category) + invalidateIntrinsicContentSize() } private func buildBar() { @@ -150,6 +300,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 +332,77 @@ 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.) + /// + /// Both fonts come from `KeyBarMetrics`, resolved `compatibleWith:` the + /// bar's current (clamped) category — the SAME resolution `barHeight` is + /// computed from, which is what makes "the bar always fits its keycaps" true + /// by construction rather than by a hand-tuned constant. + private func keycapConfiguration( + label: String, caption: String, isPrimary: Bool + ) -> UIButton.Configuration { + var config: UIButton.Configuration = isPrimary ? .tinted() : .gray() + config.cornerStyle = .fixed + config.background.cornerRadius = DS.Radius.sm8 + var attributedLabel = AttributedString(label) + attributedLabel.font = KeyBarMetrics.glyphFont(contentSizeCategory) + config.attributedTitle = attributedLabel + var attributedCaption = AttributedString(caption) + attributedCaption.font = KeyBarMetrics.captionFont(contentSizeCategory) + attributedCaption.foregroundColor = .secondaryLabel + config.attributedSubtitle = attributedCaption + config.titleAlignment = .center + config.contentInsets = KeyBarMetrics.buttonInsets + return config + } + + private func makeKeycap( + label: String, caption: String, title: String, isPrimary: Bool + ) -> UIButton { + let config = keycapConfiguration( + label: label, caption: caption, isPrimary: isPrimary + ) + 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) diff --git a/ios/App/WebTerm/Components/SessionThumbnail.swift b/ios/App/WebTerm/Components/SessionThumbnail.swift index 4b05e45..d9ad78b 100644 --- a/ios/App/WebTerm/Components/SessionThumbnail.swift +++ b/ios/App/WebTerm/Components/SessionThumbnail.swift @@ -1,5 +1,4 @@ import APIClient -import ClientTLS import Foundation import os import SwiftUI @@ -226,19 +225,25 @@ final class SessionThumbnailPipeline { cache = SessionThumbnailCache(maxEntries: maxCacheEntries) } - /// 生产装配:ephemeral URLSession(preview 字节可能含屏上密钥,内存缓存 - /// only——同 T-iOS-19 对 RO GET 的裁定),并携带 C-iOS-2 的设备证书身份, - /// 这样对隧道主机的预览取数也能通过 mTLS(否则缩略图会被 nginx 拒握手)。 - /// 身份经 provider 每次握手时按需从 keychain 载入(MEDIUM 无需重启修复: - /// 中途导入的证书下次取数即生效;缺证=nil,对本地主机无副作用)。 - static func live() -> SessionThumbnailPipeline { - let identityStore = KeychainClientIdentityStore() - let transport = URLSessionHTTPTransport(identityProvider: { - identityStore.loadedIdentityOrNil() - }) - return SessionThumbnailPipeline( + /// 生产装配:取数走**组合根装配**的那条带凭证的传输 + /// (`AppEnvironment.thumbnailTransport()`)—— ephemeral URLSession(preview + /// 字节可能含屏上密钥,内存缓存 only,同 T-iOS-19 对 RO GET 的裁定) + /// + C-iOS-2 的设备证书身份(否则隧道主机的缩略图会被 nginx 拒握手) + /// + **C1 每主机访问令牌 Cookie**。两个凭证都按请求/按握手现取, + /// 中途导入的证书、中途输入的令牌下一次取数即生效,无需重启。 + /// + /// F1(本次修复):这里原先自建一个**没有令牌源**的传输,于是在开了 + /// `WEBTERM_TOKEN` 的主机上 `GET /live-sessions/:id/preview` 一律 401; + /// 而本管线按设计永不抛错(失败即占位图),401 就被静默降级成 + /// **每一张**缩略图都是占位符,屏幕上没有任何原因可查。 + /// + /// - Parameter http: 取数传输。默认即上述生产装配;测试注入替身。 + static func live( + http: any HTTPTransport = AppEnvironment.thumbnailTransport() + ) -> SessionThumbnailPipeline { + SessionThumbnailPipeline( loader: { request in - try await APIClient(endpoint: request.endpoint, http: transport) + try await APIClient(endpoint: request.endpoint, http: http) .preview(id: request.sessionId) }, renderer: { data, cols, rows in diff --git a/ios/App/WebTerm/Components/SpeechDictation.swift b/ios/App/WebTerm/Components/SpeechDictation.swift new file mode 100644 index 0000000..e834b87 --- /dev/null +++ b/ios/App/WebTerm/Components/SpeechDictation.swift @@ -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 语义:松手即取**当前最佳假设**(不等最终结果),否则确认要多等 1–2 秒。 +@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) + } + } + } +} diff --git a/ios/App/WebTerm/Components/TerminalSearchBar.swift b/ios/App/WebTerm/Components/TerminalSearchBar.swift new file mode 100644 index 0000000..0d36e64 --- /dev/null +++ b/ios/App/WebTerm/Components/TerminalSearchBar.swift @@ -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() +} + +/// 搜索方向(web:Enter = next,Shift+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) + } +} diff --git a/ios/App/WebTerm/Components/VoicePTT.swift b/ios/App/WebTerm/Components/VoicePTT.swift new file mode 100644 index 0000000..a1ae738 --- /dev/null +++ b/ios/App/WebTerm/Components/VoicePTT.swift @@ -0,0 +1,579 @@ +import Foundation +import Observation + +/// T-iOS-31 · 语音 PTT(按住说话)+ 显式确认 + 1.5s 撤销 + epoch 防误发。 +/// +/// 本文件 = 缝 + 纯归约 + 状态机。同任务的另两块因 800 行硬上限拆出为兄弟文件: +/// `VoicePTTBanner.swift`(SwiftUI 状态卡)与 `SpeechDictation.swift`(真机识别器)。 +/// +/// 三件套逐条移植 web(plan §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 = [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 = [ + "确认", "批准", "同意", "通过", "允许", "可以", "好", "好的", "好吧", "好啊", + "是", "是的", "对", "行", "继续", "回车", + "yes", "yeah", "yep", "ok", "okay", "confirm", "approve", "accept", + "proceed", "go ahead", + ] + + static let rejectPhrases: Set = [ + "拒绝", "取消", "不行", "不要", "不用", "不同意", "不批准", "不可以", "不允许", "不通过", + "中断", "停止", "算了", "别", "不", + "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't→dont)→ 合并空白 → 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 + @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? + + init( + dictation: any VoiceDictating, + clock: any Clock, + 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 + } +} + diff --git a/ios/App/WebTerm/Components/VoicePTTBanner.swift b/ios/App/WebTerm/Components/VoicePTTBanner.swift new file mode 100644 index 0000000..030665d --- /dev/null +++ b/ios/App/WebTerm/Components/VoicePTTBanner.swift @@ -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 + } +} diff --git a/ios/App/WebTerm/DeepLinkRouter.swift b/ios/App/WebTerm/DeepLinkRouter.swift index fb26f3b..f012eb2 100644 --- a/ios/App/WebTerm/DeepLinkRouter.swift +++ b/ios/App/WebTerm/DeepLinkRouter.swift @@ -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=&join=` (scheme registered in -/// project.yml `CFBundleURLTypes`; web 分享 QR 互通的 `?join=` 解析是 -/// T-iOS-35 的增量), and +/// project.yml `CFBundleURLTypes`), +/// - **the web share link `http(s)://[:]/?join=`** +/// (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 (`/?join=`). 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 = ["", "/"] } + /// Whitelisted web-share shape (`http(s)://[:]/?join=`). + /// + /// 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 = ["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=&join=` — 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)://[:]/?join=` — 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 diff --git a/ios/App/WebTerm/DesignSystem/AppTheme.swift b/ios/App/WebTerm/DesignSystem/AppTheme.swift new file mode 100644 index 0000000..ffad9e5 --- /dev/null +++ b/ios/App/WebTerm/DesignSystem/AppTheme.swift @@ -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) + } +} diff --git a/ios/App/WebTerm/DesignSystem/Primitives.swift b/ios/App/WebTerm/DesignSystem/Primitives.swift index e95ef79..0a9e8b4 100644 --- a/ios/App/WebTerm/DesignSystem/Primitives.swift +++ b/ios/App/WebTerm/DesignSystem/Primitives.swift @@ -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)) diff --git a/ios/App/WebTerm/DesignSystem/TerminalPalette.swift b/ios/App/WebTerm/DesignSystem/TerminalPalette.swift new file mode 100644 index 0000000..f92eec0 --- /dev/null +++ b/ios/App/WebTerm/DesignSystem/TerminalPalette.swift @@ -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 里 + /// 就是这个写法,直接用它可以逐字节对照,不必手算 0–1 分量。 + 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 + ) + } +} diff --git a/ios/App/WebTerm/DesignSystem/Tokens.swift b/ios/App/WebTerm/DesignSystem/Tokens.swift index 822004c..a09e0ed 100644 --- a/ios/App/WebTerm/DesignSystem/Tokens.swift +++ b/ios/App/WebTerm/DesignSystem/Tokens.swift @@ -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 0–255 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 diff --git a/ios/App/WebTerm/DesignSystem/Typography.swift b/ios/App/WebTerm/DesignSystem/Typography.swift index f49f1c1..70fd031 100644 --- a/ios/App/WebTerm/DesignSystem/Typography.swift +++ b/ios/App/WebTerm/DesignSystem/Typography.swift @@ -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) } } diff --git a/ios/App/WebTerm/Push/PushRegistrar.swift b/ios/App/WebTerm/Push/PushRegistrar.swift index 6d45e70..695a781 100644 --- a/ios/App/WebTerm/Push/PushRegistrar.swift +++ b/ios/App/WebTerm/Push/PushRegistrar.swift @@ -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 } diff --git a/ios/App/WebTerm/Screens/GitPanelScreen.swift b/ios/App/WebTerm/Screens/GitPanelScreen.swift new file mode 100644 index 0000000..8e7e704 --- /dev/null +++ b/ios/App/WebTerm/Screens/GitPanelScreen.swift @@ -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) -> 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 "未跟踪" + } + } +} diff --git a/ios/App/WebTerm/Screens/GitPanelViews.swift b/ios/App/WebTerm/Screens/GitPanelViews.swift new file mode 100644 index 0000000..e82170f --- /dev/null +++ b/ios/App/WebTerm/Screens/GitPanelViews.swift @@ -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` 列表里的一条分界线:以上尚未推送到 ``。 +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)" + } +} diff --git a/ios/App/WebTerm/Screens/PairingCopy.swift b/ios/App/WebTerm/Screens/PairingCopy.swift new file mode 100644 index 0000000..1368510 --- /dev/null +++ b/ios/App/WebTerm/Screens/PairingCopy.swift @@ -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 = + "访问令牌格式不符合要求(16–512 位,且只能包含 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,或反馈该网段。" + } +} diff --git a/ios/App/WebTerm/Screens/PairingScreen.swift b/ios/App/WebTerm/Screens/PairingScreen.swift index 2d00fee..0d5aa1b 100644 --- a/ios/App/WebTerm/Screens/PairingScreen.swift +++ b/ios/App/WebTerm/Screens/PairingScreen.swift @@ -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 { + 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)" } diff --git a/ios/App/WebTerm/Screens/ProjectDetailScreen.swift b/ios/App/WebTerm/Screens/ProjectDetailScreen.swift index 8ee37d9..9771983 100644 --- a/ios/App/WebTerm/Screens/ProjectDetailScreen.swift +++ b/ios/App/WebTerm/Screens/ProjectDetailScreen.swift @@ -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 / remove(create 归 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 { + Binding( + get: { worktreePendingRemoval != nil }, + set: { presented in if !presented { worktreePendingRemoval = nil } } + ) + } + + /// 409 → 二次确认。关闭(含滑走)等于取消,绝不残留在待 force 态。 + private var forceDialogBinding: Binding { + 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 `:挑一条历史会话在本仓库接着跑(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: - Worktrees(T-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。" diff --git a/ios/App/WebTerm/Screens/ProjectsScreen.swift b/ios/App/WebTerm/Screens/ProjectsScreen.swift index f8be724..cbf3f90 100644 --- a/ios/App/WebTerm/Screens/ProjectsScreen.swift +++ b/ios/App/WebTerm/Screens/ProjectsScreen.swift @@ -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-32(C2):历史会话恢复走同一条开会话缝。 + onResumeClaude: { cwd, sessionId in + viewModel.requestResumeClaude(cwd: cwd, sessionId: sessionId) + } ) } // T-iPad-4 · iPhone 透传(现有全高 sheet 字节级不变);iPad 套 diff --git a/ios/App/WebTerm/Screens/ResumeHistorySheet.swift b/ios/App/WebTerm/Screens/ResumeHistorySheet.swift new file mode 100644 index 0000000..d8bb56d --- /dev/null +++ b/ios/App/WebTerm/Screens/ResumeHistorySheet.swift @@ -0,0 +1,116 @@ +import APIClient +import SwiftUI +import WireProtocol + +/// T-iOS-32 · `claude --resume ` 历史选择器(`GET /sessions`)。 +/// +/// 只列出 cwd 落在本仓库内的历史会话(在别的仓库跑过的会话,用本仓库的 cwd 去 +/// resume 是错的)。选中一条 → 在**该会话自己的 cwd** 里开新会话并注入 +/// `claude --resume \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) + ) + } + } + } +} diff --git a/ios/App/WebTerm/Screens/SessionListScreen.swift b/ios/App/WebTerm/Screens/SessionListScreen.swift index d04ebe5..dca1118 100644 --- a/ios/App/WebTerm/Screens/SessionListScreen.swift +++ b/ios/App/WebTerm/Screens/SessionListScreen.swift @@ -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 = "主机" diff --git a/ios/App/WebTerm/Screens/SettingsScreen.swift b/ios/App/WebTerm/Screens/SettingsScreen.swift new file mode 100644 index 0000000..8094612 --- /dev/null +++ b/ios/App/WebTerm/Screens/SettingsScreen.swift @@ -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 } + } +} diff --git a/ios/App/WebTerm/Screens/TerminalScreen.swift b/ios/App/WebTerm/Screens/TerminalScreen.swift index 3f12e0b..9d0865b 100644 --- a/ios/App/WebTerm/Screens/TerminalScreen.swift +++ b/ios/App/WebTerm/Screens/TerminalScreen.swift @@ -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() + } +} diff --git a/ios/App/WebTerm/Screens/WorktreeSheet.swift b/ios/App/WebTerm/Screens/WorktreeSheet.swift new file mode 100644 index 0000000..74e5c3d --- /dev/null +++ b/ios/App/WebTerm/Screens/WorktreeSheet.swift @@ -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, base: Binding + ) -> 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 立刻出现在列表里) + } + } + } +} diff --git a/ios/App/WebTerm/ViewModels/GitPanelPresentation.swift b/ios/App/WebTerm/ViewModels/GitPanelPresentation.swift new file mode 100644 index 0000000..6254544 --- /dev/null +++ b/ios/App/WebTerm/ViewModels/GitPanelPresentation.swift @@ -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 + ) + } + + /// 从未 fetch(nil)也算过期 —— "没 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 +} diff --git a/ios/App/WebTerm/ViewModels/GitPanelViewModel.swift b/ios/App/WebTerm/ViewModels/GitPanelViewModel.swift new file mode 100644 index 0000000..c43d032 --- /dev/null +++ b/ios/App/WebTerm/ViewModels/GitPanelViewModel.swift @@ -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. **分区独立降级**。日志失败不该让同步带消失,PR(gh 会走外网)单独加载, +/// 详情失败就**没有**同步带,而不是显示一个编造的"已同步"。 +/// 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 + let commit: @Sendable (_ message: String) async throws -> GitWriteOutcome + let push: @Sendable () async throws -> GitWriteOutcome + let fetchRemote: @Sendable () async throws -> GitWriteOutcome + /// 判定 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, + commit: @escaping @Sendable (String) async throws -> GitWriteOutcome, + push: @escaping @Sendable () async throws -> GitWriteOutcome, + fetchRemote: @escaping @Sendable () async throws -> GitWriteOutcome, + 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( + _ operation: Operation, + fallback: String, + write: () async throws -> GitWriteOutcome, + 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)" } +} diff --git a/ios/App/WebTerm/ViewModels/PairingViewModel.swift b/ios/App/WebTerm/ViewModels/PairingViewModel.swift index 7db901f..8483799 100644 --- a/ios/App/WebTerm/ViewModels/PairingViewModel.swift +++ b/ios/App/WebTerm/ViewModels/PairingViewModel.swift @@ -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 + /// 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 + /// 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 + /// 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, + /// 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 = { _, _ 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,或反馈该网段。" - } -} diff --git a/ios/App/WebTerm/ViewModels/ProjectsViewModel.swift b/ios/App/WebTerm/ViewModels/ProjectsViewModel.swift index 9c6e957..aabff72 100644 --- a/ios/App/WebTerm/ViewModels/ProjectsViewModel.swift +++ b/ios/App/WebTerm/ViewModels/ProjectsViewModel.swift @@ -178,6 +178,28 @@ final class ProjectsViewModel { ) } + /// T-iOS-32(C2)· 恢复一条历史会话:**同一条** `attach(null, cwd)` 缝,只把 + /// bootstrap 换成 `claude --resume \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 { diff --git a/ios/App/WebTerm/ViewModels/ResumeHistoryViewModel.swift b/ios/App/WebTerm/ViewModels/ResumeHistoryViewModel.swift new file mode 100644 index 0000000..d47fc9c --- /dev/null +++ b/ios/App/WebTerm/ViewModels/ResumeHistoryViewModel.swift @@ -0,0 +1,146 @@ +import APIClient +import Foundation +import Observation +import WireProtocol + +/// T-iOS-32 · `claude --resume ` 历史(`GET /sessions`)。 +/// +/// 服务端把主机 `~/.claude/projects` 下最近修改的会话文件列给我们( +/// `src/http/history.ts`),这正是 `claude --resume` 选择器的数据。恢复动作走的是 +/// 已有的"在此仓库开新会话"缝:`attach(null, cwd)` + attach 后注入 +/// `claude --resume \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 + /// 会话自己的 cwd(worktree 会话会回到那个 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) 的会话" } +} diff --git a/ios/App/WebTerm/ViewModels/TerminalViewModel.swift b/ios/App/WebTerm/ViewModels/TerminalViewModel.swift index e3e97ad..3c54f89 100644 --- a/ios/App/WebTerm/ViewModels/TerminalViewModel.swift +++ b/ios/App/WebTerm/ViewModels/TerminalViewModel.swift @@ -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) } } diff --git a/ios/App/WebTerm/ViewModels/WorktreeViewModel.swift b/ios/App/WebTerm/ViewModels/WorktreeViewModel.swift new file mode 100644 index 0000000..8c97346 --- /dev/null +++ b/ios/App/WebTerm/ViewModels/WorktreeViewModel.swift @@ -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 + let prune: @Sendable () async throws -> GitWriteOutcome + let remove: @Sendable (_ worktreePath: String, _ force: Bool) async throws + -> GitWriteOutcome + } + + 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 = ["~", "^", ":", "?", "*", "[", "\\"] + + 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)。" + } +} diff --git a/ios/App/WebTerm/WebTerm.entitlements b/ios/App/WebTerm/WebTerm.entitlements new file mode 100644 index 0000000..5b04ea2 --- /dev/null +++ b/ios/App/WebTerm/WebTerm.entitlements @@ -0,0 +1,28 @@ + + + + + + aps-environment + development + + diff --git a/ios/App/WebTerm/Wiring/AppEnvironment.swift b/ios/App/WebTerm/Wiring/AppEnvironment.swift index f7a5b35..f9bf51e 100644 --- a/ios/App/WebTerm/Wiring/AppEnvironment.swift +++ b/ios/App/WebTerm/Wiring/AppEnvironment.swift @@ -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,20 @@ 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. +/// - The one other `URLSession` in the app belongs to `thumbnailTransport()` +/// below — assembled HERE, from these same providers, for the same reasons +/// (F1: the session-thumbnail pipeline is built by a SwiftUI `@State` +/// initializer that has no environment to read, and used to build a +/// credential-less transport of its own). /// - 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 +36,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 @@ -46,28 +78,228 @@ struct AppEnvironment: Sendable { // missing cert is the normal pre-install state (→ nil); genuine faults // are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only // fire for tunnel hosts, so a `nil` result is inert for local hosts. - let identityStore = KeychainClientIdentityStore() - let identityProvider: @Sendable () -> ClientIdentity? = { - identityStore.loadedIdentityOrNil() + let identityProvider = keychainIdentityProvider() + 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) } + ) } - let http = URLSessionHTTPTransport(identityProvider: identityProvider) - let termTransport = URLSessionTermTransport(identityProvider: identityProvider) 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 + ) + } + + /// C-iOS-2 · The lazy, Keychain-backed device-identity provider: resolved + /// per TLS client-certificate challenge (never snapshotted), so a certificate + /// imported mid-run is presented on the NEXT handshake without a relaunch. + /// ONE definition, shared by `production()` and `thumbnailTransport()`. + static func keychainIdentityProvider() -> @Sendable () -> ClientIdentity? { + let identityStore = KeychainClientIdentityStore() + return { identityStore.loadedIdentityOrNil() } + } + + /// F1 · The HTTP transport `SessionThumbnailPipeline.live()` fetches + /// `GET /live-sessions/:id/preview` over — assembled HERE, at the + /// composition root, from the SAME two credential providers `production()` + /// installs on the app-wide transport: the lazy mTLS identity AND the + /// per-origin access token. + /// + /// WHY IT EXISTS (the bug it fixes): the pipeline used to build its own + /// `URLSessionHTTPTransport` with an identity provider but **no token + /// source**, so on a `WEBTERM_TOKEN`-gated host every preview came back 401. + /// The pipeline never throws by design — a failed preview IS a placeholder — + /// so the whole session list silently degraded to placeholder thumbnails with + /// nothing on screen saying why. It is assembled here rather than in the + /// component because the pipeline is created by a SwiftUI `@State` + /// initializer that has no `AppEnvironment` to read from. + /// + /// It is a SEPARATE `URLSession` from `production().http` for the same reason + /// (no environment at that call site), and that costs nothing: both are + /// `.ephemeral` (preview bytes are raw ring-buffer terminal output and must + /// never touch a disk cache — T-iOS-19) and both resolve their credentials + /// per request, so neither can hold a stale token. + /// + /// - Parameters: + /// - hostStore: where the per-host tokens live — Keychain in production, + /// an in-memory double in tests. + /// - identityProvider: the mTLS identity source (see above). + static func thumbnailTransport( + hostStore: any HostStore = KeychainHostStore(), + identityProvider: @escaping @Sendable () -> ClientIdentity? + = AppEnvironment.keychainIdentityProvider() + ) -> URLSessionHTTPTransport { + let tokens = AccessTokenSource(store: hostStore) + return URLSessionHTTPTransport( + identityProvider: identityProvider, + tokenForOrigin: { origin in await tokens.token(forOrigin: origin) } ) } /// 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 { + 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) + } +} diff --git a/ios/App/WebTerm/Wiring/RootView.swift b/ios/App/WebTerm/Wiring/RootView.swift index a2f7255..6bc0618 100644 --- a/ios/App/WebTerm/Wiring/RootView.swift +++ b/ios/App/WebTerm/Wiring/RootView.swift @@ -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 = "设备证书自动续期失败,将在下次前台重试" diff --git a/ios/App/WebTerm/Wiring/TerminalSessionController.swift b/ios/App/WebTerm/Wiring/TerminalSessionController.swift index 946083b..8828ebb 100644 --- a/ios/App/WebTerm/Wiring/TerminalSessionController.swift +++ b/ios/App/WebTerm/Wiring/TerminalSessionController.swift @@ -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) diff --git a/ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift b/ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift index 0bdab89..ddd9101 100644 --- a/ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift +++ b/ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift @@ -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=` 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 diff --git a/ios/App/WebTermTests/AccessTokenSourceTests.swift b/ios/App/WebTermTests/AccessTokenSourceTests.swift new file mode 100644 index 0000000..d354615 --- /dev/null +++ b/ios/App/WebTermTests/AccessTokenSourceTests.swift @@ -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("完全没有令牌的机群 → nil(LAN 零配置逐字不变,不带 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=,且返回新请求(不原地改调用方的请求)") + 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 盖上令牌 Cookie(RO 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") + } +} diff --git a/ios/App/WebTermTests/AppThemeTests.swift b/ios/App/WebTermTests/AppThemeTests.swift new file mode 100644 index 0000000..a02c67f --- /dev/null +++ b/ios/App/WebTermTests/AppThemeTests.swift @@ -0,0 +1,353 @@ +import SwiftUI +import Testing +import UIKit +@testable import WebTerm + +/// T-iOS-34 · 主题(跟随系统 / 深色 / 浅色)—— 模型、持久化、有效配色解析, +/// 以及**浅色通路的 token 审计**(doc §4 A–E 条目 1–24)。 +/// +/// 审计的核心不是"把 `.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:1(WCAG 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 两档不同,且都仍是 wash(alpha < 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:1…21: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)) + } +} diff --git a/ios/App/WebTermTests/DeepLinkJoinTests.swift b/ios/App/WebTermTests/DeepLinkJoinTests.swift new file mode 100644 index 0000000..9bc4091 --- /dev/null +++ b/ios/App/WebTermTests/DeepLinkJoinTests.swift @@ -0,0 +1,302 @@ +import Foundation +import HostRegistry +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-35 · web 分享 QR(`?join=`)互通(doc §4 A–C 组,条目 30–50)。 +/// +/// 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://:3000/?join= → .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 键 → .ignore(web 形状精确只有一个 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) + } +} diff --git a/ios/App/WebTermTests/DeepLinkRouterTests.swift b/ios/App/WebTermTests/DeepLinkRouterTests.swift index 351d0b7..86db314 100644 --- a/ios/App/WebTermTests/DeepLinkRouterTests.swift +++ b/ios/App/WebTermTests/DeepLinkRouterTests.swift @@ -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) 有了自己的白名单形状 + /// (`/?join=`,见 `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("白名单外的 scheme(ftp/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( diff --git a/ios/App/WebTermTests/DesignSystemTests.swift b/ios/App/WebTermTests/DesignSystemTests.swift index d906f3b..c112a8a 100644 --- a/ios/App/WebTermTests/DesignSystemTests.swift +++ b/ios/App/WebTermTests/DesignSystemTests.swift @@ -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) diff --git a/ios/App/WebTermTests/DynamicTypeLayoutTests.swift b/ios/App/WebTermTests/DynamicTypeLayoutTests.swift new file mode 100644 index 0000000..e7557fd --- /dev/null +++ b/ios/App/WebTermTests/DynamicTypeLayoutTests.swift @@ -0,0 +1,286 @@ +import SessionCore +import SwiftUI +import Testing +import UIKit +import WireProtocol +@testable import WebTerm + +/// T-iOS-34 · Dynamic Type 不破版(doc §4 F 组,条目 25–29)。 +/// +/// 验收口径是"最大字号不破版",所以断言必须是**量出来的**,不是"看着还行": +/// 每个受测视图套 `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("TelemetryChip:AX2 与 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 · DSButtonStyle(gate 的两个半宽按钮是最窄的按钮场景) + + @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 · KeyBar(UIKit inputAccessoryView):条高跟随键帽 + + /// F1 · 这里原是 `withKnownIssue` 记录的**已知缺口**:`KeyBarMetrics.barHeight` + /// 固定 52pt(44 + 8),而 AX5 下一枚键帽要 ~108pt(footnote 行高 52.51 + + /// caption2 行高 47.73 + 上下 4+4 内衬)——超过一半被裁,T-iOS-34 的验收 + /// 「最大字号不破版」实际是 FAIL。现已修好:条高由**键帽实际画出来的高度** + /// 推出(下限 44pt 命中目标,字号在 DS 的密集内容上限处封顶),所以本组 + /// 断言全部是真绿,`withKnownIssue` 标记已删除。 + @Test("KeyBar 在 AX5 下条高容得下它真正画出的键帽(原缺口:裁掉一半以上)") + func keyBarFitsItsKeycapsAtLargestType() { + let measured = KeyBarProbe.measure(at: KeyBarProbe.largestCategory) + + #expect( + measured.keycap <= measured.barHeight + LayoutProbe.epsilon, + "AX5 键帽需 \(measured.keycap)pt,条高只有 \(measured.barHeight)pt" + ) + #expect( + measured.typographic <= measured.barHeight + LayoutProbe.epsilon, + "AX5 排版需求 \(measured.typographic)pt,条高只有 \(measured.barHeight)pt" + ) + } + + @Test("KeyBar 在全部 12 个字号档都不裁切,且条高始终 ≥ 44pt 命中目标") + func keyBarFitsItsKeycapsAtEveryContentSize() { + for category in KeyBarProbe.allCategories { + let measured = KeyBarProbe.measure(at: category) + + #expect( + measured.keycap <= measured.barHeight + LayoutProbe.epsilon, + "\(category.rawValue):键帽 \(measured.keycap)pt > 条高 \(measured.barHeight)pt" + ) + #expect( + measured.typographic <= measured.barHeight + LayoutProbe.epsilon, + "\(category.rawValue):排版需求 \(measured.typographic)pt > 条高 \(measured.barHeight)pt" + ) + #expect( + measured.barHeight >= DS.Layout.minHitTarget, + "\(category.rawValue):条高 \(measured.barHeight)pt 低于命中目标" + ) + } + } + + /// 回归护栏 + 缺口的历史事实:**不封顶**的 AX5 排版需求(实测 108.24pt = + /// footnote 52.51 + caption2 47.73 + 内衬 8)远超出厂的固定 52pt —— 谁再把 + /// `barHeight` 写回常量,这条就红。 + @Test("未封顶的 AX5 排版需求远超出厂 52pt 固定条高(这就是原缺口)") + func unclampedLargestTypeOverflowsTheOldFixedBar() { + let shipped = DS.Layout.minHitTarget + DS.Space.sm8 + + let unclamped = KeyBarProbe.typographicRequirement(at: KeyBarProbe.largestCategory) + + #expect(unclamped > shipped * 2) + } + + /// 零回归的准确口径:**键帽还没超过 44pt 命中目标的档位**(XS…XL,含出厂 + /// 默认 L)条高逐点还是 52pt。再往上(XXL 起)键帽本身就比 44pt 高了,条高 + /// 必须跟着长——那正是本次修复,不是回归。 + @Test("默认与更小字号档条高逐点不变(44 + 8 = 52,零回归)") + func keyBarKeepsItsShippedHeightAtTheSmallerSizes() { + let shipped = DS.Layout.minHitTarget + DS.Space.sm8 + + for category in [UIContentSizeCategory.extraSmall, .small, .medium, + .large, .extraLarge] { + #expect(KeyBarMetrics.barHeight(category) == shipped, "\(category.rawValue)") + } + #expect(KeyBarProbe.measure(at: .large).barHeight == shipped) + } + + @Test("键帽真的随字号长大(不是把字号焊死换来的假绿)") + func keyBarActuallyGrowsWithType() { + let standard = KeyBarProbe.measure(at: .large) + let largest = KeyBarProbe.measure(at: KeyBarProbe.largestCategory) + + #expect(largest.keycap > standard.keycap) + #expect(largest.barHeight > standard.barHeight) + } + + /// 键栏骑在软键盘上方:长高是必须的,长疯了就把它要驱动的终端吃掉了。 + /// 故字号在 DS 的密集内容上限(`numericClamp`)处封顶——两处策略必须同源。 + @Test("字号封顶 == DS 密集内容策略,封顶后条高不再增长且不超出货架高度的两倍") + func keyBarTypeCeilingMatchesTheDesignSystemPolicy() { + let shipped = DS.Layout.minHitTarget + DS.Space.sm8 + + #expect(DynamicTypeSize(KeyBarMetrics.typeCeiling) == DS.Typography.numericClamp) + + let ceiling = KeyBarMetrics.barHeight(KeyBarMetrics.typeCeiling) + #expect(KeyBarMetrics.barHeight(KeyBarProbe.largestCategory) == ceiling) + // 封顶之前照常长大(不是从第一档就焊死)。 + #expect(ceiling > KeyBarMetrics.barHeight(.large)) + // 有界:AX5 的键栏最多是出厂高度的两倍,终端不会被键栏吃掉。 + #expect(ceiling <= shipped * 2) + } + + @Test("frame 高度与 intrinsicContentSize 一致(自适应输入视图靠后者自量)") + func keyBarFrameMatchesItsIntrinsicHeight() { + for category in [UIContentSizeCategory.large, KeyBarProbe.largestCategory] { + let bar = KeyBarView(contentSizeCategory: category) + + #expect(bar.frame.height == bar.intrinsicContentSize.height) + } + } +} + +// 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( + _ 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 度量:把**真** `KeyBarView` 建在某个字号档上,量它**实际画出来**的 +/// 键帽有多高、它**给出**的条高有多高。两个数都来自同一个真视图,所以 +/// 「不裁切」是量出来的,不是算出来的。 +/// +/// 为什么不用 `UITraitCollection.performAsCurrent { KeyBarView() }`: +/// `UIFont.preferredFont(forTextStyle:)`(**无** `compatibleWith:`)读的是 +/// **App 级** `preferredContentSizeCategory`,**不看** `UITraitCollection.current` +/// —— 那样量出来的永远是默认字号(实测 38pt)的**假绿**。修复后键栏把字号档 +/// 显式注入(`KeyBarView(contentSizeCategory:)`)并全程走 `compatibleWith:`, +/// 于是这里量到的就是屏幕上真会画出来的东西。 +@MainActor +enum KeyBarProbe { + /// AX5 内容尺寸类别。 + static let largestCategory = UIContentSizeCategory + .accessibilityExtraExtraExtraLarge + + /// 全部 12 档(7 标准 + 5 无障碍)——「不破版」是对**每一档**的断言。 + static let allCategories: [UIContentSizeCategory] = [ + .extraSmall, .small, .medium, .large, .extraLarge, .extraExtraLarge, + .extraExtraExtraLarge, .accessibilityMedium, .accessibilityLarge, + .accessibilityExtraLarge, .accessibilityExtraExtraLarge, + .accessibilityExtraExtraExtraLarge, + ] + + /// - Returns: + /// - `barHeight` 键栏给出的条高(`intrinsicContentSize`); + /// - `keycap` 该档下真键帽**实际**要的高度(从真 `KeyBarView` 的按钮量); + /// - `typographic` 同一档的**排版口径**需求 —— 两行行高 + 上下内衬,即原 + /// 「已知缺口」用例的算法(未封顶的 AX5 实测 108.24pt vs 固定 52pt)。 + /// 它是 `keycap` 的保守上界(实测在封顶档 68.86 vs 54.67),**两个都** + /// 必须被条高盖住 —— 宁可多几点余量,也不能少一点造成裁切。 + static func measure( + at category: UIContentSizeCategory + ) -> (barHeight: CGFloat, keycap: CGFloat, typographic: CGFloat) { + let bar = KeyBarView(contentSizeCategory: category) + let keycap = (bar.keyButtons + [bar.voiceButton].compactMap { $0 }) + .map { $0.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height } + .max() ?? 0 + return ( + barHeight: bar.intrinsicContentSize.height, + keycap: keycap, + // 只借用「封顶」这一个决定,高度公式本身不复用(否则就是自证)。 + typographic: typographicRequirement( + at: KeyBarMetrics.effectiveCategory(category) + ) + ) + } + + /// 两行文字 + 键帽上下内衬,逐字沿用缺口用例的算法。 + static func typographicRequirement(at category: UIContentSizeCategory) -> CGFloat { + let traits = UITraitCollection(preferredContentSizeCategory: category) + let glyph = UIFont.preferredFont(forTextStyle: .footnote, compatibleWith: traits) + let caption = UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits) + // KeyBarMetrics.buttonInsets 的上下各 xs4。 + return glyph.lineHeight + caption.lineHeight + DS.Space.xs4 * 2 + } +} diff --git a/ios/App/WebTermTests/GitPanelPresentationTests.swift b/ios/App/WebTermTests/GitPanelPresentationTests.swift new file mode 100644 index 0000000..e998f9d --- /dev/null +++ b/ios/App/WebTermTests/GitPanelPresentationTests.swift @@ -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 清单 36–47,见 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 36–43) + + @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("从未 fetch(lastFetchMs == 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( + "dirtyCount:nil → 未检查(不是 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 44–46) + + @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("推送失败")) + } +} diff --git a/ios/App/WebTermTests/GitPanelViewModelTests.swift b/ios/App/WebTermTests/GitPanelViewModelTests.swift new file mode 100644 index 0000000..18974d4 --- /dev/null +++ b/ios/App/WebTermTests/GitPanelViewModelTests.swift @@ -0,0 +1,328 @@ +import APIClient +import Foundation +import Testing +@testable import WebTerm + +/// C2 · git 面板 VM(RED 清单 47–51 + 分区独立降级)。 +/// +/// 依赖全部以闭包注入(与 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: - 写操作文案(47–49) + + @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)? = nil, + commit: (@Sendable (String) async throws -> GitWriteOutcome)? = nil, + push: (@Sendable () async throws -> GitWriteOutcome)? = nil, + fetchRemote: (@Sendable () async throws -> GitWriteOutcome)? = 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..] = [] + + func wait() async { + await withCheckedContinuation { waiters.append($0) } + } + + func release() { + let pending = waiters + waiters = [] + pending.forEach { $0.resume() } + } +} diff --git a/ios/App/WebTermTests/HostRemovalTests.swift b/ios/App/WebTermTests/HostRemovalTests.swift new file mode 100644 index 0000000..3a58d35 --- /dev/null +++ b/ios/App/WebTermTests/HostRemovalTests.swift @@ -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) + } +} diff --git a/ios/App/WebTermTests/KeyBarTests.swift b/ios/App/WebTermTests/KeyBarTests.swift index 0df7374..4529ee0 100644 --- a/ios/App/WebTermTests/KeyBarTests.swift +++ b/ios/App/WebTermTests/KeyBarTests.swift @@ -75,6 +75,102 @@ struct KeyBarTests { #expect(recorder.keys == KeyBarLayout.buttons.map(\.key)) } + // MARK: - Dynamic Type (T-iOS-34 · the bar tracks the keycap it draws) + + /// Measured height of every keycap, in layout order (the mic key last when + /// present) — the only honest way to ask "did the keycaps really re-render". + private func keycapHeights(_ bar: KeyBarView) -> [CGFloat] { + (bar.keyButtons + [bar.voiceButton].compactMap { $0 }) + .map { $0.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height } + } + + @Test("changing the text size re-renders every keycap and re-measures the bar") + func contentSizeCategoryChangeRebuildsTheKeycaps() { + // Arrange: a bar born at the standard size, mic key included. + let bar = KeyBarView( + voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}), + contentSizeCategory: .large + ) + let standardHeight = bar.intrinsicContentSize.height + let standardKeycaps = keycapHeights(bar) + + // Act: the user drags the text-size slider to the maximum. + bar.apply(contentSizeCategory: .accessibilityExtraExtraExtraLarge) + + // Assert: EVERY keycap grew (mic included) and the bar grew to hold them. + let largestKeycaps = keycapHeights(bar) + #expect(bar.contentSizeCategory == .accessibilityExtraExtraExtraLarge) + #expect(bar.intrinsicContentSize.height > standardHeight) + #expect(bar.frame.height == bar.intrinsicContentSize.height) + #expect(standardKeycaps.count == KeyBarLayout.buttons.count + 1) + #expect(zip(standardKeycaps, largestKeycaps).allSatisfy { $1 > $0 }) + #expect(largestKeycaps.allSatisfy { $0 <= bar.intrinsicContentSize.height }) + } + + /// The runtime path, not just the direct call: the user changes the text size + /// in Settings while a terminal is open, UIKit pushes a new trait collection, + /// and the bar must re-measure itself — otherwise it keeps whatever height it + /// was born with until the terminal is reopened. + @Test("a trait-collection change re-lays-out the bar (no reopen needed)") + func traitChangePropagatesToTheBar() { + // Arrange: a bar inside a real window (trait propagation needs one). + let bar = KeyBarView(contentSizeCategory: .large) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.addSubview(bar) + window.layoutIfNeeded() + let before = bar.intrinsicContentSize.height + + // Act: what UIKit does when the system text size changes. + window.traitOverrides.preferredContentSizeCategory = + .accessibilityExtraExtraExtraLarge + window.layoutIfNeeded() + + // Assert + #expect(bar.contentSizeCategory == .accessibilityExtraExtraExtraLarge) + #expect(bar.intrinsicContentSize.height > before) + } + + @Test("re-applying the same text size is a no-op (trait callbacks fire for anything)") + func reapplyingTheSameCategoryChangesNothing() { + // Arrange + let bar = KeyBarView(contentSizeCategory: .large) + let before = keycapHeights(bar) + let beforeHeight = bar.intrinsicContentSize.height + + // Act + bar.apply(contentSizeCategory: .large) + + // Assert + #expect(bar.contentSizeCategory == .large) + #expect(keycapHeights(bar) == before) + #expect(bar.intrinsicContentSize.height == beforeHeight) + } + + /// The HIG floor is enforced by a constraint, not by the glyph: at the small + /// text sizes a keycap's own content is only ~37pt tall, so without this the + /// bar would shrink below a tappable target. Asserted structurally because + /// `systemLayoutSizeFitting` reports the CONTENT size (it is what the + /// clipping test needs) and would not show the constraint at all. + @Test("every keycap carries the 44pt HIG hit-target floor, at every text size") + func keycapsCarryTheHitTargetFloor() { + for category in [UIContentSizeCategory.extraSmall, .large, + .accessibilityExtraExtraExtraLarge] { + let bar = KeyBarView( + voice: KeyBarVoiceHandlers(onDown: {}, onUp: {}), + contentSizeCategory: category + ) + + for button in bar.keyButtons + [bar.voiceButton].compactMap({ $0 }) { + #expect(button.constraints.contains { + $0.firstAttribute == .height + && $0.relation == .greaterThanOrEqual + && $0.constant == DS.Layout.minHitTarget + }) + } + #expect(bar.intrinsicContentSize.height >= DS.Layout.minHitTarget) + } + } + // MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping) @Test("hardware chords: Esc, Shift+Tab and the 7 Ctrl chords map through KeyByteMap") diff --git a/ios/App/WebTermTests/NewSessionInCwdTests.swift b/ios/App/WebTermTests/NewSessionInCwdTests.swift index 3f185a3..25c441c 100644 --- a/ios/App/WebTermTests/NewSessionInCwdTests.swift +++ b/ios/App/WebTermTests/NewSessionInCwdTests.swift @@ -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 ) ) diff --git a/ios/App/WebTermTests/PairingProbeUnauthorizedTests.swift b/ios/App/WebTermTests/PairingProbeUnauthorizedTests.swift new file mode 100644 index 0000000..dc23308 --- /dev/null +++ b/ios/App/WebTermTests/PairingProbeUnauthorizedTests.swift @@ -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=` 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) + } + } +} diff --git a/ios/App/WebTermTests/PairingTokenViewModelTests.swift b/ios/App/WebTermTests/PairingTokenViewModelTests.swift new file mode 100644 index 0000000..ad7bf71 --- /dev/null +++ b/ios/App/WebTermTests/PairingTokenViewModelTests.swift @@ -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] + private var validateResults: + [Result] + + init( + verifyResults: [Result] = [], + validateResults: [Result] + = [] + ) { + self.verifyResults = verifyResults + self.validateResults = validateResults + } + + func verify( + _ endpoint: HostEndpoint, _ token: AccessToken? + ) -> Result { + 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 { + 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-Cookie(valid)→ 带令牌重跑探针,主机连令牌一起入库") + 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) + } +} diff --git a/ios/App/WebTermTests/PairingViewModelTests.swift b/ios/App/WebTermTests/PairingViewModelTests.swift index 4c8edf5..eb12050 100644 --- a/ios/App/WebTermTests/PairingViewModelTests.swift +++ b/ios/App/WebTermTests/PairingViewModelTests.swift @@ -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, diff --git a/ios/App/WebTermTests/ProjectOpenWiringTests.swift b/ios/App/WebTermTests/ProjectOpenWiringTests.swift index 6425ef2..9527131 100644 --- a/ios/App/WebTermTests/ProjectOpenWiringTests.swift +++ b/ios/App/WebTermTests/ProjectOpenWiringTests.swift @@ -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) }) ) ) } diff --git a/ios/App/WebTermTests/ProjectResumeLaunchTests.swift b/ios/App/WebTermTests/ProjectResumeLaunchTests.swift new file mode 100644 index 0000000..2c245d3 --- /dev/null +++ b/ios/App/WebTermTests/ProjectResumeLaunchTests.swift @@ -0,0 +1,99 @@ +import APIClient +import Foundation +import HostRegistry +import TestSupport +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-32(C2)· 历史会话恢复的**发射端**:`ProjectsViewModel.requestResumeClaude` +/// 必须走与"在此仓库开新会话"完全相同的 `attach(null, cwd)` 缝,只把 bootstrap 换成 +/// `claude --resume \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 \\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 URL(gh 降级时没有 url 字段)→ nil") + func nilIsRejected() { + #expect(PrLink.url(from: nil) == nil) + } +} diff --git a/ios/App/WebTermTests/ResumeHistoryViewModelTests.swift b/ios/App/WebTermTests/ResumeHistoryViewModelTests.swift new file mode 100644 index 0000000..d7fa8a3 --- /dev/null +++ b/ios/App/WebTermTests/ResumeHistoryViewModelTests.swift @@ -0,0 +1,187 @@ +import APIClient +import Foundation +import Testing +@testable import WebTerm + +/// T-iOS-32 · `claude --resume ` 历史(`GET /sessions`)的 App 层归约。 +/// +/// RED 清单见 `docs/plans/ios-completion.md` §4(E 28–35)。安全重点:会话 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: - 过滤(28–30) + + @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: - phase(31–32) + + @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: - 命令合成(33–34,安全) + + @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 ` 且以 \\r(0x0D)结尾,绝不是 \\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,且携带会话自己的 cwd(worktree 会话回到 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 + } +} diff --git a/ios/App/WebTermTests/SessionSwitcherTests.swift b/ios/App/WebTermTests/SessionSwitcherTests.swift index 33d227b..61cfa08 100644 --- a/ios/App/WebTermTests/SessionSwitcherTests.swift +++ b/ios/App/WebTermTests/SessionSwitcherTests.swift @@ -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 ) ) diff --git a/ios/App/WebTermTests/SessionThumbnailTokenTests.swift b/ios/App/WebTermTests/SessionThumbnailTokenTests.swift new file mode 100644 index 0000000..52b87dc --- /dev/null +++ b/ios/App/WebTermTests/SessionThumbnailTokenTests.swift @@ -0,0 +1,231 @@ +import APIClient +import Foundation +import HostRegistry +import TestSupport +import Testing +import UIKit +import WireProtocol +@testable import WebTerm + +/// F1 · 令牌门主机上的缩略图管线。 +/// +/// 缺口:`SessionThumbnailPipeline.live()` 自建了一个**没有令牌源**的 +/// `URLSessionHTTPTransport`,于是在开了 `WEBTERM_TOKEN` 的主机上 +/// `GET /live-sessions/:id/preview` 一律 401;而本管线按设计**永不抛错** +/// (失败即占位图),401 就被静默降级成「每一张缩略图都是占位符」, +/// 屏幕上没有任何原因可查。 +/// +/// 这里钉三件事: +/// 1. 令牌真的到了 preview 请求头上(走的是**生产的**盖章代码路径 +/// `URLSessionHTTPTransport.authenticated`,不是测试里重写一遍); +/// 2. 只读纪律零回归 —— preview 是 RO,**绝不**带 `Origin`(Origin-iff-G); +/// 3. 没有令牌 / 令牌不对(401)时依旧**优雅降级**成占位图,不崩不抛。 +@MainActor +@Suite("SessionThumbnail 令牌接线") +struct SessionThumbnailTokenTests { + /// 32 个合法字符(§1.1 字符集 `[A-Za-z0-9._~+/=-]`,长度 16–512)。 + private static let token = "0123456789abcdefghijklmnopqrstuv" + private static let base = "http://192.168.1.20:3000" + + // MARK: - Fixtures + + /// 生产的盖章逻辑(`URLSessionHTTPTransport.authenticated`)串在一个可脚本化 + /// 的记录器前面:真实 Cookie 代码路径,零网络。 + private struct StampingTransport: HTTPTransport { + let stamper: URLSessionHTTPTransport + let recorder: FakeHTTPTransport + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + try await recorder.send(await stamper.authenticated(request)) + } + } + + private func makeEndpoint(_ base: String = SessionThumbnailTokenTests.base) throws + -> HostEndpoint { + let url = try #require(URL(string: base)) + return try #require(HostEndpoint(baseURL: url)) + } + + private func makeStore(token: String?) async throws -> InMemoryHostStore { + let store = InMemoryHostStore() + _ = try await store.upsert( + HostRegistry.Host( + id: UUID(), name: "mac", endpoint: try makeEndpoint(), + accessToken: try token.map { try AccessToken(validating: $0) } + ) + ) + return store + } + + private func previewURL(_ sessionId: UUID) throws -> URL { + try #require( + URL(string: "\(Self.base)/live-sessions/\(sessionId.uuidString.lowercased())/preview") + ) + } + + private func previewBody(_ sessionId: UUID) throws -> Data { + try #require(""" + {"id":"\(sessionId.uuidString.lowercased())","cols":80,"rows":24,"data":"hello"} + """.data(using: .utf8)) + } + + /// 装配一条**默认生产形状**的管线,只把网络出口换成记录器。 + private func makePipeline( + store: any HostStore + ) -> (pipeline: SessionThumbnailPipeline, recorder: FakeHTTPTransport) { + let recorder = FakeHTTPTransport() + let transport = StampingTransport( + stamper: AppEnvironment.thumbnailTransport( + hostStore: store, identityProvider: { nil } + ), + recorder: recorder + ) + return (SessionThumbnailPipeline.live(http: transport), recorder) + } + + // MARK: - 令牌门主机 + + @Test("令牌门主机:preview 请求带上 Cookie: webterm_auth=,且缩略图真渲染出来") + func previewRequestCarriesTheAccessTokenCookie() async throws { + // Arrange + let sessionId = UUID() + let (pipeline, recorder) = makePipeline(store: try await makeStore(token: Self.token)) + await recorder.queueSuccess( + url: try previewURL(sessionId), body: try previewBody(sessionId) + ) + + // Act + let image = await pipeline.thumbnail( + for: SessionThumbnailRequest( + endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 + ) + ) + + // Assert + let requests = await recorder.recordedRequests + #expect(requests.count == 1) + #expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) + == "\(AccessTokenCookie.name)=\(Self.token)") + #expect(!image.isPlaceholder, "带上令牌后 preview 应当成功并渲染") + } + + @Test("preview 是只读路由:带令牌也绝不带 Origin(Origin-iff-G 零回归)") + func previewStaysOriginFree() async throws { + // Arrange + let sessionId = UUID() + let (pipeline, recorder) = makePipeline(store: try await makeStore(token: Self.token)) + await recorder.queueSuccess( + url: try previewURL(sessionId), body: try previewBody(sessionId) + ) + + // Act + _ = await pipeline.thumbnail( + for: SessionThumbnailRequest( + endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 + ) + ) + + // Assert + let requests = await recorder.recordedRequests + #expect(requests.first?.value(forHTTPHeaderField: "Origin") == nil) + } + + // MARK: - 无令牌 / 令牌不对 + + @Test("主机没有令牌 → 请求逐字不带 Cookie(LAN 零配置主机零回归)") + func tokenlessHostSendsNoCookie() async throws { + // Arrange + let sessionId = UUID() + let (pipeline, recorder) = makePipeline(store: try await makeStore(token: nil)) + await recorder.queueSuccess( + url: try previewURL(sessionId), body: try previewBody(sessionId) + ) + + // Act + let image = await pipeline.thumbnail( + for: SessionThumbnailRequest( + endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 + ) + ) + + // Assert + let requests = await recorder.recordedRequests + #expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == nil) + #expect(!image.isPlaceholder) + } + + @Test("令牌缺失导致 401 → 降级成占位图,不崩不抛(管线的错误 UI 就是占位图)") + func unauthorizedDegradesToPlaceholder() async throws { + // Arrange:主机记录里没有令牌,服务器要求令牌 → 401。 + let sessionId = UUID() + let (pipeline, recorder) = makePipeline(store: try await makeStore(token: nil)) + await recorder.queueSuccess( + url: try previewURL(sessionId), status: 401, + body: try #require(#"{"error":"unauthorized"}"#.data(using: .utf8)) + ) + + // Act + let image = await pipeline.thumbnail( + for: SessionThumbnailRequest( + endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 + ) + ) + + // Assert + #expect(image == .placeholder) + #expect(await recorder.recordedRequests.count == 1) + } + + @Test("主机根本不在存储里(存储读不到该 origin)→ 无 Cookie,仍不崩") + func unknownOriginDegradesGracefully() async throws { + // Arrange:存储里是另一台主机。 + let sessionId = UUID() + let store = InMemoryHostStore() + let otherEndpoint = try makeEndpoint("http://192.168.1.99:3000") + _ = try await store.upsert( + HostRegistry.Host( + id: UUID(), name: "other", endpoint: otherEndpoint, + accessToken: try AccessToken(validating: Self.token) + ) + ) + let (pipeline, recorder) = makePipeline(store: store) + await recorder.queueSuccess( + url: try previewURL(sessionId), body: try previewBody(sessionId) + ) + + // Act + let image = await pipeline.thumbnail( + for: SessionThumbnailRequest( + endpoint: try makeEndpoint(), sessionId: sessionId, lastOutputAt: 1 + ) + ) + + // Assert:绝不把别台主机的令牌发出去。 + let requests = await recorder.recordedRequests + #expect(requests.first?.value(forHTTPHeaderField: AccessTokenCookie.header) == nil) + #expect(!image.isPlaceholder) + } + + // MARK: - 组合根装配本身 + + @Test("thumbnailTransport 就是 App 的同一条盖章传输(按 origin 取本主机令牌)") + func thumbnailTransportStampsPerOrigin() async throws { + // Arrange + let transport = AppEnvironment.thumbnailTransport( + hostStore: try await makeStore(token: Self.token), identityProvider: { nil } + ) + let mine = URLRequest(url: try previewURL(UUID())) + let other = URLRequest( + url: try #require(URL(string: "http://192.168.1.99:3000/live-sessions")) + ) + + // Act + let stamped = await transport.authenticated(mine) + let untouched = await transport.authenticated(other) + + // Assert + #expect(stamped.value(forHTTPHeaderField: AccessTokenCookie.header) + == "\(AccessTokenCookie.name)=\(Self.token)") + #expect(untouched.value(forHTTPHeaderField: AccessTokenCookie.header) == nil) + } +} diff --git a/ios/App/WebTermTests/SidebarSelectionTests.swift b/ios/App/WebTermTests/SidebarSelectionTests.swift index e225aab..c5fad80 100644 --- a/ios/App/WebTermTests/SidebarSelectionTests.swift +++ b/ios/App/WebTermTests/SidebarSelectionTests.swift @@ -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 ) ) diff --git a/ios/App/WebTermTests/TerminalSearchTests.swift b/ios/App/WebTermTests/TerminalSearchTests.swift new file mode 100644 index 0000000..5c2a041 --- /dev/null +++ b/ios/App/WebTermTests/TerminalSearchTests.swift @@ -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 清单 1–18)。 +/// +/// 契约来源: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. 查询提交策略(1–3) + + @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. 方向与引擎调用(4–9) + + @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. 打开/关闭生命周期(10–14) + + @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. 不变式:搜索是纯读(15–16) + + @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 绑定(17–18,Accept:命中并高亮) + + /// 真 `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) + } +} diff --git a/ios/App/WebTermTests/TerminalUnauthorizedTests.swift b/ios/App/WebTermTests/TerminalUnauthorizedTests.swift new file mode 100644 index 0000000..7b41b04 --- /dev/null +++ b/ios/App/WebTermTests/TerminalUnauthorizedTests.swift @@ -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) + } +} diff --git a/ios/App/WebTermTests/VoicePTTTests.swift b/ios/App/WebTermTests/VoicePTTTests.swift new file mode 100644 index 0000000..241a83d --- /dev/null +++ b/ios/App/WebTermTests/VoicePTTTests.swift @@ -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 清单 19–56)。 +/// +/// 三件套逐条移植 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? + private var arrival: CheckedContinuation? + + /// 被测代码经过闸门;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. 转写清洗(19–24) + + @Test("19 · 普通文本 → 首尾 trim 后原样") + func sanitizeKeepsPlainText() { + #expect(VoiceTranscript.sanitize(" git status ") == "git status") + } + + @Test("20 · \\r(0x0D)被剥除 —— 口述绝不自己回车执行命令") + 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. 匹配器移植(25–33) + + 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 → .text(v1 只作用 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;「不清楚」→ text;now 不被 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 降级 text;reject 不受影响") + 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 撤销窗口(34–43) + + @Test("34 · pressDown → .listening;partial 回调更新 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 防误发(44–48) + + @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. 权限与失败路径(49–52) + + @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 桥(53–56) + + @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))) + } +} diff --git a/ios/App/WebTermTests/WorktreeViewModelTests.swift b/ios/App/WebTermTests/WorktreeViewModelTests.swift new file mode 100644 index 0000000..85a74d6 --- /dev/null +++ b/ios/App/WebTermTests/WorktreeViewModelTests.swift @@ -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` §4(A 1–9、B 10–17、C 18–21、D 22–27)。 +/// 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("403(kill-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)? = nil, + prune: (@Sendable () async throws -> GitWriteOutcome)? = nil, + remove: (@Sendable (String, Bool) async throws -> GitWriteOutcome)? = 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..] = [] + + func wait() async { + await withCheckedContinuation { waiters.append($0) } + } + + func release() { + let pending = waiters + waiters = [] + pending.forEach { $0.resume() } + } +} diff --git a/ios/IntegrationTests/APIClientTokenPolicyProbe.swift b/ios/IntegrationTests/APIClientTokenPolicyProbe.swift new file mode 100644 index 0000000..a9b9166 --- /dev/null +++ b/ios/IntegrationTests/APIClientTokenPolicyProbe.swift @@ -0,0 +1,25 @@ +// +// APIClientTokenPolicyProbe.swift — F3:APIClient 侧令牌策略的取值窗口 +// +// 为什么要单独一个文件:`APIClient` 既是**模块名**又是模块里的**结构体名**, +// 于是 `APIClient.AuthCookie` 恒被解析成"结构体 APIClient 的嵌套类型"(不存在), +// 无法在同时 import 了 SessionCore 的文件里限定到模块级符号;而不限定的 +// `AuthCookie` 又会和 `SessionCore.AuthCookie` 撞名。 +// +// 解法:本文件**只** import APIClient,此处的裸 `AuthCookie` / `AccessTokenRule` +// 唯一地解析到 APIClient 的那两个 internal 类型,再以带前缀的名字转出去。 +// TokenPolicyDriftTests 用这个窗口,SessionCore 侧则直接 `SessionCore.AuthCookie`。 + +@testable import APIClient + +enum APIClientTokenPolicy { + static var cookieName: String { AuthCookie.name } + + static func headerValue(for token: String) -> String { + AuthCookie.headerValue(for: token) + } + + static func isWellFormed(_ candidate: String) -> Bool { + AccessTokenRule.isWellFormed(candidate) + } +} diff --git a/ios/IntegrationTests/AccessTokenGateTests.swift b/ios/IntegrationTests/AccessTokenGateTests.swift new file mode 100644 index 0000000..de327bb --- /dev/null +++ b/ios/IntegrationTests/AccessTokenGateTests.swift @@ -0,0 +1,352 @@ +// +// AccessTokenGateTests.swift — F3(1):访问令牌的端到端腿(真服务器 + 真客户端栈) +// +// 本波风险最高的面:一枚共享密钥被同时盖在 **HTTP 请求**(APIClient)和 +// **WS upgrade**(SessionCore/URLSessionTermTransport)上,此前只有对着 fake 的 +// 单测。这里把它接到**真的门**上:`WEBTERM_TOKEN` 启用的真 Node 服务器 +// (ServerHarness.tokenGatedServer()),跑真 `URLSessionTermTransport` / +// 真 `APIClient` / 真 `SessionEngine`。 +// +// 服务端真源(已逐条 curl 核实,见条目里的实测值): +// src/http/auth.ts、src/server.ts:340-430(/auth + authGate)、 +// src/server.ts:1353-1385(upgrade:先 Origin 再 cookie)。 +// 客户端冻结契约:docs/plans/ios-completion.md §1.1。 +// +// 断言里的四条不变式: +// 1. 正确令牌 ⇒ WS 升级成功 + attach 成功;RO 与 G 两类 HTTP 都放行。 +// 2. 错误令牌 ⇒ upgrade 401 ⇒ **终态** `.unauthorized`,**零重连尝试** +// (这条是拦住客户端把服务器自己的 10 次/分钟限流器打成暴力破解的性质)。 +// 3. 无令牌打令牌网关 ⇒ 同上终态;HTTP 侧 401 ⇒ 类型化 `.unauthorized`。 +// 4. 令牌是**加法不是替代**:合法 cookie + 外域/缺失 Origin 仍被各自的门拒绝。 +// +// 密级纪律:令牌是一次性随机串,只在进程内存里流动;下面任何 `#expect` 文案都 +// 不含令牌,只含状态码/事件/计数。 + +import Foundation +import Testing +import WireProtocol +@testable import APIClient +@testable import SessionCore + +// ─── 测试侧的真 HTTP 出口(App 层的 URLSessionHTTPTransport 不可依赖,等价重写) ── + +/// `HTTPTransport` over URLSession,cookie jar 关闭:手写的 `Cookie` 头是唯一权威 +/// (§1.1),jar 不许覆盖它,也不许把密级材料留在共享存储里。 +struct IntegrationHTTPTransport: HTTPTransport { + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await cookieFreeSession.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw HarnessError.setup("非 HTTP 响应") + } + return (data, http) + } +} + +// ─── 连接计数("零重连"必须被计数证明,不能只看没有事件) ───────────────────── + +actor ConnectCounter { + private(set) var count = 0 + func bump() { count += 1 } +} + +/// 转发到真 `URLSessionTermTransport`,只多做一件事:数 `connect` 次数。 +/// +/// 注意它**不**实现 `PingableTermTransport`,因此引擎走 `transport.connect(to:)` +/// 分支 —— 对 401 路径没有影响(连接根本没建起来,无从 ping)。 +struct CountingTermTransport: TermTransport { + let inner: URLSessionTermTransport + let counter: ConnectCounter + + func connect(to endpoint: HostEndpoint) async throws -> TransportConnection { + await counter.bump() + return try await inner.connect(to: endpoint) + } +} + +// ─── 事件收集(引擎的 events 是单消费者 AsyncStream) ───────────────────────── + +/// 收集事件直到 `isDone` 命中或超时。返回**收到的全部事件**(顺序保留)。 +func collectEvents( + from engine: SessionEngine, timeout: Duration, + until isDone: @escaping @Sendable ([SessionEvent]) -> Bool +) async -> [SessionEvent] { + let stream = engine.events + let collector = Task { () -> [SessionEvent] in + var seen: [SessionEvent] = [] + for await event in stream { + seen.append(event) + if isDone(seen) { return seen } + } + return seen + } + let deadline = Task { + try? await Task.sleep(for: timeout) + collector.cancel() + } + defer { deadline.cancel() } + return await collector.value +} + +func makeEngine( + transport: any TermTransport, endpoint: HostEndpoint +) -> SessionEngine { + SessionEngine( + transport: transport, clock: ContinuousClock(), endpoint: endpoint, + eventsSource: { _ in [] } + ) +} + +// ─── 用例 ──────────────────────────────────────────────────────────────────── + +@Suite("F3 访问令牌端到端(真令牌网关服务器)", .serialized, .timeLimit(.minutes(10))) +struct AccessTokenGateTests { + + // MARK: 1. 正确令牌 —— WS + 两类 HTTP 全通 + + @Test("正确令牌:WS 升级成功且 attach 成功;RO(GET /live-sessions)与 G(DELETE)均放行") + func correctTokenOpensWebSocketAndBothHTTPFamilies() async throws { + // Arrange + let gated = try await ServerHarness.shared.tokenGatedServer() + let token = gated.token + let transport = URLSessionTermTransport(tokenProvider: { token }) + let engine = makeEngine(transport: transport, endpoint: gated.server.endpoint) + let client = APIClient( + endpoint: gated.server.endpoint, http: IntegrationHTTPTransport(), + accessToken: token + ) + + // Act: 真 WS 升级 → 真 attach 握手 → 服务器回 attached + await engine.open(sessionId: nil, cwd: nil) + let events = await collectEvents(from: engine, timeout: HarnessTunables.markerTimeout) { + $0.contains { if case .adopted = $0 { return true } else { return false } } + } + let adopted = events.compactMap { event -> UUID? in + if case let .adopted(id) = event { return id } else { return nil } + }.first + + // Assert: 升级 + attach 都过了令牌门 + let sessionId = try #require( + adopted, "带正确令牌的 WS 升级/attach 应成功,实际事件: \(events)") + #expect(events.contains(.connection(.connected)), + "应观察到 .connected,实际事件: \(events)") + #expect(events.contains(.connection(.failed(.unauthorized))) == false, + "正确令牌不该出现 .unauthorized") + + // Assert: RO 路由(无 Origin,带 cookie)放行且看得见刚建的会话 + let live = try await client.liveSessions() + #expect(live.contains { $0.id == sessionId }, + "RO GET /live-sessions 应含刚 attach 的会话 id") + + // Assert: G 路由(带 Origin + cookie)放行 —— 204 即不抛 + await engine.close() + try await client.killSession(id: sessionId) + let afterKill = try await client.liveSessions() + #expect(afterKill.contains { $0.id == sessionId } == false, + "G DELETE /live-sessions/:id 应已回收该会话") + } + + // MARK: 2. 错误令牌 —— 401 终态 + 零重连 + + @Test("错误令牌:WS 升级 401 → 终态 .unauthorized,且**零**重连尝试(只连了 1 次)") + func wrongTokenIsTerminalAndNeverRetries() async throws { + // Arrange: 形状合法但值错误的令牌 —— 形状非法会被客户端自己挡掉, + // 那样测的就不是服务器的门了。 + let gated = try await ServerHarness.shared.tokenGatedServer() + let wrong = gated.wrongButWellFormedToken + let counter = ConnectCounter() + let transport = CountingTermTransport( + inner: URLSessionTermTransport(tokenProvider: { wrong }), counter: counter + ) + let engine = makeEngine(transport: transport, endpoint: gated.server.endpoint) + + // Act + await engine.open(sessionId: nil, cwd: nil) + let events = await collectEvents(from: engine, timeout: HarnessTunables.frameTimeout) { + $0.contains(.connection(.failed(.unauthorized))) + } + + // Assert: 终态 + #expect(events.contains(.connection(.failed(.unauthorized))), + "错误令牌应得终态 .unauthorized,实际事件: \(events)") + + // Assert: 零重连 —— 等过第一级退避(ReconnectMachine 1s,且这个窗口够第二、 + // 第三级都发生)再看计数。这条性质拦住的是"客户端拿错令牌把服务器 + // 10 次/分钟的限流器打成暴力破解"。 + try await Task.sleep(for: HarnessTunables.retryObservationWindow) + let attempts = await counter.count + #expect(attempts == 1, "应只连一次(零重连),实际 connect 次数 = \(attempts)") + #expect(events.contains { if case .connection(.reconnecting) = $0 { return true } else { return false } } == false, + "401 不得进入退避环(.reconnecting),实际事件: \(events)") + + await engine.close() + } + + /// 对照组:没有这条,「connect 次数 == 1」可能只是因为计数器/观察窗口本身失灵。 + /// 同一个 `CountingTermTransport` + 同一个观察窗口,换成**可重试**的失败 + /// (端口上没人监听 → ECONNREFUSED),必须看到 ≥2 次连接与 `.reconnecting`。 + @Test("对照组:可重试失败(端口无人监听)在同一观察窗口里确实重连 ≥2 次") + func retryableFailureDoesRetryWithinTheSameWindow() async throws { + // Arrange: 抓一个空闲端口但**不**在上面起服务器。 + let port = try findFreeLoopbackPort() + let baseURL = try #require(URL(string: "http://127.0.0.1:\(port)")) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let counter = ConnectCounter() + let transport = CountingTermTransport( + inner: URLSessionTermTransport(tokenProvider: { nil }), counter: counter + ) + let engine = makeEngine(transport: transport, endpoint: endpoint) + + // Act + await engine.open(sessionId: nil, cwd: nil) + let events = await collectEvents( + from: engine, timeout: HarnessTunables.retryObservationWindow + ) { _ in false } + + // Assert + let attempts = await counter.count + #expect(attempts >= 2, + "可重试失败应在窗口内重连,实际 connect 次数 = \(attempts);若为 1 说明上一条用例的『零重连』是观察不到而非真的没有") + #expect(events.contains { if case .connection(.reconnecting) = $0 { return true } else { return false } }, + "可重试失败应发 .reconnecting,实际事件: \(events)") + #expect(events.contains(.connection(.failed(.unauthorized))) == false, + "连不上 ≠ 未授权:不得报 .unauthorized") + + await engine.close() + } + + // MARK: 3. 无令牌打令牌网关 + + @Test("无令牌打令牌网关:WS 401 → 同一终态零重连;RO HTTP 401 → 类型化 .unauthorized") + func missingTokenAgainstGatedServerIsTerminalToo() async throws { + // Arrange: tokenProvider 返回 nil ⇒ 完全不带 Cookie 头(LAN 零配置那条老路) + let gated = try await ServerHarness.shared.tokenGatedServer() + let counter = ConnectCounter() + let transport = CountingTermTransport( + inner: URLSessionTermTransport(tokenProvider: { nil }), counter: counter + ) + let engine = makeEngine(transport: transport, endpoint: gated.server.endpoint) + let clientWithoutToken = APIClient( + endpoint: gated.server.endpoint, http: IntegrationHTTPTransport(), accessToken: nil + ) + + // Act + await engine.open(sessionId: nil, cwd: nil) + let events = await collectEvents(from: engine, timeout: HarnessTunables.frameTimeout) { + $0.contains(.connection(.failed(.unauthorized))) + } + + // Assert: WS 侧与错误令牌完全同一处理 + #expect(events.contains(.connection(.failed(.unauthorized))), + "无令牌应得终态 .unauthorized,实际事件: \(events)") + try await Task.sleep(for: HarnessTunables.retryObservationWindow) + let attempts = await counter.count + #expect(attempts == 1, "无令牌同样零重连,实际 connect 次数 = \(attempts)") + await engine.close() + + // Assert: HTTP 侧 401 ⇒ 类型化 .unauthorized(不是通用网络错,UI 才能引导补令牌) + await #expect(throws: APIClientError.unauthorized) { + _ = try await clientWithoutToken.liveSessions() + } + } + + // MARK: 4. 令牌不替代 Origin(两道门正交) + + @Test("令牌是加法不是替代:合法 cookie + 外域/缺失 Origin,WS 仍 401、G 路由仍 403") + func tokenNeverSubstitutesForTheOriginGuard() async throws { + // Arrange + let gated = try await ServerHarness.shared.tokenGatedServer() + let server = gated.server + let token = gated.token + let foreignOrigin = "http://127.0.0.1:\(server.port == HarnessTunables.mismatchedOriginPort ? HarnessTunables.mismatchedOriginPortFallback : HarnessTunables.mismatchedOriginPort)" + let bogusId = UUID().uuidString.lowercased() + + // Act + Assert(WS):Origin 先于 cookie 判定(src/server.ts:1363-1379) + let foreign = WSTestClient(server: server, origin: foreignOrigin, accessToken: token) + defer { foreign.close() } + let foreignFailure = await foreign.handshakeFailure(timeout: HarnessTunables.frameTimeout) + #expect(foreignFailure != nil, "外域 Origin + 合法 cookie 的升级必须失败") + #expect(foreign.handshakeStatusCode == 401, + "应 401,实际 status=\(String(describing: foreign.handshakeStatusCode))") + + let noOrigin = WSTestClient(server: server, origin: nil, accessToken: token) + defer { noOrigin.close() } + let noOriginFailure = await noOrigin.handshakeFailure(timeout: HarnessTunables.frameTimeout) + #expect(noOriginFailure != nil, "缺 Origin + 合法 cookie 的升级必须失败") + #expect(noOrigin.handshakeStatusCode == 401, + "应 401,实际 status=\(String(describing: noOrigin.handshakeStatusCode))") + + // Act + Assert(HTTP G):403 来自 Origin 守卫,不是 401 令牌门 —— 状态码差异 + // 本身就是"两道门各自独立"的证据。 + let path = "live-sessions/\(bogusId)" + let foreignOriginStatus = try await rawStatusCode( + server: server, method: "DELETE", path: path, origin: foreignOrigin, + accessToken: token) + let noOriginStatus = try await rawStatusCode( + server: server, method: "DELETE", path: path, origin: nil, accessToken: token) + let bothStatus = try await rawStatusCode( + server: server, method: "DELETE", path: path, origin: server.origin, + accessToken: token) + let originOnlyStatus = try await rawStatusCode( + server: server, method: "DELETE", path: path, origin: server.origin, + accessToken: nil) + + #expect(foreignOriginStatus == 403, "外域 Origin + 合法 cookie 应 403,实际 \(foreignOriginStatus)") + #expect(noOriginStatus == 403, "缺 Origin + 合法 cookie 应 403,实际 \(noOriginStatus)") + // 差分证明:两个头都对时才走到业务逻辑(id 不存在 → 404)。 + #expect(bothStatus == 404, "Origin+cookie 都对应 404(id 不存在),实际 \(bothStatus)") + // 反向差分:Origin 对但无令牌 → 401(令牌门),与上面的 403 泾渭分明。 + #expect(originOnlyStatus == 401, "有 Origin 无令牌应 401,实际 \(originOnlyStatus)") + } + + // MARK: 5. POST /auth 探针三态 + + @Test("POST /auth:正确令牌 204+Set-Cookie(.valid);错误 401(.invalidToken)") + func authProbeDistinguishesValidFromInvalid() async throws { + // Arrange + let gated = try await ServerHarness.shared.tokenGatedServer() + // 探针本身不带 cookie —— /auth 是配对期的一次性校验,恰恰是还没有 cookie 时用的。 + let client = APIClient( + endpoint: gated.server.endpoint, http: IntegrationHTTPTransport(), accessToken: nil + ) + + // Act + Assert + #expect(try await client.probeAccessToken(gated.token) == .valid, + "正确令牌应得 .valid(204 + Set-Cookie)") + #expect(try await client.probeAccessToken(gated.wrongButWellFormedToken) == .invalidToken, + "错误令牌应得 .invalidToken(401)") + } + + @Test("POST /auth:服务器未启用鉴权 → 204 但**无** Set-Cookie ⇒ .authDisabled(绝不当作已认证)") + func authProbeReportsAuthDisabledOnUngatedServer() async throws { + // Arrange: 这条只能在**没**开 WEBTERM_TOKEN 的服务器上出现,故用默认那台。 + let plain = try await ServerHarness.shared.server() + let client = APIClient( + endpoint: plain.endpoint, http: IntegrationHTTPTransport(), accessToken: nil + ) + + // Act + let result = try await client.probeAccessToken(TokenCorpus.validBase) + + // Assert: 必须是 .authDisabled 而不是 .valid —— 把"没开门"读成"已认证" + // 会让 UI 谎称主机受保护,并存下一枚什么都不守的令牌(§1.1)。 + #expect(result == .authDisabled, "未启用鉴权的服务器应得 .authDisabled,实际 \(result)") + } + + @Test("未启用鉴权的服务器:不带令牌的 RO/G 请求与从前**逐字节**一样(零回归)") + func ungatedServerKeepsZeroConfigBehaviour() async throws { + // Arrange + let plain = try await ServerHarness.shared.server() + let bogusId = UUID().uuidString.lowercased() + + // Act + let roStatus = try await rawStatusCode( + server: plain, method: "GET", path: "live-sessions", origin: nil, accessToken: nil) + let guardedStatus = try await rawStatusCode( + server: plain, method: "DELETE", path: "live-sessions/\(bogusId)", + origin: plain.origin, accessToken: nil) + + // Assert: 未设 WEBTERM_TOKEN ⇒ 整个门是关掉的,LAN 零配置不受影响。 + #expect(roStatus == 200, "未启用鉴权时 RO 应 200,实际 \(roStatus)") + #expect(guardedStatus == 404, "未启用鉴权时 G 应走到业务逻辑(404),实际 \(guardedStatus)") + } +} diff --git a/ios/IntegrationTests/GitFixture.swift b/ios/IntegrationTests/GitFixture.swift new file mode 100644 index 0000000..6086b8c --- /dev/null +++ b/ios/IntegrationTests/GitFixture.swift @@ -0,0 +1,155 @@ +// +// GitFixture.swift — T-iOS-32 端到端腿的**一次性 git 仓库**夹具 +// +// 为什么必须是自建的临时仓库:`POST /projects/worktree` 是全应用**唯一**真正 +// 写盘的功能,服务器会在 `/-worktrees/` 下真建 +// 目录、真开分支(src/http/worktrees.ts:146-160,224-256)。把它指向用户自己的 +// 仓库 = 在别人的工作区里留垃圾分支/目录。所以夹具把 repo **和**它派生出来的 +// worktree 根一起关在同一个临时目录里,`destroy()` 一把删干净。 +// +// 布局(worktreeBase 的算法与服务端逐字对应,不是猜的): +// /repo ← 夹具仓库(一次空提交,HEAD 可用) +// /repo-worktrees/ ← 服务器自己算出来的 worktree 目录 +// +// 所有断言都拿 **git 自己**的 `worktree list --porcelain` 当第二信源:只信 +// HTTP 响应体的话,"服务器说建好了" 和 "盘上真建好了" 就分不开。 + +import Darwin +import Foundation + +/// `git worktree list --porcelain` 里的一条记录(只留断言用得上的字段)。 +struct RegisteredWorktree: Sendable, Equatable { + let path: String + let branch: String? + /// git 自己标记的"目录没了、登记还在"(`prunable `)。 + let prunable: Bool +} + +/// 跑一条 git 命令并返回合并后的输出;非零退出码抛 `HarnessError.setup` +/// (夹具失败必须**响亮**,绝不能静默变成一条"断言不成立")。 +@discardableResult +func runGit(_ arguments: [String], in directory: URL) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = directory + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + var environment = ProcessInfo.processInfo.environment + environment["GIT_TERMINAL_PROMPT"] = "0" // 夹具永远不联网、永远不问密码 + process.environment = environment + try process.run() + // 先读到 EOF 再 wait:反过来会在输出撑满管道缓冲时死锁。 + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let text = String(decoding: data, as: UTF8.self) + guard process.terminationStatus == 0 else { + throw HarnessError.setup( + "git \(arguments.joined(separator: " ")) 退出码 \(process.terminationStatus): \(text)") + } + return text +} + +/// 一个临时目录里的一次性 git 仓库。值语义 + 显式 `destroy()`(用例 `defer` 调)。 +struct GitFixture: Sendable { + /// 临时根目录,**已解 symlink**(macOS 的 $TMPDIR 是 /var/… → /private/var/…; + /// 不解的话服务端 realpath 之后返回的路径与我们手上的字符串对不上)。 + let root: URL + /// 夹具仓库本体(`/repo`)。 + let repo: URL + + /// 服务端在 `worktreeRoot` 未配置时算出来的 worktree 根: + /// `/-worktrees`(src/http/worktrees.ts:151-152)。 + var worktreeBase: URL { root.appending(path: "\(repo.lastPathComponent)-worktrees") } + + /// 提交身份走 `-c`:不读、不写用户的全局 git 配置,也不触发 gpg 签名。 + private static let identity = [ + "-c", "user.email=fixture@example.invalid", + "-c", "user.name=WebTerm Fixture", + "-c", "commit.gpgsign=false", + ] + + /// 建仓 + 一次空提交(`git worktree add` 在 unborn HEAD 上会失败,所以必须有)。 + static func make() throws -> GitFixture { + let manager = FileManager.default + let raw = manager.temporaryDirectory + .appending(path: "webterm-worktree-e2e-\(UUID().uuidString)") + try manager.createDirectory(at: raw, withIntermediateDirectories: true) + // 一次性钉成规范形式(macOS 的 $TMPDIR 是 /var/… → /private/var/…)。服务端 + // 拿到的就是规范路径,它算出来的 worktree 目录、git 报回来的路径三者同形。 + let root = URL(fileURLWithPath: canonicalPath(raw.path), isDirectory: true) + let repo = root.appending(path: "repo") + try manager.createDirectory(at: repo, withIntermediateDirectories: true) + try runGit(["-c", "init.defaultBranch=main", "init", "-q"], in: repo) + try runGit(identity + ["commit", "-q", "--allow-empty", "-m", "fixture"], in: repo) + return GitFixture(root: root, repo: repo) + } + + /// 尽力而为的清理:repo 与它的 worktree 根都在 `root` 底下,一把删完。 + /// 吞错是刻意的 —— 清理失败只留一个临时目录,不该把用例判定改写掉。 + func destroy() { + try? FileManager.default.removeItem(at: root) + } + + // ── 第二信源:git 自己的登记 ──────────────────────────────────────────── + + /// 解析 `git worktree list --porcelain`(空行分隔的记录,首条是主 worktree)。 + func registeredWorktrees() throws -> [RegisteredWorktree] { + let porcelain = try runGit(["worktree", "list", "--porcelain"], in: repo) + var out: [RegisteredWorktree] = [] + var path: String? + var branch: String? + var prunable = false + func flush() { + guard let path else { return } + out.append(RegisteredWorktree(path: path, branch: branch, prunable: prunable)) + (branch, prunable) = (nil, false) + } + for rawLine in porcelain.split(separator: "\n", omittingEmptySubsequences: false) { + let line = String(rawLine) + if line.isEmpty { + flush() + path = nil + continue + } + if line.hasPrefix("worktree ") { + path = String(line.dropFirst("worktree ".count)) + } else if line.hasPrefix("branch refs/heads/") { + branch = String(line.dropFirst("branch refs/heads/".count)) + } else if line == "prunable" || line.hasPrefix("prunable ") { + prunable = true + } + } + flush() + return out + } + + /// 按规范路径找一条登记(symlink 别名与规范路径必须算同一个)。 + func registeredWorktree(atCanonicalPath target: String) throws -> RegisteredWorktree? { + let wanted = canonicalPath(target) + return try registeredWorktrees().first { canonicalPath($0.path) == wanted } + } + + /// `.git/worktrees/` 下的行政登记目录名(prune 要清掉的正是这些)。 + func administrativeEntries() -> [String] { + let dir = repo.appending(path: ".git/worktrees") + let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) + return (names ?? []).sorted() + } +} + +/// 规范绝对路径 = `realpath(3)`;路径不存在时**原样返回**。 +/// +/// 为什么不能用 `URL.resolvingSymlinksInPath()`(实测踩过,本文件的 RED 就是它): +/// 它的文档行为是「若路径以 /private 开头就**去掉** /private」,方向正好相反 —— +/// 于是夹具根停在 `/var/folders/…`,而 git 的 `worktree list` 报的是 +/// `/private/var/folders/…`。更糟的是那层转换**依赖路径是否存在**:目录被删掉之后 +/// `/private` 不再被剥离,于是"删干净了吗"这类断言会因为路径形状而不是因为事实 +/// 失败。`realpath(3)` 只做一个方向(→ 规范形式),夹具在建立时就把根钉成规范形式, +/// 此后所有比较都是纯字符串相等,与"路径此刻还存不存在"无关。 +func canonicalPath(_ path: String) -> String { + guard let resolved = realpath(path, nil) else { return path } + defer { free(resolved) } + return String(cString: resolved) +} diff --git a/ios/IntegrationTests/Package.swift b/ios/IntegrationTests/Package.swift index 5f1b808..096fb96 100644 --- a/ios/IntegrationTests/Package.swift +++ b/ios/IntegrationTests/Package.swift @@ -4,6 +4,17 @@ // ephemeral loopback port; see ServerHarness.swift for the run modes). // Flat layout (plan §2): test sources live directly in ios/IntegrationTests/. // `scripts/` holds the per-package coverage gate used by .github/workflows/ios.yml. +// +// F3 · why this package depends on FOUR client packages while every other +// package depends strictly downward: the access-token policy is duplicated in +// three modules (SessionCore.AuthCookie, HostRegistry.AccessToken, +// APIClient.AccessTokenRule) plus the server's own `WEBTERM_TOKEN_RE`, and the +// orchestrator froze `WireProtocol` (a shared-secret helper does not belong in +// the cross-language wire contract). No single package can therefore see all +// three predicates at once — this test target is the ONLY place that can, so +// the anti-drift guard lives here (TokenPolicyDriftTests). The same deps let +// the token end-to-end leg drive the REAL client transport/HTTP layer against a +// REAL token-gated server instead of fakes (AccessTokenGateTests). import PackageDescription let package = Package( @@ -11,11 +22,19 @@ let package = Package( platforms: [.iOS(.v17), .macOS(.v14)], dependencies: [ .package(path: "../Packages/WireProtocol"), + .package(path: "../Packages/SessionCore"), + .package(path: "../Packages/HostRegistry"), + .package(path: "../Packages/APIClient"), ], targets: [ .testTarget( name: "IntegrationTests", - dependencies: [.product(name: "WireProtocol", package: "WireProtocol")], + dependencies: [ + .product(name: "WireProtocol", package: "WireProtocol"), + .product(name: "SessionCore", package: "SessionCore"), + .product(name: "HostRegistry", package: "HostRegistry"), + .product(name: "APIClient", package: "APIClient"), + ], path: ".", exclude: ["scripts"] ), diff --git a/ios/IntegrationTests/ServerHarness.swift b/ios/IntegrationTests/ServerHarness.swift index b0b345f..8bba9c9 100644 --- a/ios/IntegrationTests/ServerHarness.swift +++ b/ios/IntegrationTests/ServerHarness.swift @@ -19,6 +19,16 @@ // B. 外部服务器:WEBTERM_SERVER_URL=http://127.0.0.1:(须为 loopback, // 这样其自身 origin 天然在白名单内)。 // +// F3 追加:**第二台**服务器 —— 开了 `WEBTERM_TOKEN` 的令牌网关服务器 +// (`ServerHarness.tokenGatedServer()`)。两台并存是必需的:`POST /auth` 的 +// 「204 但没有 Set-Cookie」只在**未启用**鉴权的服务器上才出现,而 401/终态用例 +// 只在**启用**的服务器上才出现,同一进程无法同时是两者(令牌只在启动时读一次)。 +// A. 默认自举:随机生成一枚符合 §1.1 字符集/长度的**一次性**令牌,经 env +// 传给子进程(与真实部署同一通路),仅存在于本进程内存;绝不写日志、 +// 绝不进 URL、绝不出现在断言文案里。 +// B. 外部服务器:WEBTERM_TOKEN_SERVER_URL + WEBTERM_TOKEN_SERVER_TOKEN +// (worktree 无 node_modules 时用:从主 checkout 手工起一台带令牌的服务器)。 +// // 与 spike 版的关键差异:WireProtocol 已冻结(T-iOS-3),Origin/wsURL 一律由 // `HostEndpoint` 单点派生(plan §5.1 铁律:禁止手拼),不再本地复刻常量。 @@ -51,6 +61,9 @@ enum HarnessTunables { static let outputAccumulationTimeout: Duration = .seconds(90) /// 等待服务器主动关闭 WS 的上限(kill 路径)。 static let closeObserveTimeout: Duration = .seconds(30) + /// F3「零重连」的观察窗口:必须显著大于 `ReconnectMachine` 的第一级退避(1s), + /// 这样"没有重连"才是结论而不是"还没轮到"。3.5s 覆盖 1s + 2s 两级。 + static let retryObservationWindow: Duration = .milliseconds(3500) } enum HarnessError: Error, CustomStringConvertible { @@ -65,6 +78,37 @@ enum HarnessError: Error, CustomStringConvertible { } } +// ─── 令牌网关服务器(F3;令牌是密级材料,见下方纪律注释) ───────────────────── + +/// 一台启用了 `WEBTERM_TOKEN` 的服务器 + 打开它的那枚一次性令牌。 +/// +/// 密级纪律(§1.1):`token` 只在进程内存里传递给 `URLRequest`/`tokenProvider`。 +/// **绝不**插进断言文案、日志、URL query —— 用例失败时只报状态码与判定结果。 +struct TokenGatedServer: Sendable { + let server: TestServer + let token: String + + /// 一枚**错误但形状合法**的令牌(同长度、同字符集)。用于 401 用例:形状 + /// 非法的令牌会被客户端自己挡掉(`AuthCookie.headerValue` 返回 nil),那测的 + /// 就不是服务器的门了。 + var wrongButWellFormedToken: String { + // 首字符换成集合内的另一个字符:长度/字符集不变,值必不相同。 + let replacement: Character = token.first == "z" ? "y" : "z" + return String(replacement) + token.dropFirst() + } +} + +/// 生成一次性令牌:32 个字符,取自 §1.1 字符集的 ASCII 字母数字子集 +/// (子集足够——测的是服务器的门,不是字符集本身;字符集由 TokenPolicyDriftTests 守)。 +func makeThrowawayToken(length: Int = 32) -> String { + let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") + var generator = SystemRandomNumberGenerator() + let characters = (0.. Character in + alphabet[Int.random(in: 0.. Pipe { @@ -138,6 +187,7 @@ private enum ServerProcessRegistry { actor ServerHarness { static let shared = ServerHarness() private var bootTask: Task? + private var tokenBootTask: Task? func server() async throws -> TestServer { if let bootTask { return try await bootTask.value } @@ -146,19 +196,28 @@ actor ServerHarness { return try await task.value } + /// F3:开了 `WEBTERM_TOKEN` 的第二台服务器(同样"起一次、全 suite 复用")。 + func tokenGatedServer() async throws -> TokenGatedServer { + if let tokenBootTask { return try await tokenBootTask.value } + let task = Task { try await Self.bootstrapTokenGated() } + tokenBootTask = task + return try await task.value + } + private static func bootstrap() async throws -> TestServer { if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] { guard let url = URL(string: external) else { throw HarnessError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)") } let server = try TestServer.make(baseURL: url) - try await awaitReady(server, process: nil, logURL: nil) + try await awaitReady(server, process: nil, logURL: nil, cookie: nil) return server } let spawned = try spawnLocalServer() ServerProcessRegistry.register(spawned.process) do { - try await awaitReady(spawned.server, process: spawned.process, logURL: spawned.logURL) + try await awaitReady( + spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil) } catch { ServerProcessRegistry.killNow() throw error @@ -166,12 +225,71 @@ actor ServerHarness { return spawned.server } - private static func spawnLocalServer() throws -> (server: TestServer, process: Process, logURL: URL) { - let repoRoot = try locateRepoRoot() - let tsx = repoRoot.appending(path: "node_modules/.bin/tsx") - guard FileManager.default.isExecutableFile(atPath: tsx.path) else { - throw HarnessError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install/npm ci") + private static func bootstrapTokenGated() async throws -> TokenGatedServer { + let environment = ProcessInfo.processInfo.environment + if let external = environment["WEBTERM_TOKEN_SERVER_URL"] { + guard let token = environment["WEBTERM_TOKEN_SERVER_TOKEN"], !token.isEmpty else { + throw HarnessError.setup( + "设了 WEBTERM_TOKEN_SERVER_URL 就必须同时设 WEBTERM_TOKEN_SERVER_TOKEN") + } + guard let url = URL(string: external) else { + throw HarnessError.setup("WEBTERM_TOKEN_SERVER_URL 不是合法 URL: \(external)") + } + let server = try TestServer.make(baseURL: url) + let gated = TokenGatedServer(server: server, token: token) + // 就绪探针必须带 cookie:令牌网关下 GET /live-sessions 无 cookie 是 401。 + try await awaitReady( + server, process: nil, logURL: nil, cookie: authCookieHeader(token: token)) + return gated } + let token = makeThrowawayToken() + let spawned = try spawnLocalServer(extraEnvironment: ["WEBTERM_TOKEN": token]) + ServerProcessRegistry.register(spawned.process) + do { + try await awaitReady( + spawned.server, process: spawned.process, logURL: spawned.logURL, + cookie: authCookieHeader(token: token)) + } catch { + ServerProcessRegistry.killNow() + throw error + } + return TokenGatedServer(server: spawned.server, token: token) + } + + /// T-iOS-32:起一台**一次性**服务器 —— 给需要 bespoke 进程环境的用例用。 + /// + /// 为什么不能复用 `server()`:`GET /sessions` 的数据源是 + /// `os.homedir()/.claude/projects`(src/http/history.ts:81),只有把子进程的 + /// `HOME` 换掉才能让它**确定**(否则断言就落在开发者本机的真实 Claude 历史上: + /// CI 上那个目录根本不存在 ⇒ 恒 `[]` ⇒ 断言恒真,等于没测;本机上又会把真实 + /// 会话内容读进测试进程)。`HOME` 只在启动时经 env 传一次,同一进程无法既是 + /// 隔离的又是真实的 —— 与 `tokenGatedServer()` 需要第二台是同一个理由。 + /// + /// 不进 shared 缓存(每次调用一台),但**照样**登记进 `ServerProcessRegistry`, + /// 所以 death-pipe watchdog 一样管它,测试进程无论怎么死都不留孤儿 tsx。 + /// 就绪失败时只 `terminate()` 自己这一台 —— 绝不调 `killNow()`(那会连别的 + /// suite 正在用的共享服务器一起杀掉)。 + /// + /// 只走自举一条路:外部服务器(`WEBTERM_SERVER_URL`)的 `HOME` 不在我们手里, + /// 拿不到本方法存在的理由,所以这里不接受外部模式。 + static func disposableServer(extraEnvironment: [String: String]) async throws -> TestServer { + let spawned = try spawnLocalServer(extraEnvironment: extraEnvironment) + ServerProcessRegistry.register(spawned.process) + do { + try await awaitReady( + spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil) + } catch { + spawned.process.terminate() + throw error + } + return spawned.server + } + + private static func spawnLocalServer( + extraEnvironment: [String: String] = [:] + ) throws -> (server: TestServer, process: Process, logURL: URL) { + let repoRoot = try locateRepoRoot() + let tsx = try locateTsx(from: repoRoot) let port = try findFreeLoopbackPort() guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else { throw HarnessError.setup("无法构造 baseURL, port=\(port)") @@ -193,6 +311,9 @@ actor ServerHarness { env["SHELL_PATH"] = "/bin/bash" // 确定性输出(不吃用户 zsh 配置) env["USE_TMUX"] = "0" env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin" + // F3:令牌经 env 传给子进程 —— 与真实部署完全同一通路(`WEBTERM_TOKEN`), + // 且不落盘、不进命令行(`ps` 看不到 env 之外的东西)。 + for (key, value) in extraEnvironment { env[key] = value } process.environment = env process.standardOutput = logHandle process.standardError = logHandle @@ -200,15 +321,20 @@ actor ServerHarness { return (server, process, logURL) } - private static func awaitReady(_ server: TestServer, process: Process?, logURL: URL?) async throws { - let probeURL = server.baseURL.appending(path: "live-sessions") + /// - Parameter cookie: 令牌网关服务器的就绪探针必须带 `Cookie: webterm_auth=…`, + /// 否则 `GET /live-sessions` 恒 401(src/server.ts:459),会误判成"未就绪"。 + private static func awaitReady( + _ server: TestServer, process: Process?, logURL: URL?, cookie: String? + ) async throws { + var probe = URLRequest(url: server.baseURL.appending(path: "live-sessions")) + if let cookie { probe.setValue(cookie, forHTTPHeaderField: "Cookie") } let deadline = ContinuousClock.now + HarnessTunables.serverReadyTimeout while ContinuousClock.now < deadline { if let process, !process.isRunning { throw HarnessError.setup( "服务器进程提前退出(exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")") } - if let (data, response) = try? await URLSession.shared.data(from: probeURL), + if let (data, response) = try? await cookieFreeSession.data(for: probe), (response as? HTTPURLResponse)?.statusCode == 200, (try? JSONSerialization.jsonObject(with: data)) is [Any] { return @@ -220,6 +346,45 @@ actor ServerHarness { } } +/// `node_modules/.bin/tsx`:从 repo 根**逐级向上**找。 +/// +/// 为什么向上找:会话工作在 `.claude/worktrees//` 里,worktree 没有自己的 +/// `node_modules`;Node 自己的模块解析同样是逐级向上,所以 worktree 里的 +/// `src/server.ts` 本来就跑得起来(依赖解析到主 checkout 的 node_modules)。 +/// 只查根目录会把这种完全正常的布局误判成"没装依赖"。 +func locateTsx(from repoRoot: URL) throws -> URL { + var directory = repoRoot.standardizedFileURL + var visited: [String] = [] + while true { + let candidate = directory.appending(path: "node_modules/.bin/tsx") + if FileManager.default.isExecutableFile(atPath: candidate.path) { return candidate } + visited.append(candidate.path) + let parent = directory.deletingLastPathComponent().standardizedFileURL + if parent.path == directory.path { break } + directory = parent + } + throw HarnessError.setup( + "找不到 node_modules/.bin/tsx(逐级向上查过 \(visited.count) 处," + + "从 \(visited.first ?? "?") 起)— 先在 repo 根目录跑 npm install/npm ci") +} + +/// `Cookie: webterm_auth=` 的头**值**(名字取自服务端 `AUTH_COOKIE_NAME`, +/// 由 TokenPolicyDriftTests 对钉 src/http/auth.ts)。 +func authCookieHeader(token: String) -> String { + "webterm_auth=\(token)" +} + +/// 关掉 cookie jar 的会话:手写的 `Cookie` 头是唯一权威(§1.1 冻结),任何主机的 +/// `Set-Cookie` 都不许覆盖它、也不许把密级材料留在共享存储里 +/// (与 `SessionCore.WSConnection.sessionConfiguration` 同一纪律)。 +let cookieFreeSession: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpShouldSetCookies = false + configuration.httpCookieAcceptPolicy = .never + configuration.httpCookieStorage = nil + return URLSession(configuration: configuration) +}() + func locateRepoRoot() throws -> URL { if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] { return URL(fileURLWithPath: override, isDirectory: true) diff --git a/ios/IntegrationTests/TokenPolicyDriftTests.swift b/ios/IntegrationTests/TokenPolicyDriftTests.swift new file mode 100644 index 0000000..5726deb --- /dev/null +++ b/ios/IntegrationTests/TokenPolicyDriftTests.swift @@ -0,0 +1,439 @@ +// +// TokenPolicyDriftTests.swift — F3(2):三份令牌策略 + 服务端正则的漂移守卫 +// +// 问题:同一条访问令牌规则(cookie 名 `webterm_auth`,字符集 `[A-Za-z0-9._~+/=-]`, +// 长度 16–512)现在有 **四份** 独立实现: +// 1. `SessionCore/AuthCookie.swift` —— Swift `Regex` 字面量(WS upgrade 头) +// 2. `HostRegistry/AccessToken.swift` —— `Set` + count(Keychain 边界) +// 3. `APIClient/Endpoints.swift`(AccessToken.swift) —— `Set` + count(HTTP 头) +// 4. `src/config.ts:178` `WEBTERM_TOKEN_RE` —— 服务端启动校验(唯一真源) +// 安全复核(774 条对抗样本)确认它们**今天**一致,但没有任何东西**维持**这一致。 +// +// 为什么守卫必须落在这里:orchestrator 裁定 `WireProtocol` 冻结(共享密钥助手不 +// 属于跨语言线协议契约),因此不能把三份合并成一份。IntegrationTests 是**唯一** +// 能同时看见三个包的目标(见 Package.swift 注释),所以"守卫 = 测试"。 +// +// 判据(任意两者不一致即红): +// - 三份 Swift 判定 + 服务端正则,在同一份对抗语料上**逐条同判**; +// - 服务端正则不是硬编码的复制品:它在运行时从 `src/config.ts` **原文抽取**, +// 所以服务端改规则 → 本用例立刻红(而不是三份 Swift 自洽地一起错)。 +// - cookie 名同样三处对钉,并对钉 `src/http/auth.ts` 的 `AUTH_COOKIE_NAME`。 +// +// 密级纪律:语料里的"合法"样本是形状合法的假串,不是任何真令牌;失败信息只打印 +// 判定结果与样本**标签**,绝不打印真实令牌(本文件也不接触真实令牌)。 + +import Foundation +import HostRegistry +import Testing +@testable import APIClient +@testable import SessionCore + +// ─── 服务端真源:运行时从 src/ 抽取(不复制粘贴,否则守卫会跟着一起漂) ──────── + +enum ServerTokenPolicy { + /// `src/config.ts` 里 `const WEBTERM_TOKEN_RE = /…/` 的正则字面量原文。 + static func extractedTokenPatternSource() throws -> String { + let source = try readRepoFile("src/config.ts") + guard let line = source.split(separator: "\n").first(where: { + $0.contains("const WEBTERM_TOKEN_RE") + }) else { + throw HarnessError.setup("src/config.ts 里找不到 WEBTERM_TOKEN_RE 定义") + } + guard let open = line.firstIndex(of: "/"), let close = line.lastIndex(of: "/"), + open < close + else { + throw HarnessError.setup("WEBTERM_TOKEN_RE 那一行不是 /…/ 正则字面量: \(line)") + } + return String(line[line.index(after: open).. String { + let source = try readRepoFile("src/http/auth.ts") + guard let line = source.split(separator: "\n").first(where: { + $0.contains("export const AUTH_COOKIE_NAME") + }) else { + throw HarnessError.setup("src/http/auth.ts 里找不到 AUTH_COOKIE_NAME 定义") + } + guard let open = line.firstIndex(of: "'"), let close = line.lastIndex(of: "'"), + open < close + else { + throw HarnessError.setup("AUTH_COOKIE_NAME 那一行没有单引号字面量: \(line)") + } + return String(line[line.index(after: open).. @Sendable (String) -> Bool { + let jsSource = try extractedTokenPatternSource() + var icuSource = jsSource + guard icuSource.hasPrefix("^"), icuSource.hasSuffix("$") else { + throw HarnessError.setup("WEBTERM_TOKEN_RE 不再是 ^…$ 整串锚定形状: \(jsSource)") + } + icuSource.removeFirst() + icuSource.removeLast() + let regex = try NSRegularExpression(pattern: "\\A\(icuSource)\\z") + return { candidate in + let range = NSRange(candidate.startIndex..., in: candidate) + return regex.firstMatch(in: candidate, options: [], range: range) != nil + } + } + + private static func readRepoFile(_ relativePath: String) throws -> String { + let url = try locateRepoRoot().appending(path: relativePath) + guard let text = try? String(contentsOf: url, encoding: .utf8) else { + throw HarnessError.setup("读不到 \(url.path)(服务端真源缺失)") + } + return text + } +} + +// ─── 对抗语料(标签 + 样本;失败时只打标签,不打样本内容) ──────────────────── + +struct TokenProbe: Sendable { + let label: String + let candidate: String +} + +enum TokenCorpus { + /// 16 字符的合法基底(形状合法的假串,不是任何真令牌)。 + static let validBase = "AbCdEfGh01234567" + + static func repeated(_ unit: String, count: Int) -> String { + String(repeating: unit, count: count) + } + + /// 全部对抗样本。覆盖任务要求的每一类:全角形、组合记号、零宽、emoji、 + /// U+2013 与 '-'、长度边界 15/16/512/513。 + static let probes: [TokenProbe] = { + var out: [TokenProbe] = [] + func add(_ label: String, _ candidate: String) { + out.append(TokenProbe(label: label, candidate: candidate)) + } + + // ── 长度边界(ASCII,四份实现必须逐条同判) ── + add("len15(ASCII)", String(repeated("a", count: 15))) + add("len16(ASCII)", String(repeated("a", count: 16))) + add("len512(ASCII)", String(repeated("a", count: 512))) + add("len513(ASCII)", String(repeated("a", count: 513))) + add("len0(empty)", "") + + // ── 字符集:合法集合逐字符 ── + add("charset-all-legal", "AZaz09._~+/=-" + "abc") + add("charset-dot-only", String(repeated(".", count: 16))) + add("charset-slash-only", String(repeated("/", count: 16))) + add("charset-equals-only", String(repeated("=", count: 16))) + add("charset-tilde-only", String(repeated("~", count: 16))) + add("charset-plus-only", String(repeated("+", count: 16))) + add("charset-hyphen-only", String(repeated("-", count: 16))) + + // ── 字符集:合法但易被误判为非法的 ── + // `_` **在**集合内(`[A-Za-z0-9._~+/=-]` 的 `.` 之后就是 `_`)。写死这条, + // 是因为它极易被人肉读错成"不在集合内"(本任务作者就读错过一次,靠下面的 + // 变异用例才发现)。 + add("underscore(在集合内)", "AbCdEfGh0123456_") + + // ── 字符集:非法但"看起来像"的 ── + add("colon", "AbCdEfGh0123456:") + add("semicolon(cookie 分隔符)", "AbCdEfGh0123456;") + add("space-interior", "AbCdEfGh 123456789") + add("percent", "AbCdEfGh0123456%") + add("backslash", "AbCdEfGh0123456\\") + add("comma", "AbCdEfGh0123456,") + add("quote-double", "AbCdEfGh0123456\"") + + // ── 头注入形状(CR/LF 必须被四份实现一致拒绝) ── + add("CR-suffix", validBase + "\r") + add("LF-suffix", validBase + "\n") + add("CRLF-injection", validBase + "\r\nX-Evil: 1") + add("LF-interior", "AbCdEfGh\n123456789") + add("NUL-suffix", validBase + "\u{0}") + add("TAB-suffix", validBase + "\t") + + // ── 首尾空白(HostRegistry 有意 trim —— 见 divergence 用例) ── + add("leading-space", " " + validBase) + add("trailing-space", validBase + " ") + add("leading-newline", "\n" + validBase) + + // ── U+2013 EN DASH vs ASCII '-'(同形不同码) ── + add("ascii-hyphen", "AbCdEfGh-1234567") + add("en-dash-U+2013", "AbCdEfGh\u{2013}1234567") + add("minus-sign-U+2212", "AbCdEfGh\u{2212}1234567") + add("non-breaking-hyphen-U+2011", "AbCdEfGh\u{2011}1234567") + + // ── 全角形(Halfwidth and Fullwidth Forms) ── + add("fullwidth-A(U+FF21)", "\u{FF21}bCdEfGh01234567") + add("fullwidth-digit0(U+FF10)", "\u{FF10}bCdEfGh01234567") + add("fullwidth-fullstop(U+FF0E)", "AbCdEfGh0123456\u{FF0E}") + add("fullwidth-solidus(U+FF0F)", "AbCdEfGh0123456\u{FF0F}") + add("fullwidth-all", String(repeated("\u{FF21}", count: 16))) + + // ── 组合记号(Swift 数 Character=1,JS 数码元=2 —— 若字符集放宽会立刻分叉) ── + add("combining-acute", "A\u{0301}bCdEfGh01234567") + add("combining-only", String(repeated("a\u{0301}", count: 16))) + // Swift 数 Character = 256(在 16…512 窗口内),JS 数 UTF-16 码元 = 512 + // (也在窗口内)—— 两种计数法都"长度合法",唯一拦住它的是字符集。 + add("combining-256-graphemes-512-units", String(repeated("a\u{0301}", count: 256))) + add("precomposed-e-acute", "\u{00E9}bCdEfGh01234567") + + // ── 零宽 / 不可见 ── + add("zero-width-space(U+200B)", "AbCdEfGh\u{200B}1234567") + add("zero-width-joiner(U+200D)", "AbCdEfGh\u{200D}1234567") + add("zero-width-nbsp(U+FEFF)", "\u{FEFF}" + validBase) + add("soft-hyphen(U+00AD)", "AbCdEfGh\u{00AD}1234567") + add("nbsp(U+00A0)", "AbCdEfGh\u{00A0}1234567") + add("LTR-override(U+202E)", "AbCdEfGh\u{202E}1234567") + + // ── emoji / 星际平面(Swift 1 Character、JS 2 码元) ── + add("emoji-single", "AbCdEfGh\u{1F600}1234567") + add("emoji-zwj-family", "AbCdEfG\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}1234567") + add("emoji-flag", "AbCdEfGh\u{1F1E8}\u{1F1F3}123456") + add("emoji-only-16-graphemes", String(repeated("\u{1F600}", count: 16))) + add("emoji-fill-to-512-units", String(repeated("\u{1F600}", count: 256))) + + // ── 同形西里尔 / 希腊(homoglyph) ── + add("cyrillic-a(U+0430)", "\u{0430}bCdEfGh01234567") + add("greek-omicron(U+03BF)", "\u{03BF}bCdEfGh01234567") + + // ── 超长边界的非 ASCII 组合 ── + add("len513-with-fullwidth-tail", String(repeated("a", count: 512)) + "\u{FF21}") + add("len511-plus-emoji", String(repeated("a", count: 511)) + "\u{1F600}") + + return out + }() +} + +// ─── 四份实现的统一判定入口 ────────────────────────────────────────────────── + +struct TokenVerdicts: Equatable { + let sessionCore: Bool + let apiClient: Bool + let hostRegistry: Bool + let server: Bool + + /// SessionCore / APIClient / 服务端三者必须**完全**同判。 + /// HostRegistry 单列:它在验证前 trim 首尾空白(有意为之,见其类型注释), + /// 因此只在"无首尾空白"的样本上参与全等断言(padded 分歧另有专门用例钉住)。 + var coreThreeAgree: Bool { + sessionCore == apiClient && apiClient == server + } + + var allFourAgree: Bool { + coreThreeAgree && hostRegistry == server + } + + var summary: String { + "SessionCore=\(sessionCore) APIClient=\(apiClient) " + + "HostRegistry=\(hostRegistry) server=\(server)" + } +} + +/// 四个判定器的集合。生产判定固定为下面的 `live(server:)`; +/// `disagreementLabels` 之所以接受任意四元组,是为了能**用一个刻意漂移的替身** +/// 证明比较逻辑真的会红(守卫自身的变异测试,且不需要动 Owns 之外的任何文件)。 +struct TokenPolicySet { + let sessionCore: @Sendable (String) -> Bool + let apiClient: @Sendable (String) -> Bool + let hostRegistry: @Sendable (String) -> Bool + let server: @Sendable (String) -> Bool + + /// 生产四元组:三份 Swift 实现 + 从 src/ 抽取的服务端正则。 + static func live(server: @escaping @Sendable (String) -> Bool) -> TokenPolicySet { + TokenPolicySet( + // 模块内部符号:两个包各有一个 internal `AuthCookie`;SessionCore 侧可以 + // 限定模块名,APIClient 侧因模块名与类型名撞车,走 APIClientTokenPolicy 窗口。 + sessionCore: { SessionCore.AuthCookie.isValidToken($0) }, + apiClient: { APIClientTokenPolicy.isWellFormed($0) }, + hostRegistry: { AccessToken(rawValue: $0) != nil }, + server: server + ) + } + + func verdicts(for candidate: String) -> TokenVerdicts { + TokenVerdicts( + sessionCore: sessionCore(candidate), apiClient: apiClient(candidate), + hostRegistry: hostRegistry(candidate), server: server(candidate) + ) + } + + /// 语料上所有分歧样本的**标签**(绝不含样本内容 —— 样本可能是形似令牌的串)。 + func disagreementLabels(over probes: [TokenProbe]) -> [String] { + probes.compactMap { probe in + let verdicts = self.verdicts(for: probe.candidate) + // 无首尾空白 ⇒ HostRegistry 的 trim 不改变输入,四份直接全等比较; + // 带空白的样本只比另外三份(trim 分歧另有专门用例钉住)。 + let agrees = hasSurroundingWhitespace(probe.candidate) + ? verdicts.coreThreeAgree + : verdicts.allFourAgree + return agrees ? nil : "[\(probe.label)] \(verdicts.summary)" + } + } +} + +func evaluateTokenPolicies( + _ candidate: String, server: @escaping @Sendable (String) -> Bool +) -> TokenVerdicts { + TokenPolicySet.live(server: server).verdicts(for: candidate) +} + +/// 无首尾空白 ⇒ HostRegistry 的 trim 不会改变输入,四份可直接全等比较。 +func hasSurroundingWhitespace(_ candidate: String) -> Bool { + candidate != candidate.trimmingCharacters(in: .whitespacesAndNewlines) +} + +// ─── 用例 ──────────────────────────────────────────────────────────────────── + +@Suite("F3 令牌策略漂移守卫(三份 Swift + 服务端正则)") +struct TokenPolicyDriftTests { + + @Test("对抗语料逐条同判:任意两份实现分歧即红") + func allImplementationsAgreeOverAdversarialCorpus() throws { + let serverPredicate = try ServerTokenPolicy.makeEvaluator() + + let disagreements = TokenPolicySet.live(server: serverPredicate) + .disagreementLabels(over: TokenCorpus.probes) + + #expect( + disagreements.isEmpty, + """ + 令牌策略已漂移(\(disagreements.count)/\(TokenCorpus.probes.count) 条样本分歧)。 + 四份实现必须同判:SessionCore/AuthCookie.swift、HostRegistry/AccessToken.swift、 + APIClient/AccessToken.swift、src/config.ts:WEBTERM_TOKEN_RE。 + 分歧样本(只打标签,不打令牌): + \(disagreements.joined(separator: "\n")) + """ + ) + // 语料本身不许被悄悄掏空(否则"零分歧"是假绿)。 + #expect(TokenCorpus.probes.count >= 50, + "对抗语料被削减到 \(TokenCorpus.probes.count) 条 —— 守卫失去意义") + } + + /// 守卫自身的变异测试:把任一份实现换成"刻意漂移一点点"的替身,比较逻辑必须 + /// 立刻报出分歧。没有这条,上面那条用例即使写成恒真也没人发现(假绿)。 + /// 用替身而不是真去改 SessionCore/HostRegistry/APIClient —— 那些文件不在本任务 + /// 的 Owns 里,且变异测试本来就不该留下副作用。 + @Test("守卫自身是响的:任一份实现哪怕只放宽一个字符,比较逻辑也必须报分歧") + func guardItselfDetectsEachSingleDrift() throws { + let serverPredicate = try ServerTokenPolicy.makeEvaluator() + let live = TokenPolicySet.live(server: serverPredicate) + // 漂移替身:多接受一个 `:`(**不**在服务端的 `[A-Za-z0-9._~+/=-]` 里; + // 注意 `_` 是在集合内的,用它当替身等于什么都没变 —— 第一版就踩了这个坑, + // 正是本用例把它抓了出来)。 + let drifted: @Sendable (String) -> Bool = { candidate in + let relaxed = candidate.replacingOccurrences(of: ":", with: ".") + return APIClientTokenPolicy.isWellFormed(relaxed) + } + let mutants: [(String, TokenPolicySet)] = [ + ("SessionCore", TokenPolicySet( + sessionCore: drifted, apiClient: live.apiClient, + hostRegistry: live.hostRegistry, server: live.server)), + ("APIClient", TokenPolicySet( + sessionCore: live.sessionCore, apiClient: drifted, + hostRegistry: live.hostRegistry, server: live.server)), + ("HostRegistry", TokenPolicySet( + sessionCore: live.sessionCore, apiClient: live.apiClient, + hostRegistry: drifted, server: live.server)), + ("server", TokenPolicySet( + sessionCore: live.sessionCore, apiClient: live.apiClient, + hostRegistry: live.hostRegistry, server: drifted)), + ] + + for (name, mutant) in mutants { + let found = mutant.disagreementLabels(over: TokenCorpus.probes) + #expect(!found.isEmpty, "\(name) 漂移未被守卫抓到 —— 守卫失效(假绿)") + } + // 对照组:未变异时必须干净(否则上面的"抓到了"可能只是本来就红)。 + #expect(live.disagreementLabels(over: TokenCorpus.probes).isEmpty) + } + + @Test("语料确实两侧都覆盖:既有被四份一致接受的,也有被四份一致拒绝的") + func corpusExercisesBothVerdicts() throws { + let serverPredicate = try ServerTokenPolicy.makeEvaluator() + let verdicts = TokenCorpus.probes.map { + evaluateTokenPolicies($0.candidate, server: serverPredicate).server + } + + // 若某天字符集被改成"什么都拒绝",上一条用例会全绿(全 false 也叫一致), + // 这条防的就是那种假绿。 + #expect(verdicts.contains(true), "语料里没有任何被接受的样本 —— 上一条用例会假绿") + #expect(verdicts.contains(false), "语料里没有任何被拒绝的样本 —— 上一条用例会假绿") + } + + @Test("服务端正则原文未变:字符集/长度窗口逐字节对钉") + func serverPatternIsStillTheFrozenShape() throws { + let pattern = try ServerTokenPolicy.extractedTokenPatternSource() + + // 契约 §1.1 冻结值。改服务端规则 ⇒ 这里红 ⇒ 必须同步改三份 Swift。 + #expect(pattern == "^[A-Za-z0-9._~+/=-]{16,512}$", + "src/config.ts 的 WEBTERM_TOKEN_RE 变了(实测 \(pattern));三份 Swift 实现必须同步更新") + } + + @Test("长度窗口 15/16/512/513:四份实现同判且落在 16…512") + func lengthBoundariesAreIdenticalEverywhere() throws { + let serverPredicate = try ServerTokenPolicy.makeEvaluator() + let cases: [(length: Int, expected: Bool)] = [ + (15, false), (16, true), (511, true), (512, true), (513, false), + ] + + for (length, expected) in cases { + let candidate = String(repeating: "a", count: length) + let verdicts = evaluateTokenPolicies(candidate, server: serverPredicate) + #expect(verdicts.allFourAgree, "长度 \(length): \(verdicts.summary)") + #expect(verdicts.server == expected, + "长度 \(length) 应为 \(expected),实测 \(verdicts.summary)") + } + } + + @Test("cookie 名三处 + 服务端 AUTH_COOKIE_NAME 全等") + func cookieNameIsPinnedEverywhere() throws { + let serverName = try ServerTokenPolicy.extractedCookieName() + + #expect(serverName == "webterm_auth", + "src/http/auth.ts 的 AUTH_COOKIE_NAME 变了(实测 \(serverName))") + #expect(SessionCore.AuthCookie.name == serverName, + "SessionCore.AuthCookie.name=\(SessionCore.AuthCookie.name) ≠ 服务端 \(serverName)") + #expect(APIClientTokenPolicy.cookieName == serverName, + "APIClient.AuthCookie.name=\(APIClientTokenPolicy.cookieName) ≠ 服务端 \(serverName)") + } + + @Test("cookie 头拼装形状三处一致:name=,无分号无空格") + func cookieHeaderAssemblyIsIdentical() throws { + let token = TokenCorpus.validBase + + let fromSessionCore = SessionCore.AuthCookie.headerValue(token: token) + let fromAPIClient = APIClientTokenPolicy.headerValue(for: token) + + #expect(fromSessionCore == "webterm_auth=\(token)") + #expect(fromAPIClient == fromSessionCore, + "两份 cookie 头拼装分歧:APIClient=\(fromAPIClient) SessionCore=\(fromSessionCore ?? "nil")") + // 拼装结果绝不能带分隔符 —— 那是头注入/cookie 拆分的形状。 + #expect(fromAPIClient.contains(";") == false) + #expect(fromAPIClient.contains("\r") == false && fromAPIClient.contains("\n") == false) + } + + @Test("已知且有意的分歧:HostRegistry 先 trim 首尾空白,另两份原样拒绝") + func hostRegistryTrimDivergenceIsIntentionalAndBounded() throws { + let serverPredicate = try ServerTokenPolicy.makeEvaluator() + let padded = " \(TokenCorpus.validBase) " + + let verdicts = evaluateTokenPolicies(padded, server: serverPredicate) + + // 这不是漂移,是 AccessToken 类型注释里写明的入口归一化(粘贴场景)。 + // 钉住它,是为了让"未来某天 trim 被去掉/被扩散到另外两份"也变成红。 + #expect(verdicts.hostRegistry, "HostRegistry 应接受首尾空白并归一化") + #expect(verdicts.sessionCore == false, "SessionCore 应原样拒绝带空白的串") + #expect(verdicts.apiClient == false, "APIClient 应原样拒绝带空白的串") + #expect(verdicts.server == false, "服务端正则应原样拒绝带空白的串") + + // 归一化的产物必须是四份都接受的 —— 否则 trim 会造出一个"存得下、发不出"的令牌。 + let normalized = try #require(AccessToken(rawValue: padded)).rawValue + let afterTrim = evaluateTokenPolicies(normalized, server: serverPredicate) + #expect(afterTrim.allFourAgree && afterTrim.server, + "trim 后的令牌必须四份都接受,实测 \(afterTrim.summary)") + } +} diff --git a/ios/IntegrationTests/WSTestClient.swift b/ios/IntegrationTests/WSTestClient.swift index b513ce4..79ae7a6 100644 --- a/ios/IntegrationTests/WSTestClient.swift +++ b/ios/IntegrationTests/WSTestClient.swift @@ -21,16 +21,29 @@ final class WSTestClient: @unchecked Sendable { /// - Parameters: /// - origin: nil = 不带 Origin header(守卫负路径)。带值时必须来自 /// `TestServer.origin`(HostEndpoint 单点派生)或刻意构造的失配值。 + /// - accessToken: nil = 不带 `Cookie` 头。带值时手写 + /// `Cookie: webterm_auth=`(§1.1 冻结做法:原生客户端不解析 + /// `Set-Cookie`、不依赖 cookie jar)。F3 需要"合法 cookie + 外域 Origin" + /// 这种真实客户端造不出来的组合来证明两道门**正交**,故这里保留原始拼装。 /// - maxMessageBytes: 默认 `Tunables.maxWSMessageBytes`(16 MiB); /// nil = 保持平台默认 1 MiB(仅 spike③ 复现分支)。 - init(server: TestServer, origin: String?, maxMessageBytes: Int? = Tunables.maxWSMessageBytes) { + init( + server: TestServer, origin: String?, accessToken: String? = nil, + maxMessageBytes: Int? = Tunables.maxWSMessageBytes + ) { var request = URLRequest(url: server.wsURL) request.timeoutInterval = 15 if let origin { // T-iOS-2 已定案的平台事实:Origin 不在 reserved-header 列表,setValue 生效。 request.setValue(origin, forHTTPHeaderField: "Origin") } - let task = URLSession.shared.webSocketTask(with: request) + if let accessToken { + request.setValue(authCookieHeader(token: accessToken), forHTTPHeaderField: "Cookie") + } + // 带 cookie 时走关掉 cookie jar 的会话:手写头必须是唯一权威, + // 共享 jar 里的 Set-Cookie 不许覆盖它(§1.1)。 + let session = accessToken == nil ? URLSession.shared : cookieFreeSession + let task = session.webSocketTask(with: request) if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes } self.task = task task.resume() @@ -261,6 +274,30 @@ func deleteLiveSession(server: TestServer, id: String, origin: String?) async th return http.statusCode } +/// F3:任意方法 + 任意 Origin/Cookie 组合的裸 HTTP 请求 → 状态码。 +/// +/// 存在的理由与 `WSTestClient(accessToken:)` 相同:真实客户端(APIClient)**不可能** +/// 造出"合法 cookie + 外域 Origin"这种组合(Origin 恒由 `HostEndpoint` 单点派生), +/// 而那正是"令牌是加法、不是替代"这一性质的判定式。 +func rawStatusCode( + server: TestServer, method: String, path: String, origin: String?, accessToken: String? +) async throws -> Int { + var request = URLRequest(url: server.baseURL.appending(path: path)) + request.httpMethod = method + // 与 APIClient 同:Accept 绝不含 text/html,否则服务器按浏览器导航处理, + // 401 会变成 302→/login(src/server.ts:366-369,459)。 + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") } + if let accessToken { + request.setValue(authCookieHeader(token: accessToken), forHTTPHeaderField: "Cookie") + } + let (_, response) = try await cookieFreeSession.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw HarnessError.setup("\(method) \(path) 非 HTTP 响应") + } + return http.statusCode +} + /// GET /live-sessions(RO,无 Origin)→ 当前会话 id 列表。 func liveSessionIds(server: TestServer) async throws -> [String] { let url = server.baseURL.appending(path: "live-sessions") diff --git a/ios/IntegrationTests/WorktreeLifecycleTests.swift b/ios/IntegrationTests/WorktreeLifecycleTests.swift new file mode 100644 index 0000000..bc3f040 --- /dev/null +++ b/ios/IntegrationTests/WorktreeLifecycleTests.swift @@ -0,0 +1,362 @@ +// +// WorktreeLifecycleTests.swift — T-iOS-32 缺失的那一半:**端到端一次** +// +// T-iOS-32("worktree 创建 + claude --resume 历史",PLAN_IOS_CLIENT.md:917)的验收 +// 是「builder 测试 + 端到端一次」。builder 侧(WorktreeViewModelTests 22 / +// ResumeHistoryViewModelTests 12 / ProjectResumeLaunchTests 7)早就绿了,但在本文件 +// 之前,`grep -rn "projects/worktree" ios/IntegrationTests/` **零命中** —— 从没有任何 +// 一条测试对着真服务器建过、删过一个真 worktree。这里补的就是这条腿。 +// +// 三条纪律: +// 1. **真**:真 Node 服务器(ServerHarness)+ 真 `APIClient`(不是 fake transport), +// 走真 HTTP。 +// 2. **不碰用户的仓库**:所有写盘都发生在自建的一次性 git 夹具里(GitFixture), +// 连服务器派生出来的 `-worktrees/` 也在夹具的临时根底下,`destroy()` 全删。 +// 3. **双信源**:HTTP 200 只说明服务器认为成功;每条断言都再问一次 **git 自己** +// (`worktree list --porcelain` / `.git/worktrees/`)和**文件系统**。 +// +// 服务端真源:src/server.ts:1094-1173(三条路由 + Origin 守卫 + 开关)、 +// src/http/worktrees.ts(validate → contain → execFile,无 shell)。 +// +// 安全面(不可软化):三条路由都是 **G** 类。缺 Origin 必须 403 —— 这是 CSWSH/CSRF +// 的唯一防线,而这三条是全应用**唯一**真写盘的通道。任何"为了过 CI 放宽"= CRITICAL。 + +import Foundation +import Testing +import WireProtocol +@testable import APIClient + +// ─── 小工具 ────────────────────────────────────────────────────────────────── + +/// 从 `GitWriteOutcome` 里取出 200 载荷;不是 `.ok` 就**抛**(夹具/前置失败要响亮, +/// 不能退化成后续一串看不懂的断言不成立)。 +func requireGitWriteOK( + _ outcome: GitWriteOutcome, _ what: String +) throws -> Payload { + guard case let .ok(payload) = outcome else { + throw HarnessError.setup("\(what) 应为 200 .ok,实际 \(outcome)") + } + return payload +} + +/// 裸 HTTP:任意方法 + 任意 Origin + 真 JSON body → (状态码, 解析后的 body)。 +/// +/// 为什么不能直接用 `APIClient` 做守卫负路径:真客户端**造不出**"没有 Origin 的 G +/// 请求"(`APIRoute.originPolicy == .guarded` 恒由 `HostEndpoint` 盖头,见 §5.1)。 +/// 差分的两条腿只差这一个头,其余逐字节相同 —— 403 才能归因到守卫本身。 +func rawJSONRequest( + server: TestServer, method: String, path: String, origin: String?, json: [String: Any] +) async throws -> (status: Int, body: [String: Any]) { + var request = URLRequest(url: server.baseURL.appending(path: path)) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // Accept 绝不含 text/html:否则服务器按浏览器导航处理,状态码语义会变。 + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") } + request.httpBody = try JSONSerialization.data(withJSONObject: json) + let (data, response) = try await cookieFreeSession.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw HarnessError.setup("\(method) \(path) 非 HTTP 响应") + } + let parsed = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + return (http.statusCode, parsed ?? [:]) +} + +// ─── 用例 ──────────────────────────────────────────────────────────────────── + +@Suite("T-iOS-32 worktree 生命周期端到端(真服务器 + 真 APIClient)", .serialized, .timeLimit(.minutes(5))) +struct WorktreeLifecycleTests { + + /// 无令牌的真客户端(默认那台服务器没开 `WEBTERM_TOKEN`)。 + private func makeClient(_ server: TestServer) -> APIClient { + APIClient(endpoint: server.endpoint, http: IntegrationHTTPTransport(), accessToken: nil) + } + + private let branchWithSlash = "feature/e2e-worktree" + private let sanitizedDirName = "feature-e2e-worktree" + + // MARK: 1. create + + @Test("create:200 带回**服务端算出来**的 path/branch(目录名被 sanitize),盘上真存在且被 git 登记") + func createProducesARealWorktreeRegisteredWithGit() async throws { + // Arrange + let server = try await ServerHarness.shared.server() + let fixture = try GitFixture.make() + defer { fixture.destroy() } + let client = makeClient(server) + + // Act + let outcome = try await client.createWorktree( + path: fixture.repo.path, branch: branchWithSlash, base: nil) + + // Assert:响应体是 git/服务端的规范值,不是我们输入的回声 + let result = try requireGitWriteOK(outcome, "POST /projects/worktree") + let createdPath = try #require(result.path, "200 必须带回服务端算出的路径") + #expect(result.branch == branchWithSlash, + "branch 应原样回来(它就是 git 建的分支名),实际 \(String(describing: result.branch))") + // 关键:目录名是 sanitizeBranchForDir 的产物,'/' 变成了 '-'。若这里等于 + // 输入回声,说明路径根本不是服务端派生的。 + #expect(URL(fileURLWithPath: createdPath).lastPathComponent == sanitizedDirName, + "目录名应是 sanitize 后的 \(sanitizedDirName),实际 \(createdPath)") + #expect(createdPath.hasPrefix(fixture.worktreeBase.path + "/"), + "路径必须落在受控根 \(fixture.worktreeBase.path) 内,实际 \(createdPath)") + #expect(createdPath.contains(branchWithSlash) == false, + "带 '/' 的原始分支名不该出现在路径里,实际 \(createdPath)") + + // Assert:文件系统(HTTP 说成功 ≠ 盘上真有) + var isDirectory: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: createdPath, isDirectory: &isDirectory) + #expect(exists && isDirectory.boolValue, "worktree 目录应真存在:\(createdPath)") + #expect(FileManager.default.fileExists(atPath: createdPath + "/.git"), + "worktree 里应有 .git 指针文件") + + // Assert:git 自己的登记(第二信源) + let registered = try #require( + try fixture.registeredWorktree(atCanonicalPath: createdPath), + "git worktree list 应登记 \(createdPath)") + #expect(registered.branch == branchWithSlash, + "git 登记的分支应是 \(branchWithSlash),实际 \(String(describing: registered.branch))") + let all = try fixture.registeredWorktrees() + #expect(all.count == 2, "应是主 worktree + 新建这一条,实际 \(all)") + #expect(fixture.administrativeEntries() == [sanitizedDirName], + "行政登记 .git/worktrees/ 应只有这一条,实际 \(fixture.administrativeEntries())") + } + + // MARK: 2. remove + + @Test("remove:200 后目录从盘上消失、从 git 登记消失、行政目录也被清掉;主 worktree 毫发无损") + func removeDeletesTheWorktreeFromDiskAndFromGit() async throws { + // Arrange:先真建一个(用真客户端,不走 git 命令行 —— 删的必须是"服务器建的那个") + let server = try await ServerHarness.shared.server() + let fixture = try GitFixture.make() + defer { fixture.destroy() } + let client = makeClient(server) + let created = try requireGitWriteOK( + try await client.createWorktree( + path: fixture.repo.path, branch: branchWithSlash, base: nil), + "前置 create") + let createdPath = try #require(created.path) + let before = try fixture.registeredWorktree(atCanonicalPath: createdPath) + #expect(before != nil, "前置:git 应先登记了它") + + // Act:干净 worktree ⇒ 不需要 force + let outcome = try await client.removeWorktree( + path: fixture.repo.path, worktreePath: createdPath, force: false) + + // Assert:响应体回的是 git 自己的规范路径 + let removed = try requireGitWriteOK(outcome, "DELETE /projects/worktree") + let removedPath = try #require(removed.path, "200 必须带回被删的规范路径") + #expect(canonicalPath(removedPath) == canonicalPath(createdPath), + "删掉的应正是刚建的那条,\(removedPath) vs \(createdPath)") + + // Assert:盘 + git 两处都没了 + #expect(FileManager.default.fileExists(atPath: createdPath) == false, + "目录应已从盘上消失:\(createdPath)") + let afterRemoval = try fixture.registeredWorktree(atCanonicalPath: createdPath) + #expect(afterRemoval == nil, "git worktree list 里不该再有它,实际 \(String(describing: afterRemoval))") + #expect(fixture.administrativeEntries().isEmpty, + "行政登记应一并清掉,实际 \(fixture.administrativeEntries())") + + // Assert:主 worktree 还在(destructive 路由绝不能顺手删了仓库本体) + let all = try fixture.registeredWorktrees() + #expect(all.count == 1, "应只剩主 worktree,实际 \(all)") + #expect(canonicalPath(all.first?.path ?? "") == canonicalPath(fixture.repo.path), + "剩下的那条必须是仓库本体") + #expect(FileManager.default.fileExists(atPath: fixture.repo.path + "/.git")) + } + + // MARK: 3. prune + + @Test("prune:手工删掉 worktree 目录后,陈旧登记被 git 标 prunable;prune 回收它,再 prune 幂等([])") + func pruneReclaimsTheStaleAdministrativeEntry() async throws { + // Arrange + let server = try await ServerHarness.shared.server() + let fixture = try GitFixture.make() + defer { fixture.destroy() } + let client = makeClient(server) + let created = try requireGitWriteOK( + try await client.createWorktree( + path: fixture.repo.path, branch: branchWithSlash, base: nil), + "前置 create") + let createdPath = try #require(created.path) + + // Act 1:绕过服务器,手工把目录删掉 —— 这正是 prune 存在的理由 + try FileManager.default.removeItem(atPath: createdPath) + + // Assert:登记还在,且被 git 标成 prunable("陈旧"是 git 的判定,不是我们的猜测) + let stale = try #require( + try fixture.registeredWorktree(atCanonicalPath: createdPath), + "目录没了但登记应该还在(这才有得 prune)") + #expect(stale.prunable, "git 应把它标成 prunable,实际 \(stale)") + #expect(fixture.administrativeEntries() == [sanitizedDirName], + "行政目录应仍在,实际 \(fixture.administrativeEntries())") + + // Act 2 + let pruned = try requireGitWriteOK( + try await client.pruneWorktrees(path: fixture.repo.path), + "POST /projects/worktree/prune") + + // Assert:回收了这一条,并且报出来的标签指向它 + #expect(pruned.pruned.count == 1, "应回收 1 条,实际 \(pruned.pruned)") + #expect(pruned.pruned.first?.hasSuffix(sanitizedDirName) == true, + "回收标签应指向 \(sanitizedDirName),实际 \(pruned.pruned)") + let afterPrune = try fixture.registeredWorktree(atCanonicalPath: createdPath) + #expect(afterPrune == nil, "陈旧登记应已消失,实际 \(String(describing: afterPrune))") + #expect(fixture.administrativeEntries().isEmpty, + "行政目录应已清掉,实际 \(fixture.administrativeEntries())") + + // Assert:幂等 —— 再来一次没得回收,仍是 200 + 空列表 + let again = try requireGitWriteOK( + try await client.pruneWorktrees(path: fixture.repo.path), "第二次 prune") + #expect(again.pruned.isEmpty, "干净仓库再 prune 应为空,实际 \(again.pruned)") + } + + // MARK: 4. G 类守卫(缺 Origin 必须 403,且**什么都没发生**) + + @Test("G 守卫端到端:三条 worktree 路由缺 Origin 一律 403 且零副作用;仅补上 Origin 的同一请求 → 200") + func worktreeRoutesRejectRequestsWithoutOrigin() async throws { + // Arrange + let server = try await ServerHarness.shared.server() + let fixture = try GitFixture.make() + defer { fixture.destroy() } + let createBody: [String: Any] = ["path": fixture.repo.path, "branch": branchWithSlash] + + // Act + Assert(create):无 Origin → 403,且**盘上什么都没建** + let blockedCreate = try await rawJSONRequest( + server: server, method: "POST", path: "projects/worktree", origin: nil, json: createBody) + #expect(blockedCreate.status == 403, + "缺 Origin 的 create 应 403(src/server.ts:1096),实际 \(blockedCreate.status)") + #expect(FileManager.default.fileExists(atPath: fixture.worktreeBase.path) == false, + "被守卫拒掉的请求不该在盘上留下 \(fixture.worktreeBase.path)") + let afterBlockedCreate = try fixture.registeredWorktrees() + #expect(afterBlockedCreate.count == 1, + "被守卫拒掉的请求不该产生任何 git 登记,实际 \(afterBlockedCreate)") + + // 差分:唯一的差别是补上 Origin —— 于是走到业务逻辑并成功 + let allowedCreate = try await rawJSONRequest( + server: server, method: "POST", path: "projects/worktree", origin: server.origin, + json: createBody) + #expect(allowedCreate.status == 200, + "带 Origin 的同一请求应 200,实际 \(allowedCreate.status)") + let createdPath = try #require(allowedCreate.body["path"] as? String) + let afterAllowedCreate = try fixture.registeredWorktree(atCanonicalPath: createdPath) + #expect(afterAllowedCreate != nil, "带 Origin 的 create 应真的登记了 \(createdPath)") + + // Act + Assert(remove):无 Origin → 403,worktree 必须**还在** + let removeBody: [String: Any] = [ + "path": fixture.repo.path, "worktreePath": createdPath, "force": false, + ] + let blockedRemove = try await rawJSONRequest( + server: server, method: "DELETE", path: "projects/worktree", origin: nil, + json: removeBody) + #expect(blockedRemove.status == 403, + "缺 Origin 的 remove 应 403,实际 \(blockedRemove.status)") + #expect(FileManager.default.fileExists(atPath: createdPath), + "被守卫拒掉的 remove 绝不能真的删掉目录") + let afterBlockedRemove = try fixture.registeredWorktree(atCanonicalPath: createdPath) + #expect(afterBlockedRemove != nil, "被守卫拒掉的 remove 不该动 git 登记") + + // Act + Assert(prune):无 Origin → 403;带 Origin → 200 + let blockedPrune = try await rawJSONRequest( + server: server, method: "POST", path: "projects/worktree/prune", origin: nil, + json: ["path": fixture.repo.path]) + #expect(blockedPrune.status == 403, + "缺 Origin 的 prune 应 403,实际 \(blockedPrune.status)") + let allowedPrune = try await rawJSONRequest( + server: server, method: "POST", path: "projects/worktree/prune", + origin: server.origin, json: ["path": fixture.repo.path]) + #expect(allowedPrune.status == 200, + "带 Origin 的 prune 应 200,实际 \(allowedPrune.status)") + + // 收尾:带 Origin 把它删掉,证明 remove 的差分腿也通 + let allowedRemove = try await rawJSONRequest( + server: server, method: "DELETE", path: "projects/worktree", origin: server.origin, + json: removeBody) + #expect(allowedRemove.status == 200, + "带 Origin 的 remove 应 200,实际 \(allowedRemove.status)") + #expect(FileManager.default.fileExists(atPath: createdPath) == false) + } + + // MARK: 5. 非法分支名 —— 400 且什么都没建 + + @Test("非法分支名:400 且**零副作用**(不建目录、不留 git 登记)") + func invalidBranchNamesAreRejectedWithoutCreatingAnything() async throws { + // Arrange:每一条都命中 validateBranchName 的一个分支 + // (src/http/worktrees.ts:95-105):'..' / 前导 '-'(flag 注入) / 空白 / + // '.lock' 结尾 / 前导 '/'。 + let server = try await ServerHarness.shared.server() + let fixture = try GitFixture.make() + defer { fixture.destroy() } + let client = makeClient(server) + let invalidBranches = ["../evil", "-force-flag", "bad name", "ends.lock", "/leading-slash"] + + for branch in invalidBranches { + // Act + let outcome = try await client.createWorktree( + path: fixture.repo.path, branch: branch, base: nil) + + // Assert:类型化拒绝 + 服务器的 SAFE 文案(绝非 git 原始 stderr) + guard case let .rejected(status, message) = outcome else { + Issue.record("分支 \(branch) 应被拒,实际 \(outcome)") + continue + } + #expect(status == 400, "分支 \(branch) 应 400,实际 \(status)") + #expect(message?.isEmpty == false, "400 应带一句可展示的原因,分支 \(branch)") + } + + // Assert:五次拒绝之后,盘上和 git 里都一无所有 + #expect(FileManager.default.fileExists(atPath: fixture.worktreeBase.path) == false, + "非法分支名不该创建受控根 \(fixture.worktreeBase.path)") + let registered = try fixture.registeredWorktrees() + #expect(registered.count == 1, "应仍只有主 worktree,实际 \(registered)") + #expect(fixture.administrativeEntries().isEmpty) + } + + // MARK: 6. T-iOS-32 的另一半 —— GET /sessions(claude --resume 历史) + + @Test("GET /sessions:真 APIClient 对着**HOME 隔离**的真服务器,把 jsonl 解成 HistorySession") + func claudeResumeHistoryDecodesFromTheRealServer() async throws { + // Arrange:自建一个假 HOME —— `/sessions` 读的是 + // `os.homedir()/.claude/projects`(src/http/history.ts:81)。不隔离的话, + // CI 上那个目录不存在 ⇒ 恒 `[]` ⇒ 断言恒真;本机上又会把用户真实的会话 + // 内容读进测试进程。两者都不可接受。 + let manager = FileManager.default + let home = manager.temporaryDirectory + .appending(path: "webterm-history-home-\(UUID().uuidString)") + .resolvingSymlinksInPath() + defer { try? manager.removeItem(at: home) } + let projectDir = home.appending(path: ".claude/projects/-fixture-demo-project") + try manager.createDirectory(at: projectDir, withIntermediateDirectories: true) + let sessionId = UUID().uuidString.lowercased() + let lines: [[String: Any]] = [ + ["type": "summary", "summary": "不是 user 行,应被跳过"], + [ + "type": "user", + "cwd": "/fixture/demo-project", + // content 的数组形态(parseSessionMeta 两种形态都要吃) + "message": ["content": [["type": "text", "text": "resume me\tplease"]]], + ], + ] + let jsonl = try lines + .map { String(decoding: try JSONSerialization.data(withJSONObject: $0), as: UTF8.self) } + .joined(separator: "\n") + try jsonl.write( + to: projectDir.appending(path: "\(sessionId).jsonl"), atomically: true, encoding: .utf8) + + let server = try await ServerHarness.disposableServer( + extraEnvironment: ["HOME": home.path]) + let client = makeClient(server) + + // Act:RO 路由 —— 真 APIClient 不带 Origin + let sessions = try await client.claudeSessions() + + // Assert:逐字段解码(HOME 隔离 ⇒ 有且仅有我们放的那一条) + #expect(sessions.count == 1, "隔离 HOME 下应恰好 1 条,实际 \(sessions.count)") + let session = try #require(sessions.first) + #expect(session.id == sessionId, "id 应是 jsonl 的文件名主干(claude --resume 拿它)") + #expect(session.cwd == "/fixture/demo-project") + #expect(session.project == "demo-project", "project = cwd 的末段") + #expect(session.preview == "resume me please", "preview 应折叠空白后截断") + #expect(session.mtimeMs > 0, "mtimeMs 是分数毫秒,必须解出正值") + } +} diff --git a/ios/IntegrationTests/scripts/coverage-gate.sh b/ios/IntegrationTests/scripts/coverage-gate.sh index 4c63555..7832552 100755 --- a/ios/IntegrationTests/scripts/coverage-gate.sh +++ b/ios/IntegrationTests/scripts/coverage-gate.sh @@ -5,6 +5,12 @@ # Usage: coverage-gate.sh # e.g. coverage-gate.sh WireProtocol # Env: COVERAGE_THRESHOLD= # default 80 (red/green demo knob) # +# GATED PACKAGES (kept in sync with .github/workflows/ios.yml's package-tests +# matrix): WireProtocol, SessionCore, HostRegistry, APIClient — the four of plan +# §9 — plus ClientTLS, added by B4 (it holds the mTLS identity/keychain code, the +# most security-sensitive package in the tree, and was the only ungated one). +# TestSupport stays ungated: it IS the test doubles. +# # WHY THIS EXISTS (plan §9 flaw, fix assigned to T-iOS-16): the raw §9 command # xcrun llvm-cov export -summary-only ... -ignore-filename-regex '(Tests|TestSupport|\.build)/' # | jq '.data[0].totals.lines.percent >= 80' @@ -20,7 +26,7 @@ set -euo pipefail -PKG="${1:?usage: coverage-gate.sh (WireProtocol|SessionCore|HostRegistry|APIClient)}" +PKG="${1:?usage: coverage-gate.sh (WireProtocol|SessionCore|HostRegistry|APIClient|ClientTLS)}" THRESHOLD="${COVERAGE_THRESHOLD:-80}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/ios/Packages/APIClient/Sources/APIClient/APIClient.swift b/ios/Packages/APIClient/Sources/APIClient/APIClient.swift index 0005cf2..974f508 100644 --- a/ios/Packages/APIClient/Sources/APIClient/APIClient.swift +++ b/ios/Packages/APIClient/Sources/APIClient/APIClient.swift @@ -3,23 +3,46 @@ import WireProtocol /// Typed client for the server's HTTP surface (frozen contract, plan §3.4). /// -/// **Origin 铁律(安全语义,plan §3.4/§5.1)**: only the two G (state-changing) -/// endpoints stamp `Origin: endpoint.originHeader`; the four RO GETs never do. -/// Stamping lives in ONE place — `APIRoute.urlRequest(for:)` — and the value is -/// single-point derived by `HostEndpoint` (never hand-assembled). +/// **Origin 铁律(安全语义,plan §3.4/§5.1)**: `Origin: endpoint.originHeader` is +/// stamped **iff** the route is G (state-changing); RO GETs never carry it. +/// Stamping lives in ONE place — `APIRoute.urlRequest(for:accessToken:)` — and +/// the value is single-point derived by `HostEndpoint` (never hand-assembled). +/// The optional access-token `Cookie` is stamped at that same single point and is +/// **orthogonal**: it never replaces Origin (ios-completion §1.1). /// /// The server is an UNTRUSTED input source at this boundary (plan §4): bodies /// are decoded tolerantly (malformed entries dropped), statuses are mapped to /// explicit `APIClientError`s, and nothing here ever crashes on bad input. -public struct APIClient: Sendable { +public struct APIClient: Sendable, CustomStringConvertible, CustomDebugStringConvertible { public let endpoint: HostEndpoint private let http: any HTTPTransport + /// The host's optional shared access token (`WEBTERM_TOKEN`, ios-completion + /// §1.1). SECRET: private, never printed (see `description`), never put in a + /// URL, only ever leaving as a `Cookie` header stamped in `APIRoute`. + /// nil = the host has no token configured (LAN zero-config). + private let accessToken: String? - public init(endpoint: HostEndpoint, http: any HTTPTransport) { + public init(endpoint: HostEndpoint, http: any HTTPTransport, accessToken: String? = nil) { self.endpoint = endpoint self.http = http + self.accessToken = accessToken } + /// Whether this client carries an access token — PRESENCE only. There is + /// deliberately no getter for the value: the token leaves this type solely + /// as a `Cookie` header (plan §5: never log, never a URL, never a report). + public var hasAccessToken: Bool { accessToken != nil } + + /// Redacted on purpose: the default reflection-based description of a + /// struct holding a secret would print it into any log line that + /// interpolates the client. + public var description: String { + "APIClient(origin: \(endpoint.originHeader), accessToken: " + + (accessToken == nil ? "none)" : ")") + } + + public var debugDescription: String { description } + // MARK: - RO (read-only — NO Origin header) /// `GET /live-sessions` (src/server.ts:257-259) — the discovery list every @@ -107,11 +130,35 @@ public struct APIClient: Sendable { // MARK: - Internals (shared with the P1 feature files, T-iOS-38) + /// The ONE request choke point. Two structural guarantees live here: + /// - a configured token is shape-validated before it can reach a header + /// (`.malformedToken`, fail-fast — never silently send an unauthenticated + /// request and let the user read the 401 as "server is down"); + /// - a 401 becomes the typed `.unauthorized` (ios-completion §1.1) for every + /// route except the two families that define their own 401 + /// (`UnauthorizedPolicy.routeDefined`). func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) { - guard let request = route.urlRequest(for: endpoint) else { + let token = try validatedAccessToken() + guard let request = route.urlRequest(for: endpoint, accessToken: token) else { throw APIClientError.invalidRequest } - return try await http.send(request) + let (data, response) = try await http.send(request) + if response.statusCode == HTTPStatus.unauthorized, + route.unauthorizedPolicy == .accessTokenGate { + throw APIClientError.unauthorized + } + return (data, response) + } + + /// nil when no token is configured; throws `.malformedToken` when one is + /// configured but violates the frozen charset/length rule (which is also + /// what makes CRLF header injection impossible). + private func validatedAccessToken() throws -> String? { + guard let accessToken else { return nil } + guard AccessTokenRule.isWellFormed(accessToken) else { + throw APIClientError.malformedToken + } + return accessToken } /// 200 → ok; 404 → `.sessionNotFound`; anything else → `.unexpectedStatus`. @@ -125,6 +172,44 @@ public struct APIClient: Sendable { throw APIClientError.unexpectedStatus(response.statusCode) } } + + /// The `/projects/*` three-prong contract, shared by `detail`/`log`/`pr` + /// (src/server.ts:1033-1042 and friends): `path` missing/empty → 400, + /// non-git dir → 404, read failure → 500. `notFound` is a parameter because + /// `/projects/worktree/state`'s 404 means "not a worktree", not "no project". + static func requireGitReadOK( + _ response: HTTPURLResponse, notFound: APIClientError = .projectNotFound + ) throws { + switch response.statusCode { + case HTTPStatus.ok: + return + case HTTPStatus.badRequest: + throw APIClientError.projectPathInvalid + case HTTPStatus.notFound: + throw notFound + case HTTPStatus.internalServerError: + throw APIClientError.gitDataUnavailable + default: + throw APIClientError.unexpectedStatus(response.statusCode) + } + } + + /// Mirror of the server's own `path` guard, applied BEFORE any network I/O + /// (validate at the boundary, plan §4) — every `/projects/*` route rejects + /// an empty path with 400, so there is nothing to learn from the round trip. + static func requireNonEmptyPath(_ path: String) throws { + guard !path.isEmpty else { + throw APIClientError.projectPathInvalid + } + } + + /// Decode a single JSON object body, or `.invalidResponseBody`. + static func decodeObject(_ type: T.Type, from data: Data) throws -> T { + guard let value = try? JSONDecoder().decode(T.self, from: data) else { + throw APIClientError.invalidResponseBody + } + return value + } } /// Named HTTP status codes used by the client (no magic numbers, plan §4). @@ -132,8 +217,12 @@ enum HTTPStatus { static let ok = 200 static let noContent = 204 static let badRequest = 400 + static let unauthorized = 401 static let forbidden = 403 static let notFound = 404 + static let conflict = 409 + static let payloadTooLarge = 413 static let tooManyRequests = 429 static let internalServerError = 500 + static let serviceUnavailable = 503 } diff --git a/ios/Packages/APIClient/Sources/APIClient/AccessToken.swift b/ios/Packages/APIClient/Sources/APIClient/AccessToken.swift new file mode 100644 index 0000000..4fd1806 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/AccessToken.swift @@ -0,0 +1,132 @@ +import Foundation +import WireProtocol + +// B1 · optional shared access token (`WEBTERM_TOKEN`) — ios-completion §1.1 +// FROZEN contract, cross-checked against `src/http/auth.ts` + `src/server.ts`. +// +// Server facts: +// | cookie name | `webterm_auth` | auth.ts:30 | +// | login endpoint | `POST /auth` | server.ts:393 | +// | request body | `{"token":""}` + JSON C-T | server.ts:396 | +// | `Accept` | MUST NOT contain `text/html` | server.ts:366 | +// | valid | 204 **with** `Set-Cookie` | server.ts:412 | +// | wrong token | 401 `{"error":"invalid token"}` | server.ts:418 | +// | rate limited | 429 (10/min/IP) | server.ts:399 | +// | auth DISABLED | 204 **without** `Set-Cookie` | server.ts:404 | +// +// IMPLEMENTATION DECISION (frozen): a native client KNOWS its token, so it +// hand-writes `Cookie: webterm_auth=` and NEVER parses `Set-Cookie` nor +// relies on a cookie jar (URLSession/OkHttp jars behave inconsistently on a WS +// upgrade and are hard to test). Hand-written header == the same pattern as the +// hand-written `Origin`, pinned by pure-function unit tests. +// +// HONEST BOUNDARY (src/http/auth.ts header): the token is a bar-raiser, NOT a +// TLS substitute. On bare `ws://`/`http://` it travels in cleartext and is +// replayable by a LAN sniffer; it only meaningfully hardens the TLS-terminated +// relay/tunnel path. + +/// The auth cookie, single-point (name + value assembly). +enum AuthCookie { + /// `AUTH_COOKIE_NAME` (src/http/auth.ts:30). + static let name = "webterm_auth" + /// Response header the probe reads. The client checks its PRESENCE only and + /// never parses its value — the token it would echo is already known. + static let setCookieHeader = "Set-Cookie" + + /// `webterm_auth=`. Callers MUST pass a token that already satisfies + /// `AccessTokenRule.isWellFormed` — the charset check is what guarantees no + /// CR/LF (header injection) and no `;` (cookie splitting) can appear here. + static func headerValue(for token: String) -> String { + "\(name)=\(token)" + } +} + +/// The frozen token shape: 16–512 characters from `[A-Za-z0-9._~+/=-]` +/// (CLAUDE.md / `src/config.ts` — the server REFUSES TO START with anything +/// else, so a shape-violating token can never be the right one). +enum AccessTokenRule { + static let minLength = 16 + static let maxLength = 512 + /// URL/cookie-safe set, byte-for-byte the server's `[A-Za-z0-9._~+/=-]`. + private static let allowed = Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~+/=-" + ) + + static func isWellFormed(_ token: String) -> Bool { + (minLength...maxLength).contains(token.count) && token.allSatisfy(allowed.contains) + } +} + +/// The four outcomes of the `POST /auth` pairing-time probe (ios-completion +/// §1.1). They are RESULTS, not errors: three of the four are perfectly normal +/// states the pairing UI must tell apart. +public enum AccessTokenProbeResult: Sendable, Equatable { + /// 204 **with** `Set-Cookie` — the token is correct; persist it (Keychain). + case valid + /// 204 **without** `Set-Cookie` — this host has auth DISABLED. It is NOT + /// "authenticated": nothing was verified and nothing should be persisted. + case authDisabled + /// 401 — wrong token. + case invalidToken + /// 429 — 10 attempts/min/IP exceeded; ask the user to wait. + case rateLimited +} + +extension Endpoints { + static let authPath = "/auth" + + /// `POST /auth` with body `{"token":…}`. + /// + /// - `.guarded`: this is a state-changing POST (it mints a session cookie), + /// so it stamps `Origin` like every other write. The server does not + /// Origin-check `/auth` today — keeping the rule uniform costs one header + /// and avoids a special case that would rot the moment it does. + /// - `.routeDefined`: this route's own 401 means "wrong token", not "the + /// gate rejected you" — the probe maps it to `.invalidToken`. + static func auth(token: String) throws -> APIRoute { + APIRoute( + method: .post, path: authPath, originPolicy: .guarded, + body: try JSONEncoder().encode(AuthTokenBody(token: token)), + unauthorizedPolicy: .routeDefined + ) + } + + private struct AuthTokenBody: Encodable { + let token: String + } +} + +extension APIClient { + /// One-shot pairing-time probe of a candidate access token + /// (`POST /auth`, ios-completion §1.1). + /// + /// The token travels ONLY in the JSON body — never in a URL query (the + /// server's `?token=` bootstrap exists for browsers, which strip it from + /// history afterwards; a native client has no reason to put a secret in a + /// URL that lands in logs). A shape-violating candidate is rejected here, + /// before any network I/O (`.malformedToken`). + /// + /// Returns one of the four frozen outcomes; any other status throws + /// `.unexpectedStatus` rather than guessing. + public func probeAccessToken(_ token: String) async throws -> AccessTokenProbeResult { + guard AccessTokenRule.isWellFormed(token) else { + throw APIClientError.malformedToken + } + let (_, response) = try await perform(try Endpoints.auth(token: token)) + switch response.statusCode { + case HTTPStatus.noContent: + // THE distinction the whole feature hinges on: a 204 without a + // Set-Cookie means the host never enabled auth. Reporting that as + // "authenticated" would persist a token that gates nothing and + // teach the user the host is protected when it is not. + return response.value(forHTTPHeaderField: AuthCookie.setCookieHeader) == nil + ? .authDisabled : .valid + case HTTPStatus.unauthorized: + return .invalidToken + case HTTPStatus.tooManyRequests: + return .rateLimited + default: + throw APIClientError.unexpectedStatus(response.statusCode) + } + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift b/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift index 0a14f9a..baab806 100644 --- a/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift +++ b/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift @@ -21,10 +21,31 @@ enum OriginPolicy: Sendable, Equatable { case guarded } +/// How a 401 on this route must be READ (ios-completion §1.1). The access-token +/// gate answers 401 for any unauthed request (src/server.ts:459 `authGate` step +/// 5), so on almost every route 401 means "token missing/wrong" → typed +/// `.unauthorized`. Two route families define their OWN 401 and must not be +/// swallowed by that rule — declared here, at the route, so the exception is +/// visible instead of hidden in a call site. +enum UnauthorizedPolicy: Sendable, Equatable { + /// Default — a 401 can only be the access-token gate. This is also the + /// correct reading for the WORKTREE writes: `src/http/worktrees.ts` has no + /// 401 of its own (403 kill-switch, else 400/404/500). + case accessTokenGate + /// The route owns its 401: `POST /auth`'s wrong-token answer + /// (src/server.ts:418) and the four **git-ops** writes — stage, commit, + /// push, fetch — where the server CLASSIFIES a host-side git credential + /// failure as 401 (src/http/git-ops.ts:108 "Push authentication required on + /// the host."). Nothing else reaches that classifier. + case routeDefined +} + /// Header/content-type names used by the builder (no magic strings inline). enum HeaderName { static let origin = "Origin" static let contentType = "Content-Type" + static let accept = "Accept" + static let cookie = "Cookie" } enum ContentTypeValue { @@ -41,28 +62,43 @@ struct APIRoute: Sendable, Equatable { /// nil for no query. Percent-encoding happens ONCE, in the route builder /// (T-iOS-38: `/projects/detail?path=`) — never at call sites. let percentEncodedQuery: String? + /// See `UnauthorizedPolicy`. Defaults to the gate reading. + let unauthorizedPolicy: UnauthorizedPolicy init( method: HTTPMethod, path: String, originPolicy: OriginPolicy, body: Data?, - percentEncodedQuery: String? = nil + percentEncodedQuery: String? = nil, + unauthorizedPolicy: UnauthorizedPolicy = .accessTokenGate ) { self.method = method self.path = path self.originPolicy = originPolicy self.body = body self.percentEncodedQuery = percentEncodedQuery + self.unauthorizedPolicy = unauthorizedPolicy } /// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port: /// the path is REPLACED, the query is REPLACED by `percentEncodedQuery` /// (dropped when nil), fragment/credentials are dropped — the same - /// derivation philosophy as `HostEndpoint.wsURL`. Origin stamping - /// happens HERE and only here (single point; hand-stamping elsewhere is a - /// review CRITICAL, plan §5.1). - func urlRequest(for endpoint: HostEndpoint) -> URLRequest? { + /// derivation philosophy as `HostEndpoint.wsURL`. + /// + /// **Every header this client sends is stamped HERE and only here** (single + /// point; hand-stamping elsewhere is a review CRITICAL, plan §5.1): + /// - `Origin` **iff** `.guarded` — the security split; + /// - `Cookie: webterm_auth=` iff a token is configured — ORTHOGONAL to + /// the Origin rule (ios-completion §1.1: the token never REPLACES Origin, + /// both travel together, on RO and G alike); + /// - `Accept: application/json` always — an `Accept` containing `text/html` + /// makes the server treat the request as a browser navigation and answer + /// 302→/login instead of 401/204 (src/server.ts:366-369,459). + /// + /// `accessToken` MUST already be shape-validated (`AccessTokenRule`); the + /// charset check is what makes a header-injection value impossible here. + func urlRequest(for endpoint: HostEndpoint, accessToken: String? = nil) -> URLRequest? { guard var components = URLComponents( url: endpoint.baseURL, resolvingAgainstBaseURL: true ) else { return nil } @@ -78,9 +114,15 @@ struct APIRoute: Sendable, Equatable { var request = URLRequest(url: url) request.httpMethod = method.rawValue + request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.accept) if originPolicy == .guarded { request.setValue(endpoint.originHeader, forHTTPHeaderField: HeaderName.origin) } + if let accessToken { + request.setValue( + AuthCookie.headerValue(for: accessToken), forHTTPHeaderField: HeaderName.cookie + ) + } if let body { request.httpBody = body request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.contentType) @@ -89,17 +131,24 @@ struct APIRoute: Sendable, Equatable { } } -/// Builders for the frozen endpoints (plan §3.4 + T-iOS-38 P1 增量). Route -/// table (verified against src/server.ts): -/// - RO — `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314) -/// · `GET /live-sessions/:id/events` (:528) · `GET /config/ui` (:609) -/// · `GET /projects` (:262) · `GET /projects/detail?path=` (:293) -/// · `GET /prefs` (:273) -/// - G — `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503) -/// · `PUT /prefs` (:278) · `POST|DELETE /push/apns-token` (frozen T-iOS-20 -/// shape, mirrors `/push/subscribe` :461-498) -/// P1 builders live beside their feature models: `ApnsToken.swift`, -/// `Projects.swift`, `Prefs.swift` (T-iOS-38 single owner). +/// Builders for the frozen endpoints (plan §3.4 · T-iOS-38 P1 · B1 增量). Route +/// table (verified against src/server.ts at the line numbers shown): +/// - RO — `GET /live-sessions` (:485) · `GET /live-sessions/:id/preview` (:585) +/// · `GET /live-sessions/:id/events` (:984) · `GET /config/ui` (:1320) +/// · `GET /projects` (:507) · `GET /projects/detail?path=` (:564) +/// · `GET /prefs` (:518) · `GET /projects/log?path=[&n=]` (:1030) +/// · `GET /projects/pr?path=` (:1058) +/// · `GET /projects/worktree/state?path=` (:542) · `GET /sessions` (:479) +/// - G — `DELETE /live-sessions/:id` (:689) · `POST /hook/decision` (:959) +/// · `PUT /prefs` (:523) · `POST|DELETE /push/apns-token` (:871/:892) +/// · `POST /auth` (:393) · `POST /live-sessions/:id/queue` (:605) +/// · `POST /projects/git/stage|commit|push|fetch` (:1184/:1219/:1256/:1290) +/// · `POST /projects/worktree` (:1095) · `DELETE /projects/worktree` (:1124) +/// · `POST /projects/worktree/prune` (:1154) +/// Builders live beside their feature models: `ApnsToken.swift`, +/// `Projects.swift`, `Prefs.swift`, `AccessToken.swift`, `GitLog.swift`, +/// `PrStatus.swift`, `WorktreeState.swift`, `GitWrite.swift`, `History.swift`, +/// `FollowupQueue.swift`. enum Endpoints { /// Strict RFC 3986 unreserved set — everything else gets percent-encoded. /// Deliberately stricter than `.urlQueryAllowed`: a bare `+` in a query is @@ -108,6 +157,21 @@ enum Endpoints { static let unreservedCharacters = CharacterSet( charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" ) + + /// THE single percent-encoding choke point for every query value in this + /// package (`?path=`, and anything added later). nil = not encodable → + /// callers surface `.invalidRequest` instead of building a broken URL. + static func percentEncode(_ value: String) -> String? { + value.addingPercentEncoding(withAllowedCharacters: unreservedCharacters) + } + + /// `path=` — the query shared by every `/projects/*` read + /// route (`detail`, `log`, `pr`, `worktree/state`). One builder, so a new + /// route cannot re-introduce the `+`-decodes-to-space class of bug. + static func pathQuery(_ path: String) -> String? { + percentEncode(path).map { "path=\($0)" } + } + static func liveSessions() -> APIRoute { APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil) } @@ -155,7 +219,7 @@ enum Endpoints { /// Server session ids are lowercase `crypto.randomUUID()` strings and /// `:id` route params are matched as EXACT strings — always serialize /// lowercase (same rule as `MessageCodec`'s attach encoding). - private static func pathId(_ id: UUID) -> String { + static func pathId(_ id: UUID) -> String { id.uuidString.lowercased() } diff --git a/ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift b/ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift new file mode 100644 index 0000000..5f247d7 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift @@ -0,0 +1,83 @@ +import Foundation +import WireProtocol + +// B1 · `POST /live-sessions/:id/queue` (src/server.ts:605-643) — **G**. +// State-changing (it causes shell input on the next idle) → Origin guard + per-IP +// rate limit. Body: `{text, appendEnter?}`. +// +// BYTE-SHUTTLE (the project's central invariant): the bytes are stored and later +// injected VERBATIM. The server never parses them as a shell command, and +// neither does this client — `text` is passed through untouched, exactly like a +// keystroke. The FE owns the Enter decision (`appendEnter` → a trailing `\r`, +// 0x0D — never `\n`), so the stored entry is byte-identical to what typing it +// would have produced. + +/// `POST /live-sessions/:id/queue` 200 body — the queue's new depth. +struct QueueDepth: Decodable { + let length: Int +} + +extension Endpoints { + /// `POST /live-sessions/:id/queue` — G. `appendEnter` is always serialized + /// (the server reads `=== true`, so an explicit `false` is honest and + /// symmetric rather than relying on an absent-key default). + static func enqueueFollowup( + sessionId: UUID, text: String, appendEnter: Bool + ) throws -> APIRoute { + APIRoute( + method: .post, path: "/live-sessions/\(pathId(sessionId))/queue", + originPolicy: .guarded, + body: try JSONEncoder().encode( + FollowupBody(text: text, appendEnter: appendEnter) + ) + ) + } + + private struct FollowupBody: Encodable { + let text: String + let appendEnter: Bool + } +} + +extension APIClient { + /// Enqueue a follow-up prompt, fired into the PTY on the session's next idle + /// (w2). Returns the queue's NEW depth. + /// + /// G → `Origin` byte-equal. Server-enforced limits (theirs, documented for + /// callers): body ≤ 16 KB, `text` + optional `\r` ≤ `queueItemMaxBytes` + /// (→ 413), depth ≤ `queueMaxItems` (→ 409, never a silent drop), per-IP + /// rate limit (→ 429), `QUEUE_ENABLED=0` (→ 503). + /// An empty `text` is rejected before any network I/O (mirrors the 400 rule). + @discardableResult + public func enqueueFollowup( + sessionId: UUID, text: String, appendEnter: Bool + ) async throws -> Int { + guard !text.isEmpty else { + throw APIClientError.queueTextInvalid + } + let route = try Endpoints.enqueueFollowup( + sessionId: sessionId, text: text, appendEnter: appendEnter + ) + let (data, response) = try await perform(route) + switch response.statusCode { + case HTTPStatus.ok: + return try Self.decodeObject(QueueDepth.self, from: data).length + case HTTPStatus.badRequest: + throw APIClientError.queueTextInvalid + case HTTPStatus.forbidden: + throw APIClientError.forbidden + case HTTPStatus.notFound: + throw APIClientError.sessionNotFound + case HTTPStatus.conflict: + throw APIClientError.queueFull + case HTTPStatus.payloadTooLarge: + throw APIClientError.queueTextTooLarge + case HTTPStatus.tooManyRequests: + throw APIClientError.rateLimited + case HTTPStatus.serviceUnavailable: + throw APIClientError.queueDisabled + default: + throw APIClientError.unexpectedStatus(response.statusCode) + } + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/GitLog.swift b/ios/Packages/APIClient/Sources/APIClient/GitLog.swift new file mode 100644 index 0000000..3bd1c80 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/GitLog.swift @@ -0,0 +1,110 @@ +import Foundation +import WireProtocol + +// B1 · `GET /projects/log?path=[&n=]` (src/server.ts:1030-1056) — RO, NO Origin. +// Response = `src/types.ts:757-772` `GitLogResult`. Always 200 on a valid git +// dir (a git failure degrades to an empty commit list server-side); `path` +// missing → 400, non-git dir → 404, read failure → 500. + +/// One commit from `git log` (src/types.ts:757-763). `hash` and `at` are +/// REQUIRED — a commit without them cannot be rendered or opened, so the entry +/// is dropped while its siblings survive. `at` = `%ct * 1000` (epoch ms, an +/// integer — unlike the `stat()`-derived timestamps elsewhere). +/// +/// Every field is INERT display text: render as plain text, never autolink, +/// never pass to a shell. +public struct CommitLogEntry: Sendable, Equatable { + public let hash: String + public let at: Int + public let subject: String + /// w6/G4: reachable from HEAD but not from `@{u}`. nil = the server did not + /// say (no upstream to compare against) — which is NOT the same as `false`. + public let unpushed: Bool? + + public init(hash: String, at: Int, subject: String = "", unpushed: Bool? = nil) { + self.hash = hash + self.at = at + self.subject = subject + self.unpushed = unpushed + } +} + +extension CommitLogEntry: Decodable { + private enum CodingKeys: String, CodingKey { + case hash, at, subject, unpushed + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + hash = try container.decode(String.self, forKey: .hash) + at = try container.decode(Int.self, forKey: .at) + subject = (try? container.decode(String.self, forKey: .subject)) ?? "" + unpushed = try? container.decode(Bool.self, forKey: .unpushed) + } +} + +/// `GET /projects/log` result (src/types.ts:765-772). +public struct GitLogResult: Sendable, Equatable { + public let commits: [CommitLogEntry] + /// More commits exist beyond the server's cap. + public let truncated: Bool + /// w6/G4: upstream short name, used to label the pushed/unpushed boundary. + /// nil ⇒ nothing to compare against, so NO boundary may be drawn. + public let upstream: String? + + public init(commits: [CommitLogEntry], truncated: Bool = false, upstream: String? = nil) { + self.commits = commits + self.truncated = truncated + self.upstream = upstream + } +} + +extension GitLogResult: Decodable { + private enum CodingKeys: String, CodingKey { + case commits, truncated, upstream + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + commits = LossyList.decode(CommitLogEntry.self, in: container, forKey: .commits) + truncated = (try? container.decode(Bool.self, forKey: .truncated)) ?? false + upstream = try? container.decode(String.self, forKey: .upstream) + } +} + +extension Endpoints { + /// Mirror of `src/http/git-log.ts:32` `GIT_LOG_MAX` — the server's `?n=` + /// clamp ceiling. Clamping client-side too keeps the URL honest about what + /// will come back (the server re-clamps regardless). + static let gitLogMaxCount = 50 + static let gitLogMinCount = 1 + + /// `GET /projects/log?path=[&n=]` — RO, no Origin. nil = `path` could not be + /// percent-encoded. A nil `n` omits the parameter (server default applies). + static func gitLog(path: String, n: Int?) -> APIRoute? { + guard var query = pathQuery(path) else { return nil } + if let n { + query += "&n=\(min(max(n, gitLogMinCount), gitLogMaxCount))" + } + return APIRoute( + method: .get, path: "/projects/log", originPolicy: .readOnly, + body: nil, percentEncodedQuery: query + ) + } +} + +extension APIClient { + /// `GET /projects/log?path=[&n=]` — the repo's recent commits. RO → no + /// Origin. `n` is clamped to `1...50`; nil leaves it to the server. + /// 400/404/500 → `.projectPathInvalid` / `.projectNotFound` / + /// `.gitDataUnavailable`; an empty path is rejected before any network I/O. + public func gitLog(path: String, n: Int? = nil) async throws -> GitLogResult { + try Self.requireNonEmptyPath(path) + guard let route = Endpoints.gitLog(path: path, n: n) else { + throw APIClientError.invalidRequest + } + let (data, response) = try await perform(route) + try Self.requireGitReadOK(response) + return try Self.decodeObject(GitLogResult.self, from: data) + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/GitWrite.swift b/ios/Packages/APIClient/Sources/APIClient/GitWrite.swift new file mode 100644 index 0000000..cdbd882 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/GitWrite.swift @@ -0,0 +1,398 @@ +import Foundation +import WireProtocol + +// B1 · the seven **G** (state-changing) git/worktree routes — the highest-risk +// channel in the app. Every one of them: `Origin` (CSRF) → `gitOpsEnabled` / +// `worktreeEnabled` kill-switch (403) → per-IP rate limit (429) → +// `isValidGitDir` three-prong (404). Sources: +// POST /projects/git/stage src/server.ts:1184-1218 {path,files,stage} +// POST /projects/git/commit src/server.ts:1219-1255 {path,message} +// POST /projects/git/push src/server.ts:1256-1289 {path} +// POST /projects/git/fetch src/server.ts:1290-1319 {path} +// POST /projects/worktree src/server.ts:1095-1123 {path,branch[,base]} +// DELETE /projects/worktree src/server.ts:1124-1153 {path,worktreePath,force} +// POST /projects/worktree/prune src/server.ts:1154-1183 {path} +// +// The remote/branch/refspec of push and fetch are ALWAYS derived server-side — +// this client cannot point them at an arbitrary URL, and never force-pushes. +// Failure bodies carry the server's already-classified, already-sanitized `error` +// string only (never raw git stderr, SEC-M10): the client shows it verbatim. + +/// Outcome of one guarded git write. Three shapes, because the server gives +/// exactly three: +/// - `.ok` — 200 with the route's payload; +/// - `.rejected` — a 4xx/5xx carrying the server's SAFE `error` message, to be +/// displayed INERTLY. **403 is overloaded** (the Origin guard AND the feature +/// kill-switch both answer 403) and the client cannot tell them apart by +/// status, so it surfaces the message instead of inventing a typed variant; +/// - `.rateLimited` — 429. Do NOT auto-retry (that is what the limiter is for). +public enum GitWriteOutcome: Sendable, Equatable { + case ok(Payload) + case rejected(status: Int, message: String?) + case rateLimited +} + +/// A guarded write's 200 payload. `degraded` is what a 200 with a missing or +/// garbled body decodes to: the write ALREADY HAPPENED, so a bad body must not +/// be reported as a failure — and the fallback must not be a crash path either. +protocol GitWritePayload: Decodable, Sendable, Equatable { + static var degraded: Self { get } +} + +// MARK: - Per-route 200 payloads + +/// `POST /projects/git/stage` → `{ok,staged,count}`. +public struct StageResult: Sendable, Equatable, Decodable { + /// true = files were staged (`git add`); false = unstaged (`git restore --staged`). + public let staged: Bool + public let count: Int + + public init(staged: Bool = false, count: Int = 0) { + self.staged = staged + self.count = count + } + + private enum CodingKeys: String, CodingKey { case staged, count } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + staged = (try? container.decode(Bool.self, forKey: .staged)) ?? false + count = LossyNumber.int(in: container, forKey: .count) ?? 0 + } +} + +/// `POST /projects/git/commit` → `{ok,commit}` (short sha; `""` is possible). +public struct CommitResult: Sendable, Equatable, Decodable { + public let commit: String + + public init(commit: String = "") { + self.commit = commit + } + + private enum CodingKeys: String, CodingKey { case commit } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + commit = (try? container.decode(String.self, forKey: .commit)) ?? "" + } +} + +/// `POST /projects/git/push` → `{ok,branch,remote}` (both server-derived). +public struct PushResult: Sendable, Equatable, Decodable { + public let branch: String? + public let remote: String? + + public init(branch: String? = nil, remote: String? = nil) { + self.branch = branch + self.remote = remote + } + + private enum CodingKeys: String, CodingKey { case branch, remote } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + branch = try? container.decode(String.self, forKey: .branch) + remote = try? container.decode(String.self, forKey: .remote) + } +} + +/// `POST /projects/git/fetch` → `{ok,remote,lastFetchMs}`. `lastFetchMs` is the +/// post-fetch `FETCH_HEAD` mtime — fractional (Double), same as `SyncState`. +/// nil means the mtime could not be read: the UI must NOT then claim the +/// `behind` count was freshly verified. +public struct FetchResult: Sendable, Equatable, Decodable { + public let remote: String? + public let lastFetchMs: Double? + + public init(remote: String? = nil, lastFetchMs: Double? = nil) { + self.remote = remote + self.lastFetchMs = lastFetchMs + } + + private enum CodingKeys: String, CodingKey { case remote, lastFetchMs } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + remote = try? container.decode(String.self, forKey: .remote) + lastFetchMs = try? container.decode(Double.self, forKey: .lastFetchMs) + } +} + +/// `POST /projects/worktree` → `{ok,path,branch}` (git's canonical values). +public struct CreateWorktreeResult: Sendable, Equatable, Decodable { + public let path: String? + public let branch: String? + + public init(path: String? = nil, branch: String? = nil) { + self.path = path + self.branch = branch + } + + private enum CodingKeys: String, CodingKey { case path, branch } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = try? container.decode(String.self, forKey: .path) + branch = try? container.decode(String.self, forKey: .branch) + } +} + +/// `DELETE /projects/worktree` → `{ok,path}` (the canonical path removed). +public struct RemoveWorktreeResult: Sendable, Equatable, Decodable { + public let path: String? + + public init(path: String? = nil) { + self.path = path + } + + private enum CodingKeys: String, CodingKey { case path } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = try? container.decode(String.self, forKey: .path) + } +} + +/// `POST /projects/worktree/prune` → `{ok,pruned}`. An empty list means +/// "nothing to prune" — the route is idempotent. +public struct PruneWorktreesResult: Sendable, Equatable, Decodable { + public let pruned: [String] + + public init(pruned: [String] = []) { + self.pruned = pruned + } + + private enum CodingKeys: String, CodingKey { case pruned } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + pruned = (try? container.decode([String].self, forKey: .pruned)) ?? [] + } +} + +// All-defaults fallbacks, declared next to nothing else so they stay one line +// each and cannot drift from the memberwise defaults above. +extension StageResult: GitWritePayload { static var degraded: Self { .init() } } +extension CommitResult: GitWritePayload { static var degraded: Self { .init() } } +extension PushResult: GitWritePayload { static var degraded: Self { .init() } } +extension FetchResult: GitWritePayload { static var degraded: Self { .init() } } +extension CreateWorktreeResult: GitWritePayload { static var degraded: Self { .init() } } +extension RemoveWorktreeResult: GitWritePayload { static var degraded: Self { .init() } } +extension PruneWorktreesResult: GitWritePayload { static var degraded: Self { .init() } } + +/// Shape of a failure body: the worktree routes emit `{error}`, git-ops +/// `{ok:false,error}`. Both carry `error` as a SAFE string. +private struct GitErrorBody: Decodable { + let error: String? +} + +// MARK: - Request bodies (frozen field-for-field against src/server.ts) + +private struct StageBody: Encodable { + let path: String + let files: [String] + let stage: Bool +} + +private struct CommitBody: Encodable { + let path: String + let message: String +} + +/// `{path}` — push · fetch · worktree prune all take exactly this. +private struct RepoPathBody: Encodable { + let path: String +} + +private struct CreateWorktreeBody: Encodable { + let path: String + let branch: String + /// Omitted from the JSON when nil — the server reads a missing `base` as + /// "branch from HEAD", and an explicit `null`/`""` is NOT the same thing. + let base: String? +} + +private struct RemoveWorktreeBody: Encodable { + let path: String + let worktreePath: String + let force: Bool +} + +// MARK: - Routes + +extension Endpoints { + /// Every guarded write here shares the shape: JSON body + `Origin`. What + /// differs is only how a **401** must be read, so that is the parameter. + private static func guardedWriteRoute( + _ method: HTTPMethod, _ path: String, _ body: Body, + unauthorizedPolicy: UnauthorizedPolicy + ) throws -> APIRoute { + APIRoute( + method: method, path: path, originPolicy: .guarded, + body: try JSONEncoder().encode(body), + unauthorizedPolicy: unauthorizedPolicy + ) + } + + /// The four **git-ops** writes — and ONLY these four. `src/http/git-ops.ts:108` + /// classifies a HOST-side git credential failure as 401 ("Push authentication + /// required on the host."), and that classifier is reached only from + /// `stageFiles`/`commit`/`push`/`fetch`. Their 401 must not be mistaken for + /// the access-token gate telling us to enter a token. + private static func gitOpsRoute( + _ method: HTTPMethod, _ path: String, _ body: Body + ) throws -> APIRoute { + try guardedWriteRoute(method, path, body, unauthorizedPolicy: .routeDefined) + } + + /// The worktree trio. Guarded, but the handler (`src/http/worktrees.ts`, + /// routed at src/server.ts:1095-1183) emits **no 401 at all** — 403 for the + /// kill-switch, else 400/404/500. So the only 401 these can ever see is the + /// access-token gate, and they take the DEFAULT reading: pinning them + /// `.routeDefined` made a gated host's 401 surface as a worktree failure + /// instead of the token flow. + private static func worktreeRoute( + _ method: HTTPMethod, _ path: String, _ body: Body + ) throws -> APIRoute { + try guardedWriteRoute(method, path, body, unauthorizedPolicy: .accessTokenGate) + } + + static func gitStage(path: String, files: [String], stage: Bool) throws -> APIRoute { + try gitOpsRoute(.post, "/projects/git/stage", StageBody(path: path, files: files, stage: stage)) + } + + static func gitCommit(path: String, message: String) throws -> APIRoute { + try gitOpsRoute(.post, "/projects/git/commit", CommitBody(path: path, message: message)) + } + + static func gitPush(path: String) throws -> APIRoute { + try gitOpsRoute(.post, "/projects/git/push", RepoPathBody(path: path)) + } + + static func gitFetch(path: String) throws -> APIRoute { + try gitOpsRoute(.post, "/projects/git/fetch", RepoPathBody(path: path)) + } + + static func createWorktree(path: String, branch: String, base: String?) throws -> APIRoute { + try worktreeRoute( + .post, "/projects/worktree", + CreateWorktreeBody(path: path, branch: branch, base: base) + ) + } + + /// DELETE **with** a JSON body — the server reads `express.json` here + /// (src/server.ts:1124), so this is the wire shape, unusual as it looks. + static func removeWorktree(path: String, worktreePath: String, force: Bool) throws -> APIRoute { + try worktreeRoute( + .delete, "/projects/worktree", + RemoveWorktreeBody(path: path, worktreePath: worktreePath, force: force) + ) + } + + static func pruneWorktrees(path: String) throws -> APIRoute { + try worktreeRoute(.post, "/projects/worktree/prune", RepoPathBody(path: path)) + } +} + +// MARK: - Client calls + +extension APIClient { + /// Stage (`stage: true` → `git add`) or unstage (`false` → + /// `git restore --staged`) specific files. The file list is capped and + /// realpath-contained server-side; body limit 64 KB. + public func gitStage( + path: String, files: [String], stage: Bool + ) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, StageResult.self) { + try Endpoints.gitStage(path: path, files: files, stage: stage) + } + } + + /// Commit the STAGED changes. `message` is length-capped server-side and + /// passed as a single `-m ` argv — never a shell string, never a + /// pathspec. An empty message comes back as `.rejected(400, …)`. + public func gitCommit( + path: String, message: String + ) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, CommitResult.self) { + try Endpoints.gitCommit(path: path, message: message) + } + } + + /// Push the current branch to its existing upstream, or `-u + /// ` when it has none. Never a force-push; the remote is derived + /// server-side. Tighter rate limit than stage/commit (network-bound). + public func gitPush(path: String) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, PushResult.self) { + try Endpoints.gitPush(path: path) + } + } + + /// Refresh remote-tracking refs (`refs/remotes` only — never a pull) so the + /// panel's `behind` stops being a stale guess. + public func gitFetch(path: String) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, FetchResult.self) { + try Endpoints.gitFetch(path: path) + } + } + + /// Create a git worktree (`base` nil ⇒ branch from HEAD). The only + /// write-to-disk feature; gated by `WORKTREE_ENABLED` (403 when off). + public func createWorktree( + path: String, branch: String, base: String? + ) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, CreateWorktreeResult.self) { + try Endpoints.createWorktree(path: path, branch: branch, base: base) + } + } + + /// Remove a worktree. Destructive: a dirty worktree needs `force: true` + /// (otherwise the server answers 409 with a safe message), and the main + /// worktree can never be removed (400). + public func removeWorktree( + path: String, worktreePath: String, force: Bool + ) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, RemoveWorktreeResult.self) { + try Endpoints.removeWorktree(path: path, worktreePath: worktreePath, force: force) + } + } + + /// Prune stale worktree registrations (idempotent — an empty `pruned` list + /// means there was nothing to reclaim). + public func pruneWorktrees(path: String) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, PruneWorktreesResult.self) { + try Endpoints.pruneWorktrees(path: path) + } + } + + /// The ONE place a guarded git write is executed and its status mapped, so + /// all seven routes cannot drift apart: empty path rejected before any + /// network I/O → 200 decoded (a garbled payload degrades to defaults rather + /// than throwing — the write already happened) → 429 `.rateLimited` → + /// everything else `.rejected` with the server's safe message. + private func performGitWrite( + path: String, + _ payload: Payload.Type, + route build: () throws -> APIRoute + ) async throws -> GitWriteOutcome { + try Self.requireNonEmptyPath(path) + let (data, response) = try await perform(try build()) + switch response.statusCode { + case HTTPStatus.ok: + let decoded = try? JSONDecoder().decode(Payload.self, from: data) + return .ok(decoded ?? Payload.degraded) + case HTTPStatus.tooManyRequests: + return .rateLimited + default: + return .rejected( + status: response.statusCode, message: Self.decodeGitError(from: data) + ) + } + } + + /// The server's SAFE `error` string, or nil when the body is empty/ + /// unparseable — the client never invents a reason. + private static func decodeGitError(from data: Data) -> String? { + (try? JSONDecoder().decode(GitErrorBody.self, from: data))?.error + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/History.swift b/ios/Packages/APIClient/Sources/APIClient/History.swift new file mode 100644 index 0000000..08508fd --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/History.swift @@ -0,0 +1,90 @@ +import Foundation +import WireProtocol + +// B1 · `GET /sessions` (src/server.ts:479-481) — RO, NO Origin. Response = +// `src/http/history.ts:13-19` `HistorySession[]`: the host's most recently +// modified Claude Code session files, i.e. the `claude --resume` picker's data +// (T-iOS-32). +// +// SECURITY (Sec H3, accepted upstream risk — documented, not introduced here): +// this route is UNAUTHENTICATED on a token-less host and returns session cwds +// plus the first ~120 chars of each first prompt. That matches the app's threat +// model (the whole app hands a shell to anyone who can reach the port; deploy +// behind Tailscale) — but it means the CLIENT must treat every field as INERT +// display text: never autolink, never interpolate into a shell command. + +/// One past Claude Code session (src/http/history.ts:13-19). +/// +/// `id` stays a **String**, not a UUID: it is the `.jsonl` filename stem the +/// host will pass to `claude --resume ` verbatim. Parsing it as a UUID would +/// buy nothing and would DROP any session whose file was renamed — while the id +/// still resumes fine. Required + non-empty, though: an entry without one cannot +/// be resumed, so it is dropped rather than rendered as a dead row. +public struct HistorySession: Sendable, Equatable { + public let id: String + /// The session's working directory (`""` when the jsonl had none). + public let cwd: String + /// Last cwd segment, for display (`"unknown"` server-side when cwd is empty). + public let project: String + /// The jsonl's mtime in ms. `fs.stat().mtimeMs` is FRACTIONAL on a real host + /// (e.g. `1785390645813.5327`), so this is a Double — decoding it as Int + /// would fail and silently drop every entry (src/http/history.ts:105). + public let mtimeMs: Double + /// First user prompt, whitespace-collapsed and truncated to 120 chars + /// server-side. INERT text. + public let preview: String + + public init(id: String, cwd: String = "", project: String = "", mtimeMs: Double, preview: String = "") { + self.id = id + self.cwd = cwd + self.project = project + self.mtimeMs = mtimeMs + self.preview = preview + } +} + +extension HistorySession: Decodable { + private enum CodingKeys: String, CodingKey { + case id, cwd, project, mtimeMs, preview + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawId = try container.decode(String.self, forKey: .id) + guard !rawId.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: .id, in: container, + debugDescription: "empty session id is not resumable" + ) + } + id = rawId + mtimeMs = try container.decode(Double.self, forKey: .mtimeMs) + cwd = (try? container.decode(String.self, forKey: .cwd)) ?? "" + project = (try? container.decode(String.self, forKey: .project)) ?? "" + preview = (try? container.decode(String.self, forKey: .preview)) ?? "" + } +} + +extension Endpoints { + /// `GET /sessions` — RO, no Origin (src/server.ts:479). + static func claudeSessions() -> APIRoute { + APIRoute(method: .get, path: "/sessions", originPolicy: .readOnly, body: nil) + } +} + +extension APIClient { + /// `GET /sessions` — the host's recent Claude Code sessions, newest first + /// (the server sorts by mtime and caps at 50). RO → no Origin. + /// + /// Malformed entries are dropped one by one; a non-array body throws + /// `.invalidResponseBody`. The server answers `[]` (never an error) when + /// `~/.claude/projects` is missing, so an empty list means "no history", + /// not "failed". + public func claudeSessions() async throws -> [HistorySession] { + let (data, response) = try await perform(Endpoints.claudeSessions()) + guard response.statusCode == HTTPStatus.ok else { + throw APIClientError.unexpectedStatus(response.statusCode) + } + return try LossyList.decodeBody(HistorySession.self, from: data) + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/Models.swift b/ios/Packages/APIClient/Sources/APIClient/Models.swift index d21b3f9..0ca48ef 100644 --- a/ios/Packages/APIClient/Sources/APIClient/Models.swift +++ b/ios/Packages/APIClient/Sources/APIClient/Models.swift @@ -187,6 +187,37 @@ public enum APIClientError: Error, Equatable, Sendable { /// 500 from `GET /projects/detail` — the server failed reading the repo /// (src/server.ts:306-309, body `{error}`). case projectDetailUnavailable + /// **401 from the access-token gate** (src/server.ts:459) — the host has + /// `WEBTERM_TOKEN` set and this request carried no (or a wrong) `webterm_auth` + /// cookie. Distinct from a transport failure ON PURPOSE (ios-completion + /// §1.1): the UI must offer "enter the access token", not "retry". + case unauthorized + /// A configured access token violates the frozen shape (16–512 chars of + /// `[A-Za-z0-9._~+/=-]`). Raised BEFORE any network I/O — a shape-invalid + /// token can never match a server token (the server refuses to start with + /// one), and refusing it here is also what makes header injection + /// impossible. + case malformedToken + /// 500 from a read-only git side-channel (`/projects/log`, `/projects/pr`, + /// `/projects/worktree/state`) — the host failed to read git state. + case gitDataUnavailable + /// 404 from `GET /projects/worktree/state` (src/server.ts:549-552) — that + /// path is not a worktree of the repo (removed / never existed). + case worktreeNotFound + /// 503 from `POST /live-sessions/:id/queue` — `QUEUE_ENABLED=0` on the host + /// (src/server.ts:608-611). A configuration state, not a failure to retry. + case queueDisabled + /// 409 from `POST /live-sessions/:id/queue` — the queue is at + /// `queueMaxItems` (src/server.ts:637-641). Never silently dropped. + case queueFull + /// 400 from `POST /live-sessions/:id/queue` — empty text or a malformed + /// session id (src/server.ts:620-627). Also raised client-side for empty + /// text before any network I/O. + case queueTextInvalid + /// 413 from `POST /live-sessions/:id/queue` — text (plus the optional + /// trailing `\r`) exceeds the host's `queueItemMaxBytes` + /// (src/server.ts:632-635). + case queueTextTooLarge /// Any other non-success status code. case unexpectedStatus(Int) @@ -213,6 +244,22 @@ public enum APIClientError: Error, Equatable, Sendable { "项目不存在(路径可能已移动或删除)。" case .projectDetailUnavailable: "读取项目详情失败,请稍后再试。" + case .unauthorized: + "该主机启用了访问令牌,请填写正确的令牌后重试。" + case .malformedToken: + "访问令牌格式不合法:需 16–512 个字符,且只能包含 A-Z a-z 0-9 . _ ~ + / = -。" + case .gitDataUnavailable: + "读取 git 状态失败,请稍后再试。" + case .worktreeNotFound: + "该 worktree 已不存在(可能已被删除或清理)。" + case .queueDisabled: + "该主机关闭了排队注入功能(QUEUE_ENABLED=0)。" + case .queueFull: + "排队已满,请先等前面的任务发出去。" + case .queueTextInvalid: + "要排队的内容为空或会话标识不合法。" + case .queueTextTooLarge: + "内容过长,超出了主机允许的单条上限。" case .unexpectedStatus(let status): "服务器返回了意外状态码 \(status)。" } diff --git a/ios/Packages/APIClient/Sources/APIClient/PairingProbe.swift b/ios/Packages/APIClient/Sources/APIClient/PairingProbe.swift index a9b0727..7b86853 100644 --- a/ios/Packages/APIClient/Sources/APIClient/PairingProbe.swift +++ b/ios/Packages/APIClient/Sources/APIClient/PairingProbe.swift @@ -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, - timeout: Duration? + timeout: Duration?, + accessToken: String? = nil ) async -> Result { 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.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 { 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 { - 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。" +} diff --git a/ios/Packages/APIClient/Sources/APIClient/PrStatus.swift b/ios/Packages/APIClient/Sources/APIClient/PrStatus.swift new file mode 100644 index 0000000..fa17880 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/PrStatus.swift @@ -0,0 +1,155 @@ +import Foundation +import WireProtocol + +// B1 · `GET /projects/pr?path=` (src/server.ts:1058-1076) — RO, NO Origin. +// Response = `src/types.ts:617-648` `PrStatus`. +// +// Out-of-band side-channel: the host spawns its own `gh` CLI, which makes a +// NETWORK call to GitHub with the host's credential. This route NEVER accepts or +// forwards a token, and `GH_ENABLED=0` disables it entirely. A valid git dir +// ALWAYS answers 200: every degrade (gh missing / unauthed / no PR / disabled) +// lives in `availability`, not in the HTTP status — so the client renders one +// chip instead of branching on errors. + +/// Why a `PrStatus` has (or lacks) PR data (src/types.ts:619-625). +/// An unknown/future wire value degrades to `.error`: a new server availability +/// must never make the chip crash or hide the row. +public enum PrAvailability: String, Sendable, Equatable, CaseIterable { + /// A PR exists for the current branch; the sibling fields are populated. + case ok + /// gh works but the branch has no PR (or no remote / default repo). + case noPr = "no-pr" + /// The `gh` binary is not on the host's PATH (ENOENT). + case notInstalled = "not-installed" + /// gh is present but not logged in (needs `gh auth login`). + case unauthenticated + /// `GH_ENABLED=0` — the feature is off and gh is never spawned. + case disabled + /// gh spawned but failed for another reason (timeout, …). Also the + /// unknown/missing fallback. + case error +} + +/// Rolled-up CI check counts from gh's `statusCheckRollup` +/// (src/types.ts:628-633). +public struct PrCheckSummary: Sendable, Equatable { + public let total: Int + public let passing: Int + public let failing: Int + public let pending: Int + + public init(total: Int = 0, passing: Int = 0, failing: Int = 0, pending: Int = 0) { + self.total = total + self.passing = passing + self.failing = failing + self.pending = pending + } +} + +extension PrCheckSummary: Decodable { + private enum CodingKeys: String, CodingKey { + case total, passing, failing, pending + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = LossyNumber.int(in: container, forKey: .total) ?? 0 + passing = LossyNumber.int(in: container, forKey: .passing) ?? 0 + failing = LossyNumber.int(in: container, forKey: .failing) ?? 0 + pending = LossyNumber.int(in: container, forKey: .pending) ?? 0 + } +} + +/// `GET /projects/pr` result (src/types.ts:635-648). Everything except +/// `availability` is present only when `availability == .ok`. +/// +/// `state` / `mergeable` stay RAW inert strings (the server already lower-cases +/// gh's `OPEN`/`MERGEABLE`): they are display text, and inventing an enum here +/// would just add a second place for a future gh value to break. +public struct PrStatus: Sendable, Equatable { + public let availability: PrAvailability + public let number: Int? + public let title: String? + public let url: String? + public let state: String? + public let isDraft: Bool? + public let mergeable: String? + public let headRefName: String? + public let baseRefName: String? + public let checks: PrCheckSummary? + + public init( + availability: PrAvailability = .error, + number: Int? = nil, + title: String? = nil, + url: String? = nil, + state: String? = nil, + isDraft: Bool? = nil, + mergeable: String? = nil, + headRefName: String? = nil, + baseRefName: String? = nil, + checks: PrCheckSummary? = nil + ) { + self.availability = availability + self.number = number + self.title = title + self.url = url + self.state = state + self.isDraft = isDraft + self.mergeable = mergeable + self.headRefName = headRefName + self.baseRefName = baseRefName + self.checks = checks + } +} + +extension PrStatus: Decodable { + private enum CodingKeys: String, CodingKey { + case availability, number, title, url, state, isDraft, mergeable + case headRefName, baseRefName, checks + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawAvailability = try? container.decode(String.self, forKey: .availability) + availability = rawAvailability.flatMap(PrAvailability.init(rawValue:)) ?? .error + number = LossyNumber.int(in: container, forKey: .number) + title = try? container.decode(String.self, forKey: .title) + url = try? container.decode(String.self, forKey: .url) + state = try? container.decode(String.self, forKey: .state) + isDraft = try? container.decode(Bool.self, forKey: .isDraft) + mergeable = try? container.decode(String.self, forKey: .mergeable) + headRefName = try? container.decode(String.self, forKey: .headRefName) + baseRefName = try? container.decode(String.self, forKey: .baseRefName) + checks = try? container.decode(PrCheckSummary.self, forKey: .checks) + } +} + +extension Endpoints { + /// `GET /projects/pr?path=` — RO, no Origin. nil = `path` not encodable. + static func projectPr(path: String) -> APIRoute? { + pathQuery(path).map { query in + APIRoute( + method: .get, path: "/projects/pr", originPolicy: .readOnly, + body: nil, percentEncodedQuery: query + ) + } + } +} + +extension APIClient { + /// `GET /projects/pr?path=` — the current branch's PR + CI rollup. RO → no + /// Origin. 400/404/500 → `.projectPathInvalid` / `.projectNotFound` / + /// `.gitDataUnavailable`; an empty path is rejected before any network I/O. + /// Note that a REACHABLE-but-degraded gh is a 200 with a non-`ok` + /// `availability`, NOT an error. + public func prStatus(path: String) async throws -> PrStatus { + try Self.requireNonEmptyPath(path) + guard let route = Endpoints.projectPr(path: path) else { + throw APIClientError.invalidRequest + } + let (data, response) = try await perform(route) + try Self.requireGitReadOK(response) + return try Self.decodeObject(PrStatus.self, from: data) + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/Projects.swift b/ios/Packages/APIClient/Sources/APIClient/Projects.swift index dd4bb40..6814ea5 100644 --- a/ios/Packages/APIClient/Sources/APIClient/Projects.swift +++ b/ios/Packages/APIClient/Sources/APIClient/Projects.swift @@ -23,10 +23,13 @@ public struct ProjectSessionRef: Sendable, Equatable { public let clientCount: Int public let createdAt: Int public let exited: Bool + /// w6/G7: where the session runs — lets the UI attribute it to a worktree + /// (src/types.ts:353). Optional additive field; nil on pre-w6 servers. + public let cwd: String? public init( id: UUID, title: String?, status: ClaudeStatus, - clientCount: Int, createdAt: Int, exited: Bool + clientCount: Int, createdAt: Int, exited: Bool, cwd: String? = nil ) { self.id = id self.title = title @@ -34,12 +37,13 @@ public struct ProjectSessionRef: Sendable, Equatable { self.clientCount = clientCount self.createdAt = createdAt self.exited = exited + self.cwd = cwd } } extension ProjectSessionRef: Decodable { private enum CodingKeys: String, CodingKey { - case id, title, status, clientCount, createdAt, exited + case id, title, status, clientCount, createdAt, exited, cwd } public init(from decoder: any Decoder) throws { @@ -53,6 +57,7 @@ extension ProjectSessionRef: Decodable { let rawStatus = try? container.decode(String.self, forKey: .status) status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown title = try? container.decode(String.self, forKey: .title) + cwd = try? container.decode(String.self, forKey: .cwd) } } @@ -69,12 +74,22 @@ public struct ProjectInfo: Sendable, Equatable { /// Uncommitted changes; only present when the server runs the dirty check. public let dirty: Bool? /// Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. + /// Derived from `fs.stat().mtimeMs`, so it arrives FRACTIONAL on a real host + /// and is decoded via `LossyNumber.int` (see there for why a plain + /// `decode(Int.self)` silently nils this field). public let lastActiveMs: Int? + /// W3 sync chip — commits on HEAD not on `@{u}` (src/types.ts:366). + public let ahead: Int? + /// W3 sync chip — commits on `@{u}` not on HEAD (src/types.ts:367). + public let behind: Int? + /// HEAD commit time in ms (`git log -1 --format=%ct * 1000`, an integer). + public let lastCommitMs: Int? public let sessions: [ProjectSessionRef] public init( name: String, path: String, isGit: Bool, branch: String?, - dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef] + dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef], + ahead: Int? = nil, behind: Int? = nil, lastCommitMs: Int? = nil ) { self.name = name self.path = path @@ -82,6 +97,9 @@ public struct ProjectInfo: Sendable, Equatable { self.branch = branch self.dirty = dirty self.lastActiveMs = lastActiveMs + self.ahead = ahead + self.behind = behind + self.lastCommitMs = lastCommitMs self.sessions = sessions } } @@ -89,6 +107,7 @@ public struct ProjectInfo: Sendable, Equatable { extension ProjectInfo: Decodable { private enum CodingKeys: String, CodingKey { case name, path, isGit, branch, dirty, lastActiveMs, sessions + case ahead, behind, lastCommitMs } public init(from decoder: any Decoder) throws { @@ -98,7 +117,10 @@ extension ProjectInfo: Decodable { isGit = try container.decode(Bool.self, forKey: .isGit) branch = try? container.decode(String.self, forKey: .branch) dirty = try? container.decode(Bool.self, forKey: .dirty) - lastActiveMs = try? container.decode(Int.self, forKey: .lastActiveMs) + lastActiveMs = LossyNumber.int(in: container, forKey: .lastActiveMs) + ahead = LossyNumber.int(in: container, forKey: .ahead) + behind = LossyNumber.int(in: container, forKey: .behind) + lastCommitMs = LossyNumber.int(in: container, forKey: .lastCommitMs) sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions) } @@ -106,10 +128,7 @@ extension ProjectInfo: Decodable { /// `invalidResponseBody`; malformed elements dropped one by one (same /// pattern as `LiveSessionInfo.decodeList`). static func decodeList(from data: Data) throws -> [ProjectInfo] { - guard let entries = try? JSONDecoder().decode([LossyBox].self, from: data) else { - throw APIClientError.invalidResponseBody - } - return entries.compactMap(\.value) + try LossyList.decodeBody(ProjectInfo.self, from: data) } } @@ -164,6 +183,11 @@ public struct ProjectDetail: Sendable, Equatable { public let isGit: Bool public let branch: String? public let dirty: Bool? + /// w6/G1: `git status --porcelain` line count, same gate as `dirty` + /// (src/types.ts:388). nil ⇒ the host has the dirty check off, NOT "clean". + public let dirtyCount: Int? + /// w6/G1: upstream sync state; nil for a non-git dir (src/types.ts:389). + public let sync: SyncState? public let worktrees: [WorktreeInfo] public let sessions: [ProjectSessionRef] public let hasClaudeMd: Bool @@ -173,13 +197,16 @@ public struct ProjectDetail: Sendable, Equatable { public init( name: String, path: String, isGit: Bool, branch: String?, dirty: Bool?, worktrees: [WorktreeInfo], sessions: [ProjectSessionRef], - hasClaudeMd: Bool, claudeMd: String? + hasClaudeMd: Bool, claudeMd: String?, + dirtyCount: Int? = nil, sync: SyncState? = nil ) { self.name = name self.path = path self.isGit = isGit self.branch = branch self.dirty = dirty + self.dirtyCount = dirtyCount + self.sync = sync self.worktrees = worktrees self.sessions = sessions self.hasClaudeMd = hasClaudeMd @@ -190,6 +217,7 @@ public struct ProjectDetail: Sendable, Equatable { extension ProjectDetail: Decodable { private enum CodingKeys: String, CodingKey { case name, path, isGit, branch, dirty, worktrees, sessions, hasClaudeMd, claudeMd + case dirtyCount, sync } public init(from decoder: any Decoder) throws { @@ -199,6 +227,8 @@ extension ProjectDetail: Decodable { isGit = try container.decode(Bool.self, forKey: .isGit) branch = try? container.decode(String.self, forKey: .branch) dirty = try? container.decode(Bool.self, forKey: .dirty) + dirtyCount = LossyNumber.int(in: container, forKey: .dirtyCount) + sync = try? container.decode(SyncState.self, forKey: .sync) hasClaudeMd = (try? container.decode(Bool.self, forKey: .hasClaudeMd)) ?? false claudeMd = try? container.decode(String.self, forKey: .claudeMd) worktrees = LossyList.decode(WorktreeInfo.self, in: container, forKey: .worktrees) @@ -206,31 +236,6 @@ extension ProjectDetail: Decodable { } } -// MARK: - Lossy decoding helpers (shared per-element tolerance) - -/// Per-element tolerance shim: a malformed element becomes nil instead of -/// failing the whole array (same pattern as WireProtocol's TimelineEvent). -struct LossyBox: Decodable { - let value: Wrapped? - - init(from decoder: any Decoder) { - value = try? Wrapped(from: decoder) - } -} - -enum LossyList { - /// Decode `[Element]` at `key`, dropping malformed elements; a missing or - /// wrong-typed array degrades to `[]`. - static func decode( - _ type: Element.Type, - in container: KeyedDecodingContainer, - forKey key: Key - ) -> [Element] { - let boxes = (try? container.decode([LossyBox].self, forKey: key)) ?? [] - return boxes.compactMap(\.value) - } -} - // MARK: - Routes + client calls extension Endpoints { @@ -240,17 +245,17 @@ extension Endpoints { } /// `GET /projects/detail?path=` — RO, no Origin (src/server.ts:293-310). - /// The ONE place `path` gets percent-encoded (strict unreserved-only set — - /// see `unreservedCharacters` for why `.urlQueryAllowed` is not enough). - /// nil = the path could not be encoded (surfaced as `.invalidRequest`). + /// `path` is encoded by the shared `pathQuery` choke point (strict + /// unreserved-only set — see `unreservedCharacters` for why + /// `.urlQueryAllowed` is not enough). nil = the path could not be encoded + /// (surfaced as `.invalidRequest`). static func projectDetail(path: String) -> APIRoute? { - guard let encoded = path.addingPercentEncoding( - withAllowedCharacters: unreservedCharacters - ) else { return nil } - return APIRoute( - method: .get, path: "/projects/detail", originPolicy: .readOnly, - body: nil, percentEncodedQuery: "path=\(encoded)" - ) + pathQuery(path).map { query in + APIRoute( + method: .get, path: "/projects/detail", originPolicy: .readOnly, + body: nil, percentEncodedQuery: query + ) + } } } diff --git a/ios/Packages/APIClient/Sources/APIClient/Tolerance.swift b/ios/Packages/APIClient/Sources/APIClient/Tolerance.swift new file mode 100644 index 0000000..2dc3cb6 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/Tolerance.swift @@ -0,0 +1,64 @@ +import Foundation + +// Shared tolerant-decoding helpers for the UNTRUSTED server boundary (plan §4). +// ONE home for all of them: unknown fields ignored, malformed elements dropped +// one by one, wrong-typed optionals degraded to nil — never a crash, never a +// whole-list failure because of a single bad entry. + +/// Per-element tolerance shim: a malformed element becomes nil instead of +/// failing the whole array (same pattern as WireProtocol's TimelineEvent). +struct LossyBox: Decodable { + let value: Wrapped? + + init(from decoder: any Decoder) { + value = try? Wrapped(from: decoder) + } +} + +enum LossyList { + /// Decode `[Element]` at `key`, dropping malformed elements; a missing or + /// wrong-typed array degrades to `[]`. + static func decode( + _ type: Element.Type, + in container: KeyedDecodingContainer, + forKey key: Key + ) -> [Element] { + let boxes = (try? container.decode([LossyBox].self, forKey: key)) ?? [] + return boxes.compactMap(\.value) + } + + /// Decode a whole top-level `[Element]` body, dropping malformed elements. + /// A non-array top level throws `.invalidResponseBody` — that is the + /// "this port speaks HTTP but is not web-terminal" signal, not a degrade. + static func decodeBody( + _ type: Element.Type, from data: Data + ) throws -> [Element] { + guard let boxes = try? JSONDecoder().decode([LossyBox].self, from: data) else { + throw APIClientError.invalidResponseBody + } + return boxes.compactMap(\.value) + } +} + +enum LossyNumber { + /// Decode an integer-valued field that the server may serialize as a + /// FRACTIONAL JSON number. + /// + /// This is not paranoia: every `*Ms` field derived from `fs.stat().mtimeMs` + /// (`ProjectInfo.lastActiveMs` — src/http/projects.ts:171, + /// `HistorySession.mtimeMs` — src/http/history.ts:105, + /// `SyncState.lastFetchMs` — src/http/projects.ts:170) carries sub-millisecond + /// precision on APFS: a real body reads `1785390645813.5327`. A plain + /// `decode(Int.self)` FAILS on that, so a bare `try?` would silently null the + /// field (or drop the whole entry) against every real server. + static func int( + in container: KeyedDecodingContainer, forKey key: Key + ) -> Int? { + if let exact = try? container.decode(Int.self, forKey: key) { return exact } + guard let fractional = try? container.decode(Double.self, forKey: key), + fractional.isFinite, + fractional >= Double(Int.min), fractional <= Double(Int.max) + else { return nil } + return Int(fractional) + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift b/ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift new file mode 100644 index 0000000..2433cdb --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift @@ -0,0 +1,120 @@ +import Foundation +import WireProtocol + +// B1 · `GET /projects/worktree/state?path=` (src/server.ts:542-562) — RO, NO +// Origin. Response = `src/types.ts:392-408` (`SyncState` / `WorktreeState`), +// fetched lazily per worktree row (w6/G7) because listing N worktrees eagerly +// would spend a git spawn per row on data nothing renders. + +/// Upstream sync state for one repo or worktree (src/types.ts:392-398). +/// +/// EVERY field degrades independently, and each absence is a NORMAL state: no +/// upstream, detached HEAD, empty repo and never-fetched all leave their field +/// nil. Two rules the UI must honour (they are the reason this type is all +/// optionals instead of zeros): +/// - `ahead` is always trustworthy (local refs only); +/// - `behind` is only as fresh as `lastFetchMs`, because `@{u}` is a locally +/// cached remote ref that only a fetch moves — a stale `behind: 0` must NEVER +/// render as "in sync"; +/// - `upstream == nil` means "nothing to compare against", which is NOT +/// "nothing to push". +public struct SyncState: Sendable, Equatable { + /// e.g. `origin/develop`; nil ⇒ the branch tracks nothing. + public let upstream: String? + /// Commits on HEAD not on `@{u}`. + public let ahead: Int? + /// Commits on `@{u}` not on HEAD — trust only with a fresh `lastFetchMs`. + public let behind: Int? + /// `FETCH_HEAD` mtime (ms); nil ⇒ never fetched. Comes from + /// `fs.stat().mtimeMs`, so it is FRACTIONAL on a real host — hence Double, + /// not Int (src/http/projects.ts readLastFetchMs). + public let lastFetchMs: Double? + /// HEAD is not on a branch ⇒ no branch, no ahead/behind. + public let detached: Bool? + + public init( + upstream: String? = nil, ahead: Int? = nil, behind: Int? = nil, + lastFetchMs: Double? = nil, detached: Bool? = nil + ) { + self.upstream = upstream + self.ahead = ahead + self.behind = behind + self.lastFetchMs = lastFetchMs + self.detached = detached + } +} + +extension SyncState: Decodable { + private enum CodingKeys: String, CodingKey { + case upstream, ahead, behind, lastFetchMs, detached + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + upstream = try? container.decode(String.self, forKey: .upstream) + ahead = LossyNumber.int(in: container, forKey: .ahead) + behind = LossyNumber.int(in: container, forKey: .behind) + lastFetchMs = try? container.decode(Double.self, forKey: .lastFetchMs) + detached = try? container.decode(Bool.self, forKey: .detached) + } +} + +/// The git state of ONE worktree (src/types.ts:402-408). +public struct WorktreeState: Sendable, Equatable { + public let path: String + public let branch: String? + public let sync: SyncState? + /// `git status --porcelain` line count; nil ⇒ the host has the dirty check + /// disabled (`PROJECT_DIRTY_CHECK=0`), which is NOT "clean". + public let dirtyCount: Int? + + public init(path: String, branch: String? = nil, sync: SyncState? = nil, dirtyCount: Int? = nil) { + self.path = path + self.branch = branch + self.sync = sync + self.dirtyCount = dirtyCount + } +} + +extension WorktreeState: Decodable { + private enum CodingKeys: String, CodingKey { + case path, branch, sync, dirtyCount + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = try container.decode(String.self, forKey: .path) + branch = try? container.decode(String.self, forKey: .branch) + sync = try? container.decode(SyncState.self, forKey: .sync) + dirtyCount = LossyNumber.int(in: container, forKey: .dirtyCount) + } +} + +extension Endpoints { + /// `GET /projects/worktree/state?path=` — RO, no Origin. + static func worktreeState(path: String) -> APIRoute? { + pathQuery(path).map { query in + APIRoute( + method: .get, path: "/projects/worktree/state", originPolicy: .readOnly, + body: nil, percentEncodedQuery: query + ) + } + } +} + +extension APIClient { + /// `GET /projects/worktree/state?path=` — branch + sync + dirty count for + /// ONE worktree row. RO → no Origin. 400 → `.projectPathInvalid`, + /// **404 → `.worktreeNotFound`** (this route's 404 means "not a worktree of + /// this repo", not "no such project"), 500 → `.gitDataUnavailable`. + /// An empty path is rejected before any network I/O. + public func worktreeState(path: String) async throws -> WorktreeState { + try Self.requireNonEmptyPath(path) + guard let route = Endpoints.worktreeState(path: path) else { + throw APIClientError.invalidRequest + } + let (data, response) = try await perform(route) + try Self.requireGitReadOK(response, notFound: .worktreeNotFound) + return try Self.decodeObject(WorktreeState.self, from: data) + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/AccessTokenTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/AccessTokenTests.swift new file mode 100644 index 0000000..975fa17 --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/AccessTokenTests.swift @@ -0,0 +1,314 @@ +import Foundation +import Testing +import TestSupport +import WireProtocol +import APIClient + +/// B1 · 访问令牌(`WEBTERM_TOKEN`)—— ios-completion §1.1 冻结契约。 +/// +/// 服务端事实(`src/http/auth.ts` + `src/server.ts:393-460`): +/// - `POST /auth`,body `{"token":""}`,`Content-Type: application/json`; +/// - **`Accept` 必须不含 `text/html`** —— 含则被当成表单走 302,而不是 204/401; +/// - 204 **有** `Set-Cookie: webterm_auth=…` ⇒ 令牌正确; +/// - 204 **无** `Set-Cookie` ⇒ 该服务器**没开鉴权**(**不得**据此认为"已认证"); +/// - 401 ⇒ 令牌错;429 ⇒ 限流(10 次/分/IP)。 +/// +/// 原生客户端**自己知道令牌**,因此手写 `Cookie: webterm_auth=`,不解析 +/// `Set-Cookie`、不依赖系统 cookie jar。`Cookie` 与 `Origin` **正交**:令牌 +/// **不替代** Origin 检查,两者都要带(`server.ts` 先 Origin 再 cookie)。 +/// +/// 安全:令牌**绝不进 URL query**(`?token=` bootstrap 只给浏览器用)、绝不进日志。 +struct AccessTokenTests { + private static let base = "http://192.168.1.5:3000" + private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b" + /// 合法形状:16–512 个 `[A-Za-z0-9._~+/=-]` 字符(CLAUDE.md / config 校验)。 + private static let token = "s3cret-token_value.~+/=" + private static let setCookieValue = + "webterm_auth=\(token); Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict" + + private func makeEndpoint(_ base: String = AccessTokenTests.base) throws -> HostEndpoint { + let url = try #require(URL(string: base)) + return try #require(HostEndpoint(baseURL: url)) + } + + private func routeURL(_ path: String) throws -> URL { + try #require(URL(string: Self.base + path)) + } + + private func makeClient( + token: String?, http: FakeHTTPTransport + ) throws -> APIClient { + APIClient(endpoint: try makeEndpoint(), http: http, accessToken: token) + } + + // MARK: - POST /auth 探针:请求形状 + + @Test("POST /auth:body 恰为 {\"token\":…}、Content-Type=JSON、Accept 不含 text/html、令牌绝不进 URL") + func authProbeRequestShapeIsFrozen() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess( + method: "POST", url: try routeURL("/auth"), status: 204, + headers: ["Set-Cookie": Self.setCookieValue] + ) + + // Act + _ = try await client.probeAccessToken(Self.token) + + // Assert + let request = try #require(await http.recordedRequests.first) + #expect(request.httpMethod == "POST") + #expect(request.url == (try routeURL("/auth"))) + #expect(request.url?.query == nil) // 令牌绝不进 URL query + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + // /auth 是变更型 POST(签发 cookie)⇒ 照 Origin-iff-G 规则带 Origin(与 Android 一致) + #expect(request.value(forHTTPHeaderField: "Origin") == (try makeEndpoint()).originHeader) + let accept = try #require(request.value(forHTTPHeaderField: "Accept")) + #expect(!accept.contains("text/html")) // 含 text/html 会被当成表单走 302 + let body = try #require(request.httpBody) + let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(Set(object.keys) == Set(["token"])) + #expect(object["token"] as? String == Self.token) + } + + @Test("每个请求都带 Accept: application/json —— 未认证时服务器才回 401 JSON 而不是 302 登录页") + func everyRequestAcceptsJSONSoTheGateAnswers401NotARedirect() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: Self.token, http: http) + await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8)) + await http.queueSuccess( + method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204 + ) + + // Act + _ = try await client.liveSessions() + try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString))) + + // Assert + let requests = await http.recordedRequests + #expect(requests.count == 2) + for request in requests { + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + } + } + + // MARK: - POST /auth 探针:四种结局互不混淆 + + @Test("204 + Set-Cookie ⇒ .valid(令牌正确,可保存)") + func probe204WithSetCookieMeansTokenValid() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess( + method: "POST", url: try routeURL("/auth"), status: 204, + headers: ["Set-Cookie": Self.setCookieValue] + ) + + // Act + let result = try await client.probeAccessToken(Self.token) + + // Assert + #expect(result == .valid) + } + + @Test("204 但无 Set-Cookie ⇒ .authDisabled(服务器没开鉴权),绝不报成 .valid") + func probe204WithoutSetCookieMeansAuthDisabledNotAuthenticated() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 204) + + // Act + let result = try await client.probeAccessToken(Self.token) + + // Assert + #expect(result == .authDisabled) + #expect(result != .valid) // 冻结契约:不得据此认为"已认证" + } + + @Test("401 ⇒ .invalidToken(不是 .unauthorized —— /auth 自己的 401 是路由语义)") + func probe401MeansInvalidTokenNotGateUnauthorized() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess( + method: "POST", url: try routeURL("/auth"), status: 401, + body: Data(#"{"error":"invalid token"}"#.utf8) + ) + + // Act + let result = try await client.probeAccessToken(Self.token) + + // Assert + #expect(result == .invalidToken) + } + + @Test("429 ⇒ .rateLimited(10 次/分/IP,src/server.ts:399-402)") + func probe429MeansRateLimited() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 429) + + // Act + let result = try await client.probeAccessToken(Self.token) + + // Assert + #expect(result == .rateLimited) + } + + @Test("其他状态码(500) ⇒ unexpectedStatus,不猜测") + func probeUnexpectedStatusThrows() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 500) + + // Act + Assert + await #expect(throws: APIClientError.unexpectedStatus(500)) { + _ = try await client.probeAccessToken(Self.token) + } + } + + @Test("形状非法的令牌(过短/越界字符/过长)联网前就拒:malformedToken,零请求") + func malformedTokenIsRejectedBeforeAnyNetworkIO() async throws { + // Arrange — 服务器启动时就校验 16–512 与字符集,故形状非法者不可能是正确令牌 + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + let malformed = [ + "short", // < 16 + String(repeating: "a", count: 513), // > 512 + "has space in it x", // 越界字符:空格 + "has\r\nCRLF-injection-x", // 越界字符:CRLF(头注入) + "中文令牌中文令牌中文令牌中文令牌", // 越界字符:非 ASCII + ] + + // Act + Assert + for candidate in malformed { + await #expect(throws: APIClientError.malformedToken) { + _ = try await client.probeAccessToken(candidate) + } + } + #expect(await http.recordedRequests.isEmpty) + } + + // MARK: - Cookie 与 Origin 正交 + + @Test("Cookie iff 配置了令牌:RO 带 Cookie 不带 Origin;G 带 Cookie **并且**带 Origin") + func cookieIsOrthogonalToTheOriginRule() async throws { + // Arrange + let http = FakeHTTPTransport() + let endpoint = try makeEndpoint() + let client = APIClient(endpoint: endpoint, http: http, accessToken: Self.token) + let id = try #require(UUID(uuidString: Self.sessionIdString)) + await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8)) + await http.queueSuccess( + method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204 + ) + + // Act + _ = try await client.liveSessions() + try await client.killSession(id: id) + + // Assert + let requests = await http.recordedRequests + let readOnly = try #require(requests.first) + let guarded = try #require(requests.last) + #expect(readOnly.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)") + #expect(readOnly.value(forHTTPHeaderField: "Origin") == nil) // RO 仍绝不带 Origin + #expect(guarded.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)") + #expect(guarded.value(forHTTPHeaderField: "Origin") == endpoint.originHeader) // 令牌不替代 Origin + } + + @Test("未配置令牌 ⇒ 完全不带 Cookie 头(LAN 零配置行为不变)") + func noTokenConfiguredMeansNoCookieHeaderAtAll() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8)) + + // Act + _ = try await client.liveSessions() + + // Assert + let request = try #require(await http.recordedRequests.first) + #expect(request.value(forHTTPHeaderField: "Cookie") == nil) + } + + @Test("配置了形状非法的令牌 ⇒ 任何调用联网前抛 malformedToken(绝不静默发无认证请求)") + func malformedConfiguredTokenFailsFastOnEveryCall() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: "bad token", http: http) + + // Act + Assert + await #expect(throws: APIClientError.malformedToken) { + _ = try await client.liveSessions() + } + #expect(await http.recordedRequests.isEmpty) + } + + // MARK: - 401 语义:类型化 .unauthorized,可与网络错区分 + + @Test("普通 RO 端点收 401 ⇒ 类型化 .unauthorized(不是 unexpectedStatus/网络错)") + func gate401OnReadOnlyEndpointSurfacesAsUnauthorized() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess( + url: try routeURL("/live-sessions"), status: 401, + body: Data(#"{"error":"authentication required"}"#.utf8) + ) + + // Act + Assert + await #expect(throws: APIClientError.unauthorized) { + _ = try await client.liveSessions() + } + } + + @Test("普通 G 端点收 401 ⇒ 同样是 .unauthorized,且话术非空(UI 引导补令牌)") + func gate401OnGuardedEndpointSurfacesAsUnauthorized() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: Self.token, http: http) + await http.queueSuccess( + method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 401 + ) + + // Act + Assert + await #expect(throws: APIClientError.unauthorized) { + try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString))) + } + #expect(!APIClientError.unauthorized.message.isEmpty) + } + + // MARK: - 令牌零泄漏 + + @Test("APIClient 的 description/debugDescription 不含令牌明文(零日志泄漏)") + func clientDescriptionNeverLeaksTheToken() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: Self.token, http: http) + + // Act + let rendered = "\(client)" + String(reflecting: client) + + // Assert + #expect(!rendered.contains(Self.token)) + #expect(rendered.contains("redacted")) + } + + @Test("hasAccessToken 只暴露有无,不暴露值") + func hasAccessTokenExposesPresenceOnly() async throws { + // Arrange + Act + let http = FakeHTTPTransport() + let withToken = try makeClient(token: Self.token, http: http) + let without = try makeClient(token: nil, http: http) + + // Assert + #expect(withToken.hasAccessToken) + #expect(!without.hasAccessToken) + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/GitPanelReadTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/GitPanelReadTests.swift new file mode 100644 index 0000000..30890ec --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/GitPanelReadTests.swift @@ -0,0 +1,435 @@ +import Foundation +import Testing +import TestSupport +import WireProtocol +import APIClient + +/// B1 · git 面板**只读**端点(ios-completion §1.2;真源 = `src/`): +/// - `GET /projects/log?path=[&n=]`(`src/server.ts:1030-1056`)→ `GitLogResult` +/// (`src/types.ts:757-772`,含 w6/G4 的 `unpushed`/`upstream`); +/// - `GET /projects/pr?path=`(`src/server.ts:1058-1076`)→ `PrStatus` +/// (`src/types.ts:617-648`);有效 git 目录一律 200,降级写在 body 的 availability 里; +/// - `GET /projects/worktree/state?path=`(`src/server.ts:542-562`)→ `WorktreeState` +/// (`src/types.ts:392-408`,`sync` 各字段独立降级); +/// - `GET /sessions`(`src/server.ts:479-481`)→ `[HistorySession]` +/// (`src/http/history.ts:13-19`,`claude --resume` 历史)。 +/// +/// 三条 `/projects/*` 都做同样的三叉校验:`path` 缺失 → 400、非 git 目录 → 404、 +/// 读失败 → 500。全部 RO ⇒ **绝不带 Origin**。 +struct GitPanelReadTests { + private static let base = "http://192.168.1.5:3000" + private static let repoPath = "/Users/dev/web-terminal" + private static let encodedRepoPath = "%2FUsers%2Fdev%2Fweb-terminal" + + private struct Fixture { + let http: FakeHTTPTransport + let client: APIClient + } + + private func makeFixture() throws -> Fixture { + let baseURL = try #require(URL(string: Self.base)) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let http = FakeHTTPTransport() + return Fixture(http: http, client: APIClient(endpoint: endpoint, http: http)) + } + + private func routeURL(_ pathAndQuery: String) throws -> URL { + try #require(URL(string: Self.base + pathAndQuery)) + } + + // MARK: - Origin iff-G(RO 侧:四个端点一律不带 Origin) + + @Test("Origin iff-G(RO 侧):log/pr/worktree-state/sessions 四个端点均为 GET 且不带 Origin") + func readOnlyGitEndpointsNeverCarryOrigin() async throws { + // Arrange + let fixture = try makeFixture() + let query = "?path=\(Self.encodedRepoPath)" + await fixture.http.queueSuccess( + url: try routeURL("/projects/log\(query)"), body: Data(#"{"commits":[]}"#.utf8) + ) + await fixture.http.queueSuccess( + url: try routeURL("/projects/pr\(query)"), body: Data(#"{"availability":"no-pr"}"#.utf8) + ) + await fixture.http.queueSuccess( + url: try routeURL("/projects/worktree/state\(query)"), + body: Data(#"{"path":"\#(Self.repoPath)"}"#.utf8) + ) + await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data("[]".utf8)) + + // Act + _ = try await fixture.client.gitLog(path: Self.repoPath) + _ = try await fixture.client.prStatus(path: Self.repoPath) + _ = try await fixture.client.worktreeState(path: Self.repoPath) + _ = try await fixture.client.claudeSessions() + + // Assert + let requests = await fixture.http.recordedRequests + #expect(requests.count == 4) + for request in requests { + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Origin") == nil) + } + } + + // MARK: - /projects/log + + @Test("log 的 path 严格 percent-encode(单点);n 缺省则不带 n 参数") + func logPathIsStrictlyEncodedAndNilCountOmitsTheParam() async throws { + // Arrange + let fixture = try makeFixture() + let raw = "/Users/dev/my proj+α" + let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1" + let expected = try routeURL("/projects/log?path=\(encoded)") + await fixture.http.queueSuccess(url: expected, body: Data(#"{"commits":[]}"#.utf8)) + + // Act + _ = try await fixture.client.gitLog(path: raw, n: nil) + + // Assert + #expect(await fixture.http.recordedRequests.first?.url == expected) + } + + @Test("log 的 n 客户端夹到 [1,50](镜像 src/http/git-log.ts GIT_LOG_MAX,服务端仍会再夹一次)") + func logCountIsClampedClientSide() async throws { + // Arrange + let fixture = try makeFixture() + let body = Data(#"{"commits":[]}"#.utf8) + let cases: [(Int, Int)] = [(0, 1), (-7, 1), (10, 10), (999, 50)] + for (_, clamped) in cases { + await fixture.http.queueSuccess( + url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)&n=\(clamped)"), + body: body + ) + } + + // Act + for (requested, _) in cases { + _ = try await fixture.client.gitLog(path: Self.repoPath, n: requested) + } + + // Assert — FakeHTTPTransport 按整 URL 精确匹配,走到这里即夹取正确 + let queries = await fixture.http.recordedRequests.compactMap(\.url?.query) + #expect(queries == cases.map { "path=\(Self.encodedRepoPath)&n=\($0.1)" }) + } + + @Test("GitLogResult 全字段解码(hash/at/subject/unpushed + truncated + upstream,w6/G4)") + func gitLogDecodesFullSample() async throws { + // Arrange + let fixture = try makeFixture() + let body = """ + {"commits":[{"hash":"abc1234","at":1720000000000,"subject":"feat: x","unpushed":true},\ + {"hash":"def5678","at":1719990000000,"subject":"fix: y"}],\ + "truncated":true,"upstream":"origin/develop"} + """ + await fixture.http.queueSuccess( + url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8) + ) + + // Act + let result = try await fixture.client.gitLog(path: Self.repoPath) + + // Assert + #expect(result.commits.count == 2) + #expect(result.truncated) + #expect(result.upstream == "origin/develop") + let first = try #require(result.commits.first) + #expect(first.hash == "abc1234") + #expect(first.at == 1_720_000_000_000) + #expect(first.subject == "feat: x") + #expect(first.unpushed == true) + #expect(result.commits.last?.unpushed == nil) // 缺省 ⇒ nil(不是 false) + } + + @Test("log 容忍:畸形 commit 逐条丢弃、subject 缺省为空、truncated 缺省 false、未知字段忽略") + func gitLogToleratesDegradedShapes() async throws { + // Arrange + let fixture = try makeFixture() + let body = """ + {"commits":[{"hash":"ok1","at":1,"subject":"s"},42,{"at":2},{"hash":"ok2","at":3},\ + {"hash":"bad","at":"nope"}],"futureField":{"x":1}} + """ + await fixture.http.queueSuccess( + url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8) + ) + + // Act + let result = try await fixture.client.gitLog(path: Self.repoPath) + + // Assert + #expect(result.commits.map(\.hash) == ["ok1", "ok2"]) + #expect(result.commits.last?.subject == "") + #expect(!result.truncated) + #expect(result.upstream == nil) + } + + @Test("log 400/404/500 → projectPathInvalid/projectNotFound/gitDataUnavailable;空 path 联网前拒") + func gitLogMapsErrorStatuses() async throws { + // Arrange + let fixture = try makeFixture() + let url = try routeURL("/projects/log?path=\(Self.encodedRepoPath)") + for status in [400, 404, 500] { + await fixture.http.queueSuccess( + url: url, status: status, body: Data(#"{"error":"nope"}"#.utf8) + ) + } + + // Act + Assert + let expected: [APIClientError] = [ + .projectPathInvalid, .projectNotFound, .gitDataUnavailable, + ] + for error in expected { + await #expect(throws: error) { + _ = try await fixture.client.gitLog(path: Self.repoPath) + } + #expect(!error.message.isEmpty) + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.gitLog(path: "") + } + #expect(await fixture.http.recordedRequests.count == 3) // 空 path 未联网 + } + + @Test("log 200 但 body 非对象 → invalidResponseBody") + func gitLogRejectsNonObjectBody() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess( + url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), + body: Data("[1,2]".utf8) + ) + + // Act + Assert + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await fixture.client.gitLog(path: Self.repoPath) + } + } + + // MARK: - /projects/pr + + @Test("PrStatus 全字段解码(availability=ok + checks 嵌套计数)") + func prStatusDecodesFullSample() async throws { + // Arrange + let fixture = try makeFixture() + let body = """ + {"availability":"ok","number":42,"title":"feat: x","url":"https://github.com/a/b/pull/42",\ + "state":"open","isDraft":false,"mergeable":"mergeable","headRefName":"feat/x",\ + "baseRefName":"develop","checks":{"total":5,"passing":4,"failing":0,"pending":1}} + """ + await fixture.http.queueSuccess( + url: try routeURL("/projects/pr?path=\(Self.encodedRepoPath)"), body: Data(body.utf8) + ) + + // Act + let pr = try await fixture.client.prStatus(path: Self.repoPath) + + // Assert + #expect(pr.availability == .ok) + #expect(pr.number == 42) + #expect(pr.state == "open") + #expect(pr.isDraft == false) + #expect(pr.mergeable == "mergeable") + #expect(pr.headRefName == "feat/x") + #expect(pr.baseRefName == "develop") + #expect(pr.checks == PrCheckSummary(total: 5, passing: 4, failing: 0, pending: 1)) + } + + @Test("PrStatus 降级:未知/缺失 availability → .error;各降级值可解;字段缺省 → nil") + func prStatusDegradesUnknownAvailability() async throws { + // Arrange + let fixture = try makeFixture() + let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)") + let bodies = [ + #"{"availability":"quantum-ci"}"#, // 未来值 → .error + #"{}"#, // 缺失 → .error + #"{"availability":"not-installed"}"#, + #"{"availability":"unauthenticated"}"#, + #"{"availability":"disabled"}"#, + #"{"availability":"no-pr"}"#, + ] + for body in bodies { + await fixture.http.queueSuccess(url: url, body: Data(body.utf8)) + } + + // Act + var seen: [PrAvailability] = [] + for _ in bodies { + seen.append(try await fixture.client.prStatus(path: Self.repoPath).availability) + } + + // Assert + #expect(seen == [.error, .error, .notInstalled, .unauthenticated, .disabled, .noPr]) + // 非 ok 时兄弟字段一律缺省 → nil(绝不编造 PR 号) + await fixture.http.queueSuccess(url: url, body: Data(#"{"availability":"no-pr"}"#.utf8)) + let degraded = try await fixture.client.prStatus(path: Self.repoPath) + #expect(degraded.number == nil) + #expect(degraded.checks == nil) + } + + @Test("pr 400/404/500 → projectPathInvalid/projectNotFound/gitDataUnavailable;空 path 联网前拒") + func prStatusMapsErrorStatuses() async throws { + // Arrange + let fixture = try makeFixture() + let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)") + for status in [400, 404, 500] { + await fixture.http.queueSuccess(url: url, status: status) + } + + // Act + Assert + for error in [APIClientError.projectPathInvalid, .projectNotFound, .gitDataUnavailable] { + await #expect(throws: error) { + _ = try await fixture.client.prStatus(path: Self.repoPath) + } + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.prStatus(path: "") + } + } + + // MARK: - /projects/worktree/state + + @Test("WorktreeState 全字段解码;sync.lastFetchMs 是 fs.stat().mtimeMs —— **带小数**也必须解出来") + func worktreeStateDecodesFullSampleIncludingFractionalMtime() async throws { + // Arrange — 1785390645813.5327 是真实 stat().mtimeMs 的形状(APFS 纳秒精度) + let fixture = try makeFixture() + let body = """ + {"path":"\(Self.repoPath)","branch":"develop","dirtyCount":3,\ + "sync":{"upstream":"origin/develop","ahead":2,"behind":1,\ + "lastFetchMs":1785390645813.5327,"detached":false}} + """ + await fixture.http.queueSuccess( + url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"), + body: Data(body.utf8) + ) + + // Act + let state = try await fixture.client.worktreeState(path: Self.repoPath) + + // Assert + #expect(state.path == Self.repoPath) + #expect(state.branch == "develop") + #expect(state.dirtyCount == 3) + let sync = try #require(state.sync) + #expect(sync.upstream == "origin/develop") + #expect(sync.ahead == 2) + #expect(sync.behind == 1) + #expect(sync.detached == false) + let lastFetchMs = try #require(sync.lastFetchMs) + #expect(abs(lastFetchMs - 1_785_390_645_813.5327) < 0.001) + } + + @Test("WorktreeState 各字段独立降级:无 upstream/detached HEAD/从未 fetch 都是正常态") + func worktreeStateDegradesEachFieldIndependently() async throws { + // Arrange + let fixture = try makeFixture() + let body = """ + {"path":"\(Self.repoPath)","sync":{"detached":true},"unknownField":1} + """ + await fixture.http.queueSuccess( + url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"), + body: Data(body.utf8) + ) + + // Act + let state = try await fixture.client.worktreeState(path: Self.repoPath) + + // Assert + #expect(state.branch == nil) + #expect(state.dirtyCount == nil) + let sync = try #require(state.sync) + #expect(sync.detached == true) + #expect(sync.upstream == nil) + #expect(sync.ahead == nil) + #expect(sync.behind == nil) + #expect(sync.lastFetchMs == nil) // 从未 fetch —— 绝不编造时间戳 + } + + @Test("worktree/state 400/404/500 → projectPathInvalid/worktreeNotFound/gitDataUnavailable;非对象 body → invalidResponseBody") + func worktreeStateMapsErrorStatuses() async throws { + // Arrange + let fixture = try makeFixture() + let url = try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)") + for status in [400, 404, 500] { + await fixture.http.queueSuccess(url: url, status: status) + } + await fixture.http.queueSuccess(url: url, body: Data("7".utf8)) + + // Act + Assert + for error in [APIClientError.projectPathInvalid, .worktreeNotFound, .gitDataUnavailable] { + await #expect(throws: error) { + _ = try await fixture.client.worktreeState(path: Self.repoPath) + } + #expect(!error.message.isEmpty) + } + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await fixture.client.worktreeState(path: Self.repoPath) + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.worktreeState(path: "") + } + } + + // MARK: - GET /sessions(claude --resume 历史) + + @Test("HistorySession 解码:mtimeMs 来自 fs.stat() —— **带小数**必须解出来(否则整条被丢)") + func claudeSessionsDecodeFractionalMtime() async throws { + // Arrange + let fixture = try makeFixture() + let body = """ + [{"id":"0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b","cwd":"/Users/dev/web-terminal",\ + "project":"web-terminal","mtimeMs":1785390645813.5327,"preview":"修一下 CJK locale"},\ + {"id":"11111111-2222-4333-8444-555555555555","cwd":"","project":"unknown",\ + "mtimeMs":1700000000000,"preview":""}] + """ + await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data(body.utf8)) + + // Act + let sessions = try await fixture.client.claudeSessions() + + // Assert + #expect(sessions.count == 2) + let first = try #require(sessions.first) + #expect(first.id == "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b") + #expect(first.cwd == "/Users/dev/web-terminal") + #expect(first.project == "web-terminal") + #expect(abs(first.mtimeMs - 1_785_390_645_813.5327) < 0.001) + #expect(first.preview == "修一下 CJK locale") + #expect(sessions.last?.mtimeMs == 1_700_000_000_000) // 整数也要能解 + } + + @Test("/sessions 容忍:畸形条目逐条丢弃、可选字段缺省、非数组 body → invalidResponseBody") + func claudeSessionsToleratesDegradedShapes() async throws { + // Arrange + let fixture = try makeFixture() + let url = try routeURL("/sessions") + let degraded = """ + [{"id":"keep-me","mtimeMs":1},"nope",{"cwd":"/x"},{"id":"","mtimeMs":2},\ + {"id":"also-keep","mtimeMs":3,"extra":true}] + """ + await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8)) + await fixture.http.queueSuccess(url: url, body: Data(#"{"error":"x"}"#.utf8)) + + // Act + let sessions = try await fixture.client.claudeSessions() + + // Assert — id 是 `claude --resume ` 的实参,缺失/空 ⇒ 该条无用,丢弃 + #expect(sessions.map(\.id) == ["keep-me", "also-keep"]) + #expect(sessions.first?.cwd == "") + #expect(sessions.first?.project == "") + #expect(sessions.first?.preview == "") + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await fixture.client.claudeSessions() + } + } + + @Test("/sessions 非 200 → unexpectedStatus") + func claudeSessionsRejectsBadStatus() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: try routeURL("/sessions"), status: 500) + + // Act + Assert + await #expect(throws: APIClientError.unexpectedStatus(500)) { + _ = try await fixture.client.claudeSessions() + } + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift new file mode 100644 index 0000000..b86e79e --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift @@ -0,0 +1,526 @@ +import Foundation +import Testing +import TestSupport +import WireProtocol +import APIClient + +/// B1 · **G(变更)** 端点:git 写通道 + worktree 管理 + w2 注入队列 +/// (ios-completion §1.2;真源 = `src/`)。 +/// +/// - `POST /projects/git/stage`(`src/server.ts:1184-1218`)body `{path,files,stage}` +/// - `POST /projects/git/commit`(`:1219-1255`)body `{path,message}` +/// - `POST /projects/git/push`(`:1256-1289`)body `{path}` +/// - `POST /projects/git/fetch`(`:1290-1319`)body `{path}` +/// - `POST /projects/worktree`(`:1095-1123`)body `{path,branch[,base]}` +/// - `DELETE /projects/worktree`(`:1124-1153`)body `{path,worktreePath,force}` +/// - `POST /projects/worktree/prune`(`:1154-1183`)body `{path}` +/// - `POST /live-sessions/:id/queue`(`:605-643`)body `{text[,appendEnter]}` +/// +/// 全部 **必带 `Origin`**。失败 body 只带服务端**已分类、已脱敏**的 `error` +/// 字符串(`src/http/git-ops.ts` / `worktrees.ts`,SEC-M10)—— 客户端逐字展示, +/// 绝不自己拼 git stderr。403 是**重载**状态(Origin 守卫失败 **和** 功能开关关闭 +/// 都是 403),客户端无法凭状态区分,因此原样展示 message。 +struct GitWriteTests { + private static let base = "http://192.168.1.5:3000" + private static let repoPath = "/Users/dev/web-terminal" + private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b" + + private struct Fixture { + let http: FakeHTTPTransport + let endpoint: HostEndpoint + let client: APIClient + } + + private func makeFixture(accessToken: String? = nil) throws -> Fixture { + let baseURL = try #require(URL(string: Self.base)) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let http = FakeHTTPTransport() + return Fixture( + http: http, endpoint: endpoint, + client: APIClient(endpoint: endpoint, http: http, accessToken: accessToken) + ) + } + + private func routeURL(_ path: String) throws -> URL { + try #require(URL(string: Self.base + path)) + } + + private func bodyObject(_ request: URLRequest) throws -> [String: Any] { + let body = try #require(request.httpBody) + return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + } + + // MARK: - Origin iff-G(G 侧:七条写路由全带 Origin,逐字符相等) + + @Test("Origin iff-G(G 侧):七条 git/worktree 写路由全部带逐字符相等的 Origin + JSON Content-Type") + func everyGitWriteRouteCarriesByteEqualOrigin() async throws { + // Arrange + let fixture = try makeFixture() + let ok = Data(#"{"ok":true}"#.utf8) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok) + await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok + ) + + // Act + _ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt"], stage: true) + _ = try await fixture.client.gitCommit(path: Self.repoPath, message: "feat: x") + _ = try await fixture.client.gitPush(path: Self.repoPath) + _ = try await fixture.client.gitFetch(path: Self.repoPath) + _ = try await fixture.client.createWorktree( + path: Self.repoPath, branch: "wt-x", base: nil + ) + _ = try await fixture.client.removeWorktree( + path: Self.repoPath, worktreePath: "\(Self.repoPath)/.claude/worktrees/x", force: false + ) + _ = try await fixture.client.pruneWorktrees(path: Self.repoPath) + + // Assert + let requests = await fixture.http.recordedRequests + #expect(requests.count == 7) + for request in requests { + #expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + } + } + + @Test("配置了访问令牌时,G 写路由同时带 Cookie 与 Origin(令牌不替代 Origin)") + func gitWriteCarriesCookieAlongsideOrigin() async throws { + // Arrange + let token = "s3cret-token_value.~+/=" + let fixture = try makeFixture(accessToken: token) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/push"), body: Data(#"{"ok":true}"#.utf8) + ) + + // Act + _ = try await fixture.client.gitPush(path: Self.repoPath) + + // Assert + let request = try #require(await fixture.http.recordedRequests.first) + #expect(request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(token)") + #expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader) + } + + // MARK: - body 形状逐字段冻结 + + @Test("body 形状:stage={path,files,stage} · commit={path,message} · push/fetch/prune={path}") + func gitWriteBodyShapesAreExact() async throws { + // Arrange + let fixture = try makeFixture() + let ok = Data(#"{"ok":true}"#.utf8) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok + ) + + // Act + _ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt", "b/c.md"], stage: false) + _ = try await fixture.client.gitCommit(path: Self.repoPath, message: "fix: 修中文") + _ = try await fixture.client.gitPush(path: Self.repoPath) + _ = try await fixture.client.gitFetch(path: Self.repoPath) + _ = try await fixture.client.pruneWorktrees(path: Self.repoPath) + + // Assert + let requests = await fixture.http.recordedRequests + let stage = try bodyObject(try #require(requests.first)) + #expect(Set(stage.keys) == Set(["path", "files", "stage"])) + #expect(stage["path"] as? String == Self.repoPath) + #expect(stage["files"] as? [String] == ["a.txt", "b/c.md"]) + #expect(stage["stage"] as? Bool == false) + let commit = try bodyObject(requests[1]) + #expect(Set(commit.keys) == Set(["path", "message"])) + #expect(commit["message"] as? String == "fix: 修中文") + for index in 2...4 { + let single = try bodyObject(requests[index]) + #expect(Set(single.keys) == Set(["path"])) + #expect(single["path"] as? String == Self.repoPath) + } + } + + @Test("worktree body:create 的 base 为 nil 时**不带该键**;remove 恒带 {path,worktreePath,force}") + func worktreeBodyShapesAreExact() async throws { + // Arrange + let fixture = try makeFixture() + let ok = Data(#"{"ok":true}"#.utf8) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok) + await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok) + + // Act + _ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-a", base: nil) + _ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-b", base: "develop") + _ = try await fixture.client.removeWorktree( + path: Self.repoPath, worktreePath: "/tmp/wt", force: true + ) + + // Assert + let requests = await fixture.http.recordedRequests + let withoutBase = try bodyObject(try #require(requests.first)) + #expect(Set(withoutBase.keys) == Set(["path", "branch"])) + #expect(withoutBase["branch"] as? String == "wt-a") + let withBase = try bodyObject(requests[1]) + #expect(Set(withBase.keys) == Set(["path", "branch", "base"])) + #expect(withBase["base"] as? String == "develop") + let remove = try bodyObject(requests[2]) + #expect(Set(remove.keys) == Set(["path", "worktreePath", "force"])) + #expect(remove["worktreePath"] as? String == "/tmp/wt") + #expect(remove["force"] as? Bool == true) + #expect(requests[2].httpMethod == "DELETE") // DELETE **带** JSON body + } + + // MARK: - 200 payload 解码(畸形 body 降级为默认值,绝不抛) + + @Test("200 payload 解码:stage/commit/push/fetch/create/remove/prune 各自形状") + func successPayloadsDecodePerRoute() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/stage"), + body: Data(#"{"ok":true,"staged":true,"count":2}"#.utf8) + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/commit"), + body: Data(#"{"ok":true,"commit":"abc1234"}"#.utf8) + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/push"), + body: Data(#"{"ok":true,"branch":"develop","remote":"origin"}"#.utf8) + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/fetch"), + body: Data(#"{"ok":true,"remote":"origin","lastFetchMs":1785390645813.5327}"#.utf8) + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree"), + body: Data(#"{"ok":true,"path":"/tmp/wt","branch":"worktree-x"}"#.utf8) + ) + await fixture.http.queueSuccess( + method: "DELETE", url: try routeURL("/projects/worktree"), + body: Data(#"{"ok":true,"path":"/tmp/wt"}"#.utf8) + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree/prune"), + body: Data(#"{"ok":true,"pruned":["wt-a","wt-b"]}"#.utf8) + ) + + // Act + Assert + #expect( + try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true) + == .ok(StageResult(staged: true, count: 2)) + ) + #expect( + try await fixture.client.gitCommit(path: Self.repoPath, message: "m") + == .ok(CommitResult(commit: "abc1234")) + ) + #expect( + try await fixture.client.gitPush(path: Self.repoPath) + == .ok(PushResult(branch: "develop", remote: "origin")) + ) + let fetched = try await fixture.client.gitFetch(path: Self.repoPath) + guard case .ok(let fetchResult) = fetched else { + Issue.record("fetch 应为 .ok") + return + } + #expect(fetchResult.remote == "origin") + // lastFetchMs 来自 fs.stat().mtimeMs —— 带小数,必须解出来 + #expect(abs(try #require(fetchResult.lastFetchMs) - 1_785_390_645_813.5327) < 0.001) + #expect( + try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil) + == .ok(CreateWorktreeResult(path: "/tmp/wt", branch: "worktree-x")) + ) + #expect( + try await fixture.client.removeWorktree( + path: Self.repoPath, worktreePath: "/tmp/wt", force: false + ) == .ok(RemoveWorktreeResult(path: "/tmp/wt")) + ) + #expect( + try await fixture.client.pruneWorktrees(path: Self.repoPath) + == .ok(PruneWorktreesResult(pruned: ["wt-a", "wt-b"])) + ) + } + + @Test("200 但 payload 缺失/畸形 → 降级为默认值(空 sha / 空 pruned),绝不抛") + func garbledSuccessPayloadDegradesToDefaults() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/commit"), body: Data("[]".utf8) + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree/prune"), + body: Data(#"{"ok":true,"pruned":"not-an-array"}"#.utf8) + ) + + // Act + Assert + #expect( + try await fixture.client.gitCommit(path: Self.repoPath, message: "m") + == .ok(CommitResult(commit: "")) + ) + #expect( + try await fixture.client.pruneWorktrees(path: Self.repoPath) + == .ok(PruneWorktreesResult(pruned: [])) + ) + } + + // MARK: - 失败映射(429 独立;其余原样带出服务端安全 message) + + @Test("429 → .rateLimited(stage/commit 共用一个限流器,push/fetch 各有更紧的),不得自动重试") + func rateLimitedIsItsOwnOutcome() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/push"), status: 429, + body: Data(#"{"error":"Too many requests."}"#.utf8) + ) + + // Act + let outcome = try await fixture.client.gitPush(path: Self.repoPath) + + // Assert + #expect(outcome == .rateLimited) + } + + @Test("403/409/400/500 → .rejected(status, 服务端已脱敏 message)逐字带出(403 重载:Origin 或开关)") + func failuresCarryTheServerSafeMessageVerbatim() async throws { + // Arrange + let fixture = try makeFixture() + let cases: [(Int, String)] = [ + (403, "Git operations are disabled."), + (409, "Nothing staged to commit."), + (400, "Set a git author identity (user.name / user.email) first."), + (500, "Git operation failed."), + ] + for (status, message) in cases { + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/commit"), status: status, + body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8) + ) + } + + // Act + Assert + for (status, message) in cases { + let outcome = try await fixture.client.gitCommit(path: Self.repoPath, message: "m") + #expect(outcome == .rejected(status: status, message: message)) + } + } + + @Test("失败 body 无法解析 → .rejected(status, nil),不编造原因") + func unparseableFailureBodyYieldsNilMessage() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree"), status: 500, + body: Data("oops".utf8) + ) + + // Act + let outcome = try await fixture.client.createWorktree( + path: Self.repoPath, branch: "x", base: nil + ) + + // Assert + #expect(outcome == .rejected(status: 500, message: nil)) + } + + @Test("push 的 401 是**路由自身**语义('Push authentication required on the host.'),不得当成访问令牌 401") + func push401IsRouteClassifiedNotTheAccessTokenGate() async throws { + // Arrange — src/http/git-ops.ts:108 把主机侧 git 凭据失败分类成 401 + let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=") + let message = "Push authentication required on the host." + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/push"), status: 401, + body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8) + ) + + // Act + let outcome = try await fixture.client.gitPush(path: Self.repoPath) + + // Assert — 若被 gate 规则吞掉就会抛 .unauthorized,那会误导用户去补令牌 + #expect(outcome == .rejected(status: 401, message: message)) + } + + /// F5 · 401 的归属必须**逐路由**钉死,不能按"写路由"一刀切。 + /// + /// 服务器只有一处会自己产出 401:`src/http/git-ops.ts:108`(主机侧 git 凭据失败), + /// 而它只被 `stageFiles`/`commit`/`push`/`fetch` 走到。worktree 三条落在 + /// `src/http/worktrees.ts`,那里根本不产 401(403 开关 / 400 / 404 / 500), + /// `src/server.ts:1095-1183` 也没加。所以在启用访问令牌的主机上,worktree 的 401 + /// 只可能是 gate —— 当成 git 失败会把用户卡在"创建 worktree 失败",而不是补令牌。 + @Test("worktree 三条的 401 是访问令牌 gate(不是 git 拒绝);git-ops 四条才是路由自身语义") + func worktreeRoutes401IsTheAccessTokenGateNotAGitRejection() async throws { + // Arrange + let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=") + let gateBody = Data(#"{"error":"authentication required"}"#.utf8) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree"), status: 401, body: gateBody + ) + await fixture.http.queueSuccess( + method: "DELETE", url: try routeURL("/projects/worktree"), status: 401, body: gateBody + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree/prune"), status: 401, body: gateBody + ) + + // Act + Assert + await #expect(throws: APIClientError.unauthorized) { + _ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil) + } + await #expect(throws: APIClientError.unauthorized) { + _ = try await fixture.client.removeWorktree( + path: Self.repoPath, worktreePath: "\(Self.repoPath)/wt", force: false + ) + } + await #expect(throws: APIClientError.unauthorized) { + _ = try await fixture.client.pruneWorktrees(path: Self.repoPath) + } + } + + @Test("git-ops 四条(stage/commit/push/fetch)全部保留路由自身的 401") + func allFourGitOpsRoutesKeepTheirOwn401() async throws { + // Arrange + let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=") + let message = "Push authentication required on the host." + let body = Data(#"{"ok":false,"error":"\#(message)"}"#.utf8) + for path in ["/projects/git/stage", "/projects/git/commit", "/projects/git/push", "/projects/git/fetch"] { + await fixture.http.queueSuccess( + method: "POST", url: try routeURL(path), status: 401, body: body + ) + } + + // Act + Assert — 服务器自己的话术必须原样到达 UI + #expect( + try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true) + == .rejected(status: 401, message: message) + ) + #expect( + try await fixture.client.gitCommit(path: Self.repoPath, message: "m") + == .rejected(status: 401, message: message) + ) + #expect( + try await fixture.client.gitPush(path: Self.repoPath) + == .rejected(status: 401, message: message) + ) + #expect( + try await fixture.client.gitFetch(path: Self.repoPath) + == .rejected(status: 401, message: message) + ) + } + + @Test("空 path 联网前拒(projectPathInvalid),七条写路由一致") + func emptyPathIsRejectedBeforeNetworkOnEveryWriteRoute() async throws { + // Arrange + let fixture = try makeFixture() + + // Act + Assert + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.gitStage(path: "", files: ["a"], stage: true) + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.gitCommit(path: "", message: "m") + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.gitPush(path: "") + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.gitFetch(path: "") + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.createWorktree(path: "", branch: "x", base: nil) + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.removeWorktree(path: "", worktreePath: "/tmp/wt", force: false) + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.pruneWorktrees(path: "") + } + #expect(await fixture.http.recordedRequests.isEmpty) + } + + // MARK: - POST /live-sessions/:id/queue(w2 pty 注入队列) + + @Test("queue 路由:POST /live-sessions/<小写 UUID>/queue,带 Origin,body 恰为 {text,appendEnter}") + func queueRouteShapeIsExact() async throws { + // Arrange + let fixture = try makeFixture() + let id = try #require(UUID(uuidString: Self.sessionIdString)) + let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue") + await fixture.http.queueSuccess( + method: "POST", url: url, body: Data(#"{"length":2}"#.utf8) + ) + + // Act + let depth = try await fixture.client.enqueueFollowup( + sessionId: id, text: "继续", appendEnter: true + ) + + // Assert + #expect(depth == 2) + let request = try #require(await fixture.http.recordedRequests.first) + #expect(request.httpMethod == "POST") + #expect(request.url == url) // :id 按字符串精确匹配 ⇒ 必须小写 + #expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader) + let body = try bodyObject(request) + #expect(Set(body.keys) == Set(["text", "appendEnter"])) + #expect(body["text"] as? String == "继续") + #expect(body["appendEnter"] as? Bool == true) + } + + @Test("queue 错误映射:400/403/404/409/413/429/503 各自类型化,话术非空") + func queueMapsEveryServerStatusToATypedError() async throws { + // Arrange + let fixture = try makeFixture() + let id = try #require(UUID(uuidString: Self.sessionIdString)) + let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue") + let cases: [(Int, APIClientError)] = [ + (400, .queueTextInvalid), + (403, .forbidden), + (404, .sessionNotFound), + (409, .queueFull), + (413, .queueTextTooLarge), + (429, .rateLimited), + (503, .queueDisabled), + (500, .unexpectedStatus(500)), + ] + for (status, _) in cases { + await fixture.http.queueSuccess(method: "POST", url: url, status: status) + } + + // Act + Assert + for (_, expected) in cases { + await #expect(throws: expected) { + _ = try await fixture.client.enqueueFollowup( + sessionId: id, text: "x", appendEnter: false + ) + } + #expect(!expected.message.isEmpty) + } + } + + @Test("queue 空 text 联网前拒(镜像服务器 400 规则);200 但 body 无 length → invalidResponseBody") + func queueRejectsEmptyTextAndGarbledSuccessBody() async throws { + // Arrange + let fixture = try makeFixture() + let id = try #require(UUID(uuidString: Self.sessionIdString)) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/live-sessions/\(Self.sessionIdString)/queue"), + body: Data(#"{"ok":true}"#.utf8) + ) + + // Act + Assert + await #expect(throws: APIClientError.queueTextInvalid) { + _ = try await fixture.client.enqueueFollowup(sessionId: id, text: "", appendEnter: true) + } + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await fixture.client.enqueueFollowup(sessionId: id, text: "x", appendEnter: false) + } + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift index 09cfabb..0b638cd 100644 --- a/ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift +++ b/ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift @@ -156,6 +156,67 @@ struct ProjectsTests { #expect(projects.last?.sessions.isEmpty == true) // sessions 缺失 → [] } + @Test("回归:lastActiveMs 来自 fs.stat().mtimeMs —— **带小数**必须解出来(否则真机上排序键永远为 nil)") + func lastActiveMsDecodesFractionalStatMtime() async throws { + // Arrange — 真实服务器值形如 1785390645813.5327(APFS 纳秒精度); + // 旧实现 decode(Int.self) 对小数直接失败,被 try? 吞成 nil。 + let body = """ + [{"name":"a","path":"/a","isGit":true,"lastActiveMs":1785390645813.5327,\ + "lastCommitMs":1720000000000,"ahead":2,"behind":0,"sessions":[]}] + """ + + // Act + let projects = try await fetchProjects(try makeFixture(), body: body) + + // Assert + let project = try #require(projects.first) + #expect(project.lastActiveMs == 1_785_390_645_813) + #expect(project.lastCommitMs == 1_720_000_000_000) + #expect(project.ahead == 2) + #expect(project.behind == 0) + } + + @Test("session ref 的 cwd(w6/G7)可选解码:有则解出,缺失 → nil") + func sessionRefDecodesOptionalCwd() async throws { + // Arrange + let body = """ + [{"name":"a","path":"/a","isGit":true,"sessions":[\ + {"id":"\(Self.sessionIdString)","status":"idle","clientCount":0,"createdAt":1,\ + "exited":false,"cwd":"/a/.claude/worktrees/x"}]}] + """ + + // Act + let projects = try await fetchProjects(try makeFixture(), body: body) + + // Assert + #expect(projects.first?.sessions.first?.cwd == "/a/.claude/worktrees/x") + } + + @Test("detail 的 dirtyCount / sync(w6/G1)可选解码,与 worktree/state 用同一 SyncState") + func projectDetailDecodesDirtyCountAndSync() async throws { + // Arrange + let fixture = try makeFixture() + let body = """ + {"name":"a","path":"/a","isGit":true,"dirty":true,"dirtyCount":7,\ + "sync":{"upstream":"origin/main","ahead":1,"behind":0,"lastFetchMs":1785390645813.5327},\ + "worktrees":[],"sessions":[],"hasClaudeMd":false} + """ + await fixture.http.queueSuccess( + url: try routeURL("/projects/detail?path=%2Fa"), body: Data(body.utf8) + ) + + // Act + let detail = try await fixture.client.projectDetail(path: "/a") + + // Assert + #expect(detail.dirtyCount == 7) + let sync = try #require(detail.sync) + #expect(sync.upstream == "origin/main") + #expect(sync.ahead == 1) + #expect(sync.behind == 0) + #expect(sync.lastFetchMs != nil) + } + @Test("/projects 非数组 body → invalidResponseBody;非 200 → unexpectedStatus") func projectsRejectsNonArrayBodyAndBadStatus() async throws { // Act + Assert — 非数组 diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/CSRTestParser.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/CSRTestParser.swift new file mode 100644 index 0000000..8a26584 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/CSRTestParser.swift @@ -0,0 +1,52 @@ +import Foundation + +// B4 · Read back the fields of a PKCS#10 CSR so tests can assert on what was +// ACTUALLY sent to the control plane (subject CN, embedded public key) instead +// of trusting the encoder's own view. Uses the throwaway `TestDER` reader from +// CertificateSigningRequestTests. + +/// The two CSR fields the store/rotation invariants are stated in terms of. +struct ParsedCSR: Equatable { + /// `CertificationRequestInfo.subject` — the single CN RDN. + let subjectCommonName: String + /// `subjectPKInfo` BIT STRING content: the X9.63 uncompressed point. + let publicPointX963: Data + /// The exact `CertificationRequestInfo` DER (the bytes that were signed). + let certificationRequestInfoDER: Data +} + +/// Parse a `CertificationRequest` DER. `nil` when the shape is not the canonical +/// `SEQUENCE { info, algId, BIT STRING }` this package emits. +func parseCSR(_ der: Data) -> ParsedCSR? { + let bytes = [UInt8](der) + guard let outer = TestDER.read(bytes, at: 0), outer.end == bytes.count else { return nil } + let parts = TestDER.children(bytes, outer) + guard parts.count == 3 else { return nil } + + let info = parts[0] + let infoChildren = TestDER.children(bytes, info) + guard infoChildren.count == 4 else { return nil } + + // subject: SEQUENCE { SET { SEQUENCE { OID commonName, UTF8String } } } + let rdnSequence = TestDER.children(bytes, infoChildren[1]) + guard let rdnSet = rdnSequence.first else { return nil } + let attributes = TestDER.children(bytes, rdnSet) + guard let attribute = attributes.first else { return nil } + let attributeParts = TestDER.children(bytes, attribute) + guard attributeParts.count == 2 else { return nil } + let commonNameBytes = Array(bytes[attributeParts[1].valueStart.. bitString.valueStart + 1 else { return nil } + let point = Data(bytes[(bitString.valueStart + 1)..? + let verified = SecKeyVerifySignature( + publicKey, + .ecdsaSignatureMessageX962SHA256, + KnownGoodCSR.certificationRequestInfoDER as CFData, + signature as CFData, + &error + ) + + // Assert + #expect(verified, "\(String(describing: error?.takeRetainedValue()))") +} + +@Test("a signer whose public key is not a 65-byte uncompressed point is rejected") +func csrRejectsNonX963PublicKey() { + // Arrange — a compressed (33-byte) point: valid EC, wrong encoding for SPKI. + let compressed = Data([0x02] + [UInt8](repeating: 0x11, count: 32)) + let signer = StubSigner(publicKey: compressed) + + // Act / Assert — must fail BEFORE signing (no PoP over a bogus SPKI). + #expect(throws: CertificateSigningRequest.CSRError.invalidPublicKey) { + _ = try CertificateSigningRequest.der(subjectCommonName: "device", signer: signer) + } + #expect(signer.signCallCount == 0) +} + +@Test("a subject long enough to need a 2-byte DER length still round-trips") +func csrLongSubjectUsesLongFormLength() throws { + // Arrange — 300 chars pushes CertificationRequestInfo past 255 bytes, so its + // length must be encoded long-form as 0x82 . + let longCommonName = String(repeating: "d", count: 300) + let signer = try KnownGoodCSR.fixedKey() + + // Act + let der = try CertificateSigningRequest.der( + subjectCommonName: longCommonName, signer: signer + ) + + // Assert + let parsed = try #require(parseCSR(der)) + #expect(parsed.subjectCommonName == longCommonName) + let infoBytes = [UInt8](parsed.certificationRequestInfoDER) + #expect(infoBytes[1] == 0x82) // long form, two length bytes + let declaredLength = Int(infoBytes[2]) << 8 | Int(infoBytes[3]) + #expect(declaredLength == infoBytes.count - 4) // minimal + accurate +} + +// MARK: - Helpers + +/// A `P256HardwareKey` that returns a caller-chosen public key and records +/// whether it was ever asked to sign. +private final class StubSigner: P256HardwareKey, @unchecked Sendable { + private let publicKey: Data + private let lock = NSLock() + private var signCalls = 0 + + init(publicKey: Data) { + self.publicKey = publicKey + } + + var signCallCount: Int { lock.withLock { signCalls } } + + func publicKeyX963() throws -> Data { publicKey } + + func sign(_ message: Data) throws -> Data { + lock.withLock { signCalls += 1 } + return Data([0x30, 0x00]) + } +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/ClientTLSSessionDelegateTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/ClientTLSSessionDelegateTests.swift new file mode 100644 index 0000000..e94651e --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/ClientTLSSessionDelegateTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import ClientTLS + +// B4 · The URLSession seam. `ClientTLSSessionDelegate` is the object every HTTP +// call to an mTLS host hangs off, so the one behaviour that matters is that the +// session-level callback ALWAYS invokes the completion handler exactly once with +// the responder's decision — a delegate that forgets to call back hangs the +// request forever (no timeout maps to a user-visible error). + +private func makeChallenge(method: String) -> URLAuthenticationChallenge { + let space = URLProtectionSpace( + host: "t1.terminal.yaojia.wang", port: 443, protocol: "https", + realm: nil, authenticationMethod: method + ) + return URLAuthenticationChallenge( + protectionSpace: space, proposedCredential: nil, previousFailureCount: 0, + failureResponse: nil, error: nil, sender: DelegateTestSender() + ) +} + +/// The responder never calls back into the sender; it only reads the protection +/// space. Present because the designated initializer demands a non-optional one. +private final class DelegateTestSender: NSObject, URLAuthenticationChallengeSender { + func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {} + func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {} + func cancel(_ challenge: URLAuthenticationChallenge) {} +} + +/// Drive the delegate and capture what it handed back. +private func resolve( + identity: ClientIdentity?, method: String +) -> (calls: Int, disposition: URLSession.AuthChallengeDisposition?, credential: URLCredential?) { + let delegate = ClientTLSSessionDelegate(identity: identity) + var calls = 0 + var disposition: URLSession.AuthChallengeDisposition? + var credential: URLCredential? + delegate.urlSession( + URLSession.shared, didReceive: makeChallenge(method: method) + ) { receivedDisposition, receivedCredential in + calls += 1 + disposition = receivedDisposition + credential = receivedCredential + } + return (calls, disposition, credential) +} + +@Test("a ClientCertificate challenge answers once with the installed identity") +func delegateAnswersClientCertificateWithIdentity() throws { + // Arrange + let identity = try PKCS12Importer.importIdentity( + data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase + ) + + // Act + let result = resolve( + identity: identity, method: NSURLAuthenticationMethodClientCertificate + ) + + // Assert + #expect(result.calls == 1) // exactly once — never zero (hang) or twice (crash) + #expect(result.disposition == .useCredential) + #expect(result.credential?.identity != nil) +} + +@Test("a ClientCertificate challenge with no identity cancels instead of hanging") +func delegateCancelsWithoutIdentity() { + // Act + let result = resolve(identity: nil, method: NSURLAuthenticationMethodClientCertificate) + + // Assert + #expect(result.calls == 1) + #expect(result.disposition == .cancelAuthenticationChallenge) + #expect(result.credential == nil) +} + +@Test("server-trust challenges fall through to the system evaluation") +func delegateDefersServerTrust() throws { + // Arrange + let identity = try PKCS12Importer.importIdentity( + data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase + ) + + // Act / Assert — the client cert must never be offered as a server-trust + // answer, and pinning is NOT this package's job (plan §5: default handling). + for candidate in [identity, nil] as [ClientIdentity?] { + let result = resolve(identity: candidate, method: NSURLAuthenticationMethodServerTrust) + #expect(result.calls == 1) + #expect(result.disposition == .performDefaultHandling) + #expect(result.credential == nil) + } +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrolledLeafFixtures.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrolledLeafFixtures.swift new file mode 100644 index 0000000..c49287b --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrolledLeafFixtures.swift @@ -0,0 +1,103 @@ +import Foundation +import Security + +// B4 · Two real, DER-decodable leaf certificates for the enrolled-identity +// persistence tests, plus the cleanup they need. +// +// Both carry the SAME public key (the `KnownGoodCSR` fixed key) and the SAME +// subject, and differ only in serial + validity — exactly the shape of a +// rotation: `SecItemAdd` treats them as two distinct items, while re-adding one +// of them byte-for-byte is `errSecDuplicateItem`. +// +// CLEANUP, and why it needs its own path: on macOS the file-based keychain +// OVERRIDES the `kSecAttrLabel` supplied at add time with the certificate's own +// subject summary (verified 2026-07-30: added under +// `…test-XYZ.device-leaf`, found only under `enrolled-device-fixture`). So the +// production `KeychainClientIdentityStore` label-keyed delete cannot remove them +// on macOS, and the tests must sweep by subject instead — `purgeFixtureLeaves()` +// runs both BEFORE (in case a previous run was killed) and AFTER every test that +// installs one. `SecItemDelete` removes ONE match per call here, hence the loop. +// +// Regenerated with (OpenSSL 3.0.18, key = KnownGoodCSR.privateKeyX963Base64): +// openssl req -x509 -new -key fixed.key.pem -subj "/CN=enrolled-device-fixture" \ +// -days 3650 -sha256 -outform DER -out leaf1.der # then -days 3651 → leaf2 +// openssl req -x509 -new -key chain.key.pem \ +// -subj "/CN=webterm-enrollment-ca-fixture" -days 3650 -sha256 -outform DER +enum EnrolledLeafFixtures { + /// The subject summary macOS files these certificates under. + static let subjectSummary = "enrolled-device-fixture" + + static var leaf: Data { der(leafBase64) } + /// A second, DISTINCT leaf (different serial) for the rotation case. + static var rotatedLeaf: Data { der(rotatedLeafBase64) } + /// An issuer certificate for the stored `caChain`. + static var issuer: Data { der(issuerBase64) } + + private static func der(_ base64: String) -> Data { + Data(base64Encoded: base64, options: .ignoreUnknownCharacters)! + } + + private static let leafBase64 = """ + MIIBmTCCAT+gAwIBAgIUcHFyXdi5QFelGKhdzcsbFlgnYzUwCgYIKoZIzj0EAwIwIjEgMB4GA1UE\ + AwwXZW5yb2xsZWQtZGV2aWNlLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAxWhcNMzYwNzI3MDc1NTAx\ + WjAiMSAwHgYDVQQDDBdlbnJvbGxlZC1kZXZpY2UtZml4dHVyZTBZMBMGByqGSM49AgEGCCqGSM49\ + AwEHA0IABGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs5\ + 6X1TF7ZCweW4mcaiJ9KjUzBRMB0GA1UdDgQWBBRAua2aufygDDq2CJTBF31OG7cNSDAfBgNVHSME\ + GDAWgBRAua2aufygDDq2CJTBF31OG7cNSDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA\ + MEUCIQCoLSypPlXvtMsQRMpztt/RpbYKc6iTmBLypqOZc0MxTAIgF/MVWpaLLYVCmcKBPxf7y9yQ\ + NX3c6BrYB/fi/WzJsmA= + """ + + private static let rotatedLeafBase64 = """ + MIIBmTCCAT+gAwIBAgIUarD2zMEMcxZJNQevMMJenCAlLpkwCgYIKoZIzj0EAwIwIjEgMB4GA1UE\ + AwwXZW5yb2xsZWQtZGV2aWNlLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAyWhcNMzYwNzI4MDc1NTAy\ + WjAiMSAwHgYDVQQDDBdlbnJvbGxlZC1kZXZpY2UtZml4dHVyZTBZMBMGByqGSM49AgEGCCqGSM49\ + AwEHA0IABGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs5\ + 6X1TF7ZCweW4mcaiJ9KjUzBRMB0GA1UdDgQWBBRAua2aufygDDq2CJTBF31OG7cNSDAfBgNVHSME\ + GDAWgBRAua2aufygDDq2CJTBF31OG7cNSDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA\ + MEUCID2FFmKH2+qHB8sICyDuxQPtyj/wOLE24t4ghBEitLRvAiEAnTQQ2FP8Wei+8ITL/K57UP2Z\ + YtYbEokKkBCj2bMr3vk= + """ + + private static let issuerBase64 = """ + MIIBpTCCAUugAwIBAgIUIkl7VDf6o9lBxtWxh7zFxPYTpo8wCgYIKoZIzj0EAwIwKDEmMCQGA1UE\ + Awwdd2VidGVybS1lbnJvbGxtZW50LWNhLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAyWhcNMzYwNzI3\ + MDc1NTAyWjAoMSYwJAYDVQQDDB13ZWJ0ZXJtLWVucm9sbG1lbnQtY2EtZml4dHVyZTBZMBMGByqG\ + SM49AgEGCCqGSM49AwEHA0IABM4iWgauVe86+SKZg+jlHkh8VbVf70pgMdGOcpJW+xWb5oqfzxY3\ + fW6pIdn3SCo9PG7X22qGtq/PLgpv97wtJvajUzBRMB0GA1UdDgQWBBTT9mdiVZTWcatuYz3NkxCb\ + DxDSAjAfBgNVHSMEGDAWgBTT9mdiVZTWcatuYz3NkxCbDxDSAjAPBgNVHRMBAf8EBTADAQH/MAoG\ + CCqGSM49BAMCA0gAMEUCIQCxfF8zyRHMW7nSmieDoBxIsOXkMFVPko86THW3TbwrUwIgMZlBP5rz\ + FT4Y/G2oA+87xceEDjCrA7FnRZrCrZ4AADk= + """ + + private static func labelQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassCertificate, + kSecAttrLabel as String: subjectSummary, + ] + } + + /// How many fixture leaves are currently installed. + static func installedCount() -> Int { + var query = labelQuery() + query[kSecReturnRef as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitAll + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { + return 0 + } + if let array = result as? [Any] { return array.count } + return result == nil ? 0 : 1 + } + + /// Remove every installed fixture leaf. Idempotent; safe to call when none + /// are installed. + static func purgeFixtureLeaves() { + var guardCounter = 0 + let maxSweeps = 16 // a leaf per enroll in a test; never unbounded + while SecItemDelete(labelQuery() as CFDictionary) == errSecSuccess, + guardCounter < maxSweeps { + guardCounter += 1 + } + } +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrollmentStubs.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrollmentStubs.swift new file mode 100644 index 0000000..d11525e --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrollmentStubs.swift @@ -0,0 +1,95 @@ +import Foundation +@testable import ClientTLS + +// B4 · Shared enrollment-transport double for the store / flow tests. +// +// Unlike the per-file stubs in DeviceEnrollmentClientTests (which only need the +// LAST request), the store tests must re-parse the CSR that was actually sent — +// that is how "renew re-signs with the SAME device key" is pinned — so this one +// records every request body and replays a scripted reply per call. + +/// A recording, scriptable `EnrollmentTransport`. +/// +/// `@unchecked Sendable`: all mutable state is behind `lock`. +final class RecordingEnrollmentTransport: EnrollmentTransport, @unchecked Sendable { + struct Reply { + let status: Int + let body: Data + } + + private let lock = NSLock() + private var pendingReplies: [Reply] + private var recorded: [URLRequest] = [] + + init(replies: [Reply]) { + pendingReplies = replies + } + + convenience init(status: Int, body: Data) { + self.init(replies: [Reply(status: status, body: body)]) + } + + var requests: [URLRequest] { lock.withLock { recorded } } + var callCount: Int { lock.withLock { recorded.count } } + + /// The JSON body of call `index`, decoded as a `[String: Any]` object. + func jsonBody(at index: Int) -> [String: Any]? { + guard let data = requests.indices.contains(index) + ? requests[index].httpBody : nil + else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + /// The `csr` field of call `index`, base64-decoded back to DER. + func csrDER(at index: Int) -> Data? { + guard let base64 = jsonBody(at: index)?["csr"] as? String else { return nil } + return Data(base64Encoded: base64) + } + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let reply: Reply = lock.withLock { + recorded.append(request) + guard !pendingReplies.isEmpty else { return Reply(status: 500, body: Data()) } + return pendingReplies.removeFirst() + } + let response = HTTPURLResponse( + url: request.url!, statusCode: reply.status, httpVersion: "HTTP/1.1", headerFields: nil + )! + return (reply.body, response) + } +} + +/// DER bytes that `SecCertificateCreateWithData` rejects (a truncated SEQUENCE). +/// +/// Every store test that drives `enroll` / `renew` to the persistence step uses +/// this as the server-returned leaf ON PURPOSE: it makes `storeEnrolledLeaf` +/// fail at the very first line — BEFORE any `SecItemAdd(kSecClassCertificate)`. +/// That matters on macOS, where `swift test` talks to the file-based login +/// keychain: it OVERRIDES the `kSecAttrLabel` of an added certificate with the +/// cert's own subject summary, so `KeychainClientIdentityStore`'s label-keyed +/// delete can never remove it again (verified 2026-07-30: adding the leaf, then +/// `SecItemDelete(kSecClass: kSecClassCertificate, kSecAttrLabel: …)` → +/// `errSecItemNotFound (-25300)`, and the cert had to be swept manually with +/// `security delete-certificate -Z `). A test that installed a leaf would +/// therefore permanently pollute the developer's login keychain. +let undecodableCertificateDER = Data([0x30, 0x01, 0x02]) + +/// A `POST /device/enroll` / `…/renew` 201 body in the A4 wire shape. +func enrollmentResponseJSON( + deviceId: String = "dev-b4", + certDER: Data = undecodableCertificateDER, + caChain: [Data] = [], + notBefore: String? = "2026-07-30T00:00:00.000Z", + notAfter: String? = "2026-10-28T00:00:00.000Z", + renewAfter: String? = "2026-09-27T00:00:00.000Z" +) -> Data { + var json: [String: Any] = [ + "deviceId": deviceId, + "cert": certDER.base64EncodedString(), + "caChain": caChain.map { $0.base64EncodedString() }, + ] + if let notBefore { json["notBefore"] = notBefore } + if let notAfter { json["notAfter"] = notAfter } + if let renewAfter { json["renewAfter"] = renewAfter } + return try! JSONSerialization.data(withJSONObject: json) +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreEnrollmentTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreEnrollmentTests.swift new file mode 100644 index 0000000..b9578bc --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreEnrollmentTests.swift @@ -0,0 +1,315 @@ +import Foundation +import Security +import Testing +@testable import ClientTLS + +// B4 · The Secure-Enclave enrollment half of `KeychainClientIdentityStore`: +// `enroll`, `renew`, `renewalState`. What is pinned here is the ORCHESTRATION — +// which key signs the CSR, which endpoint it goes to, what the request body is +// allowed to contain, and what happens to local state when the server's answer +// is unusable. The signing key is injected (`keyProvider`) or pre-installed +// under the store's derived tag, so no Secure Enclave is needed. +// +// Every test stops at the leaf-installation step by having the server return an +// undecodable certificate (`undecodableCertificateDER`) — see the note there for +// why installing a real leaf is not possible in a macOS `swift test` run. The +// happy-path leaf install + `SecItemCopyMatching(kSecClassIdentity)` assembly +// stays a device/simulator concern. + +private let controlPlaneURL = URL(string: "https://cp.terminal.yaojia.wang")! + +@Test("renewalState is nil before any enrollment", .keychainSerialized) +func renewalStateNilWhenNotEnrolled() async throws { + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + + #expect(try store.renewalState() == nil) +} + +@Test("renewalState reads back the persisted enrollment record verbatim", .keychainSerialized) +func renewalStateReadsPersistedRecord() async throws { + // Arrange — the record layout is a migration contract, so it is seeded + // directly and read back through the public API. + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let notAfter = Date(timeIntervalSinceReferenceDate: 800_000_000) + let renewAfter = Date(timeIntervalSinceReferenceDate: 790_000_000) + KeychainProbe.write( + service: keys.service, account: keys.enrollmentAccount, + data: storedEnrollmentJSON( + deviceId: "dev-42", deviceName: "Yaojia iPhone", + caChain: [Data([0x30, 0xAA])], notAfter: notAfter, renewAfter: renewAfter + ) + ) + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + + // Act + let state = try #require(try store.renewalState()) + + // Assert — deviceId drives POST /device/:id/renew; the dates drive the + // scheduler's decision. + #expect(state.deviceId == "dev-42") + #expect(state.notAfter == notAfter) + #expect(state.renewAfter == renewAfter) + #expect(state.isRenewalDue(asOf: renewAfter.addingTimeInterval(1))) + #expect(state.isRenewalDue(asOf: renewAfter.addingTimeInterval(-1)) == false) +} + +@Test("a record with no renewAfter never reports renewal due (fail-safe)", .keychainSerialized) +func renewalStateWithoutRenewAfterNeverDue() async throws { + // Arrange — an older server that omitted the advisory field. + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + KeychainProbe.write( + service: keys.service, account: keys.enrollmentAccount, + data: storedEnrollmentJSON(deviceId: "dev-legacy", deviceName: "iPad") + ) + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + + // Act + let state = try #require(try store.renewalState()) + + // Assert — the TLS stack stays the real gate; the scheduler must not guess. + #expect(state.notAfter == nil) + #expect(state.renewAfter == nil) + #expect(state.isRenewalDue(asOf: Date.distantFuture) == false) +} + +@Test("an undecodable enrollment record surfaces .corruptStoredBlob", .keychainSerialized) +func renewalStateCorruptRecord() async throws { + // Arrange + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + KeychainProbe.write( + service: keys.service, account: keys.enrollmentAccount, + data: Data("{\"deviceId\":".utf8) // truncated JSON + ) + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + + // Act / Assert — never a silent "not enrolled" (that would make the + // scheduler skip rotation forever on a device that IS enrolled). + #expect(throws: ClientIdentityStoreError.corruptStoredBlob) { + _ = try store.renewalState() + } +} + +@Test("enroll signs the CSR with the injected key and posts it to /device/enroll", .keychainSerialized) +func enrollPostsCSRSignedByTheDeviceKey() async throws { + // Arrange + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + let deviceKey = try SecureEnclaveKeyFactory.generateSoftware() + let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON()) + let client = DeviceEnrollmentClient( + baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport + ) + + // Act — the response's leaf is undecodable, so persistence fails; the CSR has + // already been built and sent, which is what this test is about. + await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) { + _ = try await store.enroll( + using: client, subdomain: "yaojia", deviceName: "Yaojia iPhone", + keyProvider: { deviceKey } + ) + } + + // Assert — one request, to the enroll endpoint, carrying the A4 body. + #expect(transport.callCount == 1) + let request = try #require(transport.requests.first) + #expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/enroll") + let body = try #require(transport.jsonBody(at: 0)) + #expect(body["subdomain"] as? String == "yaojia") + #expect(body["deviceName"] as? String == "Yaojia iPhone") + #expect(body["keyAlg"] as? String == "ec-p256") + + // …and the CSR is bound to the injected key, with the device name as CN. + let csrDER = try #require(transport.csrDER(at: 0)) + let csr = try #require(parseCSR(csrDER)) + #expect(csr.subjectCommonName == "Yaojia iPhone") + #expect(csr.publicPointX963 == (try deviceKey.publicKeyX963())) +} + +@Test("an undecodable leaf leaves NO enrollment record behind", .keychainSerialized) +func enrollDoesNotPersistOnUndecodableLeaf() async throws { + // Arrange + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON()) + let client = DeviceEnrollmentClient( + baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport + ) + + // Act + await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) { + _ = try await store.enroll( + using: client, subdomain: "yaojia", deviceName: "iPhone", + keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() } + ) + } + + // Assert — a half-written record would make `renewalState` claim an + // enrollment that has no leaf, and the scheduler would renew a phantom. + #expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0) + #expect(try store.renewalState() == nil) +} + +@Test("a rejected enrollment (403) propagates the server error and stores nothing", .keychainSerialized) +func enrollPropagatesServerRejection() async throws { + // Arrange — subdomain not owned by the account. + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + let body = try JSONSerialization.data(withJSONObject: ["error": "subdomain_not_owned"]) + let transport = RecordingEnrollmentTransport(status: 403, body: body) + let client = DeviceEnrollmentClient( + baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport + ) + + // Act / Assert + await #expect( + throws: DeviceEnrollmentError.http(status: 403, code: "subdomain_not_owned") + ) { + _ = try await store.enroll( + using: client, subdomain: "someone-else", deviceName: "iPhone", + keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() } + ) + } + #expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0) +} + +@Test("renew without an enrollment record fails locally and never calls the server", .keychainSerialized) +func renewWithoutRecordDoesNotCallServer() async throws { + // Arrange + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON()) + let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport) + + // Act / Assert — nothing to renew: a fresh install or a legacy .p12-only + // device. The scheduler must not fire a request it cannot authenticate. + await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) { + _ = try await store.renew(using: client) + } + #expect(transport.callCount == 0) +} + +@Test("renew without the device key fails locally and never calls the server", .keychainSerialized) +func renewWithoutDeviceKeyDoesNotCallServer() async throws { + // Arrange — a record exists but the Secure-Enclave key is gone (restored + // backup / manually cleared keychain). + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + KeychainProbe.write( + service: keys.service, account: keys.enrollmentAccount, + data: storedEnrollmentJSON(deviceId: "dev-99", deviceName: "iPhone") + ) + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON()) + let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport) + + // Act / Assert — a renew CSR signed by a NEW key would be rejected by the + // server (PoP against the enrolled key), so it must not be attempted. + await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) { + _ = try await store.renew(using: client) + } + #expect(transport.callCount == 0) +} + +@Test("renew re-signs with the SAME device key and posts a csr-only body to /device/:id/renew", .keychainSerialized) +func renewReusesTheEnrolledDeviceKey() async throws { + // Arrange — an enrolled device: record + the permanent key under the store's + // derived tag (the shape `enroll` leaves behind). + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let enrolledKey = try SecureEnclaveKeyFactory.generateSoftware( + tag: keys.deviceKeyTag, permanent: true + ) + let originalRecord = storedEnrollmentJSON( + deviceId: "dev-77", deviceName: "Yaojia iPad", + notAfter: Date(timeIntervalSinceReferenceDate: 800_000_000), + renewAfter: Date(timeIntervalSinceReferenceDate: 790_000_000) + ) + KeychainProbe.write( + service: keys.service, account: keys.enrollmentAccount, data: originalRecord + ) + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON()) + // Nil bearer: the renew endpoint authenticates by the CURRENT client cert. + let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport) + + // Act + await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) { + _ = try await store.renew(using: client) + } + + // Assert — the endpoint carries the stored deviceId… + #expect(transport.callCount == 1) + let request = try #require(transport.requests.first) + #expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/dev-77/renew") + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + + // …the body is `{ csr }` ONLY (a stray field 400s every silent renewal)… + let body = try #require(transport.jsonBody(at: 0)) + #expect(Set(body.keys) == ["csr"]) + + // …and the CSR re-uses the ENROLLED key (a new key would fail the server's + // PoP-against-the-enrolled-key check) with the stored device name as CN. + let csrDER = try #require(transport.csrDER(at: 0)) + let csr = try #require(parseCSR(csrDER)) + #expect(csr.publicPointX963 == (try enrolledKey.publicKeyX963())) + #expect(csr.subjectCommonName == "Yaojia iPad") + + // …and a failed rotation leaves the EXISTING enrollment intact. + let state = try #require(try store.renewalState()) + #expect(state.deviceId == "dev-77") + #expect(state.renewAfter == Date(timeIntervalSinceReferenceDate: 790_000_000)) +} + +@Test("the flow's production install step is wired to the keychain store's SE enroll", .keychainSerialized) +func enrollmentFlowWiresTheKeychainStore() async throws { + // Arrange — the `store:` convenience initializer is the composition the app + // uses: login → bearer → enroll → install. Its install step deliberately + // takes NO keyProvider, i.e. it always asks for a real Secure-Enclave key. + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + let loginBody = try JSONSerialization.data(withJSONObject: [ + "enrollToken": "enroll-bearer", "accountId": "acct-1", "expiresIn": 300, + ]) + let transport = RecordingEnrollmentTransport(replies: [ + .init(status: 201, body: loginBody), + .init(status: 201, body: enrollmentResponseJSON()), + ]) + let flow = DeviceEnrollmentFlow( + baseURL: controlPlaneURL, transport: transport, store: store + ) + + // Act + var thrown: Error? + do { + _ = try await flow.run( + password: "account-password", subdomain: "yaojia", deviceName: "iPhone" + ) + } catch { + thrown = error + } + + // Assert — login happened first, with the password and nothing else… + let loginRequest = try #require(transport.requests.first) + #expect(loginRequest.url?.path == "/auth/login") + #expect(Set(try #require(transport.jsonBody(at: 0)).keys) == ["password"]) + + // …and the failure came from the INSTALL step, not the login step: the + // convenience initializer really does route into the keychain store. Off + // device (macOS `swift test`, Simulator) `generateSecureEnclave` cannot mint + // a key, so `.secureEnclaveUnavailable` is the expected stop; an entitled + // host gets as far as the undecodable leaf (`.corruptStoredBlob`). + let error = try #require(thrown) + #expect(error is SecureEnclaveKeyError || error is ClientIdentityStoreError) + #expect(error is ControlPlaneLoginError == false) + #expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0) +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLeafTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLeafTests.swift new file mode 100644 index 0000000..c57d066 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLeafTests.swift @@ -0,0 +1,151 @@ +import Foundation +import Security +import Testing +@testable import ClientTLS + +// B4 · The persistence half of enrollment: what `storeEnrolledLeaf` actually +// leaves in the keychain. Three cases, all reachable off-device because the +// server's answer is a REAL certificate here (`EnrolledLeafFixtures`): +// +// 1. first enroll → leaf installed + enrollment record ADDED +// 2. rotation (new leaf) → distinct leaf added, record UPDATED in place +// 3. retry (same leaf) → errSecDuplicateItem tolerated, record still correct +// +// Case 2/3 matter because the record write and the leaf write are separate +// keychain operations: a device whose record says "dev-B" while the installed +// leaf is "dev-A" cannot renew (the server checks the PoP against the enrolled +// key for THAT deviceId). +// +// These tests install certificates into the real keychain, so each one sweeps the +// fixture leaves before AND after itself — see `EnrolledLeafFixtures`. + +private let controlPlaneURL = URL(string: "https://cp.terminal.yaojia.wang")! + +/// Run `body` with the fixture leaves swept on both sides. Actor-isolated like +/// its callers so nothing crosses a concurrency boundary. +private func withCleanLeafKeychain( + _ body: (StoreKeychainKeys, KeychainClientIdentityStore) async throws -> Void +) async throws { + let keys = StoreKeychainKeys.unique() + EnrolledLeafFixtures.purgeFixtureLeaves() + defer { + EnrolledLeafFixtures.purgeFixtureLeaves() + KeychainProbe.purge(keys) + } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + try await body(keys, store) +} + +private func enrollmentClient( + _ transport: RecordingEnrollmentTransport +) -> DeviceEnrollmentClient { + DeviceEnrollmentClient( + baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport + ) +} + +@Test("a first enroll installs the leaf and records the server's rotation timing", .keychainSerialized) +func enrollInstallsLeafAndRecord() async throws { + try await withCleanLeafKeychain { keys, store in + // Arrange + #expect(try store.renewalState() == nil) + let transport = RecordingEnrollmentTransport( + status: 201, + body: enrollmentResponseJSON( + deviceId: "dev-first", + certDER: EnrolledLeafFixtures.leaf, + caChain: [EnrolledLeafFixtures.issuer], + notAfter: "2026-10-28T00:00:00.000Z", + renewAfter: "2026-09-27T00:00:00.000Z" + ) + ) + + // Act — the returned summary is NOT asserted: reading it goes through + // `SecItemCopyMatching(kSecClassIdentity)`, which on macOS ignores the + // key tag and can hand back an unrelated login-keychain identity (see + // `defaultKeychainYieldsForeignIdentity`). What is persisted IS asserted. + _ = try await store.enroll( + using: enrollmentClient(transport), subdomain: "yaojia", deviceName: "Yaojia iPhone", + keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() } + ) + + // Assert — exactly one leaf installed… + #expect(EnrolledLeafFixtures.installedCount() == 1) + // …and the record carries what the server said, so a later renew can + // address the right device. + let state = try #require(try store.renewalState()) + #expect(state.deviceId == "dev-first") + #expect(state.notAfter != nil) + #expect(state.renewAfter != nil) + #expect(state.isRenewalDue(asOf: try #require(state.renewAfter))) + } +} + +@Test("rotation installs the new leaf and updates the record in place", .keychainSerialized) +func rotationUpdatesRecordInPlace() async throws { + try await withCleanLeafKeychain { keys, store in + // Arrange — already enrolled. + let first = RecordingEnrollmentTransport( + status: 201, + body: enrollmentResponseJSON( + deviceId: "dev-old", certDER: EnrolledLeafFixtures.leaf + ) + ) + _ = try await store.enroll( + using: enrollmentClient(first), subdomain: "yaojia", deviceName: "iPhone", + keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() } + ) + #expect(try store.renewalState()?.deviceId == "dev-old") + + // Act — a rotation returns a DISTINCT leaf. + let second = RecordingEnrollmentTransport( + status: 201, + body: enrollmentResponseJSON( + deviceId: "dev-new", certDER: EnrolledLeafFixtures.rotatedLeaf + ) + ) + _ = try await store.enroll( + using: enrollmentClient(second), subdomain: "yaojia", deviceName: "iPhone", + keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() } + ) + + // Assert — the record is UPDATED (one item, new value), never duplicated: + // two records at the same (service, account) would make renew pick one at + // random. + #expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 1) + #expect(try store.renewalState()?.deviceId == "dev-new") + // …and a leaf is installed at every step — the anti-lockout invariant + // (ordering itself is pinned by KeychainItemReplaceTests). + #expect(EnrolledLeafFixtures.installedCount() >= 1) + } +} + +@Test("re-storing a byte-identical leaf is tolerated and keeps the record correct", .keychainSerialized) +func reEnrollingSameLeafIsIdempotent() async throws { + try await withCleanLeafKeychain { keys, store in + // Arrange — an interrupted enroll that is retried: the server re-issues + // the SAME certificate. + let body = enrollmentResponseJSON( + deviceId: "dev-retry", certDER: EnrolledLeafFixtures.leaf + ) + let first = RecordingEnrollmentTransport(status: 201, body: body) + _ = try await store.enroll( + using: enrollmentClient(first), subdomain: "yaojia", deviceName: "iPhone", + keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() } + ) + + // Act — `SecItemAdd` now answers errSecDuplicateItem, which must NOT be + // treated as a failure (the leaf we wanted installed IS installed). + let retry = RecordingEnrollmentTransport(status: 201, body: body) + await #expect(throws: Never.self) { + _ = try await store.enroll( + using: enrollmentClient(retry), subdomain: "yaojia", deviceName: "iPhone", + keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() } + ) + } + + // Assert — still one leaf, and the record is intact. + #expect(EnrolledLeafFixtures.installedCount() == 1) + #expect(try store.renewalState()?.deviceId == "dev-retry") + } +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLiveTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLiveTests.swift new file mode 100644 index 0000000..2c48fba --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLiveTests.swift @@ -0,0 +1,210 @@ +import Foundation +import Security +import Testing +@testable import ClientTLS + +// B4 · `KeychainClientIdentityStore` against the REAL keychain — the legacy +// `.p12` half (the dual-trust migration window: devices enrolled before the +// Secure-Enclave path still carry an imported `.p12`). +// +// This package has no `SecItemShim` seam (unlike HostRegistry), so these tests +// drive the actual `SecItem*` calls. Each test gets a UUID-scoped service and +// purges it afterwards, so runs never collide and nothing is left behind. +// +// WHAT THIS LAYER CANNOT CHECK: the `kSecAttrAccessible` protection class. On +// macOS `swift test` reaches the FILE-BASED keychain, which has no +// data-protection class at all — a stored item's attributes come back as only +// `acct/cdat/class/labl/mdat/svce`, with no `pdmn` (verified 2026-07-30). The +// "AfterFirstUnlockThisDeviceOnly, never synchronized" assertion therefore +// belongs to a SIGNED simulator host, exactly as plan §9 already records for +// `KeychainHostStore`. It is NOT covered by this package's gate. + +@Test("save persists exactly one item holding the .p12 and its passphrase", .keychainSerialized) +func keychainStoreSavePersistsSingleItem() async throws { + // Arrange + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + + // Act + try store.save( + p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase + ) + + // Assert — one item, and the blob carries BOTH halves (the passphrase is + // required to re-import at every launch, so storing the .p12 alone would + // brick the identity after a relaunch). + #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1) + let stored = try #require(storedP12Fields(service: keys.service, account: keys.account)) + #expect(stored.p12 == ClientTLSFixtures.deviceP12Data.base64EncodedString()) + #expect(stored.passphrase == ClientTLSFixtures.passphrase) +} + +@Test("saving again REPLACES the stored blob instead of duplicating the item", .keychainSerialized) +func keychainStoreSaveReplaces() async throws { + // Arrange + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + try store.save( + p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase + ) + let first = try #require(storedP12Fields(service: keys.service, account: keys.account)) + + // Act — same coordinates, re-saved (the rotation / re-install path). + try store.save( + p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase + ) + + // Assert — still exactly one item (a duplicate would make loads + // order-dependent, i.e. an old cert could resurface after rotation) and the + // content is intact. + #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1) + let second = try #require(storedP12Fields(service: keys.service, account: keys.account)) + #expect(second == first) +} + +@Test("a wrong passphrase throws and writes NOTHING to the keychain", .keychainSerialized) +func keychainStoreSaveRejectsWrongPassphrase() async throws { + // Arrange + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + + // Act / Assert — validation happens BEFORE persistence. + #expect(throws: PKCS12ImportError.wrongPassphrase) { + try store.save(p12Data: ClientTLSFixtures.deviceP12Data, passphrase: "wrong") + } + #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0) +} + +@Test("a corrupt .p12 throws .corruptFile and leaves the PRIOR identity installed", .keychainSerialized) +func keychainStoreSaveKeepsPriorIdentityOnCorruptFile() async throws { + // Arrange — a good identity is already installed. + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + try store.save( + p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase + ) + let good = try #require(storedP12Fields(service: keys.service, account: keys.account)) + + // Act — a truncated file arrives (bad download / wrong file picked). + #expect(throws: PKCS12ImportError.corruptFile) { + try store.save( + p12Data: ClientTLSFixtures.deviceP12Data.prefix(64), + passphrase: ClientTLSFixtures.passphrase + ) + } + + // Assert — the working identity is untouched, not wiped by the failed install. + #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1) + let survivor = try #require(storedP12Fields(service: keys.service, account: keys.account)) + #expect(survivor == good) +} + +@Test("remove deletes the stored blob and a second remove is a no-op", .keychainSerialized) +func keychainStoreRemoveIsIdempotent() async throws { + // Arrange + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + try store.save( + p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase + ) + #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1) + + // Act + try store.remove() + + // Assert — gone, and removing again must NOT throw errSecItemNotFound + // (removal is reachable from "delete host" with no identity installed). + #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0) + #expect(throws: Never.self) { try store.remove() } + #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0) +} + +@Test("remove also clears the enrollment record and the device key", .keychainSerialized) +func keychainStoreRemoveClearsEnrolledState() async throws { + // Arrange — a device that enrolled (record + device key) AND carries a + // legacy .p12, i.e. mid-migration. + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + try store.save( + p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase + ) + KeychainProbe.write( + service: keys.service, account: keys.enrollmentAccount, + data: storedEnrollmentJSON(deviceId: "dev-remove", deviceName: "iPhone") + ) + _ = try SecureEnclaveKeyFactory.generateSoftware(tag: keys.deviceKeyTag, permanent: true) + + // Act + try store.remove() + + // Assert — removal is unconditional across BOTH paths; a leftover device key + // would make a later enroll bind a new leaf to a stale key. + #expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0) + #expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0) + #expect(try SecureEnclaveKeyFactory.load(tag: keys.deviceKeyTag) == nil) + #expect(try store.renewalState() == nil) +} + +// The `.p12` fallback inside `loadIdentity()` is only reachable when the default +// keychain holds no identities: on macOS the `kSecClassIdentity` lookup ignores +// `kSecAttrApplicationTag` and returns an arbitrary foreign identity, which +// short-circuits the fallback (see `defaultKeychainYieldsForeignIdentity`). The +// test is gated rather than fudged — it runs on a clean runner and is reported as +// skipped on a developer machine with identities in the login keychain. +@Test( + "save → loadIdentity → loadSummary roundtrips through the real keychain", + .enabled(if: !defaultKeychainYieldsForeignIdentity()) +, .keychainSerialized) +func keychainStoreLoadsBackTheStoredIdentity() async throws { + // Arrange + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + #expect(store.hasInstalledIdentity() == false) + #expect(try store.loadIdentity() == nil) + + // Act + try store.save( + p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase + ) + + // Assert — re-imported from the stored bytes + passphrase. + #expect(store.hasInstalledIdentity() == true) + let identity = try #require(try store.loadIdentity()) + #expect(identity.summary()?.subjectCommonName == ClientTLSFixtures.leafCommonName) + let summary = try #require(try store.loadSummary()) + #expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName) + + // Act / Assert — and after removal the gate closes again. + try store.remove() + #expect(store.hasInstalledIdentity() == false) + #expect(store.loadedIdentityOrNil() == nil) +} + +@Test( + "a corrupt stored blob surfaces .corruptStoredBlob, and loadedIdentityOrNil swallows it", + .enabled(if: !defaultKeychainYieldsForeignIdentity()) +, .keychainSerialized) +func keychainStoreCorruptBlobIsTyped() async throws { + // Arrange — bytes that are not the stored JSON envelope (a partial write, or + // an item written by an older schema). + let keys = StoreKeychainKeys.unique() + defer { KeychainProbe.purge(keys) } + KeychainProbe.write( + service: keys.service, account: keys.account, data: Data("not-json".utf8) + ) + let store = KeychainClientIdentityStore(service: keys.service, account: keys.account) + + // Act / Assert — typed error for the install UI… + #expect(throws: ClientIdentityStoreError.corruptStoredBlob) { + _ = try store.loadIdentity() + } + // …and launch does not crash: the convenience wrapper logs and degrades. + #expect(store.loadedIdentityOrNil() == nil) +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestProbe.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestProbe.swift new file mode 100644 index 0000000..f8cee46 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestProbe.swift @@ -0,0 +1,134 @@ +import Foundation +import Security +@testable import ClientTLS + +// B4 · Direct `SecItem*` access for the store tests. Two jobs: +// 1. INSPECT what `KeychainClientIdentityStore` actually wrote — the protection +// class and the no-iCloud-sync flag are security requirements (plan §5.3 / +// contract §1.1) that only a raw read can confirm. +// 2. SEED / CORRUPT the persisted records so the read-side error paths and the +// renew path can be driven without first performing a real enrollment +// (which would have to install a certificate — see `undecodableCertificateDER`). + +/// The keychain coordinates a `KeychainClientIdentityStore(service:account:)` +/// derives internally. Mirrored here ON PURPOSE: the persisted layout is a +/// migration contract, so the tests pin it rather than infer it. +struct StoreKeychainKeys { + let service: String + let account: String + + /// `KeychainClientIdentityStore.enrollmentAccount`. + var enrollmentAccount: String { "\(account).enrollment" } + /// `KeychainClientIdentityStore.deviceKeyTag`. + var deviceKeyTag: Data { Data("\(service).device-key".utf8) } + /// `KeychainClientIdentityStore.leafLabel`. + var leafLabel: String { "\(service).device-leaf" } + + /// A fresh, collision-free coordinate set for one test. + static func unique() -> StoreKeychainKeys { + StoreKeychainKeys( + service: "com.yaojia.webterm.clienttls.test-\(UUID().uuidString)", + account: "device-identity" + ) + } +} + +enum KeychainProbe { + private static func query(service: String, account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + /// How many generic-password items sit at `(service, account)`. `0` = nothing + /// stored; `> 1` = a write duplicated instead of replacing. + /// + /// Attributes-only, `kSecMatchLimitAll`: asking for `kSecReturnData` + /// together with `kSecMatchLimitAll` is `errSecParam (-50)` on the macOS + /// file-based keychain (verified 2026-07-30), so counting and reading are + /// separate calls. + static func count(service: String, account: String) -> Int { + var request = query(service: service, account: account) + request[kSecReturnAttributes as String] = true + request[kSecMatchLimit as String] = kSecMatchLimitAll + var result: CFTypeRef? + guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else { + return 0 + } + if let array = result as? [Any] { return array.count } + return result == nil ? 0 : 1 + } + + /// The stored bytes at `(service, account)`; `nil` when no item exists. + static func data(service: String, account: String) -> Data? { + var request = query(service: service, account: account) + request[kSecReturnData as String] = true + request[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else { + return nil + } + return result as? Data + } + + /// Write raw bytes at `(service, account)` with the store's own protection + /// class — used to seed or corrupt a record. + @discardableResult + static func write(service: String, account: String, data: Data) -> OSStatus { + SecItemDelete(query(service: service, account: account) as CFDictionary) + var attributes = query(service: service, account: account) + attributes[kSecValueData as String] = data + attributes[kSecAttrAccessible as String] = + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + return SecItemAdd(attributes as CFDictionary, nil) + } + + @discardableResult + static func delete(service: String, account: String) -> OSStatus { + SecItemDelete(query(service: service, account: account) as CFDictionary) + } + + /// Remove everything a test could have created under `keys`. + static func purge(_ keys: StoreKeychainKeys) { + delete(service: keys.service, account: keys.account) + delete(service: keys.service, account: keys.enrollmentAccount) + try? SecureEnclaveKeyFactory.delete(tag: keys.deviceKeyTag) + } +} + +/// The two fields of the stored `.p12` envelope, decoded from the keychain item. +/// +/// Compared field-by-field rather than byte-by-byte because Foundation's +/// `JSONEncoder` serializes through an unordered dictionary on Darwin: two +/// encodings of the SAME value differ in key order (verified 2026-07-30 — both +/// blobs 2109 bytes, not equal), so byte equality is not a valid invariant. +func storedP12Fields(service: String, account: String) -> (p12: String, passphrase: String)? { + guard let data = KeychainProbe.data(service: service, account: account), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let p12 = json["p12"] as? String, + let passphrase = json["passphrase"] as? String + else { return nil } + return (p12, passphrase) +} + +/// The JSON `StoredEnrollment` shape (`JSONEncoder` defaults: `Data` → base64 +/// string, `Date` → seconds since the reference date). Written directly so the +/// read path can be exercised without installing a leaf certificate. +func storedEnrollmentJSON( + deviceId: String, + deviceName: String, + caChain: [Data] = [], + notAfter: Date? = nil, + renewAfter: Date? = nil +) -> Data { + var json: [String: Any] = [ + "deviceId": deviceId, + "deviceName": deviceName, + "caChain": caChain.map { $0.base64EncodedString() }, + ] + if let notAfter { json["notAfter"] = notAfter.timeIntervalSinceReferenceDate } + if let renewAfter { json["renewAfter"] = renewAfter.timeIntervalSinceReferenceDate } + return try! JSONSerialization.data(withJSONObject: json) +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestSerialization.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestSerialization.swift new file mode 100644 index 0000000..f316082 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestSerialization.swift @@ -0,0 +1,92 @@ +import Foundation +import Security +import Testing + +// B4 · Why every real-keychain test in this package runs one at a time. +// +// `KeychainClientIdentityStore` / `SecureEnclaveKeyFactory` call `SecItem*` and +// `SecKeyCreateRandomKey` WITHOUT `kSecUseDataProtectionKeychain`, so on macOS +// `swift test` reaches the FILE-BASED login keychain. Two problems follow from +// Swift Testing's default parallel execution: +// +// 1. That backend is not safe for concurrent writes from one process: two +// simultaneous permanent-key generations intermittently fail with +// keyGenerationFailed("… Code=-25300 \"failed to generate CDSA key\" …") +// (observed 2026-07-30 — green under `--no-parallel`, red in parallel). +// 2. Certificate items are keyed by the cert's own subject on macOS, so the +// fixture-leaf sweep is GLOBAL: one test's cleanup would delete a +// concurrently-running test's freshly installed leaf. +// +// A global actor is NOT enough: an actor releases its executor at every `await`, +// and these tests await network-stub calls in the middle of their critical +// section (proved by exactly failure mode 2 appearing when a `@globalActor` was +// used). What is needed is a mutex held ACROSS suspension points — applied as a +// custom trait so the whole test body, setup and cleanup included, is inside it. + +/// A FIFO async mutex. +actor KeychainTestMutex { + static let shared = KeychainTestMutex() + + private var isHeld = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + guard isHeld else { + isHeld = true + return + } + await withCheckedContinuation { waiters.append($0) } + } + + func release() { + guard waiters.isEmpty else { + waiters.removeFirst().resume() // hand the lock straight to the next waiter + return + } + isHeld = false + } +} + +/// Serializes a test against every other keychain-touching test. +struct KeychainSerializedTrait: TestTrait, SuiteTrait, TestScoping { + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: @Sendable () async throws -> Void + ) async throws { + await KeychainTestMutex.shared.acquire() + do { + try await function() + } catch { + await KeychainTestMutex.shared.release() + throw error + } + await KeychainTestMutex.shared.release() + } +} + +extension Trait where Self == KeychainSerializedTrait { + /// Apply to every test that touches the real keychain. + static var keychainSerialized: Self { Self() } +} + +/// Does the default keychain hand back an identity for a tag that was never +/// installed? +/// +/// On macOS's file-based keychain, `SecItemCopyMatching(kSecClass: +/// kSecClassIdentity, kSecAttrApplicationTag: …)` IGNORES the tag and returns an +/// arbitrary identity from the login keychain (verified 2026-07-30 with a random +/// tag: `errSecSuccess` + an unrelated `localhost` identity). On iOS's +/// data-protection keychain the tag IS honored, which is why the production code +/// is correct on the shipping platform — but it means the `.p12` fallback branch +/// of `loadIdentity()` is only reachable in a `swift test` run whose default +/// keychain holds no identities (a clean CI runner). Tests that depend on that +/// branch are gated on this probe instead of being silently wrong. +func defaultKeychainYieldsForeignIdentity() -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrApplicationTag as String: Data("clienttls-probe-\(UUID().uuidString)".utf8), + kSecMatchLimit as String: kSecMatchLimitOne, + ] + return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KnownGoodCSRVector.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KnownGoodCSRVector.swift new file mode 100644 index 0000000..869ac23 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KnownGoodCSRVector.swift @@ -0,0 +1,70 @@ +import Foundation +import Security +@testable import ClientTLS + +/// B4 · A KNOWN-GOOD PKCS#10 vector produced by OpenSSL, used to pin the +/// hand-written DER encoder in `CertificateSigningRequest` byte-for-byte. +/// +/// The ECDSA signature is randomized, so the stable part of a CSR is its +/// `CertificationRequestInfo` — for a fixed key + fixed subject it is fully +/// deterministic. That is what is pinned here: if our encoder ever drifts (a +/// non-minimal length, a PrintableString instead of UTF8String, a wrong curve +/// OID, an attributes SET that is not `A0 00`), the bytes stop matching +/// OpenSSL's and this test fails. +/// +/// Regenerate (OpenSSL 3.0.18, 2026-07-30): +/// openssl ecparam -name prime256v1 -genkey -noout -out fixed.key.pem +/// openssl req -new -key fixed.key.pem -subj "/CN=web-terminal-device" \ +/// -outform DER -out csr.der +/// # CertificationRequestInfo = the first child of the outer SEQUENCE: +/// openssl asn1parse -inform DER -in csr.der -i # → offset 3, hl 3, l 128 +/// dd if=csr.der bs=1 skip=3 count=131 | base64 +/// # private key as X9.63 (0x04||X||Y||K) for SecKeyCreateWithData: +/// openssl ec -in fixed.key.pem -text -noout # → pub(65) || priv(32) +/// +/// SECURITY: `privateKeyX963Base64` is a THROWAWAY key generated solely for this +/// vector. It protects nothing, is never enrolled, and is never written to a +/// keychain (`SecKeyCreateWithData` keeps it in-process). Same convention as the +/// embedded `device.p12` + passphrase in `ClientTLSFixtures`. +enum KnownGoodCSR { + static let subjectCommonName = "web-terminal-device" + + /// OpenSSL's `CertificationRequestInfo` DER for `privateKeyX963Base64` + + /// `CN=web-terminal-device` (131 bytes). + static let certificationRequestInfoBase64 = """ + MIGAAgEAMB4xHDAaBgNVBAMME3dlYi10ZXJtaW5hbC1kZXZpY2UwWTATBgcqhkjOPQIBBggqhkjO\ + PQMBBwNCAARiJ8iQGXzCgho1TRYPNNcnqtgK/mrsNf+CAvoPbSH+12v1Q2OfVRgVPVYeS2AO3y87\ + Oel9Uxe2QsHluJnGoifSoAA= + """ + + /// `0x04 || X(32) || Y(32) || K(32)` — the format `SecKeyCreateWithData` + /// expects for an EC private key. + static let privateKeyX963Base64 = """ + BGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs56X1TF7ZC\ + weW4mcaiJ9Ko07ifqUA6io//Czd0XRMztqg+nY0OJcA1Y1LwoBMBbA== + """ + + static var certificationRequestInfoDER: Data { + Data(base64Encoded: certificationRequestInfoBase64, options: .ignoreUnknownCharacters)! + } + + /// The fixed key as a `P256HardwareKey`, in-process only (no keychain item). + static func fixedKey() throws -> SecureEnclaveKey { + let blob = Data( + base64Encoded: privateKeyX963Base64, options: .ignoreUnknownCharacters + )! + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeyClass as String: kSecAttrKeyClassPrivate, + kSecAttrKeySizeInBits as String: 256, + ] + var error: Unmanaged? + guard let key = SecKeyCreateWithData(blob as CFData, attributes as CFDictionary, &error) + else { + throw SecureEnclaveKeyError.keyGenerationFailed( + String(describing: error?.takeRetainedValue()) + ) + } + return SecureEnclaveKey(privateKey: key) + } +} diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/SecureEnclaveKeyTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/SecureEnclaveKeyTests.swift new file mode 100644 index 0000000..30c28e9 --- /dev/null +++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/SecureEnclaveKeyTests.swift @@ -0,0 +1,183 @@ +import Foundation +import Security +import Testing +@testable import ClientTLS + +// B4 · The device key factory. Two things must hold for the enrollment path to +// work at all: (1) a PERMANENT tagged key survives and is found again by tag — +// that is what lets the enrolled leaf bind to it and `kSecClassIdentity` +// assemble; (2) when the Secure Enclave is not usable (Simulator, missing +// entitlement) the failure is CLASSIFIED as `.secureEnclaveUnavailable`, because +// that is the signal callers use to fall back to a software key. A +// `.keyGenerationFailed` there would look like a bug instead of a platform +// limit and the fallback would never happen. +// +// Runs against the REAL keychain (no shim exists in this package): each test +// uses a UUID-scoped tag and deletes it again, so nothing leaks between runs. + +/// A per-test keychain tag — never collides with another test or another run. +private func uniqueTag() -> Data { + Data("com.yaojia.webterm.clienttls.test.key-\(UUID().uuidString)".utf8) +} + +@Test("a software key exposes a 65-byte uncompressed X9.63 point and signs verifiably") +func softwareKeySignsVerifiably() throws { + // Arrange + let key = try SecureEnclaveKeyFactory.generateSoftware() + + // Act + let point = try key.publicKeyX963() + let message = Data("certificationRequestInfo".utf8) + let signature = try key.sign(message) + + // Assert — X9.63 uncompressed form, and an X9.62 DER ECDSA signature that + // verifies under the same algorithm the server's PoP check uses. + #expect(point.count == 65) + #expect(point.first == 0x04) + let publicKey = try #require( + SecKeyCreateWithData( + point as CFData, + [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeyClass as String: kSecAttrKeyClassPublic, + kSecAttrKeySizeInBits as String: 256, + ] as CFDictionary, + nil + ) + ) + #expect( + SecKeyVerifySignature( + publicKey, .ecdsaSignatureMessageX962SHA256, + message as CFData, signature as CFData, nil + ) + ) + #expect(signature.first == 0x30) // SEQUENCE { r, s } +} + +@Test("a non-permanent key is NOT stored in the keychain", .keychainSerialized) +func nonPermanentKeyIsNotPersisted() async throws { + // Arrange + let tag = uniqueTag() + defer { try? SecureEnclaveKeyFactory.delete(tag: tag) } + + // Act — tagged but permanent: false (the unit-test / Simulator shape). + _ = try SecureEnclaveKeyFactory.generateSoftware(tag: tag, permanent: false) + + // Assert + #expect(try SecureEnclaveKeyFactory.load(tag: tag) == nil) +} + +@Test("a permanent tagged key is found again by tag and deleted idempotently", .keychainSerialized) +func permanentKeyRoundtripsByTag() async throws { + // Arrange + let tag = uniqueTag() + defer { try? SecureEnclaveKeyFactory.delete(tag: tag) } + #expect(try SecureEnclaveKeyFactory.load(tag: tag) == nil) // pre-enroll state + + // Act + let generated = try SecureEnclaveKeyFactory.generateSoftware(tag: tag, permanent: true) + let loaded = try SecureEnclaveKeyFactory.load(tag: tag) + + // Assert — the SAME key comes back (rotation must re-sign with it, never + // mint a new one). + let reloaded = try #require(loaded) + #expect(try reloaded.publicKeyX963() == (try generated.publicKeyX963())) + + // Act — delete, then delete again. + try SecureEnclaveKeyFactory.delete(tag: tag) + + // Assert — gone, and a second delete is a no-op (not a throw). + #expect(try SecureEnclaveKeyFactory.load(tag: tag) == nil) + #expect(throws: Never.self) { try SecureEnclaveKeyFactory.delete(tag: tag) } +} + +@Test("two keys under different tags stay independent", .keychainSerialized) +func tagsScopeKeysIndependently() async throws { + // Arrange + let tagA = uniqueTag() + let tagB = uniqueTag() + defer { + try? SecureEnclaveKeyFactory.delete(tag: tagA) + try? SecureEnclaveKeyFactory.delete(tag: tagB) + } + + // Act + let keyA = try SecureEnclaveKeyFactory.generateSoftware(tag: tagA, permanent: true) + let keyB = try SecureEnclaveKeyFactory.generateSoftware(tag: tagB, permanent: true) + + // Assert + #expect(try keyA.publicKeyX963() != (try keyB.publicKeyX963())) + #expect(try SecureEnclaveKeyFactory.load(tag: tagA)?.publicKeyX963() + == (try keyA.publicKeyX963())) + #expect(try SecureEnclaveKeyFactory.load(tag: tagB)?.publicKeyX963() + == (try keyB.publicKeyX963())) + + // Deleting one leaves the other installed. + try SecureEnclaveKeyFactory.delete(tag: tagA) + #expect(try SecureEnclaveKeyFactory.load(tag: tagA) == nil) + #expect(try SecureEnclaveKeyFactory.load(tag: tagB) != nil) +} + +@Test("Secure-Enclave keygen without the entitlement fails as .secureEnclaveUnavailable", .keychainSerialized) +func secureEnclaveKeygenClassifiesUnavailability() async throws { + // Arrange + let tag = uniqueTag() + defer { try? SecureEnclaveKeyFactory.delete(tag: tag) } + + // Act / Assert — an UNSIGNED test binary has no + // `com.apple.developer.kernel...`/keychain-access-group entitlement, so + // SecKeyCreateRandomKey over the SE token fails with -34018. What is under + // test is the CLASSIFICATION: it must be `.secureEnclaveUnavailable` + // (callers' fallback signal), never `.keyGenerationFailed`. + // + // On a host that CAN mint an SE key (entitled build on real hardware) the + // call legitimately succeeds; then the key must be a usable signer. Both + // outcomes are asserted so this test is honest on every machine. + do { + let key = try SecureEnclaveKeyFactory.generateSecureEnclave(tag: tag) + let signature = try key.sign(Data("probe".utf8)) + #expect(signature.first == 0x30) + } catch let error as SecureEnclaveKeyError { + guard case let .secureEnclaveUnavailable(description) = error else { + Issue.record("SE keygen failure must classify as .secureEnclaveUnavailable: \(error)") + return + } + #expect(description.isEmpty == false) // the underlying CFError is kept for logs + } +} + +@Test("signing with a public-only key surfaces .signatureFailed instead of crashing") +func signingWithPublicKeyFails() throws { + // Arrange — wrap a PUBLIC key in the signer (the shape a mis-wired + // composition root could produce). + let point = try SecureEnclaveKeyFactory.generateSoftware().publicKeyX963() + let publicKey = try #require( + SecKeyCreateWithData( + point as CFData, + [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeyClass as String: kSecAttrKeyClassPublic, + kSecAttrKeySizeInBits as String: 256, + ] as CFDictionary, + nil + ) + ) + let key = SecureEnclaveKey(privateKey: publicKey) + + // Act / Assert + do { + _ = try key.sign(Data("m".utf8)) + Issue.record("signing with a public key must throw") + } catch let error as SecureEnclaveKeyError { + guard case let .signatureFailed(description) = error else { + Issue.record("expected .signatureFailed, got \(error)") + return + } + #expect(description.isEmpty == false) + } +} + +@Test("a nil CFError is described as 'unknown' rather than crashing the error path") +func describeNilError() { + #expect(SecureEnclaveKey.describe(nil) == "unknown") +} diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/AccessToken.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/AccessToken.swift new file mode 100644 index 0000000..1430ab3 --- /dev/null +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/AccessToken.swift @@ -0,0 +1,89 @@ +import Foundation + +/// Complete failure taxonomy for a per-host access token: the three validation +/// verdicts plus the one store-level miss. One enum so the UI has a single +/// thing to switch over when it saves a token (never a generic `Error`). +public enum AccessTokenError: Error, Equatable { + case tooShort(length: Int) + case tooLong(length: Int) + /// Deliberately payload-free. An error value gets logged, wrapped in an + /// alert string, and attached to crash reports — carrying the rejected + /// token here would turn every one of those into a leak (§5.3). The + /// length cases carry only a length, which is not secret. + case invalidCharacters + /// `setAccessToken` for an id that is not in the store. Explicit error + /// rather than a silent no-op: a UI that thinks it saved a token but + /// didn't would send unauthenticated requests forever. + case unknownHost(UUID) +} + +/// The host's shared access token (`WEBTERM_TOKEN`), validated at the boundary. +/// +/// Charset and length are the FROZEN server contract (coordination doc §1.1), +/// byte-for-byte the same rule the server enforces at config load — +/// `/^[A-Za-z0-9._~+/=-]{16,512}$/` (src/config.ts:178). Validating here means +/// a token that cannot possibly authenticate is rejected at the keyboard +/// instead of turning into a mystery 401 later, and a value of this type is +/// always safe to place verbatim in a `Cookie: webterm_auth=` header (the +/// charset is exactly the cookie-safe one — nothing to escape). +/// +/// SECRET MATERIAL (§5.3). Three deliberate properties keep it out of logs: +/// - `description`/`debugDescription` are redacted, so string interpolation +/// (`"\(token)"`) — the way a token usually reaches a log — cannot leak it. +/// - `customMirror` hides the storage, so `dump()` and reflection-based crash +/// reporters see `` too. +/// - it is intentionally NOT `Codable`: nothing can serialize a token by +/// accident. `Host` encodes it explicitly (one audited call site) into the +/// Keychain item, which is the only place a token is ever persisted. +public struct AccessToken: Sendable, Hashable, RawRepresentable, + CustomStringConvertible, CustomDebugStringConvertible, + CustomReflectable { + /// §1.1 length window (server: `{16,512}`). + public static let minLength = 16 + public static let maxLength = 512 + + /// §1.1 charset `[A-Za-z0-9._~+/=-]`. A `Set` so validation is O(n) in the + /// token length with no regex engine in the hot path. + private static let allowedCharacters: Set = Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~+/=-" + ) + + /// The token, verbatim as it must appear in the `Cookie` header. + public let rawValue: String + + /// Validating init — the ONLY way a token enters the app. + /// + /// Leading/trailing whitespace is trimmed first: tokens arrive by paste, + /// and whitespace is never a legal token character, so trimming can never + /// change the meaning of a valid token. Interior whitespace is NOT + /// tolerated (that would be silent normalization of a secret). + public init(validating raw: String) throws { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let length = trimmed.count + guard length >= Self.minLength else { throw AccessTokenError.tooShort(length: length) } + guard length <= Self.maxLength else { throw AccessTokenError.tooLong(length: length) } + guard trimmed.allSatisfy(Self.allowedCharacters.contains) else { + throw AccessTokenError.invalidCharacters + } + self.rawValue = trimmed + } + + /// Lenient seam over the same rules (`RawRepresentable`): invalid → nil. + /// Used where a rejected value must degrade instead of failing a whole + /// operation — decoding a tampered Keychain record (see `Host`). + /// + /// Note: whitespace-padded input normalizes, so `rawValue` round-trips + /// only for already-trimmed strings. + public init?(rawValue: String) { + guard let token = try? AccessToken(validating: rawValue) else { return nil } + self = token + } + + public var description: String { "AccessToken(redacted, \(rawValue.count) chars)" } + + public var debugDescription: String { description } + + public var customMirror: Mirror { + Mirror(self, children: ["rawValue": ""], displayStyle: .struct) + } +} diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift index 8dd3206..deae786 100644 --- a/ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift @@ -8,16 +8,90 @@ import WireProtocol /// Note: on macOS this shadows `Foundation.Host` (NSHost) inside this module; /// downstream test/app targets that import Foundation should alias /// `typealias Host = HostRegistry.Host` or qualify. -public struct Host: Sendable, Equatable, Codable, Identifiable { +public struct Host: Sendable, Equatable, Codable, Identifiable, + CustomStringConvertible, CustomDebugStringConvertible { public let id: UUID public let name: String /// Single point of Origin/wsURL derivation — from WireProtocol, never /// redeclared here (plan §6 rule 1). public let endpoint: HostEndpoint + /// This host's access token (`WEBTERM_TOKEN`), or nil when the host has + /// no auth configured — which is the zero-config LAN default, so nil is + /// the normal state, not an error state. + /// + /// Why it lives ON the host record instead of in its own Keychain item: + /// plan §5.3 assigns the host list to the Keychain precisely as the place + /// for "host 列表(含未来 authMaterial 占位)". Piggy-backing on that one + /// item means the token inherits the audited protection policy + /// (`AfterFirstUnlockThisDeviceOnly`, never synchronizable — see + /// `KeychainItemSpec`) and, just as importantly, that removing a host + /// removes its secret in the same write — a separate item could be + /// orphaned in the Keychain forever. + public let accessToken: AccessToken? - public init(id: UUID, name: String, endpoint: HostEndpoint) { + /// `accessToken` defaults to nil so every existing three-argument call + /// site (pairing, tests, fixtures) keeps compiling and keeps meaning + /// "no token configured". + public init(id: UUID, name: String, endpoint: HostEndpoint, accessToken: AccessToken? = nil) { self.id = id self.name = name self.endpoint = endpoint + self.accessToken = accessToken } + + /// Immutable update: returns a NEW host with the token set (or cleared with + /// nil). Never mutates the receiver — the store persists the returned value. + public func withAccessToken(_ token: AccessToken?) -> Host { + Host(id: id, name: name, endpoint: endpoint, accessToken: token) + } + + // MARK: - Codable (hand-written: migration + tamper behaviour is the point) + + private enum CodingKeys: String, CodingKey { + case id, name, endpoint, accessToken + } + + /// MIGRATION SAFETY: a host paired by an earlier build has no + /// `accessToken` key at all. `decodeIfPresent` maps both "key absent" and + /// "key is null" to nil, so those records keep loading unchanged. + /// + /// TAMPER SAFETY: a stored value that no longer passes §1.1 validation + /// degrades to nil (the user re-enters a token) instead of throwing — + /// throwing here would surface as `KeychainHostStoreError.corruptedData` + /// for the WHOLE list, i.e. every paired host lost over one bad field. + /// The `endpoint` stays strictly validated (a host without a dialable URL + /// is not a host), so this leniency is scoped to the one optional field. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(UUID.self, forKey: .id) + self.name = try container.decode(String.self, forKey: .name) + self.endpoint = try container.decode(HostEndpoint.self, forKey: .endpoint) + let stored = try container.decodeIfPresent(String.self, forKey: .accessToken) + self.accessToken = stored.flatMap(AccessToken.init(rawValue:)) + } + + /// Encodes the token as a plain string under `accessToken`, and omits the + /// key entirely when there is none (an absent key is exactly what the + /// decoder above treats as "no token", so old and new shapes stay + /// interchangeable in both directions). + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(endpoint, forKey: .endpoint) + try container.encodeIfPresent(accessToken?.rawValue, forKey: .accessToken) + } + + // MARK: - Redacted description (§5.3) + + /// Hand-written so the token can never ride along into a log line: the + /// synthesized reflection dump would print the `accessToken` child, and a + /// `Host` is the natural thing to interpolate when logging a connection. + /// Only the token's PRESENCE is reported. + public var description: String { + "Host(id: \(id), name: \(name), origin: \(endpoint.originHeader), " + + "hasAccessToken: \(accessToken != nil))" + } + + public var debugDescription: String { description } } diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/HostStore.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/HostStore.swift index f2a9018..8796c62 100644 --- a/ios/Packages/HostRegistry/Sources/HostRegistry/HostStore.swift +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/HostStore.swift @@ -10,8 +10,44 @@ public protocol HostStore: Sendable { /// Returns the new collection. func upsert(_ host: Host) async throws -> [Host] /// Removing an unknown `id` is an explicit no-op: returns the unchanged - /// collection, never throws for "not found". + /// collection, never throws for "not found". Removing a host also removes + /// its `accessToken` — the token is part of the record (see `Host`), so no + /// orphaned secret can survive in storage. func remove(id: UUID) async throws -> [Host] + + /// This host's access token, or nil when none is configured / the host is + /// unknown. A read never throws for "not found" (mirrors `remove`). + func accessToken(host: UUID) async throws -> AccessToken? + /// Sets (or with nil, clears) the host's access token and returns the new + /// collection. Throws `AccessTokenError.unknownHost` rather than silently + /// dropping a token the UI believes it saved. + func setAccessToken(_ token: AccessToken?, host: UUID) async throws -> [Host] +} + +/// Generic token read/write built from `loadAll`/`upsert`, so every conformer — +/// including test doubles outside this package — gets the behaviour for free +/// and cannot drift from it. `KeychainHostStore`/`InMemoryHostStore` override +/// both to do the read-modify-write INSIDE their actor (atomic against a +/// concurrent upsert/remove); this default's two suspension points cannot be. +extension HostStore { + public func accessToken(host id: UUID) async throws -> AccessToken? { + try await loadAll().first { $0.id == id }?.accessToken + } + + public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] { + let current = try await loadAll() + guard let target = current.first(where: { $0.id == id }) else { + throw AccessTokenError.unknownHost(id) + } + guard target.accessToken != token else { return current } // no-op: unchanged + return try await upsert(target.withAccessToken(token)) + } + + /// Clearing is writing nil — named so the intent reads at the call site + /// (the token-settings UI removes a token; it does not "set nil"). + public func clearAccessToken(host id: UUID) async throws -> [Host] { + try await setAccessToken(nil, host: id) + } } // Pure collection transforms shared by all HostStore implementations (DRY); @@ -26,4 +62,12 @@ extension [Host] { func removing(id: UUID) -> [Host] { filter { $0.id != id } } + + /// Sets/clears one host's token. Returns nil when `id` is not in the + /// collection so callers can raise `AccessTokenError.unknownHost` — a + /// sentinel-free way to distinguish "unchanged" from "unknown host". + func settingAccessToken(_ token: AccessToken?, host id: UUID) -> [Host]? { + guard contains(where: { $0.id == id }) else { return nil } + return map { $0.id == id ? $0.withAccessToken(token) : $0 } + } } diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/InMemoryHostStore.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/InMemoryHostStore.swift index b8d490e..9852112 100644 --- a/ios/Packages/HostRegistry/Sources/HostRegistry/InMemoryHostStore.swift +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/InMemoryHostStore.swift @@ -29,4 +29,18 @@ public actor InMemoryHostStore: HostStore { hosts = updated return updated } + + public func accessToken(host id: UUID) async throws -> AccessToken? { + hosts.first { $0.id == id }?.accessToken + } + + /// Atomic override for the same reason as `KeychainHostStore`'s: the + /// read-modify-write stays inside the actor. + public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] { + guard let updated = hosts.settingAccessToken(token, host: id) else { + throw AccessTokenError.unknownHost(id) + } + hosts = updated + return updated + } } diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/KeychainHostStore.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/KeychainHostStore.swift index 13fc5c5..0c999fe 100644 --- a/ios/Packages/HostRegistry/Sources/HostRegistry/KeychainHostStore.swift +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/KeychainHostStore.swift @@ -65,6 +65,27 @@ public actor KeychainHostStore: HostStore { return updated } + // MARK: - Access token (stored inside the same host record — see Host) + + public func accessToken(host id: UUID) async throws -> AccessToken? { + try readHosts().first { $0.id == id }?.accessToken + } + + /// Atomic override of the `HostStore` default: the read-modify-write runs + /// inside the actor, so a concurrent `upsert`/`remove` cannot interleave + /// and lose the token (the default implementation awaits twice). + public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] { + let current = try readHosts() + guard let updated = current.settingAccessToken(token, host: id) else { + throw AccessTokenError.unknownHost(id) + } + guard updated != current else { + return current // explicit no-op: same token, nothing persisted + } + try persist(updated) + return updated + } + // MARK: - Keychain I/O (dictionaries built solely by KeychainItemSpec) private func readHosts() throws -> [Host] { diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/AccessTokenTests.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/AccessTokenTests.swift new file mode 100644 index 0000000..e0be83b --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/AccessTokenTests.swift @@ -0,0 +1,210 @@ +import Foundation +import Testing +import HostRegistry + +// AccessToken:边界校验 + "零泄漏"表示。 +// 契约来源:协调 doc §1.1 —— 字符集 `[A-Za-z0-9._~+/=-]`、长度 16–512 +// (与服务端 src/config.ts:178 的 WEBTERM_TOKEN_RE 逐字一致);令牌是密级材料, +// 绝不进日志 / URL / 崩溃报告(§5.3)。 + +// MARK: - 边界校验(字符集) + +@Test("合法令牌:覆盖全字符集 → 构造成功且 rawValue 逐字保真") +func validTokenPreservesRawValueVerbatim() throws { + // Arrange + let raw = Fixtures.validTokenString + + // Act + let token = try AccessToken(validating: raw) + + // Assert + #expect(token.rawValue == raw) +} + +@Test("非法字符 → .invalidCharacters", arguments: [ + "abcdefghijklmno p", // 空格 + "abcdefghijklmnop!", // 感叹号 + "abcdefghijklmnop%", // 百分号(需 URL 编码 → 不在 cookie-safe 集里) + "abcdefghijklmnop,", // 逗号(cookie 分隔符) + "abcdefghijklmnop;", // 分号(cookie 分隔符) + "abcdefghijklmnop令", // 非 ASCII +]) +func invalidCharactersAreRejectedWithTypedError(raw: String) { + // Act / Assert + #expect(throws: AccessTokenError.invalidCharacters) { + _ = try AccessToken(validating: raw) + } +} + +@Test("非法字符错误不携带令牌内容(错误对象本身不得成为泄漏面)") +func invalidCharactersErrorCarriesNoTokenPayload() { + // Arrange + let secretish = "supersecret-tail!!!!!!" + + // Act + var captured = "" + do { + _ = try AccessToken(validating: secretish) + Issue.record("期望抛错,实际构造成功") + } catch { + captured = String(describing: error) + String(reflecting: error) + } + + // Assert + #expect(captured.isEmpty == false) + #expect(captured.contains("supersecret") == false) +} + +// MARK: - 边界校验(长度) + +@Test("长度下限:15 → .tooShort(15)") +func tokenBelowMinimumLengthIsRejected() { + // Act / Assert + #expect(throws: AccessTokenError.tooShort(length: 15)) { + _ = try AccessToken(validating: Fixtures.tokenString(length: 15)) + } +} + +@Test("长度下限:恰好 16 → 通过") +func tokenAtMinimumLengthIsAccepted() throws { + // Act + let token = try AccessToken(validating: Fixtures.tokenString(length: AccessToken.minLength)) + + // Assert + #expect(token.rawValue.count == 16) +} + +@Test("长度上限:恰好 512 → 通过") +func tokenAtMaximumLengthIsAccepted() throws { + // Act + let token = try AccessToken(validating: Fixtures.tokenString(length: AccessToken.maxLength)) + + // Assert + #expect(token.rawValue.count == 512) +} + +@Test("长度上限:513 → .tooLong(513)") +func tokenAboveMaximumLengthIsRejected() { + // Act / Assert + #expect(throws: AccessTokenError.tooLong(length: 513)) { + _ = try AccessToken(validating: Fixtures.tokenString(length: 513)) + } +} + +@Test("空串 → .tooShort(0)(不是 invalidCharacters:先判长度更贴近用户话术)") +func emptyTokenIsRejectedAsTooShort() { + // Act / Assert + #expect(throws: AccessTokenError.tooShort(length: 0)) { + _ = try AccessToken(validating: "") + } +} + +// MARK: - 粘贴 UX:首尾空白裁剪 + +@Test("首尾空白/换行被裁剪后再校验:合法令牌不受影响(空白永非合法令牌字符)") +func surroundingWhitespaceIsTrimmedBeforeValidation() throws { + // Arrange + let pasted = " \n\t" + Fixtures.validTokenString + " \n" + + // Act + let token = try AccessToken(validating: pasted) + + // Assert + #expect(token.rawValue == Fixtures.validTokenString) +} + +@Test("中间空白不被裁剪:仍是 .invalidCharacters(不做静默规范化)") +func interiorWhitespaceIsStillInvalid() { + // Act / Assert + #expect(throws: AccessTokenError.invalidCharacters) { + _ = try AccessToken(validating: "abcdefgh ijklmnop") + } +} + +@Test("init?(rawValue:) 宽松入口:非法 → nil,不抛") +func lenientRawValueInitReturnsNilForInvalidToken() { + // Act / Assert + #expect(AccessToken(rawValue: "too-short") == nil) + #expect(AccessToken(rawValue: Fixtures.validTokenString)?.rawValue == Fixtures.validTokenString) +} + +// MARK: - §5.3 零泄漏表示 + +@Test("§5.3 description/debugDescription 不含令牌,只给长度") +func stringRepresentationsRedactTheToken() { + // Arrange + let token = Fixtures.makeToken() + + // Act + let description = token.description + let debugDescription = token.debugDescription + + // Assert + #expect(description.contains(Fixtures.validTokenString) == false) + #expect(debugDescription.contains(Fixtures.validTokenString) == false) + #expect(description.contains("redacted")) + #expect(description.contains("\(Fixtures.validTokenString.count)")) +} + +@Test("§5.3 String(describing:)/String(reflecting:) 也不泄漏(插值即日志的默认路径)") +func interpolationPathsRedactTheToken() { + // Arrange + let token = Fixtures.makeToken() + + // Act + let interpolated = "token=\(token)" + + // Assert + #expect(interpolated.contains(Fixtures.validTokenString) == false) + #expect(String(describing: token).contains(Fixtures.validTokenString) == false) + #expect(String(reflecting: token).contains(Fixtures.validTokenString) == false) +} + +@Test("§5.3 Mirror/dump 反射不泄漏令牌(崩溃报告与 dump 是常见旁路)") +func reflectionDoesNotExposeTheToken() { + // Arrange + let token = Fixtures.makeToken() + + // Act + let children = Mirror(reflecting: token).children.map { String(describing: $0.value) } + var dumped = "" + dump(token, to: &dumped) + + // Assert + #expect(children.contains(where: { $0.contains(Fixtures.validTokenString) }) == false) + #expect(dumped.contains(Fixtures.validTokenString) == false) +} + +@Test("§5.3 Host 的字符串表示不含令牌,只标注是否已配置") +func hostStringRepresentationRedactsTheToken() { + // Arrange + let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken()) + + // Act + let described = String(describing: host) + let reflected = String(reflecting: host) + var dumped = "" + dump(host, to: &dumped) + + // Assert + #expect(described.contains(Fixtures.validTokenString) == false) + #expect(reflected.contains(Fixtures.validTokenString) == false) + #expect(dumped.contains(Fixtures.validTokenString) == false) + #expect(described.contains("hasAccessToken: true")) + #expect(String(describing: Fixtures.makeHost()).contains("hasAccessToken: false")) +} + +// MARK: - 值语义 + +@Test("Equatable/Hashable:同值相等且同 hash,异值不等") +func tokenValueSemanticsAreByRawValue() { + // Arrange + let a = Fixtures.makeToken() + let b = Fixtures.makeToken() + let other = Fixtures.makeToken(Fixtures.otherValidTokenString) + + // Assert + #expect(a == b) + #expect(a.hashValue == b.hashValue) + #expect(a != other) +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostAccessTokenStoreTests.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostAccessTokenStoreTests.swift new file mode 100644 index 0000000..0bda1d5 --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostAccessTokenStoreTests.swift @@ -0,0 +1,337 @@ +import Foundation +import Security +import Testing +import HostRegistry + +// 按主机存访问令牌的 store 语义(B2):read / write / clear,以及 +// "移除主机必须一起移除其令牌"(Keychain 里不留孤儿密文)。 +// 令牌就存在既有的 host 列表 Keychain item 里 —— 计划 §5.3 明写 +// "Keychain:host 列表(含未来 authMaterial 占位)",故 remove(id:) 天然连带清除。 + +/// 取出某次 keychain 写入的 JSON 载荷文本(用于"载荷里不得再出现该令牌"断言)。 +/// +/// 必须先把 `\/` 还原成 `/`:JSONEncoder 默认转义正斜杠,而令牌字符集里恰好含 `/` —— +/// 不还原的话 `contains(token) == false` 这类"无残留密文"断言会因为转义而**假通过**。 +/// 令牌字符集 `[A-Za-z0-9._~+/=-]` 中只有 `/` 会被 JSON 转义,故这一步足够。 +private func payloadText(_ attributes: [String: any Sendable]?) -> String { + guard let data = attributes?[kSecValueData as String] as? Data else { return "" } + return String(decoding: data, as: UTF8.self).replacingOccurrences(of: "\\/", with: "/") +} + +/// 第三方 conformer:只实现 §3.3 原有三个方法,不 override 令牌方法 —— +/// 钉死协议默认实现(App 层已有的自定义 HostStore 替身走的就是这条路)。 +private actor MinimalHostStore: HostStore { + private var hosts: [Host] + + init(hosts: [Host]) { self.hosts = hosts } + + func loadAll() async throws -> [Host] { hosts } + + func upsert(_ host: Host) async throws -> [Host] { + hosts = hosts.contains(where: { $0.id == host.id }) + ? hosts.map { $0.id == host.id ? host : $0 } + : hosts + [host] + return hosts + } + + func remove(id: UUID) async throws -> [Host] { + hosts = hosts.filter { $0.id != id } + return hosts + } +} + +// MARK: - 写入 / 读取 / 清除(Keychain 实现) + +@Test("setAccessToken 后 accessToken(host:) 读回同值,且 loadAll 的 Host 带上令牌") +func setAccessTokenPersistsAndReadsBack() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + let token = Fixtures.makeToken() + + // Act + let updated = try await store.setAccessToken(token, host: host.id) + + // Assert + #expect(updated.first?.accessToken == token) + #expect(try await store.accessToken(host: host.id) == token) + #expect(try await store.loadAll().first?.accessToken == token) +} + +@Test("令牌跨 store 实例存活:同一 shim 上的新 store 读回令牌") +func accessTokenSurvivesAcrossStoreInstances() async throws { + // Arrange + let shim = FakeSecItemShim() + let host = Fixtures.makeHost() + let writer = KeychainHostStore(shim: shim) + _ = try await writer.upsert(host) + _ = try await writer.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Act + let reader = KeychainHostStore(shim: shim) + + // Assert + #expect(try await reader.accessToken(host: host.id) == Fixtures.makeToken()) +} + +@Test("未配置令牌的主机:accessToken(host:) 为 nil(不是错误)") +func accessTokenIsNilWhenNeverSet() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + + // Act / Assert + #expect(try await store.accessToken(host: host.id) == nil) +} + +@Test("未知 host 的 accessToken 查询:nil(不 throw —— 只读路径宽松)") +func accessTokenForUnknownHostIsNil() async throws { + // Arrange + let store = KeychainHostStore(shim: FakeSecItemShim()) + + // Act / Assert + #expect(try await store.accessToken(host: UUID()) == nil) +} + +@Test("setAccessToken(nil) 清除令牌,但主机本身保留") +func setAccessTokenNilClearsTokenAndKeepsHost() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Act + let updated = try await store.setAccessToken(nil, host: host.id) + + // Assert + #expect(updated.map(\.id) == [host.id]) + #expect(updated.first?.accessToken == nil) + #expect(try await store.accessToken(host: host.id) == nil) + #expect(payloadText(shim.recordedUpdates.last?.attributes).contains(Fixtures.validTokenString) == false) +} + +@Test("clearAccessToken(host:) 等价于 setAccessToken(nil, host:)") +func clearAccessTokenRemovesTheToken() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Act + let updated = try await store.clearAccessToken(host: host.id) + + // Assert + #expect(updated.first?.accessToken == nil) + #expect(try await store.accessToken(host: host.id) == nil) +} + +@Test("替换令牌:旧值不再出现在写入载荷里") +func replacingTokenLeavesNoTraceOfTheOldValue() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Act + let updated = try await store.setAccessToken( + Fixtures.makeToken(Fixtures.otherValidTokenString), host: host.id + ) + + // Assert + #expect(updated.first?.accessToken?.rawValue == Fixtures.otherValidTokenString) + let payload = payloadText(shim.recordedUpdates.last?.attributes) + #expect(payload.contains(Fixtures.otherValidTokenString)) + #expect(payload.contains(Fixtures.validTokenString) == false) +} + +@Test("重复写同一令牌:显式 no-op,零额外持久化调用") +func settingTheSameTokenTwiceIsAnExplicitNoOp() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + let addsBefore = shim.recordedAdds.count + let updatesBefore = shim.recordedUpdates.count + + // Act + let updated = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Assert + #expect(updated.first?.accessToken == Fixtures.makeToken()) + #expect(shim.recordedAdds.count == addsBefore) + #expect(shim.recordedUpdates.count == updatesBefore) +} + +@Test("给未知 host 写令牌:抛 .unknownHost,零持久化(绝不静默丢弃)") +func setAccessTokenForUnknownHostThrowsAndPersistsNothing() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + _ = try await store.upsert(Fixtures.makeHost()) + let addsBefore = shim.recordedAdds.count + let unknownId = UUID() + + // Act / Assert + await #expect(throws: AccessTokenError.unknownHost(unknownId)) { + _ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId) + } + #expect(shim.recordedAdds.count == addsBefore) + #expect(shim.recordedUpdates.isEmpty) + #expect(shim.recordedDeletes.isEmpty) +} + +// MARK: - 移除主机连带清除令牌(无孤儿密文) + +@Test("remove 最后一个主机:整个 keychain item 被删,令牌随之消失") +func removingLastHostDeletesTheItemAndItsToken() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Act + let updated = try await store.remove(id: host.id) + + // Assert + #expect(updated.isEmpty) + #expect(shim.recordedDeletes.count == 1) + #expect(try await store.accessToken(host: host.id) == nil) + #expect(try await KeychainHostStore(shim: shim).loadAll().isEmpty) +} + +@Test("remove 其中一个主机:新载荷不含其令牌,另一主机的令牌完好") +func removingOneHostStripsOnlyItsTokenFromThePayload() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let doomed = Fixtures.makeHost(name: "doomed") + let keeper = Fixtures.makeHost(name: "keeper", urlString: "https://mac.ts.net") + _ = try await store.upsert(doomed) + _ = try await store.upsert(keeper) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: doomed.id) + _ = try await store.setAccessToken( + Fixtures.makeToken(Fixtures.otherValidTokenString), host: keeper.id + ) + + // Act + let updated = try await store.remove(id: doomed.id) + + // Assert + #expect(updated.map(\.id) == [keeper.id]) + let payload = payloadText(shim.recordedUpdates.last?.attributes) + #expect(payload.contains(Fixtures.validTokenString) == false) + #expect(payload.contains(Fixtures.otherValidTokenString)) + #expect(try await store.accessToken(host: keeper.id)?.rawValue == Fixtures.otherValidTokenString) +} + +// MARK: - §5.3 属性字典不因令牌而改变 + +@Test("§5.3 带令牌写入时 add 属性字典仍是策略集:6 键、无 synchronizable、accessible 重申") +func storingATokenDoesNotWeakenTheKeychainPolicy() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim, service: "svc.test", account: "acct.test") + let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken()) + + // Act + _ = try await store.upsert(host) + + // Assert + let attrs = try #require(shim.recordedAdds.first) + #expect(attrs[kSecUseDataProtectionKeychain as String] as? Bool == true) + #expect(attrs[kSecAttrAccessible as String] as? String + == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) + #expect(attrs[kSecAttrSynchronizable as String] == nil) + #expect(attrs.count == 6) +} + +// MARK: - 实现间契约对等 + +@Test("InMemoryHostStore:read/write/clear 与 Keychain 实现语义一致") +func inMemoryStoreHasTheSameAccessTokenSemantics() async throws { + // Arrange + let host = Fixtures.makeHost() + let store = InMemoryHostStore(hosts: [host]) + let token = Fixtures.makeToken() + + // Act + let afterSet = try await store.setAccessToken(token, host: host.id) + let read = try await store.accessToken(host: host.id) + let afterClear = try await store.clearAccessToken(host: host.id) + + // Assert + #expect(afterSet.first?.accessToken == token) + #expect(read == token) + #expect(afterClear.first?.accessToken == nil) + #expect(try await store.accessToken(host: host.id) == nil) +} + +@Test("InMemoryHostStore:未知 host 写令牌同样抛 .unknownHost") +func inMemoryStoreRejectsUnknownHostToo() async { + // Arrange + let store = InMemoryHostStore() + let unknownId = UUID() + + // Act / Assert + await #expect(throws: AccessTokenError.unknownHost(unknownId)) { + _ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId) + } +} + +@Test("InMemoryHostStore:remove 主机 → 令牌一并消失") +func inMemoryStoreRemovingHostDropsItsToken() async throws { + // Arrange + let host = Fixtures.makeHost() + let store = InMemoryHostStore(hosts: [host]) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Act + _ = try await store.remove(id: host.id) + + // Assert + #expect(try await store.accessToken(host: host.id) == nil) +} + +@Test("协议默认实现:只实现原三方法的 conformer 也能 read/write/clear") +func protocolDefaultImplementationsWorkForMinimalConformers() async throws { + // Arrange + let host = Fixtures.makeHost() + let store = MinimalHostStore(hosts: [host]) + let token = Fixtures.makeToken() + + // Act + let afterSet = try await store.setAccessToken(token, host: host.id) + let read = try await store.accessToken(host: host.id) + let afterClear = try await store.clearAccessToken(host: host.id) + + // Assert + #expect(afterSet.first?.accessToken == token) + #expect(read == token) + #expect(afterClear.first?.accessToken == nil) +} + +@Test("协议默认实现:未知 host 写令牌抛 .unknownHost") +func protocolDefaultImplementationRejectsUnknownHost() async { + // Arrange + let store = MinimalHostStore(hosts: []) + let unknownId = UUID() + + // Act / Assert + await #expect(throws: AccessTokenError.unknownHost(unknownId)) { + _ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId) + } +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostTokenMigrationTests.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostTokenMigrationTests.swift new file mode 100644 index 0000000..3210d81 --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostTokenMigrationTests.swift @@ -0,0 +1,192 @@ +import Foundation +import Testing +import WireProtocol +import HostRegistry + +// 迁移安全 + 持久化形状(B2)。 +// 已配对的主机是**当前线上构建**写进 Keychain 的旧形状记录(没有 accessToken 字段); +// 新构建必须照常读出它们 —— 缺字段 = nil,绝不是解码失败(否则用户的主机列表整体变 +// .corruptedData,等于要求重新配对所有主机)。 + +/// 上一版 `Host` 的编码形状:只有 id/name/endpoint,合成 Codable。 +/// 用它来生成"旧记录",比手写 JSON 更能保真上一版编码器的输出。 +private struct LegacyHostRecord: Encodable { + let id: UUID + let name: String + let endpoint: HostEndpoint +} + +private func encodedLegacyPayload(_ host: Host) throws -> Data { + try JSONEncoder().encode([ + LegacyHostRecord(id: host.id, name: host.name, endpoint: host.endpoint), + ]) +} + +// MARK: - 旧形状读取(迁移回归) + +@Test("迁移:旧形状记录(无 accessToken 字段)照常读出,令牌为 nil") +func legacyRecordWithoutTokenFieldStillLoads() async throws { + // Arrange + let legacy = Fixtures.makeHost(name: "paired-before-tokens") + let shim = FakeSecItemShim() + shim.forceCopyData(try encodedLegacyPayload(legacy)) + let store = KeychainHostStore(shim: shim) + + // Act + let loaded = try await store.loadAll() + + // Assert + #expect(loaded.count == 1) + #expect(loaded.first?.id == legacy.id) + #expect(loaded.first?.name == "paired-before-tokens") + #expect(loaded.first?.endpoint == legacy.endpoint) + #expect(loaded.first?.accessToken == nil) + #expect(loaded == [legacy]) // 旧记录 == 令牌为 nil 的新值 +} + +@Test("迁移:逐字旧 JSON(键名钉死 id/name/endpoint.baseURL)解码成功且令牌为 nil") +func literalLegacyJSONDecodesWithNilToken() throws { + // Arrange —— 逐字旧形状,钉死上一版的键名(键名一改就红) + let json = """ + [{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F",\ + "name":"mac-studio","endpoint":{"baseURL":"http://192.168.1.5:3000"}}] + """ + + // Act + let hosts = try JSONDecoder().decode([Host].self, from: Data(json.utf8)) + + // Assert + #expect(hosts.count == 1) + #expect(hosts.first?.name == "mac-studio") + #expect(hosts.first?.endpoint.originHeader == "http://192.168.1.5:3000") + #expect(hosts.first?.accessToken == nil) +} + +@Test("被篡改的非法令牌值:降级为 nil,不把整张主机表打成 .corruptedData") +func tamperedTokenValueDegradesToNilInsteadOfCorruptingTheList() async throws { + // Arrange —— 只有外部篡改才可能出现非法值(写路径已在边界校验) + let json = """ + [{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"mac-studio",\ + "endpoint":{"baseURL":"http://192.168.1.5:3000"},"accessToken":"short!"}] + """ + let shim = FakeSecItemShim() + shim.forceCopyData(Data(json.utf8)) + let store = KeychainHostStore(shim: shim) + + // Act + let loaded = try await store.loadAll() + + // Assert + #expect(loaded.count == 1) + #expect(loaded.first?.name == "mac-studio") + #expect(loaded.first?.accessToken == nil) +} + +@Test("accessToken 为 JSON null:等同缺字段,令牌为 nil") +func explicitNullTokenDecodesAsNil() throws { + // Arrange + let json = """ + [{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"mac-studio",\ + "endpoint":{"baseURL":"http://192.168.1.5:3000"},"accessToken":null}] + """ + + // Act + let hosts = try JSONDecoder().decode([Host].self, from: Data(json.utf8)) + + // Assert + #expect(hosts.first?.accessToken == nil) +} + +@Test("endpoint 仍是硬校验:非 http(s) 的 baseURL → 解码失败(令牌宽松不放宽 endpoint)") +func endpointRemainsStrictlyValidatedOnDecode() { + // Arrange + let json = """ + [{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"bad",\ + "endpoint":{"baseURL":"ftp://192.168.1.5"}}] + """ + + // Act / Assert + #expect(throws: (any Error).self) { + _ = try JSONDecoder().decode([Host].self, from: Data(json.utf8)) + } +} + +// MARK: - 新形状 + +@Test("新形状:令牌以纯字符串存在 accessToken 键下(键名/形状钉死)") +func tokenIsPersistedAsAPlainStringUnderAccessTokenKey() throws { + // Arrange + let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken()) + + // Act + let data = try JSONEncoder().encode(host) + let object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + // Assert + #expect(object["accessToken"] as? String == Fixtures.validTokenString) +} + +@Test("新形状:令牌为 nil 时不写 accessToken 键(不留空字段)") +func nilTokenIsOmittedFromTheEncodedForm() throws { + // Arrange + let host = Fixtures.makeHost() + + // Act + let data = try JSONEncoder().encode(host) + let object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + // Assert + #expect(object.keys.contains("accessToken") == false) + #expect(object.keys.sorted() == ["endpoint", "id", "name"]) +} + +@Test("Codable 往返:带令牌的 Host 全字段保真") +func codableRoundTripPreservesTheToken() throws { + // Arrange + let host = Fixtures.makeHost(name: "studio", urlString: "https://mac.ts.net:8443") + .withAccessToken(Fixtures.makeToken()) + + // Act + let decoded = try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host)) + + // Assert + #expect(decoded == host) + #expect(decoded.accessToken == Fixtures.makeToken()) + #expect(decoded.endpoint.originHeader == "https://mac.ts.net:8443") +} + +// MARK: - 不可变性 + +@Test("withAccessToken 不可变:返回新值,原 Host 不变") +func withAccessTokenReturnsANewValueWithoutMutatingTheOriginal() { + // Arrange + let original = Fixtures.makeHost() + + // Act + let withToken = original.withAccessToken(Fixtures.makeToken()) + let cleared = withToken.withAccessToken(nil) + + // Assert + #expect(original.accessToken == nil) + #expect(withToken.accessToken == Fixtures.makeToken()) + #expect(cleared.accessToken == nil) + #expect(withToken.id == original.id) + #expect(withToken.name == original.name) + #expect(withToken.endpoint == original.endpoint) + #expect(cleared == original) +} + +@Test("Equatable 含令牌:令牌不同的同 id 主机不相等(UI 才能感知令牌变化)") +func hostsDifferingOnlyByTokenAreNotEqual() { + // Arrange + let base = Fixtures.makeHost() + + // Act / Assert + #expect(base.withAccessToken(Fixtures.makeToken()) != base) + #expect(base.withAccessToken(Fixtures.makeToken()) + != base.withAccessToken(Fixtures.makeToken(Fixtures.otherValidTokenString))) +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/TestFixtures.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/TestFixtures.swift index 20957a0..499a2f2 100644 --- a/ios/Packages/HostRegistry/Tests/HostRegistryTests/TestFixtures.swift +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/TestFixtures.swift @@ -25,4 +25,28 @@ enum Fixtures { static func makeSuiteName() -> String { "HostRegistryTests." + UUID().uuidString } + + // MARK: - Access-token fixtures (coordination doc §1.1) + + /// 22 chars exercising EVERY class of the frozen charset + /// `[A-Za-z0-9._~+/=-]` (upper/lower/digit/`.`/`_`/`~`/`+`/`/`/`=`/`-`), + /// comfortably above the 16-char minimum. Test-only value — not a secret. + static let validTokenString = "A9z._~+/=-Abcdef012345" + + /// A second, distinct valid token (for "replaced/other host" assertions). + static let otherValidTokenString = "Zy8-=/+~_.9876543210fedcba" + + /// Builds a valid token; traps loudly if a fixture string is malformed + /// (a broken fixture must fail the suite, not silently skip assertions). + static func makeToken(_ raw: String = Fixtures.validTokenString) -> AccessToken { + guard let token = AccessToken(rawValue: raw) else { + fatalError("test fixture token invalid: length \(raw.count)") + } + return token + } + + /// A repeated-character token of an exact length (length-boundary tests). + static func tokenString(length: Int) -> String { + String(repeating: "a", count: length) + } } diff --git a/ios/Packages/SessionCore/Sources/SessionCore/AuthCookie.swift b/ios/Packages/SessionCore/Sources/SessionCore/AuthCookie.swift new file mode 100644 index 0000000..f44a886 --- /dev/null +++ b/ios/Packages/SessionCore/Sources/SessionCore/AuthCookie.swift @@ -0,0 +1,42 @@ +/// The ONE place SessionCore turns a configured access token (`WEBTERM_TOKEN`) +/// into an HTTP request header (B3 · ios-completion §1.1, FROZEN). +/// +/// Why hand-written and not `HTTPCookieStorage`: the native client already KNOWS +/// the token, and `URLSession`'s cookie jar behaves inconsistently on a +/// WebSocket upgrade (and is barely testable there). A hand-written header is +/// the same pattern as the hand-written `Origin` (plan §5.1) and can be pinned +/// by pure unit tests. `Set-Cookie` is never parsed. +/// +/// SECURITY: the token is secret material. It exists here only long enough to be +/// handed to `URLRequest.setValue`; it is never logged, never interpolated into a +/// URL/query, and never carried by any error this module throws. +enum AuthCookie { + /// The server's `AUTH_COOKIE_NAME` (src/http/auth.ts:30). + static let name = "webterm_auth" + + /// `webterm_auth=` for a well-formed token; `nil` when no token is + /// configured OR the configured value is off-policy. + /// + /// Off-policy ⇒ OMIT rather than throw: a host with auth DISABLED ignores + /// the cookie entirely, so a junk stored token must not break a working + /// connection. When the host DOES enforce auth, the omitted cookie yields + /// the server's 401 → `TermTransportError.unauthorized` → the UI asks for a + /// correct token. Omitting a secret is never a leak. + static func headerValue(token: String?) -> String? { + guard let token, isValidToken(token) else { return nil } + return "\(name)=\(token)" + } + + /// True iff `candidate` satisfies the server's token policy: a byte-identical + /// port of `WEBTERM_TOKEN_RE` (src/config.ts:178) — URL/cookie-safe charset, + /// 16–512 chars. A token the server would reject at startup can never be a + /// valid credential, and enforcing the charset HERE is also what keeps a + /// hostile stored value from injecting CRLF into the request headers. + /// + /// The literal is built per call (as in `WireProtocol.Validation`): `Regex` + /// is not `Sendable`, so it cannot be hoisted into a `static let`. + static func isValidToken(_ candidate: String) -> Bool { + let tokenPolicy = /^[A-Za-z0-9._~+\/=-]{16,512}$/ + return candidate.wholeMatch(of: tokenPolicy) != nil + } +} diff --git a/ios/Packages/SessionCore/Sources/SessionCore/SessionEngine.swift b/ios/Packages/SessionCore/Sources/SessionCore/SessionEngine.swift index 51aeada..af67347 100644 --- a/ios/Packages/SessionCore/Sources/SessionCore/SessionEngine.swift +++ b/ios/Packages/SessionCore/Sources/SessionCore/SessionEngine.swift @@ -19,7 +19,8 @@ import WireProtocol /// frame goes through `MessageCodec.decodeServer`; nil → dropped + counted, /// never a crash. /// - Terminal states never re-enter backoff: `exit` (session over, including -/// spawn failure -1, M4) and `.replayTooLarge` (deterministic failure). +/// spawn failure -1, M4), `.replayTooLarge` (deterministic failure), and +/// `.unauthorized` (401 on the upgrade — B3 · ios-completion §1.1). /// /// Sizing (documented decision, plan §7 T-iOS-10): the server spawns every PTY /// at 80×24 (src/server.ts:714-717) and the frozen `open` signature carries no @@ -324,9 +325,9 @@ public actor SessionEngine { eventsContinuation.finish() return false } - if let error, isReplayTooLarge(error) { + if let error, let reason = Self.terminalFailure(for: error) { hasFailedTerminally = true - emit(.connection(.failed(.replayTooLarge))) // NEVER backoff (plan §1) + emit(.connection(.failed(reason))) // NEVER backoff (plan §1 / §1.1) eventsContinuation.finish() return false } @@ -470,8 +471,17 @@ public actor SessionEngine { generation == gen } - private func isReplayTooLarge(_ error: any Error) -> Bool { - (error as? TermTransportError) == .replayTooLarge + /// The NON-retryable transport classifications, in ONE place. Retrying + /// either is pointless: `replayTooLarge` fails deterministically forever + /// (plan §1) and `unauthorized` (401 on the upgrade — Origin or access + /// token, ios-completion §1.1) does not become correct by waiting, while a + /// retry storm on 401 reads as a brute-force attempt. `nil` = retryable. + private static func terminalFailure(for error: any Error) -> FailureReason? { + switch error as? TermTransportError { + case .replayTooLarge: return .replayTooLarge + case .unauthorized: return .unauthorized + case .sendAfterClose, .none: return nil + } } private func emit(_ event: SessionEvent) { diff --git a/ios/Packages/SessionCore/Sources/SessionCore/SessionEvent.swift b/ios/Packages/SessionCore/Sources/SessionCore/SessionEvent.swift index dde7281..65dc6e3 100644 --- a/ios/Packages/SessionCore/Sources/SessionCore/SessionEvent.swift +++ b/ios/Packages/SessionCore/Sources/SessionCore/SessionEvent.swift @@ -57,4 +57,12 @@ public enum FailureReason: Sendable, Equatable { /// enters backoff. UI copy: lower the server's SCROLLBACK_BYTES or raise /// the client cap (plan §3.2.1 coupling warning). case replayTooLarge + /// The WS upgrade was rejected with **401** (B3 · ios-completion §1.1): + /// either the host's Origin whitelist does not contain our dialed origin or + /// the access token is missing/wrong (src/server.ts:1367-1379 checks Origin + /// first, then the `webterm_auth` cookie — the status is the same, so the UI + /// must offer BOTH remedies). Never retried: the credential does not become + /// correct by waiting, and a retry storm on 401 is a brute-force signal. + /// UI copy: re-enter the access token, or fix `ALLOWED_ORIGINS` on the host. + case unauthorized } diff --git a/ios/Packages/SessionCore/Sources/SessionCore/URLSessionTermTransport.swift b/ios/Packages/SessionCore/Sources/SessionCore/URLSessionTermTransport.swift index 638b1ff..0d7e61d 100644 --- a/ios/Packages/SessionCore/Sources/SessionCore/URLSessionTermTransport.swift +++ b/ios/Packages/SessionCore/Sources/SessionCore/URLSessionTermTransport.swift @@ -26,6 +26,15 @@ public enum TermTransportError: Error, Equatable, Sendable { /// backoff loop (plan §1 / §3.2.1 coupling warning), otherwise the retry /// is deterministic and infinite. case replayTooLarge + /// The WS upgrade was answered **401** (B3 · ios-completion §1.1). Both + /// server-side upgrade gates write that status — the Origin/CSWSH check + /// first, then the access-token cookie (src/server.ts:1367-1379) — and + /// neither becomes true by waiting. NON-RETRYABLE, exactly like + /// `replayTooLarge`: the engine surfaces `connection(.failed(.unauthorized))` + /// and must never feed it into the backoff loop, because retrying a wrong + /// token forever is useless AND indistinguishable from a brute-force probe. + /// Carries NO payload — the token must never ride on an error. + case unauthorized /// `send` on a connection that already terminated (client `close()`, /// server close frame, or transport error). Mirrors /// `FakeTransportError.sendAfterClose` (TestSupport). @@ -74,6 +83,8 @@ protocol ConnectionPinger: Sendable { /// snapshot frame → every task gets `Tunables.maxWSMessageBytes` (16 MiB). /// - `receive()` delivers ONE message per call → the loop re-arms forever. /// - No automatic ping → `ConnectionPinger` (driven by `PingScheduler`). +/// - Access token: a hand-written `Cookie: webterm_auth=` next to (never +/// instead of) `Origin` — B3 · ios-completion §1.1, see `AuthCookie`. public struct URLSessionTermTransport: TermTransport { /// `URLSessionWebSocketTask.maximumMessageSize` applied to every task. private let maxMessageBytes: Int @@ -86,19 +97,32 @@ public struct URLSessionTermTransport: TermTransport { /// cleanly. Local (non-mTLS) hosts never issue the challenge, so a `nil` /// result is inert there. private let identityProvider: @Sendable () -> ClientIdentity? + /// B3 · access token, resolved LAZILY once per `connect` for the same + /// no-relaunch reason as `identityProvider`: a token entered (or rotated) + /// mid-run takes effect on the NEXT connection. `nil` ⇒ no `Cookie` header + /// at all, so LAN zero-config hosts are byte-identical to before. + /// The value is passed straight to `AuthCookie` — never logged, never stored. + private let tokenProvider: @Sendable () -> String? /// Fixed-identity convenience (snapshot callers / tests): wraps a constant /// provider, so behaviour is identical to capturing the identity directly. - public init(identity: ClientIdentity? = nil) { - self.init(identityProvider: { identity }) + public init( + identity: ClientIdentity? = nil, + tokenProvider: @escaping @Sendable () -> String? = { nil } + ) { + self.init(identityProvider: { identity }, tokenProvider: tokenProvider) } /// C-iOS-2 (MEDIUM no-relaunch fix) · per-connect identity resolution: the /// provider is re-consulted on every `connect`, so a nil→installed /// transition is picked up without relaunch. - public init(identityProvider: @escaping @Sendable () -> ClientIdentity?) { + public init( + identityProvider: @escaping @Sendable () -> ClientIdentity?, + tokenProvider: @escaping @Sendable () -> String? = { nil } + ) { self.init( - maxMessageBytes: Tunables.maxWSMessageBytes, identityProvider: identityProvider + maxMessageBytes: Tunables.maxWSMessageBytes, + identityProvider: identityProvider, tokenProvider: tokenProvider ) } @@ -107,15 +131,20 @@ public struct URLSessionTermTransport: TermTransport { /// through `init()` / `init(identityProvider:)` and the frozen /// `Tunables.maxWSMessageBytes`. init(maxMessageBytes: Int, identity: ClientIdentity? = nil) { - self.init(maxMessageBytes: maxMessageBytes, identityProvider: { identity }) + self.init( + maxMessageBytes: maxMessageBytes, identityProvider: { identity }, + tokenProvider: { nil } + ) } init( maxMessageBytes: Int, - identityProvider: @escaping @Sendable () -> ClientIdentity? + identityProvider: @escaping @Sendable () -> ClientIdentity?, + tokenProvider: @escaping @Sendable () -> String? ) { self.maxMessageBytes = maxMessageBytes self.identityProvider = identityProvider + self.tokenProvider = tokenProvider } public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection { @@ -123,12 +152,12 @@ public struct URLSessionTermTransport: TermTransport { } /// Internal: concrete-typed connect (tests assert task configuration; - /// `connectPingable` builds on it). The identity is resolved HERE, per - /// connect, from `identityProvider` (no-relaunch pickup). + /// `connectPingable` builds on it). Identity AND token are resolved HERE, + /// per connect, from their providers (no-relaunch pickup). func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection { try await WSConnection.open( endpoint: endpoint, maxMessageBytes: maxMessageBytes, - identity: identityProvider() + identity: identityProvider(), token: tokenProvider() ) } } @@ -185,11 +214,13 @@ final class WSConnection: NSObject, @unchecked Sendable { /// handshake (didOpen / didCompleteWithError — no receive-error guessing), /// then start the re-arming receive loop. static func open( - endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil + endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil, + token: String? = nil ) async throws -> WSConnection { let connection = WSConnection() connection.configure( - endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity + endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity, + token: token ) try await connection.performHandshake() connection.startReceiveLoop() @@ -209,21 +240,52 @@ final class WSConnection: NSObject, @unchecked Sendable { // MARK: Setup private func configure( - endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? + endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?, token: String? ) { // Set BEFORE building the task/resume: the mTLS challenge can only fire // after `task.resume()`, so this write happens-before any read of it. self.identity = identity - // Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1). - var request = URLRequest(url: endpoint.wsURL) - request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin") - let session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil) - let task = session.webSocketTask(with: request) + let session = URLSession( + configuration: Self.sessionConfiguration(), delegate: self, delegateQueue: nil + ) + let task = session.webSocketTask( + with: Self.upgradeRequest(endpoint: endpoint, token: token) + ) task.maximumMessageSize = maxMessageBytes self.session = session self.task = task } + /// THE single choke point for upgrade request headers. + /// + /// - `Origin`: SINGLE source of truth = `endpoint.originHeader` (plan §5.1) + /// — always sent. + /// - `Cookie`: `webterm_auth=` when a token is configured + /// (B3 · ios-completion §1.1). ADDITIVE and ORTHOGONAL: the server checks + /// Origin first and the cookie second (src/server.ts:1367-1379), so the + /// cookie never replaces Origin. Absent/off-policy token ⇒ header omitted + /// (see `AuthCookie.headerValue`). + private static func upgradeRequest(endpoint: HostEndpoint, token: String?) -> URLRequest { + var request = URLRequest(url: endpoint.wsURL) + request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin") + if let cookie = AuthCookie.headerValue(token: token) { + request.setValue(cookie, forHTTPHeaderField: "Cookie") + } + return request + } + + /// Ephemeral, with the cookie jar switched OFF: §1.1 freezes the + /// hand-written `Cookie` header as the ONLY authority, so no `Set-Cookie` + /// from any host can override it, outlive the connection, or park the secret + /// in shared storage. + private static func sessionConfiguration() -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpShouldSetCookies = false + configuration.httpCookieAcceptPolicy = .never + configuration.httpCookieStorage = nil + return configuration + } + private func performHandshake() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in @@ -331,6 +393,34 @@ final class WSConnection: NSObject, @unchecked Sendable { } } + /// HTTP status both server-side upgrade gates answer with: the Origin/CSWSH + /// check, then the access-token cookie (src/server.ts:1367-1379). + private static let unauthorizedStatusCode = 401 + + /// The upgrade never opened — decide what `connect` throws. + /// + /// A **401** is non-retryable by construction (see + /// `TermTransportError.unauthorized`), so it becomes the typed error the + /// engine treats as terminal. EVERYTHING else is rethrown VERBATIM, because + /// `PairingError.classify` (T-iOS-8) keys off the underlying POSIX/NSURLError + /// identity to tell localNetworkDenied / atsBlocked / tlsFailure apart — + /// wrapping those would break that taxonomy (see `TermTransportError` doc). + private static func handshakeFailure(task: URLSessionTask, error: (any Error)?) -> any Error { + if httpStatusCode(of: task) == unauthorizedStatusCode { + return TermTransportError.unauthorized + } + return error ?? URLError(.cannotConnectToHost) + } + + /// The HTTP status of a rejected upgrade: `URLSessionWebSocketTask` fails the + /// task on a non-101 answer and leaves the `HTTPURLResponse` on `task` + /// (mutation-verified against a scripted 401 vs 500 in + /// `URLSessionTermTransportTests`; without it the 401 arrives as the opaque + /// NSURLErrorDomain -1011 that WOULD enter the backoff loop). + private static func httpStatusCode(of task: URLSessionTask) -> Int? { + (task.response as? HTTPURLResponse)?.statusCode + } + /// T-iOS-2 spike (measured on a real server): exceeding /// `maximumMessageSize` fails `receive()` with NSPOSIXErrorDomain /// EMSGSIZE(40) "Message too long" — not a 1009 close. ENOBUFS(55) is the @@ -399,10 +489,7 @@ extension WSConnection: URLSessionWebSocketDelegate { didCompleteWithError error: (any Error)? ) { if let handshake = takeHandshakeContinuation() { - // Handshake never opened. Rethrow the UNDERLYING error verbatim — - // PairingError.classify (T-iOS-8) depends on its POSIX/NSURLError - // identity (see TermTransportError doc). - handshake.resume(throwing: error ?? URLError(.cannotConnectToHost)) + handshake.resume(throwing: Self.handshakeFailure(task: task, error: error)) session.finishTasksAndInvalidate() return } diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/AuthCookieTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/AuthCookieTests.swift new file mode 100644 index 0000000..a6dd6c6 --- /dev/null +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/AuthCookieTests.swift @@ -0,0 +1,60 @@ +import Testing +@testable import SessionCore + +/// B3 · `AuthCookie` — the ONE place SessionCore turns a configured access +/// token into an upgrade request header (`ios-completion` §1.1, FROZEN: +/// hand-written `Cookie`, never `Set-Cookie`/`HTTPCookieStorage`). +/// +/// The policy mirrored here is the server's, verbatim: +/// - cookie name `webterm_auth` (src/http/auth.ts:30 `AUTH_COOKIE_NAME`) +/// - token charset/length `^[A-Za-z0-9._~+/=-]{16,512}$` (src/config.ts:178) +@Suite("AuthCookie") +struct AuthCookieTests { + /// A well-formed token exercising every allowed character class. + private static let validToken = "Ab9._~+/=-Ab9._~+/=-" + private static let minLengthToken = String(repeating: "a", count: 16) + private static let maxLengthToken = String(repeating: "a", count: 512) + + @Test("cookie name matches the server's AUTH_COOKIE_NAME") + func cookieNameMatchesServer() { + #expect(AuthCookie.name == "webterm_auth") + } + + @Test("a valid token becomes exactly `webterm_auth=`") + func validTokenBecomesHeaderValue() { + // Arrange / Act + let header = AuthCookie.headerValue(token: Self.validToken) + + // Assert + #expect(header == "webterm_auth=\(Self.validToken)") + } + + @Test("no token configured (nil / empty) ⇒ no Cookie header at all") + func absentTokenYieldsNoHeader() { + #expect(AuthCookie.headerValue(token: nil) == nil) + #expect(AuthCookie.headerValue(token: "") == nil) + } + + @Test("length policy: 16 and 512 accepted, 15 and 513 rejected (server charset rule)") + func lengthPolicyMatchesServer() { + #expect(AuthCookie.headerValue(token: Self.minLengthToken) != nil) + #expect(AuthCookie.headerValue(token: Self.maxLengthToken) != nil) + #expect(AuthCookie.headerValue(token: String(repeating: "a", count: 15)) == nil) + #expect(AuthCookie.headerValue(token: String(repeating: "a", count: 513)) == nil) + } + + @Test( + "off-charset tokens are refused, never smuggled into the header (CRLF injection included)", + arguments: [ + "aaaaaaaaaaaaaaaa\r\nX-Evil: 1", // header injection attempt + "aaaaaaaaaaaaaaaa;path=/", // cookie-attribute injection attempt + "aaaaaaaaaaaaaaaa aaaa", // space + "aaaaaaaaaaaaaaaa\"quoted\"", + "aaaaaaaaaaaaaaaa\u{00E9}", // non-ASCII + "aaaaaaaaaaaaaaaa\u{0000}", + ] + ) + func offCharsetTokensRefused(token: String) { + #expect(AuthCookie.headerValue(token: token) == nil) + } +} diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/ScriptedWSServer.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/ScriptedWSServer.swift index 57caf7d..f0d838c 100644 --- a/ios/Packages/SessionCore/Tests/SessionCoreTests/ScriptedWSServer.swift +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/ScriptedWSServer.swift @@ -28,6 +28,22 @@ final class ScriptedWSServer: @unchecked Sendable { let headers: [String: String] } + /// A scripted NON-101 answer to the upgrade, byte-shaped like the real + /// server's reject path: it writes a bare status line and destroys the + /// socket — no headers, no body (src/server.ts:1367-1379, both the Origin + /// gate and the access-token cookie gate). + struct UpgradeRejection: Sendable { + let statusCode: Int + let reasonPhrase: String + + /// What BOTH server-side upgrade gates write (Origin, then cookie). + static let unauthorized = UpgradeRejection(statusCode: 401, reasonPhrase: "Unauthorized") + /// A non-401 rejection: must stay in the RETRYABLE class. + static let serverError = UpgradeRejection( + statusCode: 500, reasonPhrase: "Internal Server Error" + ) + } + /// RFC 6455 wire constants (named — no magic numbers). private enum Wire { static let finBit: UInt8 = 0x80 @@ -63,6 +79,7 @@ final class ScriptedWSServer: @unchecked Sendable { private var connection: NWConnection? private var rxBuffer = [UInt8]() private var isHandshakeDone = false + private var upgradeRejection: UpgradeRejection? private var startContinuation: CheckedContinuation? private var stopContinuation: CheckedContinuation? @@ -131,6 +148,13 @@ final class ScriptedWSServer: @unchecked Sendable { lock.withLock { connection }?.forceCancel() } + /// Answer every subsequent upgrade with `rejection` instead of the 101. + /// Call before `connect` (the upgrade request is still recorded, so tests + /// can assert the headers a REJECTED handshake carried). + func rejectUpgrades(with rejection: UpgradeRejection) { + lock.withLock { upgradeRejection = rejection } + } + // MARK: - Connection handling private func adopt(connection newConnection: NWConnection) { @@ -184,22 +208,31 @@ final class ScriptedWSServer: @unchecked Sendable { // MARK: - Handshake private func tryCompleteHandshake(on connection: NWConnection) { - let requestBytes: [UInt8]? = lock.withLock { + let pending: (requestBytes: [UInt8], rejection: UpgradeRejection?)? = lock.withLock { guard !isHandshakeDone, let end = Self.headerEndIndex(in: rxBuffer) else { return nil } let request = Array(rxBuffer[.. Int? { @@ -235,6 +268,10 @@ final class ScriptedWSServer: @unchecked Sendable { return Data(response.utf8) } + private static func rejectionResponse(_ rejection: UpgradeRejection) -> Data { + Data("HTTP/1.1 \(rejection.statusCode) \(rejection.reasonPhrase)\r\n\r\n".utf8) + } + // MARK: - Client frame parsing private func drainClientFrames(on connection: NWConnection) { diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/SessionEngineTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/SessionEngineTests.swift index 395011b..4231660 100644 --- a/ios/Packages/SessionCore/Tests/SessionCoreTests/SessionEngineTests.swift +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/SessionEngineTests.swift @@ -476,6 +476,70 @@ struct SessionEngineTests { #expect(await harness.transport.connectAttempts.count == 1) } + @Test("401 on the upgrade → connection(.failed(.unauthorized)) and NOT ONE reconnect attempt") + func unauthorizedIsTerminalAndNeverBacksOff() async throws { + // Arrange: the transport rejects the handshake with the typed 401 error + // (B3 · ios-completion §1.1 "WS upgrade 收 401 ⇒ 终态,不进退避环"). + let harness = try Harness() + await harness.transport.scriptConnectFailure(TermTransportError.unauthorized) + + // Act + await harness.engine.open(sessionId: nil, cwd: nil) + + // Assert: terminal failure instead of a backoff rung — retrying a wrong + // token forever is useless AND a brute-force signal. + #expect(await harness.next() == .connection(.connecting)) + #expect(await harness.next() == .connection(.failed(.unauthorized))) + #expect(await harness.next() == nil) + #expect(harness.clock.pendingSleeperCount == 0) + harness.clock.advance(by: .seconds(60)) + #expect(await harness.transport.connectAttempts.count == 1) + + // Assert: the engine is inert afterwards (same shape as .replayTooLarge). + await harness.engine.send(.input(data: "too late")) + #expect(await harness.engine.droppedOutboundFrameCount == 1) + } + + @Test("ordinary failure still backs off; a 401 on the retry stops the ladder for good") + func ordinaryFailureBacksOffThen401EndsIt() async throws { + // Arrange: attempt #1 fails the ordinary (retryable) way, attempt #2 401s. + let harness = try Harness() + await harness.transport.scriptConnectFailure() + await harness.transport.scriptConnectFailure(TermTransportError.unauthorized) + + // Act / Assert: rung 1 is unchanged by the new classification. + await harness.engine.open(sessionId: nil, cwd: nil) + #expect(await harness.next() == .connection(.connecting)) + #expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1)))) + await harness.clock.waitForSleepers(count: 1) + harness.clock.advance(by: .seconds(1)) + + // Assert: the 401 is terminal — no rung 2, no third attempt, ever. + #expect(await harness.next() == .connection(.failed(.unauthorized))) + #expect(await harness.next() == nil) + #expect(harness.clock.pendingSleeperCount == 0) + harness.clock.advance(by: .seconds(60)) + #expect(await harness.transport.connectAttempts.count == 2) + } + + @Test("unauthorized surfacing mid-stream is classified terminally too (one classifier)") + func unauthorizedOnStreamIsAlsoTerminal() async throws { + // Arrange + let sessionId = UUID() + let harness = try Harness() + await harness.openAndAdopt(adopting: sessionId) + + // Act + await harness.transport.emitError(TermTransportError.unauthorized) + + // Assert + #expect(await harness.next() == .connection(.failed(.unauthorized))) + #expect(await harness.next() == nil) + #expect(harness.clock.pendingSleeperCount == 0) + harness.clock.advance(by: .seconds(60)) + #expect(await harness.transport.connectAttempts.count == 1) + } + // MARK: - Close @Test("close() closes the transport, finishes the events stream, and leaks no tasks") diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/URLSessionTermTransportTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/URLSessionTermTransportTests.swift index df2ddf3..5b60f5c 100644 --- a/ios/Packages/SessionCore/Tests/SessionCoreTests/URLSessionTermTransportTests.swift +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/URLSessionTermTransportTests.swift @@ -30,6 +30,12 @@ struct URLSessionTermTransportTests { static let normalCloseCode: UInt16 = 1000 /// Arbitrary non-text payload for the binary-frame drop test. static let binaryPayload: [UInt8] = [0xDE, 0xAD, 0xBE, 0xEF] + /// A well-formed access token (server charset, src/config.ts:178). + static let accessToken = "Ab9._~+/=-Ab9._~+/=-" + /// A second well-formed token — proves per-connect token resolution. + static let rotatedAccessToken = "Zz0-=/+~._Zz0-=/+~._" + /// Off-charset token: must NEVER reach the wire (header injection). + static let malformedAccessToken = "aaaaaaaaaaaaaaaa\r\nX-Evil: 1" } private struct TimedOut: Error {} @@ -74,6 +80,22 @@ struct URLSessionTermTransportTests { } } + /// The first `count` upgrade requests, drained through ONE iterator + /// (`AsyncStream` is single-consumer — buffered yields are never lost). + private static func firstUpgrades( + count: Int, from server: ScriptedWSServer + ) async throws -> [ScriptedWSServer.Upgrade] { + try await withTimeout { + var collected: [ScriptedWSServer.Upgrade] = [] + var iterator = server.upgrades.makeAsyncIterator() + while collected.count < count { + guard let upgrade = await iterator.next() else { throw TimedOut() } + collected.append(upgrade) + } + return collected + } + } + private static func collect( _ count: Int, from frames: AsyncThrowingStream ) async throws -> [String] { @@ -278,6 +300,135 @@ struct URLSessionTermTransportTests { } } + // MARK: - Access token (B3 · ios-completion §1.1) + + @Test("configured token ⇒ upgrade carries Cookie: webterm_auth= AND Origin (additive, §1.1)") + func upgradeCarriesAuthCookieAlongsideOrigin() async throws { + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken }) + + let connection = try await transport.connect(to: endpoint) + let upgrade = try await Self.firstUpgrade(server) + + // The cookie NEVER replaces Origin — the server checks Origin first, + // then the cookie (src/server.ts:1367-1379). + #expect(upgrade.headers["origin"] == endpoint.originHeader) + #expect(upgrade.headers["cookie"] == "\(AuthCookie.name)=\(TestTunables.accessToken)") + await connection.close() + } + + @Test("no token configured ⇒ NO Cookie header at all (LAN zero-config unchanged)") + func upgradeOmitsCookieWhenNoTokenConfigured() async throws { + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + + let connection = try await URLSessionTermTransport().connect(to: endpoint) + let upgrade = try await Self.firstUpgrade(server) + + #expect(upgrade.headers["cookie"] == nil) + #expect(upgrade.headers["origin"] == endpoint.originHeader) + await connection.close() + } + + @Test("off-charset token is dropped, never smuggled onto the wire; Origin still sent") + func malformedTokenIsNeverSentButOriginIs() async throws { + // A malformed stored token must not break a host that has auth DISABLED + // (it ignores the cookie), and must not become a header-injection vector. + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + let transport = URLSessionTermTransport( + tokenProvider: { TestTunables.malformedAccessToken } + ) + + let connection = try await transport.connect(to: endpoint) + let upgrade = try await Self.firstUpgrade(server) + + #expect(upgrade.headers["cookie"] == nil) + #expect(upgrade.headers["x-evil"] == nil) + #expect(upgrade.headers["origin"] == endpoint.originHeader) + await connection.close() + } + + @Test("token provider is resolved PER CONNECT — a token added/rotated mid-run needs no relaunch") + func tokenProviderResolvedPerConnect() async throws { + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + let tokens = TokenSequence([TestTunables.accessToken, TestTunables.rotatedAccessToken]) + let transport = URLSessionTermTransport(tokenProvider: { tokens.next() }) + + let first = try await transport.connect(to: endpoint) + await first.close() + let second = try await transport.connect(to: endpoint) + await second.close() + let upgrades = try await Self.firstUpgrades(count: 2, from: server) + + #expect(upgrades.map { $0.headers["cookie"] } == [ + "\(AuthCookie.name)=\(TestTunables.accessToken)", + "\(AuthCookie.name)=\(TestTunables.rotatedAccessToken)", + ]) + } + + @Test("401 on the WS upgrade ⇒ typed TermTransportError.unauthorized (terminal, never retried)") + func unauthorizedUpgradeThrowsTypedUnauthorized() async throws { + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + server.rejectUpgrades(with: .unauthorized) + let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken }) + + var thrown: (any Error)? + do { + _ = try await Self.withTimeout { try await transport.connect(to: endpoint) } + } catch { + thrown = error + } + + #expect( + (thrown as? TermTransportError) == .unauthorized, + "expected .unauthorized, got \(String(describing: thrown))" + ) + } + + @Test("a NON-401 upgrade rejection stays in the retryable class (not .unauthorized)") + func nonUnauthorizedUpgradeRejectionStaysRetryable() async throws { + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + server.rejectUpgrades(with: .serverError) + + var thrown: (any Error)? + do { + _ = try await Self.withTimeout { + try await URLSessionTermTransport().connect(to: endpoint) + } + } catch { + thrown = error + } + + let failure = try #require(thrown, "a 500 upgrade must still fail the connect") + #expect(!(failure is TimedOut), "connect neither opened nor failed in time") + #expect((failure as? TermTransportError) != .unauthorized) + } + + @Test("the token never appears in a thrown error (nothing loggable carries the secret)") + func thrownErrorNeverCarriesTheToken() async throws { + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + server.rejectUpgrades(with: .unauthorized) + let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken }) + + var thrown: (any Error)? + do { + _ = try await Self.withTimeout { try await transport.connect(to: endpoint) } + } catch { + thrown = error + } + + let failure = try #require(thrown) + #expect(!String(describing: failure).contains(TestTunables.accessToken)) + #expect(!String(reflecting: failure).contains(TestTunables.accessToken)) + #expect(!failure.localizedDescription.contains(TestTunables.accessToken)) + } + /// Thread-safe invocation counter for the `@Sendable` identity provider /// (URLSession may consult it off the test's isolation domain). private final class CallCounter: @unchecked Sendable { @@ -286,5 +437,24 @@ struct URLSessionTermTransportTests { func increment() { lock.withLock { count += 1 } } var value: Int { lock.withLock { count } } } + + /// Thread-safe token script: hands out one token per `connect` (the last + /// value repeats), proving the provider is re-consulted every time. + private final class TokenSequence: @unchecked Sendable { + private let lock = NSLock() + private var remaining: [String] + + init(_ tokens: [String]) { + remaining = tokens + } + + func next() -> String? { + lock.withLock { + guard let first = remaining.first else { return nil } + if remaining.count > 1 { remaining.removeFirst() } + return first + } + } + } } #endif diff --git a/ios/README.md b/ios/README.md index 30423da..2b94e7d 100644 --- a/ios/README.md +++ b/ios/README.md @@ -16,8 +16,14 @@ small splits) get the compact stack layout; iPad regular width gets a ## Build & Run Requirements: macOS 15, Xcode 16.3 (Swift 6.1), [XcodeGen](https://github.com/yonaskolb/XcodeGen), -an iOS 17+ simulator (CI tests on iPhone 16 **and** iPad Pro 11"). SwiftTerm 1.13 -is resolved for the App target only. +an iOS 17+ simulator (CI tests on iPhone 16 **and** iPad Pro 11"). SwiftTerm is +the only third-party dependency, attached to the App target only, and **pinned to +an exact version — `exactVersion: 1.15.0`** (`project.yml`). The pin is exact, not +`from:`, because the generated `.xcodeproj` — and with it `Package.resolved` — +is gitignored, so every checkout/CI run re-resolves from scratch and a floating +requirement silently drifted (1.13.0 → 1.15.0 once broke the App target on a +`hasActiveSelection` name collision; the local property was dropped in favour of +upstream's, so the tree now rides 1.15.0 deliberately). ```bash cd ios @@ -29,7 +35,7 @@ xcodebuild -project WebTerm.xcodeproj -scheme WebTerm \ -destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' test ``` -Layout: +### Layout | Path | What | |---|---| @@ -37,15 +43,130 @@ Layout: | `Packages/WireProtocol` | Frozen wire contract (frames, validation, tunables) | | `Packages/SessionCore` | `SessionEngine` actor: connect/replay/reconnect/gate | | `Packages/HostRegistry` | Paired hosts + last-session persistence | -| `Packages/APIClient` | HTTP endpoints (guarded routes carry `Origin`) | +| `Packages/APIClient` | HTTP endpoints (guarded routes carry `Origin`, all routes carry the token `Cookie`) | +| `Packages/ClientTLS` | Device client-certificate (mTLS) identity: Secure-Enclave key, CSR, enrolment, rotation | | `Packages/TestSupport` | `FakeTransport` / `FakeClock` / `FakeHTTPTransport` | -| `IntegrationTests/` | Tests against a real Node server (`npm start`) | +| `IntegrationTests/` | Tests against a real Node server (`npm start`) + `scripts/coverage-gate.sh` | Per-package tests: `swift test --package-path Packages/`. +Coverage gate (≥ 80 % own-sources, one package per invocation): +`ios/IntegrationTests/scripts/coverage-gate.sh ` — gated packages are +`WireProtocol`, `SessionCore`, `HostRegistry`, `APIClient` and (since this wave) +`ClientTLS`. The server runs from the repo root: `npm install && npm start` (see the root [README](../README.md)). +### Signing, device builds, and the push-entitlements switch + +The signing account is a **free personal Apple team**: `DEVELOPMENT_TEAM = +C738Z66SRW` is declared in `project.yml` (a Team ID is not a secret — it is +public in every signed ipa), and without it any device build stops at *"Signing +for 'WebTerm' requires a development team"*. `CODE_SIGN_IDENTITY` is set to the +modern `Apple Development` at **target** level, because XcodeGen's +iOS-application preset injects the deprecated pre-Xcode-12 `iPhone Developer` +string at target level, which outranks anything set project-wide. + +**Device build works on the free team** (verified in this wave, commit +`c4f8b5b`): with automatic signing plus `-allowProvisioningUpdates`, Xcode mints +an on-the-fly **7-day** free-team provisioning profile and the real-device build +reports `BUILD SUCCEEDED`. A 7-day profile expires — re-run the build to re-mint. + +**Push entitlements are opt-in, via one frozen env switch.** `aps-environment` +lives in `App/WebTerm/WebTerm.entitlements`, which is **not attached by +default**: + +```bash +cd ios && WEBTERM_PUSH_ENTITLEMENTS=App/WebTerm/WebTerm.entitlements xcodegen generate # push ON +cd ios && xcodegen generate # push OFF (default) +``` + +Plainly: **on a free personal team, switching push ON makes the device build +fail** — verbatim: + +> `Personal development teams, including "Yaojia Wang", do not support the Push Notifications capability` + +So the switch is usable only on a **paid** team with the Push Notifications +capability enabled on the App ID, and a **release** build additionally needs +`aps-environment` flipped from `development` to `production` (that flip belongs +to the release pipeline, not to the checked-in file). With the variable unset, +`CODE_SIGN_ENTITLEMENTS` resolves against an empty project-level default, so no +entitlements file is signed in at all — which is exactly why the default build +works on the free team. + +The switch is read by XcodeGen at **generate** time and its default is declared +as a project-level build setting (which outranks the build-time process +environment), so an exported shell variable can never silently attach +entitlements to a project that was generated without it. + +`UIBackgroundModes: [remote-notification]` is unconditional: a background +*mode* is not an Apple capability, so it is safe on a free team — unlike the +`aps-environment` *entitlement*. + +## Access token (`WEBTERM_TOKEN`) — how this client authenticates + +A server started with `WEBTERM_TOKEN=<16–512 chars of [A-Za-z0-9._~+/=-]>` gates +**every** HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie +(`src/http/auth.ts`). A host with **no** token configured is byte-identical to +before the feature: nothing is stored, so no `Cookie` header is ever sent and LAN +zero-config still works. + +**Honest boundary, mirroring `src/http/auth.ts`: this is a bar-raiser, NOT a +TLS/Tailscale substitute.** On a bare-LAN `ws://` / `http://` deployment the +cookie and the token travel in **cleartext** — anyone sniffing the LAN sees the +token and can **replay** it (no per-request nonce, no channel binding). It is a +single shared secret: no per-user identity, no revocation except changing the env +var and restarting. It only meaningfully hardens the relay/tunnel path where the +edge terminates TLS. It does not make the server safe to port-forward. + +How the native client speaks it: + +- **The client hand-writes `Cookie: webterm_auth=` 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,76 @@ 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. + The key-bar height is derived from the content size category rather than + frozen at 52 pt, so nothing clips at any size. Its keycap **font** is clamped + at `.accessibilityLarge` — the same dense-content policy the design system + already applies to telemetry chips — because an unclamped AX5 bar wants + ~108 pt of the 17-key strip and would eat the terminal. So AX3–AX5 render + keycaps at AX2 size; a test pins the clamp to the design-system constant so + the two cannot drift, and another keeps the bar at exactly 52 pt for XS–XL. +- **Project git panel** — the ambient git state the server has served since w6, + now on the phone: a **sync band** (`↑ahead ↓behind`, upstream, detached-HEAD + and fetch-staleness states — green "in sync" *only* when `↑0 ↓0` **and** the + last fetch is under an hour old), a **log** with the unpushed-commit boundary + drawn after the last unpushed commit, **PR status**, and **stage / commit / + push / fetch** writes. Every write shows the server's own error text verbatim + when it rejects; a git-credential `401` from `push` is deliberately *not* + confused with an access-token `401`. +- **Worktree lifecycle** — create (with the web's 9 branch-name rules enforced + client-side, so an invalid name never reaches the network), **prune**, and + **remove** behind two-step confirmation: a `409` promotes to an explicit + force-confirm rather than auto-retrying, and the main/locked worktrees offer no + remove action at all. +- **`claude --resume` history** — `GET /sessions` filtered to the project's own + subtree (no half-path prefix matches), server order preserved. The session id + is whitelisted (`[A-Za-z0-9._-]{1,128}`) **before** it is composed into the + bootstrap command line, and a row whose id fails that check offers no resume + button. +- **`?join=` interop with the web share QR** — the web 🔗 share link + (`http(s)://[:]/?join=`) 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 +334,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 diff --git a/ios/project.yml b/ios/project.yml index 0ec6c85..da89829 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -21,6 +21,18 @@ settings: IPHONEOS_DEPLOYMENT_TARGET: "17.0" TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad (T-iPad-1; adaptive layout) CODE_SIGN_STYLE: Automatic + # Free personal Apple team (O="Yaojia Wang"). Required for ANY device build: + # without it xcodebuild stops at 'Signing for "WebTerm" requires a + # development team'. Not a secret — a Team ID is public in every signed ipa. + DEVELOPMENT_TEAM: C738Z66SRW + # Default for the opt-in push-entitlements switch (A1). Empty ⇒ the target's + # CODE_SIGN_ENTITLEMENTS = "${WEBTERM_PUSH_ENTITLEMENTS}" resolves to nothing + # and no entitlements file is signed in. Declaring the default HERE (rather + # than relying on the setting being undefined) keeps the switch strictly + # generate-time: a project-level build setting outranks the build-time + # process environment, so an exported shell var can never silently attach + # entitlements to a build generated without it. + WEBTERM_PUSH_ENTITLEMENTS: "" packages: WireProtocol: @@ -37,16 +49,39 @@ packages: path: Packages/TestSupport # test doubles — WebTermTests only, never the app target # SwiftTerm is the ONLY third-party dependency, attached to the App target # ONLY (plan §2) — packages must never import it. + # + # 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, and a floating `from:` would silently + # drift to whatever is newest. An exact pin makes every agent/CI run resolve + # the SAME source (an unattended drift off `from: 1.13.0` is what once broke + # the App target — see the history below); raising it is a deliberate, + # verified edit, and 1.15.0 below IS that deliberate raise. + # + # 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 - from: 1.13.0 + exactVersion: 1.15.0 targets: WebTerm: type: application platform: iOS sources: - - App/WebTerm + # The .entitlements file lives next to the sources but must never be + # bundled as a resource — it is signing input, not app content. + - path: App/WebTerm + excludes: + - WebTerm.entitlements dependencies: - package: WireProtocol - package: SessionCore @@ -57,6 +92,21 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.yaojia.webterm + # XcodeGen's iOS-application preset injects Apple's DEPRECATED + # pre-Xcode-12 development identity string at target level (which + # outranks anything set in the project-level settings above), so it has + # to be overridden right here. "Apple Development" is the modern unified + # identity Xcode 12+ issues — it is what `security find-identity` + # actually holds, so the old string could only ever resolve by alias. + CODE_SIGN_IDENTITY: "Apple Development" + # OPT-IN push entitlements (frozen switch name WEBTERM_PUSH_ENTITLEMENTS). + # XcodeGen substitutes ${VAR} from the environment at `xcodegen generate` + # time when the var is SET, and leaves the literal placeholder when it is + # UNSET — the placeholder then resolves against the project-level + # WEBTERM_PUSH_ENTITLEMENTS: "" default, i.e. no entitlements. + # default (free team): xcodegen generate + # push on (paid team): WEBTERM_PUSH_ENTITLEMENTS=App/WebTerm/WebTerm.entitlements xcodegen generate + CODE_SIGN_ENTITLEMENTS: ${WEBTERM_PUSH_ENTITLEMENTS} # Target-level on purpose (T-iOS-19 finding): XcodeGen's target platform # default overrides a project-level value. iPad adaptation (T-iPad-1) # opens this to iPhone + iPad; the adaptive layout (LayoutPolicy / @@ -105,12 +155,26 @@ targets: NSExceptionAllowsInsecureHTTPLoads: true NSLocalNetworkUsageDescription: "连接你自己电脑上的 web-terminal 服务器" NSCameraUsageDescription: "扫描 web 终端的配对二维码" + # Voice push-to-talk (T-iOS-31, P2). Both keys are mandatory before the + # feature ships: missing either one = TCC crash the moment PTT is pressed. + # Added here by A1 because project.yml has a single owner. + NSMicrophoneUsageDescription: "按住说话,把你的口述录成终端输入" + NSSpeechRecognitionUsageDescription: "把你的口述转成文字,你确认后才送进终端" + # Silent APNs wake so a hook notification can be processed without the + # app in the foreground (T-iOS-21). A background MODE is not an Apple + # capability, so it is unconditional and safe on the free team — unlike + # the aps-environment ENTITLEMENT, which stays opt-in (see above). + UIBackgroundModes: + - remote-notification # Unit-test bundle for App-target logic (ViewModels & pure UI components, # W3 T-iOS-11…14). Added by the orchestrator as a coordination point # (project.yml is T-iOS-1's Owns; same precedent as the W1 manifest wiring). - # Coverage GATE still counts only the 4 packages (plan §9) — these tests - # exist for correctness, not for the gate. + # These tests exist for correctness, NOT for the coverage gate: the gate runs + # per SwiftPM package over that package's own sources + # (IntegrationTests/scripts/coverage-gate.sh), and an App-target test bundle is + # not one of them. It now covers FIVE packages — plan §9's four plus ClientTLS, + # which B4 added (see .github/workflows/ios.yml's package-tests matrix). WebTermTests: type: bundle.unit-test platform: iOS