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:
@@ -0,0 +1,106 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user