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,84 @@
import HostRegistry
/// User-facing pairing copy (plan §3.4 taxonomy actionable wording; §5.2
/// Local-Network guidance including the iOS 18 restart caveat).
///
/// Extracted from `PairingViewModel.swift` by C1: the VM grew the access-token
/// and host-management flows, and copy is presentation, not state machine
/// (plan §4 file-size discipline many small focused files).
///
/// SECURITY (§5.3): no function here ever takes or echoes a token value. The
/// shape-rejection copy is derived from `AccessTokenError`, which deliberately
/// carries a length at most never the rejected secret.
enum PairingCopy {
static let scanRejected =
"二维码不是 http(s) 地址,无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。"
static let manualRejected =
"无法解析这个地址。请输入完整 URL例如 http://192.168.1.5:3000"
static let storeFailed =
"主机已通过验证,但保存到本机失败,请重试。"
static let localNetworkDenied =
"无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关"
+ "iOS 18 存在需要重启手机才生效的已知问题)。"
static let notWebTerminal =
"对方在响应 HTTP但不是 web-terminal——端口对吗"
static let tlsFailure =
"TLS 连接失败:证书无效或不受信任。"
static let timeout =
"连接超时。请确认主机在线、与手机在同一网络后重试。"
/// C-iOS-3 · Tunnel host reached without a device certificate installed.
static let deviceCertRequired =
"请先安装本设备证书:到 设置 →「设备证书」导入 .p12 后,再连接该隧道主机。"
/// C-iOS-3 · nginx rejected the presented client certificate (invalid /
/// revoked). Surfaced in place of the mis-classified "server cert invalid".
static let clientCertRejected =
"本设备证书无效或已吊销,请重新导入。"
// MARK: - Access token (C1 · ios-completion §1.1)
/// 401 from `POST /auth` the token itself is wrong. Never echoes it.
static let tokenInvalid =
"访问令牌不正确。请到主机上核对 WEBTERM_TOKEN 的值后重新输入。"
/// 429 the server allows 10 `/auth` attempts per minute per IP.
static let tokenRateLimited =
"尝试次数过多(主机限制每分钟 10 次),请稍后再试。"
/// 204 WITHOUT `Set-Cookie`: this host never enabled auth, so there is
/// nothing to authenticate against and nothing gets saved. The pairing
/// failure therefore has another cause almost always `ALLOWED_ORIGINS`.
static let tokenNotRequired =
"该主机没有启用访问令牌(服务器未设置 WEBTERM_TOKEN令牌不会被保存。"
+ "配对失败的原因更可能是主机的 ALLOWED_ORIGINS 不含本 App 拨号的地址。"
/// Defensive: a shape violation that slipped past the field validation.
static let tokenShapeUnknown =
"访问令牌格式不符合要求16512 位,且只能包含 A-Z a-z 0-9 . _ ~ + / = -)。"
static let hostsLoadFailed =
"无法读取已配对的主机列表(钥匙串读取失败)。"
static let hostRemoveFailed =
"移除主机失败(钥匙串写入失败),请重试。"
/// Turn the typed `AccessTokenError` into field-level copy. The length cases
/// report the length only that is not secret, and it is exactly what tells
/// the user whether they pasted a truncated value.
static func tokenShapeRejected(_ error: AccessTokenError) -> String {
switch error {
case .tooShort(let length):
return "访问令牌太短(\(length) 位,至少 \(AccessToken.minLength) 位)。"
case .tooLong(let length):
return "访问令牌太长(\(length) 位,最多 \(AccessToken.maxLength) 位)。"
case .invalidCharacters:
return "访问令牌含有不允许的字符(只能是 A-Z a-z 0-9 . _ ~ + / = -)。"
case .unknownHost:
return hostsLoadFailed
}
}
static func hostUnreachable(_ underlying: String) -> String {
"无法连接主机:\(underlying)"
}
/// §3.4 wording for the ATS cleartext block.
static func atsBlocked(host: String) -> String {
"明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内,"
+ "请改用 https / tailscale serve或反馈该网段。"
}
}