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

@@ -4,12 +4,14 @@ import Observation
import OSLog
import WireProtocol
/// T-iOS-22 · Deep-link routing.
/// T-iOS-22 / T-iOS-35 · Deep-link routing.
///
/// Two external entry points share ONE validation surface:
/// Three external entry points share ONE validation surface:
/// - `webterminal://open?host=<uuid>&join=<uuid>` (scheme registered in
/// project.yml `CFBundleURLTypes`; web QR `?join=`
/// T-iOS-35 ), and
/// project.yml `CFBundleURLTypes`),
/// - **the web share link `http(s)://<host>[:<port>]/?join=<uuid>`**
/// (T-iOS-35 the exact shape `public/share.ts:55` puts in the 🔗 QR, i.e.
/// `${location.origin}/?join=${sessionId}`), and
/// - the WEBTERM_GATE push payload `{sessionId}` (T-iOS-21 reuses
/// `route(from:)` same UUID rules, no second parser).
///
@@ -25,6 +27,11 @@ enum DeepLinkRouter {
enum Route: Equatable, Sendable {
/// `webterminal://open` with both ids syntactically valid.
case openSession(hostId: UUID, sessionId: UUID)
/// Web share link (`<origin>/?join=<uuid>`). Carries the NORMALISED
/// origin (`HostEndpoint.originHeader`) instead of a host id the web
/// UI has no idea what UUID this device filed that host under, so
/// resolution is "which paired host serves this origin" (`DeepLinkHandler`).
case joinShared(origin: String, sessionId: UUID)
/// Push payload with a valid `sessionId` (no host id in the payload
/// T-iOS-21's notification handler owns the resolution strategy).
case gateSession(sessionId: UUID)
@@ -40,6 +47,19 @@ enum DeepLinkRouter {
static let emptyPaths: Set<String> = ["", "/"]
}
/// Whitelisted web-share shape (`http(s)://<host>[:<port>]/?join=<uuid>`).
///
/// Deliberately STRICTER than the custom-scheme branch: `webterminal://` can
/// only come from something that knows our private scheme, while an http(s)
/// URL is whatever a QR code or another app decided to hand us. So the query
/// must be EXACTLY one `join` key (no "unknown keys are ignored" leniency
/// here), the path must be empty/`/`, and userinfo/fragment are rejected
/// outright (`http://user:pass@real-host@evil/` is a classic QR phish).
private enum WebShape {
static let schemes: Set<String> = ["http", "https"]
static let queryItemCount = 1
}
/// Whitelisted query keys (exact match; unknown keys are ignored).
private enum QueryKey {
static let host = "host"
@@ -55,8 +75,21 @@ enum DeepLinkRouter {
static func route(url: URL) -> Route {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.scheme?.lowercased() == LinkShape.scheme,
components.host?.lowercased() == LinkShape.action,
let scheme = components.scheme?.lowercased(),
// Credentials/fragment are never part of either whitelisted shape.
components.user == nil, components.password == nil,
components.fragment == nil
else { return .ignore }
if scheme == LinkShape.scheme { return customSchemeRoute(components) }
if WebShape.schemes.contains(scheme) { return webShareRoute(url: url, components: components) }
return .ignore
}
/// `webterminal://open?host=<uuid>&join=<uuid>` unchanged semantics
/// (unknown extra query keys stay tolerated; both ids required).
private static func customSchemeRoute(_ components: URLComponents) -> Route {
guard components.host?.lowercased() == LinkShape.action,
LinkShape.emptyPaths.contains(components.path),
let hostId = uniqueValidatedId(in: components, key: QueryKey.host),
let sessionId = uniqueValidatedId(in: components, key: QueryKey.join)
@@ -64,6 +97,23 @@ enum DeepLinkRouter {
return .openSession(hostId: hostId, sessionId: sessionId)
}
/// `http(s)://<host>[:<port>]/?join=<uuid>` the web 🔗 share QR.
///
/// The origin is derived by `HostEndpoint` (the FROZEN single derivation
/// point, plan §3.1) rather than assembled here: it also does the http(s) +
/// non-empty-host validation and the default-port/lowercasing normalisation
/// the store's own origins were built with, so the comparison in
/// `DeepLinkHandler` is apples to apples.
private static func webShareRoute(url: URL, components: URLComponents) -> Route {
guard LinkShape.emptyPaths.contains(components.path),
let items = components.queryItems, items.count == WebShape.queryItemCount,
let item = items.first, item.name == QueryKey.join,
let raw = item.value, let sessionId = validatedId(raw),
let endpoint = HostEndpoint(baseURL: url)
else { return .ignore }
return .joinShared(origin: endpoint.originHeader, sessionId: sessionId)
}
// MARK: - Push entry (WEBTERM_GATE payload, reused by T-iOS-21)
static func route(from payload: [AnyHashable: Any]) -> Route {
@@ -175,12 +225,33 @@ final class DeepLinkHandler {
// MARK: - Apply (host id resolves ONLY through the store)
private func apply(_ route: DeepLinkRouter.Route) async {
// `.gateSession` never reaches here in P1: it only exists for the
// push path, whose handling (host resolution incl.) is T-iOS-21.
guard case let .openSession(hostId, sessionId) = route else { return }
switch route {
case let .openSession(hostId, sessionId):
// Custom scheme: the link carries OUR host id.
await openSession(sessionId, onHostMatching: { $0.id == hostId })
case let .joinShared(origin, sessionId):
// T-iOS-35 · web share QR: match on the normalised origin. An
// unpaired origin must NEVER be auto-added a QR code is untrusted
// input, so it falls through to the pairing flow (where the user
// sees and confirms the target) exactly like an unknown host id.
await openSession(sessionId, onHostMatching: { $0.endpoint.originHeader == origin })
case .gateSession, .ignore:
// `.gateSession` never reaches here in P1: it only exists for the
// push path, whose handling (host resolution incl.) is T-iOS-21.
return
}
}
/// The ONE host-resolution path (both link shapes funnel through it): store
/// lookup open, or unknown pairing hint. The hint copy is deliberately
/// generic it never echoes the link's origin or session id (a link is
/// untrusted text; quoting it back is a phishing/log-injection surface).
private func openSession(
_ sessionId: UUID, onHostMatching matches: (HostRegistry.Host) -> Bool
) async {
do {
let hosts = try await loadHosts()
guard let host = hosts.first(where: { $0.id == hostId }) else {
guard let host = hosts.first(where: matches) else {
hintMessage = DeepLinkCopy.unknownHostHint
actions.showPairing()
return