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,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)
}
}