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:
@@ -7,7 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **“Tap back” tip when we cannot return for you**: after the keyboard sends you to OSG to start voice, and OSG cannot jump back on its own (the app you came from is unknown, or the jump failed), a light veil appears with a small arrow pointing at iOS's own back control at the top-left and one line, “点击返回,立即使用” / “Tap back, start right away”. It works even when OSG could not tell which app you were in, because the system control already names it. Tap anywhere to dismiss; it also disappears as soon as you leave. / **无法替你返回时的「点击返回」提示**:键盘带你进入 OSG 启动语音后,如果 OSG 没能自动跳回(不知道来自哪个 App,或跳转失败),会出现一层淡淡的遮罩,一个指向 iOS 左上角系统返回按钮的小箭头,以及一句「点击返回,立即使用」。即使没识别出你来自哪个 App 也会显示,因为系统按钮本身就写着来源 App 的名字。点任意处关闭,你一离开也会自动消失。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The keyboard only opens OSG when you need it**: opening the keyboard on the typing tabs (拼音 / English) no longer jumps to OSG. The jump now happens when you land on the voice tab, or when you start dictation, ask AI, or run a clipboard skill (including automatic ones). A short cool-down keeps flipping between tabs from reopening the app. / **只在需要时才打开 OSG**:在拼音 / 英文标签页打开键盘不再跳转到 OSG。切到语音标签页,或开始听写、提问 AI、使用剪贴板技能(包括自动触发的)时才会跳转;短暂的冷却时间避免来回切换标签页反复拉起 App。
|
||||||
|
- **Auto-return now retries and respects your setting**: going back to the previous app is tried up to four times, waiting for voice to be ready and for a late app identification, instead of a single attempt, and it now follows the “return to previous app after starting” preference instead of ignoring it. / **自动返回会重试,并遵循你的设置**:返回上一个 App 最多尝试四次,会等待语音就绪和迟到的 App 识别,不再只试一次;同时遵循「启动后返回上一个 App」设置,而不是忽略它。
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Host app identification no longer flickers**: the keyboard waits up to 0.4 s for the system to report which app you are in before it leaves, keeps a correct identification when a later empty system callback arrives, and still records an identification that arrives a few seconds after the jump. / **宿主 App 识别不再忽有忽无**:键盘离开前最多等待 0.4 秒,让系统报告你所在的 App;之后到达的空回调不会再抹掉已识别到的结果;跳转后几秒内才到达的识别结果也会被补记。
|
||||||
|
- **Return target survives a cold launch and expires safely**: starting OSG after it had been force-quit no longer wipes the app to return to, and a recorded target older than two minutes is ignored so you are never sent to an app you left long ago. / **返回目标不再在冷启动时丢失,也不会过期误跳**:强杀后冷启动 OSG 不再清掉要返回的 App;记录超过两分钟的返回目标会被忽略,不会把你送回早已离开的 App。
|
||||||
- **Auto-return to the previous app in every build**: host-app identification used to compile only in Debug, so TestFlight and App Store builds never knew which app you were typing in and could not jump back after starting a voice session. It now runs in Debug, TestFlight, and Release; if the system does not expose the host (iOS earlier than 26.4), OSG simply stays open as before. / **所有版本都能自动跳回原 App**:此前识别宿主 App 的逻辑只在 Debug 构建中编译,TestFlight 和 App Store 版本不知道你正在哪个 App 里输入,启动语音会话后无法自动跳回。现在 Debug、TestFlight、正式版都会识别;系统不提供宿主信息时(低于 iOS 26.4)与之前一样停留在 OSG。
|
- **Auto-return to the previous app in every build**: host-app identification used to compile only in Debug, so TestFlight and App Store builds never knew which app you were typing in and could not jump back after starting a voice session. It now runs in Debug, TestFlight, and Release; if the system does not expose the host (iOS earlier than 26.4), OSG simply stays open as before. / **所有版本都能自动跳回原 App**:此前识别宿主 App 的逻辑只在 Debug 构建中编译,TestFlight 和 App Store 版本不知道你正在哪个 App 里输入,启动语音会话后无法自动跳回。现在 Debug、TestFlight、正式版都会识别;系统不提供宿主信息时(低于 iOS 26.4)与之前一样停留在 OSG。
|
||||||
|
|
||||||
## [2.1.6] - 2026-10-27
|
## [2.1.6] - 2026-10-27
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
/// Throttles AI-mode LLM draft writes into App Group.
|
/// Throttles AI-mode LLM draft writes into App Group.
|
||||||
private var aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
private var aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
||||||
/// Published so Home / debug UI can show "recording" instead of a false "ready".
|
/// Published so Home / debug UI can show "recording" instead of a false "ready".
|
||||||
|
/// True while the "tap the ‹ at the top-left to go back" tip should show: the
|
||||||
|
/// session is ready after a keyboard handoff but we could not return to the
|
||||||
|
/// previous app ourselves. Cleared when the user leaves the app or taps it.
|
||||||
|
@Published private(set) var isReturnTipVisible = false
|
||||||
@Published private(set) var isUtteranceRecording = false
|
@Published private(set) var isUtteranceRecording = false
|
||||||
/// True from `stopped` until the result/error is written back to App Group.
|
/// True from `stopped` until the result/error is written back to App Group.
|
||||||
@Published private(set) var isUtteranceProcessing = false
|
@Published private(set) var isUtteranceProcessing = false
|
||||||
@@ -286,6 +290,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
private var isAppForeground = false
|
private var isAppForeground = false
|
||||||
/// True while handling a keyboard-initiated `startflow` cold start.
|
/// True while handling a keyboard-initiated `startflow` cold start.
|
||||||
private var isColdStartHandoff = false
|
private var isColdStartHandoff = false
|
||||||
|
/// Drives the retrying "return to the previous app" attempts after a handoff.
|
||||||
|
private var autoReturnTask: Task<Void, Never>?
|
||||||
/// User intent outlives a transient PiP loss; only explicit teardown clears it.
|
/// User intent outlives a transient PiP loss; only explicit teardown clears it.
|
||||||
private var wantsActiveSession = false
|
private var wantsActiveSession = false
|
||||||
/// Invalidates late async completions after retry, stop, or process teardown.
|
/// Invalidates late async completions after retry, stop, or process teardown.
|
||||||
@@ -487,7 +493,15 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
if isActive {
|
if isActive {
|
||||||
endSession()
|
endSession()
|
||||||
} else {
|
} else {
|
||||||
|
// `clearFlowState()` also wipes the pending host id. On a cold
|
||||||
|
// `startflow` launch after a force-quit the keyboard has already
|
||||||
|
// recorded where to return to; keep it or auto-return has nothing
|
||||||
|
// to return to.
|
||||||
|
let pendingHost = FlowSessionBridge.pendingHostBundleId()
|
||||||
FlowSessionBridge.clearFlowState()
|
FlowSessionBridge.clearFlowState()
|
||||||
|
if let pendingHost {
|
||||||
|
FlowSessionBridge.setPendingHostBundleId(pendingHost)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
debug("reconciled zombie persisted Flow state")
|
debug("reconciled zombie persisted Flow state")
|
||||||
case .clearOrphanedRecording(let orphaned):
|
case .clearOrphanedRecording(let orphaned):
|
||||||
@@ -607,6 +621,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
pipRecoveryOperation &+= 1
|
pipRecoveryOperation &+= 1
|
||||||
pipLifecycleState = .inactive
|
pipLifecycleState = .inactive
|
||||||
isColdStartHandoff = false
|
isColdStartHandoff = false
|
||||||
|
autoReturnTask?.cancel()
|
||||||
|
autoReturnTask = nil
|
||||||
startTask?.cancel()
|
startTask?.cancel()
|
||||||
startTask = nil
|
startTask = nil
|
||||||
startupAudioHealthTask?.cancel()
|
startupAudioHealthTask?.cancel()
|
||||||
@@ -693,6 +709,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
pipRecoveryOperation &+= 1
|
pipRecoveryOperation &+= 1
|
||||||
pipLifecycleState = .inactive
|
pipLifecycleState = .inactive
|
||||||
isColdStartHandoff = false
|
isColdStartHandoff = false
|
||||||
|
autoReturnTask?.cancel()
|
||||||
|
autoReturnTask = nil
|
||||||
startTask?.cancel()
|
startTask?.cancel()
|
||||||
startTask = nil
|
startTask = nil
|
||||||
startupAudioHealthTask?.cancel()
|
startupAudioHealthTask?.cancel()
|
||||||
@@ -785,6 +803,11 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
} else if wantsActiveSession {
|
} else if wantsActiveSession {
|
||||||
pausePiPRecoveryUntilForeground()
|
pausePiPRecoveryUntilForeground()
|
||||||
}
|
}
|
||||||
|
// The user left (by our auto-return or on their own): stop retrying
|
||||||
|
// and drop the tip so it is not still showing on their next visit.
|
||||||
|
autoReturnTask?.cancel()
|
||||||
|
autoReturnTask = nil
|
||||||
|
dismissReturnTip()
|
||||||
if isColdStartHandoff {
|
if isColdStartHandoff {
|
||||||
completeColdStartHandoff()
|
completeColdStartHandoff()
|
||||||
}
|
}
|
||||||
@@ -1511,27 +1534,69 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let hostEntry = HostReturnService.pendingHostEntry()
|
// Honor the user's "return to the previous app" preference. When it is
|
||||||
guard hostEntry != nil else {
|
// off we never jump back on our own, but the tip still tells the user
|
||||||
|
// how to go back themselves.
|
||||||
|
guard FlowSessionPolicy.skipAppSwitch() else {
|
||||||
|
presentReturnTip(reason: "autoReturnDisabled")
|
||||||
completeColdStartHandoff()
|
completeColdStartHandoff()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scheduleAutoReturnToHostIfNeeded(hostEntry: hostEntry)
|
scheduleAutoReturnToHost()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleAutoReturnToHostIfNeeded(hostEntry: HostAppEntry?) {
|
/// Tries to return to the previous app, retrying a few times. The host id the
|
||||||
let skipSwitch = true
|
/// keyboard recorded can arrive a moment after the session is ready, and the
|
||||||
guard skipSwitch, hostEntry != nil else { return }
|
/// first `open` can lose a race with PiP settling — one shot was brittle.
|
||||||
Task { @MainActor [weak self] in
|
///
|
||||||
try? await Task.sleep(nanoseconds: 450_000_000)
|
/// The tip goes up as soon as we know there is nowhere to return to, and also
|
||||||
guard let self, self.isColdStartHandoff, self.isActive,
|
/// once every attempt has failed. If a later attempt does succeed, the app
|
||||||
FlowSessionBridge.isHostReady() else { return }
|
/// moves to the background and the tip is cleared with it.
|
||||||
if HostReturnService.openPendingHostIfPossible() {
|
private func scheduleAutoReturnToHost() {
|
||||||
self.completeColdStartHandoff()
|
autoReturnTask?.cancel()
|
||||||
|
autoReturnTask = Task { @MainActor [weak self] in
|
||||||
|
for delay in FlowHandoffPolicy.hostReturnRetryDelays {
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
|
guard let self, !Task.isCancelled, self.isActive, self.isAppForeground else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not ready (yet): no point leaving, and "use it now" would be a lie.
|
||||||
|
guard FlowSessionBridge.isHostReady() else { continue }
|
||||||
|
|
||||||
|
switch await HostReturnService.openPendingHost() {
|
||||||
|
case .opened:
|
||||||
|
self.traceState("autoReturn.opened")
|
||||||
|
self.completeColdStartHandoff()
|
||||||
|
return
|
||||||
|
case .noTarget:
|
||||||
|
// Nothing to return to — guide the user now, keep retrying
|
||||||
|
// in case the keyboard's late binding lands.
|
||||||
|
self.presentReturnTip(reason: "noTarget")
|
||||||
|
case .openFailed:
|
||||||
|
self.traceState("autoReturn.openFailed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
guard let self, !Task.isCancelled, self.isActive, self.isAppForeground else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.presentReturnTip(reason: "retriesExhausted")
|
||||||
|
self.completeColdStartHandoff()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func presentReturnTip(reason: String) {
|
||||||
|
guard !isReturnTipVisible else { return }
|
||||||
|
isReturnTipVisible = true
|
||||||
|
traceState("returnTip.shown", extra: "reason=\(reason)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hides the tip (user tapped it, or the app left the foreground).
|
||||||
|
func dismissReturnTip() {
|
||||||
|
guard isReturnTipVisible else { return }
|
||||||
|
isReturnTipVisible = false
|
||||||
|
traceState("returnTip.dismissed")
|
||||||
|
}
|
||||||
|
|
||||||
/// Actively rebuilds the audio pipeline after a failed cold start instead
|
/// Actively rebuilds the audio pipeline after a failed cold start instead
|
||||||
/// of passively waiting for frames that a dead engine will never produce.
|
/// of passively waiting for frames that a dead engine will never produce.
|
||||||
/// Escalates per attempt: reassert the session → full engine rebuild →
|
/// Escalates per attempt: reassert the session → full engine rebuild →
|
||||||
|
|||||||
@@ -11,35 +11,42 @@ import OSGKeyboardShared
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
enum HostReturnService {
|
enum HostReturnService {
|
||||||
/// Attempts to return to the pending host app. Clears the pending bundle id on success.
|
/// Result of one return attempt. Callers retry on everything except `.opened`.
|
||||||
|
enum Outcome: String {
|
||||||
|
/// iOS reported the host app was actually opened.
|
||||||
|
case opened
|
||||||
|
/// No usable return target (host not recorded, or not in the whitelist).
|
||||||
|
case noTarget
|
||||||
|
/// A target exists but iOS refused / failed to open it.
|
||||||
|
case openFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to return to the pending host app and waits for iOS's *real*
|
||||||
|
/// answer. Clears the pending bundle id only when the open succeeded, so a
|
||||||
|
/// failed attempt can be retried with the same target.
|
||||||
|
///
|
||||||
|
/// Re-reads the App Group first: the keyboard process wrote the target (and
|
||||||
|
/// may still be late-binding it), so a cached value here can be stale.
|
||||||
@MainActor
|
@MainActor
|
||||||
static func openPendingHostIfPossible() -> Bool {
|
static func openPendingHost() async -> Outcome {
|
||||||
|
FlowSessionBridge.reloadFromDisk()
|
||||||
let bundleId = FlowSessionBridge.pendingHostBundleId()
|
let bundleId = FlowSessionBridge.pendingHostBundleId()
|
||||||
guard let entry = HostAppURLRegistry.lookup(bundleId: bundleId),
|
guard let entry = HostAppURLRegistry.lookup(bundleId: bundleId),
|
||||||
let url = entry.returnURL else {
|
let url = entry.returnURL else {
|
||||||
logReturn(bundleId: bundleId, outcome: "no-entry")
|
logReturn(bundleId: bundleId, outcome: Outcome.noTarget.rawValue)
|
||||||
return false
|
return .noTarget
|
||||||
}
|
}
|
||||||
UIApplication.shared.open(url, options: [:]) { success in
|
let success = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
|
||||||
logReturn(bundleId: bundleId, outcome: success ? "opened" : "open-failed")
|
UIApplication.shared.open(url, options: [:]) { opened in
|
||||||
if success {
|
continuation.resume(returning: opened)
|
||||||
FlowSessionBridge.clearPendingHostBundleId()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
let outcome: Outcome = success ? .opened : .openFailed
|
||||||
}
|
logReturn(bundleId: bundleId, outcome: outcome.rawValue)
|
||||||
|
if success {
|
||||||
@MainActor
|
FlowSessionBridge.clearPendingHostBundleId()
|
||||||
static func openHost(entry: HostAppEntry) -> Bool {
|
|
||||||
guard let url = entry.returnURL else {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
UIApplication.shared.open(url, options: [:]) { success in
|
return outcome
|
||||||
if success {
|
|
||||||
FlowSessionBridge.clearPendingHostBundleId()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -88,7 +88,16 @@ struct MainAppRoot: View {
|
|||||||
palette.background
|
palette.background
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
mainContent
|
mainContent
|
||||||
|
|
||||||
|
// After a keyboard handoff we could not auto-return from: point the
|
||||||
|
// user at the system's back control.
|
||||||
|
if flowManager.isReturnTipVisible {
|
||||||
|
ReturnTipOverlay { flowManager.dismissReturnTip() }
|
||||||
|
.transition(.opacity)
|
||||||
|
.zIndex(10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.animation(.easeOut(duration: 0.2), value: flowManager.isReturnTipVisible)
|
||||||
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
||||||
.environmentObject(flowManager)
|
.environmentObject(flowManager)
|
||||||
.environmentObject(accountSession)
|
.environmentObject(accountSession)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// ReturnTipOverlay.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Shown after a keyboard → app handoff when we could not return to the previous
|
||||||
|
// app on our own. It does one thing: point at the system's back control.
|
||||||
|
//
|
||||||
|
// iOS draws a small "◀ <previous app>" at the top-left whenever this app was
|
||||||
|
// opened from another one. We cannot move or restyle it, so the tip is just a
|
||||||
|
// light scrim, an arrow aimed at it, and a single sentence. It names no app —
|
||||||
|
// the system control already does, and it works even when we could not tell
|
||||||
|
// which app the user came from.
|
||||||
|
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ReturnTipOverlay: View {
|
||||||
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
|
/// Called when the user taps anywhere on the tip.
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
/// Drives the gentle up-and-down nudge of the arrow.
|
||||||
|
@State private var nudge = false
|
||||||
|
|
||||||
|
/// Veil over the page: light enough that the app stays visible underneath,
|
||||||
|
/// opaque enough that the centred sentence remains easy to read.
|
||||||
|
private let scrimOpacity = 0.88
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
palette.background
|
||||||
|
.opacity(scrimOpacity)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
arrowGuide
|
||||||
|
.padding(.leading, 14)
|
||||||
|
// This stack starts at the top safe-area edge (the status bar's
|
||||||
|
// bottom), where the system back control sits just above. A small
|
||||||
|
// negative inset tucks the arrow's tip right under it on every device.
|
||||||
|
.padding(.top, -6)
|
||||||
|
.offset(y: nudge ? 4 : -2)
|
||||||
|
|
||||||
|
Text("flow.returnTip.message")
|
||||||
|
.font(.system(size: 22, weight: .semibold))
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, Spacing.xl)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(perform: onDismiss)
|
||||||
|
.onAppear {
|
||||||
|
guard !reduceMotion else { return }
|
||||||
|
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
|
||||||
|
nudge = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(Text("flow.returnTip.message"))
|
||||||
|
.accessibilityHint(Text("flow.returnTip.a11y.hint"))
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pieces
|
||||||
|
|
||||||
|
/// Curved arrow plus its one-line caption, pointing up-left at the system control.
|
||||||
|
private var arrowGuide: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ReturnTipArrow()
|
||||||
|
.stroke(
|
||||||
|
palette.accent,
|
||||||
|
style: StrokeStyle(lineWidth: 2.6, lineCap: .round, lineJoin: .round)
|
||||||
|
)
|
||||||
|
.frame(width: 60, height: 50)
|
||||||
|
Text("flow.returnTip.arrowHint")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
}
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand-drawn style curved arrow whose head points up-left.
|
||||||
|
private struct ReturnTipArrow: Shape {
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
let start = CGPoint(x: rect.minX + 0.50 * rect.width, y: rect.maxY)
|
||||||
|
let end = CGPoint(x: rect.minX + 0.12 * rect.width, y: rect.minY + 3)
|
||||||
|
let control = CGPoint(x: rect.minX + 0.55 * rect.width, y: rect.minY + 0.25 * rect.height)
|
||||||
|
path.move(to: start)
|
||||||
|
path.addQuadCurve(to: end, control: control)
|
||||||
|
|
||||||
|
// Arrow head: two short strokes fanned ±30° off the curve's end tangent.
|
||||||
|
let dx = end.x - control.x
|
||||||
|
let dy = end.y - control.y
|
||||||
|
let length = max(hypot(dx, dy), 0.001)
|
||||||
|
let ux = dx / length
|
||||||
|
let uy = dy / length
|
||||||
|
let headLength: CGFloat = 11
|
||||||
|
for sign in [CGFloat(1), CGFloat(-1)] {
|
||||||
|
let angle = sign * .pi / 6
|
||||||
|
let rx = -(ux * cos(angle) - uy * sin(angle))
|
||||||
|
let ry = -(ux * sin(angle) + uy * cos(angle))
|
||||||
|
path.move(to: end)
|
||||||
|
path.addLine(to: CGPoint(x: end.x + rx * headLength, y: end.y + ry * headLength))
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -855,6 +855,11 @@
|
|||||||
|
|
||||||
/* Cold-start / session errors (never say Picture in Picture to the user) */
|
/* Cold-start / session errors (never say Picture in Picture to the user) */
|
||||||
"flow.coldStart.error.audioTimeout" = "The microphone did not become ready in time. It may be busy in another app. Please try again.";
|
"flow.coldStart.error.audioTimeout" = "The microphone did not become ready in time. It may be busy in another app. Please try again.";
|
||||||
|
|
||||||
|
/* Shown after a keyboard handoff when we cannot return automatically: points at iOS's top-left back control. */
|
||||||
|
"flow.returnTip.message" = "Tap back, start right away";
|
||||||
|
"flow.returnTip.arrowHint" = "Tap here to go back";
|
||||||
|
"flow.returnTip.a11y.hint" = "Double-tap to dismiss the tip.";
|
||||||
"flow.session.error.unsupported" = "This device cannot keep a voice session alive.";
|
"flow.session.error.unsupported" = "This device cannot keep a voice session alive.";
|
||||||
"flow.session.error.hostNotReady" = "The voice session is not ready yet. Stay in the app and try again.";
|
"flow.session.error.hostNotReady" = "The voice session is not ready yet. Stay in the app and try again.";
|
||||||
"flow.session.error.notPossible" = "The system cannot start the voice session right now. Keep the app in the foreground and try again.";
|
"flow.session.error.notPossible" = "The system cannot start the voice session right now. Keep the app in the foreground and try again.";
|
||||||
|
|||||||
@@ -855,6 +855,11 @@
|
|||||||
|
|
||||||
/* 冷启动 / 会话错误(用户可见文案不出现「画中画」) */
|
/* 冷启动 / 会话错误(用户可见文案不出现「画中画」) */
|
||||||
"flow.coldStart.error.audioTimeout" = "麦克风未能及时就绪,可能正被通话或其他 App 占用。请稍后重试。";
|
"flow.coldStart.error.audioTimeout" = "麦克风未能及时就绪,可能正被通话或其他 App 占用。请稍后重试。";
|
||||||
|
|
||||||
|
/* 键盘跳转后无法自动返回时的提示:指向 iOS 左上角的系统返回按钮 */
|
||||||
|
"flow.returnTip.message" = "点击返回,立即使用";
|
||||||
|
"flow.returnTip.arrowHint" = "点这里返回";
|
||||||
|
"flow.returnTip.a11y.hint" = "轻点关闭提示。";
|
||||||
"flow.session.error.unsupported" = "此设备无法维持语音会话。";
|
"flow.session.error.unsupported" = "此设备无法维持语音会话。";
|
||||||
"flow.session.error.hostNotReady" = "语音会话尚未就绪,请留在 App 内稍后重试。";
|
"flow.session.error.hostNotReady" = "语音会话尚未就绪,请留在 App 内稍后重试。";
|
||||||
"flow.session.error.notPossible" = "系统暂时无法启动语音会话,请保持 App 在前台后重试。";
|
"flow.session.error.notPossible" = "系统暂时无法启动语音会话,请保持 App 在前台后重试。";
|
||||||
|
|||||||
@@ -176,9 +176,9 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
public override func viewDidLoad() {
|
public override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
#if DEBUG
|
// Host identification (return-to-app after a cold-start handoff) runs in
|
||||||
HostBundleProbe.installLogging()
|
// every build; the handler also writes the DEBUG probe log.
|
||||||
#endif
|
HostAppIdentity.installCaptureHandler()
|
||||||
// Voice-first keyboard — hide the misleading "English" subtitle in Settings.
|
// Voice-first keyboard — hide the misleading "English" subtitle in Settings.
|
||||||
primaryLanguage = "mis"
|
primaryLanguage = "mis"
|
||||||
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
|
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
|
||||||
@@ -373,10 +373,12 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
lockPresentedKeyboardHeight()
|
lockPresentedKeyboardHeight()
|
||||||
refreshReturnKeyRole()
|
refreshReturnKeyRole()
|
||||||
scheduleTypingDocumentSynchronization()
|
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
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self, self.heightPhase == .presented else { return }
|
guard let self, self.heightPhase == .presented else { return }
|
||||||
self.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
|
self.flowCoordinator.armHostForVoiceSurfaceIfNeeded(reason: "keyboardOpened")
|
||||||
}
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
HostBundleProbe.recordPresentation()
|
HostBundleProbe.recordPresentation()
|
||||||
@@ -798,7 +800,13 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
|
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
|
||||||
state.cutSelection = { [weak self] in self?.textInserter.cutSelection() }
|
state.cutSelection = { [weak self] in self?.textInserter.cutSelection() }
|
||||||
state.setSurface = { [weak self] surface in
|
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()
|
stopHostReadyWait()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure the containing app has armed its low-profile PiP even when the
|
/// Demand-driven host arm: jump to the containing app only when the user is
|
||||||
/// keyboard opens directly into Pinyin/English typing mode. The mic stays
|
/// actually on a surface that needs it (the voice / assistant surface).
|
||||||
/// visually ready; if the host contract is missing, one automatic handoff
|
///
|
||||||
/// prepares PiP so the next press does not need another app switch.
|
/// 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
|
/// 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
|
/// the user returns from startflow and taps the Voice tab — height/appear
|
||||||
/// noise used to call this again while `ready` briefly lagged).
|
/// noise used to call this again while `ready` briefly lagged). The shared
|
||||||
func ensurePiPReadyOnKeyboardOpen() {
|
/// arm cooldown also keeps repeated tab flips from re-launching the app.
|
||||||
|
func armHostForVoiceSurfaceIfNeeded(reason: String) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
MemoryDeviceProbe.log("pip.ensure.entry")
|
MemoryDeviceProbe.log("pip.ensure.entry")
|
||||||
#endif
|
#endif
|
||||||
|
guard state.surface == .voice else {
|
||||||
|
traceState("hostArm.skipped", extra: "reason=\(reason) surface=\(state.surface.rawValue)")
|
||||||
|
return
|
||||||
|
}
|
||||||
guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch,
|
guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch,
|
||||||
voiceSetupReady,
|
voiceSetupReady,
|
||||||
hasFullAccess(),
|
hasFullAccess(),
|
||||||
@@ -196,7 +207,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
heartbeatStaleness: FlowSessionBridge.heartbeatStaleness()
|
heartbeatStaleness: FlowSessionBridge.heartbeatStaleness()
|
||||||
)
|
)
|
||||||
guard shouldArm else {
|
guard shouldArm else {
|
||||||
traceState("keyboardOpen.autoArmPiP.skipped", extra: "gate=0")
|
traceState("hostArm.skipped", extra: "reason=\(reason) gate=0")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +217,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
MemoryDeviceProbe.log("pip.arm.beginFlowStart")
|
MemoryDeviceProbe.log("pip.arm.beginFlowStart")
|
||||||
#endif
|
#endif
|
||||||
beginFlowStart(recordAfterHandoff: false)
|
beginFlowStart(recordAfterHandoff: false)
|
||||||
traceState("keyboardOpen.autoArmPiP")
|
traceState("hostArm.started", extra: "reason=\(reason)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshSessionState() {
|
func refreshSessionState() {
|
||||||
@@ -1138,10 +1149,34 @@ final class KeyboardFlowCoordinator {
|
|||||||
state.lastTranscript = ""
|
state.lastTranscript = ""
|
||||||
recomputeMicVoiceAvailability()
|
recomputeMicVoiceAvailability()
|
||||||
FlowSessionBridge.markPiPArmAttempt()
|
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()
|
HostAppIdentity.publishReturnTarget()
|
||||||
OSGDiag.log(
|
OSGDiag.log(
|
||||||
"beginFlowStart → openHostApp(startflow) recordAfterHandoff=\(recordAfterHandoff) "
|
"beginFlowStart → openHostApp(startflow) recordAfterHandoff=\(recordAfterHandoff) "
|
||||||
+ "\(OSGDiag.memoryTag())",
|
+ "hostKnown=\(HostAppIdentity.currentBundleId != nil) \(OSGDiag.memoryTag())",
|
||||||
category: "boot"
|
category: "boot"
|
||||||
)
|
)
|
||||||
openHostApp("startflow")
|
openHostApp("startflow")
|
||||||
|
|||||||
@@ -3,13 +3,26 @@
|
|||||||
//
|
//
|
||||||
// 识别当前宿主 App 的 bundle ID,供冷启动跳转主 App 后自动跳回。
|
// 识别当前宿主 App 的 bundle ID,供冷启动跳转主 App 后自动跳回。
|
||||||
// 依赖私有 arbiter 探针(`OSGHostArbiterProbe`),Debug / TestFlight / Release 均生效;
|
// 依赖私有 arbiter 探针(`OSGHostArbiterProbe`),Debug / TestFlight / Release 均生效;
|
||||||
// 探针装不上(系统 < 26.4、私有符号改名)时识别结果为 nil,退化为“不自动跳回”。
|
// 探针装不上(系统 < 26.4、私有符号改名)时识别结果为 nil,退化为“不自动跳回”,
|
||||||
|
// 主 App 随后会显示“点击左上角返回”的提示作为兜底。
|
||||||
// 调试日志与首页测试卡片仍只在 DEBUG 编译。
|
// 调试日志与首页测试卡片仍只在 DEBUG 编译。
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
|
||||||
enum HostAppIdentity {
|
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。
|
/// 最近一次识别到的宿主;识别不到、或宿主是 OSG 自己时为 nil。
|
||||||
static var currentBundleId: String? {
|
static var currentBundleId: String? {
|
||||||
guard let bundleId = OSGHostArbiterProbe.lastCapturedBundleId,
|
guard let bundleId = OSGHostArbiterProbe.lastCapturedBundleId,
|
||||||
@@ -19,16 +32,63 @@ enum HostAppIdentity {
|
|||||||
return bundleId
|
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。
|
/// 键盘弹出时主动让 arbiter 回调一次,避免宿主没变时拿不到最新 ID。
|
||||||
static func refreshOnPresentation() {
|
static func refreshOnPresentation() {
|
||||||
OSGHostArbiterProbe.pingArbiter()
|
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。
|
/// 识别不到时写 nil,清掉上一次残留,避免跳回错误的 App。
|
||||||
static func publishReturnTarget() {
|
static func publishReturnTarget() {
|
||||||
let bundleId = currentBundleId
|
let bundleId = currentBundleId
|
||||||
FlowSessionBridge.setPendingHostBundleId(bundleId)
|
FlowSessionBridge.setPendingHostBundleId(bundleId)
|
||||||
|
lateBindDeadline.withLock { $0 = Date().addingTimeInterval(lateBindWindow) }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let returnable = HostAppURLRegistry.lookup(bundleId: bundleId) != nil
|
let returnable = HostAppURLRegistry.lookup(bundleId: bundleId) != nil
|
||||||
HostBundleProbeLog.append(
|
HostBundleProbeLog.append(
|
||||||
@@ -42,19 +102,20 @@ enum HostAppIdentity {
|
|||||||
/// 键盘收起时作废识别结果,下次弹出由新回调重新填充。
|
/// 键盘收起时作废识别结果,下次弹出由新回调重新填充。
|
||||||
static func invalidate() {
|
static func invalidate() {
|
||||||
OSGHostArbiterProbe.invalidateCapturedBundleId()
|
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
|
#if DEBUG
|
||||||
/// 调试:把探针回调写进 App Group,主 App 首页的测试卡片读取展示。
|
/// 调试:键盘弹出时记一条标记,主 App 首页的测试卡片读取展示。
|
||||||
enum HostBundleProbe {
|
enum HostBundleProbe {
|
||||||
/// 只需安装一次;回调可能来自非主线程,闭包必须是 nonisolated。
|
|
||||||
static func installLogging() {
|
|
||||||
OSGHostArbiterProbe.setCaptureHandler { @Sendable bundleId, detail in
|
|
||||||
HostBundleProbeLog.append(event: "arbiter", bundleId: bundleId, detail: detail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 键盘弹出时记一条标记,便于把记录和每次弹出对应起来(ping 由 `HostAppIdentity` 负责)。
|
/// 键盘弹出时记一条标记,便于把记录和每次弹出对应起来(ping 由 `HostAppIdentity` 负责)。
|
||||||
static func recordPresentation() {
|
static func recordPresentation() {
|
||||||
HostBundleProbeLog.append(
|
HostBundleProbeLog.append(
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ typedef void (^OSGHostArbiterCaptureHandler)(NSString *_Nullable bundleId, NSStr
|
|||||||
/// 钩子是否已成功安装(私有类或方法不存在时为 NO)。
|
/// 钩子是否已成功安装(私有类或方法不存在时为 NO)。
|
||||||
@property (class, nonatomic, readonly) BOOL isInstalled;
|
@property (class, nonatomic, readonly) BOOL isInstalled;
|
||||||
|
|
||||||
/// 最近一次回调读到的宿主 bundle ID;回调读不到或已作废时为 nil。线程安全。
|
/// 最近一次回调读到的有效宿主 bundle ID;从未读到或已作废时为 nil。线程安全。
|
||||||
|
/// 读不到 ID 的空回调不会清掉它,只有 invalidateCapturedBundleId 会。
|
||||||
@property (class, nonatomic, readonly, nullable) NSString *lastCapturedBundleId;
|
@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";
|
NSString *detail = change ? NSStringFromClass([change class]) : @"change=nil";
|
||||||
|
|
||||||
os_unfair_lock_lock(&sLock);
|
os_unfair_lock_lock(&sLock);
|
||||||
// 读不到 ID 的回调同样覆盖旧值:宿主已经变了,旧 ID 不能再用。
|
// 只在读到有效 ID 时更新。读不到 ID 的回调(焦点切换、空事件)不再清掉已拿到的值:
|
||||||
sLastCapturedBundleId = bundleId.length > 0 ? bundleId : nil;
|
// 之前“最后一个事件为准”会让一次空回调把正确的宿主抹掉,是识别忽有忽无的来源之一。
|
||||||
|
// 宿主真的变了,由键盘收起时的 invalidateCapturedBundleId 作废旧值。
|
||||||
|
if (bundleId.length > 0) {
|
||||||
|
sLastCapturedBundleId = bundleId;
|
||||||
|
}
|
||||||
OSGHostArbiterCaptureHandler handler = sHandler;
|
OSGHostArbiterCaptureHandler handler = sHandler;
|
||||||
os_unfair_lock_unlock(&sLock);
|
os_unfair_lock_unlock(&sLock);
|
||||||
if (handler) {
|
if (handler) {
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ public enum FlowHandoffPolicy {
|
|||||||
/// capture, ASR and model warm-up remain lazy until an actual mic press.
|
/// capture, ASR and model warm-up remain lazy until an actual mic press.
|
||||||
public static let allowsProactiveHostAutoLaunch = true
|
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
|
/// 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.
|
/// from a mic-driven ready-wait recovery. Idle opens must never cold-start.
|
||||||
/// Mic press uses `shouldOpenHostColdStart` directly.
|
/// Mic press uses `shouldOpenHostColdStart` directly.
|
||||||
|
|||||||
@@ -144,15 +144,32 @@ extension FlowSessionBridge {
|
|||||||
let store = FlowSessionBridgeStorage.resolvedDefaults(defaults)
|
let store = FlowSessionBridgeStorage.resolvedDefaults(defaults)
|
||||||
if let bundleId, !bundleId.isEmpty {
|
if let bundleId, !bundleId.isEmpty {
|
||||||
store.set(bundleId, forKey: FlowSessionKeys.pendingHostBundleId)
|
store.set(bundleId, forKey: FlowSessionKeys.pendingHostBundleId)
|
||||||
|
store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.pendingHostBundleIdAt)
|
||||||
} else {
|
} else {
|
||||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||||
|
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleIdAt)
|
||||||
}
|
}
|
||||||
FlowSessionBridgeStorage.flush(store)
|
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)
|
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) {
|
public static func clearPendingHostBundleId(defaults: UserDefaults? = nil) {
|
||||||
@@ -426,6 +443,7 @@ extension FlowSessionBridge {
|
|||||||
clearTranscription(defaults: store)
|
clearTranscription(defaults: store)
|
||||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||||
|
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleIdAt)
|
||||||
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||||
clearHostReady(defaults: store, notify: false)
|
clearHostReady(defaults: store, notify: false)
|
||||||
clearHostHeavy(defaults: store)
|
clearHostHeavy(defaults: store)
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ public enum FlowSessionKeys {
|
|||||||
public static let audioLevels = "flow.audioLevels"
|
public static let audioLevels = "flow.audioLevels"
|
||||||
/// Bundle id of the app that opened `osgkeyboard://startflow` (scheme D).
|
/// Bundle id of the app that opened `osgkeyboard://startflow` (scheme D).
|
||||||
public static let pendingHostBundleId = "flow.pendingHostBundleId"
|
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).
|
/// Wall-clock of the last keyboard→`startflow` PiP arm attempt (debounce re-jumps).
|
||||||
public static let lastPiPArmAttemptAt = "flow.lastPiPArmAttemptAt.v1"
|
public static let lastPiPArmAttemptAt = "flow.lastPiPArmAttemptAt.v1"
|
||||||
/// Minimum gap between repeated proactive `startflow` jumps.
|
/// Minimum gap between repeated proactive `startflow` jumps.
|
||||||
|
|||||||
@@ -263,6 +263,16 @@ final class FlowHandoffPolicyTests: XCTestCase {
|
|||||||
XCTAssertTrue(FlowHandoffPolicy.allowsProactiveHostAutoLaunch)
|
XCTAssertTrue(FlowHandoffPolicy.allowsProactiveHostAutoLaunch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return-to-host retries: more than one chance (the first open can lose a
|
||||||
|
/// race), spaced forward in time, and bounded so the tip appears promptly.
|
||||||
|
func testHostReturnRetryDelaysAreMultipleIncreasingAndBounded() {
|
||||||
|
let delays = FlowHandoffPolicy.hostReturnRetryDelays
|
||||||
|
XCTAssertGreaterThan(delays.count, 1)
|
||||||
|
XCTAssertTrue(delays.allSatisfy { $0 > 0 })
|
||||||
|
XCTAssertEqual(delays, delays.sorted())
|
||||||
|
XCTAssertLessThanOrEqual(delays.reduce(0, +), 6, "tip must not wait out the late-bind window")
|
||||||
|
}
|
||||||
|
|
||||||
func testDebouncerIgnoresSingleDeadSample() {
|
func testDebouncerIgnoresSingleDeadSample() {
|
||||||
var debouncer = FlowColdStartDebouncer()
|
var debouncer = FlowColdStartDebouncer()
|
||||||
XCTAssertFalse(debouncer.observe(hostTrulyDead: true))
|
XCTAssertFalse(debouncer.observe(hostTrulyDead: true))
|
||||||
|
|||||||
@@ -52,4 +52,36 @@ final class FlowSessionPolicyTests: XCTestCase {
|
|||||||
FlowSessionBridge.clearPendingHostBundleId(defaults: defaults)
|
FlowSessionBridge.clearPendingHostBundleId(defaults: defaults)
|
||||||
XCTAssertNil(FlowSessionBridge.pendingHostBundleId(defaults: defaults))
|
XCTAssertNil(FlowSessionBridge.pendingHostBundleId(defaults: defaults))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPendingHostBundleIdExpiresAfterTTL() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
FlowSessionBridge.setPendingHostBundleId("com.tencent.xin", defaults: defaults)
|
||||||
|
|
||||||
|
let justInside = Date().addingTimeInterval(FlowSessionKeys.pendingHostBundleIdTTL - 5)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowSessionBridge.pendingHostBundleId(defaults: defaults, now: justInside),
|
||||||
|
"com.tencent.xin"
|
||||||
|
)
|
||||||
|
let pastTTL = Date().addingTimeInterval(FlowSessionKeys.pendingHostBundleIdTTL + 5)
|
||||||
|
XCTAssertNil(FlowSessionBridge.pendingHostBundleId(defaults: defaults, now: pastTTL))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPendingHostBundleIdWithoutTimestampStaysValid() {
|
||||||
|
// Written by an older build: no timestamp key. Must keep working.
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
defaults.set("com.tencent.xin", forKey: FlowSessionKeys.pendingHostBundleId)
|
||||||
|
|
||||||
|
let farFuture = Date().addingTimeInterval(FlowSessionKeys.pendingHostBundleIdTTL * 10)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FlowSessionBridge.pendingHostBundleId(defaults: defaults, now: farFuture),
|
||||||
|
"com.tencent.xin"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClearingPendingHostBundleIdAlsoClearsTimestamp() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
FlowSessionBridge.setPendingHostBundleId("com.tencent.xin", defaults: defaults)
|
||||||
|
FlowSessionBridge.clearPendingHostBundleId(defaults: defaults)
|
||||||
|
XCTAssertNil(defaults.object(forKey: FlowSessionKeys.pendingHostBundleIdAt))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user