Merge ios-completion: device builds unblocked, access token on both clients, P2 wave

Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.

The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.

Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.

src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.

# Conflicts:
#	android/.gitignore
#	android/README.md
#	android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
#	android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
#	android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
#	android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
#	android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
#	docs/PROGRESS_LOG.md
This commit is contained in:
Yaojia Wang
2026-07-30 18:12:03 +02:00
174 changed files with 22184 additions and 666 deletions

184
.github/workflows/android.yml vendored Normal file
View File

@@ -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=<sdk> ./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

View File

@@ -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/<P>/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:<free-port> 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_<VAR> must be an ENV VAR of the xcodebuild process (it
# strips the prefix and injects <VAR> 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

View File

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

View File

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

View File

@@ -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=<16512 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=<t>` 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:<port>` 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

View File

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

View File

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

View File

@@ -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<String, String>,
): 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<String, String>,
): 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=<t>` 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<String, String> =
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

View File

@@ -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=<t>` on EVERY route (RO included) inside the same single point that
* stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" —
* an unauthenticated LAN host behaves exactly as before. A **401** 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`. */

View File

@@ -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 校验未通过)。")

View File

@@ -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=<t>` 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<String, String>()
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
}

View File

@@ -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":"<t>"}`.
*
* 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<String>, 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 <T> jsonBodyRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
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 <T> gitWriteRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
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)

View File

@@ -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":"<t>"}` 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<String, String> = 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",
)
}
}

View File

@@ -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=<t>`;
* - 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)
}
}

View File

@@ -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=<t>` 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)
}
}

View File

@@ -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",
)
}
}
}

View File

@@ -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` <device-transfer> 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` <device-transfer> 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. -->
<application
android:name=".WebTermApp"
android:allowBackup="false"
android:label="@string/app_name"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:supportsRtl="true"
android:theme="@style/Theme.WebTerm"
android:networkSecurityConfig="@xml/network_security_config"

View File

