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

@@ -0,0 +1,302 @@
import Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-35 · web QR`?join=`doc §4 AC 3050
///
/// web URL `${location.origin}/?join=${sessionId}`
/// `public/share.ts:55` URL ****
/// 西 http(s) scheme ****
/// scheme/host/path/query query `join` fragment
/// userinfo`sessionId` `Validation.isValidSessionId`
///
/// **** `HostStore` origin `HostEndpoint.originHeader`
/// origin ****
@MainActor
@Suite("DeepLinkJoin")
struct DeepLinkJoinTests {
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
private static let v1StyleId = "11111111-2222-1333-8444-555555555555"
private static func url(_ string: String) throws -> URL {
try #require(URL(string: string))
}
// MARK: - A. web
@Test("http://<ip>:3000/?join=<v4> → .joinShared(origin, sessionId)")
func lanShareLinkRoutes() throws {
let route = DeepLinkRouter.route(
url: try Self.url("http://192.168.1.5:3000/?join=\(Self.validSessionId)")
)
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .joinShared(origin: "http://192.168.1.5:3000", sessionId: sessionId))
}
@Test("https 默认端口不出现在 origin 里")
func httpsDefaultPortOmitted() throws {
let route = DeepLinkRouter.route(
url: try Self.url("https://mac.tailnet.ts.net/?join=\(Self.validSessionId)")
)
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .joinShared(origin: "https://mac.tailnet.ts.net", sessionId: sessionId))
}
@Test("大写 scheme/host → origin 归一化为小写")
func originIsNormalizedLowercase() throws {
let route = DeepLinkRouter.route(
url: try Self.url("HTTP://MAC.LOCAL:3000/?join=\(Self.validSessionId)")
)
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .joinShared(origin: "http://mac.local:3000", sessionId: sessionId))
}
@Test("path 为空或 \"/\" 都接受origin + \"/?join=\" 产出 /")
func emptyAndRootPathsAccepted() throws {
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
let expected = DeepLinkRouter.Route.joinShared(
origin: "http://mac.local:3000", sessionId: sessionId
)
#expect(
DeepLinkRouter.route(
url: try Self.url("http://mac.local:3000/?join=\(Self.validSessionId)")
) == expected
)
#expect(
DeepLinkRouter.route(
url: try Self.url("http://mac.local:3000?join=\(Self.validSessionId)")
) == expected
)
}
// MARK: - B.
@Test("join 值非 v4 → .ignore复用 Validation不再造正则")
func nonV4JoinIgnored() throws {
#expect(
DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=\(Self.v1StyleId)"))
== .ignore
)
#expect(
DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=not-a-uuid")) == .ignore
)
#expect(DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=")) == .ignore)
}
@Test("重复 join 键 → .ignore歧义输入绝不部分应用")
func duplicateJoinIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url(
"http://mac.local/?join=\(Self.validSessionId)&join=\(Self.validSessionId)"
)
)
#expect(route == .ignore)
}
@Test("多余 query 键 → .ignoreweb 形状精确只有一个 join")
func extraQueryKeysIgnored() throws {
#expect(
DeepLinkRouter.route(
url: try Self.url("http://mac.local/?join=\(Self.validSessionId)&x=1")
) == .ignore
)
#expect(
DeepLinkRouter.route(
url: try Self.url("http://mac.local/?utm=a&join=\(Self.validSessionId)")
) == .ignore
)
}
@Test("非空 path → .ignore")
func nonEmptyPathIgnored() throws {
#expect(
DeepLinkRouter.route(
url: try Self.url("http://mac.local/manage.html?join=\(Self.validSessionId)")
) == .ignore
)
}
@Test("带 fragment → .ignore")
func fragmentIgnored() throws {
#expect(
DeepLinkRouter.route(
url: try Self.url("http://mac.local/?join=\(Self.validSessionId)#x")
) == .ignore
)
}
@Test("带 userinfo二维码钓鱼形状→ .ignore")
func userInfoIgnored() throws {
#expect(
DeepLinkRouter.route(
url: try Self.url("http://user:pass@mac.local/?join=\(Self.validSessionId)")
) == .ignore
)
}
@Test("无 host → .ignore")
func missingHostIgnored() throws {
#expect(
DeepLinkRouter.route(url: try Self.url("http:///?join=\(Self.validSessionId)"))
== .ignore
)
}
@Test("自定义 scheme 分支零回归webterminal 仍需 host+join 双 v4")
func customSchemeBranchUnchanged() throws {
#expect(
DeepLinkRouter.route(url: try Self.url("webterminal://open?join=\(Self.validSessionId)"))
== .ignore
)
let hostId = UUID()
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(
DeepLinkRouter.route(
url: try Self.url(
"webterminal://open?host=\(hostId.uuidString)&join=\(Self.validSessionId)"
)
) == .openSession(hostId: hostId, sessionId: sessionId)
)
}
}
/// C `DeepLinkHandler` origin
@MainActor
@Suite("DeepLinkJoinHandler")
struct DeepLinkJoinHandlerTests {
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
@MainActor
private final class ActionRecorder {
private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = []
private(set) var pairingShownCount = 0
var actions: DeepLinkHandler.Actions {
DeepLinkHandler.Actions(
openSession: { [weak self] host, sessionId in
self?.opened.append((host, sessionId))
},
showPairing: { [weak self] in self?.pairingShownCount += 1 }
)
}
}
private static func makeHost(_ base: String) throws -> HostRegistry.Host {
let url = try #require(URL(string: base))
let endpoint = try #require(HostEndpoint(baseURL: url))
return HostRegistry.Host(id: UUID(), name: "mac", endpoint: endpoint)
}
private static func shareURL(_ origin: String) throws -> URL {
try #require(URL(string: "\(origin)/?join=\(validSessionId)"))
}
@Test("origin 命中已配对主机 → 恰一次 openSession无 hint")
func knownOriginOpensSession() async throws {
let host = try Self.makeHost("http://192.168.1.5:3000")
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.shareURL("http://192.168.1.5:3000"))
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(recorder.opened.count == 1)
#expect(recorder.opened.first?.host == host)
#expect(recorder.opened.first?.sessionId == sessionId)
#expect(handler.hintMessage == nil)
}
@Test("origin 未配对 → 零 open + 落配对流程(绝不据链接静默新增主机)")
func unknownOriginNeverPairsSilently() async throws {
let paired = try Self.makeHost("http://192.168.1.5:3000")
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [paired] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.shareURL("http://10.0.0.9:3000"))
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 1)
#expect(handler.hintMessage == DeepLinkCopy.unknownHostHint)
}
@Test("提示文案不回显链接内容(防钓鱼/防日志注入)")
func hintNeverEchoesLinkContents() async throws {
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.shareURL("http://evil.example:3000"))
let hint = try #require(handler.hintMessage)
#expect(!hint.contains("evil.example"))
#expect(!hint.contains(Self.validSessionId))
}
@Test("origin 比对经 originHeader大小写/尾斜杠同一主机,端口不同则不是")
func originComparisonUsesOriginHeader() async throws {
let host = try Self.makeHost("http://mac.local:3000")
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.shareURL("http://MAC.LOCAL:3000"))
#expect(recorder.opened.count == 1)
await handler.handle(url: try Self.shareURL("http://mac.local:3001"))
#expect(recorder.opened.count == 1) //
#expect(recorder.pairingShownCount == 1)
}
@Test("scheme 不同 → 不是同一主机")
func schemeMismatchIsDifferentHost() async throws {
let host = try Self.makeHost("http://mac.local")
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.shareURL("https://mac.local"))
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 1)
}
@Test("冷启动 stash 对 .joinShared 同样生效(恰应用一次)")
func coldLaunchStashAppliesJoin() async throws {
let host = try Self.makeHost("http://mac.local:3000")
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.handle(url: try Self.shareURL("http://mac.local:3000"))
#expect(recorder.opened.isEmpty)
await handler.markReady()
#expect(recorder.opened.count == 1)
await handler.markReady()
#expect(recorder.opened.count == 1)
}
@Test("非法 web 链接 → ignoredCount+1 且不入 stash")
func invalidWebLinkCountedNeverStashed() async throws {
let host = try Self.makeHost("http://mac.local:3000")
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.handle(
url: try #require(URL(string: "http://mac.local:3000/?join=nope&x=1"))
)
#expect(handler.ignoredCount == 1)
await handler.markReady()
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 0)
}
}