Compare commits
11 Commits
822364d12b
...
576c033031
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
576c033031 | ||
|
|
f000a9d979 | ||
|
|
97e39949a8 | ||
|
|
ddab77b337 | ||
|
|
5cc755b0b6 | ||
|
|
284cfd193a | ||
|
|
9114630c3a | ||
|
|
a5fa843f00 | ||
|
|
850531fd07 | ||
|
|
c4f8b5b47f | ||
|
|
0de5557921 |
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
|
||||
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
|
||||
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
|
||||
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle,
|
||||
# iPhone 16 simulator): ViewModels/components of the app glue layer.
|
||||
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle) on
|
||||
# an iPhone AND an iPad simulator: ViewModels/components of the app glue
|
||||
# layer, on both the compact and the regular size class.
|
||||
# 3. integration-tests — Swift Testing against the REAL Node server. The
|
||||
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
|
||||
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
|
||||
@@ -35,14 +36,15 @@ on:
|
||||
|
||||
jobs:
|
||||
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
|
||||
# The gate covers exactly the 4 gated packages (plan §9); TestSupport runs
|
||||
# tests below without a gate (test doubles are excluded from the gate).
|
||||
# The gate covers the 4 packages of plan §9 PLUS ClientTLS (added by B4 — the
|
||||
# mTLS identity/keychain package, previously the only ungated one at 55.76%).
|
||||
# TestSupport runs tests below without a gate (test doubles are excluded).
|
||||
package-tests:
|
||||
runs-on: macos-15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: [WireProtocol, SessionCore, HostRegistry, APIClient]
|
||||
package: [WireProtocol, SessionCore, HostRegistry, APIClient, ClientTLS]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
@@ -56,47 +58,66 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- name: swift test (TestSupport — no coverage gate, plan §9 gates 4 packages)
|
||||
- name: swift test (TestSupport — no coverage gate; it IS the test doubles)
|
||||
run: swift test --package-path ios/Packages/TestSupport
|
||||
|
||||
# Layer 2: app-target unit tests (WebTermTests, hosted by the app).
|
||||
# Layer 2: app-target unit tests (WebTermTests, hosted by the app), on the
|
||||
# compact iPhone path AND the regular iPad path (T-iPad-1: the adaptive
|
||||
# NavigationSplitView layout of T-iPad-2 must be exercised in CI, not only
|
||||
# compact). One matrix instead of two near-identical jobs.
|
||||
#
|
||||
# THE NODE DEPENDENCY (B4 fix): the WebTermTests bundle contains
|
||||
# LiveServerSmokeTests, whose SimServerHarness spawns
|
||||
# `node_modules/.bin/tsx src/server.ts`. On a bare checkout there is no
|
||||
# node_modules, so the harness throws
|
||||
# setup: 找不到 …/node_modules/.bin/tsx — 先在 repo 根目录跑 npm install/npm ci
|
||||
# and the test HARD-FAILS (it is not a skip) — i.e. both legs were red on every
|
||||
# run. Fix: `npm ci` on the canonical iPhone leg, which is the one that owns the
|
||||
# live-server smoke; the iPad leg SKIPS that suite instead of paying a second
|
||||
# node-pty native build. Rationale: the iPad leg exists to exercise the app's
|
||||
# regular-width layout, not to re-verify the client↔server contract, which is
|
||||
# already covered three times over (this leg, integration-tests, ui-test).
|
||||
# `-skip-testing` (rather than a test plan) keeps the change inside this file:
|
||||
# test plans live in ios/project.yml, owned by another task.
|
||||
app-tests:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- leg: iphone
|
||||
device: iPhone 16
|
||||
needsNode: true
|
||||
extraArgs: ""
|
||||
- leg: ipad
|
||||
device: iPad Pro 11-inch (M4)
|
||||
needsNode: false
|
||||
extraArgs: "-skip-testing:WebTermTests/LiveServerSmokeTests"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- uses: actions/setup-node@v4
|
||||
if: matrix.needsNode
|
||||
with:
|
||||
node-version: 22
|
||||
- name: npm ci (LiveServerSmokeTests spawns node_modules/.bin/tsx)
|
||||
if: matrix.needsNode
|
||||
run: npm ci
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
- name: Generate project
|
||||
run: cd ios && xcodegen generate
|
||||
- name: xcodebuild test (WebTermTests, iPhone 16 simulator)
|
||||
- name: xcodebuild test (WebTermTests, ${{ matrix.device }} simulator)
|
||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
|
||||
# real data-protection keychain, which returns -34018 for UNSIGNED test
|
||||
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
|
||||
# handoff finding, verified locally in the ui-test leg runs.)
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
test
|
||||
|
||||
# iPad adaptation (T-iPad-1): run the same app suite on an iPad simulator so
|
||||
# the adaptive layout (regular size class / NavigationSplitView, T-iPad-2) is
|
||||
# exercised in CI, not only the compact iPhone path.
|
||||
ipad-tests:
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
- name: Generate project
|
||||
run: cd ios && xcodegen generate
|
||||
- name: xcodebuild test (WebTermTests, iPad Pro 11-inch simulator)
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' \
|
||||
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
|
||||
${{ matrix.extraArgs }} \
|
||||
test
|
||||
|
||||
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
|
||||
@@ -121,9 +142,27 @@ jobs:
|
||||
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
|
||||
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
|
||||
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
|
||||
#
|
||||
# iPad leg (B4 fix): ProjectsLayoutUITests has an explicit `isPad` branch
|
||||
# (landscape + NavigationSplitView sidebar reveal, T-iPad-4) that NEVER ran —
|
||||
# the only UI leg was iPhone. It now runs on an iPad simulator too. Only THAT
|
||||
# suite: HappyPathUITests has no regular-width branch at all (no sidebar
|
||||
# reveal), so running it on iPad would fail for a missing-feature reason rather
|
||||
# than a regression — making the happy path iPad-adaptive is app-layer work,
|
||||
# tracked separately.
|
||||
ui-test:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- leg: iphone
|
||||
device: iPhone 16
|
||||
suites: ""
|
||||
- leg: ipad
|
||||
device: iPad Pro 11-inch (M4)
|
||||
suites: "-only-testing:WebTermUITests/ProjectsLayoutUITests"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
@@ -159,7 +198,7 @@ jobs:
|
||||
# inputAccessoryView — it only exists while the SOFT keyboard is up.
|
||||
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
|
||||
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
|
||||
- name: xcodebuild test (WebTermUITests, iPhone 16 simulator)
|
||||
- name: xcodebuild test (WebTermUITests, ${{ matrix.device }} simulator)
|
||||
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
|
||||
# strips the prefix and injects <VAR> into the test-runner process);
|
||||
# passing it as a KEY=VALUE argument makes it a build setting, which
|
||||
@@ -172,7 +211,8 @@ jobs:
|
||||
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' test
|
||||
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
|
||||
${{ matrix.suites }} test
|
||||
- name: Server log + shutdown
|
||||
if: always()
|
||||
run: |
|
||||
@@ -181,15 +221,21 @@ jobs:
|
||||
|
||||
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
|
||||
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
|
||||
# CI-ONLY LEG: GitHub macOS runner images ship older Xcode versions whose
|
||||
# iOS 17.x simulator runtime is registered system-wide with CoreSimulator, so
|
||||
# Xcode 16.x can build against it. Local machines are NOT expected to
|
||||
# download the ~8 GB runtime. If the runner image drops the 17.x runtime,
|
||||
# the leg skips WITH A LOUD NOTICE; if the runtime exists, test failures
|
||||
# fail the job (no silent pass).
|
||||
#
|
||||
# CI-ONLY LEG: local machines are NOT expected to hold the ~7 GB iOS 17
|
||||
# runtime, so this leg is never reproducible on a dev box.
|
||||
#
|
||||
# NO SILENT SKIP (B4 fix): this step used to `exit 0` with a ::notice when the
|
||||
# runner image had no iOS 17.x runtime, and the test step was gated on
|
||||
# `if: steps.sim17.outputs.runtime != ''` — so on image drift the whole
|
||||
# deployment-floor round vanished and the job still reported GREEN. A leg that
|
||||
# can silently stop testing is worse than no leg. Now: if the runtime is
|
||||
# missing, it is DOWNLOADED (`xcodebuild -downloadPlatform iOS
|
||||
# -buildVersion`); if the download does not produce a usable runtime, the job
|
||||
# FAILS. The test step is unconditional.
|
||||
ios17-floor-tests:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 90 # the runtime download alone can take ~15 min
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3 (build toolchain)
|
||||
@@ -198,23 +244,40 @@ jobs:
|
||||
run: brew install xcodegen
|
||||
- name: Generate project
|
||||
run: cd ios && xcodegen generate
|
||||
- name: Create iOS 17.x simulator (skip-with-notice if runtime absent)
|
||||
- name: Ensure an iOS 17.x simulator runtime (download if the image lacks it)
|
||||
id: sim17
|
||||
env:
|
||||
IOS17_FALLBACK_VERSION: "17.5"
|
||||
run: |
|
||||
runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || true)"
|
||||
find_runtime() {
|
||||
xcrun simctl list runtimes \
|
||||
| grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' \
|
||||
| tail -n 1
|
||||
}
|
||||
runtime="$(find_runtime || true)"
|
||||
if [ -z "$runtime" ]; then
|
||||
echo "::notice title=iOS 17 floor leg skipped::no iOS 17.x simulator runtime on this runner image (image drift) — the deployment-floor round did NOT run"
|
||||
echo "runtime=" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
echo "::warning title=iOS 17 runtime absent::downloading iOS ${IOS17_FALLBACK_VERSION} simulator runtime (runner image drift)"
|
||||
sudo xcodebuild -downloadPlatform iOS -buildVersion "$IOS17_FALLBACK_VERSION" || true
|
||||
runtime="$(find_runtime || true)"
|
||||
fi
|
||||
if [ -z "$runtime" ]; then
|
||||
echo "::error title=iOS 17 floor leg cannot run::no iOS 17.x simulator runtime and the download failed — the deployment-floor round did NOT run, so this job fails instead of reporting a false green" >&2
|
||||
xcrun simctl list runtimes >&2
|
||||
exit 1
|
||||
fi
|
||||
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
|
||||
echo "created $udid with $runtime"
|
||||
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
|
||||
echo "udid=$udid" >> "$GITHUB_OUTPUT"
|
||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
|
||||
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
|
||||
#
|
||||
# -skip-testing LiveServerSmokeTests: same reason as the iPad leg — that
|
||||
# suite spawns node_modules/.bin/tsx and would hard-fail without `npm ci`.
|
||||
# This leg's job is the DEPLOYMENT FLOOR (does the app build and behave on
|
||||
# iOS 17), not the server contract, so it stays Node-free.
|
||||
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
|
||||
if: steps.sim17.outputs.runtime != ''
|
||||
run: |
|
||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" test
|
||||
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" \
|
||||
-skip-testing:WebTermTests/LiveServerSmokeTests \
|
||||
test
|
||||
|
||||
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.
|
||||
|
||||
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
|
||||
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only by design; the optional `WEBTERM_TOKEN` access token raises the bar but is **not** a substitute for TLS/Tailscale. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
|
||||
|
||||
---
|
||||
|
||||
@@ -42,7 +42,8 @@ On a large screen (≥ 1024px) the terminal area can split into a grid so severa
|
||||
### Projects (v0.6)
|
||||
- **Auto-discovered git repos** — scans configurable roots for `.git`, showing each repo's branch and dirty state. Read-only, cached, with a depth-bounded BFS that skips `node_modules`/dotdirs/symlinks.
|
||||
- **Per-project launchers** — each card has brand-logo buttons: **Claude** and **Codex** open a new tab running that CLI in the repo; **VS Code** asks the *host* to open the editor on that path. Projects with an active Claude session highlight the Claude button (with a count).
|
||||
- **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and a **create-worktree** action.
|
||||
- **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and worktree **create / prune / remove**.
|
||||
- **Git panel** — ambient git state on the detail page: a **sync band** (`↑ahead ↓behind`, upstream / detached-HEAD / "never fetched" states, green "in sync" only when nothing is pending *and* the last fetch is recent), a **commit log** with the unpushed boundary marked, **PR status** (via the host's `gh`, when installed), and **stage / commit / push / fetch** actions. All writes are Origin-guarded `POST /projects/git/*` routes running `git` through `execFile` (no shell) with timeouts.
|
||||
|
||||
### Walk-away workbench (v0.7)
|
||||
- **Mobile Web Push + lock-screen triage** — the host actively notifies your phone on **needs-input** (high priority, with **Allow / Deny** action buttons) and **done** (low priority). Approve or deny a held tool request straight from the lock screen without opening the app, secured by a per-decision capability token. Optional **ntfy / Pushover** bridge for setups without HTTPS Web Push.
|
||||
@@ -59,19 +60,35 @@ On a large screen (≥ 1024px) the terminal area can split into a grid so severa
|
||||
|
||||
## Clients
|
||||
|
||||
The browser is the reference client; two native clients and a remote-access
|
||||
The browser is the reference client; three native clients and a remote-access
|
||||
service are also in the repo (all consume the same wire protocol — the server
|
||||
stays a byte-shuttle).
|
||||
|
||||
- **iOS / iPad app** ([`ios/`](ios/), branch `feat/ios-client`) — a native
|
||||
SwiftUI + SwiftTerm **pure remote client** built for the walk-away loop:
|
||||
native terminal with scrollback replay, QR/manual pairing (Keychain), the
|
||||
remote **Approve / Reject** gate + three-way plan gate, **APNs push with
|
||||
lock-screen Allow / Deny behind Face ID**, deep links, Projects, multi-session
|
||||
switcher, timeline, diff, quick-reply, and an **adaptive iPhone/iPad split
|
||||
layout**. Looks like the desktop (amber-gold, dark-first). P0 needs zero server
|
||||
changes; P1 adds two declared additive touch-points. See
|
||||
[`ios/README.md`](ios/README.md). *(Not yet merged.)*
|
||||
- **iOS / iPad app** ([`ios/`](ios/)) — a native SwiftUI + SwiftTerm **pure
|
||||
remote client** built for the walk-away loop: native terminal with scrollback
|
||||
replay, QR/manual pairing (Keychain), the remote **Approve / Reject** gate +
|
||||
three-way plan gate, **APNs push with lock-screen Allow / Deny behind Face ID**,
|
||||
deep links (including the web's `?join=` share link), Projects with a **git
|
||||
panel + worktree lifecycle**, `claude --resume` history, multi-session switcher,
|
||||
timeline, diff, quick-reply, **terminal find bar**, **voice push-to-talk with a
|
||||
confirm step**, theme (system/dark/light) + Dynamic Type, and an **adaptive
|
||||
iPhone/iPad split layout**. Supports the optional `WEBTERM_TOKEN` access token
|
||||
(per-host, in the Keychain). Looks like the desktop (amber-gold, dark-first).
|
||||
P0 needed zero server changes; P1 added two declared additive touch-points.
|
||||
**Merged into `develop`.** See [`ios/README.md`](ios/README.md).
|
||||
- **Android app** ([`android/`](android/)) — a Kotlin/Compose client targeting
|
||||
functional parity with iOS, module-for-module mirroring the iOS package set
|
||||
(`:wire-protocol` / `:session-core` / `:api-client` / `:client-tls` /
|
||||
`:host-registry` / `:terminal-view` / `:app`). Same wire protocol, same
|
||||
Origin-iff-guarded discipline, same **`WEBTERM_TOKEN`** contract (hand-written
|
||||
cookie, Keystore-backed storage), plus FCM push with lock-screen Allow / Deny,
|
||||
Projects, timeline, diff, quick-reply and an adaptive layout. Terminal
|
||||
rendering wraps the Apache-2.0 **Termux** terminal view. All modules build and
|
||||
unit-test locally (`./gradlew test :app:assembleDebug koverVerify`) and the app
|
||||
has been installed and launched on an emulator; **real-device QA is still
|
||||
pending**. Design in
|
||||
[`docs/ANDROID_CLIENT_PLAN.md`](docs/ANDROID_CLIENT_PLAN.md), setup notes in
|
||||
[`android/README.md`](android/README.md).
|
||||
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
|
||||
embeds this Node server + node-pty** (all-in-one), for native notifications,
|
||||
tray, deep links and launch-at-login. Design in
|
||||
@@ -146,6 +163,7 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
|
||||
| `MAX_MSGS_PER_SEC` | `2000` | Per-connection WS frame-rate cap; over-limit frames are dropped (not disconnected). |
|
||||
| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH. |
|
||||
| `ALLOWED_ORIGINS` | (derived) | Extra allowed WS origins, comma-separated. The base list is derived from the host's NIC IPs + localhost — never from `BIND_HOST`. |
|
||||
| `WEBTERM_TOKEN` | (unset → auth **disabled**, **secret**) | Optional shared access token gating every HTTP route + the WS upgrade behind a `webterm_auth` cookie (alongside, never instead of, the Origin check). 16–512 chars of `[A-Za-z0-9._~+/=-]` or the server refuses to start. Never logged. See [Security & deployment](#security--deployment) for the honest limits. |
|
||||
| `PERM_TIMEOUT_MS` | `300000` (5 min) | How long a held tool-permission request waits for a remote decision before falling back to Claude's own prompt. Must be > 0. |
|
||||
| `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. |
|
||||
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
|
||||
@@ -191,14 +209,15 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
|
||||
|
||||
## Security & deployment
|
||||
|
||||
This is a **no-auth, LAN-only** tool by design — it hands a full shell to anyone who can reach the port. The defenses that matter:
|
||||
This is a **LAN-only** tool by design — with no token set it hands a full shell to anyone who can reach the port, and even with one set it is not an internet-facing service. The defenses that matter:
|
||||
|
||||
- **WS Origin validation (cannot be skipped):** the WebSocket handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://<your-lan-ip>:3000`. Only `WS_PATH` is accepted for upgrade.
|
||||
- **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
|
||||
- **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree` + `/worktree/prune` + `DELETE /projects/worktree`, `POST /projects/git/{stage,commit,push,fetch}`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
|
||||
- **Loopback-only hook ingest:** the side-channel ingest endpoints (`/hook`, `/hook/permission`, `/hook/status`) only accept loopback peers — the Claude process always runs on the host.
|
||||
- **Per-IP rate limits** on push subscribe and lock-screen decision routes; a per-connection WS frame-rate cap.
|
||||
- **Safe git exec + per-decision capability tokens:** all `git` calls use `execFile` (no shell) with timeouts and path validation; the lock-screen Allow/Deny is authorized by a token bound to that session's current pending request (it expires on resolve/timeout).
|
||||
- **No authentication yet.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` — the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge.
|
||||
- **Optional shared access token (`WEBTERM_TOKEN`) — a bar-raiser, not authentication.** Unset ⇒ the gate is **disabled** and behaviour is byte-identical to a no-auth server (LAN zero-config preserved). Set (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.
|
||||
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
|
||||
|
||||
## Access token — `WEBTERM_TOKEN` (B5 / ios-completion §1.1) — needs a token-enabled host
|
||||
Run the host as `WEBTERM_TOKEN=<16–512 chars> npm start`. The pure logic is JVM-tested (`AuthProbeTest`,
|
||||
`AccessTokenCookieTest`, `PairingProbeTokenTest`, `OkHttpAccessTokenTest`, `SessionEngineUnauthorizedTest`,
|
||||
`InMemoryAccessTokenStoreTest`); everything below needs real hardware + a real server.
|
||||
- [ ] `TinkAccessTokenStore` instrumented suite on a device:
|
||||
`./gradlew :host-registry:connectedDebugAndroidTest` (real AndroidKeyStore + Tink).
|
||||
- [ ] Pair a token-enabled host with the token typed → paired; the terminal attaches (the **WS upgrade**
|
||||
carries `Cookie: webterm_auth=…`), the session list, projects, diff and thumbnails all load.
|
||||
- [ ] Pair the same host with **no** token typed → the probe 401s → the confirm step comes back asking for
|
||||
the token ("该主机启用了访问令牌"); typing it and retrying pairs.
|
||||
- [ ] Wrong token → "访问令牌不正确" **under the field** (still on the confirm step, nothing stored).
|
||||
- [ ] 11 wrong tries within a minute → "尝试过多" (the server's 10/min/IP `/auth` limiter).
|
||||
- [ ] Pair a host with `WEBTERM_TOKEN` **unset** while typing a token → pairs, and NOTHING is stored
|
||||
(204-without-`Set-Cookie`); the app must not claim to be "authenticated".
|
||||
- [ ] Restart the app → the terminal re-attaches with no re-prompt (the token decrypts from the
|
||||
Keystore-backed store on a cold start).
|
||||
- [ ] Rotate `WEBTERM_TOKEN` on the host and restart it → the WS upgrade 401s → the terminal banner shows
|
||||
the 401 copy, offers **no** "新会话", and there is **no reconnect back-off storm** (verify with
|
||||
`adb logcat` / the server log: exactly one upgrade attempt).
|
||||
- [ ] `adb logcat` during all of the above: the token string appears **nowhere**; no request URL contains
|
||||
it (check the server's access log too).
|
||||
- [ ] `adb backup` / device-to-device transfer excludes the token blob (`allowBackup=false` +
|
||||
`data_extraction_rules.xml`).
|
||||
|
||||
## Nav / deep links / adaptive (A32/A26/A29)
|
||||
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
|
||||
right destination; invalid UUID ignored. (The App Link half needs the release-signed APK and
|
||||
|
||||
@@ -46,8 +46,16 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
|
||||
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
|
||||
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
|
||||
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
|
||||
| `URLSession*Transport` | `:transport-okhttp` | pure Kotlin/JVM (OkHttp) | ✅ built |
|
||||
| — (no iOS counterpart) | `:macrobenchmark` | `com.android.test` harness | ⬜ scaffolded, no sources |
|
||||
|
||||
> `:transport-okhttp` (A7) holds the OkHttp `TermTransport`/`HttpTransport`
|
||||
> implementations that the two iOS `URLSession*Transport`s consolidate into
|
||||
> (plan §3 framing note). OkHttp is a plain JVM library, so the module builds and
|
||||
> unit-tests (MockWebServer) with **no** Android SDK — including the
|
||||
> access-token cookie on the WS upgrade (`OkHttpAccessTokenTest`) and the
|
||||
> public-host cleartext refusal (`CleartextGuardTest`).
|
||||
|
||||
`:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a
|
||||
`com.android.test` module — a **separate APK** that drives `:app` out of process via
|
||||
UiAutomator, which is the only way startup/frame timings are real. It therefore cannot
|
||||
@@ -76,8 +84,45 @@ written yet.
|
||||
`:wire-protocol` is the **frozen shared contract** (Android analogue of
|
||||
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
|
||||
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
|
||||
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary
|
||||
interfaces. New wire types are added **only** here (a coordination point).
|
||||
`AuthCookie`/`AccessTokenRule`/`AccessTokenSource` (the single access-token-cookie
|
||||
derivation), and the `TermTransport` / `HttpTransport` / `PingableTermTransport`
|
||||
boundary interfaces. New wire types are added **only** here (a coordination point).
|
||||
|
||||
## Access token (`WEBTERM_TOKEN`) — how the Android client authenticates
|
||||
|
||||
A server started with `WEBTERM_TOKEN=<16–512 chars of [A-Za-z0-9._~+/=-]>` gates **every**
|
||||
HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie. The Android
|
||||
client therefore:
|
||||
|
||||
- **hand-writes `Cookie: webterm_auth=<t>` itself** for a token it already holds — one
|
||||
derivation point (`AuthCookie`), stamped in exactly three places: `:api-client`'s
|
||||
`ApiRoute.toHttpRequest` (all 12+ routes, RO GETs included), the pairing probe's two
|
||||
hand-built legs, and `:transport-okhttp`'s WS upgrade. The hand-built
|
||||
`GET /projects/diff` fetcher in `:app` stamps it too.
|
||||
- **also installs one `AuthCookieJar`** on the single shared `OkHttpClient`, which captures
|
||||
the `Set-Cookie` a live pairing (`POST /auth`) hands back and replays it on REST *and* on
|
||||
the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls). The two are
|
||||
orthogonal: the hand-written header covers a token restored from the Keystore store, the
|
||||
jar covers a cookie the server just issued. A host with neither sends no cookie at all.
|
||||
- keeps the header **orthogonal to `Origin`**: the token never replaces the CSWSH
|
||||
Origin check (the server tests Origin first, then the cookie), and read-only routes
|
||||
still carry no Origin.
|
||||
- validates a typed token ONCE at pairing time with `POST /auth`
|
||||
(`{"token":"…"}`, `Accept: application/json` — an `Accept` containing `text/html`
|
||||
makes the server answer 302 instead of 204/401). Four outcomes: 204+`Set-Cookie` =
|
||||
correct → store it; 204 **without** `Set-Cookie` = that host has no auth → store
|
||||
nothing; 401 = wrong token; 429 = rate-limited (10/min/IP).
|
||||
- stores it per host (keyed by the canonical origin) in `TinkAccessTokenStore`:
|
||||
Tink AEAD under an **AndroidKeystore-wrapped, non-exportable** master key, in an
|
||||
app-private `SharedPreferences` file, with `android:allowBackup="false"` +
|
||||
`data_extraction_rules.xml` excluding cloud backup and device transfer. The token is
|
||||
**never logged, never in a URL, never in app UI state**.
|
||||
- treats a **401 on the WS upgrade as terminal** (`FailureReason.UNAUTHORIZED`): no
|
||||
reconnect back-off loop, and the banner tells the user to re-enter the token. A 401 on
|
||||
any REST route is the typed `ApiClientError.Unauthorized`.
|
||||
|
||||
A host with no `WEBTERM_TOKEN` is byte-identical to before the feature: no token stored ⇒
|
||||
no `Cookie` header at all (LAN zero-config preserved).
|
||||
|
||||
## Toolchain
|
||||
|
||||
@@ -111,8 +156,9 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`).
|
||||
```
|
||||
|
||||
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
|
||||
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
|
||||
> small focused files — same discipline as the rest of the repo.
|
||||
> `:session-core`, `:api-client`, `:client-tls` pure half — the four modules that
|
||||
> apply the Kover plugin, and therefore the only ones `koverVerify` gates). TDD,
|
||||
> immutable data, small focused files — same discipline as the rest of the repo.
|
||||
>
|
||||
> **Do not use `--rerun-tasks` in a release gate.** It trips an AGP lint/K2 internal bug
|
||||
> (`FirDeclaration was not found for class KtProperty, fir is null`, on
|
||||
@@ -199,8 +245,7 @@ Before adding a rule, read the merged configuration R8 actually used:
|
||||
`hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to
|
||||
`wang.yaojia.webterm.HiltTestRunner`.
|
||||
|
||||
⚠️ **That runner class does not exist yet** — it is the first file the A34 author must write,
|
||||
into `app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt`:
|
||||
`app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt` supplies it:
|
||||
|
||||
```kotlin
|
||||
class HiltTestRunner : AndroidJUnitRunner() {
|
||||
@@ -215,6 +260,24 @@ replace the app-under-test's `Application` with the generated `HiltTestApplicati
|
||||
*test* APK, not into the app under test. `assembleDebugAndroidTest` builds fine without the
|
||||
class (the runner name is just a manifest value); an actual instrumentation *run* needs it.
|
||||
|
||||
The suite has been **run green on real hardware** (67/0 instrumented tests against this
|
||||
repo's own server), so `src/androidTest/**` is verified, not aspirational — but it still
|
||||
needs a connected device/emulator and is therefore **not** part of the `./gradlew test`
|
||||
gate; run it with `connectedAndroidTest`. An emulator additionally needs
|
||||
`ALLOWED_ORIGINS=http://10.0.2.2:<port>` on the server, because the server derives its
|
||||
allowed origins from NIC IPs and `10.0.2.2` (the emulator's alias for the host) is not one.
|
||||
|
||||
### What is still NOT verified here
|
||||
|
||||
- **DEFERRED — end-to-end access token**: the `WEBTERM_TOKEN` path is covered by
|
||||
unit tests (`OkHttpAccessTokenTest`, `AuthProbeTest`, `AccessTokenCookieTest`, the
|
||||
`:api-client` route tests, the `AuthCookie` derivation tests) and by
|
||||
`TinkAccessTokenStoreTest` on device, but **no automated leg on the Android side starts
|
||||
a real server with `WEBTERM_TOKEN` set and completes a cookie-gated WS upgrade** — that
|
||||
end-to-end coverage exists only on the iOS side (`ios/IntegrationTests`).
|
||||
- **DEFERRED — the rest of `DEVICE_QA_CHECKLIST.md`**: FCM push delivery under Doze on two
|
||||
physical handsets (S2) and the lock-screen Allow/Deny walkthrough have no automated cover.
|
||||
|
||||
## Android SDK setup (proven working)
|
||||
|
||||
The pure JVM modules need only a JDK + Gradle. The **Android-framework** modules
|
||||
|
||||
@@ -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`). */
|
||||
public data object TlsFailure : PairingError
|
||||
|
||||
/**
|
||||
* The host answered **401** to the probe's HTTP legs — it has `WEBTERM_TOKEN` set and we presented
|
||||
* no cookie / a wrong one (ios-completion §1.1). Distinct from [OriginRejected]: this is fixable by
|
||||
* entering the host's access token, not by editing `ALLOWED_ORIGINS`.
|
||||
*
|
||||
* NOTE the probe's ORDERING is what makes the two distinguishable: probe ① authenticates over HTTP
|
||||
* first, so a 401 there is the token; once ① has passed with our cookie, a 401 on the later WS
|
||||
* upgrade can only be the Origin allow-list (the server writes the same bare 401 for both).
|
||||
*/
|
||||
public data object AccessTokenRequired : PairingError
|
||||
|
||||
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
|
||||
public data object Timeout : PairingError
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import wang.yaojia.webterm.wire.ClientMessage
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
@@ -50,8 +52,9 @@ public suspend fun runPairingProbe(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
): PairingProbeResult =
|
||||
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)
|
||||
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT, tokens = tokens)
|
||||
|
||||
/**
|
||||
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
|
||||
@@ -63,9 +66,10 @@ internal suspend fun runPairingProbeCore(
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
timeout: Duration?,
|
||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
): PairingProbeResult {
|
||||
if (timeout == null) return performProbe(endpoint, http, ws)
|
||||
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
|
||||
if (timeout == null) return performProbe(endpoint, http, ws, tokens)
|
||||
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) }
|
||||
?: PairingProbeResult.Failure(PairingError.Timeout)
|
||||
}
|
||||
|
||||
@@ -75,10 +79,16 @@ private suspend fun performProbe(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
tokens: AccessTokenSource,
|
||||
): PairingProbeResult {
|
||||
// The access token (if any) rides BOTH HTTP legs as the hand-written auth cookie. The WS leg gets
|
||||
// it from the transport's own AccessTokenSource (the same store), so all three legs authenticate.
|
||||
val cookieHeaders = authHeaders(tokens.tokenFor(endpoint))
|
||||
|
||||
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
|
||||
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
|
||||
probeReachability(endpoint, http)?.let { return it }
|
||||
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host
|
||||
// wants an access token we do not have.
|
||||
probeReachability(endpoint, http, cookieHeaders)?.let { return it }
|
||||
|
||||
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
|
||||
// unrecognizable connect failure is classified as originRejected.
|
||||
@@ -105,7 +115,7 @@ private suspend fun performProbe(
|
||||
return try {
|
||||
when (val adoption = adoptAttachedSession(connection)) {
|
||||
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
|
||||
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
|
||||
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http, cookieHeaders)
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) { runCatching { connection.close() } }
|
||||
@@ -120,14 +130,20 @@ private suspend fun performProbe(
|
||||
private suspend fun probeReachability(
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
authHeaders: Map<String, String>,
|
||||
): PairingProbeResult.Failure? {
|
||||
val response = try {
|
||||
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
|
||||
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint), headers = authHeaders))
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
|
||||
}
|
||||
// Checked BEFORE the shape check: the 401 body is the server's auth JSON, not a session array, and
|
||||
// reporting "端口对吗?" for a token problem would send the user chasing the wrong thing.
|
||||
if (response.status == HTTP_UNAUTHORIZED) {
|
||||
return PairingProbeResult.Failure(PairingError.AccessTokenRequired)
|
||||
}
|
||||
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
|
||||
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
|
||||
}
|
||||
@@ -162,13 +178,14 @@ private suspend fun killProbeSession(
|
||||
sessionId: String,
|
||||
endpoint: HostEndpoint,
|
||||
http: HttpTransport,
|
||||
authHeaders: Map<String, String>,
|
||||
): PairingProbeResult {
|
||||
val response = try {
|
||||
http.send(
|
||||
HttpRequest(
|
||||
method = HttpMethod.DELETE,
|
||||
url = killUrl(endpoint, sessionId),
|
||||
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
|
||||
headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader),
|
||||
),
|
||||
)
|
||||
} catch (cancel: CancellationException) {
|
||||
@@ -178,6 +195,7 @@ private suspend fun killProbeSession(
|
||||
}
|
||||
return when (response.status) {
|
||||
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
|
||||
HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired)
|
||||
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
|
||||
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
|
||||
)
|
||||
@@ -219,9 +237,17 @@ private fun httpBaseUrl(endpoint: HostEndpoint): String {
|
||||
return "$scheme://$host$portPart"
|
||||
}
|
||||
|
||||
/**
|
||||
* `Cookie: webterm_auth=<t>` for the probe's HTTP legs, or NO header when the host has no token —
|
||||
* derived from the single point ([AuthCookie]), never hand-assembled.
|
||||
*/
|
||||
private fun authHeaders(token: String?): Map<String, String> =
|
||||
if (token == null) emptyMap() else mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token))
|
||||
|
||||
private const val LIVE_SESSIONS_PATH = "/live-sessions"
|
||||
private const val ORIGIN_HEADER = "Origin"
|
||||
private const val HTTP_OK = 200
|
||||
private const val HTTP_UNAUTHORIZED = 401
|
||||
private const val HTTP_NO_CONTENT = 204
|
||||
private const val HTTP_FORBIDDEN = 403
|
||||
private const val HTTP_NOT_FOUND = 404
|
||||
|
||||
@@ -24,6 +24,7 @@ import wang.yaojia.webterm.api.models.UiPrefs
|
||||
import wang.yaojia.webterm.api.models.WorktreeState
|
||||
import wang.yaojia.webterm.api.models.decodeGitError
|
||||
import wang.yaojia.webterm.api.models.decodeGitPayload
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpResponse
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
@@ -41,10 +42,18 @@ import java.util.UUID
|
||||
*
|
||||
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
|
||||
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
|
||||
*
|
||||
* **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is
|
||||
* stamped as `Cookie: webterm_auth=<t>` on EVERY route (RO included) inside the same single point that
|
||||
* stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" —
|
||||
* an unauthenticated LAN host behaves exactly as before. A **401** becomes the typed
|
||||
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token — except on the
|
||||
* routes that define their own 401 ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write family).
|
||||
*/
|
||||
public class ApiClient(
|
||||
public val endpoint: HostEndpoint,
|
||||
private val http: HttpTransport,
|
||||
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
) {
|
||||
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
|
||||
|
||||
@@ -343,9 +352,27 @@ public class ApiClient(
|
||||
|
||||
// ── Internals ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The ONE dispatch point: stamp Origin (iff guarded) + the auth cookie (iff a token is stored),
|
||||
* send, and translate a **401** into the typed [ApiClientError.Unauthorized] BEFORE any per-route
|
||||
* status mapping — otherwise a 401 on a read route would degrade into a generic status error and
|
||||
* the UI would never learn to ask for the token (ios-completion §1.1).
|
||||
*
|
||||
* The one exception is declared AT the route ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write
|
||||
* family): there a 401 is the server classifying a HOST-side git credential failure
|
||||
* (`src/http/git-ops.ts:108`), so it must flow on to [gitWrite]'s mapping and reach the user as the
|
||||
* server's own message. Swallowing it would tell the user to rotate an access token that is fine.
|
||||
*/
|
||||
private suspend fun perform(route: ApiRoute): HttpResponse {
|
||||
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
|
||||
return http.send(request)
|
||||
val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint))
|
||||
?: throw ApiClientError.InvalidRequest
|
||||
val response = http.send(request)
|
||||
if (response.status == HttpStatus.UNAUTHORIZED &&
|
||||
route.unauthorizedPolicy == UnauthorizedPolicy.ACCESS_TOKEN_GATE
|
||||
) {
|
||||
throw ApiClientError.Unauthorized
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
|
||||
|
||||
@@ -20,6 +20,15 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
|
||||
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
|
||||
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
|
||||
|
||||
/**
|
||||
* **401** from a route whose 401 can only be the access-token gate (RO or guarded): this host has
|
||||
* `WEBTERM_TOKEN` set and our request carried no cookie / a wrong one (ios-completion §1.1).
|
||||
* Distinct from [Forbidden] (that is the Origin guard) — the UI must send the user to enter or fix
|
||||
* the host's access token. The git-write family is excluded (it defines its own 401 — see
|
||||
* [UnauthorizedPolicy]), so this copy can never be shown for a host-side git credential failure.
|
||||
*/
|
||||
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
|
||||
|
||||
/** 403 from a G route's Origin guard (CSWSH defence). */
|
||||
public data object Forbidden : ApiClientError("服务器拒绝了此来源(Origin 校验未通过)。")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package wang.yaojia.webterm.api.routes
|
||||
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
@@ -10,6 +11,7 @@ internal object HttpStatus {
|
||||
const val OK = 200
|
||||
const val NO_CONTENT = 204
|
||||
const val BAD_REQUEST = 400
|
||||
const val UNAUTHORIZED = 401
|
||||
const val FORBIDDEN = 403
|
||||
const val NOT_FOUND = 404
|
||||
const val CONFLICT = 409
|
||||
@@ -27,6 +29,12 @@ internal object HttpStatus {
|
||||
internal object HeaderName {
|
||||
const val ORIGIN = "Origin"
|
||||
const val CONTENT_TYPE = "Content-Type"
|
||||
|
||||
/**
|
||||
* Explicit on the `/auth` probe: the server treats an `Accept` containing `text/html` as a browser
|
||||
* form submit and answers **302** instead of 204/401 (`src/server.ts` `acceptsHtml`).
|
||||
*/
|
||||
const val ACCEPT = "Accept"
|
||||
}
|
||||
|
||||
internal object ContentType {
|
||||
@@ -47,6 +55,23 @@ internal enum class OriginPolicy {
|
||||
GUARDED,
|
||||
}
|
||||
|
||||
/**
|
||||
* How a **401** on this route must be READ (ios-completion §1.1) — declared at the route so the
|
||||
* exception is visible instead of hidden in a call site. The Android analogue of iOS
|
||||
* `UnauthorizedPolicy` (APIClient/Endpoints.swift).
|
||||
*/
|
||||
internal enum class UnauthorizedPolicy {
|
||||
/** Default — on this route a 401 can only be the access-token gate (`src/server.ts:465`). */
|
||||
ACCESS_TOKEN_GATE,
|
||||
|
||||
/**
|
||||
* The route owns its 401 and it must NOT be read as "your access token is wrong": the git-write
|
||||
* family, where the server classifies a HOST-side git credential failure as
|
||||
* `{status:401, error:"Push authentication required on the host."}` (`src/http/git-ops.ts:108`).
|
||||
*/
|
||||
ROUTE_DEFINED,
|
||||
}
|
||||
|
||||
/**
|
||||
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
|
||||
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
|
||||
@@ -58,19 +83,37 @@ internal class ApiRoute(
|
||||
val originPolicy: OriginPolicy,
|
||||
val body: ByteArray? = null,
|
||||
val percentEncodedQuery: String? = null,
|
||||
/** Explicit `Accept` when the server's behaviour depends on it (the `/auth` probe). */
|
||||
val accept: String? = null,
|
||||
/** See [UnauthorizedPolicy]. Defaults to the gate reading. */
|
||||
val unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
|
||||
) {
|
||||
/**
|
||||
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
|
||||
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
|
||||
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
|
||||
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
|
||||
*
|
||||
* Two headers are stamped HERE and only here, and they are ORTHOGONAL (ios-completion §1.1):
|
||||
* - `Origin` **iff** the route is [OriginPolicy.GUARDED] (the CSWSH 铁律, unchanged);
|
||||
* - `Cookie: webterm_auth=<t>` whenever [accessToken] is non-null — on EVERY route, read-only
|
||||
* ones included, because the server's access-token gate sits in front of the whole app. A null
|
||||
* token adds no header at all, keeping an unauthenticated host byte-identical to before.
|
||||
* The token is secret material: it is only ever placed in this header — never in [path], never in
|
||||
* [percentEncodedQuery], never logged.
|
||||
*/
|
||||
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? {
|
||||
fun toHttpRequest(endpoint: HostEndpoint, accessToken: String? = null): HttpRequest? {
|
||||
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
|
||||
val headers = LinkedHashMap<String, String>()
|
||||
if (originPolicy == OriginPolicy.GUARDED) {
|
||||
headers[HeaderName.ORIGIN] = endpoint.originHeader
|
||||
}
|
||||
if (accessToken != null) {
|
||||
headers[AuthCookie.HEADER_NAME] = AuthCookie.headerValue(accessToken)
|
||||
}
|
||||
if (accept != null) {
|
||||
headers[HeaderName.ACCEPT] = accept
|
||||
}
|
||||
if (body != null) {
|
||||
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
|
||||
}
|
||||
|
||||
@@ -134,7 +134,40 @@ internal object Endpoints {
|
||||
|
||||
private const val FCM_TOKEN_PATH = "/push/fcm-token"
|
||||
|
||||
// ── G: access-token validation probe (`POST /auth`, ios-completion §1.1) ───────────────
|
||||
|
||||
/**
|
||||
* `POST /auth` — the pairing-time token-validation probe. Body `{"token":"<t>"}`.
|
||||
*
|
||||
* Two non-obvious requirements, both server-driven (`src/server.ts`):
|
||||
* - **`Accept` must not contain `text/html`.** The route is dual-mode: an HTML-accepting request
|
||||
* is treated as a browser form and answered with a **302** redirect instead of 204/401.
|
||||
* - the route sits BEFORE the auth gate, so it is the one request that legitimately carries no
|
||||
* auth cookie (the token is in the body — this IS the login).
|
||||
*
|
||||
* Guarded (a state-changing POST) so it stamps `Origin` like every other write; the server does not
|
||||
* Origin-check `/auth`, but keeping the Origin-iff-guarded rule uniform avoids a special case.
|
||||
*/
|
||||
fun auth(token: String): ApiRoute {
|
||||
val body = ModelJson.encodeToString(AuthBody.serializer(), AuthBody(token)).encodeToByteArray()
|
||||
return ApiRoute(
|
||||
HttpMethod.POST,
|
||||
AUTH_PATH,
|
||||
OriginPolicy.GUARDED,
|
||||
body = body,
|
||||
accept = ContentType.JSON,
|
||||
)
|
||||
}
|
||||
|
||||
private const val AUTH_PATH = "/auth"
|
||||
|
||||
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
|
||||
//
|
||||
// Guarded writes, but NOT [UnauthorizedPolicy.ROUTE_DEFINED] (F5): these land in
|
||||
// `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500), and
|
||||
// `src/server.ts:1182-1260` adds none. The only 401 they can ever see is the access-token gate, so
|
||||
// they take the DEFAULT gate reading — pinning them route-defined made a gated host's 401 surface
|
||||
// as a worktree failure instead of the token flow.
|
||||
|
||||
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
|
||||
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
|
||||
@@ -162,11 +195,11 @@ internal object Endpoints {
|
||||
|
||||
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
|
||||
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
|
||||
gitWriteRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
|
||||
|
||||
/** `POST /projects/git/commit` — `{ path, message }`. */
|
||||
fun gitCommit(path: String, message: String): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
|
||||
gitWriteRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
|
||||
|
||||
/**
|
||||
* `POST /projects/git/fetch` — `{ path }` (w6/G2). GUARDED even though it only updates
|
||||
@@ -176,11 +209,11 @@ internal object Endpoints {
|
||||
* It is NOT a pull: no working tree, no index, no merge.
|
||||
*/
|
||||
fun gitFetch(path: String): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
|
||||
gitWriteRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
|
||||
|
||||
/** `POST /projects/git/push` — `{ path }`. */
|
||||
fun gitPush(path: String): ApiRoute =
|
||||
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
|
||||
gitWriteRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
|
||||
|
||||
// ── G: W2 prompt queue (enqueue / cancel-all) ──────────────────────────────────────────
|
||||
|
||||
@@ -212,17 +245,48 @@ internal object Endpoints {
|
||||
|
||||
private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue"
|
||||
|
||||
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
|
||||
/**
|
||||
* Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]).
|
||||
*
|
||||
* The 401 reading defaults to [UnauthorizedPolicy.ACCESS_TOKEN_GATE] — correct for every route
|
||||
* whose handler never writes a 401 of its own (the W2 queue: 400/404/429/503 only; the worktree
|
||||
* trio: 403/400/404/500 only). Only the git-ops writes opt out, via [gitWriteRoute].
|
||||
*/
|
||||
private fun <T> jsonBodyRoute(
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
serializer: kotlinx.serialization.KSerializer<T>,
|
||||
value: T,
|
||||
unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
|
||||
): ApiRoute {
|
||||
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
|
||||
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
|
||||
return ApiRoute(
|
||||
method,
|
||||
path,
|
||||
OriginPolicy.GUARDED,
|
||||
body = body,
|
||||
unauthorizedPolicy = unauthorizedPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a GUARDED **git-ops** route with a `ModelJson`-encoded JSON body (Origin stamped in
|
||||
* [ApiRoute]).
|
||||
*
|
||||
* Every route built here is [UnauthorizedPolicy.ROUTE_DEFINED]: the server classifies a HOST-side
|
||||
* git credential failure as **401** with its own message (`src/http/git-ops.ts:108`), which the UI
|
||||
* must display verbatim instead of the access-token copy (mirrors iOS `gitOpsRoute`).
|
||||
*
|
||||
* Scope is exactly the four routes that reach that classifier — `stage`, `commit`, `push`, `fetch`.
|
||||
* The worktree trio must NOT be built here (F5): its handler has no 401 of its own.
|
||||
*/
|
||||
private fun <T> gitWriteRoute(
|
||||
method: HttpMethod,
|
||||
path: String,
|
||||
serializer: kotlinx.serialization.KSerializer<T>,
|
||||
value: T,
|
||||
): ApiRoute = jsonBodyRoute(method, path, serializer, value, UnauthorizedPolicy.ROUTE_DEFINED)
|
||||
|
||||
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
|
||||
private const val GIT_LOG_MAX = 50
|
||||
|
||||
@@ -259,6 +323,10 @@ internal object Endpoints {
|
||||
@Serializable
|
||||
private data class FcmTokenBody(val token: String)
|
||||
|
||||
/** `POST /auth` body. Holds secret material — never log a request built from it. */
|
||||
@Serializable
|
||||
private data class AuthBody(val token: String)
|
||||
|
||||
@Serializable
|
||||
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
|
||||
|
||||
|
||||
@@ -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")
|
||||
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
|
||||
file: the `webterm` Preferences DataStore (which holds the sealed `webterm_auth` cookie — a
|
||||
full-shell credential the server keeps alive for 30 days) and the Tink keyset/ciphertext pref
|
||||
files. `dataExtractionRules`/`fullBackupContent` were rejected because they are allow-by-
|
||||
default: every store added later is backed up until someone remembers to exclude it, and a
|
||||
silent miss here is an off-device credential leak. Nothing in this app has restore value that
|
||||
justifies that risk — re-pairing a host is a QR scan, and the hosts are LAN-local anyway.
|
||||
NOTE: this attribute is Auto Backup (cloud). Android 12+ DEVICE-TO-DEVICE transfer is governed
|
||||
by a `dataExtractionRules` <device-transfer> block, which needs an @xml resource (tracked
|
||||
separately — see the orchestrator note). Do not set this back to true. -->
|
||||
files. Nothing in this app has restore value that justifies that risk — re-pairing a host is
|
||||
a QR scan, and the hosts are LAN-local anyway.
|
||||
`allowBackup` alone only covers Auto Backup (cloud) / adb backup. Android 12+ DEVICE-TO-DEVICE
|
||||
transfer is governed by a `dataExtractionRules` <device-transfer> block, which needs an @xml
|
||||
resource — @xml/data_extraction_rules now supplies it, and it is deny-everything in BOTH
|
||||
sections rather than an exclusion list, so a store added later is excluded by default instead
|
||||
of leaking until someone remembers it. `fullBackupContent="false"` states the same for the
|
||||
pre-31 path. Do not set any of these back to true. -->
|
||||
<application
|
||||
android:name=".WebTermApp"
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WebTerm"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
|
||||
@@ -79,11 +79,16 @@ public sealed interface BannerModel {
|
||||
|
||||
/**
|
||||
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
|
||||
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance.
|
||||
* spinner** (reconnecting would hit the same wall forever), and — where a fresh session would
|
||||
* actually help — a "新会话" affordance.
|
||||
*
|
||||
* [FailureReason.UNAUTHORIZED] deliberately offers NO new-session action (B5): the server 401'd the
|
||||
* upgrade, so a new session would 401 too; the fix is to re-enter the host's access token (or fix its
|
||||
* `ALLOWED_ORIGINS`), which the copy says.
|
||||
*/
|
||||
public data class Failed(val reason: FailureReason) : BannerModel {
|
||||
override val showsSpinner: Boolean get() = false
|
||||
override val offersNewSession: Boolean get() = true
|
||||
override val offersNewSession: Boolean get() = reason != FailureReason.UNAUTHORIZED
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,6 +189,8 @@ private fun bannerCopy(model: BannerModel): String = when (model) {
|
||||
is BannerModel.Failed -> when (model.reason) {
|
||||
FailureReason.REPLAY_TOO_LARGE ->
|
||||
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
|
||||
FailureReason.UNAUTHORIZED ->
|
||||
"服务器拒绝了连接(401)。请在“配对主机”里重新填写访问令牌;若已填写,请检查主机的 ALLOWED_ORIGINS。"
|
||||
}
|
||||
is BannerModel.Exited ->
|
||||
if (model.isSpawnFailure) {
|
||||
|
||||
@@ -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.OkHttpTermTransport
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import javax.inject.Singleton
|
||||
@@ -81,10 +82,18 @@ public object NetworkModule {
|
||||
.connectionPool(connectionPool)
|
||||
.build()
|
||||
|
||||
/** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */
|
||||
/**
|
||||
* WS transport over the shared client (it derives a streaming-tuned variant sharing the pool).
|
||||
*
|
||||
* [tokens] ([AuthModule]) is what makes the **WS upgrade** carry `Cookie: webterm_auth=<t>` — the
|
||||
* REST half gets its cookie from `:api-client`'s single stamping point instead (B5 / §1.1).
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client)
|
||||
public fun provideTermTransport(
|
||||
client: OkHttpClient,
|
||||
tokens: AccessTokenSource,
|
||||
): TermTransport = OkHttpTermTransport(client, tokens)
|
||||
|
||||
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
|
||||
@Provides
|
||||
|
||||
@@ -58,6 +58,10 @@ public fun PairingPane(
|
||||
runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false)
|
||||
}
|
||||
},
|
||||
// B5 · the access-token half: validated with POST /auth, then kept in the Keystore-backed store
|
||||
// the transports read from.
|
||||
tokenStore = env.accessTokenStore,
|
||||
authProber = env.buildAccessTokenProber(),
|
||||
)
|
||||
}
|
||||
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||
@@ -189,7 +193,8 @@ public fun DiffPane(
|
||||
}
|
||||
val viewModel = remember(resolved, path) {
|
||||
DiffViewModel(
|
||||
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport),
|
||||
// The hand-built diff route needs the access-token cookie too (B5 / §1.1).
|
||||
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport, env.accessTokenStore),
|
||||
path = path,
|
||||
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
|
||||
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -70,6 +71,12 @@ import wang.yaojia.webterm.viewmodels.needsExplicitAck
|
||||
* requires an explicit acknowledge, and a tunnel host routes to the device-cert screen when the probe is
|
||||
* cert-gated. Every displayed URL/host is INERT [Text] (no autolink/markdown, plan §8).
|
||||
*
|
||||
* ### Access token (B5 / ios-completion §1.1)
|
||||
* The confirm step carries an OPTIONAL access-token field (masked, with a reveal toggle). The typed token
|
||||
* never enters the ViewModel's UI state — it is passed straight into `confirm()/retry()`, validated by
|
||||
* `POST /auth`, and (only when the server accepts it) stored in the Keystore-backed store. A wrong token /
|
||||
* rate-limit / "this host requires a token" comes back as an inert [TokenError] under the field.
|
||||
*
|
||||
* ### Permissions
|
||||
* - **CAMERA** (QR only) is requested at runtime with a rationale; a denial degrades gracefully to
|
||||
* manual entry (the QR path is never mandatory).
|
||||
@@ -113,7 +120,7 @@ public fun PairingScreen(
|
||||
is PairingUiState.Confirming -> ConfirmStep(
|
||||
state = s,
|
||||
onName = viewModel::setName,
|
||||
onConfirm = viewModel::confirm,
|
||||
onConfirm = { ack, token -> viewModel.confirm(acknowledgedPublicRisk = ack, accessToken = token) },
|
||||
onCancel = viewModel::reset,
|
||||
)
|
||||
is PairingUiState.Probing -> ProbingStep(host = s.name)
|
||||
@@ -247,10 +254,16 @@ private fun CameraPreview(onScanned: (String) -> Unit) {
|
||||
private fun ConfirmStep(
|
||||
state: PairingUiState.Confirming,
|
||||
onName: (String) -> Unit,
|
||||
onConfirm: (Boolean) -> Unit,
|
||||
onConfirm: (Boolean, String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var acknowledged by remember(state.endpoint) { mutableStateOf(false) }
|
||||
// The token lives ONLY here (and in the encrypted store once accepted) — never in the ViewModel's
|
||||
// UI state, so it cannot leak through a state snapshot / crash report (§1.1). Keyed on the endpoint
|
||||
// so a token typed for one host is dropped when the candidate changes, but survives a re-render
|
||||
// after a wrong-token error (the field keeps what the user typed).
|
||||
var token by remember(state.endpoint) { mutableStateOf("") }
|
||||
var tokenVisible by remember(state.endpoint) { mutableStateOf(false) }
|
||||
|
||||
TierWarningCard(tier = state.tier, highlight = state.awaitingAck && !acknowledged)
|
||||
// The dialed URL is validated but user/QR-sourced — render INERT (plan §8).
|
||||
@@ -268,6 +281,14 @@ private fun ConfirmStep(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
AccessTokenField(
|
||||
token = token,
|
||||
onToken = { token = it },
|
||||
visible = tokenVisible,
|
||||
onToggleVisible = { tokenVisible = !tokenVisible },
|
||||
error = state.tokenError,
|
||||
)
|
||||
|
||||
if (state.tier.needsExplicitAck) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it })
|
||||
@@ -278,13 +299,58 @@ private fun ConfirmStep(
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
|
||||
Button(
|
||||
onClick = { onConfirm(acknowledged) },
|
||||
onClick = { onConfirm(acknowledged, token) },
|
||||
enabled = !state.tier.needsExplicitAck || acknowledged,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("连接") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional access-token field (B5 / ios-completion §1.1). Masked by default with an explicit
|
||||
* reveal toggle (a token is often typed from another screen and mistypes are the #1 failure), and
|
||||
* `KeyboardType.Password` + `autoCorrectEnabled = false` so the IME never learns or suggests it.
|
||||
* [error] renders the inert, app-authored copy for the typed [TokenError].
|
||||
*/
|
||||
@Composable
|
||||
private fun AccessTokenField(
|
||||
token: String,
|
||||
onToken: (String) -> Unit,
|
||||
visible: Boolean,
|
||||
onToggleVisible: () -> Unit,
|
||||
error: TokenError?,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = onToken,
|
||||
label = { Text("访问令牌(可选,WEBTERM_TOKEN)") },
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
trailingIcon = {
|
||||
TextButton(onClick = onToggleVisible) { Text(if (visible) "隐藏" else "显示") }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = PairingCopy.describeToken(error),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "主机未设置 WEBTERM_TOKEN 时留空即可。",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TierWarningCard(tier: WarningTier, highlight: Boolean) {
|
||||
val (title, body) = tierCopy(tier)
|
||||
|
||||
@@ -18,6 +18,8 @@ import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PushResult
|
||||
import wang.yaojia.webterm.api.models.StageResult
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
@@ -439,14 +441,23 @@ public class DiffUnavailable(public val status: Int) : Exception("diff unavailab
|
||||
/**
|
||||
* Real [DiffFetcher]: builds the RO `GET /projects/diff` against [endpoint] and decodes tolerantly.
|
||||
* Read-only, so it stamps NO `Origin` header (the CSWSH 铁律 — only guarded routes carry it, plan §4.3).
|
||||
*
|
||||
* This route is hand-built (it is not one of `:api-client`'s 12), so the access-token cookie must be
|
||||
* stamped HERE too: the server's auth gate covers EVERY route, and a missing cookie 401s (B5 /
|
||||
* ios-completion §1.1). The value comes from the single derivation point ([AuthCookie]); [tokens] is
|
||||
* the same store the transports read, and a host with no token adds no header at all.
|
||||
*/
|
||||
public class HttpDiffFetcher(
|
||||
private val endpoint: HostEndpoint,
|
||||
private val http: HttpTransport,
|
||||
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
) : DiffFetcher {
|
||||
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
|
||||
val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
|
||||
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
|
||||
val headers = tokens.tokenFor(endpoint)
|
||||
?.let { mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(it)) }
|
||||
?: emptyMap()
|
||||
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url, headers = headers))
|
||||
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
|
||||
return decodeDiffResult(response.body)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.api.pairing.AuthProbeResult
|
||||
import wang.yaojia.webterm.api.pairing.PairingError
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.api.pairing.probeAccessToken
|
||||
import wang.yaojia.webterm.api.pairing.runPairingProbe
|
||||
import wang.yaojia.webterm.hostregistry.AccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostClassifier
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HostNetworkTier
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -40,24 +50,56 @@ import java.util.UUID
|
||||
*
|
||||
* On probe success the validated host is persisted via [HostStore.upsert].
|
||||
*
|
||||
* ### The `WEBTERM_TOKEN` gate (server `src/http/auth.ts`)
|
||||
* A token-gated host answers the probe's unauthenticated `GET /live-sessions` with **401**, which the
|
||||
* frozen probe taxonomy can only report as [PairingError.HttpOkButNotWebTerminal] ("端口对吗?") — so
|
||||
* without this flow such a host is impossible to pair AND the copy is misleading. On that ambiguous
|
||||
* failure the ViewModel asks [PairingProber.requiresAccessToken]; a gated host routes to
|
||||
* [PairingUiState.TokenRequired], and [submitAccessToken] posts the token (`POST /auth`) so the shared
|
||||
* cookie jar picks up the `webterm_auth` cookie, then resumes pairing through the same choke point.
|
||||
* The token is a SHELL credential: it is a parameter, never a field and never part of any UI state.
|
||||
* ### The `WEBTERM_TOKEN` gate has TWO ways in, and both end in the same place
|
||||
* A token-gated host can be met either way round, so both are supported:
|
||||
* - **Typed up front.** The confirm step has an optional token field; [confirm]/[retry] carry it into
|
||||
* RULE 5 below (validate with `POST /auth`, store, then probe).
|
||||
* - **Discovered by the probe.** A host the user did not know was gated answers the probe's
|
||||
* unauthenticated `GET /live-sessions` with **401**. When the probe classifies that as
|
||||
* [PairingError.AccessTokenRequired] the user goes back to the confirm step with
|
||||
* [TokenError.REQUIRED] under the field. An older/ambiguous host instead collapses the 401 into
|
||||
* [PairingError.HttpOkButNotWebTerminal] ("端口对吗?"), which is actively misleading — so that ONE
|
||||
* case is re-checked against [PairingProber.requiresAccessToken] and, if the host really is gated,
|
||||
* routed to the dedicated [PairingUiState.TokenRequired] prompt. [submitAccessToken] posts the token
|
||||
* (`POST /auth`), which lands the `webterm_auth` cookie in the shared cookie jar, keeps the token in
|
||||
* [tokenStore] so the WS upgrade's own `Cookie` header can carry it too, then resumes pairing through
|
||||
* the same choke point.
|
||||
* Either way the token is a SHELL credential: it is a parameter, never a field and never part of any UI
|
||||
* state.
|
||||
*
|
||||
* ### RULE 5 — the access token is validated and stored BEFORE the probe (B5, ios-completion §1.1)
|
||||
* When the user typed a token, [confirm]/[retry] first validate it with `POST /auth` ([authProber]) and,
|
||||
* only if the server ACCEPTED it (204 **with** `Set-Cookie`), persist it to the Keystore-backed
|
||||
* [tokenStore]. That ordering is load-bearing: the two-step probe's own HTTP legs AND its WS upgrade
|
||||
* read the token back out of that same store, so they can only authenticate if the token is already in
|
||||
* it. A 204 **without** `Set-Cookie` means the host has no auth at all — nothing is stored and pairing
|
||||
* continues (never treated as "authenticated"). A wrong token / rate-limit / malformed token keeps the
|
||||
* user on the confirm step with a typed [TokenError] and NEVER reaches the network probe.
|
||||
*
|
||||
* ### RULE 6 — a pairing that does not end in [PairingUiState.Paired] strands no secret (F2)
|
||||
* Storing before probing means the store can be left holding a token for a host that never paired (wrong
|
||||
* port, unreachable, user backs out). So every non-paired exit — probe failure, a throw, and cancellation
|
||||
* by [reset] or by a superseding attempt — rolls the store back to exactly what it held before this
|
||||
* attempt ([TokenAttempt]): the previous token for a re-pair, nothing at all for a first pairing. The
|
||||
* rollback runs under [NonCancellable] so an abandoned attempt still cleans up, and the next attempt
|
||||
* `join`s the one it supersedes so the two never race on the same key.
|
||||
*
|
||||
* The token itself never enters [PairingUiState] — it lives in the screen's own field and is passed in
|
||||
* per call, so no state snapshot, log or crash report can carry it.
|
||||
*
|
||||
* @param hostStore where a successfully-paired [Host] is saved.
|
||||
* @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests.
|
||||
* @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO).
|
||||
* @param tokenStore per-host access-token storage (production: the Tink/AndroidKeystore store).
|
||||
* @param authProber the `POST /auth` validation seam ([accessTokenProber] in production).
|
||||
* @param newId host-id allocator (default random UUID; deterministic in tests).
|
||||
*/
|
||||
public class PairingViewModel(
|
||||
private val hostStore: HostStore,
|
||||
private val prober: PairingProber,
|
||||
private val hasDeviceCert: suspend () -> Boolean,
|
||||
private val tokenStore: AccessTokenStore,
|
||||
private val authProber: AccessTokenProber,
|
||||
private val newId: () -> String = { UUID.randomUUID().toString() },
|
||||
) {
|
||||
private val _uiState = MutableStateFlow<PairingUiState>(PairingUiState.Entry())
|
||||
@@ -118,30 +160,34 @@ public class PairingViewModel(
|
||||
/**
|
||||
* Confirm the on-screen candidate and probe it. For a public host, [acknowledgedPublicRisk] MUST be
|
||||
* true or this re-arms the acknowledge reminder without touching the network (RULE 3).
|
||||
* [accessToken] is the (optional) `WEBTERM_TOKEN` the user typed — blank means "this host has none".
|
||||
*/
|
||||
public fun confirm(acknowledgedPublicRisk: Boolean = false) {
|
||||
public fun confirm(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") {
|
||||
val candidate = _uiState.value.candidate() ?: return
|
||||
runProbe(candidate, acknowledgedPublicRisk)
|
||||
runProbe(candidate, acknowledgedPublicRisk, accessToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry after a failure or a cert-gate refusal. Funnels through the SAME [runProbe] choke point as
|
||||
* [confirm] — so the tunnel cert-gate (RULE 4) cannot be bypassed by retrying.
|
||||
*/
|
||||
public fun retry(acknowledgedPublicRisk: Boolean = false) {
|
||||
public fun retry(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") {
|
||||
val candidate = _uiState.value.candidate() ?: return
|
||||
runProbe(candidate, acknowledgedPublicRisk)
|
||||
runProbe(candidate, acknowledgedPublicRisk, accessToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the host's shared access token (`WEBTERM_TOKEN`). Only meaningful from
|
||||
* [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never post a
|
||||
* credential to a host the user has moved on from.
|
||||
* Submit the host's shared access token (`WEBTERM_TOKEN`) from the token prompt. Only meaningful
|
||||
* from [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never
|
||||
* post a credential to a host the user has moved on from.
|
||||
*
|
||||
* [token] is passed straight through to the prober and is NEVER stored: no field, no UI state, no
|
||||
* log. On acceptance the shared cookie jar holds `webterm_auth` and pairing resumes through the same
|
||||
* choke point as [confirm]/[retry] (so RULE 4's cert-gate still applies); every other outcome
|
||||
* re-arms the prompt with a distinguishable reason and NEVER auto-retries (429 included).
|
||||
* This is the DISCOVERED half of the gate; the typed-up-front half is [confirm]'s `accessToken`
|
||||
* (RULE 5). [token] goes straight to the prober's `POST /auth` and is never held in a field or in
|
||||
* UI state. On acceptance the shared cookie jar holds `webterm_auth` AND the token is kept in
|
||||
* [tokenStore] — so the WS upgrade's own `Cookie` header authenticates exactly as it would for a
|
||||
* typed token — and pairing resumes through the same [performProbe] choke point as [confirm]/[retry]
|
||||
* (so RULE 4's cert-gate still applies, under RULE 6's rollback). Every other outcome re-arms the
|
||||
* prompt with a distinguishable reason and NEVER auto-retries (429 included).
|
||||
*/
|
||||
public fun submitAccessToken(token: String) {
|
||||
val state = _uiState.value
|
||||
@@ -149,14 +195,22 @@ public class PairingViewModel(
|
||||
if (token.isBlank()) return
|
||||
val candidate = Candidate(state.endpoint, state.name, state.tier)
|
||||
val scope = scope ?: return
|
||||
probeJob?.cancel()
|
||||
val superseded = probeJob
|
||||
superseded?.cancel()
|
||||
probeJob = scope.launch {
|
||||
superseded?.join() // same reason as runProbe: never race a superseded attempt's rollback.
|
||||
_uiState.value = PairingUiState.TokenSubmitting(candidate.endpoint, candidate.name, candidate.tier)
|
||||
when (prober.submitAccessToken(candidate.endpoint, token)) {
|
||||
// Straight into the probe body: RULE 3 is already satisfied by construction (reaching the
|
||||
// prompt required a probe, which for a public host required the explicit ack — and an ack
|
||||
// cannot be un-given), while RULE 4's cert-gate lives INSIDE performProbe and re-applies.
|
||||
TokenSubmission.Accepted -> performProbe(candidate)
|
||||
// This submit WAS the `POST /auth` validation, so RULE 5 only has to KEEP the token.
|
||||
TokenSubmission.Accepted -> performProbe(candidate) {
|
||||
writeToken(candidate, token) { error -> promptForToken(candidate, error); null }
|
||||
}
|
||||
// 204 with no Set-Cookie: the host has no auth at all (FROZEN §1.1). Store NOTHING and
|
||||
// never read it as "authenticated" — same rule as `establishToken`'s AuthDisabled.
|
||||
TokenSubmission.AuthDisabled -> performProbe(candidate) { TokenAttempt.NONE }
|
||||
TokenSubmission.Rejected -> promptForToken(candidate, TokenError.INVALID)
|
||||
TokenSubmission.RateLimited -> promptForToken(candidate, TokenError.RATE_LIMITED)
|
||||
is TokenSubmission.Unreachable -> promptForToken(candidate, TokenError.UNREACHABLE)
|
||||
@@ -165,7 +219,7 @@ public class PairingViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) {
|
||||
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean, accessToken: String) {
|
||||
// RULE 3 — public host: no probe until the risk is explicitly acknowledged. This is the
|
||||
// pre-network UI gate, so it lives here (the entry point the user drives), not in performProbe.
|
||||
if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) {
|
||||
@@ -178,17 +232,31 @@ public class PairingViewModel(
|
||||
return
|
||||
}
|
||||
val scope = scope ?: return
|
||||
probeJob?.cancel()
|
||||
probeJob = scope.launch { performProbe(candidate) }
|
||||
val superseded = probeJob
|
||||
superseded?.cancel()
|
||||
probeJob = scope.launch {
|
||||
// Wait out the attempt this one replaces: its RULE 6 rollback must finish before we read or
|
||||
// write the same store key, or the two attempts race and the loser's undo wins.
|
||||
superseded?.join()
|
||||
// RULE 5 — validate + store the token BEFORE probing (the probe authenticates from the store).
|
||||
performProbe(candidate) {
|
||||
if (accessToken.isBlank()) TokenAttempt.NONE else establishToken(candidate, accessToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE probe body — reached from [confirm]/[retry] via [runProbe] and from an accepted access
|
||||
* token. Holds RULE 4 (the cert-gate) so BOTH entry points hit it; RULE 3 (the public-risk ack) is a
|
||||
* token ([submitAccessToken]). Holds RULE 4 (the cert-gate) so BOTH entry points hit it BEFORE any
|
||||
* network I/O, then RULE 5's [tokenStep] and RULE 6's rollback; RULE 3 (the public-risk ack) is a
|
||||
* pre-network UI gate and stays in [runProbe]. Runs inside the caller's job (never re-assigns
|
||||
* [probeJob], which would cancel the coroutine it is called from).
|
||||
*
|
||||
* @param tokenStep the RULE 5 step for this entry point — validate+store a typed token, or merely
|
||||
* store one the prompt already validated. Returns the [TokenAttempt] to undo, or **null** after
|
||||
* publishing why pairing stops (meaning "do not probe").
|
||||
*/
|
||||
private suspend fun performProbe(candidate: Candidate) {
|
||||
private suspend fun performProbe(candidate: Candidate, tokenStep: suspend () -> TokenAttempt?) {
|
||||
val tier = candidate.tier
|
||||
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both
|
||||
// re-hit this same guard.
|
||||
@@ -197,18 +265,122 @@ public class PairingViewModel(
|
||||
return
|
||||
}
|
||||
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
|
||||
when (val result = prober.probe(candidate.endpoint)) {
|
||||
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
||||
is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error)
|
||||
val attempt = tokenStep() ?: return
|
||||
// RULE 6 — anything short of Paired puts the store back the way this attempt found it.
|
||||
val paired = try {
|
||||
when (val result = prober.probe(candidate.endpoint)) {
|
||||
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
||||
is PairingProbeResult.Failure -> {
|
||||
onProbeFailure(candidate, result.error)
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
// Cancelled (reset / a newer attempt) or a transport throw — the rule is the same, and
|
||||
// NonCancellable is what lets an already-cancelled attempt still clean up after itself.
|
||||
withContext(NonCancellable) { rollBackToken(candidate, attempt) }
|
||||
throw error
|
||||
}
|
||||
if (!paired) rollBackToken(candidate, attempt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate [token] with `POST /auth` and, if the server accepted it, persist it for this host.
|
||||
* Returns the [TokenAttempt] to undo should pairing not complete (RULE 6) — [TokenAttempt.NONE] when
|
||||
* the host turned out to have no auth at all — or **null** after publishing the matching
|
||||
* [TokenError] / failure state, meaning "stop, do not probe".
|
||||
*/
|
||||
private suspend fun establishToken(candidate: Candidate, token: String): TokenAttempt? {
|
||||
val outcome = try {
|
||||
authProber.validate(candidate.endpoint, token)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
// A network-level failure is not a token problem — classify it like any other probe error.
|
||||
onProbeFailure(candidate, PairingError.classify(error, candidate.endpoint))
|
||||
return null
|
||||
}
|
||||
return when (outcome) {
|
||||
AuthProbeResult.Accepted -> writeToken(candidate, token) { failToken(candidate, it) }
|
||||
// 204 with no Set-Cookie: the host has no auth. Store NOTHING (never "assume authenticated")
|
||||
// and carry on — an open host pairs exactly as it did before this feature.
|
||||
AuthProbeResult.AuthDisabled -> TokenAttempt.NONE
|
||||
AuthProbeResult.InvalidToken -> failToken(candidate, TokenError.INVALID)
|
||||
AuthProbeResult.RateLimited -> failToken(candidate, TokenError.RATE_LIMITED)
|
||||
AuthProbeResult.Malformed -> failToken(candidate, TokenError.MALFORMED)
|
||||
is AuthProbeResult.Unexpected -> failToken(candidate, TokenError.UNVERIFIABLE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed probe. [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a
|
||||
* `WEBTERM_TOKEN`-gated host's 401 collapses into — so that one case is re-checked against the token
|
||||
* gate before the misleading "wrong port?" copy is shown. Every other error is reported verbatim.
|
||||
* Persist [token], remembering what the store held before so RULE 6 can put it back. [onFailure]
|
||||
* publishes the reason in whichever step the caller is showing (the confirm field, or the token
|
||||
* prompt) and returns null, so a token we could not keep never reaches the probe.
|
||||
*/
|
||||
private suspend fun writeToken(
|
||||
candidate: Candidate,
|
||||
token: String,
|
||||
onFailure: (TokenError) -> TokenAttempt?,
|
||||
): TokenAttempt? {
|
||||
return try {
|
||||
val previous = tokenStore.tokenFor(candidate.endpoint)
|
||||
// put() == false ⇒ the token failed the client-side rule (unreachable after a server ACCEPT).
|
||||
if (!tokenStore.put(candidate.endpoint, token)) onFailure(TokenError.MALFORMED)
|
||||
else TokenAttempt(stored = true, previous = previous)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
// A throw ⇒ encrypted storage failed; never continue with a token we could not keep.
|
||||
onFailure(TokenError.STORE_FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo [attempt]'s write: restore the previous token (a failed re-pair must not cost the user the
|
||||
* one that was working) or drop the key entirely when this host had none. Never publishes state —
|
||||
* the caller has already published the reason pairing stopped.
|
||||
*/
|
||||
private suspend fun rollBackToken(candidate: Candidate, attempt: TokenAttempt) {
|
||||
if (!attempt.stored) return
|
||||
try {
|
||||
val previous = attempt.previous
|
||||
if (previous == null) tokenStore.remove(candidate.endpoint)
|
||||
else tokenStore.put(candidate.endpoint, previous)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
// Best effort: encrypted storage just failed on the undo. There is no recovery beyond this,
|
||||
// and surfacing it would replace the pairing error the user actually needs to read.
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish a token-specific error, keeping the user ON the confirm step (the field is there). */
|
||||
private fun failToken(candidate: Candidate, error: TokenError): TokenAttempt? {
|
||||
_uiState.value = PairingUiState.Confirming(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = candidate.tier,
|
||||
tokenError = error,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed probe, with TWO token-gate readings:
|
||||
* - [PairingError.AccessTokenRequired] — the probe saw the gate's own 401, so the user goes back to
|
||||
* the confirm step with the token field and [TokenError.REQUIRED];
|
||||
* - [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a `WEBTERM_TOKEN`-gated
|
||||
* host's 401 collapses into on a host the taxonomy cannot classify — so that one case is
|
||||
* re-checked against [PairingProber.requiresAccessToken] and, when the host really is gated,
|
||||
* routed to the dedicated prompt instead of the misleading "wrong port?" copy.
|
||||
* Every other error is reported verbatim.
|
||||
*/
|
||||
private suspend fun onProbeFailure(candidate: Candidate, error: PairingError) {
|
||||
// The host demands a token we do not have → back to the confirm step to enter one.
|
||||
if (error == PairingError.AccessTokenRequired) {
|
||||
failToken(candidate, TokenError.REQUIRED)
|
||||
return
|
||||
}
|
||||
if (error == PairingError.HttpOkButNotWebTerminal && prober.requiresAccessToken(candidate.endpoint)) {
|
||||
promptForToken(candidate, error = null)
|
||||
return
|
||||
@@ -222,6 +394,7 @@ public class PairingViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
/** Publish the dedicated token prompt (the discovered-gate step), optionally with a reason. */
|
||||
private fun promptForToken(candidate: Candidate, error: TokenError?) {
|
||||
_uiState.value = PairingUiState.TokenRequired(
|
||||
endpoint = candidate.endpoint,
|
||||
@@ -231,7 +404,8 @@ public class PairingViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onProbeSuccess(candidate: Candidate) {
|
||||
/** Persist the paired host. Returns false when it could not be paired (so RULE 6 undoes the token). */
|
||||
private suspend fun onProbeSuccess(candidate: Candidate): Boolean {
|
||||
val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl)
|
||||
if (host == null) {
|
||||
// Unreachable in practice (the endpoint already validated), but never persist a null host.
|
||||
@@ -242,10 +416,11 @@ public class PairingViewModel(
|
||||
error = PairingError.HttpOkButNotWebTerminal,
|
||||
message = PairingCopy.describe(PairingError.HttpOkButNotWebTerminal, candidate.tier),
|
||||
)
|
||||
return
|
||||
return false
|
||||
}
|
||||
hostStore.upsert(host)
|
||||
_uiState.value = PairingUiState.Paired(host)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun defaultName(endpoint: HostEndpoint): String =
|
||||
@@ -258,11 +433,13 @@ public class PairingViewModel(
|
||||
* `AppEnvironment.buildPairingProber()` over the ONE shared `OkHttpClient` (so the token cookie the
|
||||
* submit obtains is the same jar every later request and the WS upgrade read from).
|
||||
*
|
||||
* The two token operations are DEFAULTED so a probe-only double still satisfies the interface. Their
|
||||
* defaults are deliberately the honest "this seam cannot reach the token gate" answers — `false` and
|
||||
* [TokenSubmission.Unavailable] — never a silent success.
|
||||
* The two token operations are DEFAULTED so a probe-only double still satisfies the interface — which
|
||||
* also leaves exactly one abstract member, so this stays a `fun interface` and a test/production
|
||||
* binding can be written as `PairingProber { endpoint -> … }`. Their defaults are deliberately the
|
||||
* honest "this seam cannot reach the token gate" answers — `false` and [TokenSubmission.Unavailable] —
|
||||
* never a silent success.
|
||||
*/
|
||||
public interface PairingProber {
|
||||
public fun interface PairingProber {
|
||||
public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult
|
||||
|
||||
/**
|
||||
@@ -295,6 +472,15 @@ public sealed interface TokenSubmission {
|
||||
/** 2xx (the server answers `204` to a non-HTML request) — `Set-Cookie: webterm_auth=…` was returned. */
|
||||
public data object Accepted : TokenSubmission
|
||||
|
||||
/**
|
||||
* **2xx with NO `Set-Cookie: webterm_auth=…`** — this host has `WEBTERM_TOKEN` unset, so there is
|
||||
* nothing to authenticate (FROZEN §1.1; the same discriminator as [AuthProbeResult.AuthDisabled] on
|
||||
* the typed-token path). The submitted token MUST NOT be persisted and this MUST NOT be read as
|
||||
* "authenticated": the host is simply open, and pairing continues exactly as for a zero-config LAN
|
||||
* deploy.
|
||||
*/
|
||||
public data object AuthDisabled : TokenSubmission
|
||||
|
||||
/** `401` — the token does not match the host's `WEBTERM_TOKEN`. */
|
||||
public data object Rejected : TokenSubmission
|
||||
|
||||
@@ -312,21 +498,35 @@ public sealed interface TokenSubmission {
|
||||
public data object Unavailable : TokenSubmission
|
||||
}
|
||||
|
||||
/** Why the access-token prompt is showing an error. Maps to inert, app-authored copy in [PairingCopy]. */
|
||||
public enum class TokenError {
|
||||
/** The submitted token was rejected by the host (`401`). */
|
||||
INVALID,
|
||||
|
||||
/** The host is rate-limiting auth attempts (`429`) — wait, do not hammer it. */
|
||||
RATE_LIMITED,
|
||||
|
||||
/** The host did not answer the submit (or answered unintelligibly). */
|
||||
UNREACHABLE,
|
||||
|
||||
/** The app cannot submit a token at all on this seam. */
|
||||
UNAVAILABLE,
|
||||
/**
|
||||
* The `POST /auth` access-token validation seam ([probeAccessToken]); faked in tests. Kept separate from
|
||||
* [PairingProber] so the ORDER (validate+store, then probe) is observable in a unit test.
|
||||
*/
|
||||
public fun interface AccessTokenProber {
|
||||
public suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Production [AccessTokenProber]: the frozen one-shot `POST /auth` probe over the ONE shared
|
||||
* `OkHttpClient`'s [HttpTransport]. The token travels in the JSON body only — never a URL, never a log.
|
||||
*/
|
||||
public fun accessTokenProber(http: HttpTransport): AccessTokenProber =
|
||||
AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, http, token) }
|
||||
|
||||
/**
|
||||
* Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared
|
||||
* `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav
|
||||
* layer resolves both transports off the DI graph and hands the resulting prober to the ViewModel —
|
||||
* `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink
|
||||
* I/O on a UI thread.
|
||||
*/
|
||||
public fun pairingProber(
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
): PairingProber =
|
||||
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws, tokens) }
|
||||
|
||||
// ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -396,6 +596,11 @@ public sealed interface PairingUiState {
|
||||
val name: String,
|
||||
val tier: WarningTier,
|
||||
val awaitingAck: Boolean = false,
|
||||
/**
|
||||
* Set when the access-token step failed (or the host demands one). The token VALUE is never
|
||||
* carried here — only why it went wrong (§1.1: a secret never enters app state).
|
||||
*/
|
||||
val tokenError: TokenError? = null,
|
||||
) : PairingUiState
|
||||
|
||||
/** The two-step probe is running against [endpoint]. */
|
||||
@@ -447,9 +652,57 @@ public enum class EntryError {
|
||||
INVALID_URL,
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the access-token step failed. Rendered next to the token field on the confirm step (the typed-token
|
||||
* path) or under the dedicated [PairingUiState.TokenRequired] prompt (the discovered-gate path), so the
|
||||
* user can fix it in place (B5 / ios-completion §1.1).
|
||||
*/
|
||||
public enum class TokenError {
|
||||
/** The host answered 401 to the probe: it HAS a token configured and we presented none/a wrong one. */
|
||||
REQUIRED,
|
||||
|
||||
/** `POST /auth` → 401: the typed/submitted token is wrong. */
|
||||
INVALID,
|
||||
|
||||
/**
|
||||
* `POST /auth` → **429**: the server's 10/min/IP limiter tripped. ONE case for both entry points —
|
||||
* the typed-token path and the prompt's submit path hit the same limiter on the same route, so two
|
||||
* enum constants with two different strings were the merge keeping both branches' names for one
|
||||
* server answer. Surfaced as-is; the app NEVER auto-retries.
|
||||
*/
|
||||
RATE_LIMITED,
|
||||
|
||||
/** The typed token cannot be a server token at all (charset / 16–512 length). No request was sent. */
|
||||
MALFORMED,
|
||||
|
||||
/** `POST /auth` answered something unexpected — the token could not be verified either way. */
|
||||
UNVERIFIABLE,
|
||||
|
||||
/** The host did not answer the submit (or answered unintelligibly) — distinct from a wrong token. */
|
||||
UNREACHABLE,
|
||||
|
||||
/** The app cannot submit a token at all on this seam (a probe-only prober). */
|
||||
UNAVAILABLE,
|
||||
|
||||
/** The token was correct but encrypted on-device storage refused to keep it. */
|
||||
STORE_FAILED,
|
||||
}
|
||||
|
||||
/** The endpoint+name+tier a probe runs against, recovered from whatever state currently holds one. */
|
||||
private data class Candidate(val endpoint: HostEndpoint, val name: String, val tier: WarningTier)
|
||||
|
||||
/**
|
||||
* What one pairing attempt wrote to the access-token store, and therefore what has to be put back if the
|
||||
* attempt does not end in [PairingUiState.Paired] (RULE 6). [previous] is what the store held for this
|
||||
* host beforehand — non-null only for a re-pair, whose working token a failed attempt must not cost.
|
||||
*/
|
||||
private data class TokenAttempt(val stored: Boolean, val previous: String?) {
|
||||
companion object {
|
||||
/** No token was written (none typed, or the host has no auth) — nothing to undo. */
|
||||
val NONE: TokenAttempt = TokenAttempt(stored = false, previous = null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PairingUiState.candidate(): Candidate? = when (this) {
|
||||
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.Probing -> Candidate(endpoint, name, tier)
|
||||
@@ -462,6 +715,32 @@ private fun PairingUiState.candidate(): Candidate? = when (this) {
|
||||
|
||||
/** Maps the [PairingError] taxonomy to inert, app-authored Chinese copy (never a server string, §8). */
|
||||
internal object PairingCopy {
|
||||
/**
|
||||
* Inert, app-authored copy for each [TokenError]. Never echoes the token itself.
|
||||
*
|
||||
* A wrong token, an unreachable host and a rate-limit read differently on purpose — collapsing them
|
||||
* would tell the user to re-type a token that was fine — and the rate-limit line tells them to WAIT,
|
||||
* because the app never retries by itself.
|
||||
*/
|
||||
fun describeToken(error: TokenError): String = when (error) {
|
||||
TokenError.REQUIRED ->
|
||||
"该主机启用了访问令牌(WEBTERM_TOKEN)。请填写访问令牌后再连接。"
|
||||
TokenError.INVALID ->
|
||||
"访问令牌不正确。请核对主机上的 WEBTERM_TOKEN 后重试(区分大小写)。"
|
||||
TokenError.RATE_LIMITED ->
|
||||
"尝试次数过多,主机已暂时限流(每分钟最多 10 次)。请等待约一分钟再试——App 不会自动重试。"
|
||||
TokenError.MALFORMED ->
|
||||
"访问令牌格式不合法:需 16–512 个字符,且只能包含 A–Z a–z 0–9 . _ ~ + / = -。"
|
||||
TokenError.UNVERIFIABLE ->
|
||||
"无法验证访问令牌:服务器返回了意外响应。请确认地址和端口指向 web-terminal。"
|
||||
TokenError.UNREACHABLE ->
|
||||
"提交访问令牌时主机没有正常响应。请检查网络与地址后重试。"
|
||||
TokenError.UNAVAILABLE ->
|
||||
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
|
||||
TokenError.STORE_FAILED ->
|
||||
"无法在本机安全保存访问令牌(加密存储写入失败)。请重试。"
|
||||
}
|
||||
|
||||
fun describe(error: PairingError, tier: WarningTier): String = when (error) {
|
||||
is PairingError.HostUnreachable ->
|
||||
"无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。"
|
||||
@@ -489,22 +768,8 @@ internal object PairingCopy {
|
||||
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
|
||||
if (tier == WarningTier.TUNNEL) "客户端证书无效或已被吊销。请在“设备证书”里重新导入后再试。"
|
||||
else "TLS 握手失败:无法验证服务器证书。"
|
||||
PairingError.AccessTokenRequired -> describeToken(TokenError.REQUIRED)
|
||||
PairingError.Timeout ->
|
||||
"连接超时。主机可能不可达,或响应过慢。"
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy for the access-token prompt. A wrong token and an unreachable host read differently on
|
||||
* purpose, and the rate-limit line tells the user to WAIT — the app never retries by itself.
|
||||
*/
|
||||
fun describeToken(error: TokenError): String = when (error) {
|
||||
TokenError.INVALID ->
|
||||
"访问令牌不正确。请核对主机上 WEBTERM_TOKEN 的值(区分大小写)后重试。"
|
||||
TokenError.RATE_LIMITED ->
|
||||
"尝试次数过多,主机已暂时限流(每分钟最多 10 次)。请等待约一分钟再试——App 不会自动重试。"
|
||||
TokenError.UNREACHABLE ->
|
||||
"提交访问令牌时主机没有正常响应。请检查网络与地址后重试。"
|
||||
TokenError.UNAVAILABLE ->
|
||||
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package wang.yaojia.webterm.wiring
|
||||
|
||||
import dagger.Lazy
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import javax.inject.Inject
|
||||
@@ -23,6 +24,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
public class ApiClientFactory @Inject constructor(
|
||||
private val http: Lazy<HttpTransport>,
|
||||
private val tokens: AccessTokenSource,
|
||||
) {
|
||||
/**
|
||||
* Resolve the shared [HttpTransport] (→ builds the shared `OkHttpClient`) so the slow mTLS I/O runs
|
||||
@@ -32,6 +34,14 @@ public class ApiClientFactory @Inject constructor(
|
||||
http.get()
|
||||
}
|
||||
|
||||
/** Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read. */
|
||||
public fun create(endpoint: HostEndpoint): ApiClient = ApiClient(endpoint = endpoint, http = http.get())
|
||||
/**
|
||||
* Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read.
|
||||
*
|
||||
* Every client is handed the shared [AccessTokenSource], so EVERY REST route — including the
|
||||
* read-only GETs and the push-decision POSTs — presents this host's access token when one is stored
|
||||
* (B5 / ios-completion §1.1). The token is read per request, so pairing a host mid-run takes effect
|
||||
* without rebuilding anything.
|
||||
*/
|
||||
public fun create(endpoint: HostEndpoint): ApiClient =
|
||||
ApiClient(endpoint = endpoint, http = http.get(), tokens = tokens)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.api.models.UiConfig
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.api.pairing.probeAccessToken
|
||||
import wang.yaojia.webterm.api.pairing.runPairingProbe
|
||||
import wang.yaojia.webterm.hostregistry.AccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieStore
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
@@ -25,6 +27,7 @@ import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.transport.AuthCookieJar
|
||||
import wang.yaojia.webterm.transport.AuthCookieSnapshot
|
||||
import wang.yaojia.webterm.transport.authCookieHostKey
|
||||
import wang.yaojia.webterm.viewmodels.AccessTokenProber
|
||||
import wang.yaojia.webterm.viewmodels.ApiClientQueueGateway
|
||||
import wang.yaojia.webterm.viewmodels.HostRemovalGateway
|
||||
import wang.yaojia.webterm.viewmodels.PairingProber
|
||||
@@ -32,9 +35,12 @@ import wang.yaojia.webterm.viewmodels.PersistedUnreadWatermarkStore
|
||||
import wang.yaojia.webterm.viewmodels.QueueGateway
|
||||
import wang.yaojia.webterm.viewmodels.TokenSubmission
|
||||
import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
import wang.yaojia.webterm.wire.HttpResponse
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import java.net.URI
|
||||
@@ -81,6 +87,11 @@ import javax.inject.Singleton
|
||||
public class AppEnvironment @Inject constructor(
|
||||
public val hostStore: HostStore,
|
||||
public val lastSessionStore: LastSessionStore,
|
||||
/**
|
||||
* B5 · per-host access tokens (`WEBTERM_TOKEN`), Tink/AndroidKeystore-encrypted. The pairing screen
|
||||
* WRITES it; the transports read the same instance through [AccessTokenSource] (see `AuthModule`).
|
||||
*/
|
||||
public val accessTokenStore: AccessTokenStore,
|
||||
public val apiClientFactory: ApiClientFactory,
|
||||
public val sessionEngineFactory: SessionEngineFactory,
|
||||
public val coldStartPolicy: ColdStartPolicy,
|
||||
@@ -147,12 +158,16 @@ public class AppEnvironment @Inject constructor(
|
||||
* The submit rides the SAME client as everything else, which is the whole point: the `webterm_auth`
|
||||
* cookie it earns lands in the shared [AuthCookieJar] and is therefore replayed on every later REST
|
||||
* call AND on the WS upgrade.
|
||||
*
|
||||
* The probe also authenticates from [accessTokenStore] (B5 / §1.1) — its two HTTP legs AND its WS
|
||||
* upgrade read the token the pairing flow may have just stored, so both halves of the gate work:
|
||||
* a token typed up-front (stored, then probed) and one submitted at the prompt (cookie jar).
|
||||
*/
|
||||
public fun buildPairingProber(): PairingProber = object : PairingProber {
|
||||
private val gateway = AccessTokenGateway { httpTransportLazy.get() }
|
||||
|
||||
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult =
|
||||
runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get())
|
||||
runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get(), accessTokenStore)
|
||||
|
||||
override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean =
|
||||
gateway.requiresAccessToken(endpoint)
|
||||
@@ -161,6 +176,14 @@ public class AppEnvironment @Inject constructor(
|
||||
gateway.submit(endpoint, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `POST /auth` access-token validation prober (B5). Like [buildPairingProber], the shared
|
||||
* transport is resolved lazily INSIDE the call so building this on the UI thread does no mTLS/OkHttp
|
||||
* work.
|
||||
*/
|
||||
public fun buildAccessTokenProber(): AccessTokenProber =
|
||||
AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, httpTransportLazy.get(), token) }
|
||||
|
||||
/**
|
||||
* Build the session-list / manage-page thumbnail pipeline for one host (§6.7). Per-host because its
|
||||
* preview fetch is bound to that host's [ApiClient][wang.yaojia.webterm.api.routes.ApiClient]
|
||||
@@ -225,6 +248,7 @@ public class AppEnvironment @Inject constructor(
|
||||
hostStore = hostStore,
|
||||
lastSessionStore = lastSessionStore,
|
||||
authCookieStore = authCookieStoreLazy.get(),
|
||||
accessTokenStore = accessTokenStore,
|
||||
watermarkStore = sessionWatermarkStore,
|
||||
pushUnregister = pushUnregister,
|
||||
currentPushToken = { pushTokenSource.currentToken() },
|
||||
@@ -390,9 +414,11 @@ public fun interface PushTokenSource {
|
||||
* pushing notifications to the device forever. It is **best-effort**: a host that is switched off
|
||||
* must still be removable, and the local state is what the user asked us to forget.
|
||||
* 2. **Then the local state**, in the order that keeps a failure retryable: the session watermarks, the
|
||||
* last-session pointer, the persisted + live `webterm_auth` cookie, and the paired record LAST. Any
|
||||
* throw propagates with the host still paired, so the user can simply try again — the UI treats that
|
||||
* as "nothing was removed" ([HostRemovalGateway]).
|
||||
* last-session pointer, the persisted + live `webterm_auth` cookie, **the Keystore-held access
|
||||
* token** ([AccessTokenStore] — the same credential's second durable home; the cookie's value IS
|
||||
* the token, `src/http/auth.ts`), and the paired record LAST. Any throw propagates with the host
|
||||
* still paired, so the user can simply try again — the UI treats that as "nothing was removed"
|
||||
* ([HostRemovalGateway]).
|
||||
*
|
||||
* ### What is deliberately NOT removed
|
||||
* - **The device certificate / AndroidKeyStore identity** — it is ONE app-wide mTLS identity shared by
|
||||
@@ -409,6 +435,14 @@ public class HostRemover(
|
||||
private val hostStore: HostStore,
|
||||
private val lastSessionStore: LastSessionStore,
|
||||
private val authCookieStore: AuthCookieStore,
|
||||
/**
|
||||
* The OTHER durable home of the same shell credential (B5). `authCookieStore` alone is not
|
||||
* "every trace": the token is what the cookie's value IS (`src/http/auth.ts` builds
|
||||
* `webterm_auth=<token>`), and [AccessTokenStore] is keyed by
|
||||
* [wang.yaojia.webterm.wire.HostEndpoint.originHeader] — NOT by the paired-host record — so a
|
||||
* retained token silently re-authenticates the moment the same URL is added again.
|
||||
*/
|
||||
private val accessTokenStore: AccessTokenStore,
|
||||
private val watermarkStore: SessionWatermarkStore,
|
||||
private val pushUnregister: HostPushUnregister,
|
||||
private val currentPushToken: suspend () -> String?,
|
||||
@@ -433,6 +467,9 @@ public class HostRemover(
|
||||
authCookieStore.removeHost(hostKey)
|
||||
clearLiveCookies(hostKey) // the in-memory jar too, or the credential rides the next dial
|
||||
}
|
||||
// The SAME credential's other durable home (B5). Keyed by origin, not by host id, so it
|
||||
// survives the record and silently re-authenticates a re-added URL unless dropped here.
|
||||
accessTokenStore.remove(host.endpoint)
|
||||
hostStore.remove(hostId)
|
||||
}
|
||||
|
||||
@@ -504,6 +541,11 @@ public class AccessTokenGateway(private val http: () -> HttpTransport) {
|
||||
/**
|
||||
* `POST /auth` with [token] in an urlencoded form body.
|
||||
*
|
||||
* A 2xx is NOT on its own an acceptance: the presence of `Set-Cookie: webterm_auth=…` is the frozen
|
||||
* §1.1 discriminator between "the token is correct" ([TokenSubmission.Accepted]) and "this host has
|
||||
* no auth configured" ([TokenSubmission.AuthDisabled]) — and the caller persists a secret on the
|
||||
* first but must not on the second.
|
||||
*
|
||||
* [token] appears in the BODY only — never the URL, never a header, never the returned outcome, never
|
||||
* a log line — and nothing here retains it.
|
||||
*/
|
||||
@@ -526,18 +568,34 @@ public class AccessTokenGateway(private val http: () -> HttpTransport) {
|
||||
return TokenSubmission.Unreachable(reason = error::class.simpleName ?: UNKNOWN_ERROR)
|
||||
}
|
||||
return when {
|
||||
response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES -> TokenSubmission.Accepted
|
||||
// 2xx splits in TWO (FROZEN §1.1): only a `Set-Cookie: webterm_auth=…` means the token was
|
||||
// ACCEPTED. A bare 204 means the host has `WEBTERM_TOKEN` unset — nothing to authenticate, so
|
||||
// the caller must persist nothing. Collapsing them puts a shell credential in the Keystore
|
||||
// for an open host and makes the client believe it is authenticated.
|
||||
response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES ->
|
||||
if (response.carriesAuthCookie()) TokenSubmission.Accepted else TokenSubmission.AuthDisabled
|
||||
response.status == HTTP_UNAUTHORIZED -> TokenSubmission.Rejected
|
||||
response.status == HTTP_TOO_MANY_REQUESTS -> TokenSubmission.RateLimited
|
||||
else -> TokenSubmission.Unreachable(reason = "HTTP ${response.status}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the
|
||||
* value must name [AuthCookie.NAME] — a proxy's own `Set-Cookie` proves nothing about the gate. Same
|
||||
* rule as `:api-client`'s `AuthProbe.carriesAuthCookie`; never logs the value.
|
||||
*/
|
||||
private fun HttpResponse.carriesAuthCookie(): Boolean =
|
||||
headers.entries.any { (name, value) ->
|
||||
name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LIVE_SESSIONS_PATH = "/live-sessions"
|
||||
const val AUTH_PATH = "/auth"
|
||||
const val TOKEN_FIELD = "token"
|
||||
const val CONTENT_TYPE_HEADER = "Content-Type"
|
||||
const val SET_COOKIE_HEADER = "Set-Cookie"
|
||||
const val FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
|
||||
const val HTTP_OK = 200
|
||||
const val HTTP_MULTIPLE_CHOICES = 300
|
||||
|
||||
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))
|
||||
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.PushResult
|
||||
import wang.yaojia.webterm.api.models.StageResult
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
/**
|
||||
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
|
||||
@@ -291,4 +294,35 @@ class DiffViewModelTest {
|
||||
assertTrue(DiffUiState(canWrite = true).writeEnabled)
|
||||
assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
|
||||
}
|
||||
|
||||
// ── B5 · the hand-built diff route carries the access-token cookie ───────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the real diff fetcher stamps the auth cookie and never an Origin`() = runTest {
|
||||
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
|
||||
val http = FakeHttpTransport()
|
||||
val url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0"
|
||||
http.queueSuccess(url = url, body = """{"files":[]}""".toByteArray())
|
||||
val fetcher = HttpDiffFetcher(endpoint, http, AccessTokenSource { "0123456789abcdefTOKEN" })
|
||||
|
||||
fetcher.fetch("/repo", staged = false, base = null)
|
||||
|
||||
val request = http.recordedRequests.single()
|
||||
assertEquals("webterm_auth=0123456789abcdefTOKEN", request.headers["Cookie"])
|
||||
assertFalse(request.headers.containsKey("Origin"), "the diff route is read-only")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the real diff fetcher sends no cookie for a host without a token`() = runTest {
|
||||
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
|
||||
val http = FakeHttpTransport()
|
||||
http.queueSuccess(
|
||||
url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0",
|
||||
body = """{"files":[]}""".toByteArray(),
|
||||
)
|
||||
|
||||
HttpDiffFetcher(endpoint, http).fetch("/repo", staged = false, base = null)
|
||||
|
||||
assertFalse(http.recordedRequests.single().headers.containsKey("Cookie"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 wang.yaojia.webterm.api.pairing.PairingError
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
@@ -44,6 +45,11 @@ class PairingViewModelTest {
|
||||
hostStore = store,
|
||||
prober = prober,
|
||||
hasDeviceCert = { hasCert },
|
||||
// B5: these tests cover the NO-token paths — an empty store plus a prober that must never be
|
||||
// called (a blank token skips `POST /auth` entirely). The token paths live in
|
||||
// PairingTokenViewModelTest.
|
||||
tokenStore = InMemoryAccessTokenStore(),
|
||||
authProber = { _, _ -> error("POST /auth must not be called when no token was typed") },
|
||||
newId = { "fixed-id" },
|
||||
)
|
||||
|
||||
|
||||
@@ -55,6 +55,15 @@ class AccessTokenGatewayTest {
|
||||
private fun status(code: Int): (HttpRequest) -> HttpResponse =
|
||||
{ HttpResponse(status = code, body = ByteArray(0)) }
|
||||
|
||||
/** 204 **with** the gate's `Set-Cookie: webterm_auth=…` — the "token accepted" answer. */
|
||||
private fun acceptedWithCookie(): (HttpRequest) -> HttpResponse = {
|
||||
HttpResponse(
|
||||
status = 204,
|
||||
body = ByteArray(0),
|
||||
headers = mapOf("Set-Cookie" to "webterm_auth=$TOKEN; Path=/; HttpOnly; SameSite=Strict"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun gateway(transport: HttpTransport) = AccessTokenGateway { transport }
|
||||
|
||||
// ── Gate detection: is this host token-gated, or is it just not a web-terminal? ──────────────────
|
||||
@@ -95,8 +104,37 @@ class AccessTokenGatewayTest {
|
||||
// ── POST /auth status mapping ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `204 accepts the token`() = runTest {
|
||||
assertEquals(TokenSubmission.Accepted, gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN))
|
||||
fun `204 with the auth cookie accepts the token`() = runTest {
|
||||
assertEquals(
|
||||
TokenSubmission.Accepted,
|
||||
gateway(RecordingTransport(acceptedWithCookie())).submit(endpoint(), TOKEN),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* F4 · the FROZEN §1.1 discriminator, on the submit path too: **204 with no `Set-Cookie` means the
|
||||
* host has `WEBTERM_TOKEN` unset**, so there is nothing to authenticate. Before the merge this only
|
||||
* mattered to the jar; now `PairingViewModel.submitAccessToken` writes durable Keystore storage on
|
||||
* `Accepted`, so collapsing the two readings would persist a shell credential for a host that has no
|
||||
* auth at all — and let the client believe it is authenticated. Mirrors `AuthProbeResult.AuthDisabled`
|
||||
* on the proactive path (`AuthProbe.kt:19-24`).
|
||||
*/
|
||||
@Test
|
||||
fun `204 without Set-Cookie means the host has auth disabled, not an accepted token`() = runTest {
|
||||
assertEquals(
|
||||
TokenSubmission.AuthDisabled,
|
||||
gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only OUR cookie counts as an acceptance`() = runTest {
|
||||
// A proxy's own Set-Cookie proves nothing about the WEBTERM_TOKEN gate.
|
||||
val foreign: (HttpRequest) -> HttpResponse = {
|
||||
HttpResponse(204, ByteArray(0), mapOf("set-cookie" to "proxy_session=abc123; Path=/"))
|
||||
}
|
||||
|
||||
assertEquals(TokenSubmission.AuthDisabled, gateway(RecordingTransport(foreign)).submit(endpoint(), TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -10,9 +10,11 @@ import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -32,10 +34,15 @@ class HostRemoverTest {
|
||||
|
||||
private companion object {
|
||||
const val BASE = "http://laptop.local:3000"
|
||||
|
||||
/** A real shell credential shape (`AccessTokenRule`: 16–512 of `[A-Za-z0-9._~+/=-]`). */
|
||||
const val ACCESS_TOKEN = "0123456789abcdefTOKEN"
|
||||
val SESSION_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
val SESSION_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666")
|
||||
|
||||
fun host(id: String = "h1"): Host = Host.create(id = id, name = "laptop", baseUrl = BASE)!!
|
||||
|
||||
fun endpoint(baseUrl: String = BASE): HostEndpoint = HostEndpoint.fromBaseUrl(baseUrl)!!
|
||||
}
|
||||
|
||||
private class RecordingHostStore(initial: List<Host>) : HostStore {
|
||||
@@ -71,7 +78,9 @@ class HostRemoverTest {
|
||||
): Triple<HostRemover, RecordingPush, Quad> {
|
||||
val lastSession = InMemoryLastSessionStore()
|
||||
val cookies = InMemoryAuthCookieStore()
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val watermarks = InMemorySessionWatermarkStore()
|
||||
tokens.put(endpoint(), ACCESS_TOKEN)
|
||||
lastSession.setLastSessionId(SESSION_1.toString(), "h1")
|
||||
cookies.upsert(
|
||||
AuthCookieRecord(
|
||||
@@ -91,19 +100,21 @@ class HostRemoverTest {
|
||||
hostStore = hosts,
|
||||
lastSessionStore = lastSession,
|
||||
authCookieStore = cookies,
|
||||
accessTokenStore = tokens,
|
||||
watermarkStore = watermarks,
|
||||
pushUnregister = push,
|
||||
currentPushToken = { token },
|
||||
clearLiveCookies = { key -> cookieJarClears += key },
|
||||
log = { }, // android.util.Log is not mocked under JVM unit tests
|
||||
)
|
||||
return Triple(remover, push, Quad(hosts, lastSession, cookies, watermarks))
|
||||
return Triple(remover, push, Quad(hosts, lastSession, cookies, tokens, watermarks))
|
||||
}
|
||||
|
||||
private data class Quad(
|
||||
val hosts: RecordingHostStore,
|
||||
val lastSession: InMemoryLastSessionStore,
|
||||
val cookies: InMemoryAuthCookieStore,
|
||||
val tokens: InMemoryAccessTokenStore,
|
||||
val watermarks: InMemorySessionWatermarkStore,
|
||||
)
|
||||
|
||||
@@ -122,6 +133,41 @@ class HostRemoverTest {
|
||||
assertTrue(stores.watermarks.loadAll().isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* F1 · the merge composed a SECOND durable home for the same shell credential
|
||||
* ([wang.yaojia.webterm.hostregistry.AccessTokenStore], Tink/AndroidKeystore) and left the un-pair
|
||||
* path pointed at only the first one. The token IS the cookie's value (`src/http/auth.ts` builds
|
||||
* `webterm_auth=<token>`), so keeping it is keeping a full-shell credential for a host the user
|
||||
* asked us to forget — and it is LIVE, because `AccessTokenSource.tokenFor` keys off the endpoint's
|
||||
* origin, not the paired-host record: re-adding the same URL with the token field left blank would
|
||||
* silently re-authenticate. Mirrors iOS `HostRemovalTests.removalLeavesNoTokenBehind`.
|
||||
*/
|
||||
@Test
|
||||
fun `un-pairing leaves no access token behind`() = runTest {
|
||||
val (remover, _, stores) = fixture()
|
||||
assertEquals(ACCESS_TOKEN, stores.tokens.tokenFor(endpoint())) // precondition: it really is there
|
||||
|
||||
remover.removeHost("h1", listOf(SESSION_1.toString()))
|
||||
|
||||
assertNull(
|
||||
stores.tokens.tokenFor(endpoint()),
|
||||
"the access token is the cookie's own value — un-pairing must not keep a shell credential",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `un-pairing one host leaves another host's token alone`() = runTest {
|
||||
val other = Host.create(id = "h2", name = "other", baseUrl = "http://desk.local:3000")!!
|
||||
val hosts = RecordingHostStore(listOf(host(), other))
|
||||
val (remover, _, stores) = fixture(hosts = hosts)
|
||||
stores.tokens.put(other.endpoint, ACCESS_TOKEN)
|
||||
|
||||
remover.removeHost("h1", listOf(SESSION_1.toString()))
|
||||
|
||||
assertNull(stores.tokens.tokenFor(endpoint()))
|
||||
assertEquals(ACCESS_TOKEN, stores.tokens.tokenFor(other.endpoint), "token removal is host-scoped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the remote DELETE runs while the host is still paired`() = runTest {
|
||||
val hosts = RecordingHostStore(listOf(host()))
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.pairing.PairingError
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
||||
import wang.yaojia.webterm.viewmodels.PairingProber
|
||||
import wang.yaojia.webterm.viewmodels.PairingUiState
|
||||
@@ -78,10 +79,16 @@ class PairingTokenFlowTest {
|
||||
prober: PairingProber,
|
||||
hasCert: () -> Boolean = { false },
|
||||
store: InMemoryHostStore = InMemoryHostStore(),
|
||||
tokenStore: InMemoryAccessTokenStore = InMemoryAccessTokenStore(),
|
||||
) = PairingViewModel(
|
||||
hostStore = store,
|
||||
prober = prober,
|
||||
hasDeviceCert = { hasCert() },
|
||||
// This file drives the DISCOVERED half of the gate (prompt → submit), so the token never comes
|
||||
// in through `confirm(accessToken = …)` and `POST /auth` is the prober's `submitAccessToken`,
|
||||
// never this seam. An accepted submit is still KEPT in the store, hence a real one here.
|
||||
tokenStore = tokenStore,
|
||||
authProber = { _, _ -> error("the typed-token POST /auth path is PairingTokenViewModelTest's") },
|
||||
newId = { "fixed-id" },
|
||||
)
|
||||
|
||||
@@ -158,6 +165,52 @@ class PairingTokenFlowTest {
|
||||
assertEquals(LAN, store.loadAll().single().endpoint.baseUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* F4 · the frozen §1.1 rule, on the reactive (prompt) path. A **204 without `Set-Cookie`** means the
|
||||
* host has no auth configured at all: the submitted token must NOT reach durable storage and the
|
||||
* client must NOT consider itself authenticated. Pairing still continues — an open host pairs exactly
|
||||
* as it did before the feature existed (same as `AuthProbeResult.AuthDisabled` on the typed path).
|
||||
*/
|
||||
@Test
|
||||
fun `a host with auth disabled pairs without ever persisting the submitted token`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
prober.submission = { prober.gated = false; TokenSubmission.AuthDisabled }
|
||||
val tokenStore = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, tokenStore = tokenStore)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
vm.submitAccessToken(TOKEN)
|
||||
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
assertEquals(
|
||||
null,
|
||||
tokenStore.tokenFor(endpoint(LAN)),
|
||||
"204 without Set-Cookie = auth disabled — a secret must never be persisted for it",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an accepted token IS persisted, so the WS upgrade can authenticate`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
prober.submission = { prober.gated = false; TokenSubmission.Accepted }
|
||||
val tokenStore = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, tokenStore = tokenStore)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
vm.submitAccessToken(TOKEN)
|
||||
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
assertEquals(TOKEN, tokenStore.tokenFor(endpoint(LAN)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wrong token re-arms the prompt with an invalid-token error and does not re-probe`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
|
||||
@@ -45,6 +45,10 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
// Tink AEAD encrypts the per-host access-token blob at rest under an AndroidKeystore-wrapped
|
||||
// master key (B5 / ios-completion §1.1). A SEPARATE keyset from :client-tls-android's cert store —
|
||||
// different secrets, different lifecycles, and no module edge to the TLS half.
|
||||
implementation(libs.tink.android)
|
||||
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
|
||||
@@ -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.TransportConnection
|
||||
import wang.yaojia.webterm.wire.Tunables
|
||||
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
|
||||
|
||||
/**
|
||||
* The session lifecycle state machine (A14, plan §5 / §6.6). The Android analogue of the iOS
|
||||
@@ -51,6 +52,9 @@ import wang.yaojia.webterm.wire.Tunables
|
||||
* frame would be dropped rather than emitted onto a newer generation.
|
||||
* - **oversized replay is terminal:** a frame past [maxFrameBytes] is the non-retryable
|
||||
* [FailureReason.REPLAY_TOO_LARGE] — distinct from a retryable disconnect (never fed to backoff).
|
||||
* - **a 401 upgrade is terminal:** an [UnauthorizedUpgradeException] out of the dial (missing/invalid
|
||||
* access token, or a foreign Origin) is the non-retryable [FailureReason.UNAUTHORIZED] — it is
|
||||
* permanent until the user fixes configuration, so it NEVER enters the back-off ladder.
|
||||
*/
|
||||
public class SessionEngine(
|
||||
private val transport: TermTransport,
|
||||
@@ -76,6 +80,19 @@ public class SessionEngine(
|
||||
/** One live connection plus its optional ping capability (null = plain [TermTransport]). */
|
||||
private class OpenConn(val connection: TransportConnection, val pinger: ConnectionPinger?)
|
||||
|
||||
/**
|
||||
* Outcome of one dial. A dial failure is retryable ([Retryable]) EXCEPT an upgrade 401
|
||||
* ([Unauthorized]), which is permanent until the user fixes the access token / Origin allow-list —
|
||||
* so it must never be fed to the back-off ladder (see [FailureReason.UNAUTHORIZED]).
|
||||
*/
|
||||
private sealed interface DialResult {
|
||||
class Open(val conn: OpenConn) : DialResult
|
||||
|
||||
data object Retryable : DialResult
|
||||
|
||||
data object Unauthorized : DialResult
|
||||
}
|
||||
|
||||
private val eventsChannel = Channel<SessionEvent>(Channel.UNLIMITED)
|
||||
|
||||
/** Engine→UI event stream (single consumer; the App's EventBus fans out per R10). */
|
||||
@@ -157,7 +174,14 @@ public class SessionEngine(
|
||||
}
|
||||
|
||||
private suspend fun runOneConnection(gen: Int): EndCause {
|
||||
val open = dialOrNull() ?: return EndCause.Disconnected
|
||||
val open = when (val dial = dial()) {
|
||||
DialResult.Retryable -> return EndCause.Disconnected
|
||||
DialResult.Unauthorized -> {
|
||||
emit(Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED)))
|
||||
return EndCause.Terminal // no back-off: a 401 is permanent until reconfigured
|
||||
}
|
||||
is DialResult.Open -> dial.conn
|
||||
}
|
||||
if (closed) return abandon(open) // close() landed during dial → detach, never publish
|
||||
if (!attachFirst(open)) return EndCause.Disconnected
|
||||
if (closed) return abandon(open) // close() landed during attach → detach, never publish
|
||||
@@ -184,20 +208,37 @@ public class SessionEngine(
|
||||
return EndCause.Terminal
|
||||
}
|
||||
|
||||
private suspend fun dialOrNull(): OpenConn? =
|
||||
private suspend fun dial(): DialResult =
|
||||
try {
|
||||
if (transport is PingableTermTransport) {
|
||||
val pingable = transport.connectPingable(endpoint)
|
||||
OpenConn(pingable.connection, pingable.pinger)
|
||||
DialResult.Open(OpenConn(pingable.connection, pingable.pinger))
|
||||
} else {
|
||||
OpenConn(transport.connect(endpoint), null)
|
||||
DialResult.Open(OpenConn(transport.connect(endpoint), null))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Throwable) {
|
||||
null // connect-time failure → retryable
|
||||
} catch (error: Throwable) {
|
||||
// A 401 upgrade is the ONE non-retryable dial failure; everything else backs off.
|
||||
if (isUnauthorized(error)) DialResult.Unauthorized else DialResult.Retryable
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff [error] (or a bounded, cycle-guarded walk of its causes) is the typed 401-upgrade
|
||||
* rejection. The cause walk matters because a transport may wrap it in its own `IOException`.
|
||||
*/
|
||||
private fun isUnauthorized(error: Throwable): Boolean {
|
||||
var current: Throwable? = error
|
||||
var depth = 0
|
||||
while (current != null && depth < MAX_CAUSE_DEPTH) {
|
||||
if (current is UnauthorizedUpgradeException) return true
|
||||
if (current === current.cause) return false // defensive: never loop on a self-cause
|
||||
current = current.cause
|
||||
depth++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** attach-first (+ replay [lastDims]) BEFORE publishing the connection. */
|
||||
private suspend fun attachFirst(open: OpenConn): Boolean =
|
||||
try {
|
||||
@@ -412,5 +453,8 @@ public class SessionEngine(
|
||||
public companion object {
|
||||
/** Max [AwayDigest.recent] entries carried in the once-per-reconnect digest. */
|
||||
public const val DEFAULT_DIGEST_LIMIT: Int = 20
|
||||
|
||||
/** Bound on the `cause` walk in [isUnauthorized] (never trust an error graph not to cycle). */
|
||||
private const val MAX_CAUSE_DEPTH: Int = 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +68,14 @@ public enum class FailureReason {
|
||||
* back-off. UI copy: lower the server's SCROLLBACK_BYTES or raise the client cap.
|
||||
*/
|
||||
REPLAY_TOO_LARGE,
|
||||
|
||||
/**
|
||||
* The server answered the WS upgrade with **HTTP 401**
|
||||
* ([wang.yaojia.webterm.wire.UnauthorizedUpgradeException]) — the host's access token
|
||||
* (`WEBTERM_TOKEN`) is missing/wrong, or our `Origin` is not on its allow-list. Both are
|
||||
* permanent until the user fixes configuration, so — exactly like [REPLAY_TOO_LARGE] — the
|
||||
* engine goes terminal and NEVER enters the back-off ladder (a retry storm against a 401 is
|
||||
* pointless and looks like an attack). UI copy: go fix / re-enter the access token.
|
||||
*/
|
||||
UNAUTHORIZED,
|
||||
}
|
||||
|
||||
@@ -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.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -15,11 +16,29 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
* `BridgeInterceptor` runs for WebSocket calls too.
|
||||
*
|
||||
* ── Isolation (security-load-bearing) ────────────────────────────────────────────────────────────
|
||||
* The cookie is a **shell credential**. Two independent layers keep it on its own host:
|
||||
* The cookie is a **shell credential**. Three independent layers keep it on its own host, and keep
|
||||
* everything else out:
|
||||
* 1. storage is partitioned by [authCookieHostKey] (`scheme://host:port`), so another host's key
|
||||
* simply has no entry to read;
|
||||
* 2. what survives that lookup is still filtered through OkHttp's own `Cookie.matches(url)`, which
|
||||
* applies the RFC domain/path rules and refuses to put a `Secure` cookie on cleartext.
|
||||
* applies the RFC domain/path rules and refuses to put a `Secure` cookie on cleartext;
|
||||
* 3. **only [AuthCookie.NAME] is ever kept or returned** — see below.
|
||||
*
|
||||
* ── Why the name filter is load-bearing, not tidiness (F2) ───────────────────────────────────────
|
||||
* The app carries TWO mechanisms for the same cookie: this jar, and the hand-written
|
||||
* `Cookie: webterm_auth=<t>` that `:api-client` stamps on every REST route and [OkHttpTermTransport]
|
||||
* stamps on the WS upgrade (the FROZEN §1.1 contract). OkHttp's `BridgeInterceptor` (4.12.0) reconciles
|
||||
* them with:
|
||||
* ```
|
||||
* val cookies = cookieJar.loadForRequest(userRequest.url)
|
||||
* if (cookies.isNotEmpty()) requestBuilder.header("Cookie", cookieHeader(cookies))
|
||||
* ```
|
||||
* — the guard is "the jar returned ANYTHING", not "the jar has a `webterm_auth` for this host", and
|
||||
* `header(...)` REPLACES the whole header. So storing a foreign cookie meant any TLS-terminating
|
||||
* intermediary that sets one (Cloudflare Access, a captive portal, a future auth edge) silently
|
||||
* stripped the token off every REST call **and off the WS upgrade**, where a 401 is terminal. Keeping
|
||||
* the jar to exactly one name makes that impossible instead of merely unlikely, and stops a chatty
|
||||
* host from evicting the credential through [MAX_COOKIES_PER_HOST].
|
||||
*
|
||||
* Expiry is enforced on BOTH sides of the boundary: expired entries are pruned when read (and the
|
||||
* pruning is pushed to storage) and dropped when restored, so a stale credential is never
|
||||
@@ -48,28 +67,32 @@ public class AuthCookieJar(
|
||||
val hostKey = authCookieHostKey(url)
|
||||
val stored = byHostKey[hostKey] ?: return emptyList()
|
||||
val live = pruneExpired(hostKey, stored)
|
||||
// Second layer: RFC domain/path match + the Secure-over-cleartext refusal.
|
||||
return live.filter { it.matches(url) }
|
||||
// Second layer: RFC domain/path match + the Secure-over-cleartext refusal. Third layer: our
|
||||
// name only — a non-empty return here REPLACES the hand-written `Cookie` header (see the
|
||||
// BridgeInterceptor note above), so nothing foreign may ever be what it replaces it with.
|
||||
return live.filter { it.isOurs() && it.matches(url) }
|
||||
}
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
if (cookies.isEmpty()) return
|
||||
val ours = cookies.filter { it.isOurs() }
|
||||
if (ours.isEmpty()) return
|
||||
val hostKey = authCookieHostKey(url)
|
||||
val now = clock()
|
||||
synchronized(writeLock) {
|
||||
store(hostKey, byHostKey[hostKey].orEmpty().merged(cookies, now))
|
||||
store(hostKey, byHostKey[hostKey].orEmpty().merged(ours, now))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate one host's cookies at cold start (from `:host-registry`). Already-expired snapshots
|
||||
* and structurally invalid ones are dropped. This is a hydration path, not a change, so the
|
||||
* Rehydrate one host's cookies at cold start (from `:host-registry`). Already-expired snapshots,
|
||||
* structurally invalid ones and anything not named [AuthCookie.NAME] are dropped — records at rest
|
||||
* are untrusted, and may predate the name filter. This is a hydration path, not a change, so the
|
||||
* [AuthCookiePersister] is deliberately NOT notified.
|
||||
*/
|
||||
public fun restore(hostKey: String, cookies: List<AuthCookieSnapshot>) {
|
||||
val now = clock()
|
||||
val live = cookies
|
||||
.filter { it.expiresAtEpochMillis > now }
|
||||
.filter { it.name == AuthCookie.NAME && it.expiresAtEpochMillis > now }
|
||||
.mapNotNull { it.toCookieOrNull() }
|
||||
.capped()
|
||||
synchronized(writeLock) {
|
||||
@@ -138,6 +161,13 @@ private fun List<Cookie>.upserting(cookie: Cookie): List<Cookie> =
|
||||
private fun Cookie.sameIdentity(other: Cookie): Boolean =
|
||||
name == other.name && domain == other.domain && path == other.path
|
||||
|
||||
/**
|
||||
* The name filter (F2). [AuthCookie.NAME] is the single point of derivation in `:wire-protocol` — the
|
||||
* same constant the hand-written header is built from — so the two mechanisms can never drift apart on
|
||||
* what "our cookie" means.
|
||||
*/
|
||||
private fun Cookie.isOurs(): Boolean = name == AuthCookie.NAME
|
||||
|
||||
private fun Cookie.toSnapshot(): AuthCookieSnapshot =
|
||||
AuthCookieSnapshot(
|
||||
name = name,
|
||||
|
||||
@@ -2,18 +2,21 @@ package wang.yaojia.webterm.transport
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
|
||||
/**
|
||||
* The name of the server's shared-access-token cookie (`src/http/auth.ts` `AUTH_COOKIE_NAME`).
|
||||
* Exposed as a constant so nothing has to hard-code the string; the jar itself is name-agnostic
|
||||
* (it stores whatever the paired host sets, scoped to that host).
|
||||
*
|
||||
* An ALIAS of [AuthCookie.NAME], never a second literal: `:wire-protocol` owns the one derivation the
|
||||
* hand-written `Cookie` header is also built from, and [AuthCookieJar] now keeps/returns this name and
|
||||
* nothing else (F2), so a drift between the two spellings would be a security bug.
|
||||
*/
|
||||
public const val AUTH_COOKIE_NAME: String = "webterm_auth"
|
||||
public const val AUTH_COOKIE_NAME: String = AuthCookie.NAME
|
||||
|
||||
/**
|
||||
* Upper bound on how many cookies are retained per host. The paired server sets exactly one
|
||||
* ([AUTH_COOKIE_NAME]); the cap is a boundary guard so a broken or hostile host cannot turn the
|
||||
* client into an unbounded credential sink. Oldest entries are evicted first.
|
||||
* ([AUTH_COOKIE_NAME]) and the jar keeps no other name, so this is a residual boundary guard against a
|
||||
* host that varies domain/path to mint many entries under that one name. Oldest evicted first.
|
||||
*/
|
||||
internal const val MAX_COOKIES_PER_HOST: Int = 16
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package wang.yaojia.webterm.transport
|
||||
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.OkHttpClient
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
@@ -107,13 +108,25 @@ public class OkHttpTransports private constructor(
|
||||
public val client: OkHttpClient,
|
||||
) {
|
||||
public companion object {
|
||||
/**
|
||||
* @param identityProvider optional mTLS material (see [OkHttpClientFactory.create]).
|
||||
* @param cookieJar the shared session-cookie store (see [OkHttpClientFactory.create]). A
|
||||
* token-gated host's `Set-Cookie: webterm_auth=…` lands here and is replayed on REST calls
|
||||
* AND on the WS upgrade, because both transports share this one client.
|
||||
* @param tokens the access-token seam for the WS upgrade's `Cookie` header (ios-completion §1.1).
|
||||
* REST requests get their cookie from `:api-client`'s single stamping point instead, so this
|
||||
* only wires the WS half. Orthogonal to [cookieJar]: the hand-written header covers a token
|
||||
* the app already holds (Keystore-restored, never `Set-Cookie`-observed), the jar covers the
|
||||
* cookie a live pairing handed back.
|
||||
*/
|
||||
public fun create(
|
||||
identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE,
|
||||
cookieJar: CookieJar = CookieJar.NO_COOKIES,
|
||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
): OkHttpTransports {
|
||||
val client = OkHttpClientFactory.create(identityProvider, cookieJar)
|
||||
return OkHttpTransports(
|
||||
term = OkHttpTermTransport(client),
|
||||
term = OkHttpTermTransport(client, tokens),
|
||||
http = OkHttpHttpTransport(client),
|
||||
client = client,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ package wang.yaojia.webterm.transport
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.PingableConnection
|
||||
import wang.yaojia.webterm.wire.PingableTermTransport
|
||||
@@ -12,6 +14,9 @@ import java.util.concurrent.TimeUnit
|
||||
/** The `Origin` header name — THE CSWSH defence; stamped byte-equal from [HostEndpoint.originHeader]. */
|
||||
internal const val HEADER_ORIGIN: String = "Origin"
|
||||
|
||||
/** HTTP status the server answers an upgrade with when it rejects Origin OR the access-token cookie. */
|
||||
internal const val HTTP_UNAUTHORIZED: Int = 401
|
||||
|
||||
/**
|
||||
* The only concrete WS transport (A7): implements [PingableTermTransport] (and thus `TermTransport`)
|
||||
* over OkHttp. `SessionEngine` (A14) cannot tell it apart from the `FakeTermTransport` double.
|
||||
@@ -29,12 +34,25 @@ internal const val HEADER_ORIGIN: String = "Origin"
|
||||
* is cancellation-safe: if the caller's coroutine is cancelled (or the handshake fails) while it is
|
||||
* in flight, the just-created WebSocket is torn down before rethrowing, so no socket is leaked.
|
||||
*
|
||||
* ### Access token (ios-completion §1.1)
|
||||
* When [tokens] has a token for the host, the upgrade ALSO carries a hand-written
|
||||
* `Cookie: webterm_auth=<t>` — no OkHttp `CookieJar`, no `Set-Cookie` parsing (a jar's behaviour on a
|
||||
* WS upgrade is stack-specific and hard to test; the hand-written header is the same pattern as
|
||||
* `Origin` and is asserted byte-equal by a MockWebServer test). The two headers are orthogonal: the
|
||||
* server checks Origin first, then the cookie. No token ⇒ no header at all.
|
||||
*
|
||||
* An upgrade answered **401** (rejected Origin OR rejected/missing token — the server writes the same
|
||||
* bare 401 for both) is surfaced as the typed `UnauthorizedUpgradeException`, which `SessionEngine`
|
||||
* treats as TERMINAL. The original error is kept as its `cause` so `PairingError.classify`'s cause-walk
|
||||
* is unaffected.
|
||||
*
|
||||
* Inbound frames flow up UNMODIFIED — there is deliberately NO transport-level frame-size cap here.
|
||||
* `SessionEngine` (A14) self-measures each inbound frame's UTF-8 size and is the single authoritative
|
||||
* classifier of an oversized ring-buffer replay (the non-retryable `REPLAY_TOO_LARGE` failure).
|
||||
*/
|
||||
public class OkHttpTermTransport(
|
||||
sharedClient: OkHttpClient,
|
||||
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
) : PingableTermTransport {
|
||||
|
||||
private val wsClient: OkHttpClient = sharedClient.newBuilder()
|
||||
@@ -51,10 +69,14 @@ public class OkHttpTermTransport(
|
||||
}
|
||||
|
||||
private suspend fun openConnection(endpoint: HostEndpoint): OkHttpWebSocketConnection {
|
||||
val request = Request.Builder()
|
||||
val builder = Request.Builder()
|
||||
.url(endpoint.wsUrl) // OkHttp maps ws(s):// → http(s):// internally
|
||||
.header(HEADER_ORIGIN, endpoint.originHeader)
|
||||
.build()
|
||||
// Additive and orthogonal to Origin; absent entirely when the host has no token.
|
||||
tokens.tokenFor(endpoint)?.let { token ->
|
||||
builder.header(AuthCookie.HEADER_NAME, AuthCookie.headerValue(token))
|
||||
}
|
||||
val request = builder.build()
|
||||
val connection = OkHttpWebSocketConnection(wsClient, request)
|
||||
try {
|
||||
connection.awaitOpen()
|
||||
|
||||
@@ -15,6 +15,7 @@ import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import wang.yaojia.webterm.wire.ConnectionPinger
|
||||
import wang.yaojia.webterm.wire.TransportConnection
|
||||
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
|
||||
import java.io.IOException
|
||||
|
||||
/** RFC 6455 normal-closure status code, used for a client-initiated detach. */
|
||||
@@ -145,9 +146,15 @@ internal class OkHttpWebSocketConnection(
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
finish(t)
|
||||
// If we never opened, unblock connect() with the verbatim cause; no-op once opened.
|
||||
opened.completeExceptionally(t)
|
||||
// A 401 handshake answer is the server's ONE upgrade rejection (foreign Origin OR a
|
||||
// missing/invalid access-token cookie) and is permanent until reconfigured, so it is TYPED
|
||||
// for SessionEngine to end terminally on. `t` is kept as the cause, so the verbatim
|
||||
// transport error is still available to PairingError.classify's cause-walk. Every other
|
||||
// failure propagates untouched (frozen "rethrow verbatim" contract).
|
||||
val error = if (response?.code == HTTP_UNAUTHORIZED) UnauthorizedUpgradeException(t) else t
|
||||
finish(error)
|
||||
// If we never opened, unblock connect() with the cause; no-op once opened.
|
||||
opened.completeExceptionally(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,9 +271,13 @@ class AuthCookieJarTest {
|
||||
|
||||
@Test
|
||||
fun capsTheNumberOfCookiesStoredPerHost() {
|
||||
// Arrange: a hostile/broken server tries to make the client an unbounded cookie sink.
|
||||
// Arrange: a hostile/broken server tries to make the client an unbounded cookie sink. Since the
|
||||
// jar keeps only AUTH_COOKIE_NAME (F2), the sole remaining way to mint many entries is to vary
|
||||
// (domain, path) under that one name — so that is what the cap has to survive.
|
||||
val response = MockResponse().setResponseCode(200).setBody("{}")
|
||||
repeat(MAX_COOKIES_PER_HOST * 2) { i -> response.addHeader("Set-Cookie", "c$i=v$i; Path=/; Max-Age=$MAX_AGE_SEC") }
|
||||
repeat(MAX_COOKIES_PER_HOST * 2) { i ->
|
||||
response.addHeader("Set-Cookie", "$AUTH_COOKIE_NAME=v$i; Path=/p$i; Max-Age=$MAX_AGE_SEC")
|
||||
}
|
||||
server.enqueue(response)
|
||||
|
||||
// Act
|
||||
@@ -283,6 +287,25 @@ class AuthCookieJarTest {
|
||||
assertEquals(MAX_COOKIES_PER_HOST, persister.latestFor(hostKey())?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun neverStoresACookieThatIsNotOurs() {
|
||||
// Arrange: a TLS-terminating intermediary (Cloudflare Access, a captive portal) sets its own.
|
||||
val response = MockResponse().setResponseCode(200).setBody("{}")
|
||||
repeat(MAX_COOKIES_PER_HOST * 2) { i -> response.addHeader("Set-Cookie", "c$i=v$i; Path=/; Max-Age=$MAX_AGE_SEC") }
|
||||
server.enqueue(response)
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody("[]"))
|
||||
|
||||
// Act
|
||||
server.get("/")
|
||||
server.get("/live-sessions")
|
||||
|
||||
// Assert: nothing was kept, so nothing rides the next request — a non-empty jar would REPLACE
|
||||
// the hand-written `Cookie: webterm_auth=…` header outright (OkHttp BridgeInterceptor).
|
||||
assertNull(persister.latestFor(hostKey()))
|
||||
server.takeRequest()
|
||||
assertNull(server.takeRequest().getHeader("Cookie"))
|
||||
}
|
||||
|
||||
// ── 4. the credential never reaches a log/toString path ──────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,14 @@ Client-side frame max message size raised to **16 MiB** (guard with a client-sid
|
||||
|
||||
`GET /sessions`, `POST /hook`, `POST /hook/permission`, `POST /hook/status`, `POST /open-in-editor`, `POST /projects/worktree`, `DELETE /live-sessions` (kill-all). Push web-only: `GET /push/vapid-key`, `POST/DELETE /push/subscribe` (Android uses FCM, not VAPID).
|
||||
|
||||
> **Amendment (2026-07-30).** This list is out of date on two entries — verified on disk, not assumed:
|
||||
> - **`POST /projects/worktree`** is consumed by **both** clients now (`android/app/.../viewmodels/WorktreeViewModel.kt`, `ios/App/WebTerm/Screens/WorktreeSheet.swift`). Remove it from this list.
|
||||
> - **`GET /sessions`** (`claude --resume` history) is consumed by **iOS only** (`ios/Packages/APIClient/Sources/APIClient/History.swift:71`); Android has zero references. So the section's premise — "match iOS" — now *argues for* building it on Android, rather than against. That is an open parity gap, not a prohibition.
|
||||
>
|
||||
> Both changes landed in the `ios-completion` wave — see [`plans/ios-completion.md`](./plans/ios-completion.md).
|
||||
>
|
||||
> **Planned scope expansion, not yet built:** the five `/orphan-sessions*` routes (feature **A**, shipped server-side 2026-07-30) are a *deliberate* future addition for both clients — plan in [`plans/w7-orphan-session-clients.md`](./plans/w7-orphan-session-clients.md). They are **not** listed in §4.2/§4.3 yet, on purpose: this doc must never claim a capability that does not exist. Move them there when the work lands, not before.
|
||||
|
||||
### 4.5 The ONE additive server change Android requires (P1 push)
|
||||
|
||||
APNs routes (`POST/DELETE /push/apns-token`) do not transfer to FCM. Add, mirroring `src/push/apns.ts` behind the existing `combineNotifyServices(...)` seam (zero new event paths — `notify(session, cls, token?)` already carries needs-input/done):
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
> 落地方案文档。目标:给 web-terminal 做一个 **iPhone 原生 App**,把 vibe-coding 的"走开—被叫回—两次手势处理完"闭环装进口袋。
|
||||
> 拓扑/框架选型:**Phased-Native —— SwiftUI + SwiftTerm,4 个纯 SwiftPM 包 + 薄 App 胶水;前台会话单条活 WS,其余 HTTP 轮询;服务器零改动(P0 零触点;P1 仅声明的附加触点,见 §0.3)**。
|
||||
> 状态:**规划中(2026-07-04,未开工)**。
|
||||
> 状态:**已交付并合入 `develop`**(P0 + P1 + P2 全部落地;`feat/ios-client` 早已 merge 进 `develop`,
|
||||
> `git merge-base --is-ancestor feat/ios-client develop` 为真,勿再按"分支未合"叙述)。
|
||||
> 逐任务状态见 **§7 开头的状态总表**(2026-07-30 由 ios-completion 收尾波按源码核对重建);
|
||||
> 真机 / 付费 Apple 账号相关项一律 **DEFERRED** 并在表中标明。
|
||||
> 本文是「怎么做」的蓝图,配合 [TECH_DOC.md](./TECH_DOC.md)(why)+ [ARCHITECTURE.md](./ARCHITECTURE.md)(how);桌面版先例见 [DESKTOP_PLAN.md](./DESKTOP_PLAN.md);远程访问/中继演进见 [PLAN_RELAY_INDEX.md](./PLAN_RELAY_INDEX.md)。
|
||||
> 完成情况记录在 [PROGRESS_LOG.md](./PROGRESS_LOG.md)。工作流约束见 [CLAUDE.md](../CLAUDE.md)(查 PLAN → 做子任务(TDD) → 验证 → 更新 LOG)。
|
||||
> **G1 日志铁律**:`PROGRESS_LOG.md` 由 **orchestrator 独写**,不在任何任务的 `Owns:` 里;被派的 subagent **不写 LOG**,而是在最终返回消息末尾附上**可直接粘贴的日志条目**(状态 / 改动文件与函数 / 验证命令+结果 / 决策与偏差 / 阻塞 / 下一步),由主会话统一追加。
|
||||
@@ -62,6 +65,11 @@
|
||||
|
||||
除此之外**服务器 byte-for-byte 零改动**。若实施中发现 server 侧缺陷,修复归属对应模块(route/session 文件的 owner),按 CLAUDE.md 记 `PROGRESS_LOG.md`——iOS 任务不越界改 server。
|
||||
|
||||
> **补记(ios-completion 收尾波)**:客户端另外开始消费**早已存在**的两处服务器能力,**均无新触点**——
|
||||
> ① 访问令牌门(`POST /auth` + `webterm_auth` cookie,w5-access-token,真源 `src/http/auth.ts`);
|
||||
> ② 项目 git 面板/worktree 的 13 个 `/projects*` 路由(w4/w6,真源 `src/http/projects.ts` + `src/server.ts:507-1320`)。
|
||||
> 二者都是"服务器先有、iOS 后接",故 §0.3 的"P0 零触点 / P1 两触点"结论不变。
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构 / 进程模型
|
||||
@@ -156,6 +164,11 @@ web-terminal/
|
||||
└── LiveServerTests.swift
|
||||
```
|
||||
|
||||
> **树的现状差异(2026-07-30 核对,不改本计划的设计意图)**:`Packages/` 下现有 **6** 个包——本文的 4 个 gated 包
|
||||
> + `TestSupport` + **`ClientTLS`**(设备客户端证书/mTLS,由 [PLAN_NATIVE_TUNNEL.md](./PLAN_NATIVE_TUNNEL.md) 引入,
|
||||
> 不属于本计划)。`App/WebTerm/` 也比本文的示意树多出 `DesignSystem/`、`Wiring/`、`Push/` 三个目录
|
||||
> (分别来自 UX 打磨、iPad 适配/接线、P1 推送)。依赖方向仍严格单向向下,`WireProtocol` 仍是唯一冻结契约。
|
||||
|
||||
**构建管线**(本地与 CI 同路径):
|
||||
1. `brew install xcodegen && cd ios && xcodegen generate` → `WebTerm.xcodeproj`(不入库)。
|
||||
2. 各包独立测试:`swift test --package-path ios/Packages/<Pkg>`(纯 mac 侧,无模拟器,秒级)。
|
||||
@@ -420,13 +433,19 @@ NSCameraUsageDescription: "扫描 web 终端的配对二维码" # P0 必需:T-
|
||||
```
|
||||
|
||||
> P2 前置:T-iOS-31(语音 PTT)开工前需另加 `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`(属于该任务的前置,不属于 P0)。
|
||||
> **已落地**(ios-completion 收尾波 A1,`project.yml` 是单一 owner,故一次性加齐):两条 usage description +
|
||||
> `UIBackgroundModes: [remote-notification]`(背景**模式**不是 capability,免费 team 不受限;受限的是
|
||||
> `aps-environment` **entitlement**,故它走 `WEBTERM_PUSH_ENTITLEMENTS` 开关)。**产物层复核仍归 T-iOS-19,尚未做。**
|
||||
|
||||
- MagicDNS 名(`*.ts.net`)是 FQDN → ATS 全额适用 → 用 100.x IP、加例外域,或 `tailscale serve`(https/wss,最优)。
|
||||
- Local Network 弹窗被拒 → 连接报 POSIX "Network is down";映射到 `PairingError.localNetworkDenied` 并引导去 设置→隐私→本地网络(iOS 18 有需重启的已知 bug,话术里写明)。
|
||||
|
||||
### 5.3 凭据与本地存储
|
||||
|
||||
- **Keychain**:host 列表(含未来 authMaterial 占位)——`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`,不进 iCloud 同步。
|
||||
- **Keychain**:host 列表 **+ 每主机访问令牌**(`WEBTERM_TOKEN`,原"未来 authMaterial 占位"已由 ios-completion
|
||||
收尾波填实)——`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`、**无** `kSecAttrSynchronizable`(不进 iCloud 同步、
|
||||
不随备份离机,`SecItemShim.swift:77-86`)。`AccessToken` 类型在边界校验字符集/长度,`description`/`debugDescription`/
|
||||
`customMirror` 全部脱敏,且**故意不 `Codable`** —— 插值、`dump()`、反射式崩溃上报都拿不到它;令牌绝不进日志、绝不进 URL query。
|
||||
- **UserDefaults**:仅非机密(per-host lastSessionId、UI prefs)。
|
||||
- `/hook/decision` 的 capability `token` **只经 push payload 到达、用后即弃**(服务器侧本就单次有效+过期,src/server.ts:503-525;限频 10/min/IP);App 端绝不落盘。
|
||||
- App 内无任何硬编码 host/密钥;首个 host 必经配对流程。
|
||||
@@ -490,15 +509,112 @@ W5 验收(report-only, 并行)
|
||||
|
||||
## 7. 任务清单
|
||||
|
||||
> 状态图例:`[ ]` TODO · `[~]` 进行中 · `[x]` 完成 · `[!]` 受阻。ID 稳定,永不重编号。
|
||||
> 状态图例:`[ ]` TODO · `[~]` **部分完成**(下一行必写缺口)· `[x]` 完成 · `[!]` 受阻。ID 稳定,永不重编号。
|
||||
> **`[x]` 的判据(本文统一约定)**:代码+测试已交付,且**本环境可机器验证的部分全绿**;需要真机 / 付费 Apple 账号
|
||||
> 才能做的验收项,在该行标 **DEFERRED** 而**不**把任务降级为 `[~]`(否则每条都是"部分",完成度又读不出来)。
|
||||
> `[~]` 只留给**在本环境本可做却没做**的缺口,或本身就是真机走查的任务。
|
||||
> 每个任务自带 TDD(测试与实现同 agent 同文件组)。测试框架:Swift Testing(`@Test`/`#expect`);XCTest 仅 XCUITest。
|
||||
> 覆盖率验证命令(各包):`swift test --package-path ios/Packages/<Pkg> --enable-code-coverage` + `xcrun llvm-cov report`(阈值 80%,见 §9)。
|
||||
> 覆盖率验证命令(各包):`swift test --package-path ios/Packages/<Pkg> --enable-code-coverage` + `xcrun llvm-cov report`(阈值 80%,见 §9);
|
||||
> 实际 CI 用的是修正口径脚本 `ios/IntegrationTests/scripts/coverage-gate.sh <Pkg>`。
|
||||
>
|
||||
> **权威状态在下面这张总表**(2026-07-30 ios-completion 收尾波按**源码**逐项核对重建)。
|
||||
> 各任务标题上的方框与总表一致;**Steps 里的 `[ ]` 是原始规格清单,不是状态**——逐条执行记录在
|
||||
> `PROGRESS_LOG.md` 对应条目里,不在本文重复勾选(此前"任务已交付但满屏 `[ ]`"正是完成度读不出来的原因)。
|
||||
|
||||
### 7.0 状态总表(逐项对源码核对)
|
||||
|
||||
| ID | 状态 | 证据(文件 / 测试)与缺口 |
|
||||
|---|---|---|
|
||||
| T-iOS-1 脚手架 | `[x]` | `ios/project.yml`、`ios/.gitignore`、6 个 `Package.swift`、`.github/workflows/ios.yml` |
|
||||
| T-iOS-2 Day-1 双 spike | `[x]` | 四条自动化断言**已常驻化**:`IntegrationTests/OriginGuardTests.swift`(3) + `ReplayTests.swift`(2,含 ESC/C0 对抗)。Owns 的临时文件 `OriginSpikeTests`/`SpikeTerminalScreen` 已按计划吸收/删除(树中不存在)。**DEFERRED**:真机键盘/IME/选择 smoke |
|
||||
| T-iOS-3 WireProtocol 契约 | `[x]` | `Packages/WireProtocol/Sources` 10 文件 + **59** `@Test` |
|
||||
| T-iOS-4 TestSupport | `[x]` | `FakeTransport`/`FakeClock`/`FakeHTTPTransport` + 3 `@Test` |
|
||||
| T-iOS-5 Reconnect+Ping | `[x]` | `SessionCore/{ReconnectMachine,PingScheduler}.swift` + 对应 Tests |
|
||||
| T-iOS-6 Gate+Digest | `[x]` | `SessionCore/{GateState,AwayDigest}.swift` + 对应 Tests |
|
||||
| T-iOS-7 HostRegistry | `[x]` | 包内 8 源文件(含 `SecItemShim`/`InMemoryHostStore`)+ **73** `@Test`;覆盖率 92.49%(commit `850531f`) |
|
||||
| T-iOS-8 APIClient + 探针 | `[x]` | `PairingProbe`/`Endpoints`/`Models` 等 + 包内共 **125** `@Test`;覆盖率 92.22% |
|
||||
| T-iOS-9 URLSessionTermTransport | `[x]` | `SessionCore/URLSessionTermTransport.swift` + Tests(含 `ScriptedWSServer`) |
|
||||
| T-iOS-10 SessionEngine | `[x]` | `SessionCore/{SessionEngine,SessionEvent}.swift` + `SessionEngineTests`;包共 **108** `@Test`,覆盖率 96.74% |
|
||||
| T-iOS-11 Terminal+KeyBar | `[x]` | `Screens/TerminalScreen.swift`、`Components/{KeyBar,ReconnectBanner}.swift`、`SessionCore/KeyByteMap.swift` + `KeyBarTests`/`TerminalViewModelTests` |
|
||||
| T-iOS-12 Pairing | `[x]` | `Screens/PairingScreen.swift`+`PairingCopy.swift`、`ViewModels/PairingViewModel.swift` + `PairingViewModelTests`(本波再加令牌态,见 §7.1) |
|
||||
| T-iOS-13 SessionList | `[x]` | `Screens/SessionListScreen.swift`、`Components/TelemetryChips.swift`、`ViewModels/SessionListViewModel.swift` + Tests |
|
||||
| T-iOS-14 Gate/Digest UI | `[x]` | `Components/{GateBanner,PlanGateSheet,AwayDigestView}.swift`、`ViewModels/GateViewModel.swift` + `GateViewModelTests` |
|
||||
| T-iOS-15 App 接线+生命周期 | `[x]` | `Wiring/{RootView,AppCoordinator,PrivacyShade,ColdStartPolicy,TerminalContainerView}.swift` + `PrivacyShadeTests`/`ColdStartPolicyTests` |
|
||||
| T-iOS-16 集成 CI | `[x]` | `IntegrationTests/**` **32** `@Test`(10 基线 + 8 令牌端到端 + 8 令牌策略漂移守卫 + 6 worktree 生命周期端到端)+ `scripts/coverage-gate.sh`(现门 **5** 个包,含 ClientTLS)+ `ios.yml` 六个 job(含 iPad 单测腿/iPad UI 腿/iOS-17 底线腿)+ 新增 `android.yml`。`ServerHarness.locateTsx` 改为逐级向上解析,故 worktree 内无 `node_modules` 也能自举。**未核实**:GH Actions 平台侧的运行结果——本仓库唯一 remote 是自建 Gitea,两个 workflow **从未在任何 runner 上跑过**;本地已逐条复跑其命令 |
|
||||
| T-iOS-17 ntfy 桥验证+文档 | `[x]` | `ios/README.md` ntfy 章节(逐条引 `setup-hooks.mjs` 行号,只读验证,未动用户 hook 配置)。**DEFERRED**:手机端到端 |
|
||||
| T-iOS-18 F 走查(真机) | `[~]` | 机器可执行项已执行并指认测试名(P0 收官条目)。**缺口**:F-iOS 的真机项(QR 扫码/IME/震动/切换器遮罩目检/ntfy 端到端)仍 DEFERRED,手工清单在 LOG |
|
||||
| T-iOS-19 安全核对 | `[~]` | P0 面已逐条核(Origin 单点、G/RO 分界、五段 CIDR + 零 ArbitraryLoads、Keychain 属性)。P2 新增的 `NSMicrophoneUsageDescription`/`NSSpeechRecognitionUsageDescription` **已在产物层复核**(真机构建产物的 Info.plist 实测有这两键)。**剩余缺口**:release ipa 层核对仍未做(免费 team 无分发通道) |
|
||||
| T-iOS-20 server APNs | `[x]` | `src/push/apns.ts` + `test/push-apns.test.ts`(含本地 h2c 假 APNs 的双 e2e;具体测试数以 `npm test` 输出为准,LOG 记 65)。**DEFERRED**:真机端到端需付费账号 |
|
||||
| T-iOS-37 server lastOutputAt | `[x]` | `src/types.ts:315` 可选字段 + `src/session/manager.ts:197` 映射 + 测试 |
|
||||
| T-iOS-38 APIClient P1 契约 | `[x]` | `APIClient/{ApnsToken,Projects,Prefs}.swift` + `ApnsTokenTests`/`ProjectsTests`/`PrefsRoundTripTests` |
|
||||
| T-iOS-21 PushRegistrar + 锁屏 | `[x]` | `Push/{PushRegistrar,NotificationActionHandler}.swift` + `PushRegistrarTests`/`NotificationActionHandlerTests`/`NotificationActionParseTests`。**DEFERRED**:真机锁屏 Allow + Face ID;`aps-environment` 现为 env 开关(免费 team 开不了,见 `ios/README.md`) |
|
||||
| T-iOS-22 DeepLinkRouter | `[x]` | `DeepLinkRouter.swift` + `DeepLinkRouterTests` |
|
||||
| T-iOS-23 多会话切换器 | `[x]` | `SessionCore/{UnreadLedger,TitleSanitizer}.swift` + `Wiring/UnreadWatermarkStore.swift` + `SessionSwitcherTests`/`UnreadLedgerTests`/`TitleSanitizerTests` |
|
||||
| T-iOS-24 Timeline sheet | `[x]` | `Screens/TimelineSheet.swift`、`ViewModels/TimelineViewModel.swift` + `TimelineSheetTests` |
|
||||
| T-iOS-25 Quick-reply | `[x]` | `Components/{QuickReply,QuickReplyStore}.swift` + `QuickReplyTests` |
|
||||
| T-iOS-26 Projects | `[x]` | `Screens/{ProjectsScreen,ProjectDetailScreen}.swift`、`ViewModels/{ProjectsViewModel,ProjectDetailViewModel,ProjectGrouping}.swift` + 3 组 Tests |
|
||||
| T-iOS-27 Diff 查看器 | `[x]` | `Screens/DiffScreen.swift`、`ViewModels/DiffViewModel.swift`、`DiffFetcher.swift` + `DiffViewModelTests`/`DiffFetcherTests` |
|
||||
| T-iOS-28 会话缩略图 | `[x]` | `Components/{SessionThumbnail,SessionThumbnailRenderer}.swift` + `SessionThumbnailTests` |
|
||||
| T-iOS-29 new-in-cwd + 退出清理 | `[x]` | `TerminalScreen`/`TerminalContainerView` 增量 + `NewSessionInCwdTests` |
|
||||
| T-iOS-30 P1 验收+安全复核 | `[x]` | report-only 双 PASS 零 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` 文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿)。
|
||||
|
||||
### 7.2 已排期未开工 —— 同样不新编 `T-iOS-*` 号
|
||||
|
||||
- **`[ ]` 孤儿 tmux 会话(feature A 的客户端半边)** · **Effort: M** · 计划见
|
||||
[`docs/plans/w7-orphan-session-clients.md`](./plans/w7-orphan-session-clients.md)。
|
||||
服务端 2026-07-30 已上线五条路由(`4892fa7`/`f6ef19e`/`d39a0ab`),**iOS 与 Android 均为零接入**。
|
||||
**不是回归**:`OrphanSessionInfo` 被刻意做成独立类型而非 `LiveSessionInfo` 上的 flag,`src/server.ts:600`
|
||||
写明理由就是"两个原生客户端解码 `/live-sessions`,在那里加变体形状会破坏它们"——既有解码器不受影响。
|
||||
**但值得做**:`SessionManager.shutdown` 对 tmux 会话只 `pty.kill()` 断开客户端、不杀 shell
|
||||
(`src/session/manager.ts:410-417`),所以**服务器一重启 / 桌面 app 一退出,所有会话都变成孤儿**。
|
||||
网页 launcher 已能列出并一键重新接管,**手机上却是一个空列表**——正是本项目"走开后从手机上看"的核心用例。
|
||||
开工前须按 §7 的 P2 门槛先扩写 RED 清单(写进该计划文档的 `## RED test list` 节),未扩写不得分派。
|
||||
|
||||
### P0 — 每日可用("口袋里能开终端、能批准")· 合计 ~13 人天
|
||||
|
||||
#### W0 · 基础(串行)
|
||||
|
||||
#### T-iOS-1 · `ios/` 脚手架 + XcodeGen 工程 `[ ]` · ~0.5 pd
|
||||
#### T-iOS-1 · `ios/` 脚手架 + XcodeGen 工程 `[x]` · ~0.5 pd
|
||||
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/project.yml`、`ios/.gitignore`、5 个 `Package.swift` 空壳(4 个 gated 包 + `ios/IntegrationTests`;TestSupport 的 manifest 归 T-iOS-4 的 `**` glob)、`ios/App/WebTerm/WebTermApp.swift`(空窗)、CI workflow 骨架(`.github/workflows/ios.yml`)
|
||||
- **Depends**: 无 · **Parallel-safe**: 无(必须最先)
|
||||
- **Steps**:
|
||||
@@ -508,7 +624,10 @@ W5 验收(report-only, 并行)
|
||||
- **Accept**: `xcodegen generate && xcodebuild … build` 通过;空 App 在模拟器启动
|
||||
- **安全注**: ATS 键从本文 §5.2 逐字誊写,不许"先放开回头再收"。
|
||||
|
||||
#### T-iOS-2 · Day-1 双 spike(评审强制)`[ ]` · ~1 pd
|
||||
#### T-iOS-2 · Day-1 双 spike(评审强制)`[x]` · ~1 pd
|
||||
- **落地现状**:结论"URLSessionWebSocketTask 可发自定义 Origin"已定音(不切 Starscream);四条自动化断言**已常驻化**为
|
||||
`IntegrationTests/OriginGuardTests.swift`(3) + `ReplayTests.swift`(2),故 Owns 里的两个临时文件按计划**已删除/吸收**(树中不存在,不是丢失)。
|
||||
**DEFERRED**:真机 smoke(键盘/first-responder、中文 IME、`inputAccessoryView`、文本选择)——无真机。
|
||||
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/IntegrationTests/OriginSpikeTests.swift`、`ios/App/WebTerm/Screens/SpikeTerminalScreen.swift`(临时文件,W4 删)
|
||||
- **Depends**: T-iOS-1 · **Parallel-safe**: T-iOS-3
|
||||
- **Steps(测试先行——spike 本身就是测试)**:
|
||||
@@ -517,7 +636,7 @@ W5 验收(report-only, 并行)
|
||||
- **Accept**: 4 条自动化断言绿(其中 ③ 的"复现失败"分支用 `withKnownIssue` 记录);真机清单逐项有结论
|
||||
- **安全注**: 这是对 "URLSessionWebSocketTask 可发自定义 Origin"(MED 置信度)的一锤定音;若失败 → `[!] BLOCKED`,orchestrator 决策切 Starscream 备胎,**不得自行引入依赖**。
|
||||
|
||||
#### T-iOS-3 · `WireProtocol` 契约包(冻结,含共享 I/O 边界类型)`[ ]` · ~1.25 pd
|
||||
#### T-iOS-3 · `WireProtocol` 契约包(冻结,含共享 I/O 边界类型)`[x]` · ~1.25 pd
|
||||
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/Packages/WireProtocol/**`(Sources + Tests 全部,含 `HostEndpoint/TermTransport/HTTPTransport/TimelineEvent/Tunables`)
|
||||
- **Depends**: T-iOS-1 · **Parallel-safe**: T-iOS-2
|
||||
- **Steps(测试先行, RED)** — `Tests/WireProtocolTests/CodecRoundtripTests.swift`、`HostEndpointTests.swift`、`ServerVectorTests.swift`:
|
||||
@@ -535,7 +654,7 @@ W5 验收(report-only, 并行)
|
||||
- **Accept**: `swift test --package-path ios/Packages/WireProtocol` 全绿;覆盖率 ≥ 80%
|
||||
- **安全注**: 服务器是不可信输入源——decode 对模糊输入永不 crash(用随机字节 fuzz 一轮)。
|
||||
|
||||
#### T-iOS-4 · TestSupport 测试替身 `[ ]` · ~0.25 pd
|
||||
#### T-iOS-4 · TestSupport 测试替身 `[x]` · ~0.25 pd
|
||||
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/Packages/TestSupport/**`(含本包 `Package.swift`,仅声明对 WireProtocol 的依赖——故 W0 即可编译)
|
||||
- **Depends**: T-iOS-3(接口) · **Parallel-safe**: W1 全部
|
||||
- **Steps**: [ ] `FakeTransport`(实现 WireProtocol 的 `TermTransport`;可手动灌帧 `emit(frame:)`/`emitError`、记录 send/close 调用)[ ] `FakeClock`(手动推进)[ ] `FakeHTTPTransport`(实现 WireProtocol 的 `HTTPTransport`,按 URL 排队响应)[ ] 各带 1 条 smoke 测试(`InMemoryHostStore` 归 T-iOS-7 的 HostRegistry 包——HostStore 协议在 W1 才存在)
|
||||
@@ -543,7 +662,7 @@ W5 验收(report-only, 并行)
|
||||
|
||||
#### W1 · 叶子包(全部并行)
|
||||
|
||||
#### T-iOS-5 · `ReconnectMachine` + `PingScheduler` `[ ]` · ~0.5 pd
|
||||
#### T-iOS-5 · `ReconnectMachine` + `PingScheduler` `[x]` · ~0.5 pd
|
||||
- **Wave/阶段**: W1 / P0 · **Owns**: `SessionCore/Sources/…/{ReconnectMachine,PingScheduler}.swift`、`Tests/…/{ReconnectMachineTests,PingSchedulerTests}.swift`
|
||||
- **Depends**: T-iOS-3、T-iOS-4(FakeClock) · **Parallel-safe**: T-iOS-6/7/8
|
||||
- **Steps(测试先行, RED)**:
|
||||
@@ -555,7 +674,7 @@ W5 验收(report-only, 并行)
|
||||
- **Steps(实现, GREEN)**: [ ] §3.2 签名 [ ] 常量一律读 `Tunables`(WireProtocol,T-iOS-3 所有;值见 §3.2.1——需新增常量 → 回 T-iOS-3 改契约,无魔法数字)
|
||||
- **Accept**: `swift test --package-path ios/Packages/SessionCore --filter Reconnect` 等全绿
|
||||
|
||||
#### T-iOS-6 · `GateState` + `AwayDigest` reducer `[ ]` · ~0.5 pd
|
||||
#### T-iOS-6 · `GateState` + `AwayDigest` reducer `[x]` · ~0.5 pd
|
||||
- **Wave/阶段**: W1 / P0 · **Owns**: `SessionCore/Sources/…/{GateState,AwayDigest}.swift`、对应 Tests
|
||||
- **Depends**: T-iOS-3 · **Parallel-safe**: T-iOS-5/7/8
|
||||
- **Steps(测试先行, RED)**:
|
||||
@@ -567,7 +686,7 @@ W5 验收(report-only, 并行)
|
||||
- [ ] `limit` 截断 recent;空 events → 全零 digest(UI 可据此不渲染)
|
||||
- **Accept**: 对应 filter 全绿;reducer 纯函数、不可变
|
||||
|
||||
#### T-iOS-7 · `HostRegistry` 包 `[ ]` · ~0.5 pd
|
||||
#### T-iOS-7 · `HostRegistry` 包 `[x]` · ~0.5 pd
|
||||
- **Wave/阶段**: W1 / P0 · **Owns**: `ios/Packages/HostRegistry/**`(含 `SecItemShim.swift` 与测试替身 `InMemoryHostStore.swift`——放 Sources,供本包与 App 层 VM 测试 import)
|
||||
- **Depends**: T-iOS-3 · **Parallel-safe**: T-iOS-5/6/8
|
||||
- **Steps(测试先行, RED)** — 用 InMemory 替身测协议契约;Keychain 实现经 `SecItemShim` 缝测:
|
||||
@@ -580,7 +699,7 @@ W5 验收(report-only, 并行)
|
||||
- **Accept**: `swift test --package-path ios/Packages/HostRegistry` 全绿(覆盖率计法见 §9:KeychainHostStore 以 shim 注入计入)
|
||||
- **安全注**: Keychain 属性错一个字 = 凭据可被备份带走;review 时对照 §5.3。
|
||||
|
||||
#### T-iOS-8 · `APIClient` 包 + 配对探针 `[ ]` · ~1 pd
|
||||
#### T-iOS-8 · `APIClient` 包 + 配对探针 `[x]` · ~1 pd
|
||||
- **Wave/阶段**: W1 / P0 · **Owns**: `ios/Packages/APIClient/**`
|
||||
- **Depends**: T-iOS-3、T-iOS-4 · **Parallel-safe**: T-iOS-5/6/7
|
||||
- **Steps(测试先行, RED)** — `Tests/APIClientTests/{RequestBuilderTests,PairingProbeTests,ModelDecodingTests}.swift`:
|
||||
@@ -598,7 +717,7 @@ W5 验收(report-only, 并行)
|
||||
|
||||
#### W2 · 连接核心
|
||||
|
||||
#### T-iOS-9 · `URLSessionTermTransport` `[ ]` · ~1 pd
|
||||
#### T-iOS-9 · `URLSessionTermTransport` `[x]` · ~1 pd
|
||||
- **Wave/阶段**: W2 / P0 · **Owns**: `SessionCore/Sources/…/URLSessionTermTransport.swift`、`Tests/…/URLSessionTermTransportTests.swift`
|
||||
- **Depends**: T-iOS-2(spike 结论)、T-iOS-3 · **Parallel-safe**: T-iOS-10(接口并行)
|
||||
- **Steps(测试先行, RED)** — 对 in-process 本地 WS echo(测试内起 `NWListener` 或复用 IntegrationTests 服务器):
|
||||
@@ -613,7 +732,7 @@ W5 验收(report-only, 并行)
|
||||
- **Accept**: filter 全绿;T-iOS-16 的真服务器测试是它的最终验收
|
||||
- **安全注**: Origin 单点取自 `HostEndpoint.originHeader`,本文件出现字符串拼接 origin = review CRITICAL。
|
||||
|
||||
#### T-iOS-10 · `SessionEngine` actor `[ ]` · ~1.5 pd
|
||||
#### T-iOS-10 · `SessionEngine` actor `[x]` · ~1.5 pd
|
||||
- **Wave/阶段**: W2 / P0 · **Owns**: `SessionCore/Sources/…/{SessionEngine,SessionEvent}.swift`、`Tests/…/SessionEngineTests.swift`
|
||||
- **Depends**: T-iOS-3/4/5/6(接口)、T-iOS-9(集成汇合) · **Parallel-safe**: T-iOS-9
|
||||
- **Steps(测试先行, RED)** — 全部对 FakeTransport:
|
||||
@@ -633,7 +752,7 @@ W5 验收(report-only, 并行)
|
||||
|
||||
#### W3 · UI 胶水(全部并行;只依赖 §3 接口 + 替身)
|
||||
|
||||
#### T-iOS-11 · `TerminalScreen` + `KeyBar` `[ ]` · ~1 pd
|
||||
#### T-iOS-11 · `TerminalScreen` + `KeyBar` `[x]` · ~1 pd
|
||||
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/TerminalScreen.swift`、`Components/{KeyBar,ReconnectBanner}.swift`、`ViewModels/TerminalViewModel.swift`、`SessionCore/Sources/SessionCore/KeyByteMap.swift` + `SessionCore/Tests/…/KeyByteMapTests.swift`(字节表为纯数据,源与测试都在包内——本任务是 W3 唯一持有 SessionCore 文件者:T-iOS-12/13/14 不碰 SessionCore、W1/W2 的 SessionCore owner 已完工,并行安全;纯数据+测试计入 SessionCore 覆盖率门,只帮不损)
|
||||
- **Depends**: T-iOS-10(接口) · **Parallel-safe**: T-iOS-12/13/14
|
||||
- **Steps(测试先行, RED)**:
|
||||
@@ -645,7 +764,7 @@ W5 验收(report-only, 并行)
|
||||
- **Steps(实现, GREEN)**: [ ] `UIViewRepresentable` 包 `SwiftTerm.TerminalView`,delegate `send`→`engine.send(.input)`、`sizeChanged`→`.resize` [ ] KeyBar 为 `inputAccessoryView`,直发 engine(绕开 TerminalView 避免软键盘弹出逻辑干扰)[ ] 硬件键盘 `UIKeyCommand` 同映射 [ ] KeyBar 按钮与 `UIKeyCommand` 的标签→字节解析**一律经 `KeyByteMap` 常量**(单一事实源,镜像 `public/keybar.ts`)[ ] IME:不加自己的 keydown 拦截(SwiftTerm 自管 composition)
|
||||
- **Accept**: 字节表测试全绿;模拟器人工冒烟(真机压在 T-iOS-18)
|
||||
|
||||
#### T-iOS-12 · `PairingScreen`(QR + 手输 + 探针 UI)`[ ]` · ~0.5 pd
|
||||
#### T-iOS-12 · `PairingScreen`(QR + 手输 + 探针 UI)`[x]` · ~0.5 pd
|
||||
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/PairingScreen.swift`、`ViewModels/PairingViewModel.swift`
|
||||
- **Depends**: T-iOS-7/8(接口) · **Parallel-safe**: T-iOS-11/13/14
|
||||
- **Steps(测试先行, RED)** — VM 层(探针逻辑已在 T-iOS-8 测过,这里测状态映射):
|
||||
@@ -657,7 +776,7 @@ W5 验收(report-only, 并行)
|
||||
- **Steps(实现, GREEN)**: [ ] `DataScannerViewController`(真机 only,模拟器隐藏入口;依赖 §5.2 `NSCameraUsageDescription`)读 web UI `qr.ts` 的 origin URL [ ] 手输表单 fallback(用户自己输入的 URL 身份已知,可直连探针;复用确认态亦可)[ ] 多 host 切换入口(列表页 header 用)
|
||||
- **Accept**: VM 测试全绿;模拟器手输路径可配对本机服务器
|
||||
|
||||
#### T-iOS-13 · `SessionListScreen`(合并 chooser + dashboard)`[ ]` · ~1 pd
|
||||
#### T-iOS-13 · `SessionListScreen`(合并 chooser + dashboard)`[x]` · ~1 pd
|
||||
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/SessionListScreen.swift`、`Components/TelemetryChips.swift`、`ViewModels/SessionListViewModel.swift`
|
||||
- **Depends**: T-iOS-8(接口) · **Parallel-safe**: T-iOS-11/12/14
|
||||
- **Steps(测试先行, RED)** — VM 对 FakeHTTPTransport:
|
||||
@@ -670,7 +789,7 @@ W5 验收(report-only, 并行)
|
||||
- **Steps(实现, GREEN)**: [ ] 下拉刷新 [ ] host 切换 header [ ] 空态(无会话/未配对)
|
||||
- **Accept**: VM 测试全绿
|
||||
|
||||
#### T-iOS-14 · `GateBanner` + `PlanGateSheet` + `AwayDigestView` `[ ]` · ~1 pd
|
||||
#### T-iOS-14 · `GateBanner` + `PlanGateSheet` + `AwayDigestView` `[x]` · ~1 pd
|
||||
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Components/{GateBanner,PlanGateSheet,AwayDigestView}.swift`、`ViewModels/GateViewModel.swift`(**独立 VM**,`@MainActor @Observable`,消费 SessionEvent 的 `.gate/.digest`——不是 TerminalViewModel 的扩展,与 T-iOS-11 真并行;接入 TerminalScreen 的 wiring 归 T-iOS-15)+ 对应 Tests
|
||||
- **Depends**: T-iOS-6/10(接口) · **Parallel-safe**: T-iOS-11/12/13
|
||||
- **Steps(测试先行, RED)**:
|
||||
@@ -683,14 +802,14 @@ W5 验收(report-only, 并行)
|
||||
|
||||
#### W4 · 集成(汇合点)
|
||||
|
||||
#### T-iOS-15 · App 接线 + 生命周期 `[ ]` · ~0.5 pd
|
||||
#### T-iOS-15 · App 接线 + 生命周期 `[x]` · ~0.5 pd
|
||||
- **Wave/阶段**: W4 / P0 · **Owns**: `App/WebTerm/WebTermApp.swift`(改)、导航组装、删除 T-iOS-2 的 SpikeTerminalScreen
|
||||
- **Depends**: T-iOS-11–14 全部 · **Parallel-safe**: T-iOS-16/17
|
||||
- **Steps**: [ ] Pairing→List→Terminal 导航 + 依赖注入(真实现 wiring;含 `GateViewModel`(T-iOS-14)接入 TerminalScreen)[ ] `scenePhase == .active` → `engine.notifyForegrounded(dims:)`(重连 + 补发 resize;**这是"换设备夺回全屏"的关键**)[ ] `.background` → 主动 `close()`(干净 detach,不留半死 socket)[ ] **隐私遮罩**:`scenePhase != .active` 时终端覆盖不透明遮罩、`.active` 恢复(**必须用 `!= .active`,不能只判 `.inactive`**——覆盖切换器进入的 .inactive 与快照发生的 .background 两态;iOS 后台快照会把终端内容(API key/token/源码)写盘并展示在多任务切换器)[ ] 冷启动:有 lastSessionId 的 host → 列表页高亮"继续上次"
|
||||
- **Accept**: 模拟器全流程手工走查:配对→列表→attach→后台→前台重连回放;**切后台开切换器 → 卡片显示遮罩而非终端内容**
|
||||
- **安全注**: 组装点核对一次:所有 G 调用来自 APIClient(无绕过)、debug ATS 设置未漏进 release scheme。录屏暴露面:iOS 无公开 API 把窗口排除出截屏/录屏——只可选做 `UIScreen.isCaptured` 检测(录屏时可选拉黑终端);除此之外记为已接受残余风险(本地信任模型),**不得**计划 isSecureTextEntry 层这类非受支持 hack。
|
||||
|
||||
#### T-iOS-16 · 集成 CI(对真 Node 服务器)`[ ]` · ~0.5 pd
|
||||
#### T-iOS-16 · 集成 CI(对真 Node 服务器)`[x]` · ~0.5 pd
|
||||
- **Wave/阶段**: W4 / P0(仅依赖 W2——可提前并入第 6 批,见 §8) · **Owns**: `ios/IntegrationTests/**`(含吸收 T-iOS-2 的 OriginSpikeTests)、`.github/workflows/ios.yml`(改)
|
||||
- **Depends**: T-iOS-9/10 · **Parallel-safe**: T-iOS-11–15/17
|
||||
- **Steps(测试清单)** — macOS runner:`npm ci` → 临时端口 `npm start`(`ALLOWED_ORIGINS` 注入)→ Swift Testing:
|
||||
@@ -705,7 +824,8 @@ W5 验收(report-only, 并行)
|
||||
- **Accept**: CI job 绿;覆盖率门**演示过一次红**(故意把某包压到 80% 下或抬高阈值)再回绿;这是"客户端复刻的协议契约"的持续防漂移闸门
|
||||
- **安全注**: 本任务是 §5.1 的自动化化身;任何"为了过 CI 放宽 Origin 断言"= CRITICAL。
|
||||
|
||||
#### T-iOS-17 · ntfy 桥验证 + 文档(P0 临时通知,**零新代码**)`[ ]` · ~0.1 pd
|
||||
#### T-iOS-17 · ntfy 桥验证 + 文档(P0 临时通知,**零新代码**)`[x]` · ~0.1 pd
|
||||
- **落地现状**:`ios/README.md` 的 ntfy 章节(逐条引 `scripts/setup-hooks.mjs` 行号;只读验证,**未触碰用户真实 hook 配置**)。**DEFERRED**:手机端到端(需用户手机 + 改用户 hook 配置)。
|
||||
- **Wave/阶段**: W4 / P0 · **Owns**: iOS README 的 ntfy 章节(文档;**不建任何脚本文件**——桥已随 `npm run setup-hooks` 出货:安装逻辑 scripts/setup-hooks.mjs:227-238、env 门 :262-264,重装时 marker 自清理,见 §0.3)
|
||||
- **Depends**: 无(与 App 解耦) · **Parallel-safe**: T-iOS-15/16
|
||||
- **Steps**: [ ] 验证既有桥:设 `WEBTERM_NTFY_URL` + `WEBTERM_NTFY_TOPIC`(可选 `WEBTERM_NTFY_TOKEN`)→ `npm run setup-hooks` → 确认日志 "Installed ntfy bridge (NEEDS-INPUT=high, DONE=low)" [ ] README 写明:env 未设即完全无副作用(默认关闭,已是出货行为);topic 生成建议随机串(topic 即密码)[ ] 记录:**STUCK 不在 P0 信号内**(服务器 sweepStuck 派生态、无 hook 事件,桥发不出——P1 APNs 经事件总线补上)
|
||||
@@ -714,10 +834,12 @@ W5 验收(report-only, 并行)
|
||||
|
||||
#### W5 · 验收(report-only,G4:只报告不改码,findings 标 owning task 派回)
|
||||
|
||||
#### T-iOS-18 · 验收走查 F-iOS-1…13(真机)`[ ]` · ~0.25 pd
|
||||
#### T-iOS-18 · 验收走查 F-iOS-1…13(真机)`[~]` · ~0.25 pd
|
||||
- **缺口**:机器可执行项已执行并指认测试名;真机项(QR 扫码 / IME / 震动 / 切换器遮罩目检 / ntfy 端到端)**DEFERRED**,手工清单在 `PROGRESS_LOG.md`。
|
||||
- **Depends**: T-iOS-15/16/17 · **Owns**: 无源码(report-only)
|
||||
- **Steps**: 按 §9 验收脚本逐条执行、记录结论与录屏。
|
||||
#### T-iOS-19 · 安全核对 `[ ]` · ~0.25 pd
|
||||
#### T-iOS-19 · 安全核对 `[~]` · ~0.25 pd
|
||||
- **缺口**:P0 面已逐条核;**P2 新增的 `NSMicrophoneUsageDescription`/`NSSpeechRecognitionUsageDescription` 未在产物层复核**,release ipa 层核对仍缺(免费个人 team 无分发通道)。本波 D1 只覆盖令牌路径与 entitlements 开关。
|
||||
- **Depends**: T-iOS-15 · **Owns**: 无源码(report-only)
|
||||
- **Steps**: [ ] 对照 TECH_DOC §7 + 本文 §5 逐条核:Origin 单点派生、G/RO 分界、ATS 键 release 实况(拆 ipa 验 Info.plist——**五段 CIDR 逐段核对**,debug `NSAllowsArbitraryLoads` 会掩盖缺段)、隐私 usage description 与实际使用的 capability **一一对应**(相机/本地网络;P2 加麦克风/语音识别——拆 ipa 核对)、Keychain 属性(模拟器测试断言 `kSecAttrAccessible`)、ntfy payload 最小化、无硬编码 host/密钥、警告分层文案在位(公网阻断 + RFC1918 明文提示 + Tailscale 豁免)、**真机核:切后台开切换器 → 快照卡片是遮罩非终端内容**。
|
||||
|
||||
@@ -729,68 +851,70 @@ W5 验收(report-only, 并行)
|
||||
|
||||
> 波次:**W6(服务器触点:T-iOS-20 ∥ T-iOS-37,串行入库各自 repo 流程)与 W7 并行开跑**——W7 里只有 T-iOS-21 依赖 T-iOS-20(payload 形状)、T-iOS-23 依赖 T-iOS-37(lastOutputAt 字段),其余 W7 任务(T-iOS-22/24/25/27/28/29)不等 W6;T-iOS-38(APIClient P1 契约增量)在 W7 首发,T-iOS-21/26 依赖它 → W8(验收)。任务粒度与 P0 同规格;此处 Steps(测试) 列关键用例,细化在开工时由任务 owner 补足(不改接口)。
|
||||
|
||||
#### T-iOS-20 · server: APNs sender + token 注册端点 `[ ]` · ~2 pd
|
||||
#### T-iOS-20 · server: APNs sender + token 注册端点 `[x]` · ~2 pd
|
||||
- **落地现状**:`src/push/apns.ts` + `test/push-apns.test.ts`(含本地 h2c 假 APNs 的双 e2e);env 三件套缺失即整体 disabled、启动不 crash、密钥材料零日志。**DEFERRED**:对真 APNs 的端到端需付费账号 + `.p8`。
|
||||
- **Wave**: W6 · **Owns**: `src/push/apns.ts`(新)、`src/server.ts` 增量 route、`test/push-apns.test.ts`(**服务器触点,TypeScript 任务**,遵循根仓库 PLAN 工作流)
|
||||
- **Depends**: 无 · **Parallel-safe**: T-iOS-37/38 及 W7 除 T-iOS-21 外全部(接口先行)
|
||||
- **Steps(测试先行)**: [ ] `.p8` 缺失 → 功能整体 disabled、启动不 crash [ ] hook 事件 → APNs payload 形状(含 `/hook/decision` 用的 capability token + category)[ ] token 注册端点:G 守卫 403、限频 429、幂等注册/注销 [ ] 与既有 web-push 并行互不干扰
|
||||
- **Steps(实现)**: [ ] HTTP/2 到 `api.push.apple.com`,`.p8` 走 env 路径(无硬编码密钥)[ ] 复用 `src/push/` 的事件订阅点
|
||||
- **安全注**: capability token 语义不变(单次、过期、10/min 限频,src/server.ts:503-525);APNs payload 不含命令内容。
|
||||
|
||||
#### T-iOS-37 · server: `LiveSessionInfo.lastOutputAt` 字段 `[ ]` · ~0.25 pd
|
||||
#### T-iOS-37 · server: `LiveSessionInfo.lastOutputAt` 字段 `[x]` · ~0.25 pd
|
||||
- **Wave**: W6 · **Owns**: `src/types.ts` 的 `LiveSessionInfo` 增量字段、`src/session/manager.ts` `list()` 一行映射(并更新其 "omitted by design" 注释)、对应测试增量(**声明的服务器触点,TypeScript 任务**,遵循根仓库 PLAN 工作流;见 §0.3)
|
||||
- **Depends**: 无 · **Parallel-safe**: T-iOS-20、W7 全部
|
||||
- **Steps(测试先行)**: [ ] `GET /live-sessions` 响应含 `lastOutputAt`(服务器已逐 `pty.onData` 维护,src/types.ts:211/M3——只是序列化出来)[ ] 旧客户端兼容:字段为**新增可选**,web 前端不受影响
|
||||
- **Accept**: 根仓库 `npm test` 全绿;T-iOS-23 的 unread 水位有数据源
|
||||
|
||||
#### T-iOS-38 · `APIClient` P1 契约增量(W7 首发,其余 W7 任务的 APIClient 单一 owner)`[ ]` · ~0.5 pd
|
||||
#### T-iOS-38 · `APIClient` P1 契约增量(W7 首发,其余 W7 任务的 APIClient 单一 owner)`[x]` · ~0.5 pd
|
||||
- **Wave**: W7(首发) · **Owns**: `ios/Packages/APIClient/**` 的 **全部 P1 增量**(APNs token 注册 builder、`/projects`、`/projects/detail?path=`、`GET/PUT /prefs` builders 与解码 + Tests)——W7 期间 APIClient 文件**只有本任务可改**(对齐 T-iOS-3 冻结契约模式,避免 T-iOS-21/26 同波踩踏)
|
||||
- **Depends**: T-iOS-8;T-iOS-20(仅 token 注册 builder 的端点形状——可接口先行并行编码,形状定稿时汇合) · **Parallel-safe**: T-iOS-20/22/23/24/25/27/28/29(均不碰 APIClient 文件)
|
||||
- **Steps(测试先行)**: [ ] token 注册 builder(G,带 Origin)[ ] projects/detail/prefs builders(PUT 带 Origin)与解码 [ ] doc comment 端点约束:push subscribe body ≤ 8 KB、≤5 次/分/IP(src/server.ts:73,461-466);`PUT /prefs` ≤ 64 KB(src/server.ts:278)
|
||||
- **Accept**: `swift test --package-path ios/Packages/APIClient` 全绿;覆盖率 ≥ 80%
|
||||
|
||||
#### T-iOS-21 · PushRegistrar + 锁屏 Allow/Deny `[ ]` · ~1.5 pd
|
||||
#### T-iOS-21 · PushRegistrar + 锁屏 Allow/Deny `[x]` · ~1.5 pd
|
||||
- **落地现状**:`Push/{PushRegistrar,NotificationActionHandler}.swift` + 三组 Tests;`handleHostRemoved` 在收尾波接上真调用点(`Wiring/AppEnvironment.swift`)。**DEFERRED**:真机锁屏 Allow + Face ID 走查;`aps-environment` 需付费 team(`WEBTERM_PUSH_ENTITLEMENTS` 开关,见 `ios/README.md`)。
|
||||
- **Wave**: W7 · **Owns**: `App/WebTerm/Push/{PushRegistrar,NotificationActionHandler}.swift` + 对应 Tests(APIClient 的 token 注册 builder 归 T-iOS-38)
|
||||
- **Depends**: T-iOS-20(payload 形状)、T-iOS-38(token 注册 builder) · **Parallel-safe**: T-iOS-22–29
|
||||
- **Steps(测试先行)**: [ ] `UNNotificationCategory` Allow/Deny 注册形状——**Allow 动作必须带 `UNNotificationActionOptions.authenticationRequired`**(锁屏批准 = 授权主机执行命令;旁观者拿到锁屏手机只能 Deny——fail-safe。断言注册 category 的 Allow 选项含 `.authenticationRequired`,且两动作均**不含** `.foreground`)[ ] action handler:从 payload 取 `{sessionId, token}` → `POST /hook/decision`(带 Origin)→ **不启动 App UI**(Face ID 设备上"两次手势 + 一瞥"闭环)[ ] token 用后即弃不落盘 [ ] 决策失败(token 过期 403)→ 补一条本地通知提示进 App 处理
|
||||
- **安全注**: Allow/Deny 由系统**后台拉起主 App**、送达 `UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:)`——**无 extension 参与**(本工程没有 notification extension target;Service Extension 只能改写来押通知、收不到 action tap)。handler 用 `beginBackgroundTask/endBackgroundTask` 包住 POST,请求完成/失败后才调 completion handler;失败必须可见(本地通知兜底),绝不静默吞。
|
||||
|
||||
#### T-iOS-22 · DeepLinkRouter `[ ]` · ~1 pd
|
||||
#### T-iOS-22 · DeepLinkRouter `[x]` · ~1 pd
|
||||
- **Wave**: W7 · **Owns**: `App/WebTerm/DeepLinkRouter.swift` + Tests
|
||||
- **Steps(测试先行)**: [ ] `webterminal://open?host=<id>&join=<uuid>`:UUID v4 校验(复用 `Validation`),非法 → 忽略并留日志 [ ] 未知 host id → 落到配对页并提示 [ ] 冷/热启动两路径都直达 gated 会话 [ ] push tap → 同一路由
|
||||
- **安全注**: deep link 是外部输入——全字段白名单校验,绝不据此直接拼 URL 请求。
|
||||
|
||||
#### T-iOS-23 · 多会话切换器(unread dots + OSC 标题)`[ ]` · ~2 pd
|
||||
#### T-iOS-23 · 多会话切换器(unread dots + OSC 标题)`[x]` · ~2 pd
|
||||
- **Wave**: W7 · **Owns**: `SessionListScreen` 增强(**W7 内该文件唯一 owner**,含 T-iOS-29 移交的列表侧入口)、`SessionCore` 的 unread 记账(`UnreadLedger.swift`)、标题净化器(`TitleSanitizer.swift`)+ Tests
|
||||
- **Depends**: T-iOS-37(`lastOutputAt` 字段) · **Parallel-safe**: T-iOS-21/22/24/25/26/27/28
|
||||
- **Steps(测试先行)**: [ ] 单活 WS 不变:切会话 = close→open,回放恢复 [ ] unread 判定:`/live-sessions` 快照的 `lastOutputAt`(T-iOS-37 新增字段)> 本地 last-seen 水位 → unread 点 [ ] OSC 标题经 SwiftTerm `setTerminalTitle` delegate 上浮到列表——**标题是主机/攻击者可控输入,入列表前过净化器**:截断 `Tunables.titleMaxLength`(256);剥 Unicode 双向覆写与零宽字符(U+200B–200F、U+202A–202E、U+2066–2069——OSC 字符串解析已排除 C0,真正的仿冒向量是 bidi/零宽);渲染用 `Text(verbatim:)`(绝不走 LocalizedStringKey/Markdown)+ `.lineLimit(1)` 截断 [ ] 敌意标题解码测试:超长、U+202E payload、emoji 洪泛 → 断言净化输出 [ ] 切换 <1s 观感(回放解析在后台、feed 在 main)
|
||||
|
||||
#### T-iOS-24 · Timeline sheet(完整时间线钻取)`[ ]` · ~1 pd
|
||||
#### T-iOS-24 · Timeline sheet(完整时间线钻取)`[x]` · ~1 pd
|
||||
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/TimelineSheet.swift` + VM 测试
|
||||
- **Steps(测试先行)**: [ ] `/live-sessions/:id/events` 全量渲染(class → 图标/颜色映射)[ ] timeline disabled(空数组)→ 空态而非错误 [ ] 从 digest "展开"入口进入
|
||||
|
||||
#### T-iOS-25 · Quick-reply chips + 常用语面板 `[ ]` · ~1.5 pd
|
||||
#### T-iOS-25 · Quick-reply chips + 常用语面板 `[x]` · ~1.5 pd
|
||||
- **Wave**: W7 · **Owns**: `App/WebTerm/Components/QuickReply.swift`、本地存储(UserDefaults)+ Tests
|
||||
- **Steps(测试先行)**: [ ] chip 点击 → `input` 帧(文本 + `\r`)[ ] 自定义面板增删改序 [ ] waiting 状态才浮出(对齐 web `quick-reply.ts` 行为)
|
||||
|
||||
#### T-iOS-26 · Projects:列表 + 详情 + 在仓库起 Claude `[ ]` · ~2 pd
|
||||
#### T-iOS-26 · Projects:列表 + 详情 + 在仓库起 Claude `[x]` · ~2 pd
|
||||
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/{ProjectsScreen,ProjectDetailScreen}.swift` + VM Tests(projects/detail/prefs 的 APIClient builders 归 T-iOS-38,本任务只消费)
|
||||
- **Depends**: T-iOS-38(builders) · **Parallel-safe**: T-iOS-21/22/23/24/25/27/28/29
|
||||
- **Steps(测试先行)**: [ ] VM 消费 `/projects`、`/projects/detail?path=`、`GET/PUT /prefs`(builder 与解码测试在 T-iOS-38)[ ] favourites 同步 [ ] "在此仓库开新会话" = `attach(null, cwd)` + 注入 `claude\r` [ ] detail 的 400/404/500 `{error}` 显式路径
|
||||
|
||||
#### T-iOS-27 · Diff 查看器(只读)`[ ]` · ~1.5 pd
|
||||
#### T-iOS-27 · Diff 查看器(只读)`[x]` · ~1.5 pd
|
||||
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/DiffScreen.swift` + VM 测试
|
||||
- **Steps(测试先行)**: [ ] `DiffResult{files,staged,truncated}` 渲染、truncated 提示 [ ] staged/unstaged 切换 [ ] path 非法 404 → 友好错误
|
||||
|
||||
#### T-iOS-28 · 会话缩略图(offscreen SwiftTerm)`[ ]` · ~1.5 pd
|
||||
#### T-iOS-28 · 会话缩略图(offscreen SwiftTerm)`[x]` · ~1.5 pd
|
||||
- **Wave**: W7 · **Owns**: `App/WebTerm/Components/SessionThumbnail.swift` + 快照测试
|
||||
- **Steps(测试先行)**: [ ] `GET /live-sessions/:id/preview`(`{id,cols,rows,data}`,24KB tail)→ 离屏 TerminalView feed → 快照图 [ ] 列表滚动不掉帧(离屏渲染限并发)[ ] 404 → 占位图
|
||||
|
||||
#### T-iOS-29 · 杂项闭环:new-in-cwd + 退出会话清理 `[ ]` · ~1 pd
|
||||
#### T-iOS-29 · 杂项闭环:new-in-cwd + 退出会话清理 `[x]` · ~1 pd
|
||||
- **Wave**: W7 · **Owns**: `TerminalScreen` 的小增量 + Tests(**不碰 `SessionListScreen`**——列表侧入口/行项变更移交 T-iOS-23,该文件 W7 内单一 owner)
|
||||
- **Depends**: T-iOS-23(列表侧入口) · **Parallel-safe**: T-iOS-21/22/24/25/26/27/28
|
||||
- **Steps(测试先行)**: [ ] "在当前会话 cwd 开新会话"(`attach(null, cwd)`)[ ] exited 会话点开 → 回放 + exit 横幅 + "开新会话"动作(src/session/manager.ts:145-153 语义)
|
||||
|
||||
#### T-iOS-30 · P1 验收 + 安全复核 `[ ]` · ~1 pd(report-only)
|
||||
#### T-iOS-30 · P1 验收 + 安全复核 `[x]` · ~1 pd(report-only)
|
||||
- **Steps**: [ ] F-iOS-14/15(§9)真机走查 [ ] 安全:APNs payload 审计、token 生命周期、deep link fuzz、通知在锁屏的预览泄露面(默认隐藏内容验证)、**锁屏 Allow 动作必须 `.authenticationRequired`(真机验:锁屏 Allow 弹 Face ID/通行码;Deny 无需解锁)**。
|
||||
|
||||
**P1 合计 ≈ 17.5 人天**(含新增 T-iOS-37 0.25 + T-iOS-38 0.5;T-iOS-21/26 相应减负)。
|
||||
@@ -800,13 +924,25 @@ W5 验收(report-only, 并行)
|
||||
### P2 — 打磨 · 合计 ~8 人天
|
||||
|
||||
> P2 任务此处只给**分派必需元数据**(Wave/Owns/Depends/Accept);完整 RED 测试清单在开工时由任务 owner 按 P0 规格扩写(不改 §3 接口)。**未扩写前不得按下述描述直接分派**(§4 TDD 强制与 §6 Owns 铁律同样适用)。
|
||||
> **该前置已履行**:T-iOS-31/32/33/34/35 的逐条 RED 清单由各 builder 开工第一步写进
|
||||
> [`docs/plans/ios-completion.md`](./plans/ios-completion.md) §4(共 100+ 条,含 T-iOS-32 的 35 条、
|
||||
> T-iOS-33 的 18 条、T-iOS-31 的 38 条、T-iOS-34 的 29 条、T-iOS-35 的 21 条),再进 GREEN。
|
||||
|
||||
- **T-iOS-31** · 语音 PTT + 确认(端口匹配器 / 1.5s 撤销 / epoch 防误发)`[ ]` ~2.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/VoicePTT.swift` + VM Tests(epoch 防误发若需 SessionCore 新接口 → 经 T-iOS-6 owner 回 SessionCore 加,不直改)· **Depends**: T-iOS-6/11;**前置**:Info.plist 加 `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`(§5.2 注)· **Accept**: VM 测试 + 真机口述→确认→注入 input。
|
||||
- **T-iOS-32** · Worktree 创建(`POST /projects/worktree`,G)+ `claude --resume <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-33** · 终端内搜索 `[ ]` ~1 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/TerminalSearchBar.swift` + Tests · **Depends**: T-iOS-11 · **Accept**: SwiftTerm search API 命中高亮。
|
||||
- **T-iOS-34** · 主题 + Dynamic Type `[ ]` ~1.5 pd。**Wave**: W9 · **Owns**: 主题/字号小增量(与同波任务文件不相交,开工时列明文件清单)· **Depends**: T-iOS-11/13 · **Accept**: 亮暗主题 + 最大字号不破版。
|
||||
- **T-iOS-35** · web `?join=` 互通(分享 QR 双向)`[ ]` ~0.5 pd。**Wave**: W9 · **Owns**: `DeepLinkRouter.swift` 增量(`?join=` 解析)· **Depends**: T-iOS-22 · **Accept**: 手机扫 web 分享 QR 直达同会话。
|
||||
- **T-iOS-36** · P2 验收 `[ ]` ~1 pd(report-only)。**Wave**: W10 · **Owns**: 无源码 · **Depends**: T-iOS-31–35。
|
||||
- **T-iOS-31** · 语音 PTT + 确认(端口匹配器 / 1.5s 撤销 / epoch 防误发)`[x]` ~2.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/VoicePTT.swift` + VM Tests(epoch 防误发若需 SessionCore 新接口 → 经 T-iOS-6 owner 回 SessionCore 加,不直改)· **Depends**: T-iOS-6/11;**前置**:Info.plist 加 `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`(§5.2 注)· **Accept**: VM 测试 + 真机口述→确认→注入 input。
|
||||
- **落地**: `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 末位 🎤 键(前 17 键顺序/标签零变化)+ `VoicePTTTests` 40 测(转写清洗 / 匹配器 / 1.5s 撤销窗 / 双道 epoch 闸 / 权限失败路径 / 键栏零回归)。注入内容**不以 `\r` 结尾**,确认前零注入。
|
||||
- **DEFERRED**: 真机口述→确认→注入(需真机麦克风与语音识别授权)。
|
||||
- **T-iOS-32** · Worktree 创建(`POST /projects/worktree`,G)+ `claude --resume <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 测试 + 端到端一次。
|
||||
- **落地**: `Screens/{WorktreeSheet,ResumeHistorySheet}.swift` + `ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift`;分支名 9 条规则客户端先校验(非法名零请求)、prune 幂等文案、remove 两级确认(409→显式 force 确认,绝不自动重试)、`--resume` id 白名单后才拼命令行;`WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)。
|
||||
- **未做**: Accept 里的"端到端一次"(对真服务器真建/真删一个 worktree)没有自动化腿,也未在本环境手工跑过。
|
||||
- **T-iOS-33** · 终端内搜索 `[x]` ~1 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/TerminalSearchBar.swift` + Tests · **Depends**: T-iOS-11 · **Accept**: SwiftTerm search API 命中高亮。
|
||||
- **T-iOS-34** · 主题 + Dynamic Type `[~]` ~1.5 pd。**Wave**: W9 · **Owns**: 主题/字号小增量(与同波任务文件不相交,开工时列明文件清单)· **Depends**: T-iOS-11/13 · **Accept**: 亮暗主题 + 最大字号不破版。
|
||||
- **落地**: `DesignSystem/{AppTheme,TerminalPalette}.swift` + `Screens/SettingsScreen.swift`(齿轮入口在 `ProjectsToolbarItem` 内,stack 与 split 两根视图共享)+ Tokens/Typography 浅色档;`AppThemeTests`(24)/`DynamicTypeLayoutTests`(8)。默认仍是深色(零回归)。
|
||||
- **缺口已闭合(收尾波,2026-07-30)**: `KeyBarMetrics.barHeight` 从常量 52pt 改为 `max(minHitTarget, keycapHeight(category)) + sm8`,`intrinsicContentSize`/init frame/`apply(contentSizeCategory:)` 三处同源,并经 iOS 17 `registerForTraitChanges` 支持运行期改档。`withKnownIssue` 已删。**取舍**:键帽字体封顶 `.accessibility2`(不封顶时 AX5 需 108.24pt,会把终端吃掉),沿用 DS 对密集内容的既有策略;实测条高 XS–XL 52 / XXL 55 / XXXL 59 / AccM 68 / AccL–AX5 77。
|
||||
- **T-iOS-35** · web `?join=` 互通(分享 QR 双向)`[x]` ~0.5 pd。**Wave**: W9 · **Owns**: `DeepLinkRouter.swift` 增量(`?join=` 解析)· **Depends**: T-iOS-22 · **Accept**: 手机扫 web 分享 QR 直达同会话。
|
||||
- **落地**: `DeepLinkRouter` 增 `.joinShared` 分支 + `DeepLinkJoinTests` 19 测(含把原"scheme 不是 webterminal"的拒绝理由改写为"web 形状只许单一 `join` 键");主机身份只经 `HostStore`+`HostEndpoint.originHeader` 解析,未配对 origin 一律走配对页且 hint 不回显链接内容。
|
||||
- **DEFERRED**: 用真手机相机扫 web 分享 QR 的目视走查(模拟器无相机)。
|
||||
- **T-iOS-36** · P2 验收 `[~]` ~1 pd(report-only)。**Wave**: W10 · **Owns**: 无源码 · **Depends**: T-iOS-31–35。
|
||||
- **缺口**: 由 ios-completion 收尾波的 Wave D 验收 agent 执行中;**真实数字与结论以 `PROGRESS_LOG.md` 的该条目为准**,本文不预写"通过"。
|
||||
|
||||
**总计:P0 13 + P1 17.5 + P2 8 ≈ 38.5 人天。**
|
||||
|
||||
@@ -863,7 +999,15 @@ W5 验收(report-only, 并行)
|
||||
|
||||
每任务:**RED**(照 Steps(测试) 先写失败测试)→ **GREEN**(最小实现过测)→ **REFACTOR**(对照 §4 清单)。测试命名讲行为(`test("未知 UUID attach 后采用服务器新发的 id")`),AAA 结构。
|
||||
|
||||
### 覆盖率门(≥ 80%,只量 4 个包)
|
||||
### 覆盖率门(≥ 80%;原定 4 个包,**现为 5 个**)
|
||||
|
||||
> **现状(2026-07-30)**:门集已扩到 **5** 个包——原 4 个 + **`ClientTLS`**(ios-completion 收尾波 B4:
|
||||
> 48→84 测、55.76%→**89.49%**,此前是树里唯一未进门的包,却最安全敏感)。
|
||||
> CI 实际执行的是**修正口径**脚本 `ios/IntegrationTests/scripts/coverage-gate.sh <Pkg>`
|
||||
> (下面这段裸 `llvm-cov` 命令读的是 export TOTALS,会把静态链入的**依赖包源码**一起计进去——
|
||||
> 这个缺陷由 T-iOS-16 修掉,脚本只保留 `Packages/<P>/Sources/` 并排除 `*Placeholder*`)。
|
||||
> 最近一次记录在案的 own-sources 数字:APIClient 92.22% · HostRegistry 92.49% · SessionCore 96.74% ·
|
||||
> ClientTLS 89.49% · WireProtocol 100%。
|
||||
|
||||
```bash
|
||||
for p in WireProtocol SessionCore HostRegistry APIClient; do
|
||||
@@ -931,9 +1075,16 @@ XCUITest 只保一条 happy path:配对→attach→输入→gate approve(脆
|
||||
| 单 WS 设计与多会话切换器(P1)的张力 | 低 | 切换 = 重放恢复,成本近零;若实测不适再评估观察者 WS |
|
||||
|
||||
**待你拍板的开放项**:
|
||||
1. Bundle id / 产品名 / 图标(App 叫什么?`webterminal://` scheme 是否可用/要改名?)
|
||||
2. Apple 付费开发者账号($99/年)何时开——决定 P1 APNs 与 TestFlight 分发起点;P0 期间接受自签 sideload?
|
||||
3. 最低系统版本:iOS 17(可分发下限)还是 18/26(个人工具,availability 噪音最小)?
|
||||
1. ~~Bundle id / 产品名 / 图标~~ **已定**:`com.yaojia.webterm` / WebTerm / "Orbit" 图标(`project.yml`;
|
||||
deep-link scheme `webterminal://` 已注册并在用,另接受 web 的 `http(s)://…/?join=` 形状,T-iOS-35)。
|
||||
2. ~~Apple 付费开发者账号何时开~~ **现状已定、后果已量化**:用的是**免费个人 team**(`DEVELOPMENT_TEAM=C738Z66SRW`)。
|
||||
真机构建可用(7 天临时 profile,`-allowProvisioningUpdates`,实测 `BUILD SUCCEEDED`);
|
||||
**Push Notifications capability 免费 team 不支持** —— `aps-environment` 因此做成 `WEBTERM_PUSH_ENTITLEMENTS`
|
||||
env 开关(默认不挂;挂上则真机构建报
|
||||
`Personal development teams, including "Yaojia Wang", do not support the Push Notifications capability`)。
|
||||
⇒ **APNs 真机端到端 + TestFlight 仍待付费账号**;release 构建还需把 `aps-environment` 翻成 `production`。
|
||||
操作细节见 [`ios/README.md`](../ios/README.md#signing-device-builds-and-the-push-entitlements-switch)。
|
||||
3. ~~最低系统版本~~ **已定**:iOS **17.0**(`project.yml` `deploymentTarget`),CI 另有一条 iOS-17 底线腿。
|
||||
4. ~~P0 的 ntfy 桥要不要做成默认关闭~~ **已解决**:桥已随 `npm run setup-hooks` 出货且默认关闭(`WEBTERM_NTFY_URL`/`WEBTERM_NTFY_TOPIC` 未设即完全无副作用)——无需新做,T-iOS-17 只验证/写文档。
|
||||
5. ~~Tailscale 场景是否直接推荐 `tailscale serve`~~ **已定**:推荐 `tailscale serve`(wss)为标准部署话术(配对 UI/README 采用;绕开全部 ATS 例外与明文嗅探面,见 §5.4)。
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# PLAN_IOS_IPAD.md — iPad 适配(自适应布局,非分叉)
|
||||
|
||||
> 落地方案文档。目标:让已完成的 iPhone 客户端([PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md),P0+P1 已交付,分支 `feat/ios-client`)**原生适配 iPad**——大屏分栏、双向布局、指针/硬件键盘,而**不分叉出第二套 UI**。
|
||||
> 落地方案文档。目标:让已完成的 iPhone 客户端([PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md),P0+P1+P2 已交付,**已合入 `develop`**)**原生适配 iPad**——大屏分栏、双向布局、指针/硬件键盘,而**不分叉出第二套 UI**。
|
||||
> 拓扑决策:**单一代码库 + size-class 自适应**——`NavigationSplitView` 在 regular 宽度(iPad 全屏/大分屏)给 sidebar+detail,在 compact 宽度(iPhone、iPad Slide Over/小分屏)**自动退化为现有 stack**。iPhone 行为字节级不变。
|
||||
> 状态:**规划中(2026-07-05,未开工)**。
|
||||
> 状态:**已交付并合入 `develop`**(T-iPad-1…4 全部落地;T-iPad-5 验收 PASS_WITH_FINDINGS,4/4 findings 已修,真机项 DEFERRED)。
|
||||
> 逐任务状态见 §5 各任务标题上的方框;**Steps 里的 `[ ]` 是原始规格清单,不是状态**(执行记录在 `PROGRESS_LOG.md`)。
|
||||
> 2026-07-30 由 ios-completion 收尾波按源码核对:`Wiring/{AdaptiveRootView,SplitRootView,LayoutMode}.swift`、
|
||||
> `Components/TerminalContextMenu.swift`、`Screens/ProjectsLayout.swift` 与 `LayoutPolicyTests`/`SidebarSelectionTests`/
|
||||
> `KeyBarVisibilityTests`/`TerminalContextMenuTests`/`ProjectsLayoutTests`/`ProjectsLayoutUITests` 均在树内;
|
||||
> `project.yml` 三个 target 的 `TARGETED_DEVICE_FAMILY` 均为 `"1,2"`;`ios.yml` 有 iPad 单测腿与 iPad UI-test 腿。
|
||||
> 本文是 iPhone 计划之上的**布局适配层**,不改协议/会话模型/纯逻辑包;沿用 [PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md) 的 §3 契约、§4 工程标准、§5 安全模型、§6 并行规则。冲突以 iPhone 计划为准。
|
||||
> **G1 日志铁律**:`PROGRESS_LOG.md` 由 orchestrator 独写;被派 subagent 不写 LOG,在返回消息末尾附可粘贴条目。
|
||||
|
||||
@@ -118,7 +123,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
|
||||
|
||||
### W0 · 可安装性(串行,先行)
|
||||
|
||||
#### T-iPad-1 · device family + iPad plist/方向 `[ ]` · ~0.5 pd
|
||||
#### T-iPad-1 · device family + iPad plist/方向 `[x]` · ~0.5 pd
|
||||
- **Owns**: `ios/project.yml`(device family、iPad 方向、必要 plist)、`.github/workflows/ios.yml`(加 iPad 模拟器测试腿)
|
||||
- **Depends**: 无
|
||||
- **Steps(测试先行)**:
|
||||
@@ -132,7 +137,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
|
||||
|
||||
### W1 · 自适应导航壳(核心)
|
||||
|
||||
#### T-iPad-2 · AdaptiveRootView + NavigationSplitView + LayoutPolicy `[ ]` · ~2 pd
|
||||
#### T-iPad-2 · AdaptiveRootView + NavigationSplitView + LayoutPolicy `[x]` · ~2 pd
|
||||
- **Owns**: `Wiring/{AdaptiveRootView,SplitRootView,LayoutMode}.swift`(新)、`Wiring/RootView.swift`(改:现有 stack 抽成 `StackRootView` 子视图供 compact 复用)、`Wiring/AppCoordinator.swift`(改:sidebar 选中 ↔ 路由最小桥)、对应测试
|
||||
- **Depends**: T-iPad-1
|
||||
- **Steps(测试先行, RED)** — `LayoutPolicyTests` + `SidebarSelectionTests`:
|
||||
@@ -149,7 +154,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
|
||||
|
||||
### W2 · 逐面适配(并行,文件互斥)
|
||||
|
||||
#### T-iPad-3 · 终端面板:KeyBar 自适应 + 指针上下文菜单 `[ ]` · ~1 pd
|
||||
#### T-iPad-3 · 终端面板:KeyBar 自适应 + 指针上下文菜单 `[x]` · ~1 pd
|
||||
- **Owns**: `Components/{KeyBar,TerminalContextMenu}.swift`、`Screens/TerminalScreen.swift`(增量)、测试
|
||||
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-4
|
||||
- **Steps(测试先行)**:
|
||||
@@ -159,7 +164,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
|
||||
- **Accept**: iPad 接键盘时 KeyBar 自动隐、去键盘复现;右键菜单在 iPad 生效;iPhone 无硬件键盘时 KeyBar 行为不变
|
||||
- **安全注**: 上下文菜单的 kill/开会话是既有 G/RO 通道的又一触发面——测试名标「复用 APIClient,无绕过」
|
||||
|
||||
#### T-iPad-4 · Projects/Timeline/sheet 大屏化 `[ ]` · ~1 pd
|
||||
#### T-iPad-4 · Projects/Timeline/sheet 大屏化 `[x]` · ~1 pd
|
||||
- **Owns**: `Screens/ProjectsScreen.swift`(增量)、Projects/Timeline 呈现方式(regular 下 `.sheet` → 列/`.presentationDetents` 或 sidebar section)、测试
|
||||
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-3
|
||||
- **Steps(测试先行)**:
|
||||
@@ -170,7 +175,9 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
|
||||
|
||||
### W3 · 验收(report-only, G4)
|
||||
|
||||
#### T-iPad-5 · iPad 验收 + iPhone 回归 + 安全核对 `[ ]` · ~0.5 pd
|
||||
#### T-iPad-5 · iPad 验收 + iPhone 回归 + 安全核对 `[~]` · ~0.5 pd
|
||||
- **已执行**:模拟器侧 PASS_WITH_FINDINGS(iPhone 16 / iPad Pro 11 双套件绿,iPhone 零回归硬门守住),4 个 findings(2 MED / 2 LOW)由 orchestrator 修完 4/4;iPad 分栏 sidebar+detail 截图确认。
|
||||
- **缺口**:① 真机 iPad(分栏手势 / 硬件键盘全键位 / 指针 hover 右键 / Stage Manager)**DEFERRED**,手工清单在 `PROGRESS_LOG.md`;② iPad happy-path XCUITest **接受推迟**(split 选中逻辑已由 `SidebarSelectionTests` 覆盖,单跑 7–11 min 且脆);③ **release ipa 层**核对未做(免费个人 team 无分发通道)——与 T-iOS-19 同一缺口,且 P2 新增的麦克风/语音识别 usage description 也应一并核。
|
||||
- **Depends**: T-iPad-2/3/4 · **Owns**: 无源码(report-only,findings 派回 owner)
|
||||
- **Steps**:
|
||||
- [ ] **F-iPad 走查**(§6 清单)逐条:分栏、横竖屏、Split View/Slide Over/Stage Manager 尺寸切换、硬件键盘/指针、终端更宽列数、遮罩覆盖 detail
|
||||
@@ -214,10 +221,10 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
|
||||
| SwiftTerm 在超宽 detail 的性能/选择手感 | 低 | 复用 iPhone 已测路径;真机目视 |
|
||||
| 多窗口诱惑导致 scope 膨胀 | 中 | 本期明确单场景(§0 非目标);多窗口另立计划 |
|
||||
|
||||
**待你拍板**:
|
||||
1. iPad 最低系统版本:iPadOS 17(与 iPhone 一致)还是抬到 18/26?
|
||||
2. 本期是否要指针右键上下文菜单(T-iPad-3 后半)——纯锦上添花,可砍到后续。
|
||||
3. 多窗口(拖会话开新窗并排两终端)确认放到下一期?
|
||||
**待你拍板** —— 三项均已落定(2026-07-30 按源码核对):
|
||||
1. ~~iPad 最低系统版本~~ **已定**:iPadOS **17**,与 iPhone 一致(`project.yml` 单一 `deploymentTarget: iOS 17.0`,无独立 iPad 下限)。
|
||||
2. ~~本期是否要指针右键上下文菜单~~ **已做**:`Components/TerminalContextMenu.swift`(复制选区 / 在 cwd 开新会话 / 结束会话,全部路由既有通道,kill 仍经 APIClient 带 Origin)+ `TerminalContextMenuTests`。
|
||||
3. ~~多窗口~~ **确认推迟**:本期单场景(§0 非目标);拖会话开新窗并排两终端仍未开工,另立计划。
|
||||
|
||||
**工作量合计**:W0 0.5 + W1 2 + W2(3∥4)2 + W3 0.5 ≈ **5 人日**(并行后墙钟更短)。
|
||||
|
||||
|
||||
@@ -24,6 +24,42 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### 📱 [x] iOS 收尾波 — 6 项整改 + P2 全波 + 两端令牌(2026-07-30,worktree `ios-completion`)
|
||||
|
||||
- **起因**: 先做了一次 16-agent 的 iOS 完成度审计(2026-07-29),结论是"代码层面基本完工、交付层面卡在真机门口":43 个计划任务 27 DONE / 11 PARTIAL / 5 MISSING,**从未在任何真机上跑过一次**,且已落后服务端两个月的新功能。用户要求"全部实现",并定了三个范围问题:P2 全 5 项在内、Apple 账号是**免费个人 team**、Android 令牌一起补。
|
||||
- **编排**: 契约先冻结在 [`docs/plans/ios-completion.md`](./plans/ios-completion.md) §1(orchestrator 亲自核服务端源码),再派 3 个 Workflow 共 22 个 agent。**A 串行 → B 并行 5 → C 串行 4 → D 并行 3 → E 条件 → 收尾波 4**。C 波刻意串行:新增 `.swift` 必须 `xcodegen generate` 重写**共享的** `.xcodeproj`,并行必互踩。
|
||||
- 中途整个 C+D 波(7 个 agent)被 provider 侧 `529 Overloaded`/`ECONNRESET` 打挂;`resumeFromRunId` 把 A/B 从缓存回放、只重跑失败的,零返工。
|
||||
- **冻结契约(§1.1,踩过的坑都在里面)**: cookie 名 `webterm_auth`;`POST /auth` 体 `{"token":"…"}`;**`Accept` 头不能含 `text/html`**——含了会被当表单走 302 而不是 204/401;**204 但无 `Set-Cookie` = 服务端根本没开鉴权**,绝不能据此认为"已认证";原生客户端**自己手写 `Cookie` 头**,不解析 `Set-Cookie`、不用系统 cookie jar(URLSession/OkHttp 的 jar 在 WS upgrade 上行为不一致且难测);**令牌不替代 Origin**,两者都要带。
|
||||
- **真机构建解锁(最高杠杆,一次关掉 7 项验收阻塞)**: `DEVELOPMENT_TEAM=C738Z66SRW` + **target 级** `CODE_SIGN_IDENTITY`(废弃串 `"iPhone Developer"` 是 XcodeGen 的 product-type preset 在 target 层注入的,project 级怎么写都盖不住)→ 免费个人 team 上 `** BUILD SUCCEEDED **`,`-allowProvisioningUpdates` 现场签发 7 天 profile。**`aps-environment` 做成 env 开关且默认不挂**(`WEBTERM_PUSH_ENTITLEMENTS`),并**反向验证过必要性**:打开后真机构建报 `Personal development teams … do not support the Push Notifications capability`。
|
||||
- **顺手挖出一个会卡死全波的坑**: SwiftTerm 用浮动版本 `from: 1.13.0`,而 `.xcodeproj` 是 gitignore 的 → **任何人一 `xcodegen generate` 就解析到 1.15.0**,它在 1.14.0 新增的 `public var hasActiveSelection` 与 `TerminalScreen.swift` 本地同名属性冲突,且**加 `override` 修不了**(上游是 `public` 不是 `open`)。A1 先钉死版本止血,C3 删掉本地属性改用上游的、再抬到 1.15.0 根治。
|
||||
- **交付**(全部实测,非引用):
|
||||
|
||||
| | 之前 | 之后 |
|
||||
|---|---|---|
|
||||
| 包测试 | 310 | **452** |
|
||||
| App 测试 | 296(1 失败) | **550**(iPhone + iPad 各一遍,**0 known issue**) |
|
||||
| 集成测试 | 10 | **32** |
|
||||
| Android 测试 | 687 | **691** |
|
||||
| ClientTLS 覆盖率 | 55.76%(**不在门内**) | **89.49%**(已入门) |
|
||||
| 覆盖率门 | 4 个包 | **5 个包全过**(最低余量 +9.49pp) |
|
||||
| 真机构建 | `BUILD FAILED` | **`BUILD SUCCEEDED`** |
|
||||
|
||||
- **令牌**: 两端全链路(APIClient `/auth` 探针 + Keychain/Keystore 存储 + WS upgrade 带 Cookie + 401 终态不进退避环)。
|
||||
- **git 面板 parity**: iOS 补齐 `/projects/{log,pr,git/*,worktree*}` + `/sessions` + 队列——这是与 Android 和 web 差了两个月的那一块。
|
||||
- **P2 全 5 项**: 语音 PTT(epoch 防误发)、终端内搜索、worktree 生命周期、主题 + Dynamic Type、web `?join=` 互通。
|
||||
- **CI**: 修好 iOS 三条腿(缺 `npm ci` 会**硬失败**不是 skip / 缺 iPad UI 腿 / iOS 17 缺 runtime 时**静默报绿**),新建 `android.yml`(此前 Android 零 CI)。
|
||||
- **复核抓到并已修的 2 个 HIGH**: ① iOS 的 WS 令牌是**与主机无关**地解析的,混合机群(一台开令牌一台没开)下令牌门主机永远开不了终端,且 App 内无解 → 改为每主机一个 transport;② Android 把主机自身的 git 凭据 401(`src/http/git-ops.ts:108`)误报成"你的访问令牌错了",因为通用 401 映射跑在路由映射前面 → git 写路由标 `ROUTE_DEFINED`,保住服务端原文案。
|
||||
- **orchestrator 的两个裁定**: ① 令牌策略在 iOS 有 3 份、Android 1 份,**不解冻 `WireProtocol` 去收敛**(它是冻结的跨语言契约,共享密钥的 helper 不该进去),改为在 `IntegrationTests` 加漂移守卫——它是全树唯一能同时依赖三个包的目标;② `PairingProbe.swift` 越界改动是我特批的(B1 已收工释放该包),复核把它记为 LOW,已知悉。
|
||||
- **诚实的未闭合项**: release ipa 层核对(免费 team 无分发通道);真机人工腿(口述 / 扫码 / 锁屏 Face ID / IME);APNs 端到端(需付费账号);**两个 workflow 从未在任何 runner 上跑过**——本仓库唯一 remote 是自建 Gitea,GH Actions 从来没执行过,本地已逐条复跑其命令;Android instrumented 腿(令牌静态加密的唯一证明)设为 `workflow_dispatch` 触发,理由写在文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿。
|
||||
- **一个诚实的取舍**: 键栏在 AX3–AX5 的**字号**封顶在 `.accessibility2`。T-iOS-34 的验收原文是"最大字号不破版",现在任何档都不裁切;但"AX5 键帽以 AX5 字号渲染"是假的——不封顶时 AX5 需 108.24pt,会把终端吃掉。封顶值由测试钉死不得漂移。
|
||||
- **合并回 develop 时撞上了 `android-blocker-fixes`**(同期另一会话,见下一条)。**两边各自独立实现了 `WEBTERM_TOKEN` 闸**:它们走响应式(探针失败→提示→提交),本波走前置式(确认页填→`POST /auth`→Keystore)。合并是**组合**不是二选一——两条入口汇到同一个 `performProbe` 收口,两条路径都把令牌落到 WS upgrade 会读的那个 store。
|
||||
- **测试全绿不等于合并正确**:1224 个 Android 测试 0 失败的情况下,单独跑的语义复核仍抓出 **3 个真缺陷,其中 2 个是合并引入的**——
|
||||
① **解除配对不再擦除凭据**(🔴 安全):`HostRemover` 擦 cookie 但不擦新加的 `AccessTokenStore`。令牌就是 cookie 的值(整个 shell 的凭据),于是解绑后仍留在磁盘**且是活的**——`AccessTokenSource` 按 origin 取而不是按配对记录取,同一 URL 重新添加、令牌框留空会**静默用残留令牌认证**。两个分支单独都没这个洞。
|
||||
② **"两套 cookie 机制能安全共存"是错的**(🟠):复核 agent 反编译了本仓库钉死的 OkHttp 4.12.0 的 `BridgeInterceptor` 并用真 `MockWebServer` 复现——判据是"jar 非空"而**不是**"jar 里有这台主机的 `webterm_auth`",且是 `header()` **整头替换**。实测:jar 里有个无关 cookie(代理下发的)→ 令牌被从 REST **和 WS upgrade** 上整个抹掉;jar 里有陈旧 `webterm_auth` → 发出去的是陈旧值。修法是给 jar 按名过滤(读/写/水合三处),并补上**production 接线**下的回归测试——原有两个测试一个用 `NO_COOKIES` 建 client、一个用 `AccessTokenSource.NONE` 建 jar,**从来没有两者同时活着**,这正是缺陷能溜过去的原因。
|
||||
③ **索引里的 AndroidManifest 是非法 XML**(🔴):git 把两边的 `android:allowBackup="false"` 自动合成了**重复属性**,无冲突标记。工作树被修好了但没 `git add`,照常 commit 会提交坏的那份、Android 构建在 manifest 解析就挂。
|
||||
- 另修 2 项:`204 无 Set-Cookie`(=服务端没开鉴权)在响应式路径上被错误持久化,违反 §1.1 冻结规则;`ROUTE_DEFINED` 范围过宽——服务端唯一路由自有的 401 是 `src/http/git-ops.ts:108`,只有 stage/commit/push/fetch 能到达,worktree 三条根本产生不了,却被钉成 ROUTE_DEFINED(令牌门主机上会把 gate-401 显示成 git 拒绝)。iOS 侧同样的过宽 pinning 一并修。
|
||||
- **合并后实测**: Android **1236** 测试 0 失败(10 模块)、kover 四个受门模块 92–96%、iOS 452 包测 + **32** 集成测(对**合并后**的真服务器,含 develop 新加的孤儿 tmux 那套)+ 550 App 测 0 known issue + 覆盖率门 5/5。`src/` 与 develop HEAD 逐字节相同。
|
||||
- **遗留(非缺陷,是设计取舍)**: 两套 cookie 机制仍并存,按名过滤后不会互相抹掉,但 jar 仍优先于手写头。要让 store 无条件胜出得二选一(收敛成单机制 = 重写一边的测试套,或加 app→network interceptor 传递),那是带真实设计决策的后续项,不是 merge 能顺手做的。已用一条测试把当前优先级钉死。
|
||||
### 🤖 [x] Android 客户端"能装但不能用"修复 — 7 个真实缺陷已修,设备上 67 个 instrumented 测试全绿(2026-07-30,worktree `android-blocker-fixes`)
|
||||
|
||||
- **起因**: 例行问"android 客户端完成状况如何"。`android/PROGRESS_ANDROID.md` 写着 **"✅ ANDROID CLIENT COMPLETE — all 36 plan tasks landed"**,617 个 JVM 测试全绿,APK 也出得来。**这个"完成"只在编译层面成立** —— 从来没在任何真机或模拟器上跑过,而一旦跑,第一个动作就崩。审计(45 agent,含逐条对抗验证)+ 修复(多波 agent)已把 blocker 清掉。
|
||||
|
||||
@@ -152,7 +152,7 @@ Consciously punted in the planning docs; several become cheap once the two unloc
|
||||
| Cross-session cost rollup / trends | `FEATURE_WALKAWAY_WORKBENCH.md §B2.6` | Bounded ring beside the latest-only field + `GET /telemetry/summary` + dashboard panel. Overlaps the budget-guard quick win. **M.** |
|
||||
| Mission Control: cross-session feed + durable run log | (no cockpit persistence today) | Merged reverse-chron feed + append-only on-disk tail (reuse `subscription-store.ts` JSONL/atomic write, byte-capped) so overnight runs survive restart. The "while you were away" digest is its first slice. **M–L.** |
|
||||
| Per-project task backlog | task tracking unbuilt | Per-repo TODO store mirroring `prefs-store.ts`; one tap spawns Claude pre-filled. Local state, **not** a GitHub Issues sync (YAGNI). **M.** |
|
||||
| Host tmux session discovery + attach | user request (this is not raw-iTerm; needs tmux) | We already run as a tmux client on the user's default tmux server (sessions `web_<id>`), so `tmux ls` already sees manual/iTerm tmux sessions. Add a discovery list + "attach external tmux session" entry in the launcher → spawn a client PTY `tmux attach -t <name>`, wrapped in the session model. **S–M.** |
|
||||
| Host tmux session discovery + attach | user request (this is not raw-iTerm; needs tmux) | **Half shipped 2026-07-30** as feature **A** (`4892fa7`/`f6ef19e`/`d39a0ab`): the server enumerates its own untracked `web_<id>` tmux sessions (`GET /orphan-sessions` + 4 routes) and the web launcher shows them in a "可恢复" section — one tap re-adopts via the existing `attach(id)` path. **Remaining (a) — iOS + Android consume it:** see [`plans/w7-orphan-session-clients.md`](./plans/w7-orphan-session-clients.md). **M.** **Remaining (b) — the original ask, still unbuilt:** *foreign* tmux sessions (manual/iTerm, not named `web_*`) are still invisible; feature A deliberately only catalogues our own. **S–M.** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
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` 同一通路)。
|
||||
98
docs/plans/w7-orphan-session-clients.md
Normal file
98
docs/plans/w7-orphan-session-clients.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Orphan tmux sessions on iOS + Android (client parity)
|
||||
|
||||
> **Feature id:** `w7-orphan-session-clients` · **Branch:** `develop` · **Effort: M** · **Server changes:** ZERO (all five routes shipped 2026-07-30 as feature **A**).
|
||||
|
||||
## Why this exists (and why it is not a regression)
|
||||
|
||||
The server half shipped on `develop` in three commits — `4892fa7` (feature), `f6ef19e` (review round 1, 10 findings), `d39a0ab` (round 2). Both native clients are at **zero**: `grep -rn "orphan-sessions" ios/ android/` returns nothing.
|
||||
|
||||
**This is a new gap, not a regression.** `OrphanSessionInfo` is deliberately a *separate* type rather than a flag on `LiveSessionInfo`, and `src/server.ts:600` names the reason outright — the Android/iOS clients decode `/live-sessions`, so a variant shape there would have broken them. The existing decoders are untouched and remain correct.
|
||||
|
||||
**Why it still matters.** `SessionManager.shutdown` calls `pty.kill()` for tmux-backed sessions (`src/session/manager.ts:410-417`) — that detaches the tmux *client*, it does not kill the shell. So **every session survives a server restart or a desktop-app quit as an untracked tmux session**. Before feature A those were unreachable forever. Today the web launcher shows them in a "可恢复" section and one tap re-adopts them; **the phone still shows an empty session list**. That is precisely the walk-away case this product exists for (`CLAUDE.md` → "What This Is"), which is why this is worth a plan rather than a backlog line.
|
||||
|
||||
## Contract (routes / types / semantics)
|
||||
|
||||
All five routes live in `src/server.ts:592-670`, in their **own namespace** — not under `/live-sessions`.
|
||||
|
||||
| Method / path | Origin | Rate-limited | Success | Failure |
|
||||
|---|---|---|---|---|
|
||||
| `GET /orphan-sessions` | **no** | yes | `200` bare `OrphanSessionInfo[]`, server-sorted most-recently-active first | `429` empty body. **Never 500** — tmux failures return `[]` (`src/session/tmux.ts:237-239`) |
|
||||
| `GET /orphan-sessions/count-idle?idleDays=N` | **no** | yes | `200 {"count": number}` | `400 {"error":"idleDays must be a number >= 1"}`, `429` |
|
||||
| `GET /orphan-sessions/:id/preview` | **no** | yes | `200 {id, cols: 0, rows: 0, data}` | `400 {"error":"invalid session id"}`, `404` empty, `429` |
|
||||
| `DELETE /orphan-sessions?idleDays=N` | **YES** | no | `200 {"killed": number, "ids": string[]}` | `403` empty, `400` |
|
||||
| `DELETE /orphan-sessions/:id` | **YES** | no | `204` empty | `403`, `404` empty, `400` |
|
||||
|
||||
- **Rate limit**: `ORPHAN_PREVIEW_RATE_MAX = 240` per 60 s per IP, **one shared bucket across all three GETs** (`src/server.ts:98-100, 260`). A polling client previewing N cards burns N+1 tokens per tick — budget it explicitly.
|
||||
- **Access token**: ordinary remote routes, so `authGate` covers all five (`src/server.ts:432-474`). Unauthed with `Accept: text/html` ⇒ **302 → `/login`**; otherwise `401 {"error":"authentication required"}`. Both clients already froze the "`Accept` must not contain `text/html`" rule for `POST /auth` ([`ios-completion.md`](./ios-completion.md) §1.1) — same trap, same fix.
|
||||
|
||||
```ts
|
||||
// src/types.ts:339-350 — every field required on the wire
|
||||
export interface OrphanSessionInfo {
|
||||
id: string; // bare UUID v4; the tmux name is web_<id>
|
||||
createdAt: number; // epoch ms
|
||||
lastActivityAt: number; // tmux's OWN clock — survives server restarts
|
||||
attached: boolean; // tmux client count > 0
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Web reference implementation** — mirror it: `public/launcher.ts` (the section, cards, polling, cleanup flow, copy) and `public/preview-grid.ts` (shared card factory, the five fetch wrappers, `orphanAsLive`).
|
||||
|
||||
## The eleven things a client implementer will get wrong
|
||||
|
||||
Each of these is either a shipped review finding or a documented server subtlety. They are the reason this is a plan and not a one-line ticket.
|
||||
|
||||
1. **"Join" is not a route.** Opening an orphan is `attach(<id>)` on the ordinary WS — Case 3.5 (`src/session/manager.ts:162-170`) runs `tmux new-session -A -s web_<id>`, re-adopts the shell, and it becomes a normal `LiveSessionInfo`. No new protocol, no new endpoint. FE precedent: `public/launcher.ts:161-171`.
|
||||
2. **Preview returns `cols: 0, rows: 0` literally** (`src/server.ts:645`) — the shape matches `/live-sessions/:id/preview` only so one FE code path renders both. Size the offscreen terminal from `OrphanSessionInfo.cols/rows`, never from the preview body. **This is the single most likely silent bug on both clients** (iOS `SessionThumbnailRenderer`, Android `ThumbnailPipeline` — both currently size from the preview response).
|
||||
3. **Failure must never collapse to empty.** `[]` means "no orphans"; a 429/500/network error must leave the section untouched. One 429 rendering as "all your recovered sessions are gone" would tear down every mounted card. Web returns `null` for failure (`public/preview-grid.ts:216-231`); this was a MEDIUM review finding.
|
||||
4. **`attached: true` ≠ "this server is streaming it."** It is tmux's own client count — a plain `tmux attach` or a second server process counts (`src/session/tmux.ts:64-70`). "Untracked here" does not mean "abandoned". Bulk cleanup skips attached sessions on purpose.
|
||||
5. **Never count cards for the destructive confirmation.** The web grid caps at `MAX_ORPHAN_CARDS = 24` while the bulk DELETE matches everything the server enumerates — on the real host the dialog said "24" and would have ended 33 shells (HIGH finding, fixed in `f6ef19e`). `count-idle` exists solely to answer this; **if it fails, refuse to proceed** (`public/launcher.ts:245-256`).
|
||||
6. **`lastActivityAt` is `max(window_activity, session_activity)`** (`src/session/tmux.ts:196-206`). Reading the client clock alone was the CRITICAL round-1 bug: it only advances on *client* activity, so a detached session printing output forever looks idle, and idle-cleanup would kill exactly the long-running unattended sessions this app exists for (3 of 69 real sessions were one click from deletion). Do not re-derive idleness from `createdAt` or from `lastOutputAt` semantics.
|
||||
7. **Status is unknowable.** The web adapter maps to `status: 'unknown'`, `cwd: null`, `clientCount: attached ? 1 : 0`, `lastOutputAt: lastActivityAt` (`public/preview-grid.ts:256-277`), noting that claiming `'idle'` would assert something never looked at. Both clients' status decoders already map unknown → `.unknown`; **reuse it, do not invent an `orphan` status.**
|
||||
8. **The whole surface is inert when `USE_TMUX` is off** — `listOrphans` returns `[]`, `mayActOnOrphan` returns false (`src/session/manager.ts:437, 449`). There is **no capability endpoint** (`GET /config/ui` exposes only `allowAutoMode`/`costBudgetUsd`), so an empty list is the only signal — hide the section, never error. Same discovery hole [`w5-android-parity.md`](./w5-android-parity.md) documents for `worktreeEnabled`.
|
||||
9. **Preview once per card, latched on success** — each call spawns a `tmux capture-pane` (~58 ms measured). Latch on "preview succeeded", not "card created", or a card that loses its one request stays blank forever (`public/launcher.ts:213-227`).
|
||||
10. **Origin-iff-guarded, both directions.** The three GETs must **not** carry `Origin`; both DELETEs must carry it byte-equal. Both clients already enforce this structurally (iOS `Endpoints.swift:81`, Android `ApiRoute.toHttpRequest`) — a route-shape test asserting **both** directions is mandatory, matching the existing `GitRouteShapeTest` / `gitOpsRoute` split.
|
||||
11. **404 is overloaded three ways** on preview and single-delete: tmux off / id not a `web_*` session / the id is already tracked live. The client cannot distinguish them — surface it inertly, never as "session died". Ids are UUID v4 (`SESSION_ID_RE`); pre-validate with the existing `Validation` before any request.
|
||||
|
||||
## Files to change
|
||||
|
||||
| Path | Concrete change |
|
||||
|---|---|
|
||||
| `ios/Packages/APIClient/Sources/APIClient/**` | `OrphanSession.swift`: model + the five route builders (3 RO, 2 G) + `count-idle`; lossy decode; reuse `pathId`/`percentEncode` choke points |
|
||||
| `ios/App/WebTerm/Screens/SessionListScreen.swift` + a new section view | The "可恢复" section, hidden when the list is empty; tap → `attach(id)` through the existing controller |
|
||||
| `ios/App/WebTerm/Components/SessionThumbnail.swift` | Accept an explicit size rather than reading `cols/rows` from the preview body (see trap #2) |
|
||||
| `android/api-client/src/main/kotlin/.../{models,routes}` | `OrphanSession.kt` + the five routes on `Endpoints.kt`; all five `ACCESS_TOKEN_GATE` (no route-defined 401 exists here) |
|
||||
| `android/app/src/main/java/.../{viewmodels,screens}` | Same section + adopt wiring; same thumbnail sizing fix in `ThumbnailPipeline` |
|
||||
| `docs/ANDROID_CLIENT_PLAN.md` §4.2/§4.3 | Move `/orphan-sessions*` out of §4.4's do-not-consume list into the consumed tables **when the work lands**, not before |
|
||||
| `docs/PLAN_IOS_CLIENT.md` §7.1 | Append the delivered capability (no new `T-iOS-*` id — §7.1's stated rule) |
|
||||
| `docs/PROGRESS_LOG.md` | Orchestrator appends the entry (not the builder) |
|
||||
|
||||
## TDD steps (ordered)
|
||||
|
||||
Both clients are independent lanes after the contract is frozen; within a lane, API layer before UI.
|
||||
|
||||
**Phase A — API layer (parallel: iOS ∥ Android).** RED the route shapes first: Origin-iff-guarded in both directions; `count-idle` query encoding and the `400` mapping; the `429`-is-not-empty rule (#3); `cols/rows: 0` decoded but *not* used for sizing (#2); UUID pre-validation.
|
||||
**Phase B — presentation logic.** Sorting is server-side — assert the client preserves order and does not re-sort by `createdAt` (#6). Status maps to `.unknown` (#7). Empty list hides the section (#8).
|
||||
**Phase C — destructive flow.** `count-idle` drives the confirmation number; a failed `count-idle` blocks the action (#5); attached sessions are never offered for bulk cleanup (#4).
|
||||
**Phase D — adopt.** Tapping an orphan goes through the existing `attach(id)` path and the card transitions to a live session (#1); no new endpoint is introduced.
|
||||
**Phase E — end-to-end.** iOS `IntegrationTests` has a real-tmux-capable harness already; add a leg that creates a `web_<uuid>` tmux session, sees it listed, previews it, adopts it, and kills it. Mirror the discipline of `test/integration/orphan-sessions.test.ts`, which **deliberately never issues the bulk DELETE** because it runs against the developer's real tmux server (`:7-11`) — the client leg must be equally careful.
|
||||
|
||||
> **Dispatch gate (house rule, [`PLAN_IOS_CLIENT.md`](../PLAN_IOS_CLIENT.md) §7):** the phases above are dispatch metadata only. The full RED test list is expanded by each task owner at start-of-work into `## RED test list` below, **before** any implementation. Do not dispatch from the phase descriptions alone.
|
||||
|
||||
## Security
|
||||
|
||||
- Origin-iff-guarded on all five, asserted both directions (#10). The token is additive and never a substitute — same rule as [`ios-completion.md`](./ios-completion.md) §1.1.
|
||||
- `preview.data` is raw `capture-pane -p -e` output — **attacker-influenced bytes with escape sequences preserved**. Render it only through the existing offscreen-terminal path that already treats `/live-sessions/:id/preview` as untrusted; never into a text view, never into a log.
|
||||
- The destructive confirmation must state a **true** number (#5). A dialog that undercounts is a data-loss bug, not a UX nit — it already nearly was one.
|
||||
- `Accept` must not contain `text/html`, or the auth gate answers with a login page a JSON decoder will mis-parse.
|
||||
|
||||
## Effort & dependencies
|
||||
|
||||
**Effort: M** (~3–4 days across both clients). Zero server change. No external dependency. Depends on nothing in flight; the API layers of both clients are stable after `ios-completion`.
|
||||
|
||||
Related: [`w5-android-parity.md`](./w5-android-parity.md) (same shape — server ahead, client behind), [`ios-completion.md`](./ios-completion.md) (frozen token contract, Origin-iff-G rule).
|
||||
|
||||
## RED test list (appended by each task owner at start-of-work)
|
||||
|
||||
<!-- empty until dispatched — see the dispatch gate above -->
|
||||
@@ -54,9 +54,26 @@ struct KeyBarButtonSpec: Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Push-to-talk outlets for the 🎤 key (T-iOS-31). Supplied as a PAIR — a mic
|
||||
/// that can start but not stop would leave the microphone hot.
|
||||
struct KeyBarVoiceHandlers {
|
||||
let onDown: @MainActor () -> Void
|
||||
let onUp: @MainActor () -> Void
|
||||
}
|
||||
|
||||
/// Button order/copy transcribed from `public/keybar.ts` `KEYBAR_BUTTONS`
|
||||
/// (most-used Claude Code keys first). The 🎤 voice button is P2 (T-iOS-31).
|
||||
/// (most-used Claude Code keys first), plus the 🎤 push-to-talk key
|
||||
/// (T-iOS-31) which mirrors `keybar.ts buildMicButton` — appended at the END,
|
||||
/// only when voice handlers are supplied, and deliberately OUTSIDE
|
||||
/// `buttons`/`KeyByteMap`: it maps to no bytes at all.
|
||||
enum KeyBarLayout {
|
||||
/// 🎤 glyph + caption, transcribed from `keybar.ts buildMicButton`.
|
||||
static let voiceLabel = "🎤"
|
||||
static let voiceCaption = "语音"
|
||||
/// Accessibility title. Unlike the web (Chrome ships audio to Google), iOS
|
||||
/// prefers on-device recognition — the copy says what actually happens.
|
||||
static let voiceTitle = "按住说话 — 转成文字后要你确认才送进终端"
|
||||
|
||||
static let buttons: [KeyBarButtonSpec] = [
|
||||
KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断",
|
||||
title: "Esc — interrupt Claude / dismiss", isPrimary: true),
|
||||
@@ -98,15 +115,89 @@ enum KeyBarLayout {
|
||||
/// Layout constants — all composed from the frozen `DS` scale (no literals).
|
||||
/// UIKit reads the same CGFloat tokens the SwiftUI chrome uses, so the key bar
|
||||
/// stays on-grid with the rest of the app.
|
||||
private enum KeyBarMetrics {
|
||||
/// A hit-target-tall row plus a little breathing room (44 + 8).
|
||||
static let barHeight: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8
|
||||
///
|
||||
/// T-iOS-34 acceptance ("亮暗主题 + **最大字号不破版**") · The bar height used
|
||||
/// to be the CONSTANT 44 + 8 = 52pt while the keycap it draws is two lines of
|
||||
/// Dynamic Type text — at AX5 that keycap needs ~108pt, so more than half of it
|
||||
/// was clipped. The height is now DERIVED from the keycap at the user's text
|
||||
/// size, with two bounds that pull in opposite directions and are both required:
|
||||
/// - a 44pt FLOOR, so the small sizes keep exactly today's 52pt bar and the HIG
|
||||
/// hit target survives (zero regression);
|
||||
/// - a CEILING on the text itself (`typeCeiling`), so the bar cannot grow
|
||||
/// without limit. This bar is an `inputAccessoryView` riding on top of the
|
||||
/// soft keyboard: an unclamped AX5 keycap turns a 52pt strip into a ~108pt one
|
||||
/// and, with the keyboard already up, eats the terminal it exists to drive.
|
||||
/// Clamping the FONT (not the frame) is what keeps that honest — the height
|
||||
/// below is computed from the SAME clamped fonts the keycaps render with, so
|
||||
/// nothing is ever clipped at any size.
|
||||
///
|
||||
/// `internal`, not `private`: the acceptance is a MEASUREMENT
|
||||
/// (`DynamicTypeLayoutTests` compares these against the real UIKit fonts and
|
||||
/// against a real `KeyBarView`), and a private constant could only be
|
||||
/// re-implemented in the test — i.e. not tested at all.
|
||||
enum KeyBarMetrics {
|
||||
static let buttonSpacing: CGFloat = DS.Space.xs4
|
||||
static let contentInset: CGFloat = DS.Space.sm8
|
||||
static let buttonInsets = NSDirectionalEdgeInsets(
|
||||
top: DS.Space.xs4, leading: DS.Space.md12,
|
||||
bottom: DS.Space.xs4, trailing: DS.Space.md12
|
||||
)
|
||||
|
||||
/// Dynamic Type ceiling for the keycap text — the UIKit currency of the DS's
|
||||
/// dense-content policy `DS.Typography.numericClamp` (`.accessibility2`,
|
||||
/// whose `UIContentSizeCategory` twin is `.accessibilityLarge`). The two are
|
||||
/// pinned equal by a test so they cannot drift apart.
|
||||
///
|
||||
/// Prose in this app scales all the way to AX5 and nothing caps it; a
|
||||
/// 17-keycap control strip docked to the keyboard is the same dense case as
|
||||
/// the telemetry chips — past this point extra size only costs terminal rows.
|
||||
static let typeCeiling: UIContentSizeCategory = .accessibilityLarge
|
||||
|
||||
/// The category the keycaps actually render at: the user's, capped at
|
||||
/// `typeCeiling`.
|
||||
static func effectiveCategory(
|
||||
_ category: UIContentSizeCategory
|
||||
) -> UIContentSizeCategory {
|
||||
category > typeCeiling ? typeCeiling : category
|
||||
}
|
||||
|
||||
/// Traits carrying the effective category — the `compatibleWith:` argument
|
||||
/// for every font below. `UIFont.preferredFont(forTextStyle:)` WITHOUT it
|
||||
/// reads the app-wide category and would ignore the clamp entirely.
|
||||
static func traits(_ category: UIContentSizeCategory) -> UITraitCollection {
|
||||
UITraitCollection(preferredContentSizeCategory: effectiveCategory(category))
|
||||
}
|
||||
|
||||
/// Glyph line ("Esc", "^C", "⇧Tab"): monospaced footnote, so the glyphs stay
|
||||
/// on a common width grid.
|
||||
static func glyphFont(_ category: UIContentSizeCategory) -> UIFont {
|
||||
UIFont.monospacedSystemFont(
|
||||
ofSize: UIFont.preferredFont(
|
||||
forTextStyle: .footnote, compatibleWith: traits(category)
|
||||
).pointSize,
|
||||
weight: .semibold
|
||||
)
|
||||
}
|
||||
|
||||
/// Caption line ("中断", "模式"): caption2, secondary label colour.
|
||||
static func captionFont(_ category: UIContentSizeCategory) -> UIFont {
|
||||
UIFont.preferredFont(forTextStyle: .caption2, compatibleWith: traits(category))
|
||||
}
|
||||
|
||||
/// What ONE keycap needs at `category`: its two text lines plus the button's
|
||||
/// own vertical content insets. Rounded up — a fractional shortfall is still
|
||||
/// a clipped descender.
|
||||
static func keycapHeight(_ category: UIContentSizeCategory) -> CGFloat {
|
||||
(glyphFont(category).lineHeight + captionFont(category).lineHeight
|
||||
+ buttonInsets.top + buttonInsets.bottom).rounded(.up)
|
||||
}
|
||||
|
||||
/// The bar's height at `category`: whatever the keycap needs, never below the
|
||||
/// 44pt hit target, plus `sm8` of breathing room. At the standard sizes the
|
||||
/// keycap is smaller than 44 ⇒ 44 + 8 = 52pt, identical to the old constant.
|
||||
static func barHeight(_ category: UIContentSizeCategory) -> CGFloat {
|
||||
max(DS.Layout.minHitTarget, keycapHeight(category)) + DS.Space.sm8
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - KeyBarView (inputAccessoryView)
|
||||
@@ -117,16 +208,53 @@ private enum KeyBarMetrics {
|
||||
final class KeyBarView: UIInputView {
|
||||
/// Tap outlet. `@MainActor`-typed so the handler can drive the ViewModel.
|
||||
var onKey: (@MainActor (KeyByteMap.Key) -> Void)?
|
||||
/// Built buttons in layout order (test-visible).
|
||||
/// Built buttons in layout order (test-visible). The 🎤 key is NOT in here —
|
||||
/// it sends no bytes, so it stays out of the KeyByteMap-backed list.
|
||||
private(set) var keyButtons: [UIButton] = []
|
||||
/// The 🎤 push-to-talk key, nil unless voice handlers were supplied.
|
||||
private(set) var voiceButton: UIButton?
|
||||
|
||||
init() {
|
||||
/// The Dynamic Type category the bar is currently laid out for (T-iOS-34).
|
||||
/// Test-visible: the acceptance is "the keycap the bar DRAWS fits the bar",
|
||||
/// which is only measurable if the category the bar drew at is knowable.
|
||||
private(set) var contentSizeCategory: UIContentSizeCategory
|
||||
|
||||
private let voice: KeyBarVoiceHandlers?
|
||||
|
||||
/// - Parameters:
|
||||
/// - voice: press/release outlets for the 🎤 key (T-iOS-31). nil ⇒ no mic
|
||||
/// key at all (mirrors the web bar, which only renders it when speech is
|
||||
/// supported AND a trigger is supplied).
|
||||
/// - contentSizeCategory: the text size to lay out for. Defaults to the
|
||||
/// app's own — the same value `UIFont.preferredFont(forTextStyle:)`
|
||||
/// reads — and is re-read from the traits whenever the user changes it.
|
||||
/// Injectable so the AX5 acceptance can be measured without driving
|
||||
/// Settings (a `UITraitCollection.performAsCurrent` wrapper would NOT
|
||||
/// work: the un-`compatibleWith:` font API ignores it, which is exactly
|
||||
/// the trap the old measurement fell into).
|
||||
init(
|
||||
voice: KeyBarVoiceHandlers? = nil,
|
||||
contentSizeCategory: UIContentSizeCategory =
|
||||
UIApplication.shared.preferredContentSizeCategory
|
||||
) {
|
||||
self.voice = voice
|
||||
self.contentSizeCategory = contentSizeCategory
|
||||
super.init(
|
||||
frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight),
|
||||
frame: CGRect(
|
||||
x: 0, y: 0, width: 0,
|
||||
height: KeyBarMetrics.barHeight(contentSizeCategory)
|
||||
),
|
||||
inputViewStyle: .keyboard
|
||||
)
|
||||
allowsSelfSizing = true
|
||||
buildBar()
|
||||
// Live text-size changes: re-render the keycaps at the new size and let
|
||||
// `allowsSelfSizing` re-measure the bar. Without this the bar would keep
|
||||
// whatever height it was born with until the terminal is reopened.
|
||||
registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) {
|
||||
(bar: KeyBarView, _: UITraitCollection) in
|
||||
bar.apply(contentSizeCategory: bar.traitCollection.preferredContentSizeCategory)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "KeyBarView is code-built only")
|
||||
@@ -135,7 +263,29 @@ final class KeyBarView: UIInputView {
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: KeyBarMetrics.barHeight)
|
||||
CGSize(
|
||||
width: UIView.noIntrinsicMetric,
|
||||
height: KeyBarMetrics.barHeight(contentSizeCategory)
|
||||
)
|
||||
}
|
||||
|
||||
/// Re-render every keycap at `category` and re-measure the bar. A no-op when
|
||||
/// the category did not actually change (trait callbacks fire for unrelated
|
||||
/// trait changes too).
|
||||
func apply(contentSizeCategory category: UIContentSizeCategory) {
|
||||
guard category != contentSizeCategory else { return }
|
||||
contentSizeCategory = category
|
||||
for (button, spec) in zip(keyButtons, KeyBarLayout.buttons) {
|
||||
button.configuration = keycapConfiguration(
|
||||
label: spec.label, caption: spec.caption, isPrimary: spec.isPrimary
|
||||
)
|
||||
}
|
||||
voiceButton?.configuration = keycapConfiguration(
|
||||
label: KeyBarLayout.voiceLabel, caption: KeyBarLayout.voiceCaption,
|
||||
isPrimary: false
|
||||
)
|
||||
frame.size.height = KeyBarMetrics.barHeight(category)
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
private func buildBar() {
|
||||
@@ -150,6 +300,11 @@ final class KeyBarView: UIInputView {
|
||||
keyButtons = keyButtons + [button]
|
||||
stack.addArrangedSubview(button)
|
||||
}
|
||||
if let voice {
|
||||
let mic = makeVoiceButton(voice)
|
||||
voiceButton = mic
|
||||
stack.addArrangedSubview(mic)
|
||||
}
|
||||
|
||||
let scroll = UIScrollView()
|
||||
scroll.showsHorizontalScrollIndicator = false
|
||||
@@ -177,41 +332,77 @@ final class KeyBarView: UIInputView {
|
||||
}
|
||||
|
||||
private func makeButton(for spec: KeyBarButtonSpec) -> UIButton {
|
||||
// Keycap look: primary (Esc) wears the accent tint, the rest a subtle
|
||||
// gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is the
|
||||
// UIKit twin of DS.Palette.textSecondary — the DS UIColor surface only
|
||||
// vends the accent, so native system labels stand in at this boundary.)
|
||||
var config: UIButton.Configuration = spec.isPrimary ? .tinted() : .gray()
|
||||
config.cornerStyle = .fixed
|
||||
config.background.cornerRadius = DS.Radius.sm8
|
||||
var label = AttributedString(spec.label)
|
||||
label.font = UIFont.monospacedSystemFont(
|
||||
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
|
||||
weight: .semibold
|
||||
let button = makeKeycap(
|
||||
label: spec.label, caption: spec.caption,
|
||||
title: spec.title, isPrimary: spec.isPrimary
|
||||
)
|
||||
config.attributedTitle = label
|
||||
var caption = AttributedString(spec.caption)
|
||||
caption.font = UIFont.preferredFont(forTextStyle: .caption2)
|
||||
caption.foregroundColor = .secondaryLabel
|
||||
config.attributedSubtitle = caption
|
||||
config.titleAlignment = .center
|
||||
config.contentInsets = KeyBarMetrics.buttonInsets
|
||||
|
||||
let button = UIButton(configuration: config)
|
||||
if spec.isPrimary {
|
||||
button.tintColor = DS.Palette.accentUIColor()
|
||||
}
|
||||
button.accessibilityLabel = spec.title
|
||||
// HIG minimum touch target regardless of glyph width.
|
||||
button.heightAnchor
|
||||
.constraint(greaterThanOrEqualToConstant: DS.Layout.minHitTarget)
|
||||
.isActive = true
|
||||
button.addAction(
|
||||
UIAction { [weak self] _ in self?.onKey?(spec.key) },
|
||||
for: .touchUpInside
|
||||
)
|
||||
return button
|
||||
}
|
||||
|
||||
/// The 🎤 key: same keycap look, but **press/release** semantics instead of a
|
||||
/// tap, and it never goes through `onKey` (it maps to no bytes at all).
|
||||
/// `.touchUpOutside`/`.touchCancel` also release, so sliding a finger off the
|
||||
/// key can never leave the microphone open.
|
||||
private func makeVoiceButton(_ voice: KeyBarVoiceHandlers) -> UIButton {
|
||||
let button = makeKeycap(
|
||||
label: KeyBarLayout.voiceLabel, caption: KeyBarLayout.voiceCaption,
|
||||
title: KeyBarLayout.voiceTitle, isPrimary: false
|
||||
)
|
||||
button.addAction(UIAction { _ in voice.onDown() }, for: .touchDown)
|
||||
for event in [UIControl.Event.touchUpInside, .touchUpOutside, .touchCancel] {
|
||||
button.addAction(UIAction { _ in voice.onUp() }, for: event)
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
/// Shared keycap chrome: primary (Esc) wears the accent tint, the rest a
|
||||
/// subtle gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is
|
||||
/// the UIKit twin of DS.Palette.textSecondary — the DS UIColor surface only
|
||||
/// vends the accent, so native system labels stand in at this boundary.)
|
||||
///
|
||||
/// Both fonts come from `KeyBarMetrics`, resolved `compatibleWith:` the
|
||||
/// bar's current (clamped) category — the SAME resolution `barHeight` is
|
||||
/// computed from, which is what makes "the bar always fits its keycaps" true
|
||||
/// by construction rather than by a hand-tuned constant.
|
||||
private func keycapConfiguration(
|
||||
label: String, caption: String, isPrimary: Bool
|
||||
) -> UIButton.Configuration {
|
||||
var config: UIButton.Configuration = isPrimary ? .tinted() : .gray()
|
||||
config.cornerStyle = .fixed
|
||||
config.background.cornerRadius = DS.Radius.sm8
|
||||
var attributedLabel = AttributedString(label)
|
||||
attributedLabel.font = KeyBarMetrics.glyphFont(contentSizeCategory)
|
||||
config.attributedTitle = attributedLabel
|
||||
var attributedCaption = AttributedString(caption)
|
||||
attributedCaption.font = KeyBarMetrics.captionFont(contentSizeCategory)
|
||||
attributedCaption.foregroundColor = .secondaryLabel
|
||||
config.attributedSubtitle = attributedCaption
|
||||
config.titleAlignment = .center
|
||||
config.contentInsets = KeyBarMetrics.buttonInsets
|
||||
return config
|
||||
}
|
||||
|
||||
private func makeKeycap(
|
||||
label: String, caption: String, title: String, isPrimary: Bool
|
||||
) -> UIButton {
|
||||
let config = keycapConfiguration(
|
||||
label: label, caption: caption, isPrimary: isPrimary
|
||||
)
|
||||
let button = UIButton(configuration: config)
|
||||
if isPrimary {
|
||||
button.tintColor = DS.Palette.accentUIColor()
|
||||
}
|
||||
button.accessibilityLabel = title
|
||||
// HIG minimum touch target regardless of glyph width.
|
||||
button.heightAnchor
|
||||
.constraint(greaterThanOrEqualToConstant: DS.Layout.minHitTarget)
|
||||
.isActive = true
|
||||
return button
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import APIClient
|
||||
import ClientTLS
|
||||
import Foundation
|
||||
import os
|
||||
import SwiftUI
|
||||
@@ -226,19 +225,25 @@ final class SessionThumbnailPipeline {
|
||||
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
|
||||
}
|
||||
|
||||
/// 生产装配:ephemeral URLSession(preview 字节可能含屏上密钥,内存缓存
|
||||
/// only——同 T-iOS-19 对 RO GET 的裁定),并携带 C-iOS-2 的设备证书身份,
|
||||
/// 这样对隧道主机的预览取数也能通过 mTLS(否则缩略图会被 nginx 拒握手)。
|
||||
/// 身份经 provider 每次握手时按需从 keychain 载入(MEDIUM 无需重启修复:
|
||||
/// 中途导入的证书下次取数即生效;缺证=nil,对本地主机无副作用)。
|
||||
static func live() -> SessionThumbnailPipeline {
|
||||
let identityStore = KeychainClientIdentityStore()
|
||||
let transport = URLSessionHTTPTransport(identityProvider: {
|
||||
identityStore.loadedIdentityOrNil()
|
||||
})
|
||||
return SessionThumbnailPipeline(
|
||||
/// 生产装配:取数走**组合根装配**的那条带凭证的传输
|
||||
/// (`AppEnvironment.thumbnailTransport()`)—— ephemeral URLSession(preview
|
||||
/// 字节可能含屏上密钥,内存缓存 only,同 T-iOS-19 对 RO GET 的裁定)
|
||||
/// + C-iOS-2 的设备证书身份(否则隧道主机的缩略图会被 nginx 拒握手)
|
||||
/// + **C1 每主机访问令牌 Cookie**。两个凭证都按请求/按握手现取,
|
||||
/// 中途导入的证书、中途输入的令牌下一次取数即生效,无需重启。
|
||||
///
|
||||
/// F1(本次修复):这里原先自建一个**没有令牌源**的传输,于是在开了
|
||||
/// `WEBTERM_TOKEN` 的主机上 `GET /live-sessions/:id/preview` 一律 401;
|
||||
/// 而本管线按设计永不抛错(失败即占位图),401 就被静默降级成
|
||||
/// **每一张**缩略图都是占位符,屏幕上没有任何原因可查。
|
||||
///
|
||||
/// - Parameter http: 取数传输。默认即上述生产装配;测试注入替身。
|
||||
static func live(
|
||||
http: any HTTPTransport = AppEnvironment.thumbnailTransport()
|
||||
) -> SessionThumbnailPipeline {
|
||||
SessionThumbnailPipeline(
|
||||
loader: { request in
|
||||
try await APIClient(endpoint: request.endpoint, http: transport)
|
||||
try await APIClient(endpoint: request.endpoint, http: http)
|
||||
.preview(id: request.sessionId)
|
||||
},
|
||||
renderer: { data, cols, rows in
|
||||
|
||||
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 WireProtocol
|
||||
|
||||
/// T-iOS-22 · Deep-link routing.
|
||||
/// T-iOS-22 / T-iOS-35 · Deep-link routing.
|
||||
///
|
||||
/// Two external entry points share ONE validation surface:
|
||||
/// Three external entry points share ONE validation surface:
|
||||
/// - `webterminal://open?host=<uuid>&join=<uuid>` (scheme registered in
|
||||
/// project.yml `CFBundleURLTypes`; web 分享 QR 互通的 `?join=` 解析是
|
||||
/// T-iOS-35 的增量), and
|
||||
/// project.yml `CFBundleURLTypes`),
|
||||
/// - **the web share link `http(s)://<host>[:<port>]/?join=<uuid>`**
|
||||
/// (T-iOS-35 — the exact shape `public/share.ts:55` puts in the 🔗 QR, i.e.
|
||||
/// `${location.origin}/?join=${sessionId}`), and
|
||||
/// - the WEBTERM_GATE push payload `{sessionId}` (T-iOS-21 reuses
|
||||
/// `route(from:)` — same UUID rules, no second parser).
|
||||
///
|
||||
@@ -25,6 +27,11 @@ enum DeepLinkRouter {
|
||||
enum Route: Equatable, Sendable {
|
||||
/// `webterminal://open` with both ids syntactically valid.
|
||||
case openSession(hostId: UUID, sessionId: UUID)
|
||||
/// Web share link (`<origin>/?join=<uuid>`). Carries the NORMALISED
|
||||
/// origin (`HostEndpoint.originHeader`) instead of a host id — the web
|
||||
/// UI has no idea what UUID this device filed that host under, so
|
||||
/// resolution is "which paired host serves this origin" (`DeepLinkHandler`).
|
||||
case joinShared(origin: String, sessionId: UUID)
|
||||
/// Push payload with a valid `sessionId` (no host id in the payload —
|
||||
/// T-iOS-21's notification handler owns the resolution strategy).
|
||||
case gateSession(sessionId: UUID)
|
||||
@@ -40,6 +47,19 @@ enum DeepLinkRouter {
|
||||
static let emptyPaths: Set<String> = ["", "/"]
|
||||
}
|
||||
|
||||
/// Whitelisted web-share shape (`http(s)://<host>[:<port>]/?join=<uuid>`).
|
||||
///
|
||||
/// Deliberately STRICTER than the custom-scheme branch: `webterminal://` can
|
||||
/// only come from something that knows our private scheme, while an http(s)
|
||||
/// URL is whatever a QR code or another app decided to hand us. So the query
|
||||
/// must be EXACTLY one `join` key (no "unknown keys are ignored" leniency
|
||||
/// here), the path must be empty/`/`, and userinfo/fragment are rejected
|
||||
/// outright (`http://user:pass@real-host@evil/…` is a classic QR phish).
|
||||
private enum WebShape {
|
||||
static let schemes: Set<String> = ["http", "https"]
|
||||
static let queryItemCount = 1
|
||||
}
|
||||
|
||||
/// Whitelisted query keys (exact match; unknown keys are ignored).
|
||||
private enum QueryKey {
|
||||
static let host = "host"
|
||||
@@ -55,8 +75,21 @@ enum DeepLinkRouter {
|
||||
|
||||
static func route(url: URL) -> Route {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
components.scheme?.lowercased() == LinkShape.scheme,
|
||||
components.host?.lowercased() == LinkShape.action,
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
// Credentials/fragment are never part of either whitelisted shape.
|
||||
components.user == nil, components.password == nil,
|
||||
components.fragment == nil
|
||||
else { return .ignore }
|
||||
|
||||
if scheme == LinkShape.scheme { return customSchemeRoute(components) }
|
||||
if WebShape.schemes.contains(scheme) { return webShareRoute(url: url, components: components) }
|
||||
return .ignore
|
||||
}
|
||||
|
||||
/// `webterminal://open?host=<uuid>&join=<uuid>` — unchanged semantics
|
||||
/// (unknown extra query keys stay tolerated; both ids required).
|
||||
private static func customSchemeRoute(_ components: URLComponents) -> Route {
|
||||
guard components.host?.lowercased() == LinkShape.action,
|
||||
LinkShape.emptyPaths.contains(components.path),
|
||||
let hostId = uniqueValidatedId(in: components, key: QueryKey.host),
|
||||
let sessionId = uniqueValidatedId(in: components, key: QueryKey.join)
|
||||
@@ -64,6 +97,23 @@ enum DeepLinkRouter {
|
||||
return .openSession(hostId: hostId, sessionId: sessionId)
|
||||
}
|
||||
|
||||
/// `http(s)://<host>[:<port>]/?join=<uuid>` — the web 🔗 share QR.
|
||||
///
|
||||
/// The origin is derived by `HostEndpoint` (the FROZEN single derivation
|
||||
/// point, plan §3.1) rather than assembled here: it also does the http(s) +
|
||||
/// non-empty-host validation and the default-port/lowercasing normalisation
|
||||
/// the store's own origins were built with, so the comparison in
|
||||
/// `DeepLinkHandler` is apples to apples.
|
||||
private static func webShareRoute(url: URL, components: URLComponents) -> Route {
|
||||
guard LinkShape.emptyPaths.contains(components.path),
|
||||
let items = components.queryItems, items.count == WebShape.queryItemCount,
|
||||
let item = items.first, item.name == QueryKey.join,
|
||||
let raw = item.value, let sessionId = validatedId(raw),
|
||||
let endpoint = HostEndpoint(baseURL: url)
|
||||
else { return .ignore }
|
||||
return .joinShared(origin: endpoint.originHeader, sessionId: sessionId)
|
||||
}
|
||||
|
||||
// MARK: - Push entry (WEBTERM_GATE payload, reused by T-iOS-21)
|
||||
|
||||
static func route(from payload: [AnyHashable: Any]) -> Route {
|
||||
@@ -175,12 +225,33 @@ final class DeepLinkHandler {
|
||||
// MARK: - Apply (host id resolves ONLY through the store)
|
||||
|
||||
private func apply(_ route: DeepLinkRouter.Route) async {
|
||||
// `.gateSession` never reaches here in P1: it only exists for the
|
||||
// push path, whose handling (host resolution incl.) is T-iOS-21.
|
||||
guard case let .openSession(hostId, sessionId) = route else { return }
|
||||
switch route {
|
||||
case let .openSession(hostId, sessionId):
|
||||
// Custom scheme: the link carries OUR host id.
|
||||
await openSession(sessionId, onHostMatching: { $0.id == hostId })
|
||||
case let .joinShared(origin, sessionId):
|
||||
// T-iOS-35 · web share QR: match on the normalised origin. An
|
||||
// unpaired origin must NEVER be auto-added — a QR code is untrusted
|
||||
// input, so it falls through to the pairing flow (where the user
|
||||
// sees and confirms the target) exactly like an unknown host id.
|
||||
await openSession(sessionId, onHostMatching: { $0.endpoint.originHeader == origin })
|
||||
case .gateSession, .ignore:
|
||||
// `.gateSession` never reaches here in P1: it only exists for the
|
||||
// push path, whose handling (host resolution incl.) is T-iOS-21.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/// The ONE host-resolution path (both link shapes funnel through it): store
|
||||
/// lookup → open, or unknown → pairing hint. The hint copy is deliberately
|
||||
/// generic — it never echoes the link's origin or session id (a link is
|
||||
/// untrusted text; quoting it back is a phishing/log-injection surface).
|
||||
private func openSession(
|
||||
_ sessionId: UUID, onHostMatching matches: (HostRegistry.Host) -> Bool
|
||||
) async {
|
||||
do {
|
||||
let hosts = try await loadHosts()
|
||||
guard let host = hosts.first(where: { $0.id == hostId }) else {
|
||||
guard let host = hosts.first(where: matches) else {
|
||||
hintMessage = DeepLinkCopy.unknownHostHint
|
||||
actions.showPairing()
|
||||
return
|
||||
|
||||
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())
|
||||
.opacity(isStale ? DS.Opacity.stale : 1)
|
||||
.grayscale(isStale ? 1 : 0)
|
||||
// T-iOS-34 · a `lineLimit(1)` tabular pill cannot wrap, so past a point
|
||||
// extra size only truncates it. Same ceiling as `dsMetaText`.
|
||||
.dynamicTypeSize(...DS.Typography.numericClamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +125,12 @@ struct DSButtonStyle: ButtonStyle {
|
||||
enum Kind { case primary, secondary, destructive }
|
||||
var kind: Kind = .primary
|
||||
|
||||
/// T-iOS-34 · how far a label may shrink before it would rather overflow.
|
||||
/// A gate's two half-width buttons at AX5 hold a word that cannot hyphenate
|
||||
/// ("Approve"); a small scale-down plus wrapping keeps it inside the pill
|
||||
/// instead of bleeding past the rounded edge.
|
||||
fileprivate static let labelMinScale: CGFloat = 0.75
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
DSButtonBody(kind: kind, configuration: configuration)
|
||||
}
|
||||
@@ -137,6 +146,12 @@ struct DSButtonStyle: ButtonStyle {
|
||||
var body: some View {
|
||||
configuration.label
|
||||
.font(DS.Typography.body.weight(.semibold))
|
||||
// Dynamic Type: wrap + center + a bounded shrink, then let the
|
||||
// pill grow taller. `minHeight` (not `height`) is what keeps a
|
||||
// wrapped AX5 label from being clipped to 44pt.
|
||||
.multilineTextAlignment(.center)
|
||||
.minimumScaleFactor(DSButtonStyle.labelMinScale)
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
|
||||
.foregroundStyle(foreground)
|
||||
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
|
||||
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)
|
||||
/// continuing the web selection color.
|
||||
///
|
||||
/// T-iOS-34 · dark-mode-first no longer means dark-mode-only: the app has a real
|
||||
/// theme setting (`AppTheme` / `ThemeStore`, injected once by `RootView`), so
|
||||
/// EVERY color token must resolve sensibly in both schemes. Adaptive tokens are
|
||||
/// built with the private `adaptive(dark:light:)` helper below and their light
|
||||
/// values are contrast-audited in `AppThemeTests` (WCAG 1.4.11, 3:1 floor for
|
||||
/// non-text UI). Known accepted gap: `accent` at its frozen light value #C9892F
|
||||
/// is 2.88:1 on white — fine as a FILL (dark ink on top is 6.2:1) but marginal
|
||||
/// as bare tinted text; the value is pinned by `DesignSystemTests` and left
|
||||
/// unchanged here (reported to the orchestrator instead of silently re-toned).
|
||||
///
|
||||
/// Vocabulary (all under `DS.`):
|
||||
/// - `Palette` — adaptive `accent` (amber gold, matches desktop) · semantic `status*` colors
|
||||
/// (color is NEVER the only status signal — pair with a symbol,
|
||||
@@ -59,21 +69,36 @@ enum DS {
|
||||
/// ink for contrast — mirrors web --on-accent #1A1305).
|
||||
static let onAccent = rgb(26, 19, 5)
|
||||
/// Faint accent wash (selection/soft highlight) — web --accent-soft.
|
||||
static let accentSoft = Color(red: 0xE3 / 255.0, green: 0xA6 / 255.0, blue: 0x4A / 255.0, opacity: 0.15)
|
||||
/// Adaptive (T-iOS-34): the dark wash is the web value verbatim; on a
|
||||
/// light background a 15% wash of the BRIGHT gold reads as nothing, so
|
||||
/// light uses the deeper `--accent-2` gold at a slightly higher alpha.
|
||||
/// Still a wash in both (alpha < 0.3 — never a solid fill).
|
||||
static let accentSoft = adaptive(
|
||||
dark: UIColor(hex: 0xE3A6_4A, alpha: 0.15),
|
||||
light: UIColor(hex: 0xC989_2F, alpha: 0.18)
|
||||
)
|
||||
|
||||
// ── Semantic status colors (match the desktop/web status palette) ───
|
||||
// These are the ONLY status colors. `StatusStyle` pairs each with a
|
||||
// distinct SF Symbol so status is never conveyed by color alone. Hex
|
||||
// mirrors the web's warm status set (public/style.css:18-20) so iOS and
|
||||
// desktop read the same. `waiting` amber #F5B14C stays distinct from the
|
||||
// gold accent #E3A64A (brighter/less brown), and is only ever a small
|
||||
// badge fill, never a large accent surface.
|
||||
/// working — #46D07F (web --green).
|
||||
static let statusWorking = rgb(70, 208, 127)
|
||||
/// waiting / needs-me — #F5B14C (web --amber).
|
||||
static let statusWaiting = rgb(245, 177, 76)
|
||||
/// stuck — #FF6B6B (web --red).
|
||||
static let statusStuck = rgb(255, 107, 107)
|
||||
// distinct SF Symbol so status is never conveyed by color alone. The
|
||||
// DARK values mirror the web's warm status set (public/style.css:18-20)
|
||||
// byte for byte so iOS and desktop read the same; `waiting` amber
|
||||
// #F5B14C stays distinct from the gold accent #E3A64A (brighter/less
|
||||
// brown), and is only ever a small badge fill, never a large surface.
|
||||
//
|
||||
// T-iOS-34 · the LIGHT values are new. The web chrome is dark-only, so
|
||||
// there was nothing to mirror: each light value is the same hue darkened
|
||||
// until it clears WCAG 1.4.11 (3:1 for non-text UI) against a white
|
||||
// background — the bright dark-mode values sit at 1.96:1 (#46D07F),
|
||||
// 1.60:1 (#F5B14C) and 2.74:1 (#FF6B6B) on white, i.e. unreadable.
|
||||
// `AppThemeTests` pins BOTH the dark bytes and the light contrast floor.
|
||||
/// working — dark #46D07F (web --green) · light #1F8F52 (4.0:1 on white).
|
||||
static let statusWorking = adaptive(dark: 0x46D0_7F, light: 0x1F8F_52)
|
||||
/// waiting / needs-me — dark #F5B14C (web --amber) · light #C25E00
|
||||
/// (4.2:1; burnt orange keeps it distinct from the light gold accent).
|
||||
static let statusWaiting = adaptive(dark: 0xF5B1_4C, light: 0xC25E_00)
|
||||
/// stuck — dark #FF6B6B (web --red) · light #D22B2B (5.1:1).
|
||||
static let statusStuck = adaptive(dark: 0xFF6B_6B, light: 0xD22B_2B)
|
||||
/// idle — quiet secondary gray (distinguished from `unknown` by shape).
|
||||
static let statusIdle = Color.secondary
|
||||
/// unknown / no signal yet — gray.
|
||||
@@ -86,10 +111,12 @@ enum DS {
|
||||
// and `stuck` reuse the status tokens above (same meaning); `done`
|
||||
// reuses `statusWorking` (a completed run). `tool`/`user` get their own
|
||||
// tokens so nothing in the app hardcodes a raw SwiftUI color.
|
||||
/// tool run — indigo, tied to the app accent family (#5E9EFF-ish).
|
||||
static let timelineTool = rgb(94, 158, 255)
|
||||
/// user message — violet (#AF7BFF), distinct from accent & tool.
|
||||
static let timelineUser = rgb(175, 123, 255)
|
||||
/// tool run — dark #5E9EFF · light #2C6ED6 (4.8:1 on white; the bright
|
||||
/// blue is 2.63:1 there).
|
||||
static let timelineTool = adaptive(dark: 0x5E9E_FF, light: 0x2C6E_D6)
|
||||
/// user message — dark #AF7BFF · light #7A3BD6 (6.1:1; bright violet is
|
||||
/// 2.81:1). Distinct from accent & tool in both schemes.
|
||||
static let timelineUser = adaptive(dark: 0xAF7B_FF, light: 0x7A3B_D6)
|
||||
|
||||
// ── Surfaces & text ────────────────────────────────────────────────
|
||||
|
||||
@@ -106,18 +133,48 @@ enum DS {
|
||||
/// Tertiary label color (de-emphasized detail).
|
||||
static let textTertiary = Color(uiColor: .tertiaryLabel)
|
||||
|
||||
// ── Terminal canvas (fixed warm-dark, matches the desktop terminal) ──
|
||||
// A terminal reads as dark regardless of app appearance (like the
|
||||
// desktop). Values mirror the web chrome: --bg #100F0D / --text #ECE9E3.
|
||||
/// Terminal background — warm near-black #100F0D (web --bg).
|
||||
static let terminalBackground = rgb(16, 15, 13)
|
||||
/// Terminal foreground — warm off-white #ECE9E3 (web --text).
|
||||
static let terminalForeground = rgb(236, 233, 227)
|
||||
// ── Terminal canvas (theme-following as of T-iOS-34) ────────────────
|
||||
// Was a FIXED warm-dark canvas ("a terminal reads as dark regardless of
|
||||
// app appearance"). With a real light theme that stops being true — a
|
||||
// full-screen near-black slab is the loudest thing on a light UI. The
|
||||
// two schemes now live in `TerminalPalette` (which also vends the
|
||||
// already-resolved set SwiftTerm needs); these stay as the SwiftUI-side
|
||||
// tokens so existing call sites keep compiling and become adaptive.
|
||||
/// Terminal background — dark #100F0D (web --bg) · light #F6F7F9.
|
||||
static let terminalBackground = Color(uiColor: terminalBackgroundUIColor())
|
||||
/// Terminal foreground — dark #ECE9E3 (web --text) · light #1A1D24.
|
||||
static let terminalForeground = Color(uiColor: terminalForegroundUIColor())
|
||||
|
||||
/// Dynamic terminal background. Exposed as `UIColor` because SwiftTerm's
|
||||
/// `nativeBackgroundColor` takes UIKit colors AND flattens them on set
|
||||
/// (see `TerminalPalette`) — the bridge must not go through SwiftUI.
|
||||
static func terminalBackgroundUIColor() -> UIColor {
|
||||
TerminalPalette.dynamicBackground()
|
||||
}
|
||||
|
||||
/// Dynamic terminal foreground (same rationale as the background).
|
||||
static func terminalForegroundUIColor() -> UIColor {
|
||||
TerminalPalette.dynamicForeground()
|
||||
}
|
||||
|
||||
/// Build an opaque sRGB color from 0–255 components.
|
||||
private static func rgb(_ r: Double, _ g: Double, _ b: Double) -> Color {
|
||||
Color(.sRGB, red: r / 255, green: g / 255, blue: b / 255, opacity: 1)
|
||||
}
|
||||
|
||||
/// One scheme-adaptive color from two `0xRRGGBB` values. THE way to add
|
||||
/// an adaptive token — hand-rolling a `UIColor { trait in }` per token
|
||||
/// is how a scheme branch gets forgotten.
|
||||
private static func adaptive(dark: UInt32, light: UInt32) -> Color {
|
||||
adaptive(dark: UIColor(hex: dark), light: UIColor(hex: light))
|
||||
}
|
||||
|
||||
/// Same, for values that need a non-opaque alpha (washes).
|
||||
private static func adaptive(dark: UIColor, light: UIColor) -> Color {
|
||||
Color(uiColor: UIColor { trait in
|
||||
trait.userInterfaceStyle == .dark ? dark : light
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Space
|
||||
|
||||
@@ -37,6 +37,23 @@ extension DS {
|
||||
|
||||
/// The canonical meta-number font: caption-sized mono + tabular.
|
||||
static let metaMono = mono(.caption)
|
||||
|
||||
// ── Dynamic Type clamp for dense numerics (T-iOS-34) ────────────────
|
||||
|
||||
/// Upper bound applied to **numeric / meta** text only (telemetry chips,
|
||||
/// `dsMetaText` rows: `ctx 92% · $0.1234 · 161×50`).
|
||||
///
|
||||
/// Prose scales all the way to `.accessibility5` — that is the point of
|
||||
/// Dynamic Type and nothing here caps it. Tabular numerics are different:
|
||||
/// they live in one-line rows next to a status badge, and at AX4/AX5 a
|
||||
/// single chip row becomes three wrapped lines that push the row's real
|
||||
/// content off screen. Capping them at `.accessibility2` (already ~2×
|
||||
/// the default size) keeps the meta line legible AND keeps the row's
|
||||
/// primary text — the session name — visible.
|
||||
///
|
||||
/// Pinned in `DynamicTypeLayoutTests` both as a policy value and
|
||||
/// behaviorally (AX2 and AX5 must measure the same height).
|
||||
static let numericClamp: DynamicTypeSize = .accessibility2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +61,15 @@ extension DS {
|
||||
|
||||
/// Secondary + monospaced-tabular styling for a row's numeric meta line
|
||||
/// ("N 台设备在看 · 161×50"). Apply via `.dsMetaText()`.
|
||||
///
|
||||
/// T-iOS-34 · carries the `numericClamp` so every meta line in the app gets the
|
||||
/// same Dynamic Type ceiling from ONE place (per-screen clamps would drift).
|
||||
struct MetaText: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(DS.Typography.metaMono)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.dynamicTypeSize(...DS.Typography.numericClamp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,10 +191,18 @@ final class PushRegistrar {
|
||||
logger.error("remote notification registration failed: \(error)")
|
||||
}
|
||||
|
||||
/// 主机移除时注销该主机上的 device token(**additive hook**:当前 App
|
||||
/// 层尚无移除主机的 UI 路径——`HostStore.remove(id:)` 无消费者;未来的
|
||||
/// 移除路径应调用本方法。失败仅记日志:服务器侧对失效 token 也会经
|
||||
/// APNs 410 自行清理)。
|
||||
/// 主机移除时注销该主机上的 device token。
|
||||
///
|
||||
/// C1 · 这个钩子曾经**没有调用方**(App 层没有移除主机的 UI,
|
||||
/// `HostStore.remove(id:)` 无消费者),于是 APNs token 永远留在被移除的主机
|
||||
/// 上。现在接线是:配对页「已配对主机」→ `PairingViewModel.removeHost(id:)`
|
||||
/// → `Probe.unregisterPush` → `PushHostDeregistration.run(for:)`(在
|
||||
/// `AppEnvironment` 里解析到 `PushAppDelegate` 持有的**活**实例——device
|
||||
/// token 只在内存里,只有它知道)→ 本方法 → 之后才写存储删除主机。
|
||||
///
|
||||
/// 顺序是有意的:请求先发出,此时主机记录(以及它的访问令牌,令牌门后的
|
||||
/// 主机需要它才能通过 401)还在。失败仅记日志:用户要的是移除,且服务器侧
|
||||
/// 对失效 token 也会经 APNs 410 自行清理。
|
||||
func handleHostRemoved(_ host: HostRegistry.Host) async {
|
||||
registeredHostIds.remove(host.id)
|
||||
guard let token = currentTokenHex else { return }
|
||||
|
||||
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 isShowingScanner = false
|
||||
@State private var scannerError: String?
|
||||
/// C1 · Typed access token. Lives in `SecureField` view state only for as
|
||||
/// long as the prompt is up, and is cleared the moment it is submitted or
|
||||
/// abandoned — the persisted copy belongs in the Keychain, nowhere else.
|
||||
@State private var accessTokenText = ""
|
||||
/// C1 · Host pending the "移除主机" confirmation dialog (nil = none).
|
||||
@State private var hostPendingRemoval: HostRegistry.Host?
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
@@ -37,47 +43,162 @@ struct PairingScreen: View {
|
||||
probingView(pending)
|
||||
case .failed(let pending, let failure):
|
||||
FailureView(pending: pending, failure: failure, viewModel: viewModel)
|
||||
case .awaitingToken(let pending):
|
||||
tokenPrompt(pending, isValidating: false)
|
||||
case .validatingToken(let pending):
|
||||
tokenPrompt(pending, isValidating: true)
|
||||
case .paired(let host):
|
||||
pairedView(host)
|
||||
}
|
||||
}
|
||||
private var idleView: some View {
|
||||
|
||||
// MARK: - Access-token prompt (C1 · §1.1)
|
||||
|
||||
/// The host answered 401. `SecureField` (never a plain `TextField`), no
|
||||
/// autocorrect/autocapitalisation — a token is not prose — and the inline
|
||||
/// rejection comes from the VM, which never echoes the value back.
|
||||
private func tokenPrompt(
|
||||
_ pending: PairingViewModel.PendingHost, isValidating: Bool
|
||||
) -> some View {
|
||||
ScrollView {
|
||||
VStack(spacing: DS.Space.xl20) {
|
||||
VStack(spacing: DS.Space.md12) { // inviting hero
|
||||
Image(systemName: "desktopcomputer")
|
||||
.font(DS.Typography.largeTitle)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.padding(.top, DS.Space.sm8)
|
||||
Text(ScreenCopy.heroTitle)
|
||||
.font(DS.Typography.title)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(ScreenCopy.heroSubtitle)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
tokenFieldCard(pending, isValidating: isValidating)
|
||||
if let rejection = viewModel.tokenRejection {
|
||||
Label(rejection, systemImage: "exclamationmark.circle.fill")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
SectionHeader(title: ScreenCopy.manualSectionTitle)
|
||||
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
|
||||
.font(DS.Typography.mono())
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.go)
|
||||
.onSubmit { viewModel.submitManualURL(manualURLText) }
|
||||
.accessibilityIdentifier("pairing.urlField")
|
||||
Divider()
|
||||
Button(ScreenCopy.manualSubmit) {
|
||||
DS.Haptics.selection()
|
||||
viewModel.submitManualURL(manualURLText)
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.accessibilityIdentifier("pairing.submitButton")
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
if isValidating {
|
||||
ProgressView()
|
||||
}
|
||||
Button(ScreenCopy.tokenSubmit) { submitAccessToken() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.disabled(isValidating || accessTokenText.isEmpty)
|
||||
.accessibilityIdentifier("pairing.tokenSubmit")
|
||||
Button(ScreenCopy.cancel) {
|
||||
accessTokenText = ""
|
||||
viewModel.cancelAccessTokenEntry()
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.disabled(isValidating)
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
}
|
||||
|
||||
private func tokenFieldCard(
|
||||
_ pending: PairingViewModel.PendingHost, isValidating: Bool
|
||||
) -> some View {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
SectionHeader(title: ScreenCopy.tokenSectionTitle)
|
||||
Text(pending.displayAddress)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
Text(ScreenCopy.tokenHint)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
Divider()
|
||||
SecureField(ScreenCopy.tokenPlaceholder, text: $accessTokenText)
|
||||
.font(DS.Typography.mono())
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.go)
|
||||
.disabled(isValidating)
|
||||
.onSubmit { submitAccessToken() }
|
||||
.accessibilityIdentifier("pairing.tokenField")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand the token to the VM and drop the view's copy immediately.
|
||||
private func submitAccessToken() {
|
||||
let token = accessTokenText
|
||||
accessTokenText = ""
|
||||
Task { await viewModel.submitAccessToken(token) }
|
||||
}
|
||||
private var idleView: some View {
|
||||
idleContent
|
||||
.sheet(isPresented: $isShowingScanner) { scannerSheet }
|
||||
.task { await viewModel.loadPairedHosts() }
|
||||
.confirmationDialog(
|
||||
ScreenCopy.removeConfirmTitle,
|
||||
isPresented: removalDialogBinding,
|
||||
titleVisibility: .visible,
|
||||
presenting: hostPendingRemoval
|
||||
) { host in
|
||||
removalDialogActions(host)
|
||||
} message: { _ in
|
||||
Text(ScreenCopy.removeConfirmMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/// Presented iff a host is staged for removal; dismissal clears the staging.
|
||||
private var removalDialogBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { hostPendingRemoval != nil },
|
||||
set: { presented in if !presented { hostPendingRemoval = nil } }
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private func removalDialogActions(
|
||||
_ host: HostRegistry.Host
|
||||
) -> some View {
|
||||
Button(ScreenCopy.removeConfirm(host.name), role: .destructive) {
|
||||
hostPendingRemoval = nil
|
||||
Task { await viewModel.removeHost(id: host.id) }
|
||||
}
|
||||
Button(ScreenCopy.cancel, role: .cancel) { hostPendingRemoval = nil }
|
||||
}
|
||||
|
||||
private var hero: some View {
|
||||
VStack(spacing: DS.Space.md12) { // inviting hero
|
||||
Image(systemName: "desktopcomputer")
|
||||
.font(DS.Typography.largeTitle)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.padding(.top, DS.Space.sm8)
|
||||
Text(ScreenCopy.heroTitle)
|
||||
.font(DS.Typography.title)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(ScreenCopy.heroSubtitle)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var manualEntryCard: some View {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
SectionHeader(title: ScreenCopy.manualSectionTitle)
|
||||
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
|
||||
.font(DS.Typography.mono())
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.go)
|
||||
.onSubmit { viewModel.submitManualURL(manualURLText) }
|
||||
.accessibilityIdentifier("pairing.urlField")
|
||||
Divider()
|
||||
Button(ScreenCopy.manualSubmit) {
|
||||
DS.Haptics.selection()
|
||||
viewModel.submitManualURL(manualURLText)
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.accessibilityIdentifier("pairing.submitButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var idleContent: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: DS.Space.xl20) {
|
||||
hero
|
||||
manualEntryCard
|
||||
if let rejection = viewModel.inputRejection {
|
||||
Label(rejection, systemImage: "exclamationmark.circle.fill")
|
||||
.font(DS.Typography.caption)
|
||||
@@ -97,10 +218,72 @@ struct PairingScreen: View {
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
pairedHostsSection
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
.sheet(isPresented: $isShowingScanner) { scannerSheet }
|
||||
}
|
||||
|
||||
// MARK: - Paired hosts (C1 · remove + "re-pair to add/update the token")
|
||||
|
||||
/// The app's host-management surface: re-pairing the same address updates
|
||||
/// that host in place (that is how an existing host gains an access token
|
||||
/// after the server enabled `WEBTERM_TOKEN`), and 移除 deletes the record —
|
||||
/// which takes its stored token with it and de-registers its APNs token.
|
||||
@ViewBuilder private var pairedHostsSection: some View {
|
||||
if !viewModel.pairedHosts.isEmpty || viewModel.hostsErrorMessage != nil {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
SectionHeader(title: ScreenCopy.pairedSectionTitle)
|
||||
if let message = viewModel.hostsErrorMessage {
|
||||
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
}
|
||||
ForEach(viewModel.pairedHosts) { host in
|
||||
pairedHostRow(host)
|
||||
if host.id != viewModel.pairedHosts.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
Text(ScreenCopy.pairedSectionHint)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pairedHostRow(_ host: HostRegistry.Host) -> some View {
|
||||
HStack(spacing: DS.Space.md12) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
Text(verbatim: host.name)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Text(host.endpoint.originHeader)
|
||||
.dsMetaText()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
// PRESENCE only — a token value never reaches the screen.
|
||||
if host.accessToken != nil {
|
||||
Label(ScreenCopy.tokenStored, systemImage: "key.fill")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Button(role: .destructive) {
|
||||
hostPendingRemoval = host
|
||||
} label: {
|
||||
Label(ScreenCopy.remove, systemImage: "trash")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
.accessibilityLabel(ScreenCopy.removeAccessibility(host.name))
|
||||
.accessibilityIdentifier("pairing.removeHost")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var scannerSheet: some View {
|
||||
@@ -277,12 +460,22 @@ private struct FailureView: View {
|
||||
}
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
let needsSettings = failure.action == .openLocalNetworkSettings
|
||||
// C1 · 401 is ambiguous (token OR Origin), so BOTH remedies
|
||||
// stay on screen: the token prompt leads, retry stays put.
|
||||
let needsToken = failure.action == .enterAccessToken
|
||||
if needsSettings {
|
||||
Button(ScreenCopy.openSettings) { openAppSettings() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
}
|
||||
if needsToken {
|
||||
Button(ScreenCopy.enterToken) { viewModel.beginAccessTokenEntry() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.accessibilityIdentifier("pairing.enterTokenButton")
|
||||
}
|
||||
Button(ScreenCopy.retry) { Task { await viewModel.retry() } }
|
||||
.buttonStyle(DSButtonStyle(kind: needsSettings ? .secondary : .primary))
|
||||
.buttonStyle(DSButtonStyle(
|
||||
kind: (needsSettings || needsToken) ? .secondary : .primary
|
||||
))
|
||||
Button(ScreenCopy.back) { viewModel.cancel() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
}
|
||||
@@ -391,6 +584,24 @@ private enum ScreenCopy {
|
||||
static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
|
||||
static let publicAcknowledge = "我已了解风险,仍要连接"
|
||||
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
|
||||
// C1 · access token + host management
|
||||
static let enterToken = "输入访问令牌"
|
||||
static let tokenSectionTitle = "输入访问令牌"
|
||||
static let tokenPlaceholder = "WEBTERM_TOKEN"
|
||||
static let tokenHint =
|
||||
"这台主机设置了 WEBTERM_TOKEN。令牌只会保存在本机钥匙串里,绝不出现在网址或日志中。"
|
||||
static let tokenSubmit = "验证并保存"
|
||||
static let tokenStored = "已保存访问令牌"
|
||||
static let pairedSectionTitle = "已配对主机"
|
||||
static let pairedSectionHint =
|
||||
"想给已配对的主机补/换访问令牌:在上面重新输入同一个地址配对一次即可(不会重复添加)。"
|
||||
static let remove = "移除"
|
||||
static let removeConfirmTitle = "移除这台主机?"
|
||||
static let removeConfirmMessage =
|
||||
"会同时删除本机保存的访问令牌,并在该主机上注销本设备的推送。主机上正在跑的会话不受影响。"
|
||||
|
||||
static func removeConfirm(_ name: String) -> String { "移除「\(name)」" }
|
||||
static func removeAccessibility(_ name: String) -> String { "移除主机 \(name)" }
|
||||
|
||||
static func probing(_ address: String) -> String { "正在验证 \(address) …" }
|
||||
static func paired(_ name: String) -> String { "已配对:\(name)" }
|
||||
|
||||
@@ -5,6 +5,14 @@ import WireProtocol
|
||||
/// T-iOS-26 · 项目详情屏:sessions/worktrees/CLAUDE.md 渲染 + diff 入口
|
||||
/// (T-iOS-27 的 `DiffScreen(endpoint:path:http:)`)+ "在此仓库开新会话"。
|
||||
///
|
||||
/// C2 增量(与 web v0.6 / Android 对齐):
|
||||
/// - 头部**环境同步态**(`GitSyncBand`:↑↓ / 无上游 / 分离 HEAD / 脏度),不用敲
|
||||
/// git 命令就能看到"有没有没推的提交";
|
||||
/// - **Git 面板**入口(stage/commit/push/fetch + `git log` + PR/CI);
|
||||
/// - **worktree 生命周期**(T-iOS-32):新建 / 回收失效 / 删除(两级确认),
|
||||
/// 取代原先只读的列表;
|
||||
/// - **`claude --resume` 历史**:挑一条过去的会话在本仓库里接着跑。
|
||||
///
|
||||
/// 安全:名字/路径/分支/CLAUDE.md 内容全是**不可信服务器字节** ——
|
||||
/// 一律 `Text(verbatim:)`(绝不 LocalizedStringKey/Markdown/链接探测)。
|
||||
struct ProjectDetailScreen: View {
|
||||
@@ -12,7 +20,30 @@ struct ProjectDetailScreen: View {
|
||||
private let endpoint: HostEndpoint
|
||||
private let http: any HTTPTransport
|
||||
private let onOpenClaude: (String) -> Void
|
||||
@State private var isDiffPresented = false
|
||||
private let onResumeClaude: (String, String) -> Void
|
||||
|
||||
/// 详情屏级别的 worktree 状态机:只负责 prune / remove(create 归 sheet 自己的
|
||||
/// 实例,两个状态机互不干扰)。
|
||||
@State private var worktreeActions: WorktreeViewModel
|
||||
@State private var route: DetailRoute?
|
||||
@State private var sheet: DetailSheet?
|
||||
@State private var worktreePendingRemoval: WorktreeInfo?
|
||||
@State private var isPruneConfirmPresented = false
|
||||
|
||||
/// 推入式子屏(单一 destination,避免多个 `isPresented:` 目的地互相打断)。
|
||||
private enum DetailRoute: Hashable, Identifiable {
|
||||
case diff
|
||||
case gitPanel
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
private enum DetailSheet: Hashable, Identifiable {
|
||||
case newWorktree
|
||||
case resumeHistory
|
||||
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
private enum Metrics {
|
||||
/// CLAUDE.md 预览行数上限(内容裁剪,非视觉 token)。
|
||||
@@ -23,12 +54,17 @@ struct ProjectDetailScreen: View {
|
||||
viewModel: ProjectDetailViewModel,
|
||||
endpoint: HostEndpoint,
|
||||
http: any HTTPTransport,
|
||||
onOpenClaude: @escaping (String) -> Void
|
||||
onOpenClaude: @escaping (String) -> Void,
|
||||
onResumeClaude: @escaping (String, String) -> Void
|
||||
) {
|
||||
_viewModel = State(initialValue: viewModel)
|
||||
self.endpoint = endpoint
|
||||
self.http = http
|
||||
self.onOpenClaude = onOpenClaude
|
||||
self.onResumeClaude = onResumeClaude
|
||||
_worktreeActions = State(initialValue: .forProject(
|
||||
endpoint: endpoint, http: http, path: viewModel.path
|
||||
))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -36,14 +72,118 @@ struct ProjectDetailScreen: View {
|
||||
.navigationTitle(ProjectDetailCopy.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await viewModel.load() }
|
||||
.navigationDestination(isPresented: $isDiffPresented) {
|
||||
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
|
||||
.navigationDestination(item: $route) { destination(for: $0) }
|
||||
.sheet(item: $sheet) { presentedSheet(for: $0) }
|
||||
}
|
||||
|
||||
/// 破坏性 worktree 操作的确认层:prune 一道、remove 两道(第二道只在服务器
|
||||
/// 回 409「有未提交改动」时才出现)。挂在 `content` 上而不是 `body` 上,
|
||||
/// 是为了让 `body` 保持可读。
|
||||
@ViewBuilder private var content: some View {
|
||||
phaseContent
|
||||
.confirmationDialog(
|
||||
WorktreeCopy.pruneConfirmTitle,
|
||||
isPresented: $isPruneConfirmPresented,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(WorktreeCopy.pruneConfirm, role: .destructive) {
|
||||
Task {
|
||||
await worktreeActions.prune()
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(WorktreeCopy.pruneConfirmMessage)
|
||||
}
|
||||
.confirmationDialog(
|
||||
WorktreeCopy.removeConfirmTitle,
|
||||
isPresented: removalDialogBinding,
|
||||
titleVisibility: .visible,
|
||||
presenting: worktreePendingRemoval
|
||||
) { worktree in
|
||||
Button(WorktreeCopy.removeConfirm, role: .destructive) {
|
||||
remove(worktree)
|
||||
}
|
||||
} message: { worktree in
|
||||
Text(verbatim: WorktreeCopy.removeConfirmMessage(worktree.path))
|
||||
}
|
||||
.confirmationDialog(
|
||||
WorktreeCopy.forceConfirmTitle,
|
||||
isPresented: forceDialogBinding,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
// 409(脏工作树)后的**第二次**确认 —— 绝无单击数据丢失。
|
||||
Button(WorktreeCopy.forceConfirm, role: .destructive) {
|
||||
Task {
|
||||
await worktreeActions.confirmForceRemove()
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
Button(WorktreeCopy.cancel, role: .cancel) {
|
||||
worktreeActions.cancelForceRemove()
|
||||
}
|
||||
} message: {
|
||||
Text(WorktreeCopy.forceConfirmMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func destination(for route: DetailRoute) -> some View {
|
||||
switch route {
|
||||
case .diff:
|
||||
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
|
||||
case .gitPanel:
|
||||
GitPanelScreen(endpoint: endpoint, http: http, path: viewModel.path)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func presentedSheet(for sheet: DetailSheet) -> some View {
|
||||
switch sheet {
|
||||
case .newWorktree:
|
||||
WorktreeSheet(
|
||||
viewModel: .forProject(endpoint: endpoint, http: http, path: viewModel.path),
|
||||
onCreated: { Task { await viewModel.load() } },
|
||||
onOpenSession: onOpenClaude
|
||||
)
|
||||
case .resumeHistory:
|
||||
ResumeHistorySheet(
|
||||
viewModel: .forProject(endpoint: endpoint, http: http, path: viewModel.path),
|
||||
onResume: onResumeClaude
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅当某个 worktree 被暂存待删时呈现;关闭即清除暂存。
|
||||
private var removalDialogBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { worktreePendingRemoval != nil },
|
||||
set: { presented in if !presented { worktreePendingRemoval = nil } }
|
||||
)
|
||||
}
|
||||
|
||||
/// 409 → 二次确认。关闭(含滑走)等于取消,绝不残留在待 force 态。
|
||||
private var forceDialogBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
if case .forceConfirming = worktreeActions.removePhase { return true }
|
||||
return false
|
||||
},
|
||||
set: { presented in if !presented { worktreeActions.cancelForceRemove() } }
|
||||
)
|
||||
}
|
||||
|
||||
private func remove(_ worktree: WorktreeInfo) {
|
||||
Task {
|
||||
await worktreeActions.remove(worktreePath: worktree.path)
|
||||
if case .removed = worktreeActions.removePhase {
|
||||
DS.Haptics.warning()
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase switch
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
@ViewBuilder private var phaseContent: some View {
|
||||
switch viewModel.phase {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
@@ -93,8 +233,8 @@ struct ProjectDetailScreen: View {
|
||||
List {
|
||||
headerSection(detail)
|
||||
actionsSection(detail)
|
||||
sessionsSection(detail.sessions)
|
||||
worktreesSection(detail.worktrees)
|
||||
sessionsSection(detail)
|
||||
worktreesSection(detail)
|
||||
claudeMdSection(detail)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
@@ -127,6 +267,13 @@ struct ProjectDetailScreen: View {
|
||||
DirtyBadge()
|
||||
}
|
||||
}
|
||||
// 环境同步态(w6/G3):不敲 git 命令就知道有没有没推的提交。
|
||||
if let band = GitSyncBand.make(
|
||||
sync: detail.sync, dirtyCount: detail.dirtyCount,
|
||||
nowMs: Date().timeIntervalSince1970 * 1_000
|
||||
) {
|
||||
SyncSummaryChips(band: band)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,32 +293,53 @@ struct ProjectDetailScreen: View {
|
||||
))
|
||||
.listRowBackground(Color.clear)
|
||||
if detail.isGit {
|
||||
Button {
|
||||
isDiffPresented = true
|
||||
} label: {
|
||||
Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus")
|
||||
secondaryAction(GitPanelCopy.entryLabel, symbol: "point.3.filled.connected.trianglepath.dotted") {
|
||||
route = .gitPanel
|
||||
}
|
||||
secondaryAction(ProjectDetailCopy.viewDiff, symbol: "plus.forwardslash.minus") {
|
||||
route = .diff
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: DS.Space.xs4, leading: DS.Space.lg16,
|
||||
bottom: DS.Space.sm8, trailing: DS.Space.lg16
|
||||
))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func sessionsSection(_ sessions: [ProjectSessionRef]) -> some View {
|
||||
private func secondaryAction(
|
||||
_ title: String, symbol: String, action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Label(title, systemImage: symbol)
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: DS.Space.xs4, leading: DS.Space.lg16,
|
||||
bottom: DS.Space.xs4, trailing: DS.Space.lg16
|
||||
))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
@ViewBuilder private func sessionsSection(_ detail: ProjectDetail) -> some View {
|
||||
Section(ProjectDetailCopy.sessionsHeader) {
|
||||
if sessions.isEmpty {
|
||||
if detail.sessions.isEmpty {
|
||||
Text(ProjectDetailCopy.noSessions)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
} else {
|
||||
ForEach(sessions, id: \.id) { session in
|
||||
ForEach(detail.sessions, id: \.id) { session in
|
||||
sessionRow(session)
|
||||
}
|
||||
}
|
||||
// `claude --resume <id>`:挑一条历史会话在本仓库接着跑(T-iOS-32)。
|
||||
Button {
|
||||
sheet = .resumeHistory
|
||||
} label: {
|
||||
Label(ResumeCopy.entryLabel, systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: DS.Space.xs4, leading: DS.Space.lg16,
|
||||
bottom: DS.Space.xs4, trailing: DS.Space.lg16
|
||||
))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,16 +367,75 @@ struct ProjectDetailScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View {
|
||||
if !worktrees.isEmpty {
|
||||
Section(ProjectDetailCopy.worktreesHeader) {
|
||||
ForEach(worktrees, id: \.path) { worktree in
|
||||
// MARK: - Worktrees(T-iOS-32:读 + 建 + 回收 + 删)
|
||||
|
||||
/// git 仓库一律显示本区(哪怕只有一个 worktree)—— 标题固定,"空"不等于"没查"
|
||||
/// (w6/G5 的同一条纪律)。
|
||||
@ViewBuilder private func worktreesSection(_ detail: ProjectDetail) -> some View {
|
||||
if detail.isGit {
|
||||
Section {
|
||||
ForEach(detail.worktrees, id: \.path) { worktree in
|
||||
worktreeRow(worktree)
|
||||
}
|
||||
Button {
|
||||
sheet = .newWorktree
|
||||
} label: {
|
||||
Label(WorktreeCopy.sheetTitle, systemImage: "plus.rectangle.on.folder")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: DS.Space.xs4, leading: DS.Space.lg16,
|
||||
bottom: DS.Space.xs4, trailing: DS.Space.lg16
|
||||
))
|
||||
.listRowBackground(Color.clear)
|
||||
worktreeFeedback
|
||||
} header: {
|
||||
worktreeHeader(detail)
|
||||
} footer: {
|
||||
if detail.worktrees.contains(where: WorktreeViewModel.canRemove) {
|
||||
Text(ProjectDetailCopy.worktreeSwipeHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func worktreeHeader(_ detail: ProjectDetail) -> some View {
|
||||
HStack {
|
||||
Text(ProjectDetailCopy.worktreesHeader)
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
// 只有真的存在 prunable 登记项时才提供回收(不邀请无效动作)。
|
||||
if detail.worktrees.contains(where: { $0.prunable == true }) {
|
||||
Button(WorktreeCopy.pruneButton) {
|
||||
isPruneConfirmPresented = true
|
||||
}
|
||||
.font(DS.Typography.caption.weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.frame(minHeight: DS.Layout.minHitTarget)
|
||||
.disabled(worktreeActions.isBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// prune / remove 的回执(服务器安全文案原样显示;成功也要说一声)。
|
||||
@ViewBuilder private var worktreeFeedback: some View {
|
||||
switch worktreeActions.prunePhase {
|
||||
case .done(let message):
|
||||
InlineMessage(text: message, tone: .success)
|
||||
case .failed(let message):
|
||||
InlineMessage(text: message, tone: .error)
|
||||
case .idle, .pruning:
|
||||
EmptyView()
|
||||
}
|
||||
switch worktreeActions.removePhase {
|
||||
case .removed(let path):
|
||||
InlineMessage(text: WorktreeCopy.removed(path), tone: .success)
|
||||
case .failed(let message):
|
||||
InlineMessage(text: message, tone: .error)
|
||||
case .idle, .removing, .forceConfirming:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
@@ -225,12 +452,28 @@ struct ProjectDetailScreen: View {
|
||||
if worktree.locked == true {
|
||||
TagBadge(text: ProjectDetailCopy.worktreeLocked)
|
||||
}
|
||||
if worktree.prunable == true {
|
||||
TagBadge(text: ProjectDetailCopy.worktreePrunable)
|
||||
}
|
||||
}
|
||||
Text(verbatim: worktree.path)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
if worktree.locked == true {
|
||||
Text(WorktreeCopy.lockedHint)
|
||||
.dsMetaText()
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
// 主 worktree(仓库本体)与锁定的不给入口 —— 服务器必拒(400/409)。
|
||||
if WorktreeViewModel.canRemove(worktree) {
|
||||
Button(WorktreeCopy.removeButton, role: .destructive) {
|
||||
worktreePendingRemoval = worktree
|
||||
}
|
||||
.accessibilityLabel(WorktreeCopy.removeAccessibilityLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +511,7 @@ struct DirtyBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// worktree 属性标签(主/当前/已锁定):accent 描边胶囊。
|
||||
/// worktree 属性标签(主/当前/已锁定/可回收):accent 描边胶囊。
|
||||
struct TagBadge: View {
|
||||
let text: String
|
||||
|
||||
@@ -297,6 +540,8 @@ enum ProjectDetailCopy {
|
||||
static let worktreeMain = "主"
|
||||
static let worktreeCurrent = "当前"
|
||||
static let worktreeLocked = "已锁定"
|
||||
static let worktreePrunable = "可回收"
|
||||
static let worktreeSwipeHint = "左滑一行可删除该 worktree(主 worktree 与已锁定的不可删)。"
|
||||
static let detachedHead = "(分离 HEAD)"
|
||||
static let claudeMdHeader = "CLAUDE.md"
|
||||
static let claudeMdPresent = "本仓库包含 CLAUDE.md。"
|
||||
|
||||
@@ -39,7 +39,11 @@ struct ProjectsScreen: View {
|
||||
viewModel: viewModel.makeDetailViewModel(path: route.path),
|
||||
endpoint: viewModel.host.endpoint,
|
||||
http: viewModel.http,
|
||||
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) }
|
||||
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) },
|
||||
// T-iOS-32(C2):历史会话恢复走同一条开会话缝。
|
||||
onResumeClaude: { cwd, sessionId in
|
||||
viewModel.requestResumeClaude(cwd: cwd, sessionId: sessionId)
|
||||
}
|
||||
)
|
||||
}
|
||||
// T-iPad-4 · iPhone 透传(现有全高 sheet 字节级不变);iPad 套
|
||||
|
||||
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(ScreenCopy.addHost, systemImage: "plus")
|
||||
}
|
||||
// C1 · Same sheet as 配对新主机 — `PairingScreen` is the host
|
||||
// management surface (access token + 移除主机), and it already
|
||||
// receives the real `HostStore` through its VM. A second entry
|
||||
// with the management label is what makes those two actions
|
||||
// discoverable without a second navigation path to maintain.
|
||||
Button {
|
||||
onAddHost()
|
||||
} label: {
|
||||
Label(ScreenCopy.manageHosts, systemImage: "key")
|
||||
}
|
||||
.accessibilityIdentifier("sessions.manageHosts")
|
||||
Button {
|
||||
onEnroll()
|
||||
} label: {
|
||||
@@ -365,6 +376,8 @@ private enum ScreenCopy {
|
||||
static let newSession = "新建会话"
|
||||
static let kill = "结束"
|
||||
static let addHost = "配对新主机"
|
||||
/// C1 · Access token + 移除主机 (same sheet as `addHost` — see the menu).
|
||||
static let manageHosts = "管理主机与令牌"
|
||||
static let deviceCert = "设备证书"
|
||||
static let enroll = "自动获取证书"
|
||||
static let hostMenuFallback = "主机"
|
||||
|
||||
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 = 跟随自动默认。
|
||||
@State private var keyBarUserOverride: Bool?
|
||||
|
||||
/// T-iOS-33 · 终端内搜索面板状态(引擎在 `makeUIView` 里绑定)。
|
||||
@State private var searchModel = TerminalSearchModel()
|
||||
/// T-iOS-31 · 语音 PTT 状态机 + 它的 epoch 源。两者在 `init` 里成对建立,
|
||||
/// 因为 PTT 的注入出口/只读判据/epoch 都来自本屏的 `viewModel`。
|
||||
@State private var voiceEpochSource: VoiceEpochSource
|
||||
@State private var voiceModel: VoicePTTViewModel
|
||||
|
||||
/// 无障碍:减弱动态效果时,横幅进出塌成即时切换(DS.Motion.gated)。
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@@ -46,6 +53,33 @@ struct TerminalScreen: View {
|
||||
static let hideKeyBar = "隐藏快捷键栏"
|
||||
}
|
||||
|
||||
/// 显式 `init`(成员逐一对齐旧的合成 init,调用点不变):语音 PTT 的
|
||||
/// ViewModel 必须在 `@State` 初值里就拿到本屏的 `viewModel`,才能把注入出口
|
||||
/// 接到 `sendInput`、把 epoch 接到 sessionId/连接世代上。
|
||||
///
|
||||
/// SwiftUI 只保留**首次**的 `@State` 初值 —— 这正确,因为
|
||||
/// `TerminalContainerView` 用 `.id(controller.generation)` 换代时会整屏重建,
|
||||
/// 一代之内 `controller.terminalViewModel` 是稳定的。
|
||||
init(
|
||||
viewModel: TerminalViewModel,
|
||||
onNewSessionInCwd: (@MainActor () -> Void)? = nil,
|
||||
onKillSession: (@MainActor () -> Void)? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.onNewSessionInCwd = onNewSessionInCwd
|
||||
self.onKillSession = onKillSession
|
||||
|
||||
let epochSource = VoiceEpochSource()
|
||||
_voiceEpochSource = State(initialValue: epochSource)
|
||||
_voiceModel = State(initialValue: VoicePTTViewModel(
|
||||
dictation: SpeechDictation(),
|
||||
clock: ContinuousClock(),
|
||||
epoch: { epochSource.epoch },
|
||||
isReadOnly: { viewModel.isReadOnly },
|
||||
inject: { text in viewModel.sendInput(text) }
|
||||
))
|
||||
}
|
||||
|
||||
/// KeyBar 是否可见 —— 唯一判据经 `KeyBarVisibility` 纯谓词。
|
||||
private var isKeyBarVisible: Bool {
|
||||
KeyBarVisibility.isVisible(
|
||||
@@ -58,6 +92,8 @@ struct TerminalScreen: View {
|
||||
TerminalHostView(
|
||||
viewModel: viewModel,
|
||||
keyBarVisible: isKeyBarVisible,
|
||||
searchModel: searchModel,
|
||||
voiceModel: voiceModel,
|
||||
onNewSessionInCwd: onNewSessionInCwd,
|
||||
onKillSession: onKillSession
|
||||
)
|
||||
@@ -70,11 +106,37 @@ struct TerminalScreen: View {
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
// T-iOS-33 · 搜索条钉在右上 —— 位置对齐 web `#searchbox`
|
||||
// (`position:fixed; top; right`, style.css:812)。
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if searchModel.isPresented {
|
||||
TerminalSearchBar(model: searchModel)
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.padding(.top, DS.Space.sm8)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
// T-iOS-31 · PTT 卡贴底(紧邻键栏上的 🎤 键)。
|
||||
.overlay(alignment: .bottom) {
|
||||
VoicePTTBanner(model: voiceModel)
|
||||
.padding(.horizontal, DS.Space.md12)
|
||||
.padding(.bottom, DS.Space.xl20)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: viewModel.bannerModel
|
||||
)
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: searchModel.isPresented
|
||||
)
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: voiceModel.phase
|
||||
)
|
||||
.toolbar {
|
||||
searchToolbarItem
|
||||
newSessionToolbarItem
|
||||
keyBarToggleToolbarItem
|
||||
}
|
||||
@@ -84,7 +146,34 @@ struct TerminalScreen: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
|
||||
hasHardwareKeyboard = GCKeyboard.coalesced != nil
|
||||
}
|
||||
.onAppear { viewModel.start() }
|
||||
// T-iOS-31 · epoch 源跟住会话身份与连接世代:任一变化都作废在飞的口述。
|
||||
.onChange(of: viewModel.sessionId) { _, id in voiceEpochSource.noteSession(id) }
|
||||
.onChange(of: viewModel.banner) { _, banner in voiceEpochSource.noteBanner(banner) }
|
||||
.onAppear {
|
||||
viewModel.start()
|
||||
voiceEpochSource.noteSession(viewModel.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
/// T-iOS-33 · 搜索开关(镜像 web toolbar 的 🔍:开/关同一个键)。
|
||||
@ToolbarContentBuilder private var searchToolbarItem: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
if searchModel.isPresented {
|
||||
searchModel.dismiss()
|
||||
} else {
|
||||
searchModel.present()
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
searchModel.isPresented
|
||||
? TerminalSearchModel.Copy.close : TerminalSearchModel.Copy.open,
|
||||
systemImage: searchModel.isPresented ? "magnifyingglass.circle.fill"
|
||||
: "magnifyingglass"
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("terminal.searchToggleButton")
|
||||
}
|
||||
}
|
||||
|
||||
/// T-iPad-3 · KeyBar 手动切换。仅在硬件键盘在场时出现 —— 无硬件键盘的
|
||||
@@ -155,6 +244,10 @@ private struct TerminalHostView: UIViewRepresentable {
|
||||
/// T-iPad-3 · KeyBar (`inputAccessoryView`) 可见性,由 `KeyBarVisibility`
|
||||
/// 谓词在 SwiftUI 侧算出;`updateUIView` 仅在变化时增量应用。
|
||||
let keyBarVisible: Bool
|
||||
/// T-iOS-33 · 搜索面板 —— `makeUIView` 把 SwiftTerm 视图绑给它做检索引擎。
|
||||
let searchModel: TerminalSearchModel
|
||||
/// T-iOS-31 · PTT 状态机 —— 键栏 🎤 键的按下/松手出口。
|
||||
let voiceModel: VoicePTTViewModel
|
||||
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
|
||||
var onKillSession: (@MainActor () -> Void)? = nil
|
||||
|
||||
@@ -170,7 +263,15 @@ private struct TerminalHostView: UIViewRepresentable {
|
||||
let viewModel = viewModel
|
||||
terminal.onKeyCommand = { key in viewModel.send(key: key) }
|
||||
|
||||
let keyBar = KeyBarView()
|
||||
// T-iOS-33 · SwiftTerm 自带搜索即检索引擎(纯读,绝不产生 PTY 字节)。
|
||||
searchModel.attach(terminal)
|
||||
|
||||
// T-iOS-31 · 🎤 键:按下开录、松手收尾,全程不经 KeyByteMap。
|
||||
let voiceModel = voiceModel
|
||||
let keyBar = KeyBarView(voice: KeyBarVoiceHandlers(
|
||||
onDown: { Task { await voiceModel.pressDown() } },
|
||||
onUp: { Task { await voiceModel.pressUp() } }
|
||||
))
|
||||
keyBar.onKey = { key in viewModel.send(key: key) }
|
||||
terminal.installKeyBar(keyBar, visible: keyBarVisible)
|
||||
|
||||
@@ -323,11 +424,15 @@ final class KeyCommandTerminalView: TerminalView {
|
||||
addInteraction(UIContextMenuInteraction(delegate: delegate))
|
||||
}
|
||||
|
||||
/// Whether a selection exists — reuses SwiftTerm's own `copy` eligibility
|
||||
/// (`canPerformAction` returns `selection.active`); pure read, no bytes.
|
||||
var hasActiveSelection: Bool {
|
||||
canPerformAction(#selector(UIResponderStandardEditActions.copy(_:)), withSender: nil)
|
||||
}
|
||||
// NOTE (T-iOS-33/31 root fix): the "does a selection exist" read used to be
|
||||
// a LOCAL `hasActiveSelection` here, computed via SwiftTerm's own `copy`
|
||||
// eligibility (`canPerformAction` answers from `selection.active`). SwiftTerm
|
||||
// v1.14.0 promoted the very same thing to `public var hasActiveSelection`
|
||||
// (`selection?.active ?? false`) on its own view, which then COLLIDED with
|
||||
// the local one and pinned the dependency at 1.13.0 (`override` cannot fix a
|
||||
// `public`-but-not-`open` property). The local copy is therefore gone and
|
||||
// every caller — the pointer context menu, the find-bar tests — now reads
|
||||
// upstream's property, so the pin rides at 1.15.0. Do not reintroduce it.
|
||||
|
||||
/// Copy the current selection via SwiftTerm's own `copy(_:)` (selection →
|
||||
/// `UIPasteboard`). Pure UI: it never writes to the PTY, so the byte stream
|
||||
@@ -336,3 +441,23 @@ final class KeyCommandTerminalView: TerminalView {
|
||||
copy(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal search binding (T-iOS-33)
|
||||
|
||||
/// The find bar's engine is SwiftTerm's own scrollback search
|
||||
/// (`TerminalViewSearch.swift`): `findNext`/`findPrevious` select + scroll the
|
||||
/// match (that selection IS the highlight) and `clearSearch` drops both. Pure
|
||||
/// read — no PTY byte is produced, so search also works on an exited session.
|
||||
extension KeyCommandTerminalView: TerminalSearching {
|
||||
func searchNext(term: String) -> Bool {
|
||||
findNext(term)
|
||||
}
|
||||
|
||||
func searchPrevious(term: String) -> Bool {
|
||||
findPrevious(term)
|
||||
}
|
||||
|
||||
func searchClear() {
|
||||
clearSearch()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@Observable
|
||||
final class PairingViewModel {
|
||||
/// The probe, injected as a closure so tests can both fake results AND
|
||||
/// assert non-invocation before the user confirms (task RED list).
|
||||
typealias Probe = @Sendable (HostEndpoint) async -> Result<HostEndpoint, PairingError>
|
||||
/// Everything the pairing flow needs from the network/platform, injected as
|
||||
/// ONE value built by the composition root (`AppEnvironment.probe`).
|
||||
///
|
||||
/// Why a struct and not three parameters: `AppEnvironment.probe` is handed to
|
||||
/// this VM by `AppCoordinator`, so growing the capability set inside the
|
||||
/// value keeps the composition root the single place they are built — adding
|
||||
/// separately-defaulted parameters instead would silently fall back to
|
||||
/// defaults in production, i.e. a dead hook.
|
||||
///
|
||||
/// Tests fake results AND assert non-invocation before the user confirms
|
||||
/// (task RED list); `validateToken`/`unregisterPush` default to the
|
||||
/// zero-config behaviour so existing call sites stay one-liners.
|
||||
struct Probe: Sendable {
|
||||
/// Two-step host verification (`runPairingProbe`), carrying an optional
|
||||
/// candidate access token for a host that gates its surface (§1.1).
|
||||
let verifyHost: @Sendable (HostEndpoint, AccessToken?) async
|
||||
-> Result<HostEndpoint, PairingError>
|
||||
/// One-shot `POST /auth` validation of a candidate token — §1.1's four
|
||||
/// outcomes, or a transport-level failure.
|
||||
let validateToken: @Sendable (HostEndpoint, AccessToken) async
|
||||
-> Result<AccessTokenProbeResult, TokenProbeFailure>
|
||||
/// Side effect of removing a host: de-register THIS device's APNs token
|
||||
/// on that host (`PushRegistrar.handleHostRemoved`). Failure is the
|
||||
/// registrar's to log — removal must never be blocked by it.
|
||||
let unregisterPush: @Sendable (HostRegistry.Host) async -> Void
|
||||
|
||||
init(
|
||||
verifyHost: @escaping @Sendable (HostEndpoint, AccessToken?) async
|
||||
-> Result<HostEndpoint, PairingError>,
|
||||
/// Unscripted default = "this host has auth disabled", which is the
|
||||
/// zero-config LAN reality and never fabricates an authenticated state.
|
||||
validateToken: @escaping @Sendable (HostEndpoint, AccessToken) async
|
||||
-> Result<AccessTokenProbeResult, TokenProbeFailure> = { _, _ in
|
||||
.success(.authDisabled)
|
||||
},
|
||||
unregisterPush: @escaping @Sendable (HostRegistry.Host) async -> Void = { _ in }
|
||||
) {
|
||||
self.verifyHost = verifyHost
|
||||
self.validateToken = validateToken
|
||||
self.unregisterPush = unregisterPush
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a `POST /auth` probe never produced one of the four §1.1 outcomes.
|
||||
/// Deliberately payload-free about the token itself (§5.3).
|
||||
enum TokenProbeFailure: Error, Equatable, Sendable {
|
||||
/// Transport-level failure / unexpected status; carries the network
|
||||
/// description only (never the token).
|
||||
case unreachable(String)
|
||||
/// The candidate violated the frozen shape before any I/O — defensive:
|
||||
/// the VM validates first, so this should be unreachable in practice.
|
||||
case malformed
|
||||
}
|
||||
|
||||
// MARK: - UI state model
|
||||
|
||||
@@ -81,6 +131,11 @@ final class PairingViewModel {
|
||||
/// iOS Local Network permission was denied → deep-link to the app's
|
||||
/// Settings pane (its 本地网络 toggle lives there).
|
||||
case openLocalNetworkSettings
|
||||
/// C1 · The host answered **401**, which is ambiguous by design (Origin
|
||||
/// or access token — same status). Offer the one remedy that can be
|
||||
/// applied from the phone: type the access token. Retry stays available
|
||||
/// for the Origin case.
|
||||
case enterAccessToken
|
||||
}
|
||||
|
||||
struct FailureDisplay: Equatable, Sendable {
|
||||
@@ -93,6 +148,10 @@ final class PairingViewModel {
|
||||
case confirming(PendingHost)
|
||||
case probing(PendingHost)
|
||||
case failed(PendingHost, FailureDisplay)
|
||||
/// C1 · Access-token prompt for a host that answered 401.
|
||||
case awaitingToken(PendingHost)
|
||||
/// C1 · `POST /auth` in flight for the typed candidate.
|
||||
case validatingToken(PendingHost)
|
||||
case paired(HostRegistry.Host)
|
||||
}
|
||||
|
||||
@@ -112,11 +171,24 @@ final class PairingViewModel {
|
||||
/// Navigate signal: set exactly once when pairing completes (T-iOS-15
|
||||
/// observes it to move on to the session list).
|
||||
private(set) var pairedHost: HostRegistry.Host?
|
||||
/// Inline copy under the token field (wrong token / rate-limited / this host
|
||||
/// has no auth at all / shape violation). NEVER echoes the token itself.
|
||||
private(set) var tokenRejection: String?
|
||||
/// C1 · Already-paired hosts, for the manage section (token update + remove).
|
||||
/// Loaded explicitly by the screen — pairing itself never needs it.
|
||||
private(set) var pairedHosts: [HostRegistry.Host] = []
|
||||
/// Explicit store-failure copy for the manage section (never a silent
|
||||
/// empty list hiding a broken keychain).
|
||||
private(set) var hostsErrorMessage: String?
|
||||
|
||||
// MARK: - Dependencies (not observed)
|
||||
|
||||
@ObservationIgnored private let store: any HostStore
|
||||
@ObservationIgnored private let probe: Probe
|
||||
/// The token validated by `POST /auth` for the host being paired, held in
|
||||
/// memory ONLY until the host record is written (Keychain via `HostStore`).
|
||||
/// nil = no token (LAN default, or the host reported auth disabled).
|
||||
@ObservationIgnored private var candidateToken: AccessToken?
|
||||
/// C-iOS-3 · Whether a device client certificate is installed. Used to gate
|
||||
/// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
|
||||
/// production reads the keychain. Defaulted so existing call sites compile.
|
||||
@@ -124,7 +196,7 @@ final class PairingViewModel {
|
||||
|
||||
init(
|
||||
store: any HostStore,
|
||||
probe: @escaping Probe,
|
||||
probe: Probe, // C1 · a value now (three capabilities), no longer a closure
|
||||
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
|
||||
KeychainClientIdentityStore().hasInstalledIdentity()
|
||||
}
|
||||
@@ -170,11 +242,15 @@ final class PairingViewModel {
|
||||
}
|
||||
|
||||
/// Back out of confirm/failed to a clean entry state. Never probes.
|
||||
/// Drops the in-memory token candidate too — an abandoned pairing must not
|
||||
/// leave a secret behind for the next target.
|
||||
func cancel() {
|
||||
phase = .idle
|
||||
inputRejection = nil
|
||||
hasAcknowledgedPublicRisk = false
|
||||
needsPublicRiskAcknowledgement = false
|
||||
tokenRejection = nil
|
||||
candidateToken = nil
|
||||
}
|
||||
|
||||
// MARK: - Confirm → probe → store
|
||||
@@ -200,7 +276,9 @@ final class PairingViewModel {
|
||||
switch phase {
|
||||
case .idle, .confirming, .failed:
|
||||
return true
|
||||
case .probing, .paired:
|
||||
case .probing, .awaitingToken, .validatingToken, .paired:
|
||||
// Mid-credential-entry counts as busy: a scan landing while the
|
||||
// token prompt is up must not silently retarget the token.
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -209,6 +287,8 @@ final class PairingViewModel {
|
||||
inputRejection = nil
|
||||
hasAcknowledgedPublicRisk = false
|
||||
needsPublicRiskAcknowledgement = false
|
||||
tokenRejection = nil
|
||||
candidateToken = nil // a new target never inherits the previous token
|
||||
hostName = endpoint.baseURL.host ?? ""
|
||||
phase = .confirming(PendingHost(
|
||||
endpoint: endpoint, warning: Self.warning(for: endpoint)
|
||||
@@ -228,7 +308,10 @@ final class PairingViewModel {
|
||||
return
|
||||
}
|
||||
phase = .probing(pending)
|
||||
switch await probe(pending.endpoint) {
|
||||
// The candidate token (if any) travels with BOTH probe legs — the HTTP
|
||||
// one as a `Cookie` header via APIClient, the WS one via the
|
||||
// probe-scoped transport the composition root builds (§1.1).
|
||||
switch await probe.verifyHost(pending.endpoint, candidateToken) {
|
||||
case .failure(let error):
|
||||
phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
|
||||
case .success(let endpoint):
|
||||
@@ -239,13 +322,23 @@ final class PairingViewModel {
|
||||
/// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED
|
||||
/// endpoint. A store failure is surfaced explicitly (never swallowed);
|
||||
/// retry re-runs the whole confirm flow.
|
||||
///
|
||||
/// C1 · Re-pairing the SAME origin updates that host in place (its `id` is
|
||||
/// reused) instead of appending a duplicate. That is what makes "this host
|
||||
/// just got a WEBTERM_TOKEN" recoverable: pair it again, type the token, and
|
||||
/// the existing record — the one every `lastSessionId`/watermark is keyed by
|
||||
/// — gains the token.
|
||||
private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
|
||||
let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
|
||||
let existing = await existingHost(matching: endpoint)
|
||||
let host = HostRegistry.Host(
|
||||
id: UUID(),
|
||||
name: trimmedName.isEmpty ? fallbackName : trimmedName,
|
||||
endpoint: endpoint
|
||||
id: existing?.id ?? UUID(),
|
||||
name: resolvedName(for: endpoint, existing: existing),
|
||||
endpoint: endpoint,
|
||||
// A validated candidate wins; otherwise keep whatever the record
|
||||
// already had (re-pairing to fix a name must not wipe a working
|
||||
// credential). `.authDisabled` clears the candidate first, so a
|
||||
// host that reports no auth can never persist a token here.
|
||||
accessToken: candidateToken ?? existing?.accessToken
|
||||
)
|
||||
do {
|
||||
_ = try await store.upsert(host)
|
||||
@@ -255,10 +348,128 @@ final class PairingViewModel {
|
||||
))
|
||||
return
|
||||
}
|
||||
candidateToken = nil // persisted — drop the in-memory copy
|
||||
pairedHost = host
|
||||
phase = .paired(host)
|
||||
}
|
||||
|
||||
/// The already-paired host at the same origin, or nil. A store read failure
|
||||
/// degrades to nil (pair as new) rather than blocking the pairing.
|
||||
private func existingHost(matching endpoint: HostEndpoint) async -> HostRegistry.Host? {
|
||||
let hosts = try? await store.loadAll()
|
||||
return hosts?.first { $0.endpoint.originHeader == endpoint.originHeader }
|
||||
}
|
||||
|
||||
/// User-typed name wins; an untouched field keeps the existing host's name
|
||||
/// (re-pairing must not rename "MacBook" to "192.168.1.5").
|
||||
private func resolvedName(
|
||||
for endpoint: HostEndpoint, existing: HostRegistry.Host?
|
||||
) -> String {
|
||||
let trimmed = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let derived = endpoint.baseURL.host ?? endpoint.originHeader
|
||||
if trimmed.isEmpty || trimmed == derived {
|
||||
return existing?.name ?? derived
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// MARK: - Access token (§1.1: POST /auth is a one-shot pairing probe)
|
||||
|
||||
/// Failure page → token prompt. Only from a 401-shaped failure, which is the
|
||||
/// only failure whose remedy might be a token.
|
||||
func beginAccessTokenEntry() {
|
||||
guard case .failed(let pending, let failure) = phase,
|
||||
failure.action == .enterAccessToken else { return }
|
||||
tokenRejection = nil
|
||||
phase = .awaitingToken(pending)
|
||||
}
|
||||
|
||||
/// Token prompt → back to the confirm page (the URL is still fine; the user
|
||||
/// may prefer to fix `ALLOWED_ORIGINS` instead).
|
||||
func cancelAccessTokenEntry() {
|
||||
guard case .awaitingToken(let pending) = phase else { return }
|
||||
tokenRejection = nil
|
||||
candidateToken = nil
|
||||
phase = .confirming(pending)
|
||||
}
|
||||
|
||||
/// Validate a typed token against the host, then re-run the host probe with
|
||||
/// it. Shape is checked BEFORE any I/O (§1.1 charset/length is the server's
|
||||
/// own rule, so a shape violation can never be the right token).
|
||||
///
|
||||
/// The four §1.1 outcomes are all handled explicitly, and `authDisabled`
|
||||
/// deliberately does NOT persist anything: a 204 without `Set-Cookie` means
|
||||
/// the host never enabled auth, so claiming "authenticated" would be a lie
|
||||
/// and storing the token would gate nothing.
|
||||
func submitAccessToken(_ raw: String) async {
|
||||
guard case .awaitingToken(let pending) = phase else { return }
|
||||
let token: AccessToken
|
||||
do {
|
||||
token = try AccessToken(validating: raw)
|
||||
} catch let error as AccessTokenError {
|
||||
tokenRejection = PairingCopy.tokenShapeRejected(error)
|
||||
return
|
||||
} catch {
|
||||
tokenRejection = PairingCopy.tokenShapeUnknown
|
||||
return
|
||||
}
|
||||
tokenRejection = nil
|
||||
phase = .validatingToken(pending)
|
||||
switch await probe.validateToken(pending.endpoint, token) {
|
||||
case .success(.valid):
|
||||
candidateToken = token
|
||||
await runProbe(for: pending) // re-verify, now authenticated
|
||||
case .success(.authDisabled):
|
||||
candidateToken = nil
|
||||
tokenRejection = PairingCopy.tokenNotRequired
|
||||
phase = .awaitingToken(pending)
|
||||
case .success(.invalidToken):
|
||||
tokenRejection = PairingCopy.tokenInvalid
|
||||
phase = .awaitingToken(pending)
|
||||
case .success(.rateLimited):
|
||||
tokenRejection = PairingCopy.tokenRateLimited
|
||||
phase = .awaitingToken(pending)
|
||||
case .failure(.unreachable(let underlying)):
|
||||
tokenRejection = PairingCopy.hostUnreachable(underlying)
|
||||
phase = .awaitingToken(pending)
|
||||
case .failure(.malformed):
|
||||
tokenRejection = PairingCopy.tokenShapeUnknown
|
||||
phase = .awaitingToken(pending)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paired-host management (C1 · the removal path `handleHostRemoved` lacked)
|
||||
|
||||
/// Load the manage section's host list. Explicit error copy on failure.
|
||||
func loadPairedHosts() async {
|
||||
do {
|
||||
pairedHosts = try await store.loadAll()
|
||||
hostsErrorMessage = nil
|
||||
} catch {
|
||||
hostsErrorMessage = PairingCopy.hostsLoadFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a paired host: de-register this device's APNs token on it, then
|
||||
/// delete the record (which takes its access token with it — the token is a
|
||||
/// field of the record, so no orphaned secret can survive).
|
||||
///
|
||||
/// ORDER MATTERS: the de-registration POST goes out FIRST, while the record
|
||||
/// (and therefore the host's access token, which the request needs to get
|
||||
/// past a token gate) still exists. A failing de-registration never blocks
|
||||
/// the removal — the user asked for the host to be gone, and the server also
|
||||
/// prunes tokens itself on an APNs 410.
|
||||
func removeHost(id: UUID) async {
|
||||
guard let host = pairedHosts.first(where: { $0.id == id }) else { return }
|
||||
await probe.unregisterPush(host)
|
||||
do {
|
||||
pairedHosts = try await store.remove(id: id)
|
||||
hostsErrorMessage = nil
|
||||
} catch {
|
||||
hostsErrorMessage = PairingCopy.hostRemoveFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PairingError → copy + action (task RED list, one case each)
|
||||
|
||||
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
|
||||
@@ -289,7 +500,14 @@ final class PairingViewModel {
|
||||
case .originRejected(let hint):
|
||||
// The probe already derived the complete actionable copy from
|
||||
// endpoint.originHeader — surface it VERBATIM, never re-derive.
|
||||
return FailureDisplay(message: hint, action: .retry)
|
||||
//
|
||||
// C1 · The action is `.enterAccessToken`, not `.retry`: this case now
|
||||
// also carries the AMBIGUOUS 401 (Origin *or* token — the server
|
||||
// answers the same status for both, see `unauthorizedPairingHint`),
|
||||
// and typing a token is the only remedy reachable from the phone.
|
||||
// The failure view keeps a retry button alongside it, so the pure
|
||||
// Origin case (the 403 on the guarded kill) loses nothing.
|
||||
return FailureDisplay(message: hint, action: .enterAccessToken)
|
||||
case .atsBlocked(let host):
|
||||
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
|
||||
case .tlsFailure:
|
||||
@@ -418,40 +636,3 @@ final class PairingViewModel {
|
||||
private static let ipv4OctetCount = 4
|
||||
private static let ipv4OctetRange = 0...255
|
||||
}
|
||||
|
||||
/// User-facing pairing copy (plan §3.4 taxonomy → actionable wording; §5.2
|
||||
/// Local-Network guidance including the iOS 18 restart caveat).
|
||||
enum PairingCopy {
|
||||
static let scanRejected =
|
||||
"二维码不是 http(s) 地址,无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。"
|
||||
static let manualRejected =
|
||||
"无法解析这个地址。请输入完整 URL,例如 http://192.168.1.5:3000"
|
||||
static let storeFailed =
|
||||
"主机已通过验证,但保存到本机失败,请重试。"
|
||||
static let localNetworkDenied =
|
||||
"无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关"
|
||||
+ "(iOS 18 存在需要重启手机才生效的已知问题)。"
|
||||
static let notWebTerminal =
|
||||
"对方在响应 HTTP,但不是 web-terminal——端口对吗?"
|
||||
static let tlsFailure =
|
||||
"TLS 连接失败:证书无效或不受信任。"
|
||||
static let timeout =
|
||||
"连接超时。请确认主机在线、与手机在同一网络后重试。"
|
||||
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
|
||||
static let deviceCertRequired =
|
||||
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
|
||||
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
|
||||
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
|
||||
static let clientCertRejected =
|
||||
"本设备证书无效或已吊销,请重新导入。"
|
||||
|
||||
static func hostUnreachable(_ underlying: String) -> String {
|
||||
"无法连接主机:\(underlying)"
|
||||
}
|
||||
|
||||
/// §3.4 wording for the ATS cleartext block.
|
||||
static func atsBlocked(host: String) -> String {
|
||||
"明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内,"
|
||||
+ "请改用 https / tailscale serve,或反馈该网段。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(详情/差异屏的装配缝)
|
||||
|
||||
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 =
|
||||
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
|
||||
|
||||
/// Actionable copy for `.failed(.unauthorized)` (C1 over B3, ios-completion
|
||||
/// §1.1). The server answers **401 on the WS upgrade for two different
|
||||
/// reasons** — the Origin/CSWSH check runs first, the `webterm_auth` cookie
|
||||
/// second (src/server.ts:1367-1379) — and the status is identical, so the
|
||||
/// client provably cannot tell them apart. Hence the copy names BOTH
|
||||
/// remedies instead of guessing one. Retrying is pointless (the engine
|
||||
/// already treats this as terminal), so the message asks for a credential
|
||||
/// change, not patience.
|
||||
static let unauthorizedMessage =
|
||||
"主机拒绝了连接(401):访问令牌缺失或不正确,也可能是主机的 ALLOWED_ORIGINS 不含本 App 拨号的地址。"
|
||||
+ "请在会话列表的主机菜单里「管理主机与令牌」重新输入访问令牌;"
|
||||
+ "若令牌无误,就把主机的 ALLOWED_ORIGINS 设成 App 连接的完整地址后重启 web-terminal。"
|
||||
|
||||
/// Last VALID dims forwarded to the engine (SwiftTerm `sizeChanged`).
|
||||
/// Read by the wiring layer for `notifyForegrounded(dims:)` — the frozen
|
||||
/// §3.2 signature needs real cols/rows and this is their single source.
|
||||
@@ -271,6 +284,11 @@ final class TerminalViewModel {
|
||||
case .failed(.replayTooLarge):
|
||||
banner = .none
|
||||
phase = .failed(message: Self.replayTooLargeMessage)
|
||||
case .failed(.unauthorized):
|
||||
// Same shape as replayTooLarge: a terminal state, so the spinner
|
||||
// must go and the terminal becomes read-only with actionable copy.
|
||||
banner = .none
|
||||
phase = .failed(message: Self.unauthorizedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 HostRegistry
|
||||
import SessionCore
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Production dependency graph (composition root). One immutable
|
||||
@@ -12,12 +13,20 @@ import WireProtocol
|
||||
/// Assembly security audit (task 安全注, verified at this single point):
|
||||
/// - All `G` (state-changing) HTTP goes through `APIClient` (kill via
|
||||
/// SessionListViewModel's client, probe's kill round-trip inside
|
||||
/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own.
|
||||
/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own
|
||||
/// EXCEPT the access-token `Cookie` (C1) — see its type doc for why that one
|
||||
/// belongs at the transport and cannot bypass the Origin rule.
|
||||
/// - Origin is derived solely by `HostEndpoint` (WS: URLSessionTermTransport;
|
||||
/// HTTP: APIClient's route builder) — nothing here hand-assembles one.
|
||||
/// - Secrets: hosts in `KeychainHostStore`
|
||||
/// - Secrets: hosts (and their access tokens) in `KeychainHostStore`
|
||||
/// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it);
|
||||
/// UserDefaults carries only the non-secret per-host lastSessionId.
|
||||
/// UserDefaults carries only the non-secret per-host lastSessionId. A token
|
||||
/// never reaches a log line, a URL query, or an error description.
|
||||
/// - The one other `URLSession` in the app belongs to `thumbnailTransport()`
|
||||
/// below — assembled HERE, from these same providers, for the same reasons
|
||||
/// (F1: the session-thumbnail pipeline is built by a SwiftUI `@State`
|
||||
/// initializer that has no environment to read, and used to build a
|
||||
/// credential-less transport of its own).
|
||||
/// - No debug ATS overrides exist (project.yml declares NO
|
||||
/// NSAllowsArbitraryLoads in any configuration — only the five §5.2 CIDR
|
||||
/// exceptions), and no isSecureTextEntry-style screenshot hacks are used;
|
||||
@@ -27,15 +36,38 @@ struct AppEnvironment: Sendable {
|
||||
let hostStore: any HostStore
|
||||
let lastSessionStore: any LastSessionStore
|
||||
let http: any HTTPTransport
|
||||
/// The WS transport used when no per-host factory is injected: it carries
|
||||
/// NO access token, so it is correct only for a host that has none. Every
|
||||
/// terminal goes through `makeTermTransport(for:)`, which prefers
|
||||
/// `termTransportFactory` — production always installs one.
|
||||
let termTransport: any TermTransport
|
||||
/// Injected into `PairingViewModel` — production is `runPairingProbe`
|
||||
/// over the real transports (two-step: RO GET, then WS attach + guarded
|
||||
/// kill; only runs after the user's explicit confirm, T-iOS-12).
|
||||
/// kill; only runs after the user's explicit confirm, T-iOS-12), plus the
|
||||
/// `POST /auth` token probe and the host-removal side effect (C1).
|
||||
let probe: PairingViewModel.Probe
|
||||
/// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults).
|
||||
/// `var` + default so the memberwise init stays source-compatible for
|
||||
/// pre-P1 call sites while tests can inject an in-memory fake.
|
||||
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
|
||||
/// E1 · Builds the WS transport for ONE host, so the upgrade can carry that
|
||||
/// host's access token (§1.1). nil ⇒ `termTransport` for every host (the
|
||||
/// App-layer tests' `FakeTransport`); production installs the real factory.
|
||||
var termTransportFactory: (@Sendable (HostRegistry.Host) -> any TermTransport)?
|
||||
|
||||
/// The WS transport a terminal on `host` must dial.
|
||||
///
|
||||
/// E1 (HIGH) · This exists because the access-token cookie is PER HOST: the
|
||||
/// app previously shared one transport whose token was resolved
|
||||
/// host-independently, which returned nil for the mixed fleet the token
|
||||
/// feature is for (a tokened tunnel host beside an open LAN host) — the
|
||||
/// upgrade omitted the cookie, the server 401'd, and that failure is
|
||||
/// terminal with no in-app remedy. Android never had the bug because
|
||||
/// `OkHttpTermTransport` resolves `tokens.tokenFor(endpoint)`; this is the
|
||||
/// same rule.
|
||||
func makeTermTransport(for host: HostRegistry.Host) -> any TermTransport {
|
||||
termTransportFactory?(host) ?? termTransport
|
||||
}
|
||||
|
||||
static func production() -> AppEnvironment {
|
||||
// C-iOS-2 (MEDIUM no-relaunch fix) · Resolve the installed device client
|
||||
@@ -46,28 +78,228 @@ struct AppEnvironment: Sendable {
|
||||
// missing cert is the normal pre-install state (→ nil); genuine faults
|
||||
// are logged, not fatal (`loadedIdentityOrNil`). mTLS challenges only
|
||||
// fire for tunnel hosts, so a `nil` result is inert for local hosts.
|
||||
let identityStore = KeychainClientIdentityStore()
|
||||
let identityProvider: @Sendable () -> ClientIdentity? = {
|
||||
identityStore.loadedIdentityOrNil()
|
||||
let identityProvider = keychainIdentityProvider()
|
||||
let hostStore = KeychainHostStore()
|
||||
// C1 · ONE access-token source for the whole app, reading the same
|
||||
// Keychain records the pairing flow writes. Both transports consult it
|
||||
// per request/connect, so a token typed mid-run applies immediately —
|
||||
// no relaunch, and no token snapshot captured at composition.
|
||||
let tokens = AccessTokenSource(store: hostStore)
|
||||
let http = URLSessionHTTPTransport(
|
||||
identityProvider: identityProvider,
|
||||
tokenForOrigin: { origin in await tokens.token(forOrigin: origin) }
|
||||
)
|
||||
// E1 · ONE transport per host: the upgrade's Cookie is this host's token
|
||||
// and never another's. The provider is re-consulted on every connect
|
||||
// (rotation applies on the next reconnect, no relaunch).
|
||||
let termTransportFactory: @Sendable (HostRegistry.Host) -> any TermTransport = { host in
|
||||
URLSessionTermTransport(
|
||||
identityProvider: identityProvider,
|
||||
tokenProvider: { tokens.wsToken(for: host) }
|
||||
)
|
||||
}
|
||||
let http = URLSessionHTTPTransport(identityProvider: identityProvider)
|
||||
let termTransport = URLSessionTermTransport(identityProvider: identityProvider)
|
||||
return AppEnvironment(
|
||||
hostStore: KeychainHostStore(),
|
||||
hostStore: hostStore,
|
||||
lastSessionStore: UserDefaultsLastSessionStore(),
|
||||
http: http,
|
||||
termTransport: termTransport,
|
||||
probe: { endpoint in
|
||||
await runPairingProbe(endpoint: endpoint, http: http, ws: termTransport)
|
||||
}
|
||||
termTransport: URLSessionTermTransport(identityProvider: identityProvider),
|
||||
probe: PairingViewModel.Probe(
|
||||
verifyHost: { endpoint, token in
|
||||
// The candidate token has to reach BOTH probe legs. HTTP
|
||||
// takes it as a parameter (APIClient stamps the Cookie); the
|
||||
// WS leg gets a PROBE-SCOPED transport carrying exactly this
|
||||
// candidate — the frozen `TermTransport.connect` has no
|
||||
// credential parameter, and the host is not paired yet, so
|
||||
// the shared transport could not resolve it from the store.
|
||||
let ws = URLSessionTermTransport(
|
||||
identityProvider: identityProvider,
|
||||
tokenProvider: { token?.rawValue }
|
||||
)
|
||||
return await runPairingProbe(
|
||||
endpoint: endpoint, http: http, ws: ws,
|
||||
accessToken: token?.rawValue
|
||||
)
|
||||
},
|
||||
validateToken: { endpoint, token in
|
||||
await probeAccessToken(endpoint: endpoint, http: http, token: token)
|
||||
},
|
||||
unregisterPush: { host in
|
||||
await PushHostDeregistration.run(for: host)
|
||||
}
|
||||
),
|
||||
termTransportFactory: termTransportFactory
|
||||
)
|
||||
}
|
||||
|
||||
/// C-iOS-2 · The lazy, Keychain-backed device-identity provider: resolved
|
||||
/// per TLS client-certificate challenge (never snapshotted), so a certificate
|
||||
/// imported mid-run is presented on the NEXT handshake without a relaunch.
|
||||
/// ONE definition, shared by `production()` and `thumbnailTransport()`.
|
||||
static func keychainIdentityProvider() -> @Sendable () -> ClientIdentity? {
|
||||
let identityStore = KeychainClientIdentityStore()
|
||||
return { identityStore.loadedIdentityOrNil() }
|
||||
}
|
||||
|
||||
/// F1 · The HTTP transport `SessionThumbnailPipeline.live()` fetches
|
||||
/// `GET /live-sessions/:id/preview` over — assembled HERE, at the
|
||||
/// composition root, from the SAME two credential providers `production()`
|
||||
/// installs on the app-wide transport: the lazy mTLS identity AND the
|
||||
/// per-origin access token.
|
||||
///
|
||||
/// WHY IT EXISTS (the bug it fixes): the pipeline used to build its own
|
||||
/// `URLSessionHTTPTransport` with an identity provider but **no token
|
||||
/// source**, so on a `WEBTERM_TOKEN`-gated host every preview came back 401.
|
||||
/// The pipeline never throws by design — a failed preview IS a placeholder —
|
||||
/// so the whole session list silently degraded to placeholder thumbnails with
|
||||
/// nothing on screen saying why. It is assembled here rather than in the
|
||||
/// component because the pipeline is created by a SwiftUI `@State`
|
||||
/// initializer that has no `AppEnvironment` to read from.
|
||||
///
|
||||
/// It is a SEPARATE `URLSession` from `production().http` for the same reason
|
||||
/// (no environment at that call site), and that costs nothing: both are
|
||||
/// `.ephemeral` (preview bytes are raw ring-buffer terminal output and must
|
||||
/// never touch a disk cache — T-iOS-19) and both resolve their credentials
|
||||
/// per request, so neither can hold a stale token.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - hostStore: where the per-host tokens live — Keychain in production,
|
||||
/// an in-memory double in tests.
|
||||
/// - identityProvider: the mTLS identity source (see above).
|
||||
static func thumbnailTransport(
|
||||
hostStore: any HostStore = KeychainHostStore(),
|
||||
identityProvider: @escaping @Sendable () -> ClientIdentity?
|
||||
= AppEnvironment.keychainIdentityProvider()
|
||||
) -> URLSessionHTTPTransport {
|
||||
let tokens = AccessTokenSource(store: hostStore)
|
||||
return URLSessionHTTPTransport(
|
||||
identityProvider: identityProvider,
|
||||
tokenForOrigin: { origin in await tokens.token(forOrigin: origin) }
|
||||
)
|
||||
}
|
||||
|
||||
/// Away-digest source for a session engine: wraps `APIClient.events` per
|
||||
/// host (the engine never holds an HTTP client — plan §3.2).
|
||||
/// host (the engine never holds an HTTP client — plan §3.2). The token rides
|
||||
/// along at the transport, so this stays token-free.
|
||||
func makeEventsSource(endpoint: HostEndpoint)
|
||||
-> @Sendable (UUID) async throws -> [TimelineEvent] {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
return { id in try await client.events(id: id) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Access-token source (C1 · ios-completion §1.1)
|
||||
|
||||
/// The app's single read path for per-host access tokens.
|
||||
///
|
||||
/// Reads the `HostStore` (Keychain) on demand rather than caching a value at
|
||||
/// composition: pairing writes a token into the same records, and a snapshot
|
||||
/// taken at launch would make "type the token, then connect" require a relaunch.
|
||||
/// A Keychain read is a sub-millisecond `SecItemCopyMatching` + JSON decode, so
|
||||
/// per-request resolution is affordable at the app's polling cadence.
|
||||
///
|
||||
/// SECURITY (§5.3): the token is returned as a `String` for exactly one use — a
|
||||
/// `Cookie` header value. It is never logged, never interpolated into a URL, and
|
||||
/// the values it returns are `AccessToken`-validated (§1.1 charset), which is
|
||||
/// what makes header injection impossible.
|
||||
final class AccessTokenSource: @unchecked Sendable {
|
||||
private let store: any HostStore
|
||||
private let lock = NSLock()
|
||||
/// Origin → token, rebuilt from the store on every `token(forOrigin:)`.
|
||||
/// See `wsToken(for:)`. Guarded by `lock`; `nonisolated` state is the reason
|
||||
/// this type is `@unchecked Sendable` (an actor cannot serve the WS
|
||||
/// provider, which is synchronous).
|
||||
private var snapshot: [String: String] = [:]
|
||||
|
||||
init(store: any HostStore) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
/// This host's token, matched by ORIGIN (`HostEndpoint.originHeader` — the
|
||||
/// single derivation point, plan §5.1), or nil when the host has none / is
|
||||
/// unknown / the store read fails. A failed read degrades to "no token"
|
||||
/// rather than throwing: the request then gets the server's 401, which the
|
||||
/// UI already explains, instead of the app breaking outright.
|
||||
func token(forOrigin origin: String) async -> String? {
|
||||
let hosts = (try? await store.loadAll()) ?? []
|
||||
updateSnapshot(from: hosts)
|
||||
return hosts.first { $0.endpoint.originHeader == origin }?.accessToken?.rawValue
|
||||
}
|
||||
|
||||
/// The token a WS upgrade to `host` must present (§1.1) — for the one caller
|
||||
/// that can be given neither an `await` nor a parameter: SessionCore's
|
||||
/// frozen `tokenProvider: @Sendable () -> String?`, which the per-host
|
||||
/// transport closes over (`AppEnvironment.makeTermTransport(for:)`).
|
||||
///
|
||||
/// Two reads, in this order, and both are THIS host's own value — no answer
|
||||
/// derived from any other host can ever be returned (§5.3):
|
||||
/// 1. the latest value the store returned for this origin (so a token
|
||||
/// rotated mid-run applies on the next connect, no relaunch);
|
||||
/// 2. the `host` record itself, which the coordinator read from the same
|
||||
/// Keychain store when the session was opened — this is what makes the
|
||||
/// FIRST connect correct even before any HTTP request has warmed the
|
||||
/// snapshot (a cold-start terminal must not depend on that race), and
|
||||
/// what keeps a failed store read from silently dropping the cookie.
|
||||
///
|
||||
/// Consequence of (2), stated plainly: a token DELETED from the store keeps
|
||||
/// riding until this terminal is reopened. That is still this host's own
|
||||
/// value — the server just answers 401 if it is no longer valid — and it is
|
||||
/// the price of never dropping the cookie on a cold connect.
|
||||
func wsToken(for host: HostRegistry.Host) -> String? {
|
||||
let origin = host.endpoint.originHeader
|
||||
return lock.withLock { snapshot[origin] } ?? host.accessToken?.rawValue
|
||||
}
|
||||
|
||||
private func updateSnapshot(from hosts: [HostRegistry.Host]) {
|
||||
let resolved = hosts.reduce(into: [String: String]()) { map, host in
|
||||
guard let token = host.accessToken?.rawValue else { return }
|
||||
map[host.endpoint.originHeader] = token
|
||||
}
|
||||
lock.withLock { snapshot = resolved }
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /auth` (§1.1) as the pairing flow needs it: the four documented
|
||||
/// outcomes, or a typed transport failure. Lives here (composition root) because
|
||||
/// it is the seam between `APIClient`'s throwing API and the VM's total switch.
|
||||
private func probeAccessToken(
|
||||
endpoint: HostEndpoint,
|
||||
http: any HTTPTransport,
|
||||
token: AccessToken
|
||||
) async -> Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure> {
|
||||
do {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
return .success(try await client.probeAccessToken(token.rawValue))
|
||||
} catch APIClientError.malformedToken {
|
||||
return .failure(.malformed)
|
||||
} catch let apiError as APIClientError {
|
||||
// `.message` is user-facing copy about the STATUS, never about the token.
|
||||
return .failure(.unreachable(apiError.message))
|
||||
} catch {
|
||||
return .failure(.unreachable((error as NSError).localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Host removal → APNs de-registration (C1 · fixes the dead hook)
|
||||
|
||||
/// Bridge from "the user removed a host" to `PushRegistrar.handleHostRemoved`.
|
||||
///
|
||||
/// The registrar owns the in-memory APNs device token (deliberately never
|
||||
/// persisted — iOS re-delivers it on every registration), so the de-registration
|
||||
/// can only be done by the LIVE instance. That instance is created and held by
|
||||
/// `PushAppDelegate` (`makePushWiring`), which the system builds after this
|
||||
/// composition root — hence the resolution happens at CALL time through
|
||||
/// `UIApplication.shared.delegate`, the platform's own object, rather than any
|
||||
/// singleton of ours.
|
||||
///
|
||||
/// nil delegate / nil registrar (unit tests, XCUITest, a build where push was
|
||||
/// never wired) is a deliberate no-op: removal must never depend on push.
|
||||
enum PushHostDeregistration {
|
||||
@MainActor
|
||||
static func run(for host: HostRegistry.Host) async {
|
||||
guard let delegate = UIApplication.shared.delegate as? PushAppDelegate,
|
||||
let registrar = delegate.registrar else {
|
||||
return
|
||||
}
|
||||
await registrar.handleHostRemoved(host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,18 +16,28 @@ import SwiftUI
|
||||
/// `StackRootView` 是原 `RootView` 的 body **原样搬迁** —— compact 分支复用它
|
||||
/// 不改一行逻辑,故 iPhone 逐屏渲染与适配前一致(零回归硬性要求)。视觉层
|
||||
/// 只按冻结的 `DS.*` token 重塑(横幅/占位/遮罩),行为不动。
|
||||
/// T-iOS-34 · 主题(`ThemeStore`)就挂在这一层:它是 `@main` 唯一的实例化点,
|
||||
/// 所以「一个 store、一次注入、一处 `preferredColorScheme`」在这里成立,
|
||||
/// 不会出现两份主题真值。
|
||||
struct RootView: View {
|
||||
@Bindable var coordinator: AppCoordinator
|
||||
|
||||
/// 主题设置的单一真值(`UserDefaults` 持久化)。`@State` ⇒ 与根视图同寿命。
|
||||
@State private var themeStore = ThemeStore()
|
||||
|
||||
var body: some View {
|
||||
AdaptiveRootView(coordinator: coordinator)
|
||||
// DS:唯一的根 tint 注入点(Tokens.swift 头注约定)。子树若需别的
|
||||
// 语义色(gate 的 amber、终端的 orange)在各自局部 `.tint` 覆盖。
|
||||
.tint(DS.Palette.accent)
|
||||
// 深色优先 —— 对齐桌面 web 主题(DEFAULT_SETTINGS.theme = 'dark')。
|
||||
// 琥珀金强调色是为暖深色背景设计的(桌面 --bg #100F0D);浅色底上
|
||||
// 金字对比不足。深色下 accent/status 全部高对比,且与桌面观感一致。
|
||||
.preferredColorScheme(.dark)
|
||||
// 主题:默认仍是深色(对齐桌面 web `DEFAULT_SETTINGS.theme='dark'`,
|
||||
// 也等于此前硬锁 `.preferredColorScheme(.dark)` 的观感 ⇒ 升级零变化)。
|
||||
// 「跟随系统」时 `colorScheme` 为 nil = 不表态,交给 iOS。
|
||||
// 曾经硬锁深色的理由是「金色在浅底对比不足」;那是 token 问题而不是
|
||||
// 主题问题,已在 `Tokens.swift` 逐色补了浅色值(WCAG 3:1 起)。
|
||||
.preferredColorScheme(themeStore.theme.colorScheme)
|
||||
// 设置页与终端预览都从环境取同一个 store(不传参穿透整棵树)。
|
||||
.environment(themeStore)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,10 +204,16 @@ struct CertRenewalWarningBanner: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Projects toolbar item (shared stack + split, DRY)
|
||||
// MARK: - Root leading toolbar (shared stack + split, DRY)
|
||||
|
||||
/// 「项目」leading 工具栏入口 —— stack 与 split 共用(同 disabled 条件、同
|
||||
/// `presentProjects` 动作、同 a11y id)。根 tint 令其 label 呈 accent。
|
||||
/// 会话列表的 leading 工具栏组 —— stack 与 split 共用(同 disabled 条件、同动作、
|
||||
/// 同 a11y id)。根 tint 令其 label 呈 accent。
|
||||
///
|
||||
/// 组里现在有两项:「项目」(原有)+「设置」(T-iOS-34 主题入口)。
|
||||
/// **命名保留** `ProjectsToolbarItem`:`SplitRootView.swift` 按此名引用它,而该
|
||||
/// 文件不在 C4 的 Owns 里;把设置项加进这个共用组,是让 iPhone(stack) 与
|
||||
/// iPad(split) 同时拿到入口且不越界编辑的唯一办法。改名 →
|
||||
/// `RootLeadingToolbar` 留给拥有 `SplitRootView.swift` 的后续任务(纯机械重命名)。
|
||||
struct ProjectsToolbarItem: ToolbarContent {
|
||||
@Bindable var coordinator: AppCoordinator
|
||||
|
||||
@@ -211,6 +227,41 @@ struct ProjectsToolbarItem: ToolbarContent {
|
||||
.disabled(coordinator.sessionList.activeHost == nil)
|
||||
.accessibilityIdentifier("sessions.projectsButton")
|
||||
}
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
SettingsToolbarButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置入口(齿轮)+ 它自己的 sheet。自持 `@State` 表示态,所以不需要在
|
||||
/// `AppCoordinator` 里再开一个 `isSettingsPresented` —— 设置页与会话生命周期
|
||||
/// 完全无关,没有理由进协调器的状态机。
|
||||
///
|
||||
/// `ThemeStore` 从环境取,且是**可选**读取:若某个预览/测试没注入 store,
|
||||
/// 这里就不渲染齿轮,而不是崩(环境非可选读取在缺注入时会 crash)。
|
||||
struct SettingsToolbarButton: View {
|
||||
@Environment(ThemeStore.self) private var themeStore: ThemeStore?
|
||||
@State private var isPresented = false
|
||||
|
||||
var body: some View {
|
||||
if let themeStore {
|
||||
Button {
|
||||
isPresented = true
|
||||
} label: {
|
||||
Label(RootCopy.settings, systemImage: "gearshape")
|
||||
}
|
||||
.accessibilityIdentifier("sessions.settingsButton")
|
||||
.sheet(isPresented: $isPresented) {
|
||||
NavigationStack {
|
||||
SettingsScreen(themeStore: themeStore)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(RootCopy.done) { isPresented = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +269,7 @@ struct ProjectsToolbarItem: ToolbarContent {
|
||||
enum RootCopy {
|
||||
static let continueLast = "继续上次会话"
|
||||
static let projects = "项目"
|
||||
static let settings = "设置"
|
||||
static let done = "完成"
|
||||
/// B3 (HIGH) · Shown when the silent device-cert renewal keeps failing.
|
||||
static let certRenewalFailing = "设备证书自动续期失败,将在下次前台重试"
|
||||
|
||||
@@ -197,7 +197,10 @@ final class TerminalSessionController: Identifiable {
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
|
||||
) -> Stack {
|
||||
let engine = SessionEngine(
|
||||
transport: environment.termTransport,
|
||||
// E1 · The transport is built for THIS host, so the WS upgrade
|
||||
// carries this host's access token (§1.1) — a shared, host-blind
|
||||
// transport could not resolve a token for a mixed fleet at all.
|
||||
transport: environment.makeTermTransport(for: host),
|
||||
clock: ContinuousClock(),
|
||||
endpoint: host.endpoint,
|
||||
eventsSource: environment.makeEventsSource(endpoint: host.endpoint)
|
||||
|
||||
@@ -4,12 +4,32 @@ import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Production `HTTPTransport` (the WireProtocol seam's doc reserves
|
||||
/// the URLSession wrapper for the production side; no package owns it, so the
|
||||
/// assembly layer provides it). Deliberately logic-free: `APIClient` builds
|
||||
/// every request — including the Origin-iff-G rule (plan §3.4 铁律) — and this
|
||||
/// type only performs the exchange. Adding ANY header/URL logic here would
|
||||
/// bypass that single audited point (review CRITICAL).
|
||||
/// assembly layer provides it). Deliberately logic-free about ROUTING:
|
||||
/// `APIClient` builds every request — including the Origin-iff-G rule (plan §3.4
|
||||
/// 铁律) — and this type only performs the exchange. Adding URL or Origin logic
|
||||
/// here would bypass that single audited point (review CRITICAL).
|
||||
///
|
||||
/// C1 · The ONE exception is the access-token `Cookie` (ios-completion §1.1),
|
||||
/// and it is deliberate:
|
||||
/// - the token is **unconditional** — every request, RO and G alike — so it has
|
||||
/// no interaction with the conditional Origin rule it must never replace;
|
||||
/// - it is **per host**, resolved from the request's own origin, whereas
|
||||
/// `APIClient` instances are built ad hoc all over the App layer (list poll,
|
||||
/// previews, projects, diffs, push registration) with no access to the
|
||||
/// Keychain. Stamping at the shared transport is what makes "every request
|
||||
/// carries the token" true by construction instead of per-call-site;
|
||||
/// - it is resolved LAZILY per request from `tokenForOrigin` — the same
|
||||
/// no-relaunch pattern as the mTLS `identityProvider` below — so a token typed
|
||||
/// mid-run applies to the very next request.
|
||||
///
|
||||
/// A request that ALREADY carries a `Cookie` is left untouched: the pairing probe
|
||||
/// authenticates with a *candidate* token that is not in the store yet, and
|
||||
/// overwriting it here would silently unauthenticate the probe.
|
||||
struct URLSessionHTTPTransport: HTTPTransport {
|
||||
private let session: URLSession
|
||||
/// Resolves the stored access token for a request's origin (see type doc).
|
||||
/// `@Sendable`, async: the store is an actor over the Keychain.
|
||||
private let tokenForOrigin: @Sendable (String) async -> String?
|
||||
/// Strong reference to the mTLS delegate. URLSession retains its delegate
|
||||
/// until invalidated, but this ephemeral session is never explicitly
|
||||
/// invalidated, so holding it here documents the ownership and keeps the
|
||||
@@ -18,6 +38,7 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
||||
|
||||
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
|
||||
/// provider, so behaviour is identical to capturing the identity directly.
|
||||
/// No token source ⇒ no `Cookie` is ever added (LAN zero-config default).
|
||||
init(identity: ClientIdentity? = nil) {
|
||||
self.init(identityProvider: { identity })
|
||||
}
|
||||
@@ -37,16 +58,20 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
||||
/// contain printed secrets — and `.shared`'s default URLCache writes
|
||||
/// responses to disk. Ephemeral keeps them memory-only, matching the WS
|
||||
/// transport and the privacy-shade posture.
|
||||
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
|
||||
init(
|
||||
identityProvider: @escaping @Sendable () -> ClientIdentity?,
|
||||
tokenForOrigin: @escaping @Sendable (String) async -> String? = { _ in nil }
|
||||
) {
|
||||
let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider)
|
||||
self.tlsDelegate = delegate
|
||||
self.tokenForOrigin = tokenForOrigin
|
||||
self.session = URLSession(
|
||||
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
|
||||
)
|
||||
}
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
let (data, response) = try await session.data(for: await authenticated(request))
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
// http(s)-only endpoints (HostEndpoint validates) always produce
|
||||
// an HTTPURLResponse; anything else is a transport-level anomaly.
|
||||
@@ -54,6 +79,50 @@ struct URLSessionHTTPTransport: HTTPTransport {
|
||||
}
|
||||
return (data, httpResponse)
|
||||
}
|
||||
|
||||
/// Stamp `Cookie: webterm_auth=<t>` for the request's own origin (C1).
|
||||
/// Immutable style: returns a NEW request, never mutates the caller's.
|
||||
///
|
||||
/// `internal`, not private: this is the whole behaviour `send` adds, and it
|
||||
/// is testable with zero network — the alternative would be leaving the one
|
||||
/// line that carries a credential unverified.
|
||||
func authenticated(_ request: URLRequest) async -> URLRequest {
|
||||
guard let origin = AccessTokenCookie.origin(of: request),
|
||||
let token = await tokenForOrigin(origin) else {
|
||||
return request
|
||||
}
|
||||
return AccessTokenCookie.stamped(request, token: token)
|
||||
}
|
||||
}
|
||||
|
||||
/// The single point where an access token becomes a request header (App layer).
|
||||
/// Pure and static so the rules are unit-testable without any network.
|
||||
enum AccessTokenCookie {
|
||||
/// `AUTH_COOKIE_NAME` (src/http/auth.ts:30) — the same literal the packages
|
||||
/// pin; both of their `AuthCookie` helpers are package-internal, so the App
|
||||
/// layer needs its own single definition rather than a fourth ad-hoc string.
|
||||
static let name = "webterm_auth"
|
||||
static let header = "Cookie"
|
||||
|
||||
/// The request's origin in `HostEndpoint.originHeader` form, via the frozen
|
||||
/// single derivation point (default ports omitted, scheme/host lowercased) —
|
||||
/// never hand-assembled here. nil for a non-http(s) or host-less URL.
|
||||
static func origin(of request: URLRequest) -> String? {
|
||||
guard let url = request.url, let endpoint = HostEndpoint(baseURL: url) else {
|
||||
return nil
|
||||
}
|
||||
return endpoint.originHeader
|
||||
}
|
||||
|
||||
/// A copy of `request` carrying the token cookie — unless it already carries
|
||||
/// a `Cookie`, in which case the existing one wins (the pairing probe's
|
||||
/// candidate token must not be overwritten by the stored one).
|
||||
static func stamped(_ request: URLRequest, token: String) -> URLRequest {
|
||||
guard request.value(forHTTPHeaderField: header) == nil else { return request }
|
||||
var authenticated = request
|
||||
authenticated.setValue("\(name)=\(token)", forHTTPHeaderField: header)
|
||||
return authenticated
|
||||
}
|
||||
}
|
||||
|
||||
/// C-iOS-2 (MEDIUM no-relaunch fix) · Session-level mTLS delegate that resolves
|
||||
|
||||
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)
|
||||
|
||||
@Test("scheme 非 webterminal → .ignore")
|
||||
func wrongSchemeIgnored() throws {
|
||||
/// T-iOS-35 **有意改写**:本例原名"scheme 非 webterminal → .ignore",断言
|
||||
/// http(s) 一律被拒。web 分享 QR 互通落地后 http(s) 有了自己的白名单形状
|
||||
/// (`<origin>/?join=<uuid>`,见 `DeepLinkJoinTests`),所以那条断言的**理由**
|
||||
/// 变了:这里的 URL 仍 `.ignore`,但原因是它带了 `host=` 这个多余 query 键
|
||||
/// (web 形状精确只允许单一 `join` 键),而不是"scheme 不对"。
|
||||
/// scheme 白名单本身改由下一例(非 http/https/webterminal)继续钉住。
|
||||
@Test("https 但不是 web 分享形状(多余 query 键)→ .ignore")
|
||||
func httpsWithExtraQueryKeysIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)")
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("白名单外的 scheme(ftp/file/javascript)→ .ignore")
|
||||
func nonWhitelistedSchemeIgnored() throws {
|
||||
for scheme in ["ftp", "file", "javascript"] {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("\(scheme)://open?join=\(Self.validSessionId)")
|
||||
)
|
||||
#expect(route == .ignore, "\(scheme) 不该被接受")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("action 非 open → .ignore")
|
||||
func wrongActionIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user