From 41b8db87b51070868f43efeeb522d6831b461452 Mon Sep 17 00:00:00 2001
From: Rocky <72559939+hkgood@users.noreply.github.com>
Date: Fri, 7 Aug 2026 11:32:31 +0800
Subject: [PATCH] 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:).
---
CHANGELOG.md | 6 +
OSGKeyboard/Info.plist | 2 -
.../Services/FlowLiveActivityController.swift | 144 ----
.../FlowPictureInPictureController.swift | 10 +-
OSGKeyboard/Services/FlowSessionManager.swift | 687 ++++--------------
.../Services/FlowTerminationCoordinator.swift | 3 +-
OSGKeyboard/Views/FlowColdStartOverlay.swift | 376 ----------
OSGKeyboard/Views/HomeView.swift | 11 -
OSGKeyboard/Views/PolishStylesView.swift | 46 +-
.../Views/SettingsPreferenceRows.swift | 50 --
.../Views/SettingsSecondaryPages.swift | 72 --
OSGKeyboard/Views/SettingsView.swift | 4 -
OSGKeyboard/en.lproj/Localizable.strings | 49 +-
OSGKeyboard/zh-Hans.lproj/Localizable.strings | 49 +-
.../Services/KeyboardFlowCoordinator.swift | 3 +-
OSGKeyboardExt/en.lproj/Keyboard.strings | 2 -
OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 2 -
.../Assets.xcassets/Contents.json | 6 -
.../OSGLogo.imageset/Contents.json | 15 -
.../OSGLogo.imageset/OSGLogo.svg | 7 -
.../FlowActivityAttributes.swift | 22 -
.../FlowLiveActivityWidget.swift | 198 -----
OSGKeyboardLiveActivity/Info.plist | 31 -
.../OSGKeyboardLiveActivityBundle.swift | 12 -
.../en.lproj/Localizable.strings | 7 -
.../zh-Hans.lproj/Localizable.strings | 6 -
OSGKeyboardMac/MacPolishStylesView.swift | 38 +-
.../Models/AppGroupConfiguration.swift | 8 -
.../Models/FlowInactivityDuration.swift | 12 -
.../Models/FlowKeepAliveMode.swift | 39 -
OSGKeyboardShared/Models/ProviderConfig.swift | 19 +-
.../Models/SyncedAppSettingsV2.swift | 28 +-
.../Services/FlowKeyboardPolicies.swift | 2 +-
.../Services/FlowSessionBridge.swift | 107 +--
.../Services/FlowSessionPolicy.swift | 12 -
OSGKeyboardShared/Views/FlowDebugPanel.swift | 7 -
.../AppGroupConfigurationTests.swift | 15 -
OSGKeyboardTests/FlowHandoffPolicyTests.swift | 4 +-
OSGKeyboardTests/FlowSessionBridgeTests.swift | 58 +-
OSGKeyboardTests/FlowSessionPolicyTests.swift | 32 +-
OSGKeyboardTests/SettingsCloudSyncTests.swift | 27 +-
README.en.md | 2 +-
README.md | 2 +-
project.yml | 41 +-
44 files changed, 276 insertions(+), 1997 deletions(-)
delete mode 100644 OSGKeyboard/Services/FlowLiveActivityController.swift
delete mode 100644 OSGKeyboard/Views/FlowColdStartOverlay.swift
delete mode 100644 OSGKeyboardLiveActivity/Assets.xcassets/Contents.json
delete mode 100644 OSGKeyboardLiveActivity/Assets.xcassets/OSGLogo.imageset/Contents.json
delete mode 100644 OSGKeyboardLiveActivity/Assets.xcassets/OSGLogo.imageset/OSGLogo.svg
delete mode 100644 OSGKeyboardLiveActivity/FlowActivityAttributes.swift
delete mode 100644 OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift
delete mode 100644 OSGKeyboardLiveActivity/Info.plist
delete mode 100644 OSGKeyboardLiveActivity/OSGKeyboardLiveActivityBundle.swift
delete mode 100644 OSGKeyboardLiveActivity/en.lproj/Localizable.strings
delete mode 100644 OSGKeyboardLiveActivity/zh-Hans.lproj/Localizable.strings
delete mode 100644 OSGKeyboardShared/Models/FlowKeepAliveMode.swift
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1bbee37..d084cf7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Removed
+- **Keep-alive settings & Live Activity**: remove Settings keep-alive mode picker and its note; delete the Dynamic Island Live Activity extension and all ActivityKit session code. Voice sessions stay on silent low-profile PiP only, with user-facing copy that never names Picture in Picture. / **保活设置与灵动岛**:移除设置中的保活方式选项及说明;删除灵动岛 Live Activity 扩展与全部 ActivityKit 会话代码。语音会话仅保留静默低感知 PiP,用户可见文案不再出现「画中画」。
+
+### Fixed
+- **Custom polish style editor**: open create/edit with `sheet(item:)` so the form always loads the selected pack instead of a blank default template. / **自定义润色风格编辑**:新建/编辑改为 `sheet(item:)` 呈现,表单始终加载所选风格,而不再偶发显示空白默认模板。
+
## [1.6.5] - 2026-08-06
### Added
diff --git a/OSGKeyboard/Info.plist b/OSGKeyboard/Info.plist
index 44cacfb..1add840 100644
--- a/OSGKeyboard/Info.plist
+++ b/OSGKeyboard/Info.plist
@@ -97,8 +97,6 @@
OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.
NSSpeechRecognitionUsageDescription
OSGKeyboard uses speech recognition to transcribe your voice. Audio is processed on-device by default, or sent to your configured speech provider only when you enable cloud recognition.
- NSSupportsLiveActivities
-
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
diff --git a/OSGKeyboard/Services/FlowLiveActivityController.swift b/OSGKeyboard/Services/FlowLiveActivityController.swift
deleted file mode 100644
index cc8cc5c..0000000
--- a/OSGKeyboard/Services/FlowLiveActivityController.swift
+++ /dev/null
@@ -1,144 +0,0 @@
-// FlowLiveActivityController.swift
-// OSGKeyboard · Main App
-//
-// Starts and updates the Flow Live Activity so the Dynamic Island shows the
-// OSGKeyboard brand mark while a voice session is active.
-
-import ActivityKit
-import Foundation
-import OSGKeyboardShared
-
-enum FlowLiveActivityController {
- nonisolated(unsafe) private static var currentActivity: Activity?
- /// Last phase pushed to the Live Activity so `keepAlive()` can refresh the
- /// `staleDate` without changing what the user sees.
- nonisolated(unsafe) private static var currentPhase: FlowActivityAttributes.ContentState.Phase = .idle
-
- /// If the host app is force-quit its `endSession()` never runs, orphaning
- /// the Live Activity. `staleDate` semantics (verified against ActivityKit
- /// behaviour, not folklore): the *Dynamic Island* presentation is reliably
- /// removed shortly after the stale date passes, but the *lock-screen*
- /// banner may linger greyed-out depending on the iOS version — it is NOT
- /// guaranteed to be dismissed. Treating staleDate as "auto-cleanup" is
- /// therefore wrong on its own; the full zombie defence is this short
- /// window + launch-time reconciliation (`clearOrphanedActivities`) + the
- /// widget rendering an explicit "disconnected" state via
- /// `context.isStale`. While the host is alive the heartbeat calls
- /// `keepAlive()` every ~10 s, well inside this window.
- private static let staleWindow: TimeInterval = 30
-
- private static func freshContent(
- phase: FlowActivityAttributes.ContentState.Phase
- ) -> ActivityContent {
- ActivityContent(
- state: FlowActivityAttributes.ContentState(phase: phase),
- staleDate: Date().addingTimeInterval(staleWindow)
- )
- }
-
- /// Begin showing OSGKeyboard in the Dynamic Island for an active Flow session.
- static func startSession() {
- guard ActivityAuthorizationInfo().areActivitiesEnabled else {
- FlowDiagnostics.log("Live Activity disabled in Settings")
- return
- }
-
- endStaleActivities()
-
- guard currentActivity == nil else {
- update(phase: .idle)
- return
- }
-
- do {
- currentPhase = .idle
- currentActivity = try Activity.request(
- attributes: FlowActivityAttributes(),
- content: freshContent(phase: .idle),
- pushType: nil
- )
- FlowDiagnostics.log("Live Activity started")
- } catch {
- FlowDiagnostics.log("Live Activity start failed: \(error.localizedDescription)")
- }
- }
-
- static func update(phase: FlowActivityAttributes.ContentState.Phase) {
- guard let activity = currentActivity else { return }
- currentPhase = phase
- let content = freshContent(phase: phase)
- Task {
- await activity.update(content)
- }
- }
-
- /// Push a fresh `staleDate` without changing the visible phase. The host
- /// heartbeat calls this well inside `staleWindow` so an in-use session
- /// never looks stale; once the process dies the refreshes stop and the
- /// system reclaims the orphaned Live Activity on its own.
- static func keepAlive() {
- guard let activity = currentActivity else { return }
- let content = freshContent(phase: currentPhase)
- Task {
- await activity.update(content)
- }
- }
-
- /// Dismiss the island presentation when the Flow session ends.
- static func endSession() {
- currentPhase = .idle
- guard let activity = currentActivity else {
- endStaleActivities()
- return
- }
-
- currentActivity = nil
- Task {
- await activity.end(nil, dismissalPolicy: .immediate)
- FlowDiagnostics.log("Live Activity ended")
- }
- }
-
- /// Clear Live Activities orphaned by a previous (force-quit) host process.
- ///
- /// Safe to call on every app foreground: when this process already owns a
- /// Live Activity (`currentActivity != nil`) we leave it alone so a healthy
- /// running session is never torn down; we only sweep leftovers that belong
- /// to a dead process. Call this *before* attempting to (re)start a session
- /// so a failed start (e.g. mic timeout) still clears the stale island.
- static func clearOrphanedActivities() {
- guard currentActivity == nil else { return }
- endStaleActivities()
- }
-
- /// Host relaunch can leave orphan activities; clear them before starting anew.
- private static func endStaleActivities() {
- let staleActivities = Activity.activities
- currentActivity = nil
- guard !staleActivities.isEmpty else { return }
- Task {
- for activity in staleActivities {
- await activity.end(nil, dismissalPolicy: .immediate)
- }
- }
- }
-
- /// `applicationWillTerminate` 专用:阻塞到所有 `end` 完成,避免进程先退出而锁屏卡片残留。
- /// 等待必须带超时:ActivityKit 的 `end` 走异步 XPC,若在 watchdog 杀进程前
- /// 没有返回,无限期 `wait()` 会吞掉整个 ~5 秒终止窗口,反而让后续清理全部没跑。
- nonisolated static func endAllSynchronouslyOnTerminate() {
- let semaphore = DispatchSemaphore(value: 0)
- Task.detached(priority: .userInitiated) {
- let activities = Activity.activities
- let count = activities.count
- for activity in activities {
- await activity.end(activity.content, dismissalPolicy: .immediate)
- }
- FlowDiagnostics.log("Live Activity ended synchronously on terminate (count=\(count))")
- semaphore.signal()
- }
- _ = semaphore.wait(timeout: .now() + 2)
- currentPhase = .idle
- currentActivity = nil
- }
-}
diff --git a/OSGKeyboard/Services/FlowPictureInPictureController.swift b/OSGKeyboard/Services/FlowPictureInPictureController.swift
index 1ed594e..8703250 100644
--- a/OSGKeyboard/Services/FlowPictureInPictureController.swift
+++ b/OSGKeyboard/Services/FlowPictureInPictureController.swift
@@ -22,11 +22,11 @@ enum FlowPiPStartFailure: Equatable, Sendable {
var localizationKey: String {
switch self {
- case .unsupported: return "flow.pip.error.unsupported"
- case .hostNotReady: return "flow.pip.error.hostNotReady"
- case .notPossible: return "flow.pip.error.notPossible"
- case .systemRejected: return "flow.pip.error.systemRejected"
- case .timedOut: return "flow.pip.error.timedOut"
+ case .unsupported: return "flow.session.error.unsupported"
+ case .hostNotReady: return "flow.session.error.hostNotReady"
+ case .notPossible: return "flow.session.error.notPossible"
+ case .systemRejected: return "flow.session.error.systemRejected"
+ case .timedOut: return "flow.session.error.timedOut"
}
}
}
diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift
index 3b42697..ca0964f 100644
--- a/OSGKeyboard/Services/FlowSessionManager.swift
+++ b/OSGKeyboard/Services/FlowSessionManager.swift
@@ -38,8 +38,6 @@ 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 pipController = FlowPictureInPictureController()
@@ -64,7 +62,6 @@ final class FlowSessionManager: ObservableObject {
private var pollingTask: Task?
private var heartbeatTask: Task?
- private var expiryTask: Task?
private var levelTask: Task?
private var startTask: Task?
private var commandObserver: FlowSessionDarwinObserver?
@@ -106,22 +103,12 @@ final class FlowSessionManager: ObservableObject {
private var utteranceRecordingStartedAt: Date?
/// True while the host app scene is `.active` — drives foreground renewal.
private var isAppForeground = false
- private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
- private var backgroundTaskExpiryTask: Task?
/// True while handling a keyboard-initiated `startflow` cold start.
private var isColdStartHandoff = false
private var coldStartRecoveryTask: Task?
/// Initial proof window — cold mic sessions often need >2.5s after app switch.
private static let coldStartAudioProofTimeout: TimeInterval = 6
- private var keepAliveMode: FlowKeepAliveMode {
- FlowSessionPolicy.keepAliveMode()
- }
-
- private var usesPiPKeepAlive: Bool {
- keepAliveMode == .pictureInPicture
- }
-
var shouldDeferHostHeavyWork: Bool {
isUtteranceRecording || isUtteranceProcessing || hasUnacknowledgedTerminalResult()
}
@@ -130,21 +117,6 @@ final class FlowSessionManager: ObservableObject {
pipController.attachHostView(view)
}
- /// Live Activity is mutually exclusive with PiP keep-alive.
- private func updateLiveActivityPhase(_ phase: FlowActivityAttributes.ContentState.Phase) {
- guard !usesPiPKeepAlive else { return }
- FlowLiveActivityController.update(phase: phase)
- }
-
- private func startLiveActivityIfNeeded() {
- guard !usesPiPKeepAlive else {
- // Sweep any orphan island left from a previous Live Activity session.
- FlowLiveActivityController.clearOrphanedActivities()
- return
- }
- FlowLiveActivityController.startSession()
- }
-
/// Guards the once-per-process launch reconciliation (scene reconnects
/// recreate the `@StateObject`-owned manager within the same process).
private static var didRunLaunchReconciliation = false
@@ -153,7 +125,7 @@ final class FlowSessionManager: ObservableObject {
// Sessions are (re)started explicitly on app foreground via
// `activateOnForeground()`. We deliberately do NOT silently reattach a
// stored session here — after a force-quit that would resurrect capture
- // (and keep a stale Live Activity alive) without the user re-opening.
+ // without the user re-opening.
//
// Launch reconciliation: a brand-new process can never own an
// in-flight session, so whatever the previous generation persisted
@@ -171,7 +143,6 @@ final class FlowSessionManager: ObservableObject {
let previous = FlowSessionBridge.rotateHostGeneration()
if previous != nil || FlowSessionBridge.isSessionActive() {
FlowSessionBridge.clearFlowStateOnHostLaunch()
- FlowLiveActivityController.clearOrphanedActivities()
FlowSessionDarwin.postSessionChanged()
debug("launch reconciliation: voided previous-generation Flow state")
}
@@ -219,7 +190,7 @@ final class FlowSessionManager: ObservableObject {
)
traceState(
"startSession.request",
- extra: "coldStart=\(coldStart) reason=\(reason) duration=\(Int(duration ?? FlowSessionPolicy.sessionDuration()))"
+ extra: "coldStart=\(coldStart) reason=\(reason)"
)
guard AppGroup.isAvailable else {
debug("cannot start flow session: App Group unavailable")
@@ -247,7 +218,6 @@ final class FlowSessionManager: ObservableObject {
return
case .present:
isColdStartHandoff = true
- showColdStartPreparing()
Task { @MainActor [weak self] in
await self?.prepareExistingSessionForColdStartReturn()
}
@@ -257,7 +227,6 @@ final class FlowSessionManager: ObservableObject {
if coldStart {
isColdStartHandoff = true
- showColdStartPreparing()
}
if isActive {
@@ -295,7 +264,6 @@ final class FlowSessionManager: ObservableObject {
endSession()
} else {
FlowSessionBridge.clearFlowState()
- FlowLiveActivityController.endSession()
}
debug("reconciled zombie persisted Flow state")
case .clearOrphanedRecording(let orphaned):
@@ -309,8 +277,8 @@ final class FlowSessionManager: ObservableObject {
/// Foreground entry for Flow.
///
- /// Default is **light** for audio work: clear orphaned Live Activities /
- /// permission UI and automatically arm the low-profile PiP, but do not
+ /// Default is **light** for audio work: automatically arm the low-profile
+ /// PiP, but do not
/// start capture or ASR. The transparent PiP is intentionally cheap; the
/// 170–220 MB capture/model path still starts only on explicit speech.
///
@@ -330,87 +298,30 @@ final class FlowSessionManager: ObservableObject {
OSGDiag.log("activateOnForeground aborted reason=appGroupUnavailable", category: "flow")
return
}
- // Sweep any Live Activity a previous (force-quit) process left behind
- // *before* we try to (re)start a session. Doing it here — rather than
- // only inside `startSession()`'s success path — means a start that
- // later fails (e.g. mic proof timeout) still clears the stale island
- // instead of leaving a zombie on the lock screen / Dynamic Island.
- // No-op when this process already owns a healthy Live Activity.
- FlowLiveActivityController.clearOrphanedActivities()
-
guard AppPermissions.flowRequirementsMet else {
OSGDiag.log("activateOnForeground aborted reason=permissions", category: "flow")
sessionWarning = permissionWarningMessage()
FlowSessionBridge.setHostReady(false)
- if isColdStartHandoff {
- showColdStartPermissionFailure()
- }
- FlowLiveActivityController.endSession()
return
}
- let shouldAutoArmPiP = keepAliveMode == .pictureInPicture
- guard startCapture || shouldAutoArmPiP else {
- OSGDiag.log(
- "activateOnForeground light — skip capture (keyboard survival) \(OSGDiag.memoryTag())",
- category: "flow"
- )
- return
- }
startSession(
- reason: shouldAutoArmPiP && !startCapture
+ reason: !startCapture
? "activateOnForeground.autoPiP:\(reason)"
: "activateOnForeground:\(reason)"
)
}
- func dismissColdStartOverlay() {
+ private func completeColdStartHandoff() {
coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = nil
- // Clear handoff flags BEFORE any refreshHostReady call. Otherwise
- // refresh → reconcileColdStartOverlayIfRecovered → dismiss → refresh
- // recurses until the main-thread stack overflows (EXC_BAD_ACCESS,
- // "Thread stack size exceeded due to excessive recursion").
- coldStartContext = nil
isColdStartHandoff = false
if isActive {
refreshHostReady()
}
}
- func returnToPendingHostFromColdStart() {
- _ = HostReturnService.openPendingHostIfPossible()
- dismissColdStartOverlay()
- }
-
- func retryColdStartReadiness() {
- guard AppGroup.isAvailable else { return }
- // A failed cold start leaves capture in a running-but-dead state on
- // purpose (the recovery loop keeps probing it). A user-initiated
- // retry must instead begin from a clean pipeline: tear down capture
- // and the cached ASR instance so `startSession` rebuilds both —
- // otherwise the retry reuses the zombie engine and is guaranteed to
- // hit the same audio-proof timeout.
- coldStartRecoveryTask?.cancel()
- coldStartRecoveryTask = nil
- if usesPiPKeepAlive {
- pipController.stop()
- } else if capture.running {
- capture.stop()
- }
- sessionASR?.cancel()
- sessionASR = nil
- sessionASREngineMode = nil
- sessionASRWarmedLocaleID = nil
- startSession(coldStart: true, reason: "retryColdStartReadiness")
- }
-
- func openColdStartPermissionSettings() {
- AppPermissions.openSystemSettings()
- }
-
- /// 强杀专用同步 teardown:不等待 ASR/LLM;Live Activity 由
- /// `FlowTerminationCoordinator` 同步 `end`。
+ /// 强杀专用同步 teardown:不等待 ASR/LLM。
func prepareForProcessTermination() {
debug("prepareForProcessTermination")
if isUtteranceRecording || isUtteranceProcessing,
@@ -422,7 +333,6 @@ final class FlowSessionManager: ObservableObject {
)
}
- coldStartContext = nil
isColdStartHandoff = false
coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = nil
@@ -433,8 +343,6 @@ final class FlowSessionManager: ObservableObject {
pollingTask = nil
heartbeatTask?.cancel()
heartbeatTask = nil
- expiryTask?.cancel()
- expiryTask = nil
levelTask?.cancel()
levelTask = nil
finalizeTask?.cancel()
@@ -459,7 +367,6 @@ final class FlowSessionManager: ObservableObject {
}
pipController.stop()
- endBackgroundKeepAlive()
ScreenWakeLock.release()
sessionASR?.cancel()
@@ -504,7 +411,6 @@ final class FlowSessionManager: ObservableObject {
)
}
- coldStartContext = nil
isColdStartHandoff = false
coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = nil
@@ -515,8 +421,6 @@ final class FlowSessionManager: ObservableObject {
pollingTask = nil
heartbeatTask?.cancel()
heartbeatTask = nil
- expiryTask?.cancel()
- expiryTask = nil
levelTask?.cancel()
levelTask = nil
finalizeTask?.cancel()
@@ -542,14 +446,12 @@ final class FlowSessionManager: ObservableObject {
capture.stop()
pipController.stop()
- endBackgroundKeepAlive()
ScreenWakeLock.release()
sessionASR = nil
sessionASREngineMode = nil
sessionASRWarmedLocaleID = nil
FlowSessionBridge.markSessionInactive()
FlowSessionDarwin.postSessionChanged()
- FlowLiveActivityController.endSession()
isActive = false
sessionExpiresAt = nil
sessionWarning = nil
@@ -559,14 +461,8 @@ final class FlowSessionManager: ObservableObject {
}
func extendSession(duration: TimeInterval? = nil) {
- guard !usesPiPKeepAlive else {
- refreshHostReady()
- return
- }
- let resolved = duration ?? FlowSessionPolicy.sessionDuration()
- FlowSessionBridge.extendSession(by: resolved)
- sessionExpiresAt = Date().addingTimeInterval(resolved)
- scheduleExpiry(after: resolved)
+ _ = duration
+ refreshHostReady()
}
/// Called from `OSGKeyboardApp` when `scenePhase` changes.
@@ -582,45 +478,27 @@ final class FlowSessionManager: ObservableObject {
resumeAfterForeground()
case .inactive:
writeHeartbeatIfActive()
- if usesPiPKeepAlive, isActive {
+ if isActive {
Task { @MainActor [weak self] in
await self?.pipController.prepareForBackgroundAutoStart()
}
}
case .background:
setAppForeground(false)
- if usesPiPKeepAlive, isActive {
+ if isActive {
Task { @MainActor [weak self] in
await self?.pipController.prepareForBackgroundAutoStart()
}
}
- if coldStartContext != nil {
- dismissColdStartOverlay()
+ if isColdStartHandoff {
+ completeColdStartHandoff()
}
- // Idle continuous capture in the background jetsams the keyboard
- // (~200 MB host RSS). Keep the session contract only while speaking.
- releaseIdleCaptureForKeyboardSurvival()
beginBackgroundKeepAlive()
@unknown default:
break
}
}
- /// Stops idle AVAudioEngine capture so a backgrounded host does not crowd
- /// the keyboard extension out of memory. Active recording/processing keep
- /// the mic; the next mic press / foreground Start restarts capture.
- private func releaseIdleCaptureForKeyboardSurvival() {
- guard isActive, !usesPiPKeepAlive else { return }
- guard !isUtteranceRecording, !isUtteranceProcessing else { return }
- guard capture.running else { return }
- OSGDiag.log(
- "releaseIdleCapture (background) \(OSGDiag.memoryTag())",
- category: "flow"
- )
- capture.stop()
- refreshHostReady()
- }
-
private func writeHeartbeatIfActive() {
guard isActive else { return }
FlowSessionBridge.writeHeartbeat()
@@ -629,40 +507,14 @@ final class FlowSessionManager: ObservableObject {
private func beginBackgroundKeepAlive() {
guard isActive else { return }
FlowSessionBridge.writeHeartbeat()
- // Active PiP already owns the background execution contract. Starting
- // an additional UIApplication task here produced >30s watchdog warnings.
- guard !usesPiPKeepAlive else { return }
-
- guard backgroundTaskID == .invalid else { return }
- backgroundTaskID = UIApplication.shared.beginBackgroundTask { [weak self] in
- self?.endBackgroundKeepAlive()
- }
- backgroundTaskExpiryTask?.cancel()
- backgroundTaskExpiryTask = Task { @MainActor [weak self] in
- try? await Task.sleep(nanoseconds: 10_000_000_000)
- guard !Task.isCancelled else { return }
- self?.endBackgroundKeepAlive()
- }
- debug("background keep-alive started")
- }
-
- private func endBackgroundKeepAlive() {
- guard backgroundTaskID != .invalid else { return }
- backgroundTaskExpiryTask?.cancel()
- backgroundTaskExpiryTask = nil
- UIApplication.shared.endBackgroundTask(backgroundTaskID)
- backgroundTaskID = .invalid
- debug("background keep-alive ended")
}
private func resumeAfterForeground() {
guard isActive else {
- endBackgroundKeepAlive()
return
}
FlowSessionBridge.writeHeartbeat()
- endBackgroundKeepAlive()
Task { @MainActor [weak self] in
await self?.reactivateCaptureIfNeeded()
@@ -679,11 +531,9 @@ final class FlowSessionManager: ObservableObject {
// processing). Only reassert when an utterance is actively recording
// with capture already running — otherwise a foreground bounce was
// cold-starting the mic mid-finalize (`!pri` / session churn).
- if usesPiPKeepAlive {
- guard isUtteranceRecording, capture.running else {
- refreshHostReady()
- return
- }
+ guard isUtteranceRecording, capture.running else {
+ refreshHostReady()
+ return
}
// A system interruption (call / Siri) may be in progress. Probe it:
// `setActive(true)` inside `reassertIfRunning` fails while the
@@ -746,7 +596,6 @@ final class FlowSessionManager: ObservableObject {
reason: .noSession,
engineMode: store.engineMode,
localeId: store.localeId,
- sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(),
hostGeneration: FlowSessionBridge.currentHostGeneration()
)
)
@@ -756,24 +605,13 @@ final class FlowSessionManager: ObservableObject {
let pollingAlive = pollingTask != nil && pollingTask?.isCancelled != true
let hasRecentAudio = capture.engineHasRecentAudio(maxAge: 2)
let hasPendingDelivery = hasUnacknowledgedTerminalResult()
- let canAcceptUtterance: Bool
- if usesPiPKeepAlive {
- canAcceptUtterance = pipController.isPictureInPictureActive
- && pollingAlive
- && !isUtteranceRecording
- && !isUtteranceProcessing
- && sessionWarning == nil
- && !capture.isInterrupted
- && !hasPendingDelivery
- } else {
- canAcceptUtterance = capture.engineIsLive
- && pollingAlive
- && hasRecentAudio
- && !isUtteranceRecording
- && !isUtteranceProcessing
- && sessionWarning == nil
- && !hasPendingDelivery
- }
+ let canAcceptUtterance = pipController.isPictureInPictureActive
+ && pollingAlive
+ && !isUtteranceRecording
+ && !isUtteranceProcessing
+ && sessionWarning == nil
+ && !capture.isInterrupted
+ && !hasPendingDelivery
let reason: FlowReadySnapshot.Reason
if canAcceptUtterance {
@@ -786,16 +624,12 @@ final class FlowSessionManager: ObservableObject {
reason = .processing
} else if hasPendingDelivery {
reason = .awaitingDelivery
- } else if usesPiPKeepAlive, !pipController.isPictureInPictureActive {
+ } else if !pipController.isPictureInPictureActive {
reason = .starting
- } else if !usesPiPKeepAlive, !capture.engineIsLive {
- reason = .audioEngineNotLive
- } else if !usesPiPKeepAlive, !hasRecentAudio {
- reason = .waitingForAudioProof
- } else if usesPiPKeepAlive, pipController.isPictureInPictureActive {
+ } else if pipController.isPictureInPictureActive {
// PiP is already up — transient gates (!polling / interruption)
// are not a cold start. Prefer awaitingDelivery-adjacent idle over
- // `.starting` so the keyboard never flashes「正在启动画中画」.
+ // `.starting` so the keyboard never flashes a false “starting” state.
reason = .awaitingDelivery
} else {
reason = .starting
@@ -815,7 +649,6 @@ final class FlowSessionManager: ObservableObject {
busyUtteranceId: isUtteranceRecording || isUtteranceProcessing
? currentUtteranceId
: (hasPendingDelivery ? FlowSessionBridge.latestResult()?.utteranceId : nil),
- sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(),
hostGeneration: FlowSessionBridge.currentHostGeneration()
)
)
@@ -832,37 +665,6 @@ final class FlowSessionManager: ObservableObject {
lastReadyTraceSignature = signature
traceState("hostReady.update", extra: signature)
}
- reconcileColdStartOverlayIfRecovered()
- }
-
- /// When the host contract turns green while the cold-start overlay still
- /// shows a stale preparing/failed snapshot, heal automatically.
- private func reconcileColdStartOverlayIfRecovered() {
- guard isColdStartHandoff, isActive else { return }
- guard let context = coldStartContext else { return }
-
- // Mid-utterance is not "ready" — dismiss the ready overlay so Home
- // does not keep advertising "语音已就绪" while utt.rec=1.
- if isUtteranceRecording || isUtteranceProcessing {
- if case .ready = context.state {
- dismissColdStartOverlay()
- }
- return
- }
-
- guard FlowSessionBridge.isHostReady() else { return }
-
- switch context.state {
- case .preparing:
- presentColdStartReadyOverlay()
- case .failed:
- sessionWarning = nil
- coldStartRecoveryTask?.cancel()
- coldStartRecoveryTask = nil
- dismissColdStartOverlay()
- case .ready:
- break
- }
}
/// Home preview field gained focus while this app is the Flow host.
@@ -870,34 +672,10 @@ final class FlowSessionManager: ObservableObject {
/// custom keyboard extension sees green immediately.
func refreshForInlineKeyboardFocus() async {
guard isActive else { return }
- if usesPiPKeepAlive {
- refreshHostReady()
- FlowSessionBridge.writeHeartbeat()
- return
- }
- await reactivateCaptureIfNeeded()
refreshHostReady()
- if !FlowSessionBridge.isHostReady() {
- try? await Task.sleep(nanoseconds: 150_000_000)
- await reactivateCaptureIfNeeded()
- refreshHostReady()
- }
FlowSessionBridge.writeHeartbeat()
}
- /// Extend expiry after utterance completion based on the inactivity policy.
- private func touchSessionActivity() {
- guard isActive, !usesPiPKeepAlive 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 {
@@ -911,74 +689,32 @@ final class FlowSessionManager: ObservableObject {
sessionWarning = permissionWarningMessage()
traceState("startSessionAsync.blocked", extra: "reason=permissions")
FlowSessionBridge.setHostReady(false)
- if isColdStartHandoff {
- showColdStartPermissionFailure()
- }
isColdStartHandoff = false
return
}
- if usesPiPKeepAlive {
- switch await pipController.startAndWait() {
- case .started:
- activateFlowSessionAfterPiPProof(duration: duration)
- traceState("startSessionAsync.ready")
- debug("Flow session started (PiP keep-alive), mic released between utterances")
- case .failed(let failure):
- let message = AppL10n.string(failure.localizationKey)
- sessionWarning = message
- traceState("startSessionAsync.failed", extra: "reason=pipUnavailable failure=\(failure)")
- FlowSessionBridge.setHostReady(false)
- if isColdStartHandoff {
- showColdStartPipFailure(message: message)
- scheduleColdStartRecovery(duration: duration)
- }
- debug("PiP keep-alive failed to start: \(failure)")
- }
- return
- }
-
- do {
- try await capture.start()
- } catch {
- let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
+ switch await pipController.startAndWait() {
+ case .started:
+ activateFlowSessionAfterPiPProof(duration: duration)
+ traceState("startSessionAsync.ready")
+ debug("Flow session started (PiP keep-alive), mic released between utterances")
+ case .failed(let failure):
+ let message = AppL10n.string(failure.localizationKey)
sessionWarning = message
- traceState("startSessionAsync.failed", extra: "reason=captureStart error=\(message)")
+ traceState("startSessionAsync.failed", extra: "reason=pipUnavailable failure=\(failure)")
FlowSessionBridge.setHostReady(false)
if isColdStartHandoff {
- showColdStartAudioFailure(message: message)
- }
- debug("continuous capture failed: \(message)")
- return
- }
-
- let audioProved = await waitForAudioProof()
- guard !Task.isCancelled else { return }
- guard audioProved else {
- let message = AppL10n.string("flow.coldStart.error.audioTimeout")
- sessionWarning = message
- traceState("startSessionAsync.failed", extra: "reason=audioProofTimeout")
- FlowSessionBridge.setHostReady(false)
- if isColdStartHandoff {
- showColdStartAudioFailure(message: message)
scheduleColdStartRecovery(duration: duration)
- } else {
- capture.stop()
}
- debug("continuous capture did not produce audio frames before timeout")
- return
+ debug("PiP keep-alive failed to start: \(failure)")
}
-
- activateFlowSessionAfterAudioProof(duration: duration)
- traceState("startSessionAsync.ready")
- debug("Flow session started (\(Int(duration ?? FlowSessionPolicy.sessionDuration()))s inactivity window), continuous capture running")
}
private func activateFlowSessionAfterPiPProof(duration: TimeInterval?) {
let sessionId = activeSessionId ?? UUID()
activeSessionId = sessionId
lastHandledCommandSeq = 0
- FlowSessionBridge.markSessionActive(duration: duration, sessionId: sessionId)
+ FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId)
FlowSessionDarwin.postSessionChanged()
isActive = true
// Low-profile PiP is a system-owned keep-alive surface. Keeping the
@@ -990,135 +726,73 @@ final class FlowSessionManager: ObservableObject {
startCommandObserver()
startPolling()
startLevelPublishing()
- expiryTask?.cancel()
- expiryTask = nil
bindSessionASRIfNeeded()
// ASR warmup deferred to beginUtterance (first mic press).
- startLiveActivityIfNeeded()
refreshHostReady()
traceState("activateFlowSessionAfterPiPProof.done")
}
- private func activateFlowSessionAfterAudioProof(duration: TimeInterval?) {
- let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration()
- let sessionId = activeSessionId ?? UUID()
- activeSessionId = sessionId
- lastHandledCommandSeq = 0
- FlowSessionBridge.markSessionActive(duration: resolvedDuration, sessionId: sessionId)
- FlowSessionDarwin.postSessionChanged()
- isActive = true
- ScreenWakeLock.acquire()
- sessionExpiresAt = Date().addingTimeInterval(resolvedDuration)
-
- startHeartbeat()
- startCommandObserver()
- startPolling()
- startLevelPublishing()
- scheduleExpiry(after: resolvedDuration)
-
- bindSessionASRIfNeeded()
- // ASR warmup deferred to beginUtterance (first mic press) so session
- // start does not stack SpeechAnalyzer with Rime/CLM deploy.
- startLiveActivityIfNeeded()
-
- refreshHostReady()
- traceState("activateFlowSessionAfterAudioProof.done")
- }
-
private func prepareExistingSessionForColdStartReturn() async {
guard isColdStartHandoff, isActive else { return }
- if usesPiPKeepAlive {
- sessionWarning = nil
- if !pipController.isPictureInPictureActive {
- switch await pipController.startAndWait() {
- case .started:
- break
- case .failed(let failure):
- let message = AppL10n.string(failure.localizationKey)
- sessionWarning = message
- FlowSessionBridge.setHostReady(false)
- showColdStartPipFailure(message: message)
- scheduleColdStartRecovery(duration: nil)
- debug("existing PiP session failed cold-start restart: \(failure)")
- return
- }
- }
- refreshHostReady()
- handleColdStartAfterSessionReady()
- return
- }
- await reactivateCaptureIfNeeded()
- guard await waitForAudioProof() else {
- let message = AppL10n.string("flow.coldStart.error.audioTimeout")
- sessionWarning = message
- FlowSessionBridge.setHostReady(false)
- showColdStartAudioFailure(message: message)
- scheduleColdStartRecovery(duration: nil)
- debug("existing session failed cold-start audio proof")
- return
- }
sessionWarning = nil
+ if !pipController.isPictureInPictureActive {
+ switch await pipController.startAndWait() {
+ case .started:
+ break
+ case .failed(let failure):
+ let message = AppL10n.string(failure.localizationKey)
+ sessionWarning = message
+ FlowSessionBridge.setHostReady(false)
+ scheduleColdStartRecovery(duration: nil)
+ debug("existing PiP session failed cold-start restart: \(failure)")
+ return
+ }
+ }
refreshHostReady()
handleColdStartAfterSessionReady()
}
- private func waitForAudioProof() async -> Bool {
- await capture.awaitAudioFlowing(timeout: Self.coldStartAudioProofTimeout)
- }
-
@MainActor
private func handleColdStartAfterSessionReady() {
guard isColdStartHandoff, isActive else { return }
refreshHostReady()
guard FlowSessionBridge.isHostReady() else {
- // Busy ≠ broken: a startflow arriving mid-utterance (e.g. tapping
- // the Live Activity while dictating) finds a healthy session that
- // is simply recording/processing. Showing the audio-failure
- // overlay here would be a lie — and its recovery loop could even
- // stop capture and kill the live utterance.
+ // Busy ≠ broken: a startflow arriving mid-utterance finds a healthy
+ // session that is simply recording/processing. Treating it as a
+ // PiP failure could restart capture and kill the live utterance.
if isUtteranceRecording || isUtteranceProcessing {
- dismissColdStartOverlay()
+ completeColdStartHandoff()
debug("cold-start handoff ignored: session busy with an utterance")
return
}
- let message: String
- if usesPiPKeepAlive {
- message = AppL10n.string("flow.pip.error.notPossible")
- sessionWarning = message
- showColdStartPipFailure(message: message)
- } else {
- message = AppL10n.string("flow.coldStart.error.audioTimeout")
- sessionWarning = message
- showColdStartAudioFailure(message: message)
- }
+ let message = AppL10n.string("flow.session.error.notPossible")
+ sessionWarning = message
+ FlowSessionBridge.setHostReady(false)
scheduleColdStartRecovery(duration: nil)
debug("cold-start blocked: host ready contract not published")
return
}
- presentColdStartReadyOverlay()
- }
-
- private func presentColdStartReadyOverlay() {
let hostEntry = HostReturnService.pendingHostEntry()
- // Handoff remains fully automatic; no preparing/ready overlay is
- // mounted in the host UI.
- coldStartContext = nil
+ guard hostEntry != nil else {
+ completeColdStartHandoff()
+ return
+ }
scheduleAutoReturnToHostIfNeeded(hostEntry: hostEntry)
}
private func scheduleAutoReturnToHostIfNeeded(hostEntry: HostAppEntry?) {
- let skipSwitch = usesPiPKeepAlive || FlowSessionPolicy.skipAppSwitch()
+ let skipSwitch = true
guard skipSwitch, hostEntry != nil else { return }
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 450_000_000)
guard let self, self.isColdStartHandoff, self.isActive,
FlowSessionBridge.isHostReady() else { return }
if HostReturnService.openPendingHostIfPossible() {
- self.dismissColdStartOverlay()
+ self.completeColdStartHandoff()
}
}
}
@@ -1132,97 +806,25 @@ final class FlowSessionManager: ObservableObject {
coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = Task { @MainActor [weak self] in
guard let self else { return }
- if self.usesPiPKeepAlive {
- let outcome = await self.pipController.startAndWait()
- self.traceState("coldStartRecovery.pip", extra: "outcome=\(outcome)")
- guard !Task.isCancelled, self.isColdStartHandoff else { return }
- switch outcome {
- case .started:
- if self.isActive {
- self.sessionWarning = nil
- self.refreshHostReady()
- self.handleColdStartAfterSessionReady()
- } else {
- self.activateFlowSessionAfterPiPProof(duration: duration)
- self.handleColdStartAfterSessionReady()
- }
- case .failed(let failure):
- let message = AppL10n.string(failure.localizationKey)
- self.sessionWarning = message
- self.showColdStartPipFailure(message: message)
+ let outcome = await self.pipController.startAndWait()
+ self.traceState("coldStartRecovery.pip", extra: "outcome=\(outcome)")
+ guard !Task.isCancelled, self.isColdStartHandoff else { return }
+ switch outcome {
+ case .started:
+ if self.isActive {
+ self.sessionWarning = nil
+ self.refreshHostReady()
+ self.handleColdStartAfterSessionReady()
+ } else {
+ self.activateFlowSessionAfterPiPProof(duration: duration)
+ self.handleColdStartAfterSessionReady()
}
- return
+ case .failed(let failure):
+ self.sessionWarning = AppL10n.string(failure.localizationKey)
}
- var recovered = false
- for attempt in 1...3 {
- guard !Task.isCancelled, self.isColdStartHandoff else { return }
- switch attempt {
- case 1:
- _ = await self.capture.reassertIfRunning()
- case 2:
- self.capture.stop()
- try? await self.capture.start()
- default:
- self.capture.stop()
- try? await Task.sleep(nanoseconds: 300_000_000)
- guard !Task.isCancelled else { return }
- try? await self.capture.start()
- }
- recovered = await self.capture.awaitAudioFlowing(
- timeout: TimeInterval(attempt + 1)
- )
- self.traceState(
- "coldStartRecovery.attempt",
- extra: "attempt=\(attempt) recovered=\(recovered)"
- )
- if recovered { break }
- }
- guard !Task.isCancelled else { return }
- guard self.isColdStartHandoff else { return }
- guard recovered else {
- // Out of attempts — leave the failure overlay up; its retry
- // button now performs a full teardown so the user always has
- // a working escape hatch (no more force-quit loops). Only
- // tear capture down when no session owns it: for an active
- // session the 1 Hz heartbeat keeps self-healing, and a stop
- // here would just fight it.
- if !self.isActive {
- self.capture.stop()
- }
- self.traceState("coldStartRecovery.exhausted")
- return
- }
-
- self.sessionWarning = nil
- self.traceState("coldStartRecovery.recovered")
- if !self.isActive {
- self.activateFlowSessionAfterAudioProof(duration: duration)
- }
- self.refreshHostReady()
}
}
- private func showColdStartPreparing() {
- coldStartContext = nil
- }
-
- private func showColdStartPermissionFailure() {
- FlowSessionBridge.setHostReady(false)
- coldStartContext = nil
- }
-
- private func showColdStartAudioFailure(message: String) {
- FlowSessionBridge.setHostReady(false)
- _ = message
- coldStartContext = nil
- }
-
- private func showColdStartPipFailure(message: String) {
- FlowSessionBridge.setHostReady(false)
- _ = message
- coldStartContext = nil
- }
-
private func bindSessionASRIfNeeded(force: Bool = false) {
let engineMode = store.engineMode
if !force,
@@ -1494,54 +1096,46 @@ final class FlowSessionManager: ObservableObject {
startToken: FlowUtteranceStartToken
) async {
guard !Task.isCancelled, canContinueStart(startToken) else { return }
- if usesPiPKeepAlive {
- refreshHostReady()
- // Keep the utterance gate closed until the route is stable and the
- // tap has produced a real frame. The rolling three-second preroll
- // preserves speech spoken during this short readiness window.
- guard await startCaptureForPiPUtteranceIfNeeded() else {
- guard !Task.isCancelled, canContinueStart(startToken) else { return }
- failUtterance(
- message: AppL10n.string("flow.coldStart.error.audioTimeout"),
- kind: .audioUnavailable
- )
- return
- }
- guard !Task.isCancelled, canContinueStart(startToken) else {
- releaseOrphanedCaptureIfNeeded()
- return
- }
- let micReady: Bool
- if capture.engineHasRecentAudio(maxAge: 2) {
- micReady = true
- } else {
- micReady = await capture.awaitAudioFlowing(
- timeout: Self.coldStartAudioProofTimeout
- )
- }
- guard !Task.isCancelled, canContinueStart(startToken) else {
- releaseOrphanedCaptureIfNeeded()
- return
- }
- if !micReady {
- failUtterance(
- message: AppL10n.string("flow.coldStart.error.audioTimeout"),
- kind: .audioUnavailable
- )
- return
- }
- beginUtterance(
- utteranceId: utteranceId,
- commandSeq: commandSeq,
- requireRecentAudio: false
+ refreshHostReady()
+ // Keep the utterance gate closed until the route is stable and the
+ // tap has produced a real frame. The rolling three-second preroll
+ // preserves speech spoken during this short readiness window.
+ guard await startCaptureForPiPUtteranceIfNeeded() else {
+ guard !Task.isCancelled, canContinueStart(startToken) else { return }
+ failUtterance(
+ message: AppL10n.string("flow.coldStart.error.audioTimeout"),
+ kind: .audioUnavailable
)
- if pendingStopUtteranceId == currentUtteranceId {
- pendingStopUtteranceId = nil
- endUtterance()
- }
return
}
- beginUtterance(utteranceId: utteranceId, commandSeq: commandSeq)
+ guard !Task.isCancelled, canContinueStart(startToken) else {
+ releaseOrphanedCaptureIfNeeded()
+ return
+ }
+ let micReady: Bool
+ if capture.engineHasRecentAudio(maxAge: 2) {
+ micReady = true
+ } else {
+ micReady = await capture.awaitAudioFlowing(
+ timeout: Self.coldStartAudioProofTimeout
+ )
+ }
+ guard !Task.isCancelled, canContinueStart(startToken) else {
+ releaseOrphanedCaptureIfNeeded()
+ return
+ }
+ if !micReady {
+ failUtterance(
+ message: AppL10n.string("flow.coldStart.error.audioTimeout"),
+ kind: .audioUnavailable
+ )
+ return
+ }
+ beginUtterance(
+ utteranceId: utteranceId,
+ commandSeq: commandSeq,
+ requireRecentAudio: false
+ )
if pendingStopUtteranceId == currentUtteranceId {
pendingStopUtteranceId = nil
endUtterance()
@@ -1563,7 +1157,7 @@ final class FlowSessionManager: ObservableObject {
}
private func releaseCaptureAfterPiPUtteranceIfNeeded() {
- guard usesPiPKeepAlive, capture.running else { return }
+ guard capture.running else { return }
guard !isUtteranceRecording, !isUtteranceProcessing else { return }
capture.stop(releaseSession: false)
Task { @MainActor [weak self] in
@@ -1683,7 +1277,6 @@ final class FlowSessionManager: ObservableObject {
utteranceRecordingStartedAt = Date()
startUtteranceSafetyTimer()
refreshHostReady()
- updateLiveActivityPhase(.recording)
FlowDiagnostics.log(
"beginUtterance engine=\(store.engineMode) " +
"asrType=\(type(of: asr)) streaming=\(useStreaming) " +
@@ -1837,7 +1430,6 @@ final class FlowSessionManager: ObservableObject {
utteranceSafetyTask?.cancel()
utteranceSafetyTask = nil
refreshHostReady()
- updateLiveActivityPhase(.processing)
// Snapshot pipelined partial before drain — fallback if the final chunk ASR drops tail text.
bestPartialSnapshot = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -1864,11 +1456,9 @@ final class FlowSessionManager: ObservableObject {
+ "rms=\(FlowTrace.rms(self.utterancePCMSamples)) "
+ "capture[\(self.capture.frameReport().summary)]"
)
- if self.usesPiPKeepAlive {
- self.capture.stop(releaseSession: false)
- _ = await self.pipController.reassertKeepAliveAudioSession()
- self.pipController.updateWaveformLevels([])
- }
+ self.capture.stop(releaseSession: false)
+ _ = await self.pipController.reassertKeepAliveAudioSession()
+ self.pipController.updateWaveformLevels([])
await self.finalizeUtterance(
sessionId: drainingSessionId,
utteranceId: drainingUtteranceId,
@@ -1911,7 +1501,6 @@ final class FlowSessionManager: ObservableObject {
utteranceGeneration &+= 1
currentUtteranceId = nil
currentCommandSeq = 0
- updateLiveActivityPhase(.idle)
refreshHostReady()
debug("utterance aborted")
}
@@ -1945,7 +1534,6 @@ final class FlowSessionManager: ObservableObject {
utteranceGeneration &+= 1
currentUtteranceId = nil
currentCommandSeq = 0
- updateLiveActivityPhase(.idle)
refreshHostReady()
debug("utterance failed: \(message)")
}
@@ -1975,7 +1563,6 @@ final class FlowSessionManager: ObservableObject {
utteranceGeneration &+= 1
currentUtteranceId = nil
currentCommandSeq = 0
- updateLiveActivityPhase(.idle)
refreshHostReady()
debug("utterance processing failed: \(message)")
}
@@ -2246,10 +1833,6 @@ final class FlowSessionManager: ObservableObject {
let wasProcessing = isUtteranceProcessing
isUtteranceProcessing = false
- updateLiveActivityPhase(.idle)
- if isActive {
- touchSessionActivity()
- }
if currentUtteranceId == utteranceId || currentUtteranceId == nil {
currentUtteranceId = nil
currentCommandSeq = 0
@@ -2512,9 +2095,7 @@ final class FlowSessionManager: ObservableObject {
continue
}
let levels = self.capture.currentAudioLevels()
- if self.usesPiPKeepAlive {
- self.pipController.updateWaveformLevels(levels)
- }
+ self.pipController.updateWaveformLevels(levels)
if levels.contains(where: { $0 > 0 }) {
FlowSessionBridge.storeAudioLevels(levels)
}
@@ -2529,44 +2110,21 @@ final class FlowSessionManager: ObservableObject {
heartbeatTask?.cancel()
FlowSessionBridge.writeHeartbeat()
heartbeatTask = Task { @MainActor [weak self] in
- // Refresh the Live Activity `staleDate` every N heartbeat ticks
- // (1 Hz) — well inside `FlowLiveActivityController.staleWindow` so a
- // live session never looks stale, while a force-quit stops these
- // refreshes and lets the island go stale within ~30 s.
- let liveActivityKeepAliveEveryTicks = 10
- var tick = 0
while !Task.isCancelled {
guard let self else { break }
- if self.isActive, !self.capture.engineIsLive {
- let shouldReassert = !self.usesPiPKeepAlive
- || self.isUtteranceRecording
- || self.isUtteranceProcessing
- if shouldReassert {
- await self.reactivateCaptureIfNeeded()
- }
+ if self.isActive,
+ !self.capture.engineIsLive,
+ (self.isUtteranceRecording || self.isUtteranceProcessing) {
+ await self.reactivateCaptureIfNeeded()
}
FlowSessionBridge.writeHeartbeat()
self.refreshHostReady()
- tick += 1
- if !self.usesPiPKeepAlive, tick % liveActivityKeepAliveEveryTicks == 0 {
- FlowLiveActivityController.keepAlive()
- }
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard self.isActive else { break }
}
}
}
- private func scheduleExpiry(after duration: TimeInterval) {
- guard !usesPiPKeepAlive else { return }
- expiryTask?.cancel()
- expiryTask = Task { @MainActor [weak self] in
- try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
- guard !Task.isCancelled else { return }
- self?.endSession()
- }
- }
-
private func debug(_ message: String) {
FlowDiagnostics.log(message)
}
@@ -2588,7 +2146,6 @@ final class FlowSessionManager: ObservableObject {
FlowDebugRow("utt.proc", isUtteranceProcessing ? "1" : "0"),
FlowDebugRow("sessionId", activeSessionId.map { String($0.uuidString.prefix(8)) } ?? "nil"),
FlowDebugRow("warning", sessionWarning == nil ? "0" : "1"),
- FlowDebugRow("overlay", coldStartContext.map { String(describing: $0.state) } ?? "nil"),
FlowDebugRow("bridgeReady", FlowSessionBridge.isHostReady() ? "1" : "0")
]
// Prefer App Group snap.reason near the top of the shared block.
diff --git a/OSGKeyboard/Services/FlowTerminationCoordinator.swift b/OSGKeyboard/Services/FlowTerminationCoordinator.swift
index ec0a3bc..9531b58 100644
--- a/OSGKeyboard/Services/FlowTerminationCoordinator.swift
+++ b/OSGKeyboard/Services/FlowTerminationCoordinator.swift
@@ -3,7 +3,7 @@
//
// 桥接 `UIApplicationDelegate.applicationWillTerminate` 与 `FlowSessionManager`。
// SwiftUI 里 `FlowSessionManager` 是 `@StateObject`,AppDelegate 无法直接持有;
-// 此处用弱引用在进程退出窗口(约 5 秒)内同步释放麦克风与 Live Activity。
+// 此处用弱引用在进程退出窗口(约 5 秒)内同步释放麦克风。
import Foundation
@@ -19,6 +19,5 @@ enum FlowTerminationCoordinator {
/// 强杀 / 系统终止时调用。必须在主线程执行(`applicationWillTerminate` 保证)。
static func performSynchronousTerminationCleanup() {
sessionManager?.prepareForProcessTermination()
- FlowLiveActivityController.endAllSynchronouslyOnTerminate()
}
}
diff --git a/OSGKeyboard/Views/FlowColdStartOverlay.swift b/OSGKeyboard/Views/FlowColdStartOverlay.swift
deleted file mode 100644
index 06d2f69..0000000
--- a/OSGKeyboard/Views/FlowColdStartOverlay.swift
+++ /dev/null
@@ -1,376 +0,0 @@
-// FlowColdStartOverlay.swift
-// OSGKeyboard · Main App
-//
-// Cold-start handoff hint: a bottom-anchored, full-width gradient that keeps
-// the current app visible while Flow proves that voice input is actually
-// ready. Failure states reuse the same minimal layout and only change the
-// text — permission issues are handled with a single "open Settings" link,
-// never a second in-app permission flow.
-//
-// The overlay ignores the keyboard safe area (full-bleed over Home), so it
-// manually tracks keyboard overlap and lifts the gradient + copy together
-// to stay glued to the keyboard's top edge. MainTabView also ignores the
-// keyboard inset, so system safe-area push cannot be relied on here.
-
-import SwiftUI
-import UIKit
-import OSGKeyboardShared
-
-struct FlowColdStartContext: Equatable {
- let hostEntry: HostAppEntry?
- var state: FlowColdStartState
- /// Drives preparing / PiP-specific copy (Live Activity vs picture-in-picture).
- var keepAliveMode: FlowKeepAliveMode
-}
-
-enum FlowColdStartState: Equatable {
- case preparing
- case ready
- case failed(FlowColdStartFailure)
-}
-
-enum FlowColdStartFailure: Equatable {
- case permission(message: String)
- case audio(message: String)
- /// Picture-in-picture keep-alive could not be proven active.
- case pip(message: String)
-}
-
-struct FlowColdStartOverlay: View {
- @Environment(\.themePalette) private var palette: ThemePalette
- @Environment(\.scenePhase) private var scenePhase
-
- let context: FlowColdStartContext
- let onReturnToHost: () -> Void
- let onDismiss: () -> Void
- let onRetry: () -> Void
- let onOpenSettings: () -> Void
-
- /// Fraction of the *visible* height (above the keyboard) the gradient occupies.
- private let gradientHeightFraction: CGFloat = 0.50
-
- /// Distance from the screen bottom to the keyboard's top edge.
- @State private var keyboardOverlap: CGFloat = 0
-
- /// Ready and failure states dismiss on blank tap; preparing stays
- /// informational only (no accidental dismiss while proving audio).
- private var allowsBlankTapDismiss: Bool {
- switch context.state {
- case .ready, .failed:
- return true
- case .preparing:
- return false
- }
- }
-
- var body: some View {
- GeometryReader { geo in
- // Keep gradient proportions relative to the canvas above the keyboard
- // so the near-opaque band stays behind the title when the keyboard is up.
- let visibleHeight = max(geo.size.height - keyboardOverlap, 1)
- let gradientHeight = visibleHeight * gradientHeightFraction
- // Above the keyboard the home-indicator inset is already consumed by
- // `keyboardOverlap`; only apply it when the keyboard is hidden.
- let contentBottomPad = keyboardOverlap > 0
- ? Spacing.sm
- : max(geo.safeAreaInsets.bottom, Spacing.sm)
-
- ZStack(alignment: .bottom) {
- // Full-screen hit sink: must expand explicitly — a bare Color.clear
- // in a bottom-aligned ZStack can collapse and let taps reach Home
- // (e.g. focusing the preview field while this overlay is visible).
- Color.clear
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .contentShape(Rectangle())
- .onTapGesture {
- if allowsBlankTapDismiss {
- onDismiss()
- }
- }
- .allowsHitTesting(true)
-
- // Full-width bottom gradient: transparent at the top of the
- // band, nearly opaque at the bottom so hint text stays readable.
- LinearGradient(
- colors: [
- palette.background.opacity(0.35),
- palette.background.opacity(0.72),
- palette.background.opacity(0.97)
- ],
- startPoint: .top,
- endPoint: .bottom
- )
- .frame(height: gradientHeight)
- .frame(maxWidth: .infinity, alignment: .bottom)
- .allowsHitTesting(false)
-
- VStack(spacing: Spacing.lg) {
- content
- .padding(.horizontal, Spacing.xl)
-
- homeIndicator
- .padding(.bottom, contentBottomPad)
- }
- }
- // Lift gradient + copy as one unit so the opaque band stays glued
- // to the keyboard top edge (or the home indicator when idle).
- .padding(.bottom, keyboardOverlap)
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
- .ignoresSafeArea()
- }
- .onAppear {
- // Cold-start copy asks the user to swipe back — dismiss any in-app
- // keyboard first so Home's preview field cannot steal the scene.
- Self.resignEditingFocus()
- syncKeyboardOverlap(animated: false)
- }
- .onChange(of: scenePhase) { _, phase in
- guard phase == .active else { return }
- syncKeyboardOverlap(animated: false)
- }
- .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
- applyKeyboardOverlap(from: notification)
- }
- .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidChangeFrameNotification)) { notification in
- // Catches frames missed between mount and the first WillChange
- // (keyboard already visible when the overlay appears).
- applyKeyboardOverlap(from: notification)
- }
- .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { notification in
- applyKeyboardOverlap(0, from: notification)
- }
- .animation(.easeInOut(duration: 0.2), value: context.state)
- .accessibilityElement(children: .contain)
- }
-
- /// Reads the keyboard end frame and animates `keyboardOverlap` with the
- /// system keyboard curve so the gradient rides the same motion.
- private func applyKeyboardOverlap(from notification: Notification) {
- let overlap = Self.keyboardOverlap(from: notification)
- applyKeyboardOverlap(overlap, from: notification)
- }
-
- private func applyKeyboardOverlap(_ overlap: CGFloat, from notification: Notification) {
- let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?
- .doubleValue ?? 0.25
- withAnimation(.easeOut(duration: duration)) {
- keyboardOverlap = overlap
- }
- }
-
- private func syncKeyboardOverlap(animated: Bool) {
- let overlap = Self.probedKeyboardOverlap()
- if animated {
- withAnimation(.easeOut(duration: 0.2)) {
- keyboardOverlap = overlap
- }
- } else {
- keyboardOverlap = overlap
- }
- }
-
- /// Screen-bottom → keyboard-top distance in the key window.
- private static func keyboardOverlap(from notification: Notification) -> CGFloat {
- guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
- return 0
- }
- guard let window = keyWindow else {
- let bounds = UIScreen.main.bounds
- return max(0, bounds.maxY - frame.minY)
- }
- let frameInWindow = window.convert(frame, from: nil)
- return max(0, window.bounds.maxY - frameInWindow.minY)
- }
-
- /// Best-effort read when we may have missed keyboard notifications
- /// (overlay mounted while the keyboard was already up).
- private static func probedKeyboardOverlap() -> CGFloat {
- guard let scene = UIApplication.shared.connectedScenes
- .compactMap({ $0 as? UIWindowScene })
- .first(where: { $0.activationState == .foregroundActive })
- ?? UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first
- else {
- return 0
- }
-
- let reference = keyWindow ?? scene.windows.first
- let bounds = reference?.bounds ?? scene.screen.bounds
-
- // UITextEffectsWindow / UIRemoteKeyboardWindow host the keyboard chrome.
- for window in scene.windows {
- let name = String(describing: type(of: window))
- guard name.contains("Keyboard") || name.contains("TextEffects") else { continue }
- let overlap = max(0, bounds.maxY - window.frame.minY)
- // Full-screen effects windows are not themselves the keyboard —
- // walk for a bottom-docked subview that looks like the input host.
- if overlap >= bounds.height - 1 {
- if let docked = deepestBottomDockedSubview(in: window, referenceBounds: bounds) {
- return max(0, bounds.maxY - docked.minY)
- }
- continue
- }
- if overlap > 0 {
- return overlap
- }
- }
- return 0
- }
-
- private static func deepestBottomDockedSubview(
- in window: UIWindow,
- referenceBounds: CGRect
- ) -> CGRect? {
- var best: CGRect?
- func visit(_ view: UIView) {
- let frame = view.convert(view.bounds, to: nil)
- let touchesBottom = abs(frame.maxY - referenceBounds.maxY) < 1.5
- let tallEnough = frame.height > 120
- let notFullScreen = frame.height < referenceBounds.height * 0.92
- if touchesBottom, tallEnough, notFullScreen {
- if best == nil || frame.minY < best!.minY {
- best = frame
- }
- }
- for child in view.subviews {
- visit(child)
- }
- }
- visit(window)
- return best
- }
-
- private static var keyWindow: UIWindow? {
- UIApplication.shared.connectedScenes
- .compactMap { $0 as? UIWindowScene }
- .flatMap(\.windows)
- .first(where: \.isKeyWindow)
- }
-
- private static func resignEditingFocus() {
- UIApplication.shared.sendAction(
- #selector(UIResponder.resignFirstResponder),
- to: nil,
- from: nil,
- for: nil
- )
- }
-
- @ViewBuilder
- private var content: some View {
- VStack(spacing: Spacing.md) {
- statusIcon
-
- Text(title)
- .font(TypeStyle.title3)
- .foregroundStyle(palette.textPrimary)
- .multilineTextAlignment(.center)
-
- Text(message)
- .font(TypeStyle.body)
- .foregroundStyle(palette.textSecondary)
- .multilineTextAlignment(.center)
- .padding(.horizontal, Spacing.md)
-
- actionLink
- }
- }
-
- @ViewBuilder
- private var statusIcon: some View {
- switch context.state {
- case .preparing:
- ProgressView()
- .tint(palette.accent)
- .scaleEffect(1.1)
- .accessibilityLabel(preparingTitle)
- case .ready:
- Image(systemName: "checkmark.circle.fill")
- .font(.system(size: 26, weight: .semibold))
- .foregroundStyle(palette.accent)
- .accessibilityHidden(true)
- case .failed:
- Image(systemName: "exclamationmark.triangle.fill")
- .font(.system(size: 26, weight: .semibold))
- .foregroundStyle(palette.warning)
- .accessibilityHidden(true)
- }
- }
-
- @ViewBuilder
- private var actionLink: some View {
- switch context.state {
- case .preparing, .ready:
- EmptyView()
- case .failed(let failure):
- switch failure {
- case .permission:
- linkButton(AppL10n.string("flow.coldStart.action.settings"), action: onOpenSettings)
- case .audio, .pip:
- linkButton(AppL10n.string("flow.coldStart.action.retry"), action: onRetry)
- }
- }
- }
-
- private func linkButton(_ title: String, action: @escaping () -> Void) -> some View {
- Button(action: action) {
- Text(title)
- .font(TypeStyle.body.weight(.semibold))
- .foregroundStyle(palette.accent)
- }
- .buttonStyle(.plain)
- }
-
- private var preparingTitle: String {
- switch context.keepAliveMode {
- case .pictureInPicture:
- return AppL10n.string("flow.coldStart.preparing.pip")
- case .liveActivity:
- return AppL10n.string("flow.coldStart.preparing")
- }
- }
-
- private var title: String {
- switch context.state {
- case .preparing:
- return preparingTitle
- case .ready:
- return AppL10n.string("flow.coldStart.title")
- case .failed(let failure):
- switch failure {
- case .permission:
- return AppL10n.string("flow.coldStart.permission.title")
- case .audio:
- return AppL10n.string("flow.coldStart.audio.title")
- case .pip:
- return AppL10n.string("flow.coldStart.pip.title")
- }
- }
- }
-
- private var message: String {
- switch context.state {
- case .preparing:
- switch context.keepAliveMode {
- case .pictureInPicture:
- return AppL10n.string("flow.coldStart.preparingHint.pip")
- case .liveActivity:
- return AppL10n.string("flow.coldStart.preparingHint")
- }
- case .ready:
- return AppL10n.string("flow.coldStart.swipeHint")
- case .failed(let failure):
- switch failure {
- case .permission(let message), .audio(let message), .pip(let message):
- return message
- }
- }
- }
-
- /// System-style home indicator — anchors the swipe-to-return gesture.
- private var homeIndicator: some View {
- Capsule()
- .fill(palette.textTertiary.opacity(context.state == .ready ? 0.55 : 0.35))
- .frame(width: 134, height: 5)
- .accessibilityHidden(true)
- }
-}
diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift
index 315d950..24e69c7 100644
--- a/OSGKeyboard/Views/HomeView.swift
+++ b/OSGKeyboard/Views/HomeView.swift
@@ -76,18 +76,8 @@ struct HomeView: View {
}
.onChange(of: previewFocused) { _, focused in
guard focused else { return }
- // Cold-start overlay owns the scene (swipe-back hint); don't let
- // the preview field summon the keyboard underneath it.
- if flowManager.coldStartContext != nil {
- previewFocused = false
- return
- }
Task { await flowManager.refreshForInlineKeyboardFocus() }
}
- .onChange(of: flowManager.coldStartContext != nil) { _, showingOverlay in
- guard showingOverlay else { return }
- previewFocused = false
- }
}
// MARK: - Phone layout
@@ -495,7 +485,6 @@ struct HomeView: View {
)
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.onTapGesture {
- guard flowManager.coldStartContext == nil else { return }
previewFocused = true
}
}
diff --git a/OSGKeyboard/Views/PolishStylesView.swift b/OSGKeyboard/Views/PolishStylesView.swift
index b012315..73dd189 100644
--- a/OSGKeyboard/Views/PolishStylesView.swift
+++ b/OSGKeyboard/Views/PolishStylesView.swift
@@ -14,9 +14,10 @@ struct PolishStylesView: View {
@State private var catalog = AppGroupStore().polishStyleCatalog
@State private var activeID = AppGroupStore().activePolishStyleId
+ /// Drives the editor sheet via `sheet(item:)` so create/edit always
+ /// receives a concrete pack (avoids `isPresented` + nil race showing defaults).
@State private var editingPack: PolishStylePack?
@State private var viewingPack: PolishStylePack?
- @State private var showEditor = false
@State private var errorMessage: String?
private let store = AppGroupStore()
@@ -53,8 +54,7 @@ struct PolishStylesView: View {
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
- editingPack = nil
- showEditor = true
+ editingPack = Self.makeDraftPack()
} label: {
Image(systemName: "plus")
}
@@ -63,9 +63,12 @@ struct PolishStylesView: View {
}
}
}
- .sheet(isPresented: $showEditor) {
- PolishStyleEditorSheet(pack: editingPack) { pack in
- save(pack)
+ .sheet(item: $editingPack) { pack in
+ PolishStyleEditorSheet(
+ pack: pack,
+ isNew: !catalog.entries.contains(where: { $0.id == pack.id })
+ ) { saved in
+ save(saved)
}
}
.sheet(item: $viewingPack) { pack in
@@ -137,7 +140,6 @@ struct PolishStylesView: View {
viewingPack = pack
} else {
editingPack = pack
- showEditor = true
}
} label: {
Image(systemName: pack.kind == .builtin ? "eye" : "pencil")
@@ -242,7 +244,13 @@ struct PolishStylesView: View {
prompt: pack.prompt,
allowsAddedEmoji: pack.allowsAddedEmoji
)
- showEditor = true
+ }
+
+ private static func makeDraftPack() -> PolishStylePack {
+ PolishStylePack(
+ name: "",
+ prompt: PolishStylePackCatalog.newUserPromptTemplate
+ )
}
private func delete(_ pack: PolishStylePack) {
@@ -304,7 +312,8 @@ private struct PolishStylePromptDetailSheet: View {
}
private struct PolishStyleEditorSheet: View {
- let pack: PolishStylePack?
+ let pack: PolishStylePack
+ let isNew: Bool
let onSave: (PolishStylePack) -> Void
@Environment(\.dismiss) private var dismiss
@@ -313,12 +322,17 @@ private struct PolishStyleEditorSheet: View {
@State private var prompt: String
@State private var allowsAddedEmoji: Bool
- init(pack: PolishStylePack?, onSave: @escaping (PolishStylePack) -> Void) {
+ init(
+ pack: PolishStylePack,
+ isNew: Bool,
+ onSave: @escaping (PolishStylePack) -> Void
+ ) {
self.pack = pack
+ self.isNew = isNew
self.onSave = onSave
- _name = State(initialValue: pack?.name ?? "")
- _prompt = State(initialValue: pack?.prompt ?? PolishStylePackCatalog.newUserPromptTemplate)
- _allowsAddedEmoji = State(initialValue: pack?.allowsAddedEmoji ?? false)
+ _name = State(initialValue: pack.name)
+ _prompt = State(initialValue: pack.prompt)
+ _allowsAddedEmoji = State(initialValue: pack.allowsAddedEmoji)
}
var body: some View {
@@ -359,7 +373,7 @@ private struct PolishStyleEditorSheet: View {
Text("polishStyles.editor.hint")
}
}
- .navigationTitle(pack == nil ? "polishStyles.add" : "polishStyles.edit")
+ .navigationTitle(isNew ? "polishStyles.add" : "polishStyles.edit")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
@@ -368,13 +382,13 @@ private struct PolishStyleEditorSheet: View {
ToolbarItem(placement: .confirmationAction) {
Button("common.save") {
let result = PolishStylePack(
- id: pack?.id ?? "user.\(UUID().uuidString.lowercased())",
+ id: pack.id,
name: name,
prompt: prompt,
allowsAddedEmoji: allowsAddedEmoji
|| PolishStylePack.promptDeclaresAddedEmojiOptIn(prompt),
kind: .user,
- createdAt: pack?.createdAt ?? Date()
+ createdAt: pack.createdAt
)
onSave(result)
dismiss()
diff --git a/OSGKeyboard/Views/SettingsPreferenceRows.swift b/OSGKeyboard/Views/SettingsPreferenceRows.swift
index 11ddad0..dbcc804 100644
--- a/OSGKeyboard/Views/SettingsPreferenceRows.swift
+++ b/OSGKeyboard/Views/SettingsPreferenceRows.swift
@@ -54,56 +54,6 @@ struct AppearancePickerRow: View {
}
}
-// MARK: - Flow keep-alive mode picker row
-
-struct FlowKeepAliveModePickerRow: View {
- @Binding var selection: FlowKeepAliveMode
-
- private var options: [(id: String, label: String)] {
- FlowKeepAliveMode.allCases.map { mode in
- (mode.rawValue, AppL10n.string(mode.labelKey))
- }
- }
-
- var body: some View {
- SettingsMenuPickerRow(
- title: AppL10n.string("settings.flow.keepAlive.title"),
- options: options,
- selection: Binding(
- get: { selection.rawValue },
- set: { newValue in
- selection = FlowKeepAliveMode(rawValue: newValue) ?? .default
- }
- )
- )
- }
-}
-
-// MARK: - Flow inactivity picker row
-
-struct FlowInactivityPickerRow: View {
- @Binding var selection: FlowInactivityDuration
-
- private var options: [(id: String, label: String)] {
- FlowInactivityDuration.allCases.map { duration in
- (duration.rawValue, AppL10n.string(duration.labelKey))
- }
- }
-
- var body: some View {
- SettingsMenuPickerRow(
- title: AppL10n.string("settings.flow.inactivity.title"),
- options: options,
- selection: Binding(
- get: { selection.rawValue },
- set: { newValue in
- selection = FlowInactivityDuration(rawValue: newValue) ?? .default
- }
- )
- )
- }
-}
-
// MARK: - Handedness picker row
struct HandednessPickerRow: View {
diff --git a/OSGKeyboard/Views/SettingsSecondaryPages.swift b/OSGKeyboard/Views/SettingsSecondaryPages.swift
index 26d8fdd..391ef06 100644
--- a/OSGKeyboard/Views/SettingsSecondaryPages.swift
+++ b/OSGKeyboard/Views/SettingsSecondaryPages.swift
@@ -164,78 +164,6 @@ struct TextPolishSettingsView: View {
}
}
-// MARK: - Voice session rows (embedded in Daily)
-
-struct VoiceSessionSettingsRows: View {
- @Environment(\.themePalette) private var palette: ThemePalette
- @ObservedObject var config: ProviderConfig
-
- @State private var showActiveFlowSessionAlert = false
-
- var body: some View {
- VStack(spacing: 0) {
- FlowKeepAliveModePickerRow(
- selection: Binding(
- get: { config.flowKeepAliveMode },
- set: { applyKeepAliveModeChange($0) }
- )
- )
-
- if config.flowKeepAliveMode == .liveActivity {
- Divider().background(palette.divider)
-
- FlowInactivityPickerRow(
- selection: Binding(
- get: { config.flowInactivityDuration },
- set: { config.flowInactivityDuration = $0 }
- )
- )
-
- Divider().background(palette.divider)
-
- Toggle(isOn: $config.flowSkipAppSwitch) {
- flowSkipAppSwitchLabel
- }
- .tint(palette.accent)
- .settingsListRow()
- } else {
- Divider().background(palette.divider)
-
- Text("settings.flow.keepAlive.pictureInPicture.note")
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textTertiary)
- .frame(maxWidth: .infinity, alignment: .leading)
- .settingsListRow()
- }
- }
- .alert("settings.flow.keepAlive.activeSession.title", isPresented: $showActiveFlowSessionAlert) {
- Button("common.done", role: .cancel) {}
- } message: {
- Text("settings.flow.keepAlive.activeSession.message")
- }
- }
-
- private var flowSkipAppSwitchLabel: some View {
- VStack(alignment: .leading, spacing: Spacing.xxs) {
- Text("settings.flow.skipAppSwitch.title")
- .font(TypeStyle.body)
- .foregroundStyle(palette.textPrimary)
- Text("settings.flow.skipAppSwitch.subtitle")
- .font(TypeStyle.caption2)
- .foregroundStyle(palette.textTertiary)
- }
- }
-
- private func applyKeepAliveModeChange(_ newMode: FlowKeepAliveMode) {
- guard newMode != config.flowKeepAliveMode else { return }
- if FlowSessionBridge.isSessionActive() {
- showActiveFlowSessionAlert = true
- return
- }
- config.flowKeepAliveMode = newMode
- }
-}
-
// MARK: - General (appearance, keyboard, sync)
struct GeneralSettingsView: View {
diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift
index 62f2a16..f1d3b8e 100644
--- a/OSGKeyboard/Views/SettingsView.swift
+++ b/OSGKeyboard/Views/SettingsView.swift
@@ -136,10 +136,6 @@ struct SettingsView: View {
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible)
-
- Divider().background(palette.divider)
-
- VoiceSessionSettingsRows(config: config)
}
.surfaceCard()
}
diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings
index 7c46919..3af23d5 100644
--- a/OSGKeyboard/en.lproj/Localizable.strings
+++ b/OSGKeyboard/en.lproj/Localizable.strings
@@ -481,52 +481,17 @@
/* Polish intensity */
"settings.polishIntensity.title" = "Polish intensity";
-/* Flow session policy */
-"settings.flow.title" = "Voice session";
-"settings.flow.keepAlive.title" = "Keep-alive mode";
-"settings.flow.keepAlive.liveActivity" = "Dynamic Island";
-"settings.flow.keepAlive.liveActivity.subtitle" = "Continuous mic session with inactivity timeout.";
-"settings.flow.keepAlive.pictureInPicture" = "Picture in Picture";
-"settings.flow.keepAlive.pictureInPicture.subtitle" = "Waveform PiP keeps the app alive; mic is released between utterances.";
-"settings.flow.keepAlive.pictureInPicture.note" = "Picture in Picture stays active until you close it. Skip app switch is always on in this mode.";
-"settings.flow.keepAlive.activeSession.title" = "End the current session first";
-"settings.flow.keepAlive.activeSession.message" = "Stop the active voice session before changing keep-alive mode.";
-"settings.flow.skipAppSwitch.title" = "Skip app switch";
-"settings.flow.skipAppSwitch.subtitle" = "After a cold start, try to return to the app you came from.";
-"settings.flow.inactivity.title" = "End session after inactivity";
-"settings.flow.inactivity.1m" = "1 minute";
-"settings.flow.inactivity.5m" = "5 minutes";
-"settings.flow.inactivity.10m" = "10 minutes";
-"settings.flow.inactivity.30m" = "30 minutes";
-"settings.flow.inactivity.3h" = "3 hours";
-"settings.flow.inactivity.12h" = "12 hours";
-"settings.flow.inactivity.24h" = "24 hours";
+/* Flow session (policy fields may still sync; keep-alive is no longer shown in Settings) */
"settings.localModels.customLM.title" = "Use custom language model";
"settings.localModels.customLM.subtitle" = "Diagnostic switch. Turn off to test pure Apple on-device recognition if local ASR gets stuck or returns no speech.";
-/* Cold-start handoff (scheme B) */
-"flow.coldStart.title" = "Voice is ready";
-"flow.coldStart.preparing" = "Getting voice ready";
-"flow.coldStart.preparing.pip" = "Starting Picture in Picture";
-"flow.coldStart.preparingHint" = "Keep OSGKeyboard open for a moment while we start the microphone session.";
-"flow.coldStart.preparingHint.pip" = "Keep OSGKeyboard open while Picture in Picture starts. Then return to the keyboard to speak.";
-"flow.coldStart.permission.title" = "Permission required";
-"flow.coldStart.audio.title" = "Voice could not start";
-"flow.coldStart.pip.title" = "Picture in Picture could not start";
+/* Cold-start / session errors (never say Picture in Picture to the user) */
"flow.coldStart.error.audioTimeout" = "The microphone did not become ready in time. It may be busy in another app. Please try again.";
-"flow.pip.error.unavailable" = "Picture in Picture could not start. Stay in the app and try again.";
-"flow.pip.error.unsupported" = "This device does not support Picture in Picture.";
-"flow.pip.error.hostNotReady" = "The Picture in Picture surface is not ready yet. Stay in the app and try again.";
-"flow.pip.error.notPossible" = "The system cannot start Picture in Picture right now. Keep the app in the foreground and try again.";
-"flow.pip.error.systemRejected" = "Picture in Picture was rejected by the system. Please try again shortly.";
-"flow.pip.error.timedOut" = "Picture in Picture did not appear in time. Stay in the app and try again.";
-"flow.coldStart.action.settings" = "Open Settings";
-"flow.coldStart.action.retry" = "Try Again";
-"flow.coldStart.swipeHint" = "Swipe right along the bottom bar\nto return to your previous app.";
-"flow.coldStart.swipeAccessibility" = "Swipe right along the bottom bar to return";
-"flow.coldStart.tapToDismiss" = "Tap anywhere to close";
-"flow.coldStart.return.named" = "Return to %@";
-"flow.coldStart.return.generic" = "Return to app";
+"flow.session.error.unsupported" = "This device cannot keep a voice session alive.";
+"flow.session.error.hostNotReady" = "The voice session is not ready yet. Stay in the app and try again.";
+"flow.session.error.notPossible" = "The system cannot start the voice session right now. Keep the app in the foreground and try again.";
+"flow.session.error.systemRejected" = "The voice session was rejected by the system. Please try again shortly.";
+"flow.session.error.timedOut" = "The voice session did not start in time. Stay in the app and try again.";
/* Host app display names (scheme C whitelist) */
"hostApp.wechat" = "WeChat";
diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
index 66b6c23..10d9857 100644
--- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings
+++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
@@ -480,52 +480,17 @@
/* 润色强度 */
"settings.polishIntensity.title" = "润色强度";
-/* Flow 会话策略 */
-"settings.flow.title" = "语音会话";
-"settings.flow.keepAlive.title" = "保活方式";
-"settings.flow.keepAlive.liveActivity" = "灵动岛";
-"settings.flow.keepAlive.liveActivity.subtitle" = "麦克风常驻,可按无活动时长结束会话。";
-"settings.flow.keepAlive.pictureInPicture" = "画中画";
-"settings.flow.keepAlive.pictureInPicture.subtitle" = "波形画中画保活;句间释放麦克风。";
-"settings.flow.keepAlive.pictureInPicture.note" = "画中画将持续保活,直到你关闭小窗。此模式下始终跳过应用切换。";
-"settings.flow.keepAlive.activeSession.title" = "请先结束当前会话";
-"settings.flow.keepAlive.activeSession.message" = "更改保活方式前,请先结束正在进行的语音会话。";
-"settings.flow.skipAppSwitch.title" = "跳过应用切换";
-"settings.flow.skipAppSwitch.subtitle" = "冷启动完成后,尝试自动返回来源 App。";
-"settings.flow.inactivity.title" = "无活动后结束会话";
-"settings.flow.inactivity.1m" = "1 分钟";
-"settings.flow.inactivity.5m" = "5 分钟";
-"settings.flow.inactivity.10m" = "10 分钟";
-"settings.flow.inactivity.30m" = "30 分钟";
-"settings.flow.inactivity.3h" = "3 小时";
-"settings.flow.inactivity.12h" = "12 小时";
-"settings.flow.inactivity.24h" = "24 小时";
+/* Flow 会话(内部策略字段仍可能同步;设置页已不再展示保活选项) */
"settings.localModels.customLM.title" = "使用自定义语言模型";
"settings.localModels.customLM.subtitle" = "诊断开关。本地识别卡住或提示未识别时,可关闭它测试纯 Apple 端侧识别。";
-/* 冷启动兜底(方案 B) */
-"flow.coldStart.title" = "语音已就绪";
-"flow.coldStart.preparing" = "正在就绪";
-"flow.coldStart.preparing.pip" = "正在启动画中画";
-"flow.coldStart.preparingHint" = "请先停留片刻,我们正在启动麦克风会话。";
-"flow.coldStart.preparingHint.pip" = "请先停留片刻,我们正在启动画中画保活。就绪后可返回键盘直接说话。";
-"flow.coldStart.permission.title" = "需要权限";
-"flow.coldStart.audio.title" = "语音暂时无法启动";
-"flow.coldStart.pip.title" = "画中画暂时无法启动";
+/* 冷启动 / 会话错误(用户可见文案不出现「画中画」) */
"flow.coldStart.error.audioTimeout" = "麦克风未能及时就绪,可能正被通话或其他 App 占用。请稍后重试。";
-"flow.pip.error.unavailable" = "无法启动画中画,请留在 App 内重试。";
-"flow.pip.error.unsupported" = "此设备不支持画中画。";
-"flow.pip.error.hostNotReady" = "画中画界面尚未就绪,请留在 App 内稍后重试。";
-"flow.pip.error.notPossible" = "系统暂时无法开启画中画,请保持 App 在前台后重试。";
-"flow.pip.error.systemRejected" = "画中画启动被系统拒绝,请稍后重试。";
-"flow.pip.error.timedOut" = "画中画未能及时出现,请留在 App 内重试。";
-"flow.coldStart.action.settings" = "前往设置";
-"flow.coldStart.action.retry" = "重试";
-"flow.coldStart.swipeHint" = "沿屏幕底部的横条,从左向右滑动\n返回上一个 App。";
-"flow.coldStart.swipeAccessibility" = "沿底部横条从左向右滑动返回";
-"flow.coldStart.tapToDismiss" = "点按屏幕关闭";
-"flow.coldStart.return.named" = "返回%@";
-"flow.coldStart.return.generic" = "返回 App";
+"flow.session.error.unsupported" = "此设备无法维持语音会话。";
+"flow.session.error.hostNotReady" = "语音会话尚未就绪,请留在 App 内稍后重试。";
+"flow.session.error.notPossible" = "系统暂时无法启动语音会话,请保持 App 在前台后重试。";
+"flow.session.error.systemRejected" = "语音会话启动被系统拒绝,请稍后重试。";
+"flow.session.error.timedOut" = "语音会话未能及时启动,请留在 App 内重试。";
/* 宿主 App 显示名(方案 C 白名单) */
"hostApp.wechat" = "微信";
diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
index b84cb74..db893db 100644
--- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
+++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
@@ -122,7 +122,6 @@ final class KeyboardFlowCoordinator {
/// prepares PiP so the next press does not need another app switch.
func ensurePiPReadyOnKeyboardOpen() {
guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch,
- FlowSessionPolicy.keepAliveMode() == .pictureInPicture,
state.hasCompletedOnboarding,
hasFullAccess(),
AppGroup.isAvailable,
@@ -220,7 +219,7 @@ final class KeyboardFlowCoordinator {
// host utt.rec=1 → ready=false → keyboard forever "正在启动…".
let hostBusy = FlowKeyboardHostWarming.isHostBusy(reason: readySnapshot?.reason)
// Hold green after the session already proved ready — PiP mic release /
- // ack lag must not flash yellow「正在启动画中画».
+ // ack lag must not flash yellow「正在启动…」.
let holdReady = FlowKeyboardHostWarming.shouldHoldReady(
hostReady: hostReadyRaw,
hostBusy: hostBusy,
diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings
index f53082d..b0e1878 100644
--- a/OSGKeyboardExt/en.lproj/Keyboard.strings
+++ b/OSGKeyboardExt/en.lproj/Keyboard.strings
@@ -134,12 +134,10 @@
/* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "Voice session off";
-"keyboard.flow.sessionInactive.pip" = "Picture in Picture off — tap mic to open OSGKeyboard";
"keyboard.flow.start" = "Start";
"keyboard.flow.startA11y" = "Start voice session";
"keyboard.flow.sessionExpired" = "Voice session ended. Open OSGKeyboard to restart.";
"keyboard.flow.startingSession" = "Starting voice session…";
-"keyboard.flow.startingSession.pip" = "Starting Picture in Picture…";
"keyboard.flow.transcribing" = "Transcribing…";
"keyboard.flow.resultTimeout" = "Timed out waiting for transcription. Try again.";
"keyboard.flow.hostDisconnected" = "Voice session disconnected. Open OSGKeyboard to restart.";
diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
index 636f86e..941121a 100644
--- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
+++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
@@ -134,12 +134,10 @@
/* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "语音会话未启动";
-"keyboard.flow.sessionInactive.pip" = "画中画未启动,点麦克风打开 OSGKeyboard";
"keyboard.flow.start" = "启动";
"keyboard.flow.startA11y" = "启动语音会话";
"keyboard.flow.sessionExpired" = "语音会话已结束,请打开 OSGKeyboard 重新启动";
"keyboard.flow.startingSession" = "正在启动语音会话…";
-"keyboard.flow.startingSession.pip" = "正在启动画中画…";
"keyboard.flow.transcribing" = "识别中…";
"keyboard.flow.resultTimeout" = "等待识别结果超时,请重试";
"keyboard.flow.hostDisconnected" = "语音会话已断开,请打开 OSGKeyboard 重新启动";
diff --git a/OSGKeyboardLiveActivity/Assets.xcassets/Contents.json b/OSGKeyboardLiveActivity/Assets.xcassets/Contents.json
deleted file mode 100644
index 73c0059..0000000
--- a/OSGKeyboardLiveActivity/Assets.xcassets/Contents.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
diff --git a/OSGKeyboardLiveActivity/Assets.xcassets/OSGLogo.imageset/Contents.json b/OSGKeyboardLiveActivity/Assets.xcassets/OSGLogo.imageset/Contents.json
deleted file mode 100644
index 3ffc4b6..0000000
--- a/OSGKeyboardLiveActivity/Assets.xcassets/OSGLogo.imageset/Contents.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "images" : [
- {
- "filename" : "OSGLogo.svg",
- "idiom" : "universal"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- },
- "properties" : {
- "preserves-vector-representation" : true
- }
-}
diff --git a/OSGKeyboardLiveActivity/Assets.xcassets/OSGLogo.imageset/OSGLogo.svg b/OSGKeyboardLiveActivity/Assets.xcassets/OSGLogo.imageset/OSGLogo.svg
deleted file mode 100644
index 10f0872..0000000
--- a/OSGKeyboardLiveActivity/Assets.xcassets/OSGLogo.imageset/OSGLogo.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
diff --git a/OSGKeyboardLiveActivity/FlowActivityAttributes.swift b/OSGKeyboardLiveActivity/FlowActivityAttributes.swift
deleted file mode 100644
index 7ea949c..0000000
--- a/OSGKeyboardLiveActivity/FlowActivityAttributes.swift
+++ /dev/null
@@ -1,22 +0,0 @@
-// FlowActivityAttributes.swift
-// OSGKeyboard · Live Activity
-//
-// Shared ActivityKit model compiled into the widget extension and the
-// main app so `FlowLiveActivityController` can start/update sessions.
-
-import ActivityKit
-import Foundation
-
-/// Live Activity shown in the Dynamic Island while a Flow session is active.
-struct FlowActivityAttributes: ActivityAttributes {
- /// Dynamic content updated as the user records and processes speech.
- struct ContentState: Codable, Hashable, Sendable {
- var phase: Phase
-
- enum Phase: String, Codable, Hashable, Sendable {
- case idle
- case recording
- case processing
- }
- }
-}
diff --git a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift
deleted file mode 100644
index a91b079..0000000
--- a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift
+++ /dev/null
@@ -1,198 +0,0 @@
-// FlowLiveActivityWidget.swift
-// OSGKeyboard · Live Activity
-//
-// Dynamic Island compact leading shows the OSGKeyboard mark instead of the
-// generic system microphone glyph that appears without a Live Activity.
-
-import ActivityKit
-import SwiftUI
-import WidgetKit
-
-struct FlowLiveActivityWidget: Widget {
- /// Deep link that restarts the Flow session. When the host process is
- /// dead the activity goes stale; tapping it must take the *cold-start*
- /// path (same as the keyboard's mic button), not just open the app.
- private static let reconnectURL = URL(string: "osgkeyboard://startflow")
-
- var body: some WidgetConfiguration {
- ActivityConfiguration(for: FlowActivityAttributes.self) { context in
- FlowLiveActivityLockScreenView(
- phase: context.state.phase,
- isStale: context.isStale
- )
- .activityBackgroundTint(Color.black.opacity(0.82))
- .activitySystemActionForegroundColor(.white)
- // Deep-link to a session restart only when the host is dead —
- // tapping a HEALTHY activity should just open the app, not
- // force a cold-start handoff into a running session.
- .widgetURL(context.isStale ? Self.reconnectURL : nil)
- } dynamicIsland: { context in
- DynamicIsland {
- DynamicIslandExpandedRegion(.leading) {
- FlowLiveActivityBrandMark(height: 16)
- }
- DynamicIslandExpandedRegion(.trailing) {
- FlowLiveActivityPhaseLabel(phase: context.state.phase, isStale: context.isStale)
- }
- DynamicIslandExpandedRegion(.center) {
- Text("OSGKeyboard")
- .font(.headline)
- }
- DynamicIslandExpandedRegion(.bottom) {
- FlowLiveActivityPhaseCaption(phase: context.state.phase, isStale: context.isStale)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- } compactLeading: {
- FlowLiveActivityBrandMark(height: 12)
- } compactTrailing: {
- FlowLiveActivityTrailingGlyph(phase: context.state.phase, isStale: context.isStale)
- } minimal: {
- // The minimal slot is a tiny circle; a short wordmark keeps
- // the natural ratio without overflowing its bounds.
- FlowLiveActivityBrandMark(height: 6)
- }
- .widgetURL(context.isStale ? Self.reconnectURL : nil)
- .keylineTint(Color(red: 0.35, green: 0.55, blue: 1.0))
- }
- }
-}
-
-// MARK: - Views
-
-private struct FlowLiveActivityLockScreenView: View {
- let phase: FlowActivityAttributes.ContentState.Phase
- let isStale: Bool
-
- var body: some View {
- HStack(spacing: 12) {
- FlowLiveActivityBrandMark(height: 10.4)
- VStack(alignment: .leading, spacing: 4) {
- Text("OSGKeyboard")
- .font(.headline)
- FlowLiveActivityPhaseCaption(phase: phase, isStale: isStale)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
- Spacer(minLength: 0)
- FlowLiveActivityTrailingGlyph(phase: phase, isStale: isStale)
- }
- // A stale activity means the host process is gone — never advertise
- // "Voice session active" for a dead session; grey the card instead.
- .opacity(isStale ? 0.55 : 1)
- // iOS Live Activity lock-screen content needs margins so the leading
- // logo and trailing glyph don't touch the card edges.
- .padding(.horizontal, 16)
- .padding(.vertical, 12)
- }
-}
-
-/// Branded mark used in compactLeading so users see OSGKeyboard, not the system mic icon.
-/// Transparent white OSG glyphs render directly on the black Dynamic Island.
-private struct FlowLiveActivityBrandMark: View {
- /// The OSG wordmark is wide and short; pin the width to its true aspect
- /// ratio so it never collapses into a thin sliver inside a square frame.
- private static let aspectRatio: CGFloat = 912.0 / 251.0
-
- /// Rendered glyph height; width follows the wordmark's natural ratio.
- let height: CGFloat
-
- var body: some View {
- Image("OSGLogo")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: height * Self.aspectRatio, height: height)
- .accessibilityLabel("OSGKeyboard")
- }
-}
-
-private struct FlowLiveActivityTrailingGlyph: View {
- let phase: FlowActivityAttributes.ContentState.Phase
- var isStale: Bool = false
-
- var body: some View {
- if isStale {
- Image(systemName: "bolt.slash.circle")
- .foregroundStyle(.secondary)
- } else {
- phaseGlyph
- }
- }
-
- @ViewBuilder
- private var phaseGlyph: some View {
- switch phase {
- case .recording:
- Image(systemName: "waveform")
- .foregroundStyle(.red)
- .symbolEffect(.variableColor.iterative, options: .repeating)
- case .processing:
- Image(systemName: "ellipsis")
- .font(.title3.weight(.semibold))
- .foregroundStyle(.white)
- .symbolEffect(.variableColor.iterative, options: .repeating)
- case .idle:
- // Session ready but NOT listening — avoid a mic glyph so users
- // don't think the keyboard is recording in the background.
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.green)
- }
- }
-}
-
-private struct FlowLiveActivityPhaseLabel: View {
- let phase: FlowActivityAttributes.ContentState.Phase
- var isStale: Bool = false
-
- var body: some View {
- if isStale {
- Image(systemName: "bolt.slash.circle")
- .foregroundStyle(.secondary)
- } else {
- phaseLabel
- }
- }
-
- @ViewBuilder
- private var phaseLabel: some View {
- switch phase {
- case .recording:
- Text("REC")
- .font(.caption.monospacedDigit().weight(.bold))
- .foregroundStyle(.red)
- case .processing:
- Text("…")
- .font(.title3.weight(.semibold))
- case .idle:
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.green)
- }
- }
-}
-
-private struct FlowLiveActivityPhaseCaption: View {
- let phase: FlowActivityAttributes.ContentState.Phase
- var isStale: Bool = false
-
- var body: some View {
- if isStale {
- // Host process is gone — be honest about it and turn the card
- // into a recovery entry point (tap deep-links to startflow).
- Text("Session disconnected · tap to reconnect")
- } else {
- phaseCaption
- }
- }
-
- @ViewBuilder
- private var phaseCaption: some View {
- switch phase {
- case .idle:
- Text("Voice session active")
- case .recording:
- Text("Listening…")
- case .processing:
- Text("Transcribing…")
- }
- }
-}
diff --git a/OSGKeyboardLiveActivity/Info.plist b/OSGKeyboardLiveActivity/Info.plist
deleted file mode 100644
index 8a9eccc..0000000
--- a/OSGKeyboardLiveActivity/Info.plist
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
- CFBundleDevelopmentRegion
- $(DEVELOPMENT_LANGUAGE)
- CFBundleDisplayName
- OSGKeyboardLiveActivity
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- $(PRODUCT_NAME)
- CFBundlePackageType
- XPC!
- CFBundleShortVersionString
- $(MARKETING_VERSION)
- CFBundleVersion
- $(CURRENT_PROJECT_VERSION)
- NSExtension
-
- NSExtensionPointIdentifier
- com.apple.widgetkit-extension
-
- NSSupportsLiveActivities
-
-
-
diff --git a/OSGKeyboardLiveActivity/OSGKeyboardLiveActivityBundle.swift b/OSGKeyboardLiveActivity/OSGKeyboardLiveActivityBundle.swift
deleted file mode 100644
index ade0cdb..0000000
--- a/OSGKeyboardLiveActivity/OSGKeyboardLiveActivityBundle.swift
+++ /dev/null
@@ -1,12 +0,0 @@
-// OSGKeyboardLiveActivityBundle.swift
-// OSGKeyboard · Live Activity
-
-import SwiftUI
-import WidgetKit
-
-@main
-struct OSGKeyboardLiveActivityBundle: WidgetBundle {
- var body: some Widget {
- FlowLiveActivityWidget()
- }
-}
diff --git a/OSGKeyboardLiveActivity/en.lproj/Localizable.strings b/OSGKeyboardLiveActivity/en.lproj/Localizable.strings
deleted file mode 100644
index db8d765..0000000
--- a/OSGKeyboardLiveActivity/en.lproj/Localizable.strings
+++ /dev/null
@@ -1,7 +0,0 @@
-/* Live Activity captions. Keys are the English literals used in
- FlowLiveActivityWidget — SwiftUI Text(_:) resolves string literals as
- LocalizedStringKey against this table automatically. */
-"Voice session active" = "Voice session active";
-"Listening…" = "Listening…";
-"Transcribing…" = "Transcribing…";
-"Session disconnected · tap to reconnect" = "Session disconnected · tap to reconnect";
diff --git a/OSGKeyboardLiveActivity/zh-Hans.lproj/Localizable.strings b/OSGKeyboardLiveActivity/zh-Hans.lproj/Localizable.strings
deleted file mode 100644
index c7a037b..0000000
--- a/OSGKeyboardLiveActivity/zh-Hans.lproj/Localizable.strings
+++ /dev/null
@@ -1,6 +0,0 @@
-/* Live Activity 文案。键为 FlowLiveActivityWidget 中的英文字面量 —
- SwiftUI Text(_:) 会把字符串字面量按 LocalizedStringKey 在本表解析。 */
-"Voice session active" = "语音会话进行中";
-"Listening…" = "正在聆听…";
-"Transcribing…" = "正在转写…";
-"Session disconnected · tap to reconnect" = "会话已断开 · 点按重连";
diff --git a/OSGKeyboardMac/MacPolishStylesView.swift b/OSGKeyboardMac/MacPolishStylesView.swift
index 64bbba0..33e34bb 100644
--- a/OSGKeyboardMac/MacPolishStylesView.swift
+++ b/OSGKeyboardMac/MacPolishStylesView.swift
@@ -12,7 +12,6 @@ struct MacPolishStylesView: View {
@State private var editingPack: PolishStylePack?
@State private var viewingPack: PolishStylePack?
- @State private var showEditor = false
@State private var errorMessage: String?
private var lang: AppUILanguage { viewModel.config.uiLanguage }
@@ -44,8 +43,10 @@ struct MacPolishStylesView: View {
subtitle: MacL10n.string("mac.styles.subtitle", language: lang)
) {
Button {
- editingPack = nil
- showEditor = true
+ editingPack = PolishStylePack(
+ name: "",
+ prompt: PolishStylePackCatalog.newUserPromptTemplate
+ )
} label: {
Label(
MacL10n.string("mac.styles.add", language: lang),
@@ -78,9 +79,13 @@ struct MacPolishStylesView: View {
}
}
.background(palette.background)
- .sheet(isPresented: $showEditor) {
- MacPolishStyleEditor(pack: editingPack, language: lang) { pack in
- save(pack)
+ .sheet(item: $editingPack) { pack in
+ MacPolishStyleEditor(
+ pack: pack,
+ isNew: !catalog.entries.contains(where: { $0.id == pack.id }),
+ language: lang
+ ) { saved in
+ save(saved)
}
}
.sheet(item: $viewingPack) { pack in
@@ -140,7 +145,6 @@ struct MacPolishStylesView: View {
viewingPack = pack
} else {
editingPack = pack
- showEditor = true
}
},
duplicate: {
@@ -149,7 +153,6 @@ struct MacPolishStylesView: View {
prompt: pack.prompt,
allowsAddedEmoji: pack.allowsAddedEmoji
)
- showEditor = true
},
delete: {
delete(pack)
@@ -340,7 +343,8 @@ private struct MacPolishStylePromptDetailSheet: View {
}
private struct MacPolishStyleEditor: View {
- let pack: PolishStylePack?
+ let pack: PolishStylePack
+ let isNew: Bool
let language: AppUILanguage
let onSave: (PolishStylePack) -> Void
@@ -351,21 +355,23 @@ private struct MacPolishStyleEditor: View {
@State private var allowsAddedEmoji: Bool
init(
- pack: PolishStylePack?,
+ pack: PolishStylePack,
+ isNew: Bool,
language: AppUILanguage,
onSave: @escaping (PolishStylePack) -> Void
) {
self.pack = pack
+ self.isNew = isNew
self.language = language
self.onSave = onSave
- _name = State(initialValue: pack?.name ?? "")
- _prompt = State(initialValue: pack?.prompt ?? PolishStylePackCatalog.newUserPromptTemplate)
- _allowsAddedEmoji = State(initialValue: pack?.allowsAddedEmoji ?? false)
+ _name = State(initialValue: pack.name)
+ _prompt = State(initialValue: pack.prompt)
+ _allowsAddedEmoji = State(initialValue: pack.allowsAddedEmoji)
}
var body: some View {
VStack(alignment: .leading, spacing: Spacing.md) {
- Text(MacL10n.string(pack == nil ? "mac.styles.add" : "mac.styles.edit", language: language))
+ Text(MacL10n.string(isNew ? "mac.styles.add" : "mac.styles.edit", language: language))
.font(TypeStyle.title2)
TextField(MacL10n.string("mac.styles.name", language: language), text: $name)
.textFieldStyle(.roundedBorder)
@@ -412,13 +418,13 @@ private struct MacPolishStyleEditor: View {
Button(MacL10n.string("mac.save", language: language)) {
onSave(
PolishStylePack(
- id: pack?.id ?? "user.\(UUID().uuidString.lowercased())",
+ id: pack.id,
name: name,
prompt: prompt,
allowsAddedEmoji: allowsAddedEmoji
|| PolishStylePack.promptDeclaresAddedEmojiOptIn(prompt),
kind: .user,
- createdAt: pack?.createdAt ?? Date()
+ createdAt: pack.createdAt
)
)
dismiss()
diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
index 6353446..9fd35ec 100644
--- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift
+++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
@@ -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)
diff --git a/OSGKeyboardShared/Models/FlowInactivityDuration.swift b/OSGKeyboardShared/Models/FlowInactivityDuration.swift
index 56d90db..de6c25f 100644
--- a/OSGKeyboardShared/Models/FlowInactivityDuration.swift
+++ b/OSGKeyboardShared/Models/FlowInactivityDuration.swift
@@ -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
diff --git a/OSGKeyboardShared/Models/FlowKeepAliveMode.swift b/OSGKeyboardShared/Models/FlowKeepAliveMode.swift
deleted file mode 100644
index 2d045ff..0000000
--- a/OSGKeyboardShared/Models/FlowKeepAliveMode.swift
+++ /dev/null
@@ -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
- }
-}
diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift
index 4616734..26074bc 100644
--- a/OSGKeyboardShared/Models/ProviderConfig.swift
+++ b/OSGKeyboardShared/Models/ProviderConfig.swift
@@ -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
diff --git a/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift b/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift
index 47babba..704df1e 100644
--- a/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift
+++ b/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift
@@ -31,7 +31,6 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
public var activePolishStyleId: SyncedField
public var llmThinkingEnabled: SyncedField
public var flowSkipAppSwitch: SyncedField
- public var flowKeepAliveMode: SyncedField
public var flowInactivityDuration: SyncedField
public init(
@@ -55,7 +54,6 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
activePolishStyleId: SyncedField,
llmThinkingEnabled: SyncedField,
flowSkipAppSwitch: SyncedField,
- flowKeepAliveMode: SyncedField,
flowInactivityDuration: SyncedField
) {
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.self, forKey: .flowSkipAppSwitch)
- flowKeepAliveMode = try container.decodeIfPresent(
- SyncedField.self,
- forKey: .flowKeepAliveMode
- ) ?? SyncedField(
- value: .liveActivity,
- updatedAt: flowSkipAppSwitch.updatedAt,
- deviceID: flowSkipAppSwitch.deviceID
- )
flowInactivityDuration = try container.decode(
SyncedField.self,
forKey: .flowInactivityDuration
)
+ let legacyContainer = try decoder.container(keyedBy: LegacyCodingKeys.self)
+ _ = try legacyContainer.decodeIfPresent(
+ SyncedField.self,
+ forKey: .flowKeepAliveMode
+ )
if let asrProvider = try container.decodeIfPresent(SyncedField.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(©.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
}
@@ -431,7 +422,6 @@ 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
}
diff --git a/OSGKeyboardShared/Services/FlowKeyboardPolicies.swift b/OSGKeyboardShared/Services/FlowKeyboardPolicies.swift
index d7f09a9..b1c060a 100644
--- a/OSGKeyboardShared/Services/FlowKeyboardPolicies.swift
+++ b/OSGKeyboardShared/Services/FlowKeyboardPolicies.swift
@@ -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 yellow「正在启动画中画」even though Picture in Picture was
+ /// used to flash yellow「正在启动…」even 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(
diff --git a/OSGKeyboardShared/Services/FlowSessionBridge.swift b/OSGKeyboardShared/Services/FlowSessionBridge.swift
index 5b11c07..cc020fb 100644
--- a/OSGKeyboardShared/Services/FlowSessionBridge.swift
+++ b/OSGKeyboardShared/Services/FlowSessionBridge.swift
@@ -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(
diff --git a/OSGKeyboardShared/Services/FlowSessionPolicy.swift b/OSGKeyboardShared/Services/FlowSessionPolicy.swift
index 2b27a7d..19f24fb 100644
--- a/OSGKeyboardShared/Services/FlowSessionPolicy.swift
+++ b/OSGKeyboardShared/Services/FlowSessionPolicy.swift
@@ -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 {
diff --git a/OSGKeyboardShared/Views/FlowDebugPanel.swift b/OSGKeyboardShared/Views/FlowDebugPanel.swift
index 3dfc085..0a32e39 100644
--- a/OSGKeyboardShared/Views/FlowDebugPanel.swift
+++ b/OSGKeyboardShared/Views/FlowDebugPanel.swift
@@ -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"),
diff --git a/OSGKeyboardTests/AppGroupConfigurationTests.swift b/OSGKeyboardTests/AppGroupConfigurationTests.swift
index a39dd37..931b810 100644
--- a/OSGKeyboardTests/AppGroupConfigurationTests.swift
+++ b/OSGKeyboardTests/AppGroupConfigurationTests.swift
@@ -34,22 +34,9 @@ final class AppGroupConfigurationTests: XCTestCase {
XCTAssertEqual(config.polishIntensity, .light)
XCTAssertTrue(config.personalDictionary.entries.isEmpty)
XCTAssertTrue(config.flowSkipAppSwitch)
- XCTAssertEqual(config.flowKeepAliveMode, .pictureInPicture)
XCTAssertEqual(config.flowInactivityDuration, .fiveMinutes)
}
- func testLoadPreservesStoredLiveActivityKeepAliveMode() {
- let defaults = makeDefaults()
- defaults.set(
- FlowKeepAliveMode.liveActivity.rawValue,
- forKey: AppGroupConfiguration.Keys.flowKeepAliveMode
- )
-
- let config = AppGroupConfiguration.load(fromAvailable: defaults)
-
- XCTAssertEqual(config.flowKeepAliveMode, .liveActivity)
- }
-
func testSaveAndLoadRoundTrip() {
let defaults = makeDefaults()
var config = AppGroupConfiguration.load(fromAvailable: defaults)
@@ -73,7 +60,6 @@ final class AppGroupConfigurationTests: XCTestCase {
config.keyboardHapticIntensity = .strong
config.polishIntensity = .heavy
config.flowSkipAppSwitch = false
- config.flowKeepAliveMode = .pictureInPicture
// Use a non-default value so the round-trip actually proves persistence.
config.flowInactivityDuration = .threeHours
config.save(to: defaults)
@@ -98,7 +84,6 @@ final class AppGroupConfigurationTests: XCTestCase {
XCTAssertEqual(loaded.keyboardHapticIntensity, .strong)
XCTAssertEqual(loaded.polishIntensity, .heavy)
XCTAssertFalse(loaded.flowSkipAppSwitch)
- XCTAssertEqual(loaded.flowKeepAliveMode, .pictureInPicture)
XCTAssertEqual(loaded.flowInactivityDuration, .threeHours)
}
diff --git a/OSGKeyboardTests/FlowHandoffPolicyTests.swift b/OSGKeyboardTests/FlowHandoffPolicyTests.swift
index 2d5a0da..849aaa9 100644
--- a/OSGKeyboardTests/FlowHandoffPolicyTests.swift
+++ b/OSGKeyboardTests/FlowHandoffPolicyTests.swift
@@ -294,7 +294,7 @@ final class FlowHandoffPolicyTests: XCTestCase {
defaults.removePersistentDomain(forName: suite)
let sessionId = UUID()
- FlowSessionBridge.markSessionActive(duration: 1_800, sessionId: sessionId, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
FlowSessionBridge.writeReadySnapshot(
FlowReadySnapshot(
sessionId: sessionId,
@@ -303,7 +303,7 @@ final class FlowHandoffPolicyTests: XCTestCase {
engineMode: "local",
localeId: "zh-Hans",
busyUtteranceId: UUID(),
- sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(defaults: defaults),
+ sessionExpiresAt: nil,
hostGeneration: FlowSessionBridge.currentHostGeneration(defaults: defaults)
),
defaults: defaults
diff --git a/OSGKeyboardTests/FlowSessionBridgeTests.swift b/OSGKeyboardTests/FlowSessionBridgeTests.swift
index 1f22a8e..ef40a41 100644
--- a/OSGKeyboardTests/FlowSessionBridgeTests.swift
+++ b/OSGKeyboardTests/FlowSessionBridgeTests.swift
@@ -15,7 +15,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testSessionActiveSurvivesStaleHeartbeatWhileNotExpired() {
let defaults = makeDefaults()
- FlowSessionBridge.markSessionActive(duration: 60, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
@@ -28,7 +28,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testHostStaleWhenHeartbeatVeryOld() {
let defaults = makeDefaults()
- FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
@@ -39,7 +39,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testClearIfHostStaleRemovesZombieSession() {
let defaults = makeDefaults()
- FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setRecordingState(.stopped, defaults: defaults)
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
@@ -59,18 +59,13 @@ final class FlowSessionBridgeTests: XCTestCase {
XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults))
}
- func testSessionInactiveWhenExpired() {
+ func testPersistentSessionIgnoresLegacyExpiry() {
let defaults = makeDefaults()
- // Expiry only applies on the Live Activity keep-alive path (PiP is persistent).
- defaults.set(
- FlowKeepAliveMode.liveActivity.rawValue,
- forKey: AppGroupConfiguration.Keys.flowKeepAliveMode
- )
- FlowSessionBridge.markSessionActive(duration: 1, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let expired = Date().timeIntervalSince1970 - 5
defaults.set(expired, forKey: FlowSessionKeys.flowSessionExpires)
- XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults))
- XCTAssertFalse(FlowSessionBridge.isHostReachable(defaults: defaults))
+ XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
+ XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
}
func testRecordingStateRoundTrip() {
@@ -104,7 +99,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testClearFlowStateRemovesSessionKeys() {
let defaults = makeDefaults()
- FlowSessionBridge.markSessionActive(defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.storeTranscriptionResult("x", defaults: defaults)
FlowSessionBridge.clearFlowState(defaults: defaults)
@@ -113,18 +108,11 @@ final class FlowSessionBridgeTests: XCTestCase {
XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .idle)
}
- func testRemainingSessionDurationNilWhenExpired() {
+ func testPersistentActivationClearsLegacyExpiry() {
let defaults = makeDefaults()
- defaults.set(
- FlowKeepAliveMode.liveActivity.rawValue,
- forKey: AppGroupConfiguration.Keys.flowKeepAliveMode
- )
- FlowSessionBridge.markSessionActive(duration: 1, defaults: defaults)
- XCTAssertNotNil(FlowSessionBridge.remainingSessionDuration(defaults: defaults))
-
- let expired = Date().timeIntervalSince1970 - 5
- defaults.set(expired, forKey: FlowSessionKeys.flowSessionExpires)
- XCTAssertNil(FlowSessionBridge.remainingSessionDuration(defaults: defaults))
+ defaults.set(Date().timeIntervalSince1970 + 60, forKey: FlowSessionKeys.flowSessionExpires)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
+ XCTAssertNil(defaults.object(forKey: FlowSessionKeys.flowSessionExpires))
}
func testConsumeTranscriptionErrorIncludesKind() {
@@ -156,7 +144,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testHostReadyRequiresExplicitContract() {
let defaults = makeDefaults()
- FlowSessionBridge.markSessionActive(duration: 60, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
@@ -166,7 +154,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testHostReadyFalseWhenHeartbeatStale() {
let defaults = makeDefaults()
- FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setHostReady(true, defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
@@ -177,7 +165,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testHeartbeatRefreshKeepsHostReadyPublished() {
let defaults = makeDefaults()
- FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setHostReady(true, defaults: defaults)
FlowSessionBridge.writeHeartbeat(defaults: defaults)
@@ -187,7 +175,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testClearFlowStateClearsHostReady() {
let defaults = makeDefaults()
- FlowSessionBridge.markSessionActive(defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setHostReady(true, defaults: defaults)
FlowSessionBridge.clearFlowState(defaults: defaults)
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.flowHostReady))
@@ -305,7 +293,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testNotReadySnapshotDoesNotRefreshHeartbeat() {
let defaults = makeDefaults()
- FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
@@ -328,7 +316,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testBusySnapshotStillRefreshesHeartbeat() {
let defaults = makeDefaults()
- FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let staleHeartbeat = Date().timeIntervalSince1970 - 10
defaults.set(staleHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
@@ -359,7 +347,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testNotReadyStartingSnapshotIsRetainedWithoutRevivingHeartbeat() {
let defaults = makeDefaults()
- FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
@@ -387,7 +375,7 @@ final class FlowSessionBridgeTests: XCTestCase {
let now = Date().timeIntervalSince1970
FlowSessionBridge.rotateHostGeneration(defaults: defaults)
let liveGeneration = FlowSessionBridge.currentHostGeneration(defaults: defaults)
- FlowSessionBridge.markSessionActive(duration: 60, sessionId: sessionId, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
FlowSessionBridge.writeReadySnapshot(
FlowReadySnapshot(
sessionId: sessionId,
@@ -412,7 +400,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testClearFlowStateOnHostLaunchPreservesPendingHost() {
let defaults = makeDefaults()
- FlowSessionBridge.markSessionActive(duration: 3_600, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
FlowSessionBridge.setHostReady(true, defaults: defaults)
FlowSessionBridge.setPendingHostBundleId("com.example.host", defaults: defaults)
@@ -478,7 +466,7 @@ final class FlowSessionBridgeTests: XCTestCase {
let defaults = makeDefaults()
let sessionId = UUID()
let now = Date().timeIntervalSince1970
- FlowSessionBridge.markSessionActive(duration: 60, sessionId: sessionId, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
let snapshot = FlowReadySnapshot(
sessionId: sessionId,
ready: true,
@@ -501,7 +489,7 @@ final class FlowSessionBridgeTests: XCTestCase {
let defaults = makeDefaults()
let sessionId = UUID()
let now = Date().timeIntervalSince1970
- FlowSessionBridge.markSessionActive(duration: 60, sessionId: sessionId, defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
let skewed = FlowReadySnapshot(
sessionId: sessionId,
ready: true,
diff --git a/OSGKeyboardTests/FlowSessionPolicyTests.swift b/OSGKeyboardTests/FlowSessionPolicyTests.swift
index b2692c3..09ddb43 100644
--- a/OSGKeyboardTests/FlowSessionPolicyTests.swift
+++ b/OSGKeyboardTests/FlowSessionPolicyTests.swift
@@ -29,40 +29,20 @@ final class FlowSessionPolicyTests: XCTestCase {
XCTAssertEqual(FlowInactivityDuration.tenMinutes.timeInterval, 10 * 60)
}
- func testKeepAliveModeDefaultsToPictureInPicture() {
- let defaults = makeDefaults()
- XCTAssertEqual(FlowSessionPolicy.keepAliveMode(defaults: defaults), .pictureInPicture)
- XCTAssertFalse(FlowSessionPolicy.usesInactivityExpiry(defaults: defaults))
- }
-
func testPiPSessionHasNoInactivityExpiry() {
let defaults = makeDefaults()
- defaults.set(FlowKeepAliveMode.pictureInPicture.rawValue,
- forKey: AppGroupConfiguration.Keys.flowKeepAliveMode)
- FlowSessionBridge.markSessionActive(sessionId: UUID(), defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(sessionId: UUID(), defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
- XCTAssertNil(FlowSessionBridge.sessionExpiresAt(defaults: defaults))
-
- FlowSessionBridge.touchLastActivity(defaults: defaults)
- XCTAssertNil(FlowSessionBridge.sessionExpiresAt(defaults: defaults))
+ XCTAssertNil(defaults.object(forKey: FlowSessionKeys.flowSessionExpires))
}
- func testTouchLastActivityExtendsExpiry() {
+ func testLegacyExpiryDoesNotInvalidatePersistentSession() {
let defaults = makeDefaults()
- defaults.set(
- FlowKeepAliveMode.liveActivity.rawValue,
- forKey: AppGroupConfiguration.Keys.flowKeepAliveMode
- )
- defaults.set(FlowInactivityDuration.tenMinutes.rawValue, forKey: AppGroupConfiguration.Keys.flowInactivityDuration)
- FlowSessionBridge.markSessionActive(defaults: defaults)
+ FlowSessionBridge.markSessionActivePersistent(defaults: defaults)
+ defaults.set(Date().timeIntervalSince1970 - 30, forKey: FlowSessionKeys.flowSessionExpires)
- let staleExpiry = Date().timeIntervalSince1970 + 30
- defaults.set(staleExpiry, forKey: FlowSessionKeys.flowSessionExpires)
- FlowSessionBridge.touchLastActivity(defaults: defaults)
-
- let refreshed = FlowSessionBridge.sessionExpiresAt(defaults: defaults) ?? 0
- XCTAssertGreaterThan(refreshed, staleExpiry)
+ XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
}
func testPendingHostBundleIdRoundTrip() {
diff --git a/OSGKeyboardTests/SettingsCloudSyncTests.swift b/OSGKeyboardTests/SettingsCloudSyncTests.swift
index 544d97b..1a9597c 100644
--- a/OSGKeyboardTests/SettingsCloudSyncTests.swift
+++ b/OSGKeyboardTests/SettingsCloudSyncTests.swift
@@ -64,7 +64,6 @@ final class SettingsCloudSyncTests: XCTestCase {
activePolishStyleId: SyncedField(value: "builtin.light", updatedAt: stampA, deviceID: deviceA),
llmThinkingEnabled: SyncedField(value: false, updatedAt: stampA, deviceID: deviceA),
flowSkipAppSwitch: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
- flowKeepAliveMode: SyncedField(value: .liveActivity, updatedAt: stampA, deviceID: deviceA),
flowInactivityDuration: SyncedField(value: .twelveHours, updatedAt: stampA, deviceID: deviceA)
)
let remote = SyncedAppSettingsV2(
@@ -87,7 +86,6 @@ final class SettingsCloudSyncTests: XCTestCase {
activePolishStyleId: SyncedField(value: "builtin.formal", updatedAt: stampB, deviceID: deviceB),
llmThinkingEnabled: SyncedField(value: true, updatedAt: stampB, deviceID: deviceB),
flowSkipAppSwitch: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
- flowKeepAliveMode: SyncedField(value: .pictureInPicture, updatedAt: stampB, deviceID: deviceB),
flowInactivityDuration: SyncedField(value: .threeHours, updatedAt: stampB, deviceID: deviceB)
)
@@ -100,6 +98,31 @@ final class SettingsCloudSyncTests: XCTestCase {
XCTAssertEqual(merged.polishIntensity.value, .heavy)
}
+ func testLegacyKeepAliveFieldDecodesButIsNotReencoded() throws {
+ let payload = SyncedAppSettingsV2.seeded(
+ from: AppGroupConfiguration.load(fromAvailable: defaults),
+ deviceID: deviceA,
+ updatedAt: Date(timeIntervalSince1970: 100)
+ )
+ let encoder = JSONEncoder()
+ var object = try XCTUnwrap(
+ JSONSerialization.jsonObject(with: encoder.encode(payload)) as? [String: Any]
+ )
+ object["flowKeepAliveMode"] = [
+ "value": "liveActivity",
+ "updatedAt": 0,
+ "deviceID": "legacy-device",
+ ]
+ let legacyData = try JSONSerialization.data(withJSONObject: object)
+
+ let decoded = try JSONDecoder().decode(SyncedAppSettingsV2.self, from: legacyData)
+ let reencodedObject = try XCTUnwrap(
+ JSONSerialization.jsonObject(with: encoder.encode(decoded)) as? [String: Any]
+ )
+
+ XCTAssertNil(reencodedObject["flowKeepAliveMode"])
+ }
+
func testLegacyV1PullDoesNotClearKeychain() async throws {
try Keychain.setAPIKey("sk-local-openai", for: "openai", useICloudSync: false)
store.setSettingsICloudSyncEnabled(true)
diff --git a/README.en.md b/README.en.md
index 752638b..1ae61c3 100644
--- a/README.en.md
+++ b/README.en.md
@@ -55,7 +55,7 @@ Voice input for iPhone, iPad, and Mac. Speak in any app — polished text lands
| Post-polish translation | ✅ | ✅ |
| Personal dictionary | ✅ iCloud sync; protects polish and boosts English suggestions | ✅ |
| Dictation history | ✅ | ✅ |
-| Live UI | ✅ Dynamic Island | ✅ floating pill |
+| Live UI | — (silent background keep-alive) | ✅ floating pill |
---
diff --git a/README.md b/README.md
index 49781bf..af1298e 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,7 @@
| 润色后翻译 | ✅ | ✅ |
| 个性词库 | ✅ iCloud 同步;保护润色并参与英文补全 | ✅ |
| 听写历史 | ✅ | ✅ |
-| 灵动岛 / 听写浮层 | ✅ Live Activity | ✅ 底部胶囊浮层 |
+| 听写浮层 | —(静默后台保活) | ✅ 底部胶囊浮层 |
---
diff --git a/project.yml b/project.yml
index d05c701..3f77da9 100644
--- a/project.yml
+++ b/project.yml
@@ -52,7 +52,7 @@ settings:
STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "1.6.5"
- CURRENT_PROJECT_VERSION: "51"
+ CURRENT_PROJECT_VERSION: "52"
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target
@@ -79,8 +79,6 @@ targets:
# crashes when both are passed. iOS 26 uses Icon Composer only.
- "Assets.xcassets/AppIcon.appiconset"
- "Resources/CustomLanguageModel/**"
- # Shared with the Live Activity widget so the host app can request sessions.
- - path: OSGKeyboardLiveActivity/FlowActivityAttributes.swift
entitlements:
path: OSGKeyboard/OSGKeyboard.entitlements
properties:
@@ -153,7 +151,6 @@ targets:
NSSpeechRecognitionUsageDescription: "OSGKeyboard uses speech recognition to transcribe your voice. Audio is processed on-device by default, or sent to your configured speech provider only when you enable cloud recognition."
UIBackgroundModes:
- audio
- NSSupportsLiveActivities: true
NSAppTransportSecurity:
NSAllowsArbitraryLoads: false
ITSAppUsesNonExemptEncryption: false
@@ -235,11 +232,7 @@ targets:
# Keyboard Extension is a plugin of the main App; embed it.
embed: true
buildPhase: embedAppExtensions
- - target: OSGKeyboardLiveActivity
- embed: true
- buildPhase: embedAppExtensions
- sdk: Speech.framework
- - sdk: ActivityKit.framework
- sdk: StoreKit.framework
# =========================================================
@@ -307,37 +300,6 @@ targets:
- target: OSGKeyboardShared
embed: false
- # =========================================================
- # Live Activity Widget Extension (Dynamic Island)
- # =========================================================
- OSGKeyboardLiveActivity:
- type: app-extension
- platform: iOS
- sources:
- - path: OSGKeyboardLiveActivity
- info:
- path: OSGKeyboardLiveActivity/Info.plist
- properties:
- CFBundleDisplayName: OSGKeyboardLiveActivity
- CFBundleShortVersionString: "$(MARKETING_VERSION)"
- CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
- NSSupportsLiveActivities: true
- NSExtension:
- NSExtensionPointIdentifier: com.apple.widgetkit-extension
- settings:
- base:
- PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.liveactivity
- TARGETED_DEVICE_FAMILY: "1,2"
- SUPPORTS_MACCATALYST: NO
- SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
- SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
- CODE_SIGN_STYLE: Automatic
- DEVELOPMENT_TEAM: X329MZU23S
- dependencies:
- - sdk: WidgetKit.framework
- - sdk: SwiftUI.framework
- - sdk: ActivityKit.framework
-
# Shared Framework (主 App 与扩展共用)
# =========================================================
OSGKeyboardShared:
@@ -630,7 +592,6 @@ schemes:
targets:
OSGKeyboard: all
OSGKeyboardExt: all
- OSGKeyboardLiveActivity: all
run:
config: Debug
storeKitConfiguration: OSGKeyboard.storekit