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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(©.activePolishStyleId, value: configuration.activePolishStyleId)
|
||||
patch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
patch(©.flowKeepAliveMode, value: configuration.flowKeepAliveMode)
|
||||
patch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
}
|
||||
@@ -373,6 +391,7 @@ public extension SyncedAppSettingsV2 {
|
||||
touch(©.activePolishStyleId, value: configuration.activePolishStyleId)
|
||||
touch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
touch(©.flowKeepAliveMode, value: configuration.flowKeepAliveMode)
|
||||
touch(©.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 {
|
||||
|
||||
Reference in New Issue
Block a user