// HostBundleProbe.swift // OSGKeyboard · Keyboard Extension // // 识别当前宿主 App 的 bundle ID,供冷启动跳转主 App 后自动跳回。 // 依赖私有 arbiter 探针(`OSGHostArbiterProbe`),Debug / TestFlight / Release 均生效; // 探针装不上(系统 < 26.4、私有符号改名)时识别结果为 nil,退化为“不自动跳回”, // 主 App 随后会显示“点击左上角返回”的提示作为兜底。 // 调试日志与首页测试卡片仍只在 DEBUG 编译。 import Foundation import os import OSGKeyboardShared enum HostAppIdentity { /// 跳转前最多等多久让 arbiter 回调送达。回调通常在毫秒级到达, /// 0.4 秒既覆盖慢回调,又不会让用户感觉“点了没反应”。 static let handoffWaitTimeout: TimeInterval = 0.4 /// 跳转之后,迟到的回调仍可补写返回目标的窗口。 /// 覆盖主 App 冷启动到“就绪”的这段时间;就绪之后才读目标,所以迟到也来得及。 static let lateBindWindow: TimeInterval = 6 /// 晚绑定窗口的截止时间;回调可能在任意线程,用锁保护。 private static let lateBindDeadline = OSAllocatedUnfairLock(initialState: nil) /// 最近一次识别到的宿主;识别不到、或宿主是 OSG 自己时为 nil。 static var currentBundleId: String? { guard let bundleId = OSGHostArbiterProbe.lastCapturedBundleId, !HostAppURLRegistry.isOwnBundleId(bundleId) else { return nil } return bundleId } /// 探针钩子是否装上了。没装上时等待没有意义,直接跳过。 static var isProbeInstalled: Bool { OSGHostArbiterProbe.isInstalled } /// 安装回调处理器(所有构建)。每次 arbiter 回调: /// 1. DEBUG 下写日志; /// 2. 若正处于“刚发起跳转”的晚绑定窗口内,把新识别到的宿主补写进 App Group。 /// 探针只有一个处理器槽,所以日志与晚绑定必须合并在这里。 static func installCaptureHandler() { OSGHostArbiterProbe.setCaptureHandler { @Sendable bundleId, detail in #if DEBUG HostBundleProbeLog.append(event: "arbiter", bundleId: bundleId, detail: detail) #endif guard let bundleId, !bundleId.isEmpty, !HostAppURLRegistry.isOwnBundleId(bundleId), isLateBindWindowOpen() else { return } // App Group 写入统一回到主线程(`FlowSessionBridge` 只在主线程同步)。 DispatchQueue.main.async { FlowSessionBridge.setPendingHostBundleId(bundleId) #if DEBUG HostBundleProbeLog.append(event: "late-bind", bundleId: bundleId, detail: "published") #endif } } } /// 键盘弹出时主动让 arbiter 回调一次,避免宿主没变时拿不到最新 ID。 static func refreshOnPresentation() { OSGHostArbiterProbe.pingArbiter() } /// 跳转前调用:尽量拿到宿主 ID,但最多等 `timeout`。 /// 已经有值就立刻返回;探针没装上也立刻返回 nil;其余情况 ping 一次后短轮询。 @MainActor static func resolveForHandoff(timeout: TimeInterval = handoffWaitTimeout) async -> String? { if let bundleId = currentBundleId { return bundleId } guard isProbeInstalled else { return nil } OSGHostArbiterProbe.pingArbiter() let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { try? await Task.sleep(nanoseconds: 40_000_000) if let bundleId = currentBundleId { return bundleId } } return currentBundleId } /// 跳转主 App(`startflow`)前调用:把当前宿主写进 App Group,主 App 就绪后据此跳回, /// 并打开晚绑定窗口,让迟到的回调也能补上。 /// 识别不到时写 nil,清掉上一次残留,避免跳回错误的 App。 static func publishReturnTarget() { let bundleId = currentBundleId FlowSessionBridge.setPendingHostBundleId(bundleId) lateBindDeadline.withLock { $0 = Date().addingTimeInterval(lateBindWindow) } #if DEBUG let returnable = HostAppURLRegistry.lookup(bundleId: bundleId) != nil HostBundleProbeLog.append( event: "startflow", bundleId: bundleId, detail: returnable ? "returnable" : "no-return-url" ) #endif } /// 键盘收起时作废识别结果,下次弹出由新回调重新填充。 static func invalidate() { OSGHostArbiterProbe.invalidateCapturedBundleId() lateBindDeadline.withLock { $0 = nil } } private static func isLateBindWindowOpen() -> Bool { lateBindDeadline.withLock { deadline in guard let deadline else { return false } return Date() < deadline } } } #if DEBUG /// 调试:键盘弹出时记一条标记,主 App 首页的测试卡片读取展示。 enum HostBundleProbe { /// 键盘弹出时记一条标记,便于把记录和每次弹出对应起来(ping 由 `HostAppIdentity` 负责)。 static func recordPresentation() { HostBundleProbeLog.append( event: "appear", bundleId: nil, detail: OSGHostArbiterProbe.isInstalled ? "hook=installed" : "hook=missing" ) } } #endif