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:
184
.github/workflows/android.yml
vendored
Normal file
184
.github/workflows/android.yml
vendored
Normal 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
|
||||||
153
.github/workflows/ios.yml
vendored
153
.github/workflows/ios.yml
vendored
@@ -6,8 +6,9 @@
|
|||||||
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
|
# 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
|
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
|
||||||
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
|
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
|
||||||
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle,
|
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle) on
|
||||||
# iPhone 16 simulator): ViewModels/components of the app glue layer.
|
# 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
|
# 3. integration-tests — Swift Testing against the REAL Node server. The
|
||||||
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
|
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
|
||||||
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
|
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
|
||||||
@@ -35,14 +36,15 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
|
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
|
||||||
# The gate covers exactly the 4 gated packages (plan §9); TestSupport runs
|
# The gate covers the 4 packages of plan §9 PLUS ClientTLS (added by B4 — the
|
||||||
# tests below without a gate (test doubles are excluded from the gate).
|
# 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:
|
package-tests:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
package: [WireProtocol, SessionCore, HostRegistry, APIClient]
|
package: [WireProtocol, SessionCore, HostRegistry, APIClient, ClientTLS]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
@@ -56,47 +58,66 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
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
|
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:
|
app-tests:
|
||||||
runs-on: macos-15
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
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
|
- name: Install XcodeGen
|
||||||
run: brew install xcodegen
|
run: brew install xcodegen
|
||||||
- name: Generate project
|
- name: Generate project
|
||||||
run: cd ios && xcodegen generate
|
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
|
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
|
||||||
# real data-protection keychain, which returns -34018 for UNSIGNED test
|
# real data-protection keychain, which returns -34018 for UNSIGNED test
|
||||||
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
|
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
|
||||||
# handoff finding, verified locally in the ui-test leg runs.)
|
# handoff finding, verified locally in the ui-test leg runs.)
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
|
||||||
test
|
${{ matrix.extraArgs }} \
|
||||||
|
|
||||||
# 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)' \
|
|
||||||
test
|
test
|
||||||
|
|
||||||
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
|
# 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
|
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
|
||||||
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
|
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
|
||||||
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
|
# 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:
|
ui-test:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 45
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
@@ -159,7 +198,7 @@ jobs:
|
|||||||
# inputAccessoryView — it only exists while the SOFT keyboard is up.
|
# inputAccessoryView — it only exists while the SOFT keyboard is up.
|
||||||
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
|
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
|
||||||
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
|
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
|
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
|
||||||
# strips the prefix and injects <VAR> into the test-runner process);
|
# strips the prefix and injects <VAR> into the test-runner process);
|
||||||
# passing it as a KEY=VALUE argument makes it a build setting, which
|
# 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
|
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
|
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
|
- name: Server log + shutdown
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
@@ -181,15 +221,21 @@ jobs:
|
|||||||
|
|
||||||
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
|
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
|
||||||
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
|
# 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
|
# CI-ONLY LEG: local machines are NOT expected to hold the ~7 GB iOS 17
|
||||||
# Xcode 16.x can build against it. Local machines are NOT expected to
|
# runtime, so this leg is never reproducible on a dev box.
|
||||||
# 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
|
# NO SILENT SKIP (B4 fix): this step used to `exit 0` with a ::notice when the
|
||||||
# fail the job (no silent pass).
|
# 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:
|
ios17-floor-tests:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 45
|
timeout-minutes: 90 # the runtime download alone can take ~15 min
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3 (build toolchain)
|
- name: Select Xcode 16.3 (build toolchain)
|
||||||
@@ -198,23 +244,40 @@ jobs:
|
|||||||
run: brew install xcodegen
|
run: brew install xcodegen
|
||||||
- name: Generate project
|
- name: Generate project
|
||||||
run: cd ios && xcodegen generate
|
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
|
id: sim17
|
||||||
|
env:
|
||||||
|
IOS17_FALLBACK_VERSION: "17.5"
|
||||||
run: |
|
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
|
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 "::warning title=iOS 17 runtime absent::downloading iOS ${IOS17_FALLBACK_VERSION} simulator runtime (runner image drift)"
|
||||||
echo "runtime=" >> "$GITHUB_OUTPUT"
|
sudo xcodebuild -downloadPlatform iOS -buildVersion "$IOS17_FALLBACK_VERSION" || true
|
||||||
exit 0
|
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
|
fi
|
||||||
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
|
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
|
||||||
echo "created $udid with $runtime"
|
echo "created $udid with $runtime"
|
||||||
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "udid=$udid" >> "$GITHUB_OUTPUT"
|
echo "udid=$udid" >> "$GITHUB_OUTPUT"
|
||||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
|
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
|
||||||
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
|
# (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)
|
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
|
||||||
if: steps.sim17.outputs.runtime != ''
|
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
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
|
||||||
|
|||||||
49
README.md
49
README.md
@@ -4,7 +4,7 @@ A self-hosted, browser-based terminal **and** Claude-Code session/project workbe
|
|||||||
|
|
||||||
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the scrollback replays. The server is a **byte-shuttle** — it ferries raw bytes between the shell and the browser and never parses terminal/ANSI semantics; xterm.js renders, node-pty provides the TTY.
|
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)
|
### 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.
|
- **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).
|
- **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)
|
### 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.
|
- **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
|
## 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
|
service are also in the repo (all consume the same wire protocol — the server
|
||||||
stays a byte-shuttle).
|
stays a byte-shuttle).
|
||||||
|
|
||||||
- **iOS / iPad app** ([`ios/`](ios/), branch `feat/ios-client`) — a native
|
- **iOS / iPad app** ([`ios/`](ios/)) — a native SwiftUI + SwiftTerm **pure
|
||||||
SwiftUI + SwiftTerm **pure remote client** built for the walk-away loop:
|
remote client** built for the walk-away loop: native terminal with scrollback
|
||||||
native terminal with scrollback replay, QR/manual pairing (Keychain), the
|
replay, QR/manual pairing (Keychain), the remote **Approve / Reject** gate +
|
||||||
remote **Approve / Reject** gate + three-way plan gate, **APNs push with
|
three-way plan gate, **APNs push with lock-screen Allow / Deny behind Face ID**,
|
||||||
lock-screen Allow / Deny behind Face ID**, deep links, Projects, multi-session
|
deep links (including the web's `?join=` share link), Projects with a **git
|
||||||
switcher, timeline, diff, quick-reply, and an **adaptive iPhone/iPad split
|
panel + worktree lifecycle**, `claude --resume` history, multi-session switcher,
|
||||||
layout**. Looks like the desktop (amber-gold, dark-first). P0 needs zero server
|
timeline, diff, quick-reply, **terminal find bar**, **voice push-to-talk with a
|
||||||
changes; P1 adds two declared additive touch-points. See
|
confirm step**, theme (system/dark/light) + Dynamic Type, and an **adaptive
|
||||||
[`ios/README.md`](ios/README.md). *(Not yet merged.)*
|
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
|
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
|
||||||
embeds this Node server + node-pty** (all-in-one), for native notifications,
|
embeds this Node server + node-pty** (all-in-one), for native notifications,
|
||||||
tray, deep links and launch-at-login. Design in
|
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). |
|
| `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. |
|
| `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`. |
|
| `ALLOWED_ORIGINS` | (derived) | Extra allowed WS origins, comma-separated. The base list is derived from the host's NIC IPs + localhost — never from `BIND_HOST`. |
|
||||||
|
| `WEBTERM_TOKEN` | (unset → auth **disabled**, **secret**) | Optional shared access token gating every HTTP route + the WS upgrade behind a `webterm_auth` cookie (alongside, never instead of, the Origin check). 16–512 chars of `[A-Za-z0-9._~+/=-]` or the server refuses to start. Never logged. See [Security & deployment](#security--deployment) for the honest limits. |
|
||||||
| `PERM_TIMEOUT_MS` | `300000` (5 min) | How long a held tool-permission request waits for a remote decision before falling back to Claude's own prompt. Must be > 0. |
|
| `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. |
|
| `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. |
|
||||||
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
|
| `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
|
## 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.
|
- **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.
|
- **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.
|
- **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).
|
- **Safe git exec + per-decision capability tokens:** all `git` calls use `execFile` (no shell) with timeouts and path validation; the lock-screen Allow/Deny is authorized by a token bound to that session's current pending request (it expires on resolve/timeout).
|
||||||
- **No authentication yet.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` — the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge.
|
- **Optional shared access token (`WEBTERM_TOKEN`) — a bar-raiser, not authentication.** Unset ⇒ the gate is **disabled** and behaviour is byte-identical to a no-auth server (LAN zero-config preserved). Set (16–512 chars of `[A-Za-z0-9._~+/=-]`, else the server refuses to start) ⇒ every HTTP route **and** the WS upgrade require an `HttpOnly; SameSite=Strict` `webterm_auth` cookie, delivered once by `GET /?token=<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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,30 @@ The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumen
|
|||||||
expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY.
|
expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY.
|
||||||
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
||||||
|
|
||||||
|
## Access token — `WEBTERM_TOKEN` (B5 / ios-completion §1.1) — needs a token-enabled host
|
||||||
|
Run the host as `WEBTERM_TOKEN=<16–512 chars> npm start`. The pure logic is JVM-tested (`AuthProbeTest`,
|
||||||
|
`AccessTokenCookieTest`, `PairingProbeTokenTest`, `OkHttpAccessTokenTest`, `SessionEngineUnauthorizedTest`,
|
||||||
|
`InMemoryAccessTokenStoreTest`); everything below needs real hardware + a real server.
|
||||||
|
- [ ] `TinkAccessTokenStore` instrumented suite on a device:
|
||||||
|
`./gradlew :host-registry:connectedDebugAndroidTest` (real AndroidKeyStore + Tink).
|
||||||
|
- [ ] Pair a token-enabled host with the token typed → paired; the terminal attaches (the **WS upgrade**
|
||||||
|
carries `Cookie: webterm_auth=…`), the session list, projects, diff and thumbnails all load.
|
||||||
|
- [ ] Pair the same host with **no** token typed → the probe 401s → the confirm step comes back asking for
|
||||||
|
the token ("该主机启用了访问令牌"); typing it and retrying pairs.
|
||||||
|
- [ ] Wrong token → "访问令牌不正确" **under the field** (still on the confirm step, nothing stored).
|
||||||
|
- [ ] 11 wrong tries within a minute → "尝试过多" (the server's 10/min/IP `/auth` limiter).
|
||||||
|
- [ ] Pair a host with `WEBTERM_TOKEN` **unset** while typing a token → pairs, and NOTHING is stored
|
||||||
|
(204-without-`Set-Cookie`); the app must not claim to be "authenticated".
|
||||||
|
- [ ] Restart the app → the terminal re-attaches with no re-prompt (the token decrypts from the
|
||||||
|
Keystore-backed store on a cold start).
|
||||||
|
- [ ] Rotate `WEBTERM_TOKEN` on the host and restart it → the WS upgrade 401s → the terminal banner shows
|
||||||
|
the 401 copy, offers **no** "新会话", and there is **no reconnect back-off storm** (verify with
|
||||||
|
`adb logcat` / the server log: exactly one upgrade attempt).
|
||||||
|
- [ ] `adb logcat` during all of the above: the token string appears **nowhere**; no request URL contains
|
||||||
|
it (check the server's access log too).
|
||||||
|
- [ ] `adb backup` / device-to-device transfer excludes the token blob (`allowBackup=false` +
|
||||||
|
`data_extraction_rules.xml`).
|
||||||
|
|
||||||
## Nav / deep links / adaptive (A32/A26/A29)
|
## Nav / deep links / adaptive (A32/A26/A29)
|
||||||
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
- [ ] 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
|
right destination; invalid UUID ignored. (The App Link half needs the release-signed APK and
|
||||||
|
|||||||
@@ -46,8 +46,16 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
|
|||||||
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
|
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
|
||||||
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
||||||
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ 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 |
|
| — (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
|
`: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
|
`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
|
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
|
`:wire-protocol` is the **frozen shared contract** (Android analogue of
|
||||||
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
|
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
|
||||||
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
|
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
|
||||||
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary
|
`AuthCookie`/`AccessTokenRule`/`AccessTokenSource` (the single access-token-cookie
|
||||||
interfaces. New wire types are added **only** here (a coordination point).
|
derivation), and the `TermTransport` / `HttpTransport` / `PingableTermTransport`
|
||||||
|
boundary interfaces. New wire types are added **only** here (a coordination point).
|
||||||
|
|
||||||
|
## Access token (`WEBTERM_TOKEN`) — how the Android client authenticates
|
||||||
|
|
||||||
|
A server started with `WEBTERM_TOKEN=<16–512 chars of [A-Za-z0-9._~+/=-]>` gates **every**
|
||||||
|
HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie. The Android
|
||||||
|
client therefore:
|
||||||
|
|
||||||
|
- **hand-writes `Cookie: webterm_auth=<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
|
## Toolchain
|
||||||
|
|
||||||
@@ -111,8 +156,9 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`).
|
|||||||
```
|
```
|
||||||
|
|
||||||
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
|
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
|
||||||
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
|
> `:session-core`, `:api-client`, `:client-tls` pure half — the four modules that
|
||||||
> small focused files — same discipline as the rest of the repo.
|
> 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
|
> **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
|
> (`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
|
`hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to
|
||||||
`wang.yaojia.webterm.HiltTestRunner`.
|
`wang.yaojia.webterm.HiltTestRunner`.
|
||||||
|
|
||||||
⚠️ **That runner class does not exist yet** — it is the first file the A34 author must write,
|
`app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt` supplies it:
|
||||||
into `app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt`:
|
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
class HiltTestRunner : AndroidJUnitRunner() {
|
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
|
*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.
|
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)
|
## Android SDK setup (proven working)
|
||||||
|
|
||||||
The pure JVM modules need only a JDK + Gradle. The **Android-framework** modules
|
The pure JVM modules need only a JDK + Gradle. The **Android-framework** modules
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -52,6 +52,17 @@ public sealed interface PairingError {
|
|||||||
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
|
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
|
||||||
public data object TlsFailure : PairingError
|
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`). */
|
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
|
||||||
public data object Timeout : PairingError
|
public data object Timeout : PairingError
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
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.ClientMessage
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
@@ -50,8 +52,9 @@ public suspend fun runPairingProbe(
|
|||||||
endpoint: HostEndpoint,
|
endpoint: HostEndpoint,
|
||||||
http: HttpTransport,
|
http: HttpTransport,
|
||||||
ws: TermTransport,
|
ws: TermTransport,
|
||||||
|
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||||
): PairingProbeResult =
|
): 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
|
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
|
||||||
@@ -63,9 +66,10 @@ internal suspend fun runPairingProbeCore(
|
|||||||
http: HttpTransport,
|
http: HttpTransport,
|
||||||
ws: TermTransport,
|
ws: TermTransport,
|
||||||
timeout: Duration?,
|
timeout: Duration?,
|
||||||
|
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||||
): PairingProbeResult {
|
): PairingProbeResult {
|
||||||
if (timeout == null) return performProbe(endpoint, http, ws)
|
if (timeout == null) return performProbe(endpoint, http, ws, tokens)
|
||||||
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
|
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) }
|
||||||
?: PairingProbeResult.Failure(PairingError.Timeout)
|
?: PairingProbeResult.Failure(PairingError.Timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,10 +79,16 @@ private suspend fun performProbe(
|
|||||||
endpoint: HostEndpoint,
|
endpoint: HostEndpoint,
|
||||||
http: HttpTransport,
|
http: HttpTransport,
|
||||||
ws: TermTransport,
|
ws: TermTransport,
|
||||||
|
tokens: AccessTokenSource,
|
||||||
): PairingProbeResult {
|
): 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
|
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
|
||||||
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
|
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host
|
||||||
probeReachability(endpoint, http)?.let { return it }
|
// 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
|
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
|
||||||
// unrecognizable connect failure is classified as originRejected.
|
// unrecognizable connect failure is classified as originRejected.
|
||||||
@@ -105,7 +115,7 @@ private suspend fun performProbe(
|
|||||||
return try {
|
return try {
|
||||||
when (val adoption = adoptAttachedSession(connection)) {
|
when (val adoption = adoptAttachedSession(connection)) {
|
||||||
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
|
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 {
|
} finally {
|
||||||
withContext(NonCancellable) { runCatching { connection.close() } }
|
withContext(NonCancellable) { runCatching { connection.close() } }
|
||||||
@@ -120,14 +130,20 @@ private suspend fun performProbe(
|
|||||||
private suspend fun probeReachability(
|
private suspend fun probeReachability(
|
||||||
endpoint: HostEndpoint,
|
endpoint: HostEndpoint,
|
||||||
http: HttpTransport,
|
http: HttpTransport,
|
||||||
|
authHeaders: Map<String, String>,
|
||||||
): PairingProbeResult.Failure? {
|
): PairingProbeResult.Failure? {
|
||||||
val response = try {
|
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) {
|
} catch (cancel: CancellationException) {
|
||||||
throw cancel
|
throw cancel
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
|
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)) {
|
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
|
||||||
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
|
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||||
}
|
}
|
||||||
@@ -162,13 +178,14 @@ private suspend fun killProbeSession(
|
|||||||
sessionId: String,
|
sessionId: String,
|
||||||
endpoint: HostEndpoint,
|
endpoint: HostEndpoint,
|
||||||
http: HttpTransport,
|
http: HttpTransport,
|
||||||
|
authHeaders: Map<String, String>,
|
||||||
): PairingProbeResult {
|
): PairingProbeResult {
|
||||||
val response = try {
|
val response = try {
|
||||||
http.send(
|
http.send(
|
||||||
HttpRequest(
|
HttpRequest(
|
||||||
method = HttpMethod.DELETE,
|
method = HttpMethod.DELETE,
|
||||||
url = killUrl(endpoint, sessionId),
|
url = killUrl(endpoint, sessionId),
|
||||||
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
|
headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} catch (cancel: CancellationException) {
|
} catch (cancel: CancellationException) {
|
||||||
@@ -178,6 +195,7 @@ private suspend fun killProbeSession(
|
|||||||
}
|
}
|
||||||
return when (response.status) {
|
return when (response.status) {
|
||||||
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
|
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
|
||||||
|
HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired)
|
||||||
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
|
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
|
||||||
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
|
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
|
||||||
)
|
)
|
||||||
@@ -219,9 +237,17 @@ private fun httpBaseUrl(endpoint: HostEndpoint): String {
|
|||||||
return "$scheme://$host$portPart"
|
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 LIVE_SESSIONS_PATH = "/live-sessions"
|
||||||
private const val ORIGIN_HEADER = "Origin"
|
private const val ORIGIN_HEADER = "Origin"
|
||||||
private const val HTTP_OK = 200
|
private const val HTTP_OK = 200
|
||||||
|
private const val HTTP_UNAUTHORIZED = 401
|
||||||
private const val HTTP_NO_CONTENT = 204
|
private const val HTTP_NO_CONTENT = 204
|
||||||
private const val HTTP_FORBIDDEN = 403
|
private const val HTTP_FORBIDDEN = 403
|
||||||
private const val HTTP_NOT_FOUND = 404
|
private const val HTTP_NOT_FOUND = 404
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import wang.yaojia.webterm.api.models.UiPrefs
|
|||||||
import wang.yaojia.webterm.api.models.WorktreeState
|
import wang.yaojia.webterm.api.models.WorktreeState
|
||||||
import wang.yaojia.webterm.api.models.decodeGitError
|
import wang.yaojia.webterm.api.models.decodeGitError
|
||||||
import wang.yaojia.webterm.api.models.decodeGitPayload
|
import wang.yaojia.webterm.api.models.decodeGitPayload
|
||||||
|
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpResponse
|
import wang.yaojia.webterm.wire.HttpResponse
|
||||||
import wang.yaojia.webterm.wire.HttpTransport
|
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),
|
* 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.
|
* 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 class ApiClient(
|
||||||
public val endpoint: HostEndpoint,
|
public val endpoint: HostEndpoint,
|
||||||
private val http: HttpTransport,
|
private val http: HttpTransport,
|
||||||
|
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||||
) {
|
) {
|
||||||
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
|
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -343,9 +352,27 @@ public class ApiClient(
|
|||||||
|
|
||||||
// ── Internals ──────────────────────────────────────────────────────────────────────────
|
// ── 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 {
|
private suspend fun perform(route: ApiRoute): HttpResponse {
|
||||||
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
|
val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint))
|
||||||
return http.send(request)
|
?: 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`. */
|
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
|
||||||
|
|||||||
@@ -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). */
|
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
|
||||||
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
|
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). */
|
/** 403 from a G route's Origin guard (CSWSH defence). */
|
||||||
public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。")
|
public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package wang.yaojia.webterm.api.routes
|
package wang.yaojia.webterm.api.routes
|
||||||
|
|
||||||
|
import wang.yaojia.webterm.wire.AuthCookie
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
import wang.yaojia.webterm.wire.HttpRequest
|
import wang.yaojia.webterm.wire.HttpRequest
|
||||||
@@ -10,6 +11,7 @@ internal object HttpStatus {
|
|||||||
const val OK = 200
|
const val OK = 200
|
||||||
const val NO_CONTENT = 204
|
const val NO_CONTENT = 204
|
||||||
const val BAD_REQUEST = 400
|
const val BAD_REQUEST = 400
|
||||||
|
const val UNAUTHORIZED = 401
|
||||||
const val FORBIDDEN = 403
|
const val FORBIDDEN = 403
|
||||||
const val NOT_FOUND = 404
|
const val NOT_FOUND = 404
|
||||||
const val CONFLICT = 409
|
const val CONFLICT = 409
|
||||||
@@ -27,6 +29,12 @@ internal object HttpStatus {
|
|||||||
internal object HeaderName {
|
internal object HeaderName {
|
||||||
const val ORIGIN = "Origin"
|
const val ORIGIN = "Origin"
|
||||||
const val CONTENT_TYPE = "Content-Type"
|
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 {
|
internal object ContentType {
|
||||||
@@ -47,6 +55,23 @@ internal enum class OriginPolicy {
|
|||||||
GUARDED,
|
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
|
* 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
|
* 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 originPolicy: OriginPolicy,
|
||||||
val body: ByteArray? = null,
|
val body: ByteArray? = null,
|
||||||
val percentEncodedQuery: String? = 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
|
* 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
|
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
|
||||||
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
|
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
|
||||||
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
|
* 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 url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
|
||||||
val headers = LinkedHashMap<String, String>()
|
val headers = LinkedHashMap<String, String>()
|
||||||
if (originPolicy == OriginPolicy.GUARDED) {
|
if (originPolicy == OriginPolicy.GUARDED) {
|
||||||
headers[HeaderName.ORIGIN] = endpoint.originHeader
|
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) {
|
if (body != null) {
|
||||||
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
|
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,40 @@ internal object Endpoints {
|
|||||||
|
|
||||||
private const val FCM_TOKEN_PATH = "/push/fcm-token"
|
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) ────────────────────────────────────────
|
// ── 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. */
|
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
|
||||||
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
|
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
|
||||||
@@ -162,11 +195,11 @@ internal object Endpoints {
|
|||||||
|
|
||||||
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
|
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
|
||||||
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
|
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 }`. */
|
/** `POST /projects/git/commit` — `{ path, message }`. */
|
||||||
fun gitCommit(path: String, message: String): ApiRoute =
|
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
|
* `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.
|
* It is NOT a pull: no working tree, no index, no merge.
|
||||||
*/
|
*/
|
||||||
fun gitFetch(path: String): ApiRoute =
|
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 }`. */
|
/** `POST /projects/git/push` — `{ path }`. */
|
||||||
fun gitPush(path: String): ApiRoute =
|
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) ──────────────────────────────────────────
|
// ── 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"
|
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(
|
private fun <T> jsonBodyRoute(
|
||||||
method: HttpMethod,
|
method: HttpMethod,
|
||||||
path: String,
|
path: String,
|
||||||
serializer: kotlinx.serialization.KSerializer<T>,
|
serializer: kotlinx.serialization.KSerializer<T>,
|
||||||
value: T,
|
value: T,
|
||||||
|
unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
|
||||||
): ApiRoute {
|
): ApiRoute {
|
||||||
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
|
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. */
|
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
|
||||||
private const val GIT_LOG_MAX = 50
|
private const val GIT_LOG_MAX = 50
|
||||||
|
|
||||||
@@ -259,6 +323,10 @@ internal object Endpoints {
|
|||||||
@Serializable
|
@Serializable
|
||||||
private data class FcmTokenBody(val token: String)
|
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
|
@Serializable
|
||||||
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
|
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,4 +158,50 @@ class GitRouteShapeTest {
|
|||||||
client.gitPush("/r")
|
client.gitPush("/r")
|
||||||
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,17 +23,20 @@
|
|||||||
never in backups/cloud"). Auto Backup defaults to ON and would sweep up EVERY app-private
|
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
|
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
|
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-
|
files. Nothing in this app has restore value that justifies that risk — re-pairing a host is
|
||||||
default: every store added later is backed up until someone remembers to exclude it, and a
|
a QR scan, and the hosts are LAN-local anyway.
|
||||||
silent miss here is an off-device credential leak. Nothing in this app has restore value that
|
`allowBackup` alone only covers Auto Backup (cloud) / adb backup. Android 12+ DEVICE-TO-DEVICE
|
||||||
justifies that risk — re-pairing a host is a QR scan, and the hosts are LAN-local anyway.
|
transfer is governed by a `dataExtractionRules` <device-transfer> block, which needs an @xml
|
||||||
NOTE: this attribute is Auto Backup (cloud). Android 12+ DEVICE-TO-DEVICE transfer is governed
|
resource — @xml/data_extraction_rules now supplies it, and it is deny-everything in BOTH
|
||||||
by a `dataExtractionRules` <device-transfer> block, which needs an @xml resource (tracked
|
sections rather than an exclusion list, so a store added later is excluded by default instead
|
||||||
separately — see the orchestrator note). Do not set this back to true. -->
|
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
|
<application
|
||||||
android:name=".WebTermApp"
|
android:name=".WebTermApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:fullBackupContent="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.WebTerm"
|
android:theme="@style/Theme.WebTerm"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
|||||||
@@ -79,11 +79,16 @@ public sealed interface BannerModel {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
|
* 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 {
|
public data class Failed(val reason: FailureReason) : BannerModel {
|
||||||
override val showsSpinner: Boolean get() = false
|
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) {
|
is BannerModel.Failed -> when (model.reason) {
|
||||||
FailureReason.REPLAY_TOO_LARGE ->
|
FailureReason.REPLAY_TOO_LARGE ->
|
||||||
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
|
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
|
||||||
|
FailureReason.UNAUTHORIZED ->
|
||||||
|
"服务器拒绝了连接(401)。请在“配对主机”里重新填写访问令牌;若已填写,请检查主机的 ALLOWED_ORIGINS。"
|
||||||
}
|
}
|
||||||
is BannerModel.Exited ->
|
is BannerModel.Exited ->
|
||||||
if (model.isSpawnFailure) {
|
if (model.isSpawnFailure) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import wang.yaojia.webterm.transport.OkHttpClientFactory
|
|||||||
import wang.yaojia.webterm.transport.OkHttpHttpTransport
|
import wang.yaojia.webterm.transport.OkHttpHttpTransport
|
||||||
import wang.yaojia.webterm.transport.OkHttpTermTransport
|
import wang.yaojia.webterm.transport.OkHttpTermTransport
|
||||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||||
|
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||||
import wang.yaojia.webterm.wire.HttpTransport
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
import wang.yaojia.webterm.wire.TermTransport
|
import wang.yaojia.webterm.wire.TermTransport
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -81,10 +82,18 @@ public object NetworkModule {
|
|||||||
.connectionPool(connectionPool)
|
.connectionPool(connectionPool)
|
||||||
.build()
|
.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
|
@Provides
|
||||||
@Singleton
|
@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). */
|
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ public fun PairingPane(
|
|||||||
runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false)
|
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) }
|
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||||
@@ -189,7 +193,8 @@ public fun DiffPane(
|
|||||||
}
|
}
|
||||||
val viewModel = remember(resolved, path) {
|
val viewModel = remember(resolved, path) {
|
||||||
DiffViewModel(
|
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,
|
path = path,
|
||||||
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
|
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
|
||||||
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),
|
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
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
|
* 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).
|
* 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
|
* ### Permissions
|
||||||
* - **CAMERA** (QR only) is requested at runtime with a rationale; a denial degrades gracefully to
|
* - **CAMERA** (QR only) is requested at runtime with a rationale; a denial degrades gracefully to
|
||||||
* manual entry (the QR path is never mandatory).
|
* manual entry (the QR path is never mandatory).
|
||||||
@@ -113,7 +120,7 @@ public fun PairingScreen(
|
|||||||
is PairingUiState.Confirming -> ConfirmStep(
|
is PairingUiState.Confirming -> ConfirmStep(
|
||||||
state = s,
|
state = s,
|
||||||
onName = viewModel::setName,
|
onName = viewModel::setName,
|
||||||
onConfirm = viewModel::confirm,
|
onConfirm = { ack, token -> viewModel.confirm(acknowledgedPublicRisk = ack, accessToken = token) },
|
||||||
onCancel = viewModel::reset,
|
onCancel = viewModel::reset,
|
||||||
)
|
)
|
||||||
is PairingUiState.Probing -> ProbingStep(host = s.name)
|
is PairingUiState.Probing -> ProbingStep(host = s.name)
|
||||||
@@ -247,10 +254,16 @@ private fun CameraPreview(onScanned: (String) -> Unit) {
|
|||||||
private fun ConfirmStep(
|
private fun ConfirmStep(
|
||||||
state: PairingUiState.Confirming,
|
state: PairingUiState.Confirming,
|
||||||
onName: (String) -> Unit,
|
onName: (String) -> Unit,
|
||||||
onConfirm: (Boolean) -> Unit,
|
onConfirm: (Boolean, String) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var acknowledged by remember(state.endpoint) { mutableStateOf(false) }
|
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)
|
TierWarningCard(tier = state.tier, highlight = state.awaitingAck && !acknowledged)
|
||||||
// The dialed URL is validated but user/QR-sourced — render INERT (plan §8).
|
// The dialed URL is validated but user/QR-sourced — render INERT (plan §8).
|
||||||
@@ -268,6 +281,14 @@ private fun ConfirmStep(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AccessTokenField(
|
||||||
|
token = token,
|
||||||
|
onToken = { token = it },
|
||||||
|
visible = tokenVisible,
|
||||||
|
onToggleVisible = { tokenVisible = !tokenVisible },
|
||||||
|
error = state.tokenError,
|
||||||
|
)
|
||||||
|
|
||||||
if (state.tier.needsExplicitAck) {
|
if (state.tier.needsExplicitAck) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it })
|
Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it })
|
||||||
@@ -278,13 +299,58 @@ private fun ConfirmStep(
|
|||||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||||
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
|
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
|
||||||
Button(
|
Button(
|
||||||
onClick = { onConfirm(acknowledged) },
|
onClick = { onConfirm(acknowledged, token) },
|
||||||
enabled = !state.tier.needsExplicitAck || acknowledged,
|
enabled = !state.tier.needsExplicitAck || acknowledged,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) { Text("连接") }
|
) { 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
|
@Composable
|
||||||
private fun TierWarningCard(tier: WarningTier, highlight: Boolean) {
|
private fun TierWarningCard(tier: WarningTier, highlight: Boolean) {
|
||||||
val (title, body) = tierCopy(tier)
|
val (title, body) = tierCopy(tier)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import wang.yaojia.webterm.api.models.GitWriteOutcome
|
|||||||
import wang.yaojia.webterm.api.models.PushResult
|
import wang.yaojia.webterm.api.models.PushResult
|
||||||
import wang.yaojia.webterm.api.models.StageResult
|
import wang.yaojia.webterm.api.models.StageResult
|
||||||
import wang.yaojia.webterm.api.routes.ApiClient
|
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.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
import wang.yaojia.webterm.wire.HttpRequest
|
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.
|
* 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).
|
* 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(
|
public class HttpDiffFetcher(
|
||||||
private val endpoint: HostEndpoint,
|
private val endpoint: HostEndpoint,
|
||||||
private val http: HttpTransport,
|
private val http: HttpTransport,
|
||||||
|
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||||
) : DiffFetcher {
|
) : DiffFetcher {
|
||||||
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
|
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 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)
|
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
|
||||||
return decodeDiffResult(response.body)
|
return decodeDiffResult(response.body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
package wang.yaojia.webterm.viewmodels
|
package wang.yaojia.webterm.viewmodels
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
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.PairingError
|
||||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
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.Host
|
||||||
import wang.yaojia.webterm.hostregistry.HostStore
|
import wang.yaojia.webterm.hostregistry.HostStore
|
||||||
|
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||||
import wang.yaojia.webterm.wire.HostClassifier
|
import wang.yaojia.webterm.wire.HostClassifier
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HostNetworkTier
|
import wang.yaojia.webterm.wire.HostNetworkTier
|
||||||
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
|
import wang.yaojia.webterm.wire.TermTransport
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,24 +50,56 @@ import java.util.UUID
|
|||||||
*
|
*
|
||||||
* On probe success the validated host is persisted via [HostStore.upsert].
|
* On probe success the validated host is persisted via [HostStore.upsert].
|
||||||
*
|
*
|
||||||
* ### The `WEBTERM_TOKEN` gate (server `src/http/auth.ts`)
|
* ### The `WEBTERM_TOKEN` gate has TWO ways in, and both end in the same place
|
||||||
* A token-gated host answers the probe's unauthenticated `GET /live-sessions` with **401**, which the
|
* A token-gated host can be met either way round, so both are supported:
|
||||||
* frozen probe taxonomy can only report as [PairingError.HttpOkButNotWebTerminal] ("端口对吗?") — so
|
* - **Typed up front.** The confirm step has an optional token field; [confirm]/[retry] carry it into
|
||||||
* without this flow such a host is impossible to pair AND the copy is misleading. On that ambiguous
|
* RULE 5 below (validate with `POST /auth`, store, then probe).
|
||||||
* failure the ViewModel asks [PairingProber.requiresAccessToken]; a gated host routes to
|
* - **Discovered by the probe.** A host the user did not know was gated answers the probe's
|
||||||
* [PairingUiState.TokenRequired], and [submitAccessToken] posts the token (`POST /auth`) so the shared
|
* unauthenticated `GET /live-sessions` with **401**. When the probe classifies that as
|
||||||
* cookie jar picks up the `webterm_auth` cookie, then resumes pairing through the same choke point.
|
* [PairingError.AccessTokenRequired] the user goes back to the confirm step with
|
||||||
* The token is a SHELL credential: it is a parameter, never a field and never part of any UI state.
|
* [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 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 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 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).
|
* @param newId host-id allocator (default random UUID; deterministic in tests).
|
||||||
*/
|
*/
|
||||||
public class PairingViewModel(
|
public class PairingViewModel(
|
||||||
private val hostStore: HostStore,
|
private val hostStore: HostStore,
|
||||||
private val prober: PairingProber,
|
private val prober: PairingProber,
|
||||||
private val hasDeviceCert: suspend () -> Boolean,
|
private val hasDeviceCert: suspend () -> Boolean,
|
||||||
|
private val tokenStore: AccessTokenStore,
|
||||||
|
private val authProber: AccessTokenProber,
|
||||||
private val newId: () -> String = { UUID.randomUUID().toString() },
|
private val newId: () -> String = { UUID.randomUUID().toString() },
|
||||||
) {
|
) {
|
||||||
private val _uiState = MutableStateFlow<PairingUiState>(PairingUiState.Entry())
|
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
|
* 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).
|
* 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
|
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
|
* 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.
|
* [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
|
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
|
* Submit the host's shared access token (`WEBTERM_TOKEN`) from the token prompt. Only meaningful
|
||||||
* [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never post a
|
* from [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never
|
||||||
* credential to a host the user has moved on from.
|
* 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
|
* This is the DISCOVERED half of the gate; the typed-up-front half is [confirm]'s `accessToken`
|
||||||
* log. On acceptance the shared cookie jar holds `webterm_auth` and pairing resumes through the same
|
* (RULE 5). [token] goes straight to the prober's `POST /auth` and is never held in a field or in
|
||||||
* choke point as [confirm]/[retry] (so RULE 4's cert-gate still applies); every other outcome
|
* UI state. On acceptance the shared cookie jar holds `webterm_auth` AND the token is kept in
|
||||||
* re-arms the prompt with a distinguishable reason and NEVER auto-retries (429 included).
|
* [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) {
|
public fun submitAccessToken(token: String) {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
@@ -149,14 +195,22 @@ public class PairingViewModel(
|
|||||||
if (token.isBlank()) return
|
if (token.isBlank()) return
|
||||||
val candidate = Candidate(state.endpoint, state.name, state.tier)
|
val candidate = Candidate(state.endpoint, state.name, state.tier)
|
||||||
val scope = scope ?: return
|
val scope = scope ?: return
|
||||||
probeJob?.cancel()
|
val superseded = probeJob
|
||||||
|
superseded?.cancel()
|
||||||
probeJob = scope.launch {
|
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)
|
_uiState.value = PairingUiState.TokenSubmitting(candidate.endpoint, candidate.name, candidate.tier)
|
||||||
when (prober.submitAccessToken(candidate.endpoint, token)) {
|
when (prober.submitAccessToken(candidate.endpoint, token)) {
|
||||||
// Straight into the probe body: RULE 3 is already satisfied by construction (reaching the
|
// 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
|
// 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.
|
// 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.Rejected -> promptForToken(candidate, TokenError.INVALID)
|
||||||
TokenSubmission.RateLimited -> promptForToken(candidate, TokenError.RATE_LIMITED)
|
TokenSubmission.RateLimited -> promptForToken(candidate, TokenError.RATE_LIMITED)
|
||||||
is TokenSubmission.Unreachable -> promptForToken(candidate, TokenError.UNREACHABLE)
|
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
|
// 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.
|
// pre-network UI gate, so it lives here (the entry point the user drives), not in performProbe.
|
||||||
if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) {
|
if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) {
|
||||||
@@ -178,17 +232,31 @@ public class PairingViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val scope = scope ?: return
|
val scope = scope ?: return
|
||||||
probeJob?.cancel()
|
val superseded = probeJob
|
||||||
probeJob = scope.launch { performProbe(candidate) }
|
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
|
* 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
|
* 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).
|
* [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
|
val tier = candidate.tier
|
||||||
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both
|
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both
|
||||||
// re-hit this same guard.
|
// re-hit this same guard.
|
||||||
@@ -197,18 +265,122 @@ public class PairingViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
|
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
|
||||||
when (val result = prober.probe(candidate.endpoint)) {
|
val attempt = tokenStep() ?: return
|
||||||
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
// RULE 6 — anything short of Paired puts the store back the way this attempt found it.
|
||||||
is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error)
|
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
|
* Persist [token], remembering what the store held before so RULE 6 can put it back. [onFailure]
|
||||||
* `WEBTERM_TOKEN`-gated host's 401 collapses into — so that one case is re-checked against the token
|
* publishes the reason in whichever step the caller is showing (the confirm field, or the token
|
||||||
* gate before the misleading "wrong port?" copy is shown. Every other error is reported verbatim.
|
* 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) {
|
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)) {
|
if (error == PairingError.HttpOkButNotWebTerminal && prober.requiresAccessToken(candidate.endpoint)) {
|
||||||
promptForToken(candidate, error = null)
|
promptForToken(candidate, error = null)
|
||||||
return
|
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?) {
|
private fun promptForToken(candidate: Candidate, error: TokenError?) {
|
||||||
_uiState.value = PairingUiState.TokenRequired(
|
_uiState.value = PairingUiState.TokenRequired(
|
||||||
endpoint = candidate.endpoint,
|
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)
|
val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl)
|
||||||
if (host == null) {
|
if (host == null) {
|
||||||
// Unreachable in practice (the endpoint already validated), but never persist a null host.
|
// Unreachable in practice (the endpoint already validated), but never persist a null host.
|
||||||
@@ -242,10 +416,11 @@ public class PairingViewModel(
|
|||||||
error = PairingError.HttpOkButNotWebTerminal,
|
error = PairingError.HttpOkButNotWebTerminal,
|
||||||
message = PairingCopy.describe(PairingError.HttpOkButNotWebTerminal, candidate.tier),
|
message = PairingCopy.describe(PairingError.HttpOkButNotWebTerminal, candidate.tier),
|
||||||
)
|
)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
hostStore.upsert(host)
|
hostStore.upsert(host)
|
||||||
_uiState.value = PairingUiState.Paired(host)
|
_uiState.value = PairingUiState.Paired(host)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultName(endpoint: HostEndpoint): String =
|
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
|
* `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).
|
* 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
|
* The two token operations are DEFAULTED so a probe-only double still satisfies the interface — which
|
||||||
* defaults are deliberately the honest "this seam cannot reach the token gate" answers — `false` and
|
* also leaves exactly one abstract member, so this stays a `fun interface` and a test/production
|
||||||
* [TokenSubmission.Unavailable] — never a silent success.
|
* 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
|
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. */
|
/** 2xx (the server answers `204` to a non-HTML request) — `Set-Cookie: webterm_auth=…` was returned. */
|
||||||
public data object Accepted : TokenSubmission
|
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`. */
|
/** `401` — the token does not match the host's `WEBTERM_TOKEN`. */
|
||||||
public data object Rejected : TokenSubmission
|
public data object Rejected : TokenSubmission
|
||||||
|
|
||||||
@@ -312,21 +498,35 @@ public sealed interface TokenSubmission {
|
|||||||
public data object Unavailable : 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 `POST /auth` access-token validation seam ([probeAccessToken]); faked in tests. Kept separate from
|
||||||
/** The submitted token was rejected by the host (`401`). */
|
* [PairingProber] so the ORDER (validate+store, then probe) is observable in a unit test.
|
||||||
INVALID,
|
*/
|
||||||
|
public fun interface AccessTokenProber {
|
||||||
/** The host is rate-limiting auth attempts (`429`) — wait, do not hammer it. */
|
public suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) ───────────────────────────────────────────────────────────────────────
|
// ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -396,6 +596,11 @@ public sealed interface PairingUiState {
|
|||||||
val name: String,
|
val name: String,
|
||||||
val tier: WarningTier,
|
val tier: WarningTier,
|
||||||
val awaitingAck: Boolean = false,
|
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
|
) : PairingUiState
|
||||||
|
|
||||||
/** The two-step probe is running against [endpoint]. */
|
/** The two-step probe is running against [endpoint]. */
|
||||||
@@ -447,9 +652,57 @@ public enum class EntryError {
|
|||||||
INVALID_URL,
|
INVALID_URL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the access-token step failed. Rendered next to the token field on the confirm step (the typed-token
|
||||||
|
* path) or under the dedicated [PairingUiState.TokenRequired] prompt (the discovered-gate path), so the
|
||||||
|
* user can fix it in place (B5 / ios-completion §1.1).
|
||||||
|
*/
|
||||||
|
public enum class TokenError {
|
||||||
|
/** The host answered 401 to the probe: it HAS a token configured and we presented none/a wrong one. */
|
||||||
|
REQUIRED,
|
||||||
|
|
||||||
|
/** `POST /auth` → 401: the typed/submitted token is wrong. */
|
||||||
|
INVALID,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /auth` → **429**: the server's 10/min/IP limiter tripped. ONE case for both entry points —
|
||||||
|
* the typed-token path and the prompt's submit path hit the same limiter on the same route, so two
|
||||||
|
* enum constants with two different strings were the merge keeping both branches' names for one
|
||||||
|
* server answer. Surfaced as-is; the app NEVER auto-retries.
|
||||||
|
*/
|
||||||
|
RATE_LIMITED,
|
||||||
|
|
||||||
|
/** The typed token cannot be a server token at all (charset / 16–512 length). No request was sent. */
|
||||||
|
MALFORMED,
|
||||||
|
|
||||||
|
/** `POST /auth` answered something unexpected — the token could not be verified either way. */
|
||||||
|
UNVERIFIABLE,
|
||||||
|
|
||||||
|
/** The host did not answer the submit (or answered unintelligibly) — distinct from a wrong token. */
|
||||||
|
UNREACHABLE,
|
||||||
|
|
||||||
|
/** The app cannot submit a token at all on this seam (a probe-only prober). */
|
||||||
|
UNAVAILABLE,
|
||||||
|
|
||||||
|
/** The token was correct but encrypted on-device storage refused to keep it. */
|
||||||
|
STORE_FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
/** The endpoint+name+tier a probe runs against, recovered from whatever state currently holds one. */
|
/** 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)
|
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) {
|
private fun PairingUiState.candidate(): Candidate? = when (this) {
|
||||||
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
|
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
|
||||||
is PairingUiState.Probing -> 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). */
|
/** Maps the [PairingError] taxonomy to inert, app-authored Chinese copy (never a server string, §8). */
|
||||||
internal object PairingCopy {
|
internal object PairingCopy {
|
||||||
|
/**
|
||||||
|
* Inert, app-authored copy for each [TokenError]. Never echoes the token itself.
|
||||||
|
*
|
||||||
|
* A wrong token, an unreachable host and a rate-limit read differently on purpose — collapsing them
|
||||||
|
* would tell the user to re-type a token that was fine — and the rate-limit line tells them to WAIT,
|
||||||
|
* because the app never retries by itself.
|
||||||
|
*/
|
||||||
|
fun describeToken(error: TokenError): String = when (error) {
|
||||||
|
TokenError.REQUIRED ->
|
||||||
|
"该主机启用了访问令牌(WEBTERM_TOKEN)。请填写访问令牌后再连接。"
|
||||||
|
TokenError.INVALID ->
|
||||||
|
"访问令牌不正确。请核对主机上的 WEBTERM_TOKEN 后重试(区分大小写)。"
|
||||||
|
TokenError.RATE_LIMITED ->
|
||||||
|
"尝试次数过多,主机已暂时限流(每分钟最多 10 次)。请等待约一分钟再试——App 不会自动重试。"
|
||||||
|
TokenError.MALFORMED ->
|
||||||
|
"访问令牌格式不合法:需 16–512 个字符,且只能包含 A–Z a–z 0–9 . _ ~ + / = -。"
|
||||||
|
TokenError.UNVERIFIABLE ->
|
||||||
|
"无法验证访问令牌:服务器返回了意外响应。请确认地址和端口指向 web-terminal。"
|
||||||
|
TokenError.UNREACHABLE ->
|
||||||
|
"提交访问令牌时主机没有正常响应。请检查网络与地址后重试。"
|
||||||
|
TokenError.UNAVAILABLE ->
|
||||||
|
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
|
||||||
|
TokenError.STORE_FAILED ->
|
||||||
|
"无法在本机安全保存访问令牌(加密存储写入失败)。请重试。"
|
||||||
|
}
|
||||||
|
|
||||||
fun describe(error: PairingError, tier: WarningTier): String = when (error) {
|
fun describe(error: PairingError, tier: WarningTier): String = when (error) {
|
||||||
is PairingError.HostUnreachable ->
|
is PairingError.HostUnreachable ->
|
||||||
"无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。"
|
"无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。"
|
||||||
@@ -489,22 +768,8 @@ internal object PairingCopy {
|
|||||||
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
|
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
|
||||||
if (tier == WarningTier.TUNNEL) "客户端证书无效或已被吊销。请在“设备证书”里重新导入后再试。"
|
if (tier == WarningTier.TUNNEL) "客户端证书无效或已被吊销。请在“设备证书”里重新导入后再试。"
|
||||||
else "TLS 握手失败:无法验证服务器证书。"
|
else "TLS 握手失败:无法验证服务器证书。"
|
||||||
|
PairingError.AccessTokenRequired -> describeToken(TokenError.REQUIRED)
|
||||||
PairingError.Timeout ->
|
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 ->
|
|
||||||
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package wang.yaojia.webterm.wiring
|
|||||||
|
|
||||||
import dagger.Lazy
|
import dagger.Lazy
|
||||||
import wang.yaojia.webterm.api.routes.ApiClient
|
import wang.yaojia.webterm.api.routes.ApiClient
|
||||||
|
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpTransport
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -23,6 +24,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
public class ApiClientFactory @Inject constructor(
|
public class ApiClientFactory @Inject constructor(
|
||||||
private val http: Lazy<HttpTransport>,
|
private val http: Lazy<HttpTransport>,
|
||||||
|
private val tokens: AccessTokenSource,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Resolve the shared [HttpTransport] (→ builds the shared `OkHttpClient`) so the slow mTLS I/O runs
|
* 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()
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import kotlinx.coroutines.sync.withLock
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import wang.yaojia.webterm.api.models.UiConfig
|
import wang.yaojia.webterm.api.models.UiConfig
|
||||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
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.api.pairing.runPairingProbe
|
||||||
|
import wang.yaojia.webterm.hostregistry.AccessTokenStore
|
||||||
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
|
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
|
||||||
import wang.yaojia.webterm.hostregistry.AuthCookieStore
|
import wang.yaojia.webterm.hostregistry.AuthCookieStore
|
||||||
import wang.yaojia.webterm.hostregistry.Host
|
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.AuthCookieJar
|
||||||
import wang.yaojia.webterm.transport.AuthCookieSnapshot
|
import wang.yaojia.webterm.transport.AuthCookieSnapshot
|
||||||
import wang.yaojia.webterm.transport.authCookieHostKey
|
import wang.yaojia.webterm.transport.authCookieHostKey
|
||||||
|
import wang.yaojia.webterm.viewmodels.AccessTokenProber
|
||||||
import wang.yaojia.webterm.viewmodels.ApiClientQueueGateway
|
import wang.yaojia.webterm.viewmodels.ApiClientQueueGateway
|
||||||
import wang.yaojia.webterm.viewmodels.HostRemovalGateway
|
import wang.yaojia.webterm.viewmodels.HostRemovalGateway
|
||||||
import wang.yaojia.webterm.viewmodels.PairingProber
|
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.QueueGateway
|
||||||
import wang.yaojia.webterm.viewmodels.TokenSubmission
|
import wang.yaojia.webterm.viewmodels.TokenSubmission
|
||||||
import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore
|
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.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.HttpMethod
|
import wang.yaojia.webterm.wire.HttpMethod
|
||||||
import wang.yaojia.webterm.wire.HttpRequest
|
import wang.yaojia.webterm.wire.HttpRequest
|
||||||
|
import wang.yaojia.webterm.wire.HttpResponse
|
||||||
import wang.yaojia.webterm.wire.HttpTransport
|
import wang.yaojia.webterm.wire.HttpTransport
|
||||||
import wang.yaojia.webterm.wire.TermTransport
|
import wang.yaojia.webterm.wire.TermTransport
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
@@ -81,6 +87,11 @@ import javax.inject.Singleton
|
|||||||
public class AppEnvironment @Inject constructor(
|
public class AppEnvironment @Inject constructor(
|
||||||
public val hostStore: HostStore,
|
public val hostStore: HostStore,
|
||||||
public val lastSessionStore: LastSessionStore,
|
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 apiClientFactory: ApiClientFactory,
|
||||||
public val sessionEngineFactory: SessionEngineFactory,
|
public val sessionEngineFactory: SessionEngineFactory,
|
||||||
public val coldStartPolicy: ColdStartPolicy,
|
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`
|
* 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
|
* cookie it earns lands in the shared [AuthCookieJar] and is therefore replayed on every later REST
|
||||||
* call AND on the WS upgrade.
|
* 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 {
|
public fun buildPairingProber(): PairingProber = object : PairingProber {
|
||||||
private val gateway = AccessTokenGateway { httpTransportLazy.get() }
|
private val gateway = AccessTokenGateway { httpTransportLazy.get() }
|
||||||
|
|
||||||
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult =
|
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 =
|
override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean =
|
||||||
gateway.requiresAccessToken(endpoint)
|
gateway.requiresAccessToken(endpoint)
|
||||||
@@ -161,6 +176,14 @@ public class AppEnvironment @Inject constructor(
|
|||||||
gateway.submit(endpoint, token)
|
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
|
* 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]
|
* 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,
|
hostStore = hostStore,
|
||||||
lastSessionStore = lastSessionStore,
|
lastSessionStore = lastSessionStore,
|
||||||
authCookieStore = authCookieStoreLazy.get(),
|
authCookieStore = authCookieStoreLazy.get(),
|
||||||
|
accessTokenStore = accessTokenStore,
|
||||||
watermarkStore = sessionWatermarkStore,
|
watermarkStore = sessionWatermarkStore,
|
||||||
pushUnregister = pushUnregister,
|
pushUnregister = pushUnregister,
|
||||||
currentPushToken = { pushTokenSource.currentToken() },
|
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
|
* 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.
|
* 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
|
* 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
|
* last-session pointer, the persisted + live `webterm_auth` cookie, **the Keystore-held access
|
||||||
* throw propagates with the host still paired, so the user can simply try again — the UI treats that
|
* token** ([AccessTokenStore] — the same credential's second durable home; the cookie's value IS
|
||||||
* as "nothing was removed" ([HostRemovalGateway]).
|
* 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
|
* ### What is deliberately NOT removed
|
||||||
* - **The device certificate / AndroidKeyStore identity** — it is ONE app-wide mTLS identity shared by
|
* - **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 hostStore: HostStore,
|
||||||
private val lastSessionStore: LastSessionStore,
|
private val lastSessionStore: LastSessionStore,
|
||||||
private val authCookieStore: AuthCookieStore,
|
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 watermarkStore: SessionWatermarkStore,
|
||||||
private val pushUnregister: HostPushUnregister,
|
private val pushUnregister: HostPushUnregister,
|
||||||
private val currentPushToken: suspend () -> String?,
|
private val currentPushToken: suspend () -> String?,
|
||||||
@@ -433,6 +467,9 @@ public class HostRemover(
|
|||||||
authCookieStore.removeHost(hostKey)
|
authCookieStore.removeHost(hostKey)
|
||||||
clearLiveCookies(hostKey) // the in-memory jar too, or the credential rides the next dial
|
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)
|
hostStore.remove(hostId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,6 +541,11 @@ public class AccessTokenGateway(private val http: () -> HttpTransport) {
|
|||||||
/**
|
/**
|
||||||
* `POST /auth` with [token] in an urlencoded form body.
|
* `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
|
* [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.
|
* 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 TokenSubmission.Unreachable(reason = error::class.simpleName ?: UNKNOWN_ERROR)
|
||||||
}
|
}
|
||||||
return when {
|
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_UNAUTHORIZED -> TokenSubmission.Rejected
|
||||||
response.status == HTTP_TOO_MANY_REQUESTS -> TokenSubmission.RateLimited
|
response.status == HTTP_TOO_MANY_REQUESTS -> TokenSubmission.RateLimited
|
||||||
else -> TokenSubmission.Unreachable(reason = "HTTP ${response.status}")
|
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 {
|
private companion object {
|
||||||
const val LIVE_SESSIONS_PATH = "/live-sessions"
|
const val LIVE_SESSIONS_PATH = "/live-sessions"
|
||||||
const val AUTH_PATH = "/auth"
|
const val AUTH_PATH = "/auth"
|
||||||
const val TOKEN_FIELD = "token"
|
const val TOKEN_FIELD = "token"
|
||||||
const val CONTENT_TYPE_HEADER = "Content-Type"
|
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 FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
|
||||||
const val HTTP_OK = 200
|
const val HTTP_OK = 200
|
||||||
const val HTTP_MULTIPLE_CHOICES = 300
|
const val HTTP_MULTIPLE_CHOICES = 300
|
||||||
|
|||||||
29
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
29
android/app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||||
@@ -109,4 +109,27 @@ class ReconnectBannerTest {
|
|||||||
val connecting = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Connecting))
|
val connecting = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Connecting))
|
||||||
assertEquals(connecting, bannerModel(connecting, Output("some bytes")))
|
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))))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import wang.yaojia.webterm.api.models.CommitResult
|
|||||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||||
import wang.yaojia.webterm.api.models.PushResult
|
import wang.yaojia.webterm.api.models.PushResult
|
||||||
import wang.yaojia.webterm.api.models.StageResult
|
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
|
* 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)
|
assertTrue(DiffUiState(canWrite = true).writeEnabled)
|
||||||
assertFalse(DiffUiState(canWrite = true, base = "main").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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
|||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import wang.yaojia.webterm.api.pairing.PairingError
|
import wang.yaojia.webterm.api.pairing.PairingError
|
||||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||||
|
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
|
||||||
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
||||||
import wang.yaojia.webterm.wire.HostEndpoint
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
|
|
||||||
@@ -44,6 +45,11 @@ class PairingViewModelTest {
|
|||||||
hostStore = store,
|
hostStore = store,
|
||||||
prober = prober,
|
prober = prober,
|
||||||
hasDeviceCert = { hasCert },
|
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" },
|
newId = { "fixed-id" },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ class AccessTokenGatewayTest {
|
|||||||
private fun status(code: Int): (HttpRequest) -> HttpResponse =
|
private fun status(code: Int): (HttpRequest) -> HttpResponse =
|
||||||
{ HttpResponse(status = code, body = ByteArray(0)) }
|
{ 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 }
|
private fun gateway(transport: HttpTransport) = AccessTokenGateway { transport }
|
||||||
|
|
||||||
// ── Gate detection: is this host token-gated, or is it just not a web-terminal? ──────────────────
|
// ── 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 ────────────────────────────────────────────────────────────────────
|
// ── POST /auth status mapping ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `204 accepts the token`() = runTest {
|
fun `204 with the auth cookie accepts the token`() = runTest {
|
||||||
assertEquals(TokenSubmission.Accepted, gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN))
|
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
|
@Test
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import org.junit.jupiter.api.Test
|
|||||||
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
|
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
|
||||||
import wang.yaojia.webterm.hostregistry.Host
|
import wang.yaojia.webterm.hostregistry.Host
|
||||||
import wang.yaojia.webterm.hostregistry.HostStore
|
import wang.yaojia.webterm.hostregistry.HostStore
|
||||||
|
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
|
||||||
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
|
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
|
||||||
import wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore
|
import wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore
|
||||||
import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore
|
import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore
|
||||||
|
import wang.yaojia.webterm.wire.HostEndpoint
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,10 +34,15 @@ class HostRemoverTest {
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val BASE = "http://laptop.local:3000"
|
const val BASE = "http://laptop.local:3000"
|
||||||
|
|
||||||
|
/** A real shell credential shape (`AccessTokenRule`: 16–512 of `[A-Za-z0-9._~+/=-]`). */
|
||||||
|
const val ACCESS_TOKEN = "0123456789abcdefTOKEN"
|
||||||
val SESSION_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
val SESSION_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||||
val SESSION_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666")
|
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 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 {
|
private class RecordingHostStore(initial: List<Host>) : HostStore {
|
||||||
@@ -71,7 +78,9 @@ class HostRemoverTest {
|
|||||||
): Triple<HostRemover, RecordingPush, Quad> {
|
): Triple<HostRemover, RecordingPush, Quad> {
|
||||||
val lastSession = InMemoryLastSessionStore()
|
val lastSession = InMemoryLastSessionStore()
|
||||||
val cookies = InMemoryAuthCookieStore()
|
val cookies = InMemoryAuthCookieStore()
|
||||||
|
val tokens = InMemoryAccessTokenStore()
|
||||||
val watermarks = InMemorySessionWatermarkStore()
|
val watermarks = InMemorySessionWatermarkStore()
|
||||||
|
tokens.put(endpoint(), ACCESS_TOKEN)
|
||||||
lastSession.setLastSessionId(SESSION_1.toString(), "h1")
|
lastSession.setLastSessionId(SESSION_1.toString(), "h1")
|
||||||
cookies.upsert(
|
cookies.upsert(
|
||||||
AuthCookieRecord(
|
AuthCookieRecord(
|
||||||
@@ -91,19 +100,21 @@ class HostRemoverTest {
|
|||||||
hostStore = hosts,
|
hostStore = hosts,
|
||||||
lastSessionStore = lastSession,
|
lastSessionStore = lastSession,
|
||||||
authCookieStore = cookies,
|
authCookieStore = cookies,
|
||||||
|
accessTokenStore = tokens,
|
||||||
watermarkStore = watermarks,
|
watermarkStore = watermarks,
|
||||||
pushUnregister = push,
|
pushUnregister = push,
|
||||||
currentPushToken = { token },
|
currentPushToken = { token },
|
||||||
clearLiveCookies = { key -> cookieJarClears += key },
|
clearLiveCookies = { key -> cookieJarClears += key },
|
||||||
log = { }, // android.util.Log is not mocked under JVM unit tests
|
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(
|
private data class Quad(
|
||||||
val hosts: RecordingHostStore,
|
val hosts: RecordingHostStore,
|
||||||
val lastSession: InMemoryLastSessionStore,
|
val lastSession: InMemoryLastSessionStore,
|
||||||
val cookies: InMemoryAuthCookieStore,
|
val cookies: InMemoryAuthCookieStore,
|
||||||
|
val tokens: InMemoryAccessTokenStore,
|
||||||
val watermarks: InMemorySessionWatermarkStore,
|
val watermarks: InMemorySessionWatermarkStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,6 +133,41 @@ class HostRemoverTest {
|
|||||||
assertTrue(stores.watermarks.loadAll().isEmpty())
|
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
|
@Test
|
||||||
fun `the remote DELETE runs while the host is still paired`() = runTest {
|
fun `the remote DELETE runs while the host is still paired`() = runTest {
|
||||||
val hosts = RecordingHostStore(listOf(host()))
|
val hosts = RecordingHostStore(listOf(host()))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.junit.jupiter.api.DisplayName
|
|||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import wang.yaojia.webterm.api.pairing.PairingError
|
import wang.yaojia.webterm.api.pairing.PairingError
|
||||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||||
|
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
|
||||||
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
||||||
import wang.yaojia.webterm.viewmodels.PairingProber
|
import wang.yaojia.webterm.viewmodels.PairingProber
|
||||||
import wang.yaojia.webterm.viewmodels.PairingUiState
|
import wang.yaojia.webterm.viewmodels.PairingUiState
|
||||||
@@ -78,10 +79,16 @@ class PairingTokenFlowTest {
|
|||||||
prober: PairingProber,
|
prober: PairingProber,
|
||||||
hasCert: () -> Boolean = { false },
|
hasCert: () -> Boolean = { false },
|
||||||
store: InMemoryHostStore = InMemoryHostStore(),
|
store: InMemoryHostStore = InMemoryHostStore(),
|
||||||
|
tokenStore: InMemoryAccessTokenStore = InMemoryAccessTokenStore(),
|
||||||
) = PairingViewModel(
|
) = PairingViewModel(
|
||||||
hostStore = store,
|
hostStore = store,
|
||||||
prober = prober,
|
prober = prober,
|
||||||
hasDeviceCert = { hasCert() },
|
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" },
|
newId = { "fixed-id" },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -158,6 +165,52 @@ class PairingTokenFlowTest {
|
|||||||
assertEquals(LAN, store.loadAll().single().endpoint.baseUrl)
|
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
|
@Test
|
||||||
fun `a wrong token re-arms the prompt with an invalid-token error and does not re-probe`() =
|
fun `a wrong token re-arms the prompt with an invalid-token error and does not re-probe`() =
|
||||||
runTest(UnconfinedTestDispatcher()) {
|
runTest(UnconfinedTestDispatcher()) {
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
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)
|
testImplementation(libs.bundles.unit.test)
|
||||||
testRuntimeOnly(libs.junit.platform.launcher)
|
testRuntimeOnly(libs.junit.platform.launcher)
|
||||||
|
|||||||
@@ -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/")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import wang.yaojia.webterm.wire.TermTransport
|
|||||||
import wang.yaojia.webterm.wire.TimelineEvent
|
import wang.yaojia.webterm.wire.TimelineEvent
|
||||||
import wang.yaojia.webterm.wire.TransportConnection
|
import wang.yaojia.webterm.wire.TransportConnection
|
||||||
import wang.yaojia.webterm.wire.Tunables
|
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
|
* 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.
|
* frame would be dropped rather than emitted onto a newer generation.
|
||||||
* - **oversized replay is terminal:** a frame past [maxFrameBytes] is the non-retryable
|
* - **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).
|
* [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(
|
public class SessionEngine(
|
||||||
private val transport: TermTransport,
|
private val transport: TermTransport,
|
||||||
@@ -76,6 +80,19 @@ public class SessionEngine(
|
|||||||
/** One live connection plus its optional ping capability (null = plain [TermTransport]). */
|
/** One live connection plus its optional ping capability (null = plain [TermTransport]). */
|
||||||
private class OpenConn(val connection: TransportConnection, val pinger: ConnectionPinger?)
|
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)
|
private val eventsChannel = Channel<SessionEvent>(Channel.UNLIMITED)
|
||||||
|
|
||||||
/** Engine→UI event stream (single consumer; the App's EventBus fans out per R10). */
|
/** 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 {
|
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 (closed) return abandon(open) // close() landed during dial → detach, never publish
|
||||||
if (!attachFirst(open)) return EndCause.Disconnected
|
if (!attachFirst(open)) return EndCause.Disconnected
|
||||||
if (closed) return abandon(open) // close() landed during attach → detach, never publish
|
if (closed) return abandon(open) // close() landed during attach → detach, never publish
|
||||||
@@ -184,20 +208,37 @@ public class SessionEngine(
|
|||||||
return EndCause.Terminal
|
return EndCause.Terminal
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dialOrNull(): OpenConn? =
|
private suspend fun dial(): DialResult =
|
||||||
try {
|
try {
|
||||||
if (transport is PingableTermTransport) {
|
if (transport is PingableTermTransport) {
|
||||||
val pingable = transport.connectPingable(endpoint)
|
val pingable = transport.connectPingable(endpoint)
|
||||||
OpenConn(pingable.connection, pingable.pinger)
|
DialResult.Open(OpenConn(pingable.connection, pingable.pinger))
|
||||||
} else {
|
} else {
|
||||||
OpenConn(transport.connect(endpoint), null)
|
DialResult.Open(OpenConn(transport.connect(endpoint), null))
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (_: Throwable) {
|
} catch (error: Throwable) {
|
||||||
null // connect-time failure → retryable
|
// 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. */
|
/** attach-first (+ replay [lastDims]) BEFORE publishing the connection. */
|
||||||
private suspend fun attachFirst(open: OpenConn): Boolean =
|
private suspend fun attachFirst(open: OpenConn): Boolean =
|
||||||
try {
|
try {
|
||||||
@@ -412,5 +453,8 @@ public class SessionEngine(
|
|||||||
public companion object {
|
public companion object {
|
||||||
/** Max [AwayDigest.recent] entries carried in the once-per-reconnect digest. */
|
/** Max [AwayDigest.recent] entries carried in the once-per-reconnect digest. */
|
||||||
public const val DEFAULT_DIGEST_LIMIT: Int = 20
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,4 +68,14 @@ public enum class FailureReason {
|
|||||||
* back-off. UI copy: lower the server's SCROLLBACK_BYTES or raise the client cap.
|
* back-off. UI copy: lower the server's SCROLLBACK_BYTES or raise the client cap.
|
||||||
*/
|
*/
|
||||||
REPLAY_TOO_LARGE,
|
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package wang.yaojia.webterm.transport
|
|||||||
import okhttp3.Cookie
|
import okhttp3.Cookie
|
||||||
import okhttp3.CookieJar
|
import okhttp3.CookieJar
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
|
import wang.yaojia.webterm.wire.AuthCookie
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,11 +16,29 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
* `BridgeInterceptor` runs for WebSocket calls too.
|
* `BridgeInterceptor` runs for WebSocket calls too.
|
||||||
*
|
*
|
||||||
* ── Isolation (security-load-bearing) ────────────────────────────────────────────────────────────
|
* ── 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
|
* 1. storage is partitioned by [authCookieHostKey] (`scheme://host:port`), so another host's key
|
||||||
* simply has no entry to read;
|
* simply has no entry to read;
|
||||||
* 2. what survives that lookup is still filtered through OkHttp's own `Cookie.matches(url)`, which
|
* 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
|
* 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
|
* 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 hostKey = authCookieHostKey(url)
|
||||||
val stored = byHostKey[hostKey] ?: return emptyList()
|
val stored = byHostKey[hostKey] ?: return emptyList()
|
||||||
val live = pruneExpired(hostKey, stored)
|
val live = pruneExpired(hostKey, stored)
|
||||||
// Second layer: RFC domain/path match + the Secure-over-cleartext refusal.
|
// Second layer: RFC domain/path match + the Secure-over-cleartext refusal. Third layer: our
|
||||||
return live.filter { it.matches(url) }
|
// 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>) {
|
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 hostKey = authCookieHostKey(url)
|
||||||
val now = clock()
|
val now = clock()
|
||||||
synchronized(writeLock) {
|
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
|
* 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
|
* 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.
|
* [AuthCookiePersister] is deliberately NOT notified.
|
||||||
*/
|
*/
|
||||||
public fun restore(hostKey: String, cookies: List<AuthCookieSnapshot>) {
|
public fun restore(hostKey: String, cookies: List<AuthCookieSnapshot>) {
|
||||||
val now = clock()
|
val now = clock()
|
||||||
val live = cookies
|
val live = cookies
|
||||||
.filter { it.expiresAtEpochMillis > now }
|
.filter { it.name == AuthCookie.NAME && it.expiresAtEpochMillis > now }
|
||||||
.mapNotNull { it.toCookieOrNull() }
|
.mapNotNull { it.toCookieOrNull() }
|
||||||
.capped()
|
.capped()
|
||||||
synchronized(writeLock) {
|
synchronized(writeLock) {
|
||||||
@@ -138,6 +161,13 @@ private fun List<Cookie>.upserting(cookie: Cookie): List<Cookie> =
|
|||||||
private fun Cookie.sameIdentity(other: Cookie): Boolean =
|
private fun Cookie.sameIdentity(other: Cookie): Boolean =
|
||||||
name == other.name && domain == other.domain && path == other.path
|
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 =
|
private fun Cookie.toSnapshot(): AuthCookieSnapshot =
|
||||||
AuthCookieSnapshot(
|
AuthCookieSnapshot(
|
||||||
name = name,
|
name = name,
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ package wang.yaojia.webterm.transport
|
|||||||
|
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
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`).
|
* 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
|
* 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
|
* ([AUTH_COOKIE_NAME]) and the jar keeps no other name, so this is a residual boundary guard against a
|
||||||
* client into an unbounded credential sink. Oldest entries are evicted first.
|
* 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
|
internal const val MAX_COOKIES_PER_HOST: Int = 16
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package wang.yaojia.webterm.transport
|
|||||||
|
|
||||||
import okhttp3.CookieJar
|
import okhttp3.CookieJar
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||||
import javax.net.ssl.SSLSocketFactory
|
import javax.net.ssl.SSLSocketFactory
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
@@ -107,13 +108,25 @@ public class OkHttpTransports private constructor(
|
|||||||
public val client: OkHttpClient,
|
public val client: OkHttpClient,
|
||||||
) {
|
) {
|
||||||
public companion object {
|
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(
|
public fun create(
|
||||||
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
|
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
|
||||||
cookieJar: CookieJar = CookieJar.NO_COOKIES,
|
cookieJar: CookieJar = CookieJar.NO_COOKIES,
|
||||||
|
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||||
): OkHttpTransports {
|
): OkHttpTransports {
|
||||||
val client = OkHttpClientFactory.create(identityProvider, cookieJar)
|
val client = OkHttpClientFactory.create(identityProvider, cookieJar)
|
||||||
return OkHttpTransports(
|
return OkHttpTransports(
|
||||||
term = OkHttpTermTransport(client),
|
term = OkHttpTermTransport(client, tokens),
|
||||||
http = OkHttpHttpTransport(client),
|
http = OkHttpHttpTransport(client),
|
||||||
client = client,
|
client = client,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package wang.yaojia.webterm.transport
|
|||||||
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
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.HostEndpoint
|
||||||
import wang.yaojia.webterm.wire.PingableConnection
|
import wang.yaojia.webterm.wire.PingableConnection
|
||||||
import wang.yaojia.webterm.wire.PingableTermTransport
|
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]. */
|
/** The `Origin` header name — THE CSWSH defence; stamped byte-equal from [HostEndpoint.originHeader]. */
|
||||||
internal const val HEADER_ORIGIN: String = "Origin"
|
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`)
|
* The only concrete WS transport (A7): implements [PingableTermTransport] (and thus `TermTransport`)
|
||||||
* over OkHttp. `SessionEngine` (A14) cannot tell it apart from the `FakeTermTransport` double.
|
* 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
|
* 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.
|
* 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.
|
* 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
|
* `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).
|
* classifier of an oversized ring-buffer replay (the non-retryable `REPLAY_TOO_LARGE` failure).
|
||||||
*/
|
*/
|
||||||
public class OkHttpTermTransport(
|
public class OkHttpTermTransport(
|
||||||
sharedClient: OkHttpClient,
|
sharedClient: OkHttpClient,
|
||||||
|
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||||
) : PingableTermTransport {
|
) : PingableTermTransport {
|
||||||
|
|
||||||
private val wsClient: OkHttpClient = sharedClient.newBuilder()
|
private val wsClient: OkHttpClient = sharedClient.newBuilder()
|
||||||
@@ -51,10 +69,14 @@ public class OkHttpTermTransport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun openConnection(endpoint: HostEndpoint): OkHttpWebSocketConnection {
|
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
|
.url(endpoint.wsUrl) // OkHttp maps ws(s):// → http(s):// internally
|
||||||
.header(HEADER_ORIGIN, endpoint.originHeader)
|
.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)
|
val connection = OkHttpWebSocketConnection(wsClient, request)
|
||||||
try {
|
try {
|
||||||
connection.awaitOpen()
|
connection.awaitOpen()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import okhttp3.WebSocketListener
|
|||||||
import okio.ByteString
|
import okio.ByteString
|
||||||
import wang.yaojia.webterm.wire.ConnectionPinger
|
import wang.yaojia.webterm.wire.ConnectionPinger
|
||||||
import wang.yaojia.webterm.wire.TransportConnection
|
import wang.yaojia.webterm.wire.TransportConnection
|
||||||
|
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
/** RFC 6455 normal-closure status code, used for a client-initiated detach. */
|
/** 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?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
finish(t)
|
// A 401 handshake answer is the server's ONE upgrade rejection (foreign Origin OR a
|
||||||
// If we never opened, unblock connect() with the verbatim cause; no-op once opened.
|
// missing/invalid access-token cookie) and is permanent until reconfigured, so it is TYPED
|
||||||
opened.completeExceptionally(t)
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,9 +271,13 @@ class AuthCookieJarTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun capsTheNumberOfCookiesStoredPerHost() {
|
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("{}")
|
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)
|
server.enqueue(response)
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -283,6 +287,25 @@ class AuthCookieJarTest {
|
|||||||
assertEquals(MAX_COOKIES_PER_HOST, persister.latestFor(hostKey())?.size)
|
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 ──────────────────────
|
// ── 4. the credential never reaches a log/toString path ──────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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._~+/=-]`, 16–512 chars — a server that fails this refuses to start, so a token outside
|
||||||
|
* it can never be correct and must be rejected before any network I/O (and before it reaches storage).
|
||||||
|
*
|
||||||
|
* The charset also guarantees the token cannot forge cookie syntax (no `;`, `,`, `=`-prefix, space or
|
||||||
|
* quote injection into the `Cookie` header).
|
||||||
|
*
|
||||||
|
* [normalize] trims ONCE here (a pasted/QR-sourced token often carries stray whitespace) and returns
|
||||||
|
* the trimmed token, or `null` when it is not a possible server token.
|
||||||
|
*/
|
||||||
|
public object AccessTokenRule {
|
||||||
|
/** Server-side minimum (CLAUDE.md / config validation). */
|
||||||
|
public const val MIN_LENGTH: Int = 16
|
||||||
|
|
||||||
|
/** Server-side maximum. */
|
||||||
|
public const val MAX_LENGTH: Int = 512
|
||||||
|
|
||||||
|
private val ALLOWED: Set<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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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._~+/=-]`, 16–512) so a
|
||||||
|
* malformed token is rejected BEFORE any network I/O;
|
||||||
|
* - [AccessTokenSource.NONE] is the "no token configured" default (LAN zero-config unchanged).
|
||||||
|
*/
|
||||||
|
class AccessTokenTest {
|
||||||
|
|
||||||
|
// ── AuthCookie: the one derivation point ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cookie name and header name match the server contract`() {
|
||||||
|
assertEquals("webterm_auth", AuthCookie.NAME)
|
||||||
|
assertEquals("Cookie", AuthCookie.HEADER_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `header value is name equals token with no extra whitespace or attributes`() {
|
||||||
|
assertEquals("webterm_auth=abcdefghijklmnop", AuthCookie.headerValue("abcdefghijklmnop"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AccessTokenRule: charset + length, trimmed once ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a valid token in the server charset normalizes to itself`() {
|
||||||
|
val token = "Aa0._~+/=-Aa0._~+/=-"
|
||||||
|
assertEquals(token, AccessTokenRule.normalize(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `surrounding whitespace is trimmed once at the boundary`() {
|
||||||
|
assertEquals("0123456789abcdef", AccessTokenRule.normalize(" 0123456789abcdef\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a token shorter than the minimum or longer than the maximum is rejected`() {
|
||||||
|
assertEquals(16, AccessTokenRule.MIN_LENGTH)
|
||||||
|
assertEquals(512, AccessTokenRule.MAX_LENGTH)
|
||||||
|
assertNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MIN_LENGTH - 1)))
|
||||||
|
assertNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MAX_LENGTH + 1)))
|
||||||
|
assertNotNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MIN_LENGTH)))
|
||||||
|
assertNotNull(AccessTokenRule.normalize("a".repeat(AccessTokenRule.MAX_LENGTH)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty or blank token is rejected`() {
|
||||||
|
assertNull(AccessTokenRule.normalize(""))
|
||||||
|
assertNull(AccessTokenRule.normalize(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `characters outside the server charset are rejected`() {
|
||||||
|
// A `;` would forge a second cookie attribute; a space/quote/CJK char is simply not in the set.
|
||||||
|
listOf(
|
||||||
|
"abcdefghijklmno;x",
|
||||||
|
"abcdefghijklmno x",
|
||||||
|
"abcdefghijklmno\"x",
|
||||||
|
"abcdefghijklmno\nx",
|
||||||
|
"令牌令牌令牌令牌令牌令牌令牌令牌",
|
||||||
|
).forEach { raw ->
|
||||||
|
assertNull(AccessTokenRule.normalize(raw), "must reject: $raw")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AccessTokenSource ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `NONE yields no token so an unauthenticated LAN host keeps working`() = runTest {
|
||||||
|
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
|
||||||
|
assertNull(AccessTokenSource.NONE.tokenFor(endpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a source can be a lambda keyed by the endpoint`() = runTest {
|
||||||
|
val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
|
||||||
|
val other = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.6:3000"))
|
||||||
|
val source = AccessTokenSource { endpoint ->
|
||||||
|
if (endpoint == lan) "0123456789abcdef" else null
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("0123456789abcdef", source.tokenFor(lan))
|
||||||
|
assertNull(source.tokenFor(other))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UnauthorizedUpgradeException ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the 401 upgrade error is an IOException keeping the verbatim cause`() {
|
||||||
|
val cause = IllegalStateException("expected 101, got 401")
|
||||||
|
val error: java.io.IOException = UnauthorizedUpgradeException(cause)
|
||||||
|
|
||||||
|
assertEquals(cause, error.cause, "the verbatim transport cause must survive for classify()")
|
||||||
|
assertEquals(
|
||||||
|
"the server rejected the WebSocket upgrade with HTTP 401",
|
||||||
|
error.message,
|
||||||
|
"fixed copy — no header/token value may ever be interpolated into the message",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
> 落地方案文档。目标:给 web-terminal 做一个 **iPhone 原生 App**,把 vibe-coding 的"走开—被叫回—两次手势处理完"闭环装进口袋。
|
> 落地方案文档。目标:给 web-terminal 做一个 **iPhone 原生 App**,把 vibe-coding 的"走开—被叫回—两次手势处理完"闭环装进口袋。
|
||||||
> 拓扑/框架选型:**Phased-Native —— SwiftUI + SwiftTerm,4 个纯 SwiftPM 包 + 薄 App 胶水;前台会话单条活 WS,其余 HTTP 轮询;服务器零改动(P0 零触点;P1 仅声明的附加触点,见 §0.3)**。
|
> 拓扑/框架选型:**Phased-Native —— SwiftUI + SwiftTerm,4 个纯 SwiftPM 包 + 薄 App 胶水;前台会话单条活 WS,其余 HTTP 轮询;服务器零改动(P0 零触点;P1 仅声明的附加触点,见 §0.3)**。
|
||||||
> 状态:**规划中(2026-07-04,未开工)**。
|
> 状态:**已交付并合入 `develop`**(P0 + P1 + P2 全部落地;`feat/ios-client` 早已 merge 进 `develop`,
|
||||||
|
> `git merge-base --is-ancestor feat/ios-client develop` 为真,勿再按"分支未合"叙述)。
|
||||||
|
> 逐任务状态见 **§7 开头的状态总表**(2026-07-30 由 ios-completion 收尾波按源码核对重建);
|
||||||
|
> 真机 / 付费 Apple 账号相关项一律 **DEFERRED** 并在表中标明。
|
||||||
> 本文是「怎么做」的蓝图,配合 [TECH_DOC.md](./TECH_DOC.md)(why)+ [ARCHITECTURE.md](./ARCHITECTURE.md)(how);桌面版先例见 [DESKTOP_PLAN.md](./DESKTOP_PLAN.md);远程访问/中继演进见 [PLAN_RELAY_INDEX.md](./PLAN_RELAY_INDEX.md)。
|
> 本文是「怎么做」的蓝图,配合 [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)。
|
> 完成情况记录在 [PROGRESS_LOG.md](./PROGRESS_LOG.md)。工作流约束见 [CLAUDE.md](../CLAUDE.md)(查 PLAN → 做子任务(TDD) → 验证 → 更新 LOG)。
|
||||||
> **G1 日志铁律**:`PROGRESS_LOG.md` 由 **orchestrator 独写**,不在任何任务的 `Owns:` 里;被派的 subagent **不写 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。
|
除此之外**服务器 byte-for-byte 零改动**。若实施中发现 server 侧缺陷,修复归属对应模块(route/session 文件的 owner),按 CLAUDE.md 记 `PROGRESS_LOG.md`——iOS 任务不越界改 server。
|
||||||
|
|
||||||
|
> **补记(ios-completion 收尾波)**:客户端另外开始消费**早已存在**的两处服务器能力,**均无新触点**——
|
||||||
|
> ① 访问令牌门(`POST /auth` + `webterm_auth` cookie,w5-access-token,真源 `src/http/auth.ts`);
|
||||||
|
> ② 项目 git 面板/worktree 的 13 个 `/projects*` 路由(w4/w6,真源 `src/http/projects.ts` + `src/server.ts:507-1320`)。
|
||||||
|
> 二者都是"服务器先有、iOS 后接",故 §0.3 的"P0 零触点 / P1 两触点"结论不变。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 整体架构 / 进程模型
|
## 1. 整体架构 / 进程模型
|
||||||
@@ -156,6 +164,11 @@ web-terminal/
|
|||||||
└── LiveServerTests.swift
|
└── 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 同路径):
|
**构建管线**(本地与 CI 同路径):
|
||||||
1. `brew install xcodegen && cd ios && xcodegen generate` → `WebTerm.xcodeproj`(不入库)。
|
1. `brew install xcodegen && cd ios && xcodegen generate` → `WebTerm.xcodeproj`(不入库)。
|
||||||
2. 各包独立测试:`swift test --package-path ios/Packages/<Pkg>`(纯 mac 侧,无模拟器,秒级)。
|
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)。
|
> 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,最优)。
|
- MagicDNS 名(`*.ts.net`)是 FQDN → ATS 全额适用 → 用 100.x IP、加例外域,或 `tailscale serve`(https/wss,最优)。
|
||||||
- Local Network 弹窗被拒 → 连接报 POSIX "Network is down";映射到 `PairingError.localNetworkDenied` 并引导去 设置→隐私→本地网络(iOS 18 有需重启的已知 bug,话术里写明)。
|
- Local Network 弹窗被拒 → 连接报 POSIX "Network is down";映射到 `PairingError.localNetworkDenied` 并引导去 设置→隐私→本地网络(iOS 18 有需重启的已知 bug,话术里写明)。
|
||||||
|
|
||||||
### 5.3 凭据与本地存储
|
### 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)。
|
- **UserDefaults**:仅非机密(per-host lastSessionId、UI prefs)。
|
||||||
- `/hook/decision` 的 capability `token` **只经 push payload 到达、用后即弃**(服务器侧本就单次有效+过期,src/server.ts:503-525;限频 10/min/IP);App 端绝不落盘。
|
- `/hook/decision` 的 capability `token` **只经 push payload 到达、用后即弃**(服务器侧本就单次有效+过期,src/server.ts:503-525;限频 10/min/IP);App 端绝不落盘。
|
||||||
- App 内无任何硬编码 host/密钥;首个 host 必经配对流程。
|
- App 内无任何硬编码 host/密钥;首个 host 必经配对流程。
|
||||||
@@ -490,15 +509,100 @@ W5 验收(report-only, 并行)
|
|||||||
|
|
||||||
## 7. 任务清单
|
## 7. 任务清单
|
||||||
|
|
||||||
> 状态图例:`[ ]` TODO · `[~]` 进行中 · `[x]` 完成 · `[!]` 受阻。ID 稳定,永不重编号。
|
> 状态图例:`[ ]` TODO · `[~]` **部分完成**(下一行必写缺口)· `[x]` 完成 · `[!]` 受阻。ID 稳定,永不重编号。
|
||||||
|
> **`[x]` 的判据(本文统一约定)**:代码+测试已交付,且**本环境可机器验证的部分全绿**;需要真机 / 付费 Apple 账号
|
||||||
|
> 才能做的验收项,在该行标 **DEFERRED** 而**不**把任务降级为 `[~]`(否则每条都是"部分",完成度又读不出来)。
|
||||||
|
> `[~]` 只留给**在本环境本可做却没做**的缺口,或本身就是真机走查的任务。
|
||||||
> 每个任务自带 TDD(测试与实现同 agent 同文件组)。测试框架:Swift Testing(`@Test`/`#expect`);XCTest 仅 XCUITest。
|
> 每个任务自带 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 零 findings(LOG 条目)。**DEFERRED**:真机锁屏/unread 目视等,手工清单在 LOG |
|
||||||
|
| T-iOS-31 语音 PTT | `[x]` | `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 🎤 键 + `VoicePTTTests` **40** `@Test`。**DEFERRED**:真机口述→确认→注入 |
|
||||||
|
| T-iOS-32 Worktree + `--resume` 历史 | `[x]` | `Screens/{WorktreeSheet,ResumeHistorySheet}.swift`、`ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift` + `WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)。**Accept 的"端到端一次"已闭合**(收尾波):`IntegrationTests/WorktreeLifecycleTests.swift` **6 例**在临时 git fixture 仓上对真服务器真建/真删/真 prune——断言的是服务端**推导**出的路径与分支(`feature/e2e-worktree` → 目录 `feature-e2e-worktree`,原始分支名绝不出现在路径里),并用 `git worktree list --porcelain` 与 `.git/worktrees/` 双向核对;含 G 路由纪律的差分腿(无 `Origin` → 403 且零副作用,同一请求加 `Origin` → 200)与非法分支名不创建;`GET /sessions` 用 **HOME 隔离**的专用服务器逐字段解码(否则会打到开发者真实 Claude 历史,CI 上又退化成 `[]` 什么都证明不了) |
|
||||||
|
| T-iOS-33 终端内搜索 | `[x]` | `Components/TerminalSearchBar.swift` + `TerminalScreen` 绑定 SwiftTerm 搜索 API + `TerminalSearchTests` **18**(含 2 条真 view 高亮断言) |
|
||||||
|
| T-iOS-34 主题 + Dynamic Type | `[x]` | `DesignSystem/{AppTheme,TerminalPalette}.swift`、`Screens/SettingsScreen.swift`、`Tokens/Typography` 浅色档 + `AppThemeTests`(24)/`DynamicTypeLayoutTests`(**14**)/`KeyBarTests`(**12**)。**缺口已闭合**(收尾波):`KeyBarMetrics.barHeight` 由常量 52pt 改为按字号档推导,`withKnownIssue` 已删、换成正向断言(AX5 不裁切 · 全 12 档不裁切且 ≥44pt · XS–XL 逐点仍 52pt 零回归 · 未封顶 AX5 溢出旧固定高的护栏)。**取舍**:键帽字体封顶在 `DS.Typography.numericClamp`(.accessibility2),故 AX3–AX5 的字号不再增长(不封顶时 AX5 要 108.24pt,会吃掉终端);该封顶由测试钉死不得漂移 |
|
||||||
|
| T-iOS-35 web `?join=` 互通 | `[x]` | `DeepLinkRouter.swift` 的 `.joinShared` 分支 + `DeepLinkJoinTests` **19**(含改写后的既有拒绝用例) |
|
||||||
|
| T-iOS-36 P2 验收 | `[x]` | 两轮独立复验(Wave D + 收尾波)均已跑完,**真实数字以 `PROGRESS_LOG.md` 的该条目为准**:452 包测 / 550 App 测(iPhone 16 Pro 与 iPad Pro 11" M4 各一遍,**0 known issue**)/ 26 集成测 / 覆盖率门 5/5 / 真机构建 `BUILD SUCCEEDED` 且产物无 `aps-environment`。P2 五项逐条走查见本节上方各行 |
|
||||||
|
|
||||||
|
**说明**:上表的"测试数"是 `@Test` **声明**静态计数(`grep -rh '@Test'`),与 commit `850531f`/`a5fa843` 里实跑数字一致;
|
||||||
|
参数化用例实跑会更多。App bundle 侧共 **532** 条 `@Test` 声明。
|
||||||
|
|
||||||
|
### 7.1 ios-completion 收尾波(2026-07-29/30)新增能力 —— 无既有任务 ID
|
||||||
|
|
||||||
|
审计出的缺口用一波收尾波补齐,这些交付**不属于**上表任何 ID(不新编 `T-iOS-*` 号,避免与冻结 ID 冲突):
|
||||||
|
|
||||||
|
- **访问令牌(`WEBTERM_TOKEN`)两端接入**:iOS(`APIClient/AccessToken.swift`、`HostRegistry/AccessToken.swift`、
|
||||||
|
`SessionCore/AuthCookie.swift`、`Wiring/URLSessionHTTPTransport.swift`、配对页令牌态)与 Android
|
||||||
|
(`AuthCookie`/`AccessTokenStore`/OkHttp 侧)走**同一冻结契约**(`docs/plans/ios-completion.md` §1.1):
|
||||||
|
手写 `Cookie: webterm_auth=<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 人天
|
### P0 — 每日可用("口袋里能开终端、能批准")· 合计 ~13 人天
|
||||||
|
|
||||||
#### W0 · 基础(串行)
|
#### 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`)
|
- **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**: 无(必须最先)
|
- **Depends**: 无 · **Parallel-safe**: 无(必须最先)
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
@@ -508,7 +612,10 @@ W5 验收(report-only, 并行)
|
|||||||
- **Accept**: `xcodegen generate && xcodebuild … build` 通过;空 App 在模拟器启动
|
- **Accept**: `xcodegen generate && xcodebuild … build` 通过;空 App 在模拟器启动
|
||||||
- **安全注**: ATS 键从本文 §5.2 逐字誊写,不许"先放开回头再收"。
|
- **安全注**: 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 删)
|
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/IntegrationTests/OriginSpikeTests.swift`、`ios/App/WebTerm/Screens/SpikeTerminalScreen.swift`(临时文件,W4 删)
|
||||||
- **Depends**: T-iOS-1 · **Parallel-safe**: T-iOS-3
|
- **Depends**: T-iOS-1 · **Parallel-safe**: T-iOS-3
|
||||||
- **Steps(测试先行——spike 本身就是测试)**:
|
- **Steps(测试先行——spike 本身就是测试)**:
|
||||||
@@ -517,7 +624,7 @@ W5 验收(report-only, 并行)
|
|||||||
- **Accept**: 4 条自动化断言绿(其中 ③ 的"复现失败"分支用 `withKnownIssue` 记录);真机清单逐项有结论
|
- **Accept**: 4 条自动化断言绿(其中 ③ 的"复现失败"分支用 `withKnownIssue` 记录);真机清单逐项有结论
|
||||||
- **安全注**: 这是对 "URLSessionWebSocketTask 可发自定义 Origin"(MED 置信度)的一锤定音;若失败 → `[!] BLOCKED`,orchestrator 决策切 Starscream 备胎,**不得自行引入依赖**。
|
- **安全注**: 这是对 "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`)
|
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/Packages/WireProtocol/**`(Sources + Tests 全部,含 `HostEndpoint/TermTransport/HTTPTransport/TimelineEvent/Tunables`)
|
||||||
- **Depends**: T-iOS-1 · **Parallel-safe**: T-iOS-2
|
- **Depends**: T-iOS-1 · **Parallel-safe**: T-iOS-2
|
||||||
- **Steps(测试先行, RED)** — `Tests/WireProtocolTests/CodecRoundtripTests.swift`、`HostEndpointTests.swift`、`ServerVectorTests.swift`:
|
- **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%
|
- **Accept**: `swift test --package-path ios/Packages/WireProtocol` 全绿;覆盖率 ≥ 80%
|
||||||
- **安全注**: 服务器是不可信输入源——decode 对模糊输入永不 crash(用随机字节 fuzz 一轮)。
|
- **安全注**: 服务器是不可信输入源——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 即可编译)
|
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/Packages/TestSupport/**`(含本包 `Package.swift`,仅声明对 WireProtocol 的依赖——故 W0 即可编译)
|
||||||
- **Depends**: T-iOS-3(接口) · **Parallel-safe**: W1 全部
|
- **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 才存在)
|
- **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 · 叶子包(全部并行)
|
#### 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`
|
- **Wave/阶段**: W1 / P0 · **Owns**: `SessionCore/Sources/…/{ReconnectMachine,PingScheduler}.swift`、`Tests/…/{ReconnectMachineTests,PingSchedulerTests}.swift`
|
||||||
- **Depends**: T-iOS-3、T-iOS-4(FakeClock) · **Parallel-safe**: T-iOS-6/7/8
|
- **Depends**: T-iOS-3、T-iOS-4(FakeClock) · **Parallel-safe**: T-iOS-6/7/8
|
||||||
- **Steps(测试先行, RED)**:
|
- **Steps(测试先行, RED)**:
|
||||||
@@ -555,7 +662,7 @@ W5 验收(report-only, 并行)
|
|||||||
- **Steps(实现, GREEN)**: [ ] §3.2 签名 [ ] 常量一律读 `Tunables`(WireProtocol,T-iOS-3 所有;值见 §3.2.1——需新增常量 → 回 T-iOS-3 改契约,无魔法数字)
|
- **Steps(实现, GREEN)**: [ ] §3.2 签名 [ ] 常量一律读 `Tunables`(WireProtocol,T-iOS-3 所有;值见 §3.2.1——需新增常量 → 回 T-iOS-3 改契约,无魔法数字)
|
||||||
- **Accept**: `swift test --package-path ios/Packages/SessionCore --filter Reconnect` 等全绿
|
- **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
|
- **Wave/阶段**: W1 / P0 · **Owns**: `SessionCore/Sources/…/{GateState,AwayDigest}.swift`、对应 Tests
|
||||||
- **Depends**: T-iOS-3 · **Parallel-safe**: T-iOS-5/7/8
|
- **Depends**: T-iOS-3 · **Parallel-safe**: T-iOS-5/7/8
|
||||||
- **Steps(测试先行, RED)**:
|
- **Steps(测试先行, RED)**:
|
||||||
@@ -567,7 +674,7 @@ W5 验收(report-only, 并行)
|
|||||||
- [ ] `limit` 截断 recent;空 events → 全零 digest(UI 可据此不渲染)
|
- [ ] `limit` 截断 recent;空 events → 全零 digest(UI 可据此不渲染)
|
||||||
- **Accept**: 对应 filter 全绿;reducer 纯函数、不可变
|
- **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)
|
- **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
|
- **Depends**: T-iOS-3 · **Parallel-safe**: T-iOS-5/6/8
|
||||||
- **Steps(测试先行, RED)** — 用 InMemory 替身测协议契约;Keychain 实现经 `SecItemShim` 缝测:
|
- **Steps(测试先行, RED)** — 用 InMemory 替身测协议契约;Keychain 实现经 `SecItemShim` 缝测:
|
||||||
@@ -580,7 +687,7 @@ W5 验收(report-only, 并行)
|
|||||||
- **Accept**: `swift test --package-path ios/Packages/HostRegistry` 全绿(覆盖率计法见 §9:KeychainHostStore 以 shim 注入计入)
|
- **Accept**: `swift test --package-path ios/Packages/HostRegistry` 全绿(覆盖率计法见 §9:KeychainHostStore 以 shim 注入计入)
|
||||||
- **安全注**: Keychain 属性错一个字 = 凭据可被备份带走;review 时对照 §5.3。
|
- **安全注**: Keychain 属性错一个字 = 凭据可被备份带走;review 时对照 §5.3。
|
||||||
|
|
||||||
#### T-iOS-8 · `APIClient` 包 + 配对探针 `[ ]` · ~1 pd
|
#### T-iOS-8 · `APIClient` 包 + 配对探针 `[x]` · ~1 pd
|
||||||
- **Wave/阶段**: W1 / P0 · **Owns**: `ios/Packages/APIClient/**`
|
- **Wave/阶段**: W1 / P0 · **Owns**: `ios/Packages/APIClient/**`
|
||||||
- **Depends**: T-iOS-3、T-iOS-4 · **Parallel-safe**: T-iOS-5/6/7
|
- **Depends**: T-iOS-3、T-iOS-4 · **Parallel-safe**: T-iOS-5/6/7
|
||||||
- **Steps(测试先行, RED)** — `Tests/APIClientTests/{RequestBuilderTests,PairingProbeTests,ModelDecodingTests}.swift`:
|
- **Steps(测试先行, RED)** — `Tests/APIClientTests/{RequestBuilderTests,PairingProbeTests,ModelDecodingTests}.swift`:
|
||||||
@@ -598,7 +705,7 @@ W5 验收(report-only, 并行)
|
|||||||
|
|
||||||
#### W2 · 连接核心
|
#### 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`
|
- **Wave/阶段**: W2 / P0 · **Owns**: `SessionCore/Sources/…/URLSessionTermTransport.swift`、`Tests/…/URLSessionTermTransportTests.swift`
|
||||||
- **Depends**: T-iOS-2(spike 结论)、T-iOS-3 · **Parallel-safe**: T-iOS-10(接口并行)
|
- **Depends**: T-iOS-2(spike 结论)、T-iOS-3 · **Parallel-safe**: T-iOS-10(接口并行)
|
||||||
- **Steps(测试先行, RED)** — 对 in-process 本地 WS echo(测试内起 `NWListener` 或复用 IntegrationTests 服务器):
|
- **Steps(测试先行, RED)** — 对 in-process 本地 WS echo(测试内起 `NWListener` 或复用 IntegrationTests 服务器):
|
||||||
@@ -613,7 +720,7 @@ W5 验收(report-only, 并行)
|
|||||||
- **Accept**: filter 全绿;T-iOS-16 的真服务器测试是它的最终验收
|
- **Accept**: filter 全绿;T-iOS-16 的真服务器测试是它的最终验收
|
||||||
- **安全注**: Origin 单点取自 `HostEndpoint.originHeader`,本文件出现字符串拼接 origin = review CRITICAL。
|
- **安全注**: 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`
|
- **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
|
- **Depends**: T-iOS-3/4/5/6(接口)、T-iOS-9(集成汇合) · **Parallel-safe**: T-iOS-9
|
||||||
- **Steps(测试先行, RED)** — 全部对 FakeTransport:
|
- **Steps(测试先行, RED)** — 全部对 FakeTransport:
|
||||||
@@ -633,7 +740,7 @@ W5 验收(report-only, 并行)
|
|||||||
|
|
||||||
#### W3 · UI 胶水(全部并行;只依赖 §3 接口 + 替身)
|
#### 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 覆盖率门,只帮不损)
|
- **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
|
- **Depends**: T-iOS-10(接口) · **Parallel-safe**: T-iOS-12/13/14
|
||||||
- **Steps(测试先行, RED)**:
|
- **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)
|
- **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)
|
- **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`
|
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/PairingScreen.swift`、`ViewModels/PairingViewModel.swift`
|
||||||
- **Depends**: T-iOS-7/8(接口) · **Parallel-safe**: T-iOS-11/13/14
|
- **Depends**: T-iOS-7/8(接口) · **Parallel-safe**: T-iOS-11/13/14
|
||||||
- **Steps(测试先行, RED)** — VM 层(探针逻辑已在 T-iOS-8 测过,这里测状态映射):
|
- **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 用)
|
- **Steps(实现, GREEN)**: [ ] `DataScannerViewController`(真机 only,模拟器隐藏入口;依赖 §5.2 `NSCameraUsageDescription`)读 web UI `qr.ts` 的 origin URL [ ] 手输表单 fallback(用户自己输入的 URL 身份已知,可直连探针;复用确认态亦可)[ ] 多 host 切换入口(列表页 header 用)
|
||||||
- **Accept**: VM 测试全绿;模拟器手输路径可配对本机服务器
|
- **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`
|
- **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
|
- **Depends**: T-iOS-8(接口) · **Parallel-safe**: T-iOS-11/12/14
|
||||||
- **Steps(测试先行, RED)** — VM 对 FakeHTTPTransport:
|
- **Steps(测试先行, RED)** — VM 对 FakeHTTPTransport:
|
||||||
@@ -670,7 +777,7 @@ W5 验收(report-only, 并行)
|
|||||||
- **Steps(实现, GREEN)**: [ ] 下拉刷新 [ ] host 切换 header [ ] 空态(无会话/未配对)
|
- **Steps(实现, GREEN)**: [ ] 下拉刷新 [ ] host 切换 header [ ] 空态(无会话/未配对)
|
||||||
- **Accept**: VM 测试全绿
|
- **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
|
- **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
|
- **Depends**: T-iOS-6/10(接口) · **Parallel-safe**: T-iOS-11/12/13
|
||||||
- **Steps(测试先行, RED)**:
|
- **Steps(测试先行, RED)**:
|
||||||
@@ -683,14 +790,14 @@ W5 验收(report-only, 并行)
|
|||||||
|
|
||||||
#### W4 · 集成(汇合点)
|
#### 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
|
- **Wave/阶段**: W4 / P0 · **Owns**: `App/WebTerm/WebTermApp.swift`(改)、导航组装、删除 T-iOS-2 的 SpikeTerminalScreen
|
||||||
- **Depends**: T-iOS-11–14 全部 · **Parallel-safe**: T-iOS-16/17
|
- **Depends**: T-iOS-11–14 全部 · **Parallel-safe**: T-iOS-16/17
|
||||||
- **Steps**: [ ] Pairing→List→Terminal 导航 + 依赖注入(真实现 wiring;含 `GateViewModel`(T-iOS-14)接入 TerminalScreen)[ ] `scenePhase == .active` → `engine.notifyForegrounded(dims:)`(重连 + 补发 resize;**这是"换设备夺回全屏"的关键**)[ ] `.background` → 主动 `close()`(干净 detach,不留半死 socket)[ ] **隐私遮罩**:`scenePhase != .active` 时终端覆盖不透明遮罩、`.active` 恢复(**必须用 `!= .active`,不能只判 `.inactive`**——覆盖切换器进入的 .inactive 与快照发生的 .background 两态;iOS 后台快照会把终端内容(API key/token/源码)写盘并展示在多任务切换器)[ ] 冷启动:有 lastSessionId 的 host → 列表页高亮"继续上次"
|
- **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→后台→前台重连回放;**切后台开切换器 → 卡片显示遮罩而非终端内容**
|
- **Accept**: 模拟器全流程手工走查:配对→列表→attach→后台→前台重连回放;**切后台开切换器 → 卡片显示遮罩而非终端内容**
|
||||||
- **安全注**: 组装点核对一次:所有 G 调用来自 APIClient(无绕过)、debug ATS 设置未漏进 release scheme。录屏暴露面:iOS 无公开 API 把窗口排除出截屏/录屏——只可选做 `UIScreen.isCaptured` 检测(录屏时可选拉黑终端);除此之外记为已接受残余风险(本地信任模型),**不得**计划 isSecureTextEntry 层这类非受支持 hack。
|
- **安全注**: 组装点核对一次:所有 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`(改)
|
- **Wave/阶段**: W4 / P0(仅依赖 W2——可提前并入第 6 批,见 §8) · **Owns**: `ios/IntegrationTests/**`(含吸收 T-iOS-2 的 OriginSpikeTests)、`.github/workflows/ios.yml`(改)
|
||||||
- **Depends**: T-iOS-9/10 · **Parallel-safe**: T-iOS-11–15/17
|
- **Depends**: T-iOS-9/10 · **Parallel-safe**: T-iOS-11–15/17
|
||||||
- **Steps(测试清单)** — macOS runner:`npm ci` → 临时端口 `npm start`(`ALLOWED_ORIGINS` 注入)→ Swift Testing:
|
- **Steps(测试清单)** — macOS runner:`npm ci` → 临时端口 `npm start`(`ALLOWED_ORIGINS` 注入)→ Swift Testing:
|
||||||
@@ -705,7 +812,8 @@ W5 验收(report-only, 并行)
|
|||||||
- **Accept**: CI job 绿;覆盖率门**演示过一次红**(故意把某包压到 80% 下或抬高阈值)再回绿;这是"客户端复刻的协议契约"的持续防漂移闸门
|
- **Accept**: CI job 绿;覆盖率门**演示过一次红**(故意把某包压到 80% 下或抬高阈值)再回绿;这是"客户端复刻的协议契约"的持续防漂移闸门
|
||||||
- **安全注**: 本任务是 §5.1 的自动化化身;任何"为了过 CI 放宽 Origin 断言"= CRITICAL。
|
- **安全注**: 本任务是 §5.1 的自动化化身;任何"为了过 CI 放宽 Origin 断言"= CRITICAL。
|
||||||
|
|
||||||
#### T-iOS-17 · ntfy 桥验证 + 文档(P0 临时通知,**零新代码**)`[ ]` · ~0.1 pd
|
#### T-iOS-17 · ntfy 桥验证 + 文档(P0 临时通知,**零新代码**)`[x]` · ~0.1 pd
|
||||||
|
- **落地现状**:`ios/README.md` 的 ntfy 章节(逐条引 `scripts/setup-hooks.mjs` 行号;只读验证,**未触碰用户真实 hook 配置**)。**DEFERRED**:手机端到端(需用户手机 + 改用户 hook 配置)。
|
||||||
- **Wave/阶段**: W4 / P0 · **Owns**: iOS README 的 ntfy 章节(文档;**不建任何脚本文件**——桥已随 `npm run setup-hooks` 出货:安装逻辑 scripts/setup-hooks.mjs:227-238、env 门 :262-264,重装时 marker 自清理,见 §0.3)
|
- **Wave/阶段**: W4 / P0 · **Owns**: iOS README 的 ntfy 章节(文档;**不建任何脚本文件**——桥已随 `npm run setup-hooks` 出货:安装逻辑 scripts/setup-hooks.mjs:227-238、env 门 :262-264,重装时 marker 自清理,见 §0.3)
|
||||||
- **Depends**: 无(与 App 解耦) · **Parallel-safe**: T-iOS-15/16
|
- **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 经事件总线补上)
|
- **Steps**: [ ] 验证既有桥:设 `WEBTERM_NTFY_URL` + `WEBTERM_NTFY_TOPIC`(可选 `WEBTERM_NTFY_TOKEN`)→ `npm run setup-hooks` → 确认日志 "Installed ntfy bridge (NEEDS-INPUT=high, DONE=low)" [ ] README 写明:env 未设即完全无副作用(默认关闭,已是出货行为);topic 生成建议随机串(topic 即密码)[ ] 记录:**STUCK 不在 P0 信号内**(服务器 sweepStuck 派生态、无 hook 事件,桥发不出——P1 APNs 经事件总线补上)
|
||||||
@@ -714,10 +822,12 @@ W5 验收(report-only, 并行)
|
|||||||
|
|
||||||
#### W5 · 验收(report-only,G4:只报告不改码,findings 标 owning task 派回)
|
#### W5 · 验收(report-only,G4:只报告不改码,findings 标 owning task 派回)
|
||||||
|
|
||||||
#### T-iOS-18 · 验收走查 F-iOS-1…13(真机)`[ ]` · ~0.25 pd
|
#### T-iOS-18 · 验收走查 F-iOS-1…13(真机)`[~]` · ~0.25 pd
|
||||||
|
- **缺口**:机器可执行项已执行并指认测试名;真机项(QR 扫码 / IME / 震动 / 切换器遮罩目检 / ntfy 端到端)**DEFERRED**,手工清单在 `PROGRESS_LOG.md`。
|
||||||
- **Depends**: T-iOS-15/16/17 · **Owns**: 无源码(report-only)
|
- **Depends**: T-iOS-15/16/17 · **Owns**: 无源码(report-only)
|
||||||
- **Steps**: 按 §9 验收脚本逐条执行、记录结论与录屏。
|
- **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)
|
- **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 豁免)、**真机核:切后台开切换器 → 快照卡片是遮罩非终端内容**。
|
- **Steps**: [ ] 对照 TECH_DOC §7 + 本文 §5 逐条核:Origin 单点派生、G/RO 分界、ATS 键 release 实况(拆 ipa 验 Info.plist——**五段 CIDR 逐段核对**,debug `NSAllowsArbitraryLoads` 会掩盖缺段)、隐私 usage description 与实际使用的 capability **一一对应**(相机/本地网络;P2 加麦克风/语音识别——拆 ipa 核对)、Keychain 属性(模拟器测试断言 `kSecAttrAccessible`)、ntfy payload 最小化、无硬编码 host/密钥、警告分层文案在位(公网阻断 + RFC1918 明文提示 + Tailscale 豁免)、**真机核:切后台开切换器 → 快照卡片是遮罩非终端内容**。
|
||||||
|
|
||||||
@@ -729,68 +839,70 @@ W5 验收(report-only, 并行)
|
|||||||
|
|
||||||
> 波次:**W6(服务器触点:T-iOS-20 ∥ T-iOS-37,串行入库各自 repo 流程)与 W7 并行开跑**——W7 里只有 T-iOS-21 依赖 T-iOS-20(payload 形状)、T-iOS-23 依赖 T-iOS-37(lastOutputAt 字段),其余 W7 任务(T-iOS-22/24/25/27/28/29)不等 W6;T-iOS-38(APIClient P1 契约增量)在 W7 首发,T-iOS-21/26 依赖它 → W8(验收)。任务粒度与 P0 同规格;此处 Steps(测试) 列关键用例,细化在开工时由任务 owner 补足(不改接口)。
|
> 波次:**W6(服务器触点:T-iOS-20 ∥ T-iOS-37,串行入库各自 repo 流程)与 W7 并行开跑**——W7 里只有 T-iOS-21 依赖 T-iOS-20(payload 形状)、T-iOS-23 依赖 T-iOS-37(lastOutputAt 字段),其余 W7 任务(T-iOS-22/24/25/27/28/29)不等 W6;T-iOS-38(APIClient P1 契约增量)在 W7 首发,T-iOS-21/26 依赖它 → W8(验收)。任务粒度与 P0 同规格;此处 Steps(测试) 列关键用例,细化在开工时由任务 owner 补足(不改接口)。
|
||||||
|
|
||||||
#### T-iOS-20 · server: APNs sender + token 注册端点 `[ ]` · ~2 pd
|
#### T-iOS-20 · server: APNs sender + token 注册端点 `[x]` · ~2 pd
|
||||||
|
- **落地现状**:`src/push/apns.ts` + `test/push-apns.test.ts`(含本地 h2c 假 APNs 的双 e2e);env 三件套缺失即整体 disabled、启动不 crash、密钥材料零日志。**DEFERRED**:对真 APNs 的端到端需付费账号 + `.p8`。
|
||||||
- **Wave**: W6 · **Owns**: `src/push/apns.ts`(新)、`src/server.ts` 增量 route、`test/push-apns.test.ts`(**服务器触点,TypeScript 任务**,遵循根仓库 PLAN 工作流)
|
- **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 外全部(接口先行)
|
- **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(测试先行)**: [ ] `.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/` 的事件订阅点
|
- **Steps(实现)**: [ ] HTTP/2 到 `api.push.apple.com`,`.p8` 走 env 路径(无硬编码密钥)[ ] 复用 `src/push/` 的事件订阅点
|
||||||
- **安全注**: capability token 语义不变(单次、过期、10/min 限频,src/server.ts:503-525);APNs payload 不含命令内容。
|
- **安全注**: capability token 语义不变(单次、过期、10/min 限频,src/server.ts:503-525);APNs payload 不含命令内容。
|
||||||
|
|
||||||
#### T-iOS-37 · server: `LiveSessionInfo.lastOutputAt` 字段 `[ ]` · ~0.25 pd
|
#### T-iOS-37 · server: `LiveSessionInfo.lastOutputAt` 字段 `[x]` · ~0.25 pd
|
||||||
- **Wave**: W6 · **Owns**: `src/types.ts` 的 `LiveSessionInfo` 增量字段、`src/session/manager.ts` `list()` 一行映射(并更新其 "omitted by design" 注释)、对应测试增量(**声明的服务器触点,TypeScript 任务**,遵循根仓库 PLAN 工作流;见 §0.3)
|
- **Wave**: W6 · **Owns**: `src/types.ts` 的 `LiveSessionInfo` 增量字段、`src/session/manager.ts` `list()` 一行映射(并更新其 "omitted by design" 注释)、对应测试增量(**声明的服务器触点,TypeScript 任务**,遵循根仓库 PLAN 工作流;见 §0.3)
|
||||||
- **Depends**: 无 · **Parallel-safe**: T-iOS-20、W7 全部
|
- **Depends**: 无 · **Parallel-safe**: T-iOS-20、W7 全部
|
||||||
- **Steps(测试先行)**: [ ] `GET /live-sessions` 响应含 `lastOutputAt`(服务器已逐 `pty.onData` 维护,src/types.ts:211/M3——只是序列化出来)[ ] 旧客户端兼容:字段为**新增可选**,web 前端不受影响
|
- **Steps(测试先行)**: [ ] `GET /live-sessions` 响应含 `lastOutputAt`(服务器已逐 `pty.onData` 维护,src/types.ts:211/M3——只是序列化出来)[ ] 旧客户端兼容:字段为**新增可选**,web 前端不受影响
|
||||||
- **Accept**: 根仓库 `npm test` 全绿;T-iOS-23 的 unread 水位有数据源
|
- **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 同波踩踏)
|
- **Wave**: W7(首发) · **Owns**: `ios/Packages/APIClient/**` 的 **全部 P1 增量**(APNs token 注册 builder、`/projects`、`/projects/detail?path=`、`GET/PUT /prefs` builders 与解码 + Tests)——W7 期间 APIClient 文件**只有本任务可改**(对齐 T-iOS-3 冻结契约模式,避免 T-iOS-21/26 同波踩踏)
|
||||||
- **Depends**: T-iOS-8;T-iOS-20(仅 token 注册 builder 的端点形状——可接口先行并行编码,形状定稿时汇合) · **Parallel-safe**: T-iOS-20/22/23/24/25/27/28/29(均不碰 APIClient 文件)
|
- **Depends**: T-iOS-8;T-iOS-20(仅 token 注册 builder 的端点形状——可接口先行并行编码,形状定稿时汇合) · **Parallel-safe**: T-iOS-20/22/23/24/25/27/28/29(均不碰 APIClient 文件)
|
||||||
- **Steps(测试先行)**: [ ] token 注册 builder(G,带 Origin)[ ] projects/detail/prefs builders(PUT 带 Origin)与解码 [ ] doc comment 端点约束:push subscribe body ≤ 8 KB、≤5 次/分/IP(src/server.ts:73,461-466);`PUT /prefs` ≤ 64 KB(src/server.ts:278)
|
- **Steps(测试先行)**: [ ] token 注册 builder(G,带 Origin)[ ] projects/detail/prefs builders(PUT 带 Origin)与解码 [ ] doc comment 端点约束:push subscribe body ≤ 8 KB、≤5 次/分/IP(src/server.ts:73,461-466);`PUT /prefs` ≤ 64 KB(src/server.ts:278)
|
||||||
- **Accept**: `swift test --package-path ios/Packages/APIClient` 全绿;覆盖率 ≥ 80%
|
- **Accept**: `swift test --package-path ios/Packages/APIClient` 全绿;覆盖率 ≥ 80%
|
||||||
|
|
||||||
#### T-iOS-21 · PushRegistrar + 锁屏 Allow/Deny `[ ]` · ~1.5 pd
|
#### T-iOS-21 · PushRegistrar + 锁屏 Allow/Deny `[x]` · ~1.5 pd
|
||||||
|
- **落地现状**:`Push/{PushRegistrar,NotificationActionHandler}.swift` + 三组 Tests;`handleHostRemoved` 在收尾波接上真调用点(`Wiring/AppEnvironment.swift`)。**DEFERRED**:真机锁屏 Allow + Face ID 走查;`aps-environment` 需付费 team(`WEBTERM_PUSH_ENTITLEMENTS` 开关,见 `ios/README.md`)。
|
||||||
- **Wave**: W7 · **Owns**: `App/WebTerm/Push/{PushRegistrar,NotificationActionHandler}.swift` + 对应 Tests(APIClient 的 token 注册 builder 归 T-iOS-38)
|
- **Wave**: W7 · **Owns**: `App/WebTerm/Push/{PushRegistrar,NotificationActionHandler}.swift` + 对应 Tests(APIClient 的 token 注册 builder 归 T-iOS-38)
|
||||||
- **Depends**: T-iOS-20(payload 形状)、T-iOS-38(token 注册 builder) · **Parallel-safe**: T-iOS-22–29
|
- **Depends**: T-iOS-20(payload 形状)、T-iOS-38(token 注册 builder) · **Parallel-safe**: T-iOS-22–29
|
||||||
- **Steps(测试先行)**: [ ] `UNNotificationCategory` Allow/Deny 注册形状——**Allow 动作必须带 `UNNotificationActionOptions.authenticationRequired`**(锁屏批准 = 授权主机执行命令;旁观者拿到锁屏手机只能 Deny——fail-safe。断言注册 category 的 Allow 选项含 `.authenticationRequired`,且两动作均**不含** `.foreground`)[ ] action handler:从 payload 取 `{sessionId, token}` → `POST /hook/decision`(带 Origin)→ **不启动 App UI**(Face ID 设备上"两次手势 + 一瞥"闭环)[ ] token 用后即弃不落盘 [ ] 决策失败(token 过期 403)→ 补一条本地通知提示进 App 处理
|
- **Steps(测试先行)**: [ ] `UNNotificationCategory` Allow/Deny 注册形状——**Allow 动作必须带 `UNNotificationActionOptions.authenticationRequired`**(锁屏批准 = 授权主机执行命令;旁观者拿到锁屏手机只能 Deny——fail-safe。断言注册 category 的 Allow 选项含 `.authenticationRequired`,且两动作均**不含** `.foreground`)[ ] action handler:从 payload 取 `{sessionId, token}` → `POST /hook/decision`(带 Origin)→ **不启动 App UI**(Face ID 设备上"两次手势 + 一瞥"闭环)[ ] token 用后即弃不落盘 [ ] 决策失败(token 过期 403)→ 补一条本地通知提示进 App 处理
|
||||||
- **安全注**: Allow/Deny 由系统**后台拉起主 App**、送达 `UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:)`——**无 extension 参与**(本工程没有 notification extension target;Service Extension 只能改写来押通知、收不到 action tap)。handler 用 `beginBackgroundTask/endBackgroundTask` 包住 POST,请求完成/失败后才调 completion handler;失败必须可见(本地通知兜底),绝不静默吞。
|
- **安全注**: Allow/Deny 由系统**后台拉起主 App**、送达 `UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:)`——**无 extension 参与**(本工程没有 notification extension target;Service Extension 只能改写来押通知、收不到 action tap)。handler 用 `beginBackgroundTask/endBackgroundTask` 包住 POST,请求完成/失败后才调 completion handler;失败必须可见(本地通知兜底),绝不静默吞。
|
||||||
|
|
||||||
#### T-iOS-22 · DeepLinkRouter `[ ]` · ~1 pd
|
#### T-iOS-22 · DeepLinkRouter `[x]` · ~1 pd
|
||||||
- **Wave**: W7 · **Owns**: `App/WebTerm/DeepLinkRouter.swift` + Tests
|
- **Wave**: W7 · **Owns**: `App/WebTerm/DeepLinkRouter.swift` + Tests
|
||||||
- **Steps(测试先行)**: [ ] `webterminal://open?host=<id>&join=<uuid>`:UUID v4 校验(复用 `Validation`),非法 → 忽略并留日志 [ ] 未知 host id → 落到配对页并提示 [ ] 冷/热启动两路径都直达 gated 会话 [ ] push tap → 同一路由
|
- **Steps(测试先行)**: [ ] `webterminal://open?host=<id>&join=<uuid>`:UUID v4 校验(复用 `Validation`),非法 → 忽略并留日志 [ ] 未知 host id → 落到配对页并提示 [ ] 冷/热启动两路径都直达 gated 会话 [ ] push tap → 同一路由
|
||||||
- **安全注**: deep link 是外部输入——全字段白名单校验,绝不据此直接拼 URL 请求。
|
- **安全注**: 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
|
- **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
|
- **Depends**: T-iOS-37(`lastOutputAt` 字段) · **Parallel-safe**: T-iOS-21/22/24/25/26/27/28
|
||||||
- **Steps(测试先行)**: [ ] 单活 WS 不变:切会话 = close→open,回放恢复 [ ] unread 判定:`/live-sessions` 快照的 `lastOutputAt`(T-iOS-37 新增字段)> 本地 last-seen 水位 → unread 点 [ ] OSC 标题经 SwiftTerm `setTerminalTitle` delegate 上浮到列表——**标题是主机/攻击者可控输入,入列表前过净化器**:截断 `Tunables.titleMaxLength`(256);剥 Unicode 双向覆写与零宽字符(U+200B–200F、U+202A–202E、U+2066–2069——OSC 字符串解析已排除 C0,真正的仿冒向量是 bidi/零宽);渲染用 `Text(verbatim:)`(绝不走 LocalizedStringKey/Markdown)+ `.lineLimit(1)` 截断 [ ] 敌意标题解码测试:超长、U+202E payload、emoji 洪泛 → 断言净化输出 [ ] 切换 <1s 观感(回放解析在后台、feed 在 main)
|
- **Steps(测试先行)**: [ ] 单活 WS 不变:切会话 = close→open,回放恢复 [ ] unread 判定:`/live-sessions` 快照的 `lastOutputAt`(T-iOS-37 新增字段)> 本地 last-seen 水位 → unread 点 [ ] OSC 标题经 SwiftTerm `setTerminalTitle` delegate 上浮到列表——**标题是主机/攻击者可控输入,入列表前过净化器**:截断 `Tunables.titleMaxLength`(256);剥 Unicode 双向覆写与零宽字符(U+200B–200F、U+202A–202E、U+2066–2069——OSC 字符串解析已排除 C0,真正的仿冒向量是 bidi/零宽);渲染用 `Text(verbatim:)`(绝不走 LocalizedStringKey/Markdown)+ `.lineLimit(1)` 截断 [ ] 敌意标题解码测试:超长、U+202E payload、emoji 洪泛 → 断言净化输出 [ ] 切换 <1s 观感(回放解析在后台、feed 在 main)
|
||||||
|
|
||||||
#### T-iOS-24 · Timeline sheet(完整时间线钻取)`[ ]` · ~1 pd
|
#### T-iOS-24 · Timeline sheet(完整时间线钻取)`[x]` · ~1 pd
|
||||||
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/TimelineSheet.swift` + VM 测试
|
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/TimelineSheet.swift` + VM 测试
|
||||||
- **Steps(测试先行)**: [ ] `/live-sessions/:id/events` 全量渲染(class → 图标/颜色映射)[ ] timeline disabled(空数组)→ 空态而非错误 [ ] 从 digest "展开"入口进入
|
- **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
|
- **Wave**: W7 · **Owns**: `App/WebTerm/Components/QuickReply.swift`、本地存储(UserDefaults)+ Tests
|
||||||
- **Steps(测试先行)**: [ ] chip 点击 → `input` 帧(文本 + `\r`)[ ] 自定义面板增删改序 [ ] waiting 状态才浮出(对齐 web `quick-reply.ts` 行为)
|
- **Steps(测试先行)**: [ ] chip 点击 → `input` 帧(文本 + `\r`)[ ] 自定义面板增删改序 [ ] waiting 状态才浮出(对齐 web `quick-reply.ts` 行为)
|
||||||
|
|
||||||
#### T-iOS-26 · Projects:列表 + 详情 + 在仓库起 Claude `[ ]` · ~2 pd
|
#### T-iOS-26 · Projects:列表 + 详情 + 在仓库起 Claude `[x]` · ~2 pd
|
||||||
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/{ProjectsScreen,ProjectDetailScreen}.swift` + VM Tests(projects/detail/prefs 的 APIClient builders 归 T-iOS-38,本任务只消费)
|
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/{ProjectsScreen,ProjectDetailScreen}.swift` + VM Tests(projects/detail/prefs 的 APIClient builders 归 T-iOS-38,本任务只消费)
|
||||||
- **Depends**: T-iOS-38(builders) · **Parallel-safe**: T-iOS-21/22/23/24/25/27/28/29
|
- **Depends**: T-iOS-38(builders) · **Parallel-safe**: T-iOS-21/22/23/24/25/27/28/29
|
||||||
- **Steps(测试先行)**: [ ] VM 消费 `/projects`、`/projects/detail?path=`、`GET/PUT /prefs`(builder 与解码测试在 T-iOS-38)[ ] favourites 同步 [ ] "在此仓库开新会话" = `attach(null, cwd)` + 注入 `claude\r` [ ] detail 的 400/404/500 `{error}` 显式路径
|
- **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 测试
|
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/DiffScreen.swift` + VM 测试
|
||||||
- **Steps(测试先行)**: [ ] `DiffResult{files,staged,truncated}` 渲染、truncated 提示 [ ] staged/unstaged 切换 [ ] path 非法 404 → 友好错误
|
- **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` + 快照测试
|
- **Wave**: W7 · **Owns**: `App/WebTerm/Components/SessionThumbnail.swift` + 快照测试
|
||||||
- **Steps(测试先行)**: [ ] `GET /live-sessions/:id/preview`(`{id,cols,rows,data}`,24KB tail)→ 离屏 TerminalView feed → 快照图 [ ] 列表滚动不掉帧(离屏渲染限并发)[ ] 404 → 占位图
|
- **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)
|
- **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
|
- **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 语义)
|
- **Steps(测试先行)**: [ ] "在当前会话 cwd 开新会话"(`attach(null, cwd)`)[ ] exited 会话点开 → 回放 + exit 横幅 + "开新会话"动作(src/session/manager.ts:145-153 语义)
|
||||||
|
|
||||||
#### T-iOS-30 · P1 验收 + 安全复核 `[ ]` · ~1 pd(report-only)
|
#### T-iOS-30 · P1 验收 + 安全复核 `[x]` · ~1 pd(report-only)
|
||||||
- **Steps**: [ ] F-iOS-14/15(§9)真机走查 [ ] 安全:APNs payload 审计、token 生命周期、deep link fuzz、通知在锁屏的预览泄露面(默认隐藏内容验证)、**锁屏 Allow 动作必须 `.authenticationRequired`(真机验:锁屏 Allow 弹 Face ID/通行码;Deny 无需解锁)**。
|
- **Steps**: [ ] F-iOS-14/15(§9)真机走查 [ ] 安全:APNs payload 审计、token 生命周期、deep link fuzz、通知在锁屏的预览泄露面(默认隐藏内容验证)、**锁屏 Allow 动作必须 `.authenticationRequired`(真机验:锁屏 Allow 弹 Face ID/通行码;Deny 无需解锁)**。
|
||||||
|
|
||||||
**P1 合计 ≈ 17.5 人天**(含新增 T-iOS-37 0.25 + T-iOS-38 0.5;T-iOS-21/26 相应减负)。
|
**P1 合计 ≈ 17.5 人天**(含新增 T-iOS-37 0.25 + T-iOS-38 0.5;T-iOS-21/26 相应减负)。
|
||||||
@@ -800,13 +912,25 @@ W5 验收(report-only, 并行)
|
|||||||
### P2 — 打磨 · 合计 ~8 人天
|
### P2 — 打磨 · 合计 ~8 人天
|
||||||
|
|
||||||
> P2 任务此处只给**分派必需元数据**(Wave/Owns/Depends/Accept);完整 RED 测试清单在开工时由任务 owner 按 P0 规格扩写(不改 §3 接口)。**未扩写前不得按下述描述直接分派**(§4 TDD 强制与 §6 Owns 铁律同样适用)。
|
> P2 任务此处只给**分派必需元数据**(Wave/Owns/Depends/Accept);完整 RED 测试清单在开工时由任务 owner 按 P0 规格扩写(不改 §3 接口)。**未扩写前不得按下述描述直接分派**(§4 TDD 强制与 §6 Owns 铁律同样适用)。
|
||||||
|
> **该前置已履行**:T-iOS-31/32/33/34/35 的逐条 RED 清单由各 builder 开工第一步写进
|
||||||
|
> [`docs/plans/ios-completion.md`](./plans/ios-completion.md) §4(共 100+ 条,含 T-iOS-32 的 35 条、
|
||||||
|
> T-iOS-33 的 18 条、T-iOS-31 的 38 条、T-iOS-34 的 29 条、T-iOS-35 的 21 条),再进 GREEN。
|
||||||
|
|
||||||
- **T-iOS-31** · 语音 PTT + 确认(端口匹配器 / 1.5s 撤销 / epoch 防误发)`[ ]` ~2.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/VoicePTT.swift` + VM Tests(epoch 防误发若需 SessionCore 新接口 → 经 T-iOS-6 owner 回 SessionCore 加,不直改)· **Depends**: T-iOS-6/11;**前置**:Info.plist 加 `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`(§5.2 注)· **Accept**: VM 测试 + 真机口述→确认→注入 input。
|
- **T-iOS-31** · 语音 PTT + 确认(端口匹配器 / 1.5s 撤销 / epoch 防误发)`[x]` ~2.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/VoicePTT.swift` + VM Tests(epoch 防误发若需 SessionCore 新接口 → 经 T-iOS-6 owner 回 SessionCore 加,不直改)· **Depends**: T-iOS-6/11;**前置**:Info.plist 加 `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`(§5.2 注)· **Accept**: VM 测试 + 真机口述→确认→注入 input。
|
||||||
- **T-iOS-32** · Worktree 创建(`POST /projects/worktree`,G)+ `claude --resume <id>` 历史(`GET /sessions`)`[ ]` ~1.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Screens/WorktreeSheet.swift` + Tests(APIClient builders 经 T-iOS-38 owner 模式回 APIClient 加)· **Depends**: T-iOS-26/38 · **Accept**: builder 测试 + 端到端一次。
|
- **落地**: `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 末位 🎤 键(前 17 键顺序/标签零变化)+ `VoicePTTTests` 40 测(转写清洗 / 匹配器 / 1.5s 撤销窗 / 双道 epoch 闸 / 权限失败路径 / 键栏零回归)。注入内容**不以 `\r` 结尾**,确认前零注入。
|
||||||
- **T-iOS-33** · 终端内搜索 `[ ]` ~1 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/TerminalSearchBar.swift` + Tests · **Depends**: T-iOS-11 · **Accept**: SwiftTerm search API 命中高亮。
|
- **DEFERRED**: 真机口述→确认→注入(需真机麦克风与语音识别授权)。
|
||||||
- **T-iOS-34** · 主题 + Dynamic Type `[ ]` ~1.5 pd。**Wave**: W9 · **Owns**: 主题/字号小增量(与同波任务文件不相交,开工时列明文件清单)· **Depends**: T-iOS-11/13 · **Accept**: 亮暗主题 + 最大字号不破版。
|
- **T-iOS-32** · Worktree 创建(`POST /projects/worktree`,G)+ `claude --resume <id>` 历史(`GET /sessions`)`[~]` ~1.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Screens/WorktreeSheet.swift` + Tests(APIClient builders 经 T-iOS-38 owner 模式回 APIClient 加)· **Depends**: T-iOS-26/38 · **Accept**: builder 测试 + 端到端一次。
|
||||||
- **T-iOS-35** · web `?join=` 互通(分享 QR 双向)`[ ]` ~0.5 pd。**Wave**: W9 · **Owns**: `DeepLinkRouter.swift` 增量(`?join=` 解析)· **Depends**: T-iOS-22 · **Accept**: 手机扫 web 分享 QR 直达同会话。
|
- **落地**: `Screens/{WorktreeSheet,ResumeHistorySheet}.swift` + `ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift`;分支名 9 条规则客户端先校验(非法名零请求)、prune 幂等文案、remove 两级确认(409→显式 force 确认,绝不自动重试)、`--resume` id 白名单后才拼命令行;`WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)。
|
||||||
- **T-iOS-36** · P2 验收 `[ ]` ~1 pd(report-only)。**Wave**: W10 · **Owns**: 无源码 · **Depends**: T-iOS-31–35。
|
- **未做**: Accept 里的"端到端一次"(对真服务器真建/真删一个 worktree)没有自动化腿,也未在本环境手工跑过。
|
||||||
|
- **T-iOS-33** · 终端内搜索 `[x]` ~1 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/TerminalSearchBar.swift` + Tests · **Depends**: T-iOS-11 · **Accept**: SwiftTerm search API 命中高亮。
|
||||||
|
- **T-iOS-34** · 主题 + Dynamic Type `[~]` ~1.5 pd。**Wave**: W9 · **Owns**: 主题/字号小增量(与同波任务文件不相交,开工时列明文件清单)· **Depends**: T-iOS-11/13 · **Accept**: 亮暗主题 + 最大字号不破版。
|
||||||
|
- **落地**: `DesignSystem/{AppTheme,TerminalPalette}.swift` + `Screens/SettingsScreen.swift`(齿轮入口在 `ProjectsToolbarItem` 内,stack 与 split 两根视图共享)+ Tokens/Typography 浅色档;`AppThemeTests`(24)/`DynamicTypeLayoutTests`(8)。默认仍是深色(零回归)。
|
||||||
|
- **缺口已闭合(收尾波,2026-07-30)**: `KeyBarMetrics.barHeight` 从常量 52pt 改为 `max(minHitTarget, keycapHeight(category)) + sm8`,`intrinsicContentSize`/init frame/`apply(contentSizeCategory:)` 三处同源,并经 iOS 17 `registerForTraitChanges` 支持运行期改档。`withKnownIssue` 已删。**取舍**:键帽字体封顶 `.accessibility2`(不封顶时 AX5 需 108.24pt,会把终端吃掉),沿用 DS 对密集内容的既有策略;实测条高 XS–XL 52 / XXL 55 / XXXL 59 / AccM 68 / AccL–AX5 77。
|
||||||
|
- **T-iOS-35** · web `?join=` 互通(分享 QR 双向)`[x]` ~0.5 pd。**Wave**: W9 · **Owns**: `DeepLinkRouter.swift` 增量(`?join=` 解析)· **Depends**: T-iOS-22 · **Accept**: 手机扫 web 分享 QR 直达同会话。
|
||||||
|
- **落地**: `DeepLinkRouter` 增 `.joinShared` 分支 + `DeepLinkJoinTests` 19 测(含把原"scheme 不是 webterminal"的拒绝理由改写为"web 形状只许单一 `join` 键");主机身份只经 `HostStore`+`HostEndpoint.originHeader` 解析,未配对 origin 一律走配对页且 hint 不回显链接内容。
|
||||||
|
- **DEFERRED**: 用真手机相机扫 web 分享 QR 的目视走查(模拟器无相机)。
|
||||||
|
- **T-iOS-36** · P2 验收 `[~]` ~1 pd(report-only)。**Wave**: W10 · **Owns**: 无源码 · **Depends**: T-iOS-31–35。
|
||||||
|
- **缺口**: 由 ios-completion 收尾波的 Wave D 验收 agent 执行中;**真实数字与结论以 `PROGRESS_LOG.md` 的该条目为准**,本文不预写"通过"。
|
||||||
|
|
||||||
**总计:P0 13 + P1 17.5 + P2 8 ≈ 38.5 人天。**
|
**总计: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 结构。
|
每任务:**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
|
```bash
|
||||||
for p in WireProtocol SessionCore HostRegistry APIClient; do
|
for p in WireProtocol SessionCore HostRegistry APIClient; do
|
||||||
@@ -931,9 +1063,16 @@ XCUITest 只保一条 happy path:配对→attach→输入→gate approve(脆
|
|||||||
| 单 WS 设计与多会话切换器(P1)的张力 | 低 | 切换 = 重放恢复,成本近零;若实测不适再评估观察者 WS |
|
| 单 WS 设计与多会话切换器(P1)的张力 | 低 | 切换 = 重放恢复,成本近零;若实测不适再评估观察者 WS |
|
||||||
|
|
||||||
**待你拍板的开放项**:
|
**待你拍板的开放项**:
|
||||||
1. Bundle id / 产品名 / 图标(App 叫什么?`webterminal://` scheme 是否可用/要改名?)
|
1. ~~Bundle id / 产品名 / 图标~~ **已定**:`com.yaojia.webterm` / WebTerm / "Orbit" 图标(`project.yml`;
|
||||||
2. Apple 付费开发者账号($99/年)何时开——决定 P1 APNs 与 TestFlight 分发起点;P0 期间接受自签 sideload?
|
deep-link scheme `webterminal://` 已注册并在用,另接受 web 的 `http(s)://…/?join=` 形状,T-iOS-35)。
|
||||||
3. 最低系统版本:iOS 17(可分发下限)还是 18/26(个人工具,availability 噪音最小)?
|
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 只验证/写文档。
|
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)。
|
5. ~~Tailscale 场景是否直接推荐 `tailscale serve`~~ **已定**:推荐 `tailscale serve`(wss)为标准部署话术(配对 UI/README 采用;绕开全部 ATS 例外与明文嗅探面,见 §5.4)。
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
# PLAN_IOS_IPAD.md — iPad 适配(自适应布局,非分叉)
|
# 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 行为字节级不变。
|
> 拓扑决策:**单一代码库 + size-class 自适应**——`NavigationSplitView` 在 regular 宽度(iPad 全屏/大分屏)给 sidebar+detail,在 compact 宽度(iPhone、iPad Slide Over/小分屏)**自动退化为现有 stack**。iPhone 行为字节级不变。
|
||||||
> 状态:**规划中(2026-07-05,未开工)**。
|
> 状态:**已交付并合入 `develop`**(T-iPad-1…4 全部落地;T-iPad-5 验收 PASS_WITH_FINDINGS,4/4 findings 已修,真机项 DEFERRED)。
|
||||||
|
> 逐任务状态见 §5 各任务标题上的方框;**Steps 里的 `[ ]` 是原始规格清单,不是状态**(执行记录在 `PROGRESS_LOG.md`)。
|
||||||
|
> 2026-07-30 由 ios-completion 收尾波按源码核对:`Wiring/{AdaptiveRootView,SplitRootView,LayoutMode}.swift`、
|
||||||
|
> `Components/TerminalContextMenu.swift`、`Screens/ProjectsLayout.swift` 与 `LayoutPolicyTests`/`SidebarSelectionTests`/
|
||||||
|
> `KeyBarVisibilityTests`/`TerminalContextMenuTests`/`ProjectsLayoutTests`/`ProjectsLayoutUITests` 均在树内;
|
||||||
|
> `project.yml` 三个 target 的 `TARGETED_DEVICE_FAMILY` 均为 `"1,2"`;`ios.yml` 有 iPad 单测腿与 iPad UI-test 腿。
|
||||||
> 本文是 iPhone 计划之上的**布局适配层**,不改协议/会话模型/纯逻辑包;沿用 [PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md) 的 §3 契约、§4 工程标准、§5 安全模型、§6 并行规则。冲突以 iPhone 计划为准。
|
> 本文是 iPhone 计划之上的**布局适配层**,不改协议/会话模型/纯逻辑包;沿用 [PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md) 的 §3 契约、§4 工程标准、§5 安全模型、§6 并行规则。冲突以 iPhone 计划为准。
|
||||||
> **G1 日志铁律**:`PROGRESS_LOG.md` 由 orchestrator 独写;被派 subagent 不写 LOG,在返回消息末尾附可粘贴条目。
|
> **G1 日志铁律**:`PROGRESS_LOG.md` 由 orchestrator 独写;被派 subagent 不写 LOG,在返回消息末尾附可粘贴条目。
|
||||||
|
|
||||||
@@ -118,7 +123,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
|
|||||||
|
|
||||||
### W0 · 可安装性(串行,先行)
|
### W0 · 可安装性(串行,先行)
|
||||||
|
|
||||||
#### T-iPad-1 · device family + iPad plist/方向 `[ ]` · ~0.5 pd
|
#### T-iPad-1 · device family + iPad plist/方向 `[x]` · ~0.5 pd
|
||||||
- **Owns**: `ios/project.yml`(device family、iPad 方向、必要 plist)、`.github/workflows/ios.yml`(加 iPad 模拟器测试腿)
|
- **Owns**: `ios/project.yml`(device family、iPad 方向、必要 plist)、`.github/workflows/ios.yml`(加 iPad 模拟器测试腿)
|
||||||
- **Depends**: 无
|
- **Depends**: 无
|
||||||
- **Steps(测试先行)**:
|
- **Steps(测试先行)**:
|
||||||
@@ -132,7 +137,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
|
|||||||
|
|
||||||
### W1 · 自适应导航壳(核心)
|
### 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 选中 ↔ 路由最小桥)、对应测试
|
- **Owns**: `Wiring/{AdaptiveRootView,SplitRootView,LayoutMode}.swift`(新)、`Wiring/RootView.swift`(改:现有 stack 抽成 `StackRootView` 子视图供 compact 复用)、`Wiring/AppCoordinator.swift`(改:sidebar 选中 ↔ 路由最小桥)、对应测试
|
||||||
- **Depends**: T-iPad-1
|
- **Depends**: T-iPad-1
|
||||||
- **Steps(测试先行, RED)** — `LayoutPolicyTests` + `SidebarSelectionTests`:
|
- **Steps(测试先行, RED)** — `LayoutPolicyTests` + `SidebarSelectionTests`:
|
||||||
@@ -149,7 +154,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
|
|||||||
|
|
||||||
### W2 · 逐面适配(并行,文件互斥)
|
### W2 · 逐面适配(并行,文件互斥)
|
||||||
|
|
||||||
#### T-iPad-3 · 终端面板:KeyBar 自适应 + 指针上下文菜单 `[ ]` · ~1 pd
|
#### T-iPad-3 · 终端面板:KeyBar 自适应 + 指针上下文菜单 `[x]` · ~1 pd
|
||||||
- **Owns**: `Components/{KeyBar,TerminalContextMenu}.swift`、`Screens/TerminalScreen.swift`(增量)、测试
|
- **Owns**: `Components/{KeyBar,TerminalContextMenu}.swift`、`Screens/TerminalScreen.swift`(增量)、测试
|
||||||
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-4
|
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-4
|
||||||
- **Steps(测试先行)**:
|
- **Steps(测试先行)**:
|
||||||
@@ -159,7 +164,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
|
|||||||
- **Accept**: iPad 接键盘时 KeyBar 自动隐、去键盘复现;右键菜单在 iPad 生效;iPhone 无硬件键盘时 KeyBar 行为不变
|
- **Accept**: iPad 接键盘时 KeyBar 自动隐、去键盘复现;右键菜单在 iPad 生效;iPhone 无硬件键盘时 KeyBar 行为不变
|
||||||
- **安全注**: 上下文菜单的 kill/开会话是既有 G/RO 通道的又一触发面——测试名标「复用 APIClient,无绕过」
|
- **安全注**: 上下文菜单的 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)、测试
|
- **Owns**: `Screens/ProjectsScreen.swift`(增量)、Projects/Timeline 呈现方式(regular 下 `.sheet` → 列/`.presentationDetents` 或 sidebar section)、测试
|
||||||
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-3
|
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-3
|
||||||
- **Steps(测试先行)**:
|
- **Steps(测试先行)**:
|
||||||
@@ -170,7 +175,9 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
|
|||||||
|
|
||||||
### W3 · 验收(report-only, G4)
|
### W3 · 验收(report-only, G4)
|
||||||
|
|
||||||
#### T-iPad-5 · iPad 验收 + iPhone 回归 + 安全核对 `[ ]` · ~0.5 pd
|
#### T-iPad-5 · iPad 验收 + iPhone 回归 + 安全核对 `[~]` · ~0.5 pd
|
||||||
|
- **已执行**:模拟器侧 PASS_WITH_FINDINGS(iPhone 16 / iPad Pro 11 双套件绿,iPhone 零回归硬门守住),4 个 findings(2 MED / 2 LOW)由 orchestrator 修完 4/4;iPad 分栏 sidebar+detail 截图确认。
|
||||||
|
- **缺口**:① 真机 iPad(分栏手势 / 硬件键盘全键位 / 指针 hover 右键 / Stage Manager)**DEFERRED**,手工清单在 `PROGRESS_LOG.md`;② iPad happy-path XCUITest **接受推迟**(split 选中逻辑已由 `SidebarSelectionTests` 覆盖,单跑 7–11 min 且脆);③ **release ipa 层**核对未做(免费个人 team 无分发通道)——与 T-iOS-19 同一缺口,且 P2 新增的麦克风/语音识别 usage description 也应一并核。
|
||||||
- **Depends**: T-iPad-2/3/4 · **Owns**: 无源码(report-only,findings 派回 owner)
|
- **Depends**: T-iPad-2/3/4 · **Owns**: 无源码(report-only,findings 派回 owner)
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
- [ ] **F-iPad 走查**(§6 清单)逐条:分栏、横竖屏、Split View/Slide Over/Stage Manager 尺寸切换、硬件键盘/指针、终端更宽列数、遮罩覆盖 detail
|
- [ ] **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 已测路径;真机目视 |
|
| SwiftTerm 在超宽 detail 的性能/选择手感 | 低 | 复用 iPhone 已测路径;真机目视 |
|
||||||
| 多窗口诱惑导致 scope 膨胀 | 中 | 本期明确单场景(§0 非目标);多窗口另立计划 |
|
| 多窗口诱惑导致 scope 膨胀 | 中 | 本期明确单场景(§0 非目标);多窗口另立计划 |
|
||||||
|
|
||||||
**待你拍板**:
|
**待你拍板** —— 三项均已落定(2026-07-30 按源码核对):
|
||||||
1. iPad 最低系统版本:iPadOS 17(与 iPhone 一致)还是抬到 18/26?
|
1. ~~iPad 最低系统版本~~ **已定**:iPadOS **17**,与 iPhone 一致(`project.yml` 单一 `deploymentTarget: iOS 17.0`,无独立 iPad 下限)。
|
||||||
2. 本期是否要指针右键上下文菜单(T-iPad-3 后半)——纯锦上添花,可砍到后续。
|
2. ~~本期是否要指针右键上下文菜单~~ **已做**:`Components/TerminalContextMenu.swift`(复制选区 / 在 cwd 开新会话 / 结束会话,全部路由既有通道,kill 仍经 APIClient 带 Origin)+ `TerminalContextMenuTests`。
|
||||||
3. 多窗口(拖会话开新窗并排两终端)确认放到下一期?
|
3. ~~多窗口~~ **确认推迟**:本期单场景(§0 非目标);拖会话开新窗并排两终端仍未开工,另立计划。
|
||||||
|
|
||||||
**工作量合计**:W0 0.5 + W1 2 + W2(3∥4)2 + W3 0.5 ≈ **5 人日**(并行后墙钟更短)。
|
**工作量合计**:W0 0.5 + W1 2 + W2(3∥4)2 + W3 0.5 ≈ **5 人日**(并行后墙钟更短)。
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,42 @@
|
|||||||
|
|
||||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||||
|
|
||||||
|
### 📱 [x] iOS 收尾波 — 6 项整改 + P2 全波 + 两端令牌(2026-07-30,worktree `ios-completion`)
|
||||||
|
|
||||||
|
- **起因**: 先做了一次 16-agent 的 iOS 完成度审计(2026-07-29),结论是"代码层面基本完工、交付层面卡在真机门口":43 个计划任务 27 DONE / 11 PARTIAL / 5 MISSING,**从未在任何真机上跑过一次**,且已落后服务端两个月的新功能。用户要求"全部实现",并定了三个范围问题:P2 全 5 项在内、Apple 账号是**免费个人 team**、Android 令牌一起补。
|
||||||
|
- **编排**: 契约先冻结在 [`docs/plans/ios-completion.md`](./plans/ios-completion.md) §1(orchestrator 亲自核服务端源码),再派 3 个 Workflow 共 22 个 agent。**A 串行 → B 并行 5 → C 串行 4 → D 并行 3 → E 条件 → 收尾波 4**。C 波刻意串行:新增 `.swift` 必须 `xcodegen generate` 重写**共享的** `.xcodeproj`,并行必互踩。
|
||||||
|
- 中途整个 C+D 波(7 个 agent)被 provider 侧 `529 Overloaded`/`ECONNRESET` 打挂;`resumeFromRunId` 把 A/B 从缓存回放、只重跑失败的,零返工。
|
||||||
|
- **冻结契约(§1.1,踩过的坑都在里面)**: cookie 名 `webterm_auth`;`POST /auth` 体 `{"token":"…"}`;**`Accept` 头不能含 `text/html`**——含了会被当表单走 302 而不是 204/401;**204 但无 `Set-Cookie` = 服务端根本没开鉴权**,绝不能据此认为"已认证";原生客户端**自己手写 `Cookie` 头**,不解析 `Set-Cookie`、不用系统 cookie jar(URLSession/OkHttp 的 jar 在 WS upgrade 上行为不一致且难测);**令牌不替代 Origin**,两者都要带。
|
||||||
|
- **真机构建解锁(最高杠杆,一次关掉 7 项验收阻塞)**: `DEVELOPMENT_TEAM=C738Z66SRW` + **target 级** `CODE_SIGN_IDENTITY`(废弃串 `"iPhone Developer"` 是 XcodeGen 的 product-type preset 在 target 层注入的,project 级怎么写都盖不住)→ 免费个人 team 上 `** BUILD SUCCEEDED **`,`-allowProvisioningUpdates` 现场签发 7 天 profile。**`aps-environment` 做成 env 开关且默认不挂**(`WEBTERM_PUSH_ENTITLEMENTS`),并**反向验证过必要性**:打开后真机构建报 `Personal development teams … do not support the Push Notifications capability`。
|
||||||
|
- **顺手挖出一个会卡死全波的坑**: SwiftTerm 用浮动版本 `from: 1.13.0`,而 `.xcodeproj` 是 gitignore 的 → **任何人一 `xcodegen generate` 就解析到 1.15.0**,它在 1.14.0 新增的 `public var hasActiveSelection` 与 `TerminalScreen.swift` 本地同名属性冲突,且**加 `override` 修不了**(上游是 `public` 不是 `open`)。A1 先钉死版本止血,C3 删掉本地属性改用上游的、再抬到 1.15.0 根治。
|
||||||
|
- **交付**(全部实测,非引用):
|
||||||
|
|
||||||
|
| | 之前 | 之后 |
|
||||||
|
|---|---|---|
|
||||||
|
| 包测试 | 310 | **452** |
|
||||||
|
| App 测试 | 296(1 失败) | **550**(iPhone + iPad 各一遍,**0 known issue**) |
|
||||||
|
| 集成测试 | 10 | **32** |
|
||||||
|
| Android 测试 | 687 | **691** |
|
||||||
|
| ClientTLS 覆盖率 | 55.76%(**不在门内**) | **89.49%**(已入门) |
|
||||||
|
| 覆盖率门 | 4 个包 | **5 个包全过**(最低余量 +9.49pp) |
|
||||||
|
| 真机构建 | `BUILD FAILED` | **`BUILD SUCCEEDED`** |
|
||||||
|
|
||||||
|
- **令牌**: 两端全链路(APIClient `/auth` 探针 + Keychain/Keystore 存储 + WS upgrade 带 Cookie + 401 终态不进退避环)。
|
||||||
|
- **git 面板 parity**: iOS 补齐 `/projects/{log,pr,git/*,worktree*}` + `/sessions` + 队列——这是与 Android 和 web 差了两个月的那一块。
|
||||||
|
- **P2 全 5 项**: 语音 PTT(epoch 防误发)、终端内搜索、worktree 生命周期、主题 + Dynamic Type、web `?join=` 互通。
|
||||||
|
- **CI**: 修好 iOS 三条腿(缺 `npm ci` 会**硬失败**不是 skip / 缺 iPad UI 腿 / iOS 17 缺 runtime 时**静默报绿**),新建 `android.yml`(此前 Android 零 CI)。
|
||||||
|
- **复核抓到并已修的 2 个 HIGH**: ① iOS 的 WS 令牌是**与主机无关**地解析的,混合机群(一台开令牌一台没开)下令牌门主机永远开不了终端,且 App 内无解 → 改为每主机一个 transport;② Android 把主机自身的 git 凭据 401(`src/http/git-ops.ts:108`)误报成"你的访问令牌错了",因为通用 401 映射跑在路由映射前面 → git 写路由标 `ROUTE_DEFINED`,保住服务端原文案。
|
||||||
|
- **orchestrator 的两个裁定**: ① 令牌策略在 iOS 有 3 份、Android 1 份,**不解冻 `WireProtocol` 去收敛**(它是冻结的跨语言契约,共享密钥的 helper 不该进去),改为在 `IntegrationTests` 加漂移守卫——它是全树唯一能同时依赖三个包的目标;② `PairingProbe.swift` 越界改动是我特批的(B1 已收工释放该包),复核把它记为 LOW,已知悉。
|
||||||
|
- **诚实的未闭合项**: release ipa 层核对(免费 team 无分发通道);真机人工腿(口述 / 扫码 / 锁屏 Face ID / IME);APNs 端到端(需付费账号);**两个 workflow 从未在任何 runner 上跑过**——本仓库唯一 remote 是自建 Gitea,GH Actions 从来没执行过,本地已逐条复跑其命令;Android instrumented 腿(令牌静态加密的唯一证明)设为 `workflow_dispatch` 触发,理由写在文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿。
|
||||||
|
- **一个诚实的取舍**: 键栏在 AX3–AX5 的**字号**封顶在 `.accessibility2`。T-iOS-34 的验收原文是"最大字号不破版",现在任何档都不裁切;但"AX5 键帽以 AX5 字号渲染"是假的——不封顶时 AX5 需 108.24pt,会把终端吃掉。封顶值由测试钉死不得漂移。
|
||||||
|
- **合并回 develop 时撞上了 `android-blocker-fixes`**(同期另一会话,见下一条)。**两边各自独立实现了 `WEBTERM_TOKEN` 闸**:它们走响应式(探针失败→提示→提交),本波走前置式(确认页填→`POST /auth`→Keystore)。合并是**组合**不是二选一——两条入口汇到同一个 `performProbe` 收口,两条路径都把令牌落到 WS upgrade 会读的那个 store。
|
||||||
|
- **测试全绿不等于合并正确**:1224 个 Android 测试 0 失败的情况下,单独跑的语义复核仍抓出 **3 个真缺陷,其中 2 个是合并引入的**——
|
||||||
|
① **解除配对不再擦除凭据**(🔴 安全):`HostRemover` 擦 cookie 但不擦新加的 `AccessTokenStore`。令牌就是 cookie 的值(整个 shell 的凭据),于是解绑后仍留在磁盘**且是活的**——`AccessTokenSource` 按 origin 取而不是按配对记录取,同一 URL 重新添加、令牌框留空会**静默用残留令牌认证**。两个分支单独都没这个洞。
|
||||||
|
② **"两套 cookie 机制能安全共存"是错的**(🟠):复核 agent 反编译了本仓库钉死的 OkHttp 4.12.0 的 `BridgeInterceptor` 并用真 `MockWebServer` 复现——判据是"jar 非空"而**不是**"jar 里有这台主机的 `webterm_auth`",且是 `header()` **整头替换**。实测:jar 里有个无关 cookie(代理下发的)→ 令牌被从 REST **和 WS upgrade** 上整个抹掉;jar 里有陈旧 `webterm_auth` → 发出去的是陈旧值。修法是给 jar 按名过滤(读/写/水合三处),并补上**production 接线**下的回归测试——原有两个测试一个用 `NO_COOKIES` 建 client、一个用 `AccessTokenSource.NONE` 建 jar,**从来没有两者同时活着**,这正是缺陷能溜过去的原因。
|
||||||
|
③ **索引里的 AndroidManifest 是非法 XML**(🔴):git 把两边的 `android:allowBackup="false"` 自动合成了**重复属性**,无冲突标记。工作树被修好了但没 `git add`,照常 commit 会提交坏的那份、Android 构建在 manifest 解析就挂。
|
||||||
|
- 另修 2 项:`204 无 Set-Cookie`(=服务端没开鉴权)在响应式路径上被错误持久化,违反 §1.1 冻结规则;`ROUTE_DEFINED` 范围过宽——服务端唯一路由自有的 401 是 `src/http/git-ops.ts:108`,只有 stage/commit/push/fetch 能到达,worktree 三条根本产生不了,却被钉成 ROUTE_DEFINED(令牌门主机上会把 gate-401 显示成 git 拒绝)。iOS 侧同样的过宽 pinning 一并修。
|
||||||
|
- **合并后实测**: Android **1236** 测试 0 失败(10 模块)、kover 四个受门模块 92–96%、iOS 452 包测 + **32** 集成测(对**合并后**的真服务器,含 develop 新加的孤儿 tmux 那套)+ 550 App 测 0 known issue + 覆盖率门 5/5。`src/` 与 develop HEAD 逐字节相同。
|
||||||
|
- **遗留(非缺陷,是设计取舍)**: 两套 cookie 机制仍并存,按名过滤后不会互相抹掉,但 jar 仍优先于手写头。要让 store 无条件胜出得二选一(收敛成单机制 = 重写一边的测试套,或加 app→network interceptor 传递),那是带真实设计决策的后续项,不是 merge 能顺手做的。已用一条测试把当前优先级钉死。
|
||||||
### 🤖 [x] Android 客户端"能装但不能用"修复 — 7 个真实缺陷已修,设备上 67 个 instrumented 测试全绿(2026-07-30,worktree `android-blocker-fixes`)
|
### 🤖 [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 清掉。
|
- **起因**: 例行问"android 客户端完成状况如何"。`android/PROGRESS_ANDROID.md` 写着 **"✅ ANDROID CLIENT COMPLETE — all 36 plan tasks landed"**,617 个 JVM 测试全绿,APK 也出得来。**这个"完成"只在编译层面成立** —— 从来没在任何真机或模拟器上跑过,而一旦跑,第一个动作就崩。审计(45 agent,含逐条对抗验证)+ 修复(多波 agent)已把 blocker 清掉。
|
||||||
|
|||||||
429
docs/plans/ios-completion.md
Normal file
429
docs/plans/ios-completion.md
Normal 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._~+/=-]`,长度 16–512 | 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** | SessionCore:WS upgrade 带 `Cookie`、401→终态 `.unauthorized`、不进退避环 | `ios/Packages/SessionCore/**` |
|
||||||
|
| **B4** | ClientTLS 覆盖率 55.76%→≥80% + 纳入覆盖率门 + 修 CI 三条腿 | `ios/Packages/ClientTLS/Tests/**`、`ios/IntegrationTests/scripts/coverage-gate.sh`、`.github/workflows/ios.yml` |
|
||||||
|
| **B5** | Android `WEBTERM_TOKEN` 支持(§1.1 同一契约,OkHttp 侧) | `android/**` |
|
||||||
|
|
||||||
|
### Wave C(**串行** 4 agent)—— App 层
|
||||||
|
|
||||||
|
串行原因:新增 `.swift` 文件必须 `xcodegen generate` 重写**共享的** `.xcodeproj`,并行会互相踩;
|
||||||
|
串行后无文件归属冲突,每个 agent 可自由跑 `xcodegen + xcodebuild` 全量验证。
|
||||||
|
|
||||||
|
| ID | 任务 | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| **C1** | 令牌 UI + 传输接线;移除主机 UI(顺带修 `PushRegistrar.handleHostRemoved` 死钩子) |
|
||||||
|
| **C2** | git 面板 UI + worktree 表(**含 T-iOS-32**)+ `claude --resume` 历史 |
|
||||||
|
| **C3** | 终端内搜索(**T-iOS-33**)+ 语音 PTT(**T-iOS-31**) |
|
||||||
|
| **C4** | 主题 + Dynamic Type(**T-iOS-34**)+ web `?join=` 互通(**T-iOS-35**) |
|
||||||
|
|
||||||
|
### Wave D(并行 3 agent,report-only / 文档)
|
||||||
|
|
||||||
|
| ID | 任务 |
|
||||||
|
|---|---|
|
||||||
|
| **D1** | 安全复核:令牌路径(两端)+ entitlements + 无令牌泄漏(日志/URL/崩溃) |
|
||||||
|
| **D2** | 全量验收(**T-iOS-36** + T-iOS-18/19/30 的机器可执行部分):包测/App 测/集成/覆盖率门/模拟器构建/真机构建尝试,只报真实数字 |
|
||||||
|
| **D3** | 文档:`README.md`、`ios/README.md`、`docs/PLAN_IOS_CLIENT.md`、`docs/PLAN_IOS_IPAD.md` 勾选与过期表述 |
|
||||||
|
|
||||||
|
### Wave E(串行,条件触发)
|
||||||
|
|
||||||
|
| ID | 任务 |
|
||||||
|
|---|---|
|
||||||
|
| **E1** | 修 D1/D2 报出的 CRITICAL/HIGH(无则跳过) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 验收门(每个 builder 自查,D2 复核)
|
||||||
|
|
||||||
|
- TDD:先 RED 再 GREEN(§4 的 RED 清单是 P2 任务的前置交付物)。
|
||||||
|
- `swift build` + `swift test` 本包全绿;改到 App 层则 `xcodegen generate` + iPhone 16 Pro 模拟器 `xcodebuild ... test` 全绿。
|
||||||
|
- 受门包覆盖率 ≥80%(B4 后 ClientTLS 也进门)。
|
||||||
|
- 零 `TODO`/`FIXME`/占位实现;零硬编码密钥;令牌零日志。
|
||||||
|
- **诚实报告**:跑不了的(真机/付费账号/CI 平台)写 DEFERRED 并给出手工步骤,**不得报"通过"**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. P2 RED 测试清单(由各 P2 builder 在开工第一步追加到本节)
|
||||||
|
|
||||||
|
### T-iOS-32 · Worktree 生命周期(create/prune/remove)+ `claude --resume` 历史 —— C2 追加
|
||||||
|
|
||||||
|
真源:`src/http/worktrees.ts` / `src/server.ts:1095-1183`、`docs/plans/w4-worktree-lifecycle.md`、
|
||||||
|
web 参照实现 `public/projects.ts`(`validateBranchNameClient` :982、`confirmAndRemoveWorktree` :431、
|
||||||
|
`confirmAndPruneWorktrees` :455、`renderNewWorktreeForm` :998、`newTabForResume` `public/tabs.ts:918`)。
|
||||||
|
APIClient 侧 builder/状态码映射已在 B1 测过(125 tests),故以下全部是 **App 层归约**测试,
|
||||||
|
文件 `ios/App/WebTermTests/WorktreeViewModelTests.swift` / `ResumeHistoryViewModelTests.swift`。
|
||||||
|
|
||||||
|
**A. 分支名校验(纯函数 `WorktreeBranchRule.validate`,逐条镜像 web 的 9 条规则)**
|
||||||
|
1. RED:空串 → 非 nil 错误文案(不得放行到网络)。
|
||||||
|
2. RED:长度 > 250 → 报错;== 250 → 通过。
|
||||||
|
3. RED:含空格 / 控制字符(`\u{0}`…`\u{20}`、`\u{7f}`)→ 报错。
|
||||||
|
4. RED:以 `-` 开头 → 报错(否则会变成 git flag)。
|
||||||
|
5. RED:含 `..`、以 `.lock` 结尾 → 报错。
|
||||||
|
6. RED:含 `~ ^ : ? * [ \` 任一 → 报错。
|
||||||
|
7. RED:含 `@{` → 报错。
|
||||||
|
8. RED:以 `/` 开头、以 `/` 结尾、含 `//` → 报错。
|
||||||
|
9. RED:合法名(`feat/x`、`v1.2-fix`)→ nil。
|
||||||
|
|
||||||
|
**B. 创建 worktree(`POST /projects/worktree`,G)**
|
||||||
|
10. RED:非法分支名 → **不发请求**(记录一次调用计数 == 0),只显示校验错误。
|
||||||
|
11. RED:`.ok(CreateWorktreeResult)` → `phase == .created(path:branch:)`,且把服务器返回的
|
||||||
|
canonical `path`/`branch` 当真相(不回显本地输入)。
|
||||||
|
12. RED:`base` 留空 → 请求体的 `base` 为 nil(服务器读作「从 HEAD 切」),**不得**送 `""`。
|
||||||
|
13. RED:`.rejected(status:403, message:"…")` → 原样显示服务器安全文案(不得吞、不得自造)。
|
||||||
|
14. RED:`.rejected(status:500, message:nil)` → 兜底中文文案(非空、可读)。
|
||||||
|
15. RED:`.rateLimited` → 专用「请稍后再试」文案,且**不自动重试**。
|
||||||
|
16. RED:抛出的 `APIClientError.unauthorized` → 引导补令牌的文案(不与 500 混淆)。
|
||||||
|
17. RED:创建进行中 `isBusy == true` 且重复提交只发一次请求。
|
||||||
|
|
||||||
|
**C. prune(`POST /projects/worktree/prune`,G,破坏性 → 需确认)**
|
||||||
|
18. RED:未确认(`confirmPrune` 未调用)→ 零请求。
|
||||||
|
19. RED:确认后 `.ok(pruned: [])` → 幂等文案「没有可回收的 worktree」,非错误态。
|
||||||
|
20. RED:`.ok(pruned: [a,b])` → 文案含数量 2。
|
||||||
|
21. RED:`.rejected(404, msg)` → 显示服务器文案。
|
||||||
|
|
||||||
|
**D. remove(`DELETE /projects/worktree`,G,两级确认)**
|
||||||
|
22. RED:主 worktree(`isMain`)→ UI 不提供 remove(`canRemove == false`);locked 同样 false。
|
||||||
|
23. RED:第一次确认 → 以 `force: false` 发一次请求。
|
||||||
|
24. RED:`.rejected(409, msg)` → 进入 `.forceConfirming`(**不自动**重试),并带上服务器文案。
|
||||||
|
25. RED:`.forceConfirming` 下用户取消 → 零第二次请求。
|
||||||
|
26. RED:`.forceConfirming` 下用户确认 → 第二次请求 `force: true`。
|
||||||
|
27. RED:`.rejected(400)`(非 409)→ 直接错误态,**不**进入 force 分支。
|
||||||
|
|
||||||
|
**E. `claude --resume <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. RED:cwd 非绝对路径的历史条目 → 不可恢复(`Validation.isAbsoluteCwd` 纪律)。
|
||||||
|
|
||||||
|
### 附:C2 git 面板(非 P2,但同批交付)RED 清单
|
||||||
|
|
||||||
|
`ios/App/WebTermTests/GitPanelPresentationTests.swift` / `GitPanelViewModelTests.swift`。
|
||||||
|
真源 `docs/plans/w6-project-git-panel.md` + `public/projects.ts:615-745 makeSyncBand`。
|
||||||
|
|
||||||
|
36. RED:`sync == nil`(非 git 目录)→ 无同步带(nil),不发任何 git 请求。
|
||||||
|
37. RED:`↑0 ↓0` + fetch 在 1h 内 → **唯一**允许的绿色「已同步」。
|
||||||
|
38. RED:`↑0 ↓0` + `lastFetchMs` 超过 `FETCH_STALE_MS`(=3600_000) → **不绿**,`↓` 带「未核实」。
|
||||||
|
39. RED:`lastFetchMs == nil`(从未 fetch)→ 视为 stale,不绿。
|
||||||
|
40. RED:`upstream == nil` → 「无上游」,无 `↑↓`,**不绿**(最易犯的 bug)。
|
||||||
|
41. RED:`detached == true` → 「分离 HEAD」,无 `↑↓`,fetch 按钮禁用(`canFetch == false`)。
|
||||||
|
42. RED:`ahead == nil`(读不到)→ 显示 `↑ —` 而非 `↑ 0`,且不绿。
|
||||||
|
43. RED:`dirtyCount == nil` → 「未检查」,**不是** clean;`0` → clean;`3` → 3。
|
||||||
|
44. RED:git log 未推送边界:`upstream != nil` 时边界画在最后一个 `unpushed == true` 之后,
|
||||||
|
且只画一次;`unpushed` 只在严格 `true` 时生效。
|
||||||
|
45. RED:`upstream == nil` → 完全不画边界。
|
||||||
|
46. RED:未推送提交排在已推送之下(老日期 merge)→ 边界仍在最后一个 unpushed 之后(Set 为准,非行号)。
|
||||||
|
47. RED:stage/commit/push/fetch 的 `.rejected` 一律把服务器 `error` 原样显示;`nil` message → 兜底文案。
|
||||||
|
48. RED:commit 成功 → 清空输入框 + 显示短 sha;`.rejected(409)`(无暂存)→ 保留输入框内容。
|
||||||
|
49. RED:push `.rejected(401)` → 「主机上的 git 凭据」文案,**不**与访问令牌 401 混淆
|
||||||
|
(`unauthorizedPolicy == .routeDefined`,§1.1)。
|
||||||
|
50. RED:任一写操作进行中 → `isBusy`,重复点击只发一次。
|
||||||
|
51. RED:重命名文件 stage → 请求体同时含 `oldPath` 与 `newPath`(镜像 web)。
|
||||||
|
|
||||||
|
### T-iOS-33 · 终端内搜索 —— C3 追加
|
||||||
|
|
||||||
|
真源:SwiftTerm 1.13.0 已带搜索 API(`TerminalViewSearch.swift`:`findNext(_:options:scrollToResult:)`、
|
||||||
|
`findPrevious(...)`、`clearSearch()`,`SearchOptions(caseSensitive:false, regex:false, wholeWord:false)`
|
||||||
|
默认值与 xterm.js search addon 一致)。web 参照 `public/search.ts`(Enter=next / Shift+Enter=prev /
|
||||||
|
Esc=关闭并 `hooks.clear()`;`public/terminal-session.ts:547-553` 把三个 hook 落到 SearchAddon;
|
||||||
|
`style.css:812` searchbox 固定在**右上**)。文件 `ios/App/WebTermTests/TerminalSearchTests.swift`。
|
||||||
|
|
||||||
|
**A. 查询提交策略(纯函数 `TerminalSearchQuery.isSubmittable`)**
|
||||||
|
1. RED:空串 → 不可提交,且**零引擎调用**(不把空词丢给 SwiftTerm)。
|
||||||
|
2. RED:单个空格 `" "` → **可**提交(镜像 web:`input.value` 原样进 `findNext`,空格是合法搜索词)。
|
||||||
|
3. RED:`" foo "` → 原样传(**不 trim、不改大小写**,逐字符等于用户输入)。
|
||||||
|
|
||||||
|
**B. 方向与引擎调用(`TerminalSearchModel` over fake `TerminalSearching`)**
|
||||||
|
4. RED:`find(.next)` → 恰一次 `searchNext(term:)`,term 逐字符等于 query,零 `searchPrevious`。
|
||||||
|
5. RED:`find(.previous)` → 恰一次 `searchPrevious`,零 `searchNext`。
|
||||||
|
6. RED:引擎回 true → `outcome == .found`。
|
||||||
|
7. RED:引擎回 false → `outcome == .notFound`,且有非空「无匹配」文案。
|
||||||
|
8. RED:连续三次 `find(.next)` → 三次引擎调用(搜索游标由 SwiftTerm 自己推进,客户端不缓存)。
|
||||||
|
9. RED:未 attach 引擎(`searcher == nil`)→ 不崩、`outcome` 保持 `.idle`(预览/测试环境)。
|
||||||
|
|
||||||
|
**C. 打开/关闭生命周期**
|
||||||
|
10. RED:初始 `isPresented == false` 且 `outcome == .idle`。
|
||||||
|
11. RED:`present()`→true,`dismiss()`→false。
|
||||||
|
12. RED:`dismiss()` → 恰一次 `searchClear()`(镜像 web `hide() → hooks.clear()`)+ 清空 query + `outcome == .idle`。
|
||||||
|
13. RED:`present()` **不**调用任何引擎方法(开面板 ≠ 搜索)。
|
||||||
|
14. RED:改 query → `outcome` 复位 `.idle`(旧的「无匹配」不得挂在新词上)。
|
||||||
|
|
||||||
|
**D. 不变式:搜索是纯读**
|
||||||
|
15. RED:整个搜索流程后真 `TerminalViewModel.forwardedSendCount == 0`(零 PTY 字节,byte-shuttle 不变)。
|
||||||
|
16. RED:终端已 `.exited`(read-only)时搜索照常可用(`find` 仍打引擎)。
|
||||||
|
|
||||||
|
**E. SwiftTerm 绑定(Accept:命中并高亮)**
|
||||||
|
17. RED:真 `KeyCommandTerminalView` feed 一段文本后 `searchNext(term:)` 命中 → 返回 true **且**
|
||||||
|
`hasActiveSelection == true`(= SwiftTerm 选区高亮);搜不存在的词 → false。
|
||||||
|
18. RED:`searchClear()` → `hasActiveSelection == false`(高亮清掉)。
|
||||||
|
|
||||||
|
### T-iOS-31 · 语音 PTT + 确认 —— C3 追加
|
||||||
|
|
||||||
|
真源(三件套逐条移植 web,plan §7「端口匹配器 / 1.5s 撤销 / epoch 防误发」= port `voice-commands.ts` 的匹配器):
|
||||||
|
`public/voice.ts`(PTT 生命周期、`autoSend:false` 默认 = **不自动回车**)、
|
||||||
|
`public/voice-commands.ts`(整句精确匹配器 + 否定守卫 + `MIN_APPROVE_CONFIDENCE=0.6`)、
|
||||||
|
`public/voice-confirm.ts`(`DEFAULT_WINDOW_MS = 1500` 可撤销窗口)、
|
||||||
|
`SessionCore/GateState.swift`(epoch 防陈旧决策的既有先例 `canDecide(epoch:)`)。
|
||||||
|
文件 `ios/App/WebTermTests/VoicePTTTests.swift`。
|
||||||
|
|
||||||
|
**A. 转写清洗(安全边界,纯函数 `VoiceTranscript`)**
|
||||||
|
19. RED:普通文本 → 首尾 trim 后原样。
|
||||||
|
20. RED:含 `\r`(0x0D)→ **剥除**(口述绝不自己回车执行命令;等价 web `autoSend:false`)。
|
||||||
|
21. RED:含 `\n`/`\t`/ESC `\u{1b}`/其它 C0-C1 控制字符 → 全部剥除。
|
||||||
|
22. RED:多行 → 折成单行(换行变空格 + 合并连续空白)。
|
||||||
|
23. RED:超 `maxLength` → 截断到上限。
|
||||||
|
24. RED:清洗后为空 → `isInjectable == false`(不进确认态)。
|
||||||
|
|
||||||
|
**B. 匹配器移植(`VoiceCommandMatcher`,逐条镜像 `voice-commands.ts`)**
|
||||||
|
25. RED:无 held gate → 任何话语都是 `.text`(含「确认」)。
|
||||||
|
26. RED:gate 是 `.plan` → `.text`(v1 只作用 tool gate)。
|
||||||
|
27. RED:tool gate + 「确认」/「批准」/`ok`/`go ahead` → `.approve`。
|
||||||
|
28. RED:归一化(小写 + 去标点 + 合并空白):`OK.`/「好的!」命中;`don't` → `dont`。
|
||||||
|
29. RED:**整句精确**:「确认一下这个」→ `.text`(绝不子串匹配,否则顺口一句就批了 shell)。
|
||||||
|
30. RED:tool gate + 「拒绝」/「取消」/`stop` → `.reject`。
|
||||||
|
31. RED:否定守卫:「不批准」→ `.reject`;「不清楚」→ `.text`;`now` **不**被 `no` 前缀否定 → `.text`。
|
||||||
|
32. RED:置信度门:`.approve` 且 confidence < 0.6 → 降级 `.text`;`.reject` 不受置信度影响。
|
||||||
|
33. RED:空/纯标点话语 → `.text`。
|
||||||
|
|
||||||
|
**C. 确认 + 1.5s 撤销窗口(FakeClock,零真睡)**
|
||||||
|
34. RED:`pressDown()` → `.listening`;partial 回调更新 `.listening(partial:)`。
|
||||||
|
35. RED:`pressUp()` 得空转写 → `.idle` + `lastResolution == .empty`,**零注入**。
|
||||||
|
36. RED:`pressUp()` 得有效转写 → `.confirming(...)`,**零注入**(「确认前绝不注入」)。
|
||||||
|
37. RED:`.confirming` 下 `cancel()` → `.idle` + `.discarded` + 零注入。
|
||||||
|
38. RED:`confirm()` → `.undoWindow`,此刻**仍零注入**。
|
||||||
|
39. RED:时钟 +1.4s → 仍零注入;到 1.5s → 恰一次注入,内容 == 清洗后的转写,`lastResolution == .committed(.text(...))`。
|
||||||
|
40. RED:`.undoWindow` 内 `undo()` → 零注入 + `.undone`;再推进 10s **也不**注入(任务已取消)。
|
||||||
|
41. RED:注入内容**不以 `\r` 结尾**(用户自己按 ⏎ —— 误听绝不自动执行)。
|
||||||
|
42. RED:非 `.confirming` 态调 `confirm()` → 无副作用。
|
||||||
|
43. RED:重复 `confirm()` → 只 arm 一次、只注入一次。
|
||||||
|
|
||||||
|
**D. epoch 防误发(两道闸)**
|
||||||
|
44. RED:口述→确认之间 epoch 变了 → `confirm()` 直接丢弃:零注入 + `.staleEpoch`。
|
||||||
|
45. RED:epoch 在**撤销窗口内**变(confirm 已过、1.5s 内切会话)→ 到期仍零注入 + `.staleEpoch`。
|
||||||
|
46. RED:epoch 不变 → 正常注入(防止闸门过严的回归保护)。
|
||||||
|
47. RED:口述时 sessionId 还没 adopt(nil)、确认时已 adopt → 视为变化 → 零注入。
|
||||||
|
48. RED:`VoiceEpochPolicy.invalidates`:`.reconnecting` → true(重连后服务器可能给的是新 PTY,
|
||||||
|
客户端分辨不了 → 走安全方向);`.connecting`/`.none` → false。`VoiceEpochSource` 累加 generation。
|
||||||
|
|
||||||
|
**E. 权限与失败路径**
|
||||||
|
49. RED:麦克风授权被拒 → `.denied(.microphone)`,文案指向 设置→隐私→麦克风;零录音零注入。
|
||||||
|
50. RED:语音识别授权被拒 → `.denied(.speech)`,文案指向语音识别开关。
|
||||||
|
51. RED:识别器抛错 → `.failed(message:)`,零注入,可再按重试。
|
||||||
|
52. RED:终端只读(`.exited`)→ `pressDown()` 拒绝(`.readOnly`),**不启动录音**(`startCount == 0`)。
|
||||||
|
|
||||||
|
**F. 键栏集成(KeyBar 零回归)**
|
||||||
|
53. RED:不提供语音闭包 → 按钮数 == `KeyBarLayout.buttons.count`(17),无麦克风键。
|
||||||
|
54. RED:提供语音闭包 → 末尾多一个 🎤/「语音」键,前 17 键顺序与 accessibilityLabel **完全不变**。
|
||||||
|
55. RED:麦克风键 touchDown → `onVoiceDown` 恰一次;touchUpInside → `onVoiceUp` 恰一次;
|
||||||
|
**绝不**经 `onKey`(麦克风不映射任何 `KeyByteMap` 字节)。
|
||||||
|
56. RED:匹配到 `.approve` 且注入了 gate 桥 → 走同一 1.5s 窗口,到期调 `decide(.approve)` 而**不**注入文本。
|
||||||
|
|
||||||
|
### T-iOS-34 · 主题(跟随系统/深色/浅色)+ Dynamic Type —— C4 追加
|
||||||
|
|
||||||
|
真源:`Wiring/RootView.swift:30` 今天**硬锁** `.preferredColorScheme(.dark)`(无浅色通路);
|
||||||
|
`DesignSystem/Tokens.swift` 已声明浅色 accent `#C9892F`(web `--accent-2`)但状态色/时间线色/
|
||||||
|
`accentSoft`/终端画布仍是**单值深色专用**;终端主题只在 `Screens/TerminalScreen.swift:223-235`
|
||||||
|
`makeUIView` 时套一次。web 参照 `public/settings.ts`(`DEFAULT_SETTINGS.theme='dark'`、
|
||||||
|
`THEMES.light = {background:'#f6f7f9', foreground:'#1a1d24'}`)。
|
||||||
|
文件 `ios/App/WebTermTests/AppThemeTests.swift` / `DynamicTypeLayoutTests.swift`。
|
||||||
|
|
||||||
|
**A. 主题模型(纯值 `AppTheme`)**
|
||||||
|
1. RED:`.system.colorScheme == nil`(= 交给系统),`.dark → .dark`,`.light → .light`。
|
||||||
|
2. RED:三档 label / SF Symbol 各自非空且**互不相同**(选择器要能区分)。
|
||||||
|
3. RED:`AppTheme.allCases` 顺序 = 跟随系统 → 深色 → 浅色(选择器顺序即此顺序,UI 不重排)。
|
||||||
|
4. RED:`AppTheme(rawValue:)` 白名单:`"system"/"dark"/"light"` 命中,其它(含空串、`"Dark"`)→ nil。
|
||||||
|
|
||||||
|
**B. 持久化(`AppThemeCodec` 纯函数 + `ThemeStore` over fake defaults)**
|
||||||
|
5. RED:`decode(nil)`(从未设置)→ **`.dark`** —— 默认必须等于今天硬锁的深色(零回归)。
|
||||||
|
6. RED:`decode("solarized")` / `decode("")` / `decode("DARK")` → `.dark`(未知值不崩、不落 system)。
|
||||||
|
7. RED:三档 `encode → decode` 往返恒等。
|
||||||
|
8. RED:`ThemeStore` 空 defaults → `.dark`,且**不写盘**(首次读不产生写)。
|
||||||
|
9. RED:`select(.light)` → `theme == .light` 且 defaults 落 `"light"`;同一 defaults 新建 store → `.light`(跨启动)。
|
||||||
|
10. RED:`select` 同一档两次 → 只写一次(不产生多余写)。
|
||||||
|
11. RED:defaults 里被塞脏值 → store 读作 `.dark`,并**不**清空用户其它键。
|
||||||
|
|
||||||
|
**C. 有效配色解析(`AppTheme.resolvedScheme(system:)`)**
|
||||||
|
12. RED:`.system` + 系统 `.light` → `.light`;`.system` + 系统 `.dark` → `.dark`(透传)。
|
||||||
|
13. RED:`.dark`/`.light` 无视系统值 → 强制自身。
|
||||||
|
|
||||||
|
**D. 浅色通路的 token 审计(`UIColor.resolvedColor(with:)` 逐档解析)**
|
||||||
|
14. RED:`statusWorking`/`statusWaiting`/`statusStuck`/`timelineTool`/`timelineUser` 在 **light** 与
|
||||||
|
**dark** 下解析值**不同**(即:真的有浅色变体,不是解锁开关就完事)。
|
||||||
|
15. RED:深色档的值**逐字节不变**(#46D07F / #F5B14C / #FF6B6B / #5E9EFF / #AF7BFF)—— 深色零回归。
|
||||||
|
16. RED:上述 5 色在**两档**下相对该档 `systemBackground` 的 WCAG 对比度 **≥ 3:1**
|
||||||
|
(非文本 UI 组件门槛,1.4.11)。今天的 `#46D07F`/`#F5B14C`/`#FF6B6B` 在白底只有
|
||||||
|
1.96/1.60/2.74:1 —— 这是 RED 的核心。
|
||||||
|
17. RED:critical 三色(working/waiting/stuck)在 **light** 档仍两两可辨(不因变暗而糊成一片)。
|
||||||
|
18. RED:`accentSoft` 两档不同,且**都仍是 wash**(alpha < 0.3,不许变成实心块)。
|
||||||
|
19. RED:`onAccent` 两档**相同**(金色填充在两档都要深色墨,故它是固定值),且与 accent 对比 ≥ 4.5:1。
|
||||||
|
|
||||||
|
**E. 终端画布跟随主题(`TerminalPalette`)**
|
||||||
|
20. RED:`colors(for: .dark).background == #100F0D`、`.foreground == #ECE9E3`(桌面 web `--bg/--text`,零回归)。
|
||||||
|
21. RED:`colors(for: .light).background == #F6F7F9`、`.foreground == #1A1D24`(镜像 web `THEMES.light`)。
|
||||||
|
22. RED:两档 fg↔bg 对比度 ≥ 7:1(终端是正文,AAA 门槛)。
|
||||||
|
23. RED:caret / selection 用该档 accent 解析值(不写死深色档的金)。
|
||||||
|
24. RED:`DS.Palette.terminalBackgroundUIColor()` 是**动态** UIColor:light/dark 解析值不同
|
||||||
|
(今天是单值常量 → RED)。
|
||||||
|
|
||||||
|
**F. Dynamic Type 不破版(`UIHostingController.sizeThatFits` / UIKit `systemLayoutSizeFitting`,AX5)**
|
||||||
|
25. RED:`GateBanner` 在 320pt 宽容器、`.accessibility5` 下:宽度 ≤ 320(无横向溢出)、
|
||||||
|
高度有限且 > 标准字号高度(= 真的长高而不是被裁)。
|
||||||
|
26. RED:`TelemetryChip`(`lineLimit(1)` 的等宽数字)在 AX5 下宽度 ≤ 320,且**AX2 与 AX5 同高**
|
||||||
|
(`DS.Typography.numericClamp` 夹取生效 —— 表格数字不许炸版;落地时把原计划的
|
||||||
|
"增幅 ≤ 2.2×"换成了这条更强、更确定的等式断言)。
|
||||||
|
27. RED:`dsMetaText()` 的元信息行在 AX5 下同样受夹取(与 26 同一策略,单一出处)。
|
||||||
|
28. RED:`DSButtonStyle` 在 AX5 下高度 ≥ `DS.Layout.minHitTarget` 且标签可换行不横向溢出。
|
||||||
|
29. RED(**外部件度量,越界只报不改**):AX5 下键帽两行所需高度 vs 固定 `barHeight`(44+8)。
|
||||||
|
实测 **108.24pt vs 52pt → 裁切**(footnote 行高 52.51 + caption2 行高 47.73 + 内衬 8)。
|
||||||
|
`Components/KeyBar.swift` 不在 C4 Owns,故以 `withKnownIssue` 记为已知缺口(修好后用例会
|
||||||
|
报 "known issue was not recorded",提醒删标记)。
|
||||||
|
注:不能用 `performAsCurrent { KeyBarView() }` 量 —— `UIFont.preferredFont(forTextStyle:)`
|
||||||
|
读 App 级 content size category,不看 `UITraitCollection.current`,那样量出的是默认字号
|
||||||
|
(38pt)的**假绿**;改用 `compatibleWith:` 取真实 AX5 行高。
|
||||||
|
|
||||||
|
### T-iOS-35 · web 分享 QR(`?join=`)互通 —— C4 追加
|
||||||
|
|
||||||
|
真源:`public/share.ts:55` 的 URL 形状 **`${location.origin}/?join=${sessionId}`**;
|
||||||
|
`src/server.ts` 的 `GET /?join=<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. RED:path 为 `""` 与 `"/"` 都接受(`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. RED:origin 命中已配对主机 → `openSession(host, sessionId)` 恰一次,无 hint。
|
||||||
|
45. RED:origin 未配对 → **零** open、`showPairing` 一次、hint == `DeepLinkCopy.unknownHostHint`
|
||||||
|
(**不得**据链接内容自动新增主机)。
|
||||||
|
46. RED:hint 文案**不回显**链接内容(不含 origin 串、不含 sessionId)—— 防钓鱼/防日志注入。
|
||||||
|
47. RED:origin 比较走 `HostEndpoint.originHeader`:`http://host:3000` 与 `http://HOST:3000/` 视为同一主机;
|
||||||
|
`http://host:3001`(端口不同)**不是**同一主机。
|
||||||
|
48. RED:`https://host/` 与 `http://host/`(scheme 不同)**不是**同一主机。
|
||||||
|
49. RED:冷启动 stash 对 `.joinShared` 同样生效(ready 前 handle → markReady 后恰应用一次)。
|
||||||
|
50. RED:非法 web 链接 → `ignoredCount + 1`,且**不入 stash**(与既有 `.ignore` 同一通路)。
|
||||||
@@ -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`
|
/// 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 {
|
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] = [
|
static let buttons: [KeyBarButtonSpec] = [
|
||||||
KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断",
|
KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断",
|
||||||
title: "Esc — interrupt Claude / dismiss", isPrimary: true),
|
title: "Esc — interrupt Claude / dismiss", isPrimary: true),
|
||||||
@@ -98,15 +115,89 @@ enum KeyBarLayout {
|
|||||||
/// Layout constants — all composed from the frozen `DS` scale (no literals).
|
/// 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
|
/// UIKit reads the same CGFloat tokens the SwiftUI chrome uses, so the key bar
|
||||||
/// stays on-grid with the rest of the app.
|
/// stays on-grid with the rest of the app.
|
||||||
private enum KeyBarMetrics {
|
///
|
||||||
/// A hit-target-tall row plus a little breathing room (44 + 8).
|
/// T-iOS-34 acceptance ("亮暗主题 + **最大字号不破版**") · The bar height used
|
||||||
static let barHeight: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8
|
/// 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 buttonSpacing: CGFloat = DS.Space.xs4
|
||||||
static let contentInset: CGFloat = DS.Space.sm8
|
static let contentInset: CGFloat = DS.Space.sm8
|
||||||
static let buttonInsets = NSDirectionalEdgeInsets(
|
static let buttonInsets = NSDirectionalEdgeInsets(
|
||||||
top: DS.Space.xs4, leading: DS.Space.md12,
|
top: DS.Space.xs4, leading: DS.Space.md12,
|
||||||
bottom: DS.Space.xs4, trailing: 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)
|
// MARK: - KeyBarView (inputAccessoryView)
|
||||||
@@ -117,16 +208,53 @@ private enum KeyBarMetrics {
|
|||||||
final class KeyBarView: UIInputView {
|
final class KeyBarView: UIInputView {
|
||||||
/// Tap outlet. `@MainActor`-typed so the handler can drive the ViewModel.
|
/// Tap outlet. `@MainActor`-typed so the handler can drive the ViewModel.
|
||||||
var onKey: (@MainActor (KeyByteMap.Key) -> Void)?
|
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] = []
|
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(
|
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
|
inputViewStyle: .keyboard
|
||||||
)
|
)
|
||||||
allowsSelfSizing = true
|
allowsSelfSizing = true
|
||||||
buildBar()
|
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")
|
@available(*, unavailable, message: "KeyBarView is code-built only")
|
||||||
@@ -135,7 +263,29 @@ final class KeyBarView: UIInputView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
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() {
|
private func buildBar() {
|
||||||
@@ -150,6 +300,11 @@ final class KeyBarView: UIInputView {
|
|||||||
keyButtons = keyButtons + [button]
|
keyButtons = keyButtons + [button]
|
||||||
stack.addArrangedSubview(button)
|
stack.addArrangedSubview(button)
|
||||||
}
|
}
|
||||||
|
if let voice {
|
||||||
|
let mic = makeVoiceButton(voice)
|
||||||
|
voiceButton = mic
|
||||||
|
stack.addArrangedSubview(mic)
|
||||||
|
}
|
||||||
|
|
||||||
let scroll = UIScrollView()
|
let scroll = UIScrollView()
|
||||||
scroll.showsHorizontalScrollIndicator = false
|
scroll.showsHorizontalScrollIndicator = false
|
||||||
@@ -177,41 +332,77 @@ final class KeyBarView: UIInputView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeButton(for spec: KeyBarButtonSpec) -> UIButton {
|
private func makeButton(for spec: KeyBarButtonSpec) -> UIButton {
|
||||||
// Keycap look: primary (Esc) wears the accent tint, the rest a subtle
|
let button = makeKeycap(
|
||||||
// gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is the
|
label: spec.label, caption: spec.caption,
|
||||||
// UIKit twin of DS.Palette.textSecondary — the DS UIColor surface only
|
title: spec.title, isPrimary: spec.isPrimary
|
||||||
// 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
|
|
||||||
)
|
)
|
||||||
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(
|
button.addAction(
|
||||||
UIAction { [weak self] _ in self?.onKey?(spec.key) },
|
UIAction { [weak self] _ in self?.onKey?(spec.key) },
|
||||||
for: .touchUpInside
|
for: .touchUpInside
|
||||||
)
|
)
|
||||||
return button
|
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)
|
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import APIClient
|
import APIClient
|
||||||
import ClientTLS
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import os
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -226,19 +225,25 @@ final class SessionThumbnailPipeline {
|
|||||||
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
|
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生产装配:ephemeral URLSession(preview 字节可能含屏上密钥,内存缓存
|
/// 生产装配:取数走**组合根装配**的那条带凭证的传输
|
||||||
/// only——同 T-iOS-19 对 RO GET 的裁定),并携带 C-iOS-2 的设备证书身份,
|
/// (`AppEnvironment.thumbnailTransport()`)—— ephemeral URLSession(preview
|
||||||
/// 这样对隧道主机的预览取数也能通过 mTLS(否则缩略图会被 nginx 拒握手)。
|
/// 字节可能含屏上密钥,内存缓存 only,同 T-iOS-19 对 RO GET 的裁定)
|
||||||
/// 身份经 provider 每次握手时按需从 keychain 载入(MEDIUM 无需重启修复:
|
/// + C-iOS-2 的设备证书身份(否则隧道主机的缩略图会被 nginx 拒握手)
|
||||||
/// 中途导入的证书下次取数即生效;缺证=nil,对本地主机无副作用)。
|
/// + **C1 每主机访问令牌 Cookie**。两个凭证都按请求/按握手现取,
|
||||||
static func live() -> SessionThumbnailPipeline {
|
/// 中途导入的证书、中途输入的令牌下一次取数即生效,无需重启。
|
||||||
let identityStore = KeychainClientIdentityStore()
|
///
|
||||||
let transport = URLSessionHTTPTransport(identityProvider: {
|
/// F1(本次修复):这里原先自建一个**没有令牌源**的传输,于是在开了
|
||||||
identityStore.loadedIdentityOrNil()
|
/// `WEBTERM_TOKEN` 的主机上 `GET /live-sessions/:id/preview` 一律 401;
|
||||||
})
|
/// 而本管线按设计永不抛错(失败即占位图),401 就被静默降级成
|
||||||
return SessionThumbnailPipeline(
|
/// **每一张**缩略图都是占位符,屏幕上没有任何原因可查。
|
||||||
|
///
|
||||||
|
/// - Parameter http: 取数传输。默认即上述生产装配;测试注入替身。
|
||||||
|
static func live(
|
||||||
|
http: any HTTPTransport = AppEnvironment.thumbnailTransport()
|
||||||
|
) -> SessionThumbnailPipeline {
|
||||||
|
SessionThumbnailPipeline(
|
||||||
loader: { request in
|
loader: { request in
|
||||||
try await APIClient(endpoint: request.endpoint, http: transport)
|
try await APIClient(endpoint: request.endpoint, http: http)
|
||||||
.preview(id: request.sessionId)
|
.preview(id: request.sessionId)
|
||||||
},
|
},
|
||||||
renderer: { data, cols, rows in
|
renderer: { data, cols, rows in
|
||||||
|
|||||||
152
ios/App/WebTerm/Components/SpeechDictation.swift
Normal file
152
ios/App/WebTerm/Components/SpeechDictation.swift
Normal 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 语义:松手即取**当前最佳假设**(不等最终结果),否则确认要多等 1–2 秒。
|
||||||
|
@MainActor
|
||||||
|
final class SpeechDictation: VoiceDictating {
|
||||||
|
enum DictationError: LocalizedError {
|
||||||
|
case recognizerUnavailable
|
||||||
|
case microphoneUnavailable
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .recognizerUnavailable: "设备上的语音识别当前不可用。"
|
||||||
|
case .microphoneUnavailable: "拿不到麦克风输入。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 音频 tap 的缓冲帧数(Apple 示例值)。
|
||||||
|
private static let tapBufferSize: AVAudioFrameCount = 1024
|
||||||
|
|
||||||
|
private lazy var audioEngine = AVAudioEngine()
|
||||||
|
private lazy var recognizer = SFSpeechRecognizer(locale: .current) ?? SFSpeechRecognizer()
|
||||||
|
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||||
|
private var task: SFSpeechRecognitionTask?
|
||||||
|
private var onPartial: (@MainActor (String) -> Void)?
|
||||||
|
private var isTapInstalled = false
|
||||||
|
private var latest = VoiceDictationResult(transcript: "", confidence: nil)
|
||||||
|
|
||||||
|
func requestAuthorization() async -> VoiceAuthorization {
|
||||||
|
let speech = await Self.requestSpeechAuthorization()
|
||||||
|
switch speech {
|
||||||
|
case .authorized: break
|
||||||
|
case .restricted: return .restricted
|
||||||
|
case .denied, .notDetermined: return .deniedSpeech
|
||||||
|
@unknown default: return .deniedSpeech
|
||||||
|
}
|
||||||
|
return await Self.requestMicrophoneAuthorization() ? .granted : .deniedMicrophone
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(onPartial: @escaping @MainActor (String) -> Void) async throws {
|
||||||
|
guard let recognizer, recognizer.isAvailable else {
|
||||||
|
throw DictationError.recognizerUnavailable
|
||||||
|
}
|
||||||
|
self.onPartial = onPartial
|
||||||
|
latest = VoiceDictationResult(transcript: "", confidence: nil)
|
||||||
|
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
|
||||||
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
|
|
||||||
|
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||||
|
request.shouldReportPartialResults = true
|
||||||
|
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
|
||||||
|
self.request = request
|
||||||
|
|
||||||
|
let input = audioEngine.inputNode
|
||||||
|
let format = input.outputFormat(forBus: 0)
|
||||||
|
// 没有可用输入时 installTap 会抛 ObjC 异常(直接崩)—— 先自己拦住。
|
||||||
|
guard format.sampleRate > 0, format.channelCount > 0 else {
|
||||||
|
teardown()
|
||||||
|
throw DictationError.microphoneUnavailable
|
||||||
|
}
|
||||||
|
input.installTap(onBus: 0, bufferSize: Self.tapBufferSize, format: format) { buffer, _ in
|
||||||
|
request.append(buffer)
|
||||||
|
}
|
||||||
|
isTapInstalled = true
|
||||||
|
audioEngine.prepare()
|
||||||
|
try audioEngine.start()
|
||||||
|
|
||||||
|
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||||
|
// 非 Sendable 的 result 绝不跨隔离域 —— 先抽出标量再回主 actor。
|
||||||
|
let transcript = result?.bestTranscription.formattedString
|
||||||
|
let confidence = result.flatMap { Self.averageConfidence(of: $0.bestTranscription) }
|
||||||
|
let isFinal = result?.isFinal ?? false
|
||||||
|
let didFail = error != nil
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.ingest(
|
||||||
|
transcript: transcript, confidence: confidence,
|
||||||
|
isFinal: isFinal, didFail: didFail
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async -> VoiceDictationResult {
|
||||||
|
teardown()
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Internals
|
||||||
|
|
||||||
|
private func ingest(
|
||||||
|
transcript: String?, confidence: Double?, isFinal: Bool, didFail: Bool
|
||||||
|
) {
|
||||||
|
if let transcript, !transcript.isEmpty {
|
||||||
|
latest = VoiceDictationResult(transcript: transcript, confidence: confidence)
|
||||||
|
onPartial?(transcript)
|
||||||
|
}
|
||||||
|
guard isFinal || didFail else { return }
|
||||||
|
teardown()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 幂等收尾:结束音频、撤 tap、停引擎、放掉音频会话。
|
||||||
|
private func teardown() {
|
||||||
|
request?.endAudio()
|
||||||
|
task?.cancel()
|
||||||
|
task = nil
|
||||||
|
request = nil
|
||||||
|
onPartial = nil
|
||||||
|
if isTapInstalled {
|
||||||
|
audioEngine.inputNode.removeTap(onBus: 0)
|
||||||
|
isTapInstalled = false
|
||||||
|
}
|
||||||
|
if audioEngine.isRunning {
|
||||||
|
audioEngine.stop()
|
||||||
|
}
|
||||||
|
try? AVAudioSession.sharedInstance()
|
||||||
|
.setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func averageConfidence(of transcription: SFTranscription) -> Double? {
|
||||||
|
let values = transcription.segments.map { Double($0.confidence) }.filter { $0 > 0 }
|
||||||
|
guard !values.isEmpty else { return nil }
|
||||||
|
return values.reduce(0, +) / Double(values.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requestSpeechAuthorization() async
|
||||||
|
-> SFSpeechRecognizerAuthorizationStatus {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
SFSpeechRecognizer.requestAuthorization { status in
|
||||||
|
continuation.resume(returning: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requestMicrophoneAuthorization() async -> Bool {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
AVAudioApplication.requestRecordPermission { granted in
|
||||||
|
continuation.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
ios/App/WebTerm/Components/TerminalSearchBar.swift
Normal file
200
ios/App/WebTerm/Components/TerminalSearchBar.swift
Normal 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 搜索方向(web:Enter = next,Shift+Enter = prev)。
|
||||||
|
enum TerminalSearchDirection: String, Equatable, Sendable {
|
||||||
|
case next
|
||||||
|
case previous
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上一次搜索的结果态。`.idle` = 还没搜 / 词已改(旧结论作废)。
|
||||||
|
enum TerminalSearchOutcome: Equatable, Sendable {
|
||||||
|
case idle
|
||||||
|
case found
|
||||||
|
case notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交策略(纯函数)。**只在空串时拦**:镜像 web 的 `hooks.find(input.value, …)`
|
||||||
|
/// —— 查询词逐字符原样下传,绝不 trim、绝不改大小写(单个空格是合法搜索词)。
|
||||||
|
enum TerminalSearchQuery {
|
||||||
|
static func isSubmittable(_ raw: String) -> Bool {
|
||||||
|
!raw.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Model
|
||||||
|
|
||||||
|
/// 搜索面板状态。命中态是**派生值**:只要 `query` 变了,上一次结论自动作废回
|
||||||
|
/// `.idle`(不需要 didSet,也不会把「无匹配」挂在新词上)。
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class TerminalSearchModel {
|
||||||
|
/// 用户可见文案(named constants)。
|
||||||
|
enum Copy {
|
||||||
|
static let title = "搜索回滚缓冲"
|
||||||
|
static let placeholder = "搜索终端输出…"
|
||||||
|
static let noMatch = "无匹配"
|
||||||
|
static let previous = "上一处匹配"
|
||||||
|
static let next = "下一处匹配"
|
||||||
|
static let close = "关闭搜索"
|
||||||
|
static let open = "搜索"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次搜索的结论,连同它对应的词 —— 词一改,`outcome` 立即回 `.idle`。
|
||||||
|
private struct Attempt: Equatable {
|
||||||
|
let term: String
|
||||||
|
let didHit: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var query: String = ""
|
||||||
|
private(set) var isPresented = false
|
||||||
|
private var lastAttempt: Attempt?
|
||||||
|
|
||||||
|
/// 弱持有:SwiftTerm 视图归 UIKit 所有,面板绝不延长它的生命周期。
|
||||||
|
@ObservationIgnored private weak var searcher: (any TerminalSearching)?
|
||||||
|
|
||||||
|
/// 派生命中态。
|
||||||
|
var outcome: TerminalSearchOutcome {
|
||||||
|
guard let lastAttempt, lastAttempt.term == query else { return .idle }
|
||||||
|
return lastAttempt.didHit ? .found : .notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 状态行文案 —— 只有「无匹配」需要说话(命中时选区高亮本身就是反馈)。
|
||||||
|
var statusText: String? {
|
||||||
|
outcome == .notFound ? Copy.noMatch : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 绑定 SwiftTerm 视图(`makeUIView` 时调用一次)。
|
||||||
|
func attach(_ searcher: any TerminalSearching) {
|
||||||
|
self.searcher = searcher
|
||||||
|
}
|
||||||
|
|
||||||
|
func present() {
|
||||||
|
isPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭面板 —— 镜像 web `hide()`:清高亮(`hooks.clear()`)+ 清输入框。
|
||||||
|
func dismiss() {
|
||||||
|
isPresented = false
|
||||||
|
query = ""
|
||||||
|
lastAttempt = nil
|
||||||
|
searcher?.searchClear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 搜一次。空词零引擎调用;未绑定引擎(预览/测试)安全无操作。
|
||||||
|
func find(_ direction: TerminalSearchDirection) {
|
||||||
|
let term = query
|
||||||
|
guard TerminalSearchQuery.isSubmittable(term), let searcher else { return }
|
||||||
|
let didHit: Bool = switch direction {
|
||||||
|
case .next: searcher.searchNext(term: term)
|
||||||
|
case .previous: searcher.searchPrevious(term: term)
|
||||||
|
}
|
||||||
|
lastAttempt = Attempt(term: term, didHit: didHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View
|
||||||
|
|
||||||
|
/// 顶部右侧浮出的搜索条(位置对齐 web `#searchbox`:`position:fixed; top; right`)。
|
||||||
|
/// 输入框 submit = 下一处;↑/↓ 手动翻;× 关闭并清高亮。
|
||||||
|
struct TerminalSearchBar: View {
|
||||||
|
let model: TerminalSearchModel
|
||||||
|
/// 输入框首次出现即取焦(web `open()` 里的 `input.focus()`)。
|
||||||
|
@FocusState private var isFieldFocused: Bool
|
||||||
|
|
||||||
|
private enum Metrics {
|
||||||
|
static let fieldMinWidth: CGFloat = 140
|
||||||
|
static let fieldMaxWidth: CGFloat = 220
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: DS.Space.xs4) {
|
||||||
|
searchField
|
||||||
|
if let status = model.statusText {
|
||||||
|
Text(status)
|
||||||
|
.font(DS.Typography.caption)
|
||||||
|
.foregroundStyle(DS.Palette.statusStuck)
|
||||||
|
.accessibilityIdentifier("terminal.search.status")
|
||||||
|
}
|
||||||
|
iconButton(
|
||||||
|
systemImage: "chevron.up",
|
||||||
|
title: TerminalSearchModel.Copy.previous,
|
||||||
|
identifier: "terminal.search.previousButton"
|
||||||
|
) { model.find(.previous) }
|
||||||
|
iconButton(
|
||||||
|
systemImage: "chevron.down",
|
||||||
|
title: TerminalSearchModel.Copy.next,
|
||||||
|
identifier: "terminal.search.nextButton"
|
||||||
|
) { model.find(.next) }
|
||||||
|
iconButton(
|
||||||
|
systemImage: "xmark",
|
||||||
|
title: TerminalSearchModel.Copy.close,
|
||||||
|
identifier: "terminal.search.closeButton"
|
||||||
|
) { model.dismiss() }
|
||||||
|
}
|
||||||
|
.padding(.horizontal, DS.Space.sm8)
|
||||||
|
.padding(.vertical, DS.Space.xs4)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||||||
|
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
|
||||||
|
)
|
||||||
|
.onAppear { isFieldFocused = true }
|
||||||
|
.accessibilityLabel(TerminalSearchModel.Copy.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var searchField: some View {
|
||||||
|
TextField(
|
||||||
|
TerminalSearchModel.Copy.placeholder,
|
||||||
|
text: Binding(get: { model.query }, set: { model.query = $0 })
|
||||||
|
)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.font(DS.Typography.callout)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.submitLabel(.search)
|
||||||
|
.focused($isFieldFocused)
|
||||||
|
.frame(minWidth: Metrics.fieldMinWidth, maxWidth: Metrics.fieldMaxWidth)
|
||||||
|
.onSubmit { model.find(.next) }
|
||||||
|
.accessibilityIdentifier("terminal.search.field")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iconButton(
|
||||||
|
systemImage: String,
|
||||||
|
title: String,
|
||||||
|
identifier: String,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.frame(minWidth: DS.Layout.minHitTarget, minHeight: DS.Layout.minHitTarget)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(title)
|
||||||
|
.accessibilityIdentifier(identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
579
ios/App/WebTerm/Components/VoicePTT.swift
Normal file
579
ios/App/WebTerm/Components/VoicePTT.swift
Normal file
@@ -0,0 +1,579 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// T-iOS-31 · 语音 PTT(按住说话)+ 显式确认 + 1.5s 撤销 + epoch 防误发。
|
||||||
|
///
|
||||||
|
/// 本文件 = 缝 + 纯归约 + 状态机。同任务的另两块因 800 行硬上限拆出为兄弟文件:
|
||||||
|
/// `VoicePTTBanner.swift`(SwiftUI 状态卡)与 `SpeechDictation.swift`(真机识别器)。
|
||||||
|
///
|
||||||
|
/// 三件套逐条移植 web(plan §7「端口匹配器 / 1.5s 撤销 / epoch 防误发」= port
|
||||||
|
/// `public/voice-commands.ts` 的匹配器):
|
||||||
|
/// - `public/voice.ts`:PTT 生命周期,且 `autoSend` 默认 false ⇒ **口述绝不自己
|
||||||
|
/// 回车**(误听不得直接执行命令);
|
||||||
|
/// - `public/voice-commands.ts`:**整句精确**命令匹配器 + 否定守卫 + 0.6 置信度门;
|
||||||
|
/// - `public/voice-confirm.ts`:`DEFAULT_WINDOW_MS = 1500` 的可撤销窗口。
|
||||||
|
///
|
||||||
|
/// 安全边界(本文件是全部收口点):
|
||||||
|
/// 1. 转写来自系统识别器 = **不可信输入**:`VoiceTranscript.sanitize` 剥掉全部
|
||||||
|
/// C0/C1 控制字符(含 `\r`/ESC),折成单行、限长,然后才可能进 PTY;
|
||||||
|
/// 2. **确认前零注入**:`.confirming` 态不发一个字节,用户点「发送」才 arm;
|
||||||
|
/// 3. **1.5s 撤销窗口**:arm 后仍不发,到期才发 —— 字节一旦进 PTY 就撤不回来,
|
||||||
|
/// 所以撤销只能靠延迟发送(这是唯一可实现的语义);
|
||||||
|
/// 4. **epoch 两道闸**:口述开始时抓一次 epoch,`confirm()` 与窗口到期时各校验
|
||||||
|
/// 一次;不等就丢弃 —— 会话切换/重连之间的口述绝不可能注入到别的会话。
|
||||||
|
///
|
||||||
|
/// 识别器经 `VoiceDictating` 协议注入,故匹配器/窗口/epoch 全部可纯单测;真机
|
||||||
|
/// 口述走 `SpeechDictation`(需硬件麦克风,端到端验证属 DEFERRED)。
|
||||||
|
|
||||||
|
// MARK: - Recognizer seam
|
||||||
|
|
||||||
|
/// 授权结果(麦克风与语音识别是两个独立的 TCC 开关,文案必须分开)。
|
||||||
|
enum VoiceAuthorization: Equatable, Sendable {
|
||||||
|
case granted
|
||||||
|
case deniedMicrophone
|
||||||
|
case deniedSpeech
|
||||||
|
case restricted
|
||||||
|
|
||||||
|
var denialReason: VoiceDenialReason? {
|
||||||
|
switch self {
|
||||||
|
case .granted: nil
|
||||||
|
case .deniedMicrophone: .microphone
|
||||||
|
case .deniedSpeech: .speech
|
||||||
|
case .restricted: .restricted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 被拒原因 → 用户能照做的指引文案。
|
||||||
|
enum VoiceDenialReason: Equatable, Sendable {
|
||||||
|
case microphone
|
||||||
|
case speech
|
||||||
|
case restricted
|
||||||
|
|
||||||
|
var message: String {
|
||||||
|
switch self {
|
||||||
|
case .microphone:
|
||||||
|
"麦克风权限被拒绝。到 设置 → 隐私与安全性 → 麦克风 里打开 WebTerm 才能按住说话。"
|
||||||
|
case .speech:
|
||||||
|
"语音识别权限被拒绝。到 设置 → 隐私与安全性 → 语音识别 里打开 WebTerm。"
|
||||||
|
case .restricted:
|
||||||
|
"本设备的语音识别被限制(如「屏幕使用时间」策略),无法使用语音输入。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次口述的结果。`confidence` 只有识别器报了才有(供匹配器的置信度门用)。
|
||||||
|
struct VoiceDictationResult: Equatable, Sendable {
|
||||||
|
let transcript: String
|
||||||
|
let confidence: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 识别器缝。生产实现 `SpeechDictation`(Speech + AVAudioEngine),测试注入替身。
|
||||||
|
@MainActor
|
||||||
|
protocol VoiceDictating: AnyObject {
|
||||||
|
/// 申请麦克风 + 语音识别授权(两个 TCC 开关)。
|
||||||
|
func requestAuthorization() async -> VoiceAuthorization
|
||||||
|
/// 开始流式识别;中途假设经 `onPartial` 回来。
|
||||||
|
func start(onPartial: @escaping @MainActor (String) -> Void) async throws
|
||||||
|
/// 停止采集并给出当前最佳转写。
|
||||||
|
func stop() async -> VoiceDictationResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transcript sanitisation (security boundary)
|
||||||
|
|
||||||
|
/// 转写清洗:识别器输出是**不可信输入**,绝不原样进 PTY。
|
||||||
|
enum VoiceTranscript {
|
||||||
|
/// 单次口述允许注入的最大字符数。
|
||||||
|
static let maxLength = 2000
|
||||||
|
|
||||||
|
/// 制表/换行类空白(折成空格),其余 C0/C1 一律剥除。
|
||||||
|
private static let whitespaceControls: Set<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't→dont)→ 合并空白 → trim`(逐条对齐 web)。
|
||||||
|
static func normalize(_ raw: String) -> String {
|
||||||
|
let lowered = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let stripped = String(String.UnicodeScalarView(
|
||||||
|
lowered.unicodeScalars.filter { !punctuation.contains($0) }
|
||||||
|
))
|
||||||
|
return stripped.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 归一化后的话语是否以否定词起头(ASCII 否定词要求词边界,故 `now` 不算否定)。
|
||||||
|
static func startsWithNegation(_ normalized: String) -> Bool {
|
||||||
|
negationPrefixes.contains { prefix in
|
||||||
|
guard normalized.hasPrefix(prefix) else { return false }
|
||||||
|
guard let head = prefix.unicodeScalars.first, isASCIIWord(head) else { return true }
|
||||||
|
guard let next = normalized.dropFirst(prefix.count).unicodeScalars.first else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !isASCIIWord(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 匹配一次。任何非「整句精确 + gate 合格 + 置信度足够」的输入都落 `.text`。
|
||||||
|
static func match(transcript: String, context: VoiceMatchContext) -> VoiceAction {
|
||||||
|
let normalized = normalize(transcript)
|
||||||
|
guard context.pendingApproval, !normalized.isEmpty else { return .text }
|
||||||
|
guard context.gate == .tool else { return .text }
|
||||||
|
if startsWithNegation(normalized) {
|
||||||
|
return rejectPhrases.contains(normalized) ? .reject : .text
|
||||||
|
}
|
||||||
|
if rejectPhrases.contains(normalized) { return .reject }
|
||||||
|
guard approvePhrases.contains(normalized) else { return .text }
|
||||||
|
if let confidence = context.confidence, confidence < minApproveConfidence {
|
||||||
|
return .text
|
||||||
|
}
|
||||||
|
return .approve
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Epoch (mis-send guard)
|
||||||
|
|
||||||
|
/// 口述绑定的会话世代。`sessionId` 抓的是服务器采纳的 id,`generation` 每次**连接
|
||||||
|
/// 丢失**递增 —— 两者任一变化都作废在飞的口述。
|
||||||
|
struct VoiceEpoch: Equatable, Sendable {
|
||||||
|
let sessionId: UUID?
|
||||||
|
let generation: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// epoch 作废策略(纯谓词,单一判定点)。
|
||||||
|
enum VoiceEpochPolicy {
|
||||||
|
/// 该连接状态是否作废在飞的口述。
|
||||||
|
///
|
||||||
|
/// `.reconnecting` ⇒ 作废:连接掉过之后服务器**可能**给的是一条新 PTY(旧会话
|
||||||
|
/// 被 IDLE_TTL 回收时就会),而客户端在收到新的 `attached` 之前分辨不了。
|
||||||
|
/// 误丢弃只是重说一遍,误注入是把命令打进别的会话 —— 走安全方向。
|
||||||
|
static func invalidates(_ banner: TerminalViewModel.ConnectionBanner) -> Bool {
|
||||||
|
switch banner {
|
||||||
|
case .reconnecting: true
|
||||||
|
case .none, .connecting: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// epoch 源:由 `TerminalScreen` 喂入 `TerminalViewModel` 的 sessionId / banner 变化,
|
||||||
|
/// 供 PTT 在口述开始与注入时刻各取一次快照。
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class VoiceEpochSource {
|
||||||
|
private(set) var generation = 0
|
||||||
|
private(set) var sessionId: UUID?
|
||||||
|
|
||||||
|
var epoch: VoiceEpoch {
|
||||||
|
VoiceEpoch(sessionId: sessionId, generation: generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func noteSession(_ id: UUID?) {
|
||||||
|
sessionId = id
|
||||||
|
}
|
||||||
|
|
||||||
|
func noteBanner(_ banner: TerminalViewModel.ConnectionBanner) {
|
||||||
|
guard VoiceEpochPolicy.invalidates(banner) else { return }
|
||||||
|
generation += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pending action / resolution
|
||||||
|
|
||||||
|
/// 用户口述被判成什么动作。
|
||||||
|
enum VoiceIntent: Equatable, Sendable {
|
||||||
|
/// 普通口述文本(清洗后)。
|
||||||
|
case text(String)
|
||||||
|
/// 命令短语 —— 对已 held 的 tool gate 做决定。
|
||||||
|
case decision(VoiceDecision)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// gate 决定(approve/reject);映射到既有 gate 通道由 wiring 负责。
|
||||||
|
enum VoiceDecision: String, Equatable, Sendable {
|
||||||
|
case approve
|
||||||
|
case reject
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 待确认动作 —— 连同它**当时**的 epoch 一起冻结(防误发的核心)。
|
||||||
|
struct VoicePendingAction: Equatable, Sendable {
|
||||||
|
let intent: VoiceIntent
|
||||||
|
let epoch: VoiceEpoch
|
||||||
|
/// 给用户看的确认文案(清洗后的转写)。
|
||||||
|
let preview: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次口述最终怎么了 —— UI 的提示文案与测试的断言点。
|
||||||
|
enum VoiceResolution: Equatable, Sendable {
|
||||||
|
case committed(VoiceIntent)
|
||||||
|
case undone
|
||||||
|
case discarded
|
||||||
|
case staleEpoch
|
||||||
|
case empty
|
||||||
|
case readOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
/// gate 桥:上下文快照 + 决定出口**成对**注入。nil ⇒ 没有 gate 通道,匹配器恒
|
||||||
|
/// 退化为普通口述(与 web「没有 held gate 就一律 dictation」同义)。
|
||||||
|
struct VoiceGateBridge {
|
||||||
|
let snapshot: @MainActor () -> VoiceGateSnapshot
|
||||||
|
let decide: @MainActor (VoiceDecision) -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ViewModel
|
||||||
|
|
||||||
|
/// PTT 状态机。全部时间靠注入的 `Clock` 推进(测试零真睡)。
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class VoicePTTViewModel {
|
||||||
|
/// 可撤销窗口时长 —— 对齐 web `voice-confirm.ts` 的 `DEFAULT_WINDOW_MS = 1500`。
|
||||||
|
static let undoWindow: Duration = .milliseconds(1500)
|
||||||
|
|
||||||
|
enum Phase: Equatable {
|
||||||
|
case idle
|
||||||
|
case requestingPermission
|
||||||
|
case listening(partial: String)
|
||||||
|
case confirming(VoicePendingAction)
|
||||||
|
case undoWindow(VoicePendingAction)
|
||||||
|
case denied(VoiceDenialReason)
|
||||||
|
case failed(message: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户可见文案(named constants)。
|
||||||
|
enum Copy {
|
||||||
|
static let hold = "按住说话"
|
||||||
|
static let requesting = "正在请求麦克风权限…"
|
||||||
|
static let listening = "正在听…松手结束"
|
||||||
|
static let listeningHint = "松手后要你确认才会发送"
|
||||||
|
static let confirmTitle = "确认发送到终端"
|
||||||
|
static let approveTitle = "确认批准这次工具调用"
|
||||||
|
static let rejectTitle = "确认拒绝这次工具调用"
|
||||||
|
static let send = "发送"
|
||||||
|
static let cancel = "取消"
|
||||||
|
static let undo = "撤销"
|
||||||
|
static let sending = "1.5 秒后发送 —— 可撤销"
|
||||||
|
static let deciding = "1.5 秒后提交决定 —— 可撤销"
|
||||||
|
static let recognizerFailed = "语音识别没能启动"
|
||||||
|
static let noticeUndone = "已撤销,什么都没发送。"
|
||||||
|
static let noticeDiscarded = "已丢弃这段口述。"
|
||||||
|
static let noticeStaleEpoch = "会话已切换或重连过,这段口述没有发送(避免打进别的会话)。"
|
||||||
|
static let noticeEmpty = "没听到内容,什么都没发送。"
|
||||||
|
static let noticeReadOnly = "这个会话已结束,不能再输入。"
|
||||||
|
static let dismissNotice = "关闭提示"
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var phase: Phase = .idle
|
||||||
|
private(set) var lastResolution: VoiceResolution?
|
||||||
|
|
||||||
|
@ObservationIgnored private let dictation: any VoiceDictating
|
||||||
|
@ObservationIgnored private let clock: any Clock<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
143
ios/App/WebTerm/Components/VoicePTTBanner.swift
Normal file
143
ios/App/WebTerm/Components/VoicePTTBanner.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ import Observation
|
|||||||
import OSLog
|
import OSLog
|
||||||
import WireProtocol
|
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
|
/// - `webterminal://open?host=<uuid>&join=<uuid>` (scheme registered in
|
||||||
/// project.yml `CFBundleURLTypes`; web 分享 QR 互通的 `?join=` 解析是
|
/// project.yml `CFBundleURLTypes`),
|
||||||
/// T-iOS-35 的增量), and
|
/// - **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
|
/// - the WEBTERM_GATE push payload `{sessionId}` (T-iOS-21 reuses
|
||||||
/// `route(from:)` — same UUID rules, no second parser).
|
/// `route(from:)` — same UUID rules, no second parser).
|
||||||
///
|
///
|
||||||
@@ -25,6 +27,11 @@ enum DeepLinkRouter {
|
|||||||
enum Route: Equatable, Sendable {
|
enum Route: Equatable, Sendable {
|
||||||
/// `webterminal://open` with both ids syntactically valid.
|
/// `webterminal://open` with both ids syntactically valid.
|
||||||
case openSession(hostId: UUID, sessionId: UUID)
|
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 —
|
/// Push payload with a valid `sessionId` (no host id in the payload —
|
||||||
/// T-iOS-21's notification handler owns the resolution strategy).
|
/// T-iOS-21's notification handler owns the resolution strategy).
|
||||||
case gateSession(sessionId: UUID)
|
case gateSession(sessionId: UUID)
|
||||||
@@ -40,6 +47,19 @@ enum DeepLinkRouter {
|
|||||||
static let emptyPaths: Set<String> = ["", "/"]
|
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).
|
/// Whitelisted query keys (exact match; unknown keys are ignored).
|
||||||
private enum QueryKey {
|
private enum QueryKey {
|
||||||
static let host = "host"
|
static let host = "host"
|
||||||
@@ -55,8 +75,21 @@ enum DeepLinkRouter {
|
|||||||
|
|
||||||
static func route(url: URL) -> Route {
|
static func route(url: URL) -> Route {
|
||||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||||
components.scheme?.lowercased() == LinkShape.scheme,
|
let scheme = components.scheme?.lowercased(),
|
||||||
components.host?.lowercased() == LinkShape.action,
|
// 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),
|
LinkShape.emptyPaths.contains(components.path),
|
||||||
let hostId = uniqueValidatedId(in: components, key: QueryKey.host),
|
let hostId = uniqueValidatedId(in: components, key: QueryKey.host),
|
||||||
let sessionId = uniqueValidatedId(in: components, key: QueryKey.join)
|
let sessionId = uniqueValidatedId(in: components, key: QueryKey.join)
|
||||||
@@ -64,6 +97,23 @@ enum DeepLinkRouter {
|
|||||||
return .openSession(hostId: hostId, sessionId: sessionId)
|
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)
|
// MARK: - Push entry (WEBTERM_GATE payload, reused by T-iOS-21)
|
||||||
|
|
||||||
static func route(from payload: [AnyHashable: Any]) -> Route {
|
static func route(from payload: [AnyHashable: Any]) -> Route {
|
||||||
@@ -175,12 +225,33 @@ final class DeepLinkHandler {
|
|||||||
// MARK: - Apply (host id resolves ONLY through the store)
|
// MARK: - Apply (host id resolves ONLY through the store)
|
||||||
|
|
||||||
private func apply(_ route: DeepLinkRouter.Route) async {
|
private func apply(_ route: DeepLinkRouter.Route) async {
|
||||||
// `.gateSession` never reaches here in P1: it only exists for the
|
switch route {
|
||||||
// push path, whose handling (host resolution incl.) is T-iOS-21.
|
case let .openSession(hostId, sessionId):
|
||||||
guard case let .openSession(hostId, sessionId) = route else { return }
|
// 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 {
|
do {
|
||||||
let hosts = try await loadHosts()
|
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
|
hintMessage = DeepLinkCopy.unknownHostHint
|
||||||
actions.showPairing()
|
actions.showPairing()
|
||||||
return
|
return
|
||||||
|
|||||||
125
ios/App/WebTerm/DesignSystem/AppTheme.swift
Normal file
125
ios/App/WebTerm/DesignSystem/AppTheme.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,9 @@ struct TelemetryChip: View {
|
|||||||
.background(.quaternary, in: Capsule())
|
.background(.quaternary, in: Capsule())
|
||||||
.opacity(isStale ? DS.Opacity.stale : 1)
|
.opacity(isStale ? DS.Opacity.stale : 1)
|
||||||
.grayscale(isStale ? 1 : 0)
|
.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 }
|
enum Kind { case primary, secondary, destructive }
|
||||||
var kind: Kind = .primary
|
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 {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
DSButtonBody(kind: kind, configuration: configuration)
|
DSButtonBody(kind: kind, configuration: configuration)
|
||||||
}
|
}
|
||||||
@@ -137,6 +146,12 @@ struct DSButtonStyle: ButtonStyle {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
.font(DS.Typography.body.weight(.semibold))
|
.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)
|
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
|
||||||
.foregroundStyle(foreground)
|
.foregroundStyle(foreground)
|
||||||
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||||
|
|||||||
98
ios/App/WebTerm/DesignSystem/TerminalPalette.swift
Normal file
98
ios/App/WebTerm/DesignSystem/TerminalPalette.swift
Normal 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 里
|
||||||
|
/// 就是这个写法,直接用它可以逐字节对照,不必手算 0–1 分量。
|
||||||
|
convenience init(hex: UInt32, alpha: CGFloat = 1) {
|
||||||
|
self.init(
|
||||||
|
red: CGFloat((hex >> 16) & 0xFF) / 255,
|
||||||
|
green: CGFloat((hex >> 8) & 0xFF) / 255,
|
||||||
|
blue: CGFloat(hex & 0xFF) / 255,
|
||||||
|
alpha: alpha
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,16 @@ import UIKit
|
|||||||
/// (refined native, Apple HIG), dark-mode-first, amber-gold accent (desktop-matched)
|
/// (refined native, Apple HIG), dark-mode-first, amber-gold accent (desktop-matched)
|
||||||
/// continuing the web selection color.
|
/// 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.`):
|
/// Vocabulary (all under `DS.`):
|
||||||
/// - `Palette` — adaptive `accent` (amber gold, matches desktop) · semantic `status*` colors
|
/// - `Palette` — adaptive `accent` (amber gold, matches desktop) · semantic `status*` colors
|
||||||
/// (color is NEVER the only status signal — pair with a symbol,
|
/// (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).
|
/// ink for contrast — mirrors web --on-accent #1A1305).
|
||||||
static let onAccent = rgb(26, 19, 5)
|
static let onAccent = rgb(26, 19, 5)
|
||||||
/// Faint accent wash (selection/soft highlight) — web --accent-soft.
|
/// 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) ───
|
// ── Semantic status colors (match the desktop/web status palette) ───
|
||||||
// These are the ONLY status colors. `StatusStyle` pairs each with a
|
// These are the ONLY status colors. `StatusStyle` pairs each with a
|
||||||
// distinct SF Symbol so status is never conveyed by color alone. Hex
|
// distinct SF Symbol so status is never conveyed by color alone. The
|
||||||
// mirrors the web's warm status set (public/style.css:18-20) so iOS and
|
// DARK values mirror the web's warm status set (public/style.css:18-20)
|
||||||
// desktop read the same. `waiting` amber #F5B14C stays distinct from the
|
// byte for byte so iOS and desktop read the same; `waiting` amber
|
||||||
// gold accent #E3A64A (brighter/less brown), and is only ever a small
|
// #F5B14C stays distinct from the gold accent #E3A64A (brighter/less
|
||||||
// badge fill, never a large accent surface.
|
// brown), and is only ever a small badge fill, never a large surface.
|
||||||
/// working — #46D07F (web --green).
|
//
|
||||||
static let statusWorking = rgb(70, 208, 127)
|
// T-iOS-34 · the LIGHT values are new. The web chrome is dark-only, so
|
||||||
/// waiting / needs-me — #F5B14C (web --amber).
|
// there was nothing to mirror: each light value is the same hue darkened
|
||||||
static let statusWaiting = rgb(245, 177, 76)
|
// until it clears WCAG 1.4.11 (3:1 for non-text UI) against a white
|
||||||
/// stuck — #FF6B6B (web --red).
|
// background — the bright dark-mode values sit at 1.96:1 (#46D07F),
|
||||||
static let statusStuck = rgb(255, 107, 107)
|
// 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).
|
/// idle — quiet secondary gray (distinguished from `unknown` by shape).
|
||||||
static let statusIdle = Color.secondary
|
static let statusIdle = Color.secondary
|
||||||
/// unknown / no signal yet — gray.
|
/// unknown / no signal yet — gray.
|
||||||
@@ -86,10 +111,12 @@ enum DS {
|
|||||||
// and `stuck` reuse the status tokens above (same meaning); `done`
|
// and `stuck` reuse the status tokens above (same meaning); `done`
|
||||||
// reuses `statusWorking` (a completed run). `tool`/`user` get their own
|
// reuses `statusWorking` (a completed run). `tool`/`user` get their own
|
||||||
// tokens so nothing in the app hardcodes a raw SwiftUI color.
|
// tokens so nothing in the app hardcodes a raw SwiftUI color.
|
||||||
/// tool run — indigo, tied to the app accent family (#5E9EFF-ish).
|
/// tool run — dark #5E9EFF · light #2C6ED6 (4.8:1 on white; the bright
|
||||||
static let timelineTool = rgb(94, 158, 255)
|
/// blue is 2.63:1 there).
|
||||||
/// user message — violet (#AF7BFF), distinct from accent & tool.
|
static let timelineTool = adaptive(dark: 0x5E9E_FF, light: 0x2C6E_D6)
|
||||||
static let timelineUser = rgb(175, 123, 255)
|
/// 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 ────────────────────────────────────────────────
|
// ── Surfaces & text ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -106,18 +133,48 @@ enum DS {
|
|||||||
/// Tertiary label color (de-emphasized detail).
|
/// Tertiary label color (de-emphasized detail).
|
||||||
static let textTertiary = Color(uiColor: .tertiaryLabel)
|
static let textTertiary = Color(uiColor: .tertiaryLabel)
|
||||||
|
|
||||||
// ── Terminal canvas (fixed warm-dark, matches the desktop terminal) ──
|
// ── Terminal canvas (theme-following as of T-iOS-34) ────────────────
|
||||||
// A terminal reads as dark regardless of app appearance (like the
|
// Was a FIXED warm-dark canvas ("a terminal reads as dark regardless of
|
||||||
// desktop). Values mirror the web chrome: --bg #100F0D / --text #ECE9E3.
|
// app appearance"). With a real light theme that stops being true — a
|
||||||
/// Terminal background — warm near-black #100F0D (web --bg).
|
// full-screen near-black slab is the loudest thing on a light UI. The
|
||||||
static let terminalBackground = rgb(16, 15, 13)
|
// two schemes now live in `TerminalPalette` (which also vends the
|
||||||
/// Terminal foreground — warm off-white #ECE9E3 (web --text).
|
// already-resolved set SwiftTerm needs); these stay as the SwiftUI-side
|
||||||
static let terminalForeground = rgb(236, 233, 227)
|
// tokens so existing call sites keep compiling and become adaptive.
|
||||||
|
/// Terminal background — dark #100F0D (web --bg) · light #F6F7F9.
|
||||||
|
static let terminalBackground = Color(uiColor: terminalBackgroundUIColor())
|
||||||
|
/// Terminal foreground — dark #ECE9E3 (web --text) · light #1A1D24.
|
||||||
|
static let terminalForeground = Color(uiColor: terminalForegroundUIColor())
|
||||||
|
|
||||||
|
/// Dynamic terminal background. Exposed as `UIColor` because SwiftTerm's
|
||||||
|
/// `nativeBackgroundColor` takes UIKit colors AND flattens them on set
|
||||||
|
/// (see `TerminalPalette`) — the bridge must not go through SwiftUI.
|
||||||
|
static func terminalBackgroundUIColor() -> UIColor {
|
||||||
|
TerminalPalette.dynamicBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dynamic terminal foreground (same rationale as the background).
|
||||||
|
static func terminalForegroundUIColor() -> UIColor {
|
||||||
|
TerminalPalette.dynamicForeground()
|
||||||
|
}
|
||||||
|
|
||||||
/// Build an opaque sRGB color from 0–255 components.
|
/// Build an opaque sRGB color from 0–255 components.
|
||||||
private static func rgb(_ r: Double, _ g: Double, _ b: Double) -> Color {
|
private static func rgb(_ r: Double, _ g: Double, _ b: Double) -> Color {
|
||||||
Color(.sRGB, red: r / 255, green: g / 255, blue: b / 255, opacity: 1)
|
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
|
// MARK: - Space
|
||||||
|
|||||||
@@ -37,6 +37,23 @@ extension DS {
|
|||||||
|
|
||||||
/// The canonical meta-number font: caption-sized mono + tabular.
|
/// The canonical meta-number font: caption-sized mono + tabular.
|
||||||
static let metaMono = mono(.caption)
|
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
|
/// Secondary + monospaced-tabular styling for a row's numeric meta line
|
||||||
/// ("N 台设备在看 · 161×50"). Apply via `.dsMetaText()`.
|
/// ("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 {
|
struct MetaText: ViewModifier {
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.font(DS.Typography.metaMono)
|
.font(DS.Typography.metaMono)
|
||||||
.foregroundStyle(DS.Palette.textSecondary)
|
.foregroundStyle(DS.Palette.textSecondary)
|
||||||
|
.dynamicTypeSize(...DS.Typography.numericClamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,10 +191,18 @@ final class PushRegistrar {
|
|||||||
logger.error("remote notification registration failed: \(error)")
|
logger.error("remote notification registration failed: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 主机移除时注销该主机上的 device token(**additive hook**:当前 App
|
/// 主机移除时注销该主机上的 device token。
|
||||||
/// 层尚无移除主机的 UI 路径——`HostStore.remove(id:)` 无消费者;未来的
|
///
|
||||||
/// 移除路径应调用本方法。失败仅记日志:服务器侧对失效 token 也会经
|
/// C1 · 这个钩子曾经**没有调用方**(App 层没有移除主机的 UI,
|
||||||
/// APNs 410 自行清理)。
|
/// `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 {
|
func handleHostRemoved(_ host: HostRegistry.Host) async {
|
||||||
registeredHostIds.remove(host.id)
|
registeredHostIds.remove(host.id)
|
||||||
guard let token = currentTokenHex else { return }
|
guard let token = currentTokenHex else { return }
|
||||||
|
|||||||
268
ios/App/WebTerm/Screens/GitPanelScreen.swift
Normal file
268
ios/App/WebTerm/Screens/GitPanelScreen.swift
Normal 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 "未跟踪"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
359
ios/App/WebTerm/Screens/GitPanelViews.swift
Normal file
359
ios/App/WebTerm/Screens/GitPanelViews.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
ios/App/WebTerm/Screens/PairingCopy.swift
Normal file
84
ios/App/WebTerm/Screens/PairingCopy.swift
Normal 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 =
|
||||||
|
"访问令牌格式不符合要求(16–512 位,且只能包含 A-Z a-z 0-9 . _ ~ + / = -)。"
|
||||||
|
static let hostsLoadFailed =
|
||||||
|
"无法读取已配对的主机列表(钥匙串读取失败)。"
|
||||||
|
static let hostRemoveFailed =
|
||||||
|
"移除主机失败(钥匙串写入失败),请重试。"
|
||||||
|
|
||||||
|
/// Turn the typed `AccessTokenError` into field-level copy. The length cases
|
||||||
|
/// report the length only — that is not secret, and it is exactly what tells
|
||||||
|
/// the user whether they pasted a truncated value.
|
||||||
|
static func tokenShapeRejected(_ error: AccessTokenError) -> String {
|
||||||
|
switch error {
|
||||||
|
case .tooShort(let length):
|
||||||
|
return "访问令牌太短(\(length) 位,至少 \(AccessToken.minLength) 位)。"
|
||||||
|
case .tooLong(let length):
|
||||||
|
return "访问令牌太长(\(length) 位,最多 \(AccessToken.maxLength) 位)。"
|
||||||
|
case .invalidCharacters:
|
||||||
|
return "访问令牌含有不允许的字符(只能是 A-Z a-z 0-9 . _ ~ + / = -)。"
|
||||||
|
case .unknownHost:
|
||||||
|
return hostsLoadFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hostUnreachable(_ underlying: String) -> String {
|
||||||
|
"无法连接主机:\(underlying)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3.4 wording for the ATS cleartext block.
|
||||||
|
static func atsBlocked(host: String) -> String {
|
||||||
|
"明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内,"
|
||||||
|
+ "请改用 https / tailscale serve,或反馈该网段。"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ struct PairingScreen: View {
|
|||||||
@State private var manualURLText = ""
|
@State private var manualURLText = ""
|
||||||
@State private var isShowingScanner = false
|
@State private var isShowingScanner = false
|
||||||
@State private var scannerError: String?
|
@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 {
|
var body: some View {
|
||||||
content
|
content
|
||||||
@@ -37,47 +43,162 @@ struct PairingScreen: View {
|
|||||||
probingView(pending)
|
probingView(pending)
|
||||||
case .failed(let pending, let failure):
|
case .failed(let pending, let failure):
|
||||||
FailureView(pending: pending, failure: failure, viewModel: viewModel)
|
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):
|
case .paired(let host):
|
||||||
pairedView(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 {
|
ScrollView {
|
||||||
VStack(spacing: DS.Space.xl20) {
|
VStack(spacing: DS.Space.xl20) {
|
||||||
VStack(spacing: DS.Space.md12) { // inviting hero
|
tokenFieldCard(pending, isValidating: isValidating)
|
||||||
Image(systemName: "desktopcomputer")
|
if let rejection = viewModel.tokenRejection {
|
||||||
.font(DS.Typography.largeTitle)
|
Label(rejection, systemImage: "exclamationmark.circle.fill")
|
||||||
.foregroundStyle(DS.Palette.accent)
|
.font(DS.Typography.caption)
|
||||||
.padding(.top, DS.Space.sm8)
|
.foregroundStyle(DS.Palette.statusStuck)
|
||||||
Text(ScreenCopy.heroTitle)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.font(DS.Typography.title)
|
|
||||||
.foregroundStyle(DS.Palette.textPrimary)
|
|
||||||
Text(ScreenCopy.heroSubtitle)
|
|
||||||
.font(DS.Typography.callout)
|
|
||||||
.foregroundStyle(DS.Palette.textSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
VStack(spacing: DS.Space.md12) {
|
||||||
Card {
|
if isValidating {
|
||||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
ProgressView()
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
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 {
|
if let rejection = viewModel.inputRejection {
|
||||||
Label(rejection, systemImage: "exclamationmark.circle.fill")
|
Label(rejection, systemImage: "exclamationmark.circle.fill")
|
||||||
.font(DS.Typography.caption)
|
.font(DS.Typography.caption)
|
||||||
@@ -97,10 +218,72 @@ struct PairingScreen: View {
|
|||||||
.font(DS.Typography.caption)
|
.font(DS.Typography.caption)
|
||||||
.foregroundStyle(DS.Palette.textSecondary)
|
.foregroundStyle(DS.Palette.textSecondary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
pairedHostsSection
|
||||||
}
|
}
|
||||||
.padding(DS.Space.lg16)
|
.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 {
|
@ViewBuilder private var scannerSheet: some View {
|
||||||
@@ -277,12 +460,22 @@ private struct FailureView: View {
|
|||||||
}
|
}
|
||||||
VStack(spacing: DS.Space.md12) {
|
VStack(spacing: DS.Space.md12) {
|
||||||
let needsSettings = failure.action == .openLocalNetworkSettings
|
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 {
|
if needsSettings {
|
||||||
Button(ScreenCopy.openSettings) { openAppSettings() }
|
Button(ScreenCopy.openSettings) { openAppSettings() }
|
||||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
.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() } }
|
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() }
|
Button(ScreenCopy.back) { viewModel.cancel() }
|
||||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||||
}
|
}
|
||||||
@@ -391,6 +584,24 @@ private enum ScreenCopy {
|
|||||||
static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
|
static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
|
||||||
static let publicAcknowledge = "我已了解风险,仍要连接"
|
static let publicAcknowledge = "我已了解风险,仍要连接"
|
||||||
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
|
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 probing(_ address: String) -> String { "正在验证 \(address) …" }
|
||||||
static func paired(_ name: String) -> String { "已配对:\(name)" }
|
static func paired(_ name: String) -> String { "已配对:\(name)" }
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import WireProtocol
|
|||||||
/// T-iOS-26 · 项目详情屏:sessions/worktrees/CLAUDE.md 渲染 + diff 入口
|
/// T-iOS-26 · 项目详情屏:sessions/worktrees/CLAUDE.md 渲染 + diff 入口
|
||||||
/// (T-iOS-27 的 `DiffScreen(endpoint:path:http:)`)+ "在此仓库开新会话"。
|
/// (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 内容全是**不可信服务器字节** ——
|
/// 安全:名字/路径/分支/CLAUDE.md 内容全是**不可信服务器字节** ——
|
||||||
/// 一律 `Text(verbatim:)`(绝不 LocalizedStringKey/Markdown/链接探测)。
|
/// 一律 `Text(verbatim:)`(绝不 LocalizedStringKey/Markdown/链接探测)。
|
||||||
struct ProjectDetailScreen: View {
|
struct ProjectDetailScreen: View {
|
||||||
@@ -12,7 +20,30 @@ struct ProjectDetailScreen: View {
|
|||||||
private let endpoint: HostEndpoint
|
private let endpoint: HostEndpoint
|
||||||
private let http: any HTTPTransport
|
private let http: any HTTPTransport
|
||||||
private let onOpenClaude: (String) -> Void
|
private let onOpenClaude: (String) -> Void
|
||||||
@State private var isDiffPresented = false
|
private let onResumeClaude: (String, String) -> Void
|
||||||
|
|
||||||
|
/// 详情屏级别的 worktree 状态机:只负责 prune / remove(create 归 sheet 自己的
|
||||||
|
/// 实例,两个状态机互不干扰)。
|
||||||
|
@State private var worktreeActions: WorktreeViewModel
|
||||||
|
@State private var route: DetailRoute?
|
||||||
|
@State private var sheet: DetailSheet?
|
||||||
|
@State private var worktreePendingRemoval: WorktreeInfo?
|
||||||
|
@State private var isPruneConfirmPresented = false
|
||||||
|
|
||||||
|
/// 推入式子屏(单一 destination,避免多个 `isPresented:` 目的地互相打断)。
|
||||||
|
private enum DetailRoute: Hashable, Identifiable {
|
||||||
|
case diff
|
||||||
|
case gitPanel
|
||||||
|
|
||||||
|
var id: Self { self }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum DetailSheet: Hashable, Identifiable {
|
||||||
|
case newWorktree
|
||||||
|
case resumeHistory
|
||||||
|
|
||||||
|
var id: Self { self }
|
||||||
|
}
|
||||||
|
|
||||||
private enum Metrics {
|
private enum Metrics {
|
||||||
/// CLAUDE.md 预览行数上限(内容裁剪,非视觉 token)。
|
/// CLAUDE.md 预览行数上限(内容裁剪,非视觉 token)。
|
||||||
@@ -23,12 +54,17 @@ struct ProjectDetailScreen: View {
|
|||||||
viewModel: ProjectDetailViewModel,
|
viewModel: ProjectDetailViewModel,
|
||||||
endpoint: HostEndpoint,
|
endpoint: HostEndpoint,
|
||||||
http: any HTTPTransport,
|
http: any HTTPTransport,
|
||||||
onOpenClaude: @escaping (String) -> Void
|
onOpenClaude: @escaping (String) -> Void,
|
||||||
|
onResumeClaude: @escaping (String, String) -> Void
|
||||||
) {
|
) {
|
||||||
_viewModel = State(initialValue: viewModel)
|
_viewModel = State(initialValue: viewModel)
|
||||||
self.endpoint = endpoint
|
self.endpoint = endpoint
|
||||||
self.http = http
|
self.http = http
|
||||||
self.onOpenClaude = onOpenClaude
|
self.onOpenClaude = onOpenClaude
|
||||||
|
self.onResumeClaude = onResumeClaude
|
||||||
|
_worktreeActions = State(initialValue: .forProject(
|
||||||
|
endpoint: endpoint, http: http, path: viewModel.path
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -36,14 +72,118 @@ struct ProjectDetailScreen: View {
|
|||||||
.navigationTitle(ProjectDetailCopy.title)
|
.navigationTitle(ProjectDetailCopy.title)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.task { await viewModel.load() }
|
.task { await viewModel.load() }
|
||||||
.navigationDestination(isPresented: $isDiffPresented) {
|
.navigationDestination(item: $route) { destination(for: $0) }
|
||||||
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
|
.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
|
// MARK: - Phase switch
|
||||||
|
|
||||||
@ViewBuilder private var content: some View {
|
@ViewBuilder private var phaseContent: some View {
|
||||||
switch viewModel.phase {
|
switch viewModel.phase {
|
||||||
case .loading:
|
case .loading:
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -93,8 +233,8 @@ struct ProjectDetailScreen: View {
|
|||||||
List {
|
List {
|
||||||
headerSection(detail)
|
headerSection(detail)
|
||||||
actionsSection(detail)
|
actionsSection(detail)
|
||||||
sessionsSection(detail.sessions)
|
sessionsSection(detail)
|
||||||
worktreesSection(detail.worktrees)
|
worktreesSection(detail)
|
||||||
claudeMdSection(detail)
|
claudeMdSection(detail)
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
@@ -127,6 +267,13 @@ struct ProjectDetailScreen: View {
|
|||||||
DirtyBadge()
|
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)
|
.listRowBackground(Color.clear)
|
||||||
if detail.isGit {
|
if detail.isGit {
|
||||||
Button {
|
secondaryAction(GitPanelCopy.entryLabel, symbol: "point.3.filled.connected.trianglepath.dotted") {
|
||||||
isDiffPresented = true
|
route = .gitPanel
|
||||||
} label: {
|
}
|
||||||
Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus")
|
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) {
|
Section(ProjectDetailCopy.sessionsHeader) {
|
||||||
if sessions.isEmpty {
|
if detail.sessions.isEmpty {
|
||||||
Text(ProjectDetailCopy.noSessions)
|
Text(ProjectDetailCopy.noSessions)
|
||||||
.font(DS.Typography.caption)
|
.font(DS.Typography.caption)
|
||||||
.foregroundStyle(DS.Palette.textSecondary)
|
.foregroundStyle(DS.Palette.textSecondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(sessions, id: \.id) { session in
|
ForEach(detail.sessions, id: \.id) { session in
|
||||||
sessionRow(session)
|
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 {
|
// MARK: - Worktrees(T-iOS-32:读 + 建 + 回收 + 删)
|
||||||
if !worktrees.isEmpty {
|
|
||||||
Section(ProjectDetailCopy.worktreesHeader) {
|
/// git 仓库一律显示本区(哪怕只有一个 worktree)—— 标题固定,"空"不等于"没查"
|
||||||
ForEach(worktrees, id: \.path) { worktree in
|
/// (w6/G5 的同一条纪律)。
|
||||||
|
@ViewBuilder private func worktreesSection(_ detail: ProjectDetail) -> some View {
|
||||||
|
if detail.isGit {
|
||||||
|
Section {
|
||||||
|
ForEach(detail.worktrees, id: \.path) { worktree in
|
||||||
worktreeRow(worktree)
|
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 {
|
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
|
||||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||||
HStack(spacing: DS.Space.sm8) {
|
HStack(spacing: DS.Space.sm8) {
|
||||||
@@ -225,12 +452,28 @@ struct ProjectDetailScreen: View {
|
|||||||
if worktree.locked == true {
|
if worktree.locked == true {
|
||||||
TagBadge(text: ProjectDetailCopy.worktreeLocked)
|
TagBadge(text: ProjectDetailCopy.worktreeLocked)
|
||||||
}
|
}
|
||||||
|
if worktree.prunable == true {
|
||||||
|
TagBadge(text: ProjectDetailCopy.worktreePrunable)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(verbatim: worktree.path)
|
Text(verbatim: worktree.path)
|
||||||
.font(DS.Typography.mono(.caption))
|
.font(DS.Typography.mono(.caption))
|
||||||
.foregroundStyle(DS.Palette.textSecondary)
|
.foregroundStyle(DS.Palette.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.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 {
|
struct TagBadge: View {
|
||||||
let text: String
|
let text: String
|
||||||
|
|
||||||
@@ -297,6 +540,8 @@ enum ProjectDetailCopy {
|
|||||||
static let worktreeMain = "主"
|
static let worktreeMain = "主"
|
||||||
static let worktreeCurrent = "当前"
|
static let worktreeCurrent = "当前"
|
||||||
static let worktreeLocked = "已锁定"
|
static let worktreeLocked = "已锁定"
|
||||||
|
static let worktreePrunable = "可回收"
|
||||||
|
static let worktreeSwipeHint = "左滑一行可删除该 worktree(主 worktree 与已锁定的不可删)。"
|
||||||
static let detachedHead = "(分离 HEAD)"
|
static let detachedHead = "(分离 HEAD)"
|
||||||
static let claudeMdHeader = "CLAUDE.md"
|
static let claudeMdHeader = "CLAUDE.md"
|
||||||
static let claudeMdPresent = "本仓库包含 CLAUDE.md。"
|
static let claudeMdPresent = "本仓库包含 CLAUDE.md。"
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ struct ProjectsScreen: View {
|
|||||||
viewModel: viewModel.makeDetailViewModel(path: route.path),
|
viewModel: viewModel.makeDetailViewModel(path: route.path),
|
||||||
endpoint: viewModel.host.endpoint,
|
endpoint: viewModel.host.endpoint,
|
||||||
http: viewModel.http,
|
http: viewModel.http,
|
||||||
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) }
|
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) },
|
||||||
|
// T-iOS-32(C2):历史会话恢复走同一条开会话缝。
|
||||||
|
onResumeClaude: { cwd, sessionId in
|
||||||
|
viewModel.requestResumeClaude(cwd: cwd, sessionId: sessionId)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// T-iPad-4 · iPhone 透传(现有全高 sheet 字节级不变);iPad 套
|
// T-iPad-4 · iPhone 透传(现有全高 sheet 字节级不变);iPad 套
|
||||||
|
|||||||
116
ios/App/WebTerm/Screens/ResumeHistorySheet.swift
Normal file
116
ios/App/WebTerm/Screens/ResumeHistorySheet.swift
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -224,6 +224,17 @@ struct SessionListScreen: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label(ScreenCopy.addHost, systemImage: "plus")
|
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 {
|
Button {
|
||||||
onEnroll()
|
onEnroll()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -365,6 +376,8 @@ private enum ScreenCopy {
|
|||||||
static let newSession = "新建会话"
|
static let newSession = "新建会话"
|
||||||
static let kill = "结束"
|
static let kill = "结束"
|
||||||
static let addHost = "配对新主机"
|
static let addHost = "配对新主机"
|
||||||
|
/// C1 · Access token + 移除主机 (same sheet as `addHost` — see the menu).
|
||||||
|
static let manageHosts = "管理主机与令牌"
|
||||||
static let deviceCert = "设备证书"
|
static let deviceCert = "设备证书"
|
||||||
static let enroll = "自动获取证书"
|
static let enroll = "自动获取证书"
|
||||||
static let hostMenuFallback = "主机"
|
static let hostMenuFallback = "主机"
|
||||||
|
|||||||
122
ios/App/WebTerm/Screens/SettingsScreen.swift
Normal file
122
ios/App/WebTerm/Screens/SettingsScreen.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,13 @@ struct TerminalScreen: View {
|
|||||||
/// T-iPad-3 · 用户对 KeyBar 的显式覆盖:nil = 跟随自动默认。
|
/// T-iPad-3 · 用户对 KeyBar 的显式覆盖:nil = 跟随自动默认。
|
||||||
@State private var keyBarUserOverride: Bool?
|
@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)。
|
/// 无障碍:减弱动态效果时,横幅进出塌成即时切换(DS.Motion.gated)。
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
@@ -46,6 +53,33 @@ struct TerminalScreen: View {
|
|||||||
static let hideKeyBar = "隐藏快捷键栏"
|
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` 纯谓词。
|
/// KeyBar 是否可见 —— 唯一判据经 `KeyBarVisibility` 纯谓词。
|
||||||
private var isKeyBarVisible: Bool {
|
private var isKeyBarVisible: Bool {
|
||||||
KeyBarVisibility.isVisible(
|
KeyBarVisibility.isVisible(
|
||||||
@@ -58,6 +92,8 @@ struct TerminalScreen: View {
|
|||||||
TerminalHostView(
|
TerminalHostView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
keyBarVisible: isKeyBarVisible,
|
keyBarVisible: isKeyBarVisible,
|
||||||
|
searchModel: searchModel,
|
||||||
|
voiceModel: voiceModel,
|
||||||
onNewSessionInCwd: onNewSessionInCwd,
|
onNewSessionInCwd: onNewSessionInCwd,
|
||||||
onKillSession: onKillSession
|
onKillSession: onKillSession
|
||||||
)
|
)
|
||||||
@@ -70,11 +106,37 @@ struct TerminalScreen: View {
|
|||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
.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(
|
.animation(
|
||||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||||
value: viewModel.bannerModel
|
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 {
|
.toolbar {
|
||||||
|
searchToolbarItem
|
||||||
newSessionToolbarItem
|
newSessionToolbarItem
|
||||||
keyBarToggleToolbarItem
|
keyBarToggleToolbarItem
|
||||||
}
|
}
|
||||||
@@ -84,7 +146,34 @@ struct TerminalScreen: View {
|
|||||||
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
|
||||||
hasHardwareKeyboard = GCKeyboard.coalesced != nil
|
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 手动切换。仅在硬件键盘在场时出现 —— 无硬件键盘的
|
/// T-iPad-3 · KeyBar 手动切换。仅在硬件键盘在场时出现 —— 无硬件键盘的
|
||||||
@@ -155,6 +244,10 @@ private struct TerminalHostView: UIViewRepresentable {
|
|||||||
/// T-iPad-3 · KeyBar (`inputAccessoryView`) 可见性,由 `KeyBarVisibility`
|
/// T-iPad-3 · KeyBar (`inputAccessoryView`) 可见性,由 `KeyBarVisibility`
|
||||||
/// 谓词在 SwiftUI 侧算出;`updateUIView` 仅在变化时增量应用。
|
/// 谓词在 SwiftUI 侧算出;`updateUIView` 仅在变化时增量应用。
|
||||||
let keyBarVisible: Bool
|
let keyBarVisible: Bool
|
||||||
|
/// T-iOS-33 · 搜索面板 —— `makeUIView` 把 SwiftTerm 视图绑给它做检索引擎。
|
||||||
|
let searchModel: TerminalSearchModel
|
||||||
|
/// T-iOS-31 · PTT 状态机 —— 键栏 🎤 键的按下/松手出口。
|
||||||
|
let voiceModel: VoicePTTViewModel
|
||||||
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
|
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
|
||||||
var onKillSession: (@MainActor () -> Void)? = nil
|
var onKillSession: (@MainActor () -> Void)? = nil
|
||||||
|
|
||||||
@@ -170,7 +263,15 @@ private struct TerminalHostView: UIViewRepresentable {
|
|||||||
let viewModel = viewModel
|
let viewModel = viewModel
|
||||||
terminal.onKeyCommand = { key in viewModel.send(key: key) }
|
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) }
|
keyBar.onKey = { key in viewModel.send(key: key) }
|
||||||
terminal.installKeyBar(keyBar, visible: keyBarVisible)
|
terminal.installKeyBar(keyBar, visible: keyBarVisible)
|
||||||
|
|
||||||
@@ -323,11 +424,15 @@ final class KeyCommandTerminalView: TerminalView {
|
|||||||
addInteraction(UIContextMenuInteraction(delegate: delegate))
|
addInteraction(UIContextMenuInteraction(delegate: delegate))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a selection exists — reuses SwiftTerm's own `copy` eligibility
|
// NOTE (T-iOS-33/31 root fix): the "does a selection exist" read used to be
|
||||||
/// (`canPerformAction` returns `selection.active`); pure read, no bytes.
|
// a LOCAL `hasActiveSelection` here, computed via SwiftTerm's own `copy`
|
||||||
var hasActiveSelection: Bool {
|
// eligibility (`canPerformAction` answers from `selection.active`). SwiftTerm
|
||||||
canPerformAction(#selector(UIResponderStandardEditActions.copy(_:)), withSender: nil)
|
// 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 →
|
/// Copy the current selection via SwiftTerm's own `copy(_:)` (selection →
|
||||||
/// `UIPasteboard`). Pure UI: it never writes to the PTY, so the byte stream
|
/// `UIPasteboard`). Pure UI: it never writes to the PTY, so the byte stream
|
||||||
@@ -336,3 +441,23 @@ final class KeyCommandTerminalView: TerminalView {
|
|||||||
copy(nil)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
131
ios/App/WebTerm/Screens/WorktreeSheet.swift
Normal file
131
ios/App/WebTerm/Screens/WorktreeSheet.swift
Normal 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 立刻出现在列表里)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
ios/App/WebTerm/ViewModels/GitPanelPresentation.swift
Normal file
161
ios/App/WebTerm/ViewModels/GitPanelPresentation.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从未 fetch(nil)也算过期 —— "没 fetch 过"比"很久没 fetch"更不可信。
|
||||||
|
private static func isStale(lastFetchMs: Double?, nowMs: Double) -> Bool {
|
||||||
|
guard let lastFetchMs else { return true }
|
||||||
|
return nowMs - lastFetchMs > fetchStaleMs
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func head(for sync: SyncState) -> Head {
|
||||||
|
if sync.detached == true { return .detached }
|
||||||
|
guard let upstream = sync.upstream else { return .noUpstream }
|
||||||
|
return .tracking(upstream: upstream, ahead: sync.ahead, behind: sync.behind)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func dirty(for dirtyCount: Int?) -> Dirty {
|
||||||
|
guard let dirtyCount else { return .unknown }
|
||||||
|
return dirtyCount > 0 ? .changed(dirtyCount) : .clean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 未推送边界(w6/G4)
|
||||||
|
|
||||||
|
/// `git log` 列表里"已推送/未推送"的分界线位置。
|
||||||
|
///
|
||||||
|
/// 标记本身由服务器算(`unpushed`,按 SHA 前缀匹配 `@{u}..HEAD`)——客户端只决定
|
||||||
|
/// 画在哪一行之后,且**只画一次**。日期序会把一个未推送的 merge 排到已推送提交
|
||||||
|
/// 下面,所以边界必须取"最后一个 unpushed"的位置,而不是"前 N 行"。
|
||||||
|
enum GitLogBoundary {
|
||||||
|
/// 边界画在返回索引所指行的**下方**;nil = 不画(无上游 ⇒ 无可比对;
|
||||||
|
/// 或者一条未推送都没有)。
|
||||||
|
static func index(commits: [CommitLogEntry], upstream: String?) -> Int? {
|
||||||
|
guard upstream != nil else { return nil }
|
||||||
|
// `unpushed` 只在严格 true 时生效:nil 是"服务器没说",不是 false。
|
||||||
|
return commits.lastIndex { $0.unpushed == true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 相对时间
|
||||||
|
|
||||||
|
/// 提交时间/最近 fetch 的相对文案(中文)。服务器时间戳可能因主机时钟漂移落在
|
||||||
|
/// 未来 —— 那时退化为「刚刚」,绝不输出负数。
|
||||||
|
enum GitTimeFormat {
|
||||||
|
private static let msPerSecond: Double = 1_000
|
||||||
|
private static let secondsPerMinute: Double = 60
|
||||||
|
private static let secondsPerHour: Double = 60 * 60
|
||||||
|
private static let secondsPerDay: Double = 24 * 60 * 60
|
||||||
|
/// 一分钟以内一律「刚刚」。
|
||||||
|
private static let justNowSeconds: Double = 60
|
||||||
|
|
||||||
|
static func relative(fromMs: Double, nowMs: Double) -> String {
|
||||||
|
let seconds = max(0, (nowMs - fromMs) / msPerSecond)
|
||||||
|
if seconds < justNowSeconds { return Copy.justNow }
|
||||||
|
if seconds < secondsPerHour {
|
||||||
|
return Copy.minutesAgo(Int(seconds / secondsPerMinute))
|
||||||
|
}
|
||||||
|
if seconds < secondsPerDay {
|
||||||
|
return Copy.hoursAgo(Int(seconds / secondsPerHour))
|
||||||
|
}
|
||||||
|
return Copy.daysAgo(Int(seconds / secondsPerDay))
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Copy {
|
||||||
|
static let justNow = "刚刚"
|
||||||
|
static func minutesAgo(_ value: Int) -> String { "\(value) 分钟前" }
|
||||||
|
static func hoursAgo(_ value: Int) -> String { "\(value) 小时前" }
|
||||||
|
static func daysAgo(_ value: Int) -> String { "\(value) 天前" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 写操作失败文案
|
||||||
|
|
||||||
|
/// git 写操作(stage/commit/push/fetch/worktree)的错误话术**单一出口**。
|
||||||
|
///
|
||||||
|
/// 纪律:服务器已经把 git stderr 分类成安全短句(SEC-M10),客户端**原样显示**
|
||||||
|
/// 那句话 —— 既不吞掉(用户会以为操作成功了),也不自己编造原因。只有服务器
|
||||||
|
/// 没给 message 时才落到调用方的兜底短语。
|
||||||
|
enum GitWriteFeedback {
|
||||||
|
static func message(status: Int, serverMessage: String?, fallback: String) -> String {
|
||||||
|
guard let serverMessage, !serverMessage.isEmpty else {
|
||||||
|
return "\(fallback)(HTTP \(status))"
|
||||||
|
}
|
||||||
|
return serverMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 抛出的错误:`APIClientError` 自带面向用户的话术(含 401 引导补令牌),
|
||||||
|
/// 其余(传输层)落兜底短语 —— 绝不把 `localizedDescription` 当主文案,
|
||||||
|
/// 它是英文系统串。
|
||||||
|
static func message(for error: any Error, fallback: String) -> String {
|
||||||
|
(error as? APIClientError)?.message ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 429:限流是服务器的保护机制,**绝不**自动重试。
|
||||||
|
static let rateLimited = APIClientError.rateLimited.message
|
||||||
|
}
|
||||||
369
ios/App/WebTerm/ViewModels/GitPanelViewModel.swift
Normal file
369
ios/App/WebTerm/ViewModels/GitPanelViewModel.swift
Normal 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. **分区独立降级**。日志失败不该让同步带消失,PR(gh 会走外网)单独加载,
|
||||||
|
/// 详情失败就**没有**同步带,而不是显示一个编造的"已同步"。
|
||||||
|
/// 3. **服务器安全文案原样上浮**(SEC-M10 已在服务端把 stderr 分类成安全短句)——
|
||||||
|
/// 写操作绝不静默失败。
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class GitPanelViewModel {
|
||||||
|
|
||||||
|
/// 一个待处理的改动文件。
|
||||||
|
///
|
||||||
|
/// `stagePaths` 与显示路径分开:重命名必须**同时**把 oldPath 与 newPath 交给
|
||||||
|
/// `git add`(镜像 web `public/diff.ts` 的做法),否则只暂存新增侧、删除侧留在
|
||||||
|
/// 工作区,提交出来是"复制"而不是"重命名"。
|
||||||
|
struct ChangedFile: Equatable, Identifiable, Sendable {
|
||||||
|
let displayPath: String
|
||||||
|
let stagePaths: [String]
|
||||||
|
let status: DiffFileStatus
|
||||||
|
let added: Int
|
||||||
|
let removed: Int
|
||||||
|
|
||||||
|
var id: String { displayPath }
|
||||||
|
|
||||||
|
static func make(from file: DiffFile) -> ChangedFile {
|
||||||
|
let primary = file.newPath.isEmpty ? file.oldPath : file.newPath
|
||||||
|
let isRename = file.status == .renamed && !file.oldPath.isEmpty
|
||||||
|
&& file.oldPath != file.newPath
|
||||||
|
return ChangedFile(
|
||||||
|
displayPath: isRename ? "\(file.oldPath) → \(file.newPath)" : primary,
|
||||||
|
stagePaths: isRename ? [file.oldPath, file.newPath] : [primary],
|
||||||
|
status: file.status,
|
||||||
|
added: file.added,
|
||||||
|
removed: file.removed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 正在进行的写操作(用于禁用按钮 + 行内 spinner)。
|
||||||
|
enum Operation: Equatable {
|
||||||
|
case fetch
|
||||||
|
case commit
|
||||||
|
case push
|
||||||
|
case stage(String)
|
||||||
|
case unstage(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注入的依赖 —— 生产由 `forProject` 包 `APIClient`/`DiffFetcher`,测试注入 fake。
|
||||||
|
struct Dependencies: Sendable {
|
||||||
|
let detail: @Sendable () async throws -> ProjectDetail
|
||||||
|
let log: @Sendable () async throws -> GitLogResult
|
||||||
|
let pr: @Sendable () async throws -> PrStatus
|
||||||
|
let changes: @Sendable (_ staged: Bool) async throws -> [ChangedFile]
|
||||||
|
let stage: @Sendable (_ files: [String], _ stage: Bool) async throws
|
||||||
|
-> GitWriteOutcome<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)" }
|
||||||
|
}
|
||||||
@@ -42,9 +42,59 @@ import WireProtocol
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class PairingViewModel {
|
final class PairingViewModel {
|
||||||
/// The probe, injected as a closure so tests can both fake results AND
|
/// Everything the pairing flow needs from the network/platform, injected as
|
||||||
/// assert non-invocation before the user confirms (task RED list).
|
/// ONE value built by the composition root (`AppEnvironment.probe`).
|
||||||
typealias Probe = @Sendable (HostEndpoint) async -> Result<HostEndpoint, PairingError>
|
///
|
||||||
|
/// 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
|
// MARK: - UI state model
|
||||||
|
|
||||||
@@ -81,6 +131,11 @@ final class PairingViewModel {
|
|||||||
/// iOS Local Network permission was denied → deep-link to the app's
|
/// iOS Local Network permission was denied → deep-link to the app's
|
||||||
/// Settings pane (its 本地网络 toggle lives there).
|
/// Settings pane (its 本地网络 toggle lives there).
|
||||||
case openLocalNetworkSettings
|
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 {
|
struct FailureDisplay: Equatable, Sendable {
|
||||||
@@ -93,6 +148,10 @@ final class PairingViewModel {
|
|||||||
case confirming(PendingHost)
|
case confirming(PendingHost)
|
||||||
case probing(PendingHost)
|
case probing(PendingHost)
|
||||||
case failed(PendingHost, FailureDisplay)
|
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)
|
case paired(HostRegistry.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,11 +171,24 @@ final class PairingViewModel {
|
|||||||
/// Navigate signal: set exactly once when pairing completes (T-iOS-15
|
/// Navigate signal: set exactly once when pairing completes (T-iOS-15
|
||||||
/// observes it to move on to the session list).
|
/// observes it to move on to the session list).
|
||||||
private(set) var pairedHost: HostRegistry.Host?
|
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)
|
// MARK: - Dependencies (not observed)
|
||||||
|
|
||||||
@ObservationIgnored private let store: any HostStore
|
@ObservationIgnored private let store: any HostStore
|
||||||
@ObservationIgnored private let probe: Probe
|
@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
|
/// 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;
|
/// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
|
||||||
/// production reads the keychain. Defaulted so existing call sites compile.
|
/// production reads the keychain. Defaulted so existing call sites compile.
|
||||||
@@ -124,7 +196,7 @@ final class PairingViewModel {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
store: any HostStore,
|
store: any HostStore,
|
||||||
probe: @escaping Probe,
|
probe: Probe, // C1 · a value now (three capabilities), no longer a closure
|
||||||
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
|
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
|
||||||
KeychainClientIdentityStore().hasInstalledIdentity()
|
KeychainClientIdentityStore().hasInstalledIdentity()
|
||||||
}
|
}
|
||||||
@@ -170,11 +242,15 @@ final class PairingViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Back out of confirm/failed to a clean entry state. Never probes.
|
/// 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() {
|
func cancel() {
|
||||||
phase = .idle
|
phase = .idle
|
||||||
inputRejection = nil
|
inputRejection = nil
|
||||||
hasAcknowledgedPublicRisk = false
|
hasAcknowledgedPublicRisk = false
|
||||||
needsPublicRiskAcknowledgement = false
|
needsPublicRiskAcknowledgement = false
|
||||||
|
tokenRejection = nil
|
||||||
|
candidateToken = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Confirm → probe → store
|
// MARK: - Confirm → probe → store
|
||||||
@@ -200,7 +276,9 @@ final class PairingViewModel {
|
|||||||
switch phase {
|
switch phase {
|
||||||
case .idle, .confirming, .failed:
|
case .idle, .confirming, .failed:
|
||||||
return true
|
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
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +287,8 @@ final class PairingViewModel {
|
|||||||
inputRejection = nil
|
inputRejection = nil
|
||||||
hasAcknowledgedPublicRisk = false
|
hasAcknowledgedPublicRisk = false
|
||||||
needsPublicRiskAcknowledgement = false
|
needsPublicRiskAcknowledgement = false
|
||||||
|
tokenRejection = nil
|
||||||
|
candidateToken = nil // a new target never inherits the previous token
|
||||||
hostName = endpoint.baseURL.host ?? ""
|
hostName = endpoint.baseURL.host ?? ""
|
||||||
phase = .confirming(PendingHost(
|
phase = .confirming(PendingHost(
|
||||||
endpoint: endpoint, warning: Self.warning(for: endpoint)
|
endpoint: endpoint, warning: Self.warning(for: endpoint)
|
||||||
@@ -228,7 +308,10 @@ final class PairingViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
phase = .probing(pending)
|
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):
|
case .failure(let error):
|
||||||
phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
|
phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
|
||||||
case .success(let endpoint):
|
case .success(let endpoint):
|
||||||
@@ -239,13 +322,23 @@ final class PairingViewModel {
|
|||||||
/// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED
|
/// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED
|
||||||
/// endpoint. A store failure is surfaced explicitly (never swallowed);
|
/// endpoint. A store failure is surfaced explicitly (never swallowed);
|
||||||
/// retry re-runs the whole confirm flow.
|
/// 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 {
|
private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
|
||||||
let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let existing = await existingHost(matching: endpoint)
|
||||||
let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
|
|
||||||
let host = HostRegistry.Host(
|
let host = HostRegistry.Host(
|
||||||
id: UUID(),
|
id: existing?.id ?? UUID(),
|
||||||
name: trimmedName.isEmpty ? fallbackName : trimmedName,
|
name: resolvedName(for: endpoint, existing: existing),
|
||||||
endpoint: endpoint
|
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 {
|
do {
|
||||||
_ = try await store.upsert(host)
|
_ = try await store.upsert(host)
|
||||||
@@ -255,10 +348,128 @@ final class PairingViewModel {
|
|||||||
))
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
candidateToken = nil // persisted — drop the in-memory copy
|
||||||
pairedHost = host
|
pairedHost = host
|
||||||
phase = .paired(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)
|
// MARK: - PairingError → copy + action (task RED list, one case each)
|
||||||
|
|
||||||
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
|
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
|
||||||
@@ -289,7 +500,14 @@ final class PairingViewModel {
|
|||||||
case .originRejected(let hint):
|
case .originRejected(let hint):
|
||||||
// The probe already derived the complete actionable copy from
|
// The probe already derived the complete actionable copy from
|
||||||
// endpoint.originHeader — surface it VERBATIM, never re-derive.
|
// 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):
|
case .atsBlocked(let host):
|
||||||
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
|
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
|
||||||
case .tlsFailure:
|
case .tlsFailure:
|
||||||
@@ -418,40 +636,3 @@ final class PairingViewModel {
|
|||||||
private static let ipv4OctetCount = 4
|
private static let ipv4OctetCount = 4
|
||||||
private static let ipv4OctetRange = 0...255
|
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,或反馈该网段。"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -178,6 +178,28 @@ final class ProjectsViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// T-iOS-32(C2)· 恢复一条历史会话:**同一条** `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(详情/差异屏的装配缝)
|
// MARK: - Detail assembly(详情/差异屏的装配缝)
|
||||||
|
|
||||||
func makeDetailViewModel(path: String) -> ProjectDetailViewModel {
|
func makeDetailViewModel(path: String) -> ProjectDetailViewModel {
|
||||||
|
|||||||
146
ios/App/WebTerm/ViewModels/ResumeHistoryViewModel.swift
Normal file
146
ios/App/WebTerm/ViewModels/ResumeHistoryViewModel.swift
Normal 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
|
||||||
|
/// 会话自己的 cwd(worktree 会话会回到那个 worktree,而不是仓库根)。
|
||||||
|
let cwd: String
|
||||||
|
let bootstrapInput: String?
|
||||||
|
|
||||||
|
var id: String { session.id }
|
||||||
|
var canResume: Bool { bootstrapInput != nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Phase: Equatable {
|
||||||
|
case loading
|
||||||
|
case loaded([ResumeCandidate])
|
||||||
|
/// 服务器无历史(或全都不属于本仓库)——正常态,不是失败。
|
||||||
|
case empty
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectPath: String
|
||||||
|
private(set) var phase: Phase = .loading
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
|
private let fetch: @Sendable () async throws -> [HistorySession]
|
||||||
|
|
||||||
|
init(projectPath: String, fetch: @escaping @Sendable () async throws -> [HistorySession]) {
|
||||||
|
self.projectPath = projectPath
|
||||||
|
self.fetch = fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生产装配缝。
|
||||||
|
static func forProject(
|
||||||
|
endpoint: HostEndpoint, http: any HTTPTransport, path: String
|
||||||
|
) -> ResumeHistoryViewModel {
|
||||||
|
let client = APIClient(endpoint: endpoint, http: http)
|
||||||
|
return ResumeHistoryViewModel(projectPath: path, fetch: {
|
||||||
|
try await client.claudeSessions()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch 并归约。也是「重试」路径。
|
||||||
|
func load() async {
|
||||||
|
phase = .loading
|
||||||
|
do {
|
||||||
|
let candidates = Self.candidates(from: try await fetch(), projectPath: projectPath)
|
||||||
|
phase = candidates.isEmpty ? .empty : .loaded(candidates)
|
||||||
|
} catch {
|
||||||
|
phase = .failed(GitWriteFeedback.message(for: error, fallback: ResumeCopy.loadFailed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 过滤 + 映射(纯函数)。服务器已按 mtime 新→旧排好,**客户端不重排**。
|
||||||
|
///
|
||||||
|
/// 只保留 cwd 落在本项目内的会话:在另一个仓库跑过的会话,用本仓库的 cwd 去
|
||||||
|
/// resume 是错的。前缀比较带 `/` 边界,否则 `/repos/web-terminal-old` 会被
|
||||||
|
/// 当成 `/repos/web-terminal` 的子目录。
|
||||||
|
static func candidates(
|
||||||
|
from sessions: [HistorySession], projectPath: String
|
||||||
|
) -> [ResumeCandidate] {
|
||||||
|
let root = normalized(projectPath)
|
||||||
|
guard Validation.isAbsoluteCwd(root) else { return [] }
|
||||||
|
return sessions.compactMap { session in
|
||||||
|
let cwd = normalized(session.cwd)
|
||||||
|
guard Validation.isAbsoluteCwd(cwd), isInside(cwd: cwd, root: root) else { return nil }
|
||||||
|
return ResumeCandidate(
|
||||||
|
session: session,
|
||||||
|
cwd: cwd,
|
||||||
|
bootstrapInput: ProjectResumeCommand.bootstrapInput(sessionId: session.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 去掉尾部 `/`(根 `/` 保留),让前缀比较有确定的边界。
|
||||||
|
private static func normalized(_ path: String) -> String {
|
||||||
|
guard path.count > 1, path.hasSuffix("/") else { return path }
|
||||||
|
return String(path.dropLast())
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isInside(cwd: String, root: String) -> Bool {
|
||||||
|
cwd == root || cwd.hasPrefix(root.hasSuffix("/") ? root : root + "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 恢复命令合成(安全边界)
|
||||||
|
|
||||||
|
/// 把一个服务器给的会话 id 变成一条可以送进 PTY 的命令 —— 或者拒绝它。
|
||||||
|
///
|
||||||
|
/// 白名单而非黑名单:id 在服务端就是 `.jsonl` 的文件名主干(实际是 UUID),因此
|
||||||
|
/// `[A-Za-z0-9._-]` 足够,其余字符(空格、引号、`;`、`|`、`&`、`` ` ``、`$`、
|
||||||
|
/// 换行、路径分隔符…)全部意味着"这不是一个会话 id"。被拒的 id 绝不进命令行。
|
||||||
|
enum ProjectResumeCommand {
|
||||||
|
/// 文件名主干的合理上限(UUID 是 36)。
|
||||||
|
static let maxIdLength = 128
|
||||||
|
|
||||||
|
private static let allowed = Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
|
||||||
|
|
||||||
|
/// nil = id 不可信 ⇒ 该行不可恢复。成功时以 `\r`(0x0D)结尾 —— Enter 是 `\r`
|
||||||
|
/// 不是 `\n`(CLAUDE.md Gotchas)。
|
||||||
|
static func bootstrapInput(sessionId: String) -> String? {
|
||||||
|
guard isWellFormed(sessionId) else { return nil }
|
||||||
|
return "claude --resume \(sessionId)\r"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isWellFormed(_ sessionId: String) -> Bool {
|
||||||
|
!sessionId.isEmpty
|
||||||
|
&& sessionId.count <= maxIdLength
|
||||||
|
&& sessionId.allSatisfy(allowed.contains)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 用户可见文案(中文具名常量,plan §4)
|
||||||
|
|
||||||
|
enum ResumeCopy {
|
||||||
|
static let entryLabel = "恢复历史会话"
|
||||||
|
static let sheetTitle = "历史会话"
|
||||||
|
static let emptyTitle = "暂无历史会话"
|
||||||
|
static let emptyDetail = "主机在此仓库下还没有 Claude Code 历史记录(`~/.claude/projects`)。"
|
||||||
|
static let loadFailed = "读取历史会话失败"
|
||||||
|
static let retry = "重试"
|
||||||
|
static let resume = "恢复"
|
||||||
|
static let notResumable = "会话标识不合法,无法恢复。"
|
||||||
|
static let noPreview = "(无首条提示)"
|
||||||
|
|
||||||
|
static func resumeAccessibilityLabel(_ project: String) -> String { "恢复 \(project) 的会话" }
|
||||||
|
}
|
||||||
@@ -57,6 +57,19 @@ final class TerminalViewModel {
|
|||||||
static let replayTooLargeMessage =
|
static let replayTooLargeMessage =
|
||||||
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
|
"服务器 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`).
|
/// Last VALID dims forwarded to the engine (SwiftTerm `sizeChanged`).
|
||||||
/// Read by the wiring layer for `notifyForegrounded(dims:)` — the frozen
|
/// Read by the wiring layer for `notifyForegrounded(dims:)` — the frozen
|
||||||
/// §3.2 signature needs real cols/rows and this is their single source.
|
/// §3.2 signature needs real cols/rows and this is their single source.
|
||||||
@@ -271,6 +284,11 @@ final class TerminalViewModel {
|
|||||||
case .failed(.replayTooLarge):
|
case .failed(.replayTooLarge):
|
||||||
banner = .none
|
banner = .none
|
||||||
phase = .failed(message: Self.replayTooLargeMessage)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
327
ios/App/WebTerm/ViewModels/WorktreeViewModel.swift
Normal file
327
ios/App/WebTerm/ViewModels/WorktreeViewModel.swift
Normal 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)。"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
ios/App/WebTerm/WebTerm.entitlements
Normal file
28
ios/App/WebTerm/WebTerm.entitlements
Normal 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>
|
||||||
@@ -3,6 +3,7 @@ import ClientTLS
|
|||||||
import Foundation
|
import Foundation
|
||||||
import HostRegistry
|
import HostRegistry
|
||||||
import SessionCore
|
import SessionCore
|
||||||
|
import UIKit
|
||||||
import WireProtocol
|
import WireProtocol
|
||||||
|
|
||||||
/// T-iOS-15 · Production dependency graph (composition root). One immutable
|
/// 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):
|
/// Assembly security audit (task 安全注, verified at this single point):
|
||||||
/// - All `G` (state-changing) HTTP goes through `APIClient` (kill via
|
/// - All `G` (state-changing) HTTP goes through `APIClient` (kill via
|
||||||
/// SessionListViewModel's client, probe's kill round-trip inside
|
/// 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;
|
/// - Origin is derived solely by `HostEndpoint` (WS: URLSessionTermTransport;
|
||||||
/// HTTP: APIClient's route builder) — nothing here hand-assembles one.
|
/// 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);
|
/// (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
|
/// - No debug ATS overrides exist (project.yml declares NO
|
||||||
/// NSAllowsArbitraryLoads in any configuration — only the five §5.2 CIDR
|
/// NSAllowsArbitraryLoads in any configuration — only the five §5.2 CIDR
|
||||||
/// exceptions), and no isSecureTextEntry-style screenshot hacks are used;
|
/// exceptions), and no isSecureTextEntry-style screenshot hacks are used;
|
||||||
@@ -27,15 +36,38 @@ struct AppEnvironment: Sendable {
|
|||||||
let hostStore: any HostStore
|
let hostStore: any HostStore
|
||||||
let lastSessionStore: any LastSessionStore
|
let lastSessionStore: any LastSessionStore
|
||||||
let http: any HTTPTransport
|
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
|
let termTransport: any TermTransport
|
||||||
/// Injected into `PairingViewModel` — production is `runPairingProbe`
|
/// Injected into `PairingViewModel` — production is `runPairingProbe`
|
||||||
/// over the real transports (two-step: RO GET, then WS attach + guarded
|
/// 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
|
let probe: PairingViewModel.Probe
|
||||||
/// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults).
|
/// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults).
|
||||||
/// `var` + default so the memberwise init stays source-compatible for
|
/// `var` + default so the memberwise init stays source-compatible for
|
||||||
/// pre-P1 call sites while tests can inject an in-memory fake.
|
/// pre-P1 call sites while tests can inject an in-memory fake.
|
||||||
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
|
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 {
|
static func production() -> AppEnvironment {
|
||||||
// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
|
// 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
|
// missing cert is the normal pre-install state (→ nil); genuine faults
|
||||||
// are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only
|
// are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only
|
||||||
// fire for tunnel hosts, so a `nil` result is inert for local hosts.
|
// fire for tunnel hosts, so a `nil` result is inert for local hosts.
|
||||||
let identityStore = KeychainClientIdentityStore()
|
let identityProvider = keychainIdentityProvider()
|
||||||
let identityProvider: @Sendable () -> ClientIdentity? = {
|
let hostStore = KeychainHostStore()
|
||||||
identityStore.loadedIdentityOrNil()
|
// 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(
|
return AppEnvironment(
|
||||||
hostStore: KeychainHostStore(),
|
hostStore: hostStore,
|
||||||
lastSessionStore: UserDefaultsLastSessionStore(),
|
lastSessionStore: UserDefaultsLastSessionStore(),
|
||||||
http: http,
|
http: http,
|
||||||
termTransport: termTransport,
|
termTransport: URLSessionTermTransport(identityProvider: identityProvider),
|
||||||
probe: { endpoint in
|
probe: PairingViewModel.Probe(
|
||||||
await runPairingProbe(endpoint: endpoint, http: http, ws: termTransport)
|
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
|
/// 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)
|
func makeEventsSource(endpoint: HostEndpoint)
|
||||||
-> @Sendable (UUID) async throws -> [TimelineEvent] {
|
-> @Sendable (UUID) async throws -> [TimelineEvent] {
|
||||||
let client = APIClient(endpoint: endpoint, http: http)
|
let client = APIClient(endpoint: endpoint, http: http)
|
||||||
return { id in try await client.events(id: id) }
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,18 +16,28 @@ import SwiftUI
|
|||||||
/// `StackRootView` 是原 `RootView` 的 body **原样搬迁** —— compact 分支复用它
|
/// `StackRootView` 是原 `RootView` 的 body **原样搬迁** —— compact 分支复用它
|
||||||
/// 不改一行逻辑,故 iPhone 逐屏渲染与适配前一致(零回归硬性要求)。视觉层
|
/// 不改一行逻辑,故 iPhone 逐屏渲染与适配前一致(零回归硬性要求)。视觉层
|
||||||
/// 只按冻结的 `DS.*` token 重塑(横幅/占位/遮罩),行为不动。
|
/// 只按冻结的 `DS.*` token 重塑(横幅/占位/遮罩),行为不动。
|
||||||
|
/// T-iOS-34 · 主题(`ThemeStore`)就挂在这一层:它是 `@main` 唯一的实例化点,
|
||||||
|
/// 所以「一个 store、一次注入、一处 `preferredColorScheme`」在这里成立,
|
||||||
|
/// 不会出现两份主题真值。
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Bindable var coordinator: AppCoordinator
|
@Bindable var coordinator: AppCoordinator
|
||||||
|
|
||||||
|
/// 主题设置的单一真值(`UserDefaults` 持久化)。`@State` ⇒ 与根视图同寿命。
|
||||||
|
@State private var themeStore = ThemeStore()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
AdaptiveRootView(coordinator: coordinator)
|
AdaptiveRootView(coordinator: coordinator)
|
||||||
// DS:唯一的根 tint 注入点(Tokens.swift 头注约定)。子树若需别的
|
// DS:唯一的根 tint 注入点(Tokens.swift 头注约定)。子树若需别的
|
||||||
// 语义色(gate 的 amber、终端的 orange)在各自局部 `.tint` 覆盖。
|
// 语义色(gate 的 amber、终端的 orange)在各自局部 `.tint` 覆盖。
|
||||||
.tint(DS.Palette.accent)
|
.tint(DS.Palette.accent)
|
||||||
// 深色优先 —— 对齐桌面 web 主题(DEFAULT_SETTINGS.theme = 'dark')。
|
// 主题:默认仍是深色(对齐桌面 web `DEFAULT_SETTINGS.theme='dark'`,
|
||||||
// 琥珀金强调色是为暖深色背景设计的(桌面 --bg #100F0D);浅色底上
|
// 也等于此前硬锁 `.preferredColorScheme(.dark)` 的观感 ⇒ 升级零变化)。
|
||||||
// 金字对比不足。深色下 accent/status 全部高对比,且与桌面观感一致。
|
// 「跟随系统」时 `colorScheme` 为 nil = 不表态,交给 iOS。
|
||||||
.preferredColorScheme(.dark)
|
// 曾经硬锁深色的理由是「金色在浅底对比不足」;那是 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 条件、同
|
/// 会话列表的 leading 工具栏组 —— stack 与 split 共用(同 disabled 条件、同动作、
|
||||||
/// `presentProjects` 动作、同 a11y id)。根 tint 令其 label 呈 accent。
|
/// 同 a11y id)。根 tint 令其 label 呈 accent。
|
||||||
|
///
|
||||||
|
/// 组里现在有两项:「项目」(原有)+「设置」(T-iOS-34 主题入口)。
|
||||||
|
/// **命名保留** `ProjectsToolbarItem`:`SplitRootView.swift` 按此名引用它,而该
|
||||||
|
/// 文件不在 C4 的 Owns 里;把设置项加进这个共用组,是让 iPhone(stack) 与
|
||||||
|
/// iPad(split) 同时拿到入口且不越界编辑的唯一办法。改名 →
|
||||||
|
/// `RootLeadingToolbar` 留给拥有 `SplitRootView.swift` 的后续任务(纯机械重命名)。
|
||||||
struct ProjectsToolbarItem: ToolbarContent {
|
struct ProjectsToolbarItem: ToolbarContent {
|
||||||
@Bindable var coordinator: AppCoordinator
|
@Bindable var coordinator: AppCoordinator
|
||||||
|
|
||||||
@@ -211,6 +227,41 @@ struct ProjectsToolbarItem: ToolbarContent {
|
|||||||
.disabled(coordinator.sessionList.activeHost == nil)
|
.disabled(coordinator.sessionList.activeHost == nil)
|
||||||
.accessibilityIdentifier("sessions.projectsButton")
|
.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 {
|
enum RootCopy {
|
||||||
static let continueLast = "继续上次会话"
|
static let continueLast = "继续上次会话"
|
||||||
static let projects = "项目"
|
static let projects = "项目"
|
||||||
|
static let settings = "设置"
|
||||||
static let done = "完成"
|
static let done = "完成"
|
||||||
/// B3 (HIGH) · Shown when the silent device-cert renewal keeps failing.
|
/// B3 (HIGH) · Shown when the silent device-cert renewal keeps failing.
|
||||||
static let certRenewalFailing = "设备证书自动续期失败,将在下次前台重试"
|
static let certRenewalFailing = "设备证书自动续期失败,将在下次前台重试"
|
||||||
|
|||||||
@@ -197,7 +197,10 @@ final class TerminalSessionController: Identifiable {
|
|||||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
|
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
|
||||||
) -> Stack {
|
) -> Stack {
|
||||||
let engine = SessionEngine(
|
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(),
|
clock: ContinuousClock(),
|
||||||
endpoint: host.endpoint,
|
endpoint: host.endpoint,
|
||||||
eventsSource: environment.makeEventsSource(endpoint: host.endpoint)
|
eventsSource: environment.makeEventsSource(endpoint: host.endpoint)
|
||||||
|
|||||||
@@ -4,12 +4,32 @@ import WireProtocol
|
|||||||
|
|
||||||
/// T-iOS-15 · Production `HTTPTransport` (the WireProtocol seam's doc reserves
|
/// T-iOS-15 · Production `HTTPTransport` (the WireProtocol seam's doc reserves
|
||||||
/// the URLSession wrapper for the production side; no package owns it, so the
|
/// the URLSession wrapper for the production side; no package owns it, so the
|
||||||
/// assembly layer provides it). Deliberately logic-free: `APIClient` builds
|
/// assembly layer provides it). Deliberately logic-free about ROUTING:
|
||||||
/// every request — including the Origin-iff-G rule (plan §3.4 铁律) — and this
|
/// `APIClient` builds every request — including the Origin-iff-G rule (plan §3.4
|
||||||
/// type only performs the exchange. Adding ANY header/URL logic here would
|
/// 铁律) — and this type only performs the exchange. Adding URL or Origin logic
|
||||||
/// bypass that single audited point (review CRITICAL).
|
/// 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 {
|
struct URLSessionHTTPTransport: HTTPTransport {
|
||||||
private let session: URLSession
|
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
|
/// Strong reference to the mTLS delegate. URLSession retains its delegate
|
||||||
/// until invalidated, but this ephemeral session is never explicitly
|
/// until invalidated, but this ephemeral session is never explicitly
|
||||||
/// invalidated, so holding it here documents the ownership and keeps the
|
/// 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
|
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||||
/// provider, so behaviour is identical to capturing the identity directly.
|
/// 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) {
|
init(identity: ClientIdentity? = nil) {
|
||||||
self.init(identityProvider: { identity })
|
self.init(identityProvider: { identity })
|
||||||
}
|
}
|
||||||
@@ -37,16 +58,20 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
|||||||
/// contain printed secrets — and `.shared`'s default URLCache writes
|
/// contain printed secrets — and `.shared`'s default URLCache writes
|
||||||
/// responses to disk. Ephemeral keeps them memory-only, matching the WS
|
/// responses to disk. Ephemeral keeps them memory-only, matching the WS
|
||||||
/// transport and the privacy-shade posture.
|
/// 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)
|
let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider)
|
||||||
self.tlsDelegate = delegate
|
self.tlsDelegate = delegate
|
||||||
|
self.tokenForOrigin = tokenForOrigin
|
||||||
self.session = URLSession(
|
self.session = URLSession(
|
||||||
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
|
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
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 {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
// http(s)-only endpoints (HostEndpoint validates) always produce
|
// http(s)-only endpoints (HostEndpoint validates) always produce
|
||||||
// an HTTPURLResponse; anything else is a transport-level anomaly.
|
// an HTTPURLResponse; anything else is a transport-level anomaly.
|
||||||
@@ -54,6 +79,50 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
|||||||
}
|
}
|
||||||
return (data, httpResponse)
|
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
|
/// C-iOS-2 (MEDIUM no-relaunch fix) · Session-level mTLS delegate that resolves
|
||||||
|
|||||||
346
ios/App/WebTermTests/AccessTokenSourceTests.swift
Normal file
346
ios/App/WebTermTests/AccessTokenSourceTests.swift
Normal 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("完全没有令牌的机群 → nil(LAN 零配置逐字不变,不带 Cookie)")
|
||||||
|
func tokenlessFleetYieldsNoCookie() async throws {
|
||||||
|
let host = try makeHost("http://192.168.1.5:3000", token: nil)
|
||||||
|
let source = AccessTokenSource(store: try await makeStore([host]))
|
||||||
|
|
||||||
|
_ = await source.token(forOrigin: host.endpoint.originHeader)
|
||||||
|
|
||||||
|
#expect(source.wsToken(for: host) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// E1 · The wiring that the per-host answer depends on: a terminal's WS
|
||||||
|
/// transport is built for THE host it dials, not once for the whole app.
|
||||||
|
@MainActor
|
||||||
|
@Suite("Per-host WS transport wiring")
|
||||||
|
struct PerHostTermTransportTests {
|
||||||
|
/// `@Sendable`-safe recorder for the hosts the factory was asked about.
|
||||||
|
private final class HostRecorder: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var hosts: [HostRegistry.Host] = []
|
||||||
|
|
||||||
|
func record(_ host: HostRegistry.Host) {
|
||||||
|
lock.withLock { hosts.append(host) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var recorded: [HostRegistry.Host] { lock.withLock { hosts } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeHost(_ base: String, token: String?) throws -> HostRegistry.Host {
|
||||||
|
let url = try #require(URL(string: base))
|
||||||
|
return HostRegistry.Host(
|
||||||
|
id: UUID(), name: base,
|
||||||
|
endpoint: try #require(HostEndpoint(baseURL: url)),
|
||||||
|
accessToken: try token.map { try AccessToken(validating: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeEnvironment(
|
||||||
|
shared: FakeTransport,
|
||||||
|
factory: (@Sendable (HostRegistry.Host) -> any TermTransport)? = nil
|
||||||
|
) throws -> AppEnvironment {
|
||||||
|
let defaults = try #require(UserDefaults(suiteName: "PerHostTermTransportTests"))
|
||||||
|
return AppEnvironment(
|
||||||
|
hostStore: InMemoryHostStore(),
|
||||||
|
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||||
|
http: FakeHTTPTransport(),
|
||||||
|
termTransport: shared,
|
||||||
|
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
|
||||||
|
termTransportFactory: factory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("未注入工厂 → 回落到共享传输(既有 App 层测试装配零回归)")
|
||||||
|
func fallsBackToTheSharedTransport() throws {
|
||||||
|
let shared = FakeTransport()
|
||||||
|
let environment = try makeEnvironment(shared: shared)
|
||||||
|
|
||||||
|
let resolved = environment.makeTermTransport(for: try makeHost("http://192.168.1.5:3000", token: nil))
|
||||||
|
|
||||||
|
#expect(resolved as? FakeTransport === shared)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("注入工厂 → 每台主机各建一个传输,且工厂拿到的就是那台主机(含其令牌)")
|
||||||
|
func buildsOneTransportPerHost() throws {
|
||||||
|
let recorder = HostRecorder()
|
||||||
|
let environment = try makeEnvironment(
|
||||||
|
shared: FakeTransport(),
|
||||||
|
factory: { host in
|
||||||
|
recorder.record(host)
|
||||||
|
return FakeTransport()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let tokened = try makeHost("http://192.168.1.5:3000", token: String(repeating: "a", count: 32))
|
||||||
|
let open = try makeHost("http://192.168.1.9:3000", token: nil)
|
||||||
|
|
||||||
|
_ = environment.makeTermTransport(for: tokened)
|
||||||
|
_ = environment.makeTermTransport(for: open)
|
||||||
|
|
||||||
|
#expect(recorder.recorded.map(\.id) == [tokened.id, open.id])
|
||||||
|
#expect(recorder.recorded.first?.accessToken == tokened.accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("TerminalSessionController 用自己那台主机建传输(终端 401 的根因就在这里)")
|
||||||
|
func controllerBuildsItsTransportForItsOwnHost() throws {
|
||||||
|
let recorder = HostRecorder()
|
||||||
|
let environment = try makeEnvironment(
|
||||||
|
shared: FakeTransport(),
|
||||||
|
factory: { host in
|
||||||
|
recorder.record(host)
|
||||||
|
return FakeTransport()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let host = try makeHost("http://192.168.1.5:3000", token: String(repeating: "b", count: 32))
|
||||||
|
|
||||||
|
_ = TerminalSessionController(
|
||||||
|
host: host, sessionId: nil, environment: environment,
|
||||||
|
onPendingChanged: { _, _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(recorder.recorded.map(\.id) == [host.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C1 · The App layer's one place where a token becomes a header.
|
||||||
|
@Suite("Access-token cookie stamping")
|
||||||
|
struct AccessTokenCookieTests {
|
||||||
|
private static let token = "abcdefghijklmnopqrstuvwxyz012345"
|
||||||
|
|
||||||
|
private func request(_ url: String) throws -> URLRequest {
|
||||||
|
URLRequest(url: try #require(URL(string: url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("请求 URL → originHeader 形式的 origin(路径/查询串被忽略)")
|
||||||
|
func derivesOriginFromRequestURL() throws {
|
||||||
|
let withPath = try request("http://192.168.1.5:3000/live-sessions/abc/preview?x=1")
|
||||||
|
|
||||||
|
#expect(AccessTokenCookie.origin(of: withPath) == "http://192.168.1.5:3000")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("https 默认端口不写 :443(与服务器的 URL 规范化一致)")
|
||||||
|
func omitsDefaultHTTPSPort() throws {
|
||||||
|
let secure = try request("https://mac.tailnet.ts.net/live-sessions")
|
||||||
|
|
||||||
|
#expect(AccessTokenCookie.origin(of: secure) == "https://mac.tailnet.ts.net")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("非 http(s) / 无 host 的请求 → nil(不可能是本 App 的主机)")
|
||||||
|
func rejectsNonHTTPRequests() throws {
|
||||||
|
#expect(AccessTokenCookie.origin(of: try request("ftp://192.168.1.5/x")) == nil)
|
||||||
|
#expect(AccessTokenCookie.origin(of: URLRequest(url: URL(fileURLWithPath: "/tmp"))) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("盖上 Cookie: webterm_auth=<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 盖上令牌 Cookie(RO GET 也一样)")
|
||||||
|
func transportStampsEveryRequest() async throws {
|
||||||
|
let transport = URLSessionHTTPTransport(
|
||||||
|
identityProvider: { nil },
|
||||||
|
tokenForOrigin: { origin in
|
||||||
|
origin == "http://192.168.1.5:3000" ? Self.token : nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let readOnly = try request("http://192.168.1.5:3000/live-sessions")
|
||||||
|
|
||||||
|
let stamped = await transport.authenticated(readOnly)
|
||||||
|
|
||||||
|
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("没有该 origin 的令牌 → 请求逐字不变(LAN 零配置主机不受影响)")
|
||||||
|
func transportLeavesTokenlessOriginsAlone() async throws {
|
||||||
|
let transport = URLSessionHTTPTransport(
|
||||||
|
identityProvider: { nil }, tokenForOrigin: { _ in nil }
|
||||||
|
)
|
||||||
|
let plain = try request("http://192.168.1.9:3000/live-sessions")
|
||||||
|
|
||||||
|
let result = await transport.authenticated(plain)
|
||||||
|
|
||||||
|
#expect(result.value(forHTTPHeaderField: "Cookie") == nil)
|
||||||
|
#expect(result.allHTTPHeaderFields == plain.allHTTPHeaderFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("请求已带 Cookie(配对探针的候选令牌)→ 传输层不覆盖")
|
||||||
|
func transportNeverOverwritesTheProbeCandidate() async throws {
|
||||||
|
let transport = URLSessionHTTPTransport(
|
||||||
|
identityProvider: { nil }, tokenForOrigin: { _ in Self.token }
|
||||||
|
)
|
||||||
|
var candidate = try request("http://192.168.1.5:3000/live-sessions")
|
||||||
|
candidate.setValue("webterm_auth=candidate", forHTTPHeaderField: "Cookie")
|
||||||
|
|
||||||
|
let result = await transport.authenticated(candidate)
|
||||||
|
|
||||||
|
#expect(result.value(forHTTPHeaderField: "Cookie") == "webterm_auth=candidate")
|
||||||
|
}
|
||||||
|
}
|
||||||
353
ios/App/WebTermTests/AppThemeTests.swift
Normal file
353
ios/App/WebTermTests/AppThemeTests.swift
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Testing
|
||||||
|
import UIKit
|
||||||
|
@testable import WebTerm
|
||||||
|
|
||||||
|
/// T-iOS-34 · 主题(跟随系统 / 深色 / 浅色)—— 模型、持久化、有效配色解析,
|
||||||
|
/// 以及**浅色通路的 token 审计**(doc §4 A–E 条目 1–24)。
|
||||||
|
///
|
||||||
|
/// 审计的核心不是"把 `.preferredColorScheme(.dark)` 解锁",而是:每个语义色在
|
||||||
|
/// 浅色档下都要有**能看清**的值。故 D 组用 WCAG 对比度(非文本 UI 组件门槛
|
||||||
|
/// 1.4.11 = 3:1;终端正文按 AAA = 7:1)逐档量化,深色档同时**逐字节钉死**旧值
|
||||||
|
/// 保证零回归。
|
||||||
|
@MainActor
|
||||||
|
@Suite("AppTheme")
|
||||||
|
struct AppThemeTests {
|
||||||
|
|
||||||
|
// MARK: - A. 主题模型
|
||||||
|
|
||||||
|
@Test("colorScheme:.system 交给系统(nil),.dark/.light 强制自身")
|
||||||
|
func colorSchemePerCase() {
|
||||||
|
#expect(AppTheme.system.colorScheme == nil)
|
||||||
|
#expect(AppTheme.dark.colorScheme == .dark)
|
||||||
|
#expect(AppTheme.light.colorScheme == .light)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("三档 label / SF Symbol 非空且互不相同")
|
||||||
|
func labelsAndSymbolsAreDistinct() {
|
||||||
|
let labels = AppTheme.allCases.map(\.label)
|
||||||
|
let symbols = AppTheme.allCases.map(\.symbolName)
|
||||||
|
#expect(labels.allSatisfy { !$0.isEmpty })
|
||||||
|
#expect(symbols.allSatisfy { !$0.isEmpty })
|
||||||
|
#expect(Set(labels).count == AppTheme.allCases.count)
|
||||||
|
#expect(Set(symbols).count == AppTheme.allCases.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("allCases 顺序 = 跟随系统 → 深色 → 浅色(选择器直接用它,不重排)")
|
||||||
|
func caseOrderIsPickerOrder() {
|
||||||
|
#expect(AppTheme.allCases == [.system, .dark, .light])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rawValue 白名单:只认三个小写标识")
|
||||||
|
func rawValueWhitelist() {
|
||||||
|
#expect(AppTheme(rawValue: "system") == .system)
|
||||||
|
#expect(AppTheme(rawValue: "dark") == .dark)
|
||||||
|
#expect(AppTheme(rawValue: "light") == .light)
|
||||||
|
#expect(AppTheme(rawValue: "") == nil)
|
||||||
|
#expect(AppTheme(rawValue: "Dark") == nil)
|
||||||
|
#expect(AppTheme(rawValue: "solarized") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - B. 持久化
|
||||||
|
|
||||||
|
@Test("未设置 → .dark(默认必须等于今天硬锁的深色,零回归)")
|
||||||
|
func decodeMissingIsDark() {
|
||||||
|
#expect(AppThemeCodec.decode(nil) == .dark)
|
||||||
|
#expect(AppThemeCodec.fallback == .dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("脏值 / 空串 / 大小写不符 → .dark,不崩、不落 system")
|
||||||
|
func decodeGarbageIsDark() {
|
||||||
|
#expect(AppThemeCodec.decode("solarized") == .dark)
|
||||||
|
#expect(AppThemeCodec.decode("") == .dark)
|
||||||
|
#expect(AppThemeCodec.decode("DARK") == .dark)
|
||||||
|
#expect(AppThemeCodec.decode("\u{0}light") == .dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("三档 encode → decode 往返恒等")
|
||||||
|
func codecRoundTrips() {
|
||||||
|
for theme in AppTheme.allCases {
|
||||||
|
#expect(AppThemeCodec.decode(AppThemeCodec.encode(theme)) == theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("空 defaults → .dark,且首次读不写盘")
|
||||||
|
func storeStartsDarkWithoutWriting() {
|
||||||
|
let defaults = FakeThemeDefaults()
|
||||||
|
let store = ThemeStore(defaults: defaults)
|
||||||
|
|
||||||
|
#expect(store.theme == .dark)
|
||||||
|
#expect(defaults.writeCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("select(.light) → 落盘 \"light\",同一 defaults 新建 store 读回(跨启动)")
|
||||||
|
func selectPersistsAcrossStores() {
|
||||||
|
let defaults = FakeThemeDefaults()
|
||||||
|
let store = ThemeStore(defaults: defaults)
|
||||||
|
|
||||||
|
store.select(.light)
|
||||||
|
|
||||||
|
#expect(store.theme == .light)
|
||||||
|
#expect(defaults.values[ThemeStore.storageKey] == "light")
|
||||||
|
#expect(ThemeStore(defaults: defaults).theme == .light)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("select 同一档两次 → 只写一次")
|
||||||
|
func repeatedSelectWritesOnce() {
|
||||||
|
let defaults = FakeThemeDefaults()
|
||||||
|
let store = ThemeStore(defaults: defaults)
|
||||||
|
|
||||||
|
store.select(.light)
|
||||||
|
store.select(.light)
|
||||||
|
|
||||||
|
#expect(defaults.writeCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("defaults 里是脏值 → 读作 .dark,且不动其它键")
|
||||||
|
func dirtyStoredValueDegradesToDark() {
|
||||||
|
let defaults = FakeThemeDefaults(values: [
|
||||||
|
ThemeStore.storageKey: "; rm -rf ~",
|
||||||
|
"unrelated.key": "keep-me",
|
||||||
|
])
|
||||||
|
|
||||||
|
let store = ThemeStore(defaults: defaults)
|
||||||
|
|
||||||
|
#expect(store.theme == .dark)
|
||||||
|
#expect(defaults.values["unrelated.key"] == "keep-me")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - C. 有效配色解析
|
||||||
|
|
||||||
|
@Test("resolvedScheme:.system 透传系统值,.dark/.light 无视系统")
|
||||||
|
func resolvedScheme() {
|
||||||
|
#expect(AppTheme.system.resolvedScheme(system: .light) == .light)
|
||||||
|
#expect(AppTheme.system.resolvedScheme(system: .dark) == .dark)
|
||||||
|
#expect(AppTheme.dark.resolvedScheme(system: .light) == .dark)
|
||||||
|
#expect(AppTheme.light.resolvedScheme(system: .dark) == .light)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - D. 浅色通路 token 审计
|
||||||
|
|
||||||
|
/// 受审的 5 个语义色(状态三色 + 时间线两色)——它们原本都是单值深色专用。
|
||||||
|
private static let semanticColors: [(name: String, color: Color)] = [
|
||||||
|
("statusWorking", DS.Palette.statusWorking),
|
||||||
|
("statusWaiting", DS.Palette.statusWaiting),
|
||||||
|
("statusStuck", DS.Palette.statusStuck),
|
||||||
|
("timelineTool", DS.Palette.timelineTool),
|
||||||
|
("timelineUser", DS.Palette.timelineUser),
|
||||||
|
]
|
||||||
|
|
||||||
|
@Test("5 个语义色在 light 与 dark 下解析值不同(真有浅色变体)")
|
||||||
|
func semanticColorsAreAdaptive() {
|
||||||
|
for (name, color) in Self.semanticColors {
|
||||||
|
let dark = UIColor(color).resolved(.dark)
|
||||||
|
let light = UIColor(color).resolved(.light)
|
||||||
|
#expect(!ColorMath.approxEqual(dark, light), "\(name) 在两档下相同 → 没有浅色变体")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("深色档逐字节不变(#46D07F/#F5B14C/#FF6B6B/#5E9EFF/#AF7BFF)")
|
||||||
|
func darkSchemeValuesUnchanged() {
|
||||||
|
let expected: [(Color, UInt32)] = [
|
||||||
|
(DS.Palette.statusWorking, 0x46D0_7F),
|
||||||
|
(DS.Palette.statusWaiting, 0xF5B1_4C),
|
||||||
|
(DS.Palette.statusStuck, 0xFF6B_6B),
|
||||||
|
(DS.Palette.timelineTool, 0x5E9E_FF),
|
||||||
|
(DS.Palette.timelineUser, 0xAF7B_FF),
|
||||||
|
]
|
||||||
|
for (color, hex) in expected {
|
||||||
|
let resolved = UIColor(color).resolved(.dark)
|
||||||
|
#expect(
|
||||||
|
ColorMath.matchesHex(resolved, hex),
|
||||||
|
"深色档漂移:实际 \(ColorMath.hexString(resolved))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("两档下语义色对该档 systemBackground 的对比度 ≥ 3:1(WCAG 1.4.11)")
|
||||||
|
func semanticColorsMeetNonTextContrast() {
|
||||||
|
for style in [UIUserInterfaceStyle.dark, .light] {
|
||||||
|
let background = UIColor.systemBackground.resolved(style)
|
||||||
|
for (name, color) in Self.semanticColors {
|
||||||
|
let ratio = ColorMath.contrast(UIColor(color).resolved(style), background)
|
||||||
|
#expect(ratio >= 3, "\(name) 在 \(style == .dark ? "dark" : "light") 只有 \(ratio):1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("light 档 working/waiting/stuck 仍两两可辨")
|
||||||
|
func criticalTrioStaysDistinctInLight() {
|
||||||
|
let working = UIColor(DS.Palette.statusWorking).resolved(.light)
|
||||||
|
let waiting = UIColor(DS.Palette.statusWaiting).resolved(.light)
|
||||||
|
let stuck = UIColor(DS.Palette.statusStuck).resolved(.light)
|
||||||
|
#expect(!ColorMath.approxEqual(working, waiting))
|
||||||
|
#expect(!ColorMath.approxEqual(working, stuck))
|
||||||
|
#expect(!ColorMath.approxEqual(waiting, stuck))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("accentSoft 两档不同,且都仍是 wash(alpha < 0.3)")
|
||||||
|
func accentSoftIsAdaptiveWash() {
|
||||||
|
let soft = UIColor(DS.Palette.accentSoft)
|
||||||
|
let dark = soft.resolved(.dark)
|
||||||
|
let light = soft.resolved(.light)
|
||||||
|
#expect(!ColorMath.approxEqual(dark, light))
|
||||||
|
#expect(ColorMath.rgba(dark).a < 0.3)
|
||||||
|
#expect(ColorMath.rgba(light).a < 0.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("onAccent 两档相同(金填充恒需深墨),与 accent 对比 ≥ 4.5:1")
|
||||||
|
func onAccentIsFixedAndReadable() {
|
||||||
|
let ink = UIColor(DS.Palette.onAccent)
|
||||||
|
#expect(ColorMath.approxEqual(ink.resolved(.dark), ink.resolved(.light)))
|
||||||
|
for style in [UIUserInterfaceStyle.dark, .light] {
|
||||||
|
let ratio = ColorMath.contrast(
|
||||||
|
ink.resolved(style), DS.Palette.accentUIColor().resolved(style)
|
||||||
|
)
|
||||||
|
#expect(ratio >= 4.5, "onAccent/accent 在 \(style.rawValue) 只有 \(ratio):1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - E. 终端画布跟随主题
|
||||||
|
|
||||||
|
@Test("dark 档终端 = #100F0D / #ECE9E3(桌面 web --bg/--text,零回归)")
|
||||||
|
func terminalDarkMatchesDesktop() {
|
||||||
|
let colors = TerminalPalette.colors(for: .dark)
|
||||||
|
#expect(ColorMath.matchesHex(colors.background, 0x100F_0D),
|
||||||
|
"实际 \(ColorMath.hexString(colors.background))")
|
||||||
|
#expect(ColorMath.matchesHex(colors.foreground, 0xECE9_E3),
|
||||||
|
"实际 \(ColorMath.hexString(colors.foreground))")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("light 档终端 = #F6F7F9 / #1A1D24(镜像 web THEMES.light)")
|
||||||
|
func terminalLightMirrorsWeb() {
|
||||||
|
let colors = TerminalPalette.colors(for: .light)
|
||||||
|
#expect(ColorMath.matchesHex(colors.background, 0xF6F7_F9),
|
||||||
|
"实际 \(ColorMath.hexString(colors.background))")
|
||||||
|
#expect(ColorMath.matchesHex(colors.foreground, 0x1A1D_24),
|
||||||
|
"实际 \(ColorMath.hexString(colors.foreground))")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("两档终端 fg↔bg 对比度 ≥ 7:1(终端是正文,AAA)")
|
||||||
|
func terminalContrastIsAAA() {
|
||||||
|
for scheme in [ColorScheme.dark, .light] {
|
||||||
|
let colors = TerminalPalette.colors(for: scheme)
|
||||||
|
let ratio = ColorMath.contrast(colors.foreground, colors.background)
|
||||||
|
#expect(ratio >= 7, "终端 \(scheme) 对比度只有 \(ratio):1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("caret / selection 用该档 accent 解析值")
|
||||||
|
func terminalCaretFollowsAccent() {
|
||||||
|
for (scheme, style) in [(ColorScheme.dark, UIUserInterfaceStyle.dark),
|
||||||
|
(ColorScheme.light, UIUserInterfaceStyle.light)] {
|
||||||
|
let colors = TerminalPalette.colors(for: scheme)
|
||||||
|
let accent = DS.Palette.accentUIColor().resolved(style)
|
||||||
|
#expect(ColorMath.approxEqual(colors.caret, accent))
|
||||||
|
#expect(ColorMath.approxEqual(colors.selection, accent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("terminalBackgroundUIColor()/ForegroundUIColor() 是动态 UIColor(两档不同)")
|
||||||
|
func terminalTokensAreDynamic() {
|
||||||
|
for token in [DS.Palette.terminalBackgroundUIColor(),
|
||||||
|
DS.Palette.terminalForegroundUIColor()] {
|
||||||
|
#expect(!ColorMath.approxEqual(token.resolved(.dark), token.resolved(.light)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("SwiftUI 侧 terminalBackground/Foreground 同样跟随主题(既有调用点不退化)")
|
||||||
|
func terminalSwiftUITokensStayDynamic() {
|
||||||
|
for color in [DS.Palette.terminalBackground, DS.Palette.terminalForeground] {
|
||||||
|
let bridged = UIColor(color)
|
||||||
|
#expect(!ColorMath.approxEqual(bridged.resolved(.dark), bridged.resolved(.light)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fake defaults
|
||||||
|
|
||||||
|
/// `ThemeDefaults` 测试替身:记录写次数("首次读不写盘"、"重复 select 只写一次"
|
||||||
|
/// 两条断言都靠它),并保留其它键以证明 store 不会越界清理。
|
||||||
|
final class FakeThemeDefaults: ThemeDefaults {
|
||||||
|
private(set) var values: [String: String]
|
||||||
|
private(set) var writeCount = 0
|
||||||
|
|
||||||
|
init(values: [String: String] = [:]) {
|
||||||
|
self.values = values
|
||||||
|
}
|
||||||
|
|
||||||
|
func themeRaw(forKey key: String) -> String? { values[key] }
|
||||||
|
|
||||||
|
func setThemeRaw(_ value: String, forKey key: String) {
|
||||||
|
values = values.merging([key: value]) { _, new in new }
|
||||||
|
writeCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color math (WCAG)
|
||||||
|
|
||||||
|
/// 颜色解析 + WCAG 相对亮度/对比度。放在测试侧:生产代码不需要算对比度,
|
||||||
|
/// 但审计必须**量化**("看着还行"不是验收)。公式取 WCAG 2.1 定义。
|
||||||
|
enum ColorMath {
|
||||||
|
typealias RGBA = (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat)
|
||||||
|
|
||||||
|
static func rgba(_ color: UIColor) -> RGBA {
|
||||||
|
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||||
|
color.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||||
|
return (r, g, b, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func approxEqual(_ lhs: UIColor, _ rhs: UIColor, tolerance: CGFloat = 0.02) -> Bool {
|
||||||
|
let left = rgba(lhs), right = rgba(rhs)
|
||||||
|
return abs(left.r - right.r) < tolerance
|
||||||
|
&& abs(left.g - right.g) < tolerance
|
||||||
|
&& abs(left.b - right.b) < tolerance
|
||||||
|
&& abs(left.a - right.a) < tolerance
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WCAG 相对亮度(sRGB → 线性)。
|
||||||
|
static func luminance(_ color: UIColor) -> CGFloat {
|
||||||
|
let components = rgba(color)
|
||||||
|
func linear(_ channel: CGFloat) -> CGFloat {
|
||||||
|
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
|
||||||
|
}
|
||||||
|
return 0.2126 * linear(components.r)
|
||||||
|
+ 0.7152 * linear(components.g)
|
||||||
|
+ 0.0722 * linear(components.b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WCAG 对比度(1:1…21:1),顺序无关。
|
||||||
|
static func contrast(_ lhs: UIColor, _ rhs: UIColor) -> CGFloat {
|
||||||
|
let a = luminance(lhs), b = luminance(rhs)
|
||||||
|
let (lighter, darker) = a > b ? (a, b) : (b, a)
|
||||||
|
return (lighter + 0.05) / (darker + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 逐字节(±1/255)比对一个具体色与 `0xRRGGBB`。
|
||||||
|
static func matchesHex(_ color: UIColor, _ hex: UInt32, tolerance: CGFloat = 0.004) -> Bool {
|
||||||
|
let components = rgba(color)
|
||||||
|
let expected = (
|
||||||
|
r: CGFloat((hex >> 16) & 0xFF) / 255,
|
||||||
|
g: CGFloat((hex >> 8) & 0xFF) / 255,
|
||||||
|
b: CGFloat(hex & 0xFF) / 255
|
||||||
|
)
|
||||||
|
return abs(components.r - expected.r) < tolerance
|
||||||
|
&& abs(components.g - expected.g) < tolerance
|
||||||
|
&& abs(components.b - expected.b) < tolerance
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 失败时把实际值印成 `#RRGGBB`,便于一眼看出差多少。
|
||||||
|
static func hexString(_ color: UIColor) -> String {
|
||||||
|
let components = rgba(color)
|
||||||
|
let value = (Int(components.r * 255) << 16)
|
||||||
|
| (Int(components.g * 255) << 8) | Int(components.b * 255)
|
||||||
|
return String(format: "#%06X", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIColor {
|
||||||
|
/// 按指定外观解析动态色(审计的基本动作)。
|
||||||
|
func resolved(_ style: UIUserInterfaceStyle) -> UIColor {
|
||||||
|
resolvedColor(with: UITraitCollection(userInterfaceStyle: style))
|
||||||
|
}
|
||||||
|
}
|
||||||
302
ios/App/WebTermTests/DeepLinkJoinTests.swift
Normal file
302
ios/App/WebTermTests/DeepLinkJoinTests.swift
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import Foundation
|
||||||
|
import HostRegistry
|
||||||
|
import Testing
|
||||||
|
import WireProtocol
|
||||||
|
@testable import WebTerm
|
||||||
|
|
||||||
|
/// T-iOS-35 · web 分享 QR(`?join=`)互通(doc §4 A–C 组,条目 30–50)。
|
||||||
|
///
|
||||||
|
/// web 侧的分享 URL 形状是 `${location.origin}/?join=${sessionId}`
|
||||||
|
/// (`public/share.ts:55`)。手机拿到这个 URL 时它是**不可信外部输入**(二维码
|
||||||
|
/// 里可以是任何东西),所以 http(s) 分支比自定义 scheme 分支**更严**:
|
||||||
|
/// scheme/host/path/query 键逐项白名单、query 精确只许一个 `join`、拒 fragment、
|
||||||
|
/// 拒 userinfo,`sessionId` 仍只经冻结的 `Validation.isValidSessionId`。
|
||||||
|
///
|
||||||
|
/// 主机身份**只**经 `HostStore` 解析:origin 比对用 `HostEndpoint.originHeader`
|
||||||
|
/// (冻结的唯一 origin 派生点),未配对 → 落配对流程,**绝不**照链接内容静默新增主机。
|
||||||
|
@MainActor
|
||||||
|
@Suite("DeepLinkJoin")
|
||||||
|
struct DeepLinkJoinTests {
|
||||||
|
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||||
|
private static let v1StyleId = "11111111-2222-1333-8444-555555555555"
|
||||||
|
|
||||||
|
private static func url(_ string: String) throws -> URL {
|
||||||
|
try #require(URL(string: string))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - A. 接受 web 分享形状
|
||||||
|
|
||||||
|
@Test("http://<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 键 → .ignore(web 形状精确只有一个 join)")
|
||||||
|
func extraQueryKeysIgnored() throws {
|
||||||
|
#expect(
|
||||||
|
DeepLinkRouter.route(
|
||||||
|
url: try Self.url("http://mac.local/?join=\(Self.validSessionId)&x=1")
|
||||||
|
) == .ignore
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
DeepLinkRouter.route(
|
||||||
|
url: try Self.url("http://mac.local/?utm=a&join=\(Self.validSessionId)")
|
||||||
|
) == .ignore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("非空 path → .ignore")
|
||||||
|
func nonEmptyPathIgnored() throws {
|
||||||
|
#expect(
|
||||||
|
DeepLinkRouter.route(
|
||||||
|
url: try Self.url("http://mac.local/manage.html?join=\(Self.validSessionId)")
|
||||||
|
) == .ignore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("带 fragment → .ignore")
|
||||||
|
func fragmentIgnored() throws {
|
||||||
|
#expect(
|
||||||
|
DeepLinkRouter.route(
|
||||||
|
url: try Self.url("http://mac.local/?join=\(Self.validSessionId)#x")
|
||||||
|
) == .ignore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("带 userinfo(二维码钓鱼形状)→ .ignore")
|
||||||
|
func userInfoIgnored() throws {
|
||||||
|
#expect(
|
||||||
|
DeepLinkRouter.route(
|
||||||
|
url: try Self.url("http://user:pass@mac.local/?join=\(Self.validSessionId)")
|
||||||
|
) == .ignore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("无 host → .ignore")
|
||||||
|
func missingHostIgnored() throws {
|
||||||
|
#expect(
|
||||||
|
DeepLinkRouter.route(url: try Self.url("http:///?join=\(Self.validSessionId)"))
|
||||||
|
== .ignore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("自定义 scheme 分支零回归:webterminal 仍需 host+join 双 v4")
|
||||||
|
func customSchemeBranchUnchanged() throws {
|
||||||
|
#expect(
|
||||||
|
DeepLinkRouter.route(url: try Self.url("webterminal://open?join=\(Self.validSessionId)"))
|
||||||
|
== .ignore
|
||||||
|
)
|
||||||
|
let hostId = UUID()
|
||||||
|
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||||
|
#expect(
|
||||||
|
DeepLinkRouter.route(
|
||||||
|
url: try Self.url(
|
||||||
|
"webterminal://open?host=\(hostId.uuidString)&join=\(Self.validSessionId)"
|
||||||
|
)
|
||||||
|
) == .openSession(hostId: hostId, sessionId: sessionId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C 组 —— `DeepLinkHandler` 侧:origin → 已配对主机的解析。
|
||||||
|
@MainActor
|
||||||
|
@Suite("DeepLinkJoinHandler")
|
||||||
|
struct DeepLinkJoinHandlerTests {
|
||||||
|
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class ActionRecorder {
|
||||||
|
private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = []
|
||||||
|
private(set) var pairingShownCount = 0
|
||||||
|
|
||||||
|
var actions: DeepLinkHandler.Actions {
|
||||||
|
DeepLinkHandler.Actions(
|
||||||
|
openSession: { [weak self] host, sessionId in
|
||||||
|
self?.opened.append((host, sessionId))
|
||||||
|
},
|
||||||
|
showPairing: { [weak self] in self?.pairingShownCount += 1 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeHost(_ base: String) throws -> HostRegistry.Host {
|
||||||
|
let url = try #require(URL(string: base))
|
||||||
|
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||||
|
return HostRegistry.Host(id: UUID(), name: "mac", endpoint: endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shareURL(_ origin: String) throws -> URL {
|
||||||
|
try #require(URL(string: "\(origin)/?join=\(validSessionId)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("origin 命中已配对主机 → 恰一次 openSession,无 hint")
|
||||||
|
func knownOriginOpensSession() async throws {
|
||||||
|
let host = try Self.makeHost("http://192.168.1.5:3000")
|
||||||
|
let recorder = ActionRecorder()
|
||||||
|
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||||
|
await handler.markReady()
|
||||||
|
|
||||||
|
await handler.handle(url: try Self.shareURL("http://192.168.1.5:3000"))
|
||||||
|
|
||||||
|
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||||
|
#expect(recorder.opened.count == 1)
|
||||||
|
#expect(recorder.opened.first?.host == host)
|
||||||
|
#expect(recorder.opened.first?.sessionId == sessionId)
|
||||||
|
#expect(handler.hintMessage == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("origin 未配对 → 零 open + 落配对流程(绝不据链接静默新增主机)")
|
||||||
|
func unknownOriginNeverPairsSilently() async throws {
|
||||||
|
let paired = try Self.makeHost("http://192.168.1.5:3000")
|
||||||
|
let recorder = ActionRecorder()
|
||||||
|
let handler = DeepLinkHandler(loadHosts: { [paired] }, actions: recorder.actions)
|
||||||
|
await handler.markReady()
|
||||||
|
|
||||||
|
await handler.handle(url: try Self.shareURL("http://10.0.0.9:3000"))
|
||||||
|
|
||||||
|
#expect(recorder.opened.isEmpty)
|
||||||
|
#expect(recorder.pairingShownCount == 1)
|
||||||
|
#expect(handler.hintMessage == DeepLinkCopy.unknownHostHint)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("提示文案不回显链接内容(防钓鱼/防日志注入)")
|
||||||
|
func hintNeverEchoesLinkContents() async throws {
|
||||||
|
let recorder = ActionRecorder()
|
||||||
|
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
|
||||||
|
await handler.markReady()
|
||||||
|
|
||||||
|
await handler.handle(url: try Self.shareURL("http://evil.example:3000"))
|
||||||
|
|
||||||
|
let hint = try #require(handler.hintMessage)
|
||||||
|
#expect(!hint.contains("evil.example"))
|
||||||
|
#expect(!hint.contains(Self.validSessionId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("origin 比对经 originHeader:大小写/尾斜杠同一主机,端口不同则不是")
|
||||||
|
func originComparisonUsesOriginHeader() async throws {
|
||||||
|
let host = try Self.makeHost("http://mac.local:3000")
|
||||||
|
let recorder = ActionRecorder()
|
||||||
|
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||||
|
await handler.markReady()
|
||||||
|
|
||||||
|
await handler.handle(url: try Self.shareURL("http://MAC.LOCAL:3000"))
|
||||||
|
#expect(recorder.opened.count == 1)
|
||||||
|
|
||||||
|
await handler.handle(url: try Self.shareURL("http://mac.local:3001"))
|
||||||
|
#expect(recorder.opened.count == 1) // 端口不同 → 不是同一主机
|
||||||
|
#expect(recorder.pairingShownCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("scheme 不同 → 不是同一主机")
|
||||||
|
func schemeMismatchIsDifferentHost() async throws {
|
||||||
|
let host = try Self.makeHost("http://mac.local")
|
||||||
|
let recorder = ActionRecorder()
|
||||||
|
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||||
|
await handler.markReady()
|
||||||
|
|
||||||
|
await handler.handle(url: try Self.shareURL("https://mac.local"))
|
||||||
|
|
||||||
|
#expect(recorder.opened.isEmpty)
|
||||||
|
#expect(recorder.pairingShownCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("冷启动 stash 对 .joinShared 同样生效(恰应用一次)")
|
||||||
|
func coldLaunchStashAppliesJoin() async throws {
|
||||||
|
let host = try Self.makeHost("http://mac.local:3000")
|
||||||
|
let recorder = ActionRecorder()
|
||||||
|
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||||
|
|
||||||
|
await handler.handle(url: try Self.shareURL("http://mac.local:3000"))
|
||||||
|
#expect(recorder.opened.isEmpty)
|
||||||
|
|
||||||
|
await handler.markReady()
|
||||||
|
#expect(recorder.opened.count == 1)
|
||||||
|
|
||||||
|
await handler.markReady()
|
||||||
|
#expect(recorder.opened.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("非法 web 链接 → ignoredCount+1 且不入 stash")
|
||||||
|
func invalidWebLinkCountedNeverStashed() async throws {
|
||||||
|
let host = try Self.makeHost("http://mac.local:3000")
|
||||||
|
let recorder = ActionRecorder()
|
||||||
|
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||||
|
|
||||||
|
await handler.handle(
|
||||||
|
url: try #require(URL(string: "http://mac.local:3000/?join=nope&x=1"))
|
||||||
|
)
|
||||||
|
#expect(handler.ignoredCount == 1)
|
||||||
|
|
||||||
|
await handler.markReady()
|
||||||
|
|
||||||
|
#expect(recorder.opened.isEmpty)
|
||||||
|
#expect(recorder.pairingShownCount == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,14 +78,30 @@ struct DeepLinkRouterTests {
|
|||||||
|
|
||||||
// MARK: - URL 解析:白名单拒绝路径(任一非法 → .ignore)
|
// MARK: - URL 解析:白名单拒绝路径(任一非法 → .ignore)
|
||||||
|
|
||||||
@Test("scheme 非 webterminal → .ignore")
|
/// T-iOS-35 **有意改写**:本例原名"scheme 非 webterminal → .ignore",断言
|
||||||
func wrongSchemeIgnored() throws {
|
/// 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(
|
let route = DeepLinkRouter.route(
|
||||||
url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)")
|
url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)")
|
||||||
)
|
)
|
||||||
#expect(route == .ignore)
|
#expect(route == .ignore)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("白名单外的 scheme(ftp/file/javascript)→ .ignore")
|
||||||
|
func nonWhitelistedSchemeIgnored() throws {
|
||||||
|
for scheme in ["ftp", "file", "javascript"] {
|
||||||
|
let route = DeepLinkRouter.route(
|
||||||
|
url: try Self.url("\(scheme)://open?join=\(Self.validSessionId)")
|
||||||
|
)
|
||||||
|
#expect(route == .ignore, "\(scheme) 不该被接受")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test("action 非 open → .ignore")
|
@Test("action 非 open → .ignore")
|
||||||
func wrongActionIgnored() throws {
|
func wrongActionIgnored() throws {
|
||||||
let route = DeepLinkRouter.route(
|
let route = DeepLinkRouter.route(
|
||||||
|
|||||||
@@ -44,11 +44,21 @@ struct DesignSystemTests {
|
|||||||
#expect(!approxEqual(waiting, stuck))
|
#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() {
|
func statusColorsMatchDirection() {
|
||||||
assertColor(StatusStyle.style(for: DisplayStatus.working).color, r: 70, g: 208, b: 127) // #46D07F web --green
|
let dark = UITraitCollection(userInterfaceStyle: .dark)
|
||||||
assertColor(StatusStyle.style(for: DisplayStatus.waiting).color, r: 245, g: 177, b: 76) // #F5B14C web --amber
|
assertColor(StatusStyle.style(for: DisplayStatus.working).color, in: dark,
|
||||||
assertColor(StatusStyle.style(for: DisplayStatus.stuck).color, r: 255, g: 107, b: 107) // #FF6B6B web --red
|
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")
|
@Test("the wire ClaudeStatus bridge covers all five cases")
|
||||||
@@ -150,6 +160,13 @@ struct DesignSystemTests {
|
|||||||
assertRGBA(rgba(color), r: r, g: g, b: b)
|
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) {
|
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.r - CGFloat(r) / 255) < tolerance)
|
||||||
#expect(abs(value.g - CGFloat(g) / 255) < tolerance)
|
#expect(abs(value.g - CGFloat(g) / 255) < tolerance)
|
||||||
|
|||||||
286
ios/App/WebTermTests/DynamicTypeLayoutTests.swift
Normal file
286
ios/App/WebTermTests/DynamicTypeLayoutTests.swift
Normal 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 组,条目 25–29)。
|
||||||
|
///
|
||||||
|
/// 验收口径是"最大字号不破版",所以断言必须是**量出来的**,不是"看着还行":
|
||||||
|
/// 每个受测视图套 `UIHostingController` 在一个 320pt 宽(iPhone SE / 最窄
|
||||||
|
/// 现役机型)的提议里量 `sizeThatFits`,再按两条规则判定:
|
||||||
|
/// 1. **不横向溢出** —— 返回宽度 ≤ 提议宽度(超出即会被裁/顶出屏幕);
|
||||||
|
/// 2. **长高而不是被裁** —— AX 档高度必须 > 标准档高度(固定高容器会让它相等)。
|
||||||
|
///
|
||||||
|
/// 密集数字(`TelemetryChip` / `dsMetaText`)另走**夹取**策略:表格数字放大到
|
||||||
|
/// AX5 会把一行元信息撑成三行,故 DS 在 `DS.Typography.numericClamp` 处统一封顶,
|
||||||
|
/// 测试用"AX2 与 AX5 量出同一高度"来钉死夹取真的生效(而不是只写了个注释)。
|
||||||
|
@MainActor
|
||||||
|
@Suite("DynamicTypeLayout")
|
||||||
|
struct DynamicTypeLayoutTests {
|
||||||
|
|
||||||
|
// MARK: - F25 · GateBanner(最关键的一屏:批准/拒绝 shell 命令)
|
||||||
|
|
||||||
|
@Test("GateBanner 在 AX5 下不横向溢出,且相比标准档是长高而非被裁")
|
||||||
|
func gateBannerSurvivesLargestType() {
|
||||||
|
let gate = GateState(kind: .tool, detail: "Bash(rm -rf ./build)", epoch: 1)
|
||||||
|
let banner = GateBanner(gate: gate) { _, _ in }
|
||||||
|
|
||||||
|
let standard = LayoutProbe.fit(banner, size: .large)
|
||||||
|
let largest = LayoutProbe.fit(banner, size: .accessibility5)
|
||||||
|
|
||||||
|
#expect(largest.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
|
||||||
|
#expect(largest.height.isFinite)
|
||||||
|
#expect(largest.height > standard.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - F26/F27 · 密集数字:夹取生效
|
||||||
|
|
||||||
|
@Test("numericClamp 策略:封顶在 accessibility2,严格小于 accessibility5")
|
||||||
|
func numericClampPolicy() {
|
||||||
|
#expect(DS.Typography.numericClamp == .accessibility2)
|
||||||
|
#expect(DS.Typography.numericClamp < .accessibility5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("TelemetryChip:AX2 与 AX5 同高(夹取生效),且不横向溢出")
|
||||||
|
func telemetryChipIsClamped() {
|
||||||
|
let chip = TelemetryChip(systemImage: "cpu", text: "ctx 92% · $0.1234")
|
||||||
|
|
||||||
|
let clampEdge = LayoutProbe.fit(chip, size: DS.Typography.numericClamp)
|
||||||
|
let largest = LayoutProbe.fit(chip, size: .accessibility5)
|
||||||
|
|
||||||
|
#expect(largest.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
|
||||||
|
#expect(abs(largest.height - clampEdge.height) < LayoutProbe.epsilon)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("TelemetryChip 在夹取上限之前照常放大(不是把字号焊死)")
|
||||||
|
func telemetryChipStillScalesBelowClamp() {
|
||||||
|
let chip = TelemetryChip(text: "161×50")
|
||||||
|
|
||||||
|
let standard = LayoutProbe.fit(chip, size: .large)
|
||||||
|
let clampEdge = LayoutProbe.fit(chip, size: DS.Typography.numericClamp)
|
||||||
|
|
||||||
|
#expect(clampEdge.height > standard.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("dsMetaText 元信息行走同一夹取(单一出处,非逐屏各写一遍)")
|
||||||
|
func metaTextIsClamped() {
|
||||||
|
let meta = Text(verbatim: "2 台设备在看 · 161×50").dsMetaText()
|
||||||
|
|
||||||
|
let clampEdge = LayoutProbe.fit(meta, size: DS.Typography.numericClamp)
|
||||||
|
let largest = LayoutProbe.fit(meta, size: .accessibility5)
|
||||||
|
|
||||||
|
#expect(abs(largest.height - clampEdge.height) < LayoutProbe.epsilon)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - F28 · DSButtonStyle(gate 的两个半宽按钮是最窄的按钮场景)
|
||||||
|
|
||||||
|
@Test("DSButtonStyle 在 AX5 半宽容器里仍 ≥ 44pt 高且不横向溢出")
|
||||||
|
func buttonStyleSurvivesLargestType() {
|
||||||
|
let button = Button(GateChoiceSpec.label(for: .approve)) {}
|
||||||
|
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||||
|
let halfWidth = LayoutProbe.phoneWidth / 2
|
||||||
|
|
||||||
|
let standard = LayoutProbe.fit(button, width: halfWidth, size: .large)
|
||||||
|
let largest = LayoutProbe.fit(button, width: halfWidth, size: .accessibility5)
|
||||||
|
|
||||||
|
#expect(largest.width <= halfWidth + LayoutProbe.epsilon)
|
||||||
|
#expect(largest.height >= DS.Layout.minHitTarget)
|
||||||
|
// `minHeight`(而非 `height`)才让换行后的标签长高而不是被裁到 44pt。
|
||||||
|
#expect(largest.height > standard.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 新增设置页自身也过一遍 AX5
|
||||||
|
|
||||||
|
@Test("SettingsScreen 在 AX5 下可渲染且不横向溢出")
|
||||||
|
func settingsScreenSurvivesLargestType() {
|
||||||
|
let screen = SettingsScreen(themeStore: ThemeStore(defaults: FakeThemeDefaults()))
|
||||||
|
|
||||||
|
let size = LayoutProbe.fit(NavigationStack { screen }, size: .accessibility5)
|
||||||
|
|
||||||
|
#expect(size.width <= LayoutProbe.phoneWidth + LayoutProbe.epsilon)
|
||||||
|
#expect(size.height.isFinite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - F29 · KeyBar(UIKit inputAccessoryView):条高跟随键帽
|
||||||
|
|
||||||
|
/// F1 · 这里原是 `withKnownIssue` 记录的**已知缺口**:`KeyBarMetrics.barHeight`
|
||||||
|
/// 固定 52pt(44 + 8),而 AX5 下一枚键帽要 ~108pt(footnote 行高 52.51 +
|
||||||
|
/// caption2 行高 47.73 + 上下 4+4 内衬)——超过一半被裁,T-iOS-34 的验收
|
||||||
|
/// 「最大字号不破版」实际是 FAIL。现已修好:条高由**键帽实际画出来的高度**
|
||||||
|
/// 推出(下限 44pt 命中目标,字号在 DS 的密集内容上限处封顶),所以本组
|
||||||
|
/// 断言全部是真绿,`withKnownIssue` 标记已删除。
|
||||||
|
@Test("KeyBar 在 AX5 下条高容得下它真正画出的键帽(原缺口:裁掉一半以上)")
|
||||||
|
func keyBarFitsItsKeycapsAtLargestType() {
|
||||||
|
let measured = KeyBarProbe.measure(at: KeyBarProbe.largestCategory)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
measured.keycap <= measured.barHeight + LayoutProbe.epsilon,
|
||||||
|
"AX5 键帽需 \(measured.keycap)pt,条高只有 \(measured.barHeight)pt"
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
measured.typographic <= measured.barHeight + LayoutProbe.epsilon,
|
||||||
|
"AX5 排版需求 \(measured.typographic)pt,条高只有 \(measured.barHeight)pt"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("KeyBar 在全部 12 个字号档都不裁切,且条高始终 ≥ 44pt 命中目标")
|
||||||
|
func keyBarFitsItsKeycapsAtEveryContentSize() {
|
||||||
|
for category in KeyBarProbe.allCategories {
|
||||||
|
let measured = KeyBarProbe.measure(at: category)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
measured.keycap <= measured.barHeight + LayoutProbe.epsilon,
|
||||||
|
"\(category.rawValue):键帽 \(measured.keycap)pt > 条高 \(measured.barHeight)pt"
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
measured.typographic <= measured.barHeight + LayoutProbe.epsilon,
|
||||||
|
"\(category.rawValue):排版需求 \(measured.typographic)pt > 条高 \(measured.barHeight)pt"
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
measured.barHeight >= DS.Layout.minHitTarget,
|
||||||
|
"\(category.rawValue):条高 \(measured.barHeight)pt 低于命中目标"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回归护栏 + 缺口的历史事实:**不封顶**的 AX5 排版需求(实测 108.24pt =
|
||||||
|
/// footnote 52.51 + caption2 47.73 + 内衬 8)远超出厂的固定 52pt —— 谁再把
|
||||||
|
/// `barHeight` 写回常量,这条就红。
|
||||||
|
@Test("未封顶的 AX5 排版需求远超出厂 52pt 固定条高(这就是原缺口)")
|
||||||
|
func unclampedLargestTypeOverflowsTheOldFixedBar() {
|
||||||
|
let shipped = DS.Layout.minHitTarget + DS.Space.sm8
|
||||||
|
|
||||||
|
let unclamped = KeyBarProbe.typographicRequirement(at: KeyBarProbe.largestCategory)
|
||||||
|
|
||||||
|
#expect(unclamped > shipped * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 零回归的准确口径:**键帽还没超过 44pt 命中目标的档位**(XS…XL,含出厂
|
||||||
|
/// 默认 L)条高逐点还是 52pt。再往上(XXL 起)键帽本身就比 44pt 高了,条高
|
||||||
|
/// 必须跟着长——那正是本次修复,不是回归。
|
||||||
|
@Test("默认与更小字号档条高逐点不变(44 + 8 = 52,零回归)")
|
||||||
|
func keyBarKeepsItsShippedHeightAtTheSmallerSizes() {
|
||||||
|
let shipped = DS.Layout.minHitTarget + DS.Space.sm8
|
||||||
|
|
||||||
|
for category in [UIContentSizeCategory.extraSmall, .small, .medium,
|
||||||
|
.large, .extraLarge] {
|
||||||
|
#expect(KeyBarMetrics.barHeight(category) == shipped, "\(category.rawValue)")
|
||||||
|
}
|
||||||
|
#expect(KeyBarProbe.measure(at: .large).barHeight == shipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("键帽真的随字号长大(不是把字号焊死换来的假绿)")
|
||||||
|
func keyBarActuallyGrowsWithType() {
|
||||||
|
let standard = KeyBarProbe.measure(at: .large)
|
||||||
|
let largest = KeyBarProbe.measure(at: KeyBarProbe.largestCategory)
|
||||||
|
|
||||||
|
#expect(largest.keycap > standard.keycap)
|
||||||
|
#expect(largest.barHeight > standard.barHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 键栏骑在软键盘上方:长高是必须的,长疯了就把它要驱动的终端吃掉了。
|
||||||
|
/// 故字号在 DS 的密集内容上限(`numericClamp`)处封顶——两处策略必须同源。
|
||||||
|
@Test("字号封顶 == DS 密集内容策略,封顶后条高不再增长且不超出货架高度的两倍")
|
||||||
|
func keyBarTypeCeilingMatchesTheDesignSystemPolicy() {
|
||||||
|
let shipped = DS.Layout.minHitTarget + DS.Space.sm8
|
||||||
|
|
||||||
|
#expect(DynamicTypeSize(KeyBarMetrics.typeCeiling) == DS.Typography.numericClamp)
|
||||||
|
|
||||||
|
let ceiling = KeyBarMetrics.barHeight(KeyBarMetrics.typeCeiling)
|
||||||
|
#expect(KeyBarMetrics.barHeight(KeyBarProbe.largestCategory) == ceiling)
|
||||||
|
// 封顶之前照常长大(不是从第一档就焊死)。
|
||||||
|
#expect(ceiling > KeyBarMetrics.barHeight(.large))
|
||||||
|
// 有界:AX5 的键栏最多是出厂高度的两倍,终端不会被键栏吃掉。
|
||||||
|
#expect(ceiling <= shipped * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("frame 高度与 intrinsicContentSize 一致(自适应输入视图靠后者自量)")
|
||||||
|
func keyBarFrameMatchesItsIntrinsicHeight() {
|
||||||
|
for category in [UIContentSizeCategory.large, KeyBarProbe.largestCategory] {
|
||||||
|
let bar = KeyBarView(contentSizeCategory: category)
|
||||||
|
|
||||||
|
#expect(bar.frame.height == bar.intrinsicContentSize.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Probes
|
||||||
|
|
||||||
|
/// SwiftUI 度量:把视图放进 `UIHostingController`,在给定宽度的提议下问它
|
||||||
|
/// `sizeThatFits`。不套 `.frame(width:)` —— 那会把返回宽度强行钉成提议宽度,
|
||||||
|
/// 正好掩盖我们要抓的横向溢出。
|
||||||
|
@MainActor
|
||||||
|
enum LayoutProbe {
|
||||||
|
/// 最窄现役 iPhone 逻辑宽度(SE 系)。
|
||||||
|
static let phoneWidth: CGFloat = 320
|
||||||
|
/// 浮点度量容差。
|
||||||
|
static let epsilon: CGFloat = 0.5
|
||||||
|
|
||||||
|
static func fit<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
|
||||||
|
}
|
||||||
|
}
|
||||||
224
ios/App/WebTermTests/GitPanelPresentationTests.swift
Normal file
224
ios/App/WebTermTests/GitPanelPresentationTests.swift
Normal 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 清单 36–47,见 docs/plans/ios-completion.md §4)。
|
||||||
|
@Suite("GitPanelPresentation")
|
||||||
|
struct GitPanelPresentationTests {
|
||||||
|
/// 固定"现在":2026-07-30T12:00:00Z 的毫秒值,避免用例依赖真实时钟。
|
||||||
|
private static let nowMs: Double = 1_785_412_800_000
|
||||||
|
|
||||||
|
private static func minutesAgo(_ minutes: Double) -> Double {
|
||||||
|
nowMs - minutes * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 同步带(RED 36–43)
|
||||||
|
|
||||||
|
@Test("非 git 目录(sync == nil)→ 无同步带")
|
||||||
|
func nonGitHasNoBand() {
|
||||||
|
#expect(GitSyncBand.make(sync: nil, dirtyCount: nil, nowMs: Self.nowMs) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("↑0 ↓0 + 新鲜 fetch → 唯一允许的「已同步」绿色态")
|
||||||
|
func inSyncNeedsFreshFetch() throws {
|
||||||
|
let band = try #require(GitSyncBand.make(
|
||||||
|
sync: SyncState(
|
||||||
|
upstream: "origin/develop", ahead: 0, behind: 0,
|
||||||
|
lastFetchMs: Self.minutesAgo(5)
|
||||||
|
),
|
||||||
|
dirtyCount: 0, nowMs: Self.nowMs
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(band.isInSync)
|
||||||
|
#expect(band.isBehindVerified)
|
||||||
|
#expect(band.canFetch)
|
||||||
|
#expect(band.head == .tracking(
|
||||||
|
upstream: "origin/develop", ahead: 0, behind: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("↑0 ↓0 但 fetch 已过期(> FETCH_STALE_MS)→ 不绿,↓ 未核实")
|
||||||
|
func staleFetchIsNeverGreen() throws {
|
||||||
|
let band = try #require(GitSyncBand.make(
|
||||||
|
sync: SyncState(
|
||||||
|
upstream: "origin/develop", ahead: 0, behind: 0,
|
||||||
|
lastFetchMs: Self.minutesAgo(61)
|
||||||
|
),
|
||||||
|
dirtyCount: 0, nowMs: Self.nowMs
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(!band.isInSync)
|
||||||
|
#expect(!band.isBehindVerified)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("FETCH_STALE_MS 就是 1 小时(镜像 public/projects.ts:615)")
|
||||||
|
func staleThresholdMatchesWeb() {
|
||||||
|
#expect(GitSyncBand.fetchStaleMs == 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("从未 fetch(lastFetchMs == nil)→ 视为过期,不绿")
|
||||||
|
func neverFetchedIsStale() throws {
|
||||||
|
let band = try #require(GitSyncBand.make(
|
||||||
|
sync: SyncState(upstream: "origin/develop", ahead: 0, behind: 0, lastFetchMs: nil),
|
||||||
|
dirtyCount: 0, nowMs: Self.nowMs
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(!band.isInSync)
|
||||||
|
#expect(!band.isBehindVerified)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("无上游 → 「无上游」,没有 ↑↓,绝不绿(最易犯的 bug)")
|
||||||
|
func noUpstreamIsNeverGreen() throws {
|
||||||
|
let band = try #require(GitSyncBand.make(
|
||||||
|
sync: SyncState(upstream: nil, ahead: nil, behind: nil, lastFetchMs: Self.nowMs),
|
||||||
|
dirtyCount: 0, nowMs: Self.nowMs
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(band.head == .noUpstream)
|
||||||
|
#expect(!band.isInSync)
|
||||||
|
#expect(band.canFetch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("分离 HEAD → 「分离 HEAD」,fetch 禁用,无 ↑↓")
|
||||||
|
func detachedDisablesFetch() throws {
|
||||||
|
let band = try #require(GitSyncBand.make(
|
||||||
|
sync: SyncState(upstream: nil, ahead: nil, behind: nil, lastFetchMs: nil, detached: true),
|
||||||
|
dirtyCount: nil, nowMs: Self.nowMs
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(band.head == .detached)
|
||||||
|
#expect(!band.canFetch)
|
||||||
|
#expect(!band.isInSync)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ahead 读不到(nil)→ 保留 nil(渲染 ↑ —),且不绿")
|
||||||
|
func unknownAheadIsNotZero() throws {
|
||||||
|
let band = try #require(GitSyncBand.make(
|
||||||
|
sync: SyncState(
|
||||||
|
upstream: "origin/develop", ahead: nil, behind: 0,
|
||||||
|
lastFetchMs: Self.minutesAgo(1)
|
||||||
|
),
|
||||||
|
dirtyCount: 0, nowMs: Self.nowMs
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(band.head == .tracking(upstream: "origin/develop", ahead: nil, behind: 0))
|
||||||
|
#expect(!band.isInSync)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(
|
||||||
|
"dirtyCount:nil → 未检查(不是 clean)、0 → clean、3 → changed(3)",
|
||||||
|
arguments: [
|
||||||
|
(Int?.none, GitSyncBand.Dirty.unknown),
|
||||||
|
(Int?(0), GitSyncBand.Dirty.clean),
|
||||||
|
(Int?(3), GitSyncBand.Dirty.changed(3)),
|
||||||
|
] as [(Int?, GitSyncBand.Dirty)]
|
||||||
|
)
|
||||||
|
func dirtyTriState(dirtyCount: Int?, expected: GitSyncBand.Dirty) throws {
|
||||||
|
let band = try #require(GitSyncBand.make(
|
||||||
|
sync: SyncState(upstream: "origin/x", ahead: 0, behind: 0, lastFetchMs: Self.nowMs),
|
||||||
|
dirtyCount: dirtyCount, nowMs: Self.nowMs
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(band.dirty == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 未推送边界(RED 44–46)
|
||||||
|
|
||||||
|
@Test("边界画在最后一个 unpushed 之后,且只画一次")
|
||||||
|
func boundaryAfterLastUnpushed() {
|
||||||
|
let commits = [
|
||||||
|
CommitLogEntry(hash: "aaa", at: 3, subject: "c", unpushed: true),
|
||||||
|
CommitLogEntry(hash: "bbb", at: 2, subject: "b", unpushed: true),
|
||||||
|
CommitLogEntry(hash: "ccc", at: 1, subject: "a", unpushed: false),
|
||||||
|
]
|
||||||
|
|
||||||
|
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("unpushed 只在严格 true 时生效(nil ≠ false ≠ true)")
|
||||||
|
func nilUnpushedDrawsNoBoundary() {
|
||||||
|
let commits = [
|
||||||
|
CommitLogEntry(hash: "aaa", at: 2, subject: "c", unpushed: nil),
|
||||||
|
CommitLogEntry(hash: "bbb", at: 1, subject: "b", unpushed: false),
|
||||||
|
]
|
||||||
|
|
||||||
|
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("无上游 → 完全不画边界(没有可比对的东西)")
|
||||||
|
func noUpstreamDrawsNoBoundary() {
|
||||||
|
let commits = [CommitLogEntry(hash: "aaa", at: 1, subject: "c", unpushed: true)]
|
||||||
|
|
||||||
|
#expect(GitLogBoundary.index(commits: commits, upstream: nil) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("未推送提交排在已推送之下(老日期 merge)→ 边界仍在最后一个 unpushed 之后")
|
||||||
|
func interleavedUnpushedStillBounds() {
|
||||||
|
let commits = [
|
||||||
|
CommitLogEntry(hash: "aaa", at: 5, subject: "new pushed", unpushed: false),
|
||||||
|
CommitLogEntry(hash: "bbb", at: 4, subject: "merge of old branch", unpushed: true),
|
||||||
|
CommitLogEntry(hash: "ccc", at: 3, subject: "older pushed", unpushed: false),
|
||||||
|
]
|
||||||
|
|
||||||
|
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 相对时间
|
||||||
|
|
||||||
|
@Test(
|
||||||
|
"相对时间:秒/分/时/天各档非空且互不相同",
|
||||||
|
arguments: [0.5, 5, 90, 60 * 26] as [Double]
|
||||||
|
)
|
||||||
|
func relativeTimeBuckets(minutes: Double) {
|
||||||
|
let label = GitTimeFormat.relative(fromMs: Self.minutesAgo(minutes), nowMs: Self.nowMs)
|
||||||
|
|
||||||
|
#expect(!label.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("未来时间戳(主机时钟漂移)→ 退化为「刚刚」,不出现负数")
|
||||||
|
func futureTimestampDegrades() {
|
||||||
|
let label = GitTimeFormat.relative(fromMs: Self.nowMs + 60_000, nowMs: Self.nowMs)
|
||||||
|
|
||||||
|
#expect(!label.contains("-"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 写失败文案(RED 47)
|
||||||
|
|
||||||
|
@Test("服务器安全文案原样显示(绝不吞、绝不自造)")
|
||||||
|
func serverMessageIsSurfacedVerbatim() {
|
||||||
|
let text = GitWriteFeedback.message(
|
||||||
|
status: 409, serverMessage: "Nothing staged to commit.", fallback: "兜底"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(text.contains("Nothing staged to commit."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("服务器没给 message → 兜底中文文案(非空)")
|
||||||
|
func missingServerMessageFallsBack() {
|
||||||
|
let text = GitWriteFeedback.message(status: 500, serverMessage: nil, fallback: "提交失败")
|
||||||
|
|
||||||
|
#expect(!text.isEmpty)
|
||||||
|
#expect(text.contains("提交失败"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("抛出的 APIClientError → 其自带话术(.unauthorized 引导补令牌)")
|
||||||
|
func thrownAPIErrorUsesItsOwnCopy() {
|
||||||
|
let text = GitWriteFeedback.message(for: APIClientError.unauthorized, fallback: "兜底")
|
||||||
|
|
||||||
|
#expect(text == APIClientError.unauthorized.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("非 APIClientError(传输层)→ 兜底文案,不 crash")
|
||||||
|
func transportErrorFallsBack() {
|
||||||
|
let text = GitWriteFeedback.message(
|
||||||
|
for: URLError(.notConnectedToInternet), fallback: "推送失败"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(text.contains("推送失败"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user