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,327 @@
import APIClient
import Foundation
import Observation
import WireProtocol
/// T-iOS-32 · worktree create / prune / remove
///
/// `src/http/worktrees.ts` + `docs/plans/w4-worktree-lifecycle.md`web
/// `public/projects.ts``validateBranchNameClient``confirmAndRemoveWorktree`
/// `confirmAndPruneWorktrees`
///
///
/// 1. ****
///
/// 2. **** 409** force**
/// UI
/// 3. ****SEC-M10 git stderr
@MainActor
@Observable
final class WorktreeViewModel {
struct Dependencies: Sendable {
let create: @Sendable (_ branch: String, _ base: String?) async throws
-> GitWriteOutcome<CreateWorktreeResult>
let prune: @Sendable () async throws -> GitWriteOutcome<PruneWorktreesResult>
let remove: @Sendable (_ worktreePath: String, _ force: Bool) async throws
-> GitWriteOutcome<RemoveWorktreeResult>
}
enum CreatePhase: Equatable {
case idle
case creating
/// canonical path/branch
case created(path: String, branch: String)
case failed(String)
}
enum PrunePhase: Equatable {
case idle
case pruning
case done(String)
case failed(String)
}
enum RemovePhase: Equatable {
case idle
case removing
/// 409 force`confirmForceRemove`
/// `cancelForceRemove`
case forceConfirming(path: String, message: String)
case removed(path: String)
case failed(String)
}
/// `TextField`
var branchText = ""
/// ref = HEAD ****
var baseText = ""
private(set) var createPhase: CreatePhase = .idle
private(set) var prunePhase: PrunePhase = .idle
private(set) var removePhase: RemovePhase = .idle
@ObservationIgnored
private let dependencies: Dependencies
init(dependencies: Dependencies) {
self.dependencies = dependencies
}
///
static func forProject(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> WorktreeViewModel {
let client = APIClient(endpoint: endpoint, http: http)
return WorktreeViewModel(dependencies: Dependencies(
create: { branch, base in
try await client.createWorktree(path: path, branch: branch, base: base)
},
prune: { try await client.pruneWorktrees(path: path) },
remove: { worktreePath, force in
try await client.removeWorktree(
path: path, worktreePath: worktreePath, force: force
)
}
))
}
///
var branchValidationMessage: String? {
let branch = trimmedBranch
guard !branch.isEmpty else { return nil }
return WorktreeBranchRule.validate(branch)
}
var canSubmitCreate: Bool {
createPhase != .creating && WorktreeBranchRule.validate(trimmedBranch) == nil
}
var isBusy: Bool {
createPhase == .creating || prunePhase == .pruning || removePhase == .removing
}
/// ""worktree worktree 400
/// unlock 409 UI
static func canRemove(_ worktree: WorktreeInfo) -> Bool {
!worktree.isMain && worktree.locked != true
}
private var trimmedBranch: String {
branchText.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var trimmedBase: String? {
let base = baseText.trimmingCharacters(in: .whitespacesAndNewlines)
return base.isEmpty ? nil : base
}
// MARK: -
func create() async {
guard createPhase != .creating else { return } //
let branch = trimmedBranch
if let invalid = WorktreeBranchRule.validate(branch) {
createPhase = .failed(invalid) // I/O
return
}
createPhase = .creating
do {
switch try await dependencies.create(branch, trimmedBase) {
case .ok(let result):
createPhase = .created(
path: result.path ?? "", branch: result.branch ?? branch
)
case .rejected(let status, let message):
createPhase = .failed(GitWriteFeedback.message(
status: status, serverMessage: message, fallback: WorktreeCopy.createFailed
))
case .rateLimited:
createPhase = .failed(GitWriteFeedback.rateLimited)
}
} catch {
createPhase = .failed(GitWriteFeedback.message(
for: error, fallback: WorktreeCopy.createFailed
))
}
}
/// sheet
func resetCreate() {
branchText = ""
baseText = ""
createPhase = .idle
}
// MARK: - prune
func prune() async {
guard prunePhase != .pruning else { return }
prunePhase = .pruning
do {
switch try await dependencies.prune() {
case .ok(let result):
// =
prunePhase = .done(
result.pruned.isEmpty
? WorktreeCopy.pruneNothing
: WorktreeCopy.pruneDone(result.pruned.count)
)
case .rejected(let status, let message):
prunePhase = .failed(GitWriteFeedback.message(
status: status, serverMessage: message, fallback: WorktreeCopy.pruneFailed
))
case .rateLimited:
prunePhase = .failed(GitWriteFeedback.rateLimited)
}
} catch {
prunePhase = .failed(GitWriteFeedback.message(
for: error, fallback: WorktreeCopy.pruneFailed
))
}
}
func clearPruneResult() {
prunePhase = .idle
}
// MARK: - remove
/// `force: false`
func remove(worktreePath: String) async {
await runRemove(worktreePath: worktreePath, force: false)
}
/// `.forceConfirming`
func confirmForceRemove() async {
guard case .forceConfirming(let path, _) = removePhase else { return }
await runRemove(worktreePath: path, force: true)
}
func cancelForceRemove() {
guard case .forceConfirming = removePhase else { return }
removePhase = .idle
}
func clearRemoveResult() {
removePhase = .idle
}
private func runRemove(worktreePath: String, force: Bool) async {
guard removePhase != .removing else { return }
removePhase = .removing
do {
switch try await dependencies.remove(worktreePath, force) {
case .ok(let result):
removePhase = .removed(path: result.path ?? worktreePath)
case .rejected(let status, let message):
let text = GitWriteFeedback.message(
status: status, serverMessage: message, fallback: WorktreeCopy.removeFailed
)
// 409 = force
// force 409
removePhase = (status == WorktreeStatus.conflict && !force)
? .forceConfirming(path: worktreePath, message: text)
: .failed(text)
case .rateLimited:
removePhase = .failed(GitWriteFeedback.rateLimited)
}
} catch {
removePhase = .failed(GitWriteFeedback.message(
for: error, fallback: WorktreeCopy.removeFailed
))
}
}
}
/// VM HTTP APIClient internal
private enum WorktreeStatus {
static let conflict = 409
}
// MARK: -
/// web `validateBranchNameClient``public/projects.ts:982`
/// `git check-ref-format`
///
/// "" `execFile` argv shell `--`
/// ****
enum WorktreeBranchRule {
/// web
static let maxLength = 250
static func validate(_ branch: String) -> String? {
if branch.isEmpty { return Message.empty }
if branch.count > maxLength { return Message.tooLong }
if branch.unicodeScalars.contains(where: isForbiddenControlScalar) {
return Message.whitespaceOrControl
}
if branch.hasPrefix("-") { return Message.leadingHyphen }
if branch.contains("..") { return Message.doubleDot }
if branch.hasSuffix(".lock") { return Message.lockSuffix }
if branch.contains(where: forbiddenCharacters.contains) { return Message.forbiddenCharacter }
if branch.contains("@{") { return Message.atBrace }
if branch.hasPrefix("/") || branch.hasSuffix("/") || branch.contains("//") {
return Message.slashUsage
}
return nil
}
/// `[\x00-\x20\x7f]` C0 + DEL
private static func isForbiddenControlScalar(_ scalar: Unicode.Scalar) -> Bool {
scalar.value <= 0x20 || scalar.value == 0x7F
}
private static let forbiddenCharacters: Set<Character> = ["~", "^", ":", "?", "*", "[", "\\"]
private enum Message {
static let empty = "分支名不能为空。"
static let tooLong = "分支名过长(最多 \(maxLength) 个字符)。"
static let whitespaceOrControl = "分支名不能包含空格或控制字符。"
static let leadingHyphen = "分支名不能以 - 开头。"
static let doubleDot = "分支名不能包含 \"..\""
static let lockSuffix = "分支名不能以 \".lock\" 结尾。"
static let forbiddenCharacter = "分支名包含非法字符(~ ^ : ? * [ \\)。"
static let atBrace = "分支名不能包含 \"@{\""
static let slashUsage = "分支名的 / 用法不合法。"
}
}
// MARK: - plan §4
enum WorktreeCopy {
static let sheetTitle = "新建 Worktree"
static let branchField = "新分支名"
static let baseField = "起点(留空 = 当前 HEAD"
static let submit = "创建 Worktree"
static let cancel = "取消"
static let createdTitle = "Worktree 已创建"
static let openInNewWorktree = "在新 Worktree 开会话"
static let createFailed = "创建 worktree 失败"
static let pruneButton = "回收失效 Worktree"
static let pruneConfirmTitle = "回收文件夹已消失的 worktree"
static let pruneConfirmMessage = "只清理 git 中已失效的登记项,不会删除任何仍存在的工作树。"
static let pruneConfirm = "回收"
static let pruneNothing = "没有可回收的 worktree。"
static let pruneFailed = "回收失败"
static let removeButton = "删除"
static let removeAccessibilityLabel = "删除 worktree"
static let removeConfirmTitle = "删除这个 worktree"
static let removeConfirm = "删除"
static let forceConfirmTitle = "有未提交的改动"
static let forceConfirmMessage = "继续将丢失该 worktree 里未提交的改动。"
static let forceConfirm = "强制删除"
static let removeFailed = "删除 worktree 失败"
static let lockedHint = "已锁定:请先在终端里 unlock。"
static func pruneDone(_ count: Int) -> String { "已回收 \(count) 个失效 worktree。" }
static func removeConfirmMessage(_ path: String) -> String {
"将删除工作树目录:\(path)"
}
static func removed(_ path: String) -> String { "已删除 \(path)" }
static func created(path: String, branch: String) -> String {
"已在 \(path) 创建分支 \(branch)"
}
}