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:
@@ -30,6 +30,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let detectedAppContext = "config.detectedAppContext"
|
||||
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
||||
public static let personalDictionary = "config.personalDictionary.v1"
|
||||
/// When true, the host app auto-returns to the source app after a cold-start handoff.
|
||||
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
|
||||
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
|
||||
public static let flowInactivityDuration = "config.flowInactivityDuration"
|
||||
}
|
||||
|
||||
// MARK: - Stored fields
|
||||
@@ -49,6 +53,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public var cursorDragNavigationEnabled: Bool
|
||||
public var polishIntensity: PolishIntensity
|
||||
public var personalDictionary: PersonalDictionary
|
||||
/// Auto-return to the host app after `startflow` cold start (default on).
|
||||
public var flowSkipAppSwitch: Bool
|
||||
/// Idle timeout before the Flow session ends; resets on each utterance.
|
||||
public var flowInactivityDuration: FlowInactivityDuration
|
||||
|
||||
// MARK: - Derived
|
||||
|
||||
@@ -145,7 +153,16 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
|
||||
}(),
|
||||
polishIntensity: resolvePolishIntensity(from: defaults),
|
||||
personalDictionary: decodePersonalDictionary(from: defaults)
|
||||
personalDictionary: decodePersonalDictionary(from: defaults),
|
||||
flowSkipAppSwitch: {
|
||||
if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil {
|
||||
return true
|
||||
}
|
||||
return defaults.bool(forKey: Keys.flowSkipAppSwitch)
|
||||
}(),
|
||||
flowInactivityDuration: FlowInactivityDuration.fromStored(
|
||||
defaults.string(forKey: Keys.flowInactivityDuration)
|
||||
)
|
||||
)
|
||||
|
||||
let preset = LLMProvider.provider(id: config.providerId)
|
||||
@@ -193,6 +210,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference)
|
||||
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
|
||||
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
||||
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
|
||||
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
|
||||
Self.encodePersonalDictionary(personalDictionary, to: defaults)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// FlowInactivityDuration.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// User-selectable Flow session inactivity timeout. The timer resets after
|
||||
// each completed utterance (and on session start).
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum FlowInactivityDuration: String, CaseIterable, Identifiable, Sendable, Codable {
|
||||
case tenMinutes = "10m"
|
||||
case thirtyMinutes = "30m"
|
||||
case threeHours = "3h"
|
||||
case twelveHours = "12h"
|
||||
case twentyFourHours = "24h"
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public static let `default`: FlowInactivityDuration = .twelveHours
|
||||
|
||||
public var timeInterval: TimeInterval {
|
||||
switch self {
|
||||
case .tenMinutes: return 10 * 60
|
||||
case .thirtyMinutes: return 30 * 60
|
||||
case .threeHours: return 3 * 60 * 60
|
||||
case .twelveHours: return 12 * 60 * 60
|
||||
case .twentyFourHours: return 24 * 60 * 60
|
||||
}
|
||||
}
|
||||
|
||||
public var labelKey: String {
|
||||
switch self {
|
||||
case .tenMinutes: return "settings.flow.inactivity.10m"
|
||||
case .thirtyMinutes: return "settings.flow.inactivity.30m"
|
||||
case .threeHours: return "settings.flow.inactivity.3h"
|
||||
case .twelveHours: return "settings.flow.inactivity.12h"
|
||||
case .twentyFourHours: return "settings.flow.inactivity.24h"
|
||||
}
|
||||
}
|
||||
|
||||
public static func fromStored(_ raw: String?) -> FlowInactivityDuration {
|
||||
guard let raw, let value = FlowInactivityDuration(rawValue: raw) else {
|
||||
return .default
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,25 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// When enabled, the host app tries to return to the source app after a cold-start handoff.
|
||||
@Published public var flowSkipAppSwitch: Bool {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, flowSkipAppSwitch != configuration.flowSkipAppSwitch else { return }
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
/// Idle window before an active Flow session expires; resets on each utterance.
|
||||
@Published public var flowInactivityDuration: FlowInactivityDuration {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration,
|
||||
flowInactivityDuration != configuration.flowInactivityDuration else { return }
|
||||
configuration.flowInactivityDuration = flowInactivityDuration
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
public var isConfigured: Bool {
|
||||
// Local engine uses on-device ASR + built-in DeepSeek polish and
|
||||
// does not need a user API key. Cloud needs base URL, key, and model.
|
||||
@@ -218,6 +237,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
handednessPreference = configuration.handednessPreference
|
||||
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
|
||||
polishIntensity = configuration.polishIntensity
|
||||
flowSkipAppSwitch = configuration.flowSkipAppSwitch
|
||||
flowInactivityDuration = configuration.flowInactivityDuration
|
||||
isApplyingConfiguration = false
|
||||
}
|
||||
|
||||
|
||||
@@ -39,13 +39,16 @@ public enum FlowSessionBridge {
|
||||
// MARK: - Session lifecycle (host app)
|
||||
|
||||
public static func markSessionActive(
|
||||
duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
|
||||
duration: TimeInterval? = nil,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let expires = Date().timeIntervalSince1970 + duration
|
||||
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
|
||||
let now = Date().timeIntervalSince1970
|
||||
let expires = now + resolvedDuration
|
||||
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
|
||||
writeHeartbeat(defaults: store)
|
||||
setRecordingState(.idle, defaults: store)
|
||||
clearTranscription(defaults: store)
|
||||
@@ -69,16 +72,49 @@ public enum FlowSessionBridge {
|
||||
}
|
||||
|
||||
public static func extendSession(
|
||||
by duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
|
||||
by duration: TimeInterval? = nil,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let expires = Date().timeIntervalSince1970 + duration
|
||||
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
|
||||
let expires = Date().timeIntervalSince1970 + resolvedDuration
|
||||
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
/// Resets the inactivity timer after utterance completion or explicit activity.
|
||||
public static func touchLastActivity(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let now = Date().timeIntervalSince1970
|
||||
let duration = FlowSessionPolicy.sessionDuration(defaults: store)
|
||||
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
|
||||
store.set(now + duration, forKey: FlowSessionKeys.flowSessionExpires)
|
||||
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
// MARK: - Host return (scheme D)
|
||||
|
||||
public static func setPendingHostBundleId(_ bundleId: String?, defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let bundleId, !bundleId.isEmpty {
|
||||
store.set(bundleId, forKey: FlowSessionKeys.pendingHostBundleId)
|
||||
} else {
|
||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||
}
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func pendingHostBundleId(defaults: UserDefaults? = nil) -> String? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
return store.string(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||
}
|
||||
|
||||
public static func clearPendingHostBundleId(defaults: UserDefaults? = nil) {
|
||||
setPendingHostBundleId(nil, defaults: defaults)
|
||||
}
|
||||
|
||||
// MARK: - Session validity (keyboard)
|
||||
|
||||
/// True when the session contract is still valid (not expired).
|
||||
@@ -280,6 +316,8 @@ public enum FlowSessionBridge {
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
|
||||
clearTranscription(defaults: store)
|
||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,11 +21,15 @@ public enum FlowSessionKeys {
|
||||
/// Structured kind paired with `transcriptionError` for keyboard UI.
|
||||
public static let transcriptionErrorKind = "flow.transcriptionErrorKind"
|
||||
public static let audioLevels = "flow.audioLevels"
|
||||
/// Bundle id of the app that opened `osgkeyboard://startflow` (scheme D).
|
||||
public static let pendingHostBundleId = "flow.pendingHostBundleId"
|
||||
/// Wall-clock timestamp of the last utterance completion or session start.
|
||||
public static let lastActivityAt = "flow.lastActivityAt"
|
||||
|
||||
/// Heartbeat older than this while the host is foreground → likely killed.
|
||||
public static let heartbeatStaleInterval: TimeInterval = 3
|
||||
|
||||
/// Default Flow session length when started from the keyboard.
|
||||
/// Legacy fixed session length — prefer `FlowSessionPolicy.sessionDuration()`.
|
||||
public static let defaultSessionDuration: TimeInterval = 480
|
||||
|
||||
/// Maximum duration for a single keyboard utterance (3.5 minutes).
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// FlowSessionPolicy.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Reads Flow session behaviour preferences from the App Group.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum FlowSessionPolicy {
|
||||
public static func skipAppSwitch(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if store.object(forKey: AppGroupConfiguration.Keys.flowSkipAppSwitch) == nil {
|
||||
return true
|
||||
}
|
||||
return store.bool(forKey: AppGroupConfiguration.Keys.flowSkipAppSwitch)
|
||||
}
|
||||
|
||||
public static func inactivityDuration(defaults: UserDefaults? = nil) -> FlowInactivityDuration {
|
||||
let store = resolvedDefaults(defaults)
|
||||
return FlowInactivityDuration.fromStored(
|
||||
store.string(forKey: AppGroupConfiguration.Keys.flowInactivityDuration)
|
||||
)
|
||||
}
|
||||
|
||||
public static func sessionDuration(defaults: UserDefaults? = nil) -> TimeInterval {
|
||||
inactivityDuration(defaults: defaults).timeInterval
|
||||
}
|
||||
|
||||
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
|
||||
if let defaults { return defaults }
|
||||
guard let available = AppGroup.defaultsIfAvailable else {
|
||||
#if DEBUG
|
||||
fatalError("App Group unavailable — inject UserDefaults in tests.")
|
||||
#else
|
||||
fatalError("App Group unavailable.")
|
||||
#endif
|
||||
}
|
||||
return available
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// HostAppURLRegistry.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Public URL-scheme whitelist for returning to a known host app after a
|
||||
// cold-start Flow session handoff. One bundle id maps to one preferred URL.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct HostAppEntry: Sendable, Equatable {
|
||||
public let bundleId: String
|
||||
public let displayNameKey: String
|
||||
public let returnURLString: String
|
||||
public let tier: Int
|
||||
|
||||
public var returnURL: URL? {
|
||||
URL(string: returnURLString)
|
||||
}
|
||||
|
||||
public init(bundleId: String, displayNameKey: String, returnURLString: String, tier: Int) {
|
||||
self.bundleId = bundleId
|
||||
self.displayNameKey = displayNameKey
|
||||
self.returnURLString = returnURLString
|
||||
self.tier = tier
|
||||
}
|
||||
}
|
||||
|
||||
public enum HostAppURLRegistry {
|
||||
/// Curated whitelist for high-frequency host apps (IM, work, notes).
|
||||
public static let entries: [HostAppEntry] = [
|
||||
// Tier 1 — China IM / work
|
||||
HostAppEntry(
|
||||
bundleId: "com.tencent.xin",
|
||||
displayNameKey: "hostApp.wechat",
|
||||
returnURLString: "weixin://",
|
||||
tier: 1
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.tencent.mqq",
|
||||
displayNameKey: "hostApp.qq",
|
||||
returnURLString: "mqq://",
|
||||
tier: 1
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.tencent.wework",
|
||||
displayNameKey: "hostApp.wecom",
|
||||
returnURLString: "wxwork://",
|
||||
tier: 1
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.laiwang.DingTalk",
|
||||
displayNameKey: "hostApp.dingtalk",
|
||||
returnURLString: "dingtalk://",
|
||||
tier: 1
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.bytedance.ee.lark",
|
||||
displayNameKey: "hostApp.lark",
|
||||
returnURLString: "lark://",
|
||||
tier: 1
|
||||
),
|
||||
// Tier 2 — global IM / collaboration
|
||||
HostAppEntry(
|
||||
bundleId: "ph.telegra.Telegraph",
|
||||
displayNameKey: "hostApp.telegram",
|
||||
returnURLString: "tg://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "net.whatsapp.WhatsApp",
|
||||
displayNameKey: "hostApp.whatsapp",
|
||||
returnURLString: "whatsapp://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "jp.naver.line",
|
||||
displayNameKey: "hostApp.line",
|
||||
returnURLString: "line://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.facebook.Messenger",
|
||||
displayNameKey: "hostApp.messenger",
|
||||
returnURLString: "fb-messenger://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.tinyspeck.chatlyio",
|
||||
displayNameKey: "hostApp.slack",
|
||||
returnURLString: "slack://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.microsoft.skype.teams",
|
||||
displayNameKey: "hostApp.teams",
|
||||
returnURLString: "msteams://",
|
||||
tier: 2
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.hammerandchisel.discord",
|
||||
displayNameKey: "hostApp.discord",
|
||||
returnURLString: "discord://",
|
||||
tier: 2
|
||||
),
|
||||
// Tier 3 — notes / mail / browser
|
||||
HostAppEntry(
|
||||
bundleId: "notion.id",
|
||||
displayNameKey: "hostApp.notion",
|
||||
returnURLString: "notion://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "net.shinyfrog.bear",
|
||||
displayNameKey: "hostApp.bear",
|
||||
returnURLString: "bear://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "md.obsidian",
|
||||
displayNameKey: "hostApp.obsidian",
|
||||
returnURLString: "obsidian://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.agiletortoise.Drafts5",
|
||||
displayNameKey: "hostApp.drafts",
|
||||
returnURLString: "drafts5://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.google.Gmail",
|
||||
displayNameKey: "hostApp.gmail",
|
||||
returnURLString: "googlegmail://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.microsoft.Office.Outlook",
|
||||
displayNameKey: "hostApp.outlook",
|
||||
returnURLString: "ms-outlook://",
|
||||
tier: 3
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.google.chrome.ios",
|
||||
displayNameKey: "hostApp.chrome",
|
||||
returnURLString: "googlechrome://",
|
||||
tier: 3
|
||||
),
|
||||
// Tier 4 — China social
|
||||
HostAppEntry(
|
||||
bundleId: "com.sina.weibo",
|
||||
displayNameKey: "hostApp.weibo",
|
||||
returnURLString: "sinaweibo://",
|
||||
tier: 4
|
||||
),
|
||||
HostAppEntry(
|
||||
bundleId: "com.xingin.discover",
|
||||
displayNameKey: "hostApp.xiaohongshu",
|
||||
returnURLString: "xhsdiscover://",
|
||||
tier: 4
|
||||
)
|
||||
]
|
||||
|
||||
private static let byBundleId: [String: HostAppEntry] = {
|
||||
Dictionary(uniqueKeysWithValues: entries.map { ($0.bundleId, $0) })
|
||||
}()
|
||||
|
||||
public static func lookup(bundleId: String?) -> HostAppEntry? {
|
||||
guard let bundleId, !bundleId.isEmpty else { return nil }
|
||||
return byBundleId[bundleId]
|
||||
}
|
||||
|
||||
/// URL schemes declared in `LSApplicationQueriesSchemes` for `canOpenURL`.
|
||||
public static var querySchemes: [String] {
|
||||
Array(
|
||||
Set(
|
||||
entries.compactMap { entry -> String? in
|
||||
guard let url = entry.returnURL, let scheme = url.scheme else { return nil }
|
||||
return scheme
|
||||
}
|
||||
)
|
||||
).sorted()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user