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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user