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:
310
ios/App/WebTerm/Push/NotificationActionHandler.swift
Normal file
310
ios/App/WebTerm/Push/NotificationActionHandler.swift
Normal file
@@ -0,0 +1,310 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
331
ios/App/WebTerm/Push/PushRegistrar.swift
Normal file
331
ios/App/WebTerm/Push/PushRegistrar.swift
Normal file
@@ -0,0 +1,331 @@
|
||||
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<UNNotificationCategory>)
|
||||
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<UNNotificationCategory>) {
|
||||
center.setNotificationCategories(categories)
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) 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<UUID> = []
|
||||
|
||||
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"
|
||||
}
|
||||
Reference in New Issue
Block a user