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
311 lines
13 KiB
Swift
311 lines
13 KiB
Swift
import APIClient
|
||
import Foundation
|
||
import HostRegistry
|
||
import OSLog
|
||
import UIKit
|
||
import UserNotifications
|
||
import WireProtocol
|
||
|
||
/// T-iOS-21 · 通知动作处理:锁屏 Allow/Deny(系统**后台拉起主 App** 并送达
|
||
/// `UNUserNotificationCenterDelegate.didReceive`——本工程无 notification
|
||
/// extension target,Service Extension 也收不到 action tap)与默认点按路由。
|
||
///
|
||
/// 安全纪律:
|
||
/// - push payload 是**不可信外部输入**——sessionId 经 `DeepLinkRouter.route(from:)`
|
||
/// (冻结的 v4 白名单,不再造第二个解析器),capability token 校验 v4 形状
|
||
/// (服务器 `randomUUID()` 生成,src/server.ts:445)后**原样透传**(服务器做
|
||
/// 字节级比对),任一非法 → `.invalidPayload` 丢弃计数,绝不部分应用;
|
||
/// - token 用后即弃:只存在于解析值 → `hookDecision` 调用参数,绝不落盘、
|
||
/// 绝不进任何兜底文案/日志;
|
||
/// - POST 全程包在 begin/endBackgroundTask 里,didReceive 的 async 返回
|
||
/// (= completionHandler)在 POST settle 之后;
|
||
/// - 失败必须可见:403(token 过期/已用)与传输失败都补一条本地通知,绝不静默吞。
|
||
|
||
// MARK: - Seams
|
||
|
||
/// Seam over `UIApplication.beginBackgroundTask`/`endBackgroundTask`.
|
||
@MainActor
|
||
protocol BackgroundTaskRunning: AnyObject {
|
||
/// Returns an opaque token for `end(_:)`(生产映射 UIBackgroundTaskIdentifier)。
|
||
func begin(name: String) -> Int
|
||
func end(_ token: Int)
|
||
}
|
||
|
||
@MainActor
|
||
final class UIApplicationBackgroundTaskRunner: BackgroundTaskRunning {
|
||
private var active: [Int: UIBackgroundTaskIdentifier] = [:]
|
||
|
||
func begin(name: String) -> Int {
|
||
var identifier = UIBackgroundTaskIdentifier.invalid
|
||
identifier = UIApplication.shared.beginBackgroundTask(withName: name) {
|
||
// 系统到期回调在主线程同步调用(UIKit 文档保证)。
|
||
MainActor.assumeIsolated { self.expire(identifier) }
|
||
}
|
||
active[identifier.rawValue] = identifier
|
||
return identifier.rawValue
|
||
}
|
||
|
||
func end(_ token: Int) {
|
||
guard let identifier = active.removeValue(forKey: token) else { return }
|
||
UIApplication.shared.endBackgroundTask(identifier)
|
||
}
|
||
|
||
private func expire(_ identifier: UIBackgroundTaskIdentifier) {
|
||
guard identifier != .invalid, active.removeValue(forKey: identifier.rawValue) != nil else {
|
||
return
|
||
}
|
||
UIApplication.shared.endBackgroundTask(identifier)
|
||
}
|
||
}
|
||
|
||
/// Seam for the local-notification fallback(决策失败可见性)。
|
||
@MainActor
|
||
protocol LocalNoticePosting: AnyObject {
|
||
func post(title: String, body: String) async
|
||
}
|
||
|
||
extension UNNotificationCenterAdapter: LocalNoticePosting {
|
||
func post(title: String, body: String) async {
|
||
let content = UNMutableNotificationContent()
|
||
content.title = title
|
||
content.body = body
|
||
let request = UNNotificationRequest(
|
||
identifier: UUID().uuidString, content: content, trigger: nil
|
||
)
|
||
do {
|
||
try await add(request)
|
||
} catch {
|
||
// 最后的兜底也失败:只剩日志(不能再用通知报告通知失败)。
|
||
Logger(subsystem: PushLog.subsystem, category: PushLog.actionHandler)
|
||
.error("fallback local notice failed: \(error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 用户可见兜底文案(payload 最小化:不含 token、不含会话内容)。
|
||
enum PushDecisionCopy {
|
||
static let expiredTitle = "审批已失效"
|
||
static let expiredBody = "该次批准/拒绝已过期或已被处理,请打开 App 在会话中查看。"
|
||
static let failedTitle = "审批发送失败"
|
||
static let failedBody = "无法联系主机,请打开 App 在会话中处理。"
|
||
}
|
||
|
||
/// `parse` 的白名单产物(Sendable——didReceive 在 nonisolated 上下文解析后
|
||
/// 才跨进 MainActor,UN 对象绝不跨 isolation)。
|
||
enum ParsedNotificationAction: Sendable, Equatable {
|
||
/// Allow/Deny 动作按钮 + 合法 `{sessionId, token}`。
|
||
case decision(HookDecision, sessionId: UUID, token: String)
|
||
/// 默认点按(含 done 类通知)+ 合法 sessionId。
|
||
case openSession(sessionId: UUID)
|
||
/// 目标动作的 payload 校验失败 → 丢弃计数(镜像"非法帧静默丢弃")。
|
||
case invalidPayload
|
||
/// dismiss / 未知动作 id → no-op。
|
||
case dismissed
|
||
}
|
||
|
||
// MARK: - Handler
|
||
|
||
@MainActor
|
||
final class NotificationActionHandler: NSObject {
|
||
/// Coordinator 提供的路由效果(点按 → 打开会话)。
|
||
struct Actions {
|
||
let openSession: @MainActor (HostRegistry.Host, UUID) async -> Void
|
||
}
|
||
|
||
private enum TaskName {
|
||
static let decision = "webterm.hook-decision"
|
||
}
|
||
|
||
private let hostStore: any HostStore
|
||
private let http: any HTTPTransport
|
||
private let backgroundTasks: any BackgroundTaskRunning
|
||
private let notices: any LocalNoticePosting
|
||
private let actions: Actions
|
||
private let logger = Logger(subsystem: PushLog.subsystem, category: PushLog.actionHandler)
|
||
|
||
private(set) var invalidPayloadCount = 0
|
||
|
||
init(
|
||
hostStore: any HostStore,
|
||
http: any HTTPTransport,
|
||
backgroundTasks: any BackgroundTaskRunning,
|
||
notices: any LocalNoticePosting,
|
||
actions: Actions
|
||
) {
|
||
self.hostStore = hostStore
|
||
self.http = http
|
||
self.backgroundTasks = backgroundTasks
|
||
self.notices = notices
|
||
self.actions = actions
|
||
}
|
||
|
||
// MARK: - Parse(纯函数,payload 级可测核心)
|
||
|
||
nonisolated static func parse(
|
||
actionIdentifier: String, userInfo: [AnyHashable: Any]
|
||
) -> ParsedNotificationAction {
|
||
switch actionIdentifier {
|
||
case GateNotificationCategory.allowActionId:
|
||
return decisionAction(.allow, userInfo: userInfo)
|
||
case GateNotificationCategory.denyActionId:
|
||
return decisionAction(.deny, userInfo: userInfo)
|
||
case UNNotificationDefaultActionIdentifier:
|
||
guard case let .gateSession(sessionId) = DeepLinkRouter.route(from: userInfo) else {
|
||
return .invalidPayload
|
||
}
|
||
return .openSession(sessionId: sessionId)
|
||
default:
|
||
return .dismissed
|
||
}
|
||
}
|
||
|
||
private nonisolated static func decisionAction(
|
||
_ decision: HookDecision, userInfo: [AnyHashable: Any]
|
||
) -> ParsedNotificationAction {
|
||
guard case let .gateSession(sessionId) = DeepLinkRouter.route(from: userInfo),
|
||
let token = userInfo[PayloadKey.token] as? String,
|
||
Validation.isValidSessionId(token) // 服务器 token = randomUUID() → 同一 v4 白名单
|
||
else { return .invalidPayload }
|
||
return .decision(decision, sessionId: sessionId, token: token)
|
||
}
|
||
|
||
private enum PayloadKey {
|
||
/// 根级 capability token(T-iOS-20 payload 定稿,仅 gate 类携带)。
|
||
static let token = "token"
|
||
}
|
||
|
||
// MARK: - Handle
|
||
|
||
func handle(_ action: ParsedNotificationAction) async {
|
||
switch action {
|
||
case .dismissed:
|
||
return
|
||
case .invalidPayload:
|
||
invalidPayloadCount += 1
|
||
// 不可信输入:只记计数,不回显 payload(日志注入/隐私卫生)。
|
||
logger.notice("dropped invalid push payload (total: \(self.invalidPayloadCount))")
|
||
case let .openSession(sessionId):
|
||
await routeToSession(sessionId)
|
||
case let .decision(decision, sessionId, token):
|
||
await postDecision(decision, sessionId: sessionId, token: token)
|
||
}
|
||
}
|
||
|
||
// MARK: - Decision(Allow/Deny → POST /hook/decision)
|
||
|
||
private func postDecision(_ decision: HookDecision, sessionId: UUID, token: String) async {
|
||
let taskToken = backgroundTasks.begin(name: TaskName.decision)
|
||
defer { backgroundTasks.end(taskToken) }
|
||
do {
|
||
guard let host = try await resolveHost(sessionId: sessionId) else {
|
||
logger.error("hook decision: no paired host lists session — cannot deliver")
|
||
await notices.post(
|
||
title: PushDecisionCopy.failedTitle, body: PushDecisionCopy.failedBody
|
||
)
|
||
return
|
||
}
|
||
try await APIClient(endpoint: host.endpoint, http: http)
|
||
.hookDecision(sessionId: sessionId, decision: decision, token: token)
|
||
} catch APIClientError.decisionRejected {
|
||
// 403:token 过期/已用/不匹配(SEC-C1/M1)→ 引导进 App 处理。
|
||
await notices.post(
|
||
title: PushDecisionCopy.expiredTitle, body: PushDecisionCopy.expiredBody
|
||
)
|
||
} catch {
|
||
logger.error("hook decision POST failed: \(error)")
|
||
await notices.post(
|
||
title: PushDecisionCopy.failedTitle, body: PushDecisionCopy.failedBody
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - Default tap(→ DeepLinkRouter 已验证的 sessionId → 会话)
|
||
|
||
private func routeToSession(_ sessionId: UUID) async {
|
||
do {
|
||
guard let host = try await resolveHost(sessionId: sessionId) else {
|
||
// App 已被点按拉起:落在会话列表即可,无需报错弹窗。
|
||
logger.notice("push tap: session not found on any paired host")
|
||
return
|
||
}
|
||
await actions.openSession(host, sessionId)
|
||
} catch {
|
||
logger.error("push tap: host store read failed: \(error)")
|
||
}
|
||
}
|
||
|
||
// MARK: - Host resolution(payload 不含 host 身份——本 handler 拥有解析策略)
|
||
|
||
/// 单主机 → 直取(离线也能把 POST 打出去,错了会显式失败);多主机 →
|
||
/// 逐主机 RO 查 `/live-sessions`(无 Origin)找到持有该会话的主机。
|
||
private func resolveHost(sessionId: UUID) async throws -> HostRegistry.Host? {
|
||
let hosts = try await hostStore.loadAll()
|
||
if hosts.count <= 1 { return hosts.first }
|
||
for host in hosts {
|
||
let sessions = (try? await APIClient(endpoint: host.endpoint, http: http)
|
||
.liveSessions()) ?? []
|
||
if sessions.contains(where: { $0.id == sessionId }) { return host }
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// MARK: - UNUserNotificationCenterDelegate(薄胶水;UNNotificationResponse 无法单测构造)
|
||
|
||
extension NotificationActionHandler: UNUserNotificationCenterDelegate {
|
||
/// async 变体 = 系统的 completionHandler 在本方法 return 时才触发——
|
||
/// 任务要求"completionHandler only after the POST settles"由此保证。
|
||
/// UN 对象只在 nonisolated 入口同步读取,跨进 MainActor 的只有 Sendable
|
||
/// 的 `ParsedNotificationAction`。
|
||
nonisolated func userNotificationCenter(
|
||
_ center: UNUserNotificationCenter,
|
||
didReceive response: UNNotificationResponse
|
||
) async {
|
||
let parsed = Self.parse(
|
||
actionIdentifier: response.actionIdentifier,
|
||
userInfo: response.notification.request.content.userInfo
|
||
)
|
||
await handle(parsed)
|
||
}
|
||
}
|
||
|
||
// MARK: - AppCoordinator wiring(与 DeepLinkRouter.swift 的 makeDeepLinkHandler 同款先例)
|
||
|
||
extension AppCoordinator {
|
||
/// PushAppDelegate.didFinishLaunching 的组装点(生产依赖图单点:
|
||
/// hostStore/http 都来自 AppEnvironment,无 ad-hoc URLSession/keychain)。
|
||
func makePushWiring() -> (registrar: PushRegistrar, handler: NotificationActionHandler) {
|
||
let center = UNNotificationCenterAdapter()
|
||
let registrar = PushRegistrar(
|
||
hostStore: environment.hostStore,
|
||
http: environment.http,
|
||
center: center,
|
||
remote: UIApplicationRemoteRegistrar()
|
||
)
|
||
let handler = NotificationActionHandler(
|
||
hostStore: environment.hostStore,
|
||
http: environment.http,
|
||
backgroundTasks: UIApplicationBackgroundTaskRunner(),
|
||
notices: center,
|
||
actions: NotificationActionHandler.Actions(
|
||
openSession: { [weak self] host, sessionId in
|
||
await self?.openPushedSession(host: host, sessionId: sessionId)
|
||
}
|
||
)
|
||
)
|
||
return (registrar, handler)
|
||
}
|
||
|
||
/// 通知点按 → 直达会话。先 `bootstrap()`(幂等,`route == .loading` 才
|
||
/// 生效)确保冷启动点按不早于根路由建立;再镜像 deep-link 的
|
||
/// close→open(openDeepLinkedSession 是 T-iOS-22 文件的 private——
|
||
/// 三行复刻以尊重文件所有权)。
|
||
func openPushedSession(host: HostRegistry.Host, sessionId: UUID) async {
|
||
await bootstrap()
|
||
if terminalController != nil {
|
||
closeTerminal()
|
||
}
|
||
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
|
||
}
|
||
}
|