7f059dbd45
Migrate keyboard dictation to continuous Flow sessions with auto-start, tap-to-toggle recording, 60s countdown, five-step onboarding, and App Group IPC. Add docs/ GitHub Pages site with en/zh privacy policy for App Store compliance.
218 lines
8.3 KiB
Swift
218 lines
8.3 KiB
Swift
// FlowSessionBridge.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// TypeWhisper-style Flow session bridge: keyboard writes recording
|
|
// signals; host app writes transcription results. Legacy one-shot
|
|
// dictation handoff remains in `DictationBridge`.
|
|
|
|
import Foundation
|
|
|
|
public enum FlowSessionBridge {
|
|
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
|
|
if let defaults { return defaults }
|
|
return AppGroup.isAvailable ? AppGroup.defaults : .standard
|
|
}
|
|
|
|
/// Force cross-process visibility. Must only be called on the main thread.
|
|
private static func flush(_ store: UserDefaults) {
|
|
if Thread.isMainThread {
|
|
store.synchronize()
|
|
}
|
|
}
|
|
|
|
// MARK: - Session lifecycle (host app)
|
|
|
|
public static func markSessionActive(
|
|
duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
|
|
defaults: UserDefaults? = nil
|
|
) {
|
|
let store = resolvedDefaults(defaults)
|
|
let expires = Date().timeIntervalSince1970 + duration
|
|
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
|
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
|
writeHeartbeat(defaults: store)
|
|
setRecordingState(.idle, defaults: store)
|
|
clearTranscription(defaults: store)
|
|
flush(store)
|
|
}
|
|
|
|
public static func markSessionInactive(defaults: UserDefaults? = nil) {
|
|
let store = resolvedDefaults(defaults)
|
|
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
|
|
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
|
|
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
|
|
setRecordingState(.idle, defaults: store)
|
|
clearTranscription(defaults: store)
|
|
flush(store)
|
|
}
|
|
|
|
public static func writeHeartbeat(defaults: UserDefaults? = nil) {
|
|
let store = resolvedDefaults(defaults)
|
|
store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.flowHeartbeat)
|
|
flush(store)
|
|
}
|
|
|
|
public static func extendSession(
|
|
by duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
|
|
defaults: UserDefaults? = nil
|
|
) {
|
|
let store = resolvedDefaults(defaults)
|
|
let expires = Date().timeIntervalSince1970 + duration
|
|
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
|
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
|
flush(store)
|
|
}
|
|
|
|
// MARK: - Session validity (keyboard)
|
|
|
|
/// True when expires is in the future and heartbeat is fresh.
|
|
public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool {
|
|
let store = resolvedDefaults(defaults)
|
|
flush(store)
|
|
let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires)
|
|
guard expires > Date().timeIntervalSince1970 else { return false }
|
|
|
|
let heartbeat = store.double(forKey: FlowSessionKeys.flowHeartbeat)
|
|
guard heartbeat > 0 else { return false }
|
|
|
|
let staleness = Date().timeIntervalSince1970 - heartbeat
|
|
return staleness <= FlowSessionKeys.heartbeatStaleInterval
|
|
}
|
|
|
|
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(
|
|
_ state: FlowSessionKeys.RecordingState,
|
|
defaults: UserDefaults? = nil
|
|
) {
|
|
let store = resolvedDefaults(defaults)
|
|
store.set(state.rawValue, forKey: FlowSessionKeys.keyboardRecordingState)
|
|
flush(store)
|
|
}
|
|
|
|
public static func recordingState(
|
|
defaults: UserDefaults? = nil
|
|
) -> FlowSessionKeys.RecordingState {
|
|
let store = resolvedDefaults(defaults)
|
|
let raw = store.string(forKey: FlowSessionKeys.keyboardRecordingState) ?? FlowSessionKeys.RecordingState.idle.rawValue
|
|
return FlowSessionKeys.RecordingState(rawValue: raw) ?? .idle
|
|
}
|
|
|
|
public static func setTranscriptionLanguage(
|
|
_ localeId: String,
|
|
defaults: UserDefaults? = nil
|
|
) {
|
|
let store = resolvedDefaults(defaults)
|
|
store.set(localeId, forKey: FlowSessionKeys.transcriptionLanguage)
|
|
flush(store)
|
|
}
|
|
|
|
// MARK: - Results (host → keyboard)
|
|
|
|
public static func storeTranscriptionResult(
|
|
_ text: String,
|
|
defaults: UserDefaults? = nil
|
|
) {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
let store = resolvedDefaults(defaults)
|
|
store.set(trimmed, forKey: FlowSessionKeys.transcriptionResult)
|
|
store.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
|
setRecordingState(.idle, defaults: store)
|
|
flush(store)
|
|
}
|
|
|
|
public static func storeTranscriptionError(
|
|
_ message: String,
|
|
defaults: UserDefaults? = nil
|
|
) {
|
|
let store = resolvedDefaults(defaults)
|
|
store.set(message, forKey: FlowSessionKeys.transcriptionError)
|
|
setRecordingState(.idle, defaults: store)
|
|
flush(store)
|
|
}
|
|
|
|
/// Returns and clears a pending transcription result, if any.
|
|
public static func consumeTranscriptionResult(defaults: UserDefaults? = nil) -> String? {
|
|
let store = resolvedDefaults(defaults)
|
|
flush(store)
|
|
guard let text = store.string(forKey: FlowSessionKeys.transcriptionResult), !text.isEmpty else {
|
|
return nil
|
|
}
|
|
store.removeObject(forKey: FlowSessionKeys.transcriptionResult)
|
|
flush(store)
|
|
return text
|
|
}
|
|
|
|
/// Returns and clears a pending transcription error, if any.
|
|
public static func consumeTranscriptionError(defaults: UserDefaults? = nil) -> String? {
|
|
let store = resolvedDefaults(defaults)
|
|
flush(store)
|
|
guard let message = store.string(forKey: FlowSessionKeys.transcriptionError), !message.isEmpty else {
|
|
return nil
|
|
}
|
|
store.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
|
flush(store)
|
|
return message
|
|
}
|
|
|
|
public static func audioLevels(defaults: UserDefaults? = nil) -> [Float] {
|
|
let store = resolvedDefaults(defaults)
|
|
flush(store)
|
|
if let levels = store.array(forKey: FlowSessionKeys.audioLevels) as? [Double], !levels.isEmpty {
|
|
return levels.map { Float($0) }
|
|
}
|
|
if let levels = store.array(forKey: FlowSessionKeys.audioLevels) as? [NSNumber], !levels.isEmpty {
|
|
return levels.map { $0.floatValue }
|
|
}
|
|
return []
|
|
}
|
|
|
|
/// Host app: publish waveform bars for the keyboard (main thread only).
|
|
public static func storeAudioLevels(
|
|
_ levels: [Float],
|
|
defaults: UserDefaults? = nil
|
|
) {
|
|
let store = resolvedDefaults(defaults)
|
|
store.set(levels.map { Double($0) }, forKey: FlowSessionKeys.audioLevels)
|
|
flush(store)
|
|
}
|
|
|
|
/// Clear pending result/error before a new utterance.
|
|
public static func clearPendingTranscription(defaults: UserDefaults? = nil) {
|
|
let store = resolvedDefaults(defaults)
|
|
clearTranscription(defaults: store)
|
|
flush(store)
|
|
}
|
|
|
|
public static func clearFlowState(defaults: UserDefaults? = nil) {
|
|
let store = resolvedDefaults(defaults)
|
|
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
|
|
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
|
|
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
|
|
store.removeObject(forKey: FlowSessionKeys.keyboardRecordingState)
|
|
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
|
|
clearTranscription(defaults: store)
|
|
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
|
flush(store)
|
|
}
|
|
|
|
private static func clearTranscription(defaults: UserDefaults) {
|
|
defaults.removeObject(forKey: FlowSessionKeys.transcriptionResult)
|
|
defaults.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
|
}
|
|
}
|