feat(flow): implement ABCD session policy and host return whitelist
- Scheme A: on-demand session start, inactivity-based expiry, handoff auto-recording - Scheme B: cold-start overlay with swipe guidance and return alert - Scheme C+D: HostAppURLRegistry (20 apps) and sourceApplication capture - Settings: skip app switch toggle and inactivity duration picker - Add LSApplicationQueriesSchemes for canOpenURL checks Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -34,6 +34,30 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>weixin</string>
|
||||
<string>mqq</string>
|
||||
<string>wxwork</string>
|
||||
<string>dingtalk</string>
|
||||
<string>lark</string>
|
||||
<string>tg</string>
|
||||
<string>whatsapp</string>
|
||||
<string>line</string>
|
||||
<string>fb-messenger</string>
|
||||
<string>slack</string>
|
||||
<string>msteams</string>
|
||||
<string>discord</string>
|
||||
<string>notion</string>
|
||||
<string>bear</string>
|
||||
<string>obsidian</string>
|
||||
<string>drafts5</string>
|
||||
<string>googlegmail</string>
|
||||
<string>ms-outlook</string>
|
||||
<string>googlechrome</string>
|
||||
<string>sinaweibo</string>
|
||||
<string>xhsdiscover</string>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -6,6 +6,8 @@ import OSGKeyboardShared
|
||||
|
||||
@main
|
||||
struct OSGKeyboardApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppURLHandler.self) private var appURLHandler
|
||||
|
||||
init() {
|
||||
MaterialIconsFont.registerIfNeeded()
|
||||
if AppGroup.isAvailable {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -106,9 +106,6 @@ struct HomeView: View {
|
||||
private func refreshPermissionStatuses() {
|
||||
micStatus = AppPermissions.micStatus
|
||||
speechStatus = AppPermissions.speechStatus
|
||||
if AppPermissions.flowRequirementsMet {
|
||||
flowManager.autoStartIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePermissionGuidanceAction() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "小红书";
|
||||
|
||||
@@ -36,8 +36,6 @@ final class KeyboardFlowCoordinator {
|
||||
private var wasFlowSessionActive = false
|
||||
private var flowSessionMonitorTask: Task<Void, Never>?
|
||||
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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+22
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user