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.
360 lines
13 KiB
Swift
360 lines
13 KiB
Swift
import APIClient
|
||
import Foundation
|
||
import SwiftUI
|
||
|
||
/// C2 · git 面板的可复用子视图 —— 全部只消费 `DS` token(零硬编码颜色/间距),
|
||
/// 服务器字节一律 `Text(verbatim:)`,触达目标 ≥ `DS.Layout.minHitTarget`。
|
||
|
||
// MARK: - 同步带(w6/G3 的手机竖排版本)
|
||
|
||
/// 竖排三行的同步状态:HEAD/上游 · 待推送 · 待拉取(+ 脏度)。
|
||
///
|
||
/// 视觉语义就是那条设计铁律:**只有「已同步」可以用绿色**(`statusWorking`),
|
||
/// 「未核实/无上游/分离 HEAD」用琥珀(`statusWaiting`),其余中性。数字本身从不
|
||
/// 被染成警告色 —— 落后是信息不是错误,怀疑由芯片和脚注承担(镜像 web 的修正)。
|
||
struct GitSyncBandView: View {
|
||
let band: GitSyncBand
|
||
/// 当前分支(服务器字节)。
|
||
let branch: String?
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: DS.Space.sm8) {
|
||
headRow
|
||
if case .tracking(_, let ahead, let behind) = band.head {
|
||
countsRow(ahead: ahead, behind: behind)
|
||
}
|
||
if let footnote {
|
||
Text(footnote)
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(
|
||
band.isInSync ? DS.Palette.textSecondary : DS.Palette.statusWaiting
|
||
)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
dirtyRow
|
||
}
|
||
.padding(.vertical, DS.Space.xs4)
|
||
}
|
||
|
||
@ViewBuilder private var headRow: some View {
|
||
HStack(spacing: DS.Space.sm8) {
|
||
if let branch {
|
||
Label {
|
||
Text(verbatim: branch).lineLimit(1)
|
||
} icon: {
|
||
Image(systemName: "arrow.triangle.branch")
|
||
}
|
||
.font(DS.Typography.callout)
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
}
|
||
switch band.head {
|
||
case .detached:
|
||
SyncChip(text: GitPanelCopy.detachedHead, tone: .warning)
|
||
case .noUpstream:
|
||
SyncChip(text: GitPanelCopy.noUpstream, tone: .warning)
|
||
case .tracking(let upstream, _, _):
|
||
// 上游名是服务器字节。
|
||
Text(verbatim: upstream)
|
||
.font(DS.Typography.mono(.caption))
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
if band.isInSync {
|
||
SyncChip(text: GitPanelCopy.inSync, tone: .good)
|
||
}
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
|
||
private func countsRow(ahead: Int?, behind: Int?) -> some View {
|
||
HStack(spacing: DS.Space.md12) {
|
||
countCell(caption: GitPanelCopy.toPushCaption, symbol: "arrow.up", value: ahead)
|
||
countCell(caption: GitPanelCopy.toPullCaption, symbol: "arrow.down", value: behind)
|
||
if !band.isBehindVerified {
|
||
SyncChip(text: GitPanelCopy.unverified, tone: .warning)
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
|
||
/// nil 计数渲染成 `—`:那是"算不出来",把它当 0 正是本类型要防的谎。
|
||
private func countCell(caption: String, symbol: String, value: Int?) -> some View {
|
||
HStack(spacing: DS.Space.xs4) {
|
||
Image(systemName: symbol)
|
||
.imageScale(.small)
|
||
Text(value.map(String.init) ?? GitPanelCopy.unknownCount)
|
||
.font(DS.Typography.mono(.callout))
|
||
Text(caption)
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
}
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
|
||
@ViewBuilder private var dirtyRow: some View {
|
||
switch band.dirty {
|
||
case .unknown:
|
||
Text(GitPanelCopy.dirtyUnknown)
|
||
.dsMetaText()
|
||
case .clean:
|
||
SyncChip(text: GitPanelCopy.clean, tone: .good)
|
||
case .changed(let count):
|
||
SyncChip(text: GitPanelCopy.dirtyCount(count), tone: .dirty)
|
||
}
|
||
}
|
||
|
||
/// 为什么这个数字不可信 —— 缺了这句话,读者最容易把"没有数字"读成"没事"。
|
||
private var footnote: String? {
|
||
switch band.head {
|
||
case .detached:
|
||
return GitPanelCopy.detachedDetail
|
||
case .noUpstream:
|
||
return GitPanelCopy.noUpstreamDetail
|
||
case .tracking:
|
||
guard !band.isBehindVerified else { return nil }
|
||
return GitPanelCopy.staleFetch
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 项目详情**头部**的极简版同步态:一行胶囊,回答"有没有没推的提交"。
|
||
///
|
||
/// 与面板版共享同一份 `GitSyncBand` 判断,所以两处永不打架:绿色只可能是
|
||
/// 「已同步」,`nil` 计数显示 `—` 而不是 0,过期的 `↓` 一定带「未核实」。
|
||
struct SyncSummaryChips: View {
|
||
let band: GitSyncBand
|
||
|
||
var body: some View {
|
||
HStack(spacing: DS.Space.xs4) {
|
||
switch band.head {
|
||
case .detached:
|
||
SyncChip(text: GitPanelCopy.detachedHead, tone: .warning)
|
||
case .noUpstream:
|
||
SyncChip(text: GitPanelCopy.noUpstream, tone: .warning)
|
||
case .tracking(_, let ahead, let behind):
|
||
if band.isInSync {
|
||
SyncChip(text: GitPanelCopy.inSync, tone: .good)
|
||
} else {
|
||
trackingChips(ahead: ahead, behind: behind)
|
||
}
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder private func trackingChips(ahead: Int?, behind: Int?) -> some View {
|
||
if let ahead, ahead > 0 {
|
||
SyncChip(text: "↑ \(ahead)", tone: .neutral)
|
||
}
|
||
if let behind, behind > 0 {
|
||
SyncChip(text: "↓ \(behind)", tone: .neutral)
|
||
}
|
||
if ahead == nil || behind == nil {
|
||
// 读不到就说读不到 —— 绝不显示成 0。
|
||
SyncChip(text: "↑↓ \(GitPanelCopy.unknownCount)", tone: .warning)
|
||
}
|
||
if !band.isBehindVerified {
|
||
SyncChip(text: GitPanelCopy.unverified, tone: .warning)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 小胶囊:语义色 + 淡底(与 `DirtyBadge` 同一做法,明暗两态都清晰)。
|
||
struct SyncChip: View {
|
||
enum Tone {
|
||
case good
|
||
case warning
|
||
case dirty
|
||
case neutral
|
||
}
|
||
|
||
let text: String
|
||
var tone: Tone = .neutral
|
||
|
||
private var color: Color {
|
||
switch tone {
|
||
case .good: return DS.Palette.statusWorking
|
||
case .warning: return DS.Palette.statusWaiting
|
||
case .dirty: return DS.Palette.statusWaiting
|
||
case .neutral: return DS.Palette.textSecondary
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
Text(text)
|
||
.font(DS.Typography.caption.weight(.medium))
|
||
.foregroundStyle(color)
|
||
.padding(.horizontal, DS.Space.sm8)
|
||
.padding(.vertical, DS.Space.xs2)
|
||
.background(color.opacity(Self.fillOpacity), in: Capsule())
|
||
.lineLimit(1)
|
||
}
|
||
|
||
/// 软填充胶囊的底色透明度(与 `DirtyBadge` 一致)。
|
||
private static let fillOpacity: Double = 0.18
|
||
}
|
||
|
||
// MARK: - 未推送边界(w6/G4)
|
||
|
||
/// `git log` 列表里的一条分界线:以上尚未推送到 `<upstream>`。
|
||
struct UnpushedBoundary: View {
|
||
let upstream: String
|
||
|
||
var body: some View {
|
||
HStack(spacing: DS.Space.sm8) {
|
||
Rectangle()
|
||
.fill(DS.Palette.statusWaiting)
|
||
.frame(height: DS.Stroke.hairline)
|
||
// upstream 是服务器字节 → 拼进文案前仍走 verbatim 渲染。
|
||
Text(verbatim: GitPanelCopy.unpushedBoundary(upstream))
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.statusWaiting)
|
||
.lineLimit(1)
|
||
Rectangle()
|
||
.fill(DS.Palette.statusWaiting)
|
||
.frame(height: DS.Stroke.hairline)
|
||
}
|
||
.padding(.vertical, DS.Space.xs2)
|
||
}
|
||
}
|
||
|
||
// MARK: - 行内提示
|
||
|
||
/// 一行成功/失败提示。服务器安全文案原样显示(verbatim),绝不静默。
|
||
struct InlineMessage: View {
|
||
enum Tone {
|
||
case error
|
||
case success
|
||
case info
|
||
}
|
||
|
||
let text: String
|
||
var tone: Tone = .info
|
||
|
||
private var color: Color {
|
||
switch tone {
|
||
case .error: return DS.Palette.statusStuck
|
||
case .success: return DS.Palette.statusWorking
|
||
case .info: return DS.Palette.textSecondary
|
||
}
|
||
}
|
||
|
||
private var symbol: String {
|
||
switch tone {
|
||
case .error: return "exclamationmark.triangle"
|
||
case .success: return "checkmark.circle"
|
||
case .info: return "info.circle"
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(alignment: .top, spacing: DS.Space.sm8) {
|
||
Image(systemName: symbol)
|
||
.foregroundStyle(color)
|
||
Text(verbatim: text)
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.textPrimary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.accessibilityElement(children: .combine)
|
||
}
|
||
}
|
||
|
||
// MARK: - PR / CI 芯片(w3-pr-ci-chip 的移动端对齐)
|
||
|
||
/// 一行 PR 状态 + CI 汇总。
|
||
///
|
||
/// gh 的降级(未安装/未登录/无 PR/已关闭/出错)在服务端是 200 + `availability`,
|
||
/// 所以这里一条分支都不当错误处理 —— 芯片照常在,只是换句话说。
|
||
struct PrStatusRow: View {
|
||
let status: PrStatus
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: DS.Space.xs4) {
|
||
HStack(spacing: DS.Space.sm8) {
|
||
TelemetryChip(systemImage: "arrow.triangle.pull", text: headline)
|
||
if let checks = status.checks, checks.total > 0 {
|
||
TelemetryChip(
|
||
systemImage: checkSymbol(checks),
|
||
text: PrCopy.checks(checks),
|
||
isWarning: checks.failing > 0
|
||
)
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
if let title = status.title, !title.isEmpty {
|
||
// PR 标题是 GitHub 上的任意文本 → verbatim。
|
||
Text(verbatim: title)
|
||
.font(DS.Typography.caption)
|
||
.foregroundStyle(DS.Palette.textSecondary)
|
||
.lineLimit(2)
|
||
}
|
||
if let url = PrLink.url(from: status.url) {
|
||
Link(destination: url) {
|
||
Label(PrCopy.openInBrowser, systemImage: "safari")
|
||
.font(DS.Typography.caption)
|
||
}
|
||
.frame(minHeight: DS.Layout.minHitTarget, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var headline: String {
|
||
switch status.availability {
|
||
case .ok:
|
||
return PrCopy.number(status.number, state: status.state, isDraft: status.isDraft)
|
||
case .noPr: return PrCopy.noPr
|
||
case .notInstalled: return PrCopy.notInstalled
|
||
case .unauthenticated: return PrCopy.unauthenticated
|
||
case .disabled: return PrCopy.disabled
|
||
case .error: return PrCopy.error
|
||
}
|
||
}
|
||
|
||
private func checkSymbol(_ checks: PrCheckSummary) -> String {
|
||
if checks.failing > 0 { return "xmark.octagon" }
|
||
if checks.pending > 0 { return "clock" }
|
||
return "checkmark.seal"
|
||
}
|
||
}
|
||
|
||
/// PR 链接白名单:URL 是**服务器字节**,直接丢给 `openURL` 等于让远端选择要打开
|
||
/// 哪个 App(自定义 scheme 可以跳出浏览器)。因此只接受 https + github.com。
|
||
enum PrLink {
|
||
private static let allowedScheme = "https"
|
||
private static let allowedHostSuffix = "github.com"
|
||
|
||
static func url(from raw: String?) -> URL? {
|
||
guard let raw, let url = URL(string: raw), url.scheme?.lowercased() == allowedScheme,
|
||
let host = url.host?.lowercased(),
|
||
host == allowedHostSuffix || host.hasSuffix("." + allowedHostSuffix)
|
||
else {
|
||
return nil
|
||
}
|
||
return url
|
||
}
|
||
}
|
||
|
||
enum PrCopy {
|
||
static let noPr = "无 PR"
|
||
static let notInstalled = "主机未安装 gh"
|
||
static let unauthenticated = "gh 未登录"
|
||
static let disabled = "PR 查询已关闭"
|
||
static let error = "PR 状态获取失败"
|
||
static let openInBrowser = "在浏览器打开"
|
||
static let draft = "草稿"
|
||
|
||
static func number(_ number: Int?, state: String?, isDraft: Bool?) -> String {
|
||
var text = number.map { "PR #\($0)" } ?? "PR"
|
||
if let state, !state.isEmpty { text += " · \(state)" }
|
||
if isDraft == true { text += " · \(draft)" }
|
||
return text
|
||
}
|
||
|
||
static func checks(_ checks: PrCheckSummary) -> String {
|
||
"✓\(checks.passing) ✗\(checks.failing) ⏳\(checks.pending)"
|
||
}
|
||
}
|