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:
@@ -30,6 +30,13 @@ public enum FlowHandoffPolicy {
|
||||
/// capture, ASR and model warm-up remain lazy until an actual mic press.
|
||||
public static let allowsProactiveHostAutoLaunch = true
|
||||
|
||||
/// Delays (seconds) before each attempt to return to the previous app once
|
||||
/// the host session is ready after a keyboard handoff. A single try was
|
||||
/// brittle: the first `open` can lose a race with PiP settling, and a late
|
||||
/// host-id binding from the keyboard may land a moment after ready. The tip
|
||||
/// is the fallback once every attempt has failed.
|
||||
public static let hostReturnRetryDelays: [TimeInterval] = [0.45, 0.8, 1.2, 1.8]
|
||||
|
||||
/// Samples of "host truly dead" required before a cold-start jump is allowed
|
||||
/// from a mic-driven ready-wait recovery. Idle opens must never cold-start.
|
||||
/// Mic press uses `shouldOpenHostColdStart` directly.
|
||||
|
||||
@@ -144,15 +144,32 @@ extension FlowSessionBridge {
|
||||
let store = FlowSessionBridgeStorage.resolvedDefaults(defaults)
|
||||
if let bundleId, !bundleId.isEmpty {
|
||||
store.set(bundleId, forKey: FlowSessionKeys.pendingHostBundleId)
|
||||
store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.pendingHostBundleIdAt)
|
||||
} else {
|
||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleIdAt)
|
||||
}
|
||||
FlowSessionBridgeStorage.flush(store)
|
||||
}
|
||||
|
||||
public static func pendingHostBundleId(defaults: UserDefaults? = nil) -> String? {
|
||||
/// The host the keyboard recorded for the current `startflow` handoff, or nil
|
||||
/// when none was recorded or it is older than `pendingHostBundleIdTTL`.
|
||||
/// A value without a timestamp (written by an older build) is still honoured.
|
||||
public static func pendingHostBundleId(
|
||||
defaults: UserDefaults? = nil,
|
||||
now: Date = Date()
|
||||
) -> String? {
|
||||
let store = FlowSessionBridgeStorage.resolvedDefaults(defaults)
|
||||
return store.string(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||
guard let bundleId = store.string(forKey: FlowSessionKeys.pendingHostBundleId),
|
||||
!bundleId.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let writtenAt = store.double(forKey: FlowSessionKeys.pendingHostBundleIdAt)
|
||||
if writtenAt > 0,
|
||||
now.timeIntervalSince1970 - writtenAt > FlowSessionKeys.pendingHostBundleIdTTL {
|
||||
return nil
|
||||
}
|
||||
return bundleId
|
||||
}
|
||||
|
||||
public static func clearPendingHostBundleId(defaults: UserDefaults? = nil) {
|
||||
@@ -426,6 +443,7 @@ extension FlowSessionBridge {
|
||||
clearTranscription(defaults: store)
|
||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleIdAt)
|
||||
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
clearHostHeavy(defaults: store)
|
||||
|
||||
@@ -34,6 +34,11 @@ public enum FlowSessionKeys {
|
||||
public static let audioLevels = "flow.audioLevels"
|
||||
/// Bundle id of the app that opened `osgkeyboard://startflow` (scheme D).
|
||||
public static let pendingHostBundleId = "flow.pendingHostBundleId"
|
||||
/// Wall-clock of the last `pendingHostBundleId` write; drives the TTL below.
|
||||
public static let pendingHostBundleIdAt = "flow.pendingHostBundleIdAt.v1"
|
||||
/// A recorded host older than this is never a valid return target — the user
|
||||
/// has long since moved on, and jumping there would be a wrong-app surprise.
|
||||
public static let pendingHostBundleIdTTL: TimeInterval = 120
|
||||
/// Wall-clock of the last keyboard→`startflow` PiP arm attempt (debounce re-jumps).
|
||||
public static let lastPiPArmAttemptAt = "flow.lastPiPArmAttemptAt.v1"
|
||||
/// Minimum gap between repeated proactive `startflow` jumps.
|
||||
|
||||
Reference in New Issue
Block a user