Merge branch 'cursor/pip-keep-alive-2b89' into feature/polish-style-packs

Resolve conflicts in CHANGELOG (keep both entries) and FlowSessionManager
(utterance PCM snapshot + PiP capture release after drain).

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-07-26 10:39:38 +00:00
18 changed files with 801 additions and 36 deletions
@@ -56,6 +56,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let settingsCloudPayloadV2 = "config.settings.cloudPayload.v2"
/// When true, the host app auto-returns to the source app after a cold-start handoff.
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
/// Raw `FlowKeepAliveMode` value; mutually exclusive PiP vs Live Activity path.
public static let flowKeepAliveMode = "config.flowKeepAliveMode"
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
public static let flowInactivityDuration = "config.flowInactivityDuration"
/// One-shot: remap previous product defaults (30m / 10m) 5m.
@@ -96,6 +98,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var settingsICloudSyncEnabled: Bool
/// Auto-return to the host app after `startflow` cold start (default on).
public var flowSkipAppSwitch: Bool
/// PiP vs Live Activity keep-alive strategy (mutually exclusive).
public var flowKeepAliveMode: FlowKeepAliveMode
/// Idle timeout before the Flow session ends; resets on each utterance.
public var flowInactivityDuration: FlowInactivityDuration
/// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
@@ -273,6 +277,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
return defaults.bool(forKey: Keys.flowSkipAppSwitch)
}(),
flowKeepAliveMode: FlowKeepAliveMode.fromStored(
defaults.string(forKey: Keys.flowKeepAliveMode)
),
flowInactivityDuration: FlowInactivityDuration.fromStored(
defaults.string(forKey: Keys.flowInactivityDuration)
),
@@ -383,6 +390,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
defaults.set(activePolishStyleId, forKey: Keys.activePolishStyleId)
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
defaults.set(flowKeepAliveMode.rawValue, forKey: Keys.flowKeepAliveMode)
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled)
defaults.set(personalDictionaryICloudSyncEnabled, forKey: Keys.personalDictionaryICloudSyncEnabled)
@@ -0,0 +1,39 @@
// FlowKeepAliveMode.swift
// OSGKeyboard · Shared
//
// User-selectable Flow session keep-alive strategy (mutually exclusive).
import Foundation
public enum FlowKeepAliveMode: String, CaseIterable, Identifiable, Sendable, Codable {
/// Continuous audio capture + Live Activity (current default behaviour).
case liveActivity = "liveActivity"
/// Picture-in-picture waveform keep-alive; mic released between utterances.
case pictureInPicture = "pictureInPicture"
public var id: String { rawValue }
/// Existing installs keep the Live Activity / continuous-capture path.
public static let `default`: FlowKeepAliveMode = .liveActivity
public var labelKey: String {
switch self {
case .liveActivity: return "settings.flow.keepAlive.liveActivity"
case .pictureInPicture: return "settings.flow.keepAlive.pictureInPicture"
}
}
public var subtitleKey: String {
switch self {
case .liveActivity: return "settings.flow.keepAlive.liveActivity.subtitle"
case .pictureInPicture: return "settings.flow.keepAlive.pictureInPicture.subtitle"
}
}
public static func fromStored(_ raw: String?) -> FlowKeepAliveMode {
guard let raw, let value = FlowKeepAliveMode(rawValue: raw) else {
return .default
}
return value
}
}
+18 -1
View File
@@ -237,7 +237,22 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
}
/// Idle window before an active Flow session expires; resets on each utterance.
/// PiP vs Live Activity keep-alive (mutually exclusive).
@Published public var flowKeepAliveMode: FlowKeepAliveMode {
didSet {
guard !isApplyingConfiguration, flowKeepAliveMode != configuration.flowKeepAliveMode else { return }
configuration.flowKeepAliveMode = flowKeepAliveMode
if flowKeepAliveMode == .pictureInPicture {
configuration.flowSkipAppSwitch = true
if flowSkipAppSwitch != true {
flowSkipAppSwitch = true
}
}
persistConfiguration()
}
}
/// Idle window before an active Flow session expires; Live Activity mode only.
@Published public var flowInactivityDuration: FlowInactivityDuration {
didSet {
guard !isApplyingConfiguration,
@@ -347,6 +362,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
polishIntensity = configuration.polishIntensity
llmThinkingEnabled = configuration.llmThinkingEnabled
flowSkipAppSwitch = configuration.flowSkipAppSwitch
flowKeepAliveMode = configuration.flowKeepAliveMode
flowInactivityDuration = configuration.flowInactivityDuration
localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled
isSyncingProviderAPIKey = true
@@ -433,6 +449,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
polishIntensity = fresh.polishIntensity
llmThinkingEnabled = fresh.llmThinkingEnabled
flowSkipAppSwitch = fresh.flowSkipAppSwitch
flowKeepAliveMode = fresh.flowKeepAliveMode
flowInactivityDuration = fresh.flowInactivityDuration
localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled
isSyncingProviderAPIKey = true
@@ -30,6 +30,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
public var activePolishStyleId: SyncedField<String>
public var llmThinkingEnabled: SyncedField<Bool>
public var flowSkipAppSwitch: SyncedField<Bool>
public var flowKeepAliveMode: SyncedField<FlowKeepAliveMode>
public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
public init(
@@ -52,6 +53,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
activePolishStyleId: SyncedField<String>,
llmThinkingEnabled: SyncedField<Bool>,
flowSkipAppSwitch: SyncedField<Bool>,
flowKeepAliveMode: SyncedField<FlowKeepAliveMode>,
flowInactivityDuration: SyncedField<FlowInactivityDuration>
) {
self.schemaVersion = schemaVersion
@@ -73,6 +75,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
self.activePolishStyleId = activePolishStyleId
self.llmThinkingEnabled = llmThinkingEnabled
self.flowSkipAppSwitch = flowSkipAppSwitch
self.flowKeepAliveMode = flowKeepAliveMode
self.flowInactivityDuration = flowInactivityDuration
}
@@ -96,6 +99,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
case activePolishStyleId
case llmThinkingEnabled
case flowSkipAppSwitch
case flowKeepAliveMode
case flowInactivityDuration
}
@@ -136,6 +140,14 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
forKey: .llmThinkingEnabled
) ?? SyncedField(value: false, updatedAt: polishIntensity.updatedAt, deviceID: polishIntensity.deviceID)
flowSkipAppSwitch = try container.decode(SyncedField<Bool>.self, forKey: .flowSkipAppSwitch)
flowKeepAliveMode = try container.decodeIfPresent(
SyncedField<FlowKeepAliveMode>.self,
forKey: .flowKeepAliveMode
) ?? SyncedField(
value: .liveActivity,
updatedAt: flowSkipAppSwitch.updatedAt,
deviceID: flowSkipAppSwitch.deviceID
)
flowInactivityDuration = try container.decode(
SyncedField<FlowInactivityDuration>.self,
forKey: .flowInactivityDuration
@@ -184,6 +196,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
activePolishStyleId.updatedAt,
llmThinkingEnabled.updatedAt,
flowSkipAppSwitch.updatedAt,
flowKeepAliveMode.updatedAt,
flowInactivityDuration.updatedAt,
].max() ?? .distantPast
}
@@ -222,6 +235,7 @@ public extension SyncedAppSettingsV2 {
activePolishStyleId: field(configuration.activePolishStyleId),
llmThinkingEnabled: field(configuration.llmThinkingEnabled),
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
flowKeepAliveMode: field(configuration.flowKeepAliveMode),
flowInactivityDuration: field(configuration.flowInactivityDuration)
)
}
@@ -252,6 +266,7 @@ public extension SyncedAppSettingsV2 {
activePolishStyleId: field(PolishStylePackCatalog.defaultID),
llmThinkingEnabled: field(false),
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
flowKeepAliveMode: field(.liveActivity),
flowInactivityDuration: field(legacy.flowInactivityDuration)
)
}
@@ -288,6 +303,7 @@ public extension SyncedAppSettingsV2 {
),
llmThinkingEnabled: .merge(local: local.llmThinkingEnabled, remote: remote.llmThinkingEnabled),
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
flowKeepAliveMode: .merge(local: local.flowKeepAliveMode, remote: remote.flowKeepAliveMode),
flowInactivityDuration: .merge(
local: local.flowInactivityDuration,
remote: remote.flowInactivityDuration
@@ -314,6 +330,7 @@ public extension SyncedAppSettingsV2 {
configuration.activePolishStyleId = activePolishStyleId.value
configuration.llmThinkingEnabled = llmThinkingEnabled.value
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
configuration.flowKeepAliveMode = flowKeepAliveMode.value
configuration.flowInactivityDuration = flowInactivityDuration.value
}
@@ -342,6 +359,7 @@ public extension SyncedAppSettingsV2 {
patch(&copy.activePolishStyleId, value: configuration.activePolishStyleId)
patch(&copy.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
patch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
patch(&copy.flowKeepAliveMode, value: configuration.flowKeepAliveMode)
patch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy
}
@@ -373,6 +391,7 @@ public extension SyncedAppSettingsV2 {
touch(&copy.activePolishStyleId, value: configuration.activePolishStyleId)
touch(&copy.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
touch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
touch(&copy.flowKeepAliveMode, value: configuration.flowKeepAliveMode)
touch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy
}
@@ -312,11 +312,22 @@ public enum FlowSessionBridge {
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
if FlowSessionPolicy.usesInactivityExpiry(defaults: store) {
markSessionActiveWithExpiry(duration: duration, sessionId: sessionId, defaults: store)
} else {
markSessionActivePersistent(sessionId: sessionId, defaults: store)
}
}
/// PiP keep-alive: session stays valid until explicit teardown (no idle expiry).
public static func markSessionActivePersistent(
sessionId: UUID? = nil,
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
let now = Date().timeIntervalSince1970
let expires = now + resolvedDuration
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
writeHeartbeat(defaults: store)
clearTranscription(defaults: store)
@@ -331,7 +342,7 @@ public enum FlowSessionBridge {
heartbeatAt: now,
engineMode: AppGroupConfiguration.load(fromAvailable: store).engineMode,
localeId: AppGroupConfiguration.load(fromAvailable: store).localeId,
sessionExpiresAt: expires,
sessionExpiresAt: nil,
hostGeneration: store.string(forKey: FlowSessionKeys.hostGeneration)
)
if let data = encode(snapshot) {
@@ -343,6 +354,42 @@ public enum FlowSessionBridge {
flush(store)
}
private static func markSessionActiveWithExpiry(
duration: TimeInterval? = nil,
sessionId: UUID? = nil,
defaults: UserDefaults
) {
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: defaults)
let now = Date().timeIntervalSince1970
let expires = now + resolvedDuration
defaults.set(true, forKey: FlowSessionKeys.flowSessionActive)
defaults.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
defaults.set(now, forKey: FlowSessionKeys.lastActivityAt)
writeHeartbeat(defaults: defaults)
clearTranscription(defaults: defaults)
defaults.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
defaults.removeObject(forKey: FlowSessionKeys.flowResultPayload)
defaults.removeObject(forKey: FlowSessionKeys.flowAckPayload)
if let sessionId {
let snapshot = FlowReadySnapshot(
sessionId: sessionId,
ready: false,
reason: .starting,
heartbeatAt: now,
engineMode: AppGroupConfiguration.load(fromAvailable: defaults).engineMode,
localeId: AppGroupConfiguration.load(fromAvailable: defaults).localeId,
sessionExpiresAt: expires,
hostGeneration: defaults.string(forKey: FlowSessionKeys.hostGeneration)
)
if let data = encode(snapshot) {
defaults.set(data, forKey: FlowSessionKeys.flowReadyPayload)
}
} else {
defaults.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
}
flush(defaults)
}
public static func markSessionInactive(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
@@ -372,6 +419,7 @@ public enum FlowSessionBridge {
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
guard FlowSessionPolicy.usesInactivityExpiry(defaults: store) else { return }
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
let expires = Date().timeIntervalSince1970 + resolvedDuration
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
@@ -382,6 +430,7 @@ public enum FlowSessionBridge {
/// Resets the inactivity timer after utterance completion or explicit activity.
public static func touchLastActivity(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
guard FlowSessionPolicy.usesInactivityExpiry(defaults: store) else { return }
let now = Date().timeIntervalSince1970
let duration = FlowSessionPolicy.sessionDuration(defaults: store)
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
@@ -419,6 +468,10 @@ public enum FlowSessionBridge {
let store = resolvedDefaults(defaults)
guard store.bool(forKey: FlowSessionKeys.flowSessionActive) else { return false }
if !FlowSessionPolicy.usesInactivityExpiry(defaults: store) {
return true
}
let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires)
return expires > Date().timeIntervalSince1970
}
@@ -25,6 +25,18 @@ public enum FlowSessionPolicy {
inactivityDuration(defaults: defaults).timeInterval
}
public static func keepAliveMode(defaults: UserDefaults? = nil) -> FlowKeepAliveMode {
let store = resolvedDefaults(defaults)
return FlowKeepAliveMode.fromStored(
store.string(forKey: AppGroupConfiguration.Keys.flowKeepAliveMode)
)
}
/// PiP sessions have no inactivity expiry; only the Live Activity path times out.
public static func usesInactivityExpiry(defaults: UserDefaults? = nil) -> Bool {
keepAliveMode(defaults: defaults) == .liveActivity
}
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
if let defaults { return defaults }
guard let available = AppGroup.defaultsIfAvailable else {