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.
This commit is contained in:
@@ -176,9 +176,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
#if DEBUG
|
||||
HostBundleProbe.installLogging()
|
||||
#endif
|
||||
// Host identification (return-to-app after a cold-start handoff) runs in
|
||||
// every build; the handler also writes the DEBUG probe log.
|
||||
HostAppIdentity.installCaptureHandler()
|
||||
// Voice-first keyboard — hide the misleading "English" subtitle in Settings.
|
||||
primaryLanguage = "mis"
|
||||
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
|
||||
@@ -373,10 +373,12 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
lockPresentedKeyboardHeight()
|
||||
refreshReturnKeyRole()
|
||||
scheduleTypingDocumentSynchronization()
|
||||
// Presentation math is locked before arming the PiP handoff.
|
||||
// Presentation math is locked before arming the PiP handoff. Only a
|
||||
// keyboard that opens straight onto the voice surface arms the host here;
|
||||
// typing opens never leave the host app.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.heightPhase == .presented else { return }
|
||||
self.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
|
||||
self.flowCoordinator.armHostForVoiceSurfaceIfNeeded(reason: "keyboardOpened")
|
||||
}
|
||||
#if DEBUG
|
||||
HostBundleProbe.recordPresentation()
|
||||
@@ -798,7 +800,13 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
|
||||
state.cutSelection = { [weak self] in self?.textInserter.cutSelection() }
|
||||
state.setSurface = { [weak self] surface in
|
||||
self?.applySurface(surface)
|
||||
guard let self else { return }
|
||||
self.applySurface(surface)
|
||||
// Landing on the voice surface (tab tap, or an auto-fired clipboard
|
||||
// skill taking over the assistant surface) is the demand signal for
|
||||
// the host app. The coordinator ignores any other surface, and its
|
||||
// cooldown absorbs repeated flips.
|
||||
self.flowCoordinator.armHostForVoiceSurfaceIfNeeded(reason: "surfaceSelected")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,18 +162,29 @@ final class KeyboardFlowCoordinator {
|
||||
stopHostReadyWait()
|
||||
}
|
||||
|
||||
/// Ensure the containing app has armed its low-profile PiP even when the
|
||||
/// keyboard opens directly into Pinyin/English typing mode. The mic stays
|
||||
/// visually ready; if the host contract is missing, one automatic handoff
|
||||
/// prepares PiP so the next press does not need another app switch.
|
||||
/// Demand-driven host arm: jump to the containing app only when the user is
|
||||
/// actually on a surface that needs it (the voice / assistant surface).
|
||||
///
|
||||
/// Typing (拼音 / English) and clipboard history work without the host, so the
|
||||
/// keyboard no longer hops to the app just because it opened. Every other
|
||||
/// host-dependent entry — mic press, AI question, clipboard skill (manual or
|
||||
/// auto-fired) — already funnels through `startUtterance`, which cold-starts
|
||||
/// the host on demand. This covers the one remaining case: the user lands on
|
||||
/// the voice surface (keyboard opened there, or the tab was tapped) while the
|
||||
/// host's ready contract is missing, so the first press doesn't need a hop.
|
||||
///
|
||||
/// Must **not** re-jump when a session is already alive/warming (common after
|
||||
/// the user returns from startflow and taps the Voice tab — height/appear
|
||||
/// noise used to call this again while `ready` briefly lagged).
|
||||
func ensurePiPReadyOnKeyboardOpen() {
|
||||
/// noise used to call this again while `ready` briefly lagged). The shared
|
||||
/// arm cooldown also keeps repeated tab flips from re-launching the app.
|
||||
func armHostForVoiceSurfaceIfNeeded(reason: String) {
|
||||
#if DEBUG
|
||||
MemoryDeviceProbe.log("pip.ensure.entry")
|
||||
#endif
|
||||
guard state.surface == .voice else {
|
||||
traceState("hostArm.skipped", extra: "reason=\(reason) surface=\(state.surface.rawValue)")
|
||||
return
|
||||
}
|
||||
guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch,
|
||||
voiceSetupReady,
|
||||
hasFullAccess(),
|
||||
@@ -196,7 +207,7 @@ final class KeyboardFlowCoordinator {
|
||||
heartbeatStaleness: FlowSessionBridge.heartbeatStaleness()
|
||||
)
|
||||
guard shouldArm else {
|
||||
traceState("keyboardOpen.autoArmPiP.skipped", extra: "gate=0")
|
||||
traceState("hostArm.skipped", extra: "reason=\(reason) gate=0")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -206,7 +217,7 @@ final class KeyboardFlowCoordinator {
|
||||
MemoryDeviceProbe.log("pip.arm.beginFlowStart")
|
||||
#endif
|
||||
beginFlowStart(recordAfterHandoff: false)
|
||||
traceState("keyboardOpen.autoArmPiP")
|
||||
traceState("hostArm.started", extra: "reason=\(reason)")
|
||||
}
|
||||
|
||||
func refreshSessionState() {
|
||||
@@ -1138,10 +1149,34 @@ final class KeyboardFlowCoordinator {
|
||||
state.lastTranscript = ""
|
||||
recomputeMicVoiceAvailability()
|
||||
FlowSessionBridge.markPiPArmAttempt()
|
||||
|
||||
// The return target must be known *before* we leave — the host app reads
|
||||
// it as soon as its session is ready. The arbiter callback that carries
|
||||
// the host id can lag a few ms behind the keyboard appearing, so when the
|
||||
// probe is installed but has not delivered yet, wait briefly (bounded)
|
||||
// instead of racing it. Already-known ids and uninstalled probes skip the
|
||||
// wait and keep the jump synchronous.
|
||||
if HostAppIdentity.currentBundleId != nil || !HostAppIdentity.isProbeInstalled {
|
||||
jumpToHostForFlowStart()
|
||||
return
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
_ = await HostAppIdentity.resolveForHandoff()
|
||||
// The user may have released the mic / cancelled while we waited.
|
||||
guard let self, self.isPendingFlowStart else {
|
||||
self?.traceState("beginFlowStart.cancelledDuringIdentityWait")
|
||||
return
|
||||
}
|
||||
self.jumpToHostForFlowStart()
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes the return target and opens `osgkeyboard://startflow`.
|
||||
private func jumpToHostForFlowStart() {
|
||||
HostAppIdentity.publishReturnTarget()
|
||||
OSGDiag.log(
|
||||
"beginFlowStart → openHostApp(startflow) recordAfterHandoff=\(recordAfterHandoff) "
|
||||
+ "\(OSGDiag.memoryTag())",
|
||||
+ "hostKnown=\(HostAppIdentity.currentBundleId != nil) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
openHostApp("startflow")
|
||||
|
||||
@@ -3,13 +3,26 @@
|
||||
//
|
||||
// 识别当前宿主 App 的 bundle ID,供冷启动跳转主 App 后自动跳回。
|
||||
// 依赖私有 arbiter 探针(`OSGHostArbiterProbe`),Debug / TestFlight / Release 均生效;
|
||||
// 探针装不上(系统 < 26.4、私有符号改名)时识别结果为 nil,退化为“不自动跳回”。
|
||||
// 探针装不上(系统 < 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,
|
||||
@@ -19,16 +32,63 @@ enum HostAppIdentity {
|
||||
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()
|
||||
}
|
||||
|
||||
/// 跳转主 App(`startflow`)前调用:把当前宿主写进 App Group,主 App 就绪后据此跳回。
|
||||
/// 跳转前调用:尽量拿到宿主 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(
|
||||
@@ -42,19 +102,20 @@ enum HostAppIdentity {
|
||||
/// 键盘收起时作废识别结果,下次弹出由新回调重新填充。
|
||||
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 Group,主 App 首页的测试卡片读取展示。
|
||||
/// 调试:键盘弹出时记一条标记,主 App 首页的测试卡片读取展示。
|
||||
enum HostBundleProbe {
|
||||
/// 只需安装一次;回调可能来自非主线程,闭包必须是 nonisolated。
|
||||
static func installLogging() {
|
||||
OSGHostArbiterProbe.setCaptureHandler { @Sendable bundleId, detail in
|
||||
HostBundleProbeLog.append(event: "arbiter", bundleId: bundleId, detail: detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 键盘弹出时记一条标记,便于把记录和每次弹出对应起来(ping 由 `HostAppIdentity` 负责)。
|
||||
static func recordPresentation() {
|
||||
HostBundleProbeLog.append(
|
||||
|
||||
@@ -19,7 +19,8 @@ typedef void (^OSGHostArbiterCaptureHandler)(NSString *_Nullable bundleId, NSStr
|
||||
/// 钩子是否已成功安装(私有类或方法不存在时为 NO)。
|
||||
@property (class, nonatomic, readonly) BOOL isInstalled;
|
||||
|
||||
/// 最近一次回调读到的宿主 bundle ID;回调读不到或已作废时为 nil。线程安全。
|
||||
/// 最近一次回调读到的有效宿主 bundle ID;从未读到或已作废时为 nil。线程安全。
|
||||
/// 读不到 ID 的空回调不会清掉它,只有 invalidateCapturedBundleId 会。
|
||||
@property (class, nonatomic, readonly, nullable) NSString *lastCapturedBundleId;
|
||||
|
||||
/// 作废上一次的识别结果(键盘收起时调用,避免把上一个宿主当成当前宿主)。
|
||||
|
||||
@@ -44,8 +44,12 @@ static void OSGSwizzledKeyboardChanged(id self, SEL _cmd, id change, id completi
|
||||
NSString *detail = change ? NSStringFromClass([change class]) : @"change=nil";
|
||||
|
||||
os_unfair_lock_lock(&sLock);
|
||||
// 读不到 ID 的回调同样覆盖旧值:宿主已经变了,旧 ID 不能再用。
|
||||
sLastCapturedBundleId = bundleId.length > 0 ? bundleId : nil;
|
||||
// 只在读到有效 ID 时更新。读不到 ID 的回调(焦点切换、空事件)不再清掉已拿到的值:
|
||||
// 之前“最后一个事件为准”会让一次空回调把正确的宿主抹掉,是识别忽有忽无的来源之一。
|
||||
// 宿主真的变了,由键盘收起时的 invalidateCapturedBundleId 作废旧值。
|
||||
if (bundleId.length > 0) {
|
||||
sLastCapturedBundleId = bundleId;
|
||||
}
|
||||
OSGHostArbiterCaptureHandler handler = sHandler;
|
||||
os_unfair_lock_unlock(&sLock);
|
||||
if (handler) {
|
||||
|
||||
Reference in New Issue
Block a user