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,199 @@
import APIClient
import Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// C1 · the path `PushRegistrar.handleHostRemoved` never had.
///
/// Two properties matter beyond "the row disappears":
/// 1. the APNs de-registration actually happens, and happens BEFORE the record
/// is deleted (the request needs the host's endpoint AND its access token,
/// which live in that record);
/// 2. no secret survives: the token is a field of the removed record, so the
/// same write that drops the host drops the token.
@MainActor
@Suite("Host removal")
struct HostRemovalTests {
private static let goodToken = "abcdefghijklmnopqrstuvwxyz012345"
/// Records the interleaving of the push hook and the store write.
private actor Journal {
enum Entry: Equatable {
case unregistered(UUID)
case removed(UUID)
}
private(set) var entries: [Entry] = []
func record(_ entry: Entry) {
entries = entries + [entry]
}
}
/// `InMemoryHostStore` + journalling of `remove`, so ordering is observable.
private actor JournallingStore: HostStore {
private let base = InMemoryHostStore()
private let journal: Journal
private let removeFails: Bool
init(journal: Journal, removeFails: Bool = false) {
self.journal = journal
self.removeFails = removeFails
}
func loadAll() async throws -> [HostRegistry.Host] { try await base.loadAll() }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
try await base.upsert(host)
}
func remove(id: UUID) async throws -> [HostRegistry.Host] {
await journal.record(.removed(id))
if removeFails { throw StoreFailure() }
return try await base.remove(id: id)
}
func accessToken(host id: UUID) async throws -> AccessToken? {
try await base.accessToken(host: id)
}
func setAccessToken(
_ token: AccessToken?, host id: UUID
) async throws -> [HostRegistry.Host] {
try await base.setAccessToken(token, host: id)
}
}
private struct StoreFailure: Error {}
private actor ThrowingHostStore: HostStore {
func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
throw StoreFailure()
}
func remove(id: UUID) async throws -> [HostRegistry.Host] { throw StoreFailure() }
}
private func makeViewModel(store: any HostStore, journal: Journal) -> PairingViewModel {
PairingViewModel(
store: store,
probe: PairingViewModel.Probe(
verifyHost: { endpoint, _ in .success(endpoint) },
unregisterPush: { host in await journal.record(.unregistered(host.id)) }
)
)
}
private func makeHost(
name: String, port: Int, token: String? = nil
) throws -> HostRegistry.Host {
let url = try #require(URL(string: "http://192.168.1.5:\(port)"))
return HostRegistry.Host(
id: UUID(),
name: name,
endpoint: try #require(HostEndpoint(baseURL: url)),
accessToken: try token.map { try AccessToken(validating: $0) }
)
}
// MARK: - The wiring
@Test("移除主机:先注销该主机上的推送 token再删记录顺序是硬要求")
func removalDeregistersPushBeforeDeletingTheRecord() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal)
let host = try makeHost(name: "书房 Mac", port: 3000, token: Self.goodToken)
_ = try await store.upsert(host)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: host.id)
#expect(await journal.entries == [.unregistered(host.id), .removed(host.id)])
#expect(viewModel.pairedHosts.isEmpty)
#expect(try await store.loadAll().isEmpty)
}
@Test("移除主机后本机不留令牌(令牌是记录的一个字段,同一次写入一起消失)")
func removalLeavesNoTokenBehind() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal)
let host = try makeHost(name: "书房 Mac", port: 3000, token: Self.goodToken)
_ = try await store.upsert(host)
#expect(try await store.accessToken(host: host.id)?.rawValue == Self.goodToken)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: host.id)
#expect(try await store.accessToken(host: host.id) == nil)
#expect(try await store.loadAll().allSatisfy { $0.accessToken == nil })
}
@Test("只移除指定主机,其它主机及其令牌不受影响")
func removalIsScopedToOneHost() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal)
let doomed = try makeHost(name: "旧 Mac", port: 3000, token: Self.goodToken)
let kept = try makeHost(name: "新 Mac", port: 3100, token: Self.goodToken)
_ = try await store.upsert(doomed)
_ = try await store.upsert(kept)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: doomed.id)
#expect(viewModel.pairedHosts.map(\.id) == [kept.id])
#expect(try await store.accessToken(host: kept.id)?.rawValue == Self.goodToken)
#expect(await journal.entries == [.unregistered(doomed.id), .removed(doomed.id)])
}
@Test("未知 id → 完全 no-op不注销、不写存储")
func unknownIdIsANoOp() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal)
let host = try makeHost(name: "书房 Mac", port: 3000)
_ = try await store.upsert(host)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: UUID())
#expect(await journal.entries.isEmpty)
#expect(try await store.loadAll().count == 1)
}
@Test("存储写入失败 → 显式报错(不假装移除成功)")
func storeFailureIsSurfaced() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal, removeFails: true)
let host = try makeHost(name: "书房 Mac", port: 3000)
_ = try await store.upsert(host)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: host.id)
#expect(viewModel.hostsErrorMessage == PairingCopy.hostRemoveFailed)
#expect(viewModel.pairedHosts.map(\.id) == [host.id]) // list unchanged
}
// MARK: - Manage-section loading
@Test("加载已配对主机:成功清错,失败给出显式话术")
func loadingPairedHostsSurfacesErrors() async throws {
let journal = Journal()
let failing = makeViewModel(store: ThrowingHostStore(), journal: journal)
await failing.loadPairedHosts()
#expect(failing.pairedHosts.isEmpty)
#expect(failing.hostsErrorMessage == PairingCopy.hostsLoadFailed)
let working = makeViewModel(store: InMemoryHostStore(), journal: journal)
await working.loadPairedHosts()
#expect(working.hostsErrorMessage == nil)
}
}