Files
Rocky e09ad34437 feat(flow): on-demand host handoff with reliable return and a back-tap tip
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.
2026-10-29 16:32:52 +08:00

129 lines
5.4 KiB
Swift
Raw Permalink 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.
// 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