feat(flow): Live Activity Dynamic Island branding and mis PrimaryLanguage

- Add OSGKeyboardLiveActivity widget extension (ActivityKit) with branded
  compactLeading mark in the Dynamic Island during Flow sessions
- Wire FlowLiveActivityController into FlowSessionManager lifecycle
- Set keyboard PrimaryLanguage to mis in project.yml, Info.plist, and at runtime
- Enable NSSupportsLiveActivities on main app and widget extension targets

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-07-06 05:53:24 +00:00
parent 537a68552a
commit 6489340b2e
11 changed files with 341 additions and 2 deletions
+2
View File
@@ -58,6 +58,8 @@
<array>
<string>audio</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
@@ -0,0 +1,76 @@
// 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
@MainActor
enum FlowLiveActivityController {
private static var currentActivity: Activity<FlowActivityAttributes>?
/// Begin showing OSGKeyboard in the Dynamic Island for an active Flow session.
static func startSession() {
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
debug("Live Activity disabled in Settings")
return
}
endStaleActivities()
guard currentActivity == nil else {
update(phase: .idle)
return
}
let state = FlowActivityAttributes.ContentState(phase: .idle)
let content = ActivityContent(state: state, staleDate: nil)
do {
currentActivity = try Activity.request(
attributes: FlowActivityAttributes(),
content: content,
pushType: nil
)
debug("Live Activity started")
} catch {
debug("Live Activity start failed: \(error.localizedDescription)")
}
}
static func update(phase: FlowActivityAttributes.ContentState.Phase) {
guard let activity = currentActivity else { return }
let state = FlowActivityAttributes.ContentState(phase: phase)
let content = ActivityContent(state: state, staleDate: nil)
Task {
await activity.update(content)
}
}
/// Dismiss the island presentation when the Flow session ends.
static func endSession() {
guard let activity = currentActivity else {
endStaleActivities()
return
}
currentActivity = nil
Task {
await activity.end(nil, dismissalPolicy: .immediate)
debug("Live Activity ended")
}
}
/// Host relaunch can leave orphan activities; clear them before starting anew.
private static func endStaleActivities() {
for activity in Activity<FlowActivityAttributes>.activities {
Task {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
currentActivity = nil
}
}
@@ -156,6 +156,7 @@ final class FlowSessionManager: ObservableObject {
// (e.g. switching from cloud to local) is honoured.
bindSessionASRIfNeeded()
scheduleASRWarmup()
FlowLiveActivityController.startSession()
debug("Flow session restored (\(Int(remaining))s remaining)")
}
@@ -196,6 +197,7 @@ final class FlowSessionManager: ObservableObject {
sessionASRWarmedLocaleID = nil
FlowSessionBridge.markSessionInactive()
FlowSessionDarwin.postSessionChanged()
FlowLiveActivityController.endSession()
isActive = false
sessionExpiresAt = nil
sessionWarning = nil
@@ -339,6 +341,7 @@ final class FlowSessionManager: ObservableObject {
// while the session was idle.
bindSessionASRIfNeeded()
scheduleASRWarmup()
FlowLiveActivityController.startSession()
debug("Flow session started (\(Int(duration))s), continuous capture running")
}
@@ -437,6 +440,7 @@ final class FlowSessionManager: ObservableObject {
isUtteranceRecording = true
utteranceRecordingStartedAt = Date()
FlowLiveActivityController.update(phase: .recording)
FlowDiagnostics.log(
"beginUtterance engine=\(store.engineMode) " +
"asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
@@ -489,6 +493,7 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.setRecordingState(.processing)
isUtteranceRecording = false
isUtteranceProcessing = true
FlowLiveActivityController.update(phase: .processing)
// Do NOT cancel `asrTask` or `asr` the preview pipeline relies on
// the consumer staying alive until `.final` lands (see
@@ -517,6 +522,7 @@ final class FlowSessionManager: ObservableObject {
chunkWarnings = []
FlowSessionBridge.storeTranscriptionPartial("")
FlowSessionBridge.setRecordingState(.idle)
FlowLiveActivityController.update(phase: .idle)
debug("utterance aborted")
}
@@ -540,6 +546,7 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.storeTranscriptionPartial("")
FlowSessionBridge.storeTranscriptionError(message, kind: kind)
FlowSessionBridge.setRecordingState(.idle)
FlowLiveActivityController.update(phase: .idle)
debug("utterance failed: \(message)")
}
@@ -558,6 +565,7 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.storeTranscriptionPartial("")
FlowSessionBridge.storeTranscriptionError(message, kind: kind)
FlowSessionBridge.setRecordingState(.idle)
FlowLiveActivityController.update(phase: .idle)
debug("utterance processing failed: \(message)")
}
@@ -566,6 +574,7 @@ final class FlowSessionManager: ObservableObject {
defer {
isUtteranceProcessing = false
FlowSessionBridge.setRecordingState(.idle)
FlowLiveActivityController.update(phase: .idle)
}
let asrWait = asrWaitTimeout()