Keyboard
- Only jump to the app when the voice surface is shown (tab tap / opened
there); typing tabs no longer leave the host on keyboard open.
- Wait up to 0.4 s for the arbiter to report the host app before leaving,
and late-bind an id that arrives within 6 s of the jump.
- Empty arbiter callbacks no longer wipe an already captured host id;
install the capture handler in every build configuration.
App
- Return to the previous app with up to four retries using iOS's real
open result, and honor the skip-app-switch preference.
- Keep the pending host id across cold-start zombie reconciliation, and
expire it after two minutes (legacy values without a timestamp stay valid).
- Show a light tip pointing at the system back control when we cannot
return automatically ("点击返回,立即使用").
Tests: TTL behaviour and return retry policy.
129 lines
5.4 KiB
Swift
129 lines
5.4 KiB
Swift
// 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<Date?>(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
|