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,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
///
/// crashdevice token
/// `registerForRemoteNotifications` delegate launch/scene
/// `activate()``registeredHostIds` /
/// upsert 5/min/IP
// MARK: - SeamsUNUserNotificationCenter / 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:52T-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 hexiOS
///
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 64160 hexT-iOS-38
static func hexToken(_ data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - PushAppDelegateapp
/// `@UIApplicationDelegateAdaptor` remote-notification
/// UIApplicationDelegate SwiftUI App
///
/// 线wiring SwiftUI
/// `WebTermApp.init` coordinator `bootstrap`
/// `didFinishLaunching` registrar/handler handler
/// UNUserNotificationCenter delegateApple 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"
}