Merge feat/host-handoff-on-demand into selfhost main
CI / Validate manifests (push) Canceled after 0s
CI / SwiftLint (push) Canceled after 0s
CI / iOS / Extension (push) Canceled after 0s
CI / macOS (push) Canceled after 0s

This commit is contained in:
Rocky
2026-10-29 16:33:16 +08:00
22 changed files with 459 additions and 68 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
### Version format ### Version format
The current source-of-truth version is **2.1.6 (build 131)**. Releases use stable SemVer: The current source-of-truth version is **2.1.6 (build 132)**. Releases use stable SemVer:
| Field | File | Rule | | Field | File | Rule |
|-------|------|------| |-------|------|------|
+9
View File
@@ -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
+77 -12
View File
@@ -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 →
+28 -21
View File
@@ -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
+9
View File
@@ -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)
+110
View File
@@ -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
}
}
+5
View File
@@ -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 在前台后重试。";
+14 -6
View File
@@ -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")
+71 -10
View File
@@ -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))
}
} }
+1 -1
View File
@@ -28,7 +28,7 @@ OSGKeyboard is an AI voice keyboard for iPhone, iPad, and Mac: say it and it's t
> The public Mac DMG is the signed and notarized historical version 1.1. The macOS target in this repository has moved forward with the 2.2 codebase; build with Xcode 26 for the latest source capabilities. > The public Mac DMG is the signed and notarized historical version 1.1. The macOS target in this repository has moved forward with the 2.2 codebase; build with Xcode 26 for the latest source capabilities.
> >
> The repository version follows [`project.yml`](./project.yml) (2.1.6, build 131). The `2.1.6` and later entries in CHANGELOG are already reflected in the capability descriptions below. > The repository version follows [`project.yml`](./project.yml) (2.1.6, build 132). The `2.1.6` and later entries in CHANGELOG are already reflected in the capability descriptions below.
## One keyboard, four ways to input ## One keyboard, four ways to input
+1 -1
View File
@@ -28,7 +28,7 @@ OSGKeyboard 是面向 iPhone、iPad 与 Mac 的 AI 语音键盘:开口即文
> 公开下载的 Mac DMG 是已签名并公证的历史版本 1.1。仓库中的 macOS target 随整体源码演进到 2.2;最新源码能力请用 Xcode 26 构建。 > 公开下载的 Mac DMG 是已签名并公证的历史版本 1.1。仓库中的 macOS target 随整体源码演进到 2.2;最新源码能力请用 Xcode 26 构建。
> >
> 当前仓库版本以 [`project.yml`](./project.yml) 为准(`2.1.6` · build 131)。CHANGELOG 中 `2.1.6` 及更新的条目已经包含在本 README 的能力描述中。 > 当前仓库版本以 [`project.yml`](./project.yml) 为准(`2.1.6` · build 132)。CHANGELOG 中 `2.1.6` 及更新的条目已经包含在本 README 的能力描述中。
## 一把键盘,四种输入方式 ## 一把键盘,四种输入方式
+1 -1
View File
@@ -2,7 +2,7 @@
> OSGKeyboard is an agentic keyboard and AI voice keyboard (Chinese: AI 语音输入法 / 嘴替输入法) for iPhone, iPad, and Mac. The iOS keyboard combines on-device dictation, a unified AI assistant, a learned personal style that sounds like you, agentic clipboard skills, Chinese and English typing, and optional managed credits. On Mac, hold Option for global dictation. It is source available, not open source. > OSGKeyboard is an agentic keyboard and AI voice keyboard (Chinese: AI 语音输入法 / 嘴替输入法) for iPhone, iPad, and Mac. The iOS keyboard combines on-device dictation, a unified AI assistant, a learned personal style that sounds like you, agentic clipboard skills, Chinese and English typing, and optional managed credits. On Mac, hold Option for global dictation. It is source available, not open source.
Current repository version: 2.1.6 (build 131). Later work is listed under CHANGELOG [Unreleased]. Current repository version: 2.1.6 (build 132). Later work is listed under CHANGELOG [Unreleased].
## Category definitions ## Category definitions
+1 -1
View File
@@ -49,7 +49,7 @@ settings:
STRING_CATALOG_GENERATE_SYMBOLS: YES STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17 CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "2.1.6" MARKETING_VERSION: "2.1.6"
CURRENT_PROJECT_VERSION: "131" CURRENT_PROJECT_VERSION: "132"
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖) # 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target # 项目级签名 xcconfig,适用于所有 target