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,161 @@
import APIClient
import Foundation
// C2 · git **** I/O SwiftUI w6 "
// 绿"
//
// `docs/plans/w6-project-git-panel.md`Design rule that drives everything
// + web `public/projects.ts:615-745 makeSyncBand`iOS web
//
// MARK: - w6/G3
/// /worktree ""
///
/// optional 0`ahead`/`behind` `@{u}`
/// `@{u}` **** fetch
/// - `ahead`
/// - `behind` **** `lastFetchMs` ""
/// - `upstream == nil` ""****"西"
/// - `nil` "" `?? 0` 绿
///
struct GitSyncBand: Equatable {
/// HEAD
enum Head: Equatable {
/// HEAD ahead/behind
case detached
/// worktree
case noUpstream
/// nil = `` 0
case tracking(upstream: String, ahead: Int?, behind: Int?)
}
/// `unknown` dirty `PROJECT_DIRTY_CHECK=0`
/// **** clean ""
enum Dirty: Equatable {
case unknown
case clean
case changed(Int)
}
/// `FETCH_HEAD` `behind` web `FETCH_STALE_MS`
/// `public/projects.ts:615`
static let fetchStaleMs: Double = 60 * 60 * 1000
let head: Head
let dirty: Dirty
/// 绿 && 0 && 0 && fetch
let isInSync: Bool
/// `behind` fetch false ""
let isBehindVerified: Bool
/// HEAD fetch 400
let canFetch: Bool
/// nil = git
static func make(sync: SyncState?, dirtyCount: Int?, nowMs: Double) -> GitSyncBand? {
guard let sync else { return nil }
let isStale = Self.isStale(lastFetchMs: sync.lastFetchMs, nowMs: nowMs)
let head = Self.head(for: sync)
let isTracking: Bool
if case .tracking = head { isTracking = true } else { isTracking = false }
return GitSyncBand(
head: head,
dirty: Self.dirty(for: dirtyCount),
isInSync: isTracking && sync.ahead == 0 && sync.behind == 0 && !isStale,
isBehindVerified: isTracking && !isStale && sync.behind != nil,
canFetch: sync.detached != true
)
}
/// fetchnil " fetch "" fetch"
private static func isStale(lastFetchMs: Double?, nowMs: Double) -> Bool {
guard let lastFetchMs else { return true }
return nowMs - lastFetchMs > fetchStaleMs
}
private static func head(for sync: SyncState) -> Head {
if sync.detached == true { return .detached }
guard let upstream = sync.upstream else { return .noUpstream }
return .tracking(upstream: upstream, ahead: sync.ahead, behind: sync.behind)
}
private static func dirty(for dirtyCount: Int?) -> Dirty {
guard let dirtyCount else { return .unknown }
return dirtyCount > 0 ? .changed(dirtyCount) : .clean
}
}
// MARK: - w6/G4
/// `git log` "/"线
///
/// `unpushed` SHA `@{u}..HEAD`
/// **** merge
/// " unpushed"" N "
enum GitLogBoundary {
/// ****nil =
///
static func index(commits: [CommitLogEntry], upstream: String?) -> Int? {
guard upstream != nil else { return nil }
// `unpushed` true nil "" false
return commits.lastIndex { $0.unpushed == true }
}
}
// MARK: -
/// / fetch
/// 退
enum GitTimeFormat {
private static let msPerSecond: Double = 1_000
private static let secondsPerMinute: Double = 60
private static let secondsPerHour: Double = 60 * 60
private static let secondsPerDay: Double = 24 * 60 * 60
///
private static let justNowSeconds: Double = 60
static func relative(fromMs: Double, nowMs: Double) -> String {
let seconds = max(0, (nowMs - fromMs) / msPerSecond)
if seconds < justNowSeconds { return Copy.justNow }
if seconds < secondsPerHour {
return Copy.minutesAgo(Int(seconds / secondsPerMinute))
}
if seconds < secondsPerDay {
return Copy.hoursAgo(Int(seconds / secondsPerHour))
}
return Copy.daysAgo(Int(seconds / secondsPerDay))
}
private enum Copy {
static let justNow = "刚刚"
static func minutesAgo(_ value: Int) -> String { "\(value) 分钟前" }
static func hoursAgo(_ value: Int) -> String { "\(value) 小时前" }
static func daysAgo(_ value: Int) -> String { "\(value) 天前" }
}
}
// MARK: -
/// git stage/commit/push/fetch/worktree****
///
/// git stderr SEC-M10****
///
/// message
enum GitWriteFeedback {
static func message(status: Int, serverMessage: String?, fallback: String) -> String {
guard let serverMessage, !serverMessage.isEmpty else {
return "\(fallback)HTTP \(status)"
}
return serverMessage
}
/// `APIClientError` 401
/// `localizedDescription`
///
static func message(for error: any Error, fallback: String) -> String {
(error as? APIClientError)?.message ?? fallback
}
/// 429****
static let rateLimited = APIClientError.rateLimited.message
}