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
|
### Added
|
||||||
- **Flow Live Activity**: Dynamic Island shows the OSGKeyboard brand mark during an active voice session (ActivityKit widget extension). / **Flow 灵动岛 Live Activity**:语音会话期间在灵动岛显示 OSGKeyboard 品牌标识(ActivityKit 小组件扩展)。
|
- **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,与其他在线服务相同管线)。
|
- **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
|
### 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 同步到键盘转写行。
|
- **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
|
## [0.4.0] - 2026-07-05
|
||||||
|
|||||||
@@ -34,6 +34,30 @@
|
|||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</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>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import OSGKeyboardShared
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct OSGKeyboardApp: App {
|
struct OSGKeyboardApp: App {
|
||||||
|
@UIApplicationDelegateAdaptor(AppURLHandler.self) private var appURLHandler
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
MaterialIconsFont.registerIfNeeded()
|
MaterialIconsFont.registerIfNeeded()
|
||||||
if AppGroup.isAvailable {
|
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?
|
@Published private(set) var sessionExpiresAt: Date?
|
||||||
/// Non-nil when continuous capture failed or permissions are missing.
|
/// Non-nil when continuous capture failed or permissions are missing.
|
||||||
@Published private(set) var sessionWarning: String?
|
@Published private(set) var sessionWarning: String?
|
||||||
|
/// Cold-start handoff overlay state (scheme B).
|
||||||
|
@Published var coldStartContext: FlowColdStartContext?
|
||||||
|
|
||||||
private let capture = FlowContinuousCapture()
|
private let capture = FlowContinuousCapture()
|
||||||
private let store = AppGroupStore()
|
private let store = AppGroupStore()
|
||||||
@@ -61,6 +63,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
/// True while the host app scene is `.active` — drives foreground renewal.
|
/// True while the host app scene is `.active` — drives foreground renewal.
|
||||||
private var isAppForeground = false
|
private var isAppForeground = false
|
||||||
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
/// True while handling a keyboard-initiated `startflow` cold start.
|
||||||
|
private var isColdStartHandoff = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
@@ -71,42 +75,65 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
|
|
||||||
/// Starts a Flow session: permissions → continuous capture → App Group active.
|
/// 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 {
|
guard AppGroup.isAvailable else {
|
||||||
debug("cannot start flow session: App Group unavailable")
|
debug("cannot start flow session: App Group unavailable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if coldStart {
|
||||||
|
isColdStartHandoff = true
|
||||||
|
}
|
||||||
|
|
||||||
if isActive {
|
if isActive {
|
||||||
extendSession(duration: duration)
|
extendSession(duration: duration)
|
||||||
|
if coldStart {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.handleColdStartAfterSessionReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
startTask?.cancel()
|
startTask?.cancel()
|
||||||
startTask = Task { @MainActor [weak self] in
|
startTask = Task { @MainActor [weak self] in
|
||||||
await self?.startSessionAsync(duration: duration)
|
await self?.startSessionAsync(duration: duration)
|
||||||
|
self?.handleColdStartAfterSessionReady()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called on launch / foreground when onboarding is complete.
|
/// Restores an in-flight session after relaunch; does not auto-start a new one.
|
||||||
func autoStartIfNeeded() {
|
func restoreSessionIfNeeded() {
|
||||||
guard AppGroup.isAvailable else { return }
|
guard AppGroup.isAvailable else { return }
|
||||||
guard !isActive, !isStarting else { return }
|
guard !isActive, !isStarting else { return }
|
||||||
|
|
||||||
guard AppPermissions.flowRequirementsMet else {
|
|
||||||
sessionWarning = permissionWarningMessage()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let markedActive = AppGroup.defaults.bool(forKey: FlowSessionKeys.flowSessionActive)
|
let markedActive = AppGroup.defaults.bool(forKey: FlowSessionKeys.flowSessionActive)
|
||||||
if markedActive, FlowSessionBridge.remainingSessionDuration() != nil {
|
if markedActive, FlowSessionBridge.remainingSessionDuration() != nil {
|
||||||
Task { await bootstrapFromStorageIfNeeded() }
|
Task { await bootstrapFromStorageIfNeeded() }
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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()
|
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.
|
/// Reattach capture when the host app was killed but the session has not expired.
|
||||||
func bootstrapFromStorageIfNeeded() async {
|
func bootstrapFromStorageIfNeeded() async {
|
||||||
guard AppGroup.isAvailable, !isActive else { return }
|
guard AppGroup.isAvailable, !isActive else { return }
|
||||||
@@ -165,6 +192,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
guard isActive else { return }
|
guard isActive else { return }
|
||||||
debug("Flow session ended")
|
debug("Flow session ended")
|
||||||
|
|
||||||
|
dismissColdStartOverlay()
|
||||||
startTask?.cancel()
|
startTask?.cancel()
|
||||||
startTask = nil
|
startTask = nil
|
||||||
pollingTask?.cancel()
|
pollingTask?.cancel()
|
||||||
@@ -205,18 +233,16 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
lastFinal = ""
|
lastFinal = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func extendSession(duration: TimeInterval = FlowSessionKeys.defaultSessionDuration) {
|
func extendSession(duration: TimeInterval? = nil) {
|
||||||
FlowSessionBridge.extendSession(by: duration)
|
let resolved = duration ?? FlowSessionPolicy.sessionDuration()
|
||||||
sessionExpiresAt = Date().addingTimeInterval(duration)
|
FlowSessionBridge.extendSession(by: resolved)
|
||||||
scheduleExpiry(after: duration)
|
sessionExpiresAt = Date().addingTimeInterval(resolved)
|
||||||
|
scheduleExpiry(after: resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called from `OSGKeyboardApp` when `scenePhase` changes.
|
/// Called from `OSGKeyboardApp` when `scenePhase` changes.
|
||||||
func setAppForeground(_ foreground: Bool) {
|
func setAppForeground(_ foreground: Bool) {
|
||||||
isAppForeground = foreground
|
isAppForeground = foreground
|
||||||
if foreground, isActive {
|
|
||||||
renewSessionIfNeededWhileForeground()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full scene lifecycle — keeps Flow + ASR alive across app switches.
|
/// 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.
|
/// Extend expiry after utterance completion based on the inactivity policy.
|
||||||
private func renewSessionIfNeededWhileForeground() {
|
private func touchSessionActivity() {
|
||||||
guard isActive, isAppForeground else { return }
|
guard isActive else { return }
|
||||||
guard let remaining = FlowSessionBridge.remainingSessionDuration() else { return }
|
FlowSessionBridge.touchLastActivity()
|
||||||
let threshold = FlowSessionKeys.defaultSessionDuration * 0.25
|
if let expires = FlowSessionBridge.sessionExpiresAt() {
|
||||||
guard remaining < threshold else { return }
|
sessionExpiresAt = Date(timeIntervalSince1970: expires)
|
||||||
extendSession()
|
let remaining = expires - Date().timeIntervalSince1970
|
||||||
debug("Flow session renewed in foreground (\(Int(threshold))s threshold)")
|
if remaining > 0 {
|
||||||
|
scheduleExpiry(after: remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session start
|
// MARK: - Session start
|
||||||
|
|
||||||
private func startSessionAsync(duration: TimeInterval) async {
|
private func startSessionAsync(duration: TimeInterval?) async {
|
||||||
isStarting = true
|
isStarting = true
|
||||||
sessionWarning = nil
|
sessionWarning = nil
|
||||||
defer { isStarting = false }
|
defer { isStarting = false }
|
||||||
|
|
||||||
guard AppPermissions.flowRequirementsMet else {
|
guard AppPermissions.flowRequirementsMet else {
|
||||||
sessionWarning = permissionWarningMessage()
|
sessionWarning = permissionWarningMessage()
|
||||||
|
isColdStartHandoff = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,29 +351,46 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
} catch {
|
} catch {
|
||||||
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||||
sessionWarning = message
|
sessionWarning = message
|
||||||
|
isColdStartHandoff = false
|
||||||
debug("continuous capture failed: \(message)")
|
debug("continuous capture failed: \(message)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
FlowSessionBridge.markSessionActive(duration: duration)
|
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration()
|
||||||
|
FlowSessionBridge.markSessionActive(duration: resolvedDuration)
|
||||||
FlowSessionDarwin.postSessionChanged()
|
FlowSessionDarwin.postSessionChanged()
|
||||||
isActive = true
|
isActive = true
|
||||||
ScreenWakeLock.acquire()
|
ScreenWakeLock.acquire()
|
||||||
sessionExpiresAt = Date().addingTimeInterval(duration)
|
sessionExpiresAt = Date().addingTimeInterval(resolvedDuration)
|
||||||
|
|
||||||
startHeartbeat()
|
startHeartbeat()
|
||||||
startPolling()
|
startPolling()
|
||||||
startLevelPublishing()
|
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()
|
bindSessionASRIfNeeded()
|
||||||
scheduleASRWarmup()
|
scheduleASRWarmup()
|
||||||
FlowLiveActivityController.startSession()
|
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) {
|
private func bindSessionASRIfNeeded(force: Bool = false) {
|
||||||
@@ -575,6 +622,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
isUtteranceProcessing = false
|
isUtteranceProcessing = false
|
||||||
FlowSessionBridge.setRecordingState(.idle)
|
FlowSessionBridge.setRecordingState(.idle)
|
||||||
FlowLiveActivityController.update(phase: .idle)
|
FlowLiveActivityController.update(phase: .idle)
|
||||||
|
touchSessionActivity()
|
||||||
}
|
}
|
||||||
|
|
||||||
let asrWait = asrWaitTimeout()
|
let asrWait = asrWaitTimeout()
|
||||||
@@ -740,7 +788,6 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
heartbeatTask = Task { @MainActor [weak self] in
|
heartbeatTask = Task { @MainActor [weak self] in
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
FlowSessionBridge.writeHeartbeat()
|
FlowSessionBridge.writeHeartbeat()
|
||||||
self?.renewSessionIfNeededWhileForeground()
|
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
guard self?.isActive == true else { break }
|
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() {
|
private func refreshPermissionStatuses() {
|
||||||
micStatus = AppPermissions.micStatus
|
micStatus = AppPermissions.micStatus
|
||||||
speechStatus = AppPermissions.speechStatus
|
speechStatus = AppPermissions.speechStatus
|
||||||
if AppPermissions.flowRequirementsMet {
|
|
||||||
flowManager.autoStartIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handlePermissionGuidanceAction() {
|
private func handlePermissionGuidanceAction() {
|
||||||
|
|||||||
@@ -24,31 +24,44 @@ struct MainAppRoot: View {
|
|||||||
}
|
}
|
||||||
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
||||||
.environmentObject(flowManager)
|
.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 {
|
.onAppear {
|
||||||
flowManager.setAppForeground(scenePhase == .active)
|
flowManager.setAppForeground(scenePhase == .active)
|
||||||
|
flowManager.restoreSessionIfNeeded()
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in
|
||||||
guard url.scheme == "osgkeyboard" else { return }
|
guard let url = notification.userInfo?["url"] as? URL else { return }
|
||||||
switch url.host {
|
handleIncomingURL(url)
|
||||||
case "startflow":
|
|
||||||
flowManager.startSession()
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: config.hasCompletedOnboarding) { _, done in
|
.onChange(of: config.hasCompletedOnboarding) { _, done in
|
||||||
if done {
|
if done {
|
||||||
flowManager.autoStartIfNeeded()
|
flowManager.warmupAfterOnboardingIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
flowManager.handleScenePhase(phase)
|
flowManager.handleScenePhase(phase)
|
||||||
guard phase == .active, config.hasCompletedOnboarding else { return }
|
guard phase == .active, config.hasCompletedOnboarding else { return }
|
||||||
if flowManager.isActive {
|
flowManager.restoreSessionIfNeeded()
|
||||||
flowManager.extendSession()
|
|
||||||
} else {
|
|
||||||
flowManager.autoStartIfNeeded()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// Keep home card/input/tab layout fixed when system keyboard appears.
|
||||||
// Let the keyboard overlay the content instead of pushing it.
|
// Let the keyboard overlay the content instead of pushing it.
|
||||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||||
.onAppear { flowManager.autoStartIfNeeded() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ struct SettingsView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: Spacing.md) {
|
VStack(spacing: Spacing.md) {
|
||||||
languageAndPolishSection
|
languageAndPolishSection
|
||||||
|
flowSessionSection
|
||||||
engineSection
|
engineSection
|
||||||
// v0.2.1: hide provider/api card when the
|
// v0.2.1: hide provider/api card when the
|
||||||
// local engine is active regardless of 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
|
// MARK: - Engine
|
||||||
|
|
||||||
private var engineSection: some View {
|
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
|
// MARK: - Handedness picker row
|
||||||
|
|
||||||
private struct HandednessPickerRow: View {
|
private struct HandednessPickerRow: View {
|
||||||
|
|||||||
@@ -360,3 +360,48 @@
|
|||||||
|
|
||||||
/* v0.3.0: Polish intensity */
|
/* v0.3.0: Polish intensity */
|
||||||
"settings.polishIntensity.title" = "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: 润色档位 */
|
/* v0.3.0: 润色档位 */
|
||||||
"settings.polishIntensity.title" = "润色档位";
|
"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 wasFlowSessionActive = false
|
||||||
private var flowSessionMonitorTask: Task<Void, Never>?
|
private var flowSessionMonitorTask: Task<Void, Never>?
|
||||||
private var isAwaitingFlowResult = false
|
private var isAwaitingFlowResult = false
|
||||||
private var lastFlowAutoStartAttempt: TimeInterval = 0
|
|
||||||
private static let flowAutoStartCooldown: TimeInterval = 20
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
state: KeyboardState,
|
state: KeyboardState,
|
||||||
@@ -95,10 +93,6 @@ final class KeyboardFlowCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
wasFlowSessionActive = active
|
wasFlowSessionActive = active
|
||||||
|
|
||||||
if !active {
|
|
||||||
maybeAutoStartFlowSession()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleRecording() {
|
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() {
|
private func showFlowSessionExpiredHint() {
|
||||||
let message = ExtL10n.string("keyboard.flow.sessionExpired")
|
let message = ExtL10n.string("keyboard.flow.sessionExpired")
|
||||||
state.phase = .error(.flowSessionExpired, message: message)
|
state.phase = .error(.flowSessionExpired, message: message)
|
||||||
@@ -332,9 +314,9 @@ final class KeyboardFlowCoordinator {
|
|||||||
flowStartDeadline = 0
|
flowStartDeadline = 0
|
||||||
stopFlowWatchdog()
|
stopFlowWatchdog()
|
||||||
state.lastTranscript = ""
|
state.lastTranscript = ""
|
||||||
state.phase = .idle
|
|
||||||
refreshSessionState()
|
refreshSessionState()
|
||||||
debug("completeFlowStartHandoff")
|
startFlowRecording()
|
||||||
|
debug("completeFlowStartHandoff → auto startFlowRecording")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startFlowLevelWatchdog() {
|
private func startFlowLevelWatchdog() {
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
public static let detectedAppContext = "config.detectedAppContext"
|
public static let detectedAppContext = "config.detectedAppContext"
|
||||||
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
||||||
public static let personalDictionary = "config.personalDictionary.v1"
|
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
|
// MARK: - Stored fields
|
||||||
@@ -49,6 +53,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
public var cursorDragNavigationEnabled: Bool
|
public var cursorDragNavigationEnabled: Bool
|
||||||
public var polishIntensity: PolishIntensity
|
public var polishIntensity: PolishIntensity
|
||||||
public var personalDictionary: PersonalDictionary
|
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
|
// MARK: - Derived
|
||||||
|
|
||||||
@@ -145,7 +153,16 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
|||||||
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
|
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
|
||||||
}(),
|
}(),
|
||||||
polishIntensity: resolvePolishIntensity(from: defaults),
|
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)
|
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(handednessPreference.rawValue, forKey: Keys.handednessPreference)
|
||||||
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
|
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
|
||||||
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
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)
|
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 {
|
public var isConfigured: Bool {
|
||||||
// Local engine uses on-device ASR + built-in DeepSeek polish and
|
// 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.
|
// 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
|
handednessPreference = configuration.handednessPreference
|
||||||
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
|
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
|
||||||
polishIntensity = configuration.polishIntensity
|
polishIntensity = configuration.polishIntensity
|
||||||
|
flowSkipAppSwitch = configuration.flowSkipAppSwitch
|
||||||
|
flowInactivityDuration = configuration.flowInactivityDuration
|
||||||
isApplyingConfiguration = false
|
isApplyingConfiguration = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,13 +39,16 @@ public enum FlowSessionBridge {
|
|||||||
// MARK: - Session lifecycle (host app)
|
// MARK: - Session lifecycle (host app)
|
||||||
|
|
||||||
public static func markSessionActive(
|
public static func markSessionActive(
|
||||||
duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
|
duration: TimeInterval? = nil,
|
||||||
defaults: UserDefaults? = nil
|
defaults: UserDefaults? = nil
|
||||||
) {
|
) {
|
||||||
let store = resolvedDefaults(defaults)
|
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(true, forKey: FlowSessionKeys.flowSessionActive)
|
||||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||||
|
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
|
||||||
writeHeartbeat(defaults: store)
|
writeHeartbeat(defaults: store)
|
||||||
setRecordingState(.idle, defaults: store)
|
setRecordingState(.idle, defaults: store)
|
||||||
clearTranscription(defaults: store)
|
clearTranscription(defaults: store)
|
||||||
@@ -69,16 +72,49 @@ public enum FlowSessionBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static func extendSession(
|
public static func extendSession(
|
||||||
by duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
|
by duration: TimeInterval? = nil,
|
||||||
defaults: UserDefaults? = nil
|
defaults: UserDefaults? = nil
|
||||||
) {
|
) {
|
||||||
let store = resolvedDefaults(defaults)
|
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(true, forKey: FlowSessionKeys.flowSessionActive)
|
||||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||||
flush(store)
|
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)
|
// MARK: - Session validity (keyboard)
|
||||||
|
|
||||||
/// True when the session contract is still valid (not expired).
|
/// True when the session contract is still valid (not expired).
|
||||||
@@ -280,6 +316,8 @@ public enum FlowSessionBridge {
|
|||||||
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
|
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
|
||||||
clearTranscription(defaults: store)
|
clearTranscription(defaults: store)
|
||||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||||
|
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||||
|
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||||
flush(store)
|
flush(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,15 @@ public enum FlowSessionKeys {
|
|||||||
/// Structured kind paired with `transcriptionError` for keyboard UI.
|
/// Structured kind paired with `transcriptionError` for keyboard UI.
|
||||||
public static let transcriptionErrorKind = "flow.transcriptionErrorKind"
|
public static let transcriptionErrorKind = "flow.transcriptionErrorKind"
|
||||||
public static let audioLevels = "flow.audioLevels"
|
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.
|
/// Heartbeat older than this while the host is foreground → likely killed.
|
||||||
public static let heartbeatStaleInterval: TimeInterval = 3
|
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
|
public static let defaultSessionDuration: TimeInterval = 480
|
||||||
|
|
||||||
/// Maximum duration for a single keyboard utterance (3.5 minutes).
|
/// 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)
|
XCTAssertTrue(config.cursorDragNavigationEnabled)
|
||||||
XCTAssertEqual(config.polishIntensity, .default)
|
XCTAssertEqual(config.polishIntensity, .default)
|
||||||
XCTAssertTrue(config.personalDictionary.entries.isEmpty)
|
XCTAssertTrue(config.personalDictionary.entries.isEmpty)
|
||||||
|
XCTAssertTrue(config.flowSkipAppSwitch)
|
||||||
|
XCTAssertEqual(config.flowInactivityDuration, .twelveHours)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSaveAndLoadRoundTrip() {
|
func testSaveAndLoadRoundTrip() {
|
||||||
@@ -49,6 +51,8 @@ final class AppGroupConfigurationTests: XCTestCase {
|
|||||||
config.handednessPreference = .right
|
config.handednessPreference = .right
|
||||||
config.cursorDragNavigationEnabled = false
|
config.cursorDragNavigationEnabled = false
|
||||||
config.polishIntensity = .light
|
config.polishIntensity = .light
|
||||||
|
config.flowSkipAppSwitch = false
|
||||||
|
config.flowInactivityDuration = .thirtyMinutes
|
||||||
config.save(to: defaults)
|
config.save(to: defaults)
|
||||||
|
|
||||||
let loaded = AppGroupConfiguration.load(fromAvailable: defaults)
|
let loaded = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||||
@@ -66,6 +70,8 @@ final class AppGroupConfigurationTests: XCTestCase {
|
|||||||
XCTAssertEqual(loaded.handednessPreference, .right)
|
XCTAssertEqual(loaded.handednessPreference, .right)
|
||||||
XCTAssertFalse(loaded.cursorDragNavigationEnabled)
|
XCTAssertFalse(loaded.cursorDragNavigationEnabled)
|
||||||
XCTAssertEqual(loaded.polishIntensity, .light)
|
XCTAssertEqual(loaded.polishIntensity, .light)
|
||||||
|
XCTAssertFalse(loaded.flowSkipAppSwitch)
|
||||||
|
XCTAssertEqual(loaded.flowInactivityDuration, .thirtyMinutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTranslationEnabledDerivedFromTargetLocale() {
|
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
|
- CFBundleURLName: com.osgkeyboard.ios.settings
|
||||||
CFBundleURLSchemes:
|
CFBundleURLSchemes:
|
||||||
- osgkeyboard
|
- 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);
|
# All network calls are HTTPS (NSAppTransportSecurity above);
|
||||||
# nothing in the app uses non-exempt encryption. Declaring
|
# nothing in the app uses non-exempt encryption. Declaring
|
||||||
# `ITSAppUsesNonExemptEncryption: false` lets us skip the
|
# `ITSAppUsesNonExemptEncryption: false` lets us skip the
|
||||||
|
|||||||
Reference in New Issue
Block a user