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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user