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,366 @@
import APIClient
import Foundation
import HostRegistry
import Testing
import TestSupport
import UserNotifications
import WireProtocol
@testable import WebTerm
/// T-iOS-21 · WEBTERM_GATE category Allow
/// `.authenticationRequired` `.foreground`
@MainActor
@Suite("GateNotificationCategory")
struct GateNotificationCategoryTests {
@Test("category id 与服务器 GATE_CATEGORY 一致src/push/apns.ts:52")
func categoryIdentifierMatchesServer() {
#expect(GateNotificationCategory.category().identifier == "WEBTERM_GATE")
}
@Test("恰好两动作,顺序 Allow→Deny标题为中文具名常量")
func actionsShapeAndTitles() {
let actions = GateNotificationCategory.category().actions
#expect(actions.count == 2)
#expect(actions.first?.identifier == GateNotificationCategory.allowActionId)
#expect(actions.last?.identifier == GateNotificationCategory.denyActionId)
#expect(actions.first?.title == GateNotificationCategory.allowTitle)
#expect(actions.last?.title == GateNotificationCategory.denyTitle)
}
@Test("安全注Allow 必须带 .authenticationRequired锁屏批准 = 授权主机执行命令)")
func allowRequiresAuthentication() {
let allow = GateNotificationCategory.category().actions.first
#expect(allow?.options.contains(.authenticationRequired) == true)
}
@Test("安全注Deny 保持免认证fail-safe——旁观者只能拒绝")
func denyStaysUnauthenticated() {
let deny = GateNotificationCategory.category().actions.last
#expect(deny?.options.contains(.authenticationRequired) == false)
}
@Test("安全注:两动作都不带 .foreground锁屏两次手势闭环不拉起 UI")
func neitherActionForegrounds() {
let actions = GateNotificationCategory.category().actions
for action in actions {
#expect(!action.options.contains(.foreground))
}
}
}
/// T-iOS-21 · PushRegistrar provisional
/// alert device token
@MainActor
@Suite("PushRegistrar")
struct PushRegistrarTests {
// MARK: - Fakesseam UNUserNotificationCenter
@MainActor
private final class FakeNotificationCenter: NotificationCenterClient {
var status: UNAuthorizationStatus = .notDetermined
var grantResult: Result<Bool, any Error> = .success(true)
private(set) var requestedOptions: [UNAuthorizationOptions] = []
private(set) var registeredCategorySets: [Set<UNNotificationCategory>] = []
private(set) var addedRequests: [UNNotificationRequest] = []
func authorizationStatus() async -> UNAuthorizationStatus { status }
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
requestedOptions.append(options)
return try grantResult.get()
}
func setNotificationCategories(_ categories: Set<UNNotificationCategory>) {
registeredCategorySets.append(categories)
}
func add(_ request: UNNotificationRequest) async throws {
addedRequests.append(request)
}
}
@MainActor
private final class FakeRemoteRegistrar: RemoteNotificationRegistering {
private(set) var registerCallCount = 0
func registerForRemoteNotifications() { registerCallCount += 1 }
}
private enum StubError: Error { case authorizationFailed }
// MARK: - Fixtures
private static let hostABase = "http://192.168.1.5:3000"
private static let hostBBase = "http://192.168.1.6:3000"
/// 32 device token 64 hexAPNs
private static let tokenData = Data((0..<32).map { UInt8($0) })
private static let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
private static func makeHost(base: String, name: String = "mac") throws -> HostRegistry.Host {
let url = try #require(URL(string: base))
let endpoint = try #require(HostEndpoint(baseURL: url))
return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint)
}
private static func apnsTokenURL(base: String) throws -> URL {
try #require(URL(string: "\(base)/push/apns-token"))
}
private static func makeRegistrar(
hosts: [HostRegistry.Host],
http: FakeHTTPTransport,
center: FakeNotificationCenter,
remote: FakeRemoteRegistrar
) -> PushRegistrar {
PushRegistrar(
hostStore: InMemoryHostStore(hosts: hosts),
http: http,
center: center,
remote: remote
)
}
// MARK: - activate()
@Test("activate → 注册含 WEBTERM_GATE 的 category 集合")
func activateRegistersGateCategory() async throws {
let center = FakeNotificationCenter()
let registrar = Self.makeRegistrar(
hosts: [], http: FakeHTTPTransport(), center: center, remote: FakeRemoteRegistrar()
)
await registrar.activate()
let registered = try #require(center.registeredCategorySets.first)
#expect(registered.contains(where: { $0.identifier == GateNotificationCategory.identifier }))
}
@Test("无已配对主机 → 不请求授权、不注册远程通知(无推送来源不打扰)")
func activateWithoutHostsSkipsAuthorization() async {
let center = FakeNotificationCenter()
let remote = FakeRemoteRegistrar()
let registrar = Self.makeRegistrar(
hosts: [], http: FakeHTTPTransport(), center: center, remote: remote
)
await registrar.activate()
#expect(center.requestedOptions.isEmpty)
#expect(remote.registerCallCount == 0)
}
@Test("有主机 + notDetermined + 授予 → 以 [.alert,.sound] 请求(真授权,非 provisional并注册远程通知")
func activateRequestsRealAuthorizationAndRegisters() async throws {
let center = FakeNotificationCenter()
let remote = FakeRemoteRegistrar()
let registrar = Self.makeRegistrar(
hosts: [try Self.makeHost(base: Self.hostABase)],
http: FakeHTTPTransport(), center: center, remote: remote
)
await registrar.activate()
#expect(center.requestedOptions == [[.alert, .sound]])
// provisional Allow/Deny alert
#expect(center.requestedOptions.first?.contains(.provisional) == false)
#expect(remote.registerCallCount == 1)
}
@Test("用户拒绝授权 → 不注册远程通知、不 crash")
func activateDeniedGrantSkipsRegistration() async throws {
let center = FakeNotificationCenter()
center.grantResult = .success(false)
let remote = FakeRemoteRegistrar()
let registrar = Self.makeRegistrar(
hosts: [try Self.makeHost(base: Self.hostABase)],
http: FakeHTTPTransport(), center: center, remote: remote
)
await registrar.activate()
#expect(remote.registerCallCount == 0)
}
@Test("授权请求抛错 → 记日志、不注册、不 crash")
func activateAuthorizationErrorHandled() async throws {
let center = FakeNotificationCenter()
center.grantResult = .failure(StubError.authorizationFailed)
let remote = FakeRemoteRegistrar()
let registrar = Self.makeRegistrar(
hosts: [try Self.makeHost(base: Self.hostABase)],
http: FakeHTTPTransport(), center: center, remote: remote
)
await registrar.activate()
#expect(remote.registerCallCount == 0)
}
@Test("状态已 denied → 不再弹请求,也不注册")
func activateDeniedStatusShortCircuits() async throws {
let center = FakeNotificationCenter()
center.status = .denied
let remote = FakeRemoteRegistrar()
let registrar = Self.makeRegistrar(
hosts: [try Self.makeHost(base: Self.hostABase)],
http: FakeHTTPTransport(), center: center, remote: remote
)
await registrar.activate()
#expect(center.requestedOptions.isEmpty)
#expect(remote.registerCallCount == 0)
}
@Test("状态已 authorized → 跳过请求直接注册远程通知")
func activateAuthorizedStatusRegistersDirectly() async throws {
let center = FakeNotificationCenter()
center.status = .authorized
let remote = FakeRemoteRegistrar()
let registrar = Self.makeRegistrar(
hosts: [try Self.makeHost(base: Self.hostABase)],
http: FakeHTTPTransport(), center: center, remote: remote
)
await registrar.activate()
#expect(center.requestedOptions.isEmpty)
#expect(remote.registerCallCount == 1)
}
// MARK: - handleDeviceToken
@Test("device token → 小写 hex并对每个已配对主机 POST /push/apns-token带 Origin")
func deviceTokenRegistersWithEveryHost() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
let http = FakeHTTPTransport()
await http.queueSuccess(
method: "POST", url: try Self.apnsTokenURL(base: Self.hostABase), status: 204
)
await http.queueSuccess(
method: "POST", url: try Self.apnsTokenURL(base: Self.hostBBase), status: 204
)
let registrar = Self.makeRegistrar(
hosts: [hostA, hostB], http: http,
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
)
await registrar.handleDeviceToken(Self.tokenData)
#expect(registrar.currentTokenHex == Self.tokenHex)
let requests = await http.recordedRequests
#expect(requests.count == 2)
let urls = Set(requests.compactMap { $0.url?.absoluteString })
#expect(urls == [
"\(Self.hostABase)/push/apns-token", "\(Self.hostBBase)/push/apns-token",
])
for request in requests {
#expect(request.httpMethod == "POST")
// G Origin endpoint.originHeader
let origin = request.value(forHTTPHeaderField: "Origin")
#expect(origin == request.url?.absoluteString.replacingOccurrences(
of: "/push/apns-token", with: ""
))
let body = try #require(request.httpBody)
let decoded = try JSONDecoder().decode([String: String].self, from: body)
#expect(decoded == ["token": Self.tokenHex])
}
}
@Test("一主机失败 → 其余主机照常注册;同 token 重试只补失败的主机")
func failedHostRetriedNextTime() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
let http = FakeHTTPTransport()
let urlA = try Self.apnsTokenURL(base: Self.hostABase)
let urlB = try Self.apnsTokenURL(base: Self.hostBBase)
await http.queueFailure(method: "POST", url: urlA, error: URLError(.cannotConnectToHost))
await http.queueSuccess(method: "POST", url: urlB, status: 204)
let registrar = Self.makeRegistrar(
hosts: [hostA, hostB], http: http,
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
)
await registrar.handleDeviceToken(Self.tokenData)
#expect(await http.recordedRequests.count == 2)
// token / hostA
await http.queueSuccess(method: "POST", url: urlA, status: 204)
await registrar.handleDeviceToken(Self.tokenData)
let requests = await http.recordedRequests
#expect(requests.count == 3)
#expect(requests.last?.url == urlA)
}
@Test("token 变化 → 对全部主机重新注册")
func tokenChangeReRegistersAllHosts() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let http = FakeHTTPTransport()
let urlA = try Self.apnsTokenURL(base: Self.hostABase)
await http.queueSuccess(method: "POST", url: urlA, status: 204)
await http.queueSuccess(method: "POST", url: urlA, status: 204)
let registrar = Self.makeRegistrar(
hosts: [hostA], http: http,
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
)
await registrar.handleDeviceToken(Self.tokenData)
let changed = Data((0..<32).map { UInt8($0 &+ 1) })
await registrar.handleDeviceToken(changed)
#expect(await http.recordedRequests.count == 2)
#expect(registrar.currentTokenHex == changed.map { String(format: "%02x", $0) }.joined())
}
// MARK: - additive hook UI
@Test("handleHostRemoved有 token→ 对该主机 DELETE /push/apns-token")
func hostRemovedUnregistersToken() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let http = FakeHTTPTransport()
let urlA = try Self.apnsTokenURL(base: Self.hostABase)
await http.queueSuccess(method: "POST", url: urlA, status: 204)
await http.queueSuccess(method: "DELETE", url: urlA, status: 204)
let registrar = Self.makeRegistrar(
hosts: [hostA], http: http,
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
)
await registrar.handleDeviceToken(Self.tokenData)
await registrar.handleHostRemoved(hostA)
let last = try #require(await http.recordedRequests.last)
#expect(last.httpMethod == "DELETE")
#expect(last.url == urlA)
let body = try #require(last.httpBody)
#expect(try JSONDecoder().decode([String: String].self, from: body)
== ["token": Self.tokenHex])
}
@Test("handleHostRemoved无 token→ 不发任何请求")
func hostRemovedWithoutTokenIsNoOp() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let http = FakeHTTPTransport()
let registrar = Self.makeRegistrar(
hosts: [hostA], http: http,
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
)
await registrar.handleHostRemoved(hostA)
#expect(await http.recordedRequests.isEmpty)
}
@Test("远程注册失败回调 → 只记日志,不 crash")
func registrationFailureLoggedOnly() throws {
let registrar = Self.makeRegistrar(
hosts: [], http: FakeHTTPTransport(),
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
)
registrar.handleRegistrationFailure(URLError(.notConnectedToInternet))
#expect(registrar.currentTokenHex == nil)
}
}