refactor(flow): remove Live Activity and keep-alive mode picker

Keep voice sessions on silent low-profile PiP only, and fix custom polish style editor presentation via sheet(item:).
This commit is contained in:
Rocky
2026-08-07 11:32:31 +08:00
parent d237318bf3
commit 41b8db87b5
44 changed files with 276 additions and 1997 deletions
@@ -57,8 +57,6 @@ 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.
@@ -102,8 +100,6 @@ 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.
@@ -292,9 +288,6 @@ 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)
),
@@ -406,7 +399,6 @@ 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)
@@ -32,18 +32,6 @@ public enum FlowInactivityDuration: String, CaseIterable, Identifiable, Sendable
}
}
public var labelKey: String {
switch self {
case .oneMinute: return "settings.flow.inactivity.1m"
case .fiveMinutes: return "settings.flow.inactivity.5m"
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
@@ -1,39 +0,0 @@
// 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.
case liveActivity = "liveActivity"
/// Picture-in-picture waveform keep-alive; mic released between utterances.
case pictureInPicture = "pictureInPicture"
public var id: String { rawValue }
/// Used when no valid keep-alive preference has been stored.
public static let `default`: FlowKeepAliveMode = .pictureInPicture
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
}
}
+1 -18
View File
@@ -259,22 +259,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
}
/// 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.
/// Retained for storage compatibility; persistent PiP sessions do not expire from inactivity.
@Published public var flowInactivityDuration: FlowInactivityDuration {
didSet {
guard !isApplyingConfiguration,
@@ -406,7 +391,6 @@ 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
@@ -498,7 +482,6 @@ 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
@@ -31,7 +31,6 @@ 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(
@@ -55,7 +54,6 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
activePolishStyleId: SyncedField<String>,
llmThinkingEnabled: SyncedField<Bool>,
flowSkipAppSwitch: SyncedField<Bool>,
flowKeepAliveMode: SyncedField<FlowKeepAliveMode>,
flowInactivityDuration: SyncedField<FlowInactivityDuration>
) {
self.schemaVersion = schemaVersion
@@ -82,7 +80,6 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
self.activePolishStyleId = activePolishStyleId
self.llmThinkingEnabled = llmThinkingEnabled
self.flowSkipAppSwitch = flowSkipAppSwitch
self.flowKeepAliveMode = flowKeepAliveMode
self.flowInactivityDuration = flowInactivityDuration
}
@@ -107,10 +104,13 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
case activePolishStyleId
case llmThinkingEnabled
case flowSkipAppSwitch
case flowKeepAliveMode
case flowInactivityDuration
}
private enum LegacyCodingKeys: String, CodingKey {
case flowKeepAliveMode
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try container.decode(Int.self, forKey: .schemaVersion)
@@ -167,18 +167,15 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
deviceID: keyboardHapticIntensity.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
)
let legacyContainer = try decoder.container(keyedBy: LegacyCodingKeys.self)
_ = try legacyContainer.decodeIfPresent(
SyncedField<String>.self,
forKey: .flowKeepAliveMode
)
if let asrProvider = try container.decodeIfPresent(SyncedField<String>.self, forKey: .asrProviderId) {
asrProviderId = asrProvider
@@ -224,7 +221,6 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
activePolishStyleId.updatedAt,
llmThinkingEnabled.updatedAt,
flowSkipAppSwitch.updatedAt,
flowKeepAliveMode.updatedAt,
flowInactivityDuration.updatedAt,
].max() ?? .distantPast
}
@@ -264,7 +260,6 @@ public extension SyncedAppSettingsV2 {
activePolishStyleId: field(configuration.activePolishStyleId),
llmThinkingEnabled: field(configuration.llmThinkingEnabled),
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
flowKeepAliveMode: field(configuration.flowKeepAliveMode),
flowInactivityDuration: field(configuration.flowInactivityDuration)
)
}
@@ -296,7 +291,6 @@ public extension SyncedAppSettingsV2 {
activePolishStyleId: field(PolishStylePackCatalog.defaultID),
llmThinkingEnabled: field(false),
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
flowKeepAliveMode: field(.liveActivity),
flowInactivityDuration: field(legacy.flowInactivityDuration)
)
}
@@ -340,7 +334,6 @@ 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
@@ -368,7 +361,6 @@ public extension SyncedAppSettingsV2 {
configuration.activePolishStyleId = activePolishStyleId.value
configuration.llmThinkingEnabled = llmThinkingEnabled.value
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
configuration.flowKeepAliveMode = flowKeepAliveMode.value
configuration.flowInactivityDuration = flowInactivityDuration.value
}
@@ -398,7 +390,6 @@ 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
}
@@ -431,7 +422,6 @@ 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
}
@@ -17,7 +17,7 @@ public enum FlowKeyboardHostWarming {
/// Keep the mic green after the session has already proven ready.
///
/// Inter-utterance PiP flaps (mic release, ack lag, brief `reason=.starting`)
/// used to flash yelloweven though Picture in Picture was
/// used to flash yelloweven though the keep-alive surface was
/// already running. Hold ready through those windows; real cold starts still
/// go through `isHostWarming` while `sessionProvenReady` is false.
public static func shouldHoldReady(
@@ -405,9 +405,8 @@ public enum FlowSessionBridge {
// a permanent orange `preparingSession` state.
clearHostReady(defaults: store, notify: false)
}
if let expires = snapshot.sessionExpiresAt {
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
}
// PiP sessions are persistent; clear expiry left by older Live Activity builds.
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
// Only a genuinely live host ready, or actively serving an
// utterance may refresh the heartbeat here. A host stuck in a
// failed cold start would otherwise keep "reviving" itself on every
@@ -430,20 +429,7 @@ public enum FlowSessionBridge {
// MARK: - Session lifecycle (host app)
public static func markSessionActive(
duration: TimeInterval? = nil,
sessionId: UUID? = nil,
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
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).
/// PiP keep-alive: session stays valid until explicit teardown.
public static func markSessionActivePersistent(
sessionId: UUID? = nil,
defaults: UserDefaults? = nil
@@ -480,44 +466,6 @@ 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.flowCommandJournalPayload)
defaults.removeObject(forKey: FlowSessionKeys.flowResultPayload)
defaults.removeObject(forKey: FlowSessionKeys.flowAckPayload)
defaults.removeObject(forKey: FlowSessionKeys.pendingKeyboardUtteranceId)
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)
@@ -544,31 +492,6 @@ public enum FlowSessionBridge {
flush(store)
}
public static func extendSession(
by duration: TimeInterval? = nil,
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)
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)
guard FlowSessionPolicy.usesInactivityExpiry(defaults: store) else { return }
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) {
@@ -592,18 +515,11 @@ public enum FlowSessionBridge {
// MARK: - Session validity (keyboard)
/// True when the App Group session contract is still valid (not expired).
/// True while the persistent PiP session contract is active.
/// Does **not** mean the host can accept utterances use `isHostReady()`.
public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool {
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
return store.bool(forKey: FlowSessionKeys.flowSessionActive)
}
/// Seconds since the host last wrote `flowHeartbeat`; nil when never written.
@@ -800,19 +716,6 @@ public enum FlowSessionBridge {
return true
}
public static func sessionExpiresAt(defaults: UserDefaults? = nil) -> TimeInterval? {
let store = resolvedDefaults(defaults)
let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires)
return expires > 0 ? expires : nil
}
/// Seconds until session expiry; nil when expired or never started.
public static func remainingSessionDuration(defaults: UserDefaults? = nil) -> TimeInterval? {
guard let expires = sessionExpiresAt(defaults: defaults) else { return nil }
let remaining = expires - Date().timeIntervalSince1970
return remaining > 0 ? remaining : nil
}
// MARK: - Recording signals (keyboard host)
public static func setRecordingState(
@@ -25,18 +25,6 @@ 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 {
@@ -36,15 +36,8 @@ public enum FlowDebugAppGroupSnapshot {
}
return String(g.prefix(8))
}()
let expires: String = {
guard let ts = FlowSessionBridge.sessionExpiresAt(defaults: defaults) else { return "nil" }
let remaining = ts - Date().timeIntervalSince1970
return String(format: "%.0fs", remaining)
}()
return [
FlowDebugRow("sessionActive", FlowSessionBridge.isSessionActive(defaults: defaults) ? "1" : "0"),
FlowDebugRow("expiresIn", expires),
FlowDebugRow("hostReachable", FlowSessionBridge.isHostReachable(defaults: defaults) ? "1" : "0"),
FlowDebugRow("hostReady", FlowSessionBridge.isHostReady(defaults: defaults) ? "1" : "0"),
FlowDebugRow("hostStale", FlowSessionBridge.isHostStale(defaults: defaults) ? "1" : "0"),