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
+14 -6
View File
@@ -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")
}
}