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.
This commit is contained in:
Rocky
2026-06-19 18:05:50 +08:00
parent 6148d05093
commit 7f059dbd45
48 changed files with 3815 additions and 1065 deletions
@@ -0,0 +1,217 @@
// 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)
}
}