Files
OSGKeyboard/OSGKeyboardShared/Services/DictationBridge.swift
T
Rocky 7f059dbd45 feat: TypeWhisper Flow sessions, Phase 4 UX, and GitHub Pages privacy site
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.
2026-06-19 18:05:50 +08:00

107 lines
3.7 KiB
Swift

// DictationBridge.swift
// OSGKeyboard · Shared
//
// Lightweight App Group bridge for host-app dictation handoff:
// keyboard extension -> open host app for recording
// host app -> writes final transcript
// keyboard extension -> consumes pending transcript and inserts text
import Foundation
public enum DictationBridge {
public enum Status: String, Sendable, Equatable {
case idle
case requested
case recording
case transcribing
case done
case cancelled
case error
}
private enum Key {
static let pendingText = "dictation.pendingText"
static let updatedAt = "dictation.updatedAt"
static let status = "dictation.status"
static let statusUpdatedAt = "dictation.statusUpdatedAt"
static let statusMessage = "dictation.statusMessage"
}
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
if let defaults {
return defaults
}
return AppGroup.isAvailable ? AppGroup.defaults : .standard
}
public static func setStatus(
_ status: Status,
message: String? = nil,
defaults: UserDefaults? = nil
) {
let store = resolvedDefaults(defaults)
store.set(status.rawValue, forKey: Key.status)
store.set(Date().timeIntervalSince1970, forKey: Key.statusUpdatedAt)
if let message, !message.isEmpty {
store.set(message, forKey: Key.statusMessage)
} else {
store.removeObject(forKey: Key.statusMessage)
}
}
public static func currentStatus(
defaults: UserDefaults? = nil
) -> (status: Status, message: String?, updatedAt: TimeInterval) {
let store = resolvedDefaults(defaults)
let raw = store.string(forKey: Key.status) ?? Status.idle.rawValue
let status = Status(rawValue: raw) ?? .idle
let message = store.string(forKey: Key.statusMessage)
let updatedAt = store.double(forKey: Key.statusUpdatedAt)
return (status, message, updatedAt)
}
public static func markRequested(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
store.removeObject(forKey: Key.pendingText)
setStatus(.requested, defaults: store)
}
/// Store a transcript for the keyboard extension to consume.
public static func storePendingTranscript(_ text: String, defaults: UserDefaults? = nil) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let store = resolvedDefaults(defaults)
store.set(trimmed, forKey: Key.pendingText)
store.set(Date().timeIntervalSince1970, forKey: Key.updatedAt)
setStatus(.done, defaults: store)
}
/// Returns and clears the pending transcript if present.
public static func consumePendingTranscript(
maxAge: TimeInterval = 180,
defaults: UserDefaults? = nil
) -> String? {
let store = resolvedDefaults(defaults)
guard let text = store.string(forKey: Key.pendingText) else {
return nil
}
if maxAge > 0 {
let ts = store.double(forKey: Key.updatedAt)
if ts > 0, Date().timeIntervalSince1970 - ts > maxAge {
clear(defaults: store)
return nil
}
}
store.removeObject(forKey: Key.pendingText)
setStatus(.idle, defaults: store)
return text
}
public static func clear(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
store.removeObject(forKey: Key.pendingText)
store.removeObject(forKey: Key.updatedAt)
setStatus(.idle, defaults: store)
}
}