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:
Cursor Agent
2026-07-06 07:38:26 +00:00
parent 6489340b2e
commit 13cd7b30f9
25 changed files with 936 additions and 77 deletions
+24
View File
@@ -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>
+2
View File
@@ -6,6 +6,8 @@ import OSGKeyboardShared
@main
struct OSGKeyboardApp: App {
@UIApplicationDelegateAdaptor(AppURLHandler.self) private var appURLHandler
init() {
MaterialIconsFont.registerIfNeeded()
if AppGroup.isAvailable {
+29
View File
@@ -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
}
}
+80 -33
View File
@@ -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"))
}
}
-3
View File
@@ -106,9 +106,6 @@ struct HomeView: View {
private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus
if AppPermissions.flowRequirementsMet {
flowManager.autoStartIfNeeded()
}
}
private func handlePermissionGuidanceAction() {
+27 -14
View File
@@ -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
}
}
}
-1
View File
@@ -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() }
}
}
+64
View File
@@ -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 {
+45
View File
@@ -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" = "小红书";