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