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
@@ -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
}