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,224 @@
import APIClient
import Foundation
import Testing
@testable import WebTerm
/// C2 · / / /
///
/// `docs/plans/w6-project-git-panel.md` web
/// `public/projects.ts:615-745 makeSyncBand` "
/// 绿"RED 3647 docs/plans/ios-completion.md §4
@Suite("GitPanelPresentation")
struct GitPanelPresentationTests {
/// ""2026-07-30T12:00:00Z
private static let nowMs: Double = 1_785_412_800_000
private static func minutesAgo(_ minutes: Double) -> Double {
nowMs - minutes * 60 * 1000
}
// MARK: - RED 3643
@Test("非 git 目录sync == nil→ 无同步带")
func nonGitHasNoBand() {
#expect(GitSyncBand.make(sync: nil, dirtyCount: nil, nowMs: Self.nowMs) == nil)
}
@Test("↑0 ↓0 + 新鲜 fetch → 唯一允许的「已同步」绿色态")
func inSyncNeedsFreshFetch() throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(
upstream: "origin/develop", ahead: 0, behind: 0,
lastFetchMs: Self.minutesAgo(5)
),
dirtyCount: 0, nowMs: Self.nowMs
))
#expect(band.isInSync)
#expect(band.isBehindVerified)
#expect(band.canFetch)
#expect(band.head == .tracking(
upstream: "origin/develop", ahead: 0, behind: 0
))
}
@Test("↑0 ↓0 但 fetch 已过期(> FETCH_STALE_MS→ 不绿,↓ 未核实")
func staleFetchIsNeverGreen() throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(
upstream: "origin/develop", ahead: 0, behind: 0,
lastFetchMs: Self.minutesAgo(61)
),
dirtyCount: 0, nowMs: Self.nowMs
))
#expect(!band.isInSync)
#expect(!band.isBehindVerified)
}
@Test("FETCH_STALE_MS 就是 1 小时(镜像 public/projects.ts:615")
func staleThresholdMatchesWeb() {
#expect(GitSyncBand.fetchStaleMs == 60 * 60 * 1000)
}
@Test("从未 fetchlastFetchMs == nil→ 视为过期,不绿")
func neverFetchedIsStale() throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(upstream: "origin/develop", ahead: 0, behind: 0, lastFetchMs: nil),
dirtyCount: 0, nowMs: Self.nowMs
))
#expect(!band.isInSync)
#expect(!band.isBehindVerified)
}
@Test("无上游 → 「无上游」,没有 ↑↓,绝不绿(最易犯的 bug")
func noUpstreamIsNeverGreen() throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(upstream: nil, ahead: nil, behind: nil, lastFetchMs: Self.nowMs),
dirtyCount: 0, nowMs: Self.nowMs
))
#expect(band.head == .noUpstream)
#expect(!band.isInSync)
#expect(band.canFetch)
}
@Test("分离 HEAD → 「分离 HEAD」fetch 禁用,无 ↑↓")
func detachedDisablesFetch() throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(upstream: nil, ahead: nil, behind: nil, lastFetchMs: nil, detached: true),
dirtyCount: nil, nowMs: Self.nowMs
))
#expect(band.head == .detached)
#expect(!band.canFetch)
#expect(!band.isInSync)
}
@Test("ahead 读不到nil→ 保留 nil渲染 ↑ —),且不绿")
func unknownAheadIsNotZero() throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(
upstream: "origin/develop", ahead: nil, behind: 0,
lastFetchMs: Self.minutesAgo(1)
),
dirtyCount: 0, nowMs: Self.nowMs
))
#expect(band.head == .tracking(upstream: "origin/develop", ahead: nil, behind: 0))
#expect(!band.isInSync)
}
@Test(
"dirtyCountnil → 未检查(不是 clean、0 → clean、3 → changed(3)",
arguments: [
(Int?.none, GitSyncBand.Dirty.unknown),
(Int?(0), GitSyncBand.Dirty.clean),
(Int?(3), GitSyncBand.Dirty.changed(3)),
] as [(Int?, GitSyncBand.Dirty)]
)
func dirtyTriState(dirtyCount: Int?, expected: GitSyncBand.Dirty) throws {
let band = try #require(GitSyncBand.make(
sync: SyncState(upstream: "origin/x", ahead: 0, behind: 0, lastFetchMs: Self.nowMs),
dirtyCount: dirtyCount, nowMs: Self.nowMs
))
#expect(band.dirty == expected)
}
// MARK: - RED 4446
@Test("边界画在最后一个 unpushed 之后,且只画一次")
func boundaryAfterLastUnpushed() {
let commits = [
CommitLogEntry(hash: "aaa", at: 3, subject: "c", unpushed: true),
CommitLogEntry(hash: "bbb", at: 2, subject: "b", unpushed: true),
CommitLogEntry(hash: "ccc", at: 1, subject: "a", unpushed: false),
]
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == 1)
}
@Test("unpushed 只在严格 true 时生效nil ≠ false ≠ true")
func nilUnpushedDrawsNoBoundary() {
let commits = [
CommitLogEntry(hash: "aaa", at: 2, subject: "c", unpushed: nil),
CommitLogEntry(hash: "bbb", at: 1, subject: "b", unpushed: false),
]
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == nil)
}
@Test("无上游 → 完全不画边界(没有可比对的东西)")
func noUpstreamDrawsNoBoundary() {
let commits = [CommitLogEntry(hash: "aaa", at: 1, subject: "c", unpushed: true)]
#expect(GitLogBoundary.index(commits: commits, upstream: nil) == nil)
}
@Test("未推送提交排在已推送之下(老日期 merge→ 边界仍在最后一个 unpushed 之后")
func interleavedUnpushedStillBounds() {
let commits = [
CommitLogEntry(hash: "aaa", at: 5, subject: "new pushed", unpushed: false),
CommitLogEntry(hash: "bbb", at: 4, subject: "merge of old branch", unpushed: true),
CommitLogEntry(hash: "ccc", at: 3, subject: "older pushed", unpushed: false),
]
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == 1)
}
// MARK: -
@Test(
"相对时间:秒/分/时/天各档非空且互不相同",
arguments: [0.5, 5, 90, 60 * 26] as [Double]
)
func relativeTimeBuckets(minutes: Double) {
let label = GitTimeFormat.relative(fromMs: Self.minutesAgo(minutes), nowMs: Self.nowMs)
#expect(!label.isEmpty)
}
@Test("未来时间戳(主机时钟漂移)→ 退化为「刚刚」,不出现负数")
func futureTimestampDegrades() {
let label = GitTimeFormat.relative(fromMs: Self.nowMs + 60_000, nowMs: Self.nowMs)
#expect(!label.contains("-"))
}
// MARK: - RED 47
@Test("服务器安全文案原样显示(绝不吞、绝不自造)")
func serverMessageIsSurfacedVerbatim() {
let text = GitWriteFeedback.message(
status: 409, serverMessage: "Nothing staged to commit.", fallback: "兜底"
)
#expect(text.contains("Nothing staged to commit."))
}
@Test("服务器没给 message → 兜底中文文案(非空)")
func missingServerMessageFallsBack() {
let text = GitWriteFeedback.message(status: 500, serverMessage: nil, fallback: "提交失败")
#expect(!text.isEmpty)
#expect(text.contains("提交失败"))
}
@Test("抛出的 APIClientError → 其自带话术(.unauthorized 引导补令牌)")
func thrownAPIErrorUsesItsOwnCopy() {
let text = GitWriteFeedback.message(for: APIClientError.unauthorized, fallback: "兜底")
#expect(text == APIClientError.unauthorized.message)
}
@Test("非 APIClientError传输层→ 兜底文案,不 crash")
func transportErrorFallsBack() {
let text = GitWriteFeedback.message(
for: URLError(.notConnectedToInternet), fallback: "推送失败"
)
#expect(text.contains("推送失败"))
}
}