feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push

T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
This commit is contained in:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

@@ -0,0 +1,338 @@
import Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-22 · Deep-link routing: `webterminal://open?host=<uuid>&join=<uuid>`
/// .ignoreWEBTERM_GATE push
/// payload DeepLinkHandler stash / host
/// /
@MainActor
@Suite("DeepLinkRouter")
struct DeepLinkRouterTests {
// MARK: - Fixtures
/// v4 UUIDversion nibble=4variant=8 Validation.isValidSessionId
private static let validHostId = "11111111-2222-4333-8444-555555555555"
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
/// version nibble=1Foundation UUID(uuidString:)
/// SESSION_ID_REv4 " Validation"
private static let v1StyleId = "11111111-2222-1333-8444-555555555555"
/// variant nibble=c v4 8/9/a/b
private static let badVariantId = "11111111-2222-4333-c444-555555555555"
private static func url(_ string: String) throws -> URL {
try #require(URL(string: string))
}
private static func openURL(
host: String = validHostId, join: String = validSessionId
) throws -> URL {
try url("webterminal://open?host=\(host)&join=\(join)")
}
private static func makeHost(id: UUID) throws -> HostRegistry.Host {
let base = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: base))
return HostRegistry.Host(id: id, name: "mac", endpoint: endpoint)
}
// MARK: - URL
@Test("合法 open 链接 → .openSession(hostId, sessionId)")
func validLinkRoutes() throws {
let route = DeepLinkRouter.route(url: try Self.openURL())
let hostId = try #require(UUID(uuidString: Self.validHostId))
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
}
@Test("大写 UUID → 接受Validation 大小写不敏感)")
func uppercaseUUIDsAccepted() throws {
let route = DeepLinkRouter.route(
url: try Self.openURL(
host: Self.validHostId.uppercased(),
join: Self.validSessionId.uppercased()
)
)
let hostId = try #require(UUID(uuidString: Self.validHostId))
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
}
@Test("未知多余 query 键 → 忽略键本身,链接仍路由")
func unknownExtraKeysIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url(
"webterminal://open?host=\(Self.validHostId)&join=\(Self.validSessionId)&utm=x&foo=bar"
)
)
let hostId = try #require(UUID(uuidString: Self.validHostId))
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
}
// MARK: - URL .ignore
@Test("scheme 非 webterminal → .ignore")
func wrongSchemeIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)")
)
#expect(route == .ignore)
}
@Test("action 非 open → .ignore")
func wrongActionIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("webterminal://kill?host=\(Self.validHostId)&join=\(Self.validSessionId)")
)
#expect(route == .ignore)
}
@Test("带非空 path → .ignore白名单外的形状")
func extraPathIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url(
"webterminal://open/extra?host=\(Self.validHostId)&join=\(Self.validSessionId)"
)
)
#expect(route == .ignore)
}
@Test("缺 host 键 → .ignore")
func missingHostKeyIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("webterminal://open?join=\(Self.validSessionId)")
)
#expect(route == .ignore)
}
@Test("缺 join 键 → .ignore")
func missingJoinKeyIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("webterminal://open?host=\(Self.validHostId)")
)
#expect(route == .ignore)
}
@Test("重复 host 键 → .ignore歧义输入绝不部分应用")
func duplicateHostKeyIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url(
"webterminal://open?host=\(Self.validHostId)&host=\(Self.validHostId)&join=\(Self.validSessionId)"
)
)
#expect(route == .ignore)
}
@Test("UUID 非 v4version nibble=1→ .ignore钉住复用 Validation")
func nonV4UUIDIgnored() throws {
#expect(DeepLinkRouter.route(url: try Self.openURL(host: Self.v1StyleId)) == .ignore)
#expect(DeepLinkRouter.route(url: try Self.openURL(join: Self.v1StyleId)) == .ignore)
}
@Test("variant nibble 非 8/9/a/b → .ignore")
func badVariantUUIDIgnored() throws {
#expect(DeepLinkRouter.route(url: try Self.openURL(join: Self.badVariantId)) == .ignore)
}
@Test("空值 / 垃圾值 / 空 query → .ignore不 crash")
func fuzzedValuesIgnored() throws {
#expect(DeepLinkRouter.route(url: try Self.url("webterminal://open?host=&join=")) == .ignore)
#expect(DeepLinkRouter.route(url: try Self.url("webterminal://open")) == .ignore)
#expect(DeepLinkRouter.route(url: try Self.openURL(host: "not-a-uuid")) == .ignore)
#expect(
DeepLinkRouter.route(url: try Self.openURL(join: "'; DROP TABLE sessions;--")) == .ignore
)
}
// MARK: - Push payloadWEBTERM_GATET-iOS-21
@Test("payload 含合法 sessionId多余键忽略→ .gateSession")
func gatePayloadRoutes() throws {
let payload: [AnyHashable: Any] = [
"sessionId": Self.validSessionId,
"cls": "gate",
"token": "opaque",
"aps": ["category": "WEBTERM_GATE"],
]
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(DeepLinkRouter.route(from: payload) == .gateSession(sessionId: sessionId))
}
@Test("payload 缺 sessionId / 非字符串 / 非 v4 → .ignore")
func gatePayloadRejectsInvalid() {
#expect(DeepLinkRouter.route(from: [:]) == .ignore)
#expect(DeepLinkRouter.route(from: ["sessionId": 42]) == .ignore)
#expect(DeepLinkRouter.route(from: ["sessionId": Self.v1StyleId]) == .ignore)
}
}
/// DeepLinkHandler stash host
@MainActor
@Suite("DeepLinkHandler")
struct DeepLinkHandlerTests {
private enum StubError: Error { case storeFailure }
/// handler AppCoordinator
@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(id: UUID = UUID()) throws -> HostRegistry.Host {
let base = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: base))
return HostRegistry.Host(id: id, name: "mac", endpoint: endpoint)
}
private static func openURL(hostId: UUID, sessionId: UUID) throws -> URL {
try #require(URL(
string: "webterminal://open?host=\(hostId.uuidString)&join=\(sessionId.uuidString)"
))
}
/// UUID() v4 uuidString
private static func sessionId() -> UUID { UUID() }
// MARK: - app
@Test("热路径:已知 host → openSession(host, sessionId),无 hint")
func warmKnownHostOpens() async throws {
let host = try Self.makeHost()
let sessionId = Self.sessionId()
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: sessionId))
#expect(recorder.opened.count == 1)
#expect(recorder.opened.first?.host == host)
#expect(recorder.opened.first?.sessionId == sessionId)
#expect(recorder.pairingShownCount == 0)
#expect(handler.hintMessage == nil)
}
@Test("未知 host idUUID 合法但不在 store→ showPairing + 提示文案")
func unknownHostRoutesToPairing() async throws {
let stored = try Self.makeHost()
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [stored] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
)
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 1)
#expect(handler.hintMessage == DeepLinkCopy.unknownHostHint)
}
@Test("host store 读失败 → 显式错误文案,不 showPairing、不 crash")
func storeFailureSurfacesExplicitCopy() async throws {
let recorder = ActionRecorder()
let handler = DeepLinkHandler(
loadHosts: { throw StubError.storeFailure },
actions: recorder.actions
)
await handler.markReady()
await handler.handle(
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
)
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 0)
#expect(handler.hintMessage == DeepLinkCopy.hostLoadFailed)
}
// MARK: - + stash
@Test("非法链接 → ignoredCount+1无任何动作也不入 stash")
func invalidLinkCountedNeverStashed() async throws {
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
await handler.handle(url: try #require(URL(string: "https://evil.example/?host=x")))
#expect(handler.ignoredCount == 1)
await handler.markReady() // ready stash
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 0)
#expect(handler.hintMessage == nil)
}
// MARK: - stash
@Test("冷启动ready 前 handle → 挂起markReady → 恰好应用一次")
func coldLaunchStashAppliesOnReady() async throws {
let host = try Self.makeHost()
let sessionId = Self.sessionId()
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: sessionId))
#expect(recorder.opened.isEmpty) //
await handler.markReady()
#expect(recorder.opened.count == 1)
#expect(recorder.opened.first?.sessionId == sessionId)
await handler.markReady() // stash
#expect(recorder.opened.count == 1)
}
@Test("ready 前连续两条链接 → 只应用最新一条(单槽 stash")
func stashKeepsOnlyLatestLink() async throws {
let host = try Self.makeHost()
let first = Self.sessionId()
let second = Self.sessionId()
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: first))
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: second))
await handler.markReady()
#expect(recorder.opened.count == 1)
#expect(recorder.opened.first?.sessionId == second)
}
// MARK: - hint
@Test("clearHint → hintMessage 归位 nil")
func clearHintResets() async throws {
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
)
#expect(handler.hintMessage != nil)
handler.clearHint()
#expect(handler.hintMessage == nil)
}
}