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:
Rocky
2026-10-29 16:32:52 +08:00
parent 4dce67df84
commit e09ad34437
17 changed files with 454 additions and 63 deletions
@@ -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")