Files
web-terminal/ios/App/WebTerm/Push/PushRegistrar.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00

340 lines
14 KiB
Swift
Raw 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 · 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
///
/// C1 · ****App UI
/// `HostStore.remove(id:)` APNs token
/// 线 `PairingViewModel.removeHost(id:)`
/// `Probe.unregisterPush` `PushHostDeregistration.run(for:)`
/// `AppEnvironment` `PushAppDelegate` ****device
/// token
///
/// 访
/// 401
/// 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"
}