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:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

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