feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs

App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
This commit is contained in:
Yaojia Wang
2026-07-30 15:57:41 +02:00
parent 9114630c3a
commit 284cfd193a
70 changed files with 10271 additions and 358 deletions

View File

@@ -0,0 +1,187 @@
import APIClient
import Foundation
import Testing
@testable import WebTerm
/// T-iOS-32 · `claude --resume <id>` `GET /sessions` App
///
/// RED `docs/plans/ios-completion.md` §4E 2835 id
/// **** PTY
/// web `public/tabs.ts:918`
@MainActor
@Suite("ResumeHistoryViewModel")
struct ResumeHistoryViewModelTests {
private static let projectPath = "/repos/web-terminal"
private nonisolated static func session(
id: String = "3f2b1c4d-0000-4000-8000-000000000001",
cwd: String,
mtimeMs: Double = 1_785_390_645_813.5
) -> HistorySession {
HistorySession(
id: id, cwd: cwd, project: "web-terminal", mtimeMs: mtimeMs, preview: "修一下 CJK locale"
)
}
// MARK: - 2830
@Test("只保留 cwd 落在本项目内的会话(含仓库根与子目录/worktree")
func keepsOnlySessionsInsideProject() {
let inside = Self.session(id: "a1", cwd: Self.projectPath)
let nested = Self.session(id: "a2", cwd: Self.projectPath + "/.claude/worktrees/x")
let outside = Self.session(id: "a3", cwd: "/repos/other")
let candidates = ResumeHistoryViewModel.candidates(
from: [inside, nested, outside], projectPath: Self.projectPath
)
#expect(candidates.map(\.id) == ["a1", "a2"])
}
@Test("前缀不得半路匹配:/repos/web-terminal-old 不属于 /repos/web-terminal")
func siblingPrefixIsNotInside() {
let sibling = Self.session(id: "a1", cwd: "/repos/web-terminal-old")
let candidates = ResumeHistoryViewModel.candidates(
from: [sibling], projectPath: Self.projectPath
)
#expect(candidates.isEmpty)
}
@Test("服务器顺序mtime 新→旧)原样保留,客户端不重排")
func serverOrderIsPreserved() {
let older = Self.session(id: "old", cwd: Self.projectPath, mtimeMs: 1_000)
let newer = Self.session(id: "new", cwd: Self.projectPath, mtimeMs: 9_000)
let candidates = ResumeHistoryViewModel.candidates(
from: [newer, older], projectPath: Self.projectPath
)
#expect(candidates.map(\.id) == ["new", "old"])
}
@Test("cwd 非绝对路径 → 不可恢复Validation.isAbsoluteCwd 纪律)")
func relativeCwdIsFilteredOut() {
let relative = Self.session(id: "a1", cwd: "repos/web-terminal")
let candidates = ResumeHistoryViewModel.candidates(
from: [relative], projectPath: "repos/web-terminal"
)
#expect(candidates.isEmpty)
}
// MARK: - phase3132
@Test("空结果 → .empty不是 .failed服务器无历史时正常回 []")
func emptyListIsNotFailure() async {
let vm = ResumeHistoryViewModel(projectPath: Self.projectPath, fetch: { [] })
await vm.load()
#expect(vm.phase == .empty)
}
@Test("过滤后为空(有历史但都不属于本仓库)→ 同样 .empty")
func allFilteredOutIsEmpty() async {
let vm = ResumeHistoryViewModel(
projectPath: Self.projectPath,
fetch: { [Self.session(id: "a1", cwd: "/repos/other")] }
)
await vm.load()
#expect(vm.phase == .empty)
}
@Test("加载失败 → .failed可重试重试成功 → .loaded")
func failureIsRetryable() async {
let flag = ResumeFailOnceFlag()
let payload = Self.session(id: "a1", cwd: Self.projectPath)
let vm = ResumeHistoryViewModel(projectPath: Self.projectPath, fetch: {
if await flag.consumeShouldFail() { throw APIClientError.gitDataUnavailable }
return [payload]
})
await vm.load()
guard case .failed = vm.phase else {
Issue.record("应为 .failed实际 \(vm.phase)")
return
}
await vm.load()
#expect(vm.phase == .loaded(ResumeHistoryViewModel.candidates(
from: [payload], projectPath: Self.projectPath
)))
}
// MARK: - 3334
@Test(
"危险 id 绝不拼进 PTY 命令行(注入白名单)",
arguments: [
"abc; rm -rf ~", "abc `id`", "abc$(id)", "abc\nwhoami", "abc id", "abc'x'",
"abc\"x\"", "abc|x", "abc&x", "abc>x", "../../etc/passwd", "",
]
)
func dangerousIdsAreRejected(sessionId: String) {
#expect(
ProjectResumeCommand.bootstrapInput(sessionId: sessionId) == nil,
"\(sessionId) 不应被接受"
)
}
@Test("超长 id> 128拒绝")
func overlongIdRejected() {
let long = String(repeating: "a", count: 129)
#expect(ProjectResumeCommand.bootstrapInput(sessionId: long) == nil)
}
@Test("合法 id → `claude --resume <id>` 且以 \\r0x0D结尾绝不是 \\n")
func validIdBuildsCarriageReturnCommand() throws {
let id = "3f2b1c4d-0000-4000-8000-000000000001"
let input = try #require(ProjectResumeCommand.bootstrapInput(sessionId: id))
#expect(input == "claude --resume \(id)\r")
#expect(input.hasSuffix("\r"))
#expect(!input.contains("\n"))
}
@Test("非法 id 的历史行仍显示,但 canResume == false不提供恢复按钮")
func rowWithBadIdIsNotResumable() {
let bad = Self.session(id: "abc; rm -rf ~", cwd: Self.projectPath)
let candidates = ResumeHistoryViewModel.candidates(
from: [bad], projectPath: Self.projectPath
)
#expect(candidates.count == 1)
#expect(!candidates[0].canResume)
}
@Test("合法行 canResume == true且携带会话自己的 cwdworktree 会话回到 worktree")
func resumableRowCarriesItsOwnCwd() throws {
let nestedCwd = Self.projectPath + "/.claude/worktrees/x"
let session = Self.session(id: "a1", cwd: nestedCwd)
let candidate = try #require(ResumeHistoryViewModel.candidates(
from: [session], projectPath: Self.projectPath
).first)
#expect(candidate.canResume)
#expect(candidate.cwd == nestedCwd)
}
}
/// @Sendable fetch actor
private actor ResumeFailOnceFlag {
private var shouldFail = true
func consumeShouldFail() -> Bool {
defer { shouldFail = false }
return shouldFail
}
}