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)) } }