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.
126 lines
4.8 KiB
Swift
126 lines
4.8 KiB
Swift
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)
|
||
}
|
||
}
|