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.
340 lines
14 KiB
Swift
340 lines
14 KiB
Swift
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)。
|
||
///
|
||
/// 重试语义:注册失败只记日志绝不 crash;device token 每次
|
||
/// `registerForRemoteNotifications` 都会经 delegate 重新送达(launch/scene
|
||
/// 激活各触发一次 `activate()`),`registeredHostIds` 记账保证只补注册失败/
|
||
/// 新增的主机(服务器端 upsert 本身幂等,5/min/IP 限频下不浪费配额)。
|
||
|
||
// MARK: - Seams(UNUserNotificationCenter / 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:52,T-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 的小写 hex;仅驻内存(iOS 每次注册都重新送达,
|
||
/// 无需落盘——落盘的只有服务器侧的注册表)。
|
||
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 形状 64–160 hex,T-iOS-38)。
|
||
static func hexToken(_ data: Data) -> String {
|
||
data.map { String(format: "%02x", $0) }.joined()
|
||
}
|
||
}
|
||
|
||
// MARK: - PushAppDelegate(app 生命周期胶水,单测不覆盖——全部逻辑在上方可测类型里)
|
||
|
||
/// `@UIApplicationDelegateAdaptor` 入口:remote-notification 回调只能经
|
||
/// UIApplicationDelegate 送达,SwiftUI App 本体拿不到。
|
||
///
|
||
/// 接线路径(wiring 决策,任务允许的最小增量):SwiftUI 生命周期先跑
|
||
/// `WebTermApp.init`(在那里创建 coordinator 并静态交接到 `bootstrap`),后跑
|
||
/// `didFinishLaunching`(在此消费、组装 registrar/handler 并把 handler 设为
|
||
/// UNUserNotificationCenter delegate——Apple 要求该 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"
|
||
}
|