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