Files
web-terminal/ios/App/WebTerm/Screens/PairingCopy.swift
Yaojia Wang 284cfd193a 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.
2026-07-30 15:58:01 +02:00

85 lines
4.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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或反馈该网段。"
}
}