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

View File

@@ -72,6 +72,9 @@ struct TelemetryChip: View {
.background(.quaternary, in: Capsule())
.opacity(isStale ? DS.Opacity.stale : 1)
.grayscale(isStale ? 1 : 0)
// T-iOS-34 · a `lineLimit(1)` tabular pill cannot wrap, so past a point
// extra size only truncates it. Same ceiling as `dsMetaText`.
.dynamicTypeSize(...DS.Typography.numericClamp)
}
}
@@ -122,6 +125,12 @@ struct DSButtonStyle: ButtonStyle {
enum Kind { case primary, secondary, destructive }
var kind: Kind = .primary
/// T-iOS-34 · how far a label may shrink before it would rather overflow.
/// A gate's two half-width buttons at AX5 hold a word that cannot hyphenate
/// ("Approve"); a small scale-down plus wrapping keeps it inside the pill
/// instead of bleeding past the rounded edge.
fileprivate static let labelMinScale: CGFloat = 0.75
func makeBody(configuration: Configuration) -> some View {
DSButtonBody(kind: kind, configuration: configuration)
}
@@ -137,6 +146,12 @@ struct DSButtonStyle: ButtonStyle {
var body: some View {
configuration.label
.font(DS.Typography.body.weight(.semibold))
// Dynamic Type: wrap + center + a bounded shrink, then let the
// pill grow taller. `minHeight` (not `height`) is what keeps a
// wrapped AX5 label from being clipped to 44pt.
.multilineTextAlignment(.center)
.minimumScaleFactor(DSButtonStyle.labelMinScale)
.padding(.horizontal, DS.Space.sm8)
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
.foregroundStyle(foreground)
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12))

View File

@@ -0,0 +1,98 @@
import SwiftUI
import UIKit
/// T-iOS-34 · ****
///
/// ""
///
///
/// - = `#100F0D` / `#ECE9E3` web `--bg` / `--text`****
/// - = `#F6F7F9` / `#1A1D24` web `public/settings.ts`
/// `THEMES.light`iOS
///
/// caret / selection **** accent `#E3A64A` /
/// `#C9892F`
///
/// `SwiftTerm.TerminalView` `nativeBackgroundColor` setter
/// UIColor `terminal.backgroundColor``iOSTerminalView.swift:1207`
/// `traitCollectionDidChange` ** UIColor "
/// "** `apply`
/// `colors(for:)` `dynamic*` UIColor
/// trait
enum TerminalPalette {
/// ****
/// 便 SwiftTerm便
struct Colors: Equatable {
let background: UIColor
let foreground: UIColor
let caret: UIColor
let selection: UIColor
}
///
private enum Dark {
static let background: UInt32 = 0x100F_0D
static let foreground: UInt32 = 0xECE9_E3
}
/// web `THEMES.light`
private enum Light {
static let background: UInt32 = 0xF6F7_F9
static let foreground: UInt32 = 0x1A1D_24
}
static func colors(for scheme: ColorScheme) -> Colors {
let style = scheme.userInterfaceStyle
let accent = DS.Palette.accentUIColor()
.resolvedColor(with: UITraitCollection(userInterfaceStyle: style))
let isDark = style == .dark
return Colors(
background: UIColor(hex: isDark ? Dark.background : Light.background),
foreground: UIColor(hex: isDark ? Dark.foreground : Light.foreground),
caret: accent,
selection: accent
)
}
/// SwiftUI/UIKit trait
static func dynamicBackground() -> UIColor {
UIColor { trait in colors(for: trait.colorScheme).background }
}
///
static func dynamicForeground() -> UIColor {
UIColor { trait in colors(for: trait.colorScheme).foreground }
}
}
// MARK: - ColorScheme UIUserInterfaceStyle
extension ColorScheme {
/// SwiftUI UIKit`ColorScheme` light/dark case
var userInterfaceStyle: UIUserInterfaceStyle {
self == .dark ? .dark : .light
}
}
extension UITraitCollection {
/// UIKit SwiftUI`.unspecified` iOS
var colorScheme: ColorScheme {
userInterfaceStyle == .dark ? .dark : .light
}
}
// MARK: - Hex
extension UIColor {
/// `0xRRGGBB` sRGB token /CSS
/// 01
convenience init(hex: UInt32, alpha: CGFloat = 1) {
self.init(
red: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255,
alpha: alpha
)
}
}

View File

@@ -9,6 +9,16 @@ import UIKit
/// (refined native, Apple HIG), dark-mode-first, amber-gold accent (desktop-matched)
/// continuing the web selection color.
///
/// T-iOS-34 · dark-mode-first no longer means dark-mode-only: the app has a real
/// theme setting (`AppTheme` / `ThemeStore`, injected once by `RootView`), so
/// EVERY color token must resolve sensibly in both schemes. Adaptive tokens are
/// built with the private `adaptive(dark:light:)` helper below and their light
/// values are contrast-audited in `AppThemeTests` (WCAG 1.4.11, 3:1 floor for
/// non-text UI). Known accepted gap: `accent` at its frozen light value #C9892F
/// is 2.88:1 on white fine as a FILL (dark ink on top is 6.2:1) but marginal
/// as bare tinted text; the value is pinned by `DesignSystemTests` and left
/// unchanged here (reported to the orchestrator instead of silently re-toned).
///
/// Vocabulary (all under `DS.`):
/// - `Palette` adaptive `accent` (amber gold, matches desktop) · semantic `status*` colors
/// (color is NEVER the only status signal pair with a symbol,
@@ -59,21 +69,36 @@ enum DS {
/// ink for contrast mirrors web --on-accent #1A1305).
static let onAccent = rgb(26, 19, 5)
/// Faint accent wash (selection/soft highlight) web --accent-soft.
static let accentSoft = Color(red: 0xE3 / 255.0, green: 0xA6 / 255.0, blue: 0x4A / 255.0, opacity: 0.15)
/// Adaptive (T-iOS-34): the dark wash is the web value verbatim; on a
/// light background a 15% wash of the BRIGHT gold reads as nothing, so
/// light uses the deeper `--accent-2` gold at a slightly higher alpha.
/// Still a wash in both (alpha < 0.3 never a solid fill).
static let accentSoft = adaptive(
dark: UIColor(hex: 0xE3A6_4A, alpha: 0.15),
light: UIColor(hex: 0xC989_2F, alpha: 0.18)
)
// Semantic status colors (match the desktop/web status palette)
// These are the ONLY status colors. `StatusStyle` pairs each with a
// distinct SF Symbol so status is never conveyed by color alone. Hex
// mirrors the web's warm status set (public/style.css:18-20) so iOS and
// desktop read the same. `waiting` amber #F5B14C stays distinct from the
// gold accent #E3A64A (brighter/less brown), and is only ever a small
// badge fill, never a large accent surface.
/// working #46D07F (web --green).
static let statusWorking = rgb(70, 208, 127)
/// waiting / needs-me #F5B14C (web --amber).
static let statusWaiting = rgb(245, 177, 76)
/// stuck #FF6B6B (web --red).
static let statusStuck = rgb(255, 107, 107)
// distinct SF Symbol so status is never conveyed by color alone. The
// DARK values mirror the web's warm status set (public/style.css:18-20)
// byte for byte so iOS and desktop read the same; `waiting` amber
// #F5B14C stays distinct from the gold accent #E3A64A (brighter/less
// brown), and is only ever a small badge fill, never a large surface.
//
// T-iOS-34 · the LIGHT values are new. The web chrome is dark-only, so
// there was nothing to mirror: each light value is the same hue darkened
// until it clears WCAG 1.4.11 (3:1 for non-text UI) against a white
// background the bright dark-mode values sit at 1.96:1 (#46D07F),
// 1.60:1 (#F5B14C) and 2.74:1 (#FF6B6B) on white, i.e. unreadable.
// `AppThemeTests` pins BOTH the dark bytes and the light contrast floor.
/// working dark #46D07F (web --green) · light #1F8F52 (4.0:1 on white).
static let statusWorking = adaptive(dark: 0x46D0_7F, light: 0x1F8F_52)
/// waiting / needs-me dark #F5B14C (web --amber) · light #C25E00
/// (4.2:1; burnt orange keeps it distinct from the light gold accent).
static let statusWaiting = adaptive(dark: 0xF5B1_4C, light: 0xC25E_00)
/// stuck dark #FF6B6B (web --red) · light #D22B2B (5.1:1).
static let statusStuck = adaptive(dark: 0xFF6B_6B, light: 0xD22B_2B)
/// idle quiet secondary gray (distinguished from `unknown` by shape).
static let statusIdle = Color.secondary
/// unknown / no signal yet gray.
@@ -86,10 +111,12 @@ enum DS {
// and `stuck` reuse the status tokens above (same meaning); `done`
// reuses `statusWorking` (a completed run). `tool`/`user` get their own
// tokens so nothing in the app hardcodes a raw SwiftUI color.
/// tool run indigo, tied to the app accent family (#5E9EFF-ish).
static let timelineTool = rgb(94, 158, 255)
/// user message violet (#AF7BFF), distinct from accent & tool.
static let timelineUser = rgb(175, 123, 255)
/// tool run dark #5E9EFF · light #2C6ED6 (4.8:1 on white; the bright
/// blue is 2.63:1 there).
static let timelineTool = adaptive(dark: 0x5E9E_FF, light: 0x2C6E_D6)
/// user message dark #AF7BFF · light #7A3BD6 (6.1:1; bright violet is
/// 2.81:1). Distinct from accent & tool in both schemes.
static let timelineUser = adaptive(dark: 0xAF7B_FF, light: 0x7A3B_D6)
// Surfaces & text
@@ -106,18 +133,48 @@ enum DS {
/// Tertiary label color (de-emphasized detail).
static let textTertiary = Color(uiColor: .tertiaryLabel)
// Terminal canvas (fixed warm-dark, matches the desktop terminal)
// A terminal reads as dark regardless of app appearance (like the
// desktop). Values mirror the web chrome: --bg #100F0D / --text #ECE9E3.
/// Terminal background warm near-black #100F0D (web --bg).
static let terminalBackground = rgb(16, 15, 13)
/// Terminal foreground warm off-white #ECE9E3 (web --text).
static let terminalForeground = rgb(236, 233, 227)
// Terminal canvas (theme-following as of T-iOS-34)
// Was a FIXED warm-dark canvas ("a terminal reads as dark regardless of
// app appearance"). With a real light theme that stops being true a
// full-screen near-black slab is the loudest thing on a light UI. The
// two schemes now live in `TerminalPalette` (which also vends the
// already-resolved set SwiftTerm needs); these stay as the SwiftUI-side
// tokens so existing call sites keep compiling and become adaptive.
/// Terminal background dark #100F0D (web --bg) · light #F6F7F9.
static let terminalBackground = Color(uiColor: terminalBackgroundUIColor())
/// Terminal foreground dark #ECE9E3 (web --text) · light #1A1D24.
static let terminalForeground = Color(uiColor: terminalForegroundUIColor())
/// Dynamic terminal background. Exposed as `UIColor` because SwiftTerm's
/// `nativeBackgroundColor` takes UIKit colors AND flattens them on set
/// (see `TerminalPalette`) the bridge must not go through SwiftUI.
static func terminalBackgroundUIColor() -> UIColor {
TerminalPalette.dynamicBackground()
}
/// Dynamic terminal foreground (same rationale as the background).
static func terminalForegroundUIColor() -> UIColor {
TerminalPalette.dynamicForeground()
}
/// Build an opaque sRGB color from 0255 components.
private static func rgb(_ r: Double, _ g: Double, _ b: Double) -> Color {
Color(.sRGB, red: r / 255, green: g / 255, blue: b / 255, opacity: 1)
}
/// One scheme-adaptive color from two `0xRRGGBB` values. THE way to add
/// an adaptive token hand-rolling a `UIColor { trait in }` per token
/// is how a scheme branch gets forgotten.
private static func adaptive(dark: UInt32, light: UInt32) -> Color {
adaptive(dark: UIColor(hex: dark), light: UIColor(hex: light))
}
/// Same, for values that need a non-opaque alpha (washes).
private static func adaptive(dark: UIColor, light: UIColor) -> Color {
Color(uiColor: UIColor { trait in
trait.userInterfaceStyle == .dark ? dark : light
})
}
}
// MARK: - Space

View File

@@ -37,6 +37,23 @@ extension DS {
/// The canonical meta-number font: caption-sized mono + tabular.
static let metaMono = mono(.caption)
// Dynamic Type clamp for dense numerics (T-iOS-34)
/// Upper bound applied to **numeric / meta** text only (telemetry chips,
/// `dsMetaText` rows: `ctx 92% · $0.1234 · 161×50`).
///
/// Prose scales all the way to `.accessibility5` that is the point of
/// Dynamic Type and nothing here caps it. Tabular numerics are different:
/// they live in one-line rows next to a status badge, and at AX4/AX5 a
/// single chip row becomes three wrapped lines that push the row's real
/// content off screen. Capping them at `.accessibility2` (already ~2×
/// the default size) keeps the meta line legible AND keeps the row's
/// primary text the session name visible.
///
/// Pinned in `DynamicTypeLayoutTests` both as a policy value and
/// behaviorally (AX2 and AX5 must measure the same height).
static let numericClamp: DynamicTypeSize = .accessibility2
}
}
@@ -44,11 +61,15 @@ extension DS {
/// Secondary + monospaced-tabular styling for a row's numeric meta line
/// ("N · 161×50"). Apply via `.dsMetaText()`.
///
/// T-iOS-34 · carries the `numericClamp` so every meta line in the app gets the
/// same Dynamic Type ceiling from ONE place (per-screen clamps would drift).
struct MetaText: ViewModifier {
func body(content: Content) -> some View {
content
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.textSecondary)
.dynamicTypeSize(...DS.Typography.numericClamp)
}
}