import APIClient import Foundation import HostRegistry import OSLog import UIKit import UserNotifications import WireProtocol /// T-iOS-21 · APNs 注册侧:授权 → registerForRemoteNotifications → device /// token 对每个已配对主机 `POST /push/apns-token`(builder 归 T-iOS-38)。 /// /// 授权决策(任务要求"document the choice"):请求**真授权 [.alert, .sound]** /// 而非 provisional —— provisional 只静默投递到通知中心(无锁屏横幅、无声音、 /// 无可交互动作),而本任务的核心是锁屏 Allow/Deny 两次手势闭环,必须 alert /// 级展示(服务器 gate 推送也带 `sound: 'default'`,src/push/apns.ts:342)。 /// /// 重试语义:注册失败只记日志绝不 crash;device token 每次 /// `registerForRemoteNotifications` 都会经 delegate 重新送达(launch/scene /// 激活各触发一次 `activate()`),`registeredHostIds` 记账保证只补注册失败/ /// 新增的主机(服务器端 upsert 本身幂等,5/min/IP 限频下不浪费配额)。 // MARK: - Seams(UNUserNotificationCenter / UIApplication 不可在单测实例化流程) /// Seam over the `UNUserNotificationCenter` surface this feature touches. @MainActor protocol NotificationCenterClient: AnyObject { func authorizationStatus() async -> UNAuthorizationStatus func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool func setNotificationCategories(_ categories: Set) func add(_ request: UNNotificationRequest) async throws } @MainActor final class UNNotificationCenterAdapter: NotificationCenterClient { private let center = UNUserNotificationCenter.current() func authorizationStatus() async -> UNAuthorizationStatus { // The async `notificationSettings()` returns non-Sendable // UNNotificationSettings across an isolation hop (Swift 6 error); // extract only the Sendable status inside the completion instead. // @Sendable literal: the closure must NOT inherit this class's // MainActor isolation — UNUserNotificationCenter invokes it on a // background queue, and an isolated closure traps the Swift 6 runtime // executor check (dispatch_assert_queue_fail; W7 verify boot-crash). await withCheckedContinuation { continuation in center.getNotificationSettings { @Sendable settings in continuation.resume(returning: settings.authorizationStatus) } } } func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { // Completion-handler bridge for the same Swift 6 reason as above (the // SDK's async variant sends the non-Sendable center across isolation). try await withCheckedThrowingContinuation { continuation in center.requestAuthorization(options: options) { @Sendable granted, error in if let error { continuation.resume(throwing: error) } else { continuation.resume(returning: granted) } } } } func setNotificationCategories(_ categories: Set) { center.setNotificationCategories(categories) } func add(_ request: UNNotificationRequest) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in center.add(request) { @Sendable error in if let error { continuation.resume(throwing: error) } else { continuation.resume(returning: ()) } } } } } /// Seam over `UIApplication.registerForRemoteNotifications`. @MainActor protocol RemoteNotificationRegistering: AnyObject { func registerForRemoteNotifications() } @MainActor final class UIApplicationRemoteRegistrar: RemoteNotificationRegistering { func registerForRemoteNotifications() { UIApplication.shared.registerForRemoteNotifications() } } // MARK: - WEBTERM_GATE category(安全注的单一定义点) /// 锁屏 Allow/Deny 的 category 注册形状。**安全注(plan T-iOS-21)**: /// - Allow 必带 `.authenticationRequired` —— 锁屏批准 = 授权主机执行命令, /// 必须 Face ID/通行码确认; /// - Deny 保持免认证(fail-safe:旁观者拿到锁屏手机只能拒绝); /// - 两动作都**不带** `.foreground` —— 决策在后台 POST,不拉起 UI。 enum GateNotificationCategory { /// 与服务器 `GATE_CATEGORY` 一致(src/push/apns.ts:52,T-iOS-20 定稿)。 static let identifier = "WEBTERM_GATE" static let allowActionId = "WEBTERM_ALLOW" static let denyActionId = "WEBTERM_DENY" static let allowTitle = "允许" static let denyTitle = "拒绝" static func category() -> UNNotificationCategory { let allow = UNNotificationAction( identifier: allowActionId, title: allowTitle, options: [.authenticationRequired] ) let deny = UNNotificationAction( identifier: denyActionId, title: denyTitle, options: [.destructive] // 红色渲染,纯视觉;不含 .foreground/.authenticationRequired ) return UNNotificationCategory( identifier: identifier, actions: [allow, deny], intentIdentifiers: [], options: [] ) } } // MARK: - PushRegistrar @MainActor final class PushRegistrar { /// 真授权(非 provisional)——理由见文件头 doc。 static let authorizationOptions: UNAuthorizationOptions = [.alert, .sound] private let hostStore: any HostStore private let http: any HTTPTransport private let center: any NotificationCenterClient private let remote: any RemoteNotificationRegistering private let logger = Logger(subsystem: PushLog.subsystem, category: PushLog.registrar) /// 当前 device token 的小写 hex;仅驻内存(iOS 每次注册都重新送达, /// 无需落盘——落盘的只有服务器侧的注册表)。 private(set) var currentTokenHex: String? /// 已用 `currentTokenHex` 注册成功的主机(重试只补缺)。 private var registeredHostIds: Set = [] init( hostStore: any HostStore, http: any HTTPTransport, center: any NotificationCenterClient, remote: any RemoteNotificationRegistering ) { self.hostStore = hostStore self.http = http self.center = center self.remote = remote } /// 幂等激活:每次 launch / scene 转 active 调一次。category 注册无条件; /// 授权与远程注册只在**已有配对主机**时进行(无主机 = 无推送来源,不打扰; /// 配对后的下一次前台激活/启动会补上)。 func activate() async { center.setNotificationCategories([GateNotificationCategory.category()]) let hosts: [HostRegistry.Host] do { hosts = try await hostStore.loadAll() } catch { logger.error("push activate: host store read failed: \(error)") return } guard !hosts.isEmpty else { logger.debug("push activate: no paired hosts — skip authorization") return } guard await ensureAuthorization() else { return } remote.registerForRemoteNotifications() } /// `didRegisterForRemoteNotificationsWithDeviceToken` 入口。 func handleDeviceToken(_ deviceToken: Data) async { let hex = Self.hexToken(deviceToken) if hex != currentTokenHex { currentTokenHex = hex registeredHostIds = [] } await registerPendingHosts() } /// `didFailToRegisterForRemoteNotificationsWithError` 入口:只记日志 /// (模拟器/无 aps-environment entitlement 的构建走到这里,绝不 crash)。 func handleRegistrationFailure(_ error: any Error) { logger.error("remote notification registration failed: \(error)") } /// 主机移除时注销该主机上的 device token(**additive hook**:当前 App /// 层尚无移除主机的 UI 路径——`HostStore.remove(id:)` 无消费者;未来的 /// 移除路径应调用本方法。失败仅记日志:服务器侧对失效 token 也会经 /// APNs 410 自行清理)。 func handleHostRemoved(_ host: HostRegistry.Host) async { registeredHostIds.remove(host.id) guard let token = currentTokenHex else { return } do { try await APIClient(endpoint: host.endpoint, http: http) .unregisterApnsToken(token) } catch { logger.error("APNs token unregister failed for host \(host.id): \(error)") } } // MARK: - Internals /// notDetermined → 弹真授权请求;denied → 短路;其余(authorized/ /// provisional/ephemeral)→ 直接放行。 private func ensureAuthorization() async -> Bool { switch await center.authorizationStatus() { case .notDetermined: do { guard try await center.requestAuthorization( options: Self.authorizationOptions ) else { logger.notice("push authorization denied by user") return false } return true } catch { logger.error("push authorization request failed: \(error)") return false } case .denied: logger.notice("push authorization previously denied — skip registration") return false default: return true } } private func registerPendingHosts() async { guard let token = currentTokenHex else { return } let hosts: [HostRegistry.Host] do { hosts = try await hostStore.loadAll() } catch { logger.error("push token registration: host store read failed: \(error)") return } for host in hosts where !registeredHostIds.contains(host.id) { do { try await APIClient(endpoint: host.endpoint, http: http) .registerApnsToken(token) registeredHostIds.insert(host.id) } catch { // 失败续命:不 crash、不中断其余主机;下一次 token 送达 //(下次启动/前台激活)自动重试本主机。 logger.error("APNs token registration failed for host \(host.id): \(error)") } } } /// APNs device token → 小写 hex(服务器 wire 形状 64–160 hex,T-iOS-38)。 static func hexToken(_ data: Data) -> String { data.map { String(format: "%02x", $0) }.joined() } } // MARK: - PushAppDelegate(app 生命周期胶水,单测不覆盖——全部逻辑在上方可测类型里) /// `@UIApplicationDelegateAdaptor` 入口:remote-notification 回调只能经 /// UIApplicationDelegate 送达,SwiftUI App 本体拿不到。 /// /// 接线路径(wiring 决策,任务允许的最小增量):SwiftUI 生命周期先跑 /// `WebTermApp.init`(在那里创建 coordinator 并静态交接到 `bootstrap`),后跑 /// `didFinishLaunching`(在此消费、组装 registrar/handler 并把 handler 设为 /// UNUserNotificationCenter delegate——Apple 要求该 delegate 必须在启动完成前 /// 就位,否则冷启动的通知动作会丢)。激活(授权+注册)不在这里做,而是随 /// scenePhase 转 active(每次启动都会发生一次)由 `WebTermApp` 触发——后台 /// 拉起(锁屏 Allow/Deny)时场景不会激活,正好省掉 30 秒窗口里的注册流量。 @MainActor final class PushAppDelegate: NSObject, UIApplicationDelegate { /// WebTermApp.init → didFinishLaunching 的一次性交接槽。 static var bootstrap: AppCoordinator? /// 单测宿主(XCTest 已加载)与 XCUITest 拉起(环境带会话标识)都跳过 /// 推送接线:授权弹窗会污染确定性测试;测试用注入替身直接驱动逻辑。 static var isRunningUnderTests: Bool { NSClassFromString("XCTestCase") != nil || ProcessInfo.processInfo.environment["XCTestSessionIdentifier"] != nil } private(set) var registrar: PushRegistrar? private(set) var actionHandler: NotificationActionHandler? func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { guard !Self.isRunningUnderTests, let coordinator = Self.bootstrap else { Self.bootstrap = nil return true } Self.bootstrap = nil let wiring = coordinator.makePushWiring() registrar = wiring.registrar actionHandler = wiring.handler UNUserNotificationCenter.current().delegate = wiring.handler return true } func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { Task { await registrar?.handleDeviceToken(deviceToken) } } func application( _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error ) { registrar?.handleRegistrationFailure(error) } /// scenePhase 转 active(含每次启动)→ 幂等激活。 func activatePush() { Task { await registrar?.activate() } } } enum PushLog { static let subsystem = "com.yaojia.webterm" static let registrar = "push-registrar" static let actionHandler = "push-action" }