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.
162 lines
7.3 KiB
Swift
162 lines
7.3 KiB
Swift
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
|
||
)
|
||
}
|
||
|
||
/// 从未 fetch(nil)也算过期 —— "没 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
|
||
}
|