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