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.
85 lines
4.6 KiB
Swift
85 lines
4.6 KiB
Swift
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 =
|
||
"访问令牌格式不符合要求(16–512 位,且只能包含 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,或反馈该网段。"
|
||
}
|
||
}
|