merge: bring Live Activity removal into main

This commit is contained in:
Rocky
2026-08-07 11:32:31 +08:00
44 changed files with 276 additions and 1997 deletions
+6
View File
@@ -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
-2
View File
@@ -97,8 +97,6 @@
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>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.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -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<FlowActivityAttributes>?
/// 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<FlowActivityAttributes.ContentState> {
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<FlowActivityAttributes>.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<FlowActivityAttributes>.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
}
}
@@ -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"
}
}
}
+34 -477
View File
@@ -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<Void, Never>?
private var heartbeatTask: Task<Void, Never>?
private var expiryTask: Task<Void, Never>?
private var levelTask: Task<Void, Never>?
private var startTask: Task<Void, Never>?
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<Void, Never>?
/// True while handling a keyboard-initiated `startflow` cold start.
private var isColdStartHandoff = false
private var coldStartRecoveryTask: Task<Void, Never>?
/// 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
/// 170220 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/LLMLive 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 {
_ = duration
refreshHostReady()
return
}
let resolved = duration ?? FlowSessionPolicy.sessionDuration()
FlowSessionBridge.extendSession(by: resolved)
sessionExpiresAt = Date().addingTimeInterval(resolved)
scheduleExpiry(after: resolved)
}
/// 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,12 +531,10 @@ 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
}
}
// A system interruption (call / Siri) may be in progress. Probe it:
// `setActive(true)` inside `reassertIfRunning` fails while the
// interruption is live and succeeds once it ends which also covers
@@ -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
let canAcceptUtterance = pipController.isPictureInPictureActive
&& pollingAlive
&& !isUtteranceRecording
&& !isUtteranceProcessing
&& sessionWarning == nil
&& !capture.isInterrupted
&& !hasPendingDelivery
} else {
canAcceptUtterance = capture.engineIsLive
&& pollingAlive
&& hasRecentAudio
&& !isUtteranceRecording
&& !isUtteranceProcessing
&& sessionWarning == nil
&& !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,32 +672,8 @@ 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
@@ -911,14 +689,10 @@ 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)
@@ -930,55 +704,17 @@ final class FlowSessionManager: ObservableObject {
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
sessionWarning = message
traceState("startSessionAsync.failed", extra: "reason=captureStart error=\(message)")
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
}
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,46 +726,16 @@ 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() {
@@ -1039,7 +745,6 @@ final class FlowSessionManager: ObservableObject {
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
@@ -1047,25 +752,6 @@ final class FlowSessionManager: ObservableObject {
}
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
refreshHostReady()
handleColdStartAfterSessionReady()
}
private func waitForAudioProof() async -> Bool {
await capture.awaitAudioFlowing(timeout: Self.coldStartAudioProofTimeout)
}
@MainActor
@@ -1074,51 +760,39 @@ final class FlowSessionManager: ObservableObject {
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")
let message = AppL10n.string("flow.session.error.notPossible")
sessionWarning = message
showColdStartPipFailure(message: message)
} else {
message = AppL10n.string("flow.coldStart.error.audioTimeout")
sessionWarning = message
showColdStartAudioFailure(message: 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,7 +806,6 @@ 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 }
@@ -1147,80 +820,9 @@ final class FlowSessionManager: ObservableObject {
self.handleColdStartAfterSessionReady()
}
case .failed(let failure):
let message = AppL10n.string(failure.localizationKey)
self.sessionWarning = message
self.showColdStartPipFailure(message: message)
}
return
}
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()
self.sessionWarning = AppL10n.string(failure.localizationKey)
}
}
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) {
@@ -1494,7 +1096,6 @@ 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
@@ -1539,13 +1140,6 @@ final class FlowSessionManager: ObservableObject {
pendingStopUtteranceId = nil
endUtterance()
}
return
}
beginUtterance(utteranceId: utteranceId, commandSeq: commandSeq)
if pendingStopUtteranceId == currentUtteranceId {
pendingStopUtteranceId = nil
endUtterance()
}
}
/// Start capture for a PiP utterance without blocking on the first frame.
@@ -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([])
}
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)
}
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 {
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.
@@ -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()
}
}
@@ -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)
}
}
-11
View File
@@ -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
}
}
+30 -16
View File
@@ -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()
@@ -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 {
@@ -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 {
-4
View File
@@ -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()
}
+7 -42
View File
@@ -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";
+7 -42
View File
@@ -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" = "微信";
@@ -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,
-2
View File
@@ -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.";
@@ -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 重新启动";
@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,15 +0,0 @@
{
"images" : [
{
"filename" : "OSGLogo.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -1,7 +0,0 @@
<svg width="912" height="251" viewBox="56 387 912 251" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z" fill="white"/>
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z" fill="white"/>
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z" fill="white"/>
<path d="M512.741 483.398V473.561H625.864V483.398H512.741Z" fill="white"/>
<path d="M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -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
}
}
}
@@ -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…")
}
}
}
-31
View File
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>OSGKeyboardLiveActivity</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>
@@ -1,12 +0,0 @@
// OSGKeyboardLiveActivityBundle.swift
// OSGKeyboard · Live Activity
import SwiftUI
import WidgetKit
@main
struct OSGKeyboardLiveActivityBundle: WidgetBundle {
var body: some Widget {
FlowLiveActivityWidget()
}
}
@@ -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";
@@ -1,6 +0,0 @@
/* Live Activity 文案。键为 FlowLiveActivityWidget 中的英文字面量 —
SwiftUI Text(_:) 会把字符串字面量按 LocalizedStringKey 在本表解析。 */
"Voice session active" = "语音会话进行中";
"Listening…" = "正在聆听…";
"Transcribing…" = "正在转写…";
"Session disconnected · tap to reconnect" = "会话已断开 · 点按重连";
+22 -16
View File
@@ -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()
@@ -57,8 +57,6 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let settingsCloudPayloadV2 = "config.settings.cloudPayload.v2"
/// When true, the host app auto-returns to the source app after a cold-start handoff.
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
/// Raw `FlowKeepAliveMode` value; mutually exclusive PiP vs Live Activity path.
public static let flowKeepAliveMode = "config.flowKeepAliveMode"
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
public static let flowInactivityDuration = "config.flowInactivityDuration"
/// One-shot: remap previous product defaults (30m / 10m) 5m.
@@ -102,8 +100,6 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var settingsICloudSyncEnabled: Bool
/// Auto-return to the host app after `startflow` cold start (default on).
public var flowSkipAppSwitch: Bool
/// PiP vs Live Activity keep-alive strategy (mutually exclusive).
public var flowKeepAliveMode: FlowKeepAliveMode
/// Idle timeout before the Flow session ends; resets on each utterance.
public var flowInactivityDuration: FlowInactivityDuration
/// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
@@ -292,9 +288,6 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
return defaults.bool(forKey: Keys.flowSkipAppSwitch)
}(),
flowKeepAliveMode: FlowKeepAliveMode.fromStored(
defaults.string(forKey: Keys.flowKeepAliveMode)
),
flowInactivityDuration: FlowInactivityDuration.fromStored(
defaults.string(forKey: Keys.flowInactivityDuration)
),
@@ -406,7 +399,6 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
defaults.set(activePolishStyleId, forKey: Keys.activePolishStyleId)
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
defaults.set(flowKeepAliveMode.rawValue, forKey: Keys.flowKeepAliveMode)
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled)
defaults.set(personalDictionaryICloudSyncEnabled, forKey: Keys.personalDictionaryICloudSyncEnabled)
@@ -32,18 +32,6 @@ public enum FlowInactivityDuration: String, CaseIterable, Identifiable, Sendable
}
}
public var labelKey: String {
switch self {
case .oneMinute: return "settings.flow.inactivity.1m"
case .fiveMinutes: return "settings.flow.inactivity.5m"
case .tenMinutes: return "settings.flow.inactivity.10m"
case .thirtyMinutes: return "settings.flow.inactivity.30m"
case .threeHours: return "settings.flow.inactivity.3h"
case .twelveHours: return "settings.flow.inactivity.12h"
case .twentyFourHours: return "settings.flow.inactivity.24h"
}
}
public static func fromStored(_ raw: String?) -> FlowInactivityDuration {
guard let raw, let value = FlowInactivityDuration(rawValue: raw) else {
return .default
@@ -1,39 +0,0 @@
// FlowKeepAliveMode.swift
// OSGKeyboard · Shared
//
// User-selectable Flow session keep-alive strategy (mutually exclusive).
import Foundation
public enum FlowKeepAliveMode: String, CaseIterable, Identifiable, Sendable, Codable {
/// Continuous audio capture + Live Activity.
case liveActivity = "liveActivity"
/// Picture-in-picture waveform keep-alive; mic released between utterances.
case pictureInPicture = "pictureInPicture"
public var id: String { rawValue }
/// Used when no valid keep-alive preference has been stored.
public static let `default`: FlowKeepAliveMode = .pictureInPicture
public var labelKey: String {
switch self {
case .liveActivity: return "settings.flow.keepAlive.liveActivity"
case .pictureInPicture: return "settings.flow.keepAlive.pictureInPicture"
}
}
public var subtitleKey: String {
switch self {
case .liveActivity: return "settings.flow.keepAlive.liveActivity.subtitle"
case .pictureInPicture: return "settings.flow.keepAlive.pictureInPicture.subtitle"
}
}
public static func fromStored(_ raw: String?) -> FlowKeepAliveMode {
guard let raw, let value = FlowKeepAliveMode(rawValue: raw) else {
return .default
}
return value
}
}
+1 -18
View File
@@ -259,22 +259,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
}
/// PiP vs Live Activity keep-alive (mutually exclusive).
@Published public var flowKeepAliveMode: FlowKeepAliveMode {
didSet {
guard !isApplyingConfiguration, flowKeepAliveMode != configuration.flowKeepAliveMode else { return }
configuration.flowKeepAliveMode = flowKeepAliveMode
if flowKeepAliveMode == .pictureInPicture {
configuration.flowSkipAppSwitch = true
if flowSkipAppSwitch != true {
flowSkipAppSwitch = true
}
}
persistConfiguration()
}
}
/// Idle window before an active Flow session expires; Live Activity mode only.
/// Retained for storage compatibility; persistent PiP sessions do not expire from inactivity.
@Published public var flowInactivityDuration: FlowInactivityDuration {
didSet {
guard !isApplyingConfiguration,
@@ -406,7 +391,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
polishIntensity = configuration.polishIntensity
llmThinkingEnabled = configuration.llmThinkingEnabled
flowSkipAppSwitch = configuration.flowSkipAppSwitch
flowKeepAliveMode = configuration.flowKeepAliveMode
flowInactivityDuration = configuration.flowInactivityDuration
localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled
isSyncingProviderAPIKey = true
@@ -498,7 +482,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
polishIntensity = fresh.polishIntensity
llmThinkingEnabled = fresh.llmThinkingEnabled
flowSkipAppSwitch = fresh.flowSkipAppSwitch
flowKeepAliveMode = fresh.flowKeepAliveMode
flowInactivityDuration = fresh.flowInactivityDuration
localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled
isSyncingProviderAPIKey = true
@@ -31,7 +31,6 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
public var activePolishStyleId: SyncedField<String>
public var llmThinkingEnabled: SyncedField<Bool>
public var flowSkipAppSwitch: SyncedField<Bool>
public var flowKeepAliveMode: SyncedField<FlowKeepAliveMode>
public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
public init(
@@ -55,7 +54,6 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
activePolishStyleId: SyncedField<String>,
llmThinkingEnabled: SyncedField<Bool>,
flowSkipAppSwitch: SyncedField<Bool>,
flowKeepAliveMode: SyncedField<FlowKeepAliveMode>,
flowInactivityDuration: SyncedField<FlowInactivityDuration>
) {
self.schemaVersion = schemaVersion
@@ -82,7 +80,6 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
self.activePolishStyleId = activePolishStyleId
self.llmThinkingEnabled = llmThinkingEnabled
self.flowSkipAppSwitch = flowSkipAppSwitch
self.flowKeepAliveMode = flowKeepAliveMode
self.flowInactivityDuration = flowInactivityDuration
}
@@ -107,10 +104,13 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
case activePolishStyleId
case llmThinkingEnabled
case flowSkipAppSwitch
case flowKeepAliveMode
case flowInactivityDuration
}
private enum LegacyCodingKeys: String, CodingKey {
case flowKeepAliveMode
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try container.decode(Int.self, forKey: .schemaVersion)
@@ -167,18 +167,15 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
deviceID: keyboardHapticIntensity.deviceID
)
flowSkipAppSwitch = try container.decode(SyncedField<Bool>.self, forKey: .flowSkipAppSwitch)
flowKeepAliveMode = try container.decodeIfPresent(
SyncedField<FlowKeepAliveMode>.self,
forKey: .flowKeepAliveMode
) ?? SyncedField(
value: .liveActivity,
updatedAt: flowSkipAppSwitch.updatedAt,
deviceID: flowSkipAppSwitch.deviceID
)
flowInactivityDuration = try container.decode(
SyncedField<FlowInactivityDuration>.self,
forKey: .flowInactivityDuration
)
let legacyContainer = try decoder.container(keyedBy: LegacyCodingKeys.self)
_ = try legacyContainer.decodeIfPresent(
SyncedField<String>.self,
forKey: .flowKeepAliveMode
)
if let asrProvider = try container.decodeIfPresent(SyncedField<String>.self, forKey: .asrProviderId) {
asrProviderId = asrProvider
@@ -224,7 +221,6 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
activePolishStyleId.updatedAt,
llmThinkingEnabled.updatedAt,
flowSkipAppSwitch.updatedAt,
flowKeepAliveMode.updatedAt,
flowInactivityDuration.updatedAt,
].max() ?? .distantPast
}
@@ -264,7 +260,6 @@ public extension SyncedAppSettingsV2 {
activePolishStyleId: field(configuration.activePolishStyleId),
llmThinkingEnabled: field(configuration.llmThinkingEnabled),
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
flowKeepAliveMode: field(configuration.flowKeepAliveMode),
flowInactivityDuration: field(configuration.flowInactivityDuration)
)
}
@@ -296,7 +291,6 @@ public extension SyncedAppSettingsV2 {
activePolishStyleId: field(PolishStylePackCatalog.defaultID),
llmThinkingEnabled: field(false),
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
flowKeepAliveMode: field(.liveActivity),
flowInactivityDuration: field(legacy.flowInactivityDuration)
)
}
@@ -340,7 +334,6 @@ public extension SyncedAppSettingsV2 {
),
llmThinkingEnabled: .merge(local: local.llmThinkingEnabled, remote: remote.llmThinkingEnabled),
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
flowKeepAliveMode: .merge(local: local.flowKeepAliveMode, remote: remote.flowKeepAliveMode),
flowInactivityDuration: .merge(
local: local.flowInactivityDuration,
remote: remote.flowInactivityDuration
@@ -368,7 +361,6 @@ public extension SyncedAppSettingsV2 {
configuration.activePolishStyleId = activePolishStyleId.value
configuration.llmThinkingEnabled = llmThinkingEnabled.value
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
configuration.flowKeepAliveMode = flowKeepAliveMode.value
configuration.flowInactivityDuration = flowInactivityDuration.value
}
@@ -398,7 +390,6 @@ public extension SyncedAppSettingsV2 {
patch(&copy.activePolishStyleId, value: configuration.activePolishStyleId)
patch(&copy.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
patch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
patch(&copy.flowKeepAliveMode, value: configuration.flowKeepAliveMode)
patch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy
}
@@ -431,7 +422,6 @@ public extension SyncedAppSettingsV2 {
touch(&copy.activePolishStyleId, value: configuration.activePolishStyleId)
touch(&copy.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
touch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
touch(&copy.flowKeepAliveMode, value: configuration.flowKeepAliveMode)
touch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy
}
@@ -17,7 +17,7 @@ public enum FlowKeyboardHostWarming {
/// Keep the mic green after the session has already proven ready.
///
/// Inter-utterance PiP flaps (mic release, ack lag, brief `reason=.starting`)
/// used to flash yelloweven though Picture in Picture was
/// used to flash yelloweven though the keep-alive surface was
/// already running. Hold ready through those windows; real cold starts still
/// go through `isHostWarming` while `sessionProvenReady` is false.
public static func shouldHoldReady(
@@ -405,9 +405,8 @@ public enum FlowSessionBridge {
// a permanent orange `preparingSession` state.
clearHostReady(defaults: store, notify: false)
}
if let expires = snapshot.sessionExpiresAt {
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
}
// PiP sessions are persistent; clear expiry left by older Live Activity builds.
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
// Only a genuinely live host ready, or actively serving an
// utterance may refresh the heartbeat here. A host stuck in a
// failed cold start would otherwise keep "reviving" itself on every
@@ -430,20 +429,7 @@ public enum FlowSessionBridge {
// MARK: - Session lifecycle (host app)
public static func markSessionActive(
duration: TimeInterval? = nil,
sessionId: UUID? = nil,
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
if FlowSessionPolicy.usesInactivityExpiry(defaults: store) {
markSessionActiveWithExpiry(duration: duration, sessionId: sessionId, defaults: store)
} else {
markSessionActivePersistent(sessionId: sessionId, defaults: store)
}
}
/// PiP keep-alive: session stays valid until explicit teardown (no idle expiry).
/// PiP keep-alive: session stays valid until explicit teardown.
public static func markSessionActivePersistent(
sessionId: UUID? = nil,
defaults: UserDefaults? = nil
@@ -480,44 +466,6 @@ public enum FlowSessionBridge {
flush(store)
}
private static func markSessionActiveWithExpiry(
duration: TimeInterval? = nil,
sessionId: UUID? = nil,
defaults: UserDefaults
) {
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: defaults)
let now = Date().timeIntervalSince1970
let expires = now + resolvedDuration
defaults.set(true, forKey: FlowSessionKeys.flowSessionActive)
defaults.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
defaults.set(now, forKey: FlowSessionKeys.lastActivityAt)
writeHeartbeat(defaults: defaults)
clearTranscription(defaults: defaults)
defaults.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
defaults.removeObject(forKey: FlowSessionKeys.flowCommandJournalPayload)
defaults.removeObject(forKey: FlowSessionKeys.flowResultPayload)
defaults.removeObject(forKey: FlowSessionKeys.flowAckPayload)
defaults.removeObject(forKey: FlowSessionKeys.pendingKeyboardUtteranceId)
if let sessionId {
let snapshot = FlowReadySnapshot(
sessionId: sessionId,
ready: false,
reason: .starting,
heartbeatAt: now,
engineMode: AppGroupConfiguration.load(fromAvailable: defaults).engineMode,
localeId: AppGroupConfiguration.load(fromAvailable: defaults).localeId,
sessionExpiresAt: expires,
hostGeneration: defaults.string(forKey: FlowSessionKeys.hostGeneration)
)
if let data = encode(snapshot) {
defaults.set(data, forKey: FlowSessionKeys.flowReadyPayload)
}
} else {
defaults.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
}
flush(defaults)
}
public static func markSessionInactive(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
@@ -544,31 +492,6 @@ public enum FlowSessionBridge {
flush(store)
}
public static func extendSession(
by duration: TimeInterval? = nil,
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
guard FlowSessionPolicy.usesInactivityExpiry(defaults: store) else { return }
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
let expires = Date().timeIntervalSince1970 + resolvedDuration
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
flush(store)
}
/// Resets the inactivity timer after utterance completion or explicit activity.
public static func touchLastActivity(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
guard FlowSessionPolicy.usesInactivityExpiry(defaults: store) else { return }
let now = Date().timeIntervalSince1970
let duration = FlowSessionPolicy.sessionDuration(defaults: store)
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
store.set(now + duration, forKey: FlowSessionKeys.flowSessionExpires)
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
flush(store)
}
// MARK: - Host return (scheme D)
public static func setPendingHostBundleId(_ bundleId: String?, defaults: UserDefaults? = nil) {
@@ -592,18 +515,11 @@ public enum FlowSessionBridge {
// MARK: - Session validity (keyboard)
/// True when the App Group session contract is still valid (not expired).
/// True while the persistent PiP session contract is active.
/// Does **not** mean the host can accept utterances use `isHostReady()`.
public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool {
let store = resolvedDefaults(defaults)
guard store.bool(forKey: FlowSessionKeys.flowSessionActive) else { return false }
if !FlowSessionPolicy.usesInactivityExpiry(defaults: store) {
return true
}
let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires)
return expires > Date().timeIntervalSince1970
return store.bool(forKey: FlowSessionKeys.flowSessionActive)
}
/// Seconds since the host last wrote `flowHeartbeat`; nil when never written.
@@ -800,19 +716,6 @@ public enum FlowSessionBridge {
return true
}
public static func sessionExpiresAt(defaults: UserDefaults? = nil) -> TimeInterval? {
let store = resolvedDefaults(defaults)
let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires)
return expires > 0 ? expires : nil
}
/// Seconds until session expiry; nil when expired or never started.
public static func remainingSessionDuration(defaults: UserDefaults? = nil) -> TimeInterval? {
guard let expires = sessionExpiresAt(defaults: defaults) else { return nil }
let remaining = expires - Date().timeIntervalSince1970
return remaining > 0 ? remaining : nil
}
// MARK: - Recording signals (keyboard host)
public static func setRecordingState(
@@ -25,18 +25,6 @@ public enum FlowSessionPolicy {
inactivityDuration(defaults: defaults).timeInterval
}
public static func keepAliveMode(defaults: UserDefaults? = nil) -> FlowKeepAliveMode {
let store = resolvedDefaults(defaults)
return FlowKeepAliveMode.fromStored(
store.string(forKey: AppGroupConfiguration.Keys.flowKeepAliveMode)
)
}
/// PiP sessions have no inactivity expiry; only the Live Activity path times out.
public static func usesInactivityExpiry(defaults: UserDefaults? = nil) -> Bool {
keepAliveMode(defaults: defaults) == .liveActivity
}
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
if let defaults { return defaults }
guard let available = AppGroup.defaultsIfAvailable else {
@@ -36,15 +36,8 @@ public enum FlowDebugAppGroupSnapshot {
}
return String(g.prefix(8))
}()
let expires: String = {
guard let ts = FlowSessionBridge.sessionExpiresAt(defaults: defaults) else { return "nil" }
let remaining = ts - Date().timeIntervalSince1970
return String(format: "%.0fs", remaining)
}()
return [
FlowDebugRow("sessionActive", FlowSessionBridge.isSessionActive(defaults: defaults) ? "1" : "0"),
FlowDebugRow("expiresIn", expires),
FlowDebugRow("hostReachable", FlowSessionBridge.isHostReachable(defaults: defaults) ? "1" : "0"),
FlowDebugRow("hostReady", FlowSessionBridge.isHostReady(defaults: defaults) ? "1" : "0"),
FlowDebugRow("hostStale", FlowSessionBridge.isHostStale(defaults: defaults) ? "1" : "0"),
@@ -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)
}
@@ -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
+23 -35
View File
@@ -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,
+6 -26
View File
@@ -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() {
+25 -2
View File
@@ -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)
+1 -1
View File
@@ -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 |
---
+1 -1
View File
@@ -57,7 +57,7 @@
| 润色后翻译 | ✅ | ✅ |
| 个性词库 | ✅ iCloud 同步;保护润色并参与英文补全 | ✅ |
| 听写历史 | ✅ | ✅ |
| 灵动岛 / 听写浮层 | ✅ Live Activity | ✅ 底部胶囊浮层 |
| 听写浮层 | —(静默后台保活) | ✅ 底部胶囊浮层 |
---
+1 -40
View File
@@ -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.xcconfiggitignored,不会被覆盖)
# 项目级签名 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