Compare commits
30 Commits
1529d2c94c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
242a4e0dc1 | ||
|
|
86d100a9ad | ||
|
|
3492f0cf1f | ||
|
|
d36deb6922 | ||
|
|
a887638557 | ||
|
|
d01a69eb72 | ||
|
|
c44725618c | ||
|
|
660a40491a | ||
|
|
823432b1c8 | ||
|
|
77502ec4fe | ||
|
|
9c0097a305 | ||
|
|
f40b8f9400 | ||
|
|
4871e8ac3d | ||
|
|
aa956fcbb4 | ||
|
|
cc4d3129cc | ||
|
|
5098643355 | ||
|
|
5c8c87fb7a | ||
|
|
a2b14ab6e7 | ||
|
|
95438cdc12 | ||
|
|
2ab93c9682 | ||
|
|
cbaa08daba | ||
|
|
9b41ffa574 | ||
|
|
ba85871227 | ||
|
|
3d17bed322 | ||
|
|
340ea48478 | ||
|
|
c0279fd6e2 | ||
|
|
0ad7c31549 | ||
|
|
ad5cf06207 | ||
|
|
3020184054 | ||
|
|
a09c131539 |
220
.github/workflows/ios.yml
vendored
Normal file
@@ -0,0 +1,220 @@
|
||||
# iOS CI (T-iOS-16; hardens the T-iOS-1 skeleton). Three layers, PLAN_IOS_CLIENT §9:
|
||||
#
|
||||
# 1. package-tests — per-package `swift test` + OWN-SOURCES coverage gate
|
||||
# (>= 80%, plan §9). NOTE: the plan §9 raw llvm-cov command reads export
|
||||
# TOTALS, which count statically-linked DEPENDENCY sources compiled into
|
||||
# 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.
|
||||
# 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
|
||||
# whitelist, src/config.ts:187-226 — no ALLOWED_ORIGINS needed). This layer
|
||||
# is the standing anti-drift gate between the Swift-replicated protocol
|
||||
# and the server implementation — hence the src/** path triggers below.
|
||||
name: ios
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "ios/**"
|
||||
- ".github/workflows/ios.yml"
|
||||
# integration layer guards client<->server protocol drift:
|
||||
- "src/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
pull_request:
|
||||
paths:
|
||||
- "ios/**"
|
||||
- ".github/workflows/ios.yml"
|
||||
- "src/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
|
||||
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).
|
||||
package-tests:
|
||||
runs-on: macos-15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: [WireProtocol, SessionCore, HostRegistry, APIClient]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||
- name: swift test + own-sources coverage gate (>= 80%)
|
||||
run: ios/IntegrationTests/scripts/coverage-gate.sh ${{ matrix.package }}
|
||||
|
||||
testsupport-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: swift test (TestSupport — no coverage gate, plan §9 gates 4 packages)
|
||||
run: swift test --package-path ios/Packages/TestSupport
|
||||
|
||||
# Layer 2: app-target unit tests (WebTermTests, hosted by the app).
|
||||
app-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, iPhone 16 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)' \
|
||||
test
|
||||
|
||||
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
|
||||
# npm ci compiles node-pty (needs the Xcode toolchain — present on the
|
||||
# runner); the Swift ServerHarness then boots the server itself.
|
||||
integration-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
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: npm ci (builds node-pty native module)
|
||||
run: npm ci
|
||||
- name: swift test vs real server (ServerHarness boots tsx src/server.ts)
|
||||
run: swift test --package-path ios/IntegrationTests
|
||||
|
||||
# Layer 4 (W5, plan §9): the ONE scripted XCUITest happy path — 配对(手输) →
|
||||
# 列表 → attach → 输入 → gate approve — against a live loopback server. The
|
||||
# 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).
|
||||
ui-test:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
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
|
||||
with:
|
||||
node-version: 22
|
||||
- name: npm ci (builds node-pty native module)
|
||||
run: npm ci
|
||||
- name: Start web-terminal server on loopback
|
||||
run: |
|
||||
PORT=3217 BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 \
|
||||
nohup node_modules/.bin/tsx src/server.ts > uitest-server.log 2>&1 &
|
||||
echo $! > uitest-server.pid
|
||||
ready=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:3217/live-sessions > /dev/null; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "server did not come up on 127.0.0.1:3217" >&2
|
||||
cat uitest-server.log >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
- name: Generate project
|
||||
run: cd ios && xcodegen generate
|
||||
# The 输入 step taps the KeyBar, which is the terminal's
|
||||
# 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)
|
||||
# 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
|
||||
# never reaches the runner (verified empirically, 2026-07-05).
|
||||
# NO CODE_SIGNING_ALLOWED=NO here: the app must be (ad-hoc) signed or
|
||||
# the data-protection keychain rejects every SecItem call (-34018) and
|
||||
# pairing dies at "保存到本机失败" (verified empirically, run 6).
|
||||
# Simulator builds sign locally without any certificate.
|
||||
env:
|
||||
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
|
||||
- name: Server log + shutdown
|
||||
if: always()
|
||||
run: |
|
||||
cat uitest-server.log || true
|
||||
kill "$(cat uitest-server.pid)" 2>/dev/null || true
|
||||
|
||||
# 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).
|
||||
ios17-floor-tests:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 16.3 (build toolchain)
|
||||
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: Create iOS 17.x simulator (skip-with-notice if runtime absent)
|
||||
id: sim17
|
||||
run: |
|
||||
runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || 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
|
||||
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.
|
||||
- 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
|
||||
30
README.md
@@ -49,6 +49,36 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
|
||||
|
||||
---
|
||||
|
||||
## Clients
|
||||
|
||||
The browser is the reference client; two 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.)*
|
||||
- **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
|
||||
[`docs/DESKTOP_PLAN.md`](docs/DESKTOP_PLAN.md). *(Built; packaged & launch-
|
||||
verified on Apple Silicon.)*
|
||||
- **Rendezvous relay** (`term-relay/` · `agent/` · `relay-e2e/` · `relay-web/` ·
|
||||
`relay-auth/` · `relay-contracts/`) — an "ngrok-for-Claude-Code" with
|
||||
**end-to-end encryption** to reach a self-hosted host from anywhere, without a
|
||||
VPN or port-forwarding. Security-audited but **pre-production / not deployed**;
|
||||
see [`docs/PLAN_RELAY_INDEX.md`](docs/PLAN_RELAY_INDEX.md) and
|
||||
[`docs/DEPLOY_RELAY.md`](docs/DEPLOY_RELAY.md). For remote access **today**, use
|
||||
**Tailscale** (§ Security & deployment below).
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -13,7 +13,19 @@
|
||||
* the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim.
|
||||
* `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key,
|
||||
* NEVER logged, NEVER sent to the relay (INV2/INV9).
|
||||
*
|
||||
* PER-GENERATION EPOCH (F6 fix): K_content = HKDF(hostContentSecret, salt=sessionId, info=const) is
|
||||
* byte-identical for a given (host, sessionId) and RECOVERABLE by design — but the deterministic seal
|
||||
* nonce is seq, which resets to 0 on every sealer reconstruction (restart / re-attach). Two sealer
|
||||
* GENERATIONS would therefore seal DISTINCT plaintext under the SAME (key, nonce) → catastrophic AEAD
|
||||
* reuse. Each `createReplaySealer` call mints a FRESH, NON-SECRET `epoch` (randomUUID) that is folded
|
||||
* into K_content derivation, so a restart / new generation yields a FRESH key even for the same
|
||||
* (hostContentSecret, sessionId); seq=0 can never collide across generations. The epoch is exposed on
|
||||
* the sealer so the wiring can persist it with the ring buffer / replay stream and serve it to the
|
||||
* browser, which re-derives the matching key. Recoverability WITHIN one generation (same epoch ⇒ same
|
||||
* key) is preserved.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
||||
|
||||
/** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */
|
||||
@@ -23,13 +35,20 @@ export interface ReplayCrypto {
|
||||
}
|
||||
|
||||
export interface ReplaySealer {
|
||||
/**
|
||||
* The fresh, NON-SECRET per-generation epoch folded into K_content (F6). The wiring persists it
|
||||
* with the ring buffer / replay stream so the browser re-derives the matching key.
|
||||
*/
|
||||
readonly epoch: string
|
||||
/** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */
|
||||
seal(plaintext: Uint8Array): E2EEnvelope
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-(host, session) replay sealer. K_content is derived ONCE from
|
||||
* { hostContentSecret, sessionId, alg }; seq is strictly monotonic from 0 (INV13).
|
||||
* Build a per-(host, session) replay sealer for ONE generation. A FRESH `epoch` is minted per call
|
||||
* and folded into K_content, which is derived ONCE from { hostContentSecret, sessionId, alg, epoch };
|
||||
* seq is strictly monotonic from 0 (INV13). A restart / re-attach constructs a NEW generation with a
|
||||
* NEW epoch ⇒ a FRESH key, so seq=0 never collides across generations (F6).
|
||||
*/
|
||||
export function createReplaySealer(
|
||||
hostContentSecret: Uint8Array,
|
||||
@@ -37,9 +56,11 @@ export function createReplaySealer(
|
||||
alg: AeadAlg,
|
||||
crypto: ReplayCrypto,
|
||||
): ReplaySealer {
|
||||
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg })
|
||||
const epoch = randomUUID()
|
||||
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg, epoch })
|
||||
let seq = 0n
|
||||
return {
|
||||
epoch,
|
||||
seal(plaintext: Uint8Array): E2EEnvelope {
|
||||
const env = crypto.sealReplayFrame(key, seq, plaintext)
|
||||
seq += 1n
|
||||
|
||||
@@ -107,6 +107,7 @@ describe('createE2ETransform (T15)', () => {
|
||||
function fakeReplay(): ReplaySealer & { calls: number } {
|
||||
const r = {
|
||||
calls: 0,
|
||||
epoch: 'test-epoch',
|
||||
seal(_pt: Uint8Array) {
|
||||
r.calls += 1
|
||||
return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() }
|
||||
|
||||
@@ -2,14 +2,24 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
|
||||
import { createReplaySealer, type ReplayCrypto } from '../src/e2e/replaySeal.js'
|
||||
|
||||
/** Fake AEAD: key = tagged secret‖sessionId; ciphertext = plaintext XOR keyByte (marker hidden). */
|
||||
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } {
|
||||
/**
|
||||
* Fake AEAD. The derived key is a tag that DEPENDS on every ReplayKeyParams field — crucially on
|
||||
* `epoch` (F6), so two sealer generations for the same (secret, sessionId, alg) yield DIFFERENT keys.
|
||||
* The tag leads with `epoch` so key[0] also varies per generation; ciphertext = plaintext XOR key[0]
|
||||
* (a UUID's first char is a hex digit 0x30–0x66 → always nonzero, so the marker is always hidden).
|
||||
*/
|
||||
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[]; keys: Uint8Array[] } {
|
||||
const derivations: ReplayKeyParams[] = []
|
||||
const keys: Uint8Array[] = []
|
||||
return {
|
||||
derivations,
|
||||
keys,
|
||||
deriveContentKey(params: ReplayKeyParams): AeadKey {
|
||||
derivations.push(params)
|
||||
const tag = new TextEncoder().encode(`${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}`)
|
||||
const tag = new TextEncoder().encode(
|
||||
`${params.epoch}:${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}:${params.alg}`,
|
||||
)
|
||||
keys.push(tag)
|
||||
return tag as unknown as AeadKey
|
||||
},
|
||||
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope {
|
||||
@@ -23,12 +33,26 @@ function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } {
|
||||
const SECRET = new Uint8Array([1, 2, 3, 4])
|
||||
|
||||
describe('createReplaySealer (T19, FIX 3)', () => {
|
||||
it('derives K_content deterministically from (secret, sessionId, alg)', () => {
|
||||
const c1 = fakeCrypto()
|
||||
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c1)
|
||||
const c2 = fakeCrypto()
|
||||
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c2)
|
||||
expect(c1.derivations[0]).toEqual(c2.derivations[0])
|
||||
it('folds the exposed per-generation epoch into K_content, deriving it exactly once', () => {
|
||||
const c = fakeCrypto()
|
||||
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
expect(c.derivations).toHaveLength(1)
|
||||
expect(c.derivations[0]!.epoch).toBe(sealer.epoch)
|
||||
expect(sealer.epoch).toMatch(/^[0-9a-f-]{36}$/) // randomUUID shape
|
||||
})
|
||||
|
||||
it('recoverable WITHIN one generation: re-deriving with the exposed epoch yields the same key', () => {
|
||||
const c = fakeCrypto()
|
||||
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
// The browser re-derives from the SAME (secret, sessionId, alg, epoch) carried with the ring buffer.
|
||||
const browser = fakeCrypto()
|
||||
const browserKey = browser.deriveContentKey({
|
||||
hostContentSecret: SECRET,
|
||||
sessionId: 'sess-1',
|
||||
alg: 'aes-256-gcm',
|
||||
epoch: sealer.epoch,
|
||||
}) as unknown as Uint8Array
|
||||
expect(Buffer.from(browserKey).equals(Buffer.from(c.keys[0]!))).toBe(true)
|
||||
})
|
||||
|
||||
it('a different sessionId → a different key (per-session separation)', () => {
|
||||
@@ -38,6 +62,23 @@ describe('createReplaySealer (T19, FIX 3)', () => {
|
||||
expect(c.derivations[0]!.sessionId).not.toBe(c.derivations[1]!.sessionId)
|
||||
})
|
||||
|
||||
it('F6 regression: two generations for the SAME (secret, sessionId, alg) get DIFFERENT epochs → DIFFERENT keys, so seq=0 never collides', () => {
|
||||
const c = fakeCrypto()
|
||||
const gen1 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
const gen2 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
|
||||
// Fresh epoch per generation…
|
||||
expect(gen2.epoch).not.toBe(gen1.epoch)
|
||||
// …therefore distinct K_content even though (secret, sessionId, alg) are identical…
|
||||
expect(Buffer.from(c.keys[1]!).equals(Buffer.from(c.keys[0]!))).toBe(false)
|
||||
// …so the seq=0 seal of generation 2 uses a DIFFERENT key than the seq=0 seal of generation 1
|
||||
// (this is exactly the (key, nonce) reuse F6 prevents — same nonce, but a fresh key).
|
||||
const s1 = gen1.seal(new Uint8Array([0x41, 0x42, 0x43]))
|
||||
const s2 = gen2.seal(new Uint8Array([0x41, 0x42, 0x43]))
|
||||
expect(s1.seq).toBe(0n)
|
||||
expect(s2.seq).toBe(0n)
|
||||
expect(s1.nonce).toEqual(s2.nonce) // same deterministic nonce (seq=0)…
|
||||
})
|
||||
|
||||
it('emits a monotonic seq (INV13) and never leaks the plaintext marker (INV2)', () => {
|
||||
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
|
||||
const marker = new TextEncoder().encode('SECRET-MARKER')
|
||||
@@ -50,7 +91,7 @@ describe('createReplaySealer (T19, FIX 3)', () => {
|
||||
|
||||
it('replay seal is DISTINCT from a live h2c seal for the same plaintext (FIX 3)', () => {
|
||||
const replay = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
|
||||
// model a live seal with a different key byte
|
||||
// model a live seal with a different key byte (0x11 is below the 0x30–0x66 hex-digit epoch prefix)
|
||||
const liveKey = new Uint8Array([0x11]) as unknown as AeadKey
|
||||
const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42]))
|
||||
const rep = replay.seal(new Uint8Array([0x41, 0x42]))
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 524 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 861 KiB After Width: | Height: | Size: 162 KiB |
168
docs/DEPLOY_RELAY.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Deploying the rendezvous-relay service — build & ops plan
|
||||
|
||||
> **Status: PRE-PRODUCTION — not runnable as-is.** The 7 relay packages (P1–P6 + contracts) are
|
||||
> libraries wired through **injection seams that currently hold fail-closed stubs**. There is no
|
||||
> server process, no TLS/subdomain ingress, no Postgres/Redis/KMS wiring, and no orchestration.
|
||||
> "Deploying" the relay means **building the integration + infra layer** the packages were designed
|
||||
> to receive. This doc is the buildable spec: architecture, the seam-by-seam gaps, a phased order,
|
||||
> the env/config reference, and the ops runbook. Security posture of the packages themselves is
|
||||
> covered in [REVIEW_RELAY_SECURITY.md](./REVIEW_RELAY_SECURITY.md) (findings F1–F6 fixed).
|
||||
|
||||
---
|
||||
|
||||
## 1. Target architecture
|
||||
|
||||
A **rendezvous relay**: the agent runs on the user's own host (no inbound ports there) and **dials
|
||||
out** to the relay; the browser connects to a per-tenant subdomain; the relay muxes the two and does
|
||||
an **opaque byte splice** — it only ever sees ciphertext (INV2). End-to-end crypto (P4) is between
|
||||
browser and agent; auth/tenant-isolation (P5) gates every upgrade; the control-plane (P3) is the
|
||||
registry + CA + revocation publisher.
|
||||
|
||||
```
|
||||
Browser (P6, xterm) User's host machine
|
||||
│ WSS <sub>.term.<domain> │
|
||||
│ capability token (subprotocol/cookie) + DPoP │ shell / node-pty
|
||||
▼ ▲ loopback (opaque, INV11)
|
||||
┌──────────────────────────┐ agent mux (mTLS, ┌────┴───────────────┐
|
||||
│ RELAY DATA PLANE (P1) │◀──SPIFFE cert)─────────│ AGENT (P2) │
|
||||
│ term-relay/data-plane │ dial-OUT │ dist/cli.js, ws │
|
||||
│ • TLS + WSS ingress │ │ E2E host endpoint│
|
||||
│ • onUpgrade → P5 authz │ │ replay sealer │
|
||||
│ • subdomain → hostId │ └────────────────────┘
|
||||
│ • OPAQUE splice (INV2) │ ▲ enroll/pair
|
||||
│ • Redis sub relay:revoc. │ │
|
||||
└───────┬──────────────────┬┘ │
|
||||
│ P5 authz (lib) │ Redis pub/sub │
|
||||
▼ ▼ │
|
||||
┌──────────────────┐ ┌─────────┐ ┌──────────────────────────┴─┐
|
||||
│ relay-auth (P5) │ │ Redis │◀──│ CONTROL PLANE (P3) │
|
||||
│ tokens+DPoP, │ │ relay: │ │ control-plane, Fastify │
|
||||
│ step-up, mTLS/ │ │ revoc. │ │ • accounts/hosts/sessions │
|
||||
│ SPIFFE, revoke │ └─────────┘ │ • pairing + /enroll (CA) │──▶ Postgres
|
||||
└──────────────────┘ │ • issues agent SPIFFE certs│──▶ KMS (CA key)
|
||||
│ • publishes KillSignals │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**Trust boundaries:** the relay is UNTRUSTED for payload confidentiality (E2E, INV2); it IS trusted
|
||||
for availability + authz enforcement. The control-plane holds the CA (must be KMS-custodied). Agents
|
||||
authenticate to the relay with SPIFFE mTLS; browsers authenticate with short-lived capability tokens
|
||||
(minted by P5 after human auth) bound to a DPoP key. Never expose any port without TLS.
|
||||
|
||||
---
|
||||
|
||||
## 2. Components & runtime
|
||||
|
||||
| Pkg | Role | Runtime today | External deps |
|
||||
|---|---|---|---|
|
||||
| **relay-contracts** | frozen schemas/codecs | library | — |
|
||||
| **relay-e2e (P4)** | browser↔agent E2E crypto | library (isomorphic) | @noble/* |
|
||||
| **relay-auth (P5)** | authz, tokens+DPoP, step-up, mTLS, revoke | library ✅ audited | — |
|
||||
| **term-relay (P1)** | stateless relay data-plane + mux | **library — no server** | (needs `ws`, `tls`) |
|
||||
| **control-plane (P3)** | accounts/hosts/pairing/CA/revocation | Fastify app, **in-memory default** | fastify, **pg**, **ioredis**, KMS |
|
||||
| **agent (P2)** | host agent: dial, pair, mTLS, E2E, replay | CLI `web-terminal-agent`→`dist/cli.js` | ws |
|
||||
| **relay-web (P6)** | browser bundle (xterm, preview) | needs `build` + static serve | @xterm/* |
|
||||
|
||||
---
|
||||
|
||||
## 3. The wiring gaps (what must be BUILT to deploy)
|
||||
|
||||
Each is an injected seam currently holding a safe stub. In rough dependency order:
|
||||
|
||||
1. **P1 socket server (biggest gap).** `term-relay/data-plane/relay-node.ts` takes `WebSocketLike`
|
||||
+ `AgentListener` as **injected seams** — there is no actual listener. Build a `relay-run/` entry
|
||||
that: (a) terminates browser **WSS** on `BIND_PORT` and adapts each socket to `WebSocketLike`;
|
||||
(b) accepts agent **mTLS** connections on `AGENT_BIND_PORT` (verify SPIFFE client certs against
|
||||
`AGENT_CA_CHAIN_PATH`) and adapts them to `AgentListener`/mux; (c) calls `authorizeUpgrade`
|
||||
(→ P5 `onUpgrade`) and splices. Wire the Redis `relay:revocations` subscriber to `closeStream`.
|
||||
2. **Inject P5 into P1 + P3.** P1's `Authorizer` must call relay-auth `onUpgrade`/`onReattach` with a
|
||||
real `EnforceDeps` (registries backed by P3, revocation store, buckets, audit). P3's
|
||||
`refuseAllVerifier` must be replaced with relay-auth `verifyCapabilityToken` + the real
|
||||
`CAPABILITY_SIGN_PUBKEY_B64`. **Close F2 here:** the P1 upgrade adapter currently hardcodes
|
||||
`principal: null` — for any host with a step-up policy it must resolve + pass the authenticated
|
||||
principal, else those hosts (correctly) 403. See [[relay-stepup-needs-principal-wiring]].
|
||||
3. **P3 durable stores.** Swap `createMemoryStores` for the Postgres adapter (accounts/hosts/sessions
|
||||
registries) + run migrations; point the revocation bus at real Redis (`ioredis`).
|
||||
4. **Real KMS for the CA key.** `boot/ca-wiring.ts` uses a dev in-process Ed25519 signer ("NOT a real
|
||||
KMS"). Provide a `KmsResolver` backed by cloud KMS/HSM; the intermediate key must be
|
||||
non-exportable (§3.1). `CA_INTERMEDIATE_KMS_KEY_REF` is the ref.
|
||||
5. **Agent build + config.** Build `dist/cli.js`; configure `RELAY_URL`/`ENROLL_URL`/`SUBDOMAIN`/
|
||||
`HOST_ID`/`LOCAL_TARGET_URL`/`STATE_DIR`; enroll (redeem pairing code → SPIFFE cert +
|
||||
`hostContentSecret`).
|
||||
6. **relay-web build + serve.** `npm --prefix relay-web run build`; serve the bundle from each tenant
|
||||
subdomain (same origin as the WSS endpoint so Origin/CSWSH checks pass).
|
||||
7. **F6 replay transport (residual).** The recoverable-replay ring buffer must **persist each
|
||||
generation's `epoch` and serve the epoch matching those exact frames** (`relay-web/.../manage-page.ts`
|
||||
`loadReplay` is still a throwing stub). Until wired, the preview/replay feature is inert (fail-closed).
|
||||
|
||||
---
|
||||
|
||||
## 4. Phased build order
|
||||
|
||||
- **Phase 0 — single-host dev run (one process, self-signed).** Reuse the `e2e/harness` wiring:
|
||||
a `ws` server hosting the relay-node, control-plane with in-memory stores, P4/P5 wired for real,
|
||||
self-signed TLS, agent dialing `localhost`, relay-web served locally. Goal: an actually-running
|
||||
end-to-end relay to click through. No pg/redis/KMS.
|
||||
- **Phase 1 — single-tenant staging.** Real Postgres + Redis; real (staging) TLS cert for one
|
||||
subdomain; close F2 principal wiring; agent on a second machine dialing in; behind Tailscale.
|
||||
- **Phase 2 — multi-tenant production.** Wildcard TLS `*.term.<domain>` + subdomain routing; real
|
||||
KMS CA custody; horizontal relay nodes (stateless, share Redis); migrations/rollback; metering;
|
||||
F6 replay transport; monitoring/alerting on the audit sink + revocation latency budget.
|
||||
|
||||
---
|
||||
|
||||
## 5. Environment / config reference (accurate to the code)
|
||||
|
||||
**control-plane (P3)** — `control-plane/src/env.ts`, all required unless defaulted:
|
||||
`PG_URL`, `REDIS_URL`, `CAPABILITY_SIGN_PUBKEY_B64` (P5 verify pubkey), `CA_INTERMEDIATE_KMS_KEY_REF`,
|
||||
`CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `BASE_DOMAIN`, `HEARTBEAT_TTL_SEC`=15,
|
||||
`PAIRING_TTL_SEC`=600, `PAIRING_MAX_REDEEM_ATTEMPTS`=5.
|
||||
Routes: `POST /accounts`, `POST /accounts/:id/pairing-codes`, `POST /accounts/:id/status`,
|
||||
`GET /accounts/:id/hosts`, `DELETE /hosts/:hostId`, `POST /enroll`. (No `start` script / listen entry
|
||||
exists yet — add one.)
|
||||
|
||||
**term-relay data-plane (P1)** — `term-relay/data-plane/config.ts`:
|
||||
`BASE_DOMAIN`, `BIND_HOST`, `BIND_PORT` (browser WSS), `TLS_CERT_PATH`, `TLS_KEY_PATH`,
|
||||
`AGENT_BIND_PORT`, `AGENT_CA_CERT_PATH`, `AGENT_CA_CHAIN_PATH` (verify agent mTLS), `RELAY_NODE_ID`,
|
||||
`HEARTBEAT_INTERVAL_MS`, `INITIAL_WINDOW_BYTES`, `MAX_FRAME_BYTES`, `ROUTE_TTL_MS`. (Library — the
|
||||
process that reads this config and listens must be built, gap #1.)
|
||||
|
||||
**relay-auth (P5)** — `RELAY_AUTH_VERIFY_PUBKEY` (base64url raw Ed25519) via `loadVerifyKeyFromEnv`;
|
||||
`RELAY_TRUST_DOMAIN` for SPIFFE.
|
||||
|
||||
**agent (P2)** — `RELAY_URL`, `ENROLL_URL`, `HOST_ID`, `SUBDOMAIN`, `LOCAL_TARGET_URL`, `STATE_DIR`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Infra checklist
|
||||
|
||||
- [ ] **TLS**: browser-facing wildcard cert `*.term.<domain>` (Phase 2) or per-subdomain (Phase 1);
|
||||
separate CA + trust bundle for **agent mTLS** (`AGENT_CA_*` / `NODE_MTLS_TRUST_BUNDLE_PATH`).
|
||||
- [ ] **DNS**: wildcard `*.term.<domain>` → relay node(s); `<control-plane host>`.
|
||||
- [ ] **Postgres**: accounts/hosts/sessions schema + migrations (adapter unbuilt).
|
||||
- [ ] **Redis**: `relay:revocations` pub/sub (P3 publishes, P1 subscribes) — the INV12 teardown path.
|
||||
- [ ] **KMS/HSM**: non-exportable CA intermediate signing key (`CA_INTERMEDIATE_KMS_KEY_REF`).
|
||||
- [ ] **Ingress for agents**: agents dial OUT (mTLS) — expose only `AGENT_BIND_PORT` + browser `BIND_PORT`.
|
||||
- [ ] **Orchestration**: Dockerfiles + compose/k8s; relay nodes are stateless (crash-safe, INV7).
|
||||
|
||||
## 7. Security/ops runbook (deploy invariants)
|
||||
|
||||
- **Origin/CSWSH**: the browser WSS handshake MUST validate Origin against the tenant subdomain
|
||||
(relay-auth `onUpgrade` step 1 enforces it — pass the correct `allowedOrigins`). Never disable.
|
||||
- **Never expose without TLS**; the relay hands a shell to whoever authenticates. Prefer Tailscale
|
||||
for the agent path.
|
||||
- **Key custody (INV9)**: P5 signing key and the CA key never in process memory / logs; KMS refs only.
|
||||
- **Revocation latency (INV12)**: monitor time from `revoke()` → stream teardown; alert on budget miss.
|
||||
- **mTLS trust anchors**: agent certs verified against a **pinned** CA bundle — never a peer-supplied
|
||||
chain (relay-auth `verifyAgentCert` gates on the registry too).
|
||||
- **Audit sink (INV10)**: zero-payload; ship to append-only storage; alert on `deny` spikes /
|
||||
`cross-tenant-attempt`.
|
||||
- **Close before prod**: F2 principal wiring (gap #2) and F6 replay-epoch transport (gap #7).
|
||||
|
||||
---
|
||||
|
||||
## 8. TL;DR
|
||||
|
||||
There is no one-command deploy today. Minimum to get a *running* relay = **Phase 0** (gaps #1, #2, #5,
|
||||
#6 with in-memory stores + self-signed TLS). Production = **all gaps** + Postgres/Redis/KMS/wildcard-TLS
|
||||
+ orchestration. The security-critical *logic* is done and audited; what remains is integration + infra.
|
||||
948
docs/PLAN_IOS_CLIENT.md
Normal file
@@ -0,0 +1,948 @@
|
||||
# PLAN_IOS_CLIENT.md — iOS 原生客户端(SwiftUI + SwiftTerm,"口袋驾驶舱")
|
||||
|
||||
> 落地方案文档。目标:给 web-terminal 做一个 **iPhone 原生 App**,把 vibe-coding 的"走开—被叫回—两次手势处理完"闭环装进口袋。
|
||||
> 拓扑/框架选型:**Phased-Native —— SwiftUI + SwiftTerm,4 个纯 SwiftPM 包 + 薄 App 胶水;前台会话单条活 WS,其余 HTTP 轮询;服务器零改动(P0 零触点;P1 仅声明的附加触点,见 §0.3)**。
|
||||
> 状态:**规划中(2026-07-04,未开工)**。
|
||||
> 本文是「怎么做」的蓝图,配合 [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**,而是在最终返回消息末尾附上**可直接粘贴的日志条目**(状态 / 改动文件与函数 / 验证命令+结果 / 决策与偏差 / 阻塞 / 下一步),由主会话统一追加。
|
||||
|
||||
---
|
||||
|
||||
## 0. 目标与范围
|
||||
|
||||
### 做什么
|
||||
|
||||
一个 iPhone 原生 App(iOS 17+,Swift 6 language mode),面向核心场景:**给 Claude Code 发个任务走开,手机把你叫回来,两次手势处理完**。
|
||||
|
||||
- **完整可交互终端**:SwiftTerm 渲染,attach 即回放 ring-buffer 全量 scrollback;服务器仍是"字节搬运工",App 端 `feed()` 原样喂字节。
|
||||
- **会话跨断线存活**:杀 App / 切后台 / 断网 → 重新 attach 即恢复;`sessionId` 按 host 持久化。**前台会话一条活 WS,其余会话靠 HTTP 轮询**。
|
||||
- **一眼看全(glance list)**:会话列表 = chooser + dashboard 合并——状态点(working/waiting/idle/stuck)、telemetry 芯片(cost/context/PR,带 staleness TTL)、swipe-to-kill、下拉刷新。
|
||||
- **远程审批(THE steering primitive)**:tool gate 的 Approve/Reject 横幅 + plan gate 三选一(Approve+Auto / Approve+Review / Keep Planning),gate 到达触发 haptics。
|
||||
- **"离开期间发生了什么"digest**:重连后终端顶部渲染 away-digest(来源 `GET /live-sessions/:id/events`)。
|
||||
- **主机找得到手机**:P0 复用**既有** ntfy 桥(`npm run setup-hooks` 已内置,`WEBTERM_NTFY_URL`+`WEBTERM_NTFY_TOPIC` 设置即装,零新代码,NEEDS-INPUT/DONE);P1 换 APNs + 锁屏 Allow/Deny(notification action → **Face ID/通行码确认(`.authenticationRequired`)** → `POST /hook/decision`,仍不启动 App UI)。
|
||||
- **移动键位补全**:native `inputAccessoryView` key-bar(Esc / Shift+Tab / 方向 / Ctrl-C…,字节表逐字节复刻 `public/keybar.ts`)+ 硬件键盘 `UIKeyCommand`。
|
||||
- **配对即用**:扫 web UI 的 QR(`qr.ts` origin URL——**扫码结果是不可信外部输入,先显式确认解析出的 host 再发起任何网络请求**,见 T-iOS-12)或手输 URL;配对探针 + 可操作的错误话术("Local Network 权限被拒""Origin 被拒——在主机加 `ALLOWED_ORIGINS=<scheme>://<拨号 host>[:port]`,与 App 连接的 URL 一致")。
|
||||
|
||||
### 不做(v1 范围外)
|
||||
|
||||
- **鉴权/登录、多用户隔离**:沿用现有威胁模型(TECH_DOC §7)——LAN-only + 推荐 Tailscale;严禁公网暴露。
|
||||
- **relay / E2E 加密通道**:relay 栈仍 pre-production(无可跑服务端,见 [DEPLOY_RELAY.md](./DEPLOY_RELAY.md)),且**已审计的 TS 加密实现绝不能随手用 Swift 重写**(F6 这类 deterministic-nonce 交互连 99-agent 审计第一轮都漏了)。详见 §5.5。
|
||||
- **WKWebView 套 xterm.js**:两位评审一致否决——WKWebView 卡顿与 content-process 被杀正是本 App 要逃离的痛。
|
||||
- **iPad 优化布局、Live Activities、Widget、Mac Catalyst**:后续再议(Live Activities 依赖 APNs `liveactivity` push,P1 之后才有条件)。
|
||||
- **后台常驻 WS**:iOS 切后台数秒即 suspend,做不到也不装能做到;设计基石就是 foreground-reattach。
|
||||
- **前端/服务器重构**:`public/` 与 `src/` 一行不改(触点见 §0.3)。
|
||||
|
||||
### 已被验证掉的大风险(关键复用点,引现有代码为证)
|
||||
|
||||
服务器**早就为"多客户端、断线重连"设计好了**,iOS 客户端只是又一个说同一协议的端:
|
||||
|
||||
```ts
|
||||
// 会话/连接解耦:WS 断开只 detach,PTY 继续跑;最后一个客户端离开才开始计 idle
|
||||
// src/server.ts:791-800
|
||||
// attach(sessionId) → 全量回放 ring-buffer snapshot() 再续实时流
|
||||
// src/session/session.ts:158-170;回放前缀 soft-reset \x1b[0m(src/types.ts:167-170)
|
||||
// JOIN(mirror)语义:新 attach 加入镜像,绝不踢掉其他客户端
|
||||
// src/session/manager.ts:108-174;PTY 尺寸 latest-writer-wins(src/session/session.ts:200-211)
|
||||
// 协议解析永不抛异常,非法帧静默丢弃 —— 客户端照抄这个韧性
|
||||
// src/protocol.ts:45-86;src/server.ts:698-701
|
||||
```
|
||||
|
||||
已核实的平台事实(详见任务内引用):SwiftTerm v1.13+(MIT、SPM、商业 App 验证过)`TerminalViewDelegate.send/sizeChanged` 与本项目字节协议 1:1 对应;`URLSessionWebSocketTask` 可自定义 `Origin` header(不在 reserved-header 列表)——但此为 MED 置信度,**Day-1 spike 对真服务器实测**(T-iOS-2)。
|
||||
|
||||
### 服务器触点(server touch-points,学 relay 计划的惯例:声明而非隐藏)
|
||||
|
||||
本计划对本仓库 `src/`、`public/` 的改动**只有以下 P1 两处,均为增量;P0 零触点**:
|
||||
|
||||
| 阶段 | 触点 | 性质 |
|
||||
|---|---|---|
|
||||
| P0 | **零新代码** —— 复用**已随 `npm run setup-hooks` 发布**的 ntfy 桥(安装逻辑 scripts/setup-hooks.mjs:227-238,`WEBTERM_NTFY_URL`+`WEBTERM_NTFY_TOPIC` 设置即装:NEEDS-INPUT=high、DONE=low;env 已被 src/session/session.ts:99-100 转发进每个会话)。STUCK 不在 P0 信号列表(stuck 是服务器 `manager.sweepStuck` 派生态,无对应 Claude hook 事件,hook 桥发不出——推迟到 P1 APNs,走既有事件总线/NotifyService) | T-iOS-17 只**验证 + 写文档**,不建新文件 |
|
||||
| P1 | `src/push/` 旁新增 **APNs sender**(~150 行,`.p8` + HTTP/2),复用现有 hook 事件总线;外加一个**增量** APNs device-token 注册端点(形状在 T-iOS-20 内定稿,`G` 守卫 + 限频,对齐 `POST /push/subscribe` 的既有约定 src/server.ts:461-480) | 增量文件 + 增量 route;`/hook/decision` 原样复用(src/server.ts:503-525) |
|
||||
| P1 | `LiveSessionInfo` 增加可选 `lastOutputAt` 字段:`src/types.ts:246-256` 加一字段 + `src/session/manager.ts` `list()` 加一行映射(服务器本就逐 `pty.onData` 维护 `lastOutputAt`,src/types.ts:211/M3),供 T-iOS-23 unread 水位 | 增量字段 + 测试;TypeScript 任务 **T-iOS-37**(遵循根仓库 PLAN 工作流) |
|
||||
|
||||
除此之外**服务器 byte-for-byte 零改动**。若实施中发现 server 侧缺陷,修复归属对应模块(route/session 文件的 owner),按 CLAUDE.md 记 `PROGRESS_LOG.md`——iOS 任务不越界改 server。
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构 / 进程模型
|
||||
|
||||
```
|
||||
┌───────────────────────────── iPhone App(SwiftUI)─────────────────────────────┐
|
||||
│ │
|
||||
│ App/WebTerm(胶水层,排除在覆盖率门之外) │
|
||||
│ PairingScreen → SessionListScreen(合并 chooser+dashboard) → TerminalScreen │
|
||||
│ │ │ │ +KeyBar +GateBanner │
|
||||
│ │ │ │ +AwayDigestView │
|
||||
│ │ @MainActor @Observable ViewModel(消费 AsyncStream<SessionEvent>) │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ Packages(纯逻辑,Sendable 不可变,80% 覆盖率门): │
|
||||
│ ┌────────────┐ ┌───────────┐ ┌──────────────────────────┐ ┌───────────────┐ │
|
||||
│ │HostRegistry│ │ APIClient │ │ SessionCore │ │ WireProtocol │ │
|
||||
│ │ Keychain + │ │ URLRequest│ │ SessionEngine(actor) │→│ 冻结契约: │ │
|
||||
│ │ UserDefaults│ │ builders │ │ ReconnectMachine(纯SM) │ │ Client/Server │ │
|
||||
│ │ last- │ │ +Pairing- │ │ PingScheduler·GateState │ │ Message enums │ │
|
||||
│ │ sessionId │ │ Error 分类│ │ AwayDigest reducer │ │ MessageCodec │ │
|
||||
│ └────────────┘ └───────────┘ │ URLSessionTermTransport │ │ Validation │ │
|
||||
│ └──────────────────────────┘ └───────────────┘ │
|
||||
└──────────┬──────────────────┬──────────────────────┬───────────────────────────┘
|
||||
HTTP GET 轮询(RO,无 Origin) HTTP 变更(G,带 Origin) 单条活 WS(仅前台会话:
|
||||
/live-sessions /events … DELETE /live-sessions… Origin header + 16 MiB
|
||||
▼ ▼ maximumMessageSize+25s ping)
|
||||
┌────────────────────── Mac 上的 web-terminal 服务器(零改动) ──────────────────────┐
|
||||
│ isOriginAllowed(默认拒空 Origin) · /term WS · RingBuffer 回放 · JOIN mirror │
|
||||
└────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**为什么"单条活 WS + HTTP 轮询"而不是每会话一个 connection actor**:iOS 切后台数秒内 suspend、socket 必死(Apple forums 716118),per-session 常驻连接是在跟平台打一场必输的仗,还会制造大量"看着连着其实死了"的僵尸状态。前台会话独占唯一活 WS,切会话 = detach + attach(服务器回放 ring-buffer,切换成本 ≈ 0);后台会话的"有没有新动静"靠 `/live-sessions` 轮询(P0)→ APNs(P1)。这与服务器的 session/connection 解耦设计**严丝合缝**。接受的代价:APNs 落地前,后台会话的 unread 信号有轮询延迟(评审确认可辩护)。**升级路**:若未来要多会话并行盯屏,加第二条"观察者 WS"即可,SessionEngine 不用改。
|
||||
|
||||
**为什么 SwiftTerm 而不是 WKWebView + xterm.js**:原生渲染、原生手势与选择、软键盘/IME 全走系统栈;`TerminalViewDelegate.send(source:data:)` / `sizeChanged` / `feed(byteArray:)` 与 `input`/`resize`/`output` 帧 1:1。WKWebView 方案两位评审均否决(见 §0 不做)。SwiftTerm 历史上最麻烦的是自绘文本选择与 first-responder——Day-1 spike + 验收里专门压这块。
|
||||
|
||||
**为什么 URLSessionWebSocketTask 而不是 Starscream**:系统框架、iOS 13+ 内建;Starscream 最后一版 4.0.8 已 ~2 年未动,仅作 spike 失败时的备胎。三个已知坑在 P0 直接立规矩:**`maximumMessageSize` 默认 1 MiB,而 ring-buffer 回放是单帧全量(`buffer.snapshot()`,src/session/session.ts:165-171),JSON 会把控制字节 `\uXXXX` 转义膨胀 1–6×(src/protocol.ts:186),最坏 ≈ 6 × SCROLLBACK_BYTES(默认 2 MiB)——必须设 `Tunables.maxWSMessageBytes = 16 MiB`(≥ 6×默认 + 帧包络)**;即便如此 SCROLLBACK_BYTES 是服务器 env 可调、客户端运行时无法得知(协议无 config 握手),**超限时 `receive()` 以 NSPOSIXErrorDomain code 40(EMSGSIZE,"Message too long";T-iOS-2 spike 实测勘误——原文误标 ENOBUFS,Darwin ENOBUFS=55,归类以 40 为主、55 兜底)失败——必须归类为不可重试的 `connection(.failed(.replayTooLarge))` 显式错误态并给可操作话术("服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"),绝不喂进 backoff 重连循环**(否则确定性无限重试);**`receive()` 一次只交付一条消息,必须循环 re-arm**,忘了就静默断流;**没有自动 ping,25 s 定时 `sendPing`**(`PingScheduler`)+ 显式 "reconnecting…" 横幅,终端绝不"看着连着其实死了"。
|
||||
|
||||
**为什么 4 个纯 SwiftPM 包 + 薄 App 胶水**:逻辑全部下沉到无 UIKit 依赖的包里(`WireProtocol`/`SessionCore`/`HostRegistry`/`APIClient`),`swift test` 秒级跑、80% 覆盖率门只量"值得量的逻辑";App target 只剩 UIViewRepresentable、导航和 ViewModel 粘合。依赖方向**严格单向向下**,`WireProtocol` 是唯一冻结契约(对应 `src/types.ts` 的地位)——**共享 I/O 边界类型也在这里**(`HostEndpoint`/`TermTransport`/`TransportConnection`/`HTTPTransport`/`TimelineEvent`/`Tunables`,见 §3.1):SessionCore/HostRegistry/APIClient/TestSupport 全部只 import WireProtocol,叶子包之间零耦合,W0 的 TestSupport 就能编译。
|
||||
|
||||
**为什么 v1 只做 LAN + Tailscale、不碰 relay**:Tailscale iOS App 是系统级 VPN(Network Extension),对本 App 完全透明——tailnet IP/MagicDNS 直接可达,**无 SDK、无一行网络代码**;服务器零改动。relay 栈尚无可部署的服务端,且把刚审计完的 TS 加密(X25519/HKDF/deterministic-nonce AEAD/epoch-in-key)移植成 Swift 等于重开所有 findings(§5.5)。**升级路**:relay 上线后优先评估 WKWebView 嵌 relay-web(复用已审计密码学字节),而非 Swift 重写。
|
||||
|
||||
**为什么合并 chooser + dashboard 成一张列表**:`GET /live-sessions` 一个响应里已经带 `status + telemetry`(src/types.ts:246-256, src/session/manager.ts:181-194),拆两个界面是自造 DRY 违约;一张列表 5 秒回答"要不要介入"。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
新增独立 `ios/`(与 `desktop/` 同级同法),与 `src/`、`public/` **并列且解耦**——不污染根 `package.json`,不引入任何第三方运行时依赖(SwiftTerm 是唯一 SPM 依赖,且只挂在 App target)。
|
||||
|
||||
```
|
||||
web-terminal/
|
||||
├── src/ public/ desktop/ # 全部不动
|
||||
└── ios/ # ★ 新增
|
||||
├── project.yml # XcodeGen 声明式工程;.xcodeproj 不入库
|
||||
├── .gitignore # DerivedData / *.xcodeproj / xcuserdata
|
||||
├── Packages/
|
||||
│ ├── WireProtocol/ # 冻结契约(对应 src/types.ts + src/protocol.ts)+共享 I/O 边界类型
|
||||
│ │ ├── Package.swift
|
||||
│ │ ├── Sources/WireProtocol/
|
||||
│ │ │ ├── ClientMessage.swift ├── ServerMessage.swift
|
||||
│ │ │ ├── MessageCodec.swift ├── Validation.swift
|
||||
│ │ │ ├── WireConstants.swift ├── Tunables.swift
|
||||
│ │ │ ├── HostEndpoint.swift ├── TermTransport.swift # 含 TransportConnection
|
||||
│ │ │ ├── HTTPTransport.swift └── TimelineEvent.swift
|
||||
│ │ └── Tests/WireProtocolTests/
|
||||
│ │ ├── CodecRoundtripTests.swift
|
||||
│ │ ├── HostEndpointTests.swift # originHeader/wsURL 派生向量
|
||||
│ │ └── ServerVectorTests.swift # 从 test/protocol.test.ts 移植的跨实现向量
|
||||
│ ├── SessionCore/
|
||||
│ │ ├── Sources/SessionCore/
|
||||
│ │ │ ├── URLSessionTermTransport.swift
|
||||
│ │ │ ├── SessionEngine.swift ├── SessionEvent.swift
|
||||
│ │ │ ├── ReconnectMachine.swift ├── PingScheduler.swift
|
||||
│ │ │ ├── GateState.swift ├── AwayDigest.swift
|
||||
│ │ │ └── KeyByteMap.swift # 键位字节表(纯数据,T-iOS-11 所有)
|
||||
│ │ └── Tests/SessionCoreTests/…
|
||||
│ ├── HostRegistry/
|
||||
│ │ ├── Sources/HostRegistry/{Host,HostStore,KeychainHostStore,SecItemShim,InMemoryHostStore,LastSessionStore}.swift
|
||||
│ │ └── Tests/HostRegistryTests/…
|
||||
│ ├── APIClient/
|
||||
│ │ ├── Sources/APIClient/{APIClient,Endpoints,Models,PairingProbe,PairingError}.swift
|
||||
│ │ └── Tests/APIClientTests/…
|
||||
│ └── TestSupport/ # 测试替身(仅被各包 test target 依赖;仅依赖 WireProtocol)
|
||||
│ └── Sources/TestSupport/{FakeTransport,FakeClock,FakeHTTPTransport}.swift
|
||||
├── App/WebTerm/ # 胶水层,排除在覆盖率门之外
|
||||
│ ├── WebTermApp.swift DeepLinkRouter.swift(P1)
|
||||
│ ├── Screens/{PairingScreen,SessionListScreen,TerminalScreen}.swift
|
||||
│ ├── Components/{KeyBar,GateBanner,PlanGateSheet,AwayDigestView,ReconnectBanner,TelemetryChips}.swift
|
||||
│ ├── ViewModels/{TerminalViewModel,SessionListViewModel,PairingViewModel}.swift
|
||||
│ └── Resources/ # Assets;Info.plist 键在 project.yml 里声明
|
||||
└── IntegrationTests/ # CI 专用:对真 Node 服务器的 Swift Testing
|
||||
└── LiveServerTests.swift
|
||||
```
|
||||
|
||||
**构建管线**(本地与 CI 同路径):
|
||||
1. `brew install xcodegen && cd ios && xcodegen generate` → `WebTerm.xcodeproj`(不入库)。
|
||||
2. 各包独立测试:`swift test --package-path ios/Packages/<Pkg>`(纯 mac 侧,无模拟器,秒级)。
|
||||
3. App 构建:`xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm -destination 'platform=iOS Simulator,name=iPhone 16' build test`。
|
||||
4. 集成(CI,macOS runner):根目录 `npm ci && PORT=0 … npm start` 起真服务器(临时端口 + `ALLOWED_ORIGINS`)→ `swift test --package-path ios/IntegrationTests`(详见 T-iOS-16 与 §7)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 协议与连接核心契约(函数签名级,风格对齐 ARCHITECTURE §3)
|
||||
|
||||
> 以下签名是 W0 冻结的接口;实现细节归各任务。**不可变 + Sendable** 是硬约束:状态一律 struct 快照,变更返回新值;可变运行时句柄(socket task、URLSession)只活在 actor 内部。
|
||||
|
||||
### 3.1 `WireProtocol` — 冻结契约(对应 `src/types.ts:87-120` + `src/protocol.ts`)
|
||||
|
||||
```swift
|
||||
// 伪代码 —— 不可变、显式错误处理(decode 失败 → nil,镜像服务器"非法帧静默丢弃"语义)
|
||||
public enum ClientMessage: Sendable, Equatable {
|
||||
case attach(sessionId: UUID?, cwd: String?) // 首帧必须是它(src/server.ts:707-711)
|
||||
case input(data: String) // 原始键盘字节,逐字节透传,不过滤
|
||||
case resize(cols: Int, rows: Int) // 均为 1...1000 整数(src/protocol.ts:113-115)
|
||||
case approve(mode: ApproveMode?) // mode 仅对 plan gate 有意义
|
||||
case reject
|
||||
}
|
||||
public enum ApproveMode: String, Sendable, CaseIterable { case `default`, acceptEdits, plan, auto }
|
||||
// 注:plan gate 三选一只发 acceptEdits/default(镜像 public/tabs.ts:345-347 与 src/types.ts:84-86);
|
||||
// raw `auto` 是被 ALLOW_AUTO_MODE 门控的高危模式(默认 false,src/config.ts:385,服务器会降级 auto→default,
|
||||
// src/server.ts:765-766)——仅保留给未来的"权限模式切换器"(那里才按 uiConfig.allowAutoMode 过滤),本计划无任务消费它。
|
||||
|
||||
public enum ServerMessage: Sendable, Equatable {
|
||||
case attached(sessionId: UUID) // 永远采用服务器回发的 id
|
||||
case output(data: String) // 不透明 ANSI/UTF-8,回放与实时同型
|
||||
case exit(code: Int, reason: String?) // code == -1 → spawn 失败,reason 必有
|
||||
case status(ClaudeStatus, detail: String?, pending: Bool, gate: GateKind?)
|
||||
case telemetry(StatusTelemetry)
|
||||
}
|
||||
public enum ClaudeStatus: String, Sendable { case working, waiting, idle, unknown, stuck }
|
||||
public enum GateKind: String, Sendable { case tool, plan }
|
||||
public struct StatusTelemetry: Sendable, Equatable, Decodable {
|
||||
// 全可选字段 + 必有 at(服务器 ms 时间戳)—— 镜像 src/types.ts:406-416
|
||||
public let contextUsedPct: Double?; public let costUsd: Double?
|
||||
public let linesAdded: Int?; public let linesRemoved: Int?
|
||||
public let model: String?; public let effort: String?
|
||||
public let pr: PrInfo?; public let rate: RateInfo?; public let at: Int
|
||||
}
|
||||
|
||||
public enum MessageCodec { // 纯静态,永不 throw
|
||||
public static func encode(_ msg: ClientMessage) -> String // → JSON 文本帧
|
||||
public static func decodeServer(_ text: String) -> ServerMessage? // 非法 → nil
|
||||
}
|
||||
public enum Validation { // 与服务器同规则(src/protocol.ts:22-23,113-115,142-149)
|
||||
public static func isValidSessionId(_ s: String) -> Bool // UUID v4 正则,同 SESSION_ID_RE
|
||||
public static func isValidResize(cols: Int, rows: Int) -> Bool
|
||||
public static func isAbsoluteCwd(_ s: String) -> Bool // 必须以 "/" 开头
|
||||
}
|
||||
public enum WireConstants {
|
||||
public static let wsPath = "/term" // src/config.ts:41
|
||||
public static let replaySoftResetPrefix = "\u{1B}[0m" // src/types.ts:167-170
|
||||
public static let spawnFailedExitCode = -1
|
||||
public static let resizeRange: ClosedRange<Int> = 1...1000
|
||||
}
|
||||
|
||||
// —— 共享 I/O 边界类型(同包冻结;SessionCore/HostRegistry/APIClient/TestSupport 只 import,不得另立) ——
|
||||
public struct HostEndpoint: Sendable, Equatable, Codable {
|
||||
public let baseURL: URL // http(s)://<dialed-host>:<port>,Origin 由它单点派生
|
||||
public var wsURL: URL { get } // ws(s) 同 host 同 port + WireConstants.wsPath
|
||||
public var originHeader: String { get }
|
||||
// "<scheme>://<host>[:<port>]"——端口为 scheme 默认值(http/80、https/443)时省略,
|
||||
// 其余部分与 baseURL 逐字符一致(与浏览器 Origin 序列化一致)
|
||||
}
|
||||
public protocol TermTransport: Sendable {
|
||||
func connect(to endpoint: HostEndpoint) async throws -> TransportConnection
|
||||
}
|
||||
public struct TransportConnection: Sendable {
|
||||
public let frames: AsyncThrowingStream<String, Error> // 服务器 JSON 文本帧;结束/出错 → 断线
|
||||
public let send: @Sendable (String) async throws -> Void
|
||||
public let close: @Sendable () async -> Void
|
||||
}
|
||||
public protocol HTTPTransport: Sendable { // URLSession 薄封装;FakeHTTPTransport(TestSupport)同实现
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
}
|
||||
public struct TimelineEvent: Sendable, Equatable, Decodable {
|
||||
// 镜像 src/types.ts:428-433(GET /live-sessions/:id/events 条目);未知 class → 该条丢弃不 crash
|
||||
public let at: Int; public let `class`: String
|
||||
public let toolName: String?; public let label: String
|
||||
}
|
||||
public enum Tunables { /* 全部具名常量;值与出处见 §3.2.1 表。需新增常量 → 回 T-iOS-3 改契约 */ }
|
||||
```
|
||||
|
||||
### 3.2 `SessionCore` — 连接核心
|
||||
|
||||
```swift
|
||||
// 伪代码 —— HostEndpoint/TermTransport/TransportConnection/HTTPTransport/TimelineEvent/Tunables
|
||||
// 定义在 WireProtocol(§3.1),本包只 import。TermTransport 是唯一 WS I/O 边界,FakeTransport(TestSupport)
|
||||
// 与 URLSessionTermTransport 同实现此协议;SessionEngine 对二者不可区分。
|
||||
|
||||
public actor SessionEngine { // 每个"打开中的会话"一个 engine;前台仅一个持活
|
||||
public init(transport: any TermTransport, clock: any Clock<Duration>,
|
||||
endpoint: HostEndpoint, reconnect: ReconnectMachine = .initial,
|
||||
eventsSource: @Sendable (UUID) async throws -> [TimelineEvent])
|
||||
// eventsSource = 重连后 digest 的注入点(生产实现由 App 层用 APIClient.events 包一层;
|
||||
// 测试注入 fake)——engine 不直接持有 HTTP 客户端,保持 TermTransport 为唯一 WS 边界
|
||||
public nonisolated let events: AsyncStream<SessionEvent> // UI 消费的唯一出口
|
||||
public func open(sessionId: UUID?, cwd: String?) async // 连接→首帧 attach→回放→实时
|
||||
public func send(_ msg: ClientMessage) async // input/resize/approve/reject
|
||||
public func notifyForegrounded(dims: (cols: Int, rows: Int)) async // 重连+attach+补发 resize
|
||||
public func close() async // 显式 detach(PTY 继续跑)
|
||||
}
|
||||
public enum SessionEvent: Sendable, Equatable {
|
||||
case connection(ConnectionState) // .connecting/.connected/.reconnecting(attempt:next:)/.closed
|
||||
// /.failed(FailureReason) —— 不可重试终态(如 .replayTooLarge:
|
||||
// receive() EMSGSIZE(40)/message-too-big,绝不进 backoff 重连,UI 给可操作话术)
|
||||
case adopted(sessionId: UUID) // attached 帧;未知 UUID 会拿到新 id —— 永远采用它
|
||||
case output(String) // 直接 feed 给 SwiftTerm(@MainActor hop 由 VM 负责)
|
||||
case exited(code: Int, reason: String?)
|
||||
case gate(GateState?) // nil = gate 已解除
|
||||
case telemetry(StatusTelemetry)
|
||||
case digest(AwayDigest) // 重连完成后由 engine 拉 /events 归纳一次
|
||||
}
|
||||
|
||||
public struct ReconnectMachine: Sendable, Equatable { // 纯状态机,注入 Clock,零真实计时
|
||||
public static let initial: ReconnectMachine
|
||||
public enum Input { case connected, disconnected, retryTimerFired, foregrounded, userRetry }
|
||||
public enum Effect: Equatable { case connectNow, scheduleRetry(after: Duration), none }
|
||||
public func reduce(_ input: Input) -> (ReconnectMachine, Effect)
|
||||
// backoff 1s→2s→4s…封顶 30s(镜像 public/terminal-session.ts);connected 归零
|
||||
}
|
||||
public struct PingScheduler: Sendable { // 25s sendPing;连续 2 次无 pong → 视为断线
|
||||
public init(interval: Duration = Tunables.pingInterval)
|
||||
}
|
||||
public struct GateState: Sendable, Equatable {
|
||||
public let kind: GateKind; public let detail: String?
|
||||
public let epoch: Int // pending 上升沿 +1;approve/reject 带 epoch,陈旧操作丢弃(防误批新 gate)
|
||||
}
|
||||
public struct AwayDigest: Sendable, Equatable {
|
||||
public let toolRuns: Int; public let waitingCount: Int
|
||||
public let sawDone: Bool; public let sawStuck: Bool; public let recent: [TimelineEvent]
|
||||
public static func reduce(events: [TimelineEvent], since: Date, limit: Int) -> AwayDigest
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.1 `Tunables` 取值表(唯一取值出处;Tunables.swift 在 WireProtocol,归 T-iOS-3)
|
||||
|
||||
| 常量 | 值 | 出处 / 说明 |
|
||||
|---|---|---|
|
||||
| `pingInterval` | 25 s | §1:URLSessionWebSocketTask 无自动 ping |
|
||||
| `pongMissLimit` | 2 | 连续 2 次无 pong → 视为断线(T-iOS-5) |
|
||||
| `listPollInterval` | 5 s | 镜像 web launcher 轮询节奏(public/launcher.ts:30 `REFRESH_MS = 5000`) |
|
||||
| `telemetryStaleTtlMs` | 30_000 | 镜像 public/tabs.ts:45 `STATUSLINE_TTL_MS`(= 服务器默认 src/config.ts:63;服务器侧 env 可覆盖——iOS 固化默认值,主机改配置时会漂移,已接受) |
|
||||
| `digestFadeDelay` | 8 s | T-iOS-14 digest 自动淡出 |
|
||||
| `titleMaxLength` | 256 | T-iOS-23 OSC 标题净化上限 |
|
||||
| `pairingProbeTimeout` | 10 s | 配对探针整体 deadline(§3.4 契约裁定新增;两步探针任一步挂起超此时限 → `.timeout`) |
|
||||
| `maxWSMessageBytes` | 16 MiB(`16 * 1024 * 1024`) | ≥ 6 × 默认 SCROLLBACK_BYTES(2 MiB) + 帧包络。**耦合警示(写进 doc comment)**:回放单帧 ≈ SCROLLBACK_BYTES × JSON 转义系数(1–6×,控制字节→`\uXXXX`,src/protocol.ts:186);SCROLLBACK_BYTES 为服务器 env 可调且客户端运行时不可知——超限 → 不可重试 `.replayTooLarge`(§3.2 / T-iOS-9/10) |
|
||||
|
||||
### 3.3 `HostRegistry`
|
||||
|
||||
```swift
|
||||
public struct Host: Sendable, Equatable, Codable, Identifiable {
|
||||
public let id: UUID; public let name: String; public let endpoint: HostEndpoint
|
||||
}
|
||||
public protocol HostStore: Sendable { // Keychain 实现 + InMemory 替身(都在本包)
|
||||
func loadAll() async throws -> [Host]
|
||||
func upsert(_ host: Host) async throws -> [Host] // 返回新集合(不可变风格)
|
||||
func remove(id: UUID) async throws -> [Host]
|
||||
}
|
||||
// KeychainHostStore 经 SecItemShim 协议封装 SecItem* 调用(可测性缝):unsigned `swift test` 二进制
|
||||
// 用不了 data-protection keychain(SecItemAdd → -34018 errSecMissingEntitlement),故 swift test 层
|
||||
// 测 store 逻辑对 fake shim;真 Keychain 路径 + kSecAttrAccessible 属性断言放 xcodebuild 模拟器测试(签名宿主)。
|
||||
public protocol LastSessionStore: Sendable { // UserDefaults 实现(非机密)
|
||||
func lastSessionId(host: UUID) -> UUID?
|
||||
func setLastSessionId(_ id: UUID?, host: UUID)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 `APIClient`
|
||||
|
||||
```swift
|
||||
public struct APIClient: Sendable {
|
||||
public init(endpoint: HostEndpoint, http: any HTTPTransport) // HTTPTransport 定义在 WireProtocol,可注入替身
|
||||
// RO(无 Origin):
|
||||
public func liveSessions() async throws -> [LiveSessionInfo] // GET /live-sessions
|
||||
public func preview(id: UUID) async throws -> SessionPreview // GET /live-sessions/:id/preview
|
||||
public func events(id: UUID) async throws -> [TimelineEvent] // GET /live-sessions/:id/events
|
||||
public func uiConfig() async throws -> UiConfig // GET /config/ui {allowAutoMode}
|
||||
// 预留:未来权限模式切换器用(那里才按
|
||||
// allowAutoMode 过滤 raw auto);plan gate 不消费
|
||||
// G(必带 Origin,src/server.ts:332-339):
|
||||
public func killSession(id: UUID) async throws // DELETE /live-sessions/:id
|
||||
public func hookDecision(sessionId: UUID, decision: HookDecision, token: String) async throws
|
||||
}
|
||||
// Origin 铁律:仅 G 端点 stamp `Origin: endpoint.originHeader`;RO GET 一律不带 ——
|
||||
// 这样一旦服务器把某 RO 端点改为 G,集成测试立刻红,而不是靠巧合通过。
|
||||
public enum PairingError: Error, Equatable { // [S:hybrid] 错误分类学,逐项映射探针失败模式
|
||||
case localNetworkDenied // POSIX "Network is down" + LAN IP → 引导去设置开权限
|
||||
case hostUnreachable(underlying: String)
|
||||
case httpOkButNotWebTerminal // GET /live-sessions 非预期形状 → "端口对吗?"
|
||||
case originRejected(hint: String) // WS 401 → "在主机加 ALLOWED_ORIGINS=<scheme>://<拨号 host>[:port],与 App 连接的 URL 一致"
|
||||
case atsBlocked(host: String) // NSURLErrorDomain -1022(ATS 拦明文) → "该 IP 段不在 App 例外列表,
|
||||
// 用 https/tailscale serve,或反馈该网段"(§5.2 例外列表之外的明文目标)
|
||||
case tlsFailure, timeout
|
||||
}
|
||||
public func runPairingProbe(endpoint: HostEndpoint, http: any HTTPTransport,
|
||||
ws: any TermTransport) async -> Result<HostEndpoint, PairingError>
|
||||
// 【契约裁定 2026-07-04,T-iOS-8 BLOCKED 上报】原冻结签名返回 Result<Host,…>,但 Host 是 §3.3
|
||||
// HostRegistry 类型,APIClient 依赖边只有 WireProtocol(§1 叶子包零耦合)——签名自相矛盾。
|
||||
// 裁定:探针职责=验证 endpoint,返回 HostEndpoint;Host{id,name} 由 T-iOS-12 PairingViewModel
|
||||
// 构造后入 store(id/name 本就非探针所知)。超时经 Tunables.pairingProbeTimeout(§3.2.1 新增行)。
|
||||
// 探针两步:①GET /live-sessions(无 Origin,验可达+形状) ②WS attach(null)+立即 kill 往返
|
||||
//(带 Origin,验 isOriginAllowed 精确匹配)。任何失败 → 映射到 PairingError,UI 内联显示。
|
||||
// 注:扫码来源的 endpoint 必须先经 T-iOS-12 的"确认 host"步——用户未确认前不得调用本探针(探针①就会联网)。
|
||||
```
|
||||
|
||||
### 3.5 App 胶水(不冻结,示意)
|
||||
|
||||
`TerminalViewModel`(`@MainActor @Observable`):持有 `SessionEngine`,`for await event in engine.events` 把 `output` `feed()` 给 SwiftTerm、把 `connection/exited` 转成 UI 状态;实现 `TerminalViewDelegate.send` → `engine.send(.input(…))`、`sizeChanged` → `engine.send(.resize(…))`。`GateViewModel`(独立 VM,T-iOS-14)消费同一 events 流的 `.gate/.digest`(T-iOS-15 接线)。Swift 6 strict concurrency 编译期强制 socket→main 的 hop。`scenePhase == .active` → `engine.notifyForegrounded(dims:)`(重连 + 补发 resize,latest-writer-wins 夺回全屏);`scenePhase != .active` → 隐私遮罩(T-iOS-15)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 工程标准(每个任务的硬性门槛)
|
||||
|
||||
- **TDD 强制**:每个任务 RED(先写失败测试,来自 Steps(测试))→ GREEN(最小实现)→ REFACTOR。测试与实现同属一个任务、一个 agent。
|
||||
- **覆盖率 ≥ 80%**:unit + integration + E2E 三类都要;门只对 4 个 Packages 计(App 胶水排除)。
|
||||
- **不可变**:绝不原地 mutate;状态为不可变快照,变更返回新副本;可变运行时句柄(socket/URLSession/TerminalView)单独持有在 actor / @MainActor 类内。
|
||||
- **文件 200–400 行典型,硬上限 800**;多小文件、按 feature 组织。
|
||||
- **函数 < 50 行**,单一职责。
|
||||
- **早返回**优先,嵌套 > 4 层禁止。
|
||||
- **无魔法数字**:阈值/延迟/上限一律具名常量(`Tunables.pingInterval`、`Tunables.maxWSMessageBytes = 8 * 1024 * 1024`…)。
|
||||
- **无硬编码配置/密钥**:用户可见配置进 Settings;必需配置启动即校验、fail fast。
|
||||
- **系统边界全验证**:**把服务器当不可信输入源**——每一帧过 `MessageCodec`/`Validation` 白名单,非法 → 丢弃(不 crash、不猜);用户输入(URL、扫码结果)同样先验证。
|
||||
- **错误处理全面显式**:UI 给用户友好话术(PairingError 分类学、ReconnectBanner),内部日志留细节;绝不静默吞错。
|
||||
- **Conventional commits**(`feat:`/`fix:`/`test:`…),提交边界对齐任务。
|
||||
- **每任务完成即 code review**(CRITICAL/HIGH 必须修完才算完);网络/输入/Keychain 代码追加 security review。
|
||||
|
||||
---
|
||||
|
||||
## 5. 安全考量(沿用现有威胁模型)
|
||||
|
||||
### 5.1 Origin header(原生客户端的第一道坎)
|
||||
|
||||
`isOriginAllowed` **对 `undefined`/空 Origin 默认拒绝**(src/http/origin.ts:26-29——非浏览器客户端天然被拒),且要求 scheme+hostname+port **逐项精确匹配**白名单(src/http/origin.ts:47-51)。白名单由**主机网卡 IPv4 + 端口**派生(不是 bindHost):`http(s)://localhost:<port>`、`127.0.0.1`、各非内网卡 IP,再并入 `ALLOWED_ORIGINS` env(src/config.ts:187-226)。因此:
|
||||
|
||||
- App 必须在 **(a) WS 升级**(否则 401,src/server.ts:646-651)与 **(b) 所有 `G` 类变更 HTTP**(否则 403,src/server.ts:332-339)上显式携带 `Origin: <scheme>://<host>[:<port>]`——端口为 scheme 默认值(http/80、https/443)时省略,其余部分与实际连接 URL 逐字符一致(与浏览器 Origin 序列化一致),由 `HostEndpoint.originHeader` 单点派生,禁止手拼。
|
||||
- RO GET **一律不带** Origin(见 §3.4 铁律)。
|
||||
- Tailscale/TLS 场景:`ALLOWED_ORIGINS=<scheme>://<App 拨号的 host>[:port]`——填 App 实际连接的 URL 即可。`isOriginAllowed` 对**两侧**都做 `new URL()` 规范化后比对 protocol/hostname/port(src/http/origin.ts:31-51),https 默认端口写不写 `:443` 均匹配(WHATWG URL 把默认端口规范化为空串,双向对称)。PairingError 话术只引导"加与拨号 URL 一致的 `ALLOWED_ORIGINS`",不引入端口迷信。
|
||||
|
||||
### 5.2 ATS 与 Local Network(Info.plist 精确键,只开最小口子)
|
||||
|
||||
```yaml
|
||||
# project.yml 内声明(release 构建;debug 可临时 NSAllowsArbitraryLoads,严禁进 release ——
|
||||
# 注意:debug 全放开会掩盖缺段问题,五段 CIDR 是否齐全必须在 release ipa 层核对,见 T-iOS-19)
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsLocalNetworking: true # 只覆盖 .local / 无点主机名,不覆盖裸 IP
|
||||
NSExceptionDomains: # 裸 IP 用 CIDR 例外(iOS 17+ 语义)
|
||||
"192.168.0.0/16": { NSExceptionAllowsInsecureHTTPLoads: true }
|
||||
"10.0.0.0/8": { NSExceptionAllowsInsecureHTTPLoads: true }
|
||||
"172.16.0.0/12": { NSExceptionAllowsInsecureHTTPLoads: true } # RFC1918 第三段(iPhone 热点 172.20.10.x/企业内网)
|
||||
"100.64.0.0/10": { NSExceptionAllowsInsecureHTTPLoads: true } # Tailscale CGNAT
|
||||
"127.0.0.0/8": { NSExceptionAllowsInsecureHTTPLoads: true } # 模拟器 dev-loop(用 CIDR 而非单 IP 键——
|
||||
# loopback 单 IP 键有不匹配史,DevForums 6205)
|
||||
NSLocalNetworkUsageDescription: "连接你自己电脑上的 web-terminal 服务器" # iOS 18+ 缺失则提示异常
|
||||
NSCameraUsageDescription: "扫描 web 终端的配对二维码" # P0 必需:T-iOS-12 用 DataScannerViewController,
|
||||
# 缺失 = 打开扫码即 TCC crash
|
||||
```
|
||||
|
||||
> P2 前置:T-iOS-31(语音 PTT)开工前需另加 `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`(属于该任务的前置,不属于 P0)。
|
||||
|
||||
- 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 同步。
|
||||
- **UserDefaults**:仅非机密(per-host lastSessionId、UI prefs)。
|
||||
- `/hook/decision` 的 capability `token` **只经 push payload 到达、用后即弃**(服务器侧本就单次有效+过期,src/server.ts:503-525;限频 10/min/IP);App 端绝不落盘。
|
||||
- App 内无任何硬编码 host/密钥;首个 host 必经配对流程。
|
||||
|
||||
### 5.4 继承的威胁模型(TECH_DOC §7)
|
||||
|
||||
无鉴权 = **谁能连上端口谁就有 shell**。App 不改变这一点,只继承部署纪律:**绝不要**把服务器端口 port-forward/隧道到公网——后果是任何人拿到你的 shell;推荐 Tailscale(设备注册 + WireGuard E2E + 端口对公网不可见)。**标准部署话术推荐 `tailscale serve`(wss)**——绕开全部 ATS 例外与明文嗅探面(开放项 5 已定)。
|
||||
|
||||
配对/确认界面的提示**按 scheme + 地址类别分层**(TECH_DOC §7 明文嗅探/MITM 行的完整继承——`ws://` 在任何不可信 LAN 上暴露键击(密码/API key/Claude token)与全部输出给同网段被动嗅探与 ARP-spoof MITM;Origin 校验对此零防护):
|
||||
|
||||
| 目标 | 提示 |
|
||||
|---|---|
|
||||
| https/wss | 无 |
|
||||
| ws:// → 100.64.0.0/10 或 *.ts.net(MagicDNS) | 无明文警告(WireGuard 网络层已加密);可选正向"经 Tailscale 加密"徽标 |
|
||||
| ws:// → loopback | 无 |
|
||||
| ws:// → RFC1918/link-local | **非阻断明文提示**:流量未加密、键击可被同网嗅探,仅限可信 LAN,优先 `tailscale serve`(wss) |
|
||||
| http/ws → 公网(非 RFC1918,https 也含在"公网 host"确认警告内,见 T-iOS-12) | **最强阻断式警告**(醒目、需显式确认) |
|
||||
|
||||
### 5.5 relay / E2E:显式推迟(为什么不现在做)
|
||||
|
||||
relay 栈(term-relay/agent/relay-e2e/relay-auth…)是 pre-production 库集合:无可跑服务端进程、F6 replay 路径未端到端接线、Phase 0 集成未开工([DEPLOY_RELAY.md](./DEPLOY_RELAY.md)、[PLAN_RELAY_RUN_PHASE0.md](./PLAN_RELAY_RUN_PHASE0.md))。更关键的:relay-e2e 的密码学(X25519 握手、HKDF 方向分key、`nonce=f(seq)` 确定性 AEAD、epoch-in-key 回放)刚经 99-agent 对抗审计修完 F1–F6——**用 Swift 重写 = 重开每一个已关闭的 finding,且一条既有回归测试都带不走**(F6 恰是第一轮审计漏掉的那类交互)。故 v1 远程 = LAN + Tailscale;relay 上线后先评估 WKWebView 嵌 relay-web(字节级复用已审计 TS),Swift 移植仅在有跨语言 KAT + 独立审计预算时考虑。
|
||||
|
||||
---
|
||||
|
||||
## 6. 多 Agent 并行规则与波次
|
||||
|
||||
并行三条铁律(同 [PLAN.md](./PLAN.md) §0,违反会互相踩踏):
|
||||
|
||||
1. **文件所有权独占**:每任务 `Owns:` 独占创建/修改权。`ios/Packages/WireProtocol/**` 由 **T-iOS-3 独占并冻结**,其余任务只读 import;要加共享类型 → 回 T-iOS-3 改契约,不得各自另立。
|
||||
2. **只依赖接口不依赖实现**:`Depends:` 指"需要对方接口/产物";§3 的签名在 W0 冻结,故多数任务可与依赖方并行编码,集成时汇合。
|
||||
3. **G1**:LOG 由 orchestrator 独写;subagent 返回可粘贴条目(见页首)。
|
||||
|
||||
subagent **不能问用户、不能互相通信**:遇到本文+ARCHITECTURE+TECH_DOC 未定义的歧义 → 停下返回 `[!] BLOCKED`,**绝不猜**。单批并行控制在 **~3–5 个 agent**。验收任务 report-only(G4):findings 标 severity + owning task,修复派回该任务的 builder。
|
||||
|
||||
```
|
||||
W0 基础(串行;T-iOS-2 与 T-iOS-3 可在 T-iOS-1 后并行)
|
||||
T-iOS-1 脚手架 → T-iOS-2 Day-1 双 spike ∥ T-iOS-3 WireProtocol 契约(冻结,含共享 I/O 边界类型+Tunables) → T-iOS-4 测试替身
|
||||
│
|
||||
▼
|
||||
W1 叶子包(全部并行,互不依赖)
|
||||
T-iOS-5 Reconnect+Ping · T-iOS-6 Gate+Digest · T-iOS-7 HostRegistry · T-iOS-8 APIClient+探针
|
||||
│
|
||||
▼
|
||||
W2 连接核心
|
||||
T-iOS-9 URLSessionTermTransport → T-iOS-10 SessionEngine(可先按 §3 接口并行编码,集成汇合)
|
||||
│
|
||||
▼
|
||||
W3 UI 胶水(全部并行;只依赖 §3 接口 + FakeTransport)
|
||||
T-iOS-11 Terminal+KeyBar · T-iOS-12 Pairing · T-iOS-13 SessionList · T-iOS-14 Gate/Digest UI
|
||||
│
|
||||
▼
|
||||
W4 集成(汇合点;T-iOS-16 仅依赖 W2、T-iOS-17 零依赖——二者可提前并入第 6 批,见 §8)
|
||||
T-iOS-15 App 接线+生命周期 · T-iOS-16 集成 CI(真 Node 服务器) · T-iOS-17 ntfy 桥验证
|
||||
│
|
||||
▼
|
||||
W5 验收(report-only, 并行)
|
||||
T-iOS-18 F 走查(真机) · T-iOS-19 安全核对(对照 TECH_DOC §7 + 本文 §5)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 任务清单
|
||||
|
||||
> 状态图例:`[ ]` TODO · `[~]` 进行中 · `[x]` 完成 · `[!]` 受阻。ID 稳定,永不重编号。
|
||||
> 每个任务自带 TDD(测试与实现同 agent 同文件组)。测试框架:Swift Testing(`@Test`/`#expect`);XCTest 仅 XCUITest。
|
||||
> 覆盖率验证命令(各包):`swift test --package-path ios/Packages/<Pkg> --enable-code-coverage` + `xcrun llvm-cov report`(阈值 80%,见 §9)。
|
||||
|
||||
### P0 — 每日可用("口袋里能开终端、能批准")· 合计 ~13 人天
|
||||
|
||||
#### W0 · 基础(串行)
|
||||
|
||||
#### T-iOS-1 · `ios/` 脚手架 + XcodeGen 工程 `[ ]` · ~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**:
|
||||
- [ ] `project.yml`:App target(iOS 17 floor、Swift 6 language mode、strict concurrency、default MainActor isolation)+ 4 个本地 SPM 包引用 + SwiftTerm v1.13+(SPM,仅 App target)
|
||||
- [ ] Info.plist 键全部经 `project.yml` 声明:**§5.2 的全部键**(ATS/五段 CIDR/NSLocalNetworkUsageDescription/NSCameraUsageDescription——枚举以 §5.2 为准,不得漏项;release 无 `NSAllowsArbitraryLoads`)
|
||||
- [ ] `xcodegen generate` 产物 gitignore;CI 骨架跑 `swift test`(0 测试也过)
|
||||
- **Accept**: `xcodegen generate && xcodebuild … build` 通过;空 App 在模拟器启动
|
||||
- **安全注**: ATS 键从本文 §5.2 逐字誊写,不许"先放开回头再收"。
|
||||
|
||||
#### T-iOS-2 · Day-1 双 spike(评审强制)`[ ]` · ~1 pd
|
||||
- **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 本身就是测试)**:
|
||||
- [ ] `OriginSpikeTests`(对本仓库真服务器 `npm start`):① 无 Origin 的 WS 升级收 **401**(src/server.ts:646-651)② `Origin: http://127.0.0.1:<port>` 精确匹配 → 升级成功、`attach(null)` 收到 `attached` ③ `maximumMessageSize` 默认 1 MiB 时灌 >1 MiB 输出再 reattach → **复现失败**;设 `Tunables.maxWSMessageBytes`(16 MiB)→ 回放成功;**追加对抗用例**:预灌 ESC/C0 控制字节密集输出(JSON `\uXXXX` 转义膨胀 ~6×)再 reattach → 仍成功 ④ 端口不匹配的 Origin(如 `Origin: http://127.0.0.1:9999`)→ **401**(src/http/origin.ts:47-51 端口精确比对;注意默认端口如 `:443` 会被 `new URL()` 双向规范化、**不是**失配案例)
|
||||
- [ ] 真机 smoke(人工清单,结果记录进返回条目):SwiftTerm 键盘弹出/first-responder、中文 IME 组合输入、`inputAccessoryView` 原型上 Esc/Ctrl-C 可发、文本选择不崩
|
||||
- **Accept**: 4 条自动化断言绿(其中 ③ 的"复现失败"分支用 `withKnownIssue` 记录);真机清单逐项有结论
|
||||
- **安全注**: 这是对 "URLSessionWebSocketTask 可发自定义 Origin"(MED 置信度)的一锤定音;若失败 → `[!] BLOCKED`,orchestrator 决策切 Starscream 备胎,**不得自行引入依赖**。
|
||||
|
||||
#### T-iOS-3 · `WireProtocol` 契约包(冻结,含共享 I/O 边界类型)`[ ]` · ~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`:
|
||||
- [ ] 5 种 `ClientMessage` encode 后的 JSON 与服务器 `parseClientMessage` 接受的形状逐键一致(`attach` 必含 `sessionId` 键,null 也要显式,src/protocol.ts:132-134)
|
||||
- [ ] `decodeServer` 5 种合法帧解出对应 case;`attached` 的 sessionId 非 UUID → nil
|
||||
- [ ] 非法帧全表 → nil 且不 throw:坏 JSON、未知 type、`resize` cols=0/1001/非整数、`input.data` 非 string、缺字段
|
||||
- [ ] `Validation.isValidSessionId`:合法 UUID v4 过;`abc123`、UUID v1、大写混合按服务器正则同判(向量从 `test/protocol.test.ts` 移植)
|
||||
- [ ] `isAbsoluteCwd("/a")==true`、`("a")==false`、`("")==false`
|
||||
- [ ] `telemetry` 帧全可选字段缺省可解、`at` 缺失 → nil
|
||||
- [ ] roundtrip property:任意合法 ClientMessage `encode→(服务器视角)decode` 不变形——**`approve.mode` 除外**:服务器 `parseClientMessage` 刻意丢 mode、对一切 approve 帧返回裸 `{type:'approve'}`(src/protocol.ts:77-79),mode 由 WS 接线层对**原始帧**二次解析顶层 `mode` 键恢复(src/server.ts:91-102)。approve 只断言"形状被接受"
|
||||
- [ ] 显式向量:`encode(.approve(mode:))` 必须把 `mode` 放**顶层键**、值 ∈ {default,acceptEdits,plan,auto}(因为 src/server.ts:94-102 读的是原始帧的 `obj['mode']`,不是解析后消息)
|
||||
- [ ] `HostEndpoint` 派生向量(原 T-iOS-7 用例移入本包):`http://192.168.1.5:3000` → originHeader 同串;https+非标端口保留端口;**https+443 → 无端口后缀**;**http+80 → 无端口后缀**;wsURL 派生 scheme http→ws / https→wss + `/term`
|
||||
- [ ] `TimelineEvent` 解码:合法条目 + 未知 `class` → nil(消费方丢弃该条)
|
||||
- **Steps(实现, GREEN)**: [ ] §3.1 全部类型与函数(含 `Tunables` 常量表 §3.2.1 落地)[ ] 完成后**冻结**:新增类型/常量必须回本任务改
|
||||
- **Accept**: `swift test --package-path ios/Packages/WireProtocol` 全绿;覆盖率 ≥ 80%
|
||||
- **安全注**: 服务器是不可信输入源——decode 对模糊输入永不 crash(用随机字节 fuzz 一轮)。
|
||||
|
||||
#### T-iOS-4 · TestSupport 测试替身 `[ ]` · ~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 才存在)
|
||||
- **Accept**: `swift test --package-path ios/Packages/TestSupport` 全绿
|
||||
|
||||
#### W1 · 叶子包(全部并行)
|
||||
|
||||
#### T-iOS-5 · `ReconnectMachine` + `PingScheduler` `[ ]` · ~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)**:
|
||||
- [ ] 断线序列 → 重试延迟 1s,2s,4s,8s,16s,30s,30s(封顶)
|
||||
- [ ] `connected` 输入 → backoff 归零,下次断线从 1s 重来
|
||||
- [ ] `foregrounded`/`userRetry` → 立即 `connectNow`,不等定时器
|
||||
- [ ] reduce 是纯函数:同输入同输出;原值不被改(Equatable 断言旧快照)
|
||||
- [ ] PingScheduler:25s 触发 ping;1 次 pong 丢失容忍、连续 2 次 → 发出 disconnected 信号;FakeClock 推进驱动,测试 0 真实等待
|
||||
- **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
|
||||
- **Wave/阶段**: W1 / P0 · **Owns**: `SessionCore/Sources/…/{GateState,AwayDigest}.swift`、对应 Tests
|
||||
- **Depends**: T-iOS-3 · **Parallel-safe**: T-iOS-5/7/8
|
||||
- **Steps(测试先行, RED)**:
|
||||
- [ ] status 帧 `pending:false→true` 上升沿 epoch +1;同一 pending 持续不再 +1
|
||||
- [ ] 携带过期 epoch 的 approve → 判定丢弃(防止批到"下一个 gate")
|
||||
- [ ] `gate:'plan'` 与 `'tool'` 分别映射三选一/两选一 affordance 数据
|
||||
- [ ] digest reduce:events 里 3 tool + 1 waiting + done → `{toolRuns:3, waitingCount:1, sawDone:true}`
|
||||
- [ ] `since` 过滤:早于离开时刻的事件不计入
|
||||
- [ ] `limit` 截断 recent;空 events → 全零 digest(UI 可据此不渲染)
|
||||
- **Accept**: 对应 filter 全绿;reducer 纯函数、不可变
|
||||
|
||||
#### T-iOS-7 · `HostRegistry` 包 `[ ]` · ~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` 缝测:
|
||||
- [ ] upsert 新 host → 集合含它;同 id 再 upsert → 替换不重复
|
||||
- [ ] remove 不存在的 id → 集合不变、不 throw 隐患(显式结果)
|
||||
- [ ] `KeychainHostStore` 逻辑对 fake `SecItemShim`:add/update/delete 走对分支、错误码显式映射(**不可**在 `swift test` 直连真 Keychain——unsigned 测试二进制对 data-protection keychain 必得 `-34018 errSecMissingEntitlement`;省掉 `kSecUseDataProtectionKeychain` 又会落到 legacy 文件 keychain、验证不了 §5.3 语义)
|
||||
- [ ] lastSessionId 存取/清除
|
||||
-(`HostEndpoint` originHeader/wsURL 派生向量已移入 T-iOS-3 的 WireProtocol 测试——本包不再定义该类型)
|
||||
- **Steps(实现, GREEN)**: [ ] `SecItemShim` 协议 + 真实现(`kSecUseDataProtectionKeychain` + AfterFirstUnlockThisDeviceOnly)[ ] UserDefaults LastSessionStore [ ] 真 Keychain 路径 + `kSecAttrAccessible` 属性断言放 **xcodebuild 模拟器测试**(签名宿主 App,T-iOS-15/16 管线)
|
||||
- **Accept**: `swift test --package-path ios/Packages/HostRegistry` 全绿(覆盖率计法见 §9:KeychainHostStore 以 shim 注入计入)
|
||||
- **安全注**: Keychain 属性错一个字 = 凭据可被备份带走;review 时对照 §5.3。
|
||||
|
||||
#### T-iOS-8 · `APIClient` 包 + 配对探针 `[ ]` · ~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`:
|
||||
- [ ] **Origin 出现当且仅当 G 端点**:`liveSessions/preview/events/uiConfig` 请求无 Origin header;`killSession/hookDecision` 有且逐字符等于 `endpoint.originHeader`
|
||||
- [ ] `LiveSessionInfo` 解码:全字段样本 + `telemetry` 缺失样本(src/types.ts:246-256 形状)
|
||||
- [ ] `TimelineEvent` 解码 + 未知 `class` 值 → 该条丢弃不 crash(服务器视为不可信)
|
||||
- [ ] `hookDecision` body 形状 `{sessionId,decision,token}`;403 → 显式错误(token 过期话术)
|
||||
- [ ] 探针①失败分支:连接拒绝 → `hostUnreachable`;返回 HTML → `httpOkButNotWebTerminal`
|
||||
- [ ] 探针②失败分支:WS 401 → `originRejected(hint:…)`(hint 含 `ALLOWED_ORIGINS=<scheme>://<拨号 host>[:port]`,与 App 连接的 URL 一致——不含任何 ":443 迷信")
|
||||
- [ ] 探针全通 → `Result.success(HostEndpoint)`(契约裁定:Host 由 T-iOS-12 VM 构造,见 §3.4 注);探针成功路径里 attach(null) 后必发 kill(不留孤儿会话)
|
||||
- [ ] 超时(FakeClock)→ `.timeout`
|
||||
- **Steps(实现, GREEN)**: [ ] §3.4 签名 [ ] 服务器约束写进 doc comment(按端点精确):`hookDecision` body ≤ 4 KB(src/server.ts:503)、≤10 次/分/IP(src/server.ts:72,504-508)——P1 增量端点的限额归 T-iOS-38
|
||||
- **Accept**: `swift test --package-path ios/Packages/APIClient` 全绿;覆盖率 ≥ 80%
|
||||
- **安全注**: G/RO 分界是安全语义(§5.1),测试名里写明"iff"。
|
||||
|
||||
#### W2 · 连接核心
|
||||
|
||||
#### T-iOS-9 · `URLSessionTermTransport` `[ ]` · ~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 服务器):
|
||||
- [ ] 升级请求含 `Origin` header 且等于 endpoint.originHeader
|
||||
- [ ] `maximumMessageSize == Tunables.maxWSMessageBytes`(16 MiB)——直接断言 task 配置
|
||||
- [ ] receive 循环持续 re-arm:连发 100 帧全部到达、顺序不乱
|
||||
- [ ] 服务器关闭(close frame)→ frames stream finish;错误 → stream throw(两种可区分)
|
||||
- [ ] receive 失败 NSPOSIXErrorDomain code 40(EMSGSIZE,"Message too long"——T-iOS-2 spike 实测;原文 ENOBUFS 为笔误,55(ENOBUFS) 作兜底同判——超 `maximumMessageSize` 在 iOS 上**不是** 1009 干净关闭)→ stream throw **类型化 `.replayTooLarge` 错误**(供 engine 识别为不可重试)
|
||||
- [ ] `close()` 后 send → 显式错误,不 crash
|
||||
- [ ] 非文本(binary)帧 → 丢弃并继续收(服务器只发文本帧,但不信任它)
|
||||
- **Steps(实现, GREEN)**: [ ] `URLSessionWebSocketDelegate`(didOpen/didClose 驱动状态,不靠 receive error 猜)[ ] ping 由 PingScheduler 注入驱动
|
||||
- **Accept**: filter 全绿;T-iOS-16 的真服务器测试是它的最终验收
|
||||
- **安全注**: Origin 单点取自 `HostEndpoint.originHeader`,本文件出现字符串拼接 origin = review CRITICAL。
|
||||
|
||||
#### T-iOS-10 · `SessionEngine` actor `[ ]` · ~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:
|
||||
- [ ] `open()` 后**首帧必为 attach**,之前的 send 排队不越序(src/server.ts:707-711 语义)
|
||||
- [ ] `attached` 帧 → `adopted(sessionId:)` 事件;**未知 UUID 拿到新 id 时采用新 id**(src/session/manager.ts:166-173)
|
||||
- [ ] 回放→实时顺序:attach 后灌 3 帧 output → events 按序交付、无丢帧
|
||||
- [ ] `exit(code:-1, reason:)` → `exited` 事件且 engine 停止重连(spawn 失败不重试)
|
||||
- [ ] 断线 → `connection(.reconnecting(attempt:next:))` 事件流 + FakeClock 推进后自动重连、重新 attach **同一 sessionId**
|
||||
- [ ] `notifyForegrounded(dims:)` → 重连后补发 `resize`(latest-writer-wins);已连接时只发 resize 不重连
|
||||
- [ ] gate 时序:status(pending:true) → `gate(GateState(epoch:1))`;approve 后 pending:false → `gate(nil)`;epoch 过期的 approve 不发送
|
||||
- [ ] 重连成功后拉 events 归纳 → 恰好一次 `digest` 事件(经 init 的 `eventsSource` 参数注入 fake events 源,§3.2)
|
||||
- [ ] transport 抛 `.replayTooLarge` → `connection(.failed(.replayTooLarge))` 事件、**停止重连**(不可进 backoff 循环——否则确定性无限重试)
|
||||
- [ ] `close()` → transport.close 被调、events stream finish、无泄漏 task(用 confirmation 断言)
|
||||
- **Steps(实现, GREEN)**: [ ] §3.2 签名 [ ] attach 后立即补发一次 resize(服务器 80×24 spawn,src/server.ts:714-717)
|
||||
- **Accept**: `swift test --package-path ios/Packages/SessionCore` 全绿;SessionCore 包覆盖率 ≥ 80%
|
||||
- **安全注**: 每帧过 `MessageCodec.decodeServer`,nil → 丢弃计数(日志),绝不 crash。
|
||||
|
||||
#### W3 · UI 胶水(全部并行;只依赖 §3 接口 + 替身)
|
||||
|
||||
#### T-iOS-11 · `TerminalScreen` + `KeyBar` `[ ]` · ~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)**:
|
||||
- [ ] 字节表逐键对照 `public/keybar.ts`:Esc=`\u{1B}`、Esc·Esc、Shift+Tab=`\u{1B}[Z`、↑↓←→=`\u{1B}[A/B/D/C`、Enter=`\r`(**不是 `\n`**)、Ctrl-C=`\u{03}`、Ctrl-R/O/L/T/B/D、Tab=`\t`、`/`
|
||||
- [ ] VM:engine 发 `output` → `feed` 调用在 MainActor(编译期由 Swift 6 保证,测试断言转发次序)
|
||||
- [ ] VM:`connection(.reconnecting)` → banner 状态量;`.connected` → 隐藏
|
||||
- [ ] VM:`connection(.failed(.replayTooLarge))` → 不再重试的错误态 + 可操作话术("服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限")
|
||||
- [ ] VM:`exited` → 终端只读 + exit 提示状态
|
||||
- **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
|
||||
- **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 测过,这里测状态映射):
|
||||
- [ ] 每种 `PairingError` → 对应内联话术与"去设置/重试"动作(`localNetworkDenied` → 打开设置深链;`atsBlocked` → "明文 HTTP 被 ATS 拦截——该 IP 段不在 App 例外列表内,请用 https/`tailscale serve` 或反馈该网段")
|
||||
- [ ] **扫码 → `confirmingHost` 确认态**:展示解析出的 `scheme://host:port`(经 `HostEndpoint` 单点解析,禁止手拼),**用户未点"连接"前对该 host 零网络请求**(FakeHTTPTransport/FakeTransport 断言零调用——探针①就会联网、②会在目标机 spawn 会话,扫码内容是不可信外部输入)
|
||||
- [ ] 确认后才:跑两步探针 → 成功 → host 入 store + 跳转列表
|
||||
- [ ] 扫码结果非 http(s) URL → 拒绝并提示(输入边界验证)
|
||||
- [ ] 警告分层(§5.4,**在确认页展示**):公网 host(**http/https 均**——RFC1918、100.64/10、loopback、`.local`、`*.ts.net` 视为私网级,其余一律公网警告)→ 醒目警告;`ws://`+RFC1918/link-local → 非阻断明文嗅探提示;100.64/10 或 `*.ts.net` → 无明文警告(Tailscale 豁免);loopback → 无
|
||||
- **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
|
||||
- **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:
|
||||
- [ ] 轮询节奏:前台每 `Tunables.listPollInterval`(5 s,§3.2.1)拉一次 `/live-sessions`;离开页面停止(无泄漏 timer)
|
||||
- [ ] 状态点映射 5 态 + `pending:true` → ⚠ 徽标优先
|
||||
- [ ] telemetry staleness:`at` 距今 > `Tunables.telemetryStaleTtlMs`(30 s,镜像 public/tabs.ts:45 `STATUSLINE_TTL_MS`)→ 芯片置灰
|
||||
- [ ] swipe-to-kill → `DELETE /live-sessions/:id`(带 Origin)→ 乐观移除 + 失败回滚
|
||||
- [ ] 列表排序 newest-first 保持服务器顺序;`exited:true` 会话分组置底
|
||||
- [ ] "+ New session" → 进入 TerminalScreen 且 `open(sessionId:nil)`
|
||||
- **Steps(实现, GREEN)**: [ ] 下拉刷新 [ ] host 切换 header [ ] 空态(无会话/未配对)
|
||||
- **Accept**: VM 测试全绿
|
||||
|
||||
#### T-iOS-14 · `GateBanner` + `PlanGateSheet` + `AwayDigestView` `[ ]` · ~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)**:
|
||||
- [ ] `gate(kind:.tool)` → 两键横幅(Approve/Reject);`.plan` → 三选一 sheet(Approve+Auto / Approve+Review / Keep Planning)
|
||||
- [ ] 三选一映射(**镜像 public/tabs.ts:345-347 与 src/types.ts:84-86**):Approve+Auto→`approve(mode:.acceptEdits)`、Approve+Review→`approve(mode:.default)`、Keep Planning→`reject`。**无 allowAutoMode 门**——web 端从不在 plan gate 上做此门控;`acceptEdits` 不受 SEC-M5 auto 降级影响(降级只打 raw `auto`,src/server.ts:765-766);raw `auto` 仅保留给未来权限模式切换器(§3.1 注)
|
||||
- [ ] gate 到达 → haptic 触发一次(同 gate 不重复震)
|
||||
- [ ] digest 事件非全零 → 顶部渲染摘要行;全零 → 不渲染;点击展开 recent 明细
|
||||
- **Steps(实现, GREEN)**: [ ] `UINotificationFeedbackGenerator` [ ] digest 自动淡出(`Tunables.digestFadeDelay` = 8 s)、可手动展开
|
||||
- **Accept**: VM/映射测试全绿
|
||||
|
||||
#### W4 · 集成(汇合点)
|
||||
|
||||
#### T-iOS-15 · App 接线 + 生命周期 `[ ]` · ~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
|
||||
- **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:
|
||||
- [ ] attach(null) → attached → input `echo hi\r` → output 含 `hi`
|
||||
- [ ] resize(120,40) 后 `stty size` 输出 `40 120`(整数边界 1/1000 各一发)
|
||||
- [ ] 灌 >1 MiB 输出 → 断开 → 重 attach → 回放完整(16 MiB 上限的端到端回归);**追加对抗用例**:ESC/C0 控制字节密集回放(JSON `\uXXXX` 转义膨胀 ~6×)→ 仍完整
|
||||
- [ ] 双客户端 JOIN mirror:A、B 同 attach,A input,B 收 output
|
||||
- [ ] 无 Origin → 401;错 Origin → 401;DELETE 无 Origin → 403(G 守卫回归)
|
||||
- [ ] `DELETE /live-sessions/:id`(kill)→ 镜像客户端观察到 **WS close**(**不是** `exit` 帧——`manager.killById` 先 `ws.close()` 全部客户端再 kill,src/session/manager.ts:202-212,`sendIfOpen` 只投给 OPEN socket)且该 id 从 `GET /live-sessions` 消失
|
||||
- [ ] 自然退出广播路径:客户端 A input `exit\r` → 镜像客户端 B 收到 `{type:'exit'}` 帧(socket 仍开时 pty onExit 广播,src/session/session.ts:146-151)
|
||||
- [ ] **覆盖率门接线**:`ios.yml` 对 4 个包跑 §9 覆盖率循环,任一包 line coverage < 80% → job 红
|
||||
- **Accept**: CI job 绿;覆盖率门**演示过一次红**(故意把某包压到 80% 下或抬高阈值)再回绿;这是"客户端复刻的协议契约"的持续防漂移闸门
|
||||
- **安全注**: 本任务是 §5.1 的自动化化身;任何"为了过 CI 放宽 Origin 断言"= CRITICAL。
|
||||
|
||||
#### T-iOS-17 · ntfy 桥验证 + 文档(P0 临时通知,**零新代码**)`[ ]` · ~0.1 pd
|
||||
- **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 经事件总线补上)
|
||||
- **Accept**: 手机装 ntfy App 订阅 topic → 触发 held gate → 手机收到通知;P1 APNs 落地后 README 标注可停用
|
||||
- **安全注**: 复核既有 payload 最小化(sessionId 短前缀 + 状态词,不含 cwd/命令内容;token 绝不进命令字面量——SEC-C6 已保证)。
|
||||
|
||||
#### W5 · 验收(report-only,G4:只报告不改码,findings 标 owning task 派回)
|
||||
|
||||
#### T-iOS-18 · 验收走查 F-iOS-1…13(真机)`[ ]` · ~0.25 pd
|
||||
- **Depends**: T-iOS-15/16/17 · **Owns**: 无源码(report-only)
|
||||
- **Steps**: 按 §9 验收脚本逐条执行、记录结论与录屏。
|
||||
#### T-iOS-19 · 安全核对 `[ ]` · ~0.25 pd
|
||||
- **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 豁免)、**真机核:切后台开切换器 → 快照卡片是遮罩非终端内容**。
|
||||
|
||||
**P0 合计 ≈ 13 人天**(0.5+1+1.25+0.25 / 0.5+0.5+0.5+1 / 1+1.5 / 1+0.5+1+1 / 0.5+0.5+0.1 / 0.25+0.25)。
|
||||
|
||||
---
|
||||
|
||||
### P1 — walk-away 完整("锁屏上两次手势搞定")· 合计 ~17 人天
|
||||
|
||||
> 波次:**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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
- **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)
|
||||
- **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 相应减负)。
|
||||
|
||||
---
|
||||
|
||||
### P2 — 打磨 · 合计 ~8 人天
|
||||
|
||||
> P2 任务此处只给**分派必需元数据**(Wave/Owns/Depends/Accept);完整 RED 测试清单在开工时由任务 owner 按 P0 规格扩写(不改 §3 接口)。**未扩写前不得按下述描述直接分派**(§4 TDD 强制与 §6 Owns 铁律同样适用)。
|
||||
|
||||
- **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。
|
||||
|
||||
**总计:P0 13 + P1 17.5 + P2 8 ≈ 38.5 人天。**
|
||||
|
||||
---
|
||||
|
||||
## 8. 分派批次与模型/隔离分配(多 agent 用)
|
||||
|
||||
| 批次 | 可同时开工 | 说明 |
|
||||
|------|-----------|------|
|
||||
| 第 1 批 | T-iOS-1 | 串行,工程骨架 |
|
||||
| 第 2 批 | T-iOS-2 ∥ T-iOS-3 | spike 与契约并行(都只依赖骨架) |
|
||||
| 第 3 批 | T-iOS-4 | 替身(快,可并入第 4 批首个 agent) |
|
||||
| 第 4 批 | **T-iOS-5,6,7,8** | W1 四叶子全并行 |
|
||||
| 第 5 批 | T-iOS-9 ∥ T-iOS-10 | 接口已冻结,可并行编码,集成时汇合 |
|
||||
| 第 6 批 | **T-iOS-11,12,13,14** ∥ T-iOS-16 ∥ T-iOS-17 | W3 UI 四路并行(各对替身开发);T-iOS-16 只依赖 W2(提前把协议防漂移闸门变绿)、T-iOS-17 零依赖 |
|
||||
| 第 7 批 | T-iOS-15 | 汇合接线(依赖 T-iOS-11–14) |
|
||||
| 第 8 批 | T-iOS-18 ∥ T-iOS-19 | 验收 report-only |
|
||||
| P1 批 | T-iOS-20 ∥ T-iOS-37 ∥ T-iOS-38 → 其余 W7 并行(仅 21 等 20/38、23 等 37、26 等 38) | W6 与 W7 不整体串行(见 P1 波次注) |
|
||||
|
||||
> 单批 ~3–5 agent 甜区;worktree 前提:**W0(T-iOS-1…4)先 commit**,再派 worktree 隔离的并行 builder。
|
||||
|
||||
| 任务 | 类型 | 建议模型 | 隔离 | 理由 |
|
||||
|------|------|----------|------|------|
|
||||
| T-iOS-1 脚手架 | builder | sonnet | — | XcodeGen/Swift6 配置排错;串行 |
|
||||
| T-iOS-2 双 spike | builder | sonnet | — | 平台事实一锤定音;需真机人工配合 |
|
||||
| T-iOS-3 WireProtocol | builder | sonnet | — | 契约精度要紧;全员依赖根 |
|
||||
| T-iOS-4 替身 | builder | haiku | — | 小而机械 |
|
||||
| T-iOS-5 Reconnect+Ping | builder | sonnet | worktree | 纯状态机,时序推理 |
|
||||
| T-iOS-6 Gate+Digest | builder | sonnet | worktree | epoch 语义是防误批关键 |
|
||||
| T-iOS-7 HostRegistry | builder | haiku | worktree | 薄封装 + 明确规格 |
|
||||
| T-iOS-8 APIClient | builder | sonnet | worktree | Origin iff-G 是安全语义 |
|
||||
| T-iOS-9 WSTransport | builder | sonnet | worktree | delegate/re-arm 细节多 |
|
||||
| T-iOS-10 SessionEngine | builder | **opus** | worktree | 并发/生命周期/重连时序最硬的模块 |
|
||||
| T-iOS-11 Terminal+KeyBar | builder | sonnet | worktree | UIKit 桥接 + 字节表 |
|
||||
| T-iOS-12 Pairing | builder | haiku | worktree | VM 映射为主 |
|
||||
| T-iOS-13 SessionList | builder | sonnet | worktree | 轮询/TTL/乐观更新 |
|
||||
| T-iOS-14 Gate UI | builder | sonnet | worktree | 三选一(acceptEdits/default/reject,无 allowAutoMode 门) |
|
||||
| T-iOS-15 接线 | builder | sonnet | — | 汇合点,串行 |
|
||||
| T-iOS-16 集成 CI | builder | sonnet | — | 真服务器时序断言 + 覆盖率门接线 |
|
||||
| T-iOS-17 ntfy 验证 | builder | haiku | — | 验证既有桥 + 文档,零新代码 |
|
||||
| T-iOS-18/19 验收 | **reviewer** | sonnet | — | 只读报告;修复回流 owner |
|
||||
| T-iOS-20 APNs(server) | builder | **opus** | — | server 触点 + 密钥/token 语义 |
|
||||
| T-iOS-37 lastOutputAt(server) | builder | haiku | — | server 触点:一字段 + 一行映射 + 测试(TS 任务) |
|
||||
| T-iOS-38 APIClient P1 契约 | builder | sonnet | — | W7 首发;APIClient 单一 owner,其余任务依赖它 |
|
||||
| T-iOS-21–29 | builder | sonnet(28 可 haiku) | worktree | 常规并行 |
|
||||
| T-iOS-30/36 验收 | reviewer | sonnet | — | report-only |
|
||||
| T-iOS-31–35(P2) | builder | sonnet(33/34/35 可 haiku) | worktree | 按 P2 节元数据分派;未扩写 RED 清单前不派 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试与验收
|
||||
|
||||
### TDD 工作流
|
||||
|
||||
每任务:**RED**(照 Steps(测试) 先写失败测试)→ **GREEN**(最小实现过测)→ **REFACTOR**(对照 §4 清单)。测试命名讲行为(`test("未知 UUID attach 后采用服务器新发的 id")`),AAA 结构。
|
||||
|
||||
### 覆盖率门(≥ 80%,只量 4 个包)
|
||||
|
||||
```bash
|
||||
for p in WireProtocol SessionCore HostRegistry APIClient; do
|
||||
swift test --package-path ios/Packages/$p --enable-code-coverage
|
||||
BIN="$(swift build --package-path ios/Packages/$p --show-bin-path)/${p}PackageTests.xctest/Contents/MacOS/${p}PackageTests"
|
||||
PROF="$(swift test --package-path ios/Packages/$p --show-codecov-path | xargs dirname)/default.profdata"
|
||||
# 只量生产代码:排除测试目标自身、TestSupport 替身、SwiftPM 生成的 runner shim
|
||||
# (不排除的话 Tests/** 近 100% 覆盖会虚抬 TOTAL);jq -e 低于阈值退出非零 → CI 红
|
||||
xcrun llvm-cov export -summary-only "$BIN" -instr-profile "$PROF" \
|
||||
-ignore-filename-regex '(Tests|TestSupport|\.build)/' \
|
||||
| jq -e '.data[0].totals.lines.percent >= 80'
|
||||
done # 每包 line coverage ≥ 80%,CI 强制(接线与红/绿演示归 T-iOS-16)
|
||||
```
|
||||
|
||||
> **KeychainHostStore 计法**:`swift test` 覆盖的是经 `SecItemShim` 注入 fake 的 store 逻辑(unsigned 测试二进制拿不到 data-protection keychain,-34018);真 Keychain 路径 + `kSecAttrAccessible` 断言在 xcodebuild 模拟器测试(签名宿主)里跑,不计入本门。
|
||||
|
||||
### 集成测试(真 Node 服务器)
|
||||
|
||||
CI macOS runner:`npm ci` → `PORT=<ephemeral> ALLOWED_ORIGINS=… npm start` → `swift test --package-path ios/IntegrationTests`(用例见 T-iOS-16)。这层持续看护"客户端复刻协议"与服务器实现之间的契约漂移,也是 Origin spike 的常驻化。
|
||||
|
||||
### 设备矩阵
|
||||
|
||||
| 层 | 环境 |
|
||||
|---|---|
|
||||
| 单元/包 | macOS(swift test,无模拟器) |
|
||||
| App/XCUITest | iPhone 16 模拟器(iOS 26 SDK)+ iOS 17 最低目标模拟器各一轮 |
|
||||
| 真机必测项 | 键盘/IME/key-bar、QR 扫码、haptics、Local Network 弹窗、ntfy/APNs 通知、锁屏动作(1 台实机,iOS 18+) |
|
||||
|
||||
XCUITest 只保一条 happy path:配对→attach→输入→gate approve(脆弱面最小化)。
|
||||
|
||||
### 验收演示脚本(F-style,对齐 ARCHITECTURE §8)
|
||||
|
||||
- **F-iOS-1** iPhone 扫 Mac web UI 的 QR → 配对探针通过 → host 入列。
|
||||
- **F-iOS-2** 会话列表显示 Mac 上运行中的会话:状态点、telemetry 芯片、cwd。
|
||||
- **F-iOS-3** 点开会话 → 全量 scrollback 回放(预灌 >1 MiB 输出 + ESC/C0 密集输出,验证 16 MiB 帧上限)→ 实时流续上。
|
||||
- **F-iOS-4** 打字、跑 `vim`/`top`、旋转屏幕 → resize 生效、TUI 不错位。
|
||||
- **F-iOS-5** key-bar:Esc/Shift+Tab/方向/Ctrl-C 生效;中文 IME 输入不乱码不重复。
|
||||
- **F-iOS-6** Claude 触发 tool gate → 手机横幅 + 震动 → Approve/Reject 生效;plan gate → 三选一 sheet;**Approve+Auto 发送 `mode=acceptEdits`(与 web 端一致,public/tabs.ts:345-347)**。
|
||||
- **F-iOS-7** 杀掉 App → 重开 → 自动回到上次会话,回放无缺。
|
||||
- **F-iOS-8** 切后台 5 分钟 → 回前台 → "reconnecting…" → 自动重连 + 全屏尺寸夺回(latest-writer-wins)。
|
||||
- **F-iOS-9** 手机与浏览器同 attach 一个会话(mirror):双方都能打字、都见输出,互不踢。
|
||||
- **F-iOS-10** 离开期间 Claude 干了活 → 重连后顶部 away-digest 摘要正确。
|
||||
- **F-iOS-11** held gate 时手机收到 ntfy 通知(P0);P1 换 APNs 后:锁屏长按 → Allow → **Face ID/通行码确认(`.authenticationRequired`)** → 不开 App,Mac 侧放行(Face ID 设备上仍是两次手势 + 一瞥;Deny 无需解锁)。
|
||||
- **F-iOS-12** 负路径:服务器未白名单该 Origin → 配对页出现可操作话术(含 ALLOWED_ORIGINS 提示);Local Network 被拒 → 引导话术。
|
||||
- **F-iOS-13** 列表 swipe-to-kill → 会话消失,Mac 侧 PTY 确认被杀;exited 会话点开见末屏 + exit 横幅。
|
||||
- **F-iOS-14**(P1)push tap 深链直达 gated 会话。
|
||||
- **F-iOS-15**(P1)多会话切换:unread 点、OSC 标题、切换回放 <1s 观感。
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与开放问题
|
||||
|
||||
| 风险 / 问题 | 影响 | 缓解 / 待定 |
|
||||
|---|---|---|
|
||||
| URLSessionWebSocketTask 自定义 Origin 实为 MED 置信度 | 高 | T-iOS-2 Day-1 一锤定音;失败 → Starscream 备胎(仅此一处允许第三方依赖,需 orchestrator 拍板) |
|
||||
| SwiftTerm 键盘/IME/选择是历史雷区 | 中 | Day-1 真机 smoke + T-iOS-11 不自加 keydown 拦截 + T-iOS-18 真机验收专项 |
|
||||
| 回放单帧撞 `maximumMessageSize` | 中(已缓解——默认配置) | 16 MiB 常量(≥6×默认 SCROLLBACK_BYTES,JSON 控制字节转义最坏 6×) + spike ③ 对抗复现 + T-iOS-16 常驻回归 + 超限→不可重试 `.replayTooLarge` 显式错误态(绝不无限重连)。**残余**:主机把 SCROLLBACK_BYTES 调到 >~2.7 MB 或极端转义密集回放仍可超限(客户端运行时无法得知服务器配置)。**彻底消除**需服务器把回放 snapshot 分块成有界 `output` 帧(≤256 KiB,按 ring 既有 append-chunk 边界切,不割 ANSI/UTF-8,M2 语义)——另立 server 任务,落地后本行方可标"已消" |
|
||||
| iOS 后台 socket 必死 → 后台会话信号有轮询延迟 | 中 | 设计即前台单 WS;P1 APNs 补上"主机找手机";评审已接受 |
|
||||
| ATS 对裸 IP/CIDR 的语义在 iOS 17+ 有过变更 | 中 | §5.2 三段 CIDR + `NSAllowsLocalNetworking`;真机矩阵含 iOS 18 弹窗 bug 项 |
|
||||
| MagicDNS FQDN 触发完整 ATS | 中 | 话术引导用 100.x IP 或 `tailscale serve`(wss) |
|
||||
| 协议在 TS 与 Swift 双实现,会漂移 | 中 | T-iOS-3 移植服务器测试向量 + T-iOS-16 真服务器 CI 常驻 |
|
||||
| APNs 需付费开发者账号 + `.p8` 运维 | 中 | P0 用 ntfy 桥过渡;P1 前用户拍板账号 |
|
||||
| ntfy topic 泄露 = 通知可被旁观 | 低 | 随机 topic + payload 最小化(T-iOS-17 安全注)|
|
||||
| epoch 防误批依赖客户端自律(服务器无 epoch 概念) | 中 | GateState epoch 单测 + 验收 F-iOS-6;长期可提案服务器带 gate id(另立 server 任务,不在本计划)|
|
||||
| 单 WS 设计与多会话切换器(P1)的张力 | 低 | 切换 = 重放恢复,成本近零;若实测不适再评估观察者 WS |
|
||||
|
||||
**待你拍板的开放项**:
|
||||
1. Bundle id / 产品名 / 图标(App 叫什么?`webterminal://` scheme 是否可用/要改名?)
|
||||
2. Apple 付费开发者账号($99/年)何时开——决定 P1 APNs 与 TestFlight 分发起点;P0 期间接受自签 sideload?
|
||||
3. 最低系统版本:iOS 17(可分发下限)还是 18/26(个人工具,availability 噪音最小)?
|
||||
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)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 与现有文档的关系
|
||||
|
||||
- 本文只新增 `ios/`,加 §0.3 声明的服务器触点(**P0 零触点**——ntfy 桥复用既有 setup-hooks 实现;P1 两处:APNs sender + token 端点(T-iOS-20)、`LiveSessionInfo.lastOutputAt` 字段(T-iOS-37))。**除触点外不改 `src/` 与 `public/`**。
|
||||
- 实施中发现的 server 侧缺陷:修复归属对应模块(route/session/protocol 文件的 owner 任务),按 CLAUDE.md 记 `PROGRESS_LOG.md`——iOS 任务不越界改 server;T-iOS-20 作为 server 任务遵循根仓库工作流。
|
||||
- 冲突裁决:**ARCHITECTURE 管 how、TECH_DOC 管 why/scope**;本文是它们之上的"原生客户端"新层,不改协议/会话模型,只消费之。§0/§3 引用的 `file:line` 线协议事实以**代码现状**为准——若代码演进导致引用失效,更新本文并记 LOG。
|
||||
- 与 [DESKTOP_PLAN.md](./DESKTOP_PLAN.md) 平行:desktop 是"内嵌服务器的壳",iOS 是"纯远端客户端"——二者都不改动核心,互不依赖。
|
||||
- 与 relay 计划族([PLAN_RELAY_INDEX.md](./PLAN_RELAY_INDEX.md))的边界:v1 明确不接 relay(§5.5);relay 可部署后,接入方案另立计划文档,不回改本文任务。
|
||||
231
docs/PLAN_IOS_IPAD.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# PLAN_IOS_IPAD.md — iPad 适配(自适应布局,非分叉)
|
||||
|
||||
> 落地方案文档。目标:让已完成的 iPhone 客户端([PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md),P0+P1 已交付,分支 `feat/ios-client`)**原生适配 iPad**——大屏分栏、双向布局、指针/硬件键盘,而**不分叉出第二套 UI**。
|
||||
> 拓扑决策:**单一代码库 + size-class 自适应**——`NavigationSplitView` 在 regular 宽度(iPad 全屏/大分屏)给 sidebar+detail,在 compact 宽度(iPhone、iPad Slide Over/小分屏)**自动退化为现有 stack**。iPhone 行为字节级不变。
|
||||
> 状态:**规划中(2026-07-05,未开工)**。
|
||||
> 本文是 iPhone 计划之上的**布局适配层**,不改协议/会话模型/纯逻辑包;沿用 [PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md) 的 §3 契约、§4 工程标准、§5 安全模型、§6 并行规则。冲突以 iPhone 计划为准。
|
||||
> **G1 日志铁律**:`PROGRESS_LOG.md` 由 orchestrator 独写;被派 subagent 不写 LOG,在返回消息末尾附可粘贴条目。
|
||||
|
||||
---
|
||||
|
||||
## 0. 目标与范围
|
||||
|
||||
### 做什么
|
||||
|
||||
在 iPad(iPadOS 17+)上把「口袋驾驶舱」升级为「桌面级驾驶舱」,利用大屏做手机做不到的事:
|
||||
|
||||
- **分栏常驻**:左 sidebar = 会话列表(+ Projects 入口),右 detail = 终端 + gate/digest 叠层——不用来回 push/pop,一眼看全 + 直接介入。
|
||||
- **终端更宽**:iPad 全屏能放下接近桌面的列数,直接缓解「宽桌面 + 窄手机同看一个全屏 TUI 折行成竖条」的多设备张力(见 [PROGRESS_LOG](./PROGRESS_LOG.md) 该条)——iPad 自己就是宽屏 writer。
|
||||
- **双向布局**:横竖屏、Split View、Slide Over、Stage Manager 尺寸变化全程 size-class 自适应,绝不写死方向。
|
||||
- **硬件键盘为一等公民**:iPad 常接键盘——现有 `UIKeyCommand` 全键位复用;软键盘 KeyBar(`inputAccessoryView`)在有硬件键盘时可隐、无则保留。
|
||||
- **指针/悬停**:iPadOS 指针 hover 高亮、右键(次要点击)上下文菜单(kill/新建/在 cwd 开)。
|
||||
|
||||
### 不做(本期范围外)
|
||||
|
||||
- **多窗口 / 多场景(`UISceneConfiguration` 多实例、拖拽会话到新窗口)**:iPad 能开两个终端并排是诱人的,但涉及场景生命周期重构 + 每场景独立 SessionEngine,单列一期(见 §7 后续)。本期 = 单场景自适应分栏。
|
||||
- **Stage Manager 外接显示器专属布局**:本期只保证 Stage Manager 下尺寸变化不崩、布局自适应;不做外接屏专属多窗排布。
|
||||
- **Apple Pencil、拖放文件进终端、Mac Catalyst**:非目标(同 iPhone 计划 §0)。
|
||||
- **纯逻辑包改动**:`WireProtocol/SessionCore/HostRegistry/APIClient` 与设备无关,**一行不改**(若发现某常量隐含 iPhone 假设 → 回对应包 owner,不在本计划直改)。
|
||||
- **服务器 / `public/` / `src/`**:零触点(iPad 只是又一个说同一协议的客户端)。
|
||||
|
||||
### 关键约束:iPhone 零回归(硬性)
|
||||
|
||||
**任何自适应改动必须先满足 compact 宽度 == 现有 iPhone 行为**。每个任务的验收都含一条「iPhone 模拟器全套件仍绿 + 目视无变化」。self-check:compact size class 下走的代码路径应与改动前逐帧一致(分栏只在 regular 宽度激活)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构 / 适配策略
|
||||
|
||||
```
|
||||
size class 驱动的单一根视图(AdaptiveRootView)
|
||||
┌─────────────────────────────┴─────────────────────────────┐
|
||||
horizontalSizeClass == .compact horizontalSizeClass == .regular
|
||||
(iPhone / iPad Slide Over / 小分屏) (iPad 全屏 / 大分屏 / Stage Manager 大窗)
|
||||
│ │
|
||||
现有 NavigationStack NavigationSplitView
|
||||
列表 → push 终端(字节级不变) ├ sidebar: 会话列表 + Projects section
|
||||
└ detail : TerminalContainerView(gate/digest 叠层)
|
||||
└───────────────── 共享同一 AppCoordinator / 同一 SessionEngine ─────────────────┘
|
||||
隐私遮罩(ZStack 顶层,!= .active)· scenePhase · deep link 全部设备无关,原样复用
|
||||
```
|
||||
|
||||
**为什么自适应而非 iPad 分叉**:`horizontalSizeClass` 是同一 App 内运行时可变量(iPad 拉出 Slide Over 立刻从 regular 变 compact)——分叉两套 UI 无法应对同一次运行内的尺寸切换,且双倍维护。自适应 = 一套代码、一处 size-class 分支、compact 分支就是现有已测代码。
|
||||
|
||||
**为什么 detail 复用 TerminalContainerView 不动**:终端 + gate + digest 的组装(T-iOS-15/24 的 `TerminalContainerView`)与它挂在 push destination 还是 split detail 无关——只换外层容器,内容零改。SessionEngine 单活 WS、latest-writer-wins、隐私遮罩、生命周期全部设备无关,直接复用。
|
||||
|
||||
**列宽收益(自动,非新逻辑)**:终端列数由 SwiftTerm 的 `sizeChanged` 从其视图宽度 + 字号算出(T-iOS-11 已实现),detail 面板越宽 → cols 越多 → 越接近桌面宽度。**无新代码**,是分栏的自然结果。
|
||||
|
||||
**依赖方向**:iPad 适配全部落在 **App 胶水层**(`ios/App/WebTerm/**`);纯包不动;自适应「决策」抽成纯函数(`LayoutMode` 之于 size class,仿 `PrivacyShadePolicy` 先例)以便单测,SwiftUI 布局本身靠模拟器目视 + XCUITest。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构(新增/改动)
|
||||
|
||||
```
|
||||
ios/App/WebTerm/
|
||||
├── Wiring/
|
||||
│ ├── RootView.swift # 改:抽 compact 分支为子视图,加 regular 分支入口(T-iPad-2)
|
||||
│ ├── AdaptiveRootView.swift # ★新:size-class 分支 + LayoutMode 决策消费(T-iPad-2)
|
||||
│ ├── SplitRootView.swift # ★新:NavigationSplitView(sidebar+detail)(T-iPad-2)
|
||||
│ ├── LayoutMode.swift # ★新:纯函数 size class → LayoutMode(.stack/.split)(T-iPad-2)
|
||||
│ └── AppCoordinator.swift # 改:sidebar 选中态 ↔ 现有 terminal/projects 路由桥(最小增量)
|
||||
├── Screens/
|
||||
│ ├── SessionListScreen.swift # 改:sidebar 语境下的选中高亮 + Projects section(自适应,compact 不变)
|
||||
│ └── ProjectsScreen.swift # 改:regular 宽度下作为 sidebar section / 多列网格(compact 仍是 sheet)
|
||||
├── Components/
|
||||
│ ├── KeyBar.swift # 改:硬件键盘在场时可隐(T-iPad-3)
|
||||
│ └── TerminalContextMenu.swift # ★新:指针右键/长按上下文菜单(kill/新建/cwd 开)(T-iPad-3)
|
||||
├── project.yml # 改:TARGETED_DEVICE_FAMILY "1,2" + iPad 方向/plist(T-iPad-1)
|
||||
└── WebTermTests/ , WebTermUITests/ # 各任务的测试
|
||||
```
|
||||
|
||||
`ios/Packages/**` 零改动。
|
||||
|
||||
---
|
||||
|
||||
## 3. 契约(自适应决策的可测核)
|
||||
|
||||
```swift
|
||||
// LayoutMode.swift — 纯函数,唯一 size-class 决策点(仿 PrivacyShadePolicy)
|
||||
public enum LayoutMode: Equatable { case stack, split }
|
||||
|
||||
public enum LayoutPolicy {
|
||||
/// regular 宽度 → split(sidebar+detail);compact → stack(现有 iPhone 路径)。
|
||||
/// 唯一判据,禁止在视图里散落 sizeClass 分支。
|
||||
public static func mode(horizontalSizeClass: UserInterfaceSizeClass?) -> LayoutMode
|
||||
}
|
||||
|
||||
// SidebarSelection — split 模式下 sidebar 选中态(与现有 AppCoordinator 路由等价映射)
|
||||
enum SidebarItem: Hashable { case session(UUID), newSession, projects }
|
||||
```
|
||||
|
||||
隐私遮罩、scenePhase、deep link、SessionEngine 契约**沿用 iPhone 计划 §3,不重定义**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 工程标准
|
||||
|
||||
沿用 [PLAN_IOS_CLIENT.md §4](./PLAN_IOS_CLIENT.md) 全部(TDD 强制、不可变、文件 ≤400 行、函数 <50 行、早返回、无魔法数字、系统边界验证、显式错误、conventional commits、覆盖率仅计 4 个纯包)。追加两条 iPad 专属:
|
||||
|
||||
- **size-class 分支只出现在一处**(`LayoutPolicy`);视图内严禁散落 `if sizeClass == …`。
|
||||
- **iPhone 零回归是每个任务的验收前置**(compact 路径 == 改动前)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 任务清单
|
||||
|
||||
> 状态图例 `[ ]`/`[~]`/`[x]`/`[!]` 同 iPhone 计划。ID 稳定:`T-iPad-N`。测试框架 Swift Testing;XCUITest 仅 UI happy path。
|
||||
|
||||
### W0 · 可安装性(串行,先行)
|
||||
|
||||
#### T-iPad-1 · device family + iPad plist/方向 `[ ]` · ~0.5 pd
|
||||
- **Owns**: `ios/project.yml`(device family、iPad 方向、必要 plist)、`.github/workflows/ios.yml`(加 iPad 模拟器测试腿)
|
||||
- **Depends**: 无
|
||||
- **Steps(测试先行)**:
|
||||
- [ ] `TARGETED_DEVICE_FAMILY` 由 `"1"` 改 `"1,2"`(project + 各 target level——注意 XcodeGen target 默认覆盖,见 iPhone 计划 W5 finding,逐 target 显式设)
|
||||
- [ ] iPad 方向键全开(`UISupportedInterfaceOrientations~ipad` 含 Portrait/Landscape/UpsideDown);iPhone 方向键**不动**
|
||||
- [ ] ATS 五段 CIDR / usage description 原样(设备无关,复核仍在)
|
||||
- [ ] `xcodegen generate` → 拆构建产物 plist 断言 `UIDeviceFamily [1,2]`
|
||||
- [ ] ios.yml 加一条 `xcodebuild test` 腿跑 iPad 模拟器(iPad Pro 11" 或 iPad (A16))
|
||||
- **Accept**: iPad 模拟器 `xcodebuild build` 通过、空跑现有全套件绿(此刻仍是 iPhone 布局放大,不崩);产物 plist `[1,2]`
|
||||
- **安全注**: device family 放开后 T-iPad-5 的 ipa 核对须覆盖 iPad 产物(capability↔usage 一一对应不变)
|
||||
|
||||
### W1 · 自适应导航壳(核心)
|
||||
|
||||
#### T-iPad-2 · AdaptiveRootView + NavigationSplitView + LayoutPolicy `[ ]` · ~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`:
|
||||
- [ ] `LayoutPolicy.mode(.compact) == .stack`、`.mode(.regular) == .split`、`.mode(nil) == .stack`(未知按最保守 = 现有路径)
|
||||
- [ ] SidebarItem ↔ AppCoordinator 路由等价:选 `.session(id)` == 现有 open(id);`.newSession` == open(nil);`.projects` == 现有 projects 呈现——**同一 coordinator API,split 只是另一个触发面**(断言不新增会话生命周期路径)
|
||||
- [ ] compact 分支渲染的视图树 == 改动前 `RootView`(快照/结构断言:隐私遮罩仍 ZStack 顶层、scenePhase/deepLink/sheet 全在)
|
||||
- [ ] size class 从 regular→compact 切换(iPad 拉 Slide Over)时选中态保持、终端不重建(复用 T-iOS-29 的 `.id(controller.id)` 稳定性,断言 controller 不换)
|
||||
- **Steps(实现, GREEN)**:
|
||||
- [ ] `AdaptiveRootView` 读 `@Environment(\.horizontalSizeClass)` → `LayoutPolicy.mode` → 选 `StackRootView`(现有)或 `SplitRootView`
|
||||
- [ ] `SplitRootView` = `NavigationSplitView { sidebar } detail { TerminalContainerView 或空态 }`;sidebar = SessionListScreen(选中绑定)+ Projects section 入口;detail 空 = 「选择或新建会话」占位
|
||||
- [ ] 隐私遮罩/scenePhase/deepLink/task(bootstrap) 上提到 `AdaptiveRootView`(两分支共享,绝不重复布线)
|
||||
- **Accept**: iPad 模拟器横竖屏 + Split View 拉入拉出全程分栏/退栈自适应无崩;**iPhone 模拟器全套件绿 + 目视逐屏无变化**;`LayoutPolicy` 覆盖率 100%
|
||||
- **安全注**: 隐私遮罩必须仍是**两分支共同的** ZStack 顶层——split detail 的终端字节同样要被 `!= .active` 遮住(新增一条 iPad 遮罩目视验收:Stage Manager 切走 → detail 终端被遮)
|
||||
|
||||
### W2 · 逐面适配(并行,文件互斥)
|
||||
|
||||
#### T-iPad-3 · 终端面板:KeyBar 自适应 + 指针上下文菜单 `[ ]` · ~1 pd
|
||||
- **Owns**: `Components/{KeyBar,TerminalContextMenu}.swift`、`Screens/TerminalScreen.swift`(增量)、测试
|
||||
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-4
|
||||
- **Steps(测试先行)**:
|
||||
- [ ] KeyBar 可见性纯谓词:`GCKeyboard.coalesced != nil`(硬件键盘在场)→ 默认隐;无 → 显;用户可手动切(谓词单测,硬件态注入)
|
||||
- [ ] 上下文菜单(右键/长按)项 = {在 cwd 开新会话、kill、复制选区}——动作复用现有 OpenRequest/killSession 通道(断言不新增网络路径;kill 仍带 Origin)
|
||||
- [ ] 指针 hover 高亮不改字节流(纯 UI)
|
||||
- **Accept**: iPad 接键盘时 KeyBar 自动隐、去键盘复现;右键菜单在 iPad 生效;iPhone 无硬件键盘时 KeyBar 行为不变
|
||||
- **安全注**: 上下文菜单的 kill/开会话是既有 G/RO 通道的又一触发面——测试名标「复用 APIClient,无绕过」
|
||||
|
||||
#### T-iPad-4 · Projects/Timeline/sheet 大屏化 `[ ]` · ~1 pd
|
||||
- **Owns**: `Screens/ProjectsScreen.swift`(增量)、Projects/Timeline 呈现方式(regular 下 `.sheet` → 列/`.presentationDetents` 或 sidebar section)、测试
|
||||
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-3
|
||||
- **Steps(测试先行)**:
|
||||
- [ ] Projects 网格列数随宽度自适应纯函数(compact=1 列现状、regular=多列)——列数决策单测
|
||||
- [ ] regular 宽度下 Projects 作为 sidebar section 或 detail 列(compact 仍走现有 sheet,分组/收藏/prefs 往返逻辑零改——只换容器)
|
||||
- [ ] Timeline/其它 sheet 在 iPad 用合适 detent/尺寸不占满全屏
|
||||
- **Accept**: iPad 上 Projects 多列、Timeline 合理尺寸;iPhone 全部走原有 sheet 路径不变;prefs 未知键往返(T-iOS-38 不变量)回归绿
|
||||
|
||||
### W3 · 验收(report-only, G4)
|
||||
|
||||
#### T-iPad-5 · iPad 验收 + iPhone 回归 + 安全核对 `[ ]` · ~0.5 pd
|
||||
- **Depends**: T-iPad-2/3/4 · **Owns**: 无源码(report-only,findings 派回 owner)
|
||||
- **Steps**:
|
||||
- [ ] **F-iPad 走查**(§6 清单)逐条:分栏、横竖屏、Split View/Slide Over/Stage Manager 尺寸切换、硬件键盘/指针、终端更宽列数、遮罩覆盖 detail
|
||||
- [ ] **iPhone 零回归**:iPhone 16 全套件绿 + 逐屏目视对比无变化(compact 路径未动)
|
||||
- [ ] **安全**:拆 iPad 产物 ipa 核对(UIDeviceFamily [1,2]、五段 CIDR、usage 一一对应);分栏后隐私遮罩仍遮 detail 终端;上下文菜单/键盘触发的 G 调用仍走 APIClient 带 Origin
|
||||
- [ ] 真机项(真 iPad 手势/键盘/指针目视、Stage Manager 实机)→ DEFERRED,附手工清单
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试与验收
|
||||
|
||||
### 设备矩阵
|
||||
|
||||
| 层 | 环境 |
|
||||
|---|---|
|
||||
| 纯逻辑(LayoutPolicy/谓词) | macOS `swift test` / 宿主 WebTermTests |
|
||||
| App 自适应 | iPhone 16(compact 回归)+ iPad Pro 11"(regular)+ **iPad Split View(同一次运行内 regular↔compact 切换)** |
|
||||
| XCUITest | iPad happy path 1 条:sidebar 选会话 → detail attach → 输入 → gate approve(复用 iPhone happy path 骨架,换分栏断言) |
|
||||
| 真机必测 | 真 iPad 分栏手势、硬件键盘全键位、指针 hover/右键、Stage Manager(1 台,DEFERRED 至有设备) |
|
||||
|
||||
### 验收演示脚本(F-style)
|
||||
|
||||
- **F-iPad-1** iPad 全屏 → 左 sidebar 会话列表 + 右 detail 终端同屏;选另一会话 → detail 即时切换(回放恢复)。
|
||||
- **F-iPad-2** 横竖屏旋转 → 布局自适应、终端 resize 重绘、无错位。
|
||||
- **F-iPad-3** 拉出 Slide Over(compact)→ 自动退化为 stack(== iPhone 布局);推回全屏 → 恢复分栏;全程会话不断、终端不重建。
|
||||
- **F-iPad-4** iPad 接硬件键盘 → KeyBar 自动隐、`UIKeyCommand` 全键位可用;拔键盘 → KeyBar 回来。
|
||||
- **F-iPad-5** 指针右键会话行/终端 → 上下文菜单(kill/新建/cwd 开)生效。
|
||||
- **F-iPad-6** iPad 全屏终端列数明显多于 iPhone(接近桌面),宽 TUI 折行大幅缓解。
|
||||
- **F-iPad-7** Stage Manager/切后台 → detail 终端被隐私遮罩覆盖(快照不泄露)。
|
||||
- **F-iPad-8**(回归)iPhone 16 逐屏与适配前目视一致、全套件绿。
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与开放问题
|
||||
|
||||
| 风险 / 问题 | 影响 | 缓解 / 待定 |
|
||||
|---|---|---|
|
||||
| `NavigationSplitView` 在 size-class 频繁切换(Slide Over 反复拉)时状态/选中丢失 | 中 | 选中态提升到 AppCoordinator(设备无关单一真值),T-iPad-2 专项测切换保持;终端 `.id(controller.id)` 稳定性已在 P1 验证 |
|
||||
| 隐私遮罩在 split detail 漏遮 | 高(安全) | 遮罩上提到 AdaptiveRootView 两分支共享 ZStack 顶层;T-iPad-2/5 双重目视 |
|
||||
| iPhone 回归(compact 分支被无意改动) | 高 | 每任务验收前置 iPhone 全绿 + 目视;compact 分支复用现有 StackRootView 原样 |
|
||||
| SwiftTerm 在超宽 detail 的性能/选择手感 | 低 | 复用 iPhone 已测路径;真机目视 |
|
||||
| 多窗口诱惑导致 scope 膨胀 | 中 | 本期明确单场景(§0 非目标);多窗口另立计划 |
|
||||
|
||||
**待你拍板**:
|
||||
1. iPad 最低系统版本:iPadOS 17(与 iPhone 一致)还是抬到 18/26?
|
||||
2. 本期是否要指针右键上下文菜单(T-iPad-3 后半)——纯锦上添花,可砍到后续。
|
||||
3. 多窗口(拖会话开新窗并排两终端)确认放到下一期?
|
||||
|
||||
**工作量合计**:W0 0.5 + W1 2 + W2(3∥4)2 + W3 0.5 ≈ **5 人日**(并行后墙钟更短)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 与现有文档的关系
|
||||
|
||||
- 本文只改 `ios/App/WebTerm/**` + `project.yml`/`ios.yml`;`ios/Packages/**`、`src/`、`public/` 零改动。
|
||||
- 是 [PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md) 的**布局适配层**,复用其 §3 契约/§4 标准/§5 安全/§6 并行规则;把该计划 §0 非目标里的「iPad 优化布局」提取为本期。
|
||||
- 多设备共享 PTY 的尺寸张力([PROGRESS_LOG](./PROGRESS_LOG.md) 记录的「宽桌面+窄手机折行」):iPad 因自身是宽屏 writer 而**天然缓解**,非本计划新机制。
|
||||
- 冲突裁决:iPhone 计划管协议/会话/安全模型;本文只加自适应布局,不改之。
|
||||
79
docs/PLAN_RELAY_RUN_PHASE0.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Phase 0 — single-host runnable relay (build spec)
|
||||
|
||||
> Goal: a single `npm start` that boots an **end-to-end relay you can click through locally** —
|
||||
> real P4(crypto)+P5(auth) wiring, in-memory stores, self-signed TLS, agent dialing `localhost`,
|
||||
> `relay-web` served. No Postgres/Redis/KMS. This is the executable spec for the Phase 0 milestone
|
||||
> in [DEPLOY_RELAY.md](./DEPLOY_RELAY.md) §4. Reuses the wiring proven in `e2e/harness/`.
|
||||
>
|
||||
> **Run this build in a FRESH session** (recommend `/compact` first). Read the seam files named below
|
||||
> before writing — the exact interface shapes must be confirmed against the code, not assumed.
|
||||
|
||||
## Deliverable
|
||||
A new package `relay-run/` (top-level, symlink node_modules like `e2e/`) with one entry
|
||||
`relay-run/src/main.ts` that `npm start` runs, plus small wiring modules. Result: browser at
|
||||
`https://localhost:8443/` → authenticates → live terminal to a shell on the same machine, spliced
|
||||
through the real relay-node, E2E-encrypted (relay sees only ciphertext).
|
||||
|
||||
## Seam files to read FIRST (confirm exact shapes)
|
||||
- `term-relay/data-plane/ws-like.ts` — the `WebSocketLike` interface the relay-node consumes.
|
||||
- `term-relay/data-plane/agent-listener.ts` — `AgentListener`/`AgentTunnel` shapes (how an agent mux attaches).
|
||||
- `term-relay/data-plane/relay-node.ts` + `upgrade.ts` + `authz-port.ts` — `createRelayNode`, `authorizeUpgrade`, the `Authorizer` port (`onUpgrade`/`onReattach`).
|
||||
- `term-relay/mux/mux-session.ts` — `createMuxSession`, `MuxStreamHandle` (frame the agent side).
|
||||
- `control-plane/src/main.ts` — `buildControlPlane`/overrides (`stores`, `verifier`, `bus`, `kmsResolver`), `createMemoryStores`, `inProcessCaSigner`.
|
||||
- `agent/src/cli.ts` + `agent/src/e2e/hostEndpoint.ts` + `transport/dial.ts` — how the agent dials + runs the E2E host endpoint + loopback splice.
|
||||
- `e2e/harness/world.ts` — the REFERENCE wiring: it already composes real P5 `onUpgrade` + P4 handshake + fakes. Lift the `EnforceDeps`/registry/bucket/audit construction from here (and `e2e/harness/fakes.ts`).
|
||||
|
||||
## Build steps (order)
|
||||
1. **Scaffold `relay-run/`**: `package.json` (type module, `"start":"tsx src/main.ts"`, dep on `tsx`, `ws`), `tsconfig.json`, and `node_modules` symlinks to all relay packages + `ws`/`tsx` (mirror `e2e/`’s manual-symlink approach — avoids `npm install`). Add `relay-run/node_modules` to `.gitignore` coverage (root already ignores `node_modules/`).
|
||||
2. **Self-signed TLS**: generate a localhost cert at boot (node `selfsigned` or a checked-in dev cert) for the browser WSS listener; a throwaway CA for agent mTLS.
|
||||
3. **Control-plane (in-memory)**: boot `buildControlPlane` with `overrides = { stores: createMemoryStores(), verifier: <real P5 verifyCapabilityToken bound to the dev P5 pubkey>, bus: in-memory, kmsResolver: inProcessCaSigner }`. Listen on `:8080`. Seed one account + issue a pairing code programmatically at boot.
|
||||
4. **P5 EnforceDeps**: build the real `EnforceDeps` (hosts/sessions/revocation/buckets/audit + `stepUpPolicyFor: () => NO_STEPUP_POLICY`) backed by the in-memory control-plane registries — copy the construction from `e2e/harness`. Configure the P5 verify key (`configureVerifyKey`).
|
||||
5. **`WebSocketLike` adapter**: wrap `ws` `WebSocket` → the `WebSocketLike` interface (send/close/onmessage/onclose per ws-like.ts). Browser WSS server on `:8443`; on upgrade, build the `UpgradeRequest` (extract token from subprotocol/cookie, Origin, DPoP header) and call `authorizeUpgrade` (→ real `onUpgrade`). **Close F2 here**: pass a resolved principal when the host policy requires step-up (Phase 0 default policy = not-required, so `null` is fine).
|
||||
6. **`AgentListener` adapter**: mTLS `tls.Server` on `:8444`; verify the agent client cert against the dev CA (`verifyAgentCert`), attach each as an `AgentTunnel` + `createMuxSession`. Wire `createRelayNode({ config, agentListener, authorizer })` and the opaque splice.
|
||||
7. **Agent**: build the CLI (`dist/cli.js`) or run `agent` in-process; enroll against control-plane `POST /enroll` with the seeded pairing code (→ SPIFFE cert + `hostContentSecret` + subdomain); dial `wss://localhost:8444` (mTLS) and run the E2E host endpoint splicing to a local shell (`LOCAL_TARGET_URL` / node-pty).
|
||||
8. **relay-web**: `npm --prefix relay-web run build`; serve the bundle from the browser listener (same origin as WSS so Origin/CSWSH passes). Wire the login → P5 capability-token mint (Phase 0: a dev endpoint that mints a token after a stub human-auth, or reuse `issueCapabilityToken` directly).
|
||||
9. **`main.ts`**: bring up 3→5→6→7→8 in order; print the URL + a ready-to-use token/subdomain.
|
||||
|
||||
## Verify
|
||||
- `npm start` in `relay-run/` boots without error; console prints the local URL + subdomain.
|
||||
- Browser: open `https://localhost:8443/?...`, authenticate, see a live shell; type `whoami` → output.
|
||||
- Kill/restart the agent → session survives per design (reconnect); `revoke()` → stream tears down.
|
||||
- Add a smoke test (Vitest) that boots the world in-process and round-trips one command E2E through
|
||||
the real relay-node (extend the `e2e/harness` pattern with the real `WebSocketLike` adapter).
|
||||
|
||||
## Explicitly NOT in Phase 0
|
||||
Postgres/Redis (in-memory only), real KMS (dev in-process signer), wildcard TLS/multi-tenant subdomain
|
||||
routing, horizontal scaling, F6 replay-epoch transport (preview stays inert), metering. Those are
|
||||
Phase 1/2 in DEPLOY_RELAY.md.
|
||||
|
||||
## Build outcome (2026-07-04) + seam drifts found
|
||||
|
||||
Built as `relay-run/`. **P1 achieved + verified** (4 tests green, tsc clean): an in-process
|
||||
integration test round-trips a sealed payload both directions through the REAL `createRelayNode`
|
||||
splice + REAL `createMuxSession`, authorized by the REAL relay-auth `onUpgrade` (Origin/CSWSH +
|
||||
capability verify + DPoP PoP + single-use jti) with REAL P4 E2E; **INV2 asserted** (relay sees only
|
||||
ciphertext); negative controls (foreign Origin → 401, missing DPoP → 401). **P2 achieved** (`npm start`
|
||||
boots self-signed TLS + browser WSS `:8443` + agent mTLS `:8444` + control-plane `:8080`, prints a
|
||||
minted capability token). **P3 not landed** (no agent dial / node-pty / relay-web serve). No audited
|
||||
package modified.
|
||||
|
||||
**Two real integration blockers surfaced by actually wiring it (reported, not hacked):**
|
||||
1. **DpopContext shape drift** — term-relay's `authz-port` `DpopContext` is `{proof, publicKeyThumbprint}`
|
||||
but relay-auth needs `{proofJws, htu, htm}`. Reconciled in `relay-run/src/wiring/authorizer.ts` by
|
||||
deriving `htu`/`htm` from the relay's own authority (`expectedAud`) — spec-faithful (RFC 9449), the
|
||||
DPoP binding stays fully enforced. Consider aligning the port type upstream.
|
||||
2. **control-plane `CapabilityVerifier.verify` is SYNC, relay-auth `verifyCapabilityToken` is ASYNC**
|
||||
(WebCrypto). The real P5 verifier therefore cannot be injected into the control-plane admin routes
|
||||
→ **programmatic account+pairing seeding is blocked in Phase 0** (control-plane boots on its default
|
||||
fail-closed verifier). Fix: widen `CapabilityVerifier.verify` to return `Promise<CapabilityToken>`
|
||||
(1-line additive in `control-plane/src/api/authz.ts`), then inject the real async verifier.
|
||||
|
||||
**Next steps to a full browser click-through:** (a) the async-verify widen above; (b) a browser-usable
|
||||
DPoP carrier on the WS upgrade (subprotocol/cookie/preflight — browsers can't set a `dpop` header);
|
||||
(c) run the agent (enroll → mTLS dial → E2E host endpoint → node-pty); (d) swap the Phase-0
|
||||
`mtls.verifyPeer` stub for the registry-gated `verifyAgentCert` and serve the built `relay-web` bundle.
|
||||
|
||||
## Security notes (keep even in dev)
|
||||
- Real Origin/CSWSH check on the WSS upgrade (don’t stub it off for localhost — set allowedOrigins to
|
||||
the dev origin). Real capability-token verify + DPoP (no bypass). Self-signed certs are dev-only;
|
||||
never reuse the dev CA anywhere real. hostContentSecret 0600, never logged.
|
||||
@@ -24,6 +24,115 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`)
|
||||
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。
|
||||
- **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~`、`~/Documents`)原样列为项目。
|
||||
- **修复(TDD,先红后绿)**: ① 新增 `assignSessions` —— 每个 live 会话只归属**最深**的包含项目(`buildProjects` 改用;`buildProjectDetail` 保留前缀匹配,单项目详情页语义不变);② 新增 `dropParentFolders` —— 丢弃"非 git 且包含其他已列项目"的父文件夹条目(discovery 阶段过滤,进缓存)。
|
||||
- **验证**: 新增 3 测(最深归属/父文件夹剔除/独立非 git 历史项目保留);`test/projects.test.ts` 29/29 绿;全量 `npm test` 53 files / **1473** 全绿;`tsc --noEmit` 干净。前端零改动("Active now" 分组消费 `sessions[]`,服务器修正后自然收敛)。
|
||||
|
||||
### ✅ 全端应用图标 "Orbit" + 托盘模板图(完成 — 2026-07-06,main,commit 86d100a)
|
||||
用户选定 "Orbit"(节点绕方块光标核环绕=agent 自主替你运行),品牌沿用桌面 `--accent #E3A64A` 琥珀金 + 暖近黑 `#100F0D`。矢量母版三份(App 圆角暖底 / iOS 满幅不透明 / 托盘纯黑透明加粗),`rsvg-convert`(`brew install librsvg`)栅格化铺各端:
|
||||
- **iOS/iPad**: `Assets.xcassets/AppIcon.appiconset`(单尺寸 1024 不透明,Xcode 自动生成全套;此前**无图标**)。构建验证 `AppIcon60x60@2x`/`76x76@2x~ipad` 入 Assets.car;模拟器主屏实拍确认。
|
||||
- **桌面 App**: `desktop/resources/icon.png`(圆角 1024)→ `npm run dist:mac` 重打包出新 icns + 156MB DMG(`dist-app/Web Terminal-0.1.0-arm64.dmg`),顺带带上 projects 修复。装机需 `xattr -cr` 清 quarantine(ad-hoc 签名 Gatekeeper)。
|
||||
- **桌面托盘**: `trayTemplate.png`/`@2x` 换成正确的 macOS **模板图**(黑形状+透明底,系统按亮/暗菜单栏自动染色)——根治"一坨白"。
|
||||
- **web/PWA**: `public/icon.svg` 矢量。**待办**: 分支/main 未推送;图标对比页(5 创新+5 经典)在 job scratchpad 未入库。
|
||||
|
||||
### ✅ iOS 客户端全面 UX/UI 打磨(完成 — 2026-07-05,分支 `feat/ios-client`,commit 660a404;精致原生方向)
|
||||
用户定方向「精致原生 + 可重构」(靛紫 #7C8CFF 强调、语义状态色、SF Mono 数字、系统材质)。编排 = 一个 ultracode `Workflow`(9 agents,4 阶段):3 路审计(易用性/视觉/无障碍,各 boot 两 sim 取证)→ **冻结 DesignSystem**(协调点,防各 agent 各挑配色)→ 4 组文件互斥并行套用 → 验收。
|
||||
- **审计**: 3 视角共产出 ~40 findings,共识根因=「无设计系统」——全 App stock 系统蓝、状态仅靠颜色(违反色+形,idle 蓝撞 unread 蓝)、gate 决策按钮 <44pt、间距/圆角魔数无刻度、数字非 tabular、缩略图纯黑块、gate 中英混排、reduceMotion 未处理。
|
||||
- **冻结 DesignSystem**(`ios/App/WebTerm/DesignSystem/**` 4 文件): Tokens(自适应 accent 靛紫、语义 status 色、间距 2-24 刻度、圆角 sm8/md12/lg16、Motion gated by reduceMotion、Haptics)、Typography(SF ramp + mono tabular)、StatusStyle(status→色+SF Symbol+中文,单一真源,色+形)、Primitives(StatusBadge/TelemetryChip/Card/SectionHeader/DSButtonStyle 可复用)。根 `.tint(DS.Palette.accent)` 单点注入。
|
||||
- **4 组套用(全 DONE,纯视觉零逻辑改动)**: G1 列表行重构+状态徽标+telemetry 芯片+缩略图占位;G2 终端 gate 卡(≥44pt approve/reject)+SwiftTerm accent 主题+KeyBar/reconnect/quick-reply/digest;G3 配对 hero+警告分层视觉化+Projects 卡片+Timeline/Diff;G4 导航 chrome+iPad 分栏占位(accent 图标)+隐私遮罩+动效+`ContinueLastBanner`/`ProjectsToolbarItem` 抽共享组件。gate 文案落中文。
|
||||
- **验收 PASS_WITH_FINDINGS,设计评分 8.5/10,1 finding**: TimelineSheet class→色仍裸色(全改动里唯一越界)→ orchestrator 补 `DS.Palette.timelineTool/timelineUser` token + TimelineSheet 全走 DS + 更新钉旧裸色的测试。
|
||||
- **验证(亲验)**: iPhone 16 **290** + iPad Pro 11 **290** 全绿(修 finding 后);包+集成绿;iPad 分栏占位截图确认靛紫强调色生效。**未推送**。**待用户目视**: 会话列表行/gate 卡/配对页真机观感(截图受通知授权弹窗遮挡,建议真机或点过授权后看)。iPad 侧栏 List(selection:) 高亮审计标记为「列表组 Owns 后续 pass」——当前 onOpen 已驱动切换,视觉选中高亮可再补。
|
||||
|
||||
### ✅ iOS 客户端 iPad 适配(完成 — 2026-07-05,分支 `feat/ios-client`;自适应布局,非分叉)
|
||||
按 `docs/PLAN_IOS_IPAD.md`(5 人日)编排:W0 orchestrator 亲做 → 一个 ultracode `Workflow`(4 agents: T-iPad-2 核心串行 → T-iPad-3 ∥ T-iPad-4 → T-iPad-5 验收)。
|
||||
- **[x] T-iPad-1 device family(orchestrator 亲做,commit 77502ec)**: `TARGETED_DEVICE_FAMILY` 全 target `1`→`1,2`;iPad `~ipad` 四方向(iPhone 不动);ios.yml 加 iPad Pro 11 测试腿。实测:产物 `UIDeviceFamily [1,2]`、iPad 原生满屏(不再 iPhone 黑边)。
|
||||
- **[x] T-iPad-2 AdaptiveRootView + NavigationSplitView + LayoutPolicy(核心)**: `LayoutPolicy.mode(hSizeClass)` 唯一 size-class 决策点(regular→split/compact→stack,仿 PrivacyShadePolicy);`AdaptiveRootView` 把横切布线(**隐私遮罩 ZStack 顶层两分支共享**/scenePhase/deepLink/sheets)上提唯一一处;`StackRootView`=原 RootView body **原样搬迁**(iPhone 字节级零回归);`SplitRootView`=左 SessionListScreen(复用不改)+ 右 TerminalContainerView(复用不改,带 `.id`);AppCoordinator 加 SidebarItem 路由桥(非平行生命周期)。
|
||||
- **[x] T-iPad-3 KeyBar 自适应 + 指针右键菜单**: `KeyBarVisibility` 纯谓词(硬件键盘在场默认隐);`TerminalContextMenu`(iPad idiom,复制选区/开新会话/结束会话,一一路由既有通道);kill 经 APIClient 带 Origin 单测。
|
||||
- **[x] T-iPad-4 Projects 大屏化**: `ProjectsGridLayout.columnCount`(iPhone 恒 1/iPad 按宽 1-3);iPad 多列网格复用同款 row/分组/prefs 逻辑零改;Timeline 已有 detent 两端合理。**决策**: Projects 在 iPad 以 form sheet 呈现,其内 hSizeClass 恒 compact → 改用 `userInterfaceIdiom` 判据(与 size class 正交)。
|
||||
- **[x] T-iPad-5 验收 PASS_WITH_FINDINGS → orchestrator 修复 4/4**: **iPhone 零回归硬门守住**(277 App+261 包+10 集成);iPad 分栏工作;无 CRITICAL/HIGH/安全回归。修复:①MED kill 菜单项无法渲染 → `onKillSession` 贯穿 TerminalContainerView→两根视图 + `AppCoordinator.killCurrentSession()`(复用 kill 通道);②MED split 未消费 route → `AdaptiveRootView` 对 `.loading/.pairing` 强制走 StackRootView(iPad 首启配对不被困在空态);③LOW 「继续上次」横幅补进 split sidebar(截图确认);④LOW iPad happy-path XCUITest **接受推迟**(split 选中逻辑已由 SidebarSelectionTests 9 测真 engine 覆盖,stack happy-path 未变,XCUITest 单跑 7-11min 且脆,成本不划算)。**验证(亲验)**: iPhone 16 **277** + iPad Pro 11 **278**(+kill 桥测试)全绿;iPad 分栏 sidebar+detail 截图确认。
|
||||
- **合计**: iPad 适配零改 `ios/Packages/**`/`src/`/`public/`;纯逻辑包设备无关直接复用。**待用户**: 真机 iPad(分栏手势/硬件键盘/指针右键/Stage Manager)DEFERRED;多窗口(拖会话开新窗并排两终端)另立下一期(本期单场景)。
|
||||
|
||||
### ✅ iOS 客户端 P1 实施(完成 — 2026-07-05,分支 `feat/ios-client`,P0+P1 共 14 commits,未推送/未合并)
|
||||
- **[x] P1-A(W6+W7 首发: T-iOS-37 ∥ T-iOS-20 ∥ T-iOS-38,3 builders ∥ + verify 6/6 PASS;`/push/apns-token` 线形状由 orchestrator 预冻结,两侧并行编码零失配——verify 逐项对照 method/path/body key/hex 规则/状态码/守卫/限额全 match)**:
|
||||
- **T-iOS-37 server lastOutputAt**: `LiveSessionInfo.lastOutputAt?: number` 可选增量 + `manager.list()` 一行映射 + 4 测;web 端 grep 零消费者,tsc+全量双证兼容。
|
||||
- **T-iOS-20 server APNs**: `src/push/apns.ts`(576 行)——env 三件套 all-or-disabled(缺失→路由不挂载,启动不 crash,密钥材料零日志)、token store 照抄 subscription-store 约定(幂等/上限/0600)、**手写 ES256 JWT(node:crypto ieee-p1363,零新依赖)**、NEEDS-INPUT(priority 10+WEBTERM_GATE+capability token)/DONE(priority 5)、payload 最小化结构性保证(构造函数只读 meta.id,cwd/命令内容不可能出现)、410/BadDeviceToken 逐出、`combineNotifyServices` 与 web-push 并联互不阻塞;`hasPushTarget` 扩展(仅 APNs token 也能 hold gate)。**65 测**含双 e2e(本地 h2c 假 APNs:held gate→双通道同发同 token→/hook/decision 放行)。新增 `APNS_HOST` env(sandbox 切换+测试零网络)。**npm test 53 files/1470 全绿,tsc 干净**。
|
||||
- **T-iOS-38 APIClient P1 契约**: APNs token builders(hex 校验镜像服务器、联网前拒绝、显式 ASCII 字符集防全角)、projects/detail(**证实线上无 namespace 字段**——纯 web 端分组概念;lossy 解码;percent-encode 单点+裸 + 规避)、prefs(**未知顶层键往返逐字节存活**——防 iOS 覆写 web 偏好,关键陷阱已测;JSONValue 独立 integer case 防 42→42.0)、公开四层 HostNetworkTier(W3 dedup 欠账,PairingVM 后续 pass 可切换)。76/76 绿,覆盖 95.26%。
|
||||
- 遗留(LOW/cosmetic): Swift doc comment 里 server 行号引用漂移;PairingVM 分类去重待后续 pass。
|
||||
- **[x] P1-B UI 九连链(22→24→25→27→26→23→29→28→21 全 DONE,10 agents;verify 判 BLOCKED→orchestrator 修复后放行)**:
|
||||
- **T-iOS-22 DeepLinkRouter**: 全字段白名单(scheme/action/path 空/query 键精确/重复键拒)、UUID 复用冻结 Validation(v4);冷启动单槽 stash;`route(from:)` 供 push-tap 复用;非法链接计数不回显内容(防日志注入)。21 测。
|
||||
- **T-iOS-24 Timeline sheet**: 呈现顺序逐行镜像 web render()(slice 50→reverse);`[]`=disabled→空态非错误;复用 AwayDigestView 既有 onExpand(T-iOS-14 零改动);label Text(verbatim:)。14 测。
|
||||
- **T-iOS-25 Quick-reply**: 内置 6 chips 逐项镜像 quick-reply.ts(Esc/Enter 经 KeyByteMap 零手写转义);显示条件=持牌 gate(流上唯一 waiting 信号)&&!readOnly;UserDefaults lossy 载入。16 测。
|
||||
- **T-iOS-27 Diff 查看器**: App 层 DiffFetcher(RO 无 Origin;并注记归 T-iOS-38 后续收编);hunks 着色/staged 切换/truncated 横幅/lazy list;diff 字节 verbatim 渲染。
|
||||
- **T-iOS-26 Projects**: 分组逐条镜像 projects.ts 且**组 key 与 web 逐字节一致**(collapsed 经 /prefs 跨端共享,key 漂移=两端互丢折叠);**prefs clobber 防线**——GET 失败→toggle 仅本地绝不 PUT(空底盘 PUT=清库)、PUT 采纳服务器 echo;`claude\r` bootstrap 镜像 tabs.ts:679。
|
||||
- **T-iOS-23 切换器**: UnreadLedger(单调水位,严格 >)+TitleSanitizer(C0/C1/DEL+bidi/零宽全表,字素簇截断 256)入 SessionCore(+15 包测);LiveSessionInfo.lastOutputAt 容错解码(授权增量);列表边界二次净化。
|
||||
- **T-iOS-29 杂项**: new-in-cwd(cwd 不可信,非绝对路径按未知;**bootstrap 绝不复注入**——开新 shell≠起 claude,专项断言);exit 横幅"开新会话";**加建修复潜在 bug**: RootView 给容器补 `.id(controller.id)`(呈现中换 controller 不换 SwiftTerm 视图→终端假死)。
|
||||
- **T-iOS-28 缩略图**: 离屏 SwiftTerm 渲染管线(LRU 32/并发闸 2/预览 256KiB 上限/网格钳制),缓存键 (sessionId,lastOutputAt),CADisplayLink 注销不泄漏。
|
||||
- **T-iOS-21 PushRegistrar+锁屏**: WEBTERM_GATE category(Allow=.authenticationRequired、Deny 免认证 fail-safe、均无 .foreground,形状测试钉死);device token 对每 host 注册+记账重试;NotificationActionHandler(sessionId 复用 DeepLinkRouter 白名单、token 校验后仅入参绝不落盘、beginBackgroundTask 包 POST、403→本地通知兜底)。锁屏真机行为 DEFERRED(需付费账号)。
|
||||
- **verify(独立 agent)**: 包 261(SessionCore 92/APIClient 77/…)+App 247+集成 10 全绿;7 项语义抽查全 PASS(含恶意标题/putPrefs 唯一调用点走保留路径/token 零持久化 grep);Owns 审计干净;**但抓到 CRITICAL——生产模式启动即崩**: @MainActor UNNotificationCenterAdapter 的 completion 闭包继承主 actor 隔离,UN center 在后台队列回调→Swift 6 executor 断言 trap(测试全绿盖不住:push 接线在测试宿主下被跳过——**测试盲区实证**)。**orchestrator 修复**: 三处闭包加 `@Sendable` 字面量标注(不继承隔离);重建+实机启动验证:进程存活、崩溃报告零新增、通知授权弹窗弹出(=走过原 trap 点)、弹窗期间隐私遮罩正确覆盖(scenePhase .inactive)。
|
||||
- 遗留(LOW,归 P1-C 复核): deep link 未断言 userinfo/port(`webterminal://user:pass@open` 仍路由;id 均 v4 校验+host 只经 store 解析,影响可忽略);Diff fetcher 收编 APIClient 待后续 pass。
|
||||
- **[x] P1-C 验收+安全复核(T-iOS-30,2 reviewer ∥,report-only)—— 双双 PASS,零 findings**:
|
||||
- **Part A 功能验收**: 亲跑 261 包测+247 App 测+10 集成全绿;**启动崩溃修复复核 PASS**(3 次实机启动零崩溃,通知授权弹窗弹出=走过原 trap 点,崩溃报告计数停在修复前的 2);**`simctl push` 实测**注入 WEBTERM_GATE payload → 通知横幅真实渲染("Needs input / Session 3d34818d",目视确认最小化:仅状态词+8 位前缀,无 cwd/命令/token);F-iOS-15 服务端切片实测(双会话 `lastOutputAt` 差分正确);**意外收获**——对**你的真实服务器**(127.0.0.1:3000,pre-T-iOS-37 旧构建)实测旧版兼容降级:App 直接渲染真实会话列表(stuck 红点/`未知目录` 标题回退/`0 台设备在看 · 51×50` 与服务器精确一致/无 unread 点=正确降级)。真机项(锁屏 tap/Face ID、unread 点亮目视、OSC 标题目视)DEFERRED 附手工步骤。
|
||||
- **Part B 安全复核(对抗式)**: APNs payload 最小化 post-P1-B 仍成立(OSC 标题不进 alert,grep 证实);**capability token 全链路 CLEAN**——issue(server randomUUID+TTL)→payload(仅 gate)→handler 校验后仅入参 hookDecision→**零落盘**(grep Push/ 及全 App 无 UserDefaults/Keychain/file 写;唯一持久 token 是内存态 device token)→单次消费(server resolvePending 一次性)→**两侧日志零 token**;deep-link 50+ fuzz 全部安全(userinfo/port LOW 判定 inert——从不用于构造请求、id 仍 v4 校验,继续接受);锁屏 Allow `.authenticationRequired` 形状测试绿;prefs 唯一写入点仍守空底盘;P1-B 新攻击面(缩略图字节只进 SwiftTerm、diff verbatim、标题净化覆盖全渲染点)扫盘无泄漏;残余风险台账完整。
|
||||
- **P1 收官统计**: 累计 **518 项自动化**(261 包测 + 247 App 测 + 10 集成)+ 服务器 1470 测全绿;新增能力=远程 APNs 推送(锁屏 Allow/Deny+Face ID)、deep link、Projects 项目列表(分组/收藏/在仓库起 Claude)、多会话切换器(unread+OSC 标题净化)、Timeline/Diff/Quick-reply/缩略图;服务器仅 §0.3 声明的两处增量触点(lastOutputAt + APNs)。**待用户**: ①真机走查(T-iOS-30 报告手工清单);②Apple 付费账号(APNs 真机端到端+TestFlight);③推送分支跑首轮 GH Actions(ui-test 腿/ios17 runtime);④P2(语音 PTT/worktree/终端内搜索/主题/web ?join= 互通)是否开工——P2 任务需先按 P0 规格扩写 RED 清单再分派(§7 P2 注)。
|
||||
|
||||
### ✅ iOS 客户端 P0 实施(完成 — 2026-07-05,分支 `feat/ios-client`,7 commits,未推送/未合并)
|
||||
按 PLAN_IOS_CLIENT §8 批次推进;编排 = 每波一个 ultracode `Workflow`(builder 按任务派发 + 独立 verify agent 复跑验收)。
|
||||
- **[x] W0 基础(T-iOS-1/2/3/4,5 agents,verify 6/6 PASS)**:
|
||||
- **T-iOS-1 脚手架**: `ios/` 全结构(project.yml/XcodeGen、5 包空壳、WebTermApp 空窗、`.github/workflows/ios.yml`);Info.plist 经 plutil 核对 = §5.2 逐字(五段 CIDR,**全程零 NSAllowsArbitraryLoads**);bundle id `com.yaojia.webterm`。**偏差**: "default MainActor isolation" 属 Swift 6.2+,本机 6.1 → Swift 6 语言模式 + strict concurrency + 显式 @MainActor 替代(已注 project.yml)。**环境事实**: 本机 Xcode 16.3 原本无 iOS 模拟器 runtime → `xcodebuild -downloadPlatform iOS` 装 iOS 18.4(22E238)后模拟器构建绿。
|
||||
- **T-iOS-2 Day-1 双 spike**: **平台事实定音——URLSessionWebSocketTask 可发自定义 Origin(①无 Origin→401 ②精确匹配→101+attached ④端口失配→401),不切 Starscream**;③ 1.5MB 回放在默认 1MiB 上限失败(withKnownIssue 记录)、16MiB 成功,ESC/C0 对抗(转义≈3.6MB)成功。**勘误(已写回 PLAN §1/§3.2/T-iOS-9)**: 超限 errno 实测 NSPOSIXErrorDomain **40=EMSGSIZE**(计划原文误标 ENOBUFS=55),T-iOS-9 归类以 40 为主、55 兜底。ServerHarness 自举真服务器(tsx spawn+death-pipe watchdog,无孤儿进程)。真机 smoke 4 项 DEFERRED(无真机),SpikeTerminalScreen 已编入 App target 待 T-iOS-18。
|
||||
- **T-iOS-3 WireProtocol(冻结)**: §3.1 全类型 + Tunables §3.2.1 全表;**59 tests 全绿、覆盖率 100%**(llvm-cov 226/226);300 轮 roundtrip property + 500 轮 fuzz(decodeServer 永不 crash);**跨实现实测**: Swift encoder 输出经 tsx 喂给真 `src/protocol.ts` parseClientMessage → 13 帧+mode 顶层恢复全 PASS。自此冻结。
|
||||
- **T-iOS-4 TestSupport**: FakeTransport(actor,scripted failures+按连接记帧)/FakeClock(class+锁,`waitForSleepers` 确定性栅栏,零真实等待)/FakeHTTPTransport(记录 headers 供 Origin-iff-G 断言);3 smoke 全绿。
|
||||
- **verify(独立 agent 复跑)**: 模拟器构建 exit 0;59+3 tests;覆盖率 100%;spike 6 tests+1 known issue;`git status` 零越界。
|
||||
- **[x] W1 叶子包(T-iOS-5/6/7/8,4 builders;编排 [5→6 串行] ∥ 7 ∥ 8——5/6 同在 SessionCore 包,主树并行会互坏编译)**:
|
||||
- **T-iOS-5 ReconnectMachine+PingScheduler**: 纯 reducer,§3.2 签名逐字;backoff 1s→…→30s 封顶镜像 terminal-session.ts(连上才归零,retry/foregrounded 不重置阶梯——防狂按打穿);Ping 25s/连续 2 miss→connectionLost/取消与断线显式区分。16 测,0 真实等待,文件级覆盖 100%/95%。
|
||||
- **T-iOS-6 GateState+AwayDigest**: epoch 语义逐行镜像 terminal-session.ts:306-311(上升沿 +1、持续帧刷新 detail、下降沿不重置计数器);`canDecide(epoch:)` 防误批;affordance 映射逐字对齐 tabs.ts:345-347(绝不发 raw auto);digest 词表与 `TimelineEvent.knownClasses` 有相等性绊线测试;防御:乱序容忍、Date→ms clamp 不 trap。25 测,两文件 100%。
|
||||
- **T-iOS-7 HostRegistry**: §3.3 契约;KeychainHostStore 经 SecItemShim 缝(swift test 打 fake;真 shim kSecUseDataProtectionKeychain+AfterFirstUnlockThisDeviceOnly,属性字典单点构造+逐字单测);InMemoryHostStore 入 Sources 供 App-VM 测试。30 测,own-Sources 覆盖 88.1%。
|
||||
- **T-iOS-8 APIClient+探针**: 44 测先全绿后 **`[!] BLOCKED` 正确上报冻结契约自相矛盾**——§3.4 `runPairingProbe → Result<Host,…>` 中 Host 是 §3.3 HostRegistry 类型,而 APIClient 依赖边仅 WireProtocol(§1 零耦合),签名无法编译(两轮 review 均漏)。**裁定(orchestrator,三选一取 c)**: 探针职责=验证 endpoint → 返回 `Result<HostEndpoint,…>`,Host{id,name} 由 T-iOS-12 VM 构造(id/name 本非探针所知);同时按法定路径加 `Tunables.pairingProbeTimeout`(10s,§3.2.1 新增行+绊线断言)。裁定已写回 PLAN §3.4/§3.2.1/T-iOS-8,公开包装+冒烟测试由 orchestrator 落地。Origin **iff**-G 入测试名;探针②成功后必带 Origin kill 不留孤儿;403 kill→originRejected(HTTP 侧 G 守卫也是配对必验项)。45 测,own-Sources 覆盖 98.1%。
|
||||
- **W1 验证(orchestrator 亲验)**: 5 包 178 tests 全绿(59+3+41+30+45);覆盖率(own-Sources 口径)SessionCore 99.1%/HostRegistry 88.1%/APIClient 98.1%,全过 80% 门;`git status` 全部在 Owns 内零越界。**发现归 T-iOS-16**: §9 覆盖率命令的 ignore regex 不排静态链入的依赖包源码,CI 接线时须按包过滤(本次已按正确口径亲算)。
|
||||
- **[x] W2 连接核心(T-iOS-9→10 串行;首次派发 T-iOS-9 agent 死于 API 连接中断——现场零文件、直接 resume 重跑,非任务失败)**:
|
||||
- **T-iOS-9 URLSessionTermTransport**: 冻结 TransportConnection 不动,ping 经 SessionCore 内部(非冻结)`PingableTermTransport/ConnectionPinger` 扩展点接入;pong 时限复用 `Tunables.pingInterval`(无新常量);EMSGSIZE 归类 40 主/55 兜底→类型化 `.replayTooLarge`;connect 失败原样重抛底层错误(保住 PairingError.classify);状态 delegate 驱动,close 三态可区分;测试服务器 = 裸 TCP 手写 RFC6455 framing(可逐字验 Origin/注入 binary/超帧)。11 测,文件覆盖 97.83%,iOS triple 编译过。
|
||||
- **T-iOS-10 SessionEngine actor**: 单 Task 生命周期循环 + generation 计数防陈旧任务复活;首帧铁律 attach→lastKnownDims resize→排队帧按序 flush;终态(exit / replayTooLarge)绝不进 backoff;digest 断点原子消费恰好一次;gate 决策 canDecide 兜底、断线期决策丢弃不入队(重连后 gate 可能换代);防御:非 v4 UUID/相对 cwd 剥离(服务器会静默丢帧致挂死)。**决策**: 初始 resize 依赖首次 sizeChanged/notifyForegrounded(冻结 open 无 dims 参数,SwiftTerm 首次布局即触发,已注 doc);digest limit 传 Int.max(截断属 UI,T-iOS-14)。20 测,SessionEngine.swift 94.98%(480 行,<800 硬上限,内聚裁量已记)。
|
||||
- **W2 验证(独立 agent,5/5 PASS)**: SessionCore 72 测(41 W1 完好+31 新);四包回归全绿(**累计 209 tests**);own-Sources 覆盖 96.68%;冻结契约零改动;4 项语义抽查(Origin 单点 :169/16MiB 来自 Tunables/replayTooLarge 断重连路径逐行核/decodeServer 唯一入口 nil-drop)全数在源码落实。
|
||||
- **[x] W3 UI+提前项(7 agents: [T-iOS-11→12→13→14] 串行链 ∥ T-iOS-16 ∥ T-iOS-17;协调点 = orchestrator 预建 WebTermTests 单测 bundle(project.yml+5 包依赖,模拟器实测过)并单独提交)**:
|
||||
- **T-iOS-11 Terminal+KeyBar**: KeyByteMap(SessionCore 纯数据)逐字节复刻 keybar.ts 全 17 键(⏎=\r 非 \n 专项断言);TerminalViewModel 消费 engine.events,replayTooLarge→不可重试态+话术逐字断言;**决策**: 硬件 UIKeyCommand 仅注册 Esc/⇧Tab/Ctrl 和弦,箭头不注册(固定 CSI 会破 DECCKM 应用光标模式,vim/htop 错乱)——有排除测试;OSC52 剪贴板拒绝、链接仅 http(s)。
|
||||
- **T-iOS-12 Pairing**: Phase 状态机;扫码→confirmingHost,**确认前零网络**(recordedRequests/connectAttempts 空断言);公网需显式勾选确认;§5.4 四层警告 13 组参数化;§3.4 裁定落地(VM 构造 Host 入 store);host 分级 VM 内重实现(APIClient 的 internal 粒度不足,重复已标注归 T-iOS-38 去重);真机 DataScanner 编译隔离,generic/platform=iOS 构建过。
|
||||
- **T-iOS-13 SessionList**: 轮询 Tunables.listPollInterval,离开断言 0 sleeper+10 周期零请求(无泄漏);**发现**: LiveSessionInfo 无 pending 字段(已核 manager.list())→ ⚠ 徽标经 setPendingApproval overlay 由 T-iOS-15 从 gate 事件接入;kill 乐观移除+404=成功/403 回滚;staleness 严格 > 镜像 web;PR 链接 https-only(SEC-L5 镜像)。
|
||||
- **T-iOS-14 Gate/Digest UI**: GateViewModel 独立 VM(events 与 engine 分离注入,T-iOS-15 fan-out 零改动);三选一映射唯一事实源=SessionCore Affordance.clientMessage(acceptEdits/default/reject,App 层零 allowAutoMode 逻辑);tap-epoch+affordance 双守卫;haptic 每 epoch 恰一次(HapticSignaling 注入);digest 自动淡出 FakeClock 驱动;文案逐字镜像 tabs.ts:326-350。
|
||||
- **T-iOS-16 集成 CI**: 吸收 spike 重构 harness;10 测对真自举 Node 服务器(echo/stty resize 1..1000/16MiB+ESC/C0 对抗回放/JOIN mirror/401/401/403/kill→WS close 而非 exit 帧/自然 exit 广播);ios.yml=包矩阵+coverage-gate.sh(own-Sources 过滤,修正 §9 已知缺陷)+App 测试+集成 job;覆盖率门红→绿演示实录;CI 平台未跑(如实注明)。
|
||||
- **T-iOS-17 ntfy 文档**: ios/README.md;只读验证(引 setup-hooks.mjs file:line),**未触碰用户真实 hook 配置**(orchestrator 安全约束);payload 最小化逐行核;真机端到端 DEFERRED。
|
||||
- **W3 验证(独立 agent,全项 PASS)**: 包套件 59+3+77+30+45;App 55 测 TEST SUCCEEDED;集成 10 测+1 known issue(故意的 1MiB 复现);语义抽查 5/5(KeyByteMap 逐字节 16 项、三选一字面量+零 allowAutoMode、零网络断言实测在 :60、轮询走 Tunables、CI 覆盖率 own-Sources 过滤);Owns 审计零越界。**累计 224 单测 + 10 集成**。
|
||||
- **[x] W4 汇合(T-iOS-15,1 builder + verify 8/8 PASS)**:
|
||||
- **T-iOS-15 App 接线**: `Wiring/` 9 文件——AppEnvironment 生产 DI(真 Keychain/真探针/URLSessionHTTPTransport 零逻辑透传,Origin 规则全留 APIClient)、EventFanOut(单消费者 engine.events → TerminalVM+GateVM+SessionActivityBridge 三分支保序广播)、bridge(.adopted→存 lastSessionId/.gate→列表 ⚠ overlay/.exited→清两者,防「继续上次」指向死会话)、TerminalSessionController(.background→close 干净 detach;suspend 后 .active→重建栈+open,generation 换 SwiftUI identity 触发首次 sizeChanged 夺回全屏)、**PrivacyShade 严格 `scenePhase != .active`**(三相位测试)、冷启动路由纯函数;WebTermApp 薄 @main;**删除 SpikeTerminalScreen**(grep 零残留)。
|
||||
- **两项自动化走查代理**: ①LiveServerSmokeTests——签名宿主内真生产 DI 图端到端(探针→host 入库→liveSessions→attach→echo 回显→close→kill 清理);②KeychainHostStoreLiveTests——真 Keychain 往返+`SecItemCopyMatching` 断言 `kSecAttrAccessible==AfterFirstUnlockThisDeviceOnly`+非 synchronizable(T-iOS-7 递延项闭合)。模拟器 boot 截屏:配对页正常渲染。
|
||||
- **环境硬墙(新发现,已记 harness)**: repo 在 `~/Documents`(TCC 保护)时,模拟器 App 语境 posix_spawn 的 node 读 repo 文件被 tccd 无限阻塞(sample 实测卡 `uv_cwd→__open_nocancel`;平台二进制豁免)→ SimServerHarness 双模式:A 自举(CI/非 TCC 路径)/B `WEBTERM_SERVER_URL` 外部注入;TCC 下 0.001s 快速失败+指引。**本机跑法**: 起服务器后 `xcrun simctl spawn booted launchctl setenv WEBTERM_SERVER_URL …` 再 xcodebuild test(命令行 TEST_RUNNER_ 前缀实测传不进宿主 App 测试进程);裸跑 smoke 红=有意显式失败非回归。
|
||||
- **偏差闭合(orchestrator 代 T-iOS-11 owner 修)**: W4 曾记「alive-engine `.active` 未调 notifyForegrounded(dims:)——W3 无 dims 钩子」→ TerminalViewModel 增 `lastSentDims`(仅记有效 resize,invalid 不覆盖,有测试)+ controller `.active` 分支接 `notifyForegrounded(dims:)`(无有效 dims 时跳过,SwiftTerm 首次布局兜底)。**76 App 测全绿**(75+1)。
|
||||
- **verify(独立 agent,8/8)**: 214 包测+76 App 测+10 集成;spike 删净;遮罩规则/生命周期双向 close 路径/组装安全(G 全走 APIClient、零 ad-hoc URLSession、零 ArbitraryLoads、Origin 零手拼)逐项在源码核实;Owns 零越界。
|
||||
- **[x] W5 验收 + findings 修复(2 reviewer ∥ + 1 fix builder;均 PASS_WITH_FINDINGS→已闭合)**:
|
||||
- **T-iOS-18 F 走查**: 全部机器可执行项本轮亲自重跑(集成 10/App 76/包 214 + 模拟器冷启动·后台·重启截屏);F-iOS-1..13 逐条 EXECUTED/EVIDENCED(指认测试名)/DEFERRED(真机)(QR 扫码/IME/震动/切换器遮罩目检/ntfy 端到端,附手工步骤清单)。
|
||||
- **T-iOS-19 安全核对**: 对产物审——Origin 写入点全树恰两处且单点派生;G/RO 分界;**拆构建产物**验五段 CIDR+零 ArbitraryLoads+双 usage description;Keychain 属性;P0 不该在的面(deep link/麦克风)确认缺席;残余风险(isCaptured 跳过/ws 明文模型/epoch 仅客户端)记录完整。
|
||||
- **findings(0 CRITICAL/HIGH; 1 MED+3 LOW,全部闭合)**: ①MED §9 XCUITest happy path 缺失 → orchestrator 建 WebTermUITests target/scheme,fix agent 落地唯一用例:**配对(手输)→列表→attach→KeyBar ^L→注入 held gate(POST /hook/permission)→点 Approve→断言服务器真放行 behavior==allow**,服务器为 oracle(XCUITest 读不了 SwiftTerm 字形);幂等三分支(全新/已配对/空态)全被实走;**两次真实跑通**(439s/677s,EXIT=0)。②LOW CI 缺 iOS17 下限腿 → ios.yml 增 ios17-floor job(runtime 缺失大声跳过/存在必判,CI-only 本机未验)。③LOW URLSession.shared 磁盘缓存可落盘 preview 终端字节 → 生产 HTTPTransport 默认 .ephemeral。④LOW XcodeGen target 级默认覆盖 project 级 TARGETED_DEVICE_FAMILY(实际打包 [1,2]) → 移 target 级,产物已验 [1]。
|
||||
- **XCUITest 实证发现(已记 ios.yml/用例注释)**: `TEST_RUNNER_` env 只在 xcodebuild **进程环境**里才透传 runner(命令行 KEY=VALUE 是 build setting 到不了);**`CODE_SIGNING_ALLOWED=NO` 打断真 Keychain(-34018)**→ 顺手修掉既有 app-tests 腿同 flag(否则 KeychainHostStoreLiveTests 在 GH runner 必红);XCUITest typeText 够不着 SwiftTerm(无 AX 键盘焦点)→ 输入断言走 KeyBar ^L+清屏应答;SwiftTerm 光标闪烁烧 XCUITest 60s idle-wait/事件(单跑 7-11min,已压到 3 次终端交互)。
|
||||
- **遗留观察(report-only,非阻塞)**: 配对探针偶发残留 clientCount=0 会话(killProbeSession 竞态,run 8 一例)——归 T-iOS-8 owner 复核;真 GH Actions 首跑待推送后确认(ui-test 腿/ios17 runtime 镜像现状)。
|
||||
- **P0 收官统计**: 306 项自动化(214 包测 + 76 App 测 + 10 集成 + 1 XCUITest[计 1,参数化更多]) 全绿;覆盖率 own-Sources SessionCore 96.7%/HostRegistry 88.1%/APIClient 98.1%/WireProtocol 100%;19/19 任务完成(全部偏差有日志);服务器 src/ public/ **零改动**。**待用户**: ①真机走查清单(T-iOS-18 报告内);②推送分支跑首轮 GH Actions;③P1 开工前拍板:Apple 付费账号(APNs/TestFlight)与 bundle id/App 名(现 com.yaojia.webterm/WebTerm)。P1 首批 = T-iOS-20(APNs server) ∥ T-iOS-37(lastOutputAt) ∥ T-iOS-38(APIClient P1 契约)。**偏差(orchestrator 决策,W1 起生效)**: 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + 同包任务串行化,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。
|
||||
|
||||
### ✅ iOS 客户端实施计划 — `docs/PLAN_IOS_CLIENT.md`(规划完成 — 2026-07-04,main,未提交;纯文档,零代码改动)
|
||||
**多 agent 编排 = 一个 ultracode `Workflow`(66 agents,7 阶段)**:5 读者并行(wire contract 逐 file:line / 功能矩阵 / relay 现状 / 模板规范 / iOS 平台联网调研)→ 3 架构师独立竞标(full-native / WKWebView-hybrid / phased)→ 3 评委多视角打分(UX/工程/安全,**phased 26.5 胜出**,native 23.5、hybrid 16.5,21 条 mustSteal 嫁接)→ 综合简报 → 写手出稿(843 行)→ **4 评审交叉验证**(对照 src/ 源码逐条核实 / iOS API 联网核查 / 安全 / 计划质量)→ 每条 finding 对抗式 verify(CRITICAL/HIGH 双票)→ fixer 应用。
|
||||
- **方案**: Phased-Native「口袋驾驶舱」—— SwiftUI + SwiftTerm(MIT/SPM),4 个纯 SwiftPM 包(WireProtocol/SessionCore/HostRegistry/APIClient)+ 薄 App 胶水,`ios/` 顶层新包;前台会话单条活 WS + 其余 HTTP 轮询;iOS 17+/Swift 6/XcodeGen;**P0 服务器零触点**(通知复用既有 ntfy 桥 scripts/setup-hooks.mjs:227-238),P1 仅两处声明触点(APNs sender + `LiveSessionInfo.lastOutputAt`,后者为 TS 任务 T-iOS-37)。relay/E2E 明确推迟(已审计 TS 加密不得随手 Swift 重写)。
|
||||
- **任务机**: 38 个任务(T-iOS-1…38),P0=19 任务 W0–W5 ≈13 人日、P1 W6–W8 ≈17.5、P2 W9–W10 ≈8,总 ≈38.5 人日;每任务 Owns/Depends/测试先行 Steps/Accept/估算/分派模型。关键平台事实:原生客户端 **WS 握手必须显式带白名单内 `Origin` 头**(src/http/origin.ts:27 对 undefined 默认拒);`URLSessionWebSocketTask.maximumMessageSize` 需调大到 16 MiB(回放单帧 JSON \u 转义最坏 6×2MB)。
|
||||
- **Review 结果**: 35 findings → **32 confirmed 全部修复**、3 rejected(含「ATS 不收 CIDR」被 verify 驳回 —— iOS 17+ 确实支持 CIDR exception domains)。修复亮点:plan-gate 三选一映射纠正为 `acceptEdits/default/reject`(对照 public/tabs.ts:345-347);「:443 破坏 Origin 匹配」为**臆造 gotcha,反向纠正**(new URL() 双侧归一化);ntfy 桥从「新建」改「复用既有」;CRITICAL 共享类型无主 → 全部收编进 WireProtocol(T-iOS-3);W7 Owns 重叠拆出 T-iOS-38;锁屏 Allow 加 `.authenticationRequired`(Face ID 确认才批 shell 命令);QR 扫码改「先确认再发包」防恶意配对。
|
||||
- **验证(orchestrator 亲验)**: 文档 943 行落盘、11 节结构完整、fixer 27 组修复日志逐条对账;`git status` 仅新增该 .md,src/ 零改动。**未提交**。下一步:用户批准后按 §8 分派批次开工(W0 首日 spike 实测 Origin header)。
|
||||
|
||||
### ✅ 安全审计 relay-e2e + relay-auth — 上线前深度审计 + 修复(完成 — 2026-07-02,分支 `security/relay-auth-audit-fixes`,未提交)
|
||||
上线门:自动生成的 E2E/鉴权核心须过安全专家审计(前次复审判 approve-with-changes)。**多 agent 编排 = 两个 ultracode `Workflow`**:①**审计**(99 agents)—— 11 维 finder 并行(两包)→ 每个 finding 3 lens 对抗式 verify(exploitability/crypto-spec/refuter 多数票)→ 第二轮「重复 review」深挖 → 每包 completeness critic → dedup+定级;②**修复循环**(10 agents)—— foundation(types)→ 3 组文件互斥并行修复(module fixers)→ **verify 循环至 tsc 干净 + 全绿** → 5 路对抗式 re-audit 确认每个 exploit 已闭合。
|
||||
- **审计结论**: **relay-e2e = PASS 零改动**(所有候选在 verify 阶段被驳回:确定性 nonce 安全 —— 方向分离 + 真实 agent 侧 `createReplaySealer` 各自持独立单调 seq;strict-successor recv guard 无法导致 replay-accept)。**relay-auth = approve-with-changes,5 条确认**(2 HIGH / 1 MED / 2 LOW),全部在 human-auth/enforcement 层,全部包内可修。驳回但存档(不再复议):E2E replay nonce reuse、mTLS EKU、SPIFFE 域未 pin、reattach host-scope、TOTP 时序。完整报告见 `docs/REVIEW_RELAY_SECURITY.md`。
|
||||
- **修复**(均含回归测试): **F1 HIGH** step-up 因子降级 —— `needsStepUp` 现把新鲜度绑定到**精确方法**(`stepUpMethod`),钓来的 TOTP 不再满足 passkey;**F2 HIGH** step-up 在新建会话路径被完全跳过(生产 caller `term-relay/data-plane/upgrade.ts:105` 硬编码 `principal:null` → 生产环境 step-up 形同虚设)—— 改为**主机驱动 + fail-closed**(`policy.required` 时 principal 缺失/过期/错方法一律 403);**F3 MED** WebAuthn 断言未绑定已存凭据 —— 现校验 credentialId 且转发 pubkey 给 verifier;**F4 LOW** DPoP verify 未捕获抛出 → INV15 门未审计地崩(审计逃逸+DoS)—— `verifyDpopProof` 全体 try/catch→false + `coreAuthorize` 边界兜成干净审计 401 + issue 校验 cnfJkt 格式;**F5 LOW** 验证过的 signCount 被丢弃 → 克隆检测失效 —— `finishAuthentication` 现返回 `{principal,newSignCount}` 供持久化。
|
||||
- **验证(orchestrator 亲验)**: relay-auth `tsc --noEmit` 干净 + **122 测试全绿**(基线 104,+18 回归);relay-e2e 未动、tsc 干净、76 全绿。re-audit **5/5 闭合**(置信 0.95–0.97,均有回归测试)。
|
||||
- **遗留/behavioral note(非阻塞)**: F2 改为 fail-closed 后,**任何 `stepUpPolicyFor(host).required===true` 的主机,P1(term-relay)必须在 upgrade ctx 传入已认证 principal**,否则正确地被拒;当前 v0.9 默认 `NO_STEPUP_POLICY.required===false`,现网非 step-up 主机不受影响。P1 集成时须补此接线(见 [[relay-stepup-needs-principal-wiring]])。
|
||||
- **第二轮独立 review(Fable 5,换模型增强多样性)+ F6 修复**: 第二个 `Workflow`(fix-regression 猎取 + 新角度 sweep + 对抗式 re-attack 被驳回项)判:**5 条修复 0 regression**、被驳回/D-i-D 项 re-attack **0 存活**(确认真不可利用),但**新增 1 条 HIGH——F6**:relay-e2e `replay-key.ts` 的可恢复 `K_content` 仅由 `(hostContentSecret, sessionId)` 决定(稳定),而真实消费者 `agent/src/e2e/replaySeal.ts` 的 `let seq=0n` 在每次重建(重启/重连)时归零 → 两代 sealer 在**相同 (key, nonce)** 下封不同明文 = 灾难性 AEAD 复用。**第一轮曾误驳此项**(verifier 看到"独立单调计数器"就类比 live 路径判安全,漏了 K_content 跨重启稳定);第二轮定向 re-attack + orchestrator 亲读 `replaySeal.ts` 证实为真。**修法(用户选 epoch-in-key,4 包)**:`ReplayKeyParams` 加 `epoch`;`deriveContentKey` 把 epoch 以 `sessionId‖0x1f‖epoch` 混入 HKDF salt;`createReplaySealer` 每代 `randomUUID()` 新 epoch 并暴露;`ReplaySource` 带 epoch、浏览器按之重推。**验证(亲验)**:4 包 tsc 干净 + 全绿(relay-contracts 81 / relay-e2e 78[+2 F6 回归] / agent 133 / relay-web 99);re-audit 判闭合(回归证:两代同 seq=0 nonce 但不同 key)。**残留(fail-closed 非复用)**:ring-buffer 传输(P1/P2)须持久化并按代下发 epoch —— 已记 TODO,当前 replay 路径尚未端到端接线(`manage-page.loadReplay` 仍是 throw stub),故 F6 此刻是潜伏漏洞。完整报告 `docs/REVIEW_RELAY_SECURITY.md`(含 F6 + RESOLUTION)。
|
||||
- **动态 e2e 安全测试台(新增 `e2e/` 包)**: 跨包 harness `buildRelayWorld()` 把**真实** P5(auth)+P4(crypto)+P2(agent replay) 通过内存 seam + 不可信 relay「RelaySpy」攻击者视角接起来(仅 I/O 边界 fake,所有安全检查走生产代码路径)。全流程 issue cap→upgrade/authz→handshake→sealed session 过 relay→reattach→revoke。**21 测试全绿、tsc 干净(亲验)**。把 F1/F2/F3/F4/F6 从「静态+单测」升级为「动态攻击者实测」,另加 MITM/reflection/replay/reorder/INV2(live+replay)/cross-tenant/single-use。F5(signCount)为返回形状改动、由 relay-auth 单测覆盖;RelaySpy 代替 term-relay mux(真多进程/浏览器 e2e 需尚未建的 run tooling)。用符号链接免 `npm install`(`e2e/node_modules/*` → 各包源码)。覆盖表见 `docs/REVIEW_RELAY_SECURITY.md`。**未提交**,留待用户决定。
|
||||
|
||||
### ✅ 桌面客户端 v0.1 — Electron 一体化壳(代码完成 — 2026-07-01,分支 `feat/desktop-electron`,未提交)
|
||||
Mac/Windows 桌面 App,**内嵌现有 Node 服务器 + node-pty**(all-in-one):主进程 `startEmbeddedServer` 复用 `src/server.ts` 的 `startServer(cfg)`/`loadConfig`(**服务器零改动** —— 它本就导出可编程启动且 import 无副作用),窗口 `loadURL('http://127.0.0.1:<port>/')` 复用**前端零改动**(前端严格同源,`location.host` 自动指向内嵌服务器;Origin 白名单默认含 localhost)。核心价值 = **原生通知**(轮询 `/live-sessions` → `computeNotifications` → 系统通知)+ 托盘常驻 + 深链 `terminalapp://` + 开机自启。
|
||||
- **编排**: ultracode `Workflow` —— 脚手架(orchestrator 冻结 `desktop/src/types.ts` 契约 + package/tsconfig/esbuild/electron-builder)→ Build(4 builder 并行、文件互斥、对齐冻结签名)→ Review(correctness/security/typescript 三 lens 并行、schema 结构化 findings)。纯逻辑尽量抽出可单测;Electron glue(main/window/tray/menu/preload/embedded-server/logger)作为不可单测 wiring 排除出覆盖率(沿用既有 vitest.config 先例)。**无用 zod、无用 electron-store**(遵循项目手写校验 + 手写 JSON 持久化的最小依赖惯例)。
|
||||
|
||||
169
docs/REVIEW_RELAY_SECURITY.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Security Audit — relay-e2e + relay-auth (pre-production go-live gate)
|
||||
|
||||
**Date:** 2026-07-02 · **Branch:** `security/relay-auth-audit-fixes` · **Prior verdict:** approve-with-changes
|
||||
**Method:** multi-agent deep audit — 11 security dimensions fanned out across both packages, every
|
||||
candidate finding adversarially verified by ≥3 independent skeptic lenses (exploitability /
|
||||
crypto-spec / refuter, majority vote), a second "repeat-review" deep-dive pass, a per-package
|
||||
completeness critic, then dedup + severity re-rank. 99 agents, ~6.9M tokens. The orchestrator
|
||||
independently read 100% of both `src/` trees to adjudicate.
|
||||
|
||||
## Verdict
|
||||
|
||||
**relay-e2e (crypto core): PASS — no changes required.** Every candidate against the E2E core
|
||||
(replay-key nonce reuse, sequence/handshake ordering, key-hygiene, X25519 low-order) was **refuted**
|
||||
on verification: the deterministic-nonce discipline is safe because the direction split + the real
|
||||
agent-side `createReplaySealer` (in `term-relay/agent`) each own an independent monotonic counter;
|
||||
recv `accept()` is a strict-successor guard whose pre-auth commit cannot cause a replay-accept.
|
||||
|
||||
**relay-auth: APPROVE-WITH-CHANGES — 5 confirmed findings (2 HIGH, 1 MED, 2 LOW), all in the
|
||||
human-auth / enforcement layer.** All fixes are contained in relay-auth; the P5-local types
|
||||
(`AuthenticatedPrincipal`, `StepUpPolicy`) are extendable without touching any frozen contract.
|
||||
|
||||
### Refuted (verified NOT exploitable — recorded so they are not re-litigated)
|
||||
- E2E replay `K_content` nonce reuse — real consumer keeps an independent monotonic seq (no reset-to-0).
|
||||
- mTLS `verifyChain` missing EKU/keyUsage — every abuse cert fails the later mandatory SPIFFE-SAN →
|
||||
enrolled-&-non-revoked-host registry gate.
|
||||
- SPIFFE trust-domain not pinned on verify — gated by chain-valid + registry; defense-in-depth only.
|
||||
- Reattach host-scope "bypass" — `token.host === requestedHostId` bind at `decide.ts:62` blocks it;
|
||||
session↔host binding is enforced at connect by P1/P3. (Kept as a cheap D-i-D hardening note.)
|
||||
- TOTP non-constant-time compare / device-proof `acct` canonicalization / DPoP ±future-iat — LOW, refuted.
|
||||
|
||||
---
|
||||
|
||||
## Confirmed findings + fix plan
|
||||
|
||||
### F1 · HIGH — Step-up factor downgrade (`human/stepup/stepup.ts:33`)
|
||||
`needsStepUp()` tests `policy.requiredMethod ∈ principal.amr` and freshness via a single
|
||||
method-agnostic `stepUpAt`. `amr` is cumulative across login + every step-up, so a **passkey**
|
||||
requirement is satisfied by a passkey used at *login*, while the freshness timestamp can be produced
|
||||
by a *weaker* step-up (e.g. phished TOTP). The phishing-resistant control silently downgrades.
|
||||
**Fix:** bind freshness to the method. Add `stepUpMethod` to the principal; `needsStepUp` requires
|
||||
`stepUpMethod === requiredMethod` AND fresh; `recordStepUp` stamps the method. (Group A)
|
||||
|
||||
### F2 · HIGH — Mandatory step-up skipped on the new-session path (`enforce/onUpgrade.ts:128`)
|
||||
The gate is wrapped in `if (ctx.principal !== null)`, but the production caller
|
||||
(`term-relay/data-plane/upgrade.ts:105`) hardcodes `principal: null` on **both** connect and reattach
|
||||
(session principal exists only *after* authz). Result: **step-up never runs in production** — a valid
|
||||
DPoP-bound token opens a root shell with the mandatory passkey ceremony never performed.
|
||||
Deny-by-default is violated (absence of proof → allow).
|
||||
**Fix:** make step-up **host-driven and fail-closed**: when `stepUpPolicyFor(host).required`, DENY
|
||||
(403 `step_up_required`) unless an authenticated principal proves fresh step-up. Applies to connect
|
||||
and reattach. Default v0.9 policy is `required:false` → existing non-step-up hosts unaffected.
|
||||
**Behavioral note:** once a host sets `required:true`, the P1 caller must supply a step-up principal;
|
||||
until then such hosts correctly deny. (Group A)
|
||||
|
||||
### F3 · MEDIUM — WebAuthn assertion not bound to the stored credential (`human/webauthn/authenticate.ts:40`)
|
||||
`finishAuthentication` forwards only `signCount` to the verifier — never `cred.publicKey` /
|
||||
`cred.credentialId`, and there is no `credentialId` cross-check. Any reasonable verifier can only
|
||||
check challenge/origin/rpId, so an assertion from a *different* registered credential authenticates.
|
||||
**Fix:** add `credentialPublicKey` + `expectedCredentialId` to the verifier interface & forward them;
|
||||
assert `resp.credentialId === cred.credentialId` before trusting `verified`. (Group B)
|
||||
|
||||
### F4 · LOW — DPoP verify throws (unhandled) → INV15 gate fails un-audited (`capability/verify.ts:142`)
|
||||
`importEd25519PublicRaw(rawPub)` (and `readCnfJkt`) run outside try/catch; a thumbprint-matching but
|
||||
malformed key makes `verifyDpopProof` **reject** instead of returning false. Callers (`decide.ts:57`,
|
||||
`onUpgrade.ts:112`) don't catch → the sole authz gate throws an unhandled exception and emits **no**
|
||||
`deny` AuditEvent (audit-evasion + repeatable unhandled-rejection DoS). Fail-closed but off-contract.
|
||||
**Fix (defense-in-depth):** (a) wrap the risky calls in `verifyDpopProof` → return false; (b) wrap the
|
||||
`verifyDpopProof` call in `coreAuthorize` → clean audited `deny(401)`; (c) validate `cnfJkt` format at
|
||||
issue. (Group C)
|
||||
|
||||
### F5 · LOW — WebAuthn verified `signCount` discarded → clone detection inert (`human/webauthn/authenticate.ts:52`)
|
||||
The advanced `newSignCount` is used only for the regression check and never returned/persisted, so the
|
||||
guard forever compares against the registration-time count — a cloned authenticator is never detected.
|
||||
**Fix:** return `newSignCount` (updated credential) so the caller can persist it. (Group B)
|
||||
|
||||
---
|
||||
|
||||
## Execution plan (multi-agent, loop mode)
|
||||
|
||||
- **Foundation (barrier):** `types.ts` — add `StepUpPolicy.required: boolean` + `AuthenticatedPrincipal.stepUpMethod?: AuthMethod|null` (+ Zod). Optional `stepUpMethod` ⇒ no ripple to other principal constructors; absent ⇒ treated as null ⇒ needs-step-up (fail-safe).
|
||||
- **Fix groups (parallel, file-disjoint):**
|
||||
- **Group A (F1,F2):** `stepup.ts`, `enforce/onUpgrade.ts`, `test/{stepup,enforce}.test.ts`, `test/tripwire/cross-tenant.test.ts`.
|
||||
- **Group B (F3,F5):** `human/webauthn/{verifier,authenticate}.ts`, `test/webauthn.test.ts`.
|
||||
- **Group C (F4):** `capability/{verify,issue}.ts`, `authz/decide.ts`, `test/capability.test.ts`.
|
||||
- **Verify (loop until green):** `tsc --noEmit` + full `vitest run` in relay-auth; repair any breakage; repeat. Baseline to preserve/extend: relay-e2e 76, relay-auth 104.
|
||||
- **Re-audit (parallel adversarial):** one verifier per finding re-reads the fixed code and confirms the specific exploit is closed.
|
||||
|
||||
Each fix ships with a **regression test** encoding the exploit (F2: STRICT+null→403; F1: fresh TOTP ≠ passkey; F3: credentialId mismatch→reject; F4: malformed-jwk DPoP→false+audited deny; F5: newSignCount surfaced).
|
||||
|
||||
---
|
||||
|
||||
## Second independent review (2026-07-02, Fable 5) — RECORD CORRECTION
|
||||
|
||||
A second review (fix-regression hunt + fresh-angle sweep + adversarial re-attack of refuted items),
|
||||
run on a **different model** for reviewer diversity, returned:
|
||||
- **Fix-regression: 0** — the 5 fixes (F1–F5) survive independent scrutiny, no regressions.
|
||||
- **Re-attack of refuted/D-i-D items: 0 survived** — mTLS EKU, SPIFFE domain, reattach host-scope, TOTP
|
||||
replay, device-proof canonicalization are re-confirmed **non-exploitable**.
|
||||
- **1 new HIGH** — which the first audit had **REFUTED in error** (a false-negative). Corrected below.
|
||||
|
||||
### F6 · HIGH — Recoverable `K_content` replay key reuses `(key, nonce)` across sealer generations
|
||||
**⚠️ This corrects the earlier "relay-e2e PASS" verdict — relay-e2e's replay surface has a real nonce-reuse hazard.**
|
||||
`relay-e2e/src/replay-key.ts` `deriveContentKey` keys `K_content = HKDF(hostContentSecret, salt=sessionId,
|
||||
info=const)` — **deterministic**, and *intended* to be recoverable/stable per `(host, sessionId)`. The
|
||||
nonce is deterministic `f(seq)` and `sealReplayFrame` is stateless (no SequenceGuard). The sole real
|
||||
consumer, `agent/src/e2e/replaySeal.ts:41` `createReplaySealer`, holds `let seq = 0n` **in memory** and
|
||||
**resets to 0 on every reconstruction** (agent restart/redeploy, or a new attach), while `hostContentSecret`
|
||||
is persisted 0600 (Keystore) and `sessionId` is stable (localStorage). Unlike the LIVE h2c path — safe
|
||||
because each reconnect runs a fresh ECDH → brand-new keys — the replay path reuses the **same K_content**
|
||||
with `seq` restarting at 0 ⇒ two generations of distinct plaintext sealed under identical `(key, nonce)`.
|
||||
An untrusted relay (INV2 sees ciphertext) that observes both generations recovers plaintext-XOR (terminal
|
||||
output: commands/tokens/code) and, for AES-256-GCM, recovers the GHASH auth-subkey → forgery, which
|
||||
`openReplayCiphertext` (no cross-frame replay check) will accept.
|
||||
**Why round 1 missed it:** the round-1 verifier located `let seq = 0n`, correctly noted it's an independent
|
||||
counter, but wrongly concluded "safe" by analogy to the live path — missing that `K_content` (unlike the
|
||||
live ephemeral keys) is STABLE across restarts, so the seq reset *does* collide. Round 2's dedicated
|
||||
re-attack + orchestrator's direct read of `replaySeal.ts`/`hostEndpoint.ts` confirm the exploit.
|
||||
**Fix options (design decision — touches a frozen contract and/or the agent package, both outside the two
|
||||
originally-scoped packages):**
|
||||
- **(A) Agent-side durable seq** — persist the replay `SequenceGuard` with the ring buffer / Keystore so
|
||||
`seq` never resets for a given `(sessionId, K_content)`. Keeps `K_content` recoverable; blast radius =
|
||||
`agent/` only. Requires durable monotonic-seq guarantee across restart.
|
||||
- **(B) Per-generation epoch in `K_content`** — add an epoch/generation to `ReplayKeyParams` (frozen in
|
||||
`relay-contracts/src/e2e/types.ts`), mixed into the HKDF salt, and carry it with each frame so the browser
|
||||
re-derives the right key. Cryptographically robust (fresh key per generation) but changes a **frozen
|
||||
contract** + `relay-e2e` + `relay-web` (browser re-derivation) + `agent`.
|
||||
- Regression test (either path): two sealer generations for the same `(secret, sessionId)` must never emit
|
||||
two frames sharing `(key, nonce)`.
|
||||
|
||||
**RESOLUTION — FIXED (option B, epoch-in-key), 2026-07-02.** Implemented across all 4 packages:
|
||||
`ReplayKeyParams` (relay-contracts) gains a required `epoch: string`; `deriveContentKey` (relay-e2e) folds it
|
||||
into the HKDF salt as `utf8(sessionId) ‖ 0x1F ‖ utf8(epoch)` (unit-separator, no concat aliasing);
|
||||
`createReplaySealer` (agent) mints a fresh `randomUUID()` epoch per generation and exposes it; `ReplaySource`
|
||||
(relay-web) carries `epoch` and the browser re-derives with it. A fresh epoch per generation ⇒ a fresh key,
|
||||
so a `seq=0` reset after restart/re-attach can never collide with a prior generation's `(key, nonce)`;
|
||||
recoverability within a generation (same epoch ⇒ same key) is preserved. **Verified:** all 4 packages
|
||||
tsc-clean and green (relay-contracts 81, relay-e2e 78 [+2 F6 regressions], agent 133, relay-web 99); re-audit
|
||||
confirms closure (regression proves two generations share the seq=0 nonce yet derive different keys).
|
||||
**Residual (fail-closed, not reuse):** the ring-buffer transport (P1/P2) must persist each generation's epoch
|
||||
and serve the epoch matching those exact frames; documented as a TODO at `relay-web/.../manage-page.ts`
|
||||
(`loadReplay` is still a throwing stub — the replay path is not wired end-to-end yet, so F6 was latent).
|
||||
|
||||
---
|
||||
|
||||
## Dynamic end-to-end security harness (`e2e/`, 2026-07-02)
|
||||
|
||||
A cross-package harness (`e2e/`) now wires the **real** P5(auth) + P4(crypto) + P2(agent replay) exports
|
||||
through in-memory seams and an untrusted-relay "RelaySpy" attacker vantage — every security check runs the
|
||||
production code path; only true I/O boundaries (registries/buckets/revocation/audit/sockets) are faked.
|
||||
`buildRelayWorld()` exposes the full flow (issue cap → upgrade/authz → handshake → sealed session via relay
|
||||
→ reattach → revoke). **21 tests pass, tsc clean.** This upgrades the findings from *static + unit* validation
|
||||
to *dynamic* validation — the attacker actually attempts each exploit:
|
||||
|
||||
| Attack (dynamic) | Asserted defense |
|
||||
|---|---|
|
||||
| **F1** step-up factor downgrade | fresh TOTP step-up → `needsStepUp` true + upgrade 403; passkey → allowed |
|
||||
| **F2** step-up skipped (fail-open) | STRICT + `principal:null` → 403 `step_up_required` + audit; `required:false`+null → allowed |
|
||||
| **F3** WebAuthn credential binding | `credentialId` mismatch → `WebAuthnError` even with a verifier that returns `verified:true` |
|
||||
| **F4** DPoP unhandled throw | malformed thumbprint-matching jwk → `verifyDpopProof` resolves `false` (no throw) → clean audited `dpop_proof_failed` deny |
|
||||
| **F6** replay `(key,nonce)` reuse | two sealer generations → same seq-0 nonce but different key ⇒ ciphertext+tag differ; cross-gen open throws; within-gen recovers |
|
||||
| MITM | wrong `agentPubkey` / tampered `hostEphPub` → `FingerprintMismatchError`, no keys derived |
|
||||
| reflection / replay / reorder | c2h→client.open rejects; dup/out-of-order `open` throws (SequenceGuard) |
|
||||
| INV2 | RelaySpy ciphertext (live + replay path) never contains the plaintext marker |
|
||||
| cross-tenant (INV1) | acct-A token at acct-B host → 403 `cross_tenant` + `cross-tenant-attempt` audit |
|
||||
| capability single-use | same jti twice (fresh DPoP) → 2nd 403 `token_replayed` |
|
||||
|
||||
**Not dynamically covered:** F5 (signCount persistence) is a return-shape change, asserted by its relay-auth
|
||||
unit regression. The harness fakes the P1 relay/transport (RelaySpy) rather than running term-relay's mux
|
||||
and the control-plane HTTP — a true multi-process/browser e2e would need the (not-yet-built) run tooling.
|
||||
141
e2e/harness/dpop.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* P5 signing-key setup + capability-token / DPoP bundle builder. These wire the REAL
|
||||
* relay-auth issue + DPoP primitives. `resetVerifyKeyForTest`, `resetDpopCacheForTest`,
|
||||
* `buildDpopProof`, `jwkThumbprint`, and the WebCrypto Ed25519 helpers are TEST-ONLY / internal,
|
||||
* so they are deep-imported from `relay-auth/src/...` (relay-auth has no `exports` map, so subpath
|
||||
* imports resolve through the e2e node_modules symlink).
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { issueCapabilityToken, type DpopContext } from 'relay-auth'
|
||||
import type { CapabilityRight } from 'relay-contracts'
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import {
|
||||
configureVerifyKey,
|
||||
resetVerifyKeyForTest,
|
||||
} from 'relay-auth/src/config/keys.js'
|
||||
import {
|
||||
generateEd25519KeyPair,
|
||||
exportEd25519PublicRaw,
|
||||
} from 'relay-auth/src/crypto/ed25519.js'
|
||||
import { buildDpopProof, resetDpopCacheForTest } from 'relay-auth/src/capability/verify.js'
|
||||
import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js'
|
||||
import { principal } from './fakes.js'
|
||||
|
||||
export { resetDpopCacheForTest, jwkThumbprint }
|
||||
|
||||
export interface P5Key {
|
||||
readonly signingKey: CryptoKey
|
||||
readonly publicRaw: Uint8Array
|
||||
}
|
||||
|
||||
/** Configure the P5 verifying key globally and return the matching private signing key. */
|
||||
export async function setupP5SigningKey(): Promise<P5Key> {
|
||||
resetVerifyKeyForTest()
|
||||
const pair = await generateEd25519KeyPair()
|
||||
configureVerifyKey(pair.publicKey)
|
||||
const publicRaw = await exportEd25519PublicRaw(pair.publicKey)
|
||||
return { signingKey: pair.privateKey, publicRaw }
|
||||
}
|
||||
|
||||
export interface Ephemeral {
|
||||
readonly privateKey: CryptoKey
|
||||
readonly publicRaw: Uint8Array
|
||||
}
|
||||
|
||||
export async function makeEphemeral(): Promise<Ephemeral> {
|
||||
const pair = await generateEd25519KeyPair()
|
||||
return { privateKey: pair.privateKey, publicRaw: await exportEd25519PublicRaw(pair.publicKey) }
|
||||
}
|
||||
|
||||
export interface IssueOpts {
|
||||
readonly accountId: string
|
||||
readonly host: string
|
||||
readonly aud: string
|
||||
readonly rights?: readonly CapabilityRight[]
|
||||
readonly ttl?: number
|
||||
readonly now: number
|
||||
readonly htu?: string
|
||||
readonly htm?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A capability token bundled with a matching DPoP proof. `newDpop(at?)` mints a FRESH DPoP proof
|
||||
* (new jti) bound to the SAME ephemeral key — needed to re-present the same token under a distinct
|
||||
* DPoP (single-use jti / revocation tests) without tripping the DPoP replay cache.
|
||||
*/
|
||||
export interface CapBundle {
|
||||
readonly raw: string
|
||||
readonly dpop: DpopContext
|
||||
readonly newDpop: (at?: number) => Promise<DpopContext>
|
||||
}
|
||||
|
||||
export async function issueCapBundle(signingKey: CryptoKey, o: IssueOpts): Promise<CapBundle> {
|
||||
const eph = await makeEphemeral()
|
||||
const cnfJkt = await jwkThumbprint(eph.publicRaw)
|
||||
const raw = await issueCapabilityToken(
|
||||
{
|
||||
principal: principal(o.accountId),
|
||||
aud: o.aud,
|
||||
host: o.host,
|
||||
rights: o.rights ?? ['attach'],
|
||||
ttlSeconds: o.ttl ?? 45,
|
||||
cnfJkt,
|
||||
},
|
||||
signingKey,
|
||||
o.now,
|
||||
)
|
||||
const htu = o.htu ?? `https://${o.aud}/ws`
|
||||
const htm = o.htm ?? 'GET'
|
||||
const newDpop = async (at: number = o.now): Promise<DpopContext> => ({
|
||||
proofJws: await buildDpopProof(eph.privateKey, eph.publicRaw, {
|
||||
htu,
|
||||
htm,
|
||||
jti: randomUUID(),
|
||||
iat: at,
|
||||
}),
|
||||
htu,
|
||||
htm,
|
||||
})
|
||||
return { raw, dpop: await newDpop(o.now), newDpop }
|
||||
}
|
||||
|
||||
/**
|
||||
* F4 crafting: a capability whose `cnf.jkt` is the thumbprint of a NON-32-byte blob, plus a DPoP
|
||||
* proof whose `header.jwk.x` decodes to that same blob. The thumbprint check therefore PASSES and
|
||||
* execution reaches `importEd25519PublicRaw(blob)`, which throws on the bad length — exercising the
|
||||
* fail-safe (must resolve to false, never reject).
|
||||
*/
|
||||
export interface MalformedOpts {
|
||||
readonly accountId: string
|
||||
readonly host: string
|
||||
readonly aud: string
|
||||
readonly now: number
|
||||
readonly blobLen?: number
|
||||
}
|
||||
|
||||
export async function craftMalformedDpopBundle(
|
||||
signingKey: CryptoKey,
|
||||
o: MalformedOpts,
|
||||
): Promise<{ raw: string; dpop: DpopContext }> {
|
||||
const blob = new Uint8Array(o.blobLen ?? 16).fill(7)
|
||||
const cnfJkt = await jwkThumbprint(blob) // 43-char base64url regardless of blob length
|
||||
const raw = await issueCapabilityToken(
|
||||
{
|
||||
principal: principal(o.accountId),
|
||||
aud: o.aud,
|
||||
host: o.host,
|
||||
rights: ['attach'],
|
||||
ttlSeconds: 45,
|
||||
cnfJkt,
|
||||
},
|
||||
signingKey,
|
||||
o.now,
|
||||
)
|
||||
const htu = `https://${o.aud}/ws`
|
||||
const enc = (x: unknown): string =>
|
||||
encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(x)))
|
||||
const h = enc({ typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(blob) } })
|
||||
const p = enc({ htu, htm: 'GET', jti: randomUUID(), iat: o.now })
|
||||
const s = encodeBase64UrlBytes(new Uint8Array(64)) // arbitrary signature bytes
|
||||
return { raw, dpop: { proofJws: `${h}.${p}.${s}`, htu, htm: 'GET' } }
|
||||
}
|
||||
132
e2e/harness/fakes.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* In-memory port fakes — the ONLY seams that stand in for the true I/O boundaries P1/P3 own
|
||||
* (host registry / session registry / revocation store / token buckets / audit sink / revocation
|
||||
* bus). Every security decision still runs through the REAL relay-auth exports; these fakes only
|
||||
* supply storage. Adapted from `relay-auth/test/_helpers.ts` (same shapes, bare import paths).
|
||||
*/
|
||||
import type { HostRecord, KillSignal, RevocationBus } from 'relay-contracts'
|
||||
import type {
|
||||
AuthenticatedPrincipal,
|
||||
AuditEvent,
|
||||
AuditSink,
|
||||
HostRegistryPort,
|
||||
SessionRegistryPort,
|
||||
RevocationStore,
|
||||
TokenBucketStore,
|
||||
} from 'relay-auth'
|
||||
|
||||
/** An authenticated principal (the ONLY source of accountId, INV3). */
|
||||
export function principal(
|
||||
accountId: string,
|
||||
overrides: Partial<AuthenticatedPrincipal> = {},
|
||||
): AuthenticatedPrincipal {
|
||||
return {
|
||||
kind: 'human',
|
||||
accountId,
|
||||
principalId: 'cred-' + accountId,
|
||||
amr: ['passkey'],
|
||||
authAt: 1000,
|
||||
stepUpAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A minimal online HostRecord for the authz registry (agentPubkey here is unused by authz). */
|
||||
export function makeHostRecord(
|
||||
accountId: string,
|
||||
hostId: string,
|
||||
status: HostRecord['status'] = 'online',
|
||||
): HostRecord {
|
||||
return {
|
||||
hostId,
|
||||
accountId,
|
||||
subdomain: 'sub-' + hostId,
|
||||
agentPubkey: new Uint8Array(32),
|
||||
enrollFpr: 'fpr-' + hostId,
|
||||
status,
|
||||
lastSeen: '2026-01-01T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
revokedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function fakeHostRegistry(hosts: readonly HostRecord[]): HostRegistryPort {
|
||||
const map = new Map(hosts.map((h) => [h.hostId, h]))
|
||||
return { getById: async (id) => map.get(id) ?? null }
|
||||
}
|
||||
|
||||
export interface MutableSessionRegistry extends SessionRegistryPort {
|
||||
add(entry: { sessionId: string; hostId: string; accountId: string }): void
|
||||
}
|
||||
|
||||
export function fakeSessionRegistry(
|
||||
seed: readonly { sessionId: string; hostId: string; accountId: string }[] = [],
|
||||
): MutableSessionRegistry {
|
||||
const map = new Map(seed.map((s) => [s.sessionId, { hostId: s.hostId, accountId: s.accountId }]))
|
||||
return {
|
||||
getById: async (id) => map.get(id) ?? null,
|
||||
add: (e) => {
|
||||
map.set(e.sessionId, { hostId: e.hostId, accountId: e.accountId })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface RevocationFake extends RevocationStore {
|
||||
readonly revoked: Set<string>
|
||||
readonly consumed: Set<string>
|
||||
}
|
||||
|
||||
export function fakeRevocationStore(): RevocationFake {
|
||||
const revoked = new Set<string>()
|
||||
const consumed = new Set<string>()
|
||||
return {
|
||||
revoked,
|
||||
consumed,
|
||||
isRevoked: async (jti) => revoked.has(jti),
|
||||
revokeJti: async (jti) => {
|
||||
revoked.add(jti)
|
||||
},
|
||||
consumeOnce: async (jti) => {
|
||||
if (consumed.has(jti)) return false
|
||||
consumed.add(jti)
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface TokenBucketFake extends TokenBucketStore {
|
||||
readonly blocked: Set<string>
|
||||
readonly calls: string[]
|
||||
}
|
||||
|
||||
/** Token bucket that always allows unless a key is added to `blocked`. */
|
||||
export function fakeTokenBucket(): TokenBucketFake {
|
||||
const blocked = new Set<string>()
|
||||
const calls: string[] = []
|
||||
return {
|
||||
blocked,
|
||||
calls,
|
||||
take: async (key) => {
|
||||
calls.push(key)
|
||||
return !blocked.has(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditFake extends AuditSink {
|
||||
readonly events: AuditEvent[]
|
||||
}
|
||||
|
||||
export function fakeAuditSink(): AuditFake {
|
||||
const events: AuditEvent[] = []
|
||||
return { events, append: async (e) => void events.push(e) }
|
||||
}
|
||||
|
||||
export interface RevocationBusFake extends RevocationBus {
|
||||
readonly published: KillSignal[]
|
||||
}
|
||||
|
||||
export function fakeRevocationBus(): RevocationBusFake {
|
||||
const published: KillSignal[] = []
|
||||
return { published, publish: async (s) => void published.push(s) }
|
||||
}
|
||||
34
e2e/harness/spy.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* RelaySpy — the untrusted-relay / attacker vantage. A passthrough that records every ciphertext
|
||||
* byte it forwards (exactly what a malicious relay can see). INV2: a known plaintext marker must
|
||||
* NEVER appear in `captured`. Mirrors the `relay-e2e/test/integration.test.ts` spy.
|
||||
*/
|
||||
import { MAX_FRAME_BYTES } from 'relay-e2e'
|
||||
|
||||
export class RelaySpy {
|
||||
readonly captured: Uint8Array[] = []
|
||||
|
||||
/** Forward a sealed payload, recording a copy of the ciphertext the relay observes. */
|
||||
forward(payload: Uint8Array): Uint8Array {
|
||||
if (payload.length > MAX_FRAME_BYTES) {
|
||||
throw new Error(`payload exceeds MAX_FRAME_BYTES (${payload.length} > ${MAX_FRAME_BYTES})`)
|
||||
}
|
||||
this.captured.push(payload.slice())
|
||||
return payload
|
||||
}
|
||||
|
||||
/** Latin1 concatenation of everything the relay saw — for substring canary scans. */
|
||||
snapshot(): string {
|
||||
return this.captured.map((b) => Buffer.from(b).toString('latin1')).join(' ')
|
||||
}
|
||||
|
||||
/** True if `marker` appears in ANY captured frame (utf8 or latin1) — INV2 tripwire. */
|
||||
contains(marker: string): boolean {
|
||||
if (this.snapshot().includes(marker)) return true
|
||||
return this.captured.some(
|
||||
(b) =>
|
||||
Buffer.from(b).toString('utf8').includes(marker) ||
|
||||
Buffer.from(b).toString('latin1').includes(marker),
|
||||
)
|
||||
}
|
||||
}
|
||||
332
e2e/harness/world.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* buildRelayWorld() — composes the REAL relay-auth (P5) + relay-e2e (P4) + agent (P2 replay)
|
||||
* exports through the in-memory seams in ./fakes and exposes a clean flow API plus the untrusted
|
||||
* "RelaySpy" attacker vantage. Only the true I/O boundaries are faked; every security check is the
|
||||
* production code path. Pass an explicit `now` everywhere (no Date.now in assertions).
|
||||
*
|
||||
* The host identity used for the §4.4 handshake transcript signature is a real WebCrypto Ed25519
|
||||
* key (relay-auth's own crypto, deep-imported) so the harness needs no @noble import of its own and
|
||||
* no relay-e2e deep import (relay-e2e's `exports` map blocks subpaths).
|
||||
*/
|
||||
import { randomUUID, randomBytes } from 'node:crypto'
|
||||
import type { AeadAlg, E2EEnvelope, E2ESession, HostHello, HostRecord } from 'relay-contracts'
|
||||
import {
|
||||
createClientHandshake,
|
||||
createHostHandshake,
|
||||
createE2ESession,
|
||||
MemoryDevicePinStore,
|
||||
deriveContentKey,
|
||||
sealReplayFrame,
|
||||
openReplayCiphertext,
|
||||
encodeEnvelope,
|
||||
} from 'relay-e2e'
|
||||
import {
|
||||
onUpgrade,
|
||||
onReattach,
|
||||
revoke,
|
||||
revokeToken,
|
||||
signDeviceAuthProof,
|
||||
verifyDeviceProof,
|
||||
type AuthzOutcome,
|
||||
type EnforceDeps,
|
||||
type UpgradeContext,
|
||||
type StepUpPolicy,
|
||||
type RevocationScope,
|
||||
} from 'relay-auth'
|
||||
import {
|
||||
generateEd25519KeyPair,
|
||||
exportEd25519PublicRaw,
|
||||
importEd25519PublicRaw,
|
||||
signEd25519,
|
||||
verifyEd25519,
|
||||
} from 'relay-auth/src/crypto/ed25519.js'
|
||||
import { createReplaySealer, type ReplaySealer } from 'agent'
|
||||
import {
|
||||
fakeAuditSink,
|
||||
fakeHostRegistry,
|
||||
fakeRevocationBus,
|
||||
fakeRevocationStore,
|
||||
fakeSessionRegistry,
|
||||
fakeTokenBucket,
|
||||
makeHostRecord,
|
||||
principal,
|
||||
type AuditFake,
|
||||
type MutableSessionRegistry,
|
||||
type RevocationBusFake,
|
||||
type RevocationFake,
|
||||
type TokenBucketFake,
|
||||
} from './fakes.js'
|
||||
import {
|
||||
craftMalformedDpopBundle,
|
||||
issueCapBundle,
|
||||
resetDpopCacheForTest,
|
||||
setupP5SigningKey,
|
||||
type CapBundle,
|
||||
} from './dpop.js'
|
||||
import { RelaySpy } from './spy.js'
|
||||
|
||||
export const DEFAULT_NOW = 1_700_000_000
|
||||
const REPLAY_ALG: AeadAlg = 'xchacha20-poly1305'
|
||||
|
||||
export const NO_STEP_UP: StepUpPolicy = {
|
||||
required: false,
|
||||
maxAgeSeconds: Number.MAX_SAFE_INTEGER,
|
||||
requiredMethod: 'passkey',
|
||||
}
|
||||
export const STRICT_PASSKEY: StepUpPolicy = {
|
||||
required: true,
|
||||
maxAgeSeconds: 300,
|
||||
requiredMethod: 'passkey',
|
||||
}
|
||||
|
||||
/** A seeded host: authz identity (hostId/accountId/aud) + §4.4 e2e identity + replay secret. */
|
||||
export interface HostFixture {
|
||||
readonly label: string
|
||||
readonly hostId: string
|
||||
readonly accountId: string
|
||||
readonly aud: string
|
||||
readonly origin: string
|
||||
readonly agentPubkey: Uint8Array
|
||||
readonly hostContentSecret: Uint8Array
|
||||
readonly replayAlg: AeadAlg
|
||||
/** WebCrypto private key backing the handshake transcript signer. */
|
||||
readonly signingPriv: CryptoKey
|
||||
}
|
||||
|
||||
async function makeHostFixture(label: string, accountId: string): Promise<HostFixture> {
|
||||
const kp = await generateEd25519KeyPair()
|
||||
const agentPubkey = await exportEd25519PublicRaw(kp.publicKey)
|
||||
const sub = `${label}.term.example.com`
|
||||
return {
|
||||
label,
|
||||
hostId: randomUUID(),
|
||||
accountId,
|
||||
aud: sub,
|
||||
origin: `https://${sub}`,
|
||||
agentPubkey,
|
||||
hostContentSecret: new Uint8Array(randomBytes(32)),
|
||||
replayAlg: REPLAY_ALG,
|
||||
signingPriv: kp.privateKey,
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpgradeArgs {
|
||||
readonly raw: string
|
||||
readonly dpop: UpgradeContext['dpop']
|
||||
readonly host: string
|
||||
readonly aud: string
|
||||
readonly origin: string
|
||||
readonly now: number
|
||||
readonly principal?: UpgradeContext['principal']
|
||||
readonly requiredRight?: UpgradeContext['requiredRight']
|
||||
readonly activeSessionCount?: number
|
||||
readonly remoteAddrHash?: string
|
||||
}
|
||||
|
||||
export interface ReattachArgs extends UpgradeArgs {
|
||||
readonly sessionId: string
|
||||
}
|
||||
|
||||
export interface HandshakeOpts {
|
||||
readonly host?: HostFixture
|
||||
readonly now?: number
|
||||
/** MITM: pubkey the client verifies host_hello against (default = the real agentPubkey). */
|
||||
readonly verifyAgainstPubkey?: Uint8Array
|
||||
/** MITM: mutate the host_hello the client receives (tampered transcript / hostEphPub). */
|
||||
readonly tamperHostHello?: (h: HostHello) => HostHello
|
||||
}
|
||||
|
||||
export interface EstablishedSessions {
|
||||
readonly client: E2ESession
|
||||
readonly host: E2ESession
|
||||
readonly spy: RelaySpy
|
||||
readonly agentPubkey: Uint8Array
|
||||
}
|
||||
|
||||
export interface RelayWorld {
|
||||
readonly now: number
|
||||
readonly signingKey: CryptoKey
|
||||
readonly publicRaw: Uint8Array
|
||||
readonly hostA: HostFixture
|
||||
readonly hostB: HostFixture
|
||||
readonly allowedOrigins: readonly string[]
|
||||
readonly deps: EnforceDeps
|
||||
readonly audit: AuditFake
|
||||
readonly revocation: RevocationFake
|
||||
readonly buckets: TokenBucketFake
|
||||
readonly bus: RevocationBusFake
|
||||
readonly sessions: MutableSessionRegistry
|
||||
principal(accountId: string, over?: Parameters<typeof principal>[1]): ReturnType<typeof principal>
|
||||
setStepUpPolicy(policy: StepUpPolicy): void
|
||||
issueCap(o: {
|
||||
accountId: string
|
||||
host: string
|
||||
aud: string
|
||||
rights?: readonly UpgradeContext['requiredRight'][]
|
||||
ttl?: number
|
||||
now?: number
|
||||
}): Promise<CapBundle>
|
||||
craftMalformedDpop(o: {
|
||||
accountId: string
|
||||
host: string
|
||||
aud: string
|
||||
now?: number
|
||||
blobLen?: number
|
||||
}): Promise<{ raw: string; dpop: UpgradeContext['dpop'] }>
|
||||
upgrade(a: UpgradeArgs): Promise<AuthzOutcome>
|
||||
reattach(a: ReattachArgs): Promise<AuthzOutcome>
|
||||
addSession(e: { sessionId: string; hostId: string; accountId: string }): void
|
||||
establishSession(opts?: HandshakeOpts): Promise<EstablishedSessions>
|
||||
newReplaySealer(sessionId: string, host?: HostFixture): ReplaySealer
|
||||
/** Browser-side re-derivation + open of a replay frame (throws on AEAD failure / wrong epoch). */
|
||||
openReplay(sessionId: string, epoch: string, env: E2EEnvelope, host?: HostFixture): Uint8Array
|
||||
revokeToken(jti: string, exp?: number): Promise<void>
|
||||
revoke(scope: RevocationScope): Promise<void>
|
||||
}
|
||||
|
||||
export async function buildRelayWorld(now: number = DEFAULT_NOW): Promise<RelayWorld> {
|
||||
const { signingKey, publicRaw } = await setupP5SigningKey()
|
||||
resetDpopCacheForTest()
|
||||
|
||||
const hostA = await makeHostFixture('alice', 'acct-A')
|
||||
const hostB = await makeHostFixture('bob', 'acct-B')
|
||||
|
||||
const hostRecords: HostRecord[] = [
|
||||
makeHostRecord(hostA.accountId, hostA.hostId),
|
||||
makeHostRecord(hostB.accountId, hostB.hostId),
|
||||
]
|
||||
const hosts = fakeHostRegistry(hostRecords)
|
||||
const sessions = fakeSessionRegistry([])
|
||||
const revocation = fakeRevocationStore()
|
||||
const buckets = fakeTokenBucket()
|
||||
const audit = fakeAuditSink()
|
||||
const bus = fakeRevocationBus()
|
||||
const allowedOrigins = [hostA.origin, hostB.origin]
|
||||
|
||||
let stepUpPolicy: StepUpPolicy = NO_STEP_UP
|
||||
const deps: EnforceDeps = {
|
||||
hosts,
|
||||
sessions,
|
||||
revocation,
|
||||
buckets,
|
||||
audit,
|
||||
stepUpPolicyFor: () => stepUpPolicy,
|
||||
}
|
||||
|
||||
function ctxFor(a: UpgradeArgs): UpgradeContext {
|
||||
return {
|
||||
capabilityRaw: a.raw,
|
||||
originHeader: a.origin,
|
||||
expectedAud: a.aud,
|
||||
requestedHostId: a.host,
|
||||
requiredRight: a.requiredRight ?? 'attach',
|
||||
remoteAddrHash: a.remoteAddrHash ?? 'ip-hash',
|
||||
activeSessionCount: a.activeSessionCount ?? 0,
|
||||
dpop: a.dpop,
|
||||
principal: a.principal ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const replayCrypto = { deriveContentKey, sealReplayFrame }
|
||||
|
||||
return {
|
||||
now,
|
||||
signingKey,
|
||||
publicRaw,
|
||||
hostA,
|
||||
hostB,
|
||||
allowedOrigins,
|
||||
deps,
|
||||
audit,
|
||||
revocation,
|
||||
buckets,
|
||||
bus,
|
||||
sessions,
|
||||
principal,
|
||||
setStepUpPolicy(policy) {
|
||||
stepUpPolicy = policy
|
||||
},
|
||||
issueCap(o) {
|
||||
return issueCapBundle(signingKey, {
|
||||
accountId: o.accountId,
|
||||
host: o.host,
|
||||
aud: o.aud,
|
||||
rights: o.rights,
|
||||
ttl: o.ttl,
|
||||
now: o.now ?? now,
|
||||
})
|
||||
},
|
||||
craftMalformedDpop(o) {
|
||||
return craftMalformedDpopBundle(signingKey, {
|
||||
accountId: o.accountId,
|
||||
host: o.host,
|
||||
aud: o.aud,
|
||||
now: o.now ?? now,
|
||||
blobLen: o.blobLen,
|
||||
})
|
||||
},
|
||||
upgrade(a) {
|
||||
return onUpgrade(ctxFor(a), deps, allowedOrigins, a.now)
|
||||
},
|
||||
reattach(a) {
|
||||
return onReattach({ ...ctxFor(a), sessionId: a.sessionId }, deps, allowedOrigins, a.now)
|
||||
},
|
||||
addSession(e) {
|
||||
sessions.add(e)
|
||||
},
|
||||
async establishSession(opts: HandshakeOpts = {}): Promise<EstablishedSessions> {
|
||||
const h = opts.host ?? hostA
|
||||
const hsNow = opts.now ?? now
|
||||
const client = createClientHandshake({
|
||||
aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'],
|
||||
deviceAuthProofProvider: {
|
||||
proofFor: (_hostId, binding) =>
|
||||
signDeviceAuthProof(principal(h.accountId), binding, signingKey, hsNow),
|
||||
},
|
||||
verifier: {
|
||||
verify: async (pub, transcript, sig) =>
|
||||
verifyEd25519(await importEd25519PublicRaw(pub), sig, transcript),
|
||||
},
|
||||
pinStore: new MemoryDevicePinStore(),
|
||||
hostId: h.hostId,
|
||||
})
|
||||
const host = createHostHandshake({
|
||||
signer: { sign: (transcript) => signEd25519(h.signingPriv, transcript) },
|
||||
agentPubkey: h.agentPubkey,
|
||||
supported: ['xchacha20-poly1305', 'aes-256-gcm'],
|
||||
verifyDeviceProof: (proof, binding) => verifyDeviceProof(proof, binding, hsNow),
|
||||
})
|
||||
const clientHello = await client.start()
|
||||
const rawHostHello = await host.onClientHello(clientHello)
|
||||
const hostHello = opts.tamperHostHello ? opts.tamperHostHello(rawHostHello) : rawHostHello
|
||||
const clientResult = await client.onHostHello(
|
||||
hostHello,
|
||||
opts.verifyAgainstPubkey ?? h.agentPubkey,
|
||||
)
|
||||
return {
|
||||
client: createE2ESession('client', clientResult),
|
||||
host: createE2ESession('host', host.result!),
|
||||
spy: new RelaySpy(),
|
||||
agentPubkey: h.agentPubkey,
|
||||
}
|
||||
},
|
||||
newReplaySealer(sessionId, host = hostA) {
|
||||
return createReplaySealer(host.hostContentSecret, sessionId, host.replayAlg, replayCrypto)
|
||||
},
|
||||
openReplay(sessionId, epoch, env, host = hostA) {
|
||||
const key = deriveContentKey({
|
||||
hostContentSecret: host.hostContentSecret,
|
||||
sessionId,
|
||||
alg: host.replayAlg,
|
||||
epoch,
|
||||
})
|
||||
return openReplayCiphertext(key, encodeEnvelope(env))
|
||||
},
|
||||
async revokeToken(jti, exp = now + 60) {
|
||||
await revokeToken(jti, exp, revocation)
|
||||
},
|
||||
async revoke(scope) {
|
||||
await revoke(scope, revocation, hosts, bus, now)
|
||||
},
|
||||
}
|
||||
}
|
||||
12
e2e/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "relay-e2e-suite",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Cross-package end-to-end + adversarial security test harness for the rendezvous-relay service. Wires the REAL P1/P2/P4/P5/P6 exports through in-memory seams and an untrusted-relay attacker vantage; dynamically re-validates the audit findings F1-F6 plus MITM/replay/reflect/INV2/cross-tenant.",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
226
e2e/tests/adversarial-auth.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Auth (P5) attack matrix — dynamically re-validates the confirmed findings F1–F4 plus cross-tenant
|
||||
* isolation and capability single-use. Each case ASSERTS the defense holds through the REAL
|
||||
* relay-auth enforcement path. Pass an explicit `now` everywhere.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
needsStepUp,
|
||||
recordStepUp,
|
||||
verifyCapabilityToken,
|
||||
verifyDpopProof,
|
||||
finishAuthentication,
|
||||
WebAuthnError,
|
||||
type WebAuthnVerifier,
|
||||
type AuthenticationResponse,
|
||||
type WebAuthnCredential,
|
||||
} from 'relay-auth'
|
||||
import {
|
||||
buildRelayWorld,
|
||||
DEFAULT_NOW,
|
||||
STRICT_PASSKEY,
|
||||
NO_STEP_UP,
|
||||
type RelayWorld,
|
||||
} from '../harness/world.js'
|
||||
|
||||
const NOW = DEFAULT_NOW
|
||||
|
||||
describe('adversarial — auth (P5)', () => {
|
||||
let world: RelayWorld
|
||||
beforeEach(async () => {
|
||||
world = await buildRelayWorld(NOW)
|
||||
})
|
||||
|
||||
// ── F2 · mandatory step-up is fail-closed on the new-session path ──────────────────────────────
|
||||
describe('F2 (dynamic): host-driven step-up gate is fail-closed', () => {
|
||||
it('STRICT policy + principal:null → DENIED 403 step_up_required', async () => {
|
||||
const { hostA } = world
|
||||
world.setStepUpPolicy(STRICT_PASSKEY)
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const out = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
principal: null,
|
||||
now: NOW,
|
||||
})
|
||||
expect(out).toMatchObject({ ok: false, status: 403, reason: 'step_up_required' })
|
||||
expect(world.audit.events.some((e) => e.action === 'stepup' && e.outcome === 'deny')).toBe(true)
|
||||
})
|
||||
|
||||
it('required:false policy + principal:null → allowed (non-step-up host preserved)', async () => {
|
||||
const { hostA } = world
|
||||
world.setStepUpPolicy(NO_STEP_UP)
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const out = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
principal: null,
|
||||
now: NOW,
|
||||
})
|
||||
expect(out.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── F1 · step-up factor must be method-bound (a passkey requirement ≠ a TOTP step-up) ───────────
|
||||
describe('F1 (dynamic): step-up freshness is bound to the required method', () => {
|
||||
it('a fresh TOTP step-up does NOT satisfy a STRICT passkey policy; a passkey step-up DOES', async () => {
|
||||
const { hostA } = world
|
||||
world.setStepUpPolicy(STRICT_PASSKEY)
|
||||
|
||||
const totp = recordStepUp(world.principal('acct-A', { authAt: NOW }), 'totp', NOW)
|
||||
expect(needsStepUp(totp, STRICT_PASSKEY, NOW)).toBe(true)
|
||||
const capT = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const deniedTotp = await world.upgrade({
|
||||
raw: capT.raw,
|
||||
dpop: capT.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
principal: totp,
|
||||
now: NOW,
|
||||
})
|
||||
expect(deniedTotp).toMatchObject({ ok: false, status: 403, reason: 'step_up_required' })
|
||||
|
||||
const passkey = recordStepUp(world.principal('acct-A', { authAt: NOW }), 'passkey', NOW)
|
||||
expect(needsStepUp(passkey, STRICT_PASSKEY, NOW)).toBe(false)
|
||||
const capP = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const allowedPasskey = await world.upgrade({
|
||||
raw: capP.raw,
|
||||
dpop: capP.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
principal: passkey,
|
||||
now: NOW,
|
||||
})
|
||||
expect(allowedPasskey.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── F4 · a thumbprint-matching but malformed DPoP key must fail-safe (resolve false, never throw) ─
|
||||
describe('F4 (dynamic): verifyDpopProof is totally fail-safe', () => {
|
||||
it('a proof whose jwk.x is a non-32-byte blob RESOLVES false and yields a clean denied outcome', async () => {
|
||||
const { hostA } = world
|
||||
const craft = await world.craftMalformedDpop({
|
||||
accountId: hostA.accountId,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
})
|
||||
|
||||
// Direct: the verifier resolves to false — it never throws / rejects (audit-evasion + DoS fix).
|
||||
const tok = await verifyCapabilityToken(craft.raw, hostA.aud, NOW)
|
||||
await expect(verifyDpopProof(tok, craft.dpop, NOW)).resolves.toBe(false)
|
||||
|
||||
// Via the full enforcement path: a clean denied AuthzOutcome, not an exception.
|
||||
const out = await world.upgrade({
|
||||
raw: craft.raw,
|
||||
dpop: craft.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(out).toMatchObject({ ok: false, reason: 'dpop_proof_failed' })
|
||||
// Exactly one audited deny was emitted (no unhandled rejection, no audit evasion).
|
||||
expect(world.audit.events.some((e) => e.outcome === 'deny')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── F3 · WebAuthn assertion must be bound to the stored credential id ───────────────────────────
|
||||
describe('F3: an assertion whose credentialId ≠ the stored credential is rejected', () => {
|
||||
const RP_ID = 'alice.term.example.com'
|
||||
const ORIGIN = 'https://alice.term.example.com'
|
||||
const cred: WebAuthnCredential = {
|
||||
credentialId: 'cred-1',
|
||||
accountId: 'acct-A',
|
||||
publicKey: new Uint8Array([1, 2, 3]),
|
||||
signCount: 5,
|
||||
transports: ['internal'],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
// A permissive verifier that would "verify" ANY assertion — the credentialId cross-check must
|
||||
// still reject before the verifier's verdict is trusted.
|
||||
const forgivingVerifier: WebAuthnVerifier = {
|
||||
verifyRegistration: async () => ({
|
||||
verified: true,
|
||||
credentialId: 'cred-1',
|
||||
publicKey: new Uint8Array([1, 2, 3]),
|
||||
signCount: 0,
|
||||
transports: ['internal'],
|
||||
}),
|
||||
verifyAuthentication: async (_r, _c, _rp, _o, _pk, _cid, stored) => ({
|
||||
verified: true,
|
||||
newSignCount: stored + 1,
|
||||
}),
|
||||
}
|
||||
|
||||
it('rejects with WebAuthnError even though the mock verifier returns verified:true', async () => {
|
||||
const resp: AuthenticationResponse = {
|
||||
clientChallenge: 'chal',
|
||||
origin: ORIGIN,
|
||||
rpId: RP_ID,
|
||||
credentialId: 'other-cred', // ≠ cred.credentialId
|
||||
}
|
||||
await expect(
|
||||
finishAuthentication(cred, resp, 'chal', RP_ID, ORIGIN, forgivingVerifier, NOW),
|
||||
).rejects.toBeInstanceOf(WebAuthnError)
|
||||
await expect(
|
||||
finishAuthentication(cred, resp, 'chal', RP_ID, ORIGIN, forgivingVerifier, NOW),
|
||||
).rejects.toThrow('credential id mismatch')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cross-tenant · a token for account A can never reach a host owned by account B ──────────────
|
||||
describe('cross-tenant isolation (INV1)', () => {
|
||||
it('an A-account token presented at host B (owned by B) is DENIED 403', async () => {
|
||||
const { hostA, hostB } = world
|
||||
// token.host === requestedHostId (hostB) so host-scope passes; the registry then shows hostB
|
||||
// belongs to acct-B ≠ token.sub (acct-A) → the cross_tenant gate fires.
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostB.hostId, aud: hostB.aud })
|
||||
const out = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostB.hostId,
|
||||
aud: hostB.aud,
|
||||
origin: hostB.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(out).toMatchObject({ ok: false, status: 403 })
|
||||
if (!out.ok) expect(['cross_tenant', 'host_scope_mismatch']).toContain(out.reason)
|
||||
expect(world.audit.events.some((e) => e.action === 'cross-tenant-attempt' && e.outcome === 'deny')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Capability single-use · a token+jti may be upgraded exactly once ────────────────────────────
|
||||
describe('capability single-use (consumeOnce)', () => {
|
||||
it('the same token+jti upgraded twice → the second is denied token_replayed', async () => {
|
||||
const { hostA } = world
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const first = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(first.ok).toBe(true)
|
||||
|
||||
const second = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: await cap.newDpop(NOW), // fresh DPoP so the jti burn (not the DPoP cache) is what denies
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(second).toMatchObject({ ok: false, status: 403, reason: 'token_replayed' })
|
||||
})
|
||||
})
|
||||
})
|
||||
115
e2e/tests/adversarial-transport.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Transport (P4) attack matrix, run from the untrusted-relay / attacker vantage (the RelaySpy).
|
||||
* Each case ASSERTS the defense holds. Wires the REAL relay-e2e handshake/session + agent replay
|
||||
* sealer through the harness. Pass an explicit `now` everywhere.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { FingerprintMismatchError } from 'relay-e2e'
|
||||
import { buildRelayWorld, DEFAULT_NOW, type RelayWorld } from '../harness/world.js'
|
||||
|
||||
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
|
||||
const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b)
|
||||
const NOW = DEFAULT_NOW
|
||||
|
||||
function flipByte(b: Uint8Array, i = 0): Uint8Array {
|
||||
const c = b.slice()
|
||||
c[i] = (c[i]! ^ 0xff) & 0xff
|
||||
return c
|
||||
}
|
||||
|
||||
describe('adversarial — transport (P4)', () => {
|
||||
let world: RelayWorld
|
||||
beforeEach(async () => {
|
||||
world = await buildRelayWorld(NOW)
|
||||
})
|
||||
|
||||
describe('MITM: host_hello must verify against the registry key', () => {
|
||||
it('rejects when the client verifies host_hello against a WRONG agentPubkey (no keys derived)', async () => {
|
||||
// The malicious relay swaps in a key it controls; the client checks against hostB's real key.
|
||||
await expect(
|
||||
world.establishSession({ verifyAgainstPubkey: world.hostB.agentPubkey }),
|
||||
).rejects.toBeInstanceOf(FingerprintMismatchError)
|
||||
})
|
||||
|
||||
it('rejects a tampered host_hello (flipped hostEphPub breaks the transcript signature)', async () => {
|
||||
await expect(
|
||||
world.establishSession({
|
||||
tamperHostHello: (h) => ({ ...h, hostEphPub: flipByte(h.hostEphPub) }),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FingerprintMismatchError)
|
||||
})
|
||||
})
|
||||
|
||||
it('reflection: a c2h frame fed back into the client’s own open is rejected (direction split)', async () => {
|
||||
const { client } = await world.establishSession()
|
||||
const c2h = client.seal(utf8('sudo rm -rf /'))
|
||||
// Reflecting the client's own ciphertext back to it must fail: the client opens with the h2c
|
||||
// read key + h2c aad label, so the tag over the c2h direction never verifies.
|
||||
expect(() => client.open(c2h)).toThrow()
|
||||
})
|
||||
|
||||
it('replay: delivering the same valid frame twice → the second open throws (SequenceGuard)', async () => {
|
||||
const { client, host, spy } = await world.establishSession()
|
||||
const f0 = spy.forward(client.seal(utf8('whoami')))
|
||||
expect(fromUtf8(host.open(f0))).toBe('whoami')
|
||||
expect(() => host.open(f0)).toThrow() // seq 0 already consumed → strict-successor guard
|
||||
})
|
||||
|
||||
it('reorder: delivering seq 1 before seq 0 → open throws (strict successor)', async () => {
|
||||
const { client, host } = await world.establishSession()
|
||||
const f0 = client.seal(utf8('cmd-0'))
|
||||
const f1 = client.seal(utf8('cmd-1'))
|
||||
expect(() => host.open(f1)).toThrow() // expected seq 0, got 1
|
||||
// and the in-order pair still works on a fresh pair (sanity)
|
||||
expect(fromUtf8(host.open(f0))).toBe('cmd-0')
|
||||
expect(fromUtf8(host.open(f1))).toBe('cmd-1')
|
||||
})
|
||||
|
||||
it('INV2: the RelaySpy ciphertext never contains the plaintext marker', async () => {
|
||||
const { client, host, spy } = await world.establishSession()
|
||||
const marker = `TOP_SECRET_${crypto.randomUUID()}`
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const wire = spy.forward(client.seal(utf8(`${marker}#${i}`)))
|
||||
expect(fromUtf8(host.open(wire))).toBe(`${marker}#${i}`)
|
||||
}
|
||||
expect(spy.contains(marker)).toBe(false)
|
||||
expect(spy.captured.length).toBe(3)
|
||||
})
|
||||
|
||||
describe('F6 (dynamic): recoverable K_content must not reuse (key, nonce) across sealer generations', () => {
|
||||
it('two generations for the same (secret, sessionId) get different epochs → different keys at seq 0', () => {
|
||||
const sessionId = 'sess-restart-1'
|
||||
const gen1 = world.newReplaySealer(sessionId)
|
||||
const gen2 = world.newReplaySealer(sessionId) // simulates an agent restart / re-attach
|
||||
|
||||
expect(gen2.epoch).not.toBe(gen1.epoch)
|
||||
|
||||
const pt = utf8('IDENTICAL PLAINTEXT AT SEQ 0')
|
||||
const e1 = gen1.seal(pt)
|
||||
const e2 = gen2.seal(pt)
|
||||
|
||||
// Same deterministic nonce (seq 0) …
|
||||
expect(e1.seq).toBe(0n)
|
||||
expect(e2.seq).toBe(0n)
|
||||
expect(Buffer.from(e1.nonce).equals(Buffer.from(e2.nonce))).toBe(true)
|
||||
// … yet a FRESH key per generation ⇒ ciphertext + tag differ (no (key, nonce) reuse).
|
||||
expect(Buffer.from(e1.ciphertext).equals(Buffer.from(e2.ciphertext))).toBe(false)
|
||||
expect(Buffer.from(e1.tag).equals(Buffer.from(e2.tag))).toBe(false)
|
||||
|
||||
// Cross-generation open fails: gen1's epoch-derived key cannot open gen2's frame.
|
||||
expect(() => world.openReplay(sessionId, gen1.epoch, e2)).toThrow()
|
||||
|
||||
// Recoverability WITHIN a generation is preserved (same epoch ⇒ same key).
|
||||
expect(fromUtf8(world.openReplay(sessionId, gen1.epoch, e1))).toBe('IDENTICAL PLAINTEXT AT SEQ 0')
|
||||
expect(fromUtf8(world.openReplay(sessionId, gen2.epoch, e2))).toBe('IDENTICAL PLAINTEXT AT SEQ 0')
|
||||
})
|
||||
|
||||
it('the replay ciphertext itself never contains the plaintext marker (INV2 on the replay path)', () => {
|
||||
const sealer = world.newReplaySealer('sess-inv2')
|
||||
const marker = 'REPLAY_MARKER_ABC'
|
||||
const env = sealer.seal(utf8(marker))
|
||||
expect(Buffer.from(env.ciphertext).toString('latin1')).not.toContain(marker)
|
||||
expect(Buffer.from(env.ciphertext).toString('utf8')).not.toContain(marker)
|
||||
})
|
||||
})
|
||||
})
|
||||
99
e2e/tests/flow.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Happy-path full flow, end-to-end across P5 (auth) + P4 (E2E crypto) + P2 (replay), asserting each
|
||||
* stage. Everything runs through the REAL package exports; only registries/buckets/sockets are faked.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { buildRelayWorld, DEFAULT_NOW, type RelayWorld } from '../harness/world.js'
|
||||
|
||||
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
|
||||
const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b)
|
||||
const NOW = DEFAULT_NOW
|
||||
|
||||
describe('relay flow (happy path)', () => {
|
||||
let world: RelayWorld
|
||||
beforeEach(async () => {
|
||||
world = await buildRelayWorld(NOW)
|
||||
})
|
||||
|
||||
it('issueCap → upgrade allows (origin ok, DPoP bound, deny-by-default satisfied)', async () => {
|
||||
const { hostA } = world
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const out = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(out.ok).toBe(true)
|
||||
expect(world.audit.events.at(-1)).toMatchObject({ outcome: 'allow', action: 'attach' })
|
||||
})
|
||||
|
||||
it('handshake establishes MATCHING session keys on both sides (client↔host round-trip)', async () => {
|
||||
const { client, host } = await world.establishSession()
|
||||
// Matching keys ⇒ bidirectional plaintext round-trips through the direction split.
|
||||
const c2h = host.open(client.seal(utf8('ls -la')))
|
||||
expect(fromUtf8(c2h)).toBe('ls -la')
|
||||
const h2c = client.open(host.seal(utf8('total 0')))
|
||||
expect(fromUtf8(h2c)).toBe('total 0')
|
||||
})
|
||||
|
||||
it('seals client→host and host→client through the RelaySpy; the RelaySpy never sees plaintext (INV2)', async () => {
|
||||
const { client, host, spy } = await world.establishSession()
|
||||
const marker = `PLAINTEXT_${crypto.randomUUID()}`
|
||||
|
||||
const up = spy.forward(client.seal(utf8(marker)))
|
||||
expect(fromUtf8(host.open(up))).toBe(marker)
|
||||
const down = spy.forward(host.seal(utf8(`reply_${marker}`)))
|
||||
expect(fromUtf8(client.open(down))).toBe(`reply_${marker}`)
|
||||
|
||||
expect(spy.contains(marker)).toBe(false)
|
||||
expect(spy.captured.length).toBe(2)
|
||||
})
|
||||
|
||||
it('reattach to an own-account session is allowed', async () => {
|
||||
const { hostA } = world
|
||||
const sessionId = crypto.randomUUID()
|
||||
world.addSession({ sessionId, hostId: hostA.hostId, accountId: hostA.accountId })
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const out = await world.reattach({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
sessionId,
|
||||
now: NOW,
|
||||
})
|
||||
expect(out.ok).toBe(true)
|
||||
expect(world.audit.events.at(-1)).toMatchObject({ outcome: 'allow', action: 'reattach' })
|
||||
})
|
||||
|
||||
it('revokeToken then re-presenting the same jti (fresh DPoP) is denied', async () => {
|
||||
const { hostA } = world
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const first = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(first.ok).toBe(true)
|
||||
if (!first.ok) return
|
||||
|
||||
await world.revokeToken(first.jti)
|
||||
|
||||
const second = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: await cap.newDpop(NOW), // fresh DPoP jti so we reach the revocation gate, not the DPoP cache
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(second).toMatchObject({ ok: false, status: 403, reason: 'token_revoked' })
|
||||
})
|
||||
})
|
||||
21
e2e/tests/smoke.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
// Cross-package resolution smoke test: prove the harness can bare-import the REAL exports of
|
||||
// every relay package (each resolves via its own node_modules/relay-contracts symlink transitively).
|
||||
import { issueCapabilityToken, onUpgrade, needsStepUp } from 'relay-auth'
|
||||
import { createClientHandshake, createHostHandshake, createE2ESession, deriveContentKey } from 'relay-e2e'
|
||||
import { createReplaySealer } from 'agent'
|
||||
import { CapabilityTokenSchema } from 'relay-contracts'
|
||||
|
||||
describe('cross-package import smoke', () => {
|
||||
it('resolves the real exports of relay-auth / relay-e2e / agent / relay-contracts', () => {
|
||||
expect(typeof issueCapabilityToken).toBe('function')
|
||||
expect(typeof onUpgrade).toBe('function')
|
||||
expect(typeof needsStepUp).toBe('function')
|
||||
expect(typeof createClientHandshake).toBe('function')
|
||||
expect(typeof createHostHandshake).toBe('function')
|
||||
expect(typeof createE2ESession).toBe('function')
|
||||
expect(typeof deriveContentKey).toBe('function')
|
||||
expect(typeof createReplaySealer).toBe('function')
|
||||
expect(CapabilityTokenSchema).toBeDefined()
|
||||
})
|
||||
})
|
||||
15
e2e/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["harness/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
8
e2e/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
10
ios/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Generated by xcodegen — regenerate with: cd ios && xcodegen generate
|
||||
*.xcodeproj
|
||||
# XcodeGen-generated Info.plist (keys are declared in project.yml)
|
||||
App/WebTerm/Resources/Info.plist
|
||||
|
||||
# Xcode / SwiftPM build state
|
||||
DerivedData/
|
||||
xcuserdata/
|
||||
.build/
|
||||
.swiftpm/
|
||||
113
ios/App/WebTerm/Components/AwayDigestView.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import SessionCore
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-14 · "What happened while I was away" banner rendered above the
|
||||
/// terminal after a reconnect. Render-only: `GateViewModel` owns the state —
|
||||
/// an ALL-ZERO digest never reaches this view (the VM keeps `digest` nil),
|
||||
/// the collapsed row auto-fades after `Tunables.digestFadeDelay`, and manual
|
||||
/// expansion (which cancels the fade) reveals the recent entries.
|
||||
struct AwayDigestView: View {
|
||||
let digest: AwayDigest
|
||||
let isExpanded: Bool
|
||||
let onExpand: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
private enum Metrics {
|
||||
/// Recent entries shown when expanded (newest kept — the engine's
|
||||
/// digest is uncapped, the banner must not be).
|
||||
static let maxRecentRows = 12
|
||||
}
|
||||
|
||||
private enum Copy {
|
||||
static let prefix = "离开期间:"
|
||||
static let separator = " · "
|
||||
static let expandTitle = "展开明细"
|
||||
static let dismissTitle = "关闭摘要"
|
||||
}
|
||||
|
||||
/// Summary line: non-zero parts only, joined web-statusline style. A
|
||||
/// non-empty digest whose counted signals are all zero (e.g. only `user`
|
||||
/// events) falls back to a plain activity count so the row is never blank.
|
||||
static func summary(for digest: AwayDigest) -> String {
|
||||
let parts = [
|
||||
digest.toolRuns > 0 ? "工具调用 \(digest.toolRuns) 次" : nil,
|
||||
digest.waitingCount > 0 ? "等待审批 \(digest.waitingCount) 次" : nil,
|
||||
digest.sawDone ? "已完成" : nil,
|
||||
digest.sawStuck ? "曾卡住" : nil,
|
||||
].compactMap { $0 }
|
||||
guard !parts.isEmpty else {
|
||||
return "\(Copy.prefix)活动 \(digest.recent.count) 条"
|
||||
}
|
||||
return Copy.prefix + parts.joined(separator: Copy.separator)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.sm8) {
|
||||
summaryRow
|
||||
if isExpanded {
|
||||
recentRows
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, DS.Space.md12)
|
||||
.padding(.vertical, DS.Space.sm8)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||||
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private var summaryRow: some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
Text(Self.summary(for: digest))
|
||||
.font(DS.Typography.callout.weight(.medium))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
if !isExpanded {
|
||||
Button(Copy.expandTitle, systemImage: "chevron.down", action: onExpand)
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
Button(Copy.dismissTitle, systemImage: "xmark", action: onDismiss)
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.tint(DS.Palette.accent)
|
||||
.font(DS.Typography.callout)
|
||||
}
|
||||
|
||||
/// Newest `maxRecentRows` entries, oldest→newest. Server text (`label`) is
|
||||
/// untrusted display input — rendered as plain Text only.
|
||||
private var recentRows: some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
ForEach(
|
||||
Array(digest.recent.suffix(Metrics.maxRecentRows).enumerated()),
|
||||
id: \.offset
|
||||
) { _, event in
|
||||
recentRow(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recentRow(_ event: TimelineEvent) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
Text(Self.time(of: event))
|
||||
.font(DS.Typography.metaMono)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
Text(event.label)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private static func time(of event: TimelineEvent) -> String {
|
||||
let millisecondsPerSecond = 1_000.0
|
||||
let date = Date(timeIntervalSince1970: Double(event.at) / millisecondsPerSecond)
|
||||
return date.formatted(date: .omitted, time: .shortened)
|
||||
}
|
||||
}
|
||||
108
ios/App/WebTerm/Components/GateBanner.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import SessionCore
|
||||
import SwiftUI
|
||||
|
||||
/// Copy + wire pairing for one gate button — the SINGLE label source for both
|
||||
/// gate surfaces (`GateBanner`, `PlanGateSheet`). Labels mirror
|
||||
/// public/tabs.ts:334-350 byte-for-byte; the affordance→ClientMessage mapping
|
||||
/// itself is frozen in SessionCore (`GateState.Affordance.clientMessage`).
|
||||
struct GateChoiceSpec: Equatable {
|
||||
let label: String
|
||||
let affordance: GateState.Affordance
|
||||
|
||||
/// Ordered button set for `gate` (tool → two, plan → three), driven by the
|
||||
/// frozen `GateState.affordances` list.
|
||||
static func specs(for gate: GateState) -> [GateChoiceSpec] {
|
||||
gate.affordances.map { GateChoiceSpec(label: label(for: $0), affordance: $0) }
|
||||
}
|
||||
|
||||
static func label(for affordance: GateState.Affordance) -> String {
|
||||
switch affordance {
|
||||
case .approveAuto: return "✓ Approve + Auto"
|
||||
case .approveReview: return "✓ Approve + Review"
|
||||
case .keepPlanning: return "✎ Keep Planning"
|
||||
case .approve: return "✓ Approve"
|
||||
case .reject: return "✗ Reject"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// T-iOS-14 · Two-button banner for an ordinary TOOL gate (plan gates get the
|
||||
/// three-way `PlanGateSheet`). Render-only: state lives in `GateViewModel`.
|
||||
/// Every tap reports the affordance PLUS the epoch of the gate THIS banner
|
||||
/// rendered — the VM drops stale taps (first-line guard); wiring into
|
||||
/// TerminalScreen is T-iOS-15.
|
||||
struct GateBanner: View {
|
||||
let gate: GateState
|
||||
let onDecide: (GateState.Affordance, Int) -> Void
|
||||
|
||||
private enum Copy {
|
||||
/// Chinese lead-in above the (web-mirrored) English tool line — makes the
|
||||
/// "this needs me" intent unmissable at a glance.
|
||||
static let heading = "需要你的确认"
|
||||
}
|
||||
|
||||
private enum Metrics {
|
||||
static let messageLineLimit = 2
|
||||
}
|
||||
|
||||
/// Banner headline, mirror of public/tabs.ts:329:
|
||||
/// `Claude wants to use ${pendingTool ?? 'a tool'}`. `detail` is
|
||||
/// server-supplied display text (untrusted) — rendered as plain Text only.
|
||||
static func message(for gate: GateState) -> String {
|
||||
"Claude wants to use \(gate.detail ?? "a tool")"
|
||||
}
|
||||
|
||||
/// A prominent Card (the gate is THE core action — approve/reject a shell
|
||||
/// command). An amber "needs me" badge heads it, the tool line reads big and
|
||||
/// clear, and full-width DS buttons give unmissable tap targets.
|
||||
var body: some View {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
header
|
||||
Text(Self.message(for: gate))
|
||||
.font(DS.Typography.body.weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(Metrics.messageLineLimit)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
buttonRow
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
StatusBadge(status: .pendingApproval)
|
||||
Text(Copy.heading)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonRow: some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
|
||||
decisionButton(spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func decisionButton(_ spec: GateChoiceSpec) -> some View {
|
||||
Button(spec.label) {
|
||||
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: kind(for: spec.affordance)))
|
||||
.accessibilityIdentifier("gate.decision.\(spec.affordance)")
|
||||
}
|
||||
|
||||
/// Approve reads as the accent-filled primary, Reject as the destructive
|
||||
/// red; Keep Planning (never on a tool gate, but mapped for completeness)
|
||||
/// is the tinted secondary.
|
||||
private func kind(for affordance: GateState.Affordance) -> DSButtonStyle.Kind {
|
||||
switch affordance {
|
||||
case .approve, .approveAuto, .approveReview: return .primary
|
||||
case .reject: return .destructive
|
||||
case .keepPlanning: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
267
ios/App/WebTerm/Components/KeyBar.swift
Normal file
@@ -0,0 +1,267 @@
|
||||
import SessionCore
|
||||
import UIKit
|
||||
|
||||
/// T-iOS-11 · Mobile key bar + hardware key mapping, mirroring
|
||||
/// `public/keybar.ts` — the Claude Code keys a phone keyboard can't produce,
|
||||
/// most-used first. EVERY label→bytes lookup resolves through
|
||||
/// `KeyByteMap` (SessionCore) — no byte literal lives in this file.
|
||||
///
|
||||
/// The bar is installed as the terminal's `inputAccessoryView` and sends via
|
||||
/// the ViewModel/engine DIRECTLY (bypassing SwiftTerm's text path so a tap
|
||||
/// never pops or fights the soft keyboard — same reason the web bar bypasses
|
||||
/// xterm and calls `ws.send`).
|
||||
|
||||
// MARK: - Visibility policy (T-iPad-3)
|
||||
|
||||
/// Pure predicate deciding whether the soft-keyboard KeyBar
|
||||
/// (`inputAccessoryView`) should show — THE single decision point (mirrors
|
||||
/// `PrivacyShadePolicy` / `LayoutPolicy`, no scattered checks in views):
|
||||
/// - a hardware keyboard makes the on-screen key bar redundant → default hidden;
|
||||
/// - no hardware keyboard → shown (unchanged iPhone behavior — zero regression);
|
||||
/// - an explicit user toggle (`userOverride`) always wins over the auto default.
|
||||
///
|
||||
/// Hardware presence is injected (`GCKeyboard.coalesced != nil` at the call
|
||||
/// site) so this stays a fast, device-agnostic unit (`KeyBarVisibilityTests`).
|
||||
enum KeyBarVisibility {
|
||||
/// - Parameters:
|
||||
/// - hardwareKeyboardPresent: injected `GCKeyboard.coalesced != nil`.
|
||||
/// - userOverride: nil = follow the auto default; true/false = the user
|
||||
/// explicitly forced show/hide (wins over the hardware-driven default).
|
||||
static func isVisible(hardwareKeyboardPresent: Bool, userOverride: Bool?) -> Bool {
|
||||
if let userOverride { return userOverride }
|
||||
return !hardwareKeyboardPresent
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout data (mirror of KEYBAR_BUTTONS)
|
||||
|
||||
/// One key-bar button: glyph, short function caption (shown under the glyph,
|
||||
/// self-documenting on touch) and the full title for accessibility.
|
||||
struct KeyBarButtonSpec: Equatable, Sendable {
|
||||
let key: KeyByteMap.Key
|
||||
let label: String
|
||||
let caption: String
|
||||
let title: String
|
||||
let isPrimary: Bool
|
||||
|
||||
init(key: KeyByteMap.Key, label: String, caption: String, title: String,
|
||||
isPrimary: Bool = false) {
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.caption = caption
|
||||
self.title = title
|
||||
self.isPrimary = isPrimary
|
||||
}
|
||||
}
|
||||
|
||||
/// Button order/copy transcribed from `public/keybar.ts` `KEYBAR_BUTTONS`
|
||||
/// (most-used Claude Code keys first). The 🎤 voice button is P2 (T-iOS-31).
|
||||
enum KeyBarLayout {
|
||||
static let buttons: [KeyBarButtonSpec] = [
|
||||
KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断",
|
||||
title: "Esc — interrupt Claude / dismiss", isPrimary: true),
|
||||
KeyBarButtonSpec(key: .escEsc, label: "Esc²", caption: "回溯",
|
||||
title: "Esc Esc — rewind / clear draft"),
|
||||
KeyBarButtonSpec(key: .shiftTab, label: "⇧Tab", caption: "模式",
|
||||
title: "Shift+Tab — cycle plan / auto-accept mode"),
|
||||
KeyBarButtonSpec(key: .arrowUp, label: "↑", caption: "上一个",
|
||||
title: "Up — previous option / history"),
|
||||
KeyBarButtonSpec(key: .arrowDown, label: "↓", caption: "下一个",
|
||||
title: "Down — next option / history"),
|
||||
KeyBarButtonSpec(key: .enter, label: "⏎", caption: "确认",
|
||||
title: "Enter — confirm"),
|
||||
KeyBarButtonSpec(key: .ctrlC, label: "^C", caption: "取消",
|
||||
title: "Ctrl+C — cancel / quit"),
|
||||
KeyBarButtonSpec(key: .ctrlR, label: "^R", caption: "搜历史",
|
||||
title: "Ctrl+R — reverse-search command history"),
|
||||
KeyBarButtonSpec(key: .ctrlO, label: "^O", caption: "详情",
|
||||
title: "Ctrl+O — expand transcript / tool detail"),
|
||||
KeyBarButtonSpec(key: .ctrlL, label: "^L", caption: "重绘",
|
||||
title: "Ctrl+L — redraw screen"),
|
||||
KeyBarButtonSpec(key: .ctrlT, label: "^T", caption: "任务",
|
||||
title: "Ctrl+T — toggle task list"),
|
||||
KeyBarButtonSpec(key: .ctrlB, label: "^B", caption: "后台",
|
||||
title: "Ctrl+B — background running task"),
|
||||
KeyBarButtonSpec(key: .ctrlD, label: "^D", caption: "退出",
|
||||
title: "Ctrl+D — exit session (EOF)"),
|
||||
KeyBarButtonSpec(key: .tab, label: "Tab", caption: "补全",
|
||||
title: "Tab — complete / toggle"),
|
||||
KeyBarButtonSpec(key: .arrowLeft, label: "←", caption: "左移",
|
||||
title: "Left — move cursor left"),
|
||||
KeyBarButtonSpec(key: .arrowRight, label: "→", caption: "右移",
|
||||
title: "Right — move cursor right"),
|
||||
KeyBarButtonSpec(key: .slash, label: "/", caption: "命令",
|
||||
title: "Slash — command launcher"),
|
||||
]
|
||||
}
|
||||
|
||||
/// 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
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - KeyBarView (inputAccessoryView)
|
||||
|
||||
/// Horizontally scrolling key bar, installed as the terminal's
|
||||
/// `inputAccessoryView`. Taps call `onKey` — the screen routes them to
|
||||
/// `TerminalViewModel.send(key:)`, which resolves bytes via `KeyByteMap`.
|
||||
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).
|
||||
private(set) var keyButtons: [UIButton] = []
|
||||
|
||||
init() {
|
||||
super.init(
|
||||
frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight),
|
||||
inputViewStyle: .keyboard
|
||||
)
|
||||
allowsSelfSizing = true
|
||||
buildBar()
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "KeyBarView is code-built only")
|
||||
required init?(coder: NSCoder) {
|
||||
return nil
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: KeyBarMetrics.barHeight)
|
||||
}
|
||||
|
||||
private func buildBar() {
|
||||
let stack = UIStackView()
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = KeyBarMetrics.buttonSpacing
|
||||
stack.alignment = .center
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
for spec in KeyBarLayout.buttons {
|
||||
let button = makeButton(for: spec)
|
||||
keyButtons = keyButtons + [button]
|
||||
stack.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
let scroll = UIScrollView()
|
||||
scroll.showsHorizontalScrollIndicator = false
|
||||
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
scroll.addSubview(stack)
|
||||
addSubview(scroll)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scroll.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
scroll.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
scroll.topAnchor.constraint(equalTo: topAnchor),
|
||||
scroll.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
stack.leadingAnchor.constraint(
|
||||
equalTo: scroll.contentLayoutGuide.leadingAnchor,
|
||||
constant: KeyBarMetrics.contentInset
|
||||
),
|
||||
stack.trailingAnchor.constraint(
|
||||
equalTo: scroll.contentLayoutGuide.trailingAnchor,
|
||||
constant: -KeyBarMetrics.contentInset
|
||||
),
|
||||
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
|
||||
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
|
||||
|
||||
/// Hardware-keyboard chords routed through the SAME `KeyByteMap` mapping
|
||||
/// (plan §7 T-iOS-11). Scope decision (documented deviation): only Esc,
|
||||
/// Shift+Tab and the Ctrl chords are registered —
|
||||
/// - arrows stay SwiftTerm-native: a fixed CSI override would break
|
||||
/// application-cursor-keys mode (DECCKM: vim/htop expect `ESC O A`);
|
||||
/// - Enter/Tab/"/" are ordinary typing SwiftTerm already delivers;
|
||||
/// - Esc·Esc is simply two Esc presses.
|
||||
/// UIKeyCommand interception replaces (never duplicates) the responder's own
|
||||
/// press handling, so each chord is sent exactly once.
|
||||
enum HardwareKeyCommands {
|
||||
struct Chord: Equatable {
|
||||
let key: KeyByteMap.Key
|
||||
let input: String
|
||||
let modifiers: UIKeyModifierFlags
|
||||
}
|
||||
|
||||
static let chords: [Chord] = [
|
||||
Chord(key: .esc, input: UIKeyCommand.inputEscape, modifiers: []),
|
||||
Chord(key: .shiftTab, input: "\t", modifiers: .shift),
|
||||
Chord(key: .ctrlC, input: "c", modifiers: .control),
|
||||
Chord(key: .ctrlR, input: "r", modifiers: .control),
|
||||
Chord(key: .ctrlO, input: "o", modifiers: .control),
|
||||
Chord(key: .ctrlL, input: "l", modifiers: .control),
|
||||
Chord(key: .ctrlT, input: "t", modifiers: .control),
|
||||
Chord(key: .ctrlB, input: "b", modifiers: .control),
|
||||
Chord(key: .ctrlD, input: "d", modifiers: .control),
|
||||
]
|
||||
|
||||
/// One prioritized `UIKeyCommand` per chord, all firing `action`.
|
||||
/// (`UIKeyCommand` is `@MainActor` in the SDK, hence the isolation.)
|
||||
@MainActor
|
||||
static func build(action: Selector) -> [UIKeyCommand] {
|
||||
chords.map { chord in
|
||||
let command = UIKeyCommand(
|
||||
action: action, input: chord.input, modifierFlags: chord.modifiers
|
||||
)
|
||||
command.wantsPriorityOverSystemBehavior = true
|
||||
return command
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverse lookup for the command handler: which chord fired.
|
||||
@MainActor
|
||||
static func key(matching command: UIKeyCommand) -> KeyByteMap.Key? {
|
||||
chords.first {
|
||||
$0.input == command.input && $0.modifiers == command.modifierFlags
|
||||
}?.key
|
||||
}
|
||||
}
|
||||
118
ios/App/WebTerm/Components/PlanGateSheet.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
import SessionCore
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-14 · Three-way sheet for a PLAN gate (B4): Approve+Auto /
|
||||
/// Approve+Review / Keep Planning — mapping mirrors public/tabs.ts:345-347
|
||||
/// exactly via `GateState.Affordance.clientMessage` (acceptEdits / default /
|
||||
/// reject; never raw `auto`, and NO allowAutoMode gating — plan §7 T-iOS-14).
|
||||
///
|
||||
/// Render-only: state lives in `GateViewModel`; every tap reports the
|
||||
/// affordance PLUS the epoch of the gate THIS sheet rendered (stale-tap
|
||||
/// first-line guard). Presentation (`.sheet`) wiring is T-iOS-15.
|
||||
struct PlanGateSheet: View {
|
||||
/// Mirror of public/tabs.ts:326 plan-gate headline.
|
||||
static let title = "Claude finished planning — how should it proceed?"
|
||||
|
||||
let gate: GateState
|
||||
let onDecide: (GateState.Affordance, Int) -> Void
|
||||
|
||||
private enum Copy {
|
||||
/// Chinese lead-in above the (web-mirrored) English question.
|
||||
static let heading = "计划已就绪"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.lg16) {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
Text(Copy.heading)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
Text(Self.title)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
ScrollView {
|
||||
VStack(spacing: DS.Space.sm8) {
|
||||
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
|
||||
choiceButton(spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
}
|
||||
.padding(DS.Space.xl20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.presentationDetents([.medium])
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
/// One full-width choice Card: semantic icon + label + the plain-language
|
||||
/// caption of what the wire mode actually does + a disclosure chevron. The
|
||||
/// whole card is the (large) tap target.
|
||||
private func choiceButton(_ spec: GateChoiceSpec) -> some View {
|
||||
Button {
|
||||
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
|
||||
} label: {
|
||||
Card(padding: DS.Space.md12) {
|
||||
HStack(spacing: DS.Space.md12) {
|
||||
Image(systemName: symbol(for: spec.affordance))
|
||||
.font(DS.Typography.title)
|
||||
.foregroundStyle(iconColor(for: spec.affordance))
|
||||
.frame(width: DS.Space.xxl24)
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
Text(spec.label)
|
||||
.font(DS.Typography.body.weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(Self.caption(for: spec.affordance))
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("plan.decision.\(spec.affordance)")
|
||||
}
|
||||
|
||||
/// Secondary line under each choice: what the wire mode actually does.
|
||||
static func caption(for affordance: GateState.Affordance) -> String {
|
||||
switch affordance {
|
||||
case .approveAuto: return "执行计划,编辑自动接受(acceptEdits)"
|
||||
case .approveReview: return "执行计划,每次编辑需确认(default)"
|
||||
case .keepPlanning: return "继续规划,不执行"
|
||||
case .approve: return "允许本次工具调用"
|
||||
case .reject: return "拒绝本次工具调用"
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinct SF Symbol per choice (shape carries meaning, not just color):
|
||||
/// Auto = a bolt (fast, hands-off), Review = a checkmark (approve, confirm
|
||||
/// each edit), Keep Planning = a pencil (stay drafting).
|
||||
private func symbol(for affordance: GateState.Affordance) -> String {
|
||||
switch affordance {
|
||||
case .approveAuto: return "bolt.fill"
|
||||
case .approveReview: return "checkmark.circle"
|
||||
case .keepPlanning: return "pencil.and.outline"
|
||||
case .approve: return "checkmark.circle"
|
||||
case .reject: return "xmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
/// Both approve paths wear the accent (this is the encouraged action); Keep
|
||||
/// Planning is a quiet secondary; a bare reject (tool-shaped, unused here)
|
||||
/// stays the stuck red.
|
||||
private func iconColor(for affordance: GateState.Affordance) -> Color {
|
||||
switch affordance {
|
||||
case .approveAuto, .approveReview, .approve: return DS.Palette.accent
|
||||
case .keepPlanning: return DS.Palette.textSecondary
|
||||
case .reject: return DS.Palette.statusStuck
|
||||
}
|
||||
}
|
||||
}
|
||||
255
ios/App/WebTerm/Components/QuickReply.swift
Normal file
@@ -0,0 +1,255 @@
|
||||
import SessionCore
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-25 · Quick-reply chip row + 常用语面板 (plan §7; behavior mirror of
|
||||
/// `public/quick-reply.ts`, data layer in `QuickReplyStore.swift`).
|
||||
///
|
||||
/// Visibility decision (documented): the plan step says "waiting 状态才浮出".
|
||||
/// On the iOS `SessionEvent` stream the raw `ClaudeStatus` is folded away by
|
||||
/// the engine (`SessionEngine.applyGateFrame` keeps only pending/gate), so the
|
||||
/// stream's ONLY waiting projection is the HELD GATE — a `status:'waiting',
|
||||
/// pending:true` frame surfaces as `.gate(GateState)` and the lift as
|
||||
/// `.gate(nil)`. Chips therefore float while a gate is held AND the terminal
|
||||
/// is live (not exited/failed). Waiting-without-pending (a Notification
|
||||
/// permission_prompt with no held relay) never reaches the stream — accepted
|
||||
/// limitation; the observation point stays the SAME fan-out branches the
|
||||
/// Gate/Terminal VMs already consume (no new SessionCore surface, no extra
|
||||
/// fan-out branch needed).
|
||||
///
|
||||
/// Send path: a chip tap goes through `TerminalViewModel.sendInput` — the one
|
||||
/// ordered send pump — so a rapid double-tap yields two frames in tap order,
|
||||
/// never interleaved, and the read-only guard drops taps on a dead terminal.
|
||||
struct QuickReplyBar: View {
|
||||
enum Copy {
|
||||
static let managePhrases = "管理常用语"
|
||||
}
|
||||
|
||||
let terminalViewModel: TerminalViewModel
|
||||
let gateViewModel: GateViewModel
|
||||
let store: QuickReplyStore
|
||||
|
||||
@State private var isPanelPresented = false
|
||||
|
||||
/// Pure visibility rule (see type doc): held gate = the stream's waiting
|
||||
/// signal; read-only (exited/failed) always hides.
|
||||
static func isVisible(gate: GateState?, isReadOnly: Bool) -> Bool {
|
||||
gate != nil && !isReadOnly
|
||||
}
|
||||
|
||||
/// The production tap path (also exercised directly by tests): compose the
|
||||
/// payload via `QuickReplyPalette` and hand it to the VM's ordered pump.
|
||||
static func send(_ chip: QuickReplyChip, through viewModel: TerminalViewModel) {
|
||||
viewModel.sendInput(QuickReplyPalette.payload(for: chip))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if Self.isVisible(
|
||||
gate: gateViewModel.currentGate,
|
||||
isReadOnly: terminalViewModel.isReadOnly
|
||||
) {
|
||||
chipRow
|
||||
.sheet(isPresented: $isPanelPresented) {
|
||||
QuickReplyPanel(store: store)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chipRow: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
ForEach(store.allChips) { chip in
|
||||
chipButton(chip)
|
||||
}
|
||||
managePhrasesButton
|
||||
}
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.padding(.vertical, DS.Space.xs4)
|
||||
}
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
.overlay(
|
||||
Capsule().strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
private func chipButton(_ chip: QuickReplyChip) -> some View {
|
||||
Button {
|
||||
Self.send(chip, through: terminalViewModel)
|
||||
} label: {
|
||||
// Text(verbatim:) — user/label strings render as inert text, never
|
||||
// LocalizedStringKey/Markdown (SEC-L3 mirror; T-iOS-23 convention).
|
||||
Text(verbatim: chip.label)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, DS.Space.md12)
|
||||
.frame(minHeight: DS.Layout.minHitTarget)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var managePhrasesButton: some View {
|
||||
Button {
|
||||
isPanelPresented = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(DS.Typography.callout.weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.frame(width: DS.Layout.minHitTarget, height: DS.Layout.minHitTarget)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Copy.managePhrases)
|
||||
}
|
||||
}
|
||||
|
||||
/// 常用语面板: user-phrase CRUD + drag reorder (增删改序), presented from the
|
||||
/// chip row's `+`. The add/edit form mirrors the web inline editor: text,
|
||||
/// optional label (defaults to text) and an append-Enter toggle that starts
|
||||
/// on. Tapping an existing row loads it into the form for editing (改).
|
||||
struct QuickReplyPanel: View {
|
||||
enum Copy {
|
||||
static let title = "常用语"
|
||||
static let addSection = "添加新常用语"
|
||||
static let customSection = "自定义常用语"
|
||||
static let textPlaceholder = "要发送的文本"
|
||||
static let labelPlaceholder = "标签(可选,默认同文本)"
|
||||
static let appendEnterToggle = "发送后自动回车"
|
||||
static let addButton = "添加"
|
||||
static let saveButton = "保存修改"
|
||||
static let cancelEditButton = "取消编辑"
|
||||
static let doneButton = "完成"
|
||||
static let emptyState = "还没有自定义常用语"
|
||||
/// Payload preview suffix for append-Enter chips (web: `${text}⏎`).
|
||||
static let enterSuffixSymbol = "⏎"
|
||||
}
|
||||
|
||||
let store: QuickReplyStore
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var draftText = ""
|
||||
@State private var draftLabel = ""
|
||||
/// Mirrors the web editor default: `enterCheck.checked = true`.
|
||||
@State private var draftAppendEnter = true
|
||||
/// Non-nil while the form edits an existing chip instead of adding.
|
||||
@State private var editingChipId: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
customSection
|
||||
editorSection
|
||||
}
|
||||
.navigationTitle(Copy.title)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { EditButton() }
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(Copy.doneButton) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom phrases (delete / reorder / tap-to-edit)
|
||||
|
||||
@ViewBuilder private var customSection: some View {
|
||||
Section(Copy.customSection) {
|
||||
if store.userChips.isEmpty {
|
||||
Text(Copy.emptyState)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
} else {
|
||||
ForEach(store.userChips) { chip in
|
||||
Button {
|
||||
beginEditing(chip)
|
||||
} label: {
|
||||
chipRow(chip)
|
||||
}
|
||||
.tint(DS.Palette.textPrimary)
|
||||
}
|
||||
.onDelete { offsets in
|
||||
// Resolve ids FIRST — removing while iterating offsets
|
||||
// would shift indices under us.
|
||||
let ids = offsets.map { store.userChips[$0].id }
|
||||
for id in ids {
|
||||
store.removeChip(id: id)
|
||||
}
|
||||
}
|
||||
.onMove { fromOffsets, toOffset in
|
||||
store.moveChips(fromOffsets: fromOffsets, toOffset: toOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func chipRow(_ chip: QuickReplyChip) -> some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
// Text(verbatim:) — stored strings are boundary data (SEC-L3 mirror).
|
||||
Text(verbatim: chip.label)
|
||||
.font(DS.Typography.body)
|
||||
.lineLimit(1)
|
||||
Text(verbatim: chip.appendEnter
|
||||
? chip.text + Copy.enterSuffixSymbol
|
||||
: chip.text)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add / edit form
|
||||
|
||||
@ViewBuilder private var editorSection: some View {
|
||||
Section(Copy.addSection) {
|
||||
TextField(Copy.textPlaceholder, text: $draftText)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
TextField(Copy.labelPlaceholder, text: $draftLabel)
|
||||
Toggle(Copy.appendEnterToggle, isOn: $draftAppendEnter)
|
||||
Button(editingChipId == nil ? Copy.addButton : Copy.saveButton) {
|
||||
commitDraft()
|
||||
}
|
||||
.disabled(trimmedDraftText.isEmpty)
|
||||
if editingChipId != nil {
|
||||
Button(Copy.cancelEditButton, role: .cancel) {
|
||||
clearDraft()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var trimmedDraftText: String {
|
||||
draftText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func beginEditing(_ chip: QuickReplyChip) {
|
||||
editingChipId = chip.id
|
||||
draftText = chip.text
|
||||
draftLabel = chip.label
|
||||
draftAppendEnter = chip.appendEnter
|
||||
}
|
||||
|
||||
/// Web `onSaveClick` semantics: empty text is a no-op (button is disabled
|
||||
/// anyway — belt and braces), empty label defaults to the text.
|
||||
private func commitDraft() {
|
||||
let text = trimmedDraftText
|
||||
guard !text.isEmpty else { return }
|
||||
let trimmedLabel = draftLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let label = trimmedLabel.isEmpty ? text : trimmedLabel
|
||||
if let id = editingChipId {
|
||||
store.updateChip(id: id, text: text, label: label,
|
||||
appendEnter: draftAppendEnter)
|
||||
} else {
|
||||
store.addChip(text: text, label: label, appendEnter: draftAppendEnter)
|
||||
}
|
||||
clearDraft()
|
||||
}
|
||||
|
||||
private func clearDraft() {
|
||||
editingChipId = nil
|
||||
draftText = ""
|
||||
draftLabel = ""
|
||||
draftAppendEnter = true
|
||||
}
|
||||
}
|
||||
200
ios/App/WebTerm/Components/QuickReplyStore.swift
Normal file
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SessionCore
|
||||
|
||||
/// T-iOS-25 · Quick-reply data layer: chip model, pure immutable CRUD and the
|
||||
/// UserDefaults-backed palette store — a behavior mirror of
|
||||
/// `public/quick-reply.ts` (Chip / addChip / removeChip / reorderChip /
|
||||
/// updateChip / loadPalette / savePalette).
|
||||
///
|
||||
/// Storage decision (plan §5.3 split): the palette is NON-SECRET UI prefs →
|
||||
/// UserDefaults with an injectable suite (same pattern as
|
||||
/// `UserDefaultsLastSessionStore`), never Keychain.
|
||||
|
||||
/// One sendable snippet (mirror of the web `Chip` interface).
|
||||
struct QuickReplyChip: Sendable, Equatable, Codable, Identifiable {
|
||||
/// Stable identifier (built-ins use the `__` prefix, user chips `user_`).
|
||||
let id: String
|
||||
/// Raw bytes to send (without the Enter suffix).
|
||||
let text: String
|
||||
/// Display label on the chip button. Rendered via `Text(verbatim:)` only —
|
||||
/// the SwiftUI analogue of the web's textContent-never-innerHTML (SEC-L3).
|
||||
let label: String
|
||||
/// If true, Enter (`\r`) is appended to `text` when sending.
|
||||
let appendEnter: Bool
|
||||
}
|
||||
|
||||
/// Pure palette operations — every function returns a NEW array and never
|
||||
/// mutates its input (web quick-reply.ts CRUD, verbatim semantics).
|
||||
enum QuickReplyPalette {
|
||||
/// The six built-in chips for Claude Code interaction, order and values
|
||||
/// mirroring web `BUILT_IN_CHIPS`. Esc bytes resolve through `KeyByteMap`
|
||||
/// — hand-writing an escape sequence in the App layer is a review finding.
|
||||
static let builtInChips: [QuickReplyChip] = [
|
||||
QuickReplyChip(id: "__yes", text: "yes", label: "yes", appendEnter: true),
|
||||
QuickReplyChip(id: "__continue", text: "continue", label: "continue",
|
||||
appendEnter: true),
|
||||
QuickReplyChip(id: "__1", text: "1", label: "1", appendEnter: true),
|
||||
QuickReplyChip(id: "__2", text: "2", label: "2", appendEnter: true),
|
||||
QuickReplyChip(id: "__3", text: "3", label: "3", appendEnter: true),
|
||||
QuickReplyChip(id: "__esc", text: KeyByteMap.bytes(for: .esc), label: "Esc",
|
||||
appendEnter: false),
|
||||
]
|
||||
|
||||
/// Prefix for user-created chip ids (web: `user_<ts>_<rand>`; iOS: UUID).
|
||||
static let userChipIdPrefix = "user_"
|
||||
|
||||
/// The full byte string a chip tap sends: text plus — when `appendEnter` —
|
||||
/// the Enter byte from `KeyByteMap` (`\r`, 0x0D, NOT `\n`; CLAUDE.md gotcha).
|
||||
static func payload(for chip: QuickReplyChip) -> String {
|
||||
chip.text + (chip.appendEnter ? KeyByteMap.bytes(for: .enter) : "")
|
||||
}
|
||||
|
||||
/// New array with `chip` appended (web `addChip`).
|
||||
static func adding(_ chips: [QuickReplyChip], _ chip: QuickReplyChip) -> [QuickReplyChip] {
|
||||
chips + [chip]
|
||||
}
|
||||
|
||||
/// New array with the chip matching `id` removed (web `removeChip`).
|
||||
static func removing(_ chips: [QuickReplyChip], id: String) -> [QuickReplyChip] {
|
||||
chips.filter { $0.id != id }
|
||||
}
|
||||
|
||||
/// New array with the chip at `fromIndex` moved to `toIndex` (web
|
||||
/// `reorderChip` splice semantics). Either index out of bounds → the input
|
||||
/// returned unchanged. Operates on a local copy — value semantics
|
||||
/// guarantee the caller's array is never touched.
|
||||
static func reordering(
|
||||
_ chips: [QuickReplyChip], fromIndex: Int, toIndex: Int
|
||||
) -> [QuickReplyChip] {
|
||||
guard chips.indices.contains(fromIndex), chips.indices.contains(toIndex) else {
|
||||
return chips
|
||||
}
|
||||
var result = chips
|
||||
let moved = result.remove(at: fromIndex)
|
||||
result.insert(moved, at: toIndex)
|
||||
return result
|
||||
}
|
||||
|
||||
/// New array where the chip with `id` has non-nil fields patched (web
|
||||
/// `updateChip`: shallow merge, id immutable). Unknown id → unchanged.
|
||||
static func updating(
|
||||
_ chips: [QuickReplyChip], id: String,
|
||||
text: String? = nil, label: String? = nil, appendEnter: Bool? = nil
|
||||
) -> [QuickReplyChip] {
|
||||
chips.map { chip in
|
||||
guard chip.id == id else { return chip }
|
||||
return QuickReplyChip(
|
||||
id: chip.id,
|
||||
text: text ?? chip.text,
|
||||
label: label ?? chip.label,
|
||||
appendEnter: appendEnter ?? chip.appendEnter
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-item lossy decode wrapper (mirror of the web `filter(isValidChip)`):
|
||||
/// one malformed entry is dropped, the well-formed rest survive — stored data
|
||||
/// crosses a storage boundary and is never trusted (LastSessionStore pattern).
|
||||
private struct LossyQuickReplyChip: Decodable {
|
||||
let chip: QuickReplyChip?
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
chip = try? QuickReplyChip(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
/// Observable palette store: holds the user chips, persists every change to
|
||||
/// the injected `UserDefaults` and exposes the render list (built-ins first,
|
||||
/// then user chips — web render order).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class QuickReplyStore {
|
||||
/// Single UserDefaults key for the encoded user palette
|
||||
/// (web: localStorage `web-terminal:quick-reply-palette`).
|
||||
static let paletteDefaultsKey = "quickReplyPalette"
|
||||
|
||||
private(set) var userChips: [QuickReplyChip]
|
||||
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
|
||||
/// Built-in chips first, then the user palette (web `render()` order).
|
||||
var allChips: [QuickReplyChip] {
|
||||
QuickReplyPalette.builtInChips + userChips
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
userChips = Self.loadPalette(from: defaults)
|
||||
}
|
||||
|
||||
/// Add a new phrase (web editor `onSaveClick` semantics): text/label are
|
||||
/// trimmed, empty text is a no-op (returns false), empty label defaults to
|
||||
/// the text, the id is minted with the `user_` prefix.
|
||||
@discardableResult
|
||||
func addChip(text: String, label: String, appendEnter: Bool) -> Bool {
|
||||
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedText.isEmpty else { return false }
|
||||
let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let chip = QuickReplyChip(
|
||||
id: QuickReplyPalette.userChipIdPrefix + UUID().uuidString,
|
||||
text: trimmedText,
|
||||
label: trimmedLabel.isEmpty ? trimmedText : trimmedLabel,
|
||||
appendEnter: appendEnter
|
||||
)
|
||||
setUserChips(QuickReplyPalette.adding(userChips, chip))
|
||||
return true
|
||||
}
|
||||
|
||||
func removeChip(id: String) {
|
||||
setUserChips(QuickReplyPalette.removing(userChips, id: id))
|
||||
}
|
||||
|
||||
func moveChip(fromIndex: Int, toIndex: Int) {
|
||||
setUserChips(QuickReplyPalette.reordering(
|
||||
userChips, fromIndex: fromIndex, toIndex: toIndex
|
||||
))
|
||||
}
|
||||
|
||||
/// SwiftUI `List.onMove` adapter (IndexSet + insert-before destination on
|
||||
/// the ORIGINAL array — `Array.move` implements that convention).
|
||||
func moveChips(fromOffsets: IndexSet, toOffset: Int) {
|
||||
var next = userChips
|
||||
next.move(fromOffsets: fromOffsets, toOffset: toOffset)
|
||||
setUserChips(next)
|
||||
}
|
||||
|
||||
func updateChip(
|
||||
id: String, text: String? = nil, label: String? = nil, appendEnter: Bool? = nil
|
||||
) {
|
||||
setUserChips(QuickReplyPalette.updating(
|
||||
userChips, id: id, text: text, label: label, appendEnter: appendEnter
|
||||
))
|
||||
}
|
||||
|
||||
/// Load the persisted palette. Missing key, non-JSON data or a non-array
|
||||
/// root → `[]`; malformed items are dropped per-entry. Never throws
|
||||
/// (web `loadPalette` contract).
|
||||
static func loadPalette(from defaults: UserDefaults) -> [QuickReplyChip] {
|
||||
guard let data = defaults.data(forKey: paletteDefaultsKey) else { return [] }
|
||||
guard let lossy = try? JSONDecoder().decode([LossyQuickReplyChip].self, from: data)
|
||||
else { return [] }
|
||||
return lossy.compactMap(\.chip)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setUserChips(_ chips: [QuickReplyChip]) {
|
||||
userChips = chips
|
||||
persist(chips)
|
||||
}
|
||||
|
||||
/// Best-effort save (web `savePalette` contract: storage failure never
|
||||
/// throws or crashes — the in-memory palette stays authoritative for the
|
||||
/// session). Encoding a `[QuickReplyChip]` cannot practically fail.
|
||||
private func persist(_ chips: [QuickReplyChip]) {
|
||||
guard let data = try? JSONEncoder().encode(chips) else { return }
|
||||
defaults.set(data, forKey: Self.paletteDefaultsKey)
|
||||
}
|
||||
}
|
||||
129
ios/App/WebTerm/Components/ReconnectBanner.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-11 · Connection-state banner over the terminal (plan §1: the terminal
|
||||
/// must NEVER "look connected but dead" — every non-live state is explicit).
|
||||
///
|
||||
/// Render input is the `Model` enum only; the mapping from engine events to a
|
||||
/// `Model` lives in `TerminalViewModel.bannerModel` (tested there).
|
||||
struct ReconnectBanner: View {
|
||||
/// What the banner says. Terminal phases (`failed`/`exited`) win over
|
||||
/// transient connection states (mapping in `TerminalViewModel.bannerModel`).
|
||||
enum Model: Equatable {
|
||||
case connecting
|
||||
case reconnecting(attempt: Int, retryIn: Duration)
|
||||
/// Non-retryable failure: actionable copy, NO spinner, NO retry loop.
|
||||
case failed(message: String)
|
||||
/// Session over — terminal is read-only from here on.
|
||||
case exited(code: Int, reason: String?)
|
||||
}
|
||||
|
||||
let model: Model
|
||||
/// T-iOS-29 · "开新会话" on the EXITED banner only (session over → the
|
||||
/// natural next step; manager.ts:145-153 — the old session is done for
|
||||
/// good). nil = affordance hidden. Not offered on `.failed`: that copy
|
||||
/// asks the user to change a knob first, not to spawn again.
|
||||
var onNewSession: (@MainActor () -> Void)? = nil
|
||||
|
||||
private enum Copy {
|
||||
static let newSession = "开新会话"
|
||||
}
|
||||
|
||||
/// 精致原生:材质胶囊 + 发丝描边,状态只由前导指示色(+ 图标形状)表达,
|
||||
/// 而非整块告警底色 —— 重连读作「安静的进行中」,不惊扰(GROUP BRIEF)。
|
||||
var body: some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
leadingIndicator
|
||||
Text(text)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.multilineTextAlignment(.leading)
|
||||
if let onNewSession, Self.isNewSessionActionAvailable(for: model) {
|
||||
Button(Copy.newSession) { onNewSession() }
|
||||
.font(DS.Typography.callout.weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("terminal.exitNewSessionButton")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, DS.Space.lg16)
|
||||
.padding(.vertical, DS.Space.sm8)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
.overlay(
|
||||
Capsule().strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// Spinner while a connection is in flight, otherwise the state's icon —
|
||||
/// both painted in the semantic indicator color (shape + color, never color
|
||||
/// alone).
|
||||
@ViewBuilder private var leadingIndicator: some View {
|
||||
if isSpinning {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(indicatorColor)
|
||||
} else {
|
||||
Image(systemName: iconName)
|
||||
.foregroundStyle(indicatorColor)
|
||||
}
|
||||
}
|
||||
|
||||
/// The new-session affordance appears on the `.exited` banner only —
|
||||
/// pure predicate so the T-iOS-29 tests pin the rule.
|
||||
static func isNewSessionActionAvailable(for model: Model) -> Bool {
|
||||
if case .exited = model { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private var isSpinning: Bool {
|
||||
switch model {
|
||||
case .connecting, .reconnecting: return true
|
||||
case .failed, .exited: return false
|
||||
}
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch model {
|
||||
case .failed: return "exclamationmark.octagon.fill"
|
||||
case .exited: return "flag.checkered"
|
||||
case .connecting, .reconnecting: return "wifi"
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic indicator color (DS only): connecting is quiet gray, reconnecting
|
||||
/// is the calm accent (not an orange alarm), failed is the stuck red, exited
|
||||
/// is dimmed secondary. Paired with a distinct icon so it is never color-only.
|
||||
private var indicatorColor: Color {
|
||||
switch model {
|
||||
case .connecting: return DS.Palette.textSecondary
|
||||
case .reconnecting: return DS.Palette.accent
|
||||
case .failed: return DS.Palette.statusStuck
|
||||
case .exited: return DS.Palette.statusExited
|
||||
}
|
||||
}
|
||||
|
||||
private var text: String {
|
||||
switch model {
|
||||
case .connecting:
|
||||
return "连接中…"
|
||||
case .reconnecting(let attempt, let retryIn):
|
||||
return "连接已断开 · 第 \(attempt) 次重连将在 \(retryIn.components.seconds)s 后…"
|
||||
case .failed(let message):
|
||||
return message
|
||||
case .exited(let code, let reason):
|
||||
return Self.exitText(code: code, reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit copy: spawn failure (-1, M4) reads as an error; a normal exit reads
|
||||
/// as a conclusion. `reason` is server-supplied display text (untrusted —
|
||||
/// rendered as plain text only).
|
||||
private static func exitText(code: Int, reason: String?) -> String {
|
||||
if code == WireConstants.spawnFailedExitCode {
|
||||
return "启动 shell 失败:\(reason ?? "未知原因")"
|
||||
}
|
||||
let suffix = reason.map { "(\($0))" } ?? ""
|
||||
return "会话已退出 exit \(code)\(suffix) · 终端已只读"
|
||||
}
|
||||
}
|
||||
374
ios/App/WebTerm/Components/SessionThumbnail.swift
Normal file
@@ -0,0 +1,374 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import os
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-28 · 会话缩略图管线(`GET /live-sessions/:id/preview` → 离屏
|
||||
/// SwiftTerm → 快照 UIImage → 列表行)。本文件是纯逻辑侧:请求/键、LRU 缓存、
|
||||
/// 并发上限闸、管线与 SwiftUI 槽位;真正的离屏渲染在
|
||||
/// `SessionThumbnailRenderer.swift`(唯一 import SwiftTerm 的一侧)。
|
||||
///
|
||||
/// 设计要点(对应 Steps):
|
||||
/// - **缓存键 = (sessionId, lastOutputAt)**:`lastOutputAt`(T-iOS-37)不变 =
|
||||
/// 会话无新输出 = 缩略图不可能变 → 绝不重渲染。nil(P1 前旧服务器)意味着
|
||||
/// 没有失效信号:渲染一次后永久命中——宁可陈旧也不随每次 5s 轮询无界重
|
||||
/// 渲染(文档化降级)。
|
||||
/// - **并发上限**:离屏渲染要 spawn 一个完整 SwiftTerm 终端,列表滚动绝不能
|
||||
/// 无界并发——`SessionThumbnailRenderGate`(FIFO、permit 转移)把「取数 +
|
||||
/// 渲染」整段管线钳在 `maxConcurrentRenders`。
|
||||
/// - **失败也缓存**:404/网络错/校验失败 → `.placeholder` 同键入缓存,滚动
|
||||
/// 不会打爆故障主机;会话一有新输出(新 lastOutputAt)自然重试。
|
||||
/// - **服务器是不可信输入源**:preview 的 `data` 只喂 SwiftTerm(它就是 ANSI
|
||||
/// 解释器),永不字符串处理;但 id/几何/字节数在边界校验,不合格拒渲染。
|
||||
struct SessionThumbnailRequest: Equatable, Sendable {
|
||||
let endpoint: HostEndpoint
|
||||
let sessionId: UUID
|
||||
/// 服务器 `LiveSessionInfo.lastOutputAt`(ms since epoch;可选新增字段)。
|
||||
let lastOutputAt: Int?
|
||||
|
||||
var key: SessionThumbnailKey {
|
||||
SessionThumbnailKey(sessionId: sessionId, lastOutputAt: lastOutputAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 缓存身份:同键 = 「同一会话且自上次渲染以来没有任何新输出」。
|
||||
struct SessionThumbnailKey: Hashable, Sendable {
|
||||
let sessionId: UUID
|
||||
let lastOutputAt: Int?
|
||||
}
|
||||
|
||||
/// 管线产物。`Equatable` 对 `.rendered` 按图像身份比较(同一缓存条目 ===)。
|
||||
enum SessionThumbnailImage: Sendable {
|
||||
case rendered(UIImage)
|
||||
case placeholder
|
||||
|
||||
var isPlaceholder: Bool {
|
||||
if case .placeholder = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var uiImage: UIImage? {
|
||||
if case .rendered(let image) = self { return image }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension SessionThumbnailImage: Equatable {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.placeholder, .placeholder): return true
|
||||
case (.rendered(let a), .rendered(let b)): return a === b
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 本 feature 的具名常量(局部常量;WireProtocol 的 `Tunables` 已冻结,不动)。
|
||||
enum SessionThumbnailTunable {
|
||||
/// 同时在飞的「取数 + 离屏渲染」管线数上限——滚动不得无界 spawn 终端。
|
||||
static let maxConcurrentRenders = 2
|
||||
/// LRU 缓存条目上限(每条 ≈ 一张 2x 小快照,内存有界)。
|
||||
static let maxCacheEntries = 32
|
||||
/// 防御性字节上限:服务器默认 tail 24 KiB(src/config.ts:46
|
||||
/// DEFAULT_PREVIEW_BYTES,env `PREVIEW_BYTES` 可调)——留 ~10× 余量;
|
||||
/// 超限视为异常主机,拒渲染(喂给解释器前唯一的一次尺寸检查)。
|
||||
static let maxPreviewDataBytes = 256 * 1024
|
||||
/// 缩略图渲染网格上限:几何是不可信输入,钳制它把离屏绘制的行数/位图
|
||||
/// 尺寸钳在有界范围(真实终端极少超过 300 列;超宽会话只损失换行保真)。
|
||||
static let maxRenderCols = 300
|
||||
static let maxRenderRows = 120
|
||||
/// 镜像 web 预览的 `Math.max(2, …)`(public/preview-grid.ts:112-117)。
|
||||
static let minRenderGrid = 2
|
||||
}
|
||||
|
||||
// MARK: - LRU 缓存(不可变快照;持有方整体替换)
|
||||
|
||||
/// 定容 LRU:`order` 尾部为最近使用。所有操作返回新副本(不可变风格)。
|
||||
struct SessionThumbnailCache {
|
||||
let maxEntries: Int
|
||||
private let entries: [SessionThumbnailKey: SessionThumbnailImage]
|
||||
private let order: [SessionThumbnailKey]
|
||||
|
||||
init(maxEntries: Int) {
|
||||
self.init(maxEntries: maxEntries, entries: [:], order: [])
|
||||
}
|
||||
|
||||
private init(
|
||||
maxEntries: Int,
|
||||
entries: [SessionThumbnailKey: SessionThumbnailImage],
|
||||
order: [SessionThumbnailKey]
|
||||
) {
|
||||
self.maxEntries = max(1, maxEntries)
|
||||
self.entries = entries
|
||||
self.order = order
|
||||
}
|
||||
|
||||
func value(for key: SessionThumbnailKey) -> SessionThumbnailImage? {
|
||||
entries[key]
|
||||
}
|
||||
|
||||
/// 命中后前移(most-recently-used)。未含该键则原样返回。
|
||||
func bumping(_ key: SessionThumbnailKey) -> SessionThumbnailCache {
|
||||
guard entries[key] != nil else { return self }
|
||||
return SessionThumbnailCache(
|
||||
maxEntries: maxEntries, entries: entries,
|
||||
order: order.filter { $0 != key } + [key]
|
||||
)
|
||||
}
|
||||
|
||||
/// 插入并按容量逐出 least-recently-used。
|
||||
func inserting(
|
||||
_ image: SessionThumbnailImage, for key: SessionThumbnailKey
|
||||
) -> SessionThumbnailCache {
|
||||
var newEntries = entries.merging([key: image]) { _, new in new }
|
||||
var newOrder = order.filter { $0 != key } + [key]
|
||||
while newOrder.count > maxEntries, let oldest = newOrder.first {
|
||||
newOrder = Array(newOrder.dropFirst())
|
||||
newEntries = newEntries.filter { $0.key != oldest }
|
||||
}
|
||||
return SessionThumbnailCache(
|
||||
maxEntries: maxEntries, entries: newEntries, order: newOrder
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 并发上限闸(FIFO、permit 转移)
|
||||
|
||||
/// 渲染管线的并发闸:`acquire` 超限即按 FIFO 排队;`release` 时若有排队者,
|
||||
/// permit 原地转移(activeCount 不变)。测试屏障 `waitUntilWaiting` 与
|
||||
/// FakeClock.waitForSleepers 同手法——continuation,零轮询零真睡。
|
||||
@MainActor
|
||||
final class SessionThumbnailRenderGate {
|
||||
let limit: Int
|
||||
private(set) var activeCount = 0
|
||||
private(set) var peakActiveCount = 0
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var barriers: [(target: Int, cont: CheckedContinuation<Void, Never>)] = []
|
||||
|
||||
var waitingCount: Int { waiters.count }
|
||||
|
||||
init(limit: Int) {
|
||||
self.limit = max(1, limit)
|
||||
}
|
||||
|
||||
func acquire() async {
|
||||
if activeCount < limit {
|
||||
activeCount += 1
|
||||
peakActiveCount = max(peakActiveCount, activeCount)
|
||||
return
|
||||
}
|
||||
await withCheckedContinuation { cont in
|
||||
waiters = waiters + [cont]
|
||||
notifyBarriers()
|
||||
}
|
||||
// 被 release() 唤醒 = permit 已转移给我们,activeCount 保持不变。
|
||||
peakActiveCount = max(peakActiveCount, activeCount)
|
||||
}
|
||||
|
||||
func release() {
|
||||
guard waiters.isEmpty else {
|
||||
let next = waiters[0]
|
||||
waiters = Array(waiters.dropFirst())
|
||||
next.resume() // permit 转移,activeCount 不动
|
||||
return
|
||||
}
|
||||
activeCount = max(0, activeCount - 1)
|
||||
}
|
||||
|
||||
/// 测试屏障:挂起直到至少 `count` 个 acquire 在排队。
|
||||
func waitUntilWaiting(count: Int) async {
|
||||
if waiters.count >= count { return }
|
||||
await withCheckedContinuation { cont in
|
||||
barriers = barriers + [(count, cont)]
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyBarriers() {
|
||||
let met = barriers.filter { $0.target <= waiters.count }
|
||||
barriers = barriers.filter { $0.target > waiters.count }
|
||||
for barrier in met { barrier.cont.resume() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 管线
|
||||
|
||||
@MainActor
|
||||
final class SessionThumbnailPipeline {
|
||||
/// 取数缝:生产侧 = `APIClient.preview(id:)`(RO GET,无 Origin)。
|
||||
typealias PreviewLoader = @MainActor (SessionThumbnailRequest) async throws -> SessionPreview
|
||||
/// 渲染缝:生产侧 = `SessionThumbnailRenderer.render`(离屏 SwiftTerm)。
|
||||
/// 入参几何已经过 `clampedGeometry` 钳制。
|
||||
typealias PreviewRenderer = @MainActor (_ data: String, _ cols: Int, _ rows: Int) -> UIImage?
|
||||
|
||||
private let loader: PreviewLoader
|
||||
private let renderer: PreviewRenderer
|
||||
private let gate: SessionThumbnailRenderGate
|
||||
private var cache: SessionThumbnailCache
|
||||
/// 同键并发去重:第二个请求 await 第一个的任务,绝不双渲染。
|
||||
private var inFlight: [SessionThumbnailKey: Task<SessionThumbnailImage, Never>] = [:]
|
||||
private let logger = Logger(
|
||||
subsystem: SessionThumbnailLog.subsystem, category: SessionThumbnailLog.category
|
||||
)
|
||||
|
||||
init(
|
||||
loader: @escaping PreviewLoader,
|
||||
renderer: @escaping PreviewRenderer,
|
||||
gate: SessionThumbnailRenderGate? = nil,
|
||||
maxCacheEntries: Int = SessionThumbnailTunable.maxCacheEntries
|
||||
) {
|
||||
self.loader = loader
|
||||
self.renderer = renderer
|
||||
self.gate = gate
|
||||
?? SessionThumbnailRenderGate(limit: SessionThumbnailTunable.maxConcurrentRenders)
|
||||
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
|
||||
}
|
||||
|
||||
/// 生产装配:共享一个 ephemeral URLSession(preview 字节可能含屏上密钥,
|
||||
/// 内存缓存 only——同 T-iOS-19 对 RO GET 的裁定)。
|
||||
static func live() -> SessionThumbnailPipeline {
|
||||
SessionThumbnailPipeline(
|
||||
loader: { request in
|
||||
try await APIClient(endpoint: request.endpoint, http: liveTransport)
|
||||
.preview(id: request.sessionId)
|
||||
},
|
||||
renderer: { data, cols, rows in
|
||||
SessionThumbnailRenderer.render(data: data, cols: cols, rows: rows)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private static let liveTransport = URLSessionHTTPTransport()
|
||||
|
||||
/// 取(或渲染)一张缩略图。永不抛错:任何失败显式降级为 `.placeholder`
|
||||
/// (占位图就是缩略图的错误 UI;细节进内部日志,绝不静默无痕)。
|
||||
func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage {
|
||||
let key = request.key
|
||||
if let hit = cache.value(for: key) {
|
||||
cache = cache.bumping(key)
|
||||
return hit
|
||||
}
|
||||
if let running = inFlight[key] {
|
||||
return await running.value
|
||||
}
|
||||
let task = Task { await produce(request) }
|
||||
inFlight = inFlight.merging([key: task]) { _, new in new }
|
||||
let result = await task.value
|
||||
inFlight = inFlight.filter { $0.key != key }
|
||||
cache = cache.inserting(result, for: key)
|
||||
return result
|
||||
}
|
||||
|
||||
private func produce(_ request: SessionThumbnailRequest) async -> SessionThumbnailImage {
|
||||
await gate.acquire()
|
||||
defer { gate.release() }
|
||||
let preview: SessionPreview
|
||||
do {
|
||||
preview = try await loader(request)
|
||||
} catch {
|
||||
logger.debug("preview fetch failed: \(String(describing: error), privacy: .public)")
|
||||
return .placeholder
|
||||
}
|
||||
guard Self.isAcceptable(preview, for: request),
|
||||
let grid = Self.clampedGeometry(cols: preview.cols, rows: preview.rows) else {
|
||||
logger.debug("preview rejected at boundary (id/bytes/geometry)")
|
||||
return .placeholder
|
||||
}
|
||||
guard let image = renderer(preview.data, grid.cols, grid.rows) else {
|
||||
logger.debug("offscreen snapshot returned nil")
|
||||
return .placeholder
|
||||
}
|
||||
return .rendered(image)
|
||||
}
|
||||
|
||||
// MARK: - 不可信边界校验(data 本身只喂 SwiftTerm,绝不内容处理)
|
||||
|
||||
/// id 必须回显请求的会话;字节数必须在防御上限内。
|
||||
static func isAcceptable(
|
||||
_ preview: SessionPreview, for request: SessionThumbnailRequest
|
||||
) -> Bool {
|
||||
preview.id == request.sessionId
|
||||
&& preview.data.utf8.count <= SessionThumbnailTunable.maxPreviewDataBytes
|
||||
}
|
||||
|
||||
/// 几何校验 + 钳制:出服务器 resize 契约(`WireConstants.resizeRange`,
|
||||
/// 1...1000)→ nil(拒渲染);合法值钳到缩略图网格界(min 镜像 web
|
||||
/// `max(2,·)`,max 钳位图/绘制成本)。
|
||||
static func clampedGeometry(cols: Int, rows: Int) -> (cols: Int, rows: Int)? {
|
||||
guard Validation.isValidResize(cols: cols, rows: rows) else { return nil }
|
||||
let clampedCols = min(
|
||||
max(cols, SessionThumbnailTunable.minRenderGrid),
|
||||
SessionThumbnailTunable.maxRenderCols
|
||||
)
|
||||
let clampedRows = min(
|
||||
max(rows, SessionThumbnailTunable.minRenderGrid),
|
||||
SessionThumbnailTunable.maxRenderRows
|
||||
)
|
||||
return (clampedCols, clampedRows)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 列表行槽位(SwiftUI)
|
||||
|
||||
/// 会话列表行里的缩略图槽。`.task(id: key)` 驱动:lastOutputAt 变化自动换键
|
||||
/// 重取;缓存命中时立即出图。占位态(加载中 / 失败 / 会话已无预览)统一为
|
||||
/// 终端图标卡片——对一张缩略图,占位就是全部错误 UI。
|
||||
struct SessionThumbnailView: View {
|
||||
let request: SessionThumbnailRequest
|
||||
let pipeline: SessionThumbnailPipeline
|
||||
@State private var image: SessionThumbnailImage?
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// 媒体尺寸:宽度锁死在 `SessionThumbnailRenderer.snapshotTargetWidth`(=2×88)
|
||||
/// 的一半,快照 2x 后像素密度足够;非设计 token(缩略图固有尺寸)。
|
||||
private enum Metrics {
|
||||
static let width: CGFloat = 88
|
||||
static let height: CGFloat = 56
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
placeholder
|
||||
if let snapshot = image?.uiImage {
|
||||
Image(uiImage: snapshot)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(width: Metrics.width, height: Metrics.height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DS.Radius.sm8)
|
||||
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
// 快照淡入(遵守 Reduce Motion,塌成即时切换)。
|
||||
.animation(DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), value: image)
|
||||
.accessibilityLabel(SessionThumbnailCopy.thumbnailLabel)
|
||||
.task(id: request.key) {
|
||||
image = await pipeline.thumbnail(for: request)
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载中 / 失败 / 无预览的占位:卡片底 + 发丝边(overlay 提供)+ 终端图标。
|
||||
/// 用 DS 卡片色,不再是硬编码黑块(direction: "not a raw black rect")。
|
||||
private var placeholder: some View {
|
||||
ZStack {
|
||||
DS.Palette.card
|
||||
Image(systemName: "terminal")
|
||||
.imageScale(.large)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户可见文案(中文具名常量)。
|
||||
enum SessionThumbnailCopy {
|
||||
static let thumbnailLabel = "会话画面缩略图"
|
||||
}
|
||||
|
||||
private enum SessionThumbnailLog {
|
||||
static let subsystem = "com.yaojia.webterm"
|
||||
static let category = "session-thumbnail"
|
||||
}
|
||||
85
ios/App/WebTerm/Components/SessionThumbnailRenderer.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
import SwiftTerm
|
||||
import UIKit
|
||||
|
||||
/// T-iOS-28 · 离屏 SwiftTerm 渲染器:把一段 preview tail(不可信 ANSI 字节,
|
||||
/// **只喂终端解释器,绝不字符串处理**)喂进一个从不进视图层级的
|
||||
/// `TerminalView`,再用 `UIGraphicsImageRenderer` 对已布局的 view 出快照。
|
||||
///
|
||||
/// 与 web 预览逐点对齐(public/preview-grid.ts makePreviewCard/renderPreview):
|
||||
/// - 网格 = 服务器回报的 cols×rows(上游已钳制)——换行与真屏一致;
|
||||
/// - `scrollback: 0` ↔ `changeScrollback(nil)`:buffer 只有当前屏,
|
||||
/// `contentOffset` 恒为 0,离屏 draw 恰取「现在这一屏」;
|
||||
/// - 暗底主题 + 隐藏光标(web 用 cursor==background;此处喂 DECTCEM 隐藏序列,
|
||||
/// iOS 端直接把 caret 子视图摘掉);
|
||||
/// - 快照按 `snapshotTargetWidth` 缩放——缓存的是小位图,不是整格点阵。
|
||||
///
|
||||
/// 生命周期:`TerminalView` 在初始化时向主 runloop 挂 CADisplayLink(暂停态),
|
||||
/// 离屏用完必须 `updateUiClosed()` 注销,否则每次渲染泄漏一个 runloop 源。
|
||||
@MainActor
|
||||
enum SessionThumbnailRenderer {
|
||||
/// 快照宽度预算(pt):列表槽位 88pt 的 2 倍,配 `snapshotScale`=2 后
|
||||
/// 像素密度足够,单张缓存 ≈ 数百 KB 有界。
|
||||
static let snapshotTargetWidth: CGFloat = 176
|
||||
/// 固定 2x:快照是缩略图,不追设备 3x(内存 × 2.25 不值)。
|
||||
static let snapshotScale: CGFloat = 2
|
||||
/// 镜像 web 预览字号(public/preview-grid.ts fontSize: 12)。
|
||||
static let fontSize: CGFloat = 12
|
||||
/// 布局余量(pt):frame 取 optimal + slack,抵消 `Int(width/cellW)` 的
|
||||
/// 浮点截断——必须严格小于一个 cell 宽,否则网格会多出一列。
|
||||
static let layoutSlack: CGFloat = 0.5
|
||||
/// DECTCEM 隐藏光标(数据喂完后追加;iOS 端会摘掉 caret 子视图)。
|
||||
/// 这是我们自己的常量序列,不是对服务器数据的处理。
|
||||
static let hideCursorSequence = "\u{1b}[?25l"
|
||||
/// 镜像 web PREVIEW_THEME(#0e0f13 / #e7e8ec)。
|
||||
static let backgroundColor = UIColor(
|
||||
red: 14 / 255, green: 15 / 255, blue: 19 / 255, alpha: 1
|
||||
)
|
||||
static let foregroundColor = UIColor(
|
||||
red: 231 / 255, green: 232 / 255, blue: 236 / 255, alpha: 1
|
||||
)
|
||||
private static let initialProbeFrame = CGRect(x: 0, y: 0, width: 64, height: 64)
|
||||
|
||||
/// 渲染一张快照。`cols`/`rows` 必须已过 `clampedGeometry`(有界);失败
|
||||
/// 返回 nil(上游显式降级为占位图)。
|
||||
static func render(data: String, cols: Int, rows: Int) -> UIImage? {
|
||||
let font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
||||
let terminal = TerminalView(frame: initialProbeFrame, font: font)
|
||||
defer { terminal.updateUiClosed() } // 注销 CADisplayLink,绝不泄漏
|
||||
terminal.changeScrollback(nil) // ↔ web scrollback: 0
|
||||
terminal.resize(cols: cols, rows: rows)
|
||||
terminal.nativeBackgroundColor = backgroundColor
|
||||
terminal.nativeForegroundColor = foregroundColor
|
||||
|
||||
let optimal = terminal.getOptimalFrameSize()
|
||||
guard optimal.width > 0, optimal.height > 0 else { return nil }
|
||||
terminal.frame = CGRect(
|
||||
x: 0, y: 0,
|
||||
width: optimal.width + layoutSlack, height: optimal.height + layoutSlack
|
||||
)
|
||||
terminal.layoutIfNeeded()
|
||||
|
||||
terminal.feed(text: data) // 唯一合法去处:ANSI 解释器
|
||||
terminal.feed(text: hideCursorSequence)
|
||||
|
||||
return snapshot(of: terminal, contentSize: optimal.size)
|
||||
}
|
||||
|
||||
/// `UIGraphicsImageRenderer` + `layer.render(in:)`:离屏(无 window)视图
|
||||
/// 走 CoreGraphics draw 路径(SwiftTerm 默认不启 Metal——Metal 层无法被
|
||||
/// `layer.render` 捕获,此处也永不 `setUseMetal`)。
|
||||
private static func snapshot(of view: UIView, contentSize: CGSize) -> UIImage? {
|
||||
let scale = min(1, snapshotTargetWidth / contentSize.width)
|
||||
let size = CGSize(
|
||||
width: contentSize.width * scale, height: contentSize.height * scale
|
||||
)
|
||||
guard size.width >= 1, size.height >= 1 else { return nil }
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = snapshotScale
|
||||
format.opaque = true
|
||||
let renderer = UIGraphicsImageRenderer(size: size, format: format)
|
||||
return renderer.image { context in
|
||||
context.cgContext.scaleBy(x: scale, y: scale)
|
||||
view.layer.render(in: context.cgContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
129
ios/App/WebTerm/Components/TelemetryChips.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-13 · Telemetry chips for a session-list row — the iOS mirror of the
|
||||
/// web's `renderTelemetryGauge` (public/preview-grid.ts:197): context-usage %
|
||||
/// (warn > 80), $cost to 4 places, model chip, PR badge.
|
||||
///
|
||||
/// Staleness: chips grey out when `StatusTelemetry.at` is STRICTLY older than
|
||||
/// `Tunables.telemetryStaleTtlMs` (same `>` rule as the web's `tg-stale`).
|
||||
/// The comparison lives in `Model`, computed from an injected `nowMs` so the
|
||||
/// ViewModel tests are deterministic (task RED list).
|
||||
///
|
||||
/// Security (mirrors SEC-H5/SEC-L5): every telemetry string is server-supplied
|
||||
/// UNTRUSTED input — rendered as plain `Text` only; the PR badge becomes a
|
||||
/// tappable `Link` iff its URL parses as https, otherwise it renders as text.
|
||||
struct TelemetryChips: View {
|
||||
/// Immutable render input, derived once per list rebuild.
|
||||
struct Model: Equatable, Sendable {
|
||||
let contextUsedPct: Double?
|
||||
/// Pre-formatted `$X.XXXX` (mirrors the web's `toFixed(4)`).
|
||||
let costText: String?
|
||||
let modelName: String?
|
||||
/// Pre-formatted `PR #N`.
|
||||
let prText: String?
|
||||
let prReviewState: String?
|
||||
/// Tappable PR destination — https URLs ONLY (SEC-L5 mirror), else nil.
|
||||
let prURL: URL?
|
||||
/// True when `at` is strictly older than `Tunables.telemetryStaleTtlMs`.
|
||||
let isStale: Bool
|
||||
|
||||
/// nil when the session has no telemetry at all (row renders no chips).
|
||||
init?(telemetry: StatusTelemetry?, nowMs: Int) {
|
||||
guard let telemetry else { return nil }
|
||||
isStale = nowMs - telemetry.at > Tunables.telemetryStaleTtlMs
|
||||
contextUsedPct = telemetry.contextUsedPct
|
||||
costText = telemetry.costUsd.map { String(format: Format.cost, $0) }
|
||||
modelName = telemetry.model
|
||||
if let pr = telemetry.pr {
|
||||
prText = "PR #\(pr.number)"
|
||||
prReviewState = pr.reviewState
|
||||
prURL = Self.httpsOnlyURL(pr.url)
|
||||
} else {
|
||||
prText = nil
|
||||
prReviewState = nil
|
||||
prURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// `ctx NN%` — same rounding as the web label (`Math.round`).
|
||||
var contextText: String? {
|
||||
contextUsedPct.map { "ctx \(Int($0.rounded()))%" }
|
||||
}
|
||||
|
||||
/// Mirrors the web's `tg-ctx-warn` threshold (strictly > 80).
|
||||
var isContextWarning: Bool {
|
||||
(contextUsedPct ?? 0) > Format.contextWarnPct
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
contextUsedPct == nil && costText == nil && modelName == nil && prText == nil
|
||||
}
|
||||
|
||||
/// SEC-L5 mirror: only https URLs are ever offered as link targets.
|
||||
static func httpsOnlyURL(_ raw: String) -> URL? {
|
||||
guard let url = URL(string: raw),
|
||||
url.scheme?.lowercased() == Format.httpsScheme
|
||||
else { return nil }
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
let model: Model
|
||||
|
||||
/// Composed from the frozen `TelemetryChip` primitive — each chip is
|
||||
/// mono-tabular, greys itself out when `isStale`, and the context chip goes
|
||||
/// amber over the warn threshold. The PR chip becomes a tappable `Link`
|
||||
/// only when the VM handed us an https URL (SEC-L5 boundary stays in Model).
|
||||
var body: some View {
|
||||
HStack(spacing: DS.Space.xs4) {
|
||||
if let contextText = model.contextText {
|
||||
TelemetryChip(
|
||||
systemImage: Symbols.context,
|
||||
text: contextText,
|
||||
isStale: model.isStale,
|
||||
isWarning: model.isContextWarning
|
||||
)
|
||||
}
|
||||
if let costText = model.costText {
|
||||
TelemetryChip(text: costText, isStale: model.isStale)
|
||||
}
|
||||
if let modelName = model.modelName {
|
||||
TelemetryChip(
|
||||
systemImage: Symbols.model, text: modelName, isStale: model.isStale
|
||||
)
|
||||
}
|
||||
prBadge
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var prBadge: some View {
|
||||
if let prText = model.prText {
|
||||
let label = model.prReviewState.map { "\(prText) · \($0)" } ?? prText
|
||||
let chip = TelemetryChip(
|
||||
systemImage: Symbols.pr, text: label, isStale: model.isStale
|
||||
)
|
||||
if let url = model.prURL {
|
||||
Link(destination: url) { chip }
|
||||
} else {
|
||||
chip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chip icons (SF Symbols, no magic numbers/colors — DS owns those)
|
||||
|
||||
private enum Symbols {
|
||||
static let context = "gauge.medium"
|
||||
static let model = "cpu"
|
||||
static let pr = "arrow.triangle.branch"
|
||||
}
|
||||
|
||||
private enum Format {
|
||||
/// Mirrors web `$${costUsd.toFixed(4)}`.
|
||||
static let cost = "$%.4f"
|
||||
/// Mirrors web `tg-ctx-warn` (`contextUsedPct > 80`).
|
||||
static let contextWarnPct = 80.0
|
||||
static let httpsScheme = "https"
|
||||
}
|
||||
}
|
||||
144
ios/App/WebTerm/Components/TerminalContextMenu.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
import UIKit
|
||||
|
||||
/// T-iPad-3 · 终端面板的指针(次要点击/右键)· 长按上下文菜单。
|
||||
///
|
||||
/// 纯菜单模型(可单测)+ `UIContextMenuInteraction` 桥。每个动作都**复用既有
|
||||
/// 通道**,绝不新增网络路径:
|
||||
/// - `.copySelection` → SwiftTerm 自带的 `copy(_:)`(读选区 → 剪贴板,绝不改
|
||||
/// 字节流,纯 UI —— hover 高亮同理);
|
||||
/// - `.newInCwd` → T-iOS-29 的 `onNewInCwd`(当前会话 cwd fresh spawn);
|
||||
/// - `.kill` → `onKill`(wiring 侧走 `APIClient.killSession` —— 带
|
||||
/// Origin 的 G 端点,RO/G 铁律不变)。
|
||||
///
|
||||
/// 指针菜单仅在 iPad idiom 启用(`isPointerMenuEnabled`)—— iPhone 长按仍归
|
||||
/// SwiftTerm 选区手势,字节级零回归。
|
||||
|
||||
/// One context-menu action, each mapped to an existing channel (no new path).
|
||||
enum TerminalContextAction: String, CaseIterable, Sendable, Equatable {
|
||||
case copySelection
|
||||
case newInCwd
|
||||
case kill
|
||||
}
|
||||
|
||||
/// Display spec for one menu row (Chinese copy + SF Symbol + destructive flag).
|
||||
struct TerminalContextItem: Equatable, Sendable {
|
||||
let action: TerminalContextAction
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let isDestructive: Bool
|
||||
}
|
||||
|
||||
enum TerminalContextMenu {
|
||||
/// 用户可见文案(named constants,plan §4)。
|
||||
enum Copy {
|
||||
static let copySelection = "复制选区"
|
||||
static let newInCwd = "在当前目录开新会话"
|
||||
static let kill = "结束会话"
|
||||
}
|
||||
|
||||
private enum Symbol {
|
||||
static let copySelection = "doc.on.doc"
|
||||
static let newInCwd = "plus.rectangle.on.folder"
|
||||
static let kill = "xmark.circle"
|
||||
}
|
||||
|
||||
/// 指针上下文菜单仅在 iPad 启用。设备 idiom 与 size class 正交(不属
|
||||
/// `LayoutPolicy` 的 stack/split 判据),故独立成谓词;iPhone 上不安装
|
||||
/// interaction,长按保持 SwiftTerm 原生选区手势(零回归)。
|
||||
static func isPointerMenuEnabled(idiom: UIUserInterfaceIdiom) -> Bool {
|
||||
idiom == .pad
|
||||
}
|
||||
|
||||
/// 依可用性过滤出菜单项(固定顺序:复制选区 → 开新会话 → 结束会话)。纯函数。
|
||||
static func items(
|
||||
canCopySelection: Bool,
|
||||
canNewInCwd: Bool,
|
||||
canKill: Bool
|
||||
) -> [TerminalContextItem] {
|
||||
var result: [TerminalContextItem] = []
|
||||
if canCopySelection {
|
||||
result.append(TerminalContextItem(
|
||||
action: .copySelection, title: Copy.copySelection,
|
||||
systemImage: Symbol.copySelection, isDestructive: false
|
||||
))
|
||||
}
|
||||
if canNewInCwd {
|
||||
result.append(TerminalContextItem(
|
||||
action: .newInCwd, title: Copy.newInCwd,
|
||||
systemImage: Symbol.newInCwd, isDestructive: false
|
||||
))
|
||||
}
|
||||
if canKill {
|
||||
result.append(TerminalContextItem(
|
||||
action: .kill, title: Copy.kill,
|
||||
systemImage: Symbol.kill, isDestructive: true
|
||||
))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// 上下文菜单的可测装配核:把可用性 + 既有通道闭包收拢成 `items` 与 `perform`。
|
||||
/// wiring 供给 `onNewInCwd`(T-iOS-29)/ `onKill`(APIClient.killSession);
|
||||
/// `onCopySelection`/`hasSelection` 由终端视图注入。动作 ↔ 通道一一映射,无旁路。
|
||||
@MainActor
|
||||
struct TerminalContextMenuModel {
|
||||
let onNewInCwd: (@MainActor () -> Void)?
|
||||
let onKill: (@MainActor () -> Void)?
|
||||
let onCopySelection: @MainActor () -> Void
|
||||
let hasSelection: @MainActor () -> Bool
|
||||
|
||||
/// 当前可用的菜单项:复制选区随实时选区、开新会话/结束会话随 wiring 是否
|
||||
/// 供给了对应闭包(无闭包 = 隐藏,绝不呈现死项)。
|
||||
var items: [TerminalContextItem] {
|
||||
TerminalContextMenu.items(
|
||||
canCopySelection: hasSelection(),
|
||||
canNewInCwd: onNewInCwd != nil,
|
||||
canKill: onKill != nil
|
||||
)
|
||||
}
|
||||
|
||||
/// 把一个动作路由到它对应的既有通道(映射的唯一实现点)。
|
||||
func perform(_ action: TerminalContextAction) {
|
||||
switch action {
|
||||
case .copySelection:
|
||||
onCopySelection()
|
||||
case .newInCwd:
|
||||
onNewInCwd?()
|
||||
case .kill:
|
||||
onKill?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `UIContextMenuInteraction` 委托:菜单展开时用 `makeModel()` 取当前快照
|
||||
/// (实时选区/闭包),构建 `UIMenu`,每个 `UIAction` 触发 `model.perform`。
|
||||
/// 仅在 iPad 由终端视图安装(见 `TerminalContextMenu.isPointerMenuEnabled`)。
|
||||
@MainActor
|
||||
final class TerminalContextMenuInteractionDelegate: NSObject, UIContextMenuInteractionDelegate {
|
||||
private let makeModel: @MainActor () -> TerminalContextMenuModel
|
||||
|
||||
init(makeModel: @escaping @MainActor () -> TerminalContextMenuModel) {
|
||||
self.makeModel = makeModel
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
configurationForMenuAtLocation location: CGPoint
|
||||
) -> UIContextMenuConfiguration? {
|
||||
let model = makeModel()
|
||||
let items = model.items
|
||||
guard !items.isEmpty else { return nil } // 无可用项 → 不弹菜单
|
||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
|
||||
UIMenu(title: "", children: items.map { item in
|
||||
UIAction(
|
||||
title: item.title,
|
||||
image: UIImage(systemName: item.systemImage),
|
||||
attributes: item.isDestructive ? .destructive : []
|
||||
) { _ in
|
||||
model.perform(item.action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
251
ios/App/WebTerm/DeepLinkRouter.swift
Normal file
@@ -0,0 +1,251 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import OSLog
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-22 · Deep-link routing.
|
||||
///
|
||||
/// Two 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
|
||||
/// - the WEBTERM_GATE push payload `{sessionId}` (T-iOS-21 reuses
|
||||
/// `route(from:)` — same UUID rules, no second parser).
|
||||
///
|
||||
/// 安全注 (plan T-iOS-22): a deep link is EXTERNAL input. Every field is
|
||||
/// whitelist-validated (scheme / action / query keys / UUID v4 via the frozen
|
||||
/// `Validation.isValidSessionId` — never re-regexed); ANY invalid part →
|
||||
/// `.ignore` + counter (never crash, never partially apply). The host id
|
||||
/// resolves ONLY through a `HostStore` lookup (`DeepLinkHandler`) — no URL or
|
||||
/// request is ever built from link contents.
|
||||
enum DeepLinkRouter {
|
||||
/// Parse outcome. `.openSession` carries RAW validated ids — host
|
||||
/// EXISTENCE is resolved later against the store (`DeepLinkHandler`).
|
||||
enum Route: Equatable, Sendable {
|
||||
/// `webterminal://open` with both ids syntactically valid.
|
||||
case openSession(hostId: UUID, 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)
|
||||
/// Anything else. Callers count + log; they never partially apply.
|
||||
case ignore
|
||||
}
|
||||
|
||||
/// Whitelisted URL shape (`webterminal://open`). Scheme and URL host are
|
||||
/// case-insensitive per RFC 3986 → compared lowercased.
|
||||
private enum LinkShape {
|
||||
static let scheme = "webterminal"
|
||||
static let action = "open"
|
||||
static let emptyPaths: Set<String> = ["", "/"]
|
||||
}
|
||||
|
||||
/// Whitelisted query keys (exact match; unknown keys are ignored).
|
||||
private enum QueryKey {
|
||||
static let host = "host"
|
||||
static let join = "join"
|
||||
}
|
||||
|
||||
/// Root key of the APNs payload (server contract, T-iOS-20 final shape).
|
||||
private enum PushKey {
|
||||
static let sessionId = "sessionId"
|
||||
}
|
||||
|
||||
// MARK: - URL entry (onOpenURL)
|
||||
|
||||
static func route(url: URL) -> Route {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
components.scheme?.lowercased() == LinkShape.scheme,
|
||||
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)
|
||||
else { return .ignore }
|
||||
return .openSession(hostId: hostId, sessionId: sessionId)
|
||||
}
|
||||
|
||||
// MARK: - Push entry (WEBTERM_GATE payload, reused by T-iOS-21)
|
||||
|
||||
static func route(from payload: [AnyHashable: Any]) -> Route {
|
||||
guard let raw = payload[PushKey.sessionId] as? String,
|
||||
let sessionId = validatedId(raw)
|
||||
else { return .ignore }
|
||||
return .gateSession(sessionId: sessionId)
|
||||
}
|
||||
|
||||
// MARK: - Field validation
|
||||
|
||||
/// Exactly ONE occurrence of `key`, and its value is a v4 UUID.
|
||||
/// Duplicates are ambiguous input → nil (never partially apply).
|
||||
private static func uniqueValidatedId(in components: URLComponents, key: String) -> UUID? {
|
||||
let matches = (components.queryItems ?? []).filter { $0.name == key }
|
||||
guard matches.count == 1, let raw = matches.first?.value else { return nil }
|
||||
return validatedId(raw)
|
||||
}
|
||||
|
||||
/// Frozen-contract v4 check (`Validation.isValidSessionId`, plan §3.1)
|
||||
/// FIRST — `UUID(uuidString:)` alone would admit non-v4 ids the server's
|
||||
/// SESSION_ID_RE rejects.
|
||||
private static func validatedId(_ raw: String) -> UUID? {
|
||||
guard Validation.isValidSessionId(raw) else { return nil }
|
||||
return UUID(uuidString: raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing deep-link copy (plan §4: 显式、可操作的话术).
|
||||
enum DeepLinkCopy {
|
||||
static let hintTitle = "无法打开链接"
|
||||
static let unknownHostHint = "链接指向的主机尚未配对,请先扫码完成配对。"
|
||||
static let hostLoadFailed = "读取已配对主机失败,无法打开链接,请重进 App 后再试。"
|
||||
static let hintConfirm = "好"
|
||||
}
|
||||
|
||||
/// Applies parsed routes to the app: host-store lookup (the ONLY host-id
|
||||
/// resolution point), the cold-launch stash, the unknown-host → pairing hint,
|
||||
/// and the invalid-link counter. Pure routing stays in `DeepLinkRouter`; this
|
||||
/// class owns the stateful edges so `AppCoordinator`'s diff stays minimal.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DeepLinkHandler {
|
||||
/// Coordinator-provided effects (closures — the handler never reaches
|
||||
/// into navigation state itself).
|
||||
struct Actions {
|
||||
/// Open `sessionId` on a RESOLVED, store-known host.
|
||||
let openSession: @MainActor (HostRegistry.Host, UUID) -> Void
|
||||
/// Unknown host id → surface the pairing flow.
|
||||
let showPairing: @MainActor () -> Void
|
||||
}
|
||||
|
||||
/// Alert copy for RootView (unknown host / store failure). nil = no alert.
|
||||
private(set) var hintMessage: String?
|
||||
/// Invalid deep links dropped so far (plan: `.ignore` + log counter).
|
||||
private(set) var ignoredCount = 0
|
||||
|
||||
@ObservationIgnored private let loadHosts: @Sendable () async throws -> [HostRegistry.Host]
|
||||
@ObservationIgnored private let actions: Actions
|
||||
/// Cold-launch gate: `handle(url:)` before `markReady()` stashes instead
|
||||
/// of applying (the host list / root route may not exist yet).
|
||||
@ObservationIgnored private var isReady = false
|
||||
/// Single-slot stash — a newer link before readiness replaces the older
|
||||
/// one (two half-applied navigations would be worse than dropping one).
|
||||
@ObservationIgnored private var pendingRoute: DeepLinkRouter.Route?
|
||||
@ObservationIgnored private let logger = Logger(
|
||||
subsystem: DeepLinkLog.subsystem, category: DeepLinkLog.category
|
||||
)
|
||||
|
||||
init(
|
||||
loadHosts: @escaping @Sendable () async throws -> [HostRegistry.Host],
|
||||
actions: Actions
|
||||
) {
|
||||
self.loadHosts = loadHosts
|
||||
self.actions = actions
|
||||
}
|
||||
|
||||
// MARK: - Entry points
|
||||
|
||||
/// `.onOpenURL` lands here (via `AppCoordinator.handleDeepLink`). Invalid
|
||||
/// links are counted and dropped — they are NEVER stashed.
|
||||
func handle(url: URL) async {
|
||||
let route = DeepLinkRouter.route(url: url)
|
||||
guard route != .ignore else {
|
||||
recordIgnored()
|
||||
return
|
||||
}
|
||||
guard isReady else {
|
||||
pendingRoute = route
|
||||
return
|
||||
}
|
||||
await apply(route)
|
||||
}
|
||||
|
||||
/// Cold-start bootstrap finished (root route decided) → flush the stash.
|
||||
/// Idempotent: the slot is cleared before applying, so a second call
|
||||
/// never replays.
|
||||
func markReady() async {
|
||||
isReady = true
|
||||
guard let route = pendingRoute else { return }
|
||||
pendingRoute = nil
|
||||
await apply(route)
|
||||
}
|
||||
|
||||
func clearHint() {
|
||||
hintMessage = nil
|
||||
}
|
||||
|
||||
// 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 }
|
||||
do {
|
||||
let hosts = try await loadHosts()
|
||||
guard let host = hosts.first(where: { $0.id == hostId }) else {
|
||||
hintMessage = DeepLinkCopy.unknownHostHint
|
||||
actions.showPairing()
|
||||
return
|
||||
}
|
||||
actions.openSession(host, sessionId)
|
||||
} catch {
|
||||
// Explicit failure copy — a broken store must never look like a
|
||||
// silently dead link (plan §4 error-handling rule).
|
||||
hintMessage = DeepLinkCopy.hostLoadFailed
|
||||
logger.error("deep link host-store read failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func recordIgnored() {
|
||||
ignoredCount += 1
|
||||
// URL contents are untrusted external input — log the counter only,
|
||||
// never echo the link (log-injection / privacy hygiene).
|
||||
logger.notice("ignored invalid deep link (total: \(self.ignoredCount))")
|
||||
}
|
||||
}
|
||||
|
||||
private enum DeepLinkLog {
|
||||
static let subsystem = "com.yaojia.webterm"
|
||||
static let category = "deep-link"
|
||||
}
|
||||
|
||||
// MARK: - AppCoordinator wiring (kept here so the coordinator diff stays tiny)
|
||||
|
||||
extension AppCoordinator {
|
||||
/// Factory for the coordinator's lazy `deepLink` property.
|
||||
func makeDeepLinkHandler() -> DeepLinkHandler {
|
||||
DeepLinkHandler(
|
||||
loadHosts: { [environment] in try await environment.hostStore.loadAll() },
|
||||
actions: DeepLinkHandler.Actions(
|
||||
openSession: { [weak self] host, sessionId in
|
||||
self?.openDeepLinkedSession(host: host, sessionId: sessionId)
|
||||
},
|
||||
showPairing: { [weak self] in
|
||||
self?.showPairingForDeepLink()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// RootView's `.onOpenURL` entry (sync SwiftUI context → async apply).
|
||||
/// Fires for BOTH warm links and cold launches — on cold start the
|
||||
/// handler stashes until `bootstrap()` calls `markReady()`.
|
||||
func handleDeepLink(url: URL) {
|
||||
Task { await deepLink.handle(url: url) }
|
||||
}
|
||||
|
||||
/// Deep links may target a session while another terminal is open:
|
||||
/// detach the current one first (`open` is a no-op otherwise — its
|
||||
/// one-foreground-session guard would swallow the link).
|
||||
private func openDeepLinkedSession(host: HostRegistry.Host, sessionId: UUID) {
|
||||
if terminalController != nil {
|
||||
closeTerminal()
|
||||
}
|
||||
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
|
||||
}
|
||||
|
||||
/// Unknown host id → pairing. On the first-run pairing route the pairing
|
||||
/// screen is already frontmost — only the list route needs the sheet.
|
||||
private func showPairingForDeepLink() {
|
||||
guard route == .sessions else { return }
|
||||
presentAddHost()
|
||||
}
|
||||
}
|
||||
225
ios/App/WebTerm/DesignSystem/Primitives.swift
Normal file
@@ -0,0 +1,225 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// # Primitives — reusable SwiftUI building blocks (FROZEN public surface)
|
||||
///
|
||||
/// The four component groups compose these by exact name. Each pulls ALL of its
|
||||
/// constants from `DS`/`StatusStyle`/`DS.Typography` — no inline magic. Every
|
||||
/// primitive ships a `#Preview`.
|
||||
|
||||
// MARK: - StatusBadge
|
||||
|
||||
/// Color + distinct SF Symbol (+ optional Chinese label) for one status. The
|
||||
/// VoiceOver label is baked in from `StatusStyle`, so status is conveyed by
|
||||
/// shape, color AND speech. The symbol scales with Dynamic Type.
|
||||
struct StatusBadge: View {
|
||||
let status: DisplayStatus
|
||||
/// Show the Chinese word next to the symbol (list rows usually don't).
|
||||
var showsLabel: Bool = false
|
||||
|
||||
/// Convenience init from the wire enum.
|
||||
init(status: DisplayStatus, showsLabel: Bool = false) {
|
||||
self.status = status
|
||||
self.showsLabel = showsLabel
|
||||
}
|
||||
|
||||
init(claude: ClaudeStatus, showsLabel: Bool = false) {
|
||||
self.init(status: DisplayStatus(claude), showsLabel: showsLabel)
|
||||
}
|
||||
|
||||
private var style: StatusStyle { StatusStyle.style(for: status) }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DS.Space.xs4) {
|
||||
Image(systemName: style.symbolName)
|
||||
.foregroundStyle(style.color)
|
||||
.imageScale(.medium)
|
||||
if showsLabel {
|
||||
Text(style.label)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(style.label)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TelemetryChip
|
||||
|
||||
/// One pill of monospaced-tabular telemetry (context %, $cost, model, PR…).
|
||||
/// Greys out + desaturates when `isStale`; a `isWarning` chip switches to the
|
||||
/// waiting/amber semantic color for over-threshold context.
|
||||
struct TelemetryChip: View {
|
||||
/// Optional leading SF Symbol.
|
||||
var systemImage: String? = nil
|
||||
let text: String
|
||||
var isStale: Bool = false
|
||||
var isWarning: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DS.Space.xs2) {
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
}
|
||||
Text(text)
|
||||
}
|
||||
.font(DS.Typography.mono(.caption2))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(isWarning ? DS.Palette.statusWaiting : DS.Palette.textSecondary)
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.padding(.vertical, DS.Space.xs2)
|
||||
.background(.quaternary, in: Capsule())
|
||||
.opacity(isStale ? DS.Opacity.stale : 1)
|
||||
.grayscale(isStale ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card
|
||||
|
||||
/// Standard card container: card surface + hairline stroke + `md12` radius +
|
||||
/// standard padding. The uniform card spec for rows, grid cells and panels.
|
||||
struct Card<Content: View>: View {
|
||||
/// Inner padding (defaults to `md12`; pass `sm8` for tight rows).
|
||||
var padding: CGFloat = DS.Space.md12
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
init(padding: CGFloat = DS.Space.md12, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.padding = padding
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.padding(padding)
|
||||
.background(DS.Palette.card, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||||
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SectionHeader
|
||||
|
||||
/// A small, secondary section label (Chinese copy passed verbatim by callers).
|
||||
struct SectionHeader: View {
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DSButtonStyle
|
||||
|
||||
/// The App's button style. `primary` = accent-filled, `secondary` = tinted
|
||||
/// outline, `destructive` = red-filled. Always ≥ `minHitTarget` tall, full
|
||||
/// width, `md12` radius. Press feedback honors Reduce Motion.
|
||||
struct DSButtonStyle: ButtonStyle {
|
||||
enum Kind { case primary, secondary, destructive }
|
||||
var kind: Kind = .primary
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
DSButtonBody(kind: kind, configuration: configuration)
|
||||
}
|
||||
|
||||
/// Nested view so we can read `@Environment` (a `ButtonStyle` cannot).
|
||||
/// Must be as accessible as `DSButtonStyle` (opaque `makeBody` requirement).
|
||||
struct DSButtonBody: View {
|
||||
let kind: Kind
|
||||
let configuration: Configuration
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
var body: some View {
|
||||
configuration.label
|
||||
.font(DS.Typography.body.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
|
||||
.foregroundStyle(foreground)
|
||||
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
.overlay(border)
|
||||
.opacity(opacity)
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.fast, reduceMotion: reduceMotion),
|
||||
value: configuration.isPressed
|
||||
)
|
||||
}
|
||||
|
||||
private var foreground: Color {
|
||||
switch kind {
|
||||
// Gold accent fill needs DARK ink for contrast (mirrors web
|
||||
// --on-accent), NOT white. Red destructive fill keeps white.
|
||||
case .primary: return DS.Palette.onAccent
|
||||
case .destructive: return .white
|
||||
case .secondary: return DS.Palette.accent
|
||||
}
|
||||
}
|
||||
|
||||
private var background: Color {
|
||||
switch kind {
|
||||
case .primary: return DS.Palette.accent
|
||||
case .destructive: return DS.Palette.statusStuck
|
||||
case .secondary: return DS.Palette.card
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var border: some View {
|
||||
if kind == .secondary {
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||||
.strokeBorder(DS.Palette.accent, lineWidth: DS.Stroke.hairline)
|
||||
}
|
||||
}
|
||||
|
||||
private var opacity: Double {
|
||||
if !isEnabled { return DS.Opacity.pressed }
|
||||
return configuration.isPressed ? DS.Opacity.pressed : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("StatusBadge") {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
ForEach(DisplayStatus.allCases, id: \.self) { status in
|
||||
StatusBadge(status: status, showsLabel: true)
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
|
||||
#Preview("TelemetryChip") {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
TelemetryChip(text: "ctx 92%", isWarning: true)
|
||||
TelemetryChip(text: "$0.1234")
|
||||
TelemetryChip(systemImage: "cpu", text: "opus")
|
||||
TelemetryChip(text: "PR #7", isStale: true)
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
|
||||
#Preview("Card") {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.sm8) {
|
||||
SectionHeader(title: "会话")
|
||||
Text(verbatim: "web-terminal")
|
||||
.font(DS.Typography.headline)
|
||||
Text(verbatim: "2 台设备在看 · 161×50")
|
||||
.dsMetaText()
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
|
||||
#Preview("DSButtonStyle") {
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
Button("新建会话") {}.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
Button("继续上次会话") {}.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
Button("结束会话") {}.buttonStyle(DSButtonStyle(kind: .destructive))
|
||||
}
|
||||
.tint(DS.Palette.accent)
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
86
ios/App/WebTerm/DesignSystem/StatusStyle.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// # Status visuals — the SINGLE source (FROZEN public surface)
|
||||
///
|
||||
/// Every place that shows a session's Claude-Code status (list rows, badges,
|
||||
/// thumbnails, project-detail rows, banners) resolves it through here, so the
|
||||
/// color + shape + label stay identical everywhere. Status is expressed as
|
||||
/// **color AND a distinct SF Symbol** — never color alone (accessibility;
|
||||
/// color-blind users read the shape). Labels are Chinese for VoiceOver + UI.
|
||||
|
||||
/// The seven visual states a status indicator can show. A superset of the wire
|
||||
/// `ClaudeStatus` (working/waiting/idle/unknown/stuck) plus two App-layer
|
||||
/// emphasis states:
|
||||
/// - `pendingApproval` — a tool/plan gate is held server-side ("needs me").
|
||||
/// Outranks status; mirrors `SessionListViewModel.Indicator.pendingApproval`
|
||||
/// (this type does NOT re-implement that priority — callers decide when to
|
||||
/// use it; here it's only its visual identity).
|
||||
/// - `exited` — the session is over (read-only).
|
||||
enum DisplayStatus: CaseIterable, Sendable, Equatable {
|
||||
case working
|
||||
case waiting
|
||||
case idle
|
||||
case stuck
|
||||
case unknown
|
||||
case pendingApproval
|
||||
case exited
|
||||
|
||||
/// Bridge from the wire enum. Pure — no pending/exited emphasis (callers
|
||||
/// supply those explicitly, matching the VM's own indicator priority).
|
||||
init(_ claude: ClaudeStatus) {
|
||||
switch claude {
|
||||
case .working: self = .working
|
||||
case .waiting: self = .waiting
|
||||
case .idle: self = .idle
|
||||
case .stuck: self = .stuck
|
||||
case .unknown: self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved visuals for one status: a semantic color, a DISTINCT SF Symbol
|
||||
/// shape, and a Chinese label (used as the VoiceOver string too). The mapping
|
||||
/// itself is pure — no UIKit, deterministic, unit-testable.
|
||||
struct StatusStyle: Equatable, Sendable {
|
||||
/// Semantic color from `DS.Palette` (never the sole signal — see `symbolName`).
|
||||
let color: Color
|
||||
/// SF Symbol name. Distinct across all seven statuses so shape alone
|
||||
/// disambiguates (color-blind safe).
|
||||
let symbolName: String
|
||||
/// Chinese status word — shown as an optional label and always as the
|
||||
/// accessibility label.
|
||||
let label: String
|
||||
|
||||
/// The frozen mapping. Each status → (color, distinct symbol, 中文 label).
|
||||
static func style(for status: DisplayStatus) -> StatusStyle {
|
||||
switch status {
|
||||
case .working:
|
||||
// solid filled circle — actively running
|
||||
return StatusStyle(color: DS.Palette.statusWorking, symbolName: "circle.fill", label: "运行中")
|
||||
case .waiting:
|
||||
// clock — waiting on something
|
||||
return StatusStyle(color: DS.Palette.statusWaiting, symbolName: "clock.fill", label: "等待中")
|
||||
case .idle:
|
||||
// hollow circle — quiet/empty (gray, distinct from working's fill)
|
||||
return StatusStyle(color: DS.Palette.statusIdle, symbolName: "circle", label: "空闲")
|
||||
case .stuck:
|
||||
// triangle — alarm
|
||||
return StatusStyle(color: DS.Palette.statusStuck, symbolName: "exclamationmark.triangle.fill", label: "卡住")
|
||||
case .unknown:
|
||||
// question mark — no signal yet
|
||||
return StatusStyle(color: DS.Palette.statusUnknown, symbolName: "questionmark.circle", label: "未知")
|
||||
case .pendingApproval:
|
||||
// filled ! circle — "needs me", the single most important prompt
|
||||
return StatusStyle(color: DS.Palette.statusWaiting, symbolName: "exclamationmark.circle.fill", label: "等待审批")
|
||||
case .exited:
|
||||
// checkered flag — session over (mirrors ReconnectBanner's exited icon)
|
||||
return StatusStyle(color: DS.Palette.statusExited, symbolName: "flag.checkered", label: "已退出")
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience bridge from the wire enum.
|
||||
static func style(for claude: ClaudeStatus) -> StatusStyle {
|
||||
style(for: DisplayStatus(claude))
|
||||
}
|
||||
}
|
||||
218
ios/App/WebTerm/DesignSystem/Tokens.swift
Normal file
@@ -0,0 +1,218 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// # WebTerm Design System — token vocabulary (FROZEN public surface)
|
||||
///
|
||||
/// The single source of truth for every visual constant in the App layer.
|
||||
/// Screens/components must reference these tokens — never inline colors,
|
||||
/// spacings, radii, opacities, durations or haptics. Direction: "精致原生"
|
||||
/// (refined native, Apple HIG), dark-mode-first, amber-gold accent (desktop-matched)
|
||||
/// continuing the web selection color.
|
||||
///
|
||||
/// 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,
|
||||
/// see `StatusStyle`) · `surface`/`card`/`hairline` surfaces ·
|
||||
/// `textPrimary`/`textSecondary`/`textTertiary`.
|
||||
/// - `Space` — 2·4·8·12·16·20·24 scale (`xs2`…`xxl24`). No off-scale gaps.
|
||||
/// - `Radius` — `sm8`/`md12`/`lg16`/`pill`.
|
||||
/// - `Stroke` — `hairline` (1pt border width).
|
||||
/// - `Opacity` — `stale`/`exited`/`pressed` dimming multipliers.
|
||||
/// - `Layout` — `minHitTarget` (44pt HIG minimum).
|
||||
/// - `Motion` — `fast`/`base` eased animations + `gated(_:reduceMotion:)`
|
||||
/// which collapses to `nil` under Reduce Motion.
|
||||
/// - `Haptics` — `selection`/`success`/`warning` (`@MainActor`).
|
||||
///
|
||||
/// Companion files: `Typography.swift` (`DS.Typography`), `StatusStyle.swift`
|
||||
/// (`StatusStyle` / `DisplayStatus`), `Primitives.swift` (reusable views).
|
||||
enum DS {
|
||||
|
||||
// MARK: - Palette
|
||||
|
||||
/// Colors. `accent` is asset-free adaptive (no `.xcassets`); the semantic
|
||||
/// status colors use the direction's exact hex so light/dark stay on-brand.
|
||||
enum Palette {
|
||||
|
||||
// ── Accent (amber gold — matches the desktop/web theme) ─────────────
|
||||
// The desktop web/Electron UI uses --accent #E3A64A ("amber gold",
|
||||
// public/style.css:14) on a warm near-neutral dark surface. We match it.
|
||||
// Adaptive: dark = #E3A64A (gold), light = #C9892F (deeper gold =
|
||||
// --accent-2) for adequate contrast on a light background. Used
|
||||
// sparingly — primary actions, selection, active state only. Gold needs
|
||||
// DARK text on top → use `onAccent`, never white/textPrimary.
|
||||
|
||||
/// The one accent token. Inject once at the root via `.tint(DS.Palette.accent)`.
|
||||
static let accent = Color(uiColor: accentUIColor())
|
||||
|
||||
/// The accent as a dynamic `UIColor`. Exposed so tests can resolve the
|
||||
/// two schemes deterministically (`resolvedColor(with:)`) without going
|
||||
/// through the SwiftUI→UIKit bridge.
|
||||
static func accentUIColor() -> UIColor {
|
||||
UIColor { trait in
|
||||
trait.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0xE3 / 255.0, green: 0xA6 / 255.0, blue: 0x4A / 255.0, alpha: 1) // #E3A64A --accent
|
||||
: UIColor(red: 0xC9 / 255.0, green: 0x89 / 255.0, blue: 0x2F / 255.0, alpha: 1) // #C9892F --accent-2
|
||||
}
|
||||
}
|
||||
|
||||
/// Text/icon color to place ON an accent-filled surface (gold needs dark
|
||||
/// 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)
|
||||
|
||||
// ── 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)
|
||||
/// idle — quiet secondary gray (distinguished from `unknown` by shape).
|
||||
static let statusIdle = Color.secondary
|
||||
/// unknown / no signal yet — gray.
|
||||
static let statusUnknown = Color.gray
|
||||
/// exited — dimmed secondary (apply `Opacity.exited` on the container).
|
||||
static let statusExited = Color.secondary
|
||||
|
||||
// ── Timeline event classes (T-iOS-24) ──────────────────────────────
|
||||
// Semantic colors for the activity-timeline event classes. `waiting`
|
||||
// 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)
|
||||
|
||||
// ── Surfaces & text ────────────────────────────────────────────────
|
||||
|
||||
/// Base screen background.
|
||||
static let surface = Color(uiColor: .systemBackground)
|
||||
/// Card / grouped-content background (a step up from `surface`).
|
||||
static let card = Color(uiColor: .secondarySystemBackground)
|
||||
/// Hairline separator/border color.
|
||||
static let hairline = Color(uiColor: .separator)
|
||||
/// Primary label color.
|
||||
static let textPrimary = Color.primary
|
||||
/// Secondary label color (meta, captions).
|
||||
static let textSecondary = Color.secondary
|
||||
/// 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)
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Space
|
||||
|
||||
/// Spacing scale — 2·4·8·12·16·20·24. Every gap/padding picks one of these;
|
||||
/// there are no in-between values (the audit's 3/5/6/10/14 all round here).
|
||||
enum Space {
|
||||
static let xs2: CGFloat = 2
|
||||
static let xs4: CGFloat = 4
|
||||
static let sm8: CGFloat = 8
|
||||
static let md12: CGFloat = 12
|
||||
static let lg16: CGFloat = 16
|
||||
static let xl20: CGFloat = 20
|
||||
static let xxl24: CGFloat = 24
|
||||
}
|
||||
|
||||
// MARK: - Radius
|
||||
|
||||
/// Corner radii. Legacy 6/10 both fold into `sm8`/`md12`.
|
||||
enum Radius {
|
||||
static let sm8: CGFloat = 8
|
||||
static let md12: CGFloat = 12
|
||||
static let lg16: CGFloat = 16
|
||||
/// Fully-rounded (capsule/pill).
|
||||
static let pill: CGFloat = 999
|
||||
}
|
||||
|
||||
// MARK: - Stroke
|
||||
|
||||
/// Border widths.
|
||||
enum Stroke {
|
||||
/// Hairline card/overlay border.
|
||||
static let hairline: CGFloat = 1
|
||||
}
|
||||
|
||||
// MARK: - Opacity
|
||||
|
||||
/// Dimming multipliers (used with `.opacity()`, sometimes `.grayscale()`).
|
||||
enum Opacity {
|
||||
/// Telemetry gone stale (past its TTL).
|
||||
static let stale: Double = 0.45
|
||||
/// A session that has exited.
|
||||
static let exited: Double = 0.55
|
||||
/// Pressed-state feedback on buttons.
|
||||
static let pressed: Double = 0.72
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
/// Layout constants that are not spacings.
|
||||
enum Layout {
|
||||
/// HIG minimum touch target (44×44pt).
|
||||
static let minHitTarget: CGFloat = 44
|
||||
}
|
||||
|
||||
// MARK: - Motion
|
||||
|
||||
/// Animation tokens. Subtle, eased (~0.18–0.25s). ALWAYS route through
|
||||
/// `gated(_:reduceMotion:)` so Reduce Motion collapses to no motion.
|
||||
enum Motion {
|
||||
static let fastDuration: Double = 0.18
|
||||
static let baseDuration: Double = 0.25
|
||||
|
||||
/// Quick affordance (chips, presses).
|
||||
static let fast = Animation.easeInOut(duration: fastDuration)
|
||||
/// Standard transition (banners, sheets, list changes).
|
||||
static let base = Animation.easeInOut(duration: baseDuration)
|
||||
|
||||
/// Returns `animation` normally, or `nil` (instant, no motion) when
|
||||
/// Reduce Motion is enabled. Callers pass the environment flag.
|
||||
static func gated(_ animation: Animation, reduceMotion: Bool) -> Animation? {
|
||||
reduceMotion ? nil : animation
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Haptics
|
||||
|
||||
/// Tactile feedback for key interactions. `@MainActor` — the underlying
|
||||
/// UIKit generators must be touched on the main thread. Additive to the
|
||||
/// existing `GateViewModel` gate-arrival haptic (different call sites).
|
||||
@MainActor
|
||||
enum Haptics {
|
||||
/// Light selection tap — opening a session, tapping a key.
|
||||
static func selection() {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
|
||||
/// Success notification — a gate approve resolved.
|
||||
static func success() {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
}
|
||||
|
||||
/// Warning notification — a destructive/reject decision.
|
||||
static func warning() {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
ios/App/WebTerm/DesignSystem/Typography.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
/// # Typography — the SF type ramp (FROZEN public surface)
|
||||
///
|
||||
/// All font choices come from `DS.Typography`. The ramp maps to Apple's
|
||||
/// semantic text styles so everything scales with Dynamic Type (the a11y
|
||||
/// audit's "keep it scalable" point). Numbers/dimensions/cost/`cols×rows`/
|
||||
/// timestamps use `mono(_:)` — SF Mono with tabular figures — so columns line
|
||||
/// up and digits don't jitter as values change (matches the existing
|
||||
/// `TelemetryChips`/`Timeline` convention, now centralized).
|
||||
extension DS {
|
||||
enum Typography {
|
||||
|
||||
// ── Proportional ramp (Dynamic-Type scaling, semantic styles) ───────
|
||||
|
||||
/// Screen hero title.
|
||||
static let largeTitle = Font.largeTitle
|
||||
/// Section / prominent title.
|
||||
static let title = Font.title2
|
||||
/// Emphasis / card heading.
|
||||
static let headline = Font.headline
|
||||
/// Default body text.
|
||||
static let body = Font.body
|
||||
/// Slightly smaller body (secondary actions).
|
||||
static let callout = Font.callout
|
||||
/// Meta / caption text.
|
||||
static let caption = Font.caption
|
||||
|
||||
// ── Monospaced (numbers, dimensions, timestamps) ────────────────────
|
||||
|
||||
/// SF Mono + tabular figures at the given text style (default `.body`).
|
||||
/// Use for anything numeric that must align or not jump: `cols×rows`,
|
||||
/// device/client counts, `$cost`, context %, relative timestamps.
|
||||
static func mono(_ style: Font.TextStyle = .body) -> Font {
|
||||
.system(style, design: .monospaced).monospacedDigit()
|
||||
}
|
||||
|
||||
/// The canonical meta-number font: caption-sized mono + tabular.
|
||||
static let metaMono = mono(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MetaText style
|
||||
|
||||
/// Secondary + monospaced-tabular styling for a row's numeric meta line
|
||||
/// ("N 台设备在看 · 161×50"). Apply via `.dsMetaText()`.
|
||||
struct MetaText: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(DS.Typography.metaMono)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Style a meta/number line as secondary monospaced-tabular text.
|
||||
func dsMetaText() -> some View {
|
||||
modifier(MetaText())
|
||||
}
|
||||
}
|
||||
234
ios/App/WebTerm/DiffFetcher.swift
Normal file
@@ -0,0 +1,234 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// T-iOS-27 · App-layer fetcher for `GET /projects/diff?path=&staged=` —— the
|
||||
// read-only structured git diff (server route src/server.ts:601-618, parser
|
||||
// src/http/diff.ts). iOS mirrors the web split: parsing lives ONLY server-side;
|
||||
// this file fetches + tolerantly decodes, the screen renders verbatim.
|
||||
//
|
||||
// 为什么不在 APIClient 包里:APIClient 是 T-iOS-38 单一 owner(PLAN §6 Owns
|
||||
// 铁律),本任务不得改包。此 fetcher 走同一枚注入的 `HTTPTransport`,语义与
|
||||
// APIClient 路由构建完全同构 —— **回收进 APIClient 是 T-iOS-38 owner 的
|
||||
// follow-up**(届时本文件的路由/编码/解码可整体搬迁,测试随行)。
|
||||
//
|
||||
// Origin 铁律(plan §3.4/§5.1,iff-G):`/projects/diff` 是 RO 端点 —— 路由层
|
||||
// 没有 requireAllowedOrigin(src/server.ts:601 注释 "no Origin guard; same
|
||||
// threat model as /projects"),因此请求**绝不携带 Origin**。若服务器未来把它
|
||||
// 改为 G,测试先红而不是靠巧合通过。
|
||||
//
|
||||
// 服务器是不可信输入源(plan §4):解码逐层宽容,镜像 web 的
|
||||
// normalizeDiffResult(public/diff.ts:38-102)—— 顶层三键缺一 → 整体无效;
|
||||
// 畸形 file/hunk/line 逐条丢弃;未知枚举值降级(kind → .context、status →
|
||||
// .modified),绝不 crash。
|
||||
|
||||
// MARK: - 模型(镜像 src/types.ts:445-479;App 内部,待 T-iOS-38 回收)
|
||||
|
||||
/// One diff line's semantic kind. Unknown wire values degrade to `.context`
|
||||
/// (the web renderer's `?? 'df-context'` fallback, public/diff.ts:192).
|
||||
enum DiffLineKind: String, Sendable, Equatable {
|
||||
case added, removed, context, hunk, meta
|
||||
}
|
||||
|
||||
struct DiffLine: Sendable, Equatable {
|
||||
let kind: DiffLineKind
|
||||
/// Verbatim server bytes (marker already stripped server-side). UNTRUSTED
|
||||
/// display input — render via `Text(verbatim:)` only, never Markdown/links.
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct DiffHunk: Sendable, Equatable {
|
||||
let header: String
|
||||
let lines: [DiffLine]
|
||||
}
|
||||
|
||||
/// File-level change status. Unknown wire values degrade to `.modified`
|
||||
/// (display-only field; the web keeps the raw string for a CSS class).
|
||||
enum DiffFileStatus: String, Sendable, Equatable {
|
||||
case modified, added, deleted, renamed, binary, untracked
|
||||
}
|
||||
|
||||
struct DiffFile: Sendable, Equatable {
|
||||
let oldPath: String
|
||||
let newPath: String
|
||||
let status: DiffFileStatus
|
||||
let added: Int
|
||||
let removed: Int
|
||||
let binary: Bool
|
||||
let hunks: [DiffHunk]
|
||||
}
|
||||
|
||||
struct DiffResult: Sendable, Equatable {
|
||||
let files: [DiffFile]
|
||||
let staged: Bool
|
||||
let truncated: Bool
|
||||
}
|
||||
|
||||
// MARK: - 宽容解码(Decodable 在 extension 里,保留成员逐一初始化器)
|
||||
|
||||
extension DiffLine: Decodable {
|
||||
private enum CodingKeys: String, CodingKey { case kind, text }
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// kind/text 必须是字符串,否则整行丢弃(镜像 normalizeLine)。
|
||||
let rawKind = try container.decode(String.self, forKey: .kind)
|
||||
text = try container.decode(String.self, forKey: .text)
|
||||
kind = DiffLineKind(rawValue: rawKind) ?? .context
|
||||
}
|
||||
}
|
||||
|
||||
extension DiffHunk: Decodable {
|
||||
private enum CodingKeys: String, CodingKey { case header, lines }
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
header = try container.decode(String.self, forKey: .header)
|
||||
let boxes = try container.decode([DiffLossyBox<DiffLine>].self, forKey: .lines)
|
||||
lines = boxes.compactMap(\.value)
|
||||
}
|
||||
}
|
||||
|
||||
extension DiffFile: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case oldPath, newPath, status, added, removed, binary, hunks
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
oldPath = try container.decode(String.self, forKey: .oldPath)
|
||||
newPath = try container.decode(String.self, forKey: .newPath)
|
||||
added = try container.decode(Int.self, forKey: .added)
|
||||
removed = try container.decode(Int.self, forKey: .removed)
|
||||
binary = try container.decode(Bool.self, forKey: .binary)
|
||||
let rawStatus = try container.decode(String.self, forKey: .status)
|
||||
status = DiffFileStatus(rawValue: rawStatus) ?? .modified
|
||||
let boxes = try container.decode([DiffLossyBox<DiffHunk>].self, forKey: .hunks)
|
||||
hunks = boxes.compactMap(\.value)
|
||||
}
|
||||
}
|
||||
|
||||
extension DiffResult: Decodable {
|
||||
private enum CodingKeys: String, CodingKey { case files, staged, truncated }
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
staged = try container.decode(Bool.self, forKey: .staged)
|
||||
truncated = try container.decode(Bool.self, forKey: .truncated)
|
||||
let boxes = try container.decode([DiffLossyBox<DiffFile>].self, forKey: .files)
|
||||
files = boxes.compactMap(\.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-element tolerance shim: a malformed element becomes nil instead of
|
||||
/// failing the whole array. Local twin of APIClient's internal `LossyBox`
|
||||
/// (not importable across the package boundary) — folds together with the
|
||||
/// fetcher in the T-iOS-38 follow-up.
|
||||
private struct DiffLossyBox<Wrapped: Decodable>: Decodable {
|
||||
let value: Wrapped?
|
||||
|
||||
init(from decoder: any Decoder) {
|
||||
value = try? Wrapped(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 错误分类学(route 层语义,src/server.ts:602-618)
|
||||
|
||||
enum DiffFetchError: Error, Equatable {
|
||||
/// 空 path / 无法编码 / URL 构建失败 —— 客户端先拒,零网络。
|
||||
case invalidRequest
|
||||
/// 服务器 400:`path query parameter is required`。
|
||||
case pathInvalid
|
||||
/// 服务器 404:SEC-H7 三叉校验失败(绝对路径 + 目录 + 有 .git)。
|
||||
case projectNotFound
|
||||
/// 200 但响应体不是 DiffResult 形状(不可信输入源兜底)。
|
||||
case invalidResponse
|
||||
/// 其余状态(含 500 `failed to read diff`)。
|
||||
case unexpectedStatus(Int)
|
||||
}
|
||||
|
||||
// MARK: - DiffFetcher
|
||||
|
||||
/// Fetch a repo's structured diff over the injected transport. Immutable
|
||||
/// value; one instance per (endpoint) — the screen's VM closes over it.
|
||||
struct DiffFetcher: Sendable {
|
||||
let endpoint: HostEndpoint
|
||||
private let http: any HTTPTransport
|
||||
|
||||
/// Route constants(无魔法字符串)。`stagedFlag`:服务器精确匹配
|
||||
/// `req.query['staged'] === '1'`(src/server.ts:613)——必须发 `1`/`0`。
|
||||
/// (对照:web fetchDiff 发 `true`/`false`,永远匹配不上 `'1'` ——
|
||||
/// staged 开关在 web 端静默失效,已在任务日志上报,不属本任务 Owns。)
|
||||
private enum Route {
|
||||
static let path = "/projects/diff"
|
||||
static let pathKey = "path"
|
||||
static let stagedKey = "staged"
|
||||
static let stagedOn = "1"
|
||||
static let stagedOff = "0"
|
||||
static let methodGet = "GET"
|
||||
}
|
||||
|
||||
private enum Status {
|
||||
static let ok = 200
|
||||
static let badRequest = 400
|
||||
static let notFound = 404
|
||||
}
|
||||
|
||||
/// Strict RFC 3986 unreserved set —— 与 APIClient `Endpoints.
|
||||
/// unreservedCharacters` 同值同理(裸 `+` 会被 Express qs 解码成空格,
|
||||
/// `&`/`=` 会拆参);该常量在包内 internal 不可 import,T-iOS-38 回收时合并。
|
||||
private static let unreservedCharacters = CharacterSet(
|
||||
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
|
||||
init(endpoint: HostEndpoint, http: any HTTPTransport) {
|
||||
self.endpoint = endpoint
|
||||
self.http = http
|
||||
}
|
||||
|
||||
/// `GET /projects/diff?path=&staged=` → 宽容解码后的 `DiffResult`。
|
||||
/// 空 path 客户端先拒(镜像服务器 400 规则),不产生网络 I/O。
|
||||
func fetch(path: String, staged: Bool) async throws -> DiffResult {
|
||||
guard !path.isEmpty, let request = buildRequest(path: path, staged: staged) else {
|
||||
throw DiffFetchError.invalidRequest
|
||||
}
|
||||
let (data, response) = try await http.send(request)
|
||||
switch response.statusCode {
|
||||
case Status.ok:
|
||||
guard let result = try? JSONDecoder().decode(DiffResult.self, from: data) else {
|
||||
throw DiffFetchError.invalidResponse
|
||||
}
|
||||
return result
|
||||
case Status.badRequest:
|
||||
throw DiffFetchError.pathInvalid
|
||||
case Status.notFound:
|
||||
throw DiffFetchError.projectNotFound
|
||||
default:
|
||||
throw DiffFetchError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the RO GET —— 与 APIClient `APIRoute.urlRequest(for:)` 同派生
|
||||
/// 哲学:baseURL 只取 scheme/host/port,path/query 整体替换,fragment 与
|
||||
/// 凭据丢弃。**不 stamp Origin**(iff-G,见文件头)。
|
||||
private func buildRequest(path: String, staged: Bool) -> URLRequest? {
|
||||
guard let encodedPath = path.addingPercentEncoding(
|
||||
withAllowedCharacters: Self.unreservedCharacters
|
||||
) else { return nil }
|
||||
guard var components = URLComponents(
|
||||
url: endpoint.baseURL, resolvingAgainstBaseURL: true
|
||||
) else { return nil }
|
||||
components.path = Route.path
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
components.user = nil
|
||||
components.password = nil
|
||||
let stagedValue = staged ? Route.stagedOn : Route.stagedOff
|
||||
components.percentEncodedQuery =
|
||||
"\(Route.pathKey)=\(encodedPath)&\(Route.stagedKey)=\(stagedValue)"
|
||||
guard let url = components.url else { return nil }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = Route.methodGet
|
||||
return request
|
||||
}
|
||||
}
|
||||
310
ios/App/WebTerm/Push/NotificationActionHandler.swift
Normal file
@@ -0,0 +1,310 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import OSLog
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-21 · 通知动作处理:锁屏 Allow/Deny(系统**后台拉起主 App** 并送达
|
||||
/// `UNUserNotificationCenterDelegate.didReceive`——本工程无 notification
|
||||
/// extension target,Service Extension 也收不到 action tap)与默认点按路由。
|
||||
///
|
||||
/// 安全纪律:
|
||||
/// - push payload 是**不可信外部输入**——sessionId 经 `DeepLinkRouter.route(from:)`
|
||||
/// (冻结的 v4 白名单,不再造第二个解析器),capability token 校验 v4 形状
|
||||
/// (服务器 `randomUUID()` 生成,src/server.ts:445)后**原样透传**(服务器做
|
||||
/// 字节级比对),任一非法 → `.invalidPayload` 丢弃计数,绝不部分应用;
|
||||
/// - token 用后即弃:只存在于解析值 → `hookDecision` 调用参数,绝不落盘、
|
||||
/// 绝不进任何兜底文案/日志;
|
||||
/// - POST 全程包在 begin/endBackgroundTask 里,didReceive 的 async 返回
|
||||
/// (= completionHandler)在 POST settle 之后;
|
||||
/// - 失败必须可见:403(token 过期/已用)与传输失败都补一条本地通知,绝不静默吞。
|
||||
|
||||
// MARK: - Seams
|
||||
|
||||
/// Seam over `UIApplication.beginBackgroundTask`/`endBackgroundTask`.
|
||||
@MainActor
|
||||
protocol BackgroundTaskRunning: AnyObject {
|
||||
/// Returns an opaque token for `end(_:)`(生产映射 UIBackgroundTaskIdentifier)。
|
||||
func begin(name: String) -> Int
|
||||
func end(_ token: Int)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class UIApplicationBackgroundTaskRunner: BackgroundTaskRunning {
|
||||
private var active: [Int: UIBackgroundTaskIdentifier] = [:]
|
||||
|
||||
func begin(name: String) -> Int {
|
||||
var identifier = UIBackgroundTaskIdentifier.invalid
|
||||
identifier = UIApplication.shared.beginBackgroundTask(withName: name) {
|
||||
// 系统到期回调在主线程同步调用(UIKit 文档保证)。
|
||||
MainActor.assumeIsolated { self.expire(identifier) }
|
||||
}
|
||||
active[identifier.rawValue] = identifier
|
||||
return identifier.rawValue
|
||||
}
|
||||
|
||||
func end(_ token: Int) {
|
||||
guard let identifier = active.removeValue(forKey: token) else { return }
|
||||
UIApplication.shared.endBackgroundTask(identifier)
|
||||
}
|
||||
|
||||
private func expire(_ identifier: UIBackgroundTaskIdentifier) {
|
||||
guard identifier != .invalid, active.removeValue(forKey: identifier.rawValue) != nil else {
|
||||
return
|
||||
}
|
||||
UIApplication.shared.endBackgroundTask(identifier)
|
||||
}
|
||||
}
|
||||
|
||||
/// Seam for the local-notification fallback(决策失败可见性)。
|
||||
@MainActor
|
||||
protocol LocalNoticePosting: AnyObject {
|
||||
func post(title: String, body: String) async
|
||||
}
|
||||
|
||||
extension UNNotificationCenterAdapter: LocalNoticePosting {
|
||||
func post(title: String, body: String) async {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
let request = UNNotificationRequest(
|
||||
identifier: UUID().uuidString, content: content, trigger: nil
|
||||
)
|
||||
do {
|
||||
try await add(request)
|
||||
} catch {
|
||||
// 最后的兜底也失败:只剩日志(不能再用通知报告通知失败)。
|
||||
Logger(subsystem: PushLog.subsystem, category: PushLog.actionHandler)
|
||||
.error("fallback local notice failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户可见兜底文案(payload 最小化:不含 token、不含会话内容)。
|
||||
enum PushDecisionCopy {
|
||||
static let expiredTitle = "审批已失效"
|
||||
static let expiredBody = "该次批准/拒绝已过期或已被处理,请打开 App 在会话中查看。"
|
||||
static let failedTitle = "审批发送失败"
|
||||
static let failedBody = "无法联系主机,请打开 App 在会话中处理。"
|
||||
}
|
||||
|
||||
/// `parse` 的白名单产物(Sendable——didReceive 在 nonisolated 上下文解析后
|
||||
/// 才跨进 MainActor,UN 对象绝不跨 isolation)。
|
||||
enum ParsedNotificationAction: Sendable, Equatable {
|
||||
/// Allow/Deny 动作按钮 + 合法 `{sessionId, token}`。
|
||||
case decision(HookDecision, sessionId: UUID, token: String)
|
||||
/// 默认点按(含 done 类通知)+ 合法 sessionId。
|
||||
case openSession(sessionId: UUID)
|
||||
/// 目标动作的 payload 校验失败 → 丢弃计数(镜像"非法帧静默丢弃")。
|
||||
case invalidPayload
|
||||
/// dismiss / 未知动作 id → no-op。
|
||||
case dismissed
|
||||
}
|
||||
|
||||
// MARK: - Handler
|
||||
|
||||
@MainActor
|
||||
final class NotificationActionHandler: NSObject {
|
||||
/// Coordinator 提供的路由效果(点按 → 打开会话)。
|
||||
struct Actions {
|
||||
let openSession: @MainActor (HostRegistry.Host, UUID) async -> Void
|
||||
}
|
||||
|
||||
private enum TaskName {
|
||||
static let decision = "webterm.hook-decision"
|
||||
}
|
||||
|
||||
private let hostStore: any HostStore
|
||||
private let http: any HTTPTransport
|
||||
private let backgroundTasks: any BackgroundTaskRunning
|
||||
private let notices: any LocalNoticePosting
|
||||
private let actions: Actions
|
||||
private let logger = Logger(subsystem: PushLog.subsystem, category: PushLog.actionHandler)
|
||||
|
||||
private(set) var invalidPayloadCount = 0
|
||||
|
||||
init(
|
||||
hostStore: any HostStore,
|
||||
http: any HTTPTransport,
|
||||
backgroundTasks: any BackgroundTaskRunning,
|
||||
notices: any LocalNoticePosting,
|
||||
actions: Actions
|
||||
) {
|
||||
self.hostStore = hostStore
|
||||
self.http = http
|
||||
self.backgroundTasks = backgroundTasks
|
||||
self.notices = notices
|
||||
self.actions = actions
|
||||
}
|
||||
|
||||
// MARK: - Parse(纯函数,payload 级可测核心)
|
||||
|
||||
nonisolated static func parse(
|
||||
actionIdentifier: String, userInfo: [AnyHashable: Any]
|
||||
) -> ParsedNotificationAction {
|
||||
switch actionIdentifier {
|
||||
case GateNotificationCategory.allowActionId:
|
||||
return decisionAction(.allow, userInfo: userInfo)
|
||||
case GateNotificationCategory.denyActionId:
|
||||
return decisionAction(.deny, userInfo: userInfo)
|
||||
case UNNotificationDefaultActionIdentifier:
|
||||
guard case let .gateSession(sessionId) = DeepLinkRouter.route(from: userInfo) else {
|
||||
return .invalidPayload
|
||||
}
|
||||
return .openSession(sessionId: sessionId)
|
||||
default:
|
||||
return .dismissed
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func decisionAction(
|
||||
_ decision: HookDecision, userInfo: [AnyHashable: Any]
|
||||
) -> ParsedNotificationAction {
|
||||
guard case let .gateSession(sessionId) = DeepLinkRouter.route(from: userInfo),
|
||||
let token = userInfo[PayloadKey.token] as? String,
|
||||
Validation.isValidSessionId(token) // 服务器 token = randomUUID() → 同一 v4 白名单
|
||||
else { return .invalidPayload }
|
||||
return .decision(decision, sessionId: sessionId, token: token)
|
||||
}
|
||||
|
||||
private enum PayloadKey {
|
||||
/// 根级 capability token(T-iOS-20 payload 定稿,仅 gate 类携带)。
|
||||
static let token = "token"
|
||||
}
|
||||
|
||||
// MARK: - Handle
|
||||
|
||||
func handle(_ action: ParsedNotificationAction) async {
|
||||
switch action {
|
||||
case .dismissed:
|
||||
return
|
||||
case .invalidPayload:
|
||||
invalidPayloadCount += 1
|
||||
// 不可信输入:只记计数,不回显 payload(日志注入/隐私卫生)。
|
||||
logger.notice("dropped invalid push payload (total: \(self.invalidPayloadCount))")
|
||||
case let .openSession(sessionId):
|
||||
await routeToSession(sessionId)
|
||||
case let .decision(decision, sessionId, token):
|
||||
await postDecision(decision, sessionId: sessionId, token: token)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Decision(Allow/Deny → POST /hook/decision)
|
||||
|
||||
private func postDecision(_ decision: HookDecision, sessionId: UUID, token: String) async {
|
||||
let taskToken = backgroundTasks.begin(name: TaskName.decision)
|
||||
defer { backgroundTasks.end(taskToken) }
|
||||
do {
|
||||
guard let host = try await resolveHost(sessionId: sessionId) else {
|
||||
logger.error("hook decision: no paired host lists session — cannot deliver")
|
||||
await notices.post(
|
||||
title: PushDecisionCopy.failedTitle, body: PushDecisionCopy.failedBody
|
||||
)
|
||||
return
|
||||
}
|
||||
try await APIClient(endpoint: host.endpoint, http: http)
|
||||
.hookDecision(sessionId: sessionId, decision: decision, token: token)
|
||||
} catch APIClientError.decisionRejected {
|
||||
// 403:token 过期/已用/不匹配(SEC-C1/M1)→ 引导进 App 处理。
|
||||
await notices.post(
|
||||
title: PushDecisionCopy.expiredTitle, body: PushDecisionCopy.expiredBody
|
||||
)
|
||||
} catch {
|
||||
logger.error("hook decision POST failed: \(error)")
|
||||
await notices.post(
|
||||
title: PushDecisionCopy.failedTitle, body: PushDecisionCopy.failedBody
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Default tap(→ DeepLinkRouter 已验证的 sessionId → 会话)
|
||||
|
||||
private func routeToSession(_ sessionId: UUID) async {
|
||||
do {
|
||||
guard let host = try await resolveHost(sessionId: sessionId) else {
|
||||
// App 已被点按拉起:落在会话列表即可,无需报错弹窗。
|
||||
logger.notice("push tap: session not found on any paired host")
|
||||
return
|
||||
}
|
||||
await actions.openSession(host, sessionId)
|
||||
} catch {
|
||||
logger.error("push tap: host store read failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Host resolution(payload 不含 host 身份——本 handler 拥有解析策略)
|
||||
|
||||
/// 单主机 → 直取(离线也能把 POST 打出去,错了会显式失败);多主机 →
|
||||
/// 逐主机 RO 查 `/live-sessions`(无 Origin)找到持有该会话的主机。
|
||||
private func resolveHost(sessionId: UUID) async throws -> HostRegistry.Host? {
|
||||
let hosts = try await hostStore.loadAll()
|
||||
if hosts.count <= 1 { return hosts.first }
|
||||
for host in hosts {
|
||||
let sessions = (try? await APIClient(endpoint: host.endpoint, http: http)
|
||||
.liveSessions()) ?? []
|
||||
if sessions.contains(where: { $0.id == sessionId }) { return host }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate(薄胶水;UNNotificationResponse 无法单测构造)
|
||||
|
||||
extension NotificationActionHandler: UNUserNotificationCenterDelegate {
|
||||
/// async 变体 = 系统的 completionHandler 在本方法 return 时才触发——
|
||||
/// 任务要求"completionHandler only after the POST settles"由此保证。
|
||||
/// UN 对象只在 nonisolated 入口同步读取,跨进 MainActor 的只有 Sendable
|
||||
/// 的 `ParsedNotificationAction`。
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse
|
||||
) async {
|
||||
let parsed = Self.parse(
|
||||
actionIdentifier: response.actionIdentifier,
|
||||
userInfo: response.notification.request.content.userInfo
|
||||
)
|
||||
await handle(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AppCoordinator wiring(与 DeepLinkRouter.swift 的 makeDeepLinkHandler 同款先例)
|
||||
|
||||
extension AppCoordinator {
|
||||
/// PushAppDelegate.didFinishLaunching 的组装点(生产依赖图单点:
|
||||
/// hostStore/http 都来自 AppEnvironment,无 ad-hoc URLSession/keychain)。
|
||||
func makePushWiring() -> (registrar: PushRegistrar, handler: NotificationActionHandler) {
|
||||
let center = UNNotificationCenterAdapter()
|
||||
let registrar = PushRegistrar(
|
||||
hostStore: environment.hostStore,
|
||||
http: environment.http,
|
||||
center: center,
|
||||
remote: UIApplicationRemoteRegistrar()
|
||||
)
|
||||
let handler = NotificationActionHandler(
|
||||
hostStore: environment.hostStore,
|
||||
http: environment.http,
|
||||
backgroundTasks: UIApplicationBackgroundTaskRunner(),
|
||||
notices: center,
|
||||
actions: NotificationActionHandler.Actions(
|
||||
openSession: { [weak self] host, sessionId in
|
||||
await self?.openPushedSession(host: host, sessionId: sessionId)
|
||||
}
|
||||
)
|
||||
)
|
||||
return (registrar, handler)
|
||||
}
|
||||
|
||||
/// 通知点按 → 直达会话。先 `bootstrap()`(幂等,`route == .loading` 才
|
||||
/// 生效)确保冷启动点按不早于根路由建立;再镜像 deep-link 的
|
||||
/// close→open(openDeepLinkedSession 是 T-iOS-22 文件的 private——
|
||||
/// 三行复刻以尊重文件所有权)。
|
||||
func openPushedSession(host: HostRegistry.Host, sessionId: UUID) async {
|
||||
await bootstrap()
|
||||
if terminalController != nil {
|
||||
closeTerminal()
|
||||
}
|
||||
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
|
||||
}
|
||||
}
|
||||
331
ios/App/WebTerm/Push/PushRegistrar.swift
Normal file
@@ -0,0 +1,331 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import OSLog
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-21 · APNs 注册侧:授权 → registerForRemoteNotifications → device
|
||||
/// token 对每个已配对主机 `POST /push/apns-token`(builder 归 T-iOS-38)。
|
||||
///
|
||||
/// 授权决策(任务要求"document the choice"):请求**真授权 [.alert, .sound]**
|
||||
/// 而非 provisional —— provisional 只静默投递到通知中心(无锁屏横幅、无声音、
|
||||
/// 无可交互动作),而本任务的核心是锁屏 Allow/Deny 两次手势闭环,必须 alert
|
||||
/// 级展示(服务器 gate 推送也带 `sound: 'default'`,src/push/apns.ts:342)。
|
||||
///
|
||||
/// 重试语义:注册失败只记日志绝不 crash;device token 每次
|
||||
/// `registerForRemoteNotifications` 都会经 delegate 重新送达(launch/scene
|
||||
/// 激活各触发一次 `activate()`),`registeredHostIds` 记账保证只补注册失败/
|
||||
/// 新增的主机(服务器端 upsert 本身幂等,5/min/IP 限频下不浪费配额)。
|
||||
|
||||
// MARK: - Seams(UNUserNotificationCenter / UIApplication 不可在单测实例化流程)
|
||||
|
||||
/// Seam over the `UNUserNotificationCenter` surface this feature touches.
|
||||
@MainActor
|
||||
protocol NotificationCenterClient: AnyObject {
|
||||
func authorizationStatus() async -> UNAuthorizationStatus
|
||||
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
|
||||
func setNotificationCategories(_ categories: Set<UNNotificationCategory>)
|
||||
func add(_ request: UNNotificationRequest) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class UNNotificationCenterAdapter: NotificationCenterClient {
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
|
||||
func authorizationStatus() async -> UNAuthorizationStatus {
|
||||
// The async `notificationSettings()` returns non-Sendable
|
||||
// UNNotificationSettings across an isolation hop (Swift 6 error);
|
||||
// extract only the Sendable status inside the completion instead.
|
||||
// @Sendable literal: the closure must NOT inherit this class's
|
||||
// MainActor isolation — UNUserNotificationCenter invokes it on a
|
||||
// background queue, and an isolated closure traps the Swift 6 runtime
|
||||
// executor check (dispatch_assert_queue_fail; W7 verify boot-crash).
|
||||
await withCheckedContinuation { continuation in
|
||||
center.getNotificationSettings { @Sendable settings in
|
||||
continuation.resume(returning: settings.authorizationStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
|
||||
// Completion-handler bridge for the same Swift 6 reason as above (the
|
||||
// SDK's async variant sends the non-Sendable center across isolation).
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
center.requestAuthorization(options: options) { @Sendable granted, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setNotificationCategories(_ categories: Set<UNNotificationCategory>) {
|
||||
center.setNotificationCategories(categories)
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
center.add(request) { @Sendable error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: ())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seam over `UIApplication.registerForRemoteNotifications`.
|
||||
@MainActor
|
||||
protocol RemoteNotificationRegistering: AnyObject {
|
||||
func registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class UIApplicationRemoteRegistrar: RemoteNotificationRegistering {
|
||||
func registerForRemoteNotifications() {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WEBTERM_GATE category(安全注的单一定义点)
|
||||
|
||||
/// 锁屏 Allow/Deny 的 category 注册形状。**安全注(plan T-iOS-21)**:
|
||||
/// - Allow 必带 `.authenticationRequired` —— 锁屏批准 = 授权主机执行命令,
|
||||
/// 必须 Face ID/通行码确认;
|
||||
/// - Deny 保持免认证(fail-safe:旁观者拿到锁屏手机只能拒绝);
|
||||
/// - 两动作都**不带** `.foreground` —— 决策在后台 POST,不拉起 UI。
|
||||
enum GateNotificationCategory {
|
||||
/// 与服务器 `GATE_CATEGORY` 一致(src/push/apns.ts:52,T-iOS-20 定稿)。
|
||||
static let identifier = "WEBTERM_GATE"
|
||||
static let allowActionId = "WEBTERM_ALLOW"
|
||||
static let denyActionId = "WEBTERM_DENY"
|
||||
static let allowTitle = "允许"
|
||||
static let denyTitle = "拒绝"
|
||||
|
||||
static func category() -> UNNotificationCategory {
|
||||
let allow = UNNotificationAction(
|
||||
identifier: allowActionId, title: allowTitle,
|
||||
options: [.authenticationRequired]
|
||||
)
|
||||
let deny = UNNotificationAction(
|
||||
identifier: denyActionId, title: denyTitle,
|
||||
options: [.destructive] // 红色渲染,纯视觉;不含 .foreground/.authenticationRequired
|
||||
)
|
||||
return UNNotificationCategory(
|
||||
identifier: identifier, actions: [allow, deny],
|
||||
intentIdentifiers: [], options: []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PushRegistrar
|
||||
|
||||
@MainActor
|
||||
final class PushRegistrar {
|
||||
/// 真授权(非 provisional)——理由见文件头 doc。
|
||||
static let authorizationOptions: UNAuthorizationOptions = [.alert, .sound]
|
||||
|
||||
private let hostStore: any HostStore
|
||||
private let http: any HTTPTransport
|
||||
private let center: any NotificationCenterClient
|
||||
private let remote: any RemoteNotificationRegistering
|
||||
private let logger = Logger(subsystem: PushLog.subsystem, category: PushLog.registrar)
|
||||
|
||||
/// 当前 device token 的小写 hex;仅驻内存(iOS 每次注册都重新送达,
|
||||
/// 无需落盘——落盘的只有服务器侧的注册表)。
|
||||
private(set) var currentTokenHex: String?
|
||||
/// 已用 `currentTokenHex` 注册成功的主机(重试只补缺)。
|
||||
private var registeredHostIds: Set<UUID> = []
|
||||
|
||||
init(
|
||||
hostStore: any HostStore,
|
||||
http: any HTTPTransport,
|
||||
center: any NotificationCenterClient,
|
||||
remote: any RemoteNotificationRegistering
|
||||
) {
|
||||
self.hostStore = hostStore
|
||||
self.http = http
|
||||
self.center = center
|
||||
self.remote = remote
|
||||
}
|
||||
|
||||
/// 幂等激活:每次 launch / scene 转 active 调一次。category 注册无条件;
|
||||
/// 授权与远程注册只在**已有配对主机**时进行(无主机 = 无推送来源,不打扰;
|
||||
/// 配对后的下一次前台激活/启动会补上)。
|
||||
func activate() async {
|
||||
center.setNotificationCategories([GateNotificationCategory.category()])
|
||||
let hosts: [HostRegistry.Host]
|
||||
do {
|
||||
hosts = try await hostStore.loadAll()
|
||||
} catch {
|
||||
logger.error("push activate: host store read failed: \(error)")
|
||||
return
|
||||
}
|
||||
guard !hosts.isEmpty else {
|
||||
logger.debug("push activate: no paired hosts — skip authorization")
|
||||
return
|
||||
}
|
||||
guard await ensureAuthorization() else { return }
|
||||
remote.registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
/// `didRegisterForRemoteNotificationsWithDeviceToken` 入口。
|
||||
func handleDeviceToken(_ deviceToken: Data) async {
|
||||
let hex = Self.hexToken(deviceToken)
|
||||
if hex != currentTokenHex {
|
||||
currentTokenHex = hex
|
||||
registeredHostIds = []
|
||||
}
|
||||
await registerPendingHosts()
|
||||
}
|
||||
|
||||
/// `didFailToRegisterForRemoteNotificationsWithError` 入口:只记日志
|
||||
/// (模拟器/无 aps-environment entitlement 的构建走到这里,绝不 crash)。
|
||||
func handleRegistrationFailure(_ error: any Error) {
|
||||
logger.error("remote notification registration failed: \(error)")
|
||||
}
|
||||
|
||||
/// 主机移除时注销该主机上的 device token(**additive hook**:当前 App
|
||||
/// 层尚无移除主机的 UI 路径——`HostStore.remove(id:)` 无消费者;未来的
|
||||
/// 移除路径应调用本方法。失败仅记日志:服务器侧对失效 token 也会经
|
||||
/// APNs 410 自行清理)。
|
||||
func handleHostRemoved(_ host: HostRegistry.Host) async {
|
||||
registeredHostIds.remove(host.id)
|
||||
guard let token = currentTokenHex else { return }
|
||||
do {
|
||||
try await APIClient(endpoint: host.endpoint, http: http)
|
||||
.unregisterApnsToken(token)
|
||||
} catch {
|
||||
logger.error("APNs token unregister failed for host \(host.id): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// notDetermined → 弹真授权请求;denied → 短路;其余(authorized/
|
||||
/// provisional/ephemeral)→ 直接放行。
|
||||
private func ensureAuthorization() async -> Bool {
|
||||
switch await center.authorizationStatus() {
|
||||
case .notDetermined:
|
||||
do {
|
||||
guard try await center.requestAuthorization(
|
||||
options: Self.authorizationOptions
|
||||
) else {
|
||||
logger.notice("push authorization denied by user")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
logger.error("push authorization request failed: \(error)")
|
||||
return false
|
||||
}
|
||||
case .denied:
|
||||
logger.notice("push authorization previously denied — skip registration")
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func registerPendingHosts() async {
|
||||
guard let token = currentTokenHex else { return }
|
||||
let hosts: [HostRegistry.Host]
|
||||
do {
|
||||
hosts = try await hostStore.loadAll()
|
||||
} catch {
|
||||
logger.error("push token registration: host store read failed: \(error)")
|
||||
return
|
||||
}
|
||||
for host in hosts where !registeredHostIds.contains(host.id) {
|
||||
do {
|
||||
try await APIClient(endpoint: host.endpoint, http: http)
|
||||
.registerApnsToken(token)
|
||||
registeredHostIds.insert(host.id)
|
||||
} catch {
|
||||
// 失败续命:不 crash、不中断其余主机;下一次 token 送达
|
||||
//(下次启动/前台激活)自动重试本主机。
|
||||
logger.error("APNs token registration failed for host \(host.id): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// APNs device token → 小写 hex(服务器 wire 形状 64–160 hex,T-iOS-38)。
|
||||
static func hexToken(_ data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PushAppDelegate(app 生命周期胶水,单测不覆盖——全部逻辑在上方可测类型里)
|
||||
|
||||
/// `@UIApplicationDelegateAdaptor` 入口:remote-notification 回调只能经
|
||||
/// UIApplicationDelegate 送达,SwiftUI App 本体拿不到。
|
||||
///
|
||||
/// 接线路径(wiring 决策,任务允许的最小增量):SwiftUI 生命周期先跑
|
||||
/// `WebTermApp.init`(在那里创建 coordinator 并静态交接到 `bootstrap`),后跑
|
||||
/// `didFinishLaunching`(在此消费、组装 registrar/handler 并把 handler 设为
|
||||
/// UNUserNotificationCenter delegate——Apple 要求该 delegate 必须在启动完成前
|
||||
/// 就位,否则冷启动的通知动作会丢)。激活(授权+注册)不在这里做,而是随
|
||||
/// scenePhase 转 active(每次启动都会发生一次)由 `WebTermApp` 触发——后台
|
||||
/// 拉起(锁屏 Allow/Deny)时场景不会激活,正好省掉 30 秒窗口里的注册流量。
|
||||
@MainActor
|
||||
final class PushAppDelegate: NSObject, UIApplicationDelegate {
|
||||
/// WebTermApp.init → didFinishLaunching 的一次性交接槽。
|
||||
static var bootstrap: AppCoordinator?
|
||||
|
||||
/// 单测宿主(XCTest 已加载)与 XCUITest 拉起(环境带会话标识)都跳过
|
||||
/// 推送接线:授权弹窗会污染确定性测试;测试用注入替身直接驱动逻辑。
|
||||
static var isRunningUnderTests: Bool {
|
||||
NSClassFromString("XCTestCase") != nil
|
||||
|| ProcessInfo.processInfo.environment["XCTestSessionIdentifier"] != nil
|
||||
}
|
||||
|
||||
private(set) var registrar: PushRegistrar?
|
||||
private(set) var actionHandler: NotificationActionHandler?
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
guard !Self.isRunningUnderTests, let coordinator = Self.bootstrap else {
|
||||
Self.bootstrap = nil
|
||||
return true
|
||||
}
|
||||
Self.bootstrap = nil
|
||||
let wiring = coordinator.makePushWiring()
|
||||
registrar = wiring.registrar
|
||||
actionHandler = wiring.handler
|
||||
UNUserNotificationCenter.current().delegate = wiring.handler
|
||||
return true
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
) {
|
||||
Task { await registrar?.handleDeviceToken(deviceToken) }
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: any Error
|
||||
) {
|
||||
registrar?.handleRegistrationFailure(error)
|
||||
}
|
||||
|
||||
/// scenePhase 转 active(含每次启动)→ 幂等激活。
|
||||
func activatePush() {
|
||||
Task { await registrar?.activate() }
|
||||
}
|
||||
}
|
||||
|
||||
enum PushLog {
|
||||
static let subsystem = "com.yaojia.webterm"
|
||||
static let registrar = "push-registrar"
|
||||
static let actionHandler = "push-action"
|
||||
}
|
||||
|
After Width: | Height: | Size: 223 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "AppIcon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
1
ios/App/WebTerm/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||
273
ios/App/WebTerm/Screens/DiffScreen.swift
Normal file
@@ -0,0 +1,273 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-27 · Read-only diff viewer(镜像 web public/diff.ts 的 render-only
|
||||
/// 半边;解析只在服务器 src/http/diff.ts)。
|
||||
///
|
||||
/// 入口:T-iOS-26 的 ProjectDetail 以 `(endpoint, path)` 构造并 push/present
|
||||
/// 本屏(本任务先行导出可呈现单元)。TerminalScreen 侧的 per-session cwd 入口
|
||||
/// 未接 —— TerminalScreen(T-iOS-11 Owns)没有暴露 toolbar hook,接线移交
|
||||
/// T-iOS-26/29。
|
||||
///
|
||||
/// 安全(SEC-H4 同款纪律):diff 内容是**不可信服务器字节** —— 所有服务器
|
||||
/// 文本一律 `Text(verbatim:)`(绝不 LocalizedStringKey/Markdown/链接探测),
|
||||
/// 等宽单行呈现;行列表是惰性 List,巨 diff(服务器上限 2MB)不合成单个
|
||||
/// Text 块。
|
||||
struct DiffScreen: View {
|
||||
@State private var viewModel: DiffViewModel
|
||||
|
||||
private enum Metrics {
|
||||
/// Faint per-line tint alpha for added/removed/hunk rows. This is a
|
||||
/// **code-diff syntax constant** (like terminal/xterm colors), NOT a
|
||||
/// card/status design token — the frozen `DS.Opacity` scale
|
||||
/// (stale/exited/pressed) has no semantic slot for a syntax highlight
|
||||
/// fill. The tint HUE always comes from `DS.Palette` (below); only this
|
||||
/// scanning-aid alpha is diff-local.
|
||||
static let lineTintOpacity = 0.12
|
||||
}
|
||||
|
||||
/// 生产入口(T-iOS-26 消费):`(endpoint, path)` + 注入的传输层。
|
||||
init(endpoint: HostEndpoint, path: String, http: any HTTPTransport) {
|
||||
_viewModel = State(initialValue: .forProject(
|
||||
endpoint: endpoint, path: path, http: http
|
||||
))
|
||||
}
|
||||
|
||||
/// 测试/预览缝:直接注入 VM。
|
||||
init(viewModel: DiffViewModel) {
|
||||
_viewModel = State(initialValue: viewModel)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
scopePicker
|
||||
.padding(.horizontal, DS.Space.lg16)
|
||||
.padding(.vertical, DS.Space.sm8)
|
||||
content
|
||||
}
|
||||
.navigationTitle(DiffCopy.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await viewModel.load() } // fresh screen → one initial fetch
|
||||
}
|
||||
|
||||
// MARK: - staged/unstaged 切换(re-fetch 由 VM 去重)
|
||||
|
||||
private var scopePicker: some View {
|
||||
Picker(DiffCopy.scopePickerLabel, selection: Binding(
|
||||
get: { viewModel.staged },
|
||||
set: { newValue in Task { await viewModel.setStaged(newValue) } }
|
||||
)) {
|
||||
Text(DiffCopy.working).tag(false)
|
||||
Text(DiffCopy.staged).tag(true)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
// MARK: - Phase switch
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
switch viewModel.phase {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .empty(let truncated):
|
||||
emptyState(truncated: truncated)
|
||||
case .failed(let failure):
|
||||
failedState(failure)
|
||||
case .loaded(let presentation):
|
||||
diffList(presentation)
|
||||
}
|
||||
}
|
||||
|
||||
/// 该范围下无改动(服务器回空 files)。截断到空也要保留 banner ——
|
||||
/// “没有改动”与“改动太大被截没了”是两回事。
|
||||
private func emptyState(truncated: Bool) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
if truncated { truncatedBanner }
|
||||
ContentUnavailableView(
|
||||
DiffCopy.emptyTitle,
|
||||
systemImage: "checkmark.circle",
|
||||
description: Text(DiffCopy.emptyDetail)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 显式、可重试的错误态(path 非法 400/404 → 友好话术,任务 Steps)。
|
||||
private func failedState(_ failure: DiffViewModel.Failure) -> some View {
|
||||
let copy = Self.failureCopy(failure)
|
||||
return ContentUnavailableView {
|
||||
Label(copy.title, systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(copy.detail)
|
||||
} actions: {
|
||||
Button(DiffCopy.retry) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(DS.Palette.accent)
|
||||
}
|
||||
}
|
||||
|
||||
static func failureCopy(_ failure: DiffViewModel.Failure) -> (title: String, detail: String) {
|
||||
switch failure {
|
||||
case .pathInvalid:
|
||||
return (DiffCopy.failedPathInvalid, DiffCopy.failedPathInvalidDetail)
|
||||
case .notFound:
|
||||
return (DiffCopy.failedNotFound, DiffCopy.failedNotFoundDetail)
|
||||
case .unavailable:
|
||||
return (DiffCopy.failedUnavailable, DiffCopy.failedUnavailableDetail)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 行列表(惰性;行模型已由 VM 平铺)
|
||||
|
||||
private func diffList(_ presentation: DiffPresentation) -> some View {
|
||||
List {
|
||||
if presentation.truncated {
|
||||
truncatedBanner
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
ForEach(presentation.rows) { row in
|
||||
rowView(row)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.environment(\.defaultMinListRowHeight, 0) // 行高随内容,diff 行要紧凑
|
||||
}
|
||||
|
||||
private var truncatedBanner: some View {
|
||||
Label(DiffCopy.truncatedBanner, systemImage: "scissors")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
}
|
||||
|
||||
@ViewBuilder private func rowView(_ row: DiffRow) -> some View {
|
||||
switch row.kind {
|
||||
case .fileHeader(let header):
|
||||
fileHeaderRow(header)
|
||||
case .binaryNotice:
|
||||
Text(DiffCopy.binaryFile)
|
||||
.font(DS.Typography.caption.italic())
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.listRowSeparator(.hidden)
|
||||
case .hunkHeader(let header):
|
||||
diffTextRow(
|
||||
header, color: DS.Palette.accent,
|
||||
background: DS.Palette.accent.opacity(Metrics.lineTintOpacity)
|
||||
)
|
||||
case .line(let kind, let text):
|
||||
diffTextRow(
|
||||
text, color: Self.lineColor(kind), background: Self.lineBackground(kind)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func fileHeaderRow(_ header: DiffFileHeader) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
// 路径是服务器字节:verbatim + 单行中段截断(等宽对齐)。
|
||||
Text(verbatim: header.pathLabel)
|
||||
.font(DS.Typography.mono(.footnote).weight(.semibold))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer(minLength: 0)
|
||||
Text(verbatim: "+\(header.added)")
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.statusWorking)
|
||||
Text(verbatim: "-\(header.removed)")
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
Text(DiffStatusStyle.label(for: header.status))
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DiffStatusStyle.color(for: header.status))
|
||||
}
|
||||
.padding(.top, DS.Space.lg16)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
/// One monospaced diff line. UNTRUSTED server bytes: `Text(verbatim:)`,
|
||||
/// single-line tail truncation (read-only skim view; no wrapping blob).
|
||||
private func diffTextRow(_ text: String, color: Color, background: Color) -> some View {
|
||||
Text(verbatim: text)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.listRowBackground(background)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: DS.Space.xs2,
|
||||
leading: DS.Space.md12,
|
||||
bottom: DS.Space.xs2,
|
||||
trailing: DS.Space.md12
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - kind → 颜色(全函数;未知 kind 已在解码层降级为 .context)
|
||||
// 语义色一律取自 DS.Palette(added=working 绿 / removed=stuck 红 /
|
||||
// hunk=accent),与全 App 品牌色对齐;仅淡底 alpha 是 diff 语法常量。
|
||||
|
||||
static func lineColor(_ kind: DiffLineKind) -> Color {
|
||||
switch kind {
|
||||
case .added: return DS.Palette.statusWorking
|
||||
case .removed: return DS.Palette.statusStuck
|
||||
case .context: return DS.Palette.textPrimary
|
||||
case .hunk: return DS.Palette.accent
|
||||
case .meta: return DS.Palette.textSecondary
|
||||
}
|
||||
}
|
||||
|
||||
static func lineBackground(_ kind: DiffLineKind) -> Color {
|
||||
switch kind {
|
||||
case .added: return DS.Palette.statusWorking.opacity(Metrics.lineTintOpacity)
|
||||
case .removed: return DS.Palette.statusStuck.opacity(Metrics.lineTintOpacity)
|
||||
case .hunk: return DS.Palette.accent.opacity(Metrics.lineTintOpacity)
|
||||
case .context, .meta: return .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - status → 标签/颜色(全函数映射)
|
||||
|
||||
enum DiffStatusStyle {
|
||||
static func label(for status: DiffFileStatus) -> String {
|
||||
switch status {
|
||||
case .modified: return "修改"
|
||||
case .added: return "新增"
|
||||
case .deleted: return "删除"
|
||||
case .renamed: return "重命名"
|
||||
case .binary: return "二进制"
|
||||
case .untracked: return "未跟踪"
|
||||
}
|
||||
}
|
||||
|
||||
static func color(for status: DiffFileStatus) -> Color {
|
||||
switch status {
|
||||
case .added, .untracked: return DS.Palette.statusWorking
|
||||
case .deleted: return DS.Palette.statusStuck
|
||||
case .renamed: return DS.Palette.accent
|
||||
case .modified, .binary: return DS.Palette.textSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 用户可见文案(中文具名常量,plan 工程标准)
|
||||
|
||||
enum DiffCopy {
|
||||
static let title = "代码差异"
|
||||
static let scopePickerLabel = "差异范围"
|
||||
static let working = "工作区"
|
||||
static let staged = "已暂存"
|
||||
static let truncatedBanner = "差异过大,已截断显示(主机侧 DIFF_MAX_BYTES / DIFF_MAX_FILES 限制)。"
|
||||
static let emptyTitle = "无改动"
|
||||
static let emptyDetail = "当前范围下没有可显示的改动。"
|
||||
static let binaryFile = "二进制文件"
|
||||
static let failedPathInvalid = "路径无效"
|
||||
static let failedPathInvalidDetail = "请求的项目路径无效(服务器返回 400),请从项目列表重新进入。"
|
||||
static let failedNotFound = "项目未找到"
|
||||
static let failedNotFoundDetail = "该路径不是主机上的 git 仓库,或已被移除(404)。"
|
||||
static let failedUnavailable = "差异加载失败"
|
||||
static let failedUnavailableDetail = "无法从主机获取 diff,请检查连接后重试。"
|
||||
static let retry = "重试"
|
||||
}
|
||||
400
ios/App/WebTerm/Screens/PairingScreen.swift
Normal file
@@ -0,0 +1,400 @@
|
||||
import HostRegistry
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
#if !targetEnvironment(simulator)
|
||||
import VisionKit
|
||||
#endif
|
||||
|
||||
/// T-iOS-12 · Pairing screen: QR scan (device only) + manual URL entry + probe.
|
||||
/// Pure presentation over `PairingViewModel` — every rule (input validation, the
|
||||
/// zero-network-before-confirm gate, §5.4 warning tiers, error copy/actions)
|
||||
/// lives in the VM where it is unit-tested. Presented first-run (T-iOS-15) and
|
||||
/// as an add/switch-host sheet. Scanner (`DataScannerViewController`) is compiled
|
||||
/// out on the simulator, where manual entry is the pairing path.
|
||||
struct PairingScreen: View {
|
||||
@Bindable var viewModel: PairingViewModel
|
||||
var onPaired: (HostRegistry.Host) -> Void = { _ in } // T-iOS-15 navigate hook
|
||||
|
||||
@State private var manualURLText = ""
|
||||
@State private var isShowingScanner = false
|
||||
@State private var scannerError: String?
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle(ScreenCopy.title)
|
||||
.onChange(of: viewModel.pairedHost) { _, paired in
|
||||
guard let paired else { return }
|
||||
onPaired(paired)
|
||||
}
|
||||
}
|
||||
@ViewBuilder private var content: some View {
|
||||
switch viewModel.phase {
|
||||
case .idle:
|
||||
idleView
|
||||
case .confirming(let pending):
|
||||
ConfirmHostView(pending: pending, viewModel: viewModel)
|
||||
case .probing(let pending):
|
||||
probingView(pending)
|
||||
case .failed(let pending, let failure):
|
||||
FailureView(pending: pending, failure: failure, viewModel: viewModel)
|
||||
case .paired(let host):
|
||||
pairedView(host)
|
||||
}
|
||||
}
|
||||
private var idleView: 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)
|
||||
}
|
||||
.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")
|
||||
}
|
||||
}
|
||||
if let rejection = viewModel.inputRejection {
|
||||
Label(rejection, systemImage: "exclamationmark.circle.fill")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if PairingScanAvailability.isAvailable {
|
||||
Button {
|
||||
scannerError = nil
|
||||
isShowingScanner = true
|
||||
} label: {
|
||||
Label(ScreenCopy.scanButton, systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary)) // hidden on sim
|
||||
}
|
||||
Text(ScreenCopy.qrHint)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
.sheet(isPresented: $isShowingScanner) { scannerSheet }
|
||||
}
|
||||
|
||||
@ViewBuilder private var scannerSheet: some View {
|
||||
#if targetEnvironment(simulator)
|
||||
// Unreachable: the entry is hidden on the simulator. Kept total.
|
||||
Text(ScreenCopy.scanUnavailable)
|
||||
#else
|
||||
ZStack(alignment: .bottom) {
|
||||
QRScannerView(
|
||||
onCode: { payload in
|
||||
isShowingScanner = false
|
||||
viewModel.handleScannedCode(payload)
|
||||
},
|
||||
onError: { message in scannerError = message }
|
||||
)
|
||||
if let scannerError {
|
||||
Text(scannerError)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
.padding(DS.Space.md12)
|
||||
.background(.thinMaterial, in: RoundedRectangle(
|
||||
cornerRadius: DS.Radius.sm8
|
||||
))
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
private func probingView(_ pending: PairingViewModel.PendingHost) -> some View {
|
||||
VStack(spacing: DS.Space.lg16) {
|
||||
ProgressView()
|
||||
Text(ScreenCopy.probing(pending.displayAddress))
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(DS.Space.xl20)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
private func pairedView(_ host: HostRegistry.Host) -> some View {
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(DS.Typography.largeTitle)
|
||||
.foregroundStyle(DS.Palette.statusWorking)
|
||||
Text(ScreenCopy.paired(host.name))
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
.padding(DS.Space.xl20)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear { DS.Haptics.success() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm page — parsed address + §5.4 warning tier + host name.
|
||||
private struct ConfirmHostView: View {
|
||||
let pending: PairingViewModel.PendingHost
|
||||
@Bindable var viewModel: PairingViewModel
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: DS.Space.xl20) {
|
||||
addressCard
|
||||
warningTier
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
Button(ScreenCopy.connect) {
|
||||
DS.Haptics.selection()
|
||||
Task { await viewModel.confirmConnect() }
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.accessibilityIdentifier("pairing.confirmButton")
|
||||
Button(ScreenCopy.cancel) { viewModel.cancel() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
}
|
||||
private var addressCard: some View {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
SectionHeader(title: ScreenCopy.confirmSectionTitle)
|
||||
// Single-point-derived origin (UITest asserts this exact string).
|
||||
Text(pending.displayAddress)
|
||||
.font(DS.Typography.mono())
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.textSelection(.enabled)
|
||||
Divider()
|
||||
TextField(ScreenCopy.namePlaceholder, text: $viewModel.hostName)
|
||||
.font(DS.Typography.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// §5.4 warning tiers — LOGIC frozen (switch cases), only presentation restyled.
|
||||
@ViewBuilder private var warningTier: some View {
|
||||
switch pending.warning {
|
||||
case .none:
|
||||
EmptyView()
|
||||
case .tailscaleEncrypted: // positive accent badge — encrypted transport
|
||||
Card {
|
||||
Label(ScreenCopy.tailscaleBadge, systemImage: "lock.shield")
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
case .plaintextLAN: // subtle amber note
|
||||
Label(ScreenCopy.plaintextNotice, systemImage: "eye")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .publicHostBlocking:
|
||||
publicWarningCard
|
||||
}
|
||||
}
|
||||
|
||||
/// Prominent red card + acknowledgement gate (Toggle → `hasAcknowledgedPublicRisk`;
|
||||
/// required-message on `needsPublicRiskAcknowledgement`). Logic unchanged.
|
||||
private var publicWarningCard: some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
Label {
|
||||
Text(ScreenCopy.publicWarning)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.octagon.fill")
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
}
|
||||
Divider()
|
||||
Toggle(ScreenCopy.publicAcknowledge, isOn: $viewModel.hasAcknowledgedPublicRisk)
|
||||
.font(DS.Typography.callout)
|
||||
.tint(DS.Palette.accent)
|
||||
if viewModel.needsPublicRiskAcknowledgement {
|
||||
Label(ScreenCopy.publicAckRequired, systemImage: "arrow.up")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.md12)
|
||||
.background(DS.Palette.card, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||||
.strokeBorder(DS.Palette.statusStuck, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure page — inline copy + recovery actions (retry / settings / back).
|
||||
private struct FailureView: View {
|
||||
let pending: PairingViewModel.PendingHost
|
||||
let failure: PairingViewModel.FailureDisplay
|
||||
let viewModel: PairingViewModel
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: DS.Space.xl20) {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.sm8) {
|
||||
Text(pending.displayAddress)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Label {
|
||||
Text(failure.message)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
} icon: {
|
||||
Image(systemName: "xmark.octagon.fill")
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
let needsSettings = failure.action == .openLocalNetworkSettings
|
||||
if needsSettings {
|
||||
Button(ScreenCopy.openSettings) { openAppSettings() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
}
|
||||
Button(ScreenCopy.retry) { Task { await viewModel.retry() } }
|
||||
.buttonStyle(DSButtonStyle(kind: needsSettings ? .secondary : .primary))
|
||||
Button(ScreenCopy.back) { viewModel.cancel() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's Settings pane hosts its 本地网络 toggle (no deep link exists).
|
||||
private func openAppSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
enum PairingScanAvailability {
|
||||
/// Simulator: no camera → hidden, manual entry is the pairing path. Device:
|
||||
/// requires VisionKit support (`isSupported` is MainActor-isolated).
|
||||
@MainActor static var isAvailable: Bool {
|
||||
#if targetEnvironment(simulator)
|
||||
return false
|
||||
#else
|
||||
return DataScannerViewController.isSupported
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
/// Thin `DataScannerViewController` wrapper: QR only; first recognized code wins;
|
||||
/// payload handed to the VM untouched (validation is the VM's job).
|
||||
private struct QRScannerView: UIViewControllerRepresentable {
|
||||
let onCode: (String) -> Void
|
||||
let onError: (String) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onCode: onCode)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> DataScannerViewController {
|
||||
let scanner = DataScannerViewController(
|
||||
recognizedDataTypes: [.barcode(symbologies: [.qr])],
|
||||
qualityLevel: .balanced,
|
||||
isHighlightingEnabled: true
|
||||
)
|
||||
scanner.delegate = context.coordinator
|
||||
return scanner
|
||||
}
|
||||
|
||||
func updateUIViewController(_ scanner: DataScannerViewController, context: Context) {
|
||||
guard !scanner.isScanning else { return }
|
||||
do {
|
||||
try scanner.startScanning()
|
||||
} catch {
|
||||
// Explicit surfacing (plan §4): shows inline; manual entry remains.
|
||||
onError(ScreenCopy.scannerStartFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
||||
private let onCode: (String) -> Void
|
||||
private var hasDelivered = false
|
||||
|
||||
init(onCode: @escaping (String) -> Void) {
|
||||
self.onCode = onCode
|
||||
}
|
||||
|
||||
func dataScanner(
|
||||
_ dataScanner: DataScannerViewController,
|
||||
didAdd addedItems: [RecognizedItem],
|
||||
allItems: [RecognizedItem]
|
||||
) {
|
||||
guard !hasDelivered else { return }
|
||||
for item in addedItems {
|
||||
if case .barcode(let barcode) = item,
|
||||
let payload = barcode.payloadStringValue {
|
||||
hasDelivered = true
|
||||
onCode(payload)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private enum ScreenCopy {
|
||||
static let title = "配对主机"
|
||||
static let heroTitle = "连接你的电脑"
|
||||
static let heroSubtitle = "在同一网络里打开电脑上运行的终端会话——手动输入地址,或扫描它的配对二维码。"
|
||||
static let manualSectionTitle = "输入地址"
|
||||
static let manualPlaceholder = "http://192.168.1.5:3000"
|
||||
static let manualSubmit = "连接"
|
||||
static let scanButton = "扫描二维码"
|
||||
static let scanUnavailable = "模拟器不支持扫码,请手输地址。"
|
||||
static let qrHint = "在电脑的 web 终端工具栏点「Connect a device」可显示配对二维码;也可直接输入它的地址。"
|
||||
static let confirmSectionTitle = "确认要连接的主机"
|
||||
static let namePlaceholder = "主机名称"
|
||||
static let connect = "连接"
|
||||
static let cancel = "取消"
|
||||
static let retry = "重试"
|
||||
static let back = "返回"
|
||||
static let openSettings = "去设置"
|
||||
static let tailscaleBadge = "经 Tailscale 加密(WireGuard 网络层)"
|
||||
static let plaintextNotice = "ws:// 明文连接:键击与终端输出可被同一网络内的设备嗅探。仅限可信 LAN,推荐 tailscale serve(wss)。"
|
||||
static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
|
||||
static let publicAcknowledge = "我已了解风险,仍要连接"
|
||||
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
|
||||
|
||||
static func probing(_ address: String) -> String { "正在验证 \(address) …" }
|
||||
static func paired(_ name: String) -> String { "已配对:\(name)" }
|
||||
static func scannerStartFailed(_ reason: String) -> String {
|
||||
"无法启动相机扫描:\(reason)。可改用手输地址。"
|
||||
}
|
||||
}
|
||||
314
ios/App/WebTerm/Screens/ProjectDetailScreen.swift
Normal file
@@ -0,0 +1,314 @@
|
||||
import APIClient
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-26 · 项目详情屏:sessions/worktrees/CLAUDE.md 渲染 + diff 入口
|
||||
/// (T-iOS-27 的 `DiffScreen(endpoint:path:http:)`)+ "在此仓库开新会话"。
|
||||
///
|
||||
/// 安全:名字/路径/分支/CLAUDE.md 内容全是**不可信服务器字节** ——
|
||||
/// 一律 `Text(verbatim:)`(绝不 LocalizedStringKey/Markdown/链接探测)。
|
||||
struct ProjectDetailScreen: View {
|
||||
@State private var viewModel: ProjectDetailViewModel
|
||||
private let endpoint: HostEndpoint
|
||||
private let http: any HTTPTransport
|
||||
private let onOpenClaude: (String) -> Void
|
||||
@State private var isDiffPresented = false
|
||||
|
||||
private enum Metrics {
|
||||
/// CLAUDE.md 预览行数上限(内容裁剪,非视觉 token)。
|
||||
static let claudeMdLineLimit = 40
|
||||
}
|
||||
|
||||
init(
|
||||
viewModel: ProjectDetailViewModel,
|
||||
endpoint: HostEndpoint,
|
||||
http: any HTTPTransport,
|
||||
onOpenClaude: @escaping (String) -> Void
|
||||
) {
|
||||
_viewModel = State(initialValue: viewModel)
|
||||
self.endpoint = endpoint
|
||||
self.http = http
|
||||
self.onOpenClaude = onOpenClaude
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle(ProjectDetailCopy.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await viewModel.load() }
|
||||
.navigationDestination(isPresented: $isDiffPresented) {
|
||||
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase switch
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
switch viewModel.phase {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .failed(let failure):
|
||||
failedState(failure)
|
||||
case .loaded(let detail):
|
||||
detailList(detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 显式、可重试的错误态(400/404/500 `{error}` → 分类文案,任务 Steps)。
|
||||
private func failedState(_ failure: ProjectDetailViewModel.Failure) -> some View {
|
||||
let copy = Self.failureCopy(failure)
|
||||
return ContentUnavailableView {
|
||||
Label(copy.title, systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(copy.detail)
|
||||
} actions: {
|
||||
Button(ProjectDetailCopy.retry) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(DS.Palette.accent)
|
||||
}
|
||||
}
|
||||
|
||||
static func failureCopy(
|
||||
_ failure: ProjectDetailViewModel.Failure
|
||||
) -> (title: String, detail: String) {
|
||||
switch failure {
|
||||
case .pathInvalid:
|
||||
return (ProjectDetailCopy.failedPathInvalid,
|
||||
ProjectDetailCopy.failedPathInvalidDetail)
|
||||
case .notFound:
|
||||
return (ProjectDetailCopy.failedNotFound,
|
||||
ProjectDetailCopy.failedNotFoundDetail)
|
||||
case .unavailable:
|
||||
return (ProjectDetailCopy.failedUnavailable,
|
||||
ProjectDetailCopy.failedUnavailableDetail)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loaded
|
||||
|
||||
private func detailList(_ detail: ProjectDetail) -> some View {
|
||||
List {
|
||||
headerSection(detail)
|
||||
actionsSection(detail)
|
||||
sessionsSection(detail.sessions)
|
||||
worktreesSection(detail.worktrees)
|
||||
claudeMdSection(detail)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
private func headerSection(_ detail: ProjectDetail) -> some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
Text(verbatim: detail.name)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
// 路径是服务器字节 → verbatim + 等宽单行中段截断。
|
||||
Text(verbatim: detail.path)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
if let branch = detail.branch {
|
||||
Label {
|
||||
Text(verbatim: branch).lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
}
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
if detail.dirty == true {
|
||||
DirtyBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func actionsSection(_ detail: ProjectDetail) -> some View {
|
||||
Section {
|
||||
Button {
|
||||
DS.Haptics.selection()
|
||||
onOpenClaude(detail.path)
|
||||
} label: {
|
||||
Label(ProjectDetailCopy.openClaude, systemImage: "terminal.fill")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.listRowInsets(EdgeInsets(
|
||||
top: DS.Space.sm8, leading: DS.Space.lg16,
|
||||
bottom: detail.isGit ? DS.Space.xs4 : DS.Space.sm8, trailing: DS.Space.lg16
|
||||
))
|
||||
.listRowBackground(Color.clear)
|
||||
if detail.isGit {
|
||||
Button {
|
||||
isDiffPresented = true
|
||||
} label: {
|
||||
Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus")
|
||||
}
|
||||
.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 {
|
||||
Section(ProjectDetailCopy.sessionsHeader) {
|
||||
if sessions.isEmpty {
|
||||
Text(ProjectDetailCopy.noSessions)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
} else {
|
||||
ForEach(sessions, id: \.id) { session in
|
||||
sessionRow(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sessionRow(_ session: ProjectSessionRef) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
// 状态 = 色 + 形 + VoiceOver 标签(DS 单一状态真源);退出优先。
|
||||
StatusBadge(status: session.exited ? .exited : DisplayStatus(session.status))
|
||||
// title 是派生的 cwd 尾段(服务器字节)→ verbatim;缺失退回短 id。
|
||||
Text(verbatim: session.title ?? String(
|
||||
session.id.uuidString.lowercased().prefix(8)
|
||||
))
|
||||
.font(DS.Typography.body)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
if session.exited {
|
||||
Text(ProjectDetailCopy.sessionExited)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
} else {
|
||||
// 客户端数含数字 → 等宽 tabular 元信息。
|
||||
Text(ProjectDetailCopy.clientCount(session.clientCount))
|
||||
.dsMetaText()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View {
|
||||
if !worktrees.isEmpty {
|
||||
Section(ProjectDetailCopy.worktreesHeader) {
|
||||
ForEach(worktrees, id: \.path) { worktree in
|
||||
worktreeRow(worktree)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
Text(verbatim: worktree.branch ?? ProjectDetailCopy.detachedHead)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
if worktree.isMain {
|
||||
TagBadge(text: ProjectDetailCopy.worktreeMain)
|
||||
}
|
||||
if worktree.isCurrent {
|
||||
TagBadge(text: ProjectDetailCopy.worktreeCurrent)
|
||||
}
|
||||
if worktree.locked == true {
|
||||
TagBadge(text: ProjectDetailCopy.worktreeLocked)
|
||||
}
|
||||
}
|
||||
Text(verbatim: worktree.path)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func claudeMdSection(_ detail: ProjectDetail) -> some View {
|
||||
if detail.hasClaudeMd {
|
||||
Section(ProjectDetailCopy.claudeMdHeader) {
|
||||
if let content = detail.claudeMd {
|
||||
// 服务器已截断供展示;仍是不可信字节 → verbatim + 行数上限。
|
||||
Text(verbatim: content)
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(Metrics.claudeMdLineLimit)
|
||||
} else {
|
||||
Text(ProjectDetailCopy.claudeMdPresent)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 小徽标(DS token 化,跨 Projects/详情复用)
|
||||
|
||||
/// 「未提交」脏标:琥珀语义色文字 + 中性胶囊底(同 TelemetryChip 的 `.quaternary`)。
|
||||
struct DirtyBadge: View {
|
||||
var body: some View {
|
||||
Text(ProjectsCopy.dirtyBadge)
|
||||
.font(DS.Typography.caption.weight(.medium))
|
||||
// 软填充胶囊:amber 字 + amber 淡底(非浅灰底),明暗两态都清晰。
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.padding(.vertical, DS.Space.xs2)
|
||||
.background(DS.Palette.statusWaiting.opacity(0.18), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
/// worktree 属性标签(主/当前/已锁定):accent 描边胶囊。
|
||||
struct TagBadge: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.padding(.vertical, DS.Space.xs2)
|
||||
.overlay(
|
||||
Capsule().strokeBorder(DS.Palette.accent, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 用户可见文案(中文具名常量,plan §4)
|
||||
|
||||
enum ProjectDetailCopy {
|
||||
static let title = "项目详情"
|
||||
static let openClaude = "在此仓库开新会话"
|
||||
static let viewDiff = "查看代码差异"
|
||||
static let sessionsHeader = "运行中的会话"
|
||||
static let noSessions = "暂无运行中的会话。"
|
||||
static let sessionExited = "已退出"
|
||||
static let worktreesHeader = "Worktrees"
|
||||
static let worktreeMain = "主"
|
||||
static let worktreeCurrent = "当前"
|
||||
static let worktreeLocked = "已锁定"
|
||||
static let detachedHead = "(分离 HEAD)"
|
||||
static let claudeMdHeader = "CLAUDE.md"
|
||||
static let claudeMdPresent = "本仓库包含 CLAUDE.md。"
|
||||
static let retry = "重试"
|
||||
static let failedPathInvalid = "路径无效"
|
||||
static let failedPathInvalidDetail = "请求的项目路径无效(服务器返回 400),请从项目列表重新进入。"
|
||||
static let failedNotFound = "项目未找到"
|
||||
static let failedNotFoundDetail = "该路径不在主机的项目根目录下,或已被移除(404)。"
|
||||
static let failedUnavailable = "详情加载失败"
|
||||
static let failedUnavailableDetail = "无法从主机获取项目详情,请检查连接后重试。"
|
||||
|
||||
static func clientCount(_ count: Int) -> String {
|
||||
"\(count) 个客户端"
|
||||
}
|
||||
}
|
||||
73
ios/App/WebTerm/Screens/ProjectsLayout.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// T-iPad-4 · Projects 大屏化的**纯布局决策** —— 列数 / 是否走网格 / sheet
|
||||
/// detents 全部收在本文件的纯函数里(单一判据点,仿 `LayoutPolicy` 先例),
|
||||
/// 视图内零散落条件、100% 单测。
|
||||
///
|
||||
/// **为何按 `UIUserInterfaceIdiom` 而非 `horizontalSizeClass`**(关键、易错):
|
||||
/// iPad 上 Projects 以**表单 sheet**(form sheet)呈现,其内部
|
||||
/// `horizontalSizeClass` 恒为 **compact**(与 iPhone 横屏完全相同)。因此
|
||||
/// size class 无法在「iPhone 横屏零回归」的前提下区分「iPhone」与「iPad 的
|
||||
/// Projects 表单 sheet」—— 只有设备 idiom 能。故 Projects 自身的多列/卡片
|
||||
/// 决策以 idiom+宽度为判据;根视图的 stack/split 决策仍由 `LayoutPolicy`
|
||||
/// (唯一 size-class 读取点)负责,两者正交、各自单点。
|
||||
enum ProjectsGridLayout {
|
||||
/// iPhone / 极窄 iPad 的回退列数(现有单列布局)。
|
||||
static let singleColumn = 1
|
||||
/// iPad 下升到 2 列的最小可用宽度(表单 sheet 内宽 ~440–540pt 即两列)。
|
||||
static let twoColumnMinWidth: CGFloat = 400
|
||||
/// iPad 下升到 3 列的最小可用宽度(更宽的面板/横屏全宽时)。
|
||||
static let threeColumnMinWidth: CGFloat = 760
|
||||
|
||||
/// iPhone(`.phone`,任何朝向/尺寸)→ 恒 `singleColumn`(现有单列 List,
|
||||
/// 字节级不变)。iPad(`.pad`)→ 按**可用宽度** 1–3 列。恒 `>= 1`(永不
|
||||
/// 返回 0,防空网格)。
|
||||
static func columnCount(
|
||||
availableWidth: CGFloat,
|
||||
idiom: UIUserInterfaceIdiom
|
||||
) -> Int {
|
||||
guard idiom == .pad else { return singleColumn }
|
||||
if availableWidth >= threeColumnMinWidth { return 3 }
|
||||
if availableWidth >= twoColumnMinWidth { return 2 }
|
||||
return singleColumn
|
||||
}
|
||||
|
||||
/// 容器选择:iPad → 多列网格;其它(iPhone)→ 现有单列 List(零回归)。
|
||||
static func usesGrid(idiom: UIUserInterfaceIdiom) -> Bool {
|
||||
idiom == .pad
|
||||
}
|
||||
}
|
||||
|
||||
/// T-iPad-4 · Projects sheet 的自适应 detents 决策(同一 idiom 判据)。
|
||||
enum ProjectsSheetSizing {
|
||||
/// iPhone → `nil`:**不套 `.presentationDetents`**,保持现有默认全高 sheet
|
||||
/// (iPhone 字节级不变)。iPad → 卡片式 `[.medium, .large]`:不铺满大屏,
|
||||
/// 仍可上拉到大。
|
||||
static func detents(idiom: UIUserInterfaceIdiom) -> Set<PresentationDetent>? {
|
||||
idiom == .pad ? [.medium, .large] : nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 应用点(把「iPhone 不套、iPad 套 detents」收进一个 modifier)
|
||||
|
||||
/// 条件套 `.presentationDetents` 的 modifier —— nil 时 `content` 原样透传,
|
||||
/// 故 iPhone 分支不引入任何 sheet 尺寸修饰符(零回归)。
|
||||
private struct AdaptiveSheetDetents: ViewModifier {
|
||||
let detents: Set<PresentationDetent>?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if let detents {
|
||||
content.presentationDetents(detents)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// iPhone 透传(现有全高 sheet,字节级不变);iPad 套卡片式 detents。
|
||||
func adaptiveProjectsSheetDetents(idiom: UIUserInterfaceIdiom) -> some View {
|
||||
modifier(AdaptiveSheetDetents(detents: ProjectsSheetSizing.detents(idiom: idiom)))
|
||||
}
|
||||
}
|
||||
287
ios/App/WebTerm/Screens/ProjectsScreen.swift
Normal file
@@ -0,0 +1,287 @@
|
||||
import APIClient
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-26 · Projects 列表屏(镜像 web v0.6 projects 面板):namespace 分组
|
||||
/// 折叠分区 + 收藏星标 + dirty 徽标 + Active now 置顶 + 搜索。纯呈现 ——
|
||||
/// 分组/收藏/折叠/导航信号全部是 `ProjectsViewModel` 逻辑(单测覆盖)。
|
||||
///
|
||||
/// 安全:项目名/路径/分支是**不可信服务器字节** —— 一律 `Text(verbatim:)`
|
||||
/// (绝不 LocalizedStringKey/Markdown),单行截断。
|
||||
struct ProjectsScreen: View {
|
||||
@Bindable var viewModel: ProjectsViewModel
|
||||
/// "在此仓库开新会话" 导航钩子(AppCoordinator.openProject 消费)。
|
||||
var onOpen: (ProjectOpenRequest) -> Void = { _ in }
|
||||
/// T-iPad-4 · 设备 idiom 只在此读一次,交给 `ProjectsGridLayout`/
|
||||
/// `ProjectsSheetSizing` 决策(视图里零散落条件)。iPhone → 现有单列 List
|
||||
/// (字节级不变);iPad → 多列网格 + 卡片式 sheet detents。idiom 是设备常量、
|
||||
/// 运行期不变,故非 @Environment 即可。见 `ProjectsLayout` 注释解释为何用
|
||||
/// idiom 而非 size class(iPad 表单 sheet 内部恒 compact)。
|
||||
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
|
||||
|
||||
private enum Copy {
|
||||
static let emptyNoProjectsTitle = "暂无项目"
|
||||
static let emptyNoMatchTitle = "无匹配项目"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
adaptiveContent
|
||||
.navigationTitle(ProjectsCopy.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(text: $viewModel.searchText, prompt: ProjectsCopy.searchPrompt)
|
||||
.refreshable { await viewModel.refresh() }
|
||||
.task { await viewModel.load() }
|
||||
.onChange(of: viewModel.openRequest) { _, request in
|
||||
guard let request else { return }
|
||||
onOpen(request)
|
||||
}
|
||||
.navigationDestination(for: ProjectRoute.self) { route in
|
||||
ProjectDetailScreen(
|
||||
viewModel: viewModel.makeDetailViewModel(path: route.path),
|
||||
endpoint: viewModel.host.endpoint,
|
||||
http: viewModel.http,
|
||||
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) }
|
||||
)
|
||||
}
|
||||
// T-iPad-4 · iPhone 透传(现有全高 sheet 字节级不变);iPad 套
|
||||
// 卡片式 detents(不铺满大屏)。
|
||||
.adaptiveProjectsSheetDetents(idiom: idiom)
|
||||
}
|
||||
|
||||
// MARK: - 自适应容器(唯一 idiom 消费点,经 ProjectsGridLayout)
|
||||
|
||||
/// iPhone → 现有单列 `list`(原样复用,字节级零回归);iPad → 多列
|
||||
/// `gridList`。分组/折叠/收藏/prefs 往返全部是同一 `ProjectsViewModel`
|
||||
/// 逻辑,两条路径只换视觉容器。
|
||||
@ViewBuilder private var adaptiveContent: some View {
|
||||
if ProjectsGridLayout.usesGrid(idiom: idiom) {
|
||||
gridList
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
|
||||
private var list: some View {
|
||||
List {
|
||||
errorRows
|
||||
if let message = viewModel.emptyStateMessage {
|
||||
emptyState(message)
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
ForEach(viewModel.groups) { group in
|
||||
groupSection(group)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.overlay {
|
||||
if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 空态:区分「无项目」与「无搜索结果」(symbol + 文案;friendly,非错误)。
|
||||
private func emptyState(_ message: String) -> some View {
|
||||
ContentUnavailableView {
|
||||
Label(
|
||||
viewModel.isSearching ? Copy.emptyNoMatchTitle : Copy.emptyNoProjectsTitle,
|
||||
systemImage: viewModel.isSearching ? "magnifyingglass" : "folder"
|
||||
)
|
||||
} description: {
|
||||
Text(message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grid(regular 宽度:多列网格)
|
||||
|
||||
/// iPad 分栏/大屏下的多列网格。列数由 `ProjectsGridLayout.columnCount` 按
|
||||
/// **可用宽度**(GeometryReader)决定;分组头/折叠/收藏/行渲染全部复用 List
|
||||
/// 路径的同名 builder(`groupHeader`/`projectRow`/`errorRows`)—— 只换外层
|
||||
/// 容器,分组/收藏/prefs 往返逻辑零改。
|
||||
private var gridList: some View {
|
||||
GeometryReader { proxy in
|
||||
let columns = ProjectsGridLayout.columnCount(
|
||||
availableWidth: proxy.size.width,
|
||||
idiom: idiom
|
||||
)
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
errorRows
|
||||
if let message = viewModel.emptyStateMessage {
|
||||
emptyState(message)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
ForEach(viewModel.groups) { group in
|
||||
gridSection(group, columns: columns)
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
.overlay {
|
||||
if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func gridSection(_ group: ProjectGroup, columns: Int) -> some View {
|
||||
let isCollapsed = viewModel.isCollapsed(group)
|
||||
VStack(alignment: .leading, spacing: DS.Space.sm8) {
|
||||
if group.kind != .flat {
|
||||
groupHeader(group, isCollapsed: isCollapsed)
|
||||
.padding(.top, DS.Space.md12)
|
||||
}
|
||||
if !isCollapsed {
|
||||
LazyVGrid(
|
||||
columns: gridColumns(columns),
|
||||
alignment: .leading,
|
||||
spacing: DS.Space.sm8
|
||||
) {
|
||||
ForEach(group.projects, id: \.path) { project in
|
||||
// 卡片 = DS Card(secondary 底 + 发丝描边 + md12)。
|
||||
Card(padding: DS.Space.md12) {
|
||||
projectRow(project, group: group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 等宽弹性列(`columns >= 1` 由 `ProjectsGridLayout` 保证,绝不空网格)。
|
||||
private func gridColumns(_ count: Int) -> [GridItem] {
|
||||
Array(
|
||||
repeating: GridItem(.flexible(), spacing: DS.Space.sm8),
|
||||
count: max(count, ProjectsGridLayout.singleColumn)
|
||||
)
|
||||
}
|
||||
|
||||
/// 显式错误行(刷新失败留旧列表、prefs 失败降级本地 —— 都要可见)。
|
||||
@ViewBuilder private var errorRows: some View {
|
||||
ForEach(
|
||||
[
|
||||
viewModel.fetchErrorMessage,
|
||||
viewModel.prefsErrorMessage,
|
||||
viewModel.prefsSyncErrorMessage,
|
||||
viewModel.openErrorMessage,
|
||||
].compactMap(\.self),
|
||||
id: \.self
|
||||
) { message in
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusWaiting)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Group section(flat 无 chrome;namespace/other 可折叠)
|
||||
|
||||
@ViewBuilder private func groupSection(_ group: ProjectGroup) -> some View {
|
||||
let isCollapsed = viewModel.isCollapsed(group)
|
||||
Section {
|
||||
if !isCollapsed {
|
||||
ForEach(group.projects, id: \.path) { project in
|
||||
projectRow(project, group: group)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
if group.kind != .flat {
|
||||
groupHeader(group, isCollapsed: isCollapsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func groupHeader(_ group: ProjectGroup, isCollapsed: Bool) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
if group.isCollapsible {
|
||||
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
// namespace label 来自服务器仓库名 → verbatim。
|
||||
Text(verbatim: group.label)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
// 计数含数字 → 等宽 tabular。
|
||||
Text(verbatim: "\(group.projects.count)")
|
||||
.font(DS.Typography.metaMono)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
// 折叠的分区绝不悄悄埋掉活跃会话(镜像 web 组头徽标)。
|
||||
if group.kind != .active && group.activeCount > 0 {
|
||||
Text(ProjectsCopy.activeCountBadge(group.activeCount))
|
||||
.font(DS.Typography.metaMono)
|
||||
.foregroundStyle(DS.Palette.statusWorking)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard group.isCollapsible else { return }
|
||||
Task { await viewModel.toggleCollapsed(key: group.key) }
|
||||
}
|
||||
.accessibilityAddTraits(group.isCollapsible ? .isButton : [])
|
||||
}
|
||||
|
||||
// MARK: - Project row
|
||||
|
||||
private func projectRow(_ project: ProjectInfo, group: ProjectGroup) -> some View {
|
||||
NavigationLink(value: ProjectRoute(path: project.path)) {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
favouriteButton(project)
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs2) {
|
||||
Text(verbatim: ProjectGrouping.displayLabel(
|
||||
name: project.name, groupKey: group.key
|
||||
))
|
||||
.font(DS.Typography.body.weight(.medium))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
projectChips(project)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
// 有运行中会话 → DS 状态徽标(色 + 形 + VoiceOver,非仅靠颜色)。
|
||||
if ProjectGrouping.hasRunningSession(project) {
|
||||
StatusBadge(status: .working)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func favouriteButton(_ project: ProjectInfo) -> some View {
|
||||
Button {
|
||||
DS.Haptics.selection()
|
||||
Task { await viewModel.toggleFavourite(path: project.path) }
|
||||
} label: {
|
||||
let isFav = viewModel.isFavourite(project.path)
|
||||
Image(systemName: isFav ? "star.fill" : "star")
|
||||
.foregroundStyle(isFav ? DS.Palette.accent : DS.Palette.textTertiary)
|
||||
}
|
||||
.buttonStyle(.borderless) // List 行内独立可点
|
||||
}
|
||||
|
||||
@ViewBuilder private func projectChips(_ project: ProjectInfo) -> some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
if let branch = project.branch {
|
||||
Label {
|
||||
Text(verbatim: branch).lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
}
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
if project.dirty == true {
|
||||
DirtyBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情屏的 push 路由值(value-based navigation)。
|
||||
struct ProjectRoute: Hashable {
|
||||
let path: String
|
||||
}
|
||||
358
ios/App/WebTerm/Screens/SessionListScreen.swift
Normal file
@@ -0,0 +1,358 @@
|
||||
import HostRegistry
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-13 · Session list screen (merged chooser + dashboard). Pure
|
||||
/// presentation over `SessionListViewModel` — polling cadence, badge priority,
|
||||
/// staleness, optimistic kill and the navigation signal are all VM logic,
|
||||
/// unit-tested in `SessionListViewModelTests`.
|
||||
///
|
||||
/// UX polish (G1-list): the row is restructured for the 5-second glance —
|
||||
/// prominent title, a `StatusBadge` (semantic color + distinct SF Symbol, never
|
||||
/// color alone), a monospaced meta line, and telemetry as proper
|
||||
/// `TelemetryChip`s. Rows are DS `Card`s; active/exited sessions are split under
|
||||
/// `SectionHeader`s (presentation-only — the VM already groups exited last).
|
||||
/// Every color/spacing/radius/font/motion comes from `DS.*`.
|
||||
///
|
||||
/// The T-iOS-15 wiring provides `onOpen` (push `TerminalScreen`, open with
|
||||
/// `request.sessionId` — nil = new session) and `onAddHost` (present the
|
||||
/// pairing sheet; call `viewModel.reloadHosts()` when it completes).
|
||||
struct SessionListScreen: View {
|
||||
var viewModel: SessionListViewModel
|
||||
/// Navigation hook: fired once per `OpenRequest` (unique id per tap).
|
||||
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
|
||||
/// Host-switch header hook: "添加主机" entry (pairing sheet, T-iOS-15).
|
||||
var onAddHost: () -> Void = {}
|
||||
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
|
||||
/// render-concurrency gate across ALL rows (scrolling must never spawn
|
||||
/// unbounded offscreen terminals). `@State` keeps it stable across body
|
||||
/// rebuilds; `live()` is cheap (the URLSession is a static shared).
|
||||
@State private var thumbnails = SessionThumbnailPipeline.live()
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
// Accent is not injected app-wide; scope it here so native chrome
|
||||
// (nav bar, bordered controls) picks up the DS indigo. Presentation
|
||||
// only — no behavior change.
|
||||
.tint(DS.Palette.accent)
|
||||
.navigationTitle(ScreenCopy.title)
|
||||
.toolbar { hostMenu }
|
||||
.onAppear { viewModel.appeared() }
|
||||
.onDisappear { viewModel.disappeared() }
|
||||
.onChange(of: viewModel.openRequest) { _, request in
|
||||
guard let request else { return }
|
||||
onOpen(request)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
switch viewModel.emptyState {
|
||||
case .notPaired:
|
||||
notPairedView
|
||||
case .noSessions:
|
||||
noSessionsView
|
||||
case nil:
|
||||
sessionList
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
|
||||
private var sessionList: some View {
|
||||
List {
|
||||
if let message = viewModel.fetchErrorMessage {
|
||||
errorRow(message)
|
||||
}
|
||||
if let message = viewModel.killErrorMessage {
|
||||
errorRow(message)
|
||||
}
|
||||
|
||||
newSessionRow
|
||||
|
||||
if !activeRows.isEmpty {
|
||||
Section {
|
||||
ForEach(activeRows) { row in sessionRow(row) }
|
||||
} header: {
|
||||
SectionHeader(title: ScreenCopy.activeSection)
|
||||
}
|
||||
}
|
||||
if !exitedRows.isEmpty {
|
||||
Section {
|
||||
ForEach(exitedRows) { row in sessionRow(row) }
|
||||
} header: {
|
||||
SectionHeader(title: ScreenCopy.exitedSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.refreshable { await viewModel.refresh() }
|
||||
}
|
||||
|
||||
/// Split the VM's (already exited-last) rows into the two visual groups.
|
||||
/// Presentation only — no ordering/logic decision lives here.
|
||||
private var activeRows: [SessionListViewModel.SessionRow] {
|
||||
viewModel.rows.filter { !$0.info.exited }
|
||||
}
|
||||
|
||||
private var exitedRows: [SessionListViewModel.SessionRow] {
|
||||
viewModel.rows.filter { $0.info.exited }
|
||||
}
|
||||
|
||||
/// Inviting primary entry — accent-tinted card row.
|
||||
private var newSessionRow: some View {
|
||||
Button {
|
||||
viewModel.requestNewSession()
|
||||
} label: {
|
||||
NewSessionRow()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("sessions.newButton")
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(rowInsets(vertical: DS.Space.sm8))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
private func sessionRow(_ row: SessionListViewModel.SessionRow) -> some View {
|
||||
Button {
|
||||
viewModel.openSession(id: row.id)
|
||||
} label: {
|
||||
SessionRowView(row: row, thumbnail: thumbnailSlot(for: row))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(rowInsets(vertical: DS.Space.xs4))
|
||||
.listRowBackground(Color.clear)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.kill(sessionId: row.id) }
|
||||
} label: {
|
||||
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Carded-list row inset: full-bleed gutter (`lg16`) + a small vertical gap
|
||||
/// so adjacent cards breathe.
|
||||
private func rowInsets(vertical: CGFloat) -> EdgeInsets {
|
||||
EdgeInsets(
|
||||
top: vertical, leading: DS.Space.lg16,
|
||||
bottom: vertical, trailing: DS.Space.lg16
|
||||
)
|
||||
}
|
||||
|
||||
/// T-iOS-28 · build one row's thumbnail slot. No paired host (defensive —
|
||||
/// rows imply a host) → no slot; the request key carries `lastOutputAt`
|
||||
/// so unchanged sessions render exactly once (pipeline cache).
|
||||
private func thumbnailSlot(
|
||||
for row: SessionListViewModel.SessionRow
|
||||
) -> SessionThumbnailView? {
|
||||
guard let endpoint = viewModel.activeHost?.endpoint else { return nil }
|
||||
return SessionThumbnailView(
|
||||
request: SessionThumbnailRequest(
|
||||
endpoint: endpoint,
|
||||
sessionId: row.id,
|
||||
lastOutputAt: row.info.lastOutputAt
|
||||
),
|
||||
pipeline: thumbnails
|
||||
)
|
||||
}
|
||||
|
||||
private func errorRow(_ message: String) -> some View {
|
||||
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.statusStuck)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(rowInsets(vertical: DS.Space.xs4))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
// MARK: - Empty states
|
||||
|
||||
private var notPairedView: some View {
|
||||
ContentUnavailableView {
|
||||
Label(ScreenCopy.notPairedTitle, systemImage: "personalhotspot")
|
||||
} description: {
|
||||
Text(ScreenCopy.notPairedHint)
|
||||
} actions: {
|
||||
Button(ScreenCopy.addHost) { onAddHost() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
}
|
||||
}
|
||||
|
||||
private var noSessionsView: some View {
|
||||
ContentUnavailableView {
|
||||
Label(ScreenCopy.noSessionsTitle, systemImage: "terminal")
|
||||
} description: {
|
||||
Text(ScreenCopy.noSessionsHint)
|
||||
} actions: {
|
||||
Button(ScreenCopy.newSession) { viewModel.requestNewSession() }
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.accessibilityIdentifier("sessions.newButton")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Host-switch header (multi-host from HostStore)
|
||||
|
||||
private var hostMenu: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
ForEach(viewModel.hosts) { host in
|
||||
Button {
|
||||
Task { await viewModel.selectHost(id: host.id) }
|
||||
} label: {
|
||||
if host.id == viewModel.activeHost?.id {
|
||||
Label(host.name, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(host.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
onAddHost()
|
||||
} label: {
|
||||
Label(ScreenCopy.addHost, systemImage: "plus")
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
|
||||
systemImage: "desktopcomputer"
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("sessions.hostMenu")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row
|
||||
|
||||
/// Inviting "新建会话" card — accent icon + accent title on a DS `Card`.
|
||||
private struct NewSessionRow: View {
|
||||
var body: some View {
|
||||
Card(padding: DS.Space.md12) {
|
||||
HStack(spacing: DS.Space.md12) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(DS.Typography.title)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
Text(ScreenCopy.newSession)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One session row: `StatusBadge` · title · mono meta · telemetry chips ·
|
||||
/// optional live thumbnail — laid out inside a DS `Card`.
|
||||
private struct SessionRowView: View {
|
||||
let row: SessionListViewModel.SessionRow
|
||||
/// T-iOS-28 (additive) · trailing live-preview thumbnail; nil = no slot
|
||||
/// (no host / preview-less contexts) and the row lays out as before.
|
||||
var thumbnail: SessionThumbnailView?
|
||||
|
||||
var body: some View {
|
||||
Card(padding: DS.Space.md12) {
|
||||
HStack(alignment: .top, spacing: DS.Space.md12) {
|
||||
// ⚠ pending / exited outrank the raw status (VM decides pending;
|
||||
// exited is a row fact) — resolved to a single DisplayStatus so
|
||||
// the badge shows color + distinct shape + Chinese VoiceOver.
|
||||
StatusBadge(status: displayStatus)
|
||||
.padding(.top, DS.Space.xs2)
|
||||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||||
titleLine
|
||||
Text(meta)
|
||||
.dsMetaText()
|
||||
.lineLimit(1)
|
||||
if let telemetry = row.telemetry, !telemetry.isEmpty {
|
||||
TelemetryChips(model: telemetry)
|
||||
}
|
||||
}
|
||||
if let thumbnail {
|
||||
Spacer(minLength: DS.Space.sm8)
|
||||
thumbnail
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(row.info.exited ? DS.Opacity.exited : 1)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var titleLine: some View {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
// T-iOS-23: OSC titles are attacker-controlled — already sanitized
|
||||
// in the VM, rendered verbatim (no Markdown / LocalizedStringKey
|
||||
// interpretation), one line only.
|
||||
Text(verbatim: title)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
if row.isUnread {
|
||||
unreadDot
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unread dot (T-iOS-23): output newer than the local last-seen watermark.
|
||||
/// Accent (indigo) continues the web selection color — distinct from the
|
||||
/// gray/green status shapes.
|
||||
private var unreadDot: some View {
|
||||
Circle()
|
||||
.fill(DS.Palette.accent)
|
||||
.frame(width: DS.Space.sm8, height: DS.Space.sm8)
|
||||
.accessibilityLabel(ScreenCopy.unreadLabel)
|
||||
}
|
||||
|
||||
/// Resolve the row's status to one `DisplayStatus`. Exited is a terminal
|
||||
/// fact (top precedence); otherwise the VM's badge priority stands
|
||||
/// (pending ⚠ outranks the live status). No VM logic re-implemented here.
|
||||
private var displayStatus: DisplayStatus {
|
||||
if row.info.exited { return .exited }
|
||||
switch row.indicator {
|
||||
case .pendingApproval: return .pendingApproval
|
||||
case .status(let status): return DisplayStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitized OSC title first (T-iOS-23, mirrors web autoTitle precedence,
|
||||
/// public/tabs.ts:570), else the cwd-derived name. Both are server-supplied
|
||||
/// display text (untrusted → verbatim Text only).
|
||||
private var title: String {
|
||||
if let osc = row.title, !osc.isEmpty { return osc }
|
||||
guard let cwd = row.info.cwd, !cwd.isEmpty else { return ScreenCopy.unknownDirectory }
|
||||
return URL(fileURLWithPath: cwd).lastPathComponent
|
||||
}
|
||||
|
||||
/// Client count + `cols×rows`, rendered mono-tabular via `.dsMetaText()`.
|
||||
/// The exited state is conveyed by the badge + section + dimming, so it is
|
||||
/// not duplicated here.
|
||||
private var meta: String {
|
||||
[
|
||||
ScreenCopy.clientCount(row.info.clientCount),
|
||||
"\(row.info.cols)×\(row.info.rows)",
|
||||
].joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Screen copy
|
||||
|
||||
private enum ScreenCopy {
|
||||
static let title = "会话"
|
||||
static let newSession = "新建会话"
|
||||
static let kill = "结束"
|
||||
static let addHost = "配对新主机"
|
||||
static let hostMenuFallback = "主机"
|
||||
static let notPairedTitle = "还没有配对的主机"
|
||||
static let notPairedHint = "先配对你电脑上的 web-terminal(扫码或手输地址),会话会出现在这里。"
|
||||
static let noSessionsTitle = "主机上没有运行中的会话"
|
||||
static let noSessionsHint = "新建一个会话开始工作;关掉 App 后会话仍会在主机上继续跑。"
|
||||
static let unknownDirectory = "未知目录"
|
||||
static let unreadLabel = "有新输出"
|
||||
static let activeSection = "运行中"
|
||||
static let exitedSection = "已结束"
|
||||
|
||||
static func clientCount(_ count: Int) -> String {
|
||||
"\(count) 台设备在看"
|
||||
}
|
||||
}
|
||||
338
ios/App/WebTerm/Screens/TerminalScreen.swift
Normal file
@@ -0,0 +1,338 @@
|
||||
import GameController
|
||||
import SessionCore
|
||||
import SwiftTerm
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// T-iOS-11 · The terminal screen: SwiftTerm view + key bar + status banner.
|
||||
///
|
||||
/// Data flow (byte-shuttle preserved end to end):
|
||||
/// - inbound: `SessionEvent.output` → `TerminalViewModel` sink → `feed(text:)`
|
||||
/// (opaque ANSI/UTF-8 — the app NEVER parses terminal semantics);
|
||||
/// - outbound: SwiftTerm delegate `send` → `viewModel.sendInput`,
|
||||
/// `sizeChanged` → `viewModel.sendResize`; KeyBar taps and hardware
|
||||
/// `UIKeyCommand`s go through `viewModel.send(key:)` — every label→bytes
|
||||
/// lookup via `KeyByteMap`, bypassing SwiftTerm's text path so the soft
|
||||
/// keyboard never pops (mirrors the web bar's ws.send bypass).
|
||||
/// - IME: NO custom keydown/composition interception — SwiftTerm manages
|
||||
/// composition itself (CLAUDE.md gotcha; plan §7 T-iOS-11).
|
||||
///
|
||||
/// Navigation/lifecycle wiring (engine open/close, scenePhase) lands in
|
||||
/// T-iOS-15 — this screen only renders and routes.
|
||||
struct TerminalScreen: View {
|
||||
let viewModel: TerminalViewModel
|
||||
/// T-iOS-29 · "在当前目录开新会话":toolbar 常驻入口 + exit 横幅上的
|
||||
/// "开新会话"(同一动作 —— close→open fresh spawn,cwd 解析在
|
||||
/// `AppCoordinator.openNewSessionInCurrentCwd`)。nil = 两处入口都隐藏
|
||||
/// (预览/无 coordinator 的测试环境)。
|
||||
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
|
||||
/// T-iPad-3 · 上下文菜单「结束会话」动作 —— wiring 侧路由到
|
||||
/// `APIClient.killSession`(带 Origin 的 G 端点)。nil = 无 kill 通道时
|
||||
/// 菜单不呈现该项(预览/未布线环境)。
|
||||
var onKillSession: (@MainActor () -> Void)? = nil
|
||||
|
||||
/// T-iPad-3 · 硬件键盘在场标记(`GCKeyboard.coalesced != nil`),随
|
||||
/// 连接/断开通知更新,驱动 `KeyBarVisibility` 的自动默认。
|
||||
@State private var hasHardwareKeyboard = GCKeyboard.coalesced != nil
|
||||
/// T-iPad-3 · 用户对 KeyBar 的显式覆盖:nil = 跟随自动默认。
|
||||
@State private var keyBarUserOverride: Bool?
|
||||
|
||||
/// 无障碍:减弱动态效果时,横幅进出塌成即时切换(DS.Motion.gated)。
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
private enum Copy {
|
||||
static let newSessionInCwd = "在当前目录开新会话"
|
||||
static let showKeyBar = "显示快捷键栏"
|
||||
static let hideKeyBar = "隐藏快捷键栏"
|
||||
}
|
||||
|
||||
/// KeyBar 是否可见 —— 唯一判据经 `KeyBarVisibility` 纯谓词。
|
||||
private var isKeyBarVisible: Bool {
|
||||
KeyBarVisibility.isVisible(
|
||||
hardwareKeyboardPresent: hasHardwareKeyboard,
|
||||
userOverride: keyBarUserOverride
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TerminalHostView(
|
||||
viewModel: viewModel,
|
||||
keyBarVisible: isKeyBarVisible,
|
||||
onNewSessionInCwd: onNewSessionInCwd,
|
||||
onKillSession: onKillSession
|
||||
)
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
.overlay(alignment: .top) {
|
||||
if let model = viewModel.bannerModel {
|
||||
ReconnectBanner(model: model, onNewSession: onNewSessionInCwd)
|
||||
.padding(.horizontal, DS.Space.md12)
|
||||
.padding(.top, DS.Space.sm8)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: viewModel.bannerModel
|
||||
)
|
||||
.toolbar {
|
||||
newSessionToolbarItem
|
||||
keyBarToggleToolbarItem
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidConnect)) { _ in
|
||||
hasHardwareKeyboard = true
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
|
||||
hasHardwareKeyboard = GCKeyboard.coalesced != nil
|
||||
}
|
||||
.onAppear { viewModel.start() }
|
||||
}
|
||||
|
||||
/// T-iPad-3 · KeyBar 手动切换。仅在硬件键盘在场时出现 —— 无硬件键盘的
|
||||
/// iPhone 默认工具栏因此逐屏不变(零回归);有硬件键盘时(自动隐藏后)
|
||||
/// 用户可一键取回/再隐。切换即写显式覆盖,压过自动默认。
|
||||
@ToolbarContentBuilder private var keyBarToggleToolbarItem: some ToolbarContent {
|
||||
if hasHardwareKeyboard {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
keyBarUserOverride = !isKeyBarVisible
|
||||
} label: {
|
||||
Label(
|
||||
isKeyBarVisible ? Copy.hideKeyBar : Copy.showKeyBar,
|
||||
systemImage: isKeyBarVisible
|
||||
? "keyboard.chevron.compact.down" : "keyboard"
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("terminal.keyBarToggleButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors web `tabs.ts newTab()` (M6): the + affordance opens a fresh
|
||||
/// session in the active session's cwd, if known.
|
||||
@ToolbarContentBuilder private var newSessionToolbarItem: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if let onNewSessionInCwd {
|
||||
Button {
|
||||
onNewSessionInCwd()
|
||||
} label: {
|
||||
Label(Copy.newSessionInCwd, systemImage: "plus.rectangle.on.folder")
|
||||
}
|
||||
.accessibilityIdentifier("terminal.newInCwdButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal theme
|
||||
|
||||
/// Refined terminal theme (精致原生) matching the desktop: a fixed warm-dark
|
||||
/// canvas (#100F0D bg / #ECE9E3 fg, web --bg/--text) so the terminal reads the
|
||||
/// same on iOS and desktop regardless of app appearance, with caret/selection
|
||||
/// in the one gold accent. Every value routes through `DS` (no literals); the
|
||||
/// glyph font is Dynamic-Type-aware SF Mono so terminal text respects the
|
||||
/// user's text-size choice at launch.
|
||||
private enum TerminalTheme {
|
||||
@MainActor
|
||||
static func apply(to terminal: TerminalView) {
|
||||
terminal.font = UIFont.monospacedSystemFont(
|
||||
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
|
||||
weight: .regular
|
||||
)
|
||||
terminal.nativeBackgroundColor = UIColor(DS.Palette.terminalBackground)
|
||||
terminal.nativeForegroundColor = UIColor(DS.Palette.terminalForeground)
|
||||
terminal.caretColor = DS.Palette.accentUIColor()
|
||||
terminal.selectedTextBackgroundColor = DS.Palette.accentUIColor()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftTerm bridge
|
||||
|
||||
/// `UIViewRepresentable` around `SwiftTerm.TerminalView` (plan §3.5). The
|
||||
/// KeyBar is installed as `inputAccessoryView`; hardware chords come from the
|
||||
/// `KeyCommandTerminalView` subclass. Both route through the ViewModel.
|
||||
private struct TerminalHostView: UIViewRepresentable {
|
||||
let viewModel: TerminalViewModel
|
||||
/// T-iPad-3 · KeyBar (`inputAccessoryView`) 可见性,由 `KeyBarVisibility`
|
||||
/// 谓词在 SwiftUI 侧算出;`updateUIView` 仅在变化时增量应用。
|
||||
let keyBarVisible: Bool
|
||||
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
|
||||
var onKillSession: (@MainActor () -> Void)? = nil
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(viewModel: viewModel)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> KeyCommandTerminalView {
|
||||
let terminal = KeyCommandTerminalView(frame: .zero)
|
||||
terminal.terminalDelegate = context.coordinator
|
||||
TerminalTheme.apply(to: terminal)
|
||||
|
||||
let viewModel = viewModel
|
||||
terminal.onKeyCommand = { key in viewModel.send(key: key) }
|
||||
|
||||
let keyBar = KeyBarView()
|
||||
keyBar.onKey = { key in viewModel.send(key: key) }
|
||||
terminal.installKeyBar(keyBar, visible: keyBarVisible)
|
||||
|
||||
// T-iPad-3 · 指针右键/长按上下文菜单(仅 iPad 安装 —— iPhone 长按保持
|
||||
// SwiftTerm 选区手势)。动作复用既有通道,无新增网络路径。
|
||||
terminal.installPointerContextMenuIfSupported(
|
||||
onNewInCwd: onNewSessionInCwd,
|
||||
onKill: onKillSession
|
||||
)
|
||||
|
||||
// Output sink: buffered replay flushes now, live bytes follow.
|
||||
// @MainActor-typed closure — feeding off the main actor cannot compile.
|
||||
viewModel.attachTerminalSink { [weak terminal] text in
|
||||
terminal?.feed(text: text)
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: KeyCommandTerminalView, context: Context) {
|
||||
// State-driven UI lives in SwiftUI (banner overlay); the terminal view
|
||||
// itself is driven by the sink/delegate. The only push is the KeyBar
|
||||
// visibility (hardware-keyboard aware / user toggle) — a no-op unless it
|
||||
// actually changed, so iPhone (always-visible) never reloads input views.
|
||||
uiView.setKeyBarVisible(keyBarVisible)
|
||||
}
|
||||
|
||||
/// SwiftTerm's delegate is a pre-concurrency protocol; the conformance is
|
||||
/// `@preconcurrency` — SwiftTerm only calls it from the main thread (the
|
||||
/// view itself is `@MainActor`), which the runtime check enforces.
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, @preconcurrency TerminalViewDelegate {
|
||||
private let viewModel: TerminalViewModel
|
||||
|
||||
init(viewModel: TerminalViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
/// User typed into SwiftTerm (soft/hardware keyboard, IME result):
|
||||
/// raw bytes, passed through verbatim (invariant #9).
|
||||
func send(source: TerminalView, data: ArraySlice<UInt8>) {
|
||||
viewModel.sendInput(String(decoding: data, as: UTF8.self))
|
||||
}
|
||||
|
||||
/// Layout produced a new grid → server `resize` (SIGWINCH). This is
|
||||
/// also the latest-writer-wins size claim (v0.4 sizing model).
|
||||
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
|
||||
guard newCols > 0, newRows > 0 else { return } // pre-layout noise
|
||||
viewModel.sendResize(cols: newCols, rows: newRows)
|
||||
}
|
||||
|
||||
/// Tapped link (OSC 8 / detected URL — mirrors web M2 WebLinksAddon).
|
||||
/// The link string is untrusted terminal output: http(s) only.
|
||||
func requestOpenLink(source: TerminalView, link: String, params: [String: String]) {
|
||||
guard let url = URL(string: link),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https"
|
||||
else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
/// OSC 0/2 title (T-iOS-23): hostile input — the VM sanitizes
|
||||
/// (TitleSanitizer) before any UI/registry use, then the wiring
|
||||
/// surfaces it on the session-list row.
|
||||
func setTerminalTitle(source: TerminalView, title: String) {
|
||||
viewModel.setTerminalTitle(title)
|
||||
}
|
||||
|
||||
// cwd surfaces in the session list via the server (T-iOS-13), not
|
||||
// from the local emulator — deliberate no-op.
|
||||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
|
||||
func scrolled(source: TerminalView, position: Double) {}
|
||||
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
|
||||
|
||||
/// OSC 52 lets the HOST write the device clipboard silently — declined
|
||||
/// (server output is untrusted input; plan §4).
|
||||
func clipboardCopy(source: TerminalView, content: Data) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `TerminalView` subclass adding hardware-keyboard `UIKeyCommand`s with the
|
||||
/// SAME `KeyByteMap` mapping as the key bar (scope decision documented on
|
||||
/// `HardwareKeyCommands`). Everything else — rendering, selection, IME —
|
||||
/// is stock SwiftTerm.
|
||||
final class KeyCommandTerminalView: TerminalView {
|
||||
/// Chord outlet; the screen routes it to `TerminalViewModel.send(key:)`.
|
||||
var onKeyCommand: (@MainActor (KeyByteMap.Key) -> Void)?
|
||||
|
||||
/// Retained KeyBar so visibility can toggle it in/out of `inputAccessoryView`
|
||||
/// (T-iPad-3). The last-applied value avoids reloading input views when the
|
||||
/// visibility did not change (iPhone stays byte-identical).
|
||||
private var keyBar: KeyBarView?
|
||||
private var appliedKeyBarVisible = true
|
||||
/// Retained so the interaction's delegate outlives menu presentation.
|
||||
private var contextMenuDelegate: TerminalContextMenuInteractionDelegate?
|
||||
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
(super.keyCommands ?? [])
|
||||
+ HardwareKeyCommands.build(action: #selector(runHardwareKeyCommand(_:)))
|
||||
}
|
||||
|
||||
@objc private func runHardwareKeyCommand(_ sender: UIKeyCommand) {
|
||||
guard let key = HardwareKeyCommands.key(matching: sender) else { return }
|
||||
onKeyCommand?(key)
|
||||
}
|
||||
|
||||
// MARK: - KeyBar install / visibility (T-iPad-3)
|
||||
|
||||
/// Install the KeyBar as `inputAccessoryView`, honoring the initial
|
||||
/// visibility (hidden when a hardware keyboard makes it redundant).
|
||||
func installKeyBar(_ bar: KeyBarView, visible: Bool) {
|
||||
keyBar = bar
|
||||
appliedKeyBarVisible = visible
|
||||
inputAccessoryView = visible ? bar : nil
|
||||
}
|
||||
|
||||
/// Apply a visibility change; a no-op when unchanged so no needless
|
||||
/// `reloadInputViews()` (the KeyByteMap routing on `keyBar.onKey` is
|
||||
/// untouched — the same bar is only detached/re-attached).
|
||||
func setKeyBarVisible(_ visible: Bool) {
|
||||
guard visible != appliedKeyBarVisible else { return }
|
||||
appliedKeyBarVisible = visible
|
||||
inputAccessoryView = visible ? keyBar : nil
|
||||
reloadInputViews()
|
||||
}
|
||||
|
||||
// MARK: - Pointer context menu (T-iPad-3, iPad only)
|
||||
|
||||
/// Install the secondary-click / long-press context menu — iPad only, so
|
||||
/// iPhone long-press keeps SwiftTerm's native selection gesture (zero
|
||||
/// regression). The delegate builds a fresh model per presentation so the
|
||||
/// copy item reflects the live selection.
|
||||
func installPointerContextMenuIfSupported(
|
||||
onNewInCwd: (@MainActor () -> Void)?,
|
||||
onKill: (@MainActor () -> Void)?
|
||||
) {
|
||||
// `UIDevice.current.userInterfaceIdiom` (not `traitCollection`, which can
|
||||
// be `.unspecified` before the view joins a window at makeUIView time).
|
||||
guard TerminalContextMenu.isPointerMenuEnabled(
|
||||
idiom: UIDevice.current.userInterfaceIdiom
|
||||
) else { return }
|
||||
let delegate = TerminalContextMenuInteractionDelegate { [weak self] in
|
||||
TerminalContextMenuModel(
|
||||
onNewInCwd: onNewInCwd,
|
||||
onKill: onKill,
|
||||
onCopySelection: { [weak self] in self?.copySelectionToPasteboard() },
|
||||
hasSelection: { [weak self] in self?.hasActiveSelection ?? false }
|
||||
)
|
||||
}
|
||||
contextMenuDelegate = delegate
|
||||
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)
|
||||
}
|
||||
|
||||
/// Copy the current selection via SwiftTerm's own `copy(_:)` (selection →
|
||||
/// `UIPasteboard`). Pure UI: it never writes to the PTY, so the byte stream
|
||||
/// is untouched (invariant preserved — same as pointer hover highlight).
|
||||
func copySelectionToPasteboard() {
|
||||
copy(nil)
|
||||
}
|
||||
}
|
||||
167
ios/App/WebTerm/Screens/TimelineSheet.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-24 · Full activity-timeline drill-down, presented as a sheet from the
|
||||
/// away-digest「展开」affordance (TerminalContainerView wiring).
|
||||
///
|
||||
/// Mirrors the web timeline panel (public/timeline.ts): rows are
|
||||
/// "HH:MM · icon · label", newest-first, capped at
|
||||
/// `TimelineViewModel.maxEvents`. `label` is SERVER text (untrusted display
|
||||
/// input) — rendered via `Text(verbatim:)` only, `lineLimit(1)` (same row
|
||||
/// discipline as AwayDigestView; web sets it via textContent, SEC-H6).
|
||||
struct TimelineSheet: View {
|
||||
let viewModel: TimelineViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
content
|
||||
.navigationTitle(TimelineCopy.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.task { await viewModel.load() } // fresh VM per presentation → one fetch
|
||||
}
|
||||
|
||||
// MARK: - Phase switch
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
switch viewModel.phase {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .empty:
|
||||
emptyState
|
||||
case .failed:
|
||||
failedState
|
||||
case .loaded(let events):
|
||||
eventList(events)
|
||||
}
|
||||
}
|
||||
|
||||
/// Server replied `[]` — covers both "no activity yet" and timeline
|
||||
/// capture disabled host-side (src/server.ts:589-591). An empty timeline
|
||||
/// is a normal state, NEVER an error (task spec).
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView(
|
||||
TimelineCopy.emptyTitle,
|
||||
systemImage: "clock.badge.questionmark",
|
||||
description: Text(TimelineCopy.emptyDetail)
|
||||
)
|
||||
}
|
||||
|
||||
/// Explicit, retryable error state — the fetch can fail transiently
|
||||
/// (LAN hop, host asleep); retry re-runs the same load path.
|
||||
private var failedState: some View {
|
||||
ContentUnavailableView {
|
||||
Label(TimelineCopy.loadFailed, systemImage: "wifi.exclamationmark")
|
||||
} description: {
|
||||
Text(TimelineCopy.loadFailedDetail)
|
||||
} actions: {
|
||||
Button(TimelineCopy.retry) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(DS.Palette.accent)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rows (newest-first, already ordered by the VM)
|
||||
|
||||
private func eventList(_ events: [TimelineEvent]) -> some View {
|
||||
List {
|
||||
// Offset identity (not `at`): the server can ingest several
|
||||
// events in the same millisecond, and rows are static.
|
||||
ForEach(Array(events.enumerated()), id: \.offset) { _, event in
|
||||
row(event)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
private func row(_ event: TimelineEvent) -> some View {
|
||||
HStack(spacing: DS.Space.md12) {
|
||||
// Timestamp — mono/tabular so HH:mm columns line up (direction).
|
||||
Text(TimelineRowFormat.timeLabel(atMs: event.at))
|
||||
.font(DS.Typography.mono(.caption))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
// class → glyph + semantic color (frozen mapping; unit-tested).
|
||||
Text(verbatim: TimelineClassStyle.glyph(for: event.class))
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(TimelineClassStyle.color(for: event.class))
|
||||
.frame(width: DS.Space.xxl24)
|
||||
// Server-derived phrase — untrusted: verbatim (never
|
||||
// LocalizedStringKey/Markdown) + hard single-line truncation.
|
||||
Text(verbatim: event.label)
|
||||
.font(DS.Typography.callout)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, DS.Space.xs2)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - class → icon / color mapping
|
||||
|
||||
/// Glyphs mirror web `timelineIcon` verbatim (public/timeline.ts:88-96).
|
||||
/// Colors are semantic — the web CSS defines NO tl-icon-* colors, so iOS
|
||||
/// aligns with SessionListScreen's status color convention (waiting=orange,
|
||||
/// stuck=red) and extends it. Total functions: the server is untrusted, so an
|
||||
/// unknown class degrades to a neutral glyph/color instead of trapping (even
|
||||
/// though `APIClient.events` already drops unknown classes — defense in depth).
|
||||
enum TimelineClassStyle {
|
||||
static let fallbackGlyph = "•"
|
||||
|
||||
static func glyph(for cls: String) -> String {
|
||||
switch cls {
|
||||
case "tool": return "🔧"
|
||||
case "waiting": return "⏳"
|
||||
case "done": return "✓"
|
||||
case "stuck": return "⚠"
|
||||
case "user": return "💬"
|
||||
default: return fallbackGlyph
|
||||
}
|
||||
}
|
||||
|
||||
static func color(for cls: String) -> Color {
|
||||
// All from the frozen design system — no raw SwiftUI colors (UX finding).
|
||||
switch cls {
|
||||
case "tool": return DS.Palette.timelineTool
|
||||
case "waiting": return DS.Palette.statusWaiting
|
||||
case "done": return DS.Palette.statusWorking
|
||||
case "stuck": return DS.Palette.statusStuck
|
||||
case "user": return DS.Palette.timelineUser
|
||||
default: return DS.Palette.textSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row time formatting
|
||||
|
||||
/// Mirrors web `formatHHMM` (public/timeline.ts:101-106): 24-hour wall-clock
|
||||
/// "HH:mm". Fixed POSIX locale so a 12-hour user locale can't leak AM/PM into
|
||||
/// the fixed format; timezone injectable for deterministic tests.
|
||||
enum TimelineRowFormat {
|
||||
private static let millisecondsPerSecond = 1_000.0
|
||||
|
||||
static func timeLabel(atMs: Int, timeZone: TimeZone = .current) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = timeZone
|
||||
formatter.dateFormat = "HH:mm"
|
||||
let date = Date(timeIntervalSince1970: Double(atMs) / millisecondsPerSecond)
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 用户可见文案(中文具名常量,plan 工程标准)
|
||||
|
||||
enum TimelineCopy {
|
||||
static let title = "活动时间线"
|
||||
static let emptyTitle = "暂无活动"
|
||||
static let emptyDetail = "会话还没有可展示的事件;主机关闭时间线(TIMELINE_ENABLED=0)时也会显示为空。"
|
||||
static let loadFailed = "时间线加载失败"
|
||||
static let loadFailedDetail = "无法从主机获取活动时间线,请检查连接后重试。"
|
||||
static let retry = "重试"
|
||||
}
|
||||
164
ios/App/WebTerm/ViewModels/DiffViewModel.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-27 · State for the read-only diff viewer (`DiffScreen`).
|
||||
///
|
||||
/// The fetch closure is injected —— 生产由 `forProject` 用 `DiffFetcher` 包一
|
||||
/// 层(App 装配缝,T-iOS-26 的 ProjectDetail 入口按 (endpoint, path) 构造);
|
||||
/// 测试注入 fake。服务器数据的不可信处理(宽容解码、错误分类)已在
|
||||
/// `DiffFetcher` 完成;本 VM 只区分用户可见的四种结局:
|
||||
/// - 非空 files → `.loaded`:文件头/hunk 头/行**平铺**为惰性列表行模型
|
||||
/// (巨 diff —— 服务器上限 DIFF_MAX_BYTES 2MB —— 绝不合成单个 Text 块);
|
||||
/// - `[]` → `.empty`(truncated 位透传,截断到空也要提示);
|
||||
/// - 400/非法请求 → `.failed(.pathInvalid)`、404 → `.failed(.notFound)`、
|
||||
/// 其余 → `.failed(.unavailable)` —— 全部可经 `load()` 重试;
|
||||
/// - staged/unstaged 切换(`setStaged`)→ 以新范围重新 fetch,同值 no-op。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DiffViewModel {
|
||||
/// 用户可见的失败三分类(文案映射在 DiffScreen 的 DiffCopy)。
|
||||
enum Failure: Equatable {
|
||||
case pathInvalid
|
||||
case notFound
|
||||
case unavailable
|
||||
}
|
||||
|
||||
/// Rendering phase — an explicit enum so the screen can never show an
|
||||
/// error and diff rows at the same time (same discipline as Timeline).
|
||||
enum Phase: Equatable {
|
||||
case loading
|
||||
/// 服务器回了空 files(该范围无改动)。truncated 位仍需提示。
|
||||
case empty(truncated: Bool)
|
||||
case loaded(DiffPresentation)
|
||||
case failed(Failure)
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .loading
|
||||
/// 当前范围:false = 工作区(unstaged),true = 已暂存(--staged)。
|
||||
private(set) var staged = false
|
||||
|
||||
@ObservationIgnored
|
||||
private let fetch: @Sendable (_ staged: Bool) async throws -> DiffResult
|
||||
|
||||
init(fetch: @escaping @Sendable (_ staged: Bool) async throws -> DiffResult) {
|
||||
self.fetch = fetch
|
||||
}
|
||||
|
||||
/// 生产装配缝:`DiffScreen(endpoint:path:http:)` 经此构造(T-iOS-26 的
|
||||
/// ProjectDetail 入口只需转手这三样,无需触碰 DiffFetcher)。
|
||||
static func forProject(
|
||||
endpoint: HostEndpoint, path: String, http: any HTTPTransport
|
||||
) -> DiffViewModel {
|
||||
let fetcher = DiffFetcher(endpoint: endpoint, http: http)
|
||||
return DiffViewModel(fetch: { staged in
|
||||
try await fetcher.fetch(path: path, staged: staged)
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch and present. Also the「重试」path: callable again from `.failed`.
|
||||
func load() async {
|
||||
phase = .loading
|
||||
do {
|
||||
let result = try await fetch(staged)
|
||||
phase = Self.presentation(for: result)
|
||||
} catch let error as DiffFetchError {
|
||||
phase = .failed(Self.failure(for: error))
|
||||
} catch {
|
||||
phase = .failed(.unavailable) // 传输层等其余错误:可重试兜底
|
||||
}
|
||||
}
|
||||
|
||||
/// staged/unstaged 切换 → 重新 fetch;同值绝不重复请求(任务 Steps)。
|
||||
func setStaged(_ newValue: Bool) async {
|
||||
guard newValue != staged else { return }
|
||||
staged = newValue
|
||||
await load()
|
||||
}
|
||||
|
||||
// MARK: - 纯呈现归约(静态,可单测)
|
||||
|
||||
static func presentation(for result: DiffResult) -> Phase {
|
||||
guard !result.files.isEmpty else {
|
||||
return .empty(truncated: result.truncated)
|
||||
}
|
||||
return .loaded(DiffPresentation(
|
||||
rows: makeRows(files: result.files), truncated: result.truncated
|
||||
))
|
||||
}
|
||||
|
||||
/// 平铺:文件头 → (binary 占位 | hunk 头 → 行…)…,逐文件顺序保持服务器
|
||||
/// 返回顺序;binary 短路 hunks(镜像 web renderDiffFile 的 early return,
|
||||
/// public/diff.ts:163-166)。id 为稳定递增序号(行内容可重复,不能当身份)。
|
||||
static func makeRows(files: [DiffFile]) -> [DiffRow] {
|
||||
var rows: [DiffRow] = []
|
||||
for file in files {
|
||||
rows.append(DiffRow(id: rows.count, kind: .fileHeader(header(for: file))))
|
||||
if file.binary {
|
||||
rows.append(DiffRow(id: rows.count, kind: .binaryNotice))
|
||||
continue
|
||||
}
|
||||
for hunk in file.hunks {
|
||||
rows.append(DiffRow(id: rows.count, kind: .hunkHeader(hunk.header)))
|
||||
for line in hunk.lines {
|
||||
rows.append(DiffRow(
|
||||
id: rows.count, kind: .line(kind: line.kind, text: line.text)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
private static func failure(for error: DiffFetchError) -> Failure {
|
||||
switch error {
|
||||
case .invalidRequest, .pathInvalid:
|
||||
return .pathInvalid
|
||||
case .projectNotFound:
|
||||
return .notFound
|
||||
case .invalidResponse, .unexpectedStatus:
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename 显示 “old → new”(镜像 web,public/diff.ts:146-150),其余显示
|
||||
/// newPath。路径是服务器字节 —— 屏幕侧一律 `Text(verbatim:)`。
|
||||
private static func header(for file: DiffFile) -> DiffFileHeader {
|
||||
let pathLabel = file.status == .renamed && file.oldPath != file.newPath
|
||||
? "\(file.oldPath) → \(file.newPath)"
|
||||
: file.newPath
|
||||
return DiffFileHeader(
|
||||
pathLabel: pathLabel, added: file.added,
|
||||
removed: file.removed, status: file.status
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 行模型(惰性列表的最小呈现单元)
|
||||
|
||||
/// Display-ready flattened diff(不可变快照)。
|
||||
struct DiffPresentation: Equatable {
|
||||
let rows: [DiffRow]
|
||||
let truncated: Bool
|
||||
}
|
||||
|
||||
/// One lazy-list row. `id` = 平铺序号(稳定、唯一——文本内容可重复)。
|
||||
struct DiffRow: Equatable, Identifiable {
|
||||
enum Kind: Equatable {
|
||||
case fileHeader(DiffFileHeader)
|
||||
case binaryNotice
|
||||
case hunkHeader(String)
|
||||
case line(kind: DiffLineKind, text: String)
|
||||
}
|
||||
|
||||
let id: Int
|
||||
let kind: Kind
|
||||
}
|
||||
|
||||
/// File-header row payload(路径标签已含 rename 箭头)。
|
||||
struct DiffFileHeader: Equatable {
|
||||
let pathLabel: String
|
||||
let added: Int
|
||||
let removed: Int
|
||||
let status: DiffFileStatus
|
||||
}
|
||||
319
ios/App/WebTerm/ViewModels/GateViewModel.swift
Normal file
@@ -0,0 +1,319 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SessionCore
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
|
||||
/// Haptic seam (T-iOS-14): gate-arrival feedback is injected so tests can
|
||||
/// assert "exactly once per gate epoch" without UIKit hardware.
|
||||
@MainActor
|
||||
protocol HapticSignaling {
|
||||
/// A NEW gate (fresh epoch) is waiting for the user's decision.
|
||||
func gateDidArrive()
|
||||
}
|
||||
|
||||
/// Production haptics: notification-style buzz — a gate is Claude asking for a
|
||||
/// decision (THE steering primitive), not a mere UI tick.
|
||||
@MainActor
|
||||
final class GateHaptics: HapticSignaling {
|
||||
private let generator = UINotificationFeedbackGenerator()
|
||||
|
||||
func gateDidArrive() {
|
||||
generator.notificationOccurred(.warning)
|
||||
}
|
||||
}
|
||||
|
||||
/// T-iOS-14 · Gate + away-digest state (plan §3.5): a STANDALONE
|
||||
/// `@MainActor @Observable` VM consuming the SAME `SessionEvent` stream as
|
||||
/// `TerminalViewModel` — `.gate`/`.digest` are its domain, everything else is
|
||||
/// ignored here. TerminalScreen wiring (who fans the stream out) is T-iOS-15.
|
||||
///
|
||||
/// Stale-tap guard (FIRST line): every rendered gate button carries the epoch
|
||||
/// of the gate it was rendered against; `decide` compares that epoch — and the
|
||||
/// affordance — against the LATEST gate event received and silently drops a
|
||||
/// mismatch, so a slow tap can never resolve a NEWER gate than the one that
|
||||
/// was on screen (mirrors the web's pendingEpoch nonce,
|
||||
/// public/terminal-session.ts:98-102). The engine's `GateTracker.canDecide`
|
||||
/// is the SECOND line (SessionEngine type doc) — both must agree to send.
|
||||
///
|
||||
/// Plan-gate mapping is frozen in SessionCore
|
||||
/// (`GateState.Affordance.clientMessage`, mirror of public/tabs.ts:345-347):
|
||||
/// Approve+Auto → acceptEdits, Approve+Review → default, Keep Planning →
|
||||
/// reject. There is NO allowAutoMode gating anywhere — the web client never
|
||||
/// gates the plan three-way, and `uiConfig` is reserved for a future
|
||||
/// permission-mode picker (plan §3.1 note).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GateViewModel {
|
||||
// MARK: - Observable UI state
|
||||
|
||||
/// The latest gate event received (nil = lifted). This is what taps are
|
||||
/// validated against.
|
||||
private(set) var currentGate: GateState?
|
||||
/// Visible away digest (nil = nothing rendered; all-zero digests never
|
||||
/// become visible).
|
||||
private(set) var digest: AwayDigest?
|
||||
/// True once the user opened the recent-entries detail; expansion cancels
|
||||
/// the auto-fade (a digest must never vanish while being read).
|
||||
private(set) var isDigestExpanded = false
|
||||
|
||||
/// Tool gate → two-button `GateBanner`.
|
||||
var toolGate: GateState? { gate(of: .tool) }
|
||||
/// Plan gate → three-way `PlanGateSheet`.
|
||||
var planGate: GateState? { gate(of: .plan) }
|
||||
|
||||
// MARK: - Dependencies & plumbing (not observed)
|
||||
|
||||
@ObservationIgnored private let engine: SessionEngine
|
||||
@ObservationIgnored private let events: AsyncStream<SessionEvent>
|
||||
@ObservationIgnored private let haptics: any HapticSignaling
|
||||
@ObservationIgnored private let clock: any Clock<Duration>
|
||||
@ObservationIgnored private var consumeTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var fadeTask: Task<Void, Never>?
|
||||
/// Highest epoch that already buzzed — the haptic fires ONCE per epoch
|
||||
/// (sustained-pending refreshes reuse the epoch and stay silent).
|
||||
@ObservationIgnored private var lastHapticEpoch = 0
|
||||
/// Ordered decision queue: taps are synchronous, `engine.send` is async —
|
||||
/// one pump task preserves submission order (same pattern as
|
||||
/// TerminalViewModel's send pump).
|
||||
@ObservationIgnored private var decisionQueue: [ClientMessage] = []
|
||||
@ObservationIgnored private var isPumping = false
|
||||
|
||||
// MARK: - Test-visible diagnostics & deterministic barriers (internal)
|
||||
|
||||
/// Events applied so far — `waitUntilProcessed` barrier counter.
|
||||
@ObservationIgnored private(set) var processedEventCount = 0
|
||||
/// Decisions handed to the engine — `waitUntilForwarded` barrier counter.
|
||||
@ObservationIgnored private(set) var forwardedDecisionCount = 0
|
||||
/// Taps dropped by the first-line guard (stale epoch / lifted gate /
|
||||
/// affordance no longer offered).
|
||||
@ObservationIgnored private(set) var droppedStaleDecisionCount = 0
|
||||
/// Completed auto-fades — `waitUntilFadeCompleted` barrier counter.
|
||||
@ObservationIgnored private(set) var fadeCompletedCount = 0
|
||||
/// Test tap, called after each event is applied (state already coherent).
|
||||
@ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)?
|
||||
|
||||
private struct CountWaiter {
|
||||
let target: Int
|
||||
let continuation: CheckedContinuation<Void, Never>
|
||||
}
|
||||
|
||||
@ObservationIgnored private var eventWaiters: [CountWaiter] = []
|
||||
@ObservationIgnored private var decisionWaiters: [CountWaiter] = []
|
||||
@ObservationIgnored private var fadeWaiters: [CountWaiter] = []
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// - Parameters:
|
||||
/// - engine: send-side dependency (decisions only — open/close belong to
|
||||
/// the T-iOS-15 lifecycle owner).
|
||||
/// - events: the event stream to consume; the T-iOS-15 wiring passes a
|
||||
/// fan-out branch of `engine.events` shared with `TerminalViewModel`.
|
||||
/// - haptics: gate-arrival feedback (production: `GateHaptics`).
|
||||
/// - clock: drives the digest auto-fade (production: `ContinuousClock`).
|
||||
init(
|
||||
engine: SessionEngine, events: AsyncStream<SessionEvent>,
|
||||
haptics: any HapticSignaling, clock: any Clock<Duration>
|
||||
) {
|
||||
self.engine = engine
|
||||
self.events = events
|
||||
self.haptics = haptics
|
||||
self.clock = clock
|
||||
}
|
||||
|
||||
/// Begin consuming events. Idempotent — a second call is a no-op.
|
||||
func start() {
|
||||
guard consumeTask == nil else { return }
|
||||
consumeTask = Task { [weak self] in
|
||||
guard let events = self?.events else { return }
|
||||
for await event in events {
|
||||
guard let self else { return }
|
||||
self.apply(event)
|
||||
}
|
||||
self?.releaseAllWaiters() // stream over — never leave a test hanging
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop consuming (screen torn down); cancels the fade timer too.
|
||||
func stop() {
|
||||
consumeTask?.cancel()
|
||||
consumeTask = nil
|
||||
cancelFade()
|
||||
releaseAllWaiters()
|
||||
}
|
||||
|
||||
// MARK: - Decisions (Approve / Reject / plan three-way)
|
||||
|
||||
/// Resolve the held gate with `affordance`, tapped against the gate whose
|
||||
/// `epoch` the button rendered. First-line stale guard: the epoch must
|
||||
/// match the LATEST gate received AND the affordance must still be one the
|
||||
/// current gate offers (a same-epoch kind morph invalidates old buttons).
|
||||
/// Mismatch → dropped, nothing is sent.
|
||||
func decide(_ affordance: GateState.Affordance, epoch: Int) {
|
||||
guard let gate = currentGate, gate.epoch == epoch,
|
||||
gate.affordances.contains(affordance)
|
||||
else {
|
||||
droppedStaleDecisionCount += 1
|
||||
return
|
||||
}
|
||||
decisionQueue = decisionQueue + [affordance.clientMessage]
|
||||
pumpIfIdle()
|
||||
}
|
||||
|
||||
// MARK: - Digest interactions
|
||||
|
||||
/// Show the recent-entries detail; cancels the auto-fade so the digest
|
||||
/// never vanishes while being read (documented decision).
|
||||
func expandDigest() {
|
||||
guard digest != nil else { return }
|
||||
isDigestExpanded = true
|
||||
cancelFade()
|
||||
}
|
||||
|
||||
/// Explicitly dismiss the digest (the ✕ on the summary row).
|
||||
func dismissDigest() {
|
||||
digest = nil
|
||||
isDigestExpanded = false
|
||||
cancelFade()
|
||||
}
|
||||
|
||||
// MARK: - Event application (MainActor)
|
||||
|
||||
private func apply(_ event: SessionEvent) {
|
||||
switch event {
|
||||
case .gate(let gate):
|
||||
applyGate(gate)
|
||||
case .digest(let digest):
|
||||
applyDigest(digest)
|
||||
case .connection, .adopted, .output, .exited, .telemetry:
|
||||
break // TerminalViewModel's domain (T-iOS-11)
|
||||
}
|
||||
processedEventCount += 1
|
||||
onEventApplied?(event)
|
||||
eventWaiters = drainWaiters(eventWaiters, reached: processedEventCount)
|
||||
}
|
||||
|
||||
private func applyGate(_ gate: GateState?) {
|
||||
currentGate = gate
|
||||
guard let gate, gate.epoch > lastHapticEpoch else { return }
|
||||
lastHapticEpoch = gate.epoch // exactly one buzz per epoch
|
||||
haptics.gateDidArrive()
|
||||
}
|
||||
|
||||
/// All-zero digest → render nothing (spec). A visible digest starts
|
||||
/// collapsed and auto-fades after `Tunables.digestFadeDelay` unless the
|
||||
/// user expands it first.
|
||||
private func applyDigest(_ incoming: AwayDigest) {
|
||||
guard !incoming.isEmpty else { return }
|
||||
cancelFade()
|
||||
digest = incoming
|
||||
isDigestExpanded = false
|
||||
scheduleFade()
|
||||
}
|
||||
|
||||
private func gate(of kind: GateKind) -> GateState? {
|
||||
guard let currentGate, currentGate.kind == kind else { return nil }
|
||||
return currentGate
|
||||
}
|
||||
|
||||
// MARK: - Digest auto-fade (injected clock; zero real waits in tests)
|
||||
|
||||
private func scheduleFade() {
|
||||
let clock = self.clock
|
||||
fadeTask = Task { [weak self] in
|
||||
do {
|
||||
try await clock.sleep(for: Tunables.digestFadeDelay, tolerance: nil)
|
||||
} catch {
|
||||
return // cancelled: expanded, dismissed, replaced, or stopped
|
||||
}
|
||||
self?.completeFade()
|
||||
}
|
||||
}
|
||||
|
||||
private func completeFade() {
|
||||
guard !isDigestExpanded else { return } // expand cancels; belt & braces
|
||||
digest = nil
|
||||
fadeTask = nil
|
||||
fadeCompletedCount += 1
|
||||
fadeWaiters = drainWaiters(fadeWaiters, reached: fadeCompletedCount)
|
||||
}
|
||||
|
||||
private func cancelFade() {
|
||||
fadeTask?.cancel()
|
||||
fadeTask = nil
|
||||
}
|
||||
|
||||
// MARK: - Ordered decision pump
|
||||
|
||||
private func pumpIfIdle() {
|
||||
guard !isPumping else { return }
|
||||
isPumping = true
|
||||
Task { await self.pumpDecisions() }
|
||||
}
|
||||
|
||||
private func pumpDecisions() async {
|
||||
while let next = decisionQueue.first {
|
||||
decisionQueue = Array(decisionQueue.dropFirst())
|
||||
await engine.send(next)
|
||||
forwardedDecisionCount += 1
|
||||
decisionWaiters = drainWaiters(decisionWaiters, reached: forwardedDecisionCount)
|
||||
}
|
||||
isPumping = false
|
||||
}
|
||||
|
||||
// MARK: - Deterministic test barriers (no polling, no real sleeps)
|
||||
|
||||
/// Suspends until at least `eventCount` events have been applied.
|
||||
func waitUntilProcessed(eventCount target: Int) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
guard processedEventCount < target else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Suspends until at least `decisionCount` decisions reached the engine.
|
||||
func waitUntilForwarded(decisionCount target: Int) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
guard forwardedDecisionCount < target else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
decisionWaiters = decisionWaiters
|
||||
+ [CountWaiter(target: target, continuation: continuation)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Suspends until at least `count` auto-fades have completed.
|
||||
func waitUntilFadeCompleted(count target: Int) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
guard fadeCompletedCount < target else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
fadeWaiters = fadeWaiters + [CountWaiter(target: target, continuation: continuation)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumes every waiter satisfied by `count`; returns the still-waiting
|
||||
/// remainder (immutable style — the caller reassigns).
|
||||
private func drainWaiters(_ waiters: [CountWaiter], reached count: Int) -> [CountWaiter] {
|
||||
let satisfied = waiters.filter { $0.target <= count }
|
||||
for waiter in satisfied {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
return waiters.filter { $0.target > count }
|
||||
}
|
||||
|
||||
private func releaseAllWaiters() {
|
||||
let all = eventWaiters + decisionWaiters + fadeWaiters
|
||||
eventWaiters = []
|
||||
decisionWaiters = []
|
||||
fadeWaiters = []
|
||||
for waiter in all {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
398
ios/App/WebTerm/ViewModels/PairingViewModel.swift
Normal file
@@ -0,0 +1,398 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-12 · Pairing state machine (plan §7 / §5.4).
|
||||
///
|
||||
/// Flow: scan / manual URL → **confirm gate** → two-step probe →
|
||||
/// `Host{id,name}` into the `HostStore` + navigate signal.
|
||||
///
|
||||
/// Security invariants (plan §5 / task RED list):
|
||||
/// - Scan payloads are UNTRUSTED external input: parsed exclusively through
|
||||
/// `HostEndpoint` (single-point derivation — no hand-assembly), non-http(s)
|
||||
/// rejected with copy, and **zero network happens before the user confirms**
|
||||
/// (probe ① already GETs the target; probe ② spawns a PTY on it).
|
||||
/// - The §5.4 warning tiers render ON the confirm page; the public-host tier
|
||||
/// is BLOCKING — `confirmConnect` refuses to probe until the user has set
|
||||
/// `hasAcknowledgedPublicRisk` explicitly.
|
||||
/// - Every `PairingError` maps to inline copy + a recovery action
|
||||
/// (`localNetworkDenied` → Settings deep-link; `originRejected` surfaces the
|
||||
/// probe's hint VERBATIM; `atsBlocked` uses the §3.4 wording).
|
||||
///
|
||||
/// Documented decisions:
|
||||
/// - **Manual entry reuses the same confirm state as scanning** (task left it
|
||||
/// free): one code path, and the §5.4 warning tiers apply uniformly to
|
||||
/// typed URLs too. Convenience: input without `://` gets an `http://`
|
||||
/// prefix before the `HostEndpoint` parse (scan payloads get NO such help).
|
||||
/// - **Host classification is (re)implemented here**: APIClient's
|
||||
/// `PairingError.isPrivateOrLocalHost` is internal AND too coarse for the
|
||||
/// tiers (it collapses loopback/Tailscale/RFC1918 into one bucket).
|
||||
/// Duplication noted for the T-iOS-38 dedup pass.
|
||||
/// - `.local` (mDNS) hosts over http are shown the plaintext-LAN notice: they
|
||||
/// resolve to LAN addresses, so the §5.4 "ws:// on an untrusted LAN" row
|
||||
/// applies to them the same way.
|
||||
///
|
||||
/// §3.4 contract ruling (2026-07-04): the injected probe returns the validated
|
||||
/// `HostEndpoint`; `Host{id: UUID(), name:}` is constructed HERE (id/name are
|
||||
/// not the probe's to know). Production wiring (T-iOS-15) passes
|
||||
/// `runPairingProbe` with the real transports.
|
||||
@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>
|
||||
|
||||
// MARK: - UI state model
|
||||
|
||||
/// §5.4 warning tiers, decided from scheme + host class (see `warning(for:)`).
|
||||
enum SecurityWarning: Equatable, Sendable {
|
||||
/// https anywhere private-class, or ws→loopback: nothing to warn about.
|
||||
case none
|
||||
/// ws→100.64/10 or `*.ts.net`: WireGuard already encrypts — no
|
||||
/// plaintext warning, an optional positive badge instead.
|
||||
case tailscaleEncrypted
|
||||
/// ws→RFC1918 / link-local / `.local`: NON-blocking notice — keystrokes
|
||||
/// and output are sniffable on the same LAN; prefer `tailscale serve`.
|
||||
case plaintextLAN
|
||||
/// Public host (http AND https alike, §5.4 table): strongest BLOCKING
|
||||
/// warning — anyone who can reach the port gets a shell.
|
||||
case publicHostBlocking
|
||||
|
||||
var isBlocking: Bool { self == .publicHostBlocking }
|
||||
}
|
||||
|
||||
/// The parsed-but-not-yet-probed target shown on the confirm page.
|
||||
struct PendingHost: Equatable, Sendable {
|
||||
let endpoint: HostEndpoint
|
||||
let warning: SecurityWarning
|
||||
|
||||
/// `scheme://host[:port]` via `HostEndpoint`'s single-point derivation
|
||||
/// (browser-Origin serialization) — NEVER hand-assembled.
|
||||
var displayAddress: String { endpoint.originHeader }
|
||||
}
|
||||
|
||||
/// What the failure UI offers besides the message.
|
||||
enum RecoveryAction: Equatable, Sendable {
|
||||
case retry
|
||||
/// iOS Local Network permission was denied → deep-link to the app's
|
||||
/// Settings pane (its 本地网络 toggle lives there).
|
||||
case openLocalNetworkSettings
|
||||
}
|
||||
|
||||
struct FailureDisplay: Equatable, Sendable {
|
||||
let message: String
|
||||
let action: RecoveryAction
|
||||
}
|
||||
|
||||
enum Phase: Equatable {
|
||||
case idle
|
||||
case confirming(PendingHost)
|
||||
case probing(PendingHost)
|
||||
case failed(PendingHost, FailureDisplay)
|
||||
case paired(HostRegistry.Host)
|
||||
}
|
||||
|
||||
// MARK: - Observable state
|
||||
|
||||
private(set) var phase: Phase = .idle
|
||||
/// Inline rejection copy for invalid scan/manual input (idle-state error).
|
||||
private(set) var inputRejection: String?
|
||||
/// Editable name shown on the confirm page; defaults to the endpoint host
|
||||
/// and falls back to it when the user clears the field.
|
||||
var hostName = ""
|
||||
/// Explicit user acknowledgement for the blocking public-host warning.
|
||||
var hasAcknowledgedPublicRisk = false
|
||||
/// Set when confirm was attempted on a blocking warning WITHOUT the
|
||||
/// acknowledgement — the UI highlights the ack control.
|
||||
private(set) var needsPublicRiskAcknowledgement = false
|
||||
/// 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?
|
||||
|
||||
// MARK: - Dependencies (not observed)
|
||||
|
||||
@ObservationIgnored private let store: any HostStore
|
||||
@ObservationIgnored private let probe: Probe
|
||||
|
||||
init(store: any HostStore, probe: @escaping Probe) {
|
||||
self.store = store
|
||||
self.probe = probe
|
||||
}
|
||||
|
||||
// MARK: - Input boundaries (untrusted, validated via HostEndpoint)
|
||||
|
||||
/// QR scan result (`public/qr.ts` encodes `location.origin`). Strict: the
|
||||
/// payload must already be a full http(s) URL — no scheme inference for
|
||||
/// untrusted external input.
|
||||
func handleScannedCode(_ payload: String) {
|
||||
guard canAcceptNewTarget else { return }
|
||||
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let url = URL(string: trimmed), let endpoint = HostEndpoint(baseURL: url) else {
|
||||
inputRejection = PairingCopy.scanRejected
|
||||
return
|
||||
}
|
||||
enterConfirming(endpoint)
|
||||
}
|
||||
|
||||
/// Manually typed URL. The user knows what they typed, but it still goes
|
||||
/// through the SAME confirm state (uniform warning tiers — documented
|
||||
/// decision). Convenience: no `://` → `http://` prefix before parsing.
|
||||
func submitManualURL(_ text: String) {
|
||||
guard canAcceptNewTarget else { return }
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
inputRejection = PairingCopy.manualRejected
|
||||
return
|
||||
}
|
||||
let candidate = trimmed.contains(Self.schemeSeparator)
|
||||
? trimmed
|
||||
: Self.defaultManualScheme + trimmed
|
||||
guard let url = URL(string: candidate), let endpoint = HostEndpoint(baseURL: url) else {
|
||||
inputRejection = PairingCopy.manualRejected
|
||||
return
|
||||
}
|
||||
enterConfirming(endpoint)
|
||||
}
|
||||
|
||||
/// Back out of confirm/failed to a clean entry state. Never probes.
|
||||
func cancel() {
|
||||
phase = .idle
|
||||
inputRejection = nil
|
||||
hasAcknowledgedPublicRisk = false
|
||||
needsPublicRiskAcknowledgement = false
|
||||
}
|
||||
|
||||
// MARK: - Confirm → probe → store
|
||||
|
||||
/// The ONLY way network starts. No-op unless confirming; a blocking
|
||||
/// warning without explicit acknowledgement refuses and flags the UI.
|
||||
func confirmConnect() async {
|
||||
guard case .confirming(let pending) = phase else { return }
|
||||
if pending.warning.isBlocking && !hasAcknowledgedPublicRisk {
|
||||
needsPublicRiskAcknowledgement = true
|
||||
return
|
||||
}
|
||||
await runProbe(for: pending)
|
||||
}
|
||||
|
||||
/// Re-run the full probe against the same endpoint after a failure.
|
||||
func retry() async {
|
||||
guard case .failed(let pending, _) = phase else { return }
|
||||
await runProbe(for: pending)
|
||||
}
|
||||
|
||||
private var canAcceptNewTarget: Bool {
|
||||
switch phase {
|
||||
case .idle, .confirming, .failed:
|
||||
return true
|
||||
case .probing, .paired:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func enterConfirming(_ endpoint: HostEndpoint) {
|
||||
inputRejection = nil
|
||||
hasAcknowledgedPublicRisk = false
|
||||
needsPublicRiskAcknowledgement = false
|
||||
hostName = endpoint.baseURL.host ?? ""
|
||||
phase = .confirming(PendingHost(
|
||||
endpoint: endpoint, warning: Self.warning(for: endpoint)
|
||||
))
|
||||
}
|
||||
|
||||
private func runProbe(for pending: PendingHost) async {
|
||||
needsPublicRiskAcknowledgement = false
|
||||
phase = .probing(pending)
|
||||
switch await probe(pending.endpoint) {
|
||||
case .failure(let error):
|
||||
phase = .failed(pending, Self.display(for: error))
|
||||
case .success(let endpoint):
|
||||
await storePairedHost(endpoint: endpoint, pending: pending)
|
||||
}
|
||||
}
|
||||
|
||||
/// §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.
|
||||
private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
|
||||
let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
|
||||
let host = HostRegistry.Host(
|
||||
id: UUID(),
|
||||
name: trimmedName.isEmpty ? fallbackName : trimmedName,
|
||||
endpoint: endpoint
|
||||
)
|
||||
do {
|
||||
_ = try await store.upsert(host)
|
||||
} catch {
|
||||
phase = .failed(pending, FailureDisplay(
|
||||
message: PairingCopy.storeFailed, action: .retry
|
||||
))
|
||||
return
|
||||
}
|
||||
pairedHost = host
|
||||
phase = .paired(host)
|
||||
}
|
||||
|
||||
// MARK: - PairingError → copy + action (task RED list, one case each)
|
||||
|
||||
static func display(for error: PairingError) -> FailureDisplay {
|
||||
switch error {
|
||||
case .localNetworkDenied:
|
||||
return FailureDisplay(
|
||||
message: PairingCopy.localNetworkDenied, action: .openLocalNetworkSettings
|
||||
)
|
||||
case .hostUnreachable(let underlying):
|
||||
return FailureDisplay(
|
||||
message: PairingCopy.hostUnreachable(underlying), action: .retry
|
||||
)
|
||||
case .httpOkButNotWebTerminal:
|
||||
return FailureDisplay(message: PairingCopy.notWebTerminal, action: .retry)
|
||||
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)
|
||||
case .atsBlocked(let host):
|
||||
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
|
||||
case .tlsFailure:
|
||||
return FailureDisplay(message: PairingCopy.tlsFailure, action: .retry)
|
||||
case .timeout:
|
||||
return FailureDisplay(message: PairingCopy.timeout, action: .retry)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - §5.4 warning tiers
|
||||
|
||||
/// Decide the confirm-page warning from scheme + host class. Public hosts
|
||||
/// block regardless of scheme (§5.4 table: https is included in the
|
||||
/// public-host confirm warning); otherwise https clears every notice.
|
||||
static func warning(for endpoint: HostEndpoint) -> SecurityWarning {
|
||||
let hostClass = classifyHost(endpoint.baseURL.host ?? "")
|
||||
if hostClass == .publicHost {
|
||||
return .publicHostBlocking
|
||||
}
|
||||
if endpoint.baseURL.scheme?.lowercased() == Self.httpsScheme {
|
||||
return .none
|
||||
}
|
||||
switch hostClass {
|
||||
case .loopback:
|
||||
return .none
|
||||
case .tailscale:
|
||||
return .tailscaleEncrypted
|
||||
case .privateLAN:
|
||||
return .plaintextLAN
|
||||
case .publicHost:
|
||||
return .publicHostBlocking // unreachable; keeps the switch total
|
||||
}
|
||||
}
|
||||
|
||||
/// Address classes relevant to §5.4. NOTE: near-duplicate of APIClient's
|
||||
/// internal `isPrivateOrLocalHost` (finer-grained here) — T-iOS-38 dedup.
|
||||
enum HostClass: Equatable, Sendable {
|
||||
case loopback
|
||||
case tailscale
|
||||
case privateLAN
|
||||
case publicHost
|
||||
}
|
||||
|
||||
static func classifyHost(_ rawHost: String) -> HostClass {
|
||||
let host = rawHost.lowercased()
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) // IPv6 brackets
|
||||
if host == Self.localhostName {
|
||||
return .loopback
|
||||
}
|
||||
if host.hasSuffix(Self.tailscaleMagicDNSSuffix) {
|
||||
return .tailscale
|
||||
}
|
||||
if host.hasSuffix(Self.mdnsSuffix) {
|
||||
return .privateLAN
|
||||
}
|
||||
if let octets = ipv4Octets(host) {
|
||||
return classifyIPv4(octets)
|
||||
}
|
||||
if host.contains(":") {
|
||||
return classifyIPv6(host)
|
||||
}
|
||||
return .publicHost
|
||||
}
|
||||
|
||||
private static func classifyIPv4(_ octets: [Int]) -> HostClass {
|
||||
switch (octets[0], octets[1]) {
|
||||
case (127, _):
|
||||
return .loopback
|
||||
case (10, _), (192, 168), (169, 254):
|
||||
return .privateLAN // RFC1918 10/8, 192.168/16 · link-local 169.254/16
|
||||
case (172, 16...31):
|
||||
return .privateLAN // RFC1918 172.16/12
|
||||
case (100, 64...127):
|
||||
return .tailscale // CGNAT 100.64/10
|
||||
default:
|
||||
return .publicHost
|
||||
}
|
||||
}
|
||||
|
||||
private static func classifyIPv6(_ host: String) -> HostClass {
|
||||
if host == Self.ipv6Loopback {
|
||||
return .loopback
|
||||
}
|
||||
let isLinkLocal = host.hasPrefix(Self.ipv6LinkLocalPrefix)
|
||||
let isULA = host.hasPrefix("fc") || host.hasPrefix("fd") // fc00::/7
|
||||
return (isLinkLocal || isULA) ? .privateLAN : .publicHost
|
||||
}
|
||||
|
||||
private static func ipv4Octets(_ host: String) -> [Int]? {
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == Self.ipv4OctetCount else { return nil }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == Self.ipv4OctetCount,
|
||||
octets.allSatisfy({ Self.ipv4OctetRange.contains($0) })
|
||||
else { return nil }
|
||||
return octets
|
||||
}
|
||||
|
||||
// MARK: - Named constants (no magic values, plan §4)
|
||||
|
||||
private static let schemeSeparator = "://"
|
||||
private static let defaultManualScheme = "http://"
|
||||
private static let httpsScheme = "https"
|
||||
private static let localhostName = "localhost"
|
||||
private static let tailscaleMagicDNSSuffix = ".ts.net"
|
||||
private static let mdnsSuffix = ".local"
|
||||
private static let ipv6Loopback = "::1"
|
||||
private static let ipv6LinkLocalPrefix = "fe80"
|
||||
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 =
|
||||
"连接超时。请确认主机在线、与手机在同一网络后重试。"
|
||||
|
||||
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,或反馈该网段。"
|
||||
}
|
||||
}
|
||||
76
ios/App/WebTerm/ViewModels/ProjectDetailViewModel.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-26 · 项目详情状态(`GET /projects/detail?path=` → phase 状态机,
|
||||
/// 与 DiffViewModel/TimelineViewModel 同一纪律)。
|
||||
///
|
||||
/// fetch 闭包注入 —— 生产由 `forHost` 包 `APIClient.projectDetail(path:)`
|
||||
/// (builder 百分号编码、400/404/500 `{error}` → 类型化错误均在 T-iOS-38
|
||||
/// 完成并已测);测试注入 fake。本 VM 只归约用户可见的三种结局:
|
||||
/// - 成功 → `.loaded(ProjectDetail)`(sessions/worktrees/hasClaudeMd 透传);
|
||||
/// - 400 → `.failed(.pathInvalid)`、404 → `.failed(.notFound)`、
|
||||
/// 500/解码/传输 → `.failed(.unavailable)` —— 全部可经 `load()` 重试。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProjectDetailViewModel {
|
||||
/// 用户可见的失败三分类(文案映射在 ProjectDetailScreen)。
|
||||
enum Failure: Equatable {
|
||||
case pathInvalid
|
||||
case notFound
|
||||
case unavailable
|
||||
}
|
||||
|
||||
enum Phase: Equatable {
|
||||
case loading
|
||||
case loaded(ProjectDetail)
|
||||
case failed(Failure)
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .loading
|
||||
/// 详情/diff 的目标项目路径(来自列表行 —— 服务器数据;只透传给
|
||||
/// builder,绝不本地拼 URL)。
|
||||
let path: String
|
||||
|
||||
@ObservationIgnored
|
||||
private let fetch: @Sendable () async throws -> ProjectDetail
|
||||
|
||||
init(path: String, fetch: @escaping @Sendable () async throws -> ProjectDetail) {
|
||||
self.path = path
|
||||
self.fetch = fetch
|
||||
}
|
||||
|
||||
/// 生产装配缝(ProjectsViewModel.makeDetailViewModel 经此构造)。
|
||||
static func forHost(
|
||||
endpoint: HostEndpoint, http: any HTTPTransport, path: String
|
||||
) -> ProjectDetailViewModel {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
return ProjectDetailViewModel(path: path, fetch: {
|
||||
try await client.projectDetail(path: path)
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch 并呈现。也是「重试」路径:`.failed` 后可再次调用。
|
||||
func load() async {
|
||||
phase = .loading
|
||||
do {
|
||||
phase = .loaded(try await fetch())
|
||||
} catch let error as APIClientError {
|
||||
phase = .failed(Self.failure(for: error))
|
||||
} catch {
|
||||
phase = .failed(.unavailable) // 传输层等其余错误:可重试兜底
|
||||
}
|
||||
}
|
||||
|
||||
private static func failure(for error: APIClientError) -> Failure {
|
||||
switch error {
|
||||
case .projectPathInvalid, .invalidRequest:
|
||||
return .pathInvalid
|
||||
case .projectNotFound:
|
||||
return .notFound
|
||||
default:
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
192
ios/App/WebTerm/ViewModels/ProjectGrouping.swift
Normal file
@@ -0,0 +1,192 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
|
||||
/// T-iOS-26 · Projects 列表的纯分组逻辑 —— 逐条镜像 web v0.6 的
|
||||
/// public/projects.ts(filterProjects / sortProjects / groupProjects /
|
||||
/// displayLabel),使手机与网页看到同一套分区。
|
||||
///
|
||||
/// 关键契约:**组 key 与 web 逐字节一致**(namespace 首见大小写、哨兵
|
||||
/// `" active"` / `" other"`),因为折叠状态以组 key 存进跨端共享的
|
||||
/// `/prefs.collapsed` —— key 漂移 = 两端互相丢折叠状态。
|
||||
/// label 则是本端 UI 文案(哨兵组中文;namespace 组 = key 本身)。
|
||||
///
|
||||
/// Active 置顶(任务要求 assert reality):`ProjectSessionRef.exited` 字段
|
||||
/// 实测存在(src/types.ts:262-269),running = 任一会话 `!exited` —— 与 web
|
||||
/// `hasRunningSession` 同一判据;running 项目**复制**进置顶组,原组保留。
|
||||
enum ProjectGrouping {
|
||||
/// 哨兵组 key(带空格前缀,不可能与真实 `First.Second` namespace 撞车,
|
||||
/// 镜像 public/projects.ts:83-84)。
|
||||
static let activeGroupKey = " active"
|
||||
static let otherGroupKey = " other"
|
||||
/// namespace 至少要这么多成员才配得上独立分区(MIN_GROUP_SIZE)。
|
||||
static let minGroupSize = 2
|
||||
|
||||
// MARK: - filter(镜像 filterProjects:name/path 子串,大小写不敏感)
|
||||
|
||||
static func filter(_ projects: [ProjectInfo], query: String) -> [ProjectInfo] {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return projects }
|
||||
let lower = trimmed.lowercased()
|
||||
return projects.filter {
|
||||
$0.name.lowercased().contains(lower) || $0.path.lowercased().contains(lower)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - sort(镜像 sortProjects:收藏优先 → lastActiveMs 降序;显式稳定)
|
||||
|
||||
/// JS 的 `Array.sort` 是稳定的,web 靠它在平局时保持服务器顺序;Swift 的
|
||||
/// `sorted` 未承诺稳定 → 用输入下标做最终决胜键,行为逐字节对齐。
|
||||
static func sort(_ projects: [ProjectInfo], favourites: Set<String>) -> [ProjectInfo] {
|
||||
projects.enumerated().sorted { a, b in
|
||||
let aFav = favourites.contains(a.element.path)
|
||||
let bFav = favourites.contains(b.element.path)
|
||||
if aFav != bFav { return aFav }
|
||||
let aActive = a.element.lastActiveMs ?? 0
|
||||
let bActive = b.element.lastActiveMs ?? 0
|
||||
if aActive != bActive { return aActive > bActive }
|
||||
return a.offset < b.offset
|
||||
}.map(\.element)
|
||||
}
|
||||
|
||||
// MARK: - group(镜像 groupProjects)
|
||||
|
||||
static func group(_ projects: [ProjectInfo], favourites: Set<String>) -> [ProjectGroup] {
|
||||
let (namespaceGroups, other) = bucketByNamespace(projects, favourites: favourites)
|
||||
|
||||
// 分组一无所获 → 单一 flat 组(无 chrome 的平铺网格回退)。
|
||||
guard !namespaceGroups.isEmpty else {
|
||||
return [makeGroup(
|
||||
key: otherGroupKey, label: ProjectsCopy.allGroupLabel,
|
||||
kind: .flat, items: projects, favourites: favourites
|
||||
)]
|
||||
}
|
||||
|
||||
var groups: [ProjectGroup] = []
|
||||
let active = projects.filter(hasRunningSession)
|
||||
if !active.isEmpty {
|
||||
groups.append(makeGroup(
|
||||
key: activeGroupKey, label: ProjectsCopy.activeGroupLabel,
|
||||
kind: .active, items: active, favourites: favourites
|
||||
))
|
||||
}
|
||||
groups.append(contentsOf: orderedByRecency(namespaceGroups))
|
||||
if !other.isEmpty {
|
||||
groups.append(makeGroup(
|
||||
key: otherGroupKey, label: ProjectsCopy.otherGroupLabel,
|
||||
kind: .other, items: other, favourites: favourites
|
||||
))
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/// 组内卡片名:namespace 组剥掉 `<key>.` 前缀(大小写不敏感),免得每张
|
||||
/// 卡都在喊 `Billo.Platform.`;哨兵组保留全名(镜像 displayLabel)。
|
||||
static func displayLabel(name: String, groupKey: String) -> String {
|
||||
if groupKey == activeGroupKey || groupKey == otherGroupKey { return name }
|
||||
let prefix = "\(groupKey)."
|
||||
guard name.lowercased().hasPrefix(prefix.lowercased()) else { return name }
|
||||
return String(name.dropFirst(prefix.count))
|
||||
}
|
||||
|
||||
static func hasRunningSession(_ project: ProjectInfo) -> Bool {
|
||||
project.sessions.contains { !$0.exited }
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// namespace = 名字的前两个点分段(`'a.b.c'` → `'a.b'`;不足两段 → nil),
|
||||
/// 空段保留(镜像 JS `split('.')` 语义)。
|
||||
private static func namespaceKey(_ name: String) -> String? {
|
||||
let segments = name.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard segments.count >= 2 else { return nil }
|
||||
return segments.prefix(2).joined(separator: ".")
|
||||
}
|
||||
|
||||
/// 桶 key 小写去重、显示名取首见大小写、首见顺序稳定(镜像 JS Map 的
|
||||
/// 插入序遍历)。返回 (成组的 namespace, 塌进 Other 的项目)。
|
||||
private static func bucketByNamespace(
|
||||
_ projects: [ProjectInfo], favourites: Set<String>
|
||||
) -> (groups: [ProjectGroup], other: [ProjectInfo]) {
|
||||
var bucketOrder: [String] = []
|
||||
var buckets: [String: (display: String, items: [ProjectInfo])] = [:]
|
||||
var other: [ProjectInfo] = []
|
||||
for project in projects {
|
||||
guard let namespace = namespaceKey(project.name) else {
|
||||
other.append(project)
|
||||
continue
|
||||
}
|
||||
let lowerKey = namespace.lowercased()
|
||||
if var bucket = buckets[lowerKey] {
|
||||
bucket.items.append(project)
|
||||
buckets[lowerKey] = bucket
|
||||
} else {
|
||||
buckets[lowerKey] = (namespace, [project])
|
||||
bucketOrder.append(lowerKey)
|
||||
}
|
||||
}
|
||||
|
||||
var groups: [ProjectGroup] = []
|
||||
for lowerKey in bucketOrder {
|
||||
guard let bucket = buckets[lowerKey] else { continue }
|
||||
if bucket.items.count < minGroupSize {
|
||||
other.append(contentsOf: bucket.items)
|
||||
} else {
|
||||
groups.append(makeGroup(
|
||||
key: bucket.display, label: bucket.display,
|
||||
kind: .namespace, items: bucket.items, favourites: favourites
|
||||
))
|
||||
}
|
||||
}
|
||||
return (groups, other)
|
||||
}
|
||||
|
||||
/// namespace 分区按组内最新活跃时间降序,平局按 label 升序(镜像
|
||||
/// groupProjects 的 sort)。
|
||||
private static func orderedByRecency(_ groups: [ProjectGroup]) -> [ProjectGroup] {
|
||||
groups.sorted { a, b in
|
||||
let aMax = maxLastActive(a.projects)
|
||||
let bMax = maxLastActive(b.projects)
|
||||
if aMax != bMax { return aMax > bMax }
|
||||
return a.label.localizedCompare(b.label) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private static func maxLastActive(_ items: [ProjectInfo]) -> Int {
|
||||
items.reduce(0) { max($0, $1.lastActiveMs ?? 0) }
|
||||
}
|
||||
|
||||
private static func makeGroup(
|
||||
key: String, label: String, kind: ProjectGroupKind,
|
||||
items: [ProjectInfo], favourites: Set<String>
|
||||
) -> ProjectGroup {
|
||||
ProjectGroup(
|
||||
key: key, label: label, kind: kind,
|
||||
projects: sort(items, favourites: favourites),
|
||||
activeCount: items.filter(hasRunningSession).count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 分区种类(镜像 web `ProjectGroupKind`)。
|
||||
enum ProjectGroupKind: Equatable, Sendable {
|
||||
case active
|
||||
case namespace
|
||||
case other
|
||||
case flat
|
||||
}
|
||||
|
||||
/// 一个可折叠分区的不可变快照(镜像 web `ProjectGroup`)。
|
||||
struct ProjectGroup: Equatable, Identifiable, Sendable {
|
||||
/// 折叠状态的持久化 key —— 必须与 web 逐字节一致(/prefs 跨端共享)。
|
||||
let key: String
|
||||
let label: String
|
||||
let kind: ProjectGroupKind
|
||||
/// 已排序(收藏优先 → 活跃降序)。
|
||||
let projects: [ProjectInfo]
|
||||
/// 有运行中会话的项目数(折叠的分区绝不悄悄埋掉活跃会话)。
|
||||
let activeCount: Int
|
||||
|
||||
var id: String { key }
|
||||
/// Active now 永远展开(它就是重点);flat 无 chrome。
|
||||
var isCollapsible: Bool { kind == .namespace || kind == .other }
|
||||
}
|
||||
236
ios/App/WebTerm/ViewModels/ProjectsViewModel.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-26 · Projects 列表状态(镜像 web v0.6 projects 面板的数据面):
|
||||
/// `GET /projects` 的分组网格 + `GET/PUT /prefs` 的跨端收藏/折叠往返 +
|
||||
/// "在此仓库开新会话" 的导航信号。
|
||||
///
|
||||
/// Documented decisions:
|
||||
/// - **prefs 是 clobber 敏感面**:`PUT /prefs` 服务器侧整体替换 blob
|
||||
/// (src/server.ts:286-288),所以一律经 `UiPrefs` 的 unknown-key-preserving
|
||||
/// API 改写(web/未来服务器写入的未知顶层键原样带回);prefs GET 失败时
|
||||
/// toggle 只改本地、**绝不 PUT**(空底盘上写 = 清掉服务器收藏)。
|
||||
/// - **PUT 成功采纳服务器 echo 为新真相**(服务器会 sanitize,本地状态与
|
||||
/// 服务器逐字节对齐);失败保留本地改动 + 显式文案,下次 toggle 自然重试。
|
||||
/// - **prefs 只在首次 load 拉一次**(镜像 web mountProjects.init:刷新节奏
|
||||
/// 只重取项目,绝不用旧服务器值clobber本地未同步的编辑)。
|
||||
/// - 列表数据是不可信服务器输入:解码宽容(APIClient 已做),路径在铸造
|
||||
/// OpenRequest 前再过 `Validation.isAbsoluteCwd`(deep-link 同款纪律)。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProjectsViewModel {
|
||||
// MARK: - Observable state
|
||||
|
||||
private(set) var projects: [ProjectInfo] = []
|
||||
private(set) var hasLoadedOnce = false
|
||||
/// 最近一次项目刷新失败(旧列表保留 —— 一次丢包不清屏)。
|
||||
private(set) var fetchErrorMessage: String?
|
||||
/// prefs 加载失败:收藏/折叠只在本地生效、不回写(防 clobber)。
|
||||
private(set) var prefsErrorMessage: String?
|
||||
/// 最近一次 PUT /prefs 失败(本地改动已保留)。
|
||||
private(set) var prefsSyncErrorMessage: String?
|
||||
/// "在此仓库开新会话" 被拒(非法路径)。
|
||||
private(set) var openErrorMessage: String?
|
||||
/// 收藏的项目路径(保序:新收藏追加尾部)。
|
||||
private(set) var favourites: [String] = []
|
||||
/// 组 key → 已折叠(只存 true;展开是默认态 —— 与 web/服务器一致)。
|
||||
private(set) var collapsedGroups: [String: Bool] = [:]
|
||||
/// 导航信号(T-iOS-26):每次请求新 id,重复点按也能触发 onChange。
|
||||
private(set) var openRequest: ProjectOpenRequest?
|
||||
/// 搜索框绑定(.searchable)。
|
||||
var searchText = ""
|
||||
|
||||
// MARK: - Dependencies & internal state
|
||||
|
||||
let host: HostRegistry.Host
|
||||
@ObservationIgnored let http: any HTTPTransport
|
||||
/// 最近一次已知的完整 prefs blob(含未知键)。nil = 从未成功加载 →
|
||||
/// 绝不 PUT。
|
||||
@ObservationIgnored private var prefsBase: UiPrefs?
|
||||
|
||||
private var client: APIClient {
|
||||
APIClient(endpoint: host.endpoint, http: http)
|
||||
}
|
||||
|
||||
init(host: HostRegistry.Host, http: any HTTPTransport) {
|
||||
self.host = host
|
||||
self.http = http
|
||||
}
|
||||
|
||||
// MARK: - Derived view state
|
||||
|
||||
/// 分组快照:搜索过滤 → web 同款分组(收藏优先排序在组内完成)。
|
||||
var groups: [ProjectGroup] {
|
||||
ProjectGrouping.group(
|
||||
ProjectGrouping.filter(projects, query: searchText),
|
||||
favourites: Set(favourites)
|
||||
)
|
||||
}
|
||||
|
||||
var isSearching: Bool {
|
||||
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
/// 搜索中强制全展开(结果绝不藏在折叠 caret 后,镜像 web renderGrid)。
|
||||
func isCollapsed(_ group: ProjectGroup) -> Bool {
|
||||
group.isCollapsible && !isSearching && collapsedGroups[group.key] == true
|
||||
}
|
||||
|
||||
func isFavourite(_ path: String) -> Bool {
|
||||
favourites.contains(path)
|
||||
}
|
||||
|
||||
/// 空态文案:无项目 vs 无搜索结果(镜像 web renderGrid 的两种 msg)。
|
||||
var emptyStateMessage: String? {
|
||||
guard hasLoadedOnce, fetchErrorMessage == nil else { return nil }
|
||||
if projects.isEmpty { return ProjectsCopy.emptyNoProjects }
|
||||
if ProjectGrouping.filter(projects, query: searchText).isEmpty {
|
||||
return ProjectsCopy.emptyNoMatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Load / refresh
|
||||
|
||||
/// 首次进入:prefs(一次)+ 项目。prefs 之后留在内存(镜像 web init)。
|
||||
func load() async {
|
||||
await loadPrefsIfNeeded()
|
||||
await refresh()
|
||||
}
|
||||
|
||||
/// 只重取项目(下拉刷新与重试共用)。失败保留旧列表 + 显式文案。
|
||||
func refresh() async {
|
||||
do {
|
||||
projects = try await client.projects()
|
||||
fetchErrorMessage = nil
|
||||
hasLoadedOnce = true
|
||||
} catch {
|
||||
fetchErrorMessage = ProjectsCopy.fetchFailed(Self.errorDetail(error))
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPrefsIfNeeded() async {
|
||||
guard prefsBase == nil else { return }
|
||||
do {
|
||||
let prefs = try await client.prefs()
|
||||
adopt(prefs)
|
||||
prefsErrorMessage = nil
|
||||
} catch {
|
||||
prefsErrorMessage = ProjectsCopy.prefsLoadFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Favourites / collapse(跨端 prefs 往返)
|
||||
|
||||
func toggleFavourite(path: String) async {
|
||||
favourites = favourites.contains(path)
|
||||
? favourites.filter { $0 != path }
|
||||
: favourites + [path]
|
||||
await persistPrefs()
|
||||
}
|
||||
|
||||
func toggleCollapsed(key: String) async {
|
||||
if collapsedGroups[key] == true {
|
||||
collapsedGroups = collapsedGroups.filter { $0.key != key }
|
||||
} else {
|
||||
collapsedGroups = collapsedGroups.merging([key: true]) { _, new in new }
|
||||
}
|
||||
await persistPrefs()
|
||||
}
|
||||
|
||||
/// 以最近已知的完整 blob 为底盘改写两个已知键(未知键原样带回),PUT
|
||||
/// 后采纳服务器 echo。无底盘(prefs 从未加载成功)→ 本地生效、不回写。
|
||||
private func persistPrefs() async {
|
||||
guard let base = prefsBase else { return }
|
||||
let next = base.withFavourites(favourites).withCollapsed(collapsedGroups)
|
||||
prefsBase = next // 乐观:连续 toggle 在同一底盘上叠加
|
||||
do {
|
||||
let echoed = try await client.putPrefs(next)
|
||||
adopt(echoed)
|
||||
prefsSyncErrorMessage = nil
|
||||
} catch {
|
||||
prefsSyncErrorMessage = ProjectsCopy.prefsSyncFailed(Self.errorDetail(error))
|
||||
}
|
||||
}
|
||||
|
||||
private func adopt(_ prefs: UiPrefs) {
|
||||
prefsBase = prefs
|
||||
favourites = prefs.favourites
|
||||
collapsedGroups = prefs.collapsed
|
||||
}
|
||||
|
||||
// MARK: - 在此仓库开新会话(T-iOS-26 的核心动作)
|
||||
|
||||
/// `attach(null, cwd)` + attach 后注入 `claude\r`(帧序由 engine 的
|
||||
/// attach-first 队列保证)。路径是服务器数据 → 不可信,铸造前先验证。
|
||||
func requestOpenClaude(cwd: String) {
|
||||
guard Validation.isAbsoluteCwd(cwd) else {
|
||||
openErrorMessage = ProjectsCopy.openClaudeInvalidPath
|
||||
return
|
||||
}
|
||||
openErrorMessage = nil
|
||||
openRequest = ProjectOpenRequest(
|
||||
id: UUID(), host: host, cwd: cwd,
|
||||
bootstrapInput: ProjectLaunch.claudeBootstrapInput
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Detail assembly(详情/差异屏的装配缝)
|
||||
|
||||
func makeDetailViewModel(path: String) -> ProjectDetailViewModel {
|
||||
.forHost(endpoint: host.endpoint, http: http, path: path)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func errorDetail(_ error: any Error) -> String {
|
||||
(error as? APIClientError)?.message ?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// "在此仓库开新会话" 的导航信号 —— `SessionListViewModel.OpenRequest` 的
|
||||
/// cwd+bootstrap 平行变体(该类型属 T-iOS-13 文件,不越界扩展;由
|
||||
/// AppCoordinator.openProject 消费)。
|
||||
struct ProjectOpenRequest: Equatable, Sendable, Identifiable {
|
||||
let id: UUID
|
||||
let host: HostRegistry.Host
|
||||
/// 新会话的工作目录(已验证为绝对路径)。
|
||||
let cwd: String
|
||||
/// attach 后注入的首条输入(nil = 只开 shell)。
|
||||
let bootstrapInput: String?
|
||||
}
|
||||
|
||||
/// 项目内启动命令(镜像 public/tabs.ts:679 `openProject` 的默认 cmd)。
|
||||
enum ProjectLaunch {
|
||||
/// Enter 是 `\r`(0x0D)不是 `\n` —— 合成输入的经典坑(CLAUDE.md Gotchas)。
|
||||
static let claudeBootstrapInput = "claude\r"
|
||||
}
|
||||
|
||||
/// 用户可见文案(中文具名常量,plan §4)。
|
||||
enum ProjectsCopy {
|
||||
static let title = "项目"
|
||||
static let searchPrompt = "筛选项目…"
|
||||
static let activeGroupLabel = "活跃中"
|
||||
static let otherGroupLabel = "其他"
|
||||
static let allGroupLabel = "全部项目"
|
||||
static let dirtyBadge = "未提交"
|
||||
static let emptyNoProjects = "未发现项目。请检查主机的 PROJECT_ROOTS 配置。"
|
||||
static let emptyNoMatch = "没有匹配的项目。"
|
||||
static let prefsLoadFailed = "云端收藏/折叠状态加载失败,本次修改仅在本机生效。"
|
||||
static let openClaudeInvalidPath = "项目路径无效,无法开新会话。"
|
||||
|
||||
static func fetchFailed(_ detail: String) -> String {
|
||||
"刷新项目列表失败:\(detail)"
|
||||
}
|
||||
|
||||
static func prefsSyncFailed(_ detail: String) -> String {
|
||||
"收藏/折叠状态同步失败:\(detail)"
|
||||
}
|
||||
|
||||
static func activeCountBadge(_ count: Int) -> String {
|
||||
"● \(count) 活跃"
|
||||
}
|
||||
}
|
||||
403
ios/App/WebTerm/ViewModels/SessionListViewModel.swift
Normal file
@@ -0,0 +1,403 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import SessionCore
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-13 · Session list state (merged chooser + dashboard, plan §7):
|
||||
/// one glance answers "do I need to step in?" — status dot / ⚠ pending badge,
|
||||
/// telemetry chips with staleness, swipe-to-kill, and the navigation signal
|
||||
/// into `TerminalScreen`.
|
||||
///
|
||||
/// Polling: while the screen is visible, `GET /live-sessions` every
|
||||
/// `Tunables.listPollInterval` (mirrors public/launcher.ts `REFRESH_MS`),
|
||||
/// paced on an injected `Clock` (FakeClock in tests — zero real waits).
|
||||
/// `disappeared()` cancels the poll task; the leak test asserts the parked
|
||||
/// sleeper is reclaimed.
|
||||
///
|
||||
/// Documented decisions:
|
||||
/// - **Pending ⚠ is an overlay, not a list field**: `LiveSessionInfo`
|
||||
/// (src/types.ts:246-256) carries NO `pending` — that signal only exists on
|
||||
/// the WS status side-channel (the server's pendingApprovals), exactly like
|
||||
/// the web dashboard reads it from attached tabs (public/tabs.ts snapshot()).
|
||||
/// The T-iOS-15 wiring forwards gate/status events into
|
||||
/// `setPendingApproval`; this VM owns the merge and the badge PRIORITY.
|
||||
/// - **Kill is optimistic via a hidden-id set**: rows derive from the last
|
||||
/// fetch minus `hiddenKilledIds`, so rollback restores the exact original
|
||||
/// position, and a poll racing the DELETE cannot resurrect the row. A 404
|
||||
/// on DELETE means "already gone" and counts as success.
|
||||
/// - **A failed poll never wipes the list**: stale rows + explicit copy beat
|
||||
/// a blank screen on one dropped packet.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SessionListViewModel {
|
||||
// MARK: - Row model
|
||||
|
||||
/// What the row leads with: the held-approval badge outranks the dot.
|
||||
enum StatusIndicator: Equatable, Sendable {
|
||||
case pendingApproval
|
||||
case status(ClaudeStatus)
|
||||
|
||||
/// Mirrors public/tabs.ts:74-80 `claudeIcon`; ⚠ for a held approval.
|
||||
var glyph: String {
|
||||
switch self {
|
||||
case .pendingApproval: return "⚠"
|
||||
case .status(.working): return "⚙"
|
||||
case .status(.waiting): return "⏳"
|
||||
case .status(.idle): return "✓"
|
||||
case .status(.stuck): return "⚠"
|
||||
case .status(.unknown): return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable render snapshot of one session (rebuilt wholesale per fetch).
|
||||
struct SessionRow: Equatable, Identifiable, Sendable {
|
||||
let info: LiveSessionInfo
|
||||
let isPendingApproval: Bool
|
||||
let telemetry: TelemetryChips.Model?
|
||||
/// Sanitized OSC title (T-iOS-23) — nil = no title, row falls back to
|
||||
/// the cwd-derived name. NEVER raw delegate input (TitleSanitizer).
|
||||
let title: String?
|
||||
/// `lastOutputAt` strictly newer than the local last-seen watermark
|
||||
/// (UnreadLedger; mirrors the web tab dot, public/tabs.ts hasActivity).
|
||||
let isUnread: Bool
|
||||
|
||||
var id: UUID { info.id }
|
||||
var indicator: StatusIndicator {
|
||||
isPendingApproval ? .pendingApproval : .status(info.status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation signal consumed by the T-iOS-15 wiring. `sessionId == nil`
|
||||
/// = "+ New session" (`attach(null)` downstream). A fresh `id` per request
|
||||
/// means repeated taps re-fire `onChange` observers.
|
||||
struct OpenRequest: Equatable, Sendable, Identifiable {
|
||||
let id: UUID
|
||||
let host: HostRegistry.Host
|
||||
let sessionId: UUID?
|
||||
}
|
||||
|
||||
enum EmptyState: Equatable {
|
||||
/// No hosts in the store — pairing is the only next step.
|
||||
case notPaired
|
||||
/// Paired + fetched successfully, but nothing is running.
|
||||
case noSessions
|
||||
}
|
||||
|
||||
// MARK: - Observable state
|
||||
|
||||
private(set) var hosts: [HostRegistry.Host] = []
|
||||
private(set) var activeHost: HostRegistry.Host?
|
||||
private(set) var rows: [SessionRow] = []
|
||||
/// Last poll failed (stale rows stay visible). Cleared by the next success.
|
||||
private(set) var fetchErrorMessage: String?
|
||||
/// Last kill failed (row already rolled back).
|
||||
private(set) var killErrorMessage: String?
|
||||
/// Host store read failed — explicit, never a silent empty list.
|
||||
private(set) var hostsErrorMessage: String?
|
||||
private(set) var openRequest: OpenRequest?
|
||||
|
||||
var emptyState: EmptyState? {
|
||||
if hasAttemptedHostLoad && hosts.isEmpty { return .notPaired }
|
||||
if hasLoadedOnce && rows.isEmpty && fetchErrorMessage == nil { return .noSessions }
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Dependencies & internal state (not observed)
|
||||
|
||||
@ObservationIgnored private let hostStore: any HostStore
|
||||
@ObservationIgnored private let http: any HTTPTransport
|
||||
@ObservationIgnored private let clock: any Clock<Duration>
|
||||
/// Injected time source for telemetry staleness (ms since epoch).
|
||||
@ObservationIgnored private let nowMs: @Sendable () -> Int
|
||||
@ObservationIgnored private let unreadStore: any UnreadWatermarkStore
|
||||
@ObservationIgnored private var pollTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var latestFetched: [LiveSessionInfo] = []
|
||||
@ObservationIgnored private var hiddenKilledIds: Set<UUID> = []
|
||||
@ObservationIgnored private var pendingSessionIds: Set<UUID> = []
|
||||
/// T-iOS-23 · last-seen watermarks (loaded once, persisted per `markSeen`).
|
||||
/// NOT pruned to the active host's fetch — other hosts' watermarks must
|
||||
/// survive a host switch; `UnreadLedger.maxEntries` bounds growth instead.
|
||||
@ObservationIgnored private var unreadLedger: UnreadLedger
|
||||
/// T-iOS-23 · sanitized OSC titles by sessionId. In-memory only — replay
|
||||
/// re-emits the OSC sequence on next attach, persistence would only keep
|
||||
/// stale titles. Other hosts' entries never match a visible row.
|
||||
@ObservationIgnored private var sessionTitles: [UUID: String] = [:]
|
||||
@ObservationIgnored private var hasAttemptedHostLoad = false
|
||||
@ObservationIgnored private var hasLoadedOnce = false
|
||||
|
||||
private var apiClient: APIClient? {
|
||||
activeHost.map { APIClient(endpoint: $0.endpoint, http: http) }
|
||||
}
|
||||
|
||||
init(
|
||||
hostStore: any HostStore,
|
||||
http: any HTTPTransport,
|
||||
clock: any Clock<Duration>,
|
||||
unreadStore: any UnreadWatermarkStore,
|
||||
nowMs: @escaping @Sendable () -> Int = SessionListViewModel.currentTimeMs
|
||||
) {
|
||||
self.hostStore = hostStore
|
||||
self.http = http
|
||||
self.clock = clock
|
||||
self.unreadStore = unreadStore
|
||||
self.nowMs = nowMs
|
||||
unreadLedger = UnreadLedger(watermarks: unreadStore.load())
|
||||
}
|
||||
|
||||
// MARK: - Visibility lifecycle (poll task owner)
|
||||
|
||||
/// Screen became visible: load hosts, then poll every `listPollInterval`.
|
||||
/// Idempotent — a second call while polling is a no-op.
|
||||
func appeared() {
|
||||
guard pollTask == nil else { return }
|
||||
pollTask = Task { [weak self] in
|
||||
await self?.runPollLoop()
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen left: cancel the poll task (its parked sleep throws
|
||||
/// `CancellationError` and the loop exits — no leaked timer).
|
||||
func disappeared() {
|
||||
pollTask?.cancel()
|
||||
pollTask = nil
|
||||
releaseFetchWaiters()
|
||||
}
|
||||
|
||||
private func runPollLoop() async {
|
||||
await reloadHosts()
|
||||
while !Task.isCancelled {
|
||||
await refreshOnce()
|
||||
do {
|
||||
try await clock.sleep(for: Tunables.listPollInterval, tolerance: nil)
|
||||
} catch {
|
||||
return // cancelled while parked — the only way sleep throws
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hosts (multi-host header hook)
|
||||
|
||||
/// (Re)read the paired hosts. Also called by the T-iOS-15 wiring after the
|
||||
/// pairing sheet adds one. Keeps `activeHost` if it still exists (picking
|
||||
/// up renames), else falls back to the first host.
|
||||
func reloadHosts() async {
|
||||
do {
|
||||
hosts = try await hostStore.loadAll()
|
||||
hostsErrorMessage = nil
|
||||
} catch {
|
||||
hosts = []
|
||||
hostsErrorMessage = SessionListCopy.hostsLoadFailed
|
||||
}
|
||||
hasAttemptedHostLoad = true
|
||||
activeHost = hosts.first(where: { $0.id == activeHost?.id }) ?? hosts.first
|
||||
}
|
||||
|
||||
/// Switch the list to another paired host: reset per-host state and fetch
|
||||
/// immediately (the poll loop keeps its own cadence).
|
||||
func selectHost(id: UUID) async {
|
||||
guard let host = hosts.first(where: { $0.id == id }),
|
||||
host.id != activeHost?.id else { return }
|
||||
activeHost = host
|
||||
latestFetched = []
|
||||
hiddenKilledIds = []
|
||||
pendingSessionIds = []
|
||||
hasLoadedOnce = false
|
||||
fetchErrorMessage = nil
|
||||
killErrorMessage = nil
|
||||
rebuildRows()
|
||||
await refreshOnce()
|
||||
}
|
||||
|
||||
// MARK: - Fetch (poll tick + pull-to-refresh share one path)
|
||||
|
||||
/// Manual refresh (pull-to-refresh).
|
||||
func refresh() async {
|
||||
await refreshOnce()
|
||||
}
|
||||
|
||||
private func refreshOnce() async {
|
||||
defer {
|
||||
completedFetchCount += 1
|
||||
resumeFetchWaiters()
|
||||
}
|
||||
guard let client = apiClient else { return }
|
||||
do {
|
||||
let sessions = try await client.liveSessions()
|
||||
let fetchedIds = Set(sessions.map(\.id))
|
||||
latestFetched = sessions
|
||||
// Prune overlays for sessions the server no longer reports.
|
||||
hiddenKilledIds = hiddenKilledIds.intersection(fetchedIds)
|
||||
pendingSessionIds = pendingSessionIds.intersection(fetchedIds)
|
||||
fetchErrorMessage = nil
|
||||
hasLoadedOnce = true
|
||||
} catch {
|
||||
// Keep the stale rows — a blank list on one dropped poll is worse.
|
||||
fetchErrorMessage = SessionListCopy.fetchFailed(Self.errorDetail(error))
|
||||
}
|
||||
rebuildRows()
|
||||
}
|
||||
|
||||
// MARK: - Pending ⚠ overlay (fed by the WS status side-channel, T-iOS-15)
|
||||
|
||||
func setPendingApproval(sessionId: UUID, pending: Bool) {
|
||||
pendingSessionIds = pending
|
||||
? pendingSessionIds.union([sessionId])
|
||||
: pendingSessionIds.subtracting([sessionId])
|
||||
rebuildRows()
|
||||
}
|
||||
|
||||
// MARK: - Unread watermarks (T-iOS-23; UnreadLedger + persisted store)
|
||||
|
||||
/// The user just looked at (or is about to look at) this session: stamp
|
||||
/// the last-seen watermark NOW and persist it. Called on row tap
|
||||
/// (`openSession`) and by the coordinator when a terminal closes — output
|
||||
/// that streamed while the user was watching must not relight the dot.
|
||||
func markSeen(sessionId: UUID) {
|
||||
unreadLedger = unreadLedger.record(seen: sessionId, at: nowMs())
|
||||
unreadStore.save(unreadLedger.watermarks)
|
||||
rebuildRows()
|
||||
}
|
||||
|
||||
// MARK: - OSC title registry (T-iOS-23; fed by TerminalViewModel wiring)
|
||||
|
||||
/// Surface a terminal-reported OSC title on the session's list row.
|
||||
/// `title` is UNTRUSTED (host/attacker-controlled OSC payload) — sanitized
|
||||
/// again at THIS boundary regardless of upstream (sanitize is idempotent).
|
||||
/// Empty after sanitisation clears the entry (web `title.trim() || null`).
|
||||
func setSessionTitle(sessionId: UUID, title: String) {
|
||||
let sanitized = TitleSanitizer.sanitize(title)
|
||||
sessionTitles = sanitized.isEmpty
|
||||
? sessionTitles.filter { $0.key != sessionId }
|
||||
: sessionTitles.merging([sessionId: sanitized]) { _, new in new }
|
||||
rebuildRows()
|
||||
}
|
||||
|
||||
// MARK: - Swipe-to-kill (optimistic + rollback)
|
||||
|
||||
func kill(sessionId: UUID) async {
|
||||
guard let client = apiClient,
|
||||
latestFetched.contains(where: { $0.id == sessionId }) else { return }
|
||||
killErrorMessage = nil
|
||||
hiddenKilledIds = hiddenKilledIds.union([sessionId]) // optimistic removal
|
||||
rebuildRows()
|
||||
do {
|
||||
try await client.killSession(id: sessionId)
|
||||
} catch APIClientError.sessionNotFound {
|
||||
// Already gone (exited/reaped/killed elsewhere) — removal stands.
|
||||
} catch {
|
||||
hiddenKilledIds = hiddenKilledIds.subtracting([sessionId]) // rollback
|
||||
rebuildRows()
|
||||
killErrorMessage = SessionListCopy.killFailed(Self.errorDetail(error))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation signals
|
||||
|
||||
/// "+ New session" → `sessionId: nil` (server spawns a fresh PTY).
|
||||
func requestNewSession() {
|
||||
guard let activeHost else { return }
|
||||
openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: nil)
|
||||
}
|
||||
|
||||
/// Open a listed session. Unknown ids are refused at the boundary.
|
||||
/// Tapping = seeing: the unread watermark is stamped immediately (the
|
||||
/// terminal replays everything anyway; the dot must not outlive the tap).
|
||||
func openSession(id: UUID) {
|
||||
guard let activeHost, rows.contains(where: { $0.id == id }) else { return }
|
||||
markSeen(sessionId: id)
|
||||
openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: id)
|
||||
}
|
||||
|
||||
// MARK: - Row derivation (immutable rebuild, single owner)
|
||||
|
||||
private func rebuildRows() {
|
||||
let visible = latestFetched.filter { !hiddenKilledIds.contains($0.id) }
|
||||
let now = nowMs()
|
||||
rows = Self.groupingExitedLast(visible).map { info in
|
||||
SessionRow(
|
||||
info: info,
|
||||
isPendingApproval: pendingSessionIds.contains(info.id),
|
||||
telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now),
|
||||
title: sessionTitles[info.id],
|
||||
isUnread: unreadLedger.isUnread(
|
||||
sessionId: info.id, lastOutputAt: info.lastOutputAt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the server's order (newest-first, src/session/manager.ts:194) but
|
||||
/// group `exited:true` at the bottom — order preserved inside each group.
|
||||
static func groupingExitedLast(_ sessions: [LiveSessionInfo]) -> [LiveSessionInfo] {
|
||||
sessions.filter { !$0.exited } + sessions.filter(\.exited)
|
||||
}
|
||||
|
||||
// MARK: - Error copy helpers
|
||||
|
||||
/// `APIClientError` already carries user-facing copy; transport-level
|
||||
/// errors fall back to their localized description.
|
||||
private static func errorDetail(_ error: any Error) -> String {
|
||||
(error as? APIClientError)?.message ?? error.localizedDescription
|
||||
}
|
||||
|
||||
private nonisolated static let millisecondsPerSecond = 1_000.0
|
||||
|
||||
nonisolated static func currentTimeMs() -> Int {
|
||||
Int(Date().timeIntervalSince1970 * millisecondsPerSecond)
|
||||
}
|
||||
|
||||
// MARK: - Deterministic test barriers (same pattern as TerminalViewModel)
|
||||
|
||||
/// Completed fetch attempts (successful or not, including no-host ticks).
|
||||
@ObservationIgnored private(set) var completedFetchCount = 0
|
||||
|
||||
private struct CountWaiter {
|
||||
let target: Int
|
||||
let continuation: CheckedContinuation<Void, Never>
|
||||
}
|
||||
|
||||
@ObservationIgnored private var fetchWaiters: [CountWaiter] = []
|
||||
|
||||
/// Suspends until at least `count` fetches have completed.
|
||||
func waitUntilFetches(count target: Int) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
guard completedFetchCount < target else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
fetchWaiters = fetchWaiters + [CountWaiter(target: target, continuation: continuation)]
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeFetchWaiters() {
|
||||
let satisfied = fetchWaiters.filter { $0.target <= completedFetchCount }
|
||||
fetchWaiters = fetchWaiters.filter { $0.target > completedFetchCount }
|
||||
for waiter in satisfied {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseFetchWaiters() {
|
||||
let all = fetchWaiters
|
||||
fetchWaiters = []
|
||||
for waiter in all {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing session-list copy (plan §4: explicit errors, actionable 话术).
|
||||
enum SessionListCopy {
|
||||
static let hostsLoadFailed = "读取已配对主机失败,请关闭本页后重进。"
|
||||
|
||||
static func fetchFailed(_ detail: String) -> String {
|
||||
"刷新会话列表失败:\(detail)"
|
||||
}
|
||||
|
||||
static func killFailed(_ detail: String) -> String {
|
||||
"结束会话失败:\(detail)"
|
||||
}
|
||||
}
|
||||
352
ios/App/WebTerm/ViewModels/TerminalViewModel.swift
Normal file
@@ -0,0 +1,352 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SessionCore
|
||||
import WireProtocol
|
||||
|
||||
/// Terminal screen state (T-iOS-11, plan §3.5): consumes the engine's
|
||||
/// `SessionEvent` stream on the MainActor and turns it into UI state — output
|
||||
/// forwarded to SwiftTerm, connection banner, non-retryable failure copy and
|
||||
/// the read-only exit state. All outbound traffic (key bar, hardware key
|
||||
/// commands, SwiftTerm delegate) funnels through here into ONE ordered queue.
|
||||
///
|
||||
/// Testability / stream-sharing decision (documented per task brief):
|
||||
/// `engine.events` is a single-consumer `AsyncStream`, and GateViewModel
|
||||
/// (T-iOS-14) must eventually observe `.gate`/`.digest` from the SAME stream.
|
||||
/// So the events stream is injected SEPARATELY from the engine: today callers
|
||||
/// pass `engine.events` verbatim (tests do exactly that, over
|
||||
/// `TestSupport.FakeTransport`); the T-iOS-15 wiring may pass a fan-out branch
|
||||
/// instead without touching this class.
|
||||
///
|
||||
/// Swift 6 strict concurrency: the class is `@MainActor`, the terminal sink is
|
||||
/// `@MainActor`-typed — `feed()` off the main actor cannot compile.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TerminalViewModel {
|
||||
// MARK: - UI state model
|
||||
|
||||
/// Connection banner state (mirrors the web client's status line:
|
||||
/// public/terminal-session.ts `SessionStatus`).
|
||||
enum ConnectionBanner: Equatable {
|
||||
case none
|
||||
case connecting
|
||||
/// Retry `attempt` fires after `next` (ReconnectMachine ladder 1s→30s).
|
||||
case reconnecting(attempt: Int, next: Duration)
|
||||
}
|
||||
|
||||
/// Which terminal the user is looking at: a live one, a dead-for-good one
|
||||
/// (non-retryable failure), or a finished one (read-only).
|
||||
enum TerminalPhase: Equatable {
|
||||
case live
|
||||
/// Non-retryable terminal failure — actionable copy instead of a spinner.
|
||||
case failed(message: String)
|
||||
/// The shell exited; the terminal stays readable but accepts no input.
|
||||
case exited(code: Int, reason: String?)
|
||||
}
|
||||
|
||||
/// Terminal geometry snapshot (Equatable for test assertions; the frozen
|
||||
/// engine API takes a tuple, so `asTuple` bridges).
|
||||
struct TerminalDims: Equatable, Sendable {
|
||||
let cols: Int
|
||||
let rows: Int
|
||||
var asTuple: (cols: Int, rows: Int) { (cols, rows) }
|
||||
}
|
||||
|
||||
/// Actionable copy for `.failed(.replayTooLarge)` (plan §3.2 / §3.2.1
|
||||
/// coupling warning): reconnecting would deterministically fail forever,
|
||||
/// so the user must change a knob, not wait.
|
||||
static let replayTooLargeMessage =
|
||||
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
|
||||
|
||||
/// 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.
|
||||
private(set) var lastSentDims: TerminalDims?
|
||||
|
||||
private(set) var banner: ConnectionBanner = .none
|
||||
private(set) var phase: TerminalPhase = .live
|
||||
/// Server-adopted session id (ALWAYS the server-issued one — persisting it
|
||||
/// per host is the T-iOS-15 wiring's job via `LastSessionStore`).
|
||||
private(set) var sessionId: UUID?
|
||||
/// Sanitized OSC title (T-iOS-23). Raw `setTerminalTitle` delegate input
|
||||
/// is HOST/ATTACKER-CONTROLLED and passes `TitleSanitizer` at THIS
|
||||
/// boundary; nil = no title (empty after sanitisation).
|
||||
private(set) var terminalTitle: String?
|
||||
|
||||
/// Read-only = no input reaches the PTY (exit / terminal failure). Resize
|
||||
/// is NOT gated here — the engine owns terminal-state dropping.
|
||||
var isReadOnly: Bool { phase != .live }
|
||||
|
||||
/// Single render input for `ReconnectBanner`: terminal phases win over
|
||||
/// transient connection states.
|
||||
var bannerModel: ReconnectBanner.Model? {
|
||||
switch phase {
|
||||
case .failed(let message):
|
||||
return .failed(message: message)
|
||||
case .exited(let code, let reason):
|
||||
return .exited(code: code, reason: reason)
|
||||
case .live:
|
||||
break
|
||||
}
|
||||
switch banner {
|
||||
case .none:
|
||||
return nil
|
||||
case .connecting:
|
||||
return .connecting
|
||||
case .reconnecting(let attempt, let next):
|
||||
return .reconnecting(attempt: attempt, retryIn: next)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dependencies & plumbing (not observed)
|
||||
|
||||
@ObservationIgnored private let engine: SessionEngine
|
||||
@ObservationIgnored private let events: AsyncStream<SessionEvent>
|
||||
@ObservationIgnored private var consumeTask: Task<Void, Never>?
|
||||
/// Where output bytes go (SwiftTerm's `feed(text:)`). `@MainActor`-typed:
|
||||
/// feeding off the main actor is a compile error.
|
||||
@ObservationIgnored private var terminalSink: (@MainActor (String) -> Void)?
|
||||
/// Output that arrived before the SwiftTerm view existed; flushed in order
|
||||
/// the moment the sink attaches (replay must never be dropped).
|
||||
@ObservationIgnored private var pendingOutput: [String] = []
|
||||
/// Ordered outbound queue: UIKit callbacks are synchronous, `engine.send`
|
||||
/// is async — one pump task preserves submission order (two quick key taps
|
||||
/// must never race each other onto the wire).
|
||||
@ObservationIgnored private var sendQueue: [ClientMessage] = []
|
||||
@ObservationIgnored private var isPumping = false
|
||||
|
||||
// MARK: - Test-visible diagnostics & deterministic barriers (internal)
|
||||
|
||||
/// Events applied so far — `waitUntilProcessed` barrier counter.
|
||||
@ObservationIgnored private(set) var processedEventCount = 0
|
||||
/// Sends handed to the engine so far — `waitUntilForwarded` barrier counter.
|
||||
@ObservationIgnored private(set) var forwardedSendCount = 0
|
||||
/// Input dropped because the terminal is read-only (exit/failed).
|
||||
@ObservationIgnored private(set) var droppedReadOnlyInputCount = 0
|
||||
/// Test tap, called after each event is applied (state already coherent).
|
||||
@ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)?
|
||||
|
||||
// MARK: - OSC title surface (T-iOS-23; wired by TerminalSessionController)
|
||||
|
||||
/// List-side registry hook: fires with the ADOPTED sessionId and the
|
||||
/// SANITIZED title ("" = title cleared — the registry drops the entry).
|
||||
/// Titles arriving before adoption are held and forwarded once on
|
||||
/// `.adopted` (defensive — `attached` always precedes output on the wire).
|
||||
@ObservationIgnored var onTitleChanged: (@MainActor (UUID, String) -> Void)?
|
||||
/// Latest sanitized title not yet delivered to `onTitleChanged` because
|
||||
/// no sessionId was known at the time. nil = nothing held.
|
||||
@ObservationIgnored private var heldTitleForward: String?
|
||||
|
||||
private struct CountWaiter {
|
||||
let target: Int
|
||||
let continuation: CheckedContinuation<Void, Never>
|
||||
}
|
||||
|
||||
@ObservationIgnored private var eventWaiters: [CountWaiter] = []
|
||||
@ObservationIgnored private var sendWaiters: [CountWaiter] = []
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// - Parameters:
|
||||
/// - engine: send-side dependency (`send` only — `open`/`close` belong to
|
||||
/// the T-iOS-15 wiring that constructs the engine).
|
||||
/// - events: the event stream to consume; pass `engine.events` unless a
|
||||
/// fan-out branch is needed (see type doc).
|
||||
init(engine: SessionEngine, events: AsyncStream<SessionEvent>) {
|
||||
self.engine = engine
|
||||
self.events = events
|
||||
}
|
||||
|
||||
/// Begin consuming events. Idempotent — a second call is a no-op (the
|
||||
/// stream has exactly one consumer).
|
||||
func start() {
|
||||
guard consumeTask == nil else { return }
|
||||
consumeTask = Task { [weak self] in
|
||||
guard let events = self?.events else { return }
|
||||
for await event in events {
|
||||
guard let self else { return }
|
||||
self.apply(event)
|
||||
}
|
||||
self?.releaseAllWaiters() // stream over — never leave a test hanging
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop consuming (screen torn down). Does NOT close the engine — detach
|
||||
/// vs. keep-running is the T-iOS-15 lifecycle owner's call.
|
||||
func stop() {
|
||||
consumeTask?.cancel()
|
||||
consumeTask = nil
|
||||
releaseAllWaiters()
|
||||
}
|
||||
|
||||
// MARK: - Terminal output sink
|
||||
|
||||
/// Attach the SwiftTerm feed target. Buffered output (anything that
|
||||
/// arrived before the view existed) flushes immediately, in order.
|
||||
func attachTerminalSink(_ sink: @escaping @MainActor (String) -> Void) {
|
||||
terminalSink = sink
|
||||
let buffered = pendingOutput
|
||||
pendingOutput = []
|
||||
for chunk in buffered {
|
||||
sink(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Outbound (KeyBar / UIKeyCommand / SwiftTerm delegate)
|
||||
|
||||
/// Key-bar or hardware key press: bytes resolved through `KeyByteMap`
|
||||
/// (the single source of truth — plan §7 T-iOS-11).
|
||||
func send(key: KeyByteMap.Key) {
|
||||
sendInput(KeyByteMap.bytes(for: key))
|
||||
}
|
||||
|
||||
/// Raw input bytes, verbatim (invariant #9 — no content filtering).
|
||||
/// Dropped (and counted) while the terminal is read-only.
|
||||
func sendInput(_ data: String) {
|
||||
guard !isReadOnly else {
|
||||
droppedReadOnlyInputCount += 1
|
||||
return
|
||||
}
|
||||
enqueueSend(.input(data: data))
|
||||
}
|
||||
|
||||
/// SwiftTerm reported an OSC 0/2 title (`setTerminalTitle` delegate).
|
||||
/// Sanitize FIRST — the raw string is untrusted terminal output — then
|
||||
/// surface locally and forward to the list registry (T-iOS-23).
|
||||
func setTerminalTitle(_ raw: String) {
|
||||
let sanitized = TitleSanitizer.sanitize(raw)
|
||||
terminalTitle = sanitized.isEmpty ? nil : sanitized
|
||||
guard let sessionId else {
|
||||
heldTitleForward = sanitized
|
||||
return
|
||||
}
|
||||
onTitleChanged?(sessionId, sanitized)
|
||||
}
|
||||
|
||||
/// Terminal geometry changed (SwiftTerm `sizeChanged`). Always forwarded —
|
||||
/// the engine validates bounds and owns terminal-state dropping. Valid
|
||||
/// dims are remembered in `lastSentDims` so the T-iOS-15 wiring can feed
|
||||
/// `engine.notifyForegrounded(dims:)` on scenePhase reactivation (closes
|
||||
/// the W4 documented deviation; invalid dims never overwrite a good pair).
|
||||
func sendResize(cols: Int, rows: Int) {
|
||||
if Validation.isValidResize(cols: cols, rows: rows) {
|
||||
lastSentDims = TerminalDims(cols: cols, rows: rows)
|
||||
}
|
||||
enqueueSend(.resize(cols: cols, rows: rows))
|
||||
}
|
||||
|
||||
// MARK: - Event application (single consumer, MainActor)
|
||||
|
||||
private func apply(_ event: SessionEvent) {
|
||||
switch event {
|
||||
case .connection(let state):
|
||||
applyConnection(state)
|
||||
case .adopted(let id):
|
||||
sessionId = id // ALWAYS adopt the server-issued id
|
||||
if let held = heldTitleForward {
|
||||
heldTitleForward = nil
|
||||
onTitleChanged?(id, held)
|
||||
}
|
||||
case .output(let data):
|
||||
deliverOutput(data)
|
||||
case .exited(let code, let reason):
|
||||
phase = .exited(code: code, reason: reason)
|
||||
case .gate, .telemetry, .digest:
|
||||
break // GateViewModel's domain (T-iOS-14; wired in T-iOS-15)
|
||||
}
|
||||
processedEventCount += 1
|
||||
onEventApplied?(event)
|
||||
resumeEventWaiters()
|
||||
}
|
||||
|
||||
private func applyConnection(_ state: ConnectionState) {
|
||||
switch state {
|
||||
case .connecting:
|
||||
banner = .connecting
|
||||
case .connected:
|
||||
banner = .none
|
||||
case .reconnecting(let attempt, let next):
|
||||
banner = .reconnecting(attempt: attempt, next: next)
|
||||
case .closed:
|
||||
banner = .none // deliberate end; exit/failure phase (if any) stays
|
||||
case .failed(.replayTooLarge):
|
||||
banner = .none
|
||||
phase = .failed(message: Self.replayTooLargeMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private func deliverOutput(_ data: String) {
|
||||
guard let terminalSink else {
|
||||
pendingOutput = pendingOutput + [data]
|
||||
return
|
||||
}
|
||||
terminalSink(data)
|
||||
}
|
||||
|
||||
// MARK: - Ordered send pump
|
||||
|
||||
private func enqueueSend(_ message: ClientMessage) {
|
||||
sendQueue = sendQueue + [message]
|
||||
guard !isPumping else { return }
|
||||
isPumping = true
|
||||
Task { await self.pumpSendQueue() }
|
||||
}
|
||||
|
||||
private func pumpSendQueue() async {
|
||||
while let next = sendQueue.first {
|
||||
sendQueue = Array(sendQueue.dropFirst())
|
||||
await engine.send(next)
|
||||
forwardedSendCount += 1
|
||||
resumeSendWaiters()
|
||||
}
|
||||
isPumping = false
|
||||
}
|
||||
|
||||
// MARK: - Deterministic test barriers (no polling, no real sleeps)
|
||||
|
||||
/// Suspends until at least `eventCount` events have been applied.
|
||||
func waitUntilProcessed(eventCount target: Int) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
guard processedEventCount < target else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Suspends until at least `sendCount` messages were handed to the engine.
|
||||
func waitUntilForwarded(sendCount target: Int) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
guard forwardedSendCount < target else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
sendWaiters = sendWaiters + [CountWaiter(target: target, continuation: continuation)]
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeEventWaiters() {
|
||||
let satisfied = eventWaiters.filter { $0.target <= processedEventCount }
|
||||
eventWaiters = eventWaiters.filter { $0.target > processedEventCount }
|
||||
for waiter in satisfied {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeSendWaiters() {
|
||||
let satisfied = sendWaiters.filter { $0.target <= forwardedSendCount }
|
||||
sendWaiters = sendWaiters.filter { $0.target > forwardedSendCount }
|
||||
for waiter in satisfied {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseAllWaiters() {
|
||||
let all = eventWaiters + sendWaiters
|
||||
eventWaiters = []
|
||||
sendWaiters = []
|
||||
for waiter in all {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
81
ios/App/WebTerm/ViewModels/TimelineViewModel.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-24 · State for the full-timeline drill-down sheet (`TimelineSheet`).
|
||||
///
|
||||
/// One VM per presentation (the container view builds a fresh one on each
|
||||
/// digest「展开」tap), so every open re-fetches `GET /live-sessions/:id/events`.
|
||||
/// The fetch closure is injected — production wraps `APIClient.events` via
|
||||
/// `TerminalSessionController.timelineEventsSource`; tests inject fakes.
|
||||
///
|
||||
/// Server data is UNTRUSTED at this boundary (plan §4): the tolerant decode
|
||||
/// (malformed / unknown-class entries dropped, non-array → `[]`) already
|
||||
/// happened inside `APIClient.events` → `TimelineEvent.decodeList`. This VM
|
||||
/// only distinguishes the three USER-visible outcomes:
|
||||
/// - `[]` → `.empty` — the server replies `[]` both for "no activity yet" and
|
||||
/// for timeline capture disabled (src/server.ts:589-591); NEVER an error;
|
||||
/// - thrown fetch → `.failed` — explicit, retryable (`load()` again);
|
||||
/// - events → `.loaded`, ordered exactly like the web panel.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TimelineViewModel: Identifiable {
|
||||
/// Rendering phase — an explicit enum so the sheet can never show an
|
||||
/// error and rows at the same time.
|
||||
enum Phase: Equatable {
|
||||
case loading
|
||||
/// Display-ready rows: capped + newest-first (web parity, see
|
||||
/// `presentation(for:)`).
|
||||
case loaded([TimelineEvent])
|
||||
/// Server returned `[]` (no activity OR timeline disabled host-side).
|
||||
case empty
|
||||
/// Fetch failed — retryable via `load()`.
|
||||
case failed
|
||||
}
|
||||
|
||||
/// Web parity: `DEFAULT_MAX_EVENTS` (public/timeline.ts:20).
|
||||
static let maxEvents = 50
|
||||
|
||||
/// `.sheet(item:)` identity — a fresh VM per presentation.
|
||||
nonisolated let id = UUID()
|
||||
|
||||
private(set) var phase: Phase = .loading
|
||||
|
||||
@ObservationIgnored private let fetch: @Sendable () async throws -> [TimelineEvent]
|
||||
|
||||
init(fetch: @escaping @Sendable () async throws -> [TimelineEvent]) {
|
||||
self.fetch = fetch
|
||||
}
|
||||
|
||||
/// Assembly seam for the digest「展开」entry point: no adopted sessionId
|
||||
/// yet → no sheet (defensive — a digest only arrives after `attached`, so
|
||||
/// the id is normally known). Otherwise the id is passed to `source`
|
||||
/// verbatim on every load.
|
||||
static func forSession(
|
||||
_ sessionId: UUID?,
|
||||
source: @escaping @Sendable (UUID) async throws -> [TimelineEvent]
|
||||
) -> TimelineViewModel? {
|
||||
guard let sessionId else { return nil }
|
||||
return TimelineViewModel(fetch: { try await source(sessionId) })
|
||||
}
|
||||
|
||||
/// Fetch and present. Also the「重试」path: callable again from `.failed`.
|
||||
func load() async {
|
||||
phase = .loading
|
||||
do {
|
||||
let events = try await fetch()
|
||||
phase = Self.presentation(for: events)
|
||||
} catch {
|
||||
phase = .failed // user-facing copy lives in TimelineSheet
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure presentation reducer, mirroring the web panel's render() pipeline
|
||||
/// line for line (public/timeline.ts:174-189): the server returns
|
||||
/// oldest-first → `slice(0, maxEvents)` first, THEN reverse, so the sheet
|
||||
/// shows the same capped slice newest-first as the web timeline panel.
|
||||
static func presentation(for events: [TimelineEvent]) -> Phase {
|
||||
guard !events.isEmpty else { return .empty }
|
||||
return .loaded(Array(events.prefix(maxEvents).reversed()))
|
||||
}
|
||||
}
|
||||
35
ios/App/WebTerm/WebTermApp.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-15 · App entry: assemble the production dependency graph once and
|
||||
/// hand it to the coordinator (Pairing → SessionList → Terminal). All wiring
|
||||
/// lives under `Wiring/`; this file stays a thin `@main`.
|
||||
///
|
||||
/// T-iOS-21(增量接线,任务允许的最小改动):
|
||||
/// - `@UIApplicationDelegateAdaptor` 挂 `PushAppDelegate`(remote-notification
|
||||
/// 回调只能走 UIApplicationDelegate);SwiftUI 生命周期保证本 `init` 先于
|
||||
/// `didFinishLaunching` 运行,coordinator 经一次性静态槽交接过去,在启动
|
||||
/// 完成前就把通知 delegate 设好(冷启动动作不丢)。
|
||||
/// - scenePhase 转 `.active`(含每次可见启动)→ 幂等 `activate()`(授权 +
|
||||
/// token 注册补账);后台拉起(锁屏 Allow/Deny)不激活场景,正好不注册。
|
||||
@main
|
||||
struct WebTermApp: App {
|
||||
@UIApplicationDelegateAdaptor(PushAppDelegate.self) private var pushDelegate
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var coordinator: AppCoordinator
|
||||
|
||||
init() {
|
||||
let coordinator = AppCoordinator(environment: .production())
|
||||
_coordinator = State(initialValue: coordinator)
|
||||
PushAppDelegate.bootstrap = coordinator // 消费点:didFinishLaunching
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(coordinator: coordinator)
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard phase == .active else { return }
|
||||
pushDelegate.activatePush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
ios/App/WebTerm/Wiring/AdaptiveRootView.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
|
||||
/// T-iPad-2 · size-class 驱动的单一根视图。按 `LayoutPolicy.mode` 在 compact
|
||||
/// 宽度(iPhone / iPad Slide Over)选 `StackRootView`(现有路径,字节级不变)、
|
||||
/// 在 regular 宽度(iPad 全屏 / 大分屏 / Stage Manager 大窗)选 `SplitRootView`
|
||||
/// (分栏)。
|
||||
///
|
||||
/// **横切布线只声明一次、两分支共享**(PLAN_IOS_IPAD §5 T-iPad-2):
|
||||
/// - 隐私遮罩:ZStack 的**最顶层**,覆盖整棵导航树(含 split detail 的终端
|
||||
/// 字节)——`scenePhase != .active` 即遮(安全不变式,见 `PrivacyShadePolicy`)。
|
||||
/// 上提到这里,split detail 与 stack push 同样被遮,无遗漏。
|
||||
/// - `.task` bootstrap / `.onChange(scenePhase)` / `.onOpenURL` / deep-link 提示
|
||||
/// alert / add-host sheet / Projects sheet:全部设备无关,原样从旧 `RootView`
|
||||
/// 搬到这一层,故 compact 分支拿到与适配前完全一致的横切行为。
|
||||
///
|
||||
/// size class 是**运行时可变量**(iPad 拉出 Slide Over 立刻 regular→compact),
|
||||
/// 所以这不是两套 UI 分叉,而是同一次运行内的自适应切换:`terminalController`
|
||||
/// 归 `AppCoordinator` 所有,与布局分支正交,切换时不重建(T-iOS-29 `.id`
|
||||
/// 稳定性的前提)。
|
||||
struct AdaptiveRootView: View {
|
||||
@Bindable var coordinator: AppCoordinator
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
layoutBranch
|
||||
if PrivacyShadePolicy.isShadeVisible(for: scenePhase) {
|
||||
PrivacyShadeView()
|
||||
}
|
||||
}
|
||||
.task { await coordinator.bootstrap() }
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
coordinator.handleScenePhase(phase)
|
||||
}
|
||||
.onOpenURL { coordinator.handleDeepLink(url: $0) } // T-iOS-22
|
||||
.alert(DeepLinkCopy.hintTitle, isPresented: deepLinkHintBinding) {
|
||||
Button(DeepLinkCopy.hintConfirm) { coordinator.deepLink.clearHint() }
|
||||
} message: {
|
||||
Text(coordinator.deepLink.hintMessage ?? "")
|
||||
}
|
||||
.sheet(
|
||||
isPresented: $coordinator.isAddHostPresented,
|
||||
onDismiss: { coordinator.addHostDismissed() }
|
||||
) {
|
||||
addHostSheet
|
||||
}
|
||||
.sheet(
|
||||
isPresented: $coordinator.isProjectsPresented,
|
||||
onDismiss: { coordinator.projectsDismissed() }
|
||||
) {
|
||||
projectsSheet
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout branch (the SOLE size-class consumer)
|
||||
|
||||
@ViewBuilder private var layoutBranch: some View {
|
||||
// Split only makes sense once we're in the session list. `.loading` and
|
||||
// `.pairing` (genuine iPad first-run, no paired host) get the full-screen
|
||||
// stack flow regardless of size class — a split sidebar has nothing to
|
||||
// list yet and would strand the user on the not-paired empty state
|
||||
// (T-iPad-5 finding). Route-gate the split branch.
|
||||
switch LayoutPolicy.mode(horizontalSizeClass: horizontalSizeClass) {
|
||||
case .split where coordinator.route == .sessions:
|
||||
SplitRootView(coordinator: coordinator)
|
||||
default:
|
||||
StackRootView(coordinator: coordinator)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deep-link hint alert (T-iOS-22)
|
||||
|
||||
/// unknown host / store failure 提示(同旧 `RootView`)。
|
||||
private var deepLinkHintBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { coordinator.deepLink.hintMessage != nil },
|
||||
set: { presented in
|
||||
guard !presented else { return }
|
||||
coordinator.deepLink.clearHint()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Add-host sheet (multi-host entry, list header)
|
||||
|
||||
@ViewBuilder private var addHostSheet: some View {
|
||||
if let viewModel = coordinator.addHostPairingViewModel {
|
||||
NavigationStack {
|
||||
PairingScreen(viewModel: viewModel) { host in
|
||||
coordinator.completeAddHost(host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Projects sheet (T-iOS-26)
|
||||
|
||||
/// 自带 NavigationStack:列表 → 详情 → 差异都在 sheet 内 push;
|
||||
/// "在此仓库开新会话" 关掉 sheet 并在根导航开终端。
|
||||
@ViewBuilder private var projectsSheet: some View {
|
||||
if let viewModel = coordinator.projectsViewModel {
|
||||
NavigationStack {
|
||||
ProjectsScreen(viewModel: viewModel) { request in
|
||||
coordinator.openProject(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
302
ios/App/WebTerm/Wiring/AppCoordinator.swift
Normal file
@@ -0,0 +1,302 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Navigation + lifecycle owner: Pairing → SessionList → Terminal
|
||||
/// with the production dependency graph (plan §7 T-iOS-15 step 1).
|
||||
///
|
||||
/// - Cold start: read the host store once — no paired host → Pairing, else
|
||||
/// SessionList (`ColdStartPolicy.initialRoute`).
|
||||
/// - `SessionListScreen.onOpen` → build ONE `TerminalSessionController`
|
||||
/// (single foreground session, plan §1) and push the terminal.
|
||||
/// - Back → `closeTerminal()` → `engine.close()` (detach; PTY keeps running).
|
||||
/// - scenePhase is forwarded to the open controller (`.background` →
|
||||
/// suspend/close, `.active` → resume/rebuild). The privacy shade is view
|
||||
/// layer (RootView + PrivacyShadePolicy), not coordinator state.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppCoordinator {
|
||||
private(set) var route: ColdStartPolicy.RootRoute = .loading
|
||||
private(set) var terminalController: TerminalSessionController?
|
||||
/// First-run pairing VM (root route). Recreated per entry to keep pairing
|
||||
/// state machines single-shot.
|
||||
private(set) var rootPairingViewModel: PairingViewModel?
|
||||
/// Add-host pairing VM (sheet from the list header).
|
||||
private(set) var addHostPairingViewModel: PairingViewModel?
|
||||
var isAddHostPresented = false
|
||||
/// T-iOS-26 · Projects sheet(入口在 RootView 的 toolbar —— 不碰
|
||||
/// `SessionListScreen`,该文件 W7 内归 T-iOS-23)。每次呈现新建 VM,
|
||||
/// prefs 每次进入都重新拉取。
|
||||
private(set) var projectsViewModel: ProjectsViewModel?
|
||||
var isProjectsPresented = false
|
||||
|
||||
let sessionList: SessionListViewModel
|
||||
@ObservationIgnored let environment: AppEnvironment
|
||||
/// T-iOS-22 · Deep-link handler; all routing/wiring logic lives in
|
||||
/// DeepLinkRouter.swift (incl. the `makeDeepLinkHandler` extension).
|
||||
@ObservationIgnored private(set) lazy var deepLink: DeepLinkHandler = makeDeepLinkHandler()
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
self.environment = environment
|
||||
sessionList = SessionListViewModel(
|
||||
hostStore: environment.hostStore,
|
||||
http: environment.http,
|
||||
clock: ContinuousClock(),
|
||||
unreadStore: environment.unreadStore
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Cold start
|
||||
|
||||
/// Decide the boot route from the host store. A store READ failure routes
|
||||
/// to the session list, whose own `reloadHosts` surfaces the explicit
|
||||
/// error copy (never a silent empty pairing screen hiding a broken store).
|
||||
func bootstrap() async {
|
||||
guard route == .loading else { return }
|
||||
do {
|
||||
let hosts = try await environment.hostStore.loadAll()
|
||||
route = ColdStartPolicy.initialRoute(pairedHostCount: hosts.count)
|
||||
} catch {
|
||||
route = .sessions
|
||||
}
|
||||
if route == .pairing {
|
||||
rootPairingViewModel = makePairingViewModel()
|
||||
}
|
||||
await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22)
|
||||
}
|
||||
|
||||
/// First-run pairing done → move to the list (the paired host is already
|
||||
/// in the store — PairingViewModel upserts before signalling).
|
||||
func completeFirstPairing(_ host: HostRegistry.Host) {
|
||||
rootPairingViewModel = nil
|
||||
route = .sessions
|
||||
Task { await sessionList.reloadHosts() }
|
||||
}
|
||||
|
||||
// MARK: - Add-host sheet (list header hook)
|
||||
|
||||
func presentAddHost() {
|
||||
addHostPairingViewModel = makePairingViewModel()
|
||||
isAddHostPresented = true
|
||||
}
|
||||
|
||||
func completeAddHost(_ host: HostRegistry.Host) {
|
||||
isAddHostPresented = false
|
||||
addHostDismissed()
|
||||
}
|
||||
|
||||
/// Sheet gone (paired OR cancelled): drop the VM and refresh hosts — the
|
||||
/// list VM keeps the active host if it still exists.
|
||||
func addHostDismissed() {
|
||||
addHostPairingViewModel = nil
|
||||
Task { await sessionList.reloadHosts() }
|
||||
}
|
||||
|
||||
// MARK: - Projects (T-iOS-26)
|
||||
|
||||
/// Toolbar 入口(RootView):以当前活跃主机呈现 Projects sheet。
|
||||
func presentProjects() {
|
||||
guard let host = sessionList.activeHost else { return }
|
||||
projectsViewModel = ProjectsViewModel(host: host, http: environment.http)
|
||||
isProjectsPresented = true
|
||||
}
|
||||
|
||||
/// Sheet 消失(打开会话 OR 手动关闭):丢弃 VM。
|
||||
func projectsDismissed() {
|
||||
projectsViewModel = nil
|
||||
}
|
||||
|
||||
/// "在此仓库开新会话":关 sheet → fresh spawn(`attach(null, cwd)`)+
|
||||
/// attach 后注入 `claude\r`(帧序由 engine 的 attach-first 队列保证)。
|
||||
func openProject(_ request: ProjectOpenRequest) {
|
||||
guard terminalController == nil else { return } // one foreground session
|
||||
isProjectsPresented = false
|
||||
projectsViewModel = nil
|
||||
startTerminal(
|
||||
host: request.host, sessionId: nil,
|
||||
spawnCwd: request.cwd, bootstrapInput: request.bootstrapInput
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Terminal open/close
|
||||
|
||||
/// `SessionListScreen.onOpen` (one navigation signal per tap) and the
|
||||
/// "继续上次" banner both land here. `sessionId == nil` = new session.
|
||||
func open(_ request: SessionListViewModel.OpenRequest) {
|
||||
guard terminalController == nil else { return } // one foreground session
|
||||
startTerminal(host: request.host, sessionId: request.sessionId)
|
||||
}
|
||||
|
||||
/// 唯一的 controller 构造点(普通打开与 T-iOS-26 项目内 spawn 共用)。
|
||||
private func startTerminal(
|
||||
host: HostRegistry.Host,
|
||||
sessionId: UUID?,
|
||||
spawnCwd: String? = nil,
|
||||
bootstrapInput: String? = nil
|
||||
) {
|
||||
let controller = TerminalSessionController(
|
||||
host: host,
|
||||
sessionId: sessionId,
|
||||
environment: environment,
|
||||
onPendingChanged: { [weak self] sessionId, pending in
|
||||
self?.sessionList.setPendingApproval(sessionId: sessionId, pending: pending)
|
||||
},
|
||||
onTitleChanged: { [weak self] sessionId, title in
|
||||
// T-iOS-23 · OSC title → list row (already sanitized in the
|
||||
// VM; the list VM sanitizes once more at its own boundary).
|
||||
self?.sessionList.setSessionTitle(sessionId: sessionId, title: title)
|
||||
},
|
||||
spawnCwd: spawnCwd,
|
||||
bootstrapInput: bootstrapInput
|
||||
)
|
||||
terminalController = controller
|
||||
controller.start()
|
||||
}
|
||||
|
||||
/// Back navigation popped the terminal: explicit detach. Also the first
|
||||
/// half of every session SWITCH (single live WS invariant, plan §1):
|
||||
/// list back-nav and `openDeepLinkedSession` both close here before the
|
||||
/// next `open` — one engine at a time, always close→open with replay.
|
||||
func closeTerminal() {
|
||||
// T-iOS-23 · leaving = seen: stamp the unread watermark for the
|
||||
// adopted session so output watched in the terminal never relights
|
||||
// the list dot.
|
||||
if let sessionId = terminalController?.terminalViewModel.sessionId {
|
||||
sessionList.markSeen(sessionId: sessionId)
|
||||
}
|
||||
terminalController?.teardown()
|
||||
terminalController = nil
|
||||
}
|
||||
|
||||
/// T-iPad-3 · 终端指针上下文菜单「结束会话」→ 复用既有 kill 通道
|
||||
/// (`SessionListViewModel.kill` → `APIClient.killSession`,带 Origin,无新
|
||||
/// 网络路径),随后 detach detail。无 adopted 会话 → no-op。
|
||||
func killCurrentSession() {
|
||||
guard let sessionId = terminalController?.terminalViewModel.sessionId else { return }
|
||||
Task { await sessionList.kill(sessionId: sessionId) }
|
||||
closeTerminal()
|
||||
}
|
||||
|
||||
// MARK: - Split-view sidebar bridge (T-iPad-2)
|
||||
|
||||
/// Detail 面板当前显示的会话 → sidebar 选中态(二向绑定的 getter)。只反映
|
||||
/// **adopted** 会话;未 adopted / 无打开的 detail → nil(占位「选择或新建
|
||||
/// 会话」)。分栏与 stack 共享同一 `terminalController`(归 coordinator
|
||||
/// 所有,与布局分支正交)。
|
||||
var selectedSidebarItem: SidebarItem? {
|
||||
terminalController?.terminalViewModel.sessionId.map(SidebarItem.session)
|
||||
}
|
||||
|
||||
/// `NavigationSplitView` 的选中绑定。getter 反映 detail 会话;setter 复用
|
||||
/// 既有路由(`open` / `presentProjects`)—— 分栏只是又一个触发面,绝不新开
|
||||
/// 一条会话生命周期。取消选中(set nil)不主动关闭 detail。
|
||||
var sidebarSelection: Binding<SidebarItem?> {
|
||||
Binding(
|
||||
get: { self.selectedSidebarItem },
|
||||
set: { item in
|
||||
guard let item else { return }
|
||||
self.selectSidebarItem(item)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// sidebar 触发面:把选中项映射到**同一** open(id) / open(nil) /
|
||||
/// presentProjects API。切会话沿用单活 WS 不变式的 close→open(与
|
||||
/// new-in-cwd / deep-link 切换同一套原语),`.projects` 走既有 sheet 呈现。
|
||||
func selectSidebarItem(_ item: SidebarItem) {
|
||||
switch item {
|
||||
case .session(let sessionId):
|
||||
switchTerminal(sessionId: sessionId)
|
||||
case .newSession:
|
||||
switchTerminal(sessionId: nil)
|
||||
case .projects:
|
||||
presentProjects()
|
||||
}
|
||||
}
|
||||
|
||||
/// 单活 WS 不变式下的会话切换:已在显示同一会话 → no-op(免无谓 churn);
|
||||
/// 否则先 `closeTerminal()`(旧会话记 last-seen 水位、engine detach)再
|
||||
/// `open()`。复用既有原语,**非**平行生命周期。无 activeHost → no-op。
|
||||
private func switchTerminal(sessionId: UUID?) {
|
||||
guard let host = sessionList.activeHost else { return }
|
||||
if let sessionId, selectedSidebarItem == .session(sessionId) { return }
|
||||
if terminalController != nil { closeTerminal() }
|
||||
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
|
||||
}
|
||||
|
||||
// MARK: - "在当前目录开新会话" (T-iOS-29)
|
||||
|
||||
/// 当前终端会话的 cwd。解析次序:/live-sessions 行数据(server-adopted
|
||||
/// id 匹配行,列表 VM 的最近快照)→ controller 的 `spawnCwd`(T-iOS-26
|
||||
/// 项目 fresh-spawn 尚未进列表轮询)→ nil(未知)。服务器数据不可信:
|
||||
/// 非绝对路径按未知处理(ProjectsViewModel 同款纪律;engine 侧还会再验)。
|
||||
var currentTerminalCwd: String? {
|
||||
guard let controller = terminalController else { return nil }
|
||||
let fromRows = controller.terminalViewModel.sessionId.flatMap { id in
|
||||
sessionList.rows.first(where: { $0.id == id })?.info.cwd
|
||||
}
|
||||
let candidate = fromRows ?? controller.spawnCwd
|
||||
return candidate.flatMap { Validation.isAbsoluteCwd($0) ? $0 : nil }
|
||||
}
|
||||
|
||||
/// TerminalScreen 工具栏与 exit 横幅共用动作:在当前会话的 cwd fresh
|
||||
/// spawn(`attach(null, cwd)`,镜像 web tabs.ts `newTab()` M6)。单活
|
||||
/// WS 不变式:先 `closeTerminal()`(旧会话记 last-seen 水位、engine
|
||||
/// detach),再开新 controller。cwd 未知 → 普通新会话;无打开的终端
|
||||
/// → no-op。不注入 bootstrap —— "开新 shell"不是"起 claude"。
|
||||
func openNewSessionInCurrentCwd() {
|
||||
guard let controller = terminalController else { return }
|
||||
let host = controller.host
|
||||
let cwd = currentTerminalCwd
|
||||
closeTerminal()
|
||||
startTerminal(host: host, sessionId: nil, spawnCwd: cwd)
|
||||
}
|
||||
|
||||
// MARK: - "继续上次" (cold start step 5)
|
||||
|
||||
var continueLastSessionId: UUID? {
|
||||
ColdStartPolicy.continueLastSessionId(
|
||||
activeHost: sessionList.activeHost,
|
||||
store: environment.lastSessionStore
|
||||
)
|
||||
}
|
||||
|
||||
func openContinueLast() {
|
||||
guard let host = sessionList.activeHost, let sessionId = continueLastSessionId else {
|
||||
return
|
||||
}
|
||||
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
|
||||
}
|
||||
|
||||
// MARK: - scenePhase (plan §7 T-iOS-15 step 3)
|
||||
|
||||
func handleScenePhase(_ phase: ScenePhase) {
|
||||
switch phase {
|
||||
case .background:
|
||||
terminalController?.suspend()
|
||||
case .active:
|
||||
terminalController?.resumeIfNeeded()
|
||||
case .inactive:
|
||||
break // transient; shade covers it at the view layer
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makePairingViewModel() -> PairingViewModel {
|
||||
PairingViewModel(store: environment.hostStore, probe: environment.probe)
|
||||
}
|
||||
}
|
||||
|
||||
/// T-iPad-2 · split 模式下 sidebar 的选中项。与现有 `AppCoordinator` 路由
|
||||
/// 一一等价映射:`.session(id)` == `open(id)`、`.newSession` == `open(nil)`、
|
||||
/// `.projects` == `presentProjects()`。
|
||||
enum SidebarItem: Hashable {
|
||||
case session(UUID)
|
||||
case newSession
|
||||
case projects
|
||||
}
|
||||
60
ios/App/WebTerm/Wiring/AppEnvironment.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Production dependency graph (composition root). One immutable
|
||||
/// value assembled at launch; every screen/VM receives its dependencies from
|
||||
/// here — no ad-hoc URLSession/keychain access anywhere else in the App layer.
|
||||
///
|
||||
/// 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.
|
||||
/// - Origin is derived solely by `HostEndpoint` (WS: URLSessionTermTransport;
|
||||
/// HTTP: APIClient's route builder) — nothing here hand-assembles one.
|
||||
/// - Secrets: hosts in `KeychainHostStore`
|
||||
/// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it);
|
||||
/// UserDefaults carries only the non-secret per-host lastSessionId.
|
||||
/// - 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;
|
||||
/// `UIScreen.isCaptured` detection is SKIPPED per plan (accepted residual
|
||||
/// risk, local trust model).
|
||||
struct AppEnvironment: Sendable {
|
||||
let hostStore: any HostStore
|
||||
let lastSessionStore: any LastSessionStore
|
||||
let http: any HTTPTransport
|
||||
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).
|
||||
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()
|
||||
|
||||
static func production() -> AppEnvironment {
|
||||
let http = URLSessionHTTPTransport()
|
||||
let termTransport = URLSessionTermTransport()
|
||||
return AppEnvironment(
|
||||
hostStore: KeychainHostStore(),
|
||||
lastSessionStore: UserDefaultsLastSessionStore(),
|
||||
http: http,
|
||||
termTransport: termTransport,
|
||||
probe: { endpoint in
|
||||
await runPairingProbe(endpoint: endpoint, http: http, ws: termTransport)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Away-digest source for a session engine: wraps `APIClient.events` per
|
||||
/// host (the engine never holds an HTTP client — plan §3.2).
|
||||
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) }
|
||||
}
|
||||
}
|
||||
32
ios/App/WebTerm/Wiring/ColdStartPolicy.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
|
||||
/// T-iOS-15 · Cold-start selection logic, kept pure for unit tests
|
||||
/// (ColdStartPolicyTests): which root screen boots, and whether the session
|
||||
/// list highlights "继续上次" for the active host.
|
||||
enum ColdStartPolicy {
|
||||
enum RootRoute: Equatable {
|
||||
/// Host store not read yet.
|
||||
case loading
|
||||
/// No paired host → pairing is the only next step (plan §7 step 1).
|
||||
case pairing
|
||||
/// At least one paired host → the merged chooser/dashboard list.
|
||||
case sessions
|
||||
}
|
||||
|
||||
static func initialRoute(pairedHostCount: Int) -> RootRoute {
|
||||
pairedHostCount == 0 ? .pairing : .sessions
|
||||
}
|
||||
|
||||
/// The "继续上次" target for the list's highlight banner: the last
|
||||
/// server-adopted session persisted for the ACTIVE host (nil = no banner).
|
||||
/// Persistence side: `SessionActivityBridge` (adopted → set, exited →
|
||||
/// cleared), so a returned id was live when last seen.
|
||||
static func continueLastSessionId(
|
||||
activeHost: HostRegistry.Host?,
|
||||
store: any LastSessionStore
|
||||
) -> UUID? {
|
||||
guard let activeHost else { return nil }
|
||||
return store.lastSessionId(host: activeHost.id)
|
||||
}
|
||||
}
|
||||
53
ios/App/WebTerm/Wiring/EventFanOut.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
/// T-iOS-15 · Broadcast adapter for a single-consumer `AsyncStream`.
|
||||
///
|
||||
/// `SessionEngine.events` has exactly ONE consumer (engine contract), but the
|
||||
/// wiring needs three: `TerminalViewModel` (output/connection/exit),
|
||||
/// `GateViewModel` (gate/digest) and `SessionActivityBridge` (adopted /
|
||||
/// pending-⚠ / last-session persistence). Both VMs were designed for this —
|
||||
/// their `events` parameter is injected separately from the engine precisely
|
||||
/// so a fan-out branch can be passed instead (their type docs say so).
|
||||
///
|
||||
/// Shape: `branchCount` child streams are created up front (immutable `let`s
|
||||
/// — no late subscription, so no missed-element semantics to define); ONE pump
|
||||
/// task consumes the source and yields to every branch in order. AsyncStream's
|
||||
/// default unbounded buffering means a slow branch never drops or blocks the
|
||||
/// others. Source finish — which `SessionEngine.close()` guarantees — finishes
|
||||
/// every branch; `cancel()` is the teardown belt-and-braces for rebuilds.
|
||||
final class EventFanOut<Element: Sendable>: Sendable {
|
||||
let branches: [AsyncStream<Element>]
|
||||
private let continuations: [AsyncStream<Element>.Continuation]
|
||||
private let pumpTask: Task<Void, Never>
|
||||
|
||||
init(source: AsyncStream<Element>, branchCount: Int) {
|
||||
var branches: [AsyncStream<Element>] = []
|
||||
var continuations: [AsyncStream<Element>.Continuation] = []
|
||||
for _ in 0..<branchCount {
|
||||
let (stream, continuation) = AsyncStream<Element>.makeStream()
|
||||
branches.append(stream)
|
||||
continuations.append(continuation)
|
||||
}
|
||||
self.branches = branches
|
||||
self.continuations = continuations
|
||||
let sinks = continuations
|
||||
pumpTask = Task {
|
||||
for await element in source {
|
||||
for sink in sinks {
|
||||
sink.yield(element)
|
||||
}
|
||||
}
|
||||
for sink in sinks {
|
||||
sink.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop pumping and finish every branch immediately (idempotent — finish
|
||||
/// on a finished continuation is a no-op). Used when a suspended terminal
|
||||
/// stack is torn down and rebuilt.
|
||||
func cancel() {
|
||||
pumpTask.cancel()
|
||||
for sink in continuations {
|
||||
sink.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
22
ios/App/WebTerm/Wiring/LayoutMode.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import SwiftUI
|
||||
|
||||
/// T-iPad-2 · 自适应根视图的布局模式。
|
||||
public enum LayoutMode: Equatable {
|
||||
/// 现有 iPhone 路径:`NavigationStack`(列表 → push 终端)。
|
||||
case stack
|
||||
/// iPad 分栏:`NavigationSplitView`(sidebar 会话列表 + detail 终端)。
|
||||
case split
|
||||
}
|
||||
|
||||
/// T-iPad-2 · **唯一** size-class 决策点(仿 `PrivacyShadePolicy` 的纯谓词
|
||||
/// 先例)。视图内严禁散落 `if sizeClass == …`(PLAN_IOS_IPAD §4)—— 所有
|
||||
/// 分支只从这里读一次,故可 100% 单测。
|
||||
///
|
||||
/// regular 宽度(iPad 全屏 / 大分屏 / Stage Manager 大窗)→ `.split`;
|
||||
/// compact 或 nil(iPhone / iPad Slide Over / 小分屏 / size class 未定)→
|
||||
/// `.stack`。nil 走最保守回退:退到现有已测的 iPhone 路径,绝不误判成分栏。
|
||||
public enum LayoutPolicy {
|
||||
public static func mode(horizontalSizeClass: UserInterfaceSizeClass?) -> LayoutMode {
|
||||
horizontalSizeClass == .regular ? .split : .stack
|
||||
}
|
||||
}
|
||||
70
ios/App/WebTerm/Wiring/PrivacyShade.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-15 · Privacy shade (security-critical, plan §7 T-iOS-15).
|
||||
///
|
||||
/// iOS writes the app-switcher snapshot to DISK the moment the scene reaches
|
||||
/// `.background` — but the switcher is usually ENTERED at `.inactive`. The
|
||||
/// shade therefore covers whenever `scenePhase != .active` (the exact rule;
|
||||
/// `.inactive`-only would let the `.background` snapshot capture terminal
|
||||
/// content — API keys, tokens, source — into the on-disk switcher image).
|
||||
/// Restored on `.active`.
|
||||
///
|
||||
/// Pure mapping split from the view so the rule is unit-testable for all
|
||||
/// three phases (PrivacyShadeTests).
|
||||
enum PrivacyShadePolicy {
|
||||
static func isShadeVisible(for scenePhase: ScenePhase) -> Bool {
|
||||
scenePhase != .active
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque cover rendered ABOVE the whole navigation tree (RootView ZStack) —
|
||||
/// deliberately no animation: the snapshot moment must never race a fade.
|
||||
///
|
||||
/// Security invariant (do NOT weaken): the FULL-SCREEN base is `DS.Palette.surface`
|
||||
/// (`.systemBackground`, fully OPAQUE) + `.ignoresSafeArea()`, so no terminal
|
||||
/// byte can leak into the on-disk switcher snapshot. The branded lockup is a
|
||||
/// `.regularMaterial` card layered ON TOP of that opaque base (never over the
|
||||
/// terminal), so the material's translucency is purely decorative — it blurs
|
||||
/// the opaque surface below it, not the PTY content. Accent lock + app name +
|
||||
/// hint give the shade a refined-native identity instead of a flat cover.
|
||||
///
|
||||
/// Scope note (documented): sheets present in a separate presentation layer a
|
||||
/// root overlay cannot cover — but no terminal bytes are ever rendered in a
|
||||
/// sheet (pairing / plan-gate only), so the root-level shade covers every
|
||||
/// surface that shows PTY content.
|
||||
struct PrivacyShadeView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// OPAQUE full-screen occlusion — the security base. Must stay opaque
|
||||
// and edge-to-edge; the material card below only decorates it.
|
||||
DS.Palette.surface
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(DS.Typography.largeTitle)
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
Text(ShadeCopy.appName)
|
||||
.font(DS.Typography.headline)
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Text(ShadeCopy.hint)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, DS.Space.xxl24)
|
||||
.padding(.vertical, DS.Space.xl20)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.lg16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DS.Radius.lg16)
|
||||
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 遮罩内用户可见文案(Chinese, named constants)。
|
||||
private enum ShadeCopy {
|
||||
static let appName = "WebTerm"
|
||||
static let hint = "内容已隐藏"
|
||||
}
|
||||
181
ios/App/WebTerm/Wiring/RootView.swift
Normal file
@@ -0,0 +1,181 @@
|
||||
import HostRegistry
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-15 / T-iPad-2 · App 根入口。历史上这里是单一 `NavigationStack` +
|
||||
/// 隐私遮罩 + sheets/scenePhase/deepLink 的全部布线;iPad 自适应(T-iPad-2)
|
||||
/// 把它拆成两层:
|
||||
/// - `RootView`(本 struct)保持 `@main` 的唯一实例化点不变(WebTermApp 仍
|
||||
/// `RootView(coordinator:)`),内部委托给 `AdaptiveRootView` 并在这唯一处
|
||||
/// 注入设计系统的 accent tint(`DS.Palette.accent`,DS 建议的「根注入一次」)
|
||||
/// —— 全 App 的系统控件/工具栏/`.borderedProminent` 由此统一到靛紫强调色。
|
||||
/// - `AdaptiveRootView` 按 `horizontalSizeClass` 选 `StackRootView`(compact,
|
||||
/// 现有路径,字节级不变)或 `SplitRootView`(regular,iPad 分栏),并把
|
||||
/// 隐私遮罩 / scenePhase / deepLink / sheets 这些**设备无关的横切布线**
|
||||
/// 上提到唯一一处,两分支共享(遮罩因此仍是两分支共同的 ZStack 顶层)。
|
||||
///
|
||||
/// `StackRootView` 是原 `RootView` 的 body **原样搬迁** —— compact 分支复用它
|
||||
/// 不改一行逻辑,故 iPhone 逐屏渲染与适配前一致(零回归硬性要求)。视觉层
|
||||
/// 只按冻结的 `DS.*` token 重塑(横幅/占位/遮罩),行为不动。
|
||||
struct RootView: View {
|
||||
@Bindable var coordinator: AppCoordinator
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// T-iPad-2 · compact 宽度(iPhone / iPad Slide Over)的根视图 —— 原
|
||||
/// `RootView` 的 `NavigationStack` 主体原样搬迁,行为字节级不变。横切布线
|
||||
/// (遮罩/scenePhase/deepLink/sheets)不在这里,已上提到 `AdaptiveRootView`。
|
||||
struct StackRootView: View {
|
||||
@Bindable var coordinator: AppCoordinator
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
rootContent
|
||||
.navigationDestination(isPresented: terminalBinding) {
|
||||
terminalDestination
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route switch
|
||||
|
||||
@ViewBuilder private var rootContent: some View {
|
||||
switch coordinator.route {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
case .pairing:
|
||||
firstRunPairing
|
||||
case .sessions:
|
||||
sessionList
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var firstRunPairing: some View {
|
||||
if let viewModel = coordinator.rootPairingViewModel {
|
||||
PairingScreen(viewModel: viewModel) { host in
|
||||
coordinator.completeFirstPairing(host)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
|
||||
private var sessionList: some View {
|
||||
SessionListScreen(
|
||||
viewModel: coordinator.sessionList,
|
||||
onOpen: { coordinator.open($0) },
|
||||
onAddHost: { coordinator.presentAddHost() }
|
||||
)
|
||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||
// 横幅出现/消失走 DS 动效(reduceMotion 时塌成瞬切,无位移)。
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: coordinator.continueLastSessionId
|
||||
)
|
||||
// T-iOS-26 · Projects 入口挂在 RootView 层(SessionListScreen 是
|
||||
// T-iOS-23 的 W7 独占文件 —— 列表侧入口整合移交给它)。leading 位,
|
||||
// 避开列表自己的 topBarTrailing hostMenu。
|
||||
.toolbar { ProjectsToolbarItem(coordinator: coordinator) }
|
||||
}
|
||||
|
||||
// MARK: - "继续上次" highlight (cold start step 5)
|
||||
|
||||
@ViewBuilder private var continueLastBanner: some View {
|
||||
if coordinator.continueLastSessionId != nil {
|
||||
ContinueLastBanner { coordinator.openContinueLast() }
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Terminal push
|
||||
|
||||
private var terminalBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { coordinator.terminalController != nil },
|
||||
set: { presented in
|
||||
guard !presented else { return }
|
||||
coordinator.closeTerminal() // back → engine.close() (detach)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private var terminalDestination: some View {
|
||||
if let controller = coordinator.terminalController {
|
||||
TerminalContainerView(
|
||||
controller: controller,
|
||||
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() },
|
||||
onKillSession: { coordinator.killCurrentSession() } // T-iPad-3
|
||||
)
|
||||
// T-iOS-29 · identity PER CONTROLLER: an in-place session switch
|
||||
// (new-in-cwd, deep link) swaps the controller while the
|
||||
// destination stays presented — without a new identity SwiftUI
|
||||
// keeps the old SwiftTerm UIView and the new ViewModel's output
|
||||
// sink never attaches (makeUIView never re-runs). Also resets the
|
||||
// container's per-session @State (plan-gate dismissal, timeline).
|
||||
.id(controller.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Continue-last banner (shared stack + split, DRY)
|
||||
|
||||
/// 冷启动第 5 步的「继续上次会话」再入 CTA —— stack 底栏与 split sidebar 底栏
|
||||
/// 共用同一个组件(DRY)。设计:`.regularMaterial` 底 + 顶部发丝分隔 +
|
||||
/// 全宽 accent 主按钮(`DSButtonStyle(.primary)`),克制而不喧宾夺主。行为仅
|
||||
/// 一条 `action`(= `coordinator.openContinueLast()`),出现条件由调用点把关。
|
||||
struct ContinueLastBanner: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label(RootCopy.continueLast, systemImage: "arrow.uturn.forward.circle.fill")
|
||||
}
|
||||
.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
.accessibilityIdentifier("root.continueLastButton")
|
||||
.padding(.horizontal, DS.Space.lg16)
|
||||
.padding(.top, DS.Space.md12)
|
||||
.padding(.bottom, DS.Space.sm8)
|
||||
.background(.regularMaterial)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(DS.Palette.hairline)
|
||||
.frame(height: DS.Stroke.hairline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Projects toolbar item (shared stack + split, DRY)
|
||||
|
||||
/// 「项目」leading 工具栏入口 —— stack 与 split 共用(同 disabled 条件、同
|
||||
/// `presentProjects` 动作、同 a11y id)。根 tint 令其 label 呈 accent。
|
||||
struct ProjectsToolbarItem: ToolbarContent {
|
||||
@Bindable var coordinator: AppCoordinator
|
||||
|
||||
var body: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
coordinator.presentProjects()
|
||||
} label: {
|
||||
Label(RootCopy.projects, systemImage: "folder")
|
||||
}
|
||||
.disabled(coordinator.sessionList.activeHost == nil)
|
||||
.accessibilityIdentifier("sessions.projectsButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根层用户可见文案(internal —— `SplitRootView` 复用同一「项目」/「继续」标签,DRY)。
|
||||
enum RootCopy {
|
||||
static let continueLast = "继续上次会话"
|
||||
static let projects = "项目"
|
||||
}
|
||||
129
ios/App/WebTerm/Wiring/SessionActivityBridge.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
|
||||
/// T-iOS-15 · Third fan-out branch: session-level side effects that belong to
|
||||
/// neither terminal nor gate UI.
|
||||
///
|
||||
/// - `.adopted` → remember the SERVER-issued id (always adopt it — attaching
|
||||
/// an unknown UUID yields a brand-new session) and persist it per host via
|
||||
/// `LastSessionStore` (TerminalViewModel's doc assigns this persistence to
|
||||
/// the T-iOS-15 wiring). This id is also what a background→foreground
|
||||
/// rebuild re-attaches to.
|
||||
/// - `.gate` → forward pending (gate != nil) into
|
||||
/// `SessionListViewModel.setPendingApproval` — the ⚠ overlay hook the list
|
||||
/// VM documents ("The T-iOS-15 wiring forwards gate/status events into
|
||||
/// setPendingApproval"). Gates arriving before adoption have no session to
|
||||
/// attribute and are dropped (cannot happen on the real wire: `attached` is
|
||||
/// always first — defensive only).
|
||||
/// - `.exited` → clear the ⚠ overlay AND the persisted lastSessionId: a dead
|
||||
/// session must not stay flagged nor be offered as "继续上次" (documented
|
||||
/// decision — otherwise cold start would silently spawn a NEW session via
|
||||
/// the unknown-UUID attach path).
|
||||
///
|
||||
/// A detach (stream finish without exit) deliberately KEEPS the ⚠ overlay:
|
||||
/// the server still holds the gate; the list poll prunes it if the session
|
||||
/// disappears.
|
||||
@MainActor
|
||||
final class SessionActivityBridge {
|
||||
private(set) var adoptedSessionId: UUID?
|
||||
|
||||
private let events: AsyncStream<SessionEvent>
|
||||
private let hostId: UUID
|
||||
private let lastSessionStore: any LastSessionStore
|
||||
private let onPendingChanged: @MainActor (UUID, Bool) -> Void
|
||||
private var consumeTask: Task<Void, Never>?
|
||||
|
||||
// Deterministic test barrier (same pattern as the W3 ViewModels).
|
||||
private(set) var processedEventCount = 0
|
||||
private var eventWaiters: [CountWaiter] = []
|
||||
|
||||
private struct CountWaiter {
|
||||
let target: Int
|
||||
let continuation: CheckedContinuation<Void, Never>
|
||||
}
|
||||
|
||||
init(
|
||||
events: AsyncStream<SessionEvent>,
|
||||
hostId: UUID,
|
||||
lastSessionStore: any LastSessionStore,
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
|
||||
) {
|
||||
self.events = events
|
||||
self.hostId = hostId
|
||||
self.lastSessionStore = lastSessionStore
|
||||
self.onPendingChanged = onPendingChanged
|
||||
}
|
||||
|
||||
/// Begin consuming. Idempotent — the branch has exactly one consumer.
|
||||
func start() {
|
||||
guard consumeTask == nil else { return }
|
||||
consumeTask = Task { [weak self] in
|
||||
guard let events = self?.events else { return }
|
||||
for await event in events {
|
||||
guard let self else { return }
|
||||
self.apply(event)
|
||||
}
|
||||
self?.releaseAllWaiters()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
consumeTask?.cancel()
|
||||
consumeTask = nil
|
||||
releaseAllWaiters()
|
||||
}
|
||||
|
||||
// MARK: - Event application
|
||||
|
||||
private func apply(_ event: SessionEvent) {
|
||||
switch event {
|
||||
case .adopted(let sessionId):
|
||||
adoptedSessionId = sessionId
|
||||
lastSessionStore.setLastSessionId(sessionId, host: hostId)
|
||||
case .gate(let gate):
|
||||
forwardPending(gate != nil)
|
||||
case .exited:
|
||||
forwardPending(false)
|
||||
lastSessionStore.setLastSessionId(nil, host: hostId)
|
||||
case .connection, .output, .telemetry, .digest:
|
||||
break // terminal / gate UI domain
|
||||
}
|
||||
processedEventCount += 1
|
||||
resumeEventWaiters()
|
||||
}
|
||||
|
||||
private func forwardPending(_ pending: Bool) {
|
||||
guard let adoptedSessionId else { return }
|
||||
onPendingChanged(adoptedSessionId, pending)
|
||||
}
|
||||
|
||||
// MARK: - Test barrier
|
||||
|
||||
/// Suspends until at least `eventCount` events have been applied.
|
||||
func waitUntilProcessed(eventCount target: Int) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
guard processedEventCount < target else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)]
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeEventWaiters() {
|
||||
let satisfied = eventWaiters.filter { $0.target <= processedEventCount }
|
||||
eventWaiters = eventWaiters.filter { $0.target > processedEventCount }
|
||||
for waiter in satisfied {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseAllWaiters() {
|
||||
let all = eventWaiters
|
||||
eventWaiters = []
|
||||
for waiter in all {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
104
ios/App/WebTerm/Wiring/SplitRootView.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import SwiftUI
|
||||
|
||||
/// T-iPad-2 · regular 宽度(iPad 全屏 / 大分屏 / Stage Manager 大窗)的分栏根
|
||||
/// 视图。左 sidebar = 会话列表(复用 `SessionListScreen` **不改一行** + 与
|
||||
/// stack 同款的「项目」leading 工具栏入口);右 detail = `TerminalContainerView`
|
||||
/// (终端 + gate/digest 叠层,复用不动),无打开会话时给「选择或新建会话」占位。
|
||||
///
|
||||
/// 设计要点:
|
||||
/// - **detail 复用 `TerminalContainerView`**:终端组装与它挂 push destination 还是
|
||||
/// split detail 无关,只换外层容器、内容零改(PLAN_IOS_IPAD §1)。
|
||||
/// - **选中态经 `AppCoordinator` 单一真值**:sidebar 触发面全部走
|
||||
/// `selectSidebarItem` / `open`(同 stack 的既有路由),不新开会话生命周期。
|
||||
/// `coordinator.sidebarSelection` 提供二向绑定;行 highlight 需 sidebar List
|
||||
/// 采纳 `selection:`(`SessionListScreen` 的 List,属列表组 Owns),见文末注。
|
||||
/// - **detail 终端仍带 `.id(controller.id)`**:换会话时新 controller → 新
|
||||
/// SwiftTerm 视图(回放 ring buffer),identity 稳定复用 T-iOS-29 语义。
|
||||
/// - **隐私遮罩不在这里**:它在 `AdaptiveRootView` 的 ZStack 顶层,两分支共享,
|
||||
/// 故 detail 终端字节同样在 `scenePhase != .active` 时被遮(安全不变式)。
|
||||
/// - **视觉层**:横幅/占位/工具栏全部走冻结 `DS.*` token 与共享 Primitive,
|
||||
/// 与 stack 分支像素级一致(继续横幅、项目入口都是同一组件)。
|
||||
struct SplitRootView: View {
|
||||
@Bindable var coordinator: AppCoordinator
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
sidebar
|
||||
} detail: {
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sidebar(会话列表 + 项目入口,复用 SessionListScreen)
|
||||
|
||||
private var sidebar: some View {
|
||||
SessionListScreen(
|
||||
viewModel: coordinator.sessionList,
|
||||
onOpen: { request in
|
||||
// 分栏只是又一个触发面:映射到同一 selectSidebarItem 路由。
|
||||
coordinator.selectSidebarItem(sidebarItem(for: request))
|
||||
},
|
||||
onAddHost: { coordinator.presentAddHost() }
|
||||
)
|
||||
.safeAreaInset(edge: .bottom) { continueLastBanner }
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: coordinator.continueLastSessionId
|
||||
)
|
||||
.toolbar { ProjectsToolbarItem(coordinator: coordinator) }
|
||||
}
|
||||
|
||||
/// 与 stack 底栏同款「继续上次会话」(冷启动第 5 步)—— 复用共享
|
||||
/// `ContinueLastBanner`,iPad 用户不丢该入口(T-iPad-5 finding)。
|
||||
@ViewBuilder private var continueLastBanner: some View {
|
||||
if coordinator.continueLastSessionId != nil {
|
||||
ContinueLastBanner { coordinator.openContinueLast() }
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表的 `OpenRequest` → sidebar 选中项(sessionId 有无 = 采纳既有 / 新建)。
|
||||
private func sidebarItem(for request: SessionListViewModel.OpenRequest) -> SidebarItem {
|
||||
request.sessionId.map(SidebarItem.session) ?? .newSession
|
||||
}
|
||||
|
||||
// MARK: - Detail(终端或占位)
|
||||
|
||||
@ViewBuilder private var detail: some View {
|
||||
if let controller = coordinator.terminalController {
|
||||
NavigationStack {
|
||||
TerminalContainerView(
|
||||
controller: controller,
|
||||
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() },
|
||||
onKillSession: { coordinator.killCurrentSession() } // T-iPad-3
|
||||
)
|
||||
.id(controller.id) // T-iOS-29 · identity per controller
|
||||
}
|
||||
} else {
|
||||
placeholder
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
/// 空 detail 占位 —— accent 图标 + 邀约式文案(DS 方向:ContentUnavailableView
|
||||
/// + 好符号 + 友好中文)。图标着 accent 靛紫,与全 App 强调色呼应。
|
||||
private var placeholder: some View {
|
||||
ContentUnavailableView {
|
||||
Label {
|
||||
Text(SplitCopy.placeholderTitle)
|
||||
} icon: {
|
||||
Image(systemName: "terminal")
|
||||
.foregroundStyle(DS.Palette.accent)
|
||||
}
|
||||
} description: {
|
||||
Text(SplitCopy.placeholderHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// split 专属用户可见文案(「项目」/「继续」标签复用 `RootCopy`,DRY)。
|
||||
private enum SplitCopy {
|
||||
static let placeholderTitle = "选择或新建会话"
|
||||
static let placeholderHint = "从左侧选择一个运行中的会话,或新建一个开始工作。"
|
||||
}
|
||||
183
ios/App/WebTerm/Wiring/TerminalContainerView.swift
Normal file
@@ -0,0 +1,183 @@
|
||||
import SessionCore
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-15 · Terminal destination view: TerminalScreen (T-iOS-11) plus the
|
||||
/// gate/digest surfaces (T-iOS-14) wired to the controller's GateViewModel —
|
||||
/// the "接入 TerminalScreen 的 wiring 归 T-iOS-15" hand-off.
|
||||
///
|
||||
/// Layout decisions (documented):
|
||||
/// - `.id(controller.generation)`: a rebuild (background→foreground) swaps the
|
||||
/// ViewModels; without a new identity SwiftUI would keep the old UIView and
|
||||
/// `makeUIView` (where the output sink attaches) would never run again.
|
||||
/// - Digest + tool-gate banner live in a TOP overlay stack: the bottom edge
|
||||
/// belongs to the keyboard + key bar. TerminalScreen's own ReconnectBanner
|
||||
/// is also top-aligned, but it hides on `.connected` while gates/digests
|
||||
/// only arrive connected — practical overlap is nil.
|
||||
/// - Plan gate presents as a medium-detent sheet; swiping it away is allowed
|
||||
/// (the user may need to scroll the terminal to read the plan), and a
|
||||
/// "计划待批" re-entry chip appears until the gate resolves. Deciding
|
||||
/// removes the gate server-side → `planGate` goes nil → sheet dismisses.
|
||||
struct TerminalContainerView: View {
|
||||
let controller: TerminalSessionController
|
||||
/// T-iOS-29 · pass-through to TerminalScreen's toolbar/exit-banner
|
||||
/// "在当前目录开新会话" action (RootView supplies the coordinator hop).
|
||||
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
|
||||
/// T-iPad-3 · pass-through to TerminalScreen's pointer context-menu
|
||||
/// "结束会话" action (root layers supply the coordinator hop). nil on
|
||||
/// surfaces without a kill affordance (e.g. previews).
|
||||
var onKillSession: (@MainActor () -> Void)? = nil
|
||||
/// Epoch of a plan gate the user swiped away — suppresses re-present for
|
||||
/// THAT gate only; a new epoch re-presents automatically.
|
||||
@State private var dismissedPlanGateEpoch: Int?
|
||||
/// T-iOS-24 (additive) · Non-nil while the full-timeline sheet is up; a
|
||||
/// FRESH VM per presentation (each open re-fetches /events).
|
||||
@State private var timelineViewModel: TimelineViewModel?
|
||||
/// T-iOS-25 (additive) · Quick-reply palette store — per-container over
|
||||
/// `.standard` defaults (non-secret UI prefs, plan §5.3 split).
|
||||
@State private var quickReplyStore = QuickReplyStore()
|
||||
/// 无障碍:减弱动态效果时,overlay 进出塌成即时切换(DS.Motion.gated)。
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
private enum Metrics {
|
||||
/// Clears TerminalScreen's own top-aligned ReconnectBanner zone: a
|
||||
/// hit-target-tall pill plus a gap. (They rarely co-exist — the banner
|
||||
/// hides on `.connected` while gates/digests only arrive connected.)
|
||||
static let overlayTopPadding: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8
|
||||
}
|
||||
|
||||
private enum Copy {
|
||||
/// The re-entry chip's own amber StatusBadge carries the "needs me"
|
||||
/// warning shape, so the copy itself stays plain (no emoji).
|
||||
static let planGatePending = "计划待批准"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TerminalScreen(
|
||||
viewModel: controller.terminalViewModel,
|
||||
onNewSessionInCwd: onNewSessionInCwd,
|
||||
onKillSession: onKillSession
|
||||
)
|
||||
.id(controller.generation)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.overlay(alignment: .top) { topOverlays }
|
||||
.overlay(alignment: .bottom) { quickReplyOverlay }
|
||||
.sheet(isPresented: planGateBinding) { planGateSheet }
|
||||
.sheet(item: $timelineViewModel) { TimelineSheet(viewModel: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Quick-reply chips (T-iOS-25, additive)
|
||||
|
||||
/// Chips float at the bottom edge (above the keyboard's safe area) ONLY
|
||||
/// while a gate is waiting — visibility/read-only logic lives in
|
||||
/// `QuickReplyBar.isVisible`, driven by the SAME fan-out branches the two
|
||||
/// VMs already consume (no extra branch needed).
|
||||
@ViewBuilder private var quickReplyOverlay: some View {
|
||||
QuickReplyBar(
|
||||
terminalViewModel: controller.terminalViewModel,
|
||||
gateViewModel: controller.gateViewModel,
|
||||
store: quickReplyStore
|
||||
)
|
||||
.padding(.horizontal, DS.Space.md12)
|
||||
.padding(.bottom, DS.Space.sm8)
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: controller.gateViewModel.currentGate
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Digest + tool gate (top stack)
|
||||
|
||||
@ViewBuilder private var topOverlays: some View {
|
||||
let gateViewModel = controller.gateViewModel
|
||||
VStack(spacing: DS.Space.sm8) {
|
||||
if let digest = gateViewModel.digest {
|
||||
AwayDigestView(
|
||||
digest: digest,
|
||||
isExpanded: gateViewModel.isDigestExpanded,
|
||||
onExpand: { expandDigestAndPresentTimeline() },
|
||||
onDismiss: { gateViewModel.dismissDigest() }
|
||||
)
|
||||
}
|
||||
if let gate = gateViewModel.toolGate {
|
||||
GateBanner(gate: gate) { affordance, epoch in
|
||||
gateViewModel.decide(affordance, epoch: epoch)
|
||||
}
|
||||
}
|
||||
if hasDismissedPendingPlanGate {
|
||||
planGatePendingChip
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, DS.Space.md12)
|
||||
.padding(.top, Metrics.overlayTopPadding)
|
||||
.animation(DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: gateViewModel.toolGate)
|
||||
.animation(DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
|
||||
value: gateViewModel.digest)
|
||||
}
|
||||
|
||||
/// The "plan still pending" re-entry chip shown after the user swiped the
|
||||
/// plan sheet away — an amber pending-approval pill that re-presents the
|
||||
/// sheet on tap.
|
||||
private var planGatePendingChip: some View {
|
||||
Button {
|
||||
dismissedPlanGateEpoch = nil
|
||||
} label: {
|
||||
HStack(spacing: DS.Space.xs4) {
|
||||
StatusBadge(status: .pendingApproval)
|
||||
Text(Copy.planGatePending)
|
||||
.font(DS.Typography.callout.weight(.medium))
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, DS.Space.md12)
|
||||
.frame(minHeight: DS.Layout.minHitTarget)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
.overlay(
|
||||
Capsule().strokeBorder(DS.Palette.statusWaiting, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Timeline drill-down (T-iOS-24, additive)
|
||||
|
||||
/// The digest「展开」affordance does BOTH: inline expansion (which cancels
|
||||
/// the auto-fade, T-iOS-14 semantics — the digest must survive under the
|
||||
/// sheet) and the full-timeline sheet. No adopted sessionId yet →
|
||||
/// `forSession` returns nil → inline expand only (defensive; a digest
|
||||
/// only ever arrives after `attached`).
|
||||
private func expandDigestAndPresentTimeline() {
|
||||
controller.gateViewModel.expandDigest()
|
||||
timelineViewModel = TimelineViewModel.forSession(
|
||||
controller.terminalViewModel.sessionId,
|
||||
source: controller.timelineEventsSource
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Plan gate sheet
|
||||
|
||||
private var hasDismissedPendingPlanGate: Bool {
|
||||
guard let gate = controller.gateViewModel.planGate else { return false }
|
||||
return gate.epoch == dismissedPlanGateEpoch
|
||||
}
|
||||
|
||||
private var planGateBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
guard let gate = controller.gateViewModel.planGate else { return false }
|
||||
return gate.epoch != dismissedPlanGateEpoch
|
||||
},
|
||||
set: { presented in
|
||||
guard !presented else { return }
|
||||
dismissedPlanGateEpoch = controller.gateViewModel.planGate?.epoch
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private var planGateSheet: some View {
|
||||
if let gate = controller.gateViewModel.planGate {
|
||||
PlanGateSheet(gate: gate) { affordance, epoch in
|
||||
controller.gateViewModel.decide(affordance, epoch: epoch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
255
ios/App/WebTerm/Wiring/TerminalSessionController.swift
Normal file
@@ -0,0 +1,255 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import SessionCore
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · One open terminal = one controller: builds and owns the
|
||||
/// per-session stack (engine → fan-out → TerminalViewModel + GateViewModel +
|
||||
/// SessionActivityBridge) and drives the scenePhase lifecycle.
|
||||
///
|
||||
/// Lifecycle (plan §7 T-iOS-15):
|
||||
/// - `.background` → `suspend()`: `engine.close()` — a clean detach (the
|
||||
/// server-side PTY keeps running), never a half-dead socket that iOS will
|
||||
/// kill mid-suspend anyway.
|
||||
/// - `.active` after a suspend → `resumeIfNeeded()`: `close()` is terminal for
|
||||
/// an engine, so the stack is REBUILT and re-opened against the
|
||||
/// server-adopted sessionId. The fresh SwiftTerm view (new `generation` →
|
||||
/// new UIView) replays the full ring buffer and fires `sizeChanged` on first
|
||||
/// layout — that resize is the latest-writer-wins full-screen reclaim
|
||||
/// (换设备夺回全屏), immediately after the attach.
|
||||
/// - `.active` with the engine still alive (transient `.inactive`, e.g.
|
||||
/// control center / app switcher peek): `engine.notifyForegrounded(dims:)`
|
||||
/// with `TerminalViewModel.lastSentDims` — reconnect-if-needed + resize
|
||||
/// reclaim (latest-writer-wins). Skipped only when no valid dims were ever
|
||||
/// sent (SwiftTerm has not laid out yet → its first `sizeChanged` covers
|
||||
/// it). The W4 documented deviation is CLOSED (orchestrator added the
|
||||
/// `lastSentDims` accessor on behalf of the T-iOS-11 owner).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TerminalSessionController: Identifiable {
|
||||
let id = UUID()
|
||||
|
||||
/// Bumped on every rebuild; the container view uses it as the SwiftUI
|
||||
/// identity of `TerminalScreen`, forcing a fresh `makeUIView` so the new
|
||||
/// ViewModel's output sink actually attaches to a new SwiftTerm view.
|
||||
private(set) var generation = 0
|
||||
private(set) var terminalViewModel: TerminalViewModel
|
||||
private(set) var gateViewModel: GateViewModel
|
||||
|
||||
/// T-iOS-29 · read-only exposure for the coordinator's new-in-cwd flow
|
||||
/// (the host to reopen against — a controller is host-bound for life).
|
||||
@ObservationIgnored let host: HostRegistry.Host
|
||||
@ObservationIgnored private let environment: AppEnvironment
|
||||
@ObservationIgnored private let onPendingChanged: @MainActor (UUID, Bool) -> Void
|
||||
/// T-iOS-23 · OSC-title outlet (adopted sessionId + SANITIZED title) —
|
||||
/// re-attached to every rebuilt TerminalViewModel so a background→
|
||||
/// foreground rebuild never silently drops the list-title feed.
|
||||
@ObservationIgnored private let onTitleChanged: @MainActor (UUID, String) -> Void
|
||||
/// T-iOS-26 · fresh-spawn 变体:新会话的工作目录(`attach(null, cwd)`)。
|
||||
/// 仅在目标 sessionId 为 nil 时生效 —— 服务器对既有会话忽略 cwd。
|
||||
/// (T-iOS-29 起对 coordinator 只读可见:fresh spawn 未进列表轮询时,
|
||||
/// new-in-cwd 以它为 cwd 回退。)
|
||||
@ObservationIgnored let spawnCwd: String?
|
||||
/// T-iOS-26 · attach 后注入的首条输入(如 `claude\r`)。engine 的
|
||||
/// attach-first 队列语义保证它绝不先于 attach 出线;采纳既有会话 id 后
|
||||
/// 的重开(suspend→resume)不再注入。
|
||||
@ObservationIgnored private let bootstrapInput: String?
|
||||
/// 最近一次 open(+bootstrap) 提交任务 —— 测试屏障(ProjectOpenWiringTests)。
|
||||
@ObservationIgnored private(set) var openTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var engine: SessionEngine
|
||||
@ObservationIgnored private var fanOut: EventFanOut<SessionEvent>
|
||||
@ObservationIgnored private var bridge: SessionActivityBridge
|
||||
/// What the next (re)open attaches to: the server-adopted id once known,
|
||||
/// else the list's requested id (nil = new session).
|
||||
@ObservationIgnored private var targetSessionId: UUID?
|
||||
@ObservationIgnored private var isSuspended = false
|
||||
@ObservationIgnored private var hasStarted = false
|
||||
@ObservationIgnored private var isTornDown = false
|
||||
|
||||
/// Fan-out branch order (single place, no magic indices).
|
||||
private enum Branch {
|
||||
static let terminal = 0
|
||||
static let gate = 1
|
||||
static let activity = 2
|
||||
static let count = 3
|
||||
}
|
||||
|
||||
init(
|
||||
host: HostRegistry.Host,
|
||||
sessionId: UUID?,
|
||||
environment: AppEnvironment,
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void,
|
||||
onTitleChanged: @escaping @MainActor (UUID, String) -> Void = { _, _ in },
|
||||
spawnCwd: String? = nil,
|
||||
bootstrapInput: String? = nil
|
||||
) {
|
||||
self.host = host
|
||||
self.environment = environment
|
||||
self.onPendingChanged = onPendingChanged
|
||||
self.onTitleChanged = onTitleChanged
|
||||
self.spawnCwd = spawnCwd
|
||||
self.bootstrapInput = bootstrapInput
|
||||
self.targetSessionId = sessionId
|
||||
let stack = Self.makeStack(
|
||||
host: host, environment: environment, onPendingChanged: onPendingChanged
|
||||
)
|
||||
engine = stack.engine
|
||||
fanOut = stack.fanOut
|
||||
terminalViewModel = stack.terminalViewModel
|
||||
gateViewModel = stack.gateViewModel
|
||||
bridge = stack.bridge
|
||||
terminalViewModel.onTitleChanged = onTitleChanged
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle entry points
|
||||
|
||||
/// Kick off: consumers first, then the engine connect (events buffer
|
||||
/// unbounded, so this order is belt-and-braces, not a race fix).
|
||||
func start() {
|
||||
guard !hasStarted, !isTornDown else { return }
|
||||
hasStarted = true
|
||||
startConsumers()
|
||||
openEngineAtTarget()
|
||||
}
|
||||
|
||||
/// scenePhase → `.background`: clean detach (PTY keeps running).
|
||||
func suspend() {
|
||||
guard hasStarted, !isSuspended, !isTornDown else { return }
|
||||
isSuspended = true
|
||||
rememberAdoptedSession()
|
||||
stopConsumers()
|
||||
let engine = engine
|
||||
Task { await engine.close() }
|
||||
}
|
||||
|
||||
/// scenePhase → `.active`: rebuild-and-reopen if we suspended; with the
|
||||
/// engine still alive, notifyForegrounded (reconnect + size reclaim).
|
||||
func resumeIfNeeded() {
|
||||
guard !isTornDown else { return }
|
||||
guard isSuspended else {
|
||||
notifyForegroundedIfPossible()
|
||||
return
|
||||
}
|
||||
isSuspended = false
|
||||
rebuildStack()
|
||||
startConsumers()
|
||||
openEngineAtTarget()
|
||||
}
|
||||
|
||||
/// (Re)open 当前目标:既有会话 → 裸 attach;fresh spawn(目标 id 为
|
||||
/// nil)→ 带 cwd attach + attach 后注入 bootstrap(T-iOS-26)。suspend
|
||||
/// 前已采纳的 id 走前一分支,bootstrap 不会重放。
|
||||
private func openEngineAtTarget() {
|
||||
let engine = engine
|
||||
let sessionId = targetSessionId
|
||||
let cwd = sessionId == nil ? spawnCwd : nil
|
||||
let bootstrap = sessionId == nil ? bootstrapInput : nil
|
||||
openTask = Task {
|
||||
await engine.open(sessionId: sessionId, cwd: cwd)
|
||||
if let bootstrap {
|
||||
await engine.send(.input(data: bootstrap))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alive-engine `.active` hop: feed the engine the last VALID dims the
|
||||
/// terminal reported so it reconnects (if dropped during `.inactive`) and
|
||||
/// re-stamps the PTY size (latest-writer-wins reclaims full screen).
|
||||
private func notifyForegroundedIfPossible() {
|
||||
guard hasStarted, let dims = terminalViewModel.lastSentDims else { return }
|
||||
let engine = engine
|
||||
Task { await engine.notifyForegrounded(dims: dims.asTuple) }
|
||||
}
|
||||
|
||||
/// Back navigation / screen dismissed: detach for good. Idempotent.
|
||||
func teardown() {
|
||||
guard !isTornDown else { return }
|
||||
isTornDown = true
|
||||
rememberAdoptedSession()
|
||||
stopConsumers()
|
||||
let engine = engine
|
||||
Task { await engine.close() }
|
||||
}
|
||||
|
||||
// MARK: - Timeline drill-down (T-iOS-24, additive)
|
||||
|
||||
/// Per-host timeline fetcher for the container's `TimelineSheet` — the
|
||||
/// same `APIClient.events` wrapper the engine's away-digest uses; the
|
||||
/// single derivation point stays `AppEnvironment.makeEventsSource`.
|
||||
var timelineEventsSource: @Sendable (UUID) async throws -> [TimelineEvent] {
|
||||
environment.makeEventsSource(endpoint: host.endpoint)
|
||||
}
|
||||
|
||||
// MARK: - Stack assembly
|
||||
|
||||
private struct Stack {
|
||||
let engine: SessionEngine
|
||||
let fanOut: EventFanOut<SessionEvent>
|
||||
let terminalViewModel: TerminalViewModel
|
||||
let gateViewModel: GateViewModel
|
||||
let bridge: SessionActivityBridge
|
||||
}
|
||||
|
||||
private static func makeStack(
|
||||
host: HostRegistry.Host,
|
||||
environment: AppEnvironment,
|
||||
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
|
||||
) -> Stack {
|
||||
let engine = SessionEngine(
|
||||
transport: environment.termTransport,
|
||||
clock: ContinuousClock(),
|
||||
endpoint: host.endpoint,
|
||||
eventsSource: environment.makeEventsSource(endpoint: host.endpoint)
|
||||
)
|
||||
let fanOut = EventFanOut(source: engine.events, branchCount: Branch.count)
|
||||
return Stack(
|
||||
engine: engine,
|
||||
fanOut: fanOut,
|
||||
terminalViewModel: TerminalViewModel(
|
||||
engine: engine, events: fanOut.branches[Branch.terminal]
|
||||
),
|
||||
gateViewModel: GateViewModel(
|
||||
engine: engine, events: fanOut.branches[Branch.gate],
|
||||
haptics: GateHaptics(), clock: ContinuousClock()
|
||||
),
|
||||
bridge: SessionActivityBridge(
|
||||
events: fanOut.branches[Branch.activity], hostId: host.id,
|
||||
lastSessionStore: environment.lastSessionStore,
|
||||
onPendingChanged: onPendingChanged
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func rebuildStack() {
|
||||
fanOut.cancel()
|
||||
let stack = Self.makeStack(
|
||||
host: host, environment: environment, onPendingChanged: onPendingChanged
|
||||
)
|
||||
engine = stack.engine
|
||||
fanOut = stack.fanOut
|
||||
terminalViewModel = stack.terminalViewModel
|
||||
gateViewModel = stack.gateViewModel
|
||||
bridge = stack.bridge
|
||||
terminalViewModel.onTitleChanged = onTitleChanged // rebuilt VM re-wired
|
||||
generation += 1 // new SwiftUI identity → fresh SwiftTerm view
|
||||
}
|
||||
|
||||
private func startConsumers() {
|
||||
terminalViewModel.start()
|
||||
gateViewModel.start()
|
||||
bridge.start()
|
||||
}
|
||||
|
||||
private func stopConsumers() {
|
||||
terminalViewModel.stop()
|
||||
gateViewModel.stop()
|
||||
bridge.stop()
|
||||
}
|
||||
|
||||
/// The reopen target: always prefer the server-adopted id (it may differ
|
||||
/// from what was requested — unknown UUIDs mint new sessions).
|
||||
private func rememberAdoptedSession() {
|
||||
targetSessionId = bridge.adoptedSessionId ?? targetSessionId
|
||||
}
|
||||
}
|
||||
31
ios/App/WebTerm/Wiring/URLSessionHTTPTransport.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
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).
|
||||
struct URLSessionHTTPTransport: HTTPTransport {
|
||||
private let session: URLSession
|
||||
|
||||
/// Default is EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies
|
||||
/// include `/live-sessions/:id/preview` — raw terminal ring-buffer bytes
|
||||
/// that may 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(session: URLSession = URLSession(configuration: .ephemeral)) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
let (data, response) = try await session.data(for: 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.
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
return (data, httpResponse)
|
||||
}
|
||||
}
|
||||
44
ios/App/WebTerm/Wiring/UnreadWatermarkStore.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// T-iOS-23 · Persistence seam for `SessionCore.UnreadLedger` watermarks.
|
||||
/// The ledger itself is persistence-agnostic (plan §7) — the App layer wires
|
||||
/// UserDefaults here. NON-SECRET UI state only (plan §5.3 split: Keychain =
|
||||
/// secrets, UserDefaults = prefs), same tier as `LastSessionStore`.
|
||||
protocol UnreadWatermarkStore: Sendable {
|
||||
func load() -> [UUID: Int]
|
||||
func save(_ watermarks: [UUID: Int])
|
||||
}
|
||||
|
||||
/// UserDefaults-backed implementation with an injectable suite for tests
|
||||
/// (mirrors `UserDefaultsLastSessionStore`). `@unchecked Sendable`:
|
||||
/// `UserDefaults` is documented thread-safe and this wrapper adds no mutable
|
||||
/// state of its own.
|
||||
struct UserDefaultsUnreadWatermarkStore: UnreadWatermarkStore, @unchecked Sendable {
|
||||
private static let key = "unreadWatermarks"
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
func load() -> [UUID: Int] {
|
||||
guard let raw = defaults.dictionary(forKey: Self.key) as? [String: Int] else {
|
||||
return [:]
|
||||
}
|
||||
// Stored data crosses a storage boundary — validate every key; garbage
|
||||
// is dropped, never trusted (two case-variant spellings of one UUID
|
||||
// collapse via max, so this can never crash on duplicates).
|
||||
let pairs = raw.compactMap { key, value in
|
||||
UUID(uuidString: key).map { ($0, value) }
|
||||
}
|
||||
return Dictionary(pairs, uniquingKeysWith: max)
|
||||
}
|
||||
|
||||
func save(_ watermarks: [UUID: Int]) {
|
||||
let plist = Dictionary(
|
||||
uniqueKeysWithValues: watermarks.map { ($0.key.uuidString, $0.value) }
|
||||
)
|
||||
defaults.set(plist, forKey: Self.key)
|
||||
}
|
||||
}
|
||||
65
ios/App/WebTermTests/ColdStartPolicyTests.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-15 · Cold-start selection logic (pure): which root screen boots, and
|
||||
/// whether the session list should highlight "继续上次" for the active host.
|
||||
@MainActor
|
||||
@Suite("ColdStartPolicy")
|
||||
struct ColdStartPolicyTests {
|
||||
private final class StubLastSessionStore: LastSessionStore, @unchecked Sendable {
|
||||
private let stored: [UUID: UUID]
|
||||
init(stored: [UUID: UUID] = [:]) { self.stored = stored }
|
||||
func lastSessionId(host: UUID) -> UUID? { stored[host] }
|
||||
func setLastSessionId(_ id: UUID?, host: UUID) {}
|
||||
}
|
||||
|
||||
private static func makeHost(name: String = "mac") throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
// MARK: - Root route: no paired host → Pairing; else SessionList
|
||||
|
||||
@Test("无配对 host → 冷启动进 Pairing")
|
||||
func noHostsBootsIntoPairing() {
|
||||
#expect(ColdStartPolicy.initialRoute(pairedHostCount: 0) == .pairing)
|
||||
}
|
||||
|
||||
@Test("有配对 host → 冷启动进 SessionList")
|
||||
func pairedHostsBootIntoSessionList() {
|
||||
#expect(ColdStartPolicy.initialRoute(pairedHostCount: 1) == .sessions)
|
||||
#expect(ColdStartPolicy.initialRoute(pairedHostCount: 3) == .sessions)
|
||||
}
|
||||
|
||||
// MARK: - "继续上次" highlight
|
||||
|
||||
@Test("active host 存有 lastSessionId → 给出继续上次目标")
|
||||
func continueLastReturnsStoredSession() throws {
|
||||
let host = try Self.makeHost()
|
||||
let sessionId = UUID()
|
||||
let store = StubLastSessionStore(stored: [host.id: sessionId])
|
||||
|
||||
let target = ColdStartPolicy.continueLastSessionId(activeHost: host, store: store)
|
||||
|
||||
#expect(target == sessionId)
|
||||
}
|
||||
|
||||
@Test("该 host 无记录 → 无继续上次高亮")
|
||||
func continueLastNilWithoutRecord() throws {
|
||||
let host = try Self.makeHost()
|
||||
let other = UUID()
|
||||
let store = StubLastSessionStore(stored: [other: UUID()])
|
||||
|
||||
#expect(ColdStartPolicy.continueLastSessionId(activeHost: host, store: store) == nil)
|
||||
}
|
||||
|
||||
@Test("尚无 active host(hosts 未加载/为空)→ 无高亮")
|
||||
func continueLastNilWithoutActiveHost() {
|
||||
let store = StubLastSessionStore()
|
||||
#expect(ColdStartPolicy.continueLastSessionId(activeHost: nil, store: store) == nil)
|
||||
}
|
||||
}
|
||||
338
ios/App/WebTermTests/DeepLinkRouterTests.swift
Normal file
@@ -0,0 +1,338 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-22 · Deep-link routing: `webterminal://open?host=<uuid>&join=<uuid>`
|
||||
/// 解析(全字段白名单,任一非法 → .ignore,绝不部分应用)、WEBTERM_GATE push
|
||||
/// payload 的同源校验入口,以及 DeepLinkHandler 的冷启动 stash / 未知 host
|
||||
/// 落配对页 / 忽略计数。
|
||||
@MainActor
|
||||
@Suite("DeepLinkRouter")
|
||||
struct DeepLinkRouterTests {
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// v4 UUID(version nibble=4、variant=8)——通过 Validation.isValidSessionId。
|
||||
private static let validHostId = "11111111-2222-4333-8444-555555555555"
|
||||
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||
/// version nibble=1:Foundation 的 UUID(uuidString:) 接受,但服务器的
|
||||
/// SESSION_ID_RE(v4 专属)拒绝——用它钉住"复用 Validation、不再造正则"。
|
||||
private static let v1StyleId = "11111111-2222-1333-8444-555555555555"
|
||||
/// variant nibble=c(合法 v4 要求 8/9/a/b)。
|
||||
private static let badVariantId = "11111111-2222-4333-c444-555555555555"
|
||||
|
||||
private static func url(_ string: String) throws -> URL {
|
||||
try #require(URL(string: string))
|
||||
}
|
||||
|
||||
private static func openURL(
|
||||
host: String = validHostId, join: String = validSessionId
|
||||
) throws -> URL {
|
||||
try url("webterminal://open?host=\(host)&join=\(join)")
|
||||
}
|
||||
|
||||
private static func makeHost(id: UUID) throws -> HostRegistry.Host {
|
||||
let base = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: base))
|
||||
return HostRegistry.Host(id: id, name: "mac", endpoint: endpoint)
|
||||
}
|
||||
|
||||
// MARK: - URL 解析:合法路径
|
||||
|
||||
@Test("合法 open 链接 → .openSession(hostId, sessionId)")
|
||||
func validLinkRoutes() throws {
|
||||
let route = DeepLinkRouter.route(url: try Self.openURL())
|
||||
|
||||
let hostId = try #require(UUID(uuidString: Self.validHostId))
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("大写 UUID → 接受(Validation 大小写不敏感)")
|
||||
func uppercaseUUIDsAccepted() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.openURL(
|
||||
host: Self.validHostId.uppercased(),
|
||||
join: Self.validSessionId.uppercased()
|
||||
)
|
||||
)
|
||||
|
||||
let hostId = try #require(UUID(uuidString: Self.validHostId))
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("未知多余 query 键 → 忽略键本身,链接仍路由")
|
||||
func unknownExtraKeysIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url(
|
||||
"webterminal://open?host=\(Self.validHostId)&join=\(Self.validSessionId)&utm=x&foo=bar"
|
||||
)
|
||||
)
|
||||
|
||||
let hostId = try #require(UUID(uuidString: Self.validHostId))
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
|
||||
}
|
||||
|
||||
// MARK: - URL 解析:白名单拒绝路径(任一非法 → .ignore)
|
||||
|
||||
@Test("scheme 非 webterminal → .ignore")
|
||||
func wrongSchemeIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)")
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("action 非 open → .ignore")
|
||||
func wrongActionIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("webterminal://kill?host=\(Self.validHostId)&join=\(Self.validSessionId)")
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("带非空 path → .ignore(白名单外的形状)")
|
||||
func extraPathIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url(
|
||||
"webterminal://open/extra?host=\(Self.validHostId)&join=\(Self.validSessionId)"
|
||||
)
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("缺 host 键 → .ignore")
|
||||
func missingHostKeyIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("webterminal://open?join=\(Self.validSessionId)")
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("缺 join 键 → .ignore")
|
||||
func missingJoinKeyIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("webterminal://open?host=\(Self.validHostId)")
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("重复 host 键 → .ignore(歧义输入绝不部分应用)")
|
||||
func duplicateHostKeyIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url(
|
||||
"webterminal://open?host=\(Self.validHostId)&host=\(Self.validHostId)&join=\(Self.validSessionId)"
|
||||
)
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("UUID 非 v4(version nibble=1)→ .ignore:钉住复用 Validation")
|
||||
func nonV4UUIDIgnored() throws {
|
||||
#expect(DeepLinkRouter.route(url: try Self.openURL(host: Self.v1StyleId)) == .ignore)
|
||||
#expect(DeepLinkRouter.route(url: try Self.openURL(join: Self.v1StyleId)) == .ignore)
|
||||
}
|
||||
|
||||
@Test("variant nibble 非 8/9/a/b → .ignore")
|
||||
func badVariantUUIDIgnored() throws {
|
||||
#expect(DeepLinkRouter.route(url: try Self.openURL(join: Self.badVariantId)) == .ignore)
|
||||
}
|
||||
|
||||
@Test("空值 / 垃圾值 / 空 query → .ignore,不 crash")
|
||||
func fuzzedValuesIgnored() throws {
|
||||
#expect(DeepLinkRouter.route(url: try Self.url("webterminal://open?host=&join=")) == .ignore)
|
||||
#expect(DeepLinkRouter.route(url: try Self.url("webterminal://open")) == .ignore)
|
||||
#expect(DeepLinkRouter.route(url: try Self.openURL(host: "not-a-uuid")) == .ignore)
|
||||
#expect(
|
||||
DeepLinkRouter.route(url: try Self.openURL(join: "'; DROP TABLE sessions;--")) == .ignore
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Push payload(WEBTERM_GATE,T-iOS-21 复用同一路由)
|
||||
|
||||
@Test("payload 含合法 sessionId(多余键忽略)→ .gateSession")
|
||||
func gatePayloadRoutes() throws {
|
||||
let payload: [AnyHashable: Any] = [
|
||||
"sessionId": Self.validSessionId,
|
||||
"cls": "gate",
|
||||
"token": "opaque",
|
||||
"aps": ["category": "WEBTERM_GATE"],
|
||||
]
|
||||
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(DeepLinkRouter.route(from: payload) == .gateSession(sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("payload 缺 sessionId / 非字符串 / 非 v4 → .ignore")
|
||||
func gatePayloadRejectsInvalid() {
|
||||
#expect(DeepLinkRouter.route(from: [:]) == .ignore)
|
||||
#expect(DeepLinkRouter.route(from: ["sessionId": 42]) == .ignore)
|
||||
#expect(DeepLinkRouter.route(from: ["sessionId": Self.v1StyleId]) == .ignore)
|
||||
}
|
||||
}
|
||||
|
||||
/// DeepLinkHandler:热路径直达、冷启动 stash、未知 host 落配对、忽略计数。
|
||||
@MainActor
|
||||
@Suite("DeepLinkHandler")
|
||||
struct DeepLinkHandlerTests {
|
||||
private enum StubError: Error { case storeFailure }
|
||||
|
||||
/// 记录 handler 触发的动作(测试替身;生产侧由 AppCoordinator 提供闭包)。
|
||||
@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(id: UUID = UUID()) throws -> HostRegistry.Host {
|
||||
let base = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: base))
|
||||
return HostRegistry.Host(id: id, name: "mac", endpoint: endpoint)
|
||||
}
|
||||
|
||||
private static func openURL(hostId: UUID, sessionId: UUID) throws -> URL {
|
||||
try #require(URL(
|
||||
string: "webterminal://open?host=\(hostId.uuidString)&join=\(sessionId.uuidString)"
|
||||
))
|
||||
}
|
||||
|
||||
/// UUID() 是 v4,但 uuidString 是大写——直接可作合法链接值。
|
||||
private static func sessionId() -> UUID { UUID() }
|
||||
|
||||
// MARK: - 热路径(app 已就绪)
|
||||
|
||||
@Test("热路径:已知 host → openSession(host, sessionId),无 hint")
|
||||
func warmKnownHostOpens() async throws {
|
||||
let host = try Self.makeHost()
|
||||
let sessionId = Self.sessionId()
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: sessionId))
|
||||
|
||||
#expect(recorder.opened.count == 1)
|
||||
#expect(recorder.opened.first?.host == host)
|
||||
#expect(recorder.opened.first?.sessionId == sessionId)
|
||||
#expect(recorder.pairingShownCount == 0)
|
||||
#expect(handler.hintMessage == nil)
|
||||
}
|
||||
|
||||
@Test("未知 host id(UUID 合法但不在 store)→ showPairing + 提示文案")
|
||||
func unknownHostRoutesToPairing() async throws {
|
||||
let stored = try Self.makeHost()
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [stored] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(
|
||||
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
|
||||
)
|
||||
|
||||
#expect(recorder.opened.isEmpty)
|
||||
#expect(recorder.pairingShownCount == 1)
|
||||
#expect(handler.hintMessage == DeepLinkCopy.unknownHostHint)
|
||||
}
|
||||
|
||||
@Test("host store 读失败 → 显式错误文案,不 showPairing、不 crash")
|
||||
func storeFailureSurfacesExplicitCopy() async throws {
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(
|
||||
loadHosts: { throw StubError.storeFailure },
|
||||
actions: recorder.actions
|
||||
)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(
|
||||
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
|
||||
)
|
||||
|
||||
#expect(recorder.opened.isEmpty)
|
||||
#expect(recorder.pairingShownCount == 0)
|
||||
#expect(handler.hintMessage == DeepLinkCopy.hostLoadFailed)
|
||||
}
|
||||
|
||||
// MARK: - 非法链接:计数 + 不入 stash
|
||||
|
||||
@Test("非法链接 → ignoredCount+1,无任何动作,也不入 stash")
|
||||
func invalidLinkCountedNeverStashed() async throws {
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
|
||||
|
||||
await handler.handle(url: try #require(URL(string: "https://evil.example/?host=x")))
|
||||
#expect(handler.ignoredCount == 1)
|
||||
|
||||
await handler.markReady() // ready 后 stash 若被污染会在此触发动作
|
||||
|
||||
#expect(recorder.opened.isEmpty)
|
||||
#expect(recorder.pairingShownCount == 0)
|
||||
#expect(handler.hintMessage == nil)
|
||||
}
|
||||
|
||||
// MARK: - 冷启动 stash
|
||||
|
||||
@Test("冷启动:ready 前 handle → 挂起;markReady → 恰好应用一次")
|
||||
func coldLaunchStashAppliesOnReady() async throws {
|
||||
let host = try Self.makeHost()
|
||||
let sessionId = Self.sessionId()
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
|
||||
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: sessionId))
|
||||
#expect(recorder.opened.isEmpty) // 未就绪:不得提前应用
|
||||
|
||||
await handler.markReady()
|
||||
#expect(recorder.opened.count == 1)
|
||||
#expect(recorder.opened.first?.sessionId == sessionId)
|
||||
|
||||
await handler.markReady() // 幂等:stash 已清空,不得重放
|
||||
#expect(recorder.opened.count == 1)
|
||||
}
|
||||
|
||||
@Test("ready 前连续两条链接 → 只应用最新一条(单槽 stash)")
|
||||
func stashKeepsOnlyLatestLink() async throws {
|
||||
let host = try Self.makeHost()
|
||||
let first = Self.sessionId()
|
||||
let second = Self.sessionId()
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
|
||||
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: first))
|
||||
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: second))
|
||||
await handler.markReady()
|
||||
|
||||
#expect(recorder.opened.count == 1)
|
||||
#expect(recorder.opened.first?.sessionId == second)
|
||||
}
|
||||
|
||||
// MARK: - hint 生命周期
|
||||
|
||||
@Test("clearHint → hintMessage 归位 nil")
|
||||
func clearHintResets() async throws {
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
await handler.handle(
|
||||
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
|
||||
)
|
||||
#expect(handler.hintMessage != nil)
|
||||
|
||||
handler.clearHint()
|
||||
|
||||
#expect(handler.hintMessage == nil)
|
||||
}
|
||||
}
|
||||
158
ios/App/WebTermTests/DesignSystemTests.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// UX-A · Proves the FROZEN design system: the status mapping is complete and
|
||||
/// "color + shape" (never color alone), the token scales are well-formed, and
|
||||
/// the adaptive accent resolves distinctly in light vs dark. Pure logic — runs
|
||||
/// on both iPhone and iPad sims with no rendering.
|
||||
@Suite("DesignSystem")
|
||||
struct DesignSystemTests {
|
||||
|
||||
// MARK: - StatusStyle: complete, distinct, color + shape
|
||||
|
||||
@Test("every status maps to a non-empty symbol + Chinese label")
|
||||
func everyStatusHasSymbolAndLabel() {
|
||||
for status in DisplayStatus.allCases {
|
||||
let style = StatusStyle.style(for: status)
|
||||
#expect(!style.symbolName.isEmpty, "\(status) has no symbol")
|
||||
#expect(!style.label.isEmpty, "\(status) has no label")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("status is never color-alone — all seven symbols are distinct")
|
||||
func symbolsAreDistinctAcrossStatuses() {
|
||||
let symbols = DisplayStatus.allCases.map { StatusStyle.style(for: $0).symbolName }
|
||||
#expect(Set(symbols).count == DisplayStatus.allCases.count)
|
||||
}
|
||||
|
||||
@Test("Chinese labels are all distinct")
|
||||
func labelsAreDistinct() {
|
||||
let labels = DisplayStatus.allCases.map { StatusStyle.style(for: $0).label }
|
||||
#expect(Set(labels).count == DisplayStatus.allCases.count)
|
||||
}
|
||||
|
||||
@Test("the critical trio working/waiting/stuck use distinct colors")
|
||||
func criticalColorsAreDistinct() {
|
||||
let working = rgba(StatusStyle.style(for: DisplayStatus.working).color)
|
||||
let waiting = rgba(StatusStyle.style(for: DisplayStatus.waiting).color)
|
||||
let stuck = rgba(StatusStyle.style(for: DisplayStatus.stuck).color)
|
||||
#expect(!approxEqual(working, waiting))
|
||||
#expect(!approxEqual(working, stuck))
|
||||
#expect(!approxEqual(waiting, stuck))
|
||||
}
|
||||
|
||||
@Test("semantic status colors match the desktop/web status palette")
|
||||
func statusColorsMatchDirection() {
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.working).color, r: 70, g: 208, b: 127) // #46D07F web --green
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.waiting).color, r: 245, g: 177, b: 76) // #F5B14C web --amber
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.stuck).color, r: 255, g: 107, b: 107) // #FF6B6B web --red
|
||||
}
|
||||
|
||||
@Test("the wire ClaudeStatus bridge covers all five cases")
|
||||
func wireBridgeCoversEveryCase() {
|
||||
let pairs: [(ClaudeStatus, DisplayStatus)] = [
|
||||
(.working, .working), (.waiting, .waiting), (.idle, .idle),
|
||||
(.stuck, .stuck), (.unknown, .unknown),
|
||||
]
|
||||
for (wire, expected) in pairs {
|
||||
#expect(DisplayStatus(wire) == expected)
|
||||
#expect(StatusStyle.style(for: wire) == StatusStyle.style(for: expected))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token scales are well-formed
|
||||
|
||||
@Test("spacing scale is exactly 2·4·8·12·16·20·24 and strictly increasing")
|
||||
func spacingScaleIsMonotonic() {
|
||||
let scale: [CGFloat] = [
|
||||
DS.Space.xs2, DS.Space.xs4, DS.Space.sm8,
|
||||
DS.Space.md12, DS.Space.lg16, DS.Space.xl20, DS.Space.xxl24,
|
||||
]
|
||||
#expect(scale == [2, 4, 8, 12, 16, 20, 24])
|
||||
#expect(isStrictlyIncreasing(scale))
|
||||
}
|
||||
|
||||
@Test("radius scale is strictly increasing sm8 < md12 < lg16 < pill")
|
||||
func radiusScaleIsMonotonic() {
|
||||
let scale: [CGFloat] = [DS.Radius.sm8, DS.Radius.md12, DS.Radius.lg16, DS.Radius.pill]
|
||||
#expect(scale == [8, 12, 16, 999])
|
||||
#expect(isStrictlyIncreasing(scale))
|
||||
}
|
||||
|
||||
@Test("opacity dimming tokens are within (0, 1)")
|
||||
func opacityTokensAreFractions() {
|
||||
for value in [DS.Opacity.stale, DS.Opacity.exited, DS.Opacity.pressed] {
|
||||
#expect(value > 0 && value < 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("minimum hit target meets the 44pt HIG floor")
|
||||
func hitTargetMeetsHIG() {
|
||||
#expect(DS.Layout.minHitTarget >= 44)
|
||||
}
|
||||
|
||||
// MARK: - Motion honors Reduce Motion
|
||||
|
||||
@Test("motion collapses to nil under Reduce Motion, animates otherwise")
|
||||
func motionGating() {
|
||||
#expect(DS.Motion.gated(DS.Motion.base, reduceMotion: true) == nil)
|
||||
#expect(DS.Motion.gated(DS.Motion.base, reduceMotion: false) != nil)
|
||||
#expect(DS.Motion.fastDuration < DS.Motion.baseDuration)
|
||||
}
|
||||
|
||||
// MARK: - Adaptive accent resolves for both schemes
|
||||
|
||||
@Test("accent is defined and distinct in light vs dark")
|
||||
func accentIsAdaptive() {
|
||||
let accent = DS.Palette.accentUIColor()
|
||||
let dark = accent.resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark))
|
||||
let light = accent.resolvedColor(with: UITraitCollection(userInterfaceStyle: .light))
|
||||
|
||||
let darkRGBA = rgba(dark)
|
||||
let lightRGBA = rgba(light)
|
||||
// Both fully opaque, and visibly different between schemes.
|
||||
#expect(darkRGBA.a == 1)
|
||||
#expect(lightRGBA.a == 1)
|
||||
#expect(!approxEqual(darkRGBA, lightRGBA))
|
||||
// Desktop amber gold: dark ≈ #E3A64A (--accent), light ≈ #C9892F (--accent-2).
|
||||
assertRGBA(darkRGBA, r: 0xE3, g: 0xA6, b: 0x4A)
|
||||
assertRGBA(lightRGBA, r: 0xC9, g: 0x89, b: 0x2F)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private typealias RGBA = (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat)
|
||||
|
||||
private func isStrictlyIncreasing(_ values: [CGFloat]) -> Bool {
|
||||
zip(values, values.dropFirst()).allSatisfy { $0 < $1 }
|
||||
}
|
||||
|
||||
private func rgba(_ color: Color) -> RGBA {
|
||||
rgba(UIColor(color))
|
||||
}
|
||||
|
||||
private 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)
|
||||
}
|
||||
|
||||
private func approxEqual(_ lhs: RGBA, _ rhs: RGBA, tolerance: CGFloat = 0.02) -> Bool {
|
||||
abs(lhs.r - rhs.r) < tolerance
|
||||
&& abs(lhs.g - rhs.g) < tolerance
|
||||
&& abs(lhs.b - rhs.b) < tolerance
|
||||
}
|
||||
|
||||
private func assertColor(_ color: Color, r: Int, g: Int, b: Int) {
|
||||
assertRGBA(rgba(color), r: r, g: g, b: b)
|
||||
}
|
||||
|
||||
private func assertRGBA(_ value: RGBA, r: Int, g: Int, b: Int, tolerance: CGFloat = 0.02) {
|
||||
#expect(abs(value.r - CGFloat(r) / 255) < tolerance)
|
||||
#expect(abs(value.g - CGFloat(g) / 255) < tolerance)
|
||||
#expect(abs(value.b - CGFloat(b) / 255) < tolerance)
|
||||
}
|
||||
}
|
||||
250
ios/App/WebTermTests/DiffFetcherTests.swift
Normal file
@@ -0,0 +1,250 @@
|
||||
import Foundation
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-27 · Diff 查看器 —— `DiffFetcher`(App 层,GET /projects/diff)。
|
||||
///
|
||||
/// 覆盖面(Steps 测试先行):
|
||||
/// - 请求形状:GET + path/staged 严格百分号编码 + **无 Origin**(RO 端点,
|
||||
/// §3.4 iff-G 铁律;服务器路由无 requireAllowedOrigin,src/server.ts:601-618);
|
||||
/// - `staged` 序列化为 `1`/`0` —— 服务器精确匹配 `=== '1'`(src/server.ts:613);
|
||||
/// - `DiffResult{files,staged,truncated}` 宽容解码(镜像 public/diff.ts
|
||||
/// normalizeDiffResult:顶层三键缺一 → 整体无效;畸形 file/hunk/line 逐条
|
||||
/// 丢弃;未知 kind → .context、未知 status → .modified——服务器是不可信输入源);
|
||||
/// - 400 → .pathInvalid、404 → .projectNotFound(SEC-H7 三叉校验失败)、
|
||||
/// 其余状态 → .unexpectedStatus;
|
||||
/// - 空 path 客户端先拒(镜像服务器 400 规则),零网络。
|
||||
struct DiffFetcherTests {
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static func makeEndpoint() throws -> HostEndpoint {
|
||||
let url = try #require(URL(string: "http://127.0.0.1:3000"))
|
||||
return try #require(HostEndpoint(baseURL: url))
|
||||
}
|
||||
|
||||
private static func makeFetcher() throws -> (DiffFetcher, FakeHTTPTransport) {
|
||||
let fake = FakeHTTPTransport()
|
||||
let fetcher = DiffFetcher(endpoint: try makeEndpoint(), http: fake)
|
||||
return (fetcher, fake)
|
||||
}
|
||||
|
||||
private static func url(_ s: String) throws -> URL {
|
||||
try #require(URL(string: s))
|
||||
}
|
||||
|
||||
/// 一份完整合法响应体(modified 文件 + 1 hunk + 4 行,覆盖 4 种 kind)。
|
||||
private static let fullBody = Data("""
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"oldPath": "src/a.ts", "newPath": "src/a.ts", "status": "modified",
|
||||
"added": 2, "removed": 1, "binary": false,
|
||||
"hunks": [
|
||||
{ "header": "@@ -1,3 +1,4 @@",
|
||||
"lines": [
|
||||
{ "kind": "context", "text": "unchanged" },
|
||||
{ "kind": "removed", "text": "old line" },
|
||||
{ "kind": "added", "text": "new line" },
|
||||
{ "kind": "meta", "text": "No newline at end of file" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
],
|
||||
"staged": false,
|
||||
"truncated": false
|
||||
}
|
||||
""".utf8)
|
||||
|
||||
// MARK: - 请求形状
|
||||
|
||||
@Test("GET /projects/diff?path=<enc>&staged=0,无 Origin(RO 端点,iff-G 铁律)")
|
||||
func requestShapeIsReadOnlyGetWithoutOrigin() async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||||
)
|
||||
await fake.queueSuccess(url: expected, body: Self.fullBody)
|
||||
|
||||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||||
|
||||
let recorded = await fake.recordedRequests
|
||||
let request = try #require(recorded.first)
|
||||
#expect(recorded.count == 1)
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.url == expected)
|
||||
// RO 一律不带 Origin:服务器若把该端点改为 G,这里先红(§3.4 铁律)。
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
|
||||
}
|
||||
|
||||
@Test("staged=true → staged=1(服务器 === '1' 精确匹配,src/server.ts:613)")
|
||||
func stagedSerializesAsOne() async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=1"
|
||||
)
|
||||
await fake.queueSuccess(url: expected, body: Self.fullBody)
|
||||
|
||||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: true)
|
||||
|
||||
let recorded = await fake.recordedRequests
|
||||
#expect(recorded.first?.url == expected)
|
||||
}
|
||||
|
||||
@Test("path 严格百分号编码:空格/&/+/= 全部转义(unreserved-only 集)")
|
||||
func pathIsStrictlyPercentEncoded() async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
// " " → %20、"&" → %26、"+" → %2B、"=" → %3D、"/" → %2F
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Fa%20b%26c%2Bd%3De&staged=0"
|
||||
)
|
||||
await fake.queueSuccess(url: expected, body: Self.fullBody)
|
||||
|
||||
_ = try await fetcher.fetch(path: "/tmp/a b&c+d=e", staged: false)
|
||||
|
||||
let recorded = await fake.recordedRequests
|
||||
#expect(recorded.first?.url == expected)
|
||||
}
|
||||
|
||||
@Test("空 path → .invalidRequest,零网络(镜像服务器 400 规则,先于 I/O 拒绝)")
|
||||
func emptyPathRejectedBeforeNetwork() async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
|
||||
await #expect(throws: DiffFetchError.invalidRequest) {
|
||||
_ = try await fetcher.fetch(path: "", staged: false)
|
||||
}
|
||||
|
||||
let recorded = await fake.recordedRequests
|
||||
#expect(recorded.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 解码(服务器为不可信输入源)
|
||||
|
||||
@Test("200 完整体 → DiffResult 各字段逐一到位")
|
||||
func decodesFullBody() async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||||
)
|
||||
await fake.queueSuccess(url: expected, body: Self.fullBody)
|
||||
|
||||
let result = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||||
|
||||
#expect(result.staged == false)
|
||||
#expect(result.truncated == false)
|
||||
#expect(result.files.count == 1)
|
||||
let file = try #require(result.files.first)
|
||||
#expect(file.newPath == "src/a.ts")
|
||||
#expect(file.status == .modified)
|
||||
#expect(file.added == 2)
|
||||
#expect(file.removed == 1)
|
||||
#expect(file.binary == false)
|
||||
let hunk = try #require(file.hunks.first)
|
||||
#expect(hunk.header == "@@ -1,3 +1,4 @@")
|
||||
#expect(hunk.lines.map(\.kind) == [.context, .removed, .added, .meta])
|
||||
#expect(hunk.lines.map(\.text) == [
|
||||
"unchanged", "old line", "new line", "No newline at end of file",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("宽容解码:畸形 file 条目丢弃、未知 kind → .context、未知 status → .modified")
|
||||
func tolerantDecodingDropsMalformedEntries() async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let body = Data("""
|
||||
{
|
||||
"files": [
|
||||
42,
|
||||
{ "oldPath": 1, "newPath": "x" },
|
||||
{
|
||||
"oldPath": "u.txt", "newPath": "u.txt", "status": "exotic-future",
|
||||
"added": 0, "removed": 0, "binary": false,
|
||||
"hunks": [
|
||||
{ "lines": [] },
|
||||
{ "header": "@@ -1 +1 @@",
|
||||
"lines": [
|
||||
{ "kind": "sparkle", "text": "mystery" },
|
||||
{ "kind": "added" },
|
||||
{ "kind": "added", "text": "kept" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
],
|
||||
"staged": true,
|
||||
"truncated": true
|
||||
}
|
||||
""".utf8)
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=1"
|
||||
)
|
||||
await fake.queueSuccess(url: expected, body: body)
|
||||
|
||||
let result = try await fetcher.fetch(path: "/tmp/repo", staged: true)
|
||||
|
||||
#expect(result.truncated == true)
|
||||
#expect(result.files.count == 1) // 42 与缺字段对象被丢弃
|
||||
let file = try #require(result.files.first)
|
||||
#expect(file.status == .modified) // 未知 status 降级
|
||||
#expect(file.hunks.count == 1) // 缺 header 的 hunk 被丢弃
|
||||
let lines = try #require(file.hunks.first).lines
|
||||
#expect(lines.count == 2) // 缺 text 的行被丢弃
|
||||
#expect(lines.first?.kind == .context) // 未知 kind 降级(同 web df-context 兜底)
|
||||
#expect(lines.last?.text == "kept")
|
||||
}
|
||||
|
||||
@Test("顶层畸形(缺键 / 非对象 / 非 JSON)→ .invalidResponse", arguments: [
|
||||
#"{"staged": false, "truncated": false}"#,
|
||||
#"[1, 2, 3]"#,
|
||||
"not json at all",
|
||||
])
|
||||
func malformedTopLevelIsInvalidResponse(raw: String) async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||||
)
|
||||
await fake.queueSuccess(url: expected, body: Data(raw.utf8))
|
||||
|
||||
await #expect(throws: DiffFetchError.invalidResponse) {
|
||||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 错误状态映射(route 层语义,src/server.ts:602-618)
|
||||
|
||||
@Test("400 → .pathInvalid、404 → .projectNotFound、500 → .unexpectedStatus(500)")
|
||||
func statusCodesMapToTypedErrors() async throws {
|
||||
let cases: [(Int, DiffFetchError)] = [
|
||||
(400, .pathInvalid),
|
||||
(404, .projectNotFound),
|
||||
(500, .unexpectedStatus(500)),
|
||||
(403, .unexpectedStatus(403)),
|
||||
]
|
||||
for (status, expectedError) in cases {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||||
)
|
||||
await fake.queueSuccess(
|
||||
url: expected, status: status, body: Data(#"{"error":"x"}"#.utf8)
|
||||
)
|
||||
|
||||
await #expect(throws: expectedError) {
|
||||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("传输层错误原样上抛(不吞、不错误分类)")
|
||||
func transportErrorsPropagate() async throws {
|
||||
struct Boom: Error {}
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||||
)
|
||||
await fake.queueFailure(url: expected, error: Boom())
|
||||
|
||||
await #expect(throws: Boom.self) {
|
||||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
242
ios/App/WebTermTests/DiffViewModelTests.swift
Normal file
@@ -0,0 +1,242 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-27 · Diff 查看器 —— `DiffViewModel`(phase 状态机 + 行平铺)。
|
||||
///
|
||||
/// 覆盖面(Steps 测试先行):
|
||||
/// - `DiffResult{files,staged,truncated}` → 呈现:文件头 → hunk 头 → 行,
|
||||
/// 平铺为惰性列表的行模型(巨 diff 不合成单个 Text 块);
|
||||
/// - truncated → banner 数据位(空态与非空态都要携带);
|
||||
/// - staged/unstaged 切换 → 以新 staged 重新 fetch;同值不重复请求;
|
||||
/// - path 非法(400/404)→ 显式友好错误 phase,其余错误 → 可重试的 unavailable;
|
||||
/// - binary 文件短路(镜像 web renderDiffFile 的 early return)、renamed 路径标签。
|
||||
@MainActor
|
||||
@Suite("DiffViewModel")
|
||||
struct DiffViewModelTests {
|
||||
// MARK: - Fixtures
|
||||
|
||||
private nonisolated static func line(_ kind: DiffLineKind, _ text: String) -> DiffLine {
|
||||
DiffLine(kind: kind, text: text)
|
||||
}
|
||||
|
||||
private nonisolated static func modifiedFile() -> DiffFile {
|
||||
DiffFile(
|
||||
oldPath: "src/a.ts", newPath: "src/a.ts", status: .modified,
|
||||
added: 1, removed: 1, binary: false,
|
||||
hunks: [DiffHunk(header: "@@ -1,2 +1,2 @@", lines: [
|
||||
line(.removed, "old"),
|
||||
line(.added, "new"),
|
||||
])]
|
||||
)
|
||||
}
|
||||
|
||||
private nonisolated static func result(
|
||||
files: [DiffFile], staged: Bool = false, truncated: Bool = false
|
||||
) -> DiffResult {
|
||||
DiffResult(files: files, staged: staged, truncated: truncated)
|
||||
}
|
||||
|
||||
/// 记录每次 fetch 收到的 staged 参数(@Sendable 闭包内可变状态 → actor)。
|
||||
private actor FetchRecorder {
|
||||
private(set) var stagedArgs: [Bool] = []
|
||||
func record(_ staged: Bool) { stagedArgs = stagedArgs + [staged] }
|
||||
}
|
||||
|
||||
// MARK: - Phase 状态机
|
||||
|
||||
@Test("初始:phase = .loading、staged = false(默认工作区视图)")
|
||||
func initialState() {
|
||||
let vm = DiffViewModel(fetch: { _ in Self.result(files: []) })
|
||||
|
||||
#expect(vm.phase == .loading)
|
||||
#expect(vm.staged == false)
|
||||
}
|
||||
|
||||
@Test("load 成功(非空)→ .loaded,行序:文件头 → hunk 头 → 行,id 稳定递增")
|
||||
func loadFlattensRowsInOrder() async throws {
|
||||
let vm = DiffViewModel(fetch: { _ in Self.result(files: [Self.modifiedFile()]) })
|
||||
|
||||
await vm.load()
|
||||
|
||||
guard case .loaded(let presentation) = vm.phase else {
|
||||
Issue.record("期望 .loaded,实际 \(vm.phase)")
|
||||
return
|
||||
}
|
||||
#expect(presentation.truncated == false)
|
||||
#expect(presentation.rows.map(\.id) == [0, 1, 2, 3]) // Identifiable:惰性列表身份
|
||||
#expect(presentation.rows.map(\.kind) == [
|
||||
.fileHeader(DiffFileHeader(
|
||||
pathLabel: "src/a.ts", added: 1, removed: 1, status: .modified
|
||||
)),
|
||||
.hunkHeader("@@ -1,2 +1,2 @@"),
|
||||
.line(kind: .removed, text: "old"),
|
||||
.line(kind: .added, text: "new"),
|
||||
])
|
||||
}
|
||||
|
||||
@Test("空 files → .empty(truncated 数据位透传)", arguments: [false, true])
|
||||
func emptyFilesBecomeEmptyPhase(truncated: Bool) async {
|
||||
let vm = DiffViewModel(fetch: { _ in
|
||||
Self.result(files: [], truncated: truncated)
|
||||
})
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .empty(truncated: truncated))
|
||||
}
|
||||
|
||||
@Test("truncated + 非空 → .loaded 且 presentation.truncated = true(banner 提示位)")
|
||||
func truncatedFlagSurvivesIntoPresentation() async throws {
|
||||
let vm = DiffViewModel(fetch: { _ in
|
||||
Self.result(files: [Self.modifiedFile()], truncated: true)
|
||||
})
|
||||
|
||||
await vm.load()
|
||||
|
||||
guard case .loaded(let presentation) = vm.phase else {
|
||||
Issue.record("期望 .loaded,实际 \(vm.phase)")
|
||||
return
|
||||
}
|
||||
#expect(presentation.truncated == true)
|
||||
}
|
||||
|
||||
// MARK: - 行平铺规则(镜像 web renderDiffFile)
|
||||
|
||||
@Test("binary 文件:文件头 + 二进制占位,hunk 全部短路(web early return 同款)")
|
||||
func binaryFileShortCircuitsHunks() {
|
||||
let binary = DiffFile(
|
||||
oldPath: "logo.png", newPath: "logo.png", status: .binary,
|
||||
added: 0, removed: 0, binary: true,
|
||||
hunks: [DiffHunk(header: "@@ junk @@", lines: [Self.line(.added, "x")])]
|
||||
)
|
||||
|
||||
let rows = DiffViewModel.makeRows(files: [binary])
|
||||
|
||||
#expect(rows.map(\.kind) == [
|
||||
.fileHeader(DiffFileHeader(
|
||||
pathLabel: "logo.png", added: 0, removed: 0, status: .binary
|
||||
)),
|
||||
.binaryNotice,
|
||||
])
|
||||
}
|
||||
|
||||
@Test("renamed 且新旧路径不同 → 路径标签 “old → new”(web 同款箭头)")
|
||||
func renamedFileShowsArrowLabel() {
|
||||
let renamed = DiffFile(
|
||||
oldPath: "old/name.ts", newPath: "new/name.ts", status: .renamed,
|
||||
added: 0, removed: 0, binary: false, hunks: []
|
||||
)
|
||||
|
||||
let rows = DiffViewModel.makeRows(files: [renamed])
|
||||
|
||||
guard case .fileHeader(let header)? = rows.first?.kind else {
|
||||
Issue.record("期望 fileHeader,实际 \(String(describing: rows.first))")
|
||||
return
|
||||
}
|
||||
#expect(header.pathLabel == "old/name.ts → new/name.ts")
|
||||
}
|
||||
|
||||
@Test("untracked(0 hunk、非 binary)→ 仅文件头行")
|
||||
func untrackedFileIsHeaderOnly() {
|
||||
let untracked = DiffFile(
|
||||
oldPath: "notes.md", newPath: "notes.md", status: .untracked,
|
||||
added: 0, removed: 0, binary: false, hunks: []
|
||||
)
|
||||
|
||||
let rows = DiffViewModel.makeRows(files: [untracked])
|
||||
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows.first?.kind == .fileHeader(DiffFileHeader(
|
||||
pathLabel: "notes.md", added: 0, removed: 0, status: .untracked
|
||||
)))
|
||||
}
|
||||
|
||||
// MARK: - 错误映射(400/404 → 友好错误,任务 Steps)
|
||||
|
||||
@Test("DiffFetchError → Failure 映射:400/非法请求 → pathInvalid,404 → notFound,其余 → unavailable")
|
||||
func fetchErrorsMapToFailures() async {
|
||||
struct RandomError: Error {}
|
||||
let cases: [(any Error, DiffViewModel.Failure)] = [
|
||||
(DiffFetchError.pathInvalid, .pathInvalid),
|
||||
(DiffFetchError.invalidRequest, .pathInvalid),
|
||||
(DiffFetchError.projectNotFound, .notFound),
|
||||
(DiffFetchError.invalidResponse, .unavailable),
|
||||
(DiffFetchError.unexpectedStatus(500), .unavailable),
|
||||
(RandomError(), .unavailable),
|
||||
]
|
||||
for (thrown, expected) in cases {
|
||||
let vm = DiffViewModel(fetch: { _ in throw thrown })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .failed(expected))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("重试:失败后再次 load 成功 → .loaded(错误态可恢复)")
|
||||
func retryAfterFailureRecovers() async {
|
||||
struct Boom: Error {}
|
||||
let recorder = FetchRecorder()
|
||||
let vm = DiffViewModel(fetch: { staged in
|
||||
await recorder.record(staged)
|
||||
if await recorder.stagedArgs.count == 1 { throw Boom() }
|
||||
return Self.result(files: [Self.modifiedFile()])
|
||||
})
|
||||
|
||||
await vm.load()
|
||||
#expect(vm.phase == .failed(.unavailable))
|
||||
|
||||
await vm.load() // DiffScreen「重试」按钮走的就是这条路径
|
||||
|
||||
guard case .loaded = vm.phase else {
|
||||
Issue.record("期望 .loaded,实际 \(vm.phase)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - staged/unstaged 切换(re-fetch 语义)
|
||||
|
||||
@Test("setStaged(true) → 以 staged=true 重新 fetch;同值再设 → 不重复请求")
|
||||
func setStagedRefetchesOnlyOnChange() async {
|
||||
let recorder = FetchRecorder()
|
||||
let vm = DiffViewModel(fetch: { staged in
|
||||
await recorder.record(staged)
|
||||
return Self.result(files: [], staged: staged)
|
||||
})
|
||||
await vm.load()
|
||||
|
||||
await vm.setStaged(true)
|
||||
await vm.setStaged(true) // 同值:必须是 no-op
|
||||
await vm.setStaged(false)
|
||||
|
||||
#expect(vm.staged == false)
|
||||
#expect(await recorder.stagedArgs == [false, true, false])
|
||||
}
|
||||
|
||||
// MARK: - 用户可见文案(中文具名常量;空态 ≠ 错误态)
|
||||
|
||||
@Test("文案常量非空且空态/错误态互不相同")
|
||||
func copyConstantsAreDistinct() {
|
||||
#expect(!DiffCopy.title.isEmpty)
|
||||
#expect(!DiffCopy.truncatedBanner.isEmpty)
|
||||
#expect(!DiffCopy.emptyTitle.isEmpty)
|
||||
#expect(!DiffCopy.binaryFile.isEmpty)
|
||||
#expect(!DiffCopy.retry.isEmpty)
|
||||
#expect(DiffCopy.emptyTitle != DiffCopy.failedUnavailable)
|
||||
#expect(DiffCopy.failedPathInvalid != DiffCopy.failedNotFound)
|
||||
}
|
||||
|
||||
@Test("状态标签全函数映射(含全部六种 FileStatus,中文)")
|
||||
func statusLabelsAreTotalAndChinese() {
|
||||
let all: [DiffFileStatus] = [
|
||||
.modified, .added, .deleted, .renamed, .binary, .untracked,
|
||||
]
|
||||
for status in all {
|
||||
#expect(!DiffStatusStyle.label(for: status).isEmpty)
|
||||
}
|
||||
#expect(DiffStatusStyle.label(for: .added) == "新增")
|
||||
#expect(DiffStatusStyle.label(for: .deleted) == "删除")
|
||||
}
|
||||
}
|
||||
79
ios/App/WebTermTests/EventFanOutTests.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-15 · Fan-out adapter tests. `SessionEngine.events` is a
|
||||
/// single-consumer `AsyncStream`, but TerminalViewModel, GateViewModel AND the
|
||||
/// session-activity bridge must all observe it — `EventFanOut` is the wiring
|
||||
/// layer's broadcast seam. These tests pin its contract: every branch receives
|
||||
/// every element, in order, and source termination / cancel() propagate.
|
||||
@Suite("EventFanOut")
|
||||
struct EventFanOutTests {
|
||||
private static let branchCount = 3
|
||||
|
||||
@Test("每个分支都按序收到全部元素")
|
||||
func allBranchesReceiveAllElementsInOrder() async {
|
||||
// Arrange
|
||||
let (source, continuation) = AsyncStream<Int>.makeStream()
|
||||
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||
|
||||
// Act
|
||||
for value in 1...5 {
|
||||
continuation.yield(value)
|
||||
}
|
||||
continuation.finish()
|
||||
|
||||
// Assert: unbounded buffering — late consumption still sees everything.
|
||||
for branch in fanOut.branches {
|
||||
var received: [Int] = []
|
||||
for await value in branch {
|
||||
received.append(value)
|
||||
}
|
||||
#expect(received == [1, 2, 3, 4, 5])
|
||||
}
|
||||
}
|
||||
|
||||
@Test("分支数量与请求一致")
|
||||
func branchCountMatchesRequest() {
|
||||
let (source, _) = AsyncStream<Int>.makeStream()
|
||||
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||
#expect(fanOut.branches.count == Self.branchCount)
|
||||
}
|
||||
|
||||
@Test("源结束 → 所有分支结束(消费循环退出,不悬挂)")
|
||||
func sourceFinishFinishesEveryBranch() async {
|
||||
// Arrange
|
||||
let (source, continuation) = AsyncStream<String>.makeStream()
|
||||
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||
|
||||
// Act
|
||||
continuation.yield("only")
|
||||
continuation.finish()
|
||||
|
||||
// Assert: every branch loop terminates after draining.
|
||||
for branch in fanOut.branches {
|
||||
var count = 0
|
||||
for await _ in branch {
|
||||
count += 1
|
||||
}
|
||||
#expect(count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("cancel() → 分支立即终止(teardown 不泄漏消费任务)")
|
||||
func cancelTerminatesBranches() async {
|
||||
// Arrange
|
||||
let (source, continuation) = AsyncStream<Int>.makeStream()
|
||||
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||
|
||||
// Act: cancel without ever finishing the source.
|
||||
fanOut.cancel()
|
||||
continuation.yield(42) // post-cancel input must not hang consumers
|
||||
|
||||
// Assert: iteration completes (content is unspecified mid-flight —
|
||||
// termination is the contract under test).
|
||||
for branch in fanOut.branches {
|
||||
for await _ in branch {}
|
||||
}
|
||||
#expect(Bool(true)) // reaching here = no hang
|
||||
}
|
||||
}
|
||||
402
ios/App/WebTermTests/GateViewModelTests.swift
Normal file
@@ -0,0 +1,402 @@
|
||||
import Foundation
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-14 · GateViewModel + gate/digest components (plan §7). All tests run
|
||||
/// against a REAL `SessionEngine` over `TestSupport.FakeTransport` +
|
||||
/// `FakeClock` (same testability choice as TerminalViewModelTests): the VM
|
||||
/// consumes `engine.events` exactly as the T-iOS-15 wiring will — zero real
|
||||
/// waits, zero network, and the engine's `GateTracker.canDecide` second-line
|
||||
/// guard stays live underneath the VM's first-line tap-epoch guard.
|
||||
@MainActor
|
||||
@Suite("GateViewModel")
|
||||
struct GateViewModelTests {
|
||||
// MARK: - Server-side frame fixtures (untrusted wire input, hand-built JSON)
|
||||
|
||||
private enum ServerFrames {
|
||||
static func attached(_ id: UUID) -> String {
|
||||
#"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"#
|
||||
}
|
||||
|
||||
static func status(pending: Bool, gate: String? = nil, detail: String? = nil) -> String {
|
||||
var fields = ["\"type\":\"status\"", "\"status\":\"working\"", "\"pending\":\(pending)"]
|
||||
if let gate { fields.append("\"gate\":\"\(gate)\"") }
|
||||
if let detail { fields.append("\"detail\":\"\(detail)\"") }
|
||||
return "{\(fields.joined(separator: ","))}"
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestStreamError: Error {}
|
||||
|
||||
// MARK: - Test doubles
|
||||
|
||||
/// Records haptic firings — the "exactly once per gate epoch" probe.
|
||||
@MainActor
|
||||
private final class HapticRecorder: HapticSignaling {
|
||||
private(set) var gateArrivalCount = 0
|
||||
func gateDidArrive() { gateArrivalCount += 1 }
|
||||
}
|
||||
|
||||
// MARK: - Harness
|
||||
|
||||
/// Engine over fakes + the VM under test, wired exactly like production.
|
||||
@MainActor
|
||||
private final class Harness {
|
||||
let transport = FakeTransport()
|
||||
let clock = FakeClock()
|
||||
let haptics = HapticRecorder()
|
||||
let engine: SessionEngine
|
||||
let viewModel: GateViewModel
|
||||
|
||||
init(
|
||||
eventsSource: @escaping @Sendable (UUID) async throws -> [TimelineEvent] = { _ in [] }
|
||||
) throws {
|
||||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
engine = SessionEngine(
|
||||
transport: transport, clock: clock, endpoint: endpoint,
|
||||
eventsSource: eventsSource
|
||||
)
|
||||
viewModel = GateViewModel(
|
||||
engine: engine, events: engine.events, haptics: haptics, clock: clock
|
||||
)
|
||||
viewModel.start()
|
||||
}
|
||||
|
||||
/// Standard preamble: open → .connecting → .connected → server confirms
|
||||
/// the attach. 3 events reach the VM.
|
||||
func openAndAdopt(_ id: UUID = UUID()) async {
|
||||
await engine.open(sessionId: nil, cwd: nil)
|
||||
await transport.emit(frame: ServerFrames.attached(id))
|
||||
await viewModel.waitUntilProcessed(eventCount: 3)
|
||||
}
|
||||
|
||||
/// Frames the client sent on connection 0 (attach first, then decisions).
|
||||
func wireFrames() async -> [String] {
|
||||
let byConnection = await transport.sentFramesByConnection
|
||||
return byConnection.first ?? []
|
||||
}
|
||||
|
||||
/// Drop the wire, ride the 1s backoff rung, reconnect, re-adopt `id` —
|
||||
/// the away window that makes the engine emit exactly one digest.
|
||||
/// Event count after: 7 (3 preamble + reconnecting + connected +
|
||||
/// adopted + digest).
|
||||
func reconnectForDigest(_ id: UUID) async {
|
||||
await transport.emitError(TestStreamError())
|
||||
await viewModel.waitUntilProcessed(eventCount: 4) // .reconnecting
|
||||
await clock.waitForSleepers(count: 1) // backoff rung parked
|
||||
clock.advance(by: .seconds(1))
|
||||
await transport.emit(frame: ServerFrames.attached(id))
|
||||
await viewModel.waitUntilProcessed(eventCount: 7)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gate routing (tool → banner, plan → sheet)
|
||||
|
||||
@Test("tool gate → two-button banner state; plan gate → three-way sheet state; lifted → neither")
|
||||
func gateRoutingToolVsPlan() async throws {
|
||||
// Arrange
|
||||
let harness = try Harness()
|
||||
await harness.openAndAdopt()
|
||||
|
||||
// Act: tool gate rises (epoch 1).
|
||||
await harness.transport.emit(
|
||||
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 4)
|
||||
|
||||
// Assert: banner state only.
|
||||
#expect(harness.viewModel.toolGate == GateState(kind: .tool, detail: "Bash", epoch: 1))
|
||||
#expect(harness.viewModel.planGate == nil)
|
||||
|
||||
// Act: gate lifted.
|
||||
await harness.transport.emit(frame: ServerFrames.status(pending: false))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 5)
|
||||
|
||||
// Assert: neither surface renders.
|
||||
#expect(harness.viewModel.toolGate == nil)
|
||||
#expect(harness.viewModel.planGate == nil)
|
||||
|
||||
// Act: plan gate rises (epoch 2).
|
||||
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 6)
|
||||
|
||||
// Assert: sheet state only.
|
||||
#expect(harness.viewModel.planGate == GateState(kind: .plan, detail: nil, epoch: 2))
|
||||
#expect(harness.viewModel.toolGate == nil)
|
||||
}
|
||||
|
||||
// MARK: - Wire mapping (mirror of public/tabs.ts:345-347 / src/types.ts:84-86)
|
||||
|
||||
@Test("plan three-way maps EXACTLY: Approve+Auto→acceptEdits · Approve+Review→default · Keep Planning→reject — no allowAutoMode gate anywhere")
|
||||
func planThreeWayMappingMirrorsWebClient() async throws {
|
||||
// Arrange: a held plan gate (epoch 1). Note the VM has NO HTTP client
|
||||
// and never consults /config/ui — uiConfig.allowAutoMode is reserved
|
||||
// for a future permission-mode picker (plan §3.1 note).
|
||||
let harness = try Harness()
|
||||
await harness.openAndAdopt()
|
||||
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 4)
|
||||
let epoch = try #require(harness.viewModel.planGate?.epoch)
|
||||
|
||||
// Act: all three choices against the held gate (web sends per click).
|
||||
harness.viewModel.decide(.approveAuto, epoch: epoch)
|
||||
harness.viewModel.decide(.approveReview, epoch: epoch)
|
||||
harness.viewModel.decide(.keepPlanning, epoch: epoch)
|
||||
await harness.viewModel.waitUntilForwarded(decisionCount: 3)
|
||||
|
||||
// Assert: exact wire frames, in order — never raw `auto`.
|
||||
#expect(await harness.wireFrames() == [
|
||||
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
|
||||
MessageCodec.encode(.approve(mode: .acceptEdits)),
|
||||
MessageCodec.encode(.approve(mode: .default)),
|
||||
MessageCodec.encode(.reject),
|
||||
])
|
||||
}
|
||||
|
||||
@Test("tool two-way maps: Approve→approve(mode:nil) · Reject→reject")
|
||||
func toolTwoWayMapping() async throws {
|
||||
// Arrange
|
||||
let harness = try Harness()
|
||||
await harness.openAndAdopt()
|
||||
await harness.transport.emit(
|
||||
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 4)
|
||||
let epoch = try #require(harness.viewModel.toolGate?.epoch)
|
||||
|
||||
// Act
|
||||
harness.viewModel.decide(.approve, epoch: epoch)
|
||||
harness.viewModel.decide(.reject, epoch: epoch)
|
||||
await harness.viewModel.waitUntilForwarded(decisionCount: 2)
|
||||
|
||||
// Assert: bare approve (no mode key) then reject.
|
||||
#expect(await harness.wireFrames() == [
|
||||
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
|
||||
MessageCodec.encode(.approve(mode: nil)),
|
||||
MessageCodec.encode(.reject),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Tap-epoch guard (first line; engine canDecide is the second)
|
||||
|
||||
@Test("a tap carrying a stale epoch is dropped and never sent — a slow tap must not approve the NEXT gate")
|
||||
func staleEpochTapIsDroppedNotSent() async throws {
|
||||
// Arrange
|
||||
let harness = try Harness()
|
||||
await harness.openAndAdopt()
|
||||
|
||||
// Act: tap before any gate ever rose → dropped.
|
||||
harness.viewModel.decide(.approve, epoch: 0)
|
||||
#expect(harness.viewModel.droppedStaleDecisionCount == 1)
|
||||
|
||||
// Arrange: gate 1 rises, resolves, gate 2 rises (the dangerous window).
|
||||
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 4)
|
||||
await harness.transport.emit(frame: ServerFrames.status(pending: false))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 5)
|
||||
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 6)
|
||||
|
||||
// Act: a tap rendered against gate 1 lands now → dropped, not sent.
|
||||
harness.viewModel.decide(.approve, epoch: 1)
|
||||
#expect(harness.viewModel.droppedStaleDecisionCount == 2)
|
||||
#expect(harness.viewModel.forwardedDecisionCount == 0)
|
||||
|
||||
// Act: a fresh tap against the CURRENT gate (epoch 2) goes through.
|
||||
harness.viewModel.decide(.approve, epoch: 2)
|
||||
await harness.viewModel.waitUntilForwarded(decisionCount: 1)
|
||||
|
||||
// Assert: exactly one approve ever reached the wire.
|
||||
#expect(await harness.wireFrames() == [
|
||||
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
|
||||
MessageCodec.encode(.approve(mode: nil)),
|
||||
])
|
||||
}
|
||||
|
||||
@Test("same-epoch kind morph (tool→plan sustained refresh): the tool tap is dropped, the plan choice goes through")
|
||||
func kindMorphSameEpochDropsForeignAffordance() async throws {
|
||||
// Arrange: tool gate epoch 1, then a sustained-pending refresh flips
|
||||
// the kind to plan WITHOUT minting a new epoch (GateTracker semantics).
|
||||
let harness = try Harness()
|
||||
await harness.openAndAdopt()
|
||||
await harness.transport.emit(
|
||||
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 4)
|
||||
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 5)
|
||||
#expect(harness.viewModel.planGate?.epoch == 1)
|
||||
|
||||
// Act: the tap rendered on the old TOOL banner is no longer an offered
|
||||
// affordance — dropped even though the epoch still matches.
|
||||
harness.viewModel.decide(.approve, epoch: 1)
|
||||
#expect(harness.viewModel.droppedStaleDecisionCount == 1)
|
||||
|
||||
// Act: a choice from the CURRENT plan sheet goes through.
|
||||
harness.viewModel.decide(.approveAuto, epoch: 1)
|
||||
await harness.viewModel.waitUntilForwarded(decisionCount: 1)
|
||||
|
||||
// Assert
|
||||
#expect(await harness.wireFrames() == [
|
||||
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
|
||||
MessageCodec.encode(.approve(mode: .acceptEdits)),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Haptics (exactly once per gate epoch)
|
||||
|
||||
@Test("haptic fires exactly once per gate epoch — sustained refreshes and lifts never re-buzz")
|
||||
func hapticFiresExactlyOncePerGateEpoch() async throws {
|
||||
// Arrange
|
||||
let harness = try Harness()
|
||||
await harness.openAndAdopt()
|
||||
#expect(harness.haptics.gateArrivalCount == 0)
|
||||
|
||||
// Act: rising edge mints epoch 1 → one buzz.
|
||||
await harness.transport.emit(
|
||||
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 4)
|
||||
#expect(harness.haptics.gateArrivalCount == 1)
|
||||
|
||||
// Act: sustained-pending refresh (same epoch, new detail) → NO re-buzz.
|
||||
await harness.transport.emit(
|
||||
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Read"))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 5)
|
||||
#expect(harness.haptics.gateArrivalCount == 1)
|
||||
|
||||
// Act: gate lifted → no buzz for a nil gate.
|
||||
await harness.transport.emit(frame: ServerFrames.status(pending: false))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 6)
|
||||
#expect(harness.haptics.gateArrivalCount == 1)
|
||||
|
||||
// Act: a NEW gate (epoch 2) → second buzz.
|
||||
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
|
||||
await harness.viewModel.waitUntilProcessed(eventCount: 7)
|
||||
#expect(harness.haptics.gateArrivalCount == 2)
|
||||
}
|
||||
|
||||
// MARK: - Away digest (render / fade / expand)
|
||||
|
||||
@Test("non-zero digest renders the summary state; the all-zero digest renders nothing and parks no fade timer")
|
||||
func digestNonZeroRendersAndAllZeroDoesNot() async throws {
|
||||
// Arrange: one away-window event (far-future `at` passes the `since`
|
||||
// filter; the engine stamps the disconnect moment with the real Date).
|
||||
let sessionId = UUID()
|
||||
let awayEvent = TimelineEvent(
|
||||
at: Int.max / 2, class: "tool", toolName: "Bash", label: "ran Bash")
|
||||
let harness = try Harness(eventsSource: { _ in [awayEvent] })
|
||||
await harness.openAndAdopt(sessionId)
|
||||
|
||||
// Act
|
||||
await harness.reconnectForDigest(sessionId)
|
||||
|
||||
// Assert: summary state set, collapsed by default.
|
||||
#expect(harness.viewModel.digest == AwayDigest(
|
||||
toolRuns: 1, waitingCount: 0, sawDone: false, sawStuck: false,
|
||||
recent: [awayEvent]))
|
||||
#expect(!harness.viewModel.isDigestExpanded)
|
||||
|
||||
// Arrange/Act: an empty away timeline → `.digest(.empty)`.
|
||||
let empty = try Harness(eventsSource: { _ in [] })
|
||||
await empty.openAndAdopt(sessionId)
|
||||
await empty.reconnectForDigest(sessionId)
|
||||
|
||||
// Assert: nothing rendered, and no fade timer was ever scheduled.
|
||||
#expect(empty.viewModel.digest == nil)
|
||||
#expect(empty.clock.pendingSleeperCount == 0)
|
||||
}
|
||||
|
||||
@Test("digest auto-fades after Tunables.digestFadeDelay on the injected clock")
|
||||
func digestAutoFadesAfterDelay() async throws {
|
||||
// Arrange: a visible digest.
|
||||
let sessionId = UUID()
|
||||
let awayEvent = TimelineEvent(
|
||||
at: Int.max / 2, class: "waiting", toolName: nil, label: "requested approval")
|
||||
let harness = try Harness(eventsSource: { _ in [awayEvent] })
|
||||
await harness.openAndAdopt(sessionId)
|
||||
await harness.reconnectForDigest(sessionId)
|
||||
#expect(harness.viewModel.digest != nil)
|
||||
|
||||
// Act: the fade timer parks on the fake clock; fire it.
|
||||
await harness.clock.waitForSleepers(count: 1)
|
||||
harness.clock.advance(by: Tunables.digestFadeDelay)
|
||||
await harness.viewModel.waitUntilFadeCompleted(count: 1)
|
||||
|
||||
// Assert
|
||||
#expect(harness.viewModel.digest == nil)
|
||||
}
|
||||
|
||||
@Test("manual expand shows recent entries and cancels the fade — an expanded digest never vanishes under the reader")
|
||||
func expandedDigestNeverFadesAndShowsRecent() async throws {
|
||||
// Arrange: a visible digest with its fade timer parked.
|
||||
let sessionId = UUID()
|
||||
let awayEvent = TimelineEvent(
|
||||
at: Int.max / 2, class: "tool", toolName: "Edit", label: "edited 3 files")
|
||||
let harness = try Harness(eventsSource: { _ in [awayEvent] })
|
||||
await harness.openAndAdopt(sessionId)
|
||||
await harness.reconnectForDigest(sessionId)
|
||||
await harness.clock.waitForSleepers(count: 1)
|
||||
|
||||
// Act: the user expands the summary row.
|
||||
harness.viewModel.expandDigest()
|
||||
|
||||
// Assert: expanded, recent entries available, fade timer CANCELLED.
|
||||
#expect(harness.viewModel.isDigestExpanded)
|
||||
#expect(harness.viewModel.digest?.recent == [awayEvent])
|
||||
#expect(harness.clock.pendingSleeperCount == 0)
|
||||
|
||||
// Act: even way past the fade delay the digest stays.
|
||||
harness.clock.advance(by: Tunables.digestFadeDelay * 10)
|
||||
#expect(harness.viewModel.digest != nil)
|
||||
|
||||
// Act: explicit dismiss clears everything.
|
||||
harness.viewModel.dismissDigest()
|
||||
#expect(harness.viewModel.digest == nil)
|
||||
#expect(!harness.viewModel.isDigestExpanded)
|
||||
}
|
||||
|
||||
// MARK: - Component copy (mirror of public/tabs.ts:326-350)
|
||||
|
||||
@Test("banner/sheet copy and choice labels mirror the web client exactly")
|
||||
func componentCopyMirrorsWebClient() {
|
||||
// Tool banner: "Claude wants to use ${pendingTool ?? 'a tool'}".
|
||||
let toolGate = GateState(kind: .tool, detail: "Bash", epoch: 1)
|
||||
#expect(GateBanner.message(for: toolGate) == "Claude wants to use Bash")
|
||||
#expect(GateBanner.message(for: GateState(kind: .tool, detail: nil, epoch: 1))
|
||||
== "Claude wants to use a tool")
|
||||
#expect(GateChoiceSpec.specs(for: toolGate).map(\.label)
|
||||
== ["✓ Approve", "✗ Reject"])
|
||||
#expect(GateChoiceSpec.specs(for: toolGate).map(\.affordance)
|
||||
== [.approve, .reject])
|
||||
|
||||
// Plan sheet: three-way, exact labels and title.
|
||||
let planGate = GateState(kind: .plan, detail: nil, epoch: 2)
|
||||
#expect(GateChoiceSpec.specs(for: planGate).map(\.label)
|
||||
== ["✓ Approve + Auto", "✓ Approve + Review", "✎ Keep Planning"])
|
||||
#expect(GateChoiceSpec.specs(for: planGate).map(\.affordance)
|
||||
== [.approveAuto, .approveReview, .keepPlanning])
|
||||
#expect(PlanGateSheet.title == "Claude finished planning — how should it proceed?")
|
||||
}
|
||||
|
||||
@Test("digest summary composes non-zero parts only; user-only activity falls back to a count")
|
||||
func digestSummaryComposition() {
|
||||
// Arrange / Act / Assert: all four signal parts.
|
||||
let full = AwayDigest(
|
||||
toolRuns: 3, waitingCount: 1, sawDone: true, sawStuck: false, recent: [])
|
||||
#expect(AwayDigestView.summary(for: full)
|
||||
== "离开期间:工具调用 3 次 · 等待审批 1 次 · 已完成")
|
||||
|
||||
// Stuck-only digest.
|
||||
let stuck = AwayDigest(
|
||||
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: true, recent: [])
|
||||
#expect(AwayDigestView.summary(for: stuck) == "离开期间:曾卡住")
|
||||
|
||||
// Non-empty digest whose counted signals are all zero (e.g. only
|
||||
// `user` events) still gets a rendered fallback line.
|
||||
let userOnly = AwayDigest(
|
||||
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false,
|
||||
recent: [TimelineEvent(at: 1, class: "user", toolName: nil, label: "typed a prompt")])
|
||||
#expect(AwayDigestView.summary(for: userOnly) == "离开期间:活动 1 条")
|
||||
}
|
||||
}
|
||||
132
ios/App/WebTermTests/KeyBarTests.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import SessionCore
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-11 · KeyBar component + hardware key commands (plan §7).
|
||||
/// The bar's layout mirrors `public/keybar.ts` `KEYBAR_BUTTONS` (order, glyphs,
|
||||
/// captions, primary flag) and EVERY label→bytes lookup goes through
|
||||
/// `KeyByteMap` — no byte literal exists in the App layer.
|
||||
@MainActor
|
||||
@Suite("KeyBar")
|
||||
struct KeyBarTests {
|
||||
@MainActor
|
||||
private final class KeyRecorder {
|
||||
private(set) var keys: [KeyByteMap.Key] = []
|
||||
func record(_ key: KeyByteMap.Key) { keys = keys + [key] }
|
||||
}
|
||||
|
||||
// MARK: - Layout data (mirror of KEYBAR_BUTTONS)
|
||||
|
||||
@Test("button order mirrors public/keybar.ts KEYBAR_BUTTONS exactly")
|
||||
func layoutOrderMirrorsWebKeybarButtons() {
|
||||
let expectedOrder: [KeyByteMap.Key] = [
|
||||
.esc, .escEsc, .shiftTab, .arrowUp, .arrowDown, .enter, .ctrlC,
|
||||
.ctrlR, .ctrlO, .ctrlL, .ctrlT, .ctrlB, .ctrlD, .tab,
|
||||
.arrowLeft, .arrowRight, .slash,
|
||||
]
|
||||
#expect(KeyBarLayout.buttons.map(\.key) == expectedOrder)
|
||||
}
|
||||
|
||||
@Test("glyph labels mirror the web bar; Esc is the only primary button")
|
||||
func labelsAndPrimaryFlagMirrorWeb() {
|
||||
let expectedLabels = [
|
||||
"Esc", "Esc²", "⇧Tab", "↑", "↓", "⏎", "^C", "^R", "^O", "^L",
|
||||
"^T", "^B", "^D", "Tab", "←", "→", "/",
|
||||
]
|
||||
#expect(KeyBarLayout.buttons.map(\.label) == expectedLabels)
|
||||
#expect(KeyBarLayout.buttons.filter(\.isPrimary).map(\.key) == [.esc])
|
||||
}
|
||||
|
||||
@Test("every button spec resolves to real bytes through KeyByteMap (single source of truth)")
|
||||
func everySpecResolvesThroughKeyByteMap() {
|
||||
for spec in KeyBarLayout.buttons {
|
||||
#expect(!KeyByteMap.bytes(for: spec.key).isEmpty,
|
||||
"no bytes for \(spec.key.rawValue)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - KeyBarView (UIKit input accessory)
|
||||
|
||||
@Test("KeyBarView builds one button per spec with the web title as accessibility label")
|
||||
func keyBarViewBuildsOneButtonPerSpec() {
|
||||
// Arrange / Act
|
||||
let bar = KeyBarView()
|
||||
|
||||
// Assert
|
||||
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
|
||||
#expect(bar.keyButtons.map(\.accessibilityLabel)
|
||||
== KeyBarLayout.buttons.map(\.title))
|
||||
}
|
||||
|
||||
@Test("tapping button i fires onKey with layout key i (bytes resolved downstream via KeyByteMap)")
|
||||
func tappingButtonsFiresOnKeyInLayoutOrder() {
|
||||
// Arrange
|
||||
let bar = KeyBarView()
|
||||
let recorder = KeyRecorder()
|
||||
bar.onKey = { recorder.record($0) }
|
||||
|
||||
// Act: tap every button once, in order.
|
||||
for button in bar.keyButtons {
|
||||
button.sendActions(for: .touchUpInside)
|
||||
}
|
||||
|
||||
// Assert
|
||||
#expect(recorder.keys == KeyBarLayout.buttons.map(\.key))
|
||||
}
|
||||
|
||||
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
|
||||
|
||||
@Test("hardware chords: Esc, Shift+Tab and the 7 Ctrl chords map through KeyByteMap")
|
||||
func hardwareChordsCoverEscShiftTabAndCtrlChords() {
|
||||
let expected: [(KeyByteMap.Key, String, UIKeyModifierFlags)] = [
|
||||
(.esc, UIKeyCommand.inputEscape, []),
|
||||
(.shiftTab, "\t", .shift),
|
||||
(.ctrlC, "c", .control),
|
||||
(.ctrlR, "r", .control),
|
||||
(.ctrlO, "o", .control),
|
||||
(.ctrlL, "l", .control),
|
||||
(.ctrlT, "t", .control),
|
||||
(.ctrlB, "b", .control),
|
||||
(.ctrlD, "d", .control),
|
||||
]
|
||||
#expect(HardwareKeyCommands.chords.count == expected.count)
|
||||
for (key, input, modifiers) in expected {
|
||||
let chord = HardwareKeyCommands.chords.first { $0.key == key }
|
||||
#expect(chord?.input == input, "input mismatch for \(key.rawValue)")
|
||||
#expect(chord?.modifiers == modifiers, "modifiers mismatch for \(key.rawValue)")
|
||||
#expect(!KeyByteMap.bytes(for: key).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("build() emits one prioritized UIKeyCommand per chord and key(matching:) round-trips")
|
||||
func buildEmitsPrioritizedCommandsAndRoundTrips() {
|
||||
// Arrange / Act
|
||||
let commands = HardwareKeyCommands.build(
|
||||
action: #selector(UIResponder.becomeFirstResponder) // any selector; not invoked
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(commands.count == HardwareKeyCommands.chords.count)
|
||||
for command in commands {
|
||||
#expect(command.wantsPriorityOverSystemBehavior)
|
||||
let key = HardwareKeyCommands.key(matching: command)
|
||||
#expect(key != nil, "no chord matches \(String(describing: command.input))")
|
||||
}
|
||||
#expect(Set(commands.compactMap { HardwareKeyCommands.key(matching: $0) }).count
|
||||
== HardwareKeyCommands.chords.count)
|
||||
}
|
||||
|
||||
@Test("hardware mapping never hijacks plain typing keys or arrows (SwiftTerm owns them — DECCKM)")
|
||||
func hardwareMappingExcludesPlainKeysAndArrows() {
|
||||
// Arrows must stay SwiftTerm-native: a fixed \u{1B}[A override would
|
||||
// break application-cursor-keys mode (vim/htop send \u{1B}OA there).
|
||||
// Enter/Tab/"/" are ordinary typing; Esc·Esc is two Esc presses.
|
||||
let excluded: Set<KeyByteMap.Key> = [
|
||||
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
|
||||
.enter, .tab, .slash, .escEsc,
|
||||
]
|
||||
let chordKeys = Set(HardwareKeyCommands.chords.map(\.key))
|
||||
#expect(chordKeys.isDisjoint(with: excluded))
|
||||
}
|
||||
}
|
||||
33
ios/App/WebTermTests/KeyBarVisibilityTests.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iPad-3 · KeyBar 可见性纯谓词。硬件键盘在场时软键盘 KeyBar
|
||||
/// (`inputAccessoryView`)冗余 → 默认隐;无硬件键盘 → 显(iPhone 行为不变);
|
||||
/// 用户显式切换(`userOverride`)始终压过自动默认。硬件态经参数注入(模拟器
|
||||
/// 硬件键盘状态未必可脚本化 —— 谓词单测是真值源)。
|
||||
@Suite("KeyBarVisibility (T-iPad-3)")
|
||||
struct KeyBarVisibilityTests {
|
||||
// MARK: - 自动默认(跟随硬件键盘在场)
|
||||
|
||||
@Test("硬件键盘在场 + 无用户覆盖 → 隐藏(软 KeyBar 冗余)")
|
||||
func hardwarePresentAutoHides() {
|
||||
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: true, userOverride: nil) == false)
|
||||
}
|
||||
|
||||
@Test("无硬件键盘 + 无用户覆盖 → 显示(iPhone 默认,零回归)")
|
||||
func noHardwareAutoShows() {
|
||||
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: false, userOverride: nil) == true)
|
||||
}
|
||||
|
||||
// MARK: - 用户覆盖压过自动
|
||||
|
||||
@Test("用户强制显示压过硬件在场的自动隐藏")
|
||||
func userForceShowWinsOverHardware() {
|
||||
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: true, userOverride: true) == true)
|
||||
}
|
||||
|
||||
@Test("用户强制隐藏压过无硬件的自动显示")
|
||||
func userForceHideWinsOverNoHardware() {
|
||||
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: false, userOverride: false) == false)
|
||||
}
|
||||
}
|
||||
79
ios/App/WebTermTests/KeychainHostStoreLiveTests.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Security
|
||||
import Testing
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Keychain-on-simulator assertion (deferred from T-iOS-7).
|
||||
///
|
||||
/// `swift test` binaries are unsigned → the data-protection keychain answers
|
||||
/// `errSecMissingEntitlement` (-34018), so the package layer only tests the
|
||||
/// store against a fake `SecItemShim`. THIS bundle runs signed inside the
|
||||
/// WebTerm app host, so here the REAL `LiveSecItemShim` path is exercised
|
||||
/// end-to-end and the §5.3 protection class is asserted on the stored item.
|
||||
@Suite("KeychainHostStore (real keychain, signed app host)", .serialized)
|
||||
struct KeychainHostStoreLiveTests {
|
||||
/// Test-only service/account — never collides with the production item.
|
||||
private static let service = "com.yaojia.webterm.host-registry.livetests"
|
||||
private static let account = "hosts-live-roundtrip"
|
||||
|
||||
private static func makeHost() throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: "http://192.168.1.9:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
return HostRegistry.Host(id: UUID(), name: "live-roundtrip", endpoint: endpoint)
|
||||
}
|
||||
|
||||
/// Removes the test item regardless of test outcome.
|
||||
private static func deleteTestItem() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecUseDataProtectionKeychain as String: true,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
@Test("真 Keychain 往返(upsert/loadAll/remove)+ kSecAttrAccessible 属性断言")
|
||||
func realKeychainRoundtripAndProtectionClass() async throws {
|
||||
Self.deleteTestItem() // clean slate from any earlier aborted run
|
||||
defer { Self.deleteTestItem() } // never leave the test item behind
|
||||
|
||||
// Arrange: REAL shim (default init), isolated service/account.
|
||||
let store = KeychainHostStore(service: Self.service, account: Self.account)
|
||||
let host = try Self.makeHost()
|
||||
|
||||
// Act + Assert: upsert → visible via loadAll.
|
||||
let afterUpsert = try await store.upsert(host)
|
||||
#expect(afterUpsert == [host])
|
||||
#expect(try await store.loadAll() == [host])
|
||||
|
||||
// Assert §5.3: the STORED item's protection class, straight from
|
||||
// SecItemCopyMatching attributes (not from our own spec constants).
|
||||
var result: CFTypeRef?
|
||||
let attributesQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Self.service,
|
||||
kSecAttrAccount as String: Self.account,
|
||||
kSecUseDataProtectionKeychain as String: true,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
let status = SecItemCopyMatching(attributesQuery as CFDictionary, &result)
|
||||
#expect(status == errSecSuccess, "SecItemCopyMatching failed: \(status)")
|
||||
let attributes = try #require(result as? [String: Any])
|
||||
let accessible = try #require(attributes[kSecAttrAccessible as String] as? String)
|
||||
#expect(
|
||||
accessible == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String,
|
||||
"stored item must be AfterFirstUnlockThisDeviceOnly (§5.3), got \(accessible)"
|
||||
)
|
||||
// §5.3: never iCloud-synced — the item must not be synchronizable.
|
||||
let synchronizable = attributes[kSecAttrSynchronizable as String] as? Bool ?? false
|
||||
#expect(!synchronizable)
|
||||
|
||||
// Act + Assert: remove → empty store (item deleted).
|
||||
let afterRemove = try await store.remove(id: host.id)
|
||||
#expect(afterRemove.isEmpty)
|
||||
#expect(try await store.loadAll() == [])
|
||||
}
|
||||
}
|
||||
28
ios/App/WebTermTests/LayoutPolicyTests.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iPad-2 · 自适应根视图的唯一 size-class 决策点(仿 `PrivacyShadePolicy`
|
||||
/// 的纯谓词先例)。regular 宽度 → 分栏(sidebar+detail);compact/未知 →
|
||||
/// 现有 iPhone stack 路径。这是**唯一**判据 —— 视图里严禁散落
|
||||
/// `if sizeClass == …`(PLAN_IOS_IPAD §4)。
|
||||
///
|
||||
/// 覆盖 `UserInterfaceSizeClass` 的三种可能取值(.regular / .compact / nil),
|
||||
/// 后者是「size class 尚未确定」的最保守回退:退到现有已测路径。
|
||||
@Suite("LayoutPolicy (T-iPad-2)")
|
||||
struct LayoutPolicyTests {
|
||||
@Test("regular 宽度 → split(iPad 全屏/大分屏:sidebar + detail)")
|
||||
func regularIsSplit() {
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: .regular) == .split)
|
||||
}
|
||||
|
||||
@Test("compact 宽度 → stack(iPhone / iPad Slide Over:字节级复用现有路径)")
|
||||
func compactIsStack() {
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: .compact) == .stack)
|
||||
}
|
||||
|
||||
@Test("nil(size class 未定)→ stack(最保守:退现有 iPhone 路径)")
|
||||
func nilIsStack() {
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: nil) == .stack)
|
||||
}
|
||||
}
|
||||
114
ios/App/WebTermTests/LiveServerSmokeTests.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-15 · Hosted live-server smoke — the automated stand-in for the manual
|
||||
/// 配对→列表→attach walkthrough (plan §7 T-iOS-15; the true device tap-through
|
||||
/// + app-switcher shade visual check remain DEFERRED to T-iOS-18).
|
||||
///
|
||||
/// Drives the REAL production DI graph end-to-end against the repo's real
|
||||
/// Node server: `runPairingProbe` over `URLSessionHTTPTransport` +
|
||||
/// `URLSessionTermTransport` (WS upgrade with Origin + guarded kill
|
||||
/// round-trip) → Host stored (InMemory per task) → `APIClient.liveSessions` →
|
||||
/// `SessionEngine.open(nil)` → input echo → output marker → `close()`.
|
||||
@Suite("Live-server smoke (production DI graph)", .serialized)
|
||||
struct LiveServerSmokeTests {
|
||||
/// Collects engine output off the single-consumer stream.
|
||||
private actor OutputObserver {
|
||||
private(set) var adoptedId: UUID?
|
||||
private var buffer = ""
|
||||
private static let tailLength = 400
|
||||
|
||||
func recordAdopted(_ id: UUID) { adoptedId = id }
|
||||
|
||||
/// Appends a chunk; true once the accumulated output contains `marker`.
|
||||
func appendAndCheck(_ chunk: String, marker: String) -> Bool {
|
||||
buffer += chunk
|
||||
return buffer.contains(marker)
|
||||
}
|
||||
|
||||
var tail: String { String(buffer.suffix(Self.tailLength)) }
|
||||
}
|
||||
|
||||
@Test("探针→host 入库→liveSessions→attach(null)→echo→output→close")
|
||||
func productionGraphEndToEnd() async throws {
|
||||
let endpoint = try await SimServerHarness.shared.endpoint()
|
||||
|
||||
// Production transports — the same instances AppEnvironment wires.
|
||||
let http = URLSessionHTTPTransport()
|
||||
let ws = URLSessionTermTransport()
|
||||
|
||||
// ① Pairing probe (two-step: RO GET, WS attach + Origin, guarded kill).
|
||||
let probed: HostEndpoint
|
||||
switch await runPairingProbe(endpoint: endpoint, http: http, ws: ws) {
|
||||
case .failure(let error):
|
||||
Issue.record("pairing probe failed: \(error)")
|
||||
return
|
||||
case .success(let validated):
|
||||
probed = validated
|
||||
}
|
||||
|
||||
// ② Host stored (InMemory stand-in is explicitly allowed here).
|
||||
let store = InMemoryHostStore()
|
||||
let host = HostRegistry.Host(id: UUID(), name: "smoke", endpoint: probed)
|
||||
_ = try await store.upsert(host)
|
||||
#expect(try await store.loadAll().map(\.id) == [host.id])
|
||||
|
||||
// ③ Session list over the real HTTP path (RO — no Origin).
|
||||
let api = APIClient(endpoint: probed, http: http)
|
||||
_ = try await api.liveSessions()
|
||||
|
||||
// ④ Real engine: attach(null) → shell echo → observe evaluated marker.
|
||||
// Arithmetic dedup: the TYPED command contains "$((...))", only the
|
||||
// EVALUATED output contains the final marker string.
|
||||
let base = 4200
|
||||
let offset = Int.random(in: 1...999)
|
||||
let marker = "smoke-\(base + offset)"
|
||||
let command = "echo smoke-$((\(base)+\(offset)))\r"
|
||||
|
||||
let engine = SessionEngine(
|
||||
transport: ws, clock: ContinuousClock(), endpoint: probed,
|
||||
eventsSource: { id in try await api.events(id: id) }
|
||||
)
|
||||
let observer = OutputObserver()
|
||||
let consumeTask = Task { () -> Bool in
|
||||
for await event in engine.events {
|
||||
switch event {
|
||||
case .adopted(let id):
|
||||
await observer.recordAdopted(id)
|
||||
case .output(let chunk):
|
||||
if await observer.appendAndCheck(chunk, marker: marker) { return true }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
await engine.open(sessionId: nil, cwd: nil)
|
||||
await engine.send(.input(data: command)) // queues until attach-ready
|
||||
|
||||
let watchdog = Task {
|
||||
try? await Task.sleep(for: SmokeTunables.outputTimeout)
|
||||
consumeTask.cancel() // AsyncStream iteration honors cancellation
|
||||
}
|
||||
let sawMarker = await consumeTask.value
|
||||
watchdog.cancel()
|
||||
await engine.close()
|
||||
|
||||
let outputTail = await observer.tail
|
||||
#expect(sawMarker, "output 未包含 \(marker);tail: \(outputTail)")
|
||||
|
||||
// Cleanup: kill the smoke session via the guarded G route (Origin).
|
||||
let adopted = try #require(await observer.adoptedId, "attached 帧未到达")
|
||||
do {
|
||||
try await api.killSession(id: adopted)
|
||||
} catch APIClientError.sessionNotFound {
|
||||
// Already gone — cleanup goal reached.
|
||||
}
|
||||
}
|
||||
}
|
||||
290
ios/App/WebTermTests/NewSessionInCwdTests.swift
Normal file
@@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-29 · 杂项闭环:new-in-cwd + 退出会话清理。
|
||||
///
|
||||
/// 1. "在当前目录开新会话"(TerminalScreen 工具栏 / exit 横幅共用动作)→
|
||||
/// `AppCoordinator.openNewSessionInCurrentCwd()`:单活 WS 不变式下的
|
||||
/// close→open,新会话首帧 `attach(null, cwd)`(镜像 web tabs.ts
|
||||
/// `newTab()` M6——取活跃会话 cwd)。cwd 解析次序:/live-sessions 行数据
|
||||
/// (server-adopted id 匹配行)→ controller 的 spawnCwd(T-iOS-26 项目
|
||||
/// fresh-spawn 尚未进列表轮询)→ 未知(普通新会话)。服务器数据不可信:
|
||||
/// 非绝对路径按未知处理(ProjectsViewModel 同款纪律)。
|
||||
/// 2. exited 会话点开 → 回放渲染 + exit 横幅(TerminalViewModel P0 已有)+
|
||||
/// 横幅"开新会话"动作(src/session/manager.ts:145-153:exited 会话在
|
||||
/// reap 前保持可列出,attach 即回放 ring buffer + 补发 exit 帧)。
|
||||
///
|
||||
/// 真 `SessionEngine`/`AppCoordinator` over FakeTransport/FakeHTTPTransport,
|
||||
/// 零真实等待(openTask + waitUntilProcessed 双屏障,ProjectOpenWiringTests
|
||||
/// 同款)。
|
||||
@MainActor
|
||||
@Suite("NewSessionInCwd (T-iOS-29)")
|
||||
struct NewSessionInCwdTests {
|
||||
private nonisolated static let base = "http://192.168.1.5:3000"
|
||||
|
||||
// MARK: - Fixtures(SessionSwitcherTests 的 WiringFixture 同款)
|
||||
|
||||
private struct Fixture {
|
||||
let transport: FakeTransport
|
||||
let http: FakeHTTPTransport
|
||||
let host: HostRegistry.Host
|
||||
let unreadStore: InMemoryUnreadWatermarkStore
|
||||
let environment: AppEnvironment
|
||||
}
|
||||
|
||||
private func makeFixture(suiteName: String) throws -> Fixture {
|
||||
let baseURL = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let transport = FakeTransport()
|
||||
let http = FakeHTTPTransport()
|
||||
let unreadStore = InMemoryUnreadWatermarkStore()
|
||||
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
return Fixture(
|
||||
transport: transport,
|
||||
http: http,
|
||||
host: host,
|
||||
unreadStore: unreadStore,
|
||||
environment: AppEnvironment(
|
||||
hostStore: InMemoryHostStore(hosts: [host]),
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: http,
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) },
|
||||
unreadStore: unreadStore
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func listURL() throws -> URL {
|
||||
try #require(URL(string: "\(Self.base)/live-sessions"))
|
||||
}
|
||||
|
||||
private func sessionJSON(id: UUID, cwd: String?, exited: Bool = false) -> String {
|
||||
let cwdJSON = cwd.map { ",\"cwd\":\"\($0)\"" } ?? ""
|
||||
return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":1700000000000"
|
||||
+ ",\"clientCount\":0,\"status\":\"idle\",\"exited\":\(exited)"
|
||||
+ "\(cwdJSON),\"cols\":80,\"rows\":24}"
|
||||
}
|
||||
|
||||
private func listBody(_ entries: [String]) -> Data {
|
||||
Data("[\(entries.joined(separator: ","))]".utf8)
|
||||
}
|
||||
|
||||
/// 让列表 VM 先掌握行数据(cwd 的唯一服务器来源)。
|
||||
private func primeRows(_ coordinator: AppCoordinator, fixture: Fixture, rows: [String]) async throws {
|
||||
await coordinator.sessionList.reloadHosts()
|
||||
await fixture.http.queueSuccess(url: try listURL(), body: listBody(rows))
|
||||
await coordinator.sessionList.refresh()
|
||||
}
|
||||
|
||||
/// attach 握手完成(服务器 adopted):openTask 完成 ∧ 前 3 个事件
|
||||
/// (connecting/connected/adopted)已应用。
|
||||
private func adopt(
|
||||
_ controller: TerminalSessionController,
|
||||
transport: FakeTransport,
|
||||
sessionId: UUID
|
||||
) async {
|
||||
await controller.openTask?.value
|
||||
await transport.emit(
|
||||
frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"#
|
||||
)
|
||||
await controller.terminalViewModel.waitUntilProcessed(eventCount: 3)
|
||||
}
|
||||
|
||||
/// 新 controller 的 open 已提交且连接事件已冲刷(帧必已落地)。
|
||||
private func awaitAttachSettled(_ controller: TerminalSessionController) async {
|
||||
await controller.openTask?.value
|
||||
await controller.terminalViewModel.waitUntilProcessed(eventCount: 2)
|
||||
}
|
||||
|
||||
// MARK: - 1. 在当前会话 cwd 开新会话
|
||||
|
||||
@Test("cwd 来自 /live-sessions 行 → 切换后新连接首帧 attach(null, cwd);旧会话记 last-seen")
|
||||
func newInCwdUsesListRowCwd() async throws {
|
||||
// Arrange:列表已知该会话 cwd,打开并 adopted。
|
||||
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.rowCwd")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
let sessionId = UUID()
|
||||
try await primeRows(
|
||||
coordinator, fixture: fixture,
|
||||
rows: [sessionJSON(id: sessionId, cwd: "/Users/dev/proj")]
|
||||
)
|
||||
coordinator.open(SessionListViewModel.OpenRequest(
|
||||
id: UUID(), host: fixture.host, sessionId: sessionId
|
||||
))
|
||||
let first = try #require(coordinator.terminalController)
|
||||
await adopt(first, transport: fixture.transport, sessionId: sessionId)
|
||||
|
||||
// Act:工具栏/横幅动作。
|
||||
coordinator.openNewSessionInCurrentCwd()
|
||||
|
||||
// Assert:close→open —— 新 controller、新连接,首帧带行 cwd 的 fresh spawn。
|
||||
let second = try #require(coordinator.terminalController)
|
||||
#expect(second !== first)
|
||||
await awaitAttachSettled(second)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection.count == 2)
|
||||
#expect(byConnection[1] == [
|
||||
MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj"))
|
||||
])
|
||||
// 旧会话经 closeTerminal 记 last-seen(切走即已读)。
|
||||
#expect(fixture.unreadStore.snapshot[sessionId] != nil)
|
||||
second.teardown()
|
||||
}
|
||||
|
||||
@Test("行缺席(fresh spawn 未入轮询)→ 回退 controller.spawnCwd;bootstrap 绝不复注入")
|
||||
func newInCwdFallsBackToSpawnCwdWithoutBootstrap() async throws {
|
||||
// Arrange:项目内 fresh spawn(spawnCwd + claude bootstrap),列表无行。
|
||||
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.spawnCwd")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
coordinator.openProject(ProjectOpenRequest(
|
||||
id: UUID(), host: fixture.host, cwd: "/repos/api",
|
||||
bootstrapInput: ProjectLaunch.claudeBootstrapInput
|
||||
))
|
||||
let first = try #require(coordinator.terminalController)
|
||||
await adopt(first, transport: fixture.transport, sessionId: UUID())
|
||||
|
||||
// Act
|
||||
coordinator.openNewSessionInCurrentCwd()
|
||||
|
||||
// Assert:同 cwd,但只有裸 attach —— claude\r 不属于"开新 shell"。
|
||||
let second = try #require(coordinator.terminalController)
|
||||
await awaitAttachSettled(second)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection.count == 2)
|
||||
#expect(byConnection[1] == [
|
||||
MessageCodec.encode(.attach(sessionId: nil, cwd: "/repos/api"))
|
||||
])
|
||||
second.teardown()
|
||||
}
|
||||
|
||||
@Test("cwd 全未知(无行、无 spawnCwd)→ 普通新会话 attach(null, nil)")
|
||||
func newInCwdWithUnknownCwdOpensPlainSession() async throws {
|
||||
// Arrange:直接"+ 新会话"打开(无列表行),adopted id 不在任何行里。
|
||||
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.unknown")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
coordinator.open(SessionListViewModel.OpenRequest(
|
||||
id: UUID(), host: fixture.host, sessionId: nil
|
||||
))
|
||||
let first = try #require(coordinator.terminalController)
|
||||
await adopt(first, transport: fixture.transport, sessionId: UUID())
|
||||
|
||||
// Act
|
||||
coordinator.openNewSessionInCurrentCwd()
|
||||
|
||||
// Assert
|
||||
let second = try #require(coordinator.terminalController)
|
||||
await awaitAttachSettled(second)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection.count == 2)
|
||||
#expect(byConnection[1] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
|
||||
second.teardown()
|
||||
}
|
||||
|
||||
@Test("服务器行 cwd 非绝对路径(不可信输入)→ 按未知处理,attach(null, nil)")
|
||||
func hostileRelativeCwdIsTreatedAsUnknown() async throws {
|
||||
// Arrange:敌意/损坏的服务器数据 —— cwd 不是绝对路径。
|
||||
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.hostile")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
let sessionId = UUID()
|
||||
try await primeRows(
|
||||
coordinator, fixture: fixture,
|
||||
rows: [sessionJSON(id: sessionId, cwd: "repos/../etc")]
|
||||
)
|
||||
coordinator.open(SessionListViewModel.OpenRequest(
|
||||
id: UUID(), host: fixture.host, sessionId: sessionId
|
||||
))
|
||||
let first = try #require(coordinator.terminalController)
|
||||
await adopt(first, transport: fixture.transport, sessionId: sessionId)
|
||||
|
||||
// Act
|
||||
coordinator.openNewSessionInCurrentCwd()
|
||||
|
||||
// Assert:非法 cwd 绝不透传。
|
||||
let second = try #require(coordinator.terminalController)
|
||||
await awaitAttachSettled(second)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection[1] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
|
||||
second.teardown()
|
||||
}
|
||||
|
||||
@Test("无打开的终端 → 动作 no-op(不 spawn、不连接)")
|
||||
func actionWithoutOpenTerminalIsNoOp() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.noop")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
|
||||
// Act
|
||||
coordinator.openNewSessionInCurrentCwd()
|
||||
|
||||
// Assert
|
||||
#expect(coordinator.terminalController == nil)
|
||||
let attempts = await fixture.transport.connectAttempts
|
||||
#expect(attempts.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 2. exited 会话:回放 + exit 横幅 + 横幅"开新会话"
|
||||
|
||||
@Test("exited 会话点开:回放渲染 + exit 横幅只读 → 动作以该行 cwd 开新会话")
|
||||
func exitedSessionReplaysThenBannerActionReusesCwd() async throws {
|
||||
// Arrange:列表里的 exited 行(manager.ts:145-153——reap 前仍可列出)。
|
||||
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.exited")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
let sessionId = UUID()
|
||||
try await primeRows(
|
||||
coordinator, fixture: fixture,
|
||||
rows: [sessionJSON(id: sessionId, cwd: "/Users/dev/proj", exited: true)]
|
||||
)
|
||||
coordinator.open(SessionListViewModel.OpenRequest(
|
||||
id: UUID(), host: fixture.host, sessionId: sessionId
|
||||
))
|
||||
let first = try #require(coordinator.terminalController)
|
||||
var fed: [String] = []
|
||||
first.terminalViewModel.attachTerminalSink { fed.append($0) }
|
||||
await first.openTask?.value
|
||||
|
||||
// Act:服务器语义 —— 回放 ring buffer,随后补发 exit。
|
||||
await fixture.transport.emit(
|
||||
frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"#
|
||||
)
|
||||
await fixture.transport.emit(frame: #"{"type":"output","data":"[replay] $ make done"}"#)
|
||||
await fixture.transport.emit(frame: #"{"type":"exit","code":0}"#)
|
||||
await first.terminalViewModel.waitUntilProcessed(eventCount: 5)
|
||||
|
||||
// Assert:回放已渲染、exit 横幅只读。
|
||||
#expect(fed == ["[replay] $ make done"])
|
||||
#expect(first.terminalViewModel.bannerModel == .exited(code: 0, reason: nil))
|
||||
#expect(first.terminalViewModel.isReadOnly)
|
||||
|
||||
// Act:横幅"开新会话"(同一 coordinator 动作)。
|
||||
coordinator.openNewSessionInCurrentCwd()
|
||||
|
||||
// Assert:同 cwd 的 fresh spawn。
|
||||
let second = try #require(coordinator.terminalController)
|
||||
await awaitAttachSettled(second)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection[1] == [
|
||||
MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj"))
|
||||
])
|
||||
second.teardown()
|
||||
}
|
||||
|
||||
@Test("横幅'开新会话'只在 exited 态可用(failed/连接态不提供)")
|
||||
func bannerNewSessionAffordanceOnlyWhenExited() {
|
||||
#expect(ReconnectBanner.isNewSessionActionAvailable(for: .exited(code: 0, reason: nil)))
|
||||
#expect(ReconnectBanner.isNewSessionActionAvailable(
|
||||
for: .exited(code: -1, reason: "spawn failed")
|
||||
))
|
||||
#expect(!ReconnectBanner.isNewSessionActionAvailable(for: .connecting))
|
||||
#expect(!ReconnectBanner.isNewSessionActionAvailable(
|
||||
for: .reconnecting(attempt: 1, retryIn: .seconds(1))
|
||||
))
|
||||
#expect(!ReconnectBanner.isNewSessionActionAvailable(for: .failed(message: "x")))
|
||||
}
|
||||
}
|
||||
329
ios/App/WebTermTests/NotificationActionHandlerTests.swift
Normal file
@@ -0,0 +1,329 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import TestSupport
|
||||
import UserNotifications
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-21 · NotificationActionHandler.handle:Allow/Deny → POST
|
||||
/// /hook/decision(背景任务包住、403 → 本地通知兜底)、默认点按 →
|
||||
/// DeepLinkRouter 同源路由到会话(parse 的 payload 级用例在
|
||||
/// NotificationActionParseTests.swift)。
|
||||
/// 说明:UNNotificationResponse 无法在单测构造 —— didReceive 薄胶水不在
|
||||
/// 单测覆盖内,全部逻辑经 parse(actionIdentifier:userInfo:) + handle(_:) 测。
|
||||
@MainActor
|
||||
@Suite("NotificationActionHandler")
|
||||
struct NotificationActionHandlerTests {
|
||||
// MARK: - Fakes
|
||||
|
||||
@MainActor
|
||||
private final class FakeBackgroundTasks: BackgroundTaskRunning {
|
||||
private(set) var begunNames: [String] = []
|
||||
private(set) var endedTokens: [Int] = []
|
||||
private var nextToken = 0
|
||||
|
||||
func begin(name: String) -> Int {
|
||||
begunNames.append(name)
|
||||
nextToken += 1
|
||||
return nextToken
|
||||
}
|
||||
|
||||
func end(_ token: Int) { endedTokens.append(token) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeNoticePoster: LocalNoticePosting {
|
||||
private(set) var posted: [(title: String, body: String)] = []
|
||||
func post(title: String, body: String) async { posted.append((title, body)) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class OpenRecorder {
|
||||
private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = []
|
||||
var actions: NotificationActionHandler.Actions {
|
||||
NotificationActionHandler.Actions(openSession: { [weak self] host, sessionId in
|
||||
self?.opened.append((host, sessionId))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static let sessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||
/// 服务器 capability token = crypto.randomUUID()(src/server.ts:445)→ v4 形状。
|
||||
private static let token = "7c1de1e0-9a2b-4c3d-8e4f-5a6b7c8d9e0f"
|
||||
private static let hostABase = "http://192.168.1.5:3000"
|
||||
private static let hostBBase = "http://192.168.1.6:3000"
|
||||
|
||||
private static func makeHost(base: String, name: String = "mac") throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
private struct Harness {
|
||||
let handler: NotificationActionHandler
|
||||
let http: FakeHTTPTransport
|
||||
let backgroundTasks: FakeBackgroundTasks
|
||||
let notices: FakeNoticePoster
|
||||
let recorder: OpenRecorder
|
||||
}
|
||||
|
||||
private static func makeHarness(hosts: [HostRegistry.Host]) -> Harness {
|
||||
let http = FakeHTTPTransport()
|
||||
let backgroundTasks = FakeBackgroundTasks()
|
||||
let notices = FakeNoticePoster()
|
||||
let recorder = OpenRecorder()
|
||||
let handler = NotificationActionHandler(
|
||||
hostStore: InMemoryHostStore(hosts: hosts),
|
||||
http: http,
|
||||
backgroundTasks: backgroundTasks,
|
||||
notices: notices,
|
||||
actions: recorder.actions
|
||||
)
|
||||
return Harness(
|
||||
handler: handler, http: http, backgroundTasks: backgroundTasks,
|
||||
notices: notices, recorder: recorder
|
||||
)
|
||||
}
|
||||
|
||||
private static func decisionURL(base: String) throws -> URL {
|
||||
try #require(URL(string: "\(base)/hook/decision"))
|
||||
}
|
||||
|
||||
private static func liveSessionsURL(base: String) throws -> URL {
|
||||
try #require(URL(string: "\(base)/live-sessions"))
|
||||
}
|
||||
|
||||
/// 多主机场景的两条 RO 探查响应一次排好(A 无、B 持有目标会话)。
|
||||
private static func queueTwoHostProbe(_ harness: Harness) async throws {
|
||||
await harness.http.queueSuccess(
|
||||
method: "GET", url: try liveSessionsURL(base: hostABase),
|
||||
body: liveSessionsBody(containing: nil)
|
||||
)
|
||||
await harness.http.queueSuccess(
|
||||
method: "GET", url: try liveSessionsURL(base: hostBBase),
|
||||
body: liveSessionsBody(containing: sessionId)
|
||||
)
|
||||
}
|
||||
|
||||
/// /live-sessions 响应体:可选包含目标会话(必填字段齐全的最小形状)。
|
||||
private static func liveSessionsBody(containing sessionId: String?) -> Data {
|
||||
guard let sessionId else { return Data("[]".utf8) }
|
||||
let json = """
|
||||
[{"id":"\(sessionId)","createdAt":1,"clientCount":0,"status":"waiting",
|
||||
"exited":false,"cols":80,"rows":24}]
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
// MARK: - handle(.decision):POST /hook/decision
|
||||
|
||||
@Test("单主机 Allow → 直接 POST(免 RO 探查),体为 {sessionId,decision,token},带 Origin,背景任务成对")
|
||||
func decisionSingleHostPosts() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
await harness.http.queueSuccess(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 204
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
let requests = await harness.http.recordedRequests
|
||||
#expect(requests.count == 1)
|
||||
let request = try #require(requests.first)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == Self.hostABase)
|
||||
let body = try JSONDecoder().decode(
|
||||
[String: String].self, from: try #require(request.httpBody)
|
||||
)
|
||||
#expect(body == [
|
||||
"sessionId": Self.sessionId, "decision": "allow", "token": Self.token,
|
||||
])
|
||||
#expect(harness.backgroundTasks.begunNames.count == 1)
|
||||
#expect(harness.backgroundTasks.endedTokens.count == 1)
|
||||
#expect(harness.notices.posted.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Deny → decision 字段为 deny")
|
||||
func decisionDenyPostsDeny() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
await harness.http.queueSuccess(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 204
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.deny, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
let request = try #require(await harness.http.recordedRequests.first)
|
||||
let body = try JSONDecoder().decode(
|
||||
[String: String].self, from: try #require(request.httpBody)
|
||||
)
|
||||
#expect(body["decision"] == "deny")
|
||||
}
|
||||
|
||||
@Test("403(token 过期/已用)→ 本地通知兜底提示进 App;文案不含 token;背景任务成对")
|
||||
func decisionRejectedPostsExpiredNotice() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
await harness.http.queueSuccess(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 403
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
#expect(harness.notices.posted.count == 1)
|
||||
let notice = try #require(harness.notices.posted.first)
|
||||
#expect(notice.title == PushDecisionCopy.expiredTitle)
|
||||
#expect(notice.body == PushDecisionCopy.expiredBody)
|
||||
// capability token 用后即弃:绝不出现在任何兜底文案里。
|
||||
#expect(!notice.body.contains(Self.token) && !notice.title.contains(Self.token))
|
||||
#expect(harness.backgroundTasks.endedTokens.count == 1)
|
||||
}
|
||||
|
||||
@Test("传输层失败 → 本地通知兜底(失败可见,绝不静默吞);背景任务成对")
|
||||
func decisionTransportFailurePostsFailedNotice() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
await harness.http.queueFailure(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostABase),
|
||||
error: URLError(.cannotConnectToHost)
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
#expect(harness.notices.posted.count == 1)
|
||||
#expect(harness.notices.posted.first?.title == PushDecisionCopy.failedTitle)
|
||||
#expect(harness.backgroundTasks.begunNames.count == 1)
|
||||
#expect(harness.backgroundTasks.endedTokens.count == 1)
|
||||
}
|
||||
|
||||
@Test("多主机 → 先 RO 查 /live-sessions(无 Origin)定位持有会话的主机,再对其 POST")
|
||||
func decisionMultiHostResolvesOwner() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
|
||||
let harness = Self.makeHarness(hosts: [hostA, hostB])
|
||||
try await Self.queueTwoHostProbe(harness)
|
||||
await harness.http.queueSuccess(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostBBase), status: 204
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
let requests = await harness.http.recordedRequests
|
||||
let gets = requests.filter { $0.httpMethod == "GET" }
|
||||
#expect(gets.count == 2)
|
||||
for get in gets { // RO 铁律:探查一律不带 Origin
|
||||
#expect(get.value(forHTTPHeaderField: "Origin") == nil)
|
||||
}
|
||||
let posts = requests.filter { $0.httpMethod == "POST" }
|
||||
#expect(posts.count == 1)
|
||||
#expect(posts.first?.url == (try Self.decisionURL(base: Self.hostBBase)))
|
||||
#expect(harness.notices.posted.isEmpty)
|
||||
}
|
||||
|
||||
@Test("多主机但会话无处可寻 → 不 POST,本地通知兜底")
|
||||
func decisionUnresolvableHostFailsVisibly() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
|
||||
let harness = Self.makeHarness(hosts: [hostA, hostB])
|
||||
await harness.http.queueSuccess(
|
||||
method: "GET", url: try Self.liveSessionsURL(base: Self.hostABase),
|
||||
body: Self.liveSessionsBody(containing: nil)
|
||||
)
|
||||
await harness.http.queueSuccess(
|
||||
method: "GET", url: try Self.liveSessionsURL(base: Self.hostBBase),
|
||||
body: Self.liveSessionsBody(containing: nil)
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
let posts = await harness.http.recordedRequests.filter { $0.httpMethod == "POST" }
|
||||
#expect(posts.isEmpty)
|
||||
#expect(harness.notices.posted.first?.title == PushDecisionCopy.failedTitle)
|
||||
}
|
||||
|
||||
// MARK: - handle(.openSession):默认点按路由
|
||||
|
||||
@Test("单主机点按 → openSession(host, id),不发决策 POST,也不开背景任务")
|
||||
func openSessionSingleHostRoutes() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(.openSession(sessionId: sessionId))
|
||||
|
||||
#expect(harness.recorder.opened.count == 1)
|
||||
#expect(harness.recorder.opened.first?.host == host)
|
||||
#expect(harness.recorder.opened.first?.sessionId == sessionId)
|
||||
#expect(await harness.http.recordedRequests.isEmpty)
|
||||
#expect(harness.backgroundTasks.begunNames.isEmpty)
|
||||
}
|
||||
|
||||
@Test("多主机点按 → RO 定位后打开持有主机")
|
||||
func openSessionMultiHostResolves() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
|
||||
let harness = Self.makeHarness(hosts: [hostA, hostB])
|
||||
try await Self.queueTwoHostProbe(harness)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(.openSession(sessionId: sessionId))
|
||||
|
||||
#expect(harness.recorder.opened.first?.host == hostB)
|
||||
}
|
||||
|
||||
@Test("无主机可配 → 不打开、不 crash(App 已被点按拉起,落在列表页即可)")
|
||||
func openSessionUnresolvedIsSafeNoOp() async throws {
|
||||
let harness = Self.makeHarness(hosts: [])
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(.openSession(sessionId: sessionId))
|
||||
|
||||
#expect(harness.recorder.opened.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 其余分支
|
||||
|
||||
@Test(".invalidPayload → 计数 +1,无网络、无通知、无路由")
|
||||
func invalidPayloadCountedOnly() async throws {
|
||||
let harness = Self.makeHarness(hosts: [])
|
||||
|
||||
await harness.handler.handle(.invalidPayload)
|
||||
|
||||
#expect(harness.handler.invalidPayloadCount == 1)
|
||||
#expect(await harness.http.recordedRequests.isEmpty)
|
||||
#expect(harness.notices.posted.isEmpty)
|
||||
#expect(harness.recorder.opened.isEmpty)
|
||||
}
|
||||
|
||||
@Test(".dismissed → 完全 no-op")
|
||||
func dismissedIsNoOp() async throws {
|
||||
let harness = Self.makeHarness(hosts: [])
|
||||
|
||||
await harness.handler.handle(.dismissed)
|
||||
|
||||
#expect(harness.handler.invalidPayloadCount == 0)
|
||||
#expect(await harness.http.recordedRequests.isEmpty)
|
||||
}
|
||||
}
|
||||
104
ios/App/WebTermTests/NotificationActionParseTests.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
import UserNotifications
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-21 · `NotificationActionHandler.parse`:payload 级白名单解析
|
||||
///(push payload 是不可信外部输入——sessionId 复用 DeepLinkRouter 的冻结
|
||||
/// v4 校验,token 校验 v4 形状后原样透传,任一非法 → .invalidPayload)。
|
||||
@MainActor
|
||||
@Suite("NotificationActionHandler.parse")
|
||||
struct NotificationActionParseTests {
|
||||
private static let sessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||
/// 服务器 capability token = crypto.randomUUID()(src/server.ts:445)→ v4 形状。
|
||||
private static let token = "7c1de1e0-9a2b-4c3d-8e4f-5a6b7c8d9e0f"
|
||||
|
||||
private static func gateUserInfo(
|
||||
sessionId: String = sessionId, token: Any? = token
|
||||
) -> [AnyHashable: Any] {
|
||||
var info: [AnyHashable: Any] = [
|
||||
"aps": ["category": "WEBTERM_GATE"],
|
||||
"sessionId": sessionId,
|
||||
"cls": "needs-input",
|
||||
]
|
||||
if let token { info["token"] = token }
|
||||
return info
|
||||
}
|
||||
|
||||
@Test("Allow 动作 + 合法 payload → .decision(.allow)")
|
||||
func parseAllowAction() throws {
|
||||
let parsed = NotificationActionHandler.parse(
|
||||
actionIdentifier: GateNotificationCategory.allowActionId,
|
||||
userInfo: Self.gateUserInfo()
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
#expect(parsed == .decision(.allow, sessionId: sessionId, token: Self.token))
|
||||
}
|
||||
|
||||
@Test("Deny 动作 + 合法 payload → .decision(.deny)")
|
||||
func parseDenyAction() throws {
|
||||
let parsed = NotificationActionHandler.parse(
|
||||
actionIdentifier: GateNotificationCategory.denyActionId,
|
||||
userInfo: Self.gateUserInfo()
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
#expect(parsed == .decision(.deny, sessionId: sessionId, token: Self.token))
|
||||
}
|
||||
|
||||
@Test("决策动作缺 token / token 非字符串 / token 非 v4 形状 → .invalidPayload")
|
||||
func parseDecisionRejectsBadToken() {
|
||||
for bad in [nil, 42 as Any, "not-a-token", "11111111-2222-1333-8444-555555555555"] {
|
||||
let parsed = NotificationActionHandler.parse(
|
||||
actionIdentifier: GateNotificationCategory.allowActionId,
|
||||
userInfo: Self.gateUserInfo(token: bad)
|
||||
)
|
||||
#expect(parsed == .invalidPayload)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("决策动作 sessionId 非法(非 v4 / 缺失)→ .invalidPayload")
|
||||
func parseDecisionRejectsBadSessionId() {
|
||||
let badId = NotificationActionHandler.parse(
|
||||
actionIdentifier: GateNotificationCategory.allowActionId,
|
||||
userInfo: Self.gateUserInfo(sessionId: "garbage")
|
||||
)
|
||||
#expect(badId == .invalidPayload)
|
||||
let missing = NotificationActionHandler.parse(
|
||||
actionIdentifier: GateNotificationCategory.denyActionId,
|
||||
userInfo: ["token": Self.token]
|
||||
)
|
||||
#expect(missing == .invalidPayload)
|
||||
}
|
||||
|
||||
@Test("默认点按 + 合法 sessionId → .openSession(复用 DeepLinkRouter 同一解析)")
|
||||
func parseDefaultTapRoutes() throws {
|
||||
let parsed = NotificationActionHandler.parse(
|
||||
actionIdentifier: UNNotificationDefaultActionIdentifier,
|
||||
userInfo: Self.gateUserInfo(token: nil) // done 类通知无 token,点按仍可路由
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
#expect(parsed == .openSession(sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("默认点按 + 非法 payload → .invalidPayload")
|
||||
func parseDefaultTapRejectsGarbage() {
|
||||
let parsed = NotificationActionHandler.parse(
|
||||
actionIdentifier: UNNotificationDefaultActionIdentifier,
|
||||
userInfo: ["sessionId": "'; DROP TABLE sessions;--"]
|
||||
)
|
||||
#expect(parsed == .invalidPayload)
|
||||
}
|
||||
|
||||
@Test("dismiss / 未知动作 id → .dismissed(no-op)")
|
||||
func parseDismissAndUnknownActions() {
|
||||
#expect(NotificationActionHandler.parse(
|
||||
actionIdentifier: UNNotificationDismissActionIdentifier,
|
||||
userInfo: Self.gateUserInfo()
|
||||
) == .dismissed)
|
||||
#expect(NotificationActionHandler.parse(
|
||||
actionIdentifier: "EVIL_ACTION", userInfo: Self.gateUserInfo()
|
||||
) == .dismissed)
|
||||
}
|
||||
}
|
||||
433
ios/App/WebTermTests/PairingViewModelTests.swift
Normal file
@@ -0,0 +1,433 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-12 · PairingViewModel (plan §7 / §5.4). Probe LOGIC is T-iOS-8's
|
||||
/// domain — these tests cover the state mapping around it:
|
||||
/// scan/manual input → confirm gate (ZERO network before the user says go) →
|
||||
/// probe → Host into the store + navigate signal, error taxonomy → copy +
|
||||
/// action, and the §5.4 warning tiers.
|
||||
///
|
||||
/// Determinism: the probe is injected as a closure; scripted results come from
|
||||
/// an actor-backed `ProbeScript` (also the non-invocation counter). The
|
||||
/// end-to-end test runs the REAL `runPairingProbe` over `FakeHTTPTransport` +
|
||||
/// `FakeTransport` — zero real network, zero real waits.
|
||||
@MainActor
|
||||
@Suite("PairingViewModel")
|
||||
struct PairingViewModelTests {
|
||||
// MARK: - Probe script (scripted results + invocation recording)
|
||||
|
||||
private actor ProbeScript {
|
||||
private(set) var calls: [HostEndpoint] = []
|
||||
private var results: [Result<HostEndpoint, PairingError>]
|
||||
|
||||
/// Empty `results` = always succeed with the probed endpoint.
|
||||
init(results: [Result<HostEndpoint, PairingError>] = []) {
|
||||
self.results = results
|
||||
}
|
||||
|
||||
func invoke(_ endpoint: HostEndpoint) -> Result<HostEndpoint, PairingError> {
|
||||
calls = calls + [endpoint]
|
||||
guard let next = results.first else { return .success(endpoint) }
|
||||
results = Array(results.dropFirst())
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
private func makeViewModel(
|
||||
store: any HostStore = InMemoryHostStore(),
|
||||
script: ProbeScript
|
||||
) -> PairingViewModel {
|
||||
PairingViewModel(store: store, probe: { await script.invoke($0) })
|
||||
}
|
||||
|
||||
private struct StoreFailure: Error {}
|
||||
|
||||
private actor ThrowingHostStore: HostStore {
|
||||
func loadAll() async throws -> [HostRegistry.Host] { [] }
|
||||
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
|
||||
throw StoreFailure()
|
||||
}
|
||||
func remove(id: UUID) async throws -> [HostRegistry.Host] { [] }
|
||||
}
|
||||
|
||||
// MARK: - Scan → confirm gate → REAL two-step probe → store + navigate
|
||||
|
||||
@Test("scan shows the HostEndpoint-parsed address, no network until confirm; then probe → Host into store + navigate signal")
|
||||
func scanConfirmGateThenRealProbePairsHost() async throws {
|
||||
// Arrange: REAL runPairingProbe over fakes — the strongest proof that
|
||||
// (a) nothing touches the wire before the user confirms and (b) the
|
||||
// production closure wiring is exercised end to end.
|
||||
let http = FakeHTTPTransport()
|
||||
let ws = FakeTransport()
|
||||
let store = InMemoryHostStore()
|
||||
let viewModel = PairingViewModel(
|
||||
store: store,
|
||||
probe: { await runPairingProbe(endpoint: $0, http: http, ws: ws) }
|
||||
)
|
||||
let base = "http://192.168.1.5:3000"
|
||||
let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
|
||||
// Script the full happy path UP FRONT — if the VM probed before
|
||||
// confirm, recordedRequests would already be non-empty below.
|
||||
await http.queueSuccess(
|
||||
url: try #require(URL(string: "\(base)/live-sessions")),
|
||||
body: Data("[]".utf8)
|
||||
)
|
||||
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(probeSessionId)"}"#)
|
||||
await http.queueSuccess(
|
||||
method: "DELETE",
|
||||
url: try #require(URL(string: "\(base)/live-sessions/\(probeSessionId)")),
|
||||
status: 204
|
||||
)
|
||||
|
||||
// Act: scan the web UI QR payload (public/qr.ts encodes location.origin).
|
||||
viewModel.handleScannedCode(base)
|
||||
|
||||
// Assert: confirm state shows the single-point-derived address and the
|
||||
// scanned host has seen ZERO network traffic (probe ① would GET, probe
|
||||
// ② would spawn a PTY on the target — untrusted scan input, plan §5).
|
||||
guard case .confirming(let pending) = viewModel.phase else {
|
||||
Issue.record("expected .confirming, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(pending.displayAddress == base)
|
||||
#expect(pending.endpoint.originHeader == base)
|
||||
#expect(pending.warning == .plaintextLAN)
|
||||
#expect(await http.recordedRequests.isEmpty)
|
||||
#expect(await ws.connectAttempts.isEmpty)
|
||||
#expect(viewModel.pairedHost == nil)
|
||||
|
||||
// Act: name the host, then confirm — ONLY now may the probe run.
|
||||
viewModel.hostName = "书房 Mac"
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
// Assert: paired + navigate signal; Host{id,name} constructed by the
|
||||
// VM (§3.4 contract ruling) and upserted into the store.
|
||||
guard case .paired(let host) = viewModel.phase else {
|
||||
Issue.record("expected .paired, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(host.name == "书房 Mac")
|
||||
#expect(host.endpoint == pending.endpoint)
|
||||
#expect(viewModel.pairedHost == host)
|
||||
#expect(try await store.loadAll() == [host])
|
||||
|
||||
// Assert: exactly the two probe HTTP calls, in order, Origin stamped
|
||||
// iff guarded (§3.4 铁律) — and one WS attach round-trip, closed.
|
||||
let requests = await http.recordedRequests
|
||||
#expect(requests.map(\.httpMethod) == ["GET", "DELETE"])
|
||||
#expect(requests[0].value(forHTTPHeaderField: "Origin") == nil)
|
||||
#expect(requests[1].value(forHTTPHeaderField: "Origin") == base)
|
||||
#expect(await ws.connectAttempts.count == 1)
|
||||
#expect(await ws.closeCallCount == 1)
|
||||
|
||||
// Assert: a stray scan cannot preempt a finished pairing.
|
||||
viewModel.handleScannedCode("http://10.0.0.9:3000")
|
||||
#expect(viewModel.phase == .paired(host))
|
||||
}
|
||||
|
||||
// MARK: - Input boundary: scan payloads are untrusted external input
|
||||
|
||||
@Test("non-http(s) scan payloads are rejected with copy and zero probe calls", arguments: [
|
||||
"ftp://192.168.1.5:3000",
|
||||
"ws://192.168.1.5:3000",
|
||||
"javascript:alert(1)",
|
||||
"WIFI:S:mynet;T:WPA;P:hunter2;;",
|
||||
"",
|
||||
])
|
||||
func scanRejectsNonHTTPPayloads(payload: String) async throws {
|
||||
// Arrange
|
||||
let script = ProbeScript()
|
||||
let viewModel = makeViewModel(script: script)
|
||||
|
||||
// Act
|
||||
viewModel.handleScannedCode(payload)
|
||||
|
||||
// Assert: stays idle, inline rejection copy, probe never invoked.
|
||||
#expect(viewModel.phase == .idle)
|
||||
#expect(viewModel.inputRejection == PairingCopy.scanRejected)
|
||||
#expect(await script.calls.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Manual entry (documented decision: reuses the confirm state)
|
||||
|
||||
@Test("manual entry reuses the confirm state; a bare host:port gets the http:// convenience prefix", arguments: [
|
||||
("http://192.168.1.5:3000", "http://192.168.1.5:3000"),
|
||||
("192.168.1.5:3000", "http://192.168.1.5:3000"),
|
||||
("https://mac.tail1234.ts.net", "https://mac.tail1234.ts.net"),
|
||||
])
|
||||
func manualEntryEntersConfirmState(input: String, expectedOrigin: String) async throws {
|
||||
// Arrange
|
||||
let script = ProbeScript()
|
||||
let viewModel = makeViewModel(script: script)
|
||||
|
||||
// Act
|
||||
viewModel.submitManualURL(input)
|
||||
|
||||
// Assert: SAME confirm state as the scan path (uniform §5.4 warning
|
||||
// surface — documented T-iOS-12 decision), still zero probe calls.
|
||||
guard case .confirming(let pending) = viewModel.phase else {
|
||||
Issue.record("expected .confirming for \(input), got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(pending.endpoint.originHeader == expectedOrigin)
|
||||
#expect(await script.calls.isEmpty)
|
||||
}
|
||||
|
||||
@Test("unparseable manual input is rejected with copy", arguments: [
|
||||
"", " ", "://nope", "http://",
|
||||
])
|
||||
func manualEntryRejectsUnparseableInput(input: String) async throws {
|
||||
// Arrange
|
||||
let script = ProbeScript()
|
||||
let viewModel = makeViewModel(script: script)
|
||||
|
||||
// Act
|
||||
viewModel.submitManualURL(input)
|
||||
|
||||
// Assert
|
||||
#expect(viewModel.phase == .idle)
|
||||
#expect(viewModel.inputRejection == PairingCopy.manualRejected)
|
||||
#expect(await script.calls.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - §5.4 warning tiers (shown on the confirm page)
|
||||
|
||||
@Test("warning tiers follow the §5.4 table", arguments: [
|
||||
// public host → strongest BLOCKING warning, http AND https alike
|
||||
("http://203.0.113.7:3000", PairingViewModel.SecurityWarning.publicHostBlocking),
|
||||
("https://example.com", PairingViewModel.SecurityWarning.publicHostBlocking),
|
||||
// ws:// to RFC1918 / link-local / .local → non-blocking plaintext notice
|
||||
("http://192.168.1.5:3000", PairingViewModel.SecurityWarning.plaintextLAN),
|
||||
("http://10.1.2.3:3000", PairingViewModel.SecurityWarning.plaintextLAN),
|
||||
("http://172.20.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
|
||||
("http://169.254.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
|
||||
("http://mymac.local:3000", PairingViewModel.SecurityWarning.plaintextLAN),
|
||||
// Tailscale (100.64/10 CGNAT or MagicDNS *.ts.net) → no plaintext
|
||||
// warning (WireGuard already encrypts); positive badge instead
|
||||
("http://100.101.102.103:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
|
||||
("http://mac.tail1234.ts.net:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
|
||||
// loopback → none; https to a private-class host → none
|
||||
("http://127.0.0.1:3000", PairingViewModel.SecurityWarning.none),
|
||||
("http://localhost:3000", PairingViewModel.SecurityWarning.none),
|
||||
("https://192.168.1.5:3000", PairingViewModel.SecurityWarning.none),
|
||||
("https://mac.tail1234.ts.net", PairingViewModel.SecurityWarning.none),
|
||||
])
|
||||
func warningTiersFollowTable(url: String, expected: PairingViewModel.SecurityWarning) throws {
|
||||
// Arrange
|
||||
let baseURL = try #require(URL(string: url))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
|
||||
// Act & Assert
|
||||
#expect(PairingViewModel.warning(for: endpoint) == expected)
|
||||
}
|
||||
|
||||
@Test("public-host blocking warning requires explicit acknowledgement before any probe")
|
||||
func blockingWarningGatesTheProbe() async throws {
|
||||
// Arrange
|
||||
let script = ProbeScript()
|
||||
let viewModel = makeViewModel(script: script)
|
||||
viewModel.handleScannedCode("http://203.0.113.7:3000")
|
||||
guard case .confirming(let pending) = viewModel.phase else {
|
||||
Issue.record("expected .confirming, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(pending.warning == .publicHostBlocking)
|
||||
|
||||
// Act: confirm WITHOUT acknowledging the risk.
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
// Assert: no probe, still confirming, the UI is told to demand the ack.
|
||||
#expect(await script.calls.isEmpty)
|
||||
#expect(viewModel.phase == .confirming(pending))
|
||||
#expect(viewModel.needsPublicRiskAcknowledgement)
|
||||
|
||||
// Act: explicit acknowledgement, then confirm again.
|
||||
viewModel.hasAcknowledgedPublicRisk = true
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
// Assert: probe ran exactly once and pairing completed.
|
||||
#expect(await script.calls.count == 1)
|
||||
guard case .paired = viewModel.phase else {
|
||||
Issue.record("expected .paired, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PairingError taxonomy → inline copy + recovery action
|
||||
|
||||
@Test("every PairingError maps to actionable copy and the right recovery action", arguments: [
|
||||
(PairingError.localNetworkDenied,
|
||||
PairingViewModel.RecoveryAction.openLocalNetworkSettings,
|
||||
["本地网络", "设置"]),
|
||||
(PairingError.hostUnreachable(underlying: "Connection refused"),
|
||||
PairingViewModel.RecoveryAction.retry,
|
||||
["Connection refused"]),
|
||||
(PairingError.httpOkButNotWebTerminal,
|
||||
PairingViewModel.RecoveryAction.retry,
|
||||
["端口"]),
|
||||
(PairingError.originRejected(hint: "在主机加 ALLOWED_ORIGINS=http://192.168.1.5:3000"),
|
||||
PairingViewModel.RecoveryAction.retry,
|
||||
["ALLOWED_ORIGINS=http://192.168.1.5:3000"]),
|
||||
(PairingError.atsBlocked(host: "198.18.0.1"),
|
||||
PairingViewModel.RecoveryAction.retry,
|
||||
["ATS", "198.18.0.1", "tailscale serve", "例外"]),
|
||||
(PairingError.tlsFailure,
|
||||
PairingViewModel.RecoveryAction.retry,
|
||||
["TLS"]),
|
||||
(PairingError.timeout,
|
||||
PairingViewModel.RecoveryAction.retry,
|
||||
["超时"]),
|
||||
])
|
||||
func pairingErrorMapsToCopyAndAction(
|
||||
error: PairingError,
|
||||
expectedAction: PairingViewModel.RecoveryAction,
|
||||
requiredFragments: [String]
|
||||
) async throws {
|
||||
// Arrange: private-class host so no blocking-warning gate interferes.
|
||||
let script = ProbeScript(results: [.failure(error)])
|
||||
let viewModel = makeViewModel(script: script)
|
||||
viewModel.handleScannedCode("http://192.168.1.5:3000")
|
||||
|
||||
// Act
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
// Assert
|
||||
guard case .failed(_, let failure) = viewModel.phase else {
|
||||
Issue.record("expected .failed for \(error), got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(failure.action == expectedAction)
|
||||
#expect(!failure.message.isEmpty)
|
||||
for fragment in requiredFragments {
|
||||
#expect(failure.message.contains(fragment),
|
||||
"copy for \(error) must contain \(fragment)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("originRejected surfaces the probe's hint VERBATIM as the whole message")
|
||||
func originRejectedHintIsVerbatim() async throws {
|
||||
// Arrange: the hint the probe derives from endpoint.originHeader is
|
||||
// already the complete actionable copy — never rewrap or re-derive it.
|
||||
let hint = "服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=http://192.168.1.5:3000"
|
||||
+ "(与 App 连接的 URL 完全一致)后重启 web-terminal,再重试配对。"
|
||||
let script = ProbeScript(results: [.failure(.originRejected(hint: hint))])
|
||||
let viewModel = makeViewModel(script: script)
|
||||
viewModel.handleScannedCode("http://192.168.1.5:3000")
|
||||
|
||||
// Act
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
// Assert
|
||||
guard case .failed(_, let failure) = viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(failure.message == hint)
|
||||
}
|
||||
|
||||
// MARK: - Retry / cancel
|
||||
|
||||
@Test("retry re-runs the probe against the same endpoint and can succeed")
|
||||
func retryRerunsProbeAfterFailure() async throws {
|
||||
// Arrange: first probe times out, second succeeds.
|
||||
let script = ProbeScript(results: [.failure(.timeout)])
|
||||
let store = InMemoryHostStore()
|
||||
let viewModel = makeViewModel(store: store, script: script)
|
||||
viewModel.handleScannedCode("http://192.168.1.5:3000")
|
||||
await viewModel.confirmConnect()
|
||||
guard case .failed = viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
|
||||
// Act
|
||||
await viewModel.retry()
|
||||
|
||||
// Assert: two probe calls, same endpoint, pairing completed.
|
||||
let calls = await script.calls
|
||||
#expect(calls.count == 2)
|
||||
#expect(calls.first == calls.last)
|
||||
guard case .paired(let host) = viewModel.phase else {
|
||||
Issue.record("expected .paired, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(try await store.loadAll() == [host])
|
||||
}
|
||||
|
||||
@Test("cancel returns to idle without ever probing")
|
||||
func cancelReturnsToIdleWithoutProbe() async throws {
|
||||
// Arrange
|
||||
let script = ProbeScript()
|
||||
let viewModel = makeViewModel(script: script)
|
||||
viewModel.handleScannedCode("http://192.168.1.5:3000")
|
||||
|
||||
// Act
|
||||
viewModel.cancel()
|
||||
|
||||
// Assert
|
||||
#expect(viewModel.phase == .idle)
|
||||
#expect(viewModel.inputRejection == nil)
|
||||
#expect(await script.calls.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Store failure is explicit, never silent
|
||||
|
||||
@Test("a store failure after a successful probe surfaces an explicit retryable error")
|
||||
func storeFailureSurfacesExplicitError() async throws {
|
||||
// Arrange
|
||||
let script = ProbeScript()
|
||||
let viewModel = makeViewModel(store: ThrowingHostStore(), script: script)
|
||||
viewModel.handleScannedCode("http://192.168.1.5:3000")
|
||||
|
||||
// Act
|
||||
await viewModel.confirmConnect()
|
||||
|
||||
// Assert: failed with the dedicated copy; no navigate signal.
|
||||
guard case .failed(_, let failure) = viewModel.phase else {
|
||||
Issue.record("expected .failed, got \(viewModel.phase)")
|
||||
return
|
||||
}
|
||||
#expect(failure.message == PairingCopy.storeFailed)
|
||||
#expect(failure.action == .retry)
|
||||
#expect(viewModel.pairedHost == nil)
|
||||
}
|
||||
|
||||
// MARK: - Host naming
|
||||
|
||||
@Test("host name defaults to the endpoint host and user names are trimmed")
|
||||
func hostNameDefaultsAndTrims() async throws {
|
||||
// Arrange & Act: untouched name → default = endpoint host.
|
||||
let script = ProbeScript()
|
||||
let storeA = InMemoryHostStore()
|
||||
let viewModelA = makeViewModel(store: storeA, script: script)
|
||||
viewModelA.handleScannedCode("http://192.168.1.5:3000")
|
||||
#expect(viewModelA.hostName == "192.168.1.5")
|
||||
await viewModelA.confirmConnect()
|
||||
|
||||
// Assert
|
||||
guard case .paired(let defaultNamed) = viewModelA.phase else {
|
||||
Issue.record("expected .paired, got \(viewModelA.phase)")
|
||||
return
|
||||
}
|
||||
#expect(defaultNamed.name == "192.168.1.5")
|
||||
|
||||
// Arrange & Act: user-typed name is trimmed before storing.
|
||||
let storeB = InMemoryHostStore()
|
||||
let viewModelB = makeViewModel(store: storeB, script: script)
|
||||
viewModelB.handleScannedCode("http://192.168.1.5:3000")
|
||||
viewModelB.hostName = " 书房 Mac "
|
||||
await viewModelB.confirmConnect()
|
||||
|
||||
// Assert
|
||||
guard case .paired(let userNamed) = viewModelB.phase else {
|
||||
Issue.record("expected .paired, got \(viewModelB.phase)")
|
||||
return
|
||||
}
|
||||
#expect(userNamed.name == "书房 Mac")
|
||||
}
|
||||
}
|
||||
29
ios/App/WebTermTests/PrivacyShadeTests.swift
Normal file
@@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-15 · Privacy-shade state mapping (plan §7 T-iOS-15, security-critical).
|
||||
///
|
||||
/// The rule under test is EXACT: the shade covers whenever
|
||||
/// `scenePhase != .active` — NOT just `.inactive`. Entering the app switcher
|
||||
/// is `.inactive`, but the moment iOS writes the switcher snapshot to disk is
|
||||
/// `.background`; covering only one of the two leaks terminal content
|
||||
/// (API keys / tokens / source) into the on-disk snapshot.
|
||||
@MainActor
|
||||
@Suite("PrivacyShadePolicy")
|
||||
struct PrivacyShadeTests {
|
||||
@Test("scenePhase == .active → 遮罩隐藏(恢复终端)")
|
||||
func activeShowsTerminal() {
|
||||
#expect(!PrivacyShadePolicy.isShadeVisible(for: .active))
|
||||
}
|
||||
|
||||
@Test("scenePhase == .inactive → 遮罩可见(切换器入口态)")
|
||||
func inactiveIsCovered() {
|
||||
#expect(PrivacyShadePolicy.isShadeVisible(for: .inactive))
|
||||
}
|
||||
|
||||
@Test("scenePhase == .background → 遮罩可见(快照写盘时刻)")
|
||||
func backgroundIsCovered() {
|
||||
#expect(PrivacyShadePolicy.isShadeVisible(for: .background))
|
||||
}
|
||||
}
|
||||
118
ios/App/WebTermTests/ProjectDetailViewModelTests.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-26 · ProjectDetailViewModel(详情 phase 状态机 + 400/404/500 显式
|
||||
/// 错误路径,镜像 DiffViewModel 的四结局纪律)。
|
||||
///
|
||||
/// fetch 闭包注入(生产由 `forHost` 包 `APIClient.projectDetail(path:)`,
|
||||
/// 其 builder/解码/状态码映射已在 APIClient 包内测过 —— 此处只测 VM 归约)。
|
||||
@MainActor
|
||||
@Suite("ProjectDetailViewModel")
|
||||
struct ProjectDetailViewModelTests {
|
||||
private nonisolated static func detail(
|
||||
sessions: [ProjectSessionRef] = [],
|
||||
worktrees: [WorktreeInfo] = [],
|
||||
hasClaudeMd: Bool = false
|
||||
) -> ProjectDetail {
|
||||
ProjectDetail(
|
||||
name: "web-terminal", path: "/repos/web-terminal", isGit: true,
|
||||
branch: "main", dirty: true, worktrees: worktrees,
|
||||
sessions: sessions, hasClaudeMd: hasClaudeMd, claudeMd: nil
|
||||
)
|
||||
}
|
||||
|
||||
@Test("初始 .loading;load 成功 → .loaded(sessions/worktrees/hasClaudeMd 透传)")
|
||||
func loadSuccess() async throws {
|
||||
let payload = Self.detail(
|
||||
sessions: [ProjectSessionRef(
|
||||
id: UUID(), title: "web-terminal", status: .working,
|
||||
clientCount: 2, createdAt: 1, exited: false
|
||||
)],
|
||||
worktrees: [WorktreeInfo(
|
||||
path: "/repos/wt", branch: "feat/x", head: "abc",
|
||||
isMain: false, isCurrent: true, locked: nil, prunable: nil
|
||||
)],
|
||||
hasClaudeMd: true
|
||||
)
|
||||
let vm = ProjectDetailViewModel(path: payload.path, fetch: { payload })
|
||||
#expect(vm.phase == .loading)
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .loaded(payload))
|
||||
}
|
||||
|
||||
@Test(
|
||||
"错误映射:400→pathInvalid、404→notFound、500/解码/传输→unavailable",
|
||||
arguments: [
|
||||
(APIClientError.projectPathInvalid, ProjectDetailViewModel.Failure.pathInvalid),
|
||||
(APIClientError.projectNotFound, .notFound),
|
||||
(APIClientError.projectDetailUnavailable, .unavailable),
|
||||
(APIClientError.invalidResponseBody, .unavailable),
|
||||
]
|
||||
)
|
||||
func errorMapping(
|
||||
error: APIClientError, expected: ProjectDetailViewModel.Failure
|
||||
) async {
|
||||
let vm = ProjectDetailViewModel(path: "/p", fetch: { throw error })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .failed(expected))
|
||||
}
|
||||
|
||||
@Test("传输层任意错误 → .unavailable(可重试兜底,绝不 crash)")
|
||||
func transportErrorFallsBackToUnavailable() async {
|
||||
let vm = ProjectDetailViewModel(
|
||||
path: "/p", fetch: { throw URLError(.notConnectedToInternet) }
|
||||
)
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .failed(.unavailable))
|
||||
}
|
||||
|
||||
@Test("重试路径:failed 后再次 load 成功 → loaded")
|
||||
func retryAfterFailure() async {
|
||||
let flag = FailOnceFlag()
|
||||
let payload = Self.detail()
|
||||
let vm = ProjectDetailViewModel(path: payload.path, fetch: {
|
||||
if await flag.consumeShouldFail() { throw APIClientError.projectDetailUnavailable }
|
||||
return payload
|
||||
})
|
||||
|
||||
await vm.load()
|
||||
#expect(vm.phase == .failed(.unavailable))
|
||||
|
||||
await vm.load()
|
||||
#expect(vm.phase == .loaded(payload))
|
||||
}
|
||||
|
||||
@Test("failureCopy:三种失败各有非空、互不相同的中文文案")
|
||||
func failureCopyIsDistinct() {
|
||||
let copies = [
|
||||
ProjectDetailScreen.failureCopy(.pathInvalid),
|
||||
ProjectDetailScreen.failureCopy(.notFound),
|
||||
ProjectDetailScreen.failureCopy(.unavailable),
|
||||
]
|
||||
|
||||
for copy in copies {
|
||||
#expect(!copy.title.isEmpty)
|
||||
#expect(!copy.detail.isEmpty)
|
||||
}
|
||||
#expect(Set(copies.map(\.title)).count == copies.count)
|
||||
}
|
||||
}
|
||||
|
||||
/// @Sendable fetch 闭包里的可变一次性失败开关(actor 隔离)。
|
||||
private actor FailOnceFlag {
|
||||
private var shouldFail = true
|
||||
|
||||
func consumeShouldFail() -> Bool {
|
||||
defer { shouldFail = false }
|
||||
return shouldFail
|
||||
}
|
||||
}
|
||||
211
ios/App/WebTermTests/ProjectGroupingTests.swift
Normal file
@@ -0,0 +1,211 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-26 · Projects 列表的纯分组逻辑 —— 逐条镜像 web v0.6 的
|
||||
/// public/projects.ts 规则(filterProjects / sortProjects / groupProjects /
|
||||
/// displayLabel):
|
||||
/// - namespace = 名字的前两个点分段(不足两段 → 无 namespace);
|
||||
/// - 成员 < MIN_GROUP_SIZE(2) 的 namespace 塌进 Other;
|
||||
/// - 有运行中会话的项目**复制**进置顶的 "Active now" 组(sessions[].exited
|
||||
/// 字段实测存在 —— active 置顶按任务要求 assert reality 后实现);
|
||||
/// - 一个 namespace 组都没有 → 单一 flat 组(无 chrome 的平铺网格回退);
|
||||
/// - 组 key 与 web 逐字节一致(" active" / " other" / namespace 原始大小写),
|
||||
/// 因为 collapsed 状态经 /prefs 跨端共享,key 不一致就互相丢状态。
|
||||
struct ProjectGroupingTests {
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static func session(exited: Bool) -> ProjectSessionRef {
|
||||
ProjectSessionRef(
|
||||
id: UUID(), title: nil, status: .working,
|
||||
clientCount: 1, createdAt: 0, exited: exited
|
||||
)
|
||||
}
|
||||
|
||||
private static func project(
|
||||
_ name: String,
|
||||
path: String? = nil,
|
||||
lastActiveMs: Int? = nil,
|
||||
running: Bool = false,
|
||||
exitedSession: Bool = false
|
||||
) -> ProjectInfo {
|
||||
var sessions: [ProjectSessionRef] = []
|
||||
if running { sessions.append(session(exited: false)) }
|
||||
if exitedSession { sessions.append(session(exited: true)) }
|
||||
return ProjectInfo(
|
||||
name: name, path: path ?? "/repos/\(name)", isGit: true,
|
||||
branch: "main", dirty: nil, lastActiveMs: lastActiveMs,
|
||||
sessions: sessions
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - filter(大小写不敏感的 name/path 子串,镜像 filterProjects)
|
||||
|
||||
@Test("filter:空查询原样返回全部(含前后空白仅剩空白的查询)")
|
||||
func filterEmptyQueryReturnsAll() {
|
||||
let projects = [Self.project("a"), Self.project("b")]
|
||||
|
||||
#expect(ProjectGrouping.filter(projects, query: "") == projects)
|
||||
#expect(ProjectGrouping.filter(projects, query: " ") == projects)
|
||||
}
|
||||
|
||||
@Test("filter:name 与 path 子串均命中,大小写不敏感")
|
||||
func filterMatchesNameAndPathCaseInsensitively() {
|
||||
let byName = Self.project("Billo.Platform.api", path: "/x/one")
|
||||
let byPath = Self.project("zzz", path: "/Users/dev/BILLO-extras")
|
||||
let miss = Self.project("other", path: "/y/two")
|
||||
|
||||
let result = ProjectGrouping.filter([byName, byPath, miss], query: "billo")
|
||||
|
||||
#expect(result == [byName, byPath])
|
||||
}
|
||||
|
||||
// MARK: - sort(favourites 优先 → lastActiveMs 降序 → 输入序稳定)
|
||||
|
||||
@Test("sort:收藏优先,其余按 lastActiveMs 降序,缺失视为 0")
|
||||
func sortFavouritesFirstThenRecency() {
|
||||
let fav = Self.project("fav", lastActiveMs: 1)
|
||||
let newer = Self.project("newer", lastActiveMs: 100)
|
||||
let older = Self.project("older", lastActiveMs: 50)
|
||||
let never = Self.project("never", lastActiveMs: nil)
|
||||
|
||||
let result = ProjectGrouping.sort(
|
||||
[never, older, fav, newer], favourites: [fav.path]
|
||||
)
|
||||
|
||||
#expect(result == [fav, newer, older, never])
|
||||
}
|
||||
|
||||
@Test("sort:全键相等时保持输入顺序(稳定排序,镜像 JS stable sort)")
|
||||
func sortIsStableOnTies() {
|
||||
let a = Self.project("a", lastActiveMs: 5)
|
||||
let b = Self.project("b", lastActiveMs: 5)
|
||||
let c = Self.project("c", lastActiveMs: 5)
|
||||
|
||||
#expect(ProjectGrouping.sort([a, b, c], favourites: []) == [a, b, c])
|
||||
}
|
||||
|
||||
// MARK: - namespace 分组
|
||||
|
||||
@Test("group:≥2 成员的 namespace 成组,单成员与无点名塌进 Other")
|
||||
func groupNamespacesAndOther() {
|
||||
let apiProj = Self.project("Billo.Platform.api", lastActiveMs: 2)
|
||||
let webProj = Self.project("Billo.Platform.web", lastActiveMs: 1)
|
||||
let solo = Self.project("solo.thing")
|
||||
let plain = Self.project("plain")
|
||||
|
||||
let groups = ProjectGrouping.group(
|
||||
[apiProj, webProj, solo, plain], favourites: []
|
||||
)
|
||||
|
||||
#expect(groups.count == 2)
|
||||
#expect(groups[0].kind == .namespace)
|
||||
#expect(groups[0].key == "Billo.Platform")
|
||||
#expect(groups[0].label == "Billo.Platform")
|
||||
#expect(groups[0].projects == [apiProj, webProj])
|
||||
#expect(groups[1].kind == .other)
|
||||
#expect(groups[1].key == ProjectGrouping.otherGroupKey)
|
||||
// 镜像 web groupProjects:other 先收无点名(noNamespace),再追加
|
||||
// 塌掉的单成员 namespace(public/projects.ts:163-166)→ plain 在前。
|
||||
#expect(groups[1].projects == [plain, solo])
|
||||
}
|
||||
|
||||
@Test("group:namespace 桶大小写不敏感合并,显示名取首见大小写")
|
||||
func groupBucketsCaseInsensitively() {
|
||||
let first = Self.project("Billo.Platform.a")
|
||||
let second = Self.project("billo.platform.b")
|
||||
|
||||
let groups = ProjectGrouping.group([first, second], favourites: [])
|
||||
|
||||
#expect(groups.count == 1)
|
||||
#expect(groups[0].key == "Billo.Platform")
|
||||
#expect(groups[0].projects == [first, second])
|
||||
}
|
||||
|
||||
@Test("group:没有任何 namespace 组 → 单一 flat 组(全部项目,无 chrome 回退)")
|
||||
func groupFlatFallback() {
|
||||
let solo = Self.project("solo.thing", lastActiveMs: 1)
|
||||
let plain = Self.project("plain", lastActiveMs: 2)
|
||||
|
||||
let groups = ProjectGrouping.group([solo, plain], favourites: [])
|
||||
|
||||
#expect(groups.count == 1)
|
||||
#expect(groups[0].kind == .flat)
|
||||
#expect(groups[0].key == ProjectGrouping.otherGroupKey)
|
||||
#expect(groups[0].projects == [plain, solo]) // recency 降序
|
||||
#expect(groups[0].isCollapsible == false)
|
||||
}
|
||||
|
||||
@Test("group:运行中的项目复制进置顶 Active 组;activeCount 逐组统计")
|
||||
func groupActivePinning() {
|
||||
let runningA = Self.project("Billo.Platform.api", lastActiveMs: 2, running: true)
|
||||
let idleB = Self.project("Billo.Platform.web", lastActiveMs: 1)
|
||||
let exitedOnly = Self.project("Billo.Platform.cli", exitedSession: true)
|
||||
|
||||
let groups = ProjectGrouping.group(
|
||||
[runningA, idleB, exitedOnly], favourites: []
|
||||
)
|
||||
|
||||
#expect(groups.count == 2)
|
||||
#expect(groups[0].kind == .active)
|
||||
#expect(groups[0].key == ProjectGrouping.activeGroupKey)
|
||||
#expect(groups[0].projects == [runningA]) // exited 会话不算 running
|
||||
#expect(groups[0].activeCount == 1)
|
||||
#expect(groups[1].kind == .namespace)
|
||||
#expect(groups[1].projects.contains(runningA)) // 复制而非移动
|
||||
#expect(groups[1].activeCount == 1)
|
||||
#expect(groups[0].isCollapsible == false) // Active now 永远展开
|
||||
#expect(groups[1].isCollapsible == true)
|
||||
}
|
||||
|
||||
@Test("group:namespace 组按组内最新 lastActiveMs 降序,平局按 label 升序")
|
||||
func groupOrdersNamespacesByRecencyThenLabel() {
|
||||
let older1 = Self.project("Aaa.Team.x", lastActiveMs: 10)
|
||||
let older2 = Self.project("Aaa.Team.y", lastActiveMs: 20)
|
||||
let newer1 = Self.project("Zzz.Team.x", lastActiveMs: 5)
|
||||
let newer2 = Self.project("Zzz.Team.y", lastActiveMs: 99)
|
||||
let tieA1 = Self.project("Bbb.Tie.x", lastActiveMs: 20)
|
||||
let tieA2 = Self.project("Bbb.Tie.y", lastActiveMs: 3)
|
||||
|
||||
let groups = ProjectGrouping.group(
|
||||
[older1, older2, newer1, newer2, tieA1, tieA2], favourites: []
|
||||
)
|
||||
|
||||
#expect(groups.map(\.key) == ["Zzz.Team", "Aaa.Team", "Bbb.Tie"])
|
||||
}
|
||||
|
||||
// MARK: - displayLabel(组内卡片名去掉 namespace 前缀)
|
||||
|
||||
@Test("displayLabel:namespace 组内剥前缀(大小写不敏感);哨兵组保留全名")
|
||||
func displayLabelStripsNamespacePrefix() {
|
||||
#expect(ProjectGrouping.displayLabel(
|
||||
name: "Billo.Platform.api", groupKey: "Billo.Platform"
|
||||
) == "api")
|
||||
#expect(ProjectGrouping.displayLabel(
|
||||
name: "billo.platform.api", groupKey: "Billo.Platform"
|
||||
) == "api")
|
||||
#expect(ProjectGrouping.displayLabel(
|
||||
name: "unrelated", groupKey: "Billo.Platform"
|
||||
) == "unrelated")
|
||||
#expect(ProjectGrouping.displayLabel(
|
||||
name: "Billo.Platform.api", groupKey: ProjectGrouping.activeGroupKey
|
||||
) == "Billo.Platform.api")
|
||||
#expect(ProjectGrouping.displayLabel(
|
||||
name: "Billo.Platform.api", groupKey: ProjectGrouping.otherGroupKey
|
||||
) == "Billo.Platform.api")
|
||||
}
|
||||
|
||||
@Test("group:组内排序收藏优先(favourites 参与 sortProjects)")
|
||||
func groupSortsFavouritesFirstWithinGroup() {
|
||||
let apiProj = Self.project("Ns.Grp.api", lastActiveMs: 9)
|
||||
let webProj = Self.project("Ns.Grp.web", lastActiveMs: 1)
|
||||
|
||||
let groups = ProjectGrouping.group(
|
||||
[apiProj, webProj], favourites: [webProj.path]
|
||||
)
|
||||
|
||||
#expect(groups.count == 1)
|
||||
#expect(groups[0].projects == [webProj, apiProj])
|
||||
}
|
||||
}
|
||||
90
ios/App/WebTermTests/ProjectOpenWiringTests.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-26 · "在此仓库开新会话" 的接线证明:`TerminalSessionController` 以
|
||||
/// `spawnCwd` + `bootstrapInput` 启动时,线上帧序 = `attach(null, cwd)` →
|
||||
/// `input("claude\r")`(engine 的 attach-first 队列语义保证 bootstrap 绝不
|
||||
/// 先于 attach 出线);带 sessionId 的常规打开则既无 cwd 也无 bootstrap。
|
||||
///
|
||||
/// 真 `SessionEngine` over `FakeTransport`(零真实等待):帧断言经
|
||||
/// `openTask` + `waitUntilProcessed` 双屏障(open+send 已提交 ∧ attach
|
||||
/// 握手已完成 → 帧必已落地)。
|
||||
@MainActor
|
||||
@Suite("ProjectOpenWiring")
|
||||
struct ProjectOpenWiringTests {
|
||||
private struct Fixture {
|
||||
let transport: FakeTransport
|
||||
let host: HostRegistry.Host
|
||||
let environment: AppEnvironment
|
||||
}
|
||||
|
||||
private func makeFixture() throws -> Fixture {
|
||||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let transport = FakeTransport()
|
||||
let defaults = try #require(UserDefaults(suiteName: "ProjectOpenWiringTests"))
|
||||
return Fixture(
|
||||
transport: transport,
|
||||
host: HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint),
|
||||
environment: AppEnvironment(
|
||||
hostStore: InMemoryHostStore(),
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: FakeHTTPTransport(),
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 双屏障:controller 的 open+bootstrap Task 完成(send 已提交/入队)∧
|
||||
/// attach 握手完成(.connecting/.connected 已到 VM → 队列已冲刷)。
|
||||
private func awaitAttachSettled(_ controller: TerminalSessionController) async {
|
||||
await controller.openTask?.value
|
||||
await controller.terminalViewModel.waitUntilProcessed(eventCount: 2)
|
||||
}
|
||||
|
||||
@Test("spawn 变体:帧序 = attach(null, cwd) → input(claude\\r)")
|
||||
func spawnSendsAttachWithCwdThenBootstrapInput() async throws {
|
||||
let fixture = try makeFixture()
|
||||
let controller = TerminalSessionController(
|
||||
host: fixture.host, sessionId: nil, environment: fixture.environment,
|
||||
onPendingChanged: { _, _ in },
|
||||
spawnCwd: "/repos/api",
|
||||
bootstrapInput: ProjectLaunch.claudeBootstrapInput
|
||||
)
|
||||
|
||||
controller.start()
|
||||
await awaitAttachSettled(controller)
|
||||
|
||||
let frames = await fixture.transport.sentFrames
|
||||
#expect(frames == [
|
||||
MessageCodec.encode(.attach(sessionId: nil, cwd: "/repos/api")),
|
||||
MessageCodec.encode(.input(data: ProjectLaunch.claudeBootstrapInput)),
|
||||
])
|
||||
controller.teardown()
|
||||
}
|
||||
|
||||
@Test("常规打开(带 sessionId):只有 attach,无 cwd、无 bootstrap")
|
||||
func plainOpenSendsBareAttach() async throws {
|
||||
let fixture = try makeFixture()
|
||||
let sessionId = UUID()
|
||||
let controller = TerminalSessionController(
|
||||
host: fixture.host, sessionId: sessionId,
|
||||
environment: fixture.environment,
|
||||
onPendingChanged: { _, _ in }
|
||||
)
|
||||
|
||||
controller.start()
|
||||
await awaitAttachSettled(controller)
|
||||
|
||||
let frames = await fixture.transport.sentFrames
|
||||
#expect(frames == [MessageCodec.encode(.attach(sessionId: sessionId, cwd: nil))])
|
||||
controller.teardown()
|
||||
}
|
||||
}
|
||||