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:
125
ios/App/WebTerm/DesignSystem/AppTheme.swift
Normal file
125
ios/App/WebTerm/DesignSystem/AppTheme.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-34 · 主题设置(跟随系统 / 深色 / 浅色)。
|
||||
///
|
||||
/// 历史:`RootView` 曾**硬锁** `.preferredColorScheme(.dark)`,理由写在注释里
|
||||
/// ——「琥珀金强调色是为暖深色背景设计的,浅底上金字对比不足」。那个理由对
|
||||
/// **当时的 token 取值**成立,但结论下错了:正确做法不是禁掉浅色,而是给每个
|
||||
/// 语义色一个能看清的浅色值(见 `Tokens.swift` 的 light 分支与
|
||||
/// `AppThemeTests` 的 WCAG 断言)。本类型就是解锁后的那个选择。
|
||||
///
|
||||
/// 默认值 = `.dark`,与桌面 web 的 `DEFAULT_SETTINGS.theme = 'dark'`
|
||||
/// (`public/settings.ts:33`)以及此前的硬锁行为一致 —— 老用户升级后观感零变化。
|
||||
enum AppTheme: String, CaseIterable, Equatable, Sendable {
|
||||
/// 跟随系统外观(iOS 设置里的浅色/深色/自动)。
|
||||
case system
|
||||
/// 强制深色(历史默认)。
|
||||
case dark
|
||||
/// 强制浅色。
|
||||
case light
|
||||
|
||||
/// 注入给 SwiftUI 的 `preferredColorScheme` 值。`nil` = 不表态,交给系统
|
||||
/// ——这正是「跟随系统」的实现方式,不能用当前系统值去"快照",否则用户在
|
||||
/// 系统里切换外观时 App 不跟。
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .dark: return .dark
|
||||
case .light: return .light
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择器行文案(中文,与全 App 一致)。
|
||||
var label: String {
|
||||
switch self {
|
||||
case .system: return "跟随系统"
|
||||
case .dark: return "深色"
|
||||
case .light: return "浅色"
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择器行图标。三档互不相同 —— 颜色之外还有形状(DS 的一贯纪律:
|
||||
/// 语义绝不只靠颜色传达)。
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .system: return "circle.lefthalf.filled"
|
||||
case .dark: return "moon.fill"
|
||||
case .light: return "sun.max.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// 本设置在给定系统外观下的**有效**配色。终端画布等需要"现在到底是深还是浅"
|
||||
/// 的地方用它(`.system` 时答案来自环境,不是常量)。
|
||||
func resolvedScheme(system: ColorScheme) -> ColorScheme {
|
||||
colorScheme ?? system
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 持久化编解码(纯函数)
|
||||
|
||||
/// 存/读的唯一编解码点。读侧**永不抛错**:磁盘上的值是可能被改坏/被旧版本写坏的
|
||||
/// 外部输入,任何不在白名单里的串都退回 `fallback`(而不是崩、也不是悄悄落到
|
||||
/// 「跟随系统」——那会让用户的显式选择变成另一档)。
|
||||
enum AppThemeCodec {
|
||||
/// 未设置 / 脏值时的落点。等于历史硬锁的深色 ⇒ 零回归。
|
||||
static let fallback = AppTheme.dark
|
||||
|
||||
static func decode(_ raw: String?) -> AppTheme {
|
||||
raw.flatMap(AppTheme.init(rawValue:)) ?? fallback
|
||||
}
|
||||
|
||||
static func encode(_ theme: AppTheme) -> String {
|
||||
theme.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 存储接缝
|
||||
|
||||
/// `UserDefaults` 的可测接缝(同 `SecItemShim` 的房规:live 实现零逻辑,
|
||||
/// 逻辑全在可单测的一侧)。**只存主题标识**——这里绝不放任何密级材料
|
||||
/// (令牌/证书只进 Keychain,§1.1)。
|
||||
protocol ThemeDefaults {
|
||||
func themeRaw(forKey key: String) -> String?
|
||||
func setThemeRaw(_ value: String, forKey key: String)
|
||||
}
|
||||
|
||||
/// 直通 `UserDefaults.standard`。无存储属性 ⇒ 无并发状态。
|
||||
struct LiveThemeDefaults: ThemeDefaults {
|
||||
func themeRaw(forKey key: String) -> String? {
|
||||
UserDefaults.standard.string(forKey: key)
|
||||
}
|
||||
|
||||
func setThemeRaw(_ value: String, forKey key: String) {
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ThemeStore
|
||||
|
||||
/// 主题的单一真值。`RootView` 持有它、注入环境;设置页读写它。
|
||||
///
|
||||
/// 写策略:`select` 对**同值**是 no-op(不产生多余写);读只在 init 发生一次,
|
||||
/// 之后内存里的 `theme` 就是真值(`@Observable` 驱动 UI)。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ThemeStore {
|
||||
/// `UserDefaults` 键。带 App 反查前缀,避免与任何库的键撞车。
|
||||
static let storageKey = "webterm.appearance.theme"
|
||||
|
||||
private(set) var theme: AppTheme
|
||||
|
||||
@ObservationIgnored private let defaults: any ThemeDefaults
|
||||
|
||||
init(defaults: any ThemeDefaults = LiveThemeDefaults()) {
|
||||
self.defaults = defaults
|
||||
self.theme = AppThemeCodec.decode(defaults.themeRaw(forKey: Self.storageKey))
|
||||
}
|
||||
|
||||
/// 选一档。同值直接返回 —— 既省一次写,也避免 `@Observable` 无谓触发重绘。
|
||||
func select(_ next: AppTheme) {
|
||||
guard next != theme else { return }
|
||||
theme = next
|
||||
defaults.setThemeRaw(AppThemeCodec.encode(next), forKey: Self.storageKey)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user