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,328 @@
import APIClient
import Foundation
import Testing
@testable import WebTerm
/// C2 · git VMRED 4751 +
///
/// ProjectDetailViewModel/DiffViewModel
/// / APIClient VM
///
@MainActor
@Suite("GitPanelViewModel")
struct GitPanelViewModelTests {
private nonisolated static let path = "/repos/web-terminal"
private nonisolated static let nowMs: Double = 1_785_412_800_000
private nonisolated static func detail(
sync: SyncState? = SyncState(
upstream: "origin/develop", ahead: 2, behind: 0, lastFetchMs: 1_785_412_500_000
),
dirtyCount: Int? = 3
) -> ProjectDetail {
ProjectDetail(
name: "web-terminal", path: path, isGit: true, branch: "develop", dirty: true,
worktrees: [], sessions: [], hasClaudeMd: false, claudeMd: nil,
dirtyCount: dirtyCount, sync: sync
)
}
// MARK: -
@Test("load 成功 → 同步带/分支/日志/改动列表就位")
func loadPopulatesEverySection() async {
let vm = Self.makeViewModel(
log: { GitLogResult(commits: [
CommitLogEntry(hash: "abc1234", at: 1_785_412_000_000, subject: "feat: x", unpushed: true),
], truncated: false, upstream: "origin/develop") },
changes: { staged in
staged ? [] : [Self.changedFile("src/a.ts")]
}
)
await vm.load()
#expect(vm.isLoaded)
#expect(vm.branch == "develop")
#expect(vm.band?.head == .tracking(upstream: "origin/develop", ahead: 2, behind: 0))
#expect(vm.band?.dirty == .changed(3))
#expect(vm.log?.commits.count == 1)
#expect(vm.unstaged.map(\.displayPath) == ["src/a.ts"])
#expect(vm.staged.isEmpty)
}
@Test("日志失败不拖垮面板:其余分区照常,日志区显示自己的错误")
func logFailureIsContained() async {
let vm = Self.makeViewModel(log: { throw APIClientError.gitDataUnavailable })
await vm.load()
#expect(vm.isLoaded)
#expect(vm.band != nil)
#expect(vm.logErrorMessage == APIClientError.gitDataUnavailable.message)
}
@Test("详情失败 → 状态区错误 + 无同步带(绝不编造 ↑↓)")
func detailFailureLeavesNoBand() async {
let vm = Self.makeViewModel(detail: { throw APIClientError.projectNotFound })
await vm.load()
#expect(vm.band == nil)
#expect(vm.stateErrorMessage == APIClientError.projectNotFound.message)
}
@Test("PR 单独加载gh 是一次外网调用,不阻塞面板首屏)")
func prLoadsSeparately() async {
let vm = Self.makeViewModel(pr: { PrStatus(availability: .ok, number: 7, title: "t") })
await vm.load()
#expect(vm.pr == nil)
await vm.loadPullRequest()
#expect(vm.pr?.number == 7)
}
@Test("gh 未安装/未登录是 200 + 非 ok availability不是错误")
func degradedGhIsNotAnError() async {
let vm = Self.makeViewModel(pr: { PrStatus(availability: .notInstalled) })
await vm.loadPullRequest()
#expect(vm.pr?.availability == .notInstalled)
#expect(vm.errorMessage == nil)
}
// MARK: - 4749
@Test("stage 被拒 → 服务器安全文案原样显示")
func stageRejectionSurfacesServerMessage() async {
let vm = Self.makeViewModel(
stage: { _, _ in .rejected(status: 403, message: "Git operations are disabled.") }
)
await vm.setStaged(Self.changedFile("src/a.ts"), staged: true)
#expect(vm.errorMessage == "Git operations are disabled.")
}
@Test("commit 成功 → 清空输入框 + 短 sha 提示")
func commitSuccessClearsDraft() async {
let vm = Self.makeViewModel(commit: { _ in .ok(CommitResult(commit: "abc1234")) })
vm.commitMessage = "fix: 修一下"
await vm.commit()
#expect(vm.commitMessage.isEmpty)
#expect(vm.noticeMessage?.contains("abc1234") == true)
#expect(vm.errorMessage == nil)
}
@Test("commit 409无暂存→ 保留输入框内容,显示服务器文案")
func commitConflictKeepsDraft() async {
let vm = Self.makeViewModel(
commit: { _ in .rejected(status: 409, message: "Nothing staged to commit.") }
)
vm.commitMessage = "fix: 修一下"
await vm.commit()
#expect(vm.commitMessage == "fix: 修一下")
#expect(vm.errorMessage == "Nothing staged to commit.")
}
@Test("空提交信息 → 一次请求都不发")
func blankCommitMessageSkipsNetwork() async {
let spy = GitPanelSpy()
let vm = Self.makeViewModel(spy: spy)
vm.commitMessage = " "
await vm.commit()
#expect(await spy.commitCalls == 0)
#expect(vm.errorMessage == GitPanelCopy.commitMessageRequired)
}
@Test("push 401 是主机 git 凭据问题 → 用服务器文案,不与访问令牌 401 混淆")
func pushAuthIsNotTheAccessTokenGate() async {
let vm = Self.makeViewModel(
push: { .rejected(status: 401, message: "Push authentication required on the host.") }
)
await vm.push()
#expect(vm.errorMessage == "Push authentication required on the host.")
#expect(vm.errorMessage != APIClientError.unauthorized.message)
}
@Test("429 → 限流文案,且不自动重试")
func rateLimitedIsNotRetried() async {
let spy = GitPanelSpy()
let vm = Self.makeViewModel(spy: spy, push: { .rateLimited })
await vm.push()
#expect(await spy.pushCalls == 1)
#expect(vm.errorMessage == GitWriteFeedback.rateLimited)
}
@Test("fetch 成功 → 重新读取详情lastFetchMs 由服务器给,客户端绝不自己往前拨)")
func fetchReloadsStateFromServer() async {
let spy = GitPanelSpy()
let vm = Self.makeViewModel(spy: spy, fetchRemote: { .ok(FetchResult(remote: "origin", lastFetchMs: Self.nowMs)) })
await vm.load()
let loadsAfterFirstLoad = await spy.detailCalls
await vm.fetchRemote()
#expect(await spy.detailCalls == loadsAfterFirstLoad + 1)
}
// MARK: - 50
@Test("写操作进行中重复点击 → 只发一次请求")
func busyDeduplicatesWrites() async {
let gate = GitPanelGate()
let spy = GitPanelSpy()
let vm = Self.makeViewModel(
spy: spy,
push: {
await gate.wait()
return .ok(PushResult(branch: "develop", remote: "origin"))
}
)
let first = Task { await vm.push() }
await Self.spin(until: { vm.busy == .push })
await vm.push()
#expect(await spy.pushCalls == 1)
await gate.release()
await first.value
#expect(vm.busy == nil)
}
// MARK: - 51
@Test("重命名 → 同时送 oldPath 与 newPath否则删除侧不会被暂存")
func renameStagesBothPaths() {
let file = DiffFile(
oldPath: "src/old.ts", newPath: "src/new.ts", status: .renamed,
added: 1, removed: 1, binary: false, hunks: []
)
let changed = GitPanelViewModel.ChangedFile.make(from: file)
#expect(changed.stagePaths == ["src/old.ts", "src/new.ts"])
#expect(changed.displayPath.contains("src/new.ts"))
}
@Test("普通修改 → 只送 newPath")
func modifiedStagesOnePath() {
let file = DiffFile(
oldPath: "src/a.ts", newPath: "src/a.ts", status: .modified,
added: 2, removed: 0, binary: false, hunks: []
)
let changed = GitPanelViewModel.ChangedFile.make(from: file)
#expect(changed.stagePaths == ["src/a.ts"])
}
// MARK: - Fixtures
private nonisolated static func changedFile(_ path: String) -> GitPanelViewModel.ChangedFile {
GitPanelViewModel.ChangedFile(
displayPath: path, stagePaths: [path], status: .modified, added: 1, removed: 0
)
}
private static func makeViewModel(
spy: GitPanelSpy = GitPanelSpy(),
detail: (@Sendable () async throws -> ProjectDetail)? = nil,
log: (@Sendable () async throws -> GitLogResult)? = nil,
pr: (@Sendable () async throws -> PrStatus)? = nil,
changes: (@Sendable (Bool) async throws -> [GitPanelViewModel.ChangedFile])? = nil,
stage: (@Sendable ([String], Bool) async throws -> GitWriteOutcome<StageResult>)? = nil,
commit: (@Sendable (String) async throws -> GitWriteOutcome<CommitResult>)? = nil,
push: (@Sendable () async throws -> GitWriteOutcome<PushResult>)? = nil,
fetchRemote: (@Sendable () async throws -> GitWriteOutcome<FetchResult>)? = nil
) -> GitPanelViewModel {
GitPanelViewModel(
path: path,
dependencies: GitPanelViewModel.Dependencies(
detail: {
await spy.recordDetail()
if let detail { return try await detail() }
return Self.detail()
},
log: {
if let log { return try await log() }
return GitLogResult(commits: [], truncated: false, upstream: "origin/develop")
},
pr: {
if let pr { return try await pr() }
return PrStatus(availability: .noPr)
},
changes: { staged in
if let changes { return try await changes(staged) }
return []
},
stage: { files, isStaging in
await spy.recordStage()
if let stage { return try await stage(files, isStaging) }
return .ok(StageResult(staged: isStaging, count: files.count))
},
commit: { message in
await spy.recordCommit()
if let commit { return try await commit(message) }
return .ok(CommitResult(commit: "abc1234"))
},
push: {
await spy.recordPush()
if let push { return try await push() }
return .ok(PushResult(branch: "develop", remote: "origin"))
},
fetchRemote: {
if let fetchRemote { return try await fetchRemote() }
return .ok(FetchResult(remote: "origin", lastFetchMs: nowMs))
},
nowMs: { nowMs }
)
)
}
private static func spin(
until condition: @MainActor () -> Bool, maxYields: Int = 1_000
) async {
for _ in 0..<maxYields where !condition() {
await Task.yield()
}
}
}
private actor GitPanelSpy {
private(set) var detailCalls = 0
private(set) var stageCalls = 0
private(set) var commitCalls = 0
private(set) var pushCalls = 0
func recordDetail() { detailCalls += 1 }
func recordStage() { stageCalls += 1 }
func recordCommit() { commitCalls += 1 }
func recordPush() { pushCalls += 1 }
}
private actor GitPanelGate {
private var waiters: [CheckedContinuation<Void, Never>] = []
func wait() async {
await withCheckedContinuation { waiters.append($0) }
}
func release() {
let pending = waiters
waiters = []
pending.forEach { $0.resume() }
}
}