diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5e29179..c41d914 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,8 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Flow Live Activity**: Dynamic Island shows the OSGKeyboard brand mark during an active voice session (ActivityKit widget extension). / **Flow 灵动岛 Live Activity**:语音会话期间在灵动岛显示 OSGKeyboard 品牌标识(ActivityKit 小组件扩展)。
- **Xiaomi MiMo cloud provider**: preset for the cloud engine with `mimo-v2.5` polish via `api.xiaomimimo.com` (on-device ASR, same pipeline as other online providers). / **小米 MiMo 云端引擎**:云端引擎新增预设,经 `api.xiaomimimo.com` 使用 `mimo-v2.5` 润色(端侧 ASR,与其他在线服务相同管线)。
+- **Flow session policy (A)**: on-demand session start from the keyboard; inactivity-based expiry (10m–24h, default 12h) reset after each utterance; handoff auto-starts recording. / **Flow 会话策略(A)**:键盘按需启动会话;无活动超时(10 分钟–24 小时,默认 12 小时)每句结束后重置;交接完成后自动开始录音。
+- **Skip app switch (B+C)**: settings toggle (default on) plus cold-start overlay with swipe-back guidance and optional “Return to [App]” alert. / **跳过应用切换(B+C)**:设置开关(默认开)+ 冷启动极简页(右滑引导 + 可选「返回 [App]」弹窗)。
+- **Host return whitelist (C+D)**: `HostAppURLRegistry` with 20 high-frequency host apps; `sourceApplication` capture on `startflow`. / **宿主回跳白名单(C+D)**:`HostAppURLRegistry` 覆盖 20 个高频宿主 App;`startflow` 时记录 `sourceApplication`。
### Changed
+- **Keyboard language label**: `PrimaryLanguage` set to `mis` so Settings no longer shows a misleading “English” subtitle under OSGKeyboard. / **键盘语言标签**:`PrimaryLanguage` 设为 `mis`,系统设置中 OSGKeyboard 下不再显示误导性的「英文」副标题。
- **Flow ASR pipelining**: shorter first chunk (2.5s) and 5s follow-ups so short utterances start on-device recognition while still recording; session-level ASR warmup and format cache reuse; live partials mirrored to the keyboard transcript line. / **Flow ASR 流水线**:首块 2.5 秒、后续 5 秒,短句录音期间即开始端侧识别;会话级 ASR 预热与格式缓存复用;实时 partial 同步到键盘转写行。
## [0.4.0] - 2026-07-05
diff --git a/OSGKeyboard/Info.plist b/OSGKeyboard/Info.plist
index c16f9f1..40b99ff 100644
--- a/OSGKeyboard/Info.plist
+++ b/OSGKeyboard/Info.plist
@@ -34,6 +34,30 @@
+ LSApplicationQueriesSchemes
+
+ weixin
+ mqq
+ wxwork
+ dingtalk
+ lark
+ tg
+ whatsapp
+ line
+ fb-messenger
+ slack
+ msteams
+ discord
+ notion
+ bear
+ obsidian
+ drafts5
+ googlegmail
+ ms-outlook
+ googlechrome
+ sinaweibo
+ xhsdiscover
+
CFBundleVersion
$(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption
diff --git a/OSGKeyboard/OSGKeyboardApp.swift b/OSGKeyboard/OSGKeyboardApp.swift
index ee83ca1..c8521bc 100644
--- a/OSGKeyboard/OSGKeyboardApp.swift
+++ b/OSGKeyboard/OSGKeyboardApp.swift
@@ -6,6 +6,8 @@ import OSGKeyboardShared
@main
struct OSGKeyboardApp: App {
+ @UIApplicationDelegateAdaptor(AppURLHandler.self) private var appURLHandler
+
init() {
MaterialIconsFont.registerIfNeeded()
if AppGroup.isAvailable {
diff --git a/OSGKeyboard/Services/AppURLHandler.swift b/OSGKeyboard/Services/AppURLHandler.swift
new file mode 100644
index 0000000..7848f2d
--- /dev/null
+++ b/OSGKeyboard/Services/AppURLHandler.swift
@@ -0,0 +1,29 @@
+// AppURLHandler.swift
+// OSGKeyboard · Main App
+//
+// Captures `sourceApplication` from UIKit open-URL options (scheme D).
+
+import UIKit
+import OSGKeyboardShared
+
+extension Notification.Name {
+ static let osgKeyboardOpenURL = Notification.Name("osgkeyboard.openURL")
+}
+
+final class AppURLHandler: NSObject, UIApplicationDelegate {
+ func application(
+ _ application: UIApplication,
+ open url: URL,
+ options: [UIApplication.OpenURLOptionsKey: Any] = [:]
+ ) -> Bool {
+ if let source = options[.sourceApplication] as? String {
+ FlowSessionBridge.setPendingHostBundleId(source)
+ }
+ NotificationCenter.default.post(
+ name: .osgKeyboardOpenURL,
+ object: nil,
+ userInfo: ["url": url]
+ )
+ return true
+ }
+}
diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift
index cb52b7a..c2862e9 100644
--- a/OSGKeyboard/Services/FlowSessionManager.swift
+++ b/OSGKeyboard/Services/FlowSessionManager.swift
@@ -19,6 +19,8 @@ final class FlowSessionManager: ObservableObject {
@Published private(set) var sessionExpiresAt: Date?
/// Non-nil when continuous capture failed or permissions are missing.
@Published private(set) var sessionWarning: String?
+ /// Cold-start handoff overlay state (scheme B).
+ @Published var coldStartContext: FlowColdStartContext?
private let capture = FlowContinuousCapture()
private let store = AppGroupStore()
@@ -61,6 +63,8 @@ final class FlowSessionManager: ObservableObject {
/// True while the host app scene is `.active` — drives foreground renewal.
private var isAppForeground = false
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
+ /// True while handling a keyboard-initiated `startflow` cold start.
+ private var isColdStartHandoff = false
init() {
Task { @MainActor [weak self] in
@@ -71,42 +75,65 @@ final class FlowSessionManager: ObservableObject {
// MARK: - Public
/// Starts a Flow session: permissions → continuous capture → App Group active.
- func startSession(duration: TimeInterval = FlowSessionKeys.defaultSessionDuration) {
+ func startSession(duration: TimeInterval? = nil, coldStart: Bool = false) {
guard AppGroup.isAvailable else {
debug("cannot start flow session: App Group unavailable")
return
}
+ if coldStart {
+ isColdStartHandoff = true
+ }
+
if isActive {
extendSession(duration: duration)
+ if coldStart {
+ Task { @MainActor [weak self] in
+ self?.handleColdStartAfterSessionReady()
+ }
+ }
return
}
startTask?.cancel()
startTask = Task { @MainActor [weak self] in
await self?.startSessionAsync(duration: duration)
+ self?.handleColdStartAfterSessionReady()
}
}
- /// Called on launch / foreground when onboarding is complete.
- func autoStartIfNeeded() {
+ /// Restores an in-flight session after relaunch; does not auto-start a new one.
+ func restoreSessionIfNeeded() {
guard AppGroup.isAvailable else { return }
guard !isActive, !isStarting else { return }
- guard AppPermissions.flowRequirementsMet else {
- sessionWarning = permissionWarningMessage()
- return
- }
-
let markedActive = AppGroup.defaults.bool(forKey: FlowSessionKeys.flowSessionActive)
if markedActive, FlowSessionBridge.remainingSessionDuration() != nil {
Task { await bootstrapFromStorageIfNeeded() }
+ }
+ }
+
+ /// One-shot warmup after onboarding when permissions are already granted.
+ func warmupAfterOnboardingIfNeeded() {
+ guard AppGroup.isAvailable else { return }
+ guard !isActive, !isStarting else { return }
+ guard AppPermissions.flowRequirementsMet else {
+ sessionWarning = permissionWarningMessage()
return
}
-
startSession()
}
+ func dismissColdStartOverlay() {
+ coldStartContext = nil
+ isColdStartHandoff = false
+ }
+
+ func returnToPendingHostFromColdStart() {
+ _ = HostReturnService.openPendingHostIfPossible()
+ dismissColdStartOverlay()
+ }
+
/// Reattach capture when the host app was killed but the session has not expired.
func bootstrapFromStorageIfNeeded() async {
guard AppGroup.isAvailable, !isActive else { return }
@@ -165,6 +192,7 @@ final class FlowSessionManager: ObservableObject {
guard isActive else { return }
debug("Flow session ended")
+ dismissColdStartOverlay()
startTask?.cancel()
startTask = nil
pollingTask?.cancel()
@@ -205,18 +233,16 @@ final class FlowSessionManager: ObservableObject {
lastFinal = ""
}
- func extendSession(duration: TimeInterval = FlowSessionKeys.defaultSessionDuration) {
- FlowSessionBridge.extendSession(by: duration)
- sessionExpiresAt = Date().addingTimeInterval(duration)
- scheduleExpiry(after: duration)
+ func extendSession(duration: TimeInterval? = nil) {
+ let resolved = duration ?? FlowSessionPolicy.sessionDuration()
+ FlowSessionBridge.extendSession(by: resolved)
+ sessionExpiresAt = Date().addingTimeInterval(resolved)
+ scheduleExpiry(after: resolved)
}
/// Called from `OSGKeyboardApp` when `scenePhase` changes.
func setAppForeground(_ foreground: Bool) {
isAppForeground = foreground
- if foreground, isActive {
- renewSessionIfNeededWhileForeground()
- }
}
/// Full scene lifecycle — keeps Flow + ASR alive across app switches.
@@ -294,25 +320,29 @@ final class FlowSessionManager: ObservableObject {
}
}
- /// Extend the session before it expires while the host app stays in foreground.
- private func renewSessionIfNeededWhileForeground() {
- guard isActive, isAppForeground else { return }
- guard let remaining = FlowSessionBridge.remainingSessionDuration() else { return }
- let threshold = FlowSessionKeys.defaultSessionDuration * 0.25
- guard remaining < threshold else { return }
- extendSession()
- debug("Flow session renewed in foreground (\(Int(threshold))s threshold)")
+ /// Extend expiry after utterance completion based on the inactivity policy.
+ private func touchSessionActivity() {
+ guard isActive else { return }
+ FlowSessionBridge.touchLastActivity()
+ if let expires = FlowSessionBridge.sessionExpiresAt() {
+ sessionExpiresAt = Date(timeIntervalSince1970: expires)
+ let remaining = expires - Date().timeIntervalSince1970
+ if remaining > 0 {
+ scheduleExpiry(after: remaining)
+ }
+ }
}
// MARK: - Session start
- private func startSessionAsync(duration: TimeInterval) async {
+ private func startSessionAsync(duration: TimeInterval?) async {
isStarting = true
sessionWarning = nil
defer { isStarting = false }
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
+ isColdStartHandoff = false
return
}
@@ -321,29 +351,46 @@ final class FlowSessionManager: ObservableObject {
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
sessionWarning = message
+ isColdStartHandoff = false
debug("continuous capture failed: \(message)")
return
}
- FlowSessionBridge.markSessionActive(duration: duration)
+ let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration()
+ FlowSessionBridge.markSessionActive(duration: resolvedDuration)
FlowSessionDarwin.postSessionChanged()
isActive = true
ScreenWakeLock.acquire()
- sessionExpiresAt = Date().addingTimeInterval(duration)
+ sessionExpiresAt = Date().addingTimeInterval(resolvedDuration)
startHeartbeat()
startPolling()
startLevelPublishing()
- scheduleExpiry(after: duration)
+ scheduleExpiry(after: resolvedDuration)
- // v0.2.0: iOS `SpeechAnalyzer` needs no warm-up; just refresh
- // the cached ASR service in case the user flipped engines
- // while the session was idle.
bindSessionASRIfNeeded()
scheduleASRWarmup()
FlowLiveActivityController.startSession()
- debug("Flow session started (\(Int(duration))s), continuous capture running")
+ debug("Flow session started (\(Int(resolvedDuration))s inactivity window), continuous capture running")
+ }
+
+ @MainActor
+ private func handleColdStartAfterSessionReady() {
+ guard isColdStartHandoff, isActive else { return }
+
+ let hostEntry = HostReturnService.pendingHostEntry()
+ let skipSwitch = FlowSessionPolicy.skipAppSwitch()
+
+ if skipSwitch, hostEntry != nil, HostReturnService.openPendingHostIfPossible() {
+ dismissColdStartOverlay()
+ return
+ }
+
+ coldStartContext = FlowColdStartContext(
+ hostEntry: hostEntry,
+ showReturnAlert: hostEntry != nil
+ )
}
private func bindSessionASRIfNeeded(force: Bool = false) {
@@ -575,6 +622,7 @@ final class FlowSessionManager: ObservableObject {
isUtteranceProcessing = false
FlowSessionBridge.setRecordingState(.idle)
FlowLiveActivityController.update(phase: .idle)
+ touchSessionActivity()
}
let asrWait = asrWaitTimeout()
@@ -740,7 +788,6 @@ final class FlowSessionManager: ObservableObject {
heartbeatTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
FlowSessionBridge.writeHeartbeat()
- self?.renewSessionIfNeededWhileForeground()
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard self?.isActive == true else { break }
}
diff --git a/OSGKeyboard/Services/HostReturnService.swift b/OSGKeyboard/Services/HostReturnService.swift
new file mode 100644
index 0000000..29da01c
--- /dev/null
+++ b/OSGKeyboard/Services/HostReturnService.swift
@@ -0,0 +1,46 @@
+// HostReturnService.swift
+// OSGKeyboard · Main App
+//
+// Opens a whitelisted host-app URL after a cold-start Flow handoff.
+
+import UIKit
+import OSGKeyboardShared
+
+enum HostReturnService {
+ /// Attempts to return to the pending host app. Clears the pending bundle id on success.
+ @MainActor
+ static func openPendingHostIfPossible() -> Bool {
+ let bundleId = FlowSessionBridge.pendingHostBundleId()
+ guard let entry = HostAppURLRegistry.lookup(bundleId: bundleId),
+ let url = entry.returnURL else {
+ return false
+ }
+ guard UIApplication.shared.canOpenURL(url) else {
+ return false
+ }
+ UIApplication.shared.open(url, options: [:]) { success in
+ if success {
+ FlowSessionBridge.clearPendingHostBundleId()
+ }
+ }
+ return true
+ }
+
+ @MainActor
+ static func openHost(entry: HostAppEntry) -> Bool {
+ guard let url = entry.returnURL, UIApplication.shared.canOpenURL(url) else {
+ return false
+ }
+ UIApplication.shared.open(url, options: [:]) { success in
+ if success {
+ FlowSessionBridge.clearPendingHostBundleId()
+ }
+ }
+ return true
+ }
+
+ @MainActor
+ static func pendingHostEntry() -> HostAppEntry? {
+ HostAppURLRegistry.lookup(bundleId: FlowSessionBridge.pendingHostBundleId())
+ }
+}
diff --git a/OSGKeyboard/Views/FlowColdStartOverlay.swift b/OSGKeyboard/Views/FlowColdStartOverlay.swift
new file mode 100644
index 0000000..1e9ea77
--- /dev/null
+++ b/OSGKeyboard/Views/FlowColdStartOverlay.swift
@@ -0,0 +1,111 @@
+// FlowColdStartOverlay.swift
+// OSGKeyboard · Main App
+//
+// Minimal cold-start handoff UI: swipe-back guidance and optional return alert.
+
+import SwiftUI
+import OSGKeyboardShared
+
+struct FlowColdStartContext: Equatable {
+ let hostEntry: HostAppEntry?
+ var showReturnAlert: Bool
+}
+
+struct FlowColdStartOverlay: View {
+ @Environment(\.themePalette) private var palette: ThemePalette
+
+ let context: FlowColdStartContext
+ let onReturnToHost: () -> Void
+ let onDismiss: () -> Void
+
+ @State private var showAlert: Bool
+
+ init(
+ context: FlowColdStartContext,
+ onReturnToHost: @escaping () -> Void,
+ onDismiss: @escaping () -> Void
+ ) {
+ self.context = context
+ self.onReturnToHost = onReturnToHost
+ self.onDismiss = onDismiss
+ _showAlert = State(initialValue: context.showReturnAlert)
+ }
+
+ var body: some View {
+ ZStack {
+ palette.background.opacity(0.96)
+ .ignoresSafeArea()
+
+ VStack(spacing: Spacing.xl) {
+ Image(systemName: "waveform.circle.fill")
+ .font(.system(size: 56))
+ .foregroundStyle(palette.accent)
+ .accessibilityHidden(true)
+
+ Text("flow.coldStart.title")
+ .font(TypeStyle.title3)
+ .foregroundStyle(palette.textPrimary)
+ .multilineTextAlignment(.center)
+
+ Text(swipeHintKey)
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textSecondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, Spacing.lg)
+
+ swipeHintAnimation
+ .padding(.top, Spacing.md)
+
+ Button(action: onDismiss) {
+ Text("flow.coldStart.dismiss")
+ .font(TypeStyle.body.weight(.semibold))
+ .foregroundStyle(palette.accent)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, Spacing.md)
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, Spacing.xl)
+ .padding(.top, Spacing.lg)
+ }
+ .padding(Spacing.xl)
+ }
+ .alert(alertTitle, isPresented: $showAlert) {
+ if context.hostEntry != nil {
+ Button(returnButtonTitle, action: onReturnToHost)
+ }
+ Button("flow.coldStart.dismiss", role: .cancel, action: onDismiss)
+ } message: {
+ Text("flow.coldStart.alert.message")
+ }
+ }
+
+ private var swipeHintKey: LocalizedStringKey {
+ context.hostEntry == nil
+ ? "flow.coldStart.swipeHint"
+ : "flow.coldStart.swipeHint.withSystemBack"
+ }
+
+ private var alertTitle: String {
+ AppL10n.string("flow.coldStart.alert.title")
+ }
+
+ private var returnButtonTitle: String {
+ guard let entry = context.hostEntry else {
+ return AppL10n.string("flow.coldStart.return.generic")
+ }
+ let appName = AppL10n.string(entry.displayNameKey)
+ return AppL10n.format("flow.coldStart.return.named", appName)
+ }
+
+ private var swipeHintAnimation: some View {
+ VStack(spacing: Spacing.sm) {
+ Image(systemName: "chevron.up")
+ .font(.system(size: 20, weight: .semibold))
+ .foregroundStyle(palette.textTertiary)
+ RoundedRectangle(cornerRadius: 3, style: .continuous)
+ .fill(palette.textTertiary.opacity(0.5))
+ .frame(width: 120, height: 5)
+ }
+ .accessibilityLabel(AppL10n.string("flow.coldStart.swipeAccessibility"))
+ }
+}
diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift
index bf7cc48..52e673f 100644
--- a/OSGKeyboard/Views/HomeView.swift
+++ b/OSGKeyboard/Views/HomeView.swift
@@ -106,9 +106,6 @@ struct HomeView: View {
private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus
- if AppPermissions.flowRequirementsMet {
- flowManager.autoStartIfNeeded()
- }
}
private func handlePermissionGuidanceAction() {
diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift
index 2a701f7..74d4761 100644
--- a/OSGKeyboard/Views/MainAppRoot.swift
+++ b/OSGKeyboard/Views/MainAppRoot.swift
@@ -24,31 +24,44 @@ struct MainAppRoot: View {
}
.environment(\.locale, config.uiLanguage.swiftUILocale)
.environmentObject(flowManager)
+ .overlay {
+ if let context = flowManager.coldStartContext {
+ FlowColdStartOverlay(
+ context: context,
+ onReturnToHost: { flowManager.returnToPendingHostFromColdStart() },
+ onDismiss: { flowManager.dismissColdStartOverlay() }
+ )
+ .transition(.opacity)
+ }
+ }
+ .animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil)
.onAppear {
flowManager.setAppForeground(scenePhase == .active)
+ flowManager.restoreSessionIfNeeded()
}
- .onOpenURL { url in
- guard url.scheme == "osgkeyboard" else { return }
- switch url.host {
- case "startflow":
- flowManager.startSession()
- default:
- break
- }
+ .onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in
+ guard let url = notification.userInfo?["url"] as? URL else { return }
+ handleIncomingURL(url)
}
.onChange(of: config.hasCompletedOnboarding) { _, done in
if done {
- flowManager.autoStartIfNeeded()
+ flowManager.warmupAfterOnboardingIfNeeded()
}
}
.onChange(of: scenePhase) { _, phase in
flowManager.handleScenePhase(phase)
guard phase == .active, config.hasCompletedOnboarding else { return }
- if flowManager.isActive {
- flowManager.extendSession()
- } else {
- flowManager.autoStartIfNeeded()
- }
+ flowManager.restoreSessionIfNeeded()
+ }
+ }
+
+ private func handleIncomingURL(_ url: URL) {
+ guard url.scheme == "osgkeyboard" else { return }
+ switch url.host {
+ case "startflow":
+ flowManager.startSession(coldStart: true)
+ default:
+ break
}
}
}
diff --git a/OSGKeyboard/Views/MainTabView.swift b/OSGKeyboard/Views/MainTabView.swift
index d4abdb8..9fdfe3e 100644
--- a/OSGKeyboard/Views/MainTabView.swift
+++ b/OSGKeyboard/Views/MainTabView.swift
@@ -48,6 +48,5 @@ struct MainTabView: View {
// Keep home card/input/tab layout fixed when system keyboard appears.
// Let the keyboard overlay the content instead of pushing it.
.ignoresSafeArea(.keyboard, edges: .bottom)
- .onAppear { flowManager.autoStartIfNeeded() }
}
}
diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift
index 6c7cca4..ae10183 100644
--- a/OSGKeyboard/Views/SettingsView.swift
+++ b/OSGKeyboard/Views/SettingsView.swift
@@ -40,6 +40,7 @@ struct SettingsView: View {
ScrollView {
VStack(spacing: Spacing.md) {
languageAndPolishSection
+ flowSessionSection
engineSection
// v0.2.1: hide provider/api card when the
// local engine is active regardless of the
@@ -101,6 +102,44 @@ struct SettingsView: View {
}
}
+ // MARK: - Flow session
+
+ private var flowSessionSection: some View {
+ VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
+ sectionHeader("settings.flow.title")
+ VStack(spacing: 0) {
+ Toggle(isOn: $config.flowSkipAppSwitch) {
+ VStack(alignment: .leading, spacing: Spacing.xxs) {
+ Text("settings.flow.skipAppSwitch.title")
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textPrimary)
+ Text("settings.flow.skipAppSwitch.subtitle")
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textTertiary)
+ }
+ }
+ .tint(palette.accent)
+ .padding(.horizontal, Spacing.md)
+ .padding(.vertical, Spacing.sm)
+ .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
+
+ Divider().background(palette.divider)
+
+ FlowInactivityPickerRow(
+ selection: Binding(
+ get: { config.flowInactivityDuration },
+ set: { config.flowInactivityDuration = $0 }
+ )
+ )
+ }
+ .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ .stroke(palette.divider, lineWidth: 0.5)
+ )
+ }
+ }
+
// MARK: - Engine
private var engineSection: some View {
@@ -434,6 +473,31 @@ private struct AppLanguagePickerRow: View {
}
}
+// MARK: - Flow inactivity picker row
+
+private struct FlowInactivityPickerRow: View {
+ @Binding var selection: FlowInactivityDuration
+
+ private var options: [(id: String, label: String)] {
+ FlowInactivityDuration.allCases.map { duration in
+ (duration.rawValue, AppL10n.string(duration.labelKey))
+ }
+ }
+
+ var body: some View {
+ PickerRow(
+ title: AppL10n.string("settings.flow.inactivity.title"),
+ options: options,
+ selection: Binding(
+ get: { selection.rawValue },
+ set: { newValue in
+ selection = FlowInactivityDuration(rawValue: newValue) ?? .default
+ }
+ )
+ )
+ }
+}
+
// MARK: - Handedness picker row
private struct HandednessPickerRow: View {
diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings
index 59b646b..2ff56f1 100644
--- a/OSGKeyboard/en.lproj/Localizable.strings
+++ b/OSGKeyboard/en.lproj/Localizable.strings
@@ -360,3 +360,48 @@
/* v0.3.0: Polish intensity */
"settings.polishIntensity.title" = "Polish intensity";
+
+/* Flow session policy */
+"settings.flow.title" = "Voice session";
+"settings.flow.skipAppSwitch.title" = "Skip app switch";
+"settings.flow.skipAppSwitch.subtitle" = "After a cold start, try to return to the app you came from.";
+"settings.flow.inactivity.title" = "End session after inactivity";
+"settings.flow.inactivity.10m" = "10 minutes";
+"settings.flow.inactivity.30m" = "30 minutes";
+"settings.flow.inactivity.3h" = "3 hours";
+"settings.flow.inactivity.12h" = "12 hours";
+"settings.flow.inactivity.24h" = "24 hours";
+
+/* Cold-start handoff (scheme B) */
+"flow.coldStart.title" = "Voice is ready";
+"flow.coldStart.swipeHint" = "Swipe up from the bottom edge to return to your previous app.";
+"flow.coldStart.swipeHint.withSystemBack" = "Tap ‹ in the top-left or swipe up from the bottom to return.";
+"flow.coldStart.dismiss" = "Got it";
+"flow.coldStart.swipeAccessibility" = "Swipe up from the bottom to return";
+"flow.coldStart.alert.title" = "Voice is ready";
+"flow.coldStart.alert.message" = "You can return to continue typing, or swipe up from the bottom.";
+"flow.coldStart.return.named" = "Return to %@";
+"flow.coldStart.return.generic" = "Return to app";
+
+/* Host app display names (scheme C whitelist) */
+"hostApp.wechat" = "WeChat";
+"hostApp.qq" = "QQ";
+"hostApp.wecom" = "WeCom";
+"hostApp.dingtalk" = "DingTalk";
+"hostApp.lark" = "Lark";
+"hostApp.telegram" = "Telegram";
+"hostApp.whatsapp" = "WhatsApp";
+"hostApp.line" = "LINE";
+"hostApp.messenger" = "Messenger";
+"hostApp.slack" = "Slack";
+"hostApp.teams" = "Microsoft Teams";
+"hostApp.discord" = "Discord";
+"hostApp.notion" = "Notion";
+"hostApp.bear" = "Bear";
+"hostApp.obsidian" = "Obsidian";
+"hostApp.drafts" = "Drafts";
+"hostApp.gmail" = "Gmail";
+"hostApp.outlook" = "Outlook";
+"hostApp.chrome" = "Chrome";
+"hostApp.weibo" = "Weibo";
+"hostApp.xiaohongshu" = "RED";
diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
index 1d67e36..2868061 100644
--- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings
+++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
@@ -359,3 +359,48 @@
/* v0.3.0: 润色档位 */
"settings.polishIntensity.title" = "润色档位";
+
+/* Flow 会话策略 */
+"settings.flow.title" = "语音会话";
+"settings.flow.skipAppSwitch.title" = "跳过应用切换";
+"settings.flow.skipAppSwitch.subtitle" = "冷启动完成后,尝试自动返回来源 App。";
+"settings.flow.inactivity.title" = "无活动后结束会话";
+"settings.flow.inactivity.10m" = "10 分钟";
+"settings.flow.inactivity.30m" = "30 分钟";
+"settings.flow.inactivity.3h" = "3 小时";
+"settings.flow.inactivity.12h" = "12 小时";
+"settings.flow.inactivity.24h" = "24 小时";
+
+/* 冷启动兜底(方案 B) */
+"flow.coldStart.title" = "语音已就绪";
+"flow.coldStart.swipeHint" = "从屏幕底部边缘向上滑动,返回上一个 App。";
+"flow.coldStart.swipeHint.withSystemBack" = "点左上角 ‹ 或从底部向上滑动返回。";
+"flow.coldStart.dismiss" = "知道了";
+"flow.coldStart.swipeAccessibility" = "从底部向上滑动返回";
+"flow.coldStart.alert.title" = "语音已就绪";
+"flow.coldStart.alert.message" = "可返回继续输入,或从底部向上滑动返回。";
+"flow.coldStart.return.named" = "返回%@";
+"flow.coldStart.return.generic" = "返回 App";
+
+/* 宿主 App 显示名(方案 C 白名单) */
+"hostApp.wechat" = "微信";
+"hostApp.qq" = "QQ";
+"hostApp.wecom" = "企业微信";
+"hostApp.dingtalk" = "钉钉";
+"hostApp.lark" = "飞书";
+"hostApp.telegram" = "Telegram";
+"hostApp.whatsapp" = "WhatsApp";
+"hostApp.line" = "LINE";
+"hostApp.messenger" = "Messenger";
+"hostApp.slack" = "Slack";
+"hostApp.teams" = "Microsoft Teams";
+"hostApp.discord" = "Discord";
+"hostApp.notion" = "Notion";
+"hostApp.bear" = "Bear";
+"hostApp.obsidian" = "Obsidian";
+"hostApp.drafts" = "Drafts";
+"hostApp.gmail" = "Gmail";
+"hostApp.outlook" = "Outlook";
+"hostApp.chrome" = "Chrome";
+"hostApp.weibo" = "微博";
+"hostApp.xiaohongshu" = "小红书";
diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
index 6ea4fe8..833a568 100644
--- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
+++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
@@ -36,8 +36,6 @@ final class KeyboardFlowCoordinator {
private var wasFlowSessionActive = false
private var flowSessionMonitorTask: Task?
private var isAwaitingFlowResult = false
- private var lastFlowAutoStartAttempt: TimeInterval = 0
- private static let flowAutoStartCooldown: TimeInterval = 20
init(
state: KeyboardState,
@@ -95,10 +93,6 @@ final class KeyboardFlowCoordinator {
}
}
wasFlowSessionActive = active
-
- if !active {
- maybeAutoStartFlowSession()
- }
}
func toggleRecording() {
@@ -224,18 +218,6 @@ final class KeyboardFlowCoordinator {
}
}
- private func maybeAutoStartFlowSession() {
- guard !FlowSessionBridge.isSessionActive() else { return }
- guard !isPendingFlowStart, !isFlowRecording, !isAwaitingFlowResult else { return }
- guard hasFullAccess(), AppGroup.isAvailable else { return }
- guard case .idle = state.phase else { return }
-
- let now = Date().timeIntervalSince1970
- guard now - lastFlowAutoStartAttempt >= Self.flowAutoStartCooldown else { return }
- lastFlowAutoStartAttempt = now
- beginFlowStart()
- }
-
private func showFlowSessionExpiredHint() {
let message = ExtL10n.string("keyboard.flow.sessionExpired")
state.phase = .error(.flowSessionExpired, message: message)
@@ -332,9 +314,9 @@ final class KeyboardFlowCoordinator {
flowStartDeadline = 0
stopFlowWatchdog()
state.lastTranscript = ""
- state.phase = .idle
refreshSessionState()
- debug("completeFlowStartHandoff")
+ startFlowRecording()
+ debug("completeFlowStartHandoff → auto startFlowRecording")
}
private func startFlowLevelWatchdog() {
diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
index 1c99efa..b3a01fe 100644
--- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift
+++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
@@ -30,6 +30,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let detectedAppContext = "config.detectedAppContext"
public static let detectedAppContextAt = "config.detectedAppContextAt"
public static let personalDictionary = "config.personalDictionary.v1"
+ /// When true, the host app auto-returns to the source app after a cold-start handoff.
+ public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
+ /// Raw `FlowInactivityDuration` value; session expires after this idle window.
+ public static let flowInactivityDuration = "config.flowInactivityDuration"
}
// MARK: - Stored fields
@@ -49,6 +53,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var cursorDragNavigationEnabled: Bool
public var polishIntensity: PolishIntensity
public var personalDictionary: PersonalDictionary
+ /// Auto-return to the host app after `startflow` cold start (default on).
+ public var flowSkipAppSwitch: Bool
+ /// Idle timeout before the Flow session ends; resets on each utterance.
+ public var flowInactivityDuration: FlowInactivityDuration
// MARK: - Derived
@@ -145,7 +153,16 @@ public struct AppGroupConfiguration: Sendable, Equatable {
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
}(),
polishIntensity: resolvePolishIntensity(from: defaults),
- personalDictionary: decodePersonalDictionary(from: defaults)
+ personalDictionary: decodePersonalDictionary(from: defaults),
+ flowSkipAppSwitch: {
+ if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil {
+ return true
+ }
+ return defaults.bool(forKey: Keys.flowSkipAppSwitch)
+ }(),
+ flowInactivityDuration: FlowInactivityDuration.fromStored(
+ defaults.string(forKey: Keys.flowInactivityDuration)
+ )
)
let preset = LLMProvider.provider(id: config.providerId)
@@ -193,6 +210,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference)
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
+ defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
+ defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
Self.encodePersonalDictionary(personalDictionary, to: defaults)
}
diff --git a/OSGKeyboardShared/Models/FlowInactivityDuration.swift b/OSGKeyboardShared/Models/FlowInactivityDuration.swift
new file mode 100644
index 0000000..51b6a89
--- /dev/null
+++ b/OSGKeyboardShared/Models/FlowInactivityDuration.swift
@@ -0,0 +1,46 @@
+// FlowInactivityDuration.swift
+// OSGKeyboard · Shared
+//
+// User-selectable Flow session inactivity timeout. The timer resets after
+// each completed utterance (and on session start).
+
+import Foundation
+
+public enum FlowInactivityDuration: String, CaseIterable, Identifiable, Sendable, Codable {
+ case tenMinutes = "10m"
+ case thirtyMinutes = "30m"
+ case threeHours = "3h"
+ case twelveHours = "12h"
+ case twentyFourHours = "24h"
+
+ public var id: String { rawValue }
+
+ public static let `default`: FlowInactivityDuration = .twelveHours
+
+ public var timeInterval: TimeInterval {
+ switch self {
+ case .tenMinutes: return 10 * 60
+ case .thirtyMinutes: return 30 * 60
+ case .threeHours: return 3 * 60 * 60
+ case .twelveHours: return 12 * 60 * 60
+ case .twentyFourHours: return 24 * 60 * 60
+ }
+ }
+
+ public var labelKey: String {
+ switch self {
+ case .tenMinutes: return "settings.flow.inactivity.10m"
+ case .thirtyMinutes: return "settings.flow.inactivity.30m"
+ case .threeHours: return "settings.flow.inactivity.3h"
+ case .twelveHours: return "settings.flow.inactivity.12h"
+ case .twentyFourHours: return "settings.flow.inactivity.24h"
+ }
+ }
+
+ public static func fromStored(_ raw: String?) -> FlowInactivityDuration {
+ guard let raw, let value = FlowInactivityDuration(rawValue: raw) else {
+ return .default
+ }
+ return value
+ }
+}
diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift
index 18d9763..dde83f6 100644
--- a/OSGKeyboardShared/Models/ProviderConfig.swift
+++ b/OSGKeyboardShared/Models/ProviderConfig.swift
@@ -171,6 +171,25 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
}
+ /// When enabled, the host app tries to return to the source app after a cold-start handoff.
+ @Published public var flowSkipAppSwitch: Bool {
+ didSet {
+ guard !isApplyingConfiguration, flowSkipAppSwitch != configuration.flowSkipAppSwitch else { return }
+ configuration.flowSkipAppSwitch = flowSkipAppSwitch
+ persistConfiguration()
+ }
+ }
+
+ /// Idle window before an active Flow session expires; resets on each utterance.
+ @Published public var flowInactivityDuration: FlowInactivityDuration {
+ didSet {
+ guard !isApplyingConfiguration,
+ flowInactivityDuration != configuration.flowInactivityDuration else { return }
+ configuration.flowInactivityDuration = flowInactivityDuration
+ persistConfiguration()
+ }
+ }
+
public var isConfigured: Bool {
// Local engine uses on-device ASR + built-in DeepSeek polish and
// does not need a user API key. Cloud needs base URL, key, and model.
@@ -218,6 +237,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
handednessPreference = configuration.handednessPreference
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
polishIntensity = configuration.polishIntensity
+ flowSkipAppSwitch = configuration.flowSkipAppSwitch
+ flowInactivityDuration = configuration.flowInactivityDuration
isApplyingConfiguration = false
}
diff --git a/OSGKeyboardShared/Services/FlowSessionBridge.swift b/OSGKeyboardShared/Services/FlowSessionBridge.swift
index 500c95c..65fb653 100644
--- a/OSGKeyboardShared/Services/FlowSessionBridge.swift
+++ b/OSGKeyboardShared/Services/FlowSessionBridge.swift
@@ -39,13 +39,16 @@ public enum FlowSessionBridge {
// MARK: - Session lifecycle (host app)
public static func markSessionActive(
- duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
+ duration: TimeInterval? = nil,
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
- let expires = Date().timeIntervalSince1970 + duration
+ let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
+ let now = Date().timeIntervalSince1970
+ let expires = now + resolvedDuration
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
+ store.set(now, forKey: FlowSessionKeys.lastActivityAt)
writeHeartbeat(defaults: store)
setRecordingState(.idle, defaults: store)
clearTranscription(defaults: store)
@@ -69,16 +72,49 @@ public enum FlowSessionBridge {
}
public static func extendSession(
- by duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
+ by duration: TimeInterval? = nil,
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
- let expires = Date().timeIntervalSince1970 + duration
+ let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
+ let expires = Date().timeIntervalSince1970 + resolvedDuration
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
flush(store)
}
+ /// Resets the inactivity timer after utterance completion or explicit activity.
+ public static func touchLastActivity(defaults: UserDefaults? = nil) {
+ let store = resolvedDefaults(defaults)
+ let now = Date().timeIntervalSince1970
+ let duration = FlowSessionPolicy.sessionDuration(defaults: store)
+ store.set(now, forKey: FlowSessionKeys.lastActivityAt)
+ store.set(now + duration, forKey: FlowSessionKeys.flowSessionExpires)
+ store.set(true, forKey: FlowSessionKeys.flowSessionActive)
+ flush(store)
+ }
+
+ // MARK: - Host return (scheme D)
+
+ public static func setPendingHostBundleId(_ bundleId: String?, defaults: UserDefaults? = nil) {
+ let store = resolvedDefaults(defaults)
+ if let bundleId, !bundleId.isEmpty {
+ store.set(bundleId, forKey: FlowSessionKeys.pendingHostBundleId)
+ } else {
+ store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
+ }
+ flush(store)
+ }
+
+ public static func pendingHostBundleId(defaults: UserDefaults? = nil) -> String? {
+ let store = resolvedDefaults(defaults)
+ return store.string(forKey: FlowSessionKeys.pendingHostBundleId)
+ }
+
+ public static func clearPendingHostBundleId(defaults: UserDefaults? = nil) {
+ setPendingHostBundleId(nil, defaults: defaults)
+ }
+
// MARK: - Session validity (keyboard)
/// True when the session contract is still valid (not expired).
@@ -280,6 +316,8 @@ public enum FlowSessionBridge {
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
clearTranscription(defaults: store)
store.removeObject(forKey: FlowSessionKeys.audioLevels)
+ store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
+ store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
flush(store)
}
diff --git a/OSGKeyboardShared/Services/FlowSessionKeys.swift b/OSGKeyboardShared/Services/FlowSessionKeys.swift
index cd98e4a..b5a8856 100644
--- a/OSGKeyboardShared/Services/FlowSessionKeys.swift
+++ b/OSGKeyboardShared/Services/FlowSessionKeys.swift
@@ -21,11 +21,15 @@ public enum FlowSessionKeys {
/// Structured kind paired with `transcriptionError` for keyboard UI.
public static let transcriptionErrorKind = "flow.transcriptionErrorKind"
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 timestamp of the last utterance completion or session start.
+ public static let lastActivityAt = "flow.lastActivityAt"
/// Heartbeat older than this while the host is foreground → likely killed.
public static let heartbeatStaleInterval: TimeInterval = 3
- /// Default Flow session length when started from the keyboard.
+ /// Legacy fixed session length — prefer `FlowSessionPolicy.sessionDuration()`.
public static let defaultSessionDuration: TimeInterval = 480
/// Maximum duration for a single keyboard utterance (3.5 minutes).
diff --git a/OSGKeyboardShared/Services/FlowSessionPolicy.swift b/OSGKeyboardShared/Services/FlowSessionPolicy.swift
new file mode 100644
index 0000000..19f24fb
--- /dev/null
+++ b/OSGKeyboardShared/Services/FlowSessionPolicy.swift
@@ -0,0 +1,39 @@
+// FlowSessionPolicy.swift
+// OSGKeyboard · Shared
+//
+// Reads Flow session behaviour preferences from the App Group.
+
+import Foundation
+
+public enum FlowSessionPolicy {
+ public static func skipAppSwitch(defaults: UserDefaults? = nil) -> Bool {
+ let store = resolvedDefaults(defaults)
+ if store.object(forKey: AppGroupConfiguration.Keys.flowSkipAppSwitch) == nil {
+ return true
+ }
+ return store.bool(forKey: AppGroupConfiguration.Keys.flowSkipAppSwitch)
+ }
+
+ public static func inactivityDuration(defaults: UserDefaults? = nil) -> FlowInactivityDuration {
+ let store = resolvedDefaults(defaults)
+ return FlowInactivityDuration.fromStored(
+ store.string(forKey: AppGroupConfiguration.Keys.flowInactivityDuration)
+ )
+ }
+
+ public static func sessionDuration(defaults: UserDefaults? = nil) -> TimeInterval {
+ inactivityDuration(defaults: defaults).timeInterval
+ }
+
+ private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
+ if let defaults { return defaults }
+ guard let available = AppGroup.defaultsIfAvailable else {
+ #if DEBUG
+ fatalError("App Group unavailable — inject UserDefaults in tests.")
+ #else
+ fatalError("App Group unavailable.")
+ #endif
+ }
+ return available
+ }
+}
diff --git a/OSGKeyboardShared/Services/HostAppURLRegistry.swift b/OSGKeyboardShared/Services/HostAppURLRegistry.swift
new file mode 100644
index 0000000..cbeda1d
--- /dev/null
+++ b/OSGKeyboardShared/Services/HostAppURLRegistry.swift
@@ -0,0 +1,182 @@
+// HostAppURLRegistry.swift
+// OSGKeyboard · Shared
+//
+// Public URL-scheme whitelist for returning to a known host app after a
+// cold-start Flow session handoff. One bundle id maps to one preferred URL.
+
+import Foundation
+
+public struct HostAppEntry: Sendable, Equatable {
+ public let bundleId: String
+ public let displayNameKey: String
+ public let returnURLString: String
+ public let tier: Int
+
+ public var returnURL: URL? {
+ URL(string: returnURLString)
+ }
+
+ public init(bundleId: String, displayNameKey: String, returnURLString: String, tier: Int) {
+ self.bundleId = bundleId
+ self.displayNameKey = displayNameKey
+ self.returnURLString = returnURLString
+ self.tier = tier
+ }
+}
+
+public enum HostAppURLRegistry {
+ /// Curated whitelist for high-frequency host apps (IM, work, notes).
+ public static let entries: [HostAppEntry] = [
+ // Tier 1 — China IM / work
+ HostAppEntry(
+ bundleId: "com.tencent.xin",
+ displayNameKey: "hostApp.wechat",
+ returnURLString: "weixin://",
+ tier: 1
+ ),
+ HostAppEntry(
+ bundleId: "com.tencent.mqq",
+ displayNameKey: "hostApp.qq",
+ returnURLString: "mqq://",
+ tier: 1
+ ),
+ HostAppEntry(
+ bundleId: "com.tencent.wework",
+ displayNameKey: "hostApp.wecom",
+ returnURLString: "wxwork://",
+ tier: 1
+ ),
+ HostAppEntry(
+ bundleId: "com.laiwang.DingTalk",
+ displayNameKey: "hostApp.dingtalk",
+ returnURLString: "dingtalk://",
+ tier: 1
+ ),
+ HostAppEntry(
+ bundleId: "com.bytedance.ee.lark",
+ displayNameKey: "hostApp.lark",
+ returnURLString: "lark://",
+ tier: 1
+ ),
+ // Tier 2 — global IM / collaboration
+ HostAppEntry(
+ bundleId: "ph.telegra.Telegraph",
+ displayNameKey: "hostApp.telegram",
+ returnURLString: "tg://",
+ tier: 2
+ ),
+ HostAppEntry(
+ bundleId: "net.whatsapp.WhatsApp",
+ displayNameKey: "hostApp.whatsapp",
+ returnURLString: "whatsapp://",
+ tier: 2
+ ),
+ HostAppEntry(
+ bundleId: "jp.naver.line",
+ displayNameKey: "hostApp.line",
+ returnURLString: "line://",
+ tier: 2
+ ),
+ HostAppEntry(
+ bundleId: "com.facebook.Messenger",
+ displayNameKey: "hostApp.messenger",
+ returnURLString: "fb-messenger://",
+ tier: 2
+ ),
+ HostAppEntry(
+ bundleId: "com.tinyspeck.chatlyio",
+ displayNameKey: "hostApp.slack",
+ returnURLString: "slack://",
+ tier: 2
+ ),
+ HostAppEntry(
+ bundleId: "com.microsoft.skype.teams",
+ displayNameKey: "hostApp.teams",
+ returnURLString: "msteams://",
+ tier: 2
+ ),
+ HostAppEntry(
+ bundleId: "com.hammerandchisel.discord",
+ displayNameKey: "hostApp.discord",
+ returnURLString: "discord://",
+ tier: 2
+ ),
+ // Tier 3 — notes / mail / browser
+ HostAppEntry(
+ bundleId: "notion.id",
+ displayNameKey: "hostApp.notion",
+ returnURLString: "notion://",
+ tier: 3
+ ),
+ HostAppEntry(
+ bundleId: "net.shinyfrog.bear",
+ displayNameKey: "hostApp.bear",
+ returnURLString: "bear://",
+ tier: 3
+ ),
+ HostAppEntry(
+ bundleId: "md.obsidian",
+ displayNameKey: "hostApp.obsidian",
+ returnURLString: "obsidian://",
+ tier: 3
+ ),
+ HostAppEntry(
+ bundleId: "com.agiletortoise.Drafts5",
+ displayNameKey: "hostApp.drafts",
+ returnURLString: "drafts5://",
+ tier: 3
+ ),
+ HostAppEntry(
+ bundleId: "com.google.Gmail",
+ displayNameKey: "hostApp.gmail",
+ returnURLString: "googlegmail://",
+ tier: 3
+ ),
+ HostAppEntry(
+ bundleId: "com.microsoft.Office.Outlook",
+ displayNameKey: "hostApp.outlook",
+ returnURLString: "ms-outlook://",
+ tier: 3
+ ),
+ HostAppEntry(
+ bundleId: "com.google.chrome.ios",
+ displayNameKey: "hostApp.chrome",
+ returnURLString: "googlechrome://",
+ tier: 3
+ ),
+ // Tier 4 — China social
+ HostAppEntry(
+ bundleId: "com.sina.weibo",
+ displayNameKey: "hostApp.weibo",
+ returnURLString: "sinaweibo://",
+ tier: 4
+ ),
+ HostAppEntry(
+ bundleId: "com.xingin.discover",
+ displayNameKey: "hostApp.xiaohongshu",
+ returnURLString: "xhsdiscover://",
+ tier: 4
+ )
+ ]
+
+ private static let byBundleId: [String: HostAppEntry] = {
+ Dictionary(uniqueKeysWithValues: entries.map { ($0.bundleId, $0) })
+ }()
+
+ public static func lookup(bundleId: String?) -> HostAppEntry? {
+ guard let bundleId, !bundleId.isEmpty else { return nil }
+ return byBundleId[bundleId]
+ }
+
+ /// URL schemes declared in `LSApplicationQueriesSchemes` for `canOpenURL`.
+ public static var querySchemes: [String] {
+ Array(
+ Set(
+ entries.compactMap { entry -> String? in
+ guard let url = entry.returnURL, let scheme = url.scheme else { return nil }
+ return scheme
+ }
+ )
+ ).sorted()
+ }
+}
diff --git a/OSGKeyboardTests/AppGroupConfigurationTests.swift b/OSGKeyboardTests/AppGroupConfigurationTests.swift
index 6324abb..f34221b 100644
--- a/OSGKeyboardTests/AppGroupConfigurationTests.swift
+++ b/OSGKeyboardTests/AppGroupConfigurationTests.swift
@@ -30,6 +30,8 @@ final class AppGroupConfigurationTests: XCTestCase {
XCTAssertTrue(config.cursorDragNavigationEnabled)
XCTAssertEqual(config.polishIntensity, .default)
XCTAssertTrue(config.personalDictionary.entries.isEmpty)
+ XCTAssertTrue(config.flowSkipAppSwitch)
+ XCTAssertEqual(config.flowInactivityDuration, .twelveHours)
}
func testSaveAndLoadRoundTrip() {
@@ -49,6 +51,8 @@ final class AppGroupConfigurationTests: XCTestCase {
config.handednessPreference = .right
config.cursorDragNavigationEnabled = false
config.polishIntensity = .light
+ config.flowSkipAppSwitch = false
+ config.flowInactivityDuration = .thirtyMinutes
config.save(to: defaults)
let loaded = AppGroupConfiguration.load(fromAvailable: defaults)
@@ -66,6 +70,8 @@ final class AppGroupConfigurationTests: XCTestCase {
XCTAssertEqual(loaded.handednessPreference, .right)
XCTAssertFalse(loaded.cursorDragNavigationEnabled)
XCTAssertEqual(loaded.polishIntensity, .light)
+ XCTAssertFalse(loaded.flowSkipAppSwitch)
+ XCTAssertEqual(loaded.flowInactivityDuration, .thirtyMinutes)
}
func testTranslationEnabledDerivedFromTargetLocale() {
diff --git a/OSGKeyboardTests/FlowSessionPolicyTests.swift b/OSGKeyboardTests/FlowSessionPolicyTests.swift
new file mode 100644
index 0000000..8c17907
--- /dev/null
+++ b/OSGKeyboardTests/FlowSessionPolicyTests.swift
@@ -0,0 +1,46 @@
+// FlowSessionPolicyTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+final class FlowSessionPolicyTests: XCTestCase {
+ private func makeDefaults() -> UserDefaults {
+ let suite = "group.com.osgkeyboard.shared.tests.policy.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ return defaults
+ }
+
+ func testSkipAppSwitchDefaultsToTrue() {
+ let defaults = makeDefaults()
+ XCTAssertTrue(FlowSessionPolicy.skipAppSwitch(defaults: defaults))
+ }
+
+ func testInactivityDurationDefaultsToTwelveHours() {
+ let defaults = makeDefaults()
+ XCTAssertEqual(FlowSessionPolicy.inactivityDuration(defaults: defaults), .twelveHours)
+ XCTAssertEqual(FlowSessionPolicy.sessionDuration(defaults: defaults), 12 * 60 * 60)
+ }
+
+ func testTouchLastActivityExtendsExpiry() {
+ let defaults = makeDefaults()
+ defaults.set(FlowInactivityDuration.tenMinutes.rawValue, forKey: AppGroupConfiguration.Keys.flowInactivityDuration)
+ FlowSessionBridge.markSessionActive(defaults: defaults)
+
+ let staleExpiry = Date().timeIntervalSince1970 + 30
+ defaults.set(staleExpiry, forKey: FlowSessionKeys.flowSessionExpires)
+ FlowSessionBridge.touchLastActivity(defaults: defaults)
+
+ let refreshed = FlowSessionBridge.sessionExpiresAt(defaults: defaults) ?? 0
+ XCTAssertGreaterThan(refreshed, staleExpiry)
+ }
+
+ func testPendingHostBundleIdRoundTrip() {
+ let defaults = makeDefaults()
+ FlowSessionBridge.setPendingHostBundleId("com.tencent.xin", defaults: defaults)
+ XCTAssertEqual(FlowSessionBridge.pendingHostBundleId(defaults: defaults), "com.tencent.xin")
+ FlowSessionBridge.clearPendingHostBundleId(defaults: defaults)
+ XCTAssertNil(FlowSessionBridge.pendingHostBundleId(defaults: defaults))
+ }
+}
diff --git a/OSGKeyboardTests/HostAppURLRegistryTests.swift b/OSGKeyboardTests/HostAppURLRegistryTests.swift
new file mode 100644
index 0000000..afe6b3c
--- /dev/null
+++ b/OSGKeyboardTests/HostAppURLRegistryTests.swift
@@ -0,0 +1,28 @@
+// HostAppURLRegistryTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+final class HostAppURLRegistryTests: XCTestCase {
+ func testWeChatLookup() {
+ let entry = HostAppURLRegistry.lookup(bundleId: "com.tencent.xin")
+ XCTAssertEqual(entry?.returnURLString, "weixin://")
+ XCTAssertEqual(entry?.displayNameKey, "hostApp.wechat")
+ }
+
+ func testUnknownBundleReturnsNil() {
+ XCTAssertNil(HostAppURLRegistry.lookup(bundleId: "com.apple.MobileSMS"))
+ XCTAssertNil(HostAppURLRegistry.lookup(bundleId: nil))
+ XCTAssertNil(HostAppURLRegistry.lookup(bundleId: ""))
+ }
+
+ func testQuerySchemesIncludesWeChat() {
+ XCTAssertTrue(HostAppURLRegistry.querySchemes.contains("weixin"))
+ }
+
+ func testEntriesHaveUniqueBundleIds() {
+ let ids = HostAppURLRegistry.entries.map(\.bundleId)
+ XCTAssertEqual(Set(ids).count, ids.count)
+ }
+}
diff --git a/project.yml b/project.yml
index c34d4f0..0eca770 100644
--- a/project.yml
+++ b/project.yml
@@ -118,6 +118,28 @@ targets:
- CFBundleURLName: com.osgkeyboard.ios.settings
CFBundleURLSchemes:
- osgkeyboard
+ LSApplicationQueriesSchemes:
+ - weixin
+ - mqq
+ - wxwork
+ - dingtalk
+ - lark
+ - tg
+ - whatsapp
+ - line
+ - fb-messenger
+ - slack
+ - msteams
+ - discord
+ - notion
+ - bear
+ - obsidian
+ - drafts5
+ - googlegmail
+ - ms-outlook
+ - googlechrome
+ - sinaweibo
+ - xhsdiscover
# All network calls are HTTPS (NSAppTransportSecurity above);
# nothing in the app uses non-exempt encryption. Declaring
# `ITSAppUsesNonExemptEncryption: false` lets us skip the