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:
@@ -42,9 +42,59 @@ import WireProtocol
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PairingViewModel {
|
||||
/// The probe, injected as a closure so tests can both fake results AND
|
||||
/// assert non-invocation before the user confirms (task RED list).
|
||||
typealias Probe = @Sendable (HostEndpoint) async -> Result<HostEndpoint, PairingError>
|
||||
/// Everything the pairing flow needs from the network/platform, injected as
|
||||
/// ONE value built by the composition root (`AppEnvironment.probe`).
|
||||
///
|
||||
/// Why a struct and not three parameters: `AppEnvironment.probe` is handed to
|
||||
/// this VM by `AppCoordinator`, so growing the capability set inside the
|
||||
/// value keeps the composition root the single place they are built — adding
|
||||
/// separately-defaulted parameters instead would silently fall back to
|
||||
/// defaults in production, i.e. a dead hook.
|
||||
///
|
||||
/// Tests fake results AND assert non-invocation before the user confirms
|
||||
/// (task RED list); `validateToken`/`unregisterPush` default to the
|
||||
/// zero-config behaviour so existing call sites stay one-liners.
|
||||
struct Probe: Sendable {
|
||||
/// Two-step host verification (`runPairingProbe`), carrying an optional
|
||||
/// candidate access token for a host that gates its surface (§1.1).
|
||||
let verifyHost: @Sendable (HostEndpoint, AccessToken?) async
|
||||
-> Result<HostEndpoint, PairingError>
|
||||
/// One-shot `POST /auth` validation of a candidate token — §1.1's four
|
||||
/// outcomes, or a transport-level failure.
|
||||
let validateToken: @Sendable (HostEndpoint, AccessToken) async
|
||||
-> Result<AccessTokenProbeResult, TokenProbeFailure>
|
||||
/// Side effect of removing a host: de-register THIS device's APNs token
|
||||
/// on that host (`PushRegistrar.handleHostRemoved`). Failure is the
|
||||
/// registrar's to log — removal must never be blocked by it.
|
||||
let unregisterPush: @Sendable (HostRegistry.Host) async -> Void
|
||||
|
||||
init(
|
||||
verifyHost: @escaping @Sendable (HostEndpoint, AccessToken?) async
|
||||
-> Result<HostEndpoint, PairingError>,
|
||||
/// Unscripted default = "this host has auth disabled", which is the
|
||||
/// zero-config LAN reality and never fabricates an authenticated state.
|
||||
validateToken: @escaping @Sendable (HostEndpoint, AccessToken) async
|
||||
-> Result<AccessTokenProbeResult, TokenProbeFailure> = { _, _ in
|
||||
.success(.authDisabled)
|
||||
},
|
||||
unregisterPush: @escaping @Sendable (HostRegistry.Host) async -> Void = { _ in }
|
||||
) {
|
||||
self.verifyHost = verifyHost
|
||||
self.validateToken = validateToken
|
||||
self.unregisterPush = unregisterPush
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a `POST /auth` probe never produced one of the four §1.1 outcomes.
|
||||
/// Deliberately payload-free about the token itself (§5.3).
|
||||
enum TokenProbeFailure: Error, Equatable, Sendable {
|
||||
/// Transport-level failure / unexpected status; carries the network
|
||||
/// description only (never the token).
|
||||
case unreachable(String)
|
||||
/// The candidate violated the frozen shape before any I/O — defensive:
|
||||
/// the VM validates first, so this should be unreachable in practice.
|
||||
case malformed
|
||||
}
|
||||
|
||||
// MARK: - UI state model
|
||||
|
||||
@@ -81,6 +131,11 @@ final class PairingViewModel {
|
||||
/// iOS Local Network permission was denied → deep-link to the app's
|
||||
/// Settings pane (its 本地网络 toggle lives there).
|
||||
case openLocalNetworkSettings
|
||||
/// C1 · The host answered **401**, which is ambiguous by design (Origin
|
||||
/// or access token — same status). Offer the one remedy that can be
|
||||
/// applied from the phone: type the access token. Retry stays available
|
||||
/// for the Origin case.
|
||||
case enterAccessToken
|
||||
}
|
||||
|
||||
struct FailureDisplay: Equatable, Sendable {
|
||||
@@ -93,6 +148,10 @@ final class PairingViewModel {
|
||||
case confirming(PendingHost)
|
||||
case probing(PendingHost)
|
||||
case failed(PendingHost, FailureDisplay)
|
||||
/// C1 · Access-token prompt for a host that answered 401.
|
||||
case awaitingToken(PendingHost)
|
||||
/// C1 · `POST /auth` in flight for the typed candidate.
|
||||
case validatingToken(PendingHost)
|
||||
case paired(HostRegistry.Host)
|
||||
}
|
||||
|
||||
@@ -112,11 +171,24 @@ final class PairingViewModel {
|
||||
/// Navigate signal: set exactly once when pairing completes (T-iOS-15
|
||||
/// observes it to move on to the session list).
|
||||
private(set) var pairedHost: HostRegistry.Host?
|
||||
/// Inline copy under the token field (wrong token / rate-limited / this host
|
||||
/// has no auth at all / shape violation). NEVER echoes the token itself.
|
||||
private(set) var tokenRejection: String?
|
||||
/// C1 · Already-paired hosts, for the manage section (token update + remove).
|
||||
/// Loaded explicitly by the screen — pairing itself never needs it.
|
||||
private(set) var pairedHosts: [HostRegistry.Host] = []
|
||||
/// Explicit store-failure copy for the manage section (never a silent
|
||||
/// empty list hiding a broken keychain).
|
||||
private(set) var hostsErrorMessage: String?
|
||||
|
||||
// MARK: - Dependencies (not observed)
|
||||
|
||||
@ObservationIgnored private let store: any HostStore
|
||||
@ObservationIgnored private let probe: Probe
|
||||
/// The token validated by `POST /auth` for the host being paired, held in
|
||||
/// memory ONLY until the host record is written (Keychain via `HostStore`).
|
||||
/// nil = no token (LAN default, or the host reported auth disabled).
|
||||
@ObservationIgnored private var candidateToken: AccessToken?
|
||||
/// C-iOS-3 · Whether a device client certificate is installed. Used to gate
|
||||
/// the probe for tunnel hosts (mTLS-only). Injected so tests control it;
|
||||
/// production reads the keychain. Defaulted so existing call sites compile.
|
||||
@@ -124,7 +196,7 @@ final class PairingViewModel {
|
||||
|
||||
init(
|
||||
store: any HostStore,
|
||||
probe: @escaping Probe,
|
||||
probe: Probe, // C1 · a value now (three capabilities), no longer a closure
|
||||
isDeviceCertInstalled: @escaping @Sendable () -> Bool = {
|
||||
KeychainClientIdentityStore().hasInstalledIdentity()
|
||||
}
|
||||
@@ -170,11 +242,15 @@ final class PairingViewModel {
|
||||
}
|
||||
|
||||
/// Back out of confirm/failed to a clean entry state. Never probes.
|
||||
/// Drops the in-memory token candidate too — an abandoned pairing must not
|
||||
/// leave a secret behind for the next target.
|
||||
func cancel() {
|
||||
phase = .idle
|
||||
inputRejection = nil
|
||||
hasAcknowledgedPublicRisk = false
|
||||
needsPublicRiskAcknowledgement = false
|
||||
tokenRejection = nil
|
||||
candidateToken = nil
|
||||
}
|
||||
|
||||
// MARK: - Confirm → probe → store
|
||||
@@ -200,7 +276,9 @@ final class PairingViewModel {
|
||||
switch phase {
|
||||
case .idle, .confirming, .failed:
|
||||
return true
|
||||
case .probing, .paired:
|
||||
case .probing, .awaitingToken, .validatingToken, .paired:
|
||||
// Mid-credential-entry counts as busy: a scan landing while the
|
||||
// token prompt is up must not silently retarget the token.
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -209,6 +287,8 @@ final class PairingViewModel {
|
||||
inputRejection = nil
|
||||
hasAcknowledgedPublicRisk = false
|
||||
needsPublicRiskAcknowledgement = false
|
||||
tokenRejection = nil
|
||||
candidateToken = nil // a new target never inherits the previous token
|
||||
hostName = endpoint.baseURL.host ?? ""
|
||||
phase = .confirming(PendingHost(
|
||||
endpoint: endpoint, warning: Self.warning(for: endpoint)
|
||||
@@ -228,7 +308,10 @@ final class PairingViewModel {
|
||||
return
|
||||
}
|
||||
phase = .probing(pending)
|
||||
switch await probe(pending.endpoint) {
|
||||
// The candidate token (if any) travels with BOTH probe legs — the HTTP
|
||||
// one as a `Cookie` header via APIClient, the WS one via the
|
||||
// probe-scoped transport the composition root builds (§1.1).
|
||||
switch await probe.verifyHost(pending.endpoint, candidateToken) {
|
||||
case .failure(let error):
|
||||
phase = .failed(pending, Self.display(for: error, endpoint: pending.endpoint))
|
||||
case .success(let endpoint):
|
||||
@@ -239,13 +322,23 @@ final class PairingViewModel {
|
||||
/// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED
|
||||
/// endpoint. A store failure is surfaced explicitly (never swallowed);
|
||||
/// retry re-runs the whole confirm flow.
|
||||
///
|
||||
/// C1 · Re-pairing the SAME origin updates that host in place (its `id` is
|
||||
/// reused) instead of appending a duplicate. That is what makes "this host
|
||||
/// just got a WEBTERM_TOKEN" recoverable: pair it again, type the token, and
|
||||
/// the existing record — the one every `lastSessionId`/watermark is keyed by
|
||||
/// — gains the token.
|
||||
private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
|
||||
let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
|
||||
let existing = await existingHost(matching: endpoint)
|
||||
let host = HostRegistry.Host(
|
||||
id: UUID(),
|
||||
name: trimmedName.isEmpty ? fallbackName : trimmedName,
|
||||
endpoint: endpoint
|
||||
id: existing?.id ?? UUID(),
|
||||
name: resolvedName(for: endpoint, existing: existing),
|
||||
endpoint: endpoint,
|
||||
// A validated candidate wins; otherwise keep whatever the record
|
||||
// already had (re-pairing to fix a name must not wipe a working
|
||||
// credential). `.authDisabled` clears the candidate first, so a
|
||||
// host that reports no auth can never persist a token here.
|
||||
accessToken: candidateToken ?? existing?.accessToken
|
||||
)
|
||||
do {
|
||||
_ = try await store.upsert(host)
|
||||
@@ -255,10 +348,128 @@ final class PairingViewModel {
|
||||
))
|
||||
return
|
||||
}
|
||||
candidateToken = nil // persisted — drop the in-memory copy
|
||||
pairedHost = host
|
||||
phase = .paired(host)
|
||||
}
|
||||
|
||||
/// The already-paired host at the same origin, or nil. A store read failure
|
||||
/// degrades to nil (pair as new) rather than blocking the pairing.
|
||||
private func existingHost(matching endpoint: HostEndpoint) async -> HostRegistry.Host? {
|
||||
let hosts = try? await store.loadAll()
|
||||
return hosts?.first { $0.endpoint.originHeader == endpoint.originHeader }
|
||||
}
|
||||
|
||||
/// User-typed name wins; an untouched field keeps the existing host's name
|
||||
/// (re-pairing must not rename "MacBook" to "192.168.1.5").
|
||||
private func resolvedName(
|
||||
for endpoint: HostEndpoint, existing: HostRegistry.Host?
|
||||
) -> String {
|
||||
let trimmed = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let derived = endpoint.baseURL.host ?? endpoint.originHeader
|
||||
if trimmed.isEmpty || trimmed == derived {
|
||||
return existing?.name ?? derived
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// MARK: - Access token (§1.1: POST /auth is a one-shot pairing probe)
|
||||
|
||||
/// Failure page → token prompt. Only from a 401-shaped failure, which is the
|
||||
/// only failure whose remedy might be a token.
|
||||
func beginAccessTokenEntry() {
|
||||
guard case .failed(let pending, let failure) = phase,
|
||||
failure.action == .enterAccessToken else { return }
|
||||
tokenRejection = nil
|
||||
phase = .awaitingToken(pending)
|
||||
}
|
||||
|
||||
/// Token prompt → back to the confirm page (the URL is still fine; the user
|
||||
/// may prefer to fix `ALLOWED_ORIGINS` instead).
|
||||
func cancelAccessTokenEntry() {
|
||||
guard case .awaitingToken(let pending) = phase else { return }
|
||||
tokenRejection = nil
|
||||
candidateToken = nil
|
||||
phase = .confirming(pending)
|
||||
}
|
||||
|
||||
/// Validate a typed token against the host, then re-run the host probe with
|
||||
/// it. Shape is checked BEFORE any I/O (§1.1 charset/length is the server's
|
||||
/// own rule, so a shape violation can never be the right token).
|
||||
///
|
||||
/// The four §1.1 outcomes are all handled explicitly, and `authDisabled`
|
||||
/// deliberately does NOT persist anything: a 204 without `Set-Cookie` means
|
||||
/// the host never enabled auth, so claiming "authenticated" would be a lie
|
||||
/// and storing the token would gate nothing.
|
||||
func submitAccessToken(_ raw: String) async {
|
||||
guard case .awaitingToken(let pending) = phase else { return }
|
||||
let token: AccessToken
|
||||
do {
|
||||
token = try AccessToken(validating: raw)
|
||||
} catch let error as AccessTokenError {
|
||||
tokenRejection = PairingCopy.tokenShapeRejected(error)
|
||||
return
|
||||
} catch {
|
||||
tokenRejection = PairingCopy.tokenShapeUnknown
|
||||
return
|
||||
}
|
||||
tokenRejection = nil
|
||||
phase = .validatingToken(pending)
|
||||
switch await probe.validateToken(pending.endpoint, token) {
|
||||
case .success(.valid):
|
||||
candidateToken = token
|
||||
await runProbe(for: pending) // re-verify, now authenticated
|
||||
case .success(.authDisabled):
|
||||
candidateToken = nil
|
||||
tokenRejection = PairingCopy.tokenNotRequired
|
||||
phase = .awaitingToken(pending)
|
||||
case .success(.invalidToken):
|
||||
tokenRejection = PairingCopy.tokenInvalid
|
||||
phase = .awaitingToken(pending)
|
||||
case .success(.rateLimited):
|
||||
tokenRejection = PairingCopy.tokenRateLimited
|
||||
phase = .awaitingToken(pending)
|
||||
case .failure(.unreachable(let underlying)):
|
||||
tokenRejection = PairingCopy.hostUnreachable(underlying)
|
||||
phase = .awaitingToken(pending)
|
||||
case .failure(.malformed):
|
||||
tokenRejection = PairingCopy.tokenShapeUnknown
|
||||
phase = .awaitingToken(pending)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paired-host management (C1 · the removal path `handleHostRemoved` lacked)
|
||||
|
||||
/// Load the manage section's host list. Explicit error copy on failure.
|
||||
func loadPairedHosts() async {
|
||||
do {
|
||||
pairedHosts = try await store.loadAll()
|
||||
hostsErrorMessage = nil
|
||||
} catch {
|
||||
hostsErrorMessage = PairingCopy.hostsLoadFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a paired host: de-register this device's APNs token on it, then
|
||||
/// delete the record (which takes its access token with it — the token is a
|
||||
/// field of the record, so no orphaned secret can survive).
|
||||
///
|
||||
/// ORDER MATTERS: the de-registration POST goes out FIRST, while the record
|
||||
/// (and therefore the host's access token, which the request needs to get
|
||||
/// past a token gate) still exists. A failing de-registration never blocks
|
||||
/// the removal — the user asked for the host to be gone, and the server also
|
||||
/// prunes tokens itself on an APNs 410.
|
||||
func removeHost(id: UUID) async {
|
||||
guard let host = pairedHosts.first(where: { $0.id == id }) else { return }
|
||||
await probe.unregisterPush(host)
|
||||
do {
|
||||
pairedHosts = try await store.remove(id: id)
|
||||
hostsErrorMessage = nil
|
||||
} catch {
|
||||
hostsErrorMessage = PairingCopy.hostRemoveFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PairingError → copy + action (task RED list, one case each)
|
||||
|
||||
/// C-iOS-3 · Host-aware display. nginx rejects an invalid/absent/revoked
|
||||
@@ -289,7 +500,14 @@ final class PairingViewModel {
|
||||
case .originRejected(let hint):
|
||||
// The probe already derived the complete actionable copy from
|
||||
// endpoint.originHeader — surface it VERBATIM, never re-derive.
|
||||
return FailureDisplay(message: hint, action: .retry)
|
||||
//
|
||||
// C1 · The action is `.enterAccessToken`, not `.retry`: this case now
|
||||
// also carries the AMBIGUOUS 401 (Origin *or* token — the server
|
||||
// answers the same status for both, see `unauthorizedPairingHint`),
|
||||
// and typing a token is the only remedy reachable from the phone.
|
||||
// The failure view keeps a retry button alongside it, so the pure
|
||||
// Origin case (the 403 on the guarded kill) loses nothing.
|
||||
return FailureDisplay(message: hint, action: .enterAccessToken)
|
||||
case .atsBlocked(let host):
|
||||
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
|
||||
case .tlsFailure:
|
||||
@@ -418,40 +636,3 @@ final class PairingViewModel {
|
||||
private static let ipv4OctetCount = 4
|
||||
private static let ipv4OctetRange = 0...255
|
||||
}
|
||||
|
||||
/// User-facing pairing copy (plan §3.4 taxonomy → actionable wording; §5.2
|
||||
/// Local-Network guidance including the iOS 18 restart caveat).
|
||||
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 =
|
||||
"本设备证书无效或已吊销,请重新导入。"
|
||||
|
||||
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,或反馈该网段。"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user