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,353 @@
import SwiftUI
import Testing
import UIKit
@testable import WebTerm
/// T-iOS-34 · / /
/// ** token **doc §4 AE 124
///
/// " `.preferredColorScheme(.dark)` "
/// **** D WCAG UI
/// 1.4.11 = 3:1 AAA = 7:1****
///
@MainActor
@Suite("AppTheme")
struct AppThemeTests {
// MARK: - A.
@Test("colorScheme.system 交给系统nil.dark/.light 强制自身")
func colorSchemePerCase() {
#expect(AppTheme.system.colorScheme == nil)
#expect(AppTheme.dark.colorScheme == .dark)
#expect(AppTheme.light.colorScheme == .light)
}
@Test("三档 label / SF Symbol 非空且互不相同")
func labelsAndSymbolsAreDistinct() {
let labels = AppTheme.allCases.map(\.label)
let symbols = AppTheme.allCases.map(\.symbolName)
#expect(labels.allSatisfy { !$0.isEmpty })
#expect(symbols.allSatisfy { !$0.isEmpty })
#expect(Set(labels).count == AppTheme.allCases.count)
#expect(Set(symbols).count == AppTheme.allCases.count)
}
@Test("allCases 顺序 = 跟随系统 → 深色 → 浅色(选择器直接用它,不重排)")
func caseOrderIsPickerOrder() {
#expect(AppTheme.allCases == [.system, .dark, .light])
}
@Test("rawValue 白名单:只认三个小写标识")
func rawValueWhitelist() {
#expect(AppTheme(rawValue: "system") == .system)
#expect(AppTheme(rawValue: "dark") == .dark)
#expect(AppTheme(rawValue: "light") == .light)
#expect(AppTheme(rawValue: "") == nil)
#expect(AppTheme(rawValue: "Dark") == nil)
#expect(AppTheme(rawValue: "solarized") == nil)
}
// MARK: - B.
@Test("未设置 → .dark默认必须等于今天硬锁的深色零回归")
func decodeMissingIsDark() {
#expect(AppThemeCodec.decode(nil) == .dark)
#expect(AppThemeCodec.fallback == .dark)
}
@Test("脏值 / 空串 / 大小写不符 → .dark不崩、不落 system")
func decodeGarbageIsDark() {
#expect(AppThemeCodec.decode("solarized") == .dark)
#expect(AppThemeCodec.decode("") == .dark)
#expect(AppThemeCodec.decode("DARK") == .dark)
#expect(AppThemeCodec.decode("\u{0}light") == .dark)
}
@Test("三档 encode → decode 往返恒等")
func codecRoundTrips() {
for theme in AppTheme.allCases {
#expect(AppThemeCodec.decode(AppThemeCodec.encode(theme)) == theme)
}
}
@Test("空 defaults → .dark且首次读不写盘")
func storeStartsDarkWithoutWriting() {
let defaults = FakeThemeDefaults()
let store = ThemeStore(defaults: defaults)
#expect(store.theme == .dark)
#expect(defaults.writeCount == 0)
}
@Test("select(.light) → 落盘 \"light\",同一 defaults 新建 store 读回(跨启动)")
func selectPersistsAcrossStores() {
let defaults = FakeThemeDefaults()
let store = ThemeStore(defaults: defaults)
store.select(.light)
#expect(store.theme == .light)
#expect(defaults.values[ThemeStore.storageKey] == "light")
#expect(ThemeStore(defaults: defaults).theme == .light)
}
@Test("select 同一档两次 → 只写一次")
func repeatedSelectWritesOnce() {
let defaults = FakeThemeDefaults()
let store = ThemeStore(defaults: defaults)
store.select(.light)
store.select(.light)
#expect(defaults.writeCount == 1)
}
@Test("defaults 里是脏值 → 读作 .dark且不动其它键")
func dirtyStoredValueDegradesToDark() {
let defaults = FakeThemeDefaults(values: [
ThemeStore.storageKey: "; rm -rf ~",
"unrelated.key": "keep-me",
])
let store = ThemeStore(defaults: defaults)
#expect(store.theme == .dark)
#expect(defaults.values["unrelated.key"] == "keep-me")
}
// MARK: - C.
@Test("resolvedScheme.system 透传系统值,.dark/.light 无视系统")
func resolvedScheme() {
#expect(AppTheme.system.resolvedScheme(system: .light) == .light)
#expect(AppTheme.system.resolvedScheme(system: .dark) == .dark)
#expect(AppTheme.dark.resolvedScheme(system: .light) == .dark)
#expect(AppTheme.light.resolvedScheme(system: .dark) == .light)
}
// MARK: - D. token
/// 5 + 线
private static let semanticColors: [(name: String, color: Color)] = [
("statusWorking", DS.Palette.statusWorking),
("statusWaiting", DS.Palette.statusWaiting),
("statusStuck", DS.Palette.statusStuck),
("timelineTool", DS.Palette.timelineTool),
("timelineUser", DS.Palette.timelineUser),
]
@Test("5 个语义色在 light 与 dark 下解析值不同(真有浅色变体)")
func semanticColorsAreAdaptive() {
for (name, color) in Self.semanticColors {
let dark = UIColor(color).resolved(.dark)
let light = UIColor(color).resolved(.light)
#expect(!ColorMath.approxEqual(dark, light), "\(name) 在两档下相同 → 没有浅色变体")
}
}
@Test("深色档逐字节不变(#46D07F/#F5B14C/#FF6B6B/#5E9EFF/#AF7BFF")
func darkSchemeValuesUnchanged() {
let expected: [(Color, UInt32)] = [
(DS.Palette.statusWorking, 0x46D0_7F),
(DS.Palette.statusWaiting, 0xF5B1_4C),
(DS.Palette.statusStuck, 0xFF6B_6B),
(DS.Palette.timelineTool, 0x5E9E_FF),
(DS.Palette.timelineUser, 0xAF7B_FF),
]
for (color, hex) in expected {
let resolved = UIColor(color).resolved(.dark)
#expect(
ColorMath.matchesHex(resolved, hex),
"深色档漂移:实际 \(ColorMath.hexString(resolved))"
)
}
}
@Test("两档下语义色对该档 systemBackground 的对比度 ≥ 3:1WCAG 1.4.11")
func semanticColorsMeetNonTextContrast() {
for style in [UIUserInterfaceStyle.dark, .light] {
let background = UIColor.systemBackground.resolved(style)
for (name, color) in Self.semanticColors {
let ratio = ColorMath.contrast(UIColor(color).resolved(style), background)
#expect(ratio >= 3, "\(name)\(style == .dark ? "dark" : "light") 只有 \(ratio):1")
}
}
}
@Test("light 档 working/waiting/stuck 仍两两可辨")
func criticalTrioStaysDistinctInLight() {
let working = UIColor(DS.Palette.statusWorking).resolved(.light)
let waiting = UIColor(DS.Palette.statusWaiting).resolved(.light)
let stuck = UIColor(DS.Palette.statusStuck).resolved(.light)
#expect(!ColorMath.approxEqual(working, waiting))
#expect(!ColorMath.approxEqual(working, stuck))
#expect(!ColorMath.approxEqual(waiting, stuck))
}
@Test("accentSoft 两档不同,且都仍是 washalpha < 0.3")
func accentSoftIsAdaptiveWash() {
let soft = UIColor(DS.Palette.accentSoft)
let dark = soft.resolved(.dark)
let light = soft.resolved(.light)
#expect(!ColorMath.approxEqual(dark, light))
#expect(ColorMath.rgba(dark).a < 0.3)
#expect(ColorMath.rgba(light).a < 0.3)
}
@Test("onAccent 两档相同(金填充恒需深墨),与 accent 对比 ≥ 4.5:1")
func onAccentIsFixedAndReadable() {
let ink = UIColor(DS.Palette.onAccent)
#expect(ColorMath.approxEqual(ink.resolved(.dark), ink.resolved(.light)))
for style in [UIUserInterfaceStyle.dark, .light] {
let ratio = ColorMath.contrast(
ink.resolved(style), DS.Palette.accentUIColor().resolved(style)
)
#expect(ratio >= 4.5, "onAccent/accent 在 \(style.rawValue) 只有 \(ratio):1")
}
}
// MARK: - E.
@Test("dark 档终端 = #100F0D / #ECE9E3桌面 web --bg/--text零回归")
func terminalDarkMatchesDesktop() {
let colors = TerminalPalette.colors(for: .dark)
#expect(ColorMath.matchesHex(colors.background, 0x100F_0D),
"实际 \(ColorMath.hexString(colors.background))")
#expect(ColorMath.matchesHex(colors.foreground, 0xECE9_E3),
"实际 \(ColorMath.hexString(colors.foreground))")
}
@Test("light 档终端 = #F6F7F9 / #1A1D24镜像 web THEMES.light")
func terminalLightMirrorsWeb() {
let colors = TerminalPalette.colors(for: .light)
#expect(ColorMath.matchesHex(colors.background, 0xF6F7_F9),
"实际 \(ColorMath.hexString(colors.background))")
#expect(ColorMath.matchesHex(colors.foreground, 0x1A1D_24),
"实际 \(ColorMath.hexString(colors.foreground))")
}
@Test("两档终端 fg↔bg 对比度 ≥ 7:1终端是正文AAA")
func terminalContrastIsAAA() {
for scheme in [ColorScheme.dark, .light] {
let colors = TerminalPalette.colors(for: scheme)
let ratio = ColorMath.contrast(colors.foreground, colors.background)
#expect(ratio >= 7, "终端 \(scheme) 对比度只有 \(ratio):1")
}
}
@Test("caret / selection 用该档 accent 解析值")
func terminalCaretFollowsAccent() {
for (scheme, style) in [(ColorScheme.dark, UIUserInterfaceStyle.dark),
(ColorScheme.light, UIUserInterfaceStyle.light)] {
let colors = TerminalPalette.colors(for: scheme)
let accent = DS.Palette.accentUIColor().resolved(style)
#expect(ColorMath.approxEqual(colors.caret, accent))
#expect(ColorMath.approxEqual(colors.selection, accent))
}
}
@Test("terminalBackgroundUIColor()/ForegroundUIColor() 是动态 UIColor两档不同")
func terminalTokensAreDynamic() {
for token in [DS.Palette.terminalBackgroundUIColor(),
DS.Palette.terminalForegroundUIColor()] {
#expect(!ColorMath.approxEqual(token.resolved(.dark), token.resolved(.light)))
}
}
@Test("SwiftUI 侧 terminalBackground/Foreground 同样跟随主题(既有调用点不退化)")
func terminalSwiftUITokensStayDynamic() {
for color in [DS.Palette.terminalBackground, DS.Palette.terminalForeground] {
let bridged = UIColor(color)
#expect(!ColorMath.approxEqual(bridged.resolved(.dark), bridged.resolved(.light)))
}
}
}
// MARK: - Fake defaults
/// `ThemeDefaults` """ select "
/// store
final class FakeThemeDefaults: ThemeDefaults {
private(set) var values: [String: String]
private(set) var writeCount = 0
init(values: [String: String] = [:]) {
self.values = values
}
func themeRaw(forKey key: String) -> String? { values[key] }
func setThemeRaw(_ value: String, forKey key: String) {
values = values.merging([key: value]) { _, new in new }
writeCount += 1
}
}
// MARK: - Color math (WCAG)
/// + WCAG /
/// ****"" WCAG 2.1
enum ColorMath {
typealias RGBA = (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat)
static func rgba(_ color: UIColor) -> RGBA {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
color.getRed(&r, green: &g, blue: &b, alpha: &a)
return (r, g, b, a)
}
static func approxEqual(_ lhs: UIColor, _ rhs: UIColor, tolerance: CGFloat = 0.02) -> Bool {
let left = rgba(lhs), right = rgba(rhs)
return abs(left.r - right.r) < tolerance
&& abs(left.g - right.g) < tolerance
&& abs(left.b - right.b) < tolerance
&& abs(left.a - right.a) < tolerance
}
/// WCAG sRGB 线
static func luminance(_ color: UIColor) -> CGFloat {
let components = rgba(color)
func linear(_ channel: CGFloat) -> CGFloat {
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
}
return 0.2126 * linear(components.r)
+ 0.7152 * linear(components.g)
+ 0.0722 * linear(components.b)
}
/// WCAG 1:121:1
static func contrast(_ lhs: UIColor, _ rhs: UIColor) -> CGFloat {
let a = luminance(lhs), b = luminance(rhs)
let (lighter, darker) = a > b ? (a, b) : (b, a)
return (lighter + 0.05) / (darker + 0.05)
}
/// ±1/255 `0xRRGGBB`
static func matchesHex(_ color: UIColor, _ hex: UInt32, tolerance: CGFloat = 0.004) -> Bool {
let components = rgba(color)
let expected = (
r: CGFloat((hex >> 16) & 0xFF) / 255,
g: CGFloat((hex >> 8) & 0xFF) / 255,
b: CGFloat(hex & 0xFF) / 255
)
return abs(components.r - expected.r) < tolerance
&& abs(components.g - expected.g) < tolerance
&& abs(components.b - expected.b) < tolerance
}
/// `#RRGGBB`便
static func hexString(_ color: UIColor) -> String {
let components = rgba(color)
let value = (Int(components.r * 255) << 16)
| (Int(components.g * 255) << 8) | Int(components.b * 255)
return String(format: "#%06X", value)
}
}
extension UIColor {
///
func resolved(_ style: UIUserInterfaceStyle) -> UIColor {
resolvedColor(with: UITraitCollection(userInterfaceStyle: style))
}
}