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,369 @@
import APIClient
import Foundation
import Observation
import WireProtocol
/// C2 · git `docs/plans/w6-project-git-panel.md`
///
/// **** web
/// + + fetch· stage/unstage · commit · push ·
/// `git log`· PR/CI
///
///
/// 1. ** git **/
/// `ahead`/`behind`/`lastFetchMs` w6
/// push
/// 2. ****PRgh
/// ****""
/// 3. ****SEC-M10 stderr
///
@MainActor
@Observable
final class GitPanelViewModel {
///
///
/// `stagePaths` **** oldPath newPath
/// `git add` web `public/diff.ts`
/// """"
struct ChangedFile: Equatable, Identifiable, Sendable {
let displayPath: String
let stagePaths: [String]
let status: DiffFileStatus
let added: Int
let removed: Int
var id: String { displayPath }
static func make(from file: DiffFile) -> ChangedFile {
let primary = file.newPath.isEmpty ? file.oldPath : file.newPath
let isRename = file.status == .renamed && !file.oldPath.isEmpty
&& file.oldPath != file.newPath
return ChangedFile(
displayPath: isRename ? "\(file.oldPath)\(file.newPath)" : primary,
stagePaths: isRename ? [file.oldPath, file.newPath] : [primary],
status: file.status,
added: file.added,
removed: file.removed
)
}
}
/// + spinner
enum Operation: Equatable {
case fetch
case commit
case push
case stage(String)
case unstage(String)
}
/// `forProject` `APIClient`/`DiffFetcher` fake
struct Dependencies: Sendable {
let detail: @Sendable () async throws -> ProjectDetail
let log: @Sendable () async throws -> GitLogResult
let pr: @Sendable () async throws -> PrStatus
let changes: @Sendable (_ staged: Bool) async throws -> [ChangedFile]
let stage: @Sendable (_ files: [String], _ stage: Bool) async throws
-> GitWriteOutcome<StageResult>
let commit: @Sendable (_ message: String) async throws -> GitWriteOutcome<CommitResult>
let push: @Sendable () async throws -> GitWriteOutcome<PushResult>
let fetchRemote: @Sendable () async throws -> GitWriteOutcome<FetchResult>
/// fetch "" 1h
let nowMs: @Sendable () -> Double
init(
detail: @escaping @Sendable () async throws -> ProjectDetail,
log: @escaping @Sendable () async throws -> GitLogResult,
pr: @escaping @Sendable () async throws -> PrStatus,
changes: @escaping @Sendable (Bool) async throws -> [ChangedFile],
stage: @escaping @Sendable ([String], Bool) async throws -> GitWriteOutcome<StageResult>,
commit: @escaping @Sendable (String) async throws -> GitWriteOutcome<CommitResult>,
push: @escaping @Sendable () async throws -> GitWriteOutcome<PushResult>,
fetchRemote: @escaping @Sendable () async throws -> GitWriteOutcome<FetchResult>,
nowMs: @escaping @Sendable () -> Double = { Date().timeIntervalSince1970 * 1_000 }
) {
self.detail = detail
self.log = log
self.pr = pr
self.changes = changes
self.stage = stage
self.commit = commit
self.push = push
self.fetchRemote = fetchRemote
self.nowMs = nowMs
}
}
let path: String
private(set) var isLoaded = false
private(set) var branch: String?
/// nil = git **** `stateErrorMessage`
private(set) var band: GitSyncBand?
private(set) var stateErrorMessage: String?
private(set) var log: GitLogResult?
private(set) var logErrorMessage: String?
private(set) var pr: PrStatus?
private(set) var unstaged: [ChangedFile] = []
private(set) var staged: [ChangedFile] = []
private(set) var changesErrorMessage: String?
private(set) var busy: Operation?
///
private(set) var errorMessage: String?
///
private(set) var noticeMessage: String?
/// 稿`TextField`
var commitMessage = ""
@ObservationIgnored
private let dependencies: Dependencies
init(path: String, dependencies: Dependencies) {
self.path = path
self.dependencies = dependencies
}
/// ProjectDetailScreen endpoint/http/path
static func forProject(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> GitPanelViewModel {
let client = APIClient(endpoint: endpoint, http: http)
let diff = DiffFetcher(endpoint: endpoint, http: http)
return GitPanelViewModel(path: path, dependencies: Dependencies(
detail: { try await client.projectDetail(path: path) },
log: { try await client.gitLog(path: path) },
pr: { try await client.prStatus(path: path) },
changes: { staged in
try await diff.fetch(path: path, staged: staged).files.map(ChangedFile.make(from:))
},
stage: { files, stage in
try await client.gitStage(path: path, files: files, stage: stage)
},
commit: { message in try await client.gitCommit(path: path, message: message) },
push: { try await client.gitPush(path: path) },
fetchRemote: { try await client.gitFetch(path: path) }
))
}
var canCommit: Bool {
busy == nil && !commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// HEAD fetch 400
var canFetch: Bool { busy == nil && band?.canFetch == true }
// MARK: -
/// +
func load() async {
await loadState()
await loadLog()
await loadChanges()
isLoaded = true
}
/// PR/CI gh
func loadPullRequest() async {
do {
pr = try await dependencies.pr()
} catch {
// 200 + availability "gh "
//
pr = nil
}
}
private func loadState() async {
do {
let detail = try await dependencies.detail()
branch = detail.branch
band = GitSyncBand.make(
sync: detail.sync, dirtyCount: detail.dirtyCount, nowMs: dependencies.nowMs()
)
stateErrorMessage = nil
} catch {
branch = nil
band = nil // ""
stateErrorMessage = GitWriteFeedback.message(
for: error, fallback: GitPanelCopy.stateFailed
)
}
}
private func loadLog() async {
do {
log = try await dependencies.log()
logErrorMessage = nil
} catch {
logErrorMessage = GitWriteFeedback.message(for: error, fallback: GitPanelCopy.logFailed)
}
}
private func loadChanges() async {
do {
unstaged = try await dependencies.changes(false)
staged = try await dependencies.changes(true)
changesErrorMessage = nil
} catch {
changesErrorMessage = GitWriteFeedback.message(
for: error, fallback: GitPanelCopy.changesFailed
)
}
}
// MARK: -
func setStaged(_ file: ChangedFile, staged: Bool) async {
await perform(
staged ? .stage(file.id) : .unstage(file.id),
fallback: staged ? GitPanelCopy.stageFailed : GitPanelCopy.unstageFailed,
write: { try await self.dependencies.stage(file.stagePaths, staged) },
onSuccess: { [weak self] (result: StageResult) in
self?.noticeMessage = staged
? GitPanelCopy.staged(result.count)
: GitPanelCopy.unstaged(result.count)
}
)
}
func commit() async {
let message = commitMessage.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else {
errorMessage = GitPanelCopy.commitMessageRequired
return
}
await perform(
.commit,
fallback: GitPanelCopy.commitFailed,
write: { try await self.dependencies.commit(message) },
onSuccess: { [weak self] (result: CommitResult) in
// 稿409
self?.commitMessage = ""
self?.noticeMessage = GitPanelCopy.committed(result.commit)
}
)
}
func push() async {
await perform(
.push,
fallback: GitPanelCopy.pushFailed,
write: { try await self.dependencies.push() },
onSuccess: { [weak self] (result: PushResult) in
self?.noticeMessage = GitPanelCopy.pushed(
branch: result.branch, remote: result.remote
)
}
)
}
func fetchRemote() async {
await perform(
.fetch,
fallback: GitPanelCopy.fetchFailed,
write: { try await self.dependencies.fetchRemote() },
onSuccess: { [weak self] (_: FetchResult) in
self?.noticeMessage = GitPanelCopy.fetched
}
)
}
/// **
/// **`write`/`onSuccess` MainActor
private func perform<Payload: Sendable & Equatable>(
_ operation: Operation,
fallback: String,
write: () async throws -> GitWriteOutcome<Payload>,
onSuccess: (Payload) -> Void
) async {
guard busy == nil else { return } //
busy = operation
errorMessage = nil
noticeMessage = nil
do {
switch try await write() {
case .ok(let payload):
onSuccess(payload)
busy = nil
await load() // w6 push
return
case .rejected(let status, let message):
errorMessage = GitWriteFeedback.message(
status: status, serverMessage: message, fallback: fallback
)
case .rateLimited:
errorMessage = GitWriteFeedback.rateLimited //
}
} catch {
errorMessage = GitWriteFeedback.message(for: error, fallback: fallback)
}
busy = nil
}
}
// MARK: - plan §4
enum GitPanelCopy {
static let title = "Git 面板"
static let entryLabel = "Git 面板"
static let stateSection = "同步状态"
static let changesSection = "改动"
static let commitSection = "提交"
static let logSection = "最近提交"
static let prSection = "Pull Request"
static let upstreamCaption = "上游"
static let toPushCaption = "待推送"
static let toPullCaption = "待拉取"
static let unknownCount = ""
static let inSync = "已同步"
static let unverified = "未核实"
static let noUpstream = "无上游"
static let detachedHead = "分离 HEAD"
static let dirtyUnknown = "未检查改动"
static let clean = "工作区干净"
static let neverFetched = "从未 fetch —— 待拉取数不可信"
static let staleFetch = "上次 fetch 已久 —— 待拉取数不可信"
static let noUpstreamDetail = "此分支不跟踪任何上游,没有可报告的待推送/待拉取。"
static let detachedDetail = "HEAD 处于分离状态:没有分支,也无法 fetch。"
static let fetchButton = "Fetch"
static let fetchHint = "只刷新远端引用,不 pull、不合并"
static let commitButton = "提交"
static let pushButton = "推送"
static let commitPlaceholder = "提交信息"
static let stageButton = "暂存"
static let unstageButton = "取消暂存"
static let noChanges = "工作区没有待提交的改动。"
static let noStaged = "暂存区为空。"
static let noCommits = "暂无提交记录。"
static let truncatedLog = "仅显示最近若干条提交。"
static let commitMessageRequired = "请先填写提交信息。"
static let stateFailed = "读取 git 状态失败"
static let logFailed = "读取提交记录失败"
static let changesFailed = "读取改动列表失败"
static let stageFailed = "暂存失败"
static let unstageFailed = "取消暂存失败"
static let commitFailed = "提交失败"
static let pushFailed = "推送失败"
static let fetchFailed = "Fetch 失败"
static let fetched = "已刷新远端引用。"
static func staged(_ count: Int) -> String { "已暂存 \(count) 个文件。" }
static func unstaged(_ count: Int) -> String { "已取消暂存 \(count) 个文件。" }
static func committed(_ commit: String) -> String {
commit.isEmpty ? "已提交。" : "已提交 \(commit)"
}
static func pushed(branch: String?, remote: String?) -> String {
guard let branch, let remote else { return "已推送。" }
return "已推送 \(branch)\(remote)"
}
static func unpushedBoundary(_ upstream: String) -> String { "以上未推送到 \(upstream)" }
static func dirtyCount(_ count: Int) -> String { "\(count) 处未提交改动" }
static func lastFetched(_ label: String) -> String { "上次 fetch\(label)" }
}