Files
web-terminal/ios/App/WebTerm/Push/NotificationActionHandler.swift
Yaojia Wang f40b8f9400 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
2026-07-05 16:15:57 +02:00

311 lines
13 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import APIClient
import Foundation
import HostRegistry
import OSLog
import UIKit
import UserNotifications
import WireProtocol
/// T-iOS-21 · Allow/Deny** App**
/// `UNUserNotificationCenterDelegate.didReceive` notification
/// extension targetService 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
/// - 403token /
// 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` SendabledidReceive nonisolated
/// MainActorUN 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: - Parsepayload
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 tokenT-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: - DecisionAllow/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 {
// 403token //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 resolutionpayload 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: - UNUserNotificationCenterDelegateUNNotificationResponse
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
/// closeopenopenDeepLinkedSession 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))
}
}