From e09ad344371dddb291f8dc706a39f97348802893 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:32:52 +0800 Subject: [PATCH] feat(flow): on-demand host handoff with reliable return and a back-tap tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 9 ++ OSGKeyboard/Services/FlowSessionManager.swift | 89 ++++++++++++-- OSGKeyboard/Services/HostReturnService.swift | 49 ++++---- OSGKeyboard/Views/MainAppRoot.swift | 9 ++ OSGKeyboard/Views/ReturnTipOverlay.swift | 110 ++++++++++++++++++ OSGKeyboard/en.lproj/Localizable.strings | 5 + OSGKeyboard/zh-Hans.lproj/Localizable.strings | 5 + OSGKeyboardExt/KeyboardViewController.swift | 20 +++- .../Services/KeyboardFlowCoordinator.swift | 53 +++++++-- .../Utilities/HostBundleProbe.swift | 81 +++++++++++-- .../Utilities/OSGHostArbiterProbe.h | 3 +- .../Utilities/OSGHostArbiterProbe.m | 8 +- .../Models/FlowHandoffPolicy.swift | 7 ++ .../FlowSessionBridge+Lifecycle.swift | 22 +++- .../Services/FlowSessionKeys.swift | 5 + OSGKeyboardTests/FlowHandoffPolicyTests.swift | 10 ++ OSGKeyboardTests/FlowSessionPolicyTests.swift | 32 +++++ 17 files changed, 454 insertions(+), 63 deletions(-) create mode 100644 OSGKeyboard/Views/ReturnTipOverlay.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 981c35f..01596ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 +- **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。 ## [2.1.6] - 2026-10-27 diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index 4e05759..9f5b0ce 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -259,6 +259,10 @@ final class FlowSessionManager: ObservableObject { /// Throttles AI-mode LLM draft writes into App Group. private var aiAnswerStreamThrottle = AIAnswerStreamThrottle() /// 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 /// True from `stopped` until the result/error is written back to App Group. @Published private(set) var isUtteranceProcessing = false @@ -286,6 +290,8 @@ final class FlowSessionManager: ObservableObject { private var isAppForeground = false /// True while handling a keyboard-initiated `startflow` cold start. private var isColdStartHandoff = false + /// Drives the retrying "return to the previous app" attempts after a handoff. + private var autoReturnTask: Task? /// User intent outlives a transient PiP loss; only explicit teardown clears it. private var wantsActiveSession = false /// Invalidates late async completions after retry, stop, or process teardown. @@ -487,7 +493,15 @@ final class FlowSessionManager: ObservableObject { if isActive { endSession() } 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() + if let pendingHost { + FlowSessionBridge.setPendingHostBundleId(pendingHost) + } } debug("reconciled zombie persisted Flow state") case .clearOrphanedRecording(let orphaned): @@ -607,6 +621,8 @@ final class FlowSessionManager: ObservableObject { pipRecoveryOperation &+= 1 pipLifecycleState = .inactive isColdStartHandoff = false + autoReturnTask?.cancel() + autoReturnTask = nil startTask?.cancel() startTask = nil startupAudioHealthTask?.cancel() @@ -693,6 +709,8 @@ final class FlowSessionManager: ObservableObject { pipRecoveryOperation &+= 1 pipLifecycleState = .inactive isColdStartHandoff = false + autoReturnTask?.cancel() + autoReturnTask = nil startTask?.cancel() startTask = nil startupAudioHealthTask?.cancel() @@ -785,6 +803,11 @@ final class FlowSessionManager: ObservableObject { } else if wantsActiveSession { 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 { completeColdStartHandoff() } @@ -1511,27 +1534,69 @@ final class FlowSessionManager: ObservableObject { return } - let hostEntry = HostReturnService.pendingHostEntry() - guard hostEntry != nil else { + // Honor the user's "return to the previous app" preference. When it is + // 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() return } - scheduleAutoReturnToHostIfNeeded(hostEntry: hostEntry) + scheduleAutoReturnToHost() } - private func scheduleAutoReturnToHostIfNeeded(hostEntry: HostAppEntry?) { - let skipSwitch = true - guard skipSwitch, hostEntry != nil else { return } - Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 450_000_000) - guard let self, self.isColdStartHandoff, self.isActive, - FlowSessionBridge.isHostReady() else { return } - if HostReturnService.openPendingHostIfPossible() { - self.completeColdStartHandoff() + /// Tries to return to the previous app, retrying a few times. The host id the + /// keyboard recorded can arrive a moment after the session is ready, and the + /// first `open` can lose a race with PiP settling — one shot was brittle. + /// + /// The tip goes up as soon as we know there is nowhere to return to, and also + /// once every attempt has failed. If a later attempt does succeed, the app + /// moves to the background and the tip is cleared with it. + private func scheduleAutoReturnToHost() { + 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 /// of passively waiting for frames that a dead engine will never produce. /// Escalates per attempt: reassert the session → full engine rebuild → diff --git a/OSGKeyboard/Services/HostReturnService.swift b/OSGKeyboard/Services/HostReturnService.swift index 4c9de48..7c3fab8 100644 --- a/OSGKeyboard/Services/HostReturnService.swift +++ b/OSGKeyboard/Services/HostReturnService.swift @@ -11,35 +11,42 @@ import OSGKeyboardShared import UIKit 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 - static func openPendingHostIfPossible() -> Bool { + static func openPendingHost() async -> Outcome { + FlowSessionBridge.reloadFromDisk() let bundleId = FlowSessionBridge.pendingHostBundleId() guard let entry = HostAppURLRegistry.lookup(bundleId: bundleId), let url = entry.returnURL else { - logReturn(bundleId: bundleId, outcome: "no-entry") - return false + logReturn(bundleId: bundleId, outcome: Outcome.noTarget.rawValue) + return .noTarget } - UIApplication.shared.open(url, options: [:]) { success in - logReturn(bundleId: bundleId, outcome: success ? "opened" : "open-failed") - if success { - FlowSessionBridge.clearPendingHostBundleId() + let success = await withCheckedContinuation { (continuation: CheckedContinuation) in + UIApplication.shared.open(url, options: [:]) { opened in + continuation.resume(returning: opened) } } - return true - } - - @MainActor - static func openHost(entry: HostAppEntry) -> Bool { - guard let url = entry.returnURL else { - return false + let outcome: Outcome = success ? .opened : .openFailed + logReturn(bundleId: bundleId, outcome: outcome.rawValue) + if success { + FlowSessionBridge.clearPendingHostBundleId() } - UIApplication.shared.open(url, options: [:]) { success in - if success { - FlowSessionBridge.clearPendingHostBundleId() - } - } - return true + return outcome } @MainActor diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift index f2b3348..0a1e5a6 100644 --- a/OSGKeyboard/Views/MainAppRoot.swift +++ b/OSGKeyboard/Views/MainAppRoot.swift @@ -88,7 +88,16 @@ struct MainAppRoot: View { palette.background .ignoresSafeArea() 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) .environmentObject(flowManager) .environmentObject(accountSession) diff --git a/OSGKeyboard/Views/ReturnTipOverlay.swift b/OSGKeyboard/Views/ReturnTipOverlay.swift new file mode 100644 index 0000000..2659382 --- /dev/null +++ b/OSGKeyboard/Views/ReturnTipOverlay.swift @@ -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 "◀ " 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 + } +} diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 8082994..ad8520d 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -855,6 +855,11 @@ /* 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."; + +/* 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.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."; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 3a89cd8..c52ea82 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -855,6 +855,11 @@ /* 冷启动 / 会话错误(用户可见文案不出现「画中画」) */ "flow.coldStart.error.audioTimeout" = "麦克风未能及时就绪,可能正被通话或其他 App 占用。请稍后重试。"; + +/* 键盘跳转后无法自动返回时的提示:指向 iOS 左上角的系统返回按钮 */ +"flow.returnTip.message" = "点击返回,立即使用"; +"flow.returnTip.arrowHint" = "点这里返回"; +"flow.returnTip.a11y.hint" = "轻点关闭提示。"; "flow.session.error.unsupported" = "此设备无法维持语音会话。"; "flow.session.error.hostNotReady" = "语音会话尚未就绪,请留在 App 内稍后重试。"; "flow.session.error.notPossible" = "系统暂时无法启动语音会话,请保持 App 在前台后重试。"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index f9d070f..4f4b982 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -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") } } diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift index e17f280..d39d02c 100644 --- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift +++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift @@ -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") diff --git a/OSGKeyboardExt/Utilities/HostBundleProbe.swift b/OSGKeyboardExt/Utilities/HostBundleProbe.swift index 0804bbc..09f6cc5 100644 --- a/OSGKeyboardExt/Utilities/HostBundleProbe.swift +++ b/OSGKeyboardExt/Utilities/HostBundleProbe.swift @@ -3,13 +3,26 @@ // // 识别当前宿主 App 的 bundle ID,供冷启动跳转主 App 后自动跳回。 // 依赖私有 arbiter 探针(`OSGHostArbiterProbe`),Debug / TestFlight / Release 均生效; -// 探针装不上(系统 < 26.4、私有符号改名)时识别结果为 nil,退化为“不自动跳回”。 +// 探针装不上(系统 < 26.4、私有符号改名)时识别结果为 nil,退化为“不自动跳回”, +// 主 App 随后会显示“点击左上角返回”的提示作为兜底。 // 调试日志与首页测试卡片仍只在 DEBUG 编译。 import Foundation +import os import OSGKeyboardShared enum HostAppIdentity { + /// 跳转前最多等多久让 arbiter 回调送达。回调通常在毫秒级到达, + /// 0.4 秒既覆盖慢回调,又不会让用户感觉“点了没反应”。 + static let handoffWaitTimeout: TimeInterval = 0.4 + + /// 跳转之后,迟到的回调仍可补写返回目标的窗口。 + /// 覆盖主 App 冷启动到“就绪”的这段时间;就绪之后才读目标,所以迟到也来得及。 + static let lateBindWindow: TimeInterval = 6 + + /// 晚绑定窗口的截止时间;回调可能在任意线程,用锁保护。 + private static let lateBindDeadline = OSAllocatedUnfairLock(initialState: nil) + /// 最近一次识别到的宿主;识别不到、或宿主是 OSG 自己时为 nil。 static var currentBundleId: String? { guard let bundleId = OSGHostArbiterProbe.lastCapturedBundleId, @@ -19,16 +32,63 @@ enum HostAppIdentity { 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。 static func refreshOnPresentation() { 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。 static func publishReturnTarget() { let bundleId = currentBundleId FlowSessionBridge.setPendingHostBundleId(bundleId) + lateBindDeadline.withLock { $0 = Date().addingTimeInterval(lateBindWindow) } #if DEBUG let returnable = HostAppURLRegistry.lookup(bundleId: bundleId) != nil HostBundleProbeLog.append( @@ -42,19 +102,20 @@ enum HostAppIdentity { /// 键盘收起时作废识别结果,下次弹出由新回调重新填充。 static func invalidate() { 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 -/// 调试:把探针回调写进 App Group,主 App 首页的测试卡片读取展示。 +/// 调试:键盘弹出时记一条标记,主 App 首页的测试卡片读取展示。 enum HostBundleProbe { - /// 只需安装一次;回调可能来自非主线程,闭包必须是 nonisolated。 - static func installLogging() { - OSGHostArbiterProbe.setCaptureHandler { @Sendable bundleId, detail in - HostBundleProbeLog.append(event: "arbiter", bundleId: bundleId, detail: detail) - } - } - /// 键盘弹出时记一条标记,便于把记录和每次弹出对应起来(ping 由 `HostAppIdentity` 负责)。 static func recordPresentation() { HostBundleProbeLog.append( diff --git a/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.h b/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.h index c37fa4d..4eb89eb 100644 --- a/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.h +++ b/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.h @@ -19,7 +19,8 @@ typedef void (^OSGHostArbiterCaptureHandler)(NSString *_Nullable bundleId, NSStr /// 钩子是否已成功安装(私有类或方法不存在时为 NO)。 @property (class, nonatomic, readonly) BOOL isInstalled; -/// 最近一次回调读到的宿主 bundle ID;回调读不到或已作废时为 nil。线程安全。 +/// 最近一次回调读到的有效宿主 bundle ID;从未读到或已作废时为 nil。线程安全。 +/// 读不到 ID 的空回调不会清掉它,只有 invalidateCapturedBundleId 会。 @property (class, nonatomic, readonly, nullable) NSString *lastCapturedBundleId; /// 作废上一次的识别结果(键盘收起时调用,避免把上一个宿主当成当前宿主)。 diff --git a/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.m b/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.m index 7dfef6d..a07d000 100644 --- a/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.m +++ b/OSGKeyboardExt/Utilities/OSGHostArbiterProbe.m @@ -44,8 +44,12 @@ static void OSGSwizzledKeyboardChanged(id self, SEL _cmd, id change, id completi NSString *detail = change ? NSStringFromClass([change class]) : @"change=nil"; os_unfair_lock_lock(&sLock); - // 读不到 ID 的回调同样覆盖旧值:宿主已经变了,旧 ID 不能再用。 - sLastCapturedBundleId = bundleId.length > 0 ? bundleId : nil; + // 只在读到有效 ID 时更新。读不到 ID 的回调(焦点切换、空事件)不再清掉已拿到的值: + // 之前“最后一个事件为准”会让一次空回调把正确的宿主抹掉,是识别忽有忽无的来源之一。 + // 宿主真的变了,由键盘收起时的 invalidateCapturedBundleId 作废旧值。 + if (bundleId.length > 0) { + sLastCapturedBundleId = bundleId; + } OSGHostArbiterCaptureHandler handler = sHandler; os_unfair_lock_unlock(&sLock); if (handler) { diff --git a/OSGKeyboardShared/Models/FlowHandoffPolicy.swift b/OSGKeyboardShared/Models/FlowHandoffPolicy.swift index 4b90262..b4bc02f 100644 --- a/OSGKeyboardShared/Models/FlowHandoffPolicy.swift +++ b/OSGKeyboardShared/Models/FlowHandoffPolicy.swift @@ -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. diff --git a/OSGKeyboardShared/Services/FlowSessionBridge/FlowSessionBridge+Lifecycle.swift b/OSGKeyboardShared/Services/FlowSessionBridge/FlowSessionBridge+Lifecycle.swift index babfa10..0d9e091 100644 --- a/OSGKeyboardShared/Services/FlowSessionBridge/FlowSessionBridge+Lifecycle.swift +++ b/OSGKeyboardShared/Services/FlowSessionBridge/FlowSessionBridge+Lifecycle.swift @@ -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) diff --git a/OSGKeyboardShared/Services/FlowSessionKeys.swift b/OSGKeyboardShared/Services/FlowSessionKeys.swift index b980bfc..50d04fc 100644 --- a/OSGKeyboardShared/Services/FlowSessionKeys.swift +++ b/OSGKeyboardShared/Services/FlowSessionKeys.swift @@ -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. diff --git a/OSGKeyboardTests/FlowHandoffPolicyTests.swift b/OSGKeyboardTests/FlowHandoffPolicyTests.swift index fbddc75..34ea91b 100644 --- a/OSGKeyboardTests/FlowHandoffPolicyTests.swift +++ b/OSGKeyboardTests/FlowHandoffPolicyTests.swift @@ -263,6 +263,16 @@ final class FlowHandoffPolicyTests: XCTestCase { 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() { var debouncer = FlowColdStartDebouncer() XCTAssertFalse(debouncer.observe(hostTrulyDead: true)) diff --git a/OSGKeyboardTests/FlowSessionPolicyTests.swift b/OSGKeyboardTests/FlowSessionPolicyTests.swift index 3b81a6d..9a52cea 100644 --- a/OSGKeyboardTests/FlowSessionPolicyTests.swift +++ b/OSGKeyboardTests/FlowSessionPolicyTests.swift @@ -52,4 +52,36 @@ final class FlowSessionPolicyTests: XCTestCase { FlowSessionBridge.clearPendingHostBundleId(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)) + } }