@@ -79,11 +79,16 @@ public sealed interface BannerModel {
/**
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance.
* spinner** (reconnecting would hit the same wall forever), and — where a fresh session would
* actually help — a "新会话" affordance.
*
* [FailureReason.UNAUTHORIZED] deliberately offers NO new-session action (B5): the server 401'd the
* upgrade, so a new session would 401 too; the fix is to re-enter the host's access token (or fix its
* `ALLOWED_ORIGINS`), which the copy says.
*/
public data class Failed(val reason: FailureReason) : BannerModel {
override val showsSpinner: Boolean get() = false
override val offersNewSession: Boolean get() = true
override val offersNewSession: Boolean get() = reason != FailureReason.UNAUTHORIZED
}
/**
@@ -184,6 +189,8 @@ private fun bannerCopy(model: BannerModel): String = when (model) {
is BannerModel.Failed -> when (model.reason) {
FailureReason.REPLAY_TOO_LARGE ->
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
FailureReason.UNAUTHORIZED ->
"服务器拒绝了连接401。请在“配对主机”里重新填写访问令牌若已填写请检查主机的 ALLOWED_ORIGINS。"
}
is BannerModel.Exited ->
if (model.isSpawnFailure) {

View File

@@ -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
}

View File

@@ -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=<t>` — 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

View File

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

View File

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

View File

@@ -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)
}

View File

@@ -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>(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 / 16512 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 ->
"访问令牌格式不合法:需 16512 个字符,且只能包含 AZ az 09 . _ ~ + / = -。"
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 ->
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
}
}

View File

@@ -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<HttpTransport>,
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)
}

View File

@@ -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=<token>`), 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

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
B5 / ios-completion §1.1 — the app holds SECRET material on device: the per-host access token
(`WEBTERM_TOKEN`, Tink-encrypted in webterm_access_token_prefs) and the mTLS device identity
(AndroidKeyStore key + Tink-encrypted cert blob). None of it may leave the device.
`android:allowBackup="false"` already opts out of cloud backup / adb backup on every API level; this
file additionally covers API 31+ cloud-backup and device-to-device TRANSFER, where a plain
allowBackup=false does NOT by itself block transfer. Both sections exclude everything.
(The AndroidKeyStore private key is non-exportable regardless, so a transferred blob would be
undecryptable anyway — this is defence in depth, and it keeps the intent explicit.)
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>

View File

@@ -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))))
}
}

View File

@@ -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"))
}
}

View File

@@ -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<String>()
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<HostEndpoint>()
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<String>()
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<Unit>()
val neverAnswers = CompletableDeferred<PairingProbeResult>()
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")
}
}
}

View File

@@ -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" },
)

View File

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

View File

@@ -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`: 16512 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<Host>) : HostStore {
@@ -71,7 +78,9 @@ class HostRemoverTest {
): Triple<HostRemover, RecordingPush, Quad> {
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=<token>`), 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()))

View File

@@ -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()) {

View File

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

View File

@@ -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/")))
}
}

View File

@@ -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<String, String> = emptyMap(),
) : AccessTokenStore {
private val mutex = Mutex()
// Defensive copy so a mutable map handed to the ctor cannot alias our state.
private var tokensByOrigin: Map<String, String> = 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 }
}
}

View File

@@ -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<String, String>? = 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<String, String> =
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<String, String> =
cache ?: withContext(io) { decodeBlob(prefs().getString(BLOB_KEY, null)) }
private suspend fun persist(tokens: Map<String, String>) {
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<String, String> {
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())
}
}

View File

@@ -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")))
}
}

View File

@@ -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<SessionEvent>(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
}
}

View File

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

View File

@@ -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<SessionEvent> {
val out = mutableListOf<SessionEvent>()
backgroundScope.launch { engine.events.collect { out += it } }
return out
}
private fun List<SessionEvent>.connectionStates(): List<ConnectionState> =
filterIsInstance<Connection>().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<ConnectionState.Failed>().single().reason,
)
}
}

View File

@@ -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=<t>` 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<Cookie>) {
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<AuthCookieSnapshot>) {
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<Cookie>.upserting(cookie: Cookie): List<Cookie> =
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,

View File

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

View File

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

View File

@@ -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=<t>` — 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()

View File

@@ -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)
}
}
}

View File

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

View File

@@ -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<String, List<AuthCookieSnapshot>>()
override fun persist(hostKey: String, cookies: List<AuthCookieSnapshot>) {
latest[hostKey] = cookies
}
fun latestFor(hostKey: String): List<AuthCookieSnapshot>? = latest[hostKey]
}
private class SilentCoexistenceWebSocket : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) = Unit
}

View File

@@ -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=<t>` 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
}

View File

@@ -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=<t>` 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=<token>` — 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._~+/=-]`, 16512 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<Char> =
(('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 }
}
}

View File

@@ -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"
}
}

View File

@@ -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=<t>` 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._~+/=-]`, 16512) 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",
)
}
}

View File

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

View File

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

View File

@@ -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` 触发,理由写在文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿。
- **一个诚实的取舍**: 键栏在 AX3AX5 的**字号**封顶在 `.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 四个受门模块 9296%、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 清掉。

View File

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

View File

@@ -54,9 +54,26 @@ struct KeyBarButtonSpec: Equatable, Sendable {
}
}
/// Push-to-talk outlets for the 🎤 key (T-iOS-31). Supplied as a PAIR a mic
/// that can start but not stop would leave the microphone hot.
struct KeyBarVoiceHandlers {
let onDown: @MainActor () -> Void
let onUp: @MainActor () -> Void
}
/// Button order/copy transcribed from `public/keybar.ts` `KEYBAR_BUTTONS`
/// (most-used Claude Code keys first). The 🎤 voice button is P2 (T-iOS-31).
/// (most-used Claude Code keys first), plus the 🎤 push-to-talk key
/// (T-iOS-31) which mirrors `keybar.ts buildMicButton` appended at the END,
/// only when voice handlers are supplied, and deliberately OUTSIDE
/// `buttons`/`KeyByteMap`: it maps to no bytes at all.
enum KeyBarLayout {
/// 🎤 glyph + caption, transcribed from `keybar.ts buildMicButton`.
static let voiceLabel = "🎤"
static let voiceCaption = "语音"
/// Accessibility title. Unlike the web (Chrome ships audio to Google), iOS
/// prefers on-device recognition the copy says what actually happens.
static let voiceTitle = "按住说话 — 转成文字后要你确认才送进终端"
static let buttons: [KeyBarButtonSpec] = [
KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断",
title: "Esc — interrupt Claude / dismiss", isPrimary: true),
@@ -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)

View File

@@ -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 URLSessionpreview
/// 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 URLSessionpreview
/// 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
OPT-IN ONLY — this file is NOT attached to the target by default.
The signing account is a FREE personal Apple team (Team ID C738Z66SRW), and a
free team cannot enable the Push Notifications capability. Attaching
aps-environment unconditionally makes every device build fail with
"Provisioning profile ... doesn't support the Push Notifications capability".
Attach it only on a paid-team machine, by exporting the frozen env switch at
generate time (see ios/README.md):
WEBTERM_PUSH_ENTITLEMENTS=App/WebTerm/WebTerm.entitlements xcodegen generate
With the variable unset, project.yml resolves CODE_SIGN_ENTITLEMENTS to the
empty string, so no entitlements file is signed in.
aps-environment=development is the sandbox APNs environment (dev builds +
TestFlight-from-Xcode). A distribution build must flip it to "production";
keep that flip with the release pipeline, not here.
-->
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>

View File

@@ -3,6 +3,7 @@ import ClientTLS
import Foundation
import HostRegistry
import SessionCore
import UIKit
import WireProtocol
/// T-iOS-15 · Production dependency graph (composition root). One immutable
@@ -12,12 +13,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<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure> {
do {
let client = APIClient(endpoint: endpoint, http: http)
return .success(try await client.probeAccessToken(token.rawValue))
} catch APIClientError.malformedToken {
return .failure(.malformed)
} catch let apiError as APIClientError {
// `.message` is user-facing copy about the STATUS, never about the token.
return .failure(.unreachable(apiError.message))
} catch {
return .failure(.unreachable((error as NSError).localizedDescription))
}
}
// MARK: - Host removal APNs de-registration (C1 · fixes the dead hook)
/// Bridge from "the user removed a host" to `PushRegistrar.handleHostRemoved`.
///
/// The registrar owns the in-memory APNs device token (deliberately never
/// persisted iOS re-delivers it on every registration), so the de-registration
/// can only be done by the LIVE instance. That instance is created and held by
/// `PushAppDelegate` (`makePushWiring`), which the system builds after this
/// composition root hence the resolution happens at CALL time through
/// `UIApplication.shared.delegate`, the platform's own object, rather than any
/// singleton of ours.
///
/// nil delegate / nil registrar (unit tests, XCUITest, a build where push was
/// never wired) is a deliberate no-op: removal must never depend on push.
enum PushHostDeregistration {
@MainActor
static func run(for host: HostRegistry.Host) async {
guard let delegate = UIApplication.shared.delegate as? PushAppDelegate,
let registrar = delegate.registrar else {
return
}
await registrar.handleHostRemoved(host)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,286 @@
import SessionCore
import SwiftUI
import Testing
import UIKit
import WireProtocol
@testable import WebTerm
/// T-iOS-34 · Dynamic Type doc §4 F 2529
///
/// ""****""
/// `UIHostingController` 320pt iPhone SE /
/// `sizeThatFits`
/// 1. **** /
/// 2. **** AX >
///
/// `TelemetryChip` / `dsMetaText`****
/// AX5 DS `DS.Typography.numericClamp`
/// "AX2 AX5 "
@MainActor
@Suite("DynamicTypeLayout")
struct DynamicTypeLayoutTests {
// MARK: - F25 · GateBanner/ shell
@Test("GateBanner 在 AX5 下不横向溢出,且相比标准档是长高而非被裁")
func gateBannerSurvivesLargestType() {
let gate = GateState(kind: .tool, detail: "Bash(rm -rf ./build)", epoch: 1)
let banner = GateBanner(gate: gate) { _, _ in }
let standard = LayoutProbe.fit(banner, size: .large)
let largest = LayoutProbe.fit(banner, size: .accessibility5)
#expect(largest.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
#expect(largest.height.isFinite)
#expect(largest.height > standard.height)
}
// MARK: - F26/F27 ·
@Test("numericClamp 策略:封顶在 accessibility2严格小于 accessibility5")
func numericClampPolicy() {
#expect(DS.Typography.numericClamp == .accessibility2)
#expect(DS.Typography.numericClamp < .accessibility5)
}
@Test("TelemetryChipAX2 与 AX5 同高(夹取生效),且不横向溢出")
func telemetryChipIsClamped() {
let chip = TelemetryChip(systemImage: "cpu", text: "ctx 92% · $0.1234")
let clampEdge = LayoutProbe.fit(chip, size: DS.Typography.numericClamp)
let largest = LayoutProbe.fit(chip, size: .accessibility5)
#expect(largest.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
#expect(abs(largest.height - clampEdge.height) < LayoutProbe.epsilon)
}
@Test("TelemetryChip 在夹取上限之前照常放大(不是把字号焊死)")
func telemetryChipStillScalesBelowClamp() {
let chip = TelemetryChip(text: "161×50")
let standard = LayoutProbe.fit(chip, size: .large)
let clampEdge = LayoutProbe.fit(chip, size: DS.Typography.numericClamp)
#expect(clampEdge.height > standard.height)
}
@Test("dsMetaText 元信息行走同一夹取(单一出处,非逐屏各写一遍)")
func metaTextIsClamped() {
let meta = Text(verbatim: "2 台设备在看 · 161×50").dsMetaText()
let clampEdge = LayoutProbe.fit(meta, size: DS.Typography.numericClamp)
let largest = LayoutProbe.fit(meta, size: .accessibility5)
#expect(abs(largest.height - clampEdge.height) < LayoutProbe.epsilon)
}
// MARK: - F28 · DSButtonStylegate
@Test("DSButtonStyle 在 AX5 半宽容器里仍 ≥ 44pt 高且不横向溢出")
func buttonStyleSurvivesLargestType() {
let button = Button(GateChoiceSpec.label(for: .approve)) {}
.buttonStyle(DSButtonStyle(kind: .primary))
let halfWidth = LayoutProbe.phoneWidth / 2
let standard = LayoutProbe.fit(button, width: halfWidth, size: .large)
let largest = LayoutProbe.fit(button, width: halfWidth, size: .accessibility5)
#expect(largest.width <= halfWidth + LayoutProbe.epsilon)
#expect(largest.height >= DS.Layout.minHitTarget)
// `minHeight` `height` 44pt
#expect(largest.height > standard.height)
}
// MARK: - AX5
@Test("SettingsScreen 在 AX5 下可渲染且不横向溢出")
func settingsScreenSurvivesLargestType() {
let screen = SettingsScreen(themeStore: ThemeStore(defaults: FakeThemeDefaults()))
let size = LayoutProbe.fit(NavigationStack { screen }, size: .accessibility5)
#expect(size.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
#expect(size.height.isFinite)
}
// MARK: - F29 · KeyBarUIKit inputAccessoryView
/// F1 · `withKnownIssue` ****`KeyBarMetrics.barHeight`
/// 52pt44 + 8 AX5 ~108ptfootnote 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 **XSXL
/// L 52ptXXL 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<V: View>(
_ view: V, width: CGFloat = phoneWidth, size: DynamicTypeSize
) -> CGSize {
let host = UIHostingController(rootView: view.dynamicTypeSize(size))
host.view.layoutIfNeeded()
return host.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
}
}
/// UIKit **** `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
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More