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

@@ -57,6 +57,19 @@ final class TerminalViewModel {
static let replayTooLargeMessage =
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
/// Actionable copy for `.failed(.unauthorized)` (C1 over B3, ios-completion
/// §1.1). The server answers **401 on the WS upgrade for two different
/// reasons** the Origin/CSWSH check runs first, the `webterm_auth` cookie
/// second (src/server.ts:1367-1379) and the status is identical, so the
/// client provably cannot tell them apart. Hence the copy names BOTH
/// remedies instead of guessing one. Retrying is pointless (the engine
/// already treats this as terminal), so the message asks for a credential
/// change, not patience.
static let unauthorizedMessage =
"主机拒绝了连接401访问令牌缺失或不正确也可能是主机的 ALLOWED_ORIGINS 不含本 App 拨号的地址。"
+ "请在会话列表的主机菜单里「管理主机与令牌」重新输入访问令牌;"
+ "若令牌无误,就把主机的 ALLOWED_ORIGINS 设成 App 连接的完整地址后重启 web-terminal。"
/// Last VALID dims forwarded to the engine (SwiftTerm `sizeChanged`).
/// Read by the wiring layer for `notifyForegrounded(dims:)` the frozen
/// §3.2 signature needs real cols/rows and this is their single source.
@@ -271,6 +284,11 @@ final class TerminalViewModel {
case .failed(.replayTooLarge):
banner = .none
phase = .failed(message: Self.replayTooLargeMessage)
case .failed(.unauthorized):
// Same shape as replayTooLarge: a terminal state, so the spinner
// must go and the terminal becomes read-only with actionable copy.
banner = .none
phase = .failed(message: Self.unauthorizedMessage)
}
}