feat: harden Flow cold-start/force-quit and polish macOS dictation UX
Fix cold-start overlay recursion that overflowed the main-thread stack when recording began while the ready overlay was still up; also remove temporary on-screen Flow DEBUG panels after the orange-mic investigation, and land the macOS overlay/catalog/layout polish plus related Flow recovery hardening.
This commit is contained in:
@@ -13,10 +13,18 @@ import Foundation
|
||||
/// Keep this protocol narrow: only what the shared pipeline needs today.
|
||||
/// Platform-specific settings UI and iCloud sync stay on concrete stores.
|
||||
public protocol ConfigurationStore: Sendable {
|
||||
/// Polish / LLM provider id.
|
||||
var providerId: String { get }
|
||||
var baseURL: String { get }
|
||||
var apiKey: String { get }
|
||||
var model: String { get }
|
||||
|
||||
/// Cloud ASR provider id — independent from polish when `engineMode == "cloud"`.
|
||||
var asrProviderId: String { get }
|
||||
var asrBaseURL: String { get }
|
||||
var asrApiKey: String { get }
|
||||
var asrModel: String { get }
|
||||
|
||||
var engineMode: String { get }
|
||||
var polishIntensity: PolishIntensity { get }
|
||||
var personalDictionary: PersonalDictionary { get }
|
||||
|
||||
@@ -180,6 +180,8 @@ public enum TypeStyle {
|
||||
public static let title3 = Font.system(size: 20, weight: .semibold)
|
||||
public static let title2 = Font.system(size: 22, weight: .bold)
|
||||
public static let title = Font.system(size: 28, weight: .bold)
|
||||
/// Home brand line + History / Dictionary / Settings page titles.
|
||||
public static let pageTitle = Font.system(size: 30, weight: .semibold)
|
||||
public static let largeTitle = Font.system(size: 34, weight: .bold)
|
||||
/// Subtle status line under the brand mark (home header).
|
||||
public static let status = Font.system(size: 13, weight: .regular)
|
||||
|
||||
@@ -16,6 +16,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
/// Legacy plaintext slot — migrated to Keychain on first read.
|
||||
public static let apiKeyLegacy = "config.apiKey"
|
||||
public static let model = "config.model"
|
||||
/// Cloud ASR provider — independent from polish `providerId`.
|
||||
public static let asrProviderId = "config.asrProviderId"
|
||||
public static let asrBaseURL = "config.asrBaseURL"
|
||||
public static let asrModel = "config.asrModel"
|
||||
public static let modeId = "config.modeId"
|
||||
public static let localeId = "config.localeId"
|
||||
public static let engineMode = "config.engineMode"
|
||||
@@ -51,6 +55,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public var providerId: String
|
||||
public var baseURL: String
|
||||
public var model: String
|
||||
/// Cloud-engine speech-to-text provider (OpenLess-style split from polish).
|
||||
public var asrProviderId: String
|
||||
public var asrBaseURL: String
|
||||
public var asrModel: String
|
||||
public var modeId: String
|
||||
public var localeId: String
|
||||
public var engineMode: String
|
||||
@@ -95,18 +103,32 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
: .polish
|
||||
}
|
||||
|
||||
/// Local engine pins the LLM step to DeepSeek; cloud uses the user's provider.
|
||||
public var polishProviderIdOverride: String? {
|
||||
engineMode == "local" ? "deepseek" : nil
|
||||
}
|
||||
/// Polish LLM provider. Local engine no longer pins DeepSeek — user picks in Settings.
|
||||
public var polishProviderIdOverride: String? { nil }
|
||||
|
||||
public var isCloudAPIKeyMissingForVoiceInput: Bool {
|
||||
public var isCloudLLMKeyMissing: Bool {
|
||||
guard engineMode == "cloud" else { return false }
|
||||
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
/// API key lives in the Keychain (cross-process, encrypted at rest).
|
||||
/// When settings iCloud sync is on, reads synchronizable Keychain items first.
|
||||
public var isCloudASRKeyMissing: Bool {
|
||||
guard engineMode == "cloud" else { return false }
|
||||
return asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
public var isPolishKeyMissing: Bool {
|
||||
if !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return false
|
||||
}
|
||||
return !PreconfiguredKeys.isDeepseekConfigured
|
||||
}
|
||||
|
||||
public var isCloudAPIKeyMissingForVoiceInput: Bool {
|
||||
guard engineMode == "cloud" else { return false }
|
||||
return isCloudASRKeyMissing || isCloudLLMKeyMissing
|
||||
}
|
||||
|
||||
/// Polish LLM uses `providerId` + Keychain `provider.<id>`.
|
||||
public var apiKey: String {
|
||||
Self.resolveAPIKey(
|
||||
defaults: nil,
|
||||
@@ -115,6 +137,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
/// Cloud ASR uses `asrProviderId` + Keychain `asr.<id>` (falls back to legacy `provider.<id>`).
|
||||
public var asrApiKey: String {
|
||||
Self.resolveASRAPIKey(
|
||||
defaults: nil,
|
||||
providerId: asrProviderId,
|
||||
preferICloudSync: settingsICloudSyncEnabled
|
||||
)
|
||||
}
|
||||
|
||||
public func makeClient() -> LLMClient {
|
||||
OpenAICompatibleClient(
|
||||
baseURL: baseURL,
|
||||
@@ -123,6 +154,20 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolved cloud ASR model — user override or catalog default.
|
||||
public var resolvedASRModel: String {
|
||||
let trimmed = asrModel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
return CloudASRModelCatalog.defaultModel(for: asrProviderId)
|
||||
}
|
||||
|
||||
/// Resolved cloud ASR base URL for prompt-style providers.
|
||||
public var resolvedASRBaseURL: String {
|
||||
let trimmed = asrBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
return LLMProvider.provider(id: asrProviderId).defaultBaseURL
|
||||
}
|
||||
|
||||
// MARK: - Detected app context
|
||||
|
||||
public func detectedAppContext(from defaults: UserDefaults) -> (context: AppContext, observedAt: Date)? {
|
||||
@@ -152,9 +197,18 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
providerId: defaults.string(forKey: Keys.providerId) ?? "openai",
|
||||
baseURL: "",
|
||||
model: "",
|
||||
asrProviderId: defaults.string(forKey: Keys.asrProviderId) ?? "",
|
||||
asrBaseURL: "",
|
||||
asrModel: "",
|
||||
modeId: defaults.string(forKey: Keys.modeId) ?? "polish",
|
||||
localeId: defaults.string(forKey: Keys.localeId) ?? "auto",
|
||||
engineMode: defaults.string(forKey: Keys.engineMode) ?? "cloud",
|
||||
// Privacy-critical default: `local` keeps raw audio on-device
|
||||
// (SpeechAnalyzer). The `cloud` engine uploads recorded audio to
|
||||
// the user's configured ASR provider and must stay an explicit,
|
||||
// acknowledged opt-in (see `hasAcknowledgedCloudSharing`) — a
|
||||
// cloud default would contradict every privacy claim the app
|
||||
// makes in its docs, App Store listing, and permission prompts.
|
||||
engineMode: defaults.string(forKey: Keys.engineMode) ?? "local",
|
||||
hasCompletedOnboarding: defaults.bool(forKey: Keys.hasCompletedOnboarding),
|
||||
onboardingPage: {
|
||||
let saved = defaults.integer(forKey: Keys.onboardingPage)
|
||||
@@ -212,6 +266,19 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
config.model = defaults.string(forKey: Keys.model) ?? preset.defaultModel
|
||||
}
|
||||
|
||||
if config.asrProviderId.isEmpty {
|
||||
config.asrProviderId = config.providerId
|
||||
defaults.set(config.asrProviderId, forKey: Keys.asrProviderId)
|
||||
}
|
||||
let asrPreset = LLMProvider.provider(id: config.asrProviderId)
|
||||
if config.asrBaseURL.isEmpty {
|
||||
config.asrBaseURL = defaults.string(forKey: Keys.asrBaseURL) ?? asrPreset.defaultBaseURL
|
||||
}
|
||||
if config.asrModel.isEmpty {
|
||||
config.asrModel = defaults.string(forKey: Keys.asrModel)
|
||||
?? CloudASRModelCatalog.defaultModel(for: config.asrProviderId)
|
||||
}
|
||||
|
||||
// One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain.
|
||||
_ = resolveAPIKey(
|
||||
defaults: defaults,
|
||||
@@ -219,6 +286,26 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
preferICloudSync: config.settingsICloudSyncEnabled
|
||||
)
|
||||
|
||||
// One-shot default migration for installs that predate an explicit
|
||||
// stored value. The privacy-safe defaults ("local", 30 min TTL) are
|
||||
// for NEW installs only — an existing user who ran on the old
|
||||
// defaults must keep their behavior, both because silently changing
|
||||
// engines under someone is wrong, and because iCloud settings sync
|
||||
// would stamp the flip as a fresh "edit" and propagate it to every
|
||||
// other device, overriding choices made there. Persisting the
|
||||
// resolved value makes the decision stable and sync-invisible.
|
||||
let isExistingInstall = defaults.bool(forKey: Keys.hasCompletedOnboarding)
|
||||
if defaults.string(forKey: Keys.engineMode) == nil {
|
||||
let resolved = isExistingInstall ? "cloud" : "local"
|
||||
config.engineMode = resolved
|
||||
defaults.set(resolved, forKey: Keys.engineMode)
|
||||
}
|
||||
if defaults.string(forKey: Keys.flowInactivityDuration) == nil {
|
||||
let resolved: FlowInactivityDuration = isExistingInstall ? .twelveHours : .default
|
||||
config.flowInactivityDuration = resolved
|
||||
defaults.set(resolved.rawValue, forKey: Keys.flowInactivityDuration)
|
||||
}
|
||||
|
||||
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
||||
if config.engineMode == "cloud", config.modeId != "polish" {
|
||||
config.modeId = "polish"
|
||||
@@ -234,6 +321,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
defaults.set(openAI.defaultBaseURL, forKey: Keys.baseURL)
|
||||
defaults.set(openAI.defaultModel, forKey: Keys.model)
|
||||
}
|
||||
if config.engineMode == "cloud", config.asrProviderId == "deepseek" {
|
||||
let openAI = LLMProvider.provider(id: "openai")
|
||||
config.asrProviderId = openAI.id
|
||||
config.asrBaseURL = openAI.defaultBaseURL
|
||||
config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id)
|
||||
defaults.set(openAI.id, forKey: Keys.asrProviderId)
|
||||
defaults.set(openAI.defaultBaseURL, forKey: Keys.asrBaseURL)
|
||||
defaults.set(openAI.defaultModel, forKey: Keys.asrModel)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -242,6 +338,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
defaults.set(providerId, forKey: Keys.providerId)
|
||||
defaults.set(baseURL, forKey: Keys.baseURL)
|
||||
defaults.set(model, forKey: Keys.model)
|
||||
defaults.set(asrProviderId, forKey: Keys.asrProviderId)
|
||||
defaults.set(asrBaseURL, forKey: Keys.asrBaseURL)
|
||||
defaults.set(asrModel, forKey: Keys.asrModel)
|
||||
defaults.set(modeId, forKey: Keys.modeId)
|
||||
defaults.set(localeId, forKey: Keys.localeId)
|
||||
defaults.set(engineMode, forKey: Keys.engineMode)
|
||||
@@ -328,4 +427,16 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
static func resolveASRAPIKey(
|
||||
defaults: UserDefaults?,
|
||||
providerId: String,
|
||||
preferICloudSync: Bool = false
|
||||
) -> String {
|
||||
if let stored = Keychain.asrApiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty {
|
||||
return stored
|
||||
}
|
||||
// Pre-split installs: one shared key under `provider.<id>`.
|
||||
return resolveAPIKey(defaults: defaults, providerId: providerId, preferICloudSync: preferICloudSync)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// CloudProviderRole.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Distinguishes cloud ASR credentials from polish LLM credentials
|
||||
// (OpenLess-style split).
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum CloudProviderRole: String, Sendable, Equatable {
|
||||
case asr
|
||||
case polish
|
||||
}
|
||||
@@ -15,7 +15,12 @@ public enum FlowInactivityDuration: String, CaseIterable, Identifiable, Sendable
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public static let `default`: FlowInactivityDuration = .twelveHours
|
||||
/// 30 minutes, not hours: competitors cap sessions at 5–60 min for a
|
||||
/// reason — a very long TTL keeps advertising "session active" long after
|
||||
/// the host process is likely suspended or dead, amplifying every stale-
|
||||
/// state bug into hours of confusing UI. Users can still opt into longer
|
||||
/// windows explicitly.
|
||||
public static let `default`: FlowInactivityDuration = .thirtyMinutes
|
||||
|
||||
public var timeInterval: TimeInterval {
|
||||
switch self {
|
||||
|
||||
@@ -108,4 +108,11 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
public static var userSelectablePresets: [LLMProvider] {
|
||||
presets.filter(\.isUserSelectable)
|
||||
}
|
||||
|
||||
/// Cloud ASR presets (excludes providers without a cloud transcription API).
|
||||
public static var asrSelectablePresets: [LLMProvider] {
|
||||
userSelectablePresets.filter {
|
||||
CloudASRModelCatalog.strategy(for: $0.id) != .localFallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,9 @@ public struct LocalASRModelDefinition: Codable, Sendable, Equatable, Identifiabl
|
||||
public let recommendedLocales: [String]
|
||||
public let supportsHotwords: Bool
|
||||
public let hotwordMode: LocalASRHotwordMode
|
||||
/// Optional localization key for a short quality/speed badge
|
||||
/// (e.g. `mac.localASR.badge.fastest`).
|
||||
public let badgeKey: String?
|
||||
public let installKind: LocalASRInstallKind
|
||||
public let installRelativePath: String?
|
||||
public let archiveBaseName: String?
|
||||
|
||||
@@ -9,7 +9,16 @@ import Foundation
|
||||
extension PersonalDictionary {
|
||||
public static let kvsKeyV2 = "personalDictionary.v2"
|
||||
public static let legacyKVSKey = "personalDictionary.v1"
|
||||
public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60
|
||||
/// Tombstones guard against deleted entries "resurrecting" when a
|
||||
/// long-offline device rejoins and re-merges them. A short wall-clock
|
||||
/// retention re-opened that window after only 90 days; a year keeps the
|
||||
/// window closed for any realistically dormant device while staying tiny
|
||||
/// on the wire (a tombstone is ~60 bytes of JSON), and the count cap
|
||||
/// bounds the worst case regardless of clock.
|
||||
public static let tombstoneRetention: TimeInterval = 365 * 24 * 60 * 60
|
||||
/// Hard cap independent of wall clock — the oldest tombstones are
|
||||
/// dropped first once exceeded.
|
||||
public static let maxTombstones = 500
|
||||
|
||||
/// Merges two dictionary snapshots for cross-device sync.
|
||||
///
|
||||
@@ -100,11 +109,19 @@ extension PersonalDictionary {
|
||||
clearedAt: Date?
|
||||
) -> [UUID: Date] {
|
||||
let cutoff = Date().addingTimeInterval(-tombstoneRetention)
|
||||
return tombstones.filter { _, deletedAt in
|
||||
var kept = tombstones.filter { _, deletedAt in
|
||||
if deletedAt < cutoff { return false }
|
||||
if let clearedAt, deletedAt <= clearedAt { return false }
|
||||
return true
|
||||
}
|
||||
// Enforce the count cap that makes the 365-day retention safe on the
|
||||
// KVS byte budget: keep the NEWEST tombstones (dropping an old one
|
||||
// early only re-opens the resurrection window for that one entry).
|
||||
if kept.count > maxTombstones {
|
||||
let newest = kept.sorted { $0.value > $1.value }.prefix(maxTombstones)
|
||||
kept = Dictionary(uniqueKeysWithValues: newest.map { ($0.key, $0.value) })
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
private static func later(of lhs: Date?, and rhs: Date?) -> Date? {
|
||||
|
||||
@@ -53,6 +53,44 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
@Published public var asrProviderId: String {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, asrProviderId != configuration.asrProviderId else { return }
|
||||
configuration.asrProviderId = asrProviderId
|
||||
isSyncingASRProviderAPIKey = true
|
||||
asrApiKey = configuration.asrApiKey
|
||||
isSyncingASRProviderAPIKey = false
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
@Published public var asrBaseURL: String {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, asrBaseURL != configuration.asrBaseURL else { return }
|
||||
configuration.asrBaseURL = asrBaseURL
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
@Published public var asrApiKey: String {
|
||||
didSet {
|
||||
guard oldValue != asrApiKey, !isSyncingASRProviderAPIKey else { return }
|
||||
do {
|
||||
try Keychain.setASRAPIKey(
|
||||
asrApiKey,
|
||||
for: asrProviderId,
|
||||
useICloudSync: configuration.settingsICloudSyncEnabled
|
||||
)
|
||||
} catch {
|
||||
OSGLog.config.warning("ASR Keychain write failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published public var asrModel: String {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, asrModel != configuration.asrModel else { return }
|
||||
configuration.asrModel = asrModel
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
@Published public var modeId: String {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, modeId != configuration.modeId else { return }
|
||||
@@ -67,8 +105,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
/// "local" → on-device ASR + built-in DeepSeek polish.
|
||||
/// "cloud" → provider cloud ASR (with personal dictionary) + user's cloud LLM polish.
|
||||
/// "local" → on-device ASR + user's LLM polish (or built-in DeepSeek).
|
||||
/// "cloud" → user's cloud ASR + user's cloud LLM polish (independent picks).
|
||||
@Published public var engineMode: String {
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
|
||||
@@ -213,25 +251,38 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
|
||||
public var isConfigured: Bool {
|
||||
// Local engine uses on-device ASR + built-in DeepSeek polish and
|
||||
// does not need a user API key. Cloud needs base URL, key, and model.
|
||||
if isLocalEngine { return true }
|
||||
return !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty
|
||||
if isLocalEngine {
|
||||
return isPolishConfigured
|
||||
}
|
||||
return isASRConfigured && isPolishConfigured
|
||||
}
|
||||
|
||||
public var isPolishConfigured: Bool {
|
||||
if !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return !baseURL.isEmpty && !model.isEmpty
|
||||
}
|
||||
return PreconfiguredKeys.isDeepseekConfigured
|
||||
}
|
||||
|
||||
public var isASRConfigured: Bool {
|
||||
guard !isLocalEngine else { return true }
|
||||
return !asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& (!asrBaseURL.isEmpty || CloudASRModelCatalog.strategy(for: asrProviderId) != .prompt)
|
||||
}
|
||||
|
||||
/// On-device ASR only; no cloud API required.
|
||||
public var isLocalEngine: Bool { configuration.isLocalEngine }
|
||||
|
||||
/// Local engine always polishes via the built-in DeepSeek path.
|
||||
public var shouldPolishLocalTranscript: Bool { isLocalEngine }
|
||||
|
||||
/// Cloud engine uses `providerId`. Local engine pins DeepSeek.
|
||||
public var localModeProviderId: String { "deepseek" }
|
||||
/// Built-in DeepSeek path when the user has not supplied their own LLM key.
|
||||
public var localModeProviderId: String {
|
||||
apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "deepseek" : providerId
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private var configuration: AppGroupConfiguration
|
||||
private var isApplyingConfiguration = false
|
||||
private var isSyncingProviderAPIKey = false
|
||||
private var isSyncingASRProviderAPIKey = false
|
||||
|
||||
public init(defaults: UserDefaults? = nil) {
|
||||
guard let resolvedDefaults = defaults ?? AppGroup.defaultsIfAvailable else {
|
||||
@@ -270,6 +321,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
baseURL = configuration.baseURL
|
||||
apiKey = configuration.apiKey
|
||||
model = configuration.model
|
||||
asrProviderId = configuration.asrProviderId
|
||||
asrBaseURL = configuration.asrBaseURL
|
||||
asrModel = configuration.asrModel
|
||||
modeId = configuration.modeId
|
||||
localeId = configuration.localeId
|
||||
engineMode = configuration.engineMode
|
||||
@@ -284,6 +338,12 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
flowSkipAppSwitch = configuration.flowSkipAppSwitch
|
||||
flowInactivityDuration = configuration.flowInactivityDuration
|
||||
localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled
|
||||
isSyncingProviderAPIKey = true
|
||||
apiKey = configuration.apiKey
|
||||
isSyncingProviderAPIKey = false
|
||||
isSyncingASRProviderAPIKey = true
|
||||
asrApiKey = configuration.asrApiKey
|
||||
isSyncingASRProviderAPIKey = false
|
||||
isApplyingConfiguration = false
|
||||
}
|
||||
|
||||
@@ -293,6 +353,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
if engineMode == "cloud", providerId == "deepseek" {
|
||||
apply(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
if engineMode == "cloud", asrProviderId == "deepseek" {
|
||||
applyAsr(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
}
|
||||
|
||||
private func persistConfiguration(postConfigChanged: Bool = false) {
|
||||
@@ -324,6 +387,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
providerId = fresh.providerId
|
||||
baseURL = fresh.baseURL
|
||||
model = fresh.model
|
||||
asrProviderId = fresh.asrProviderId
|
||||
asrBaseURL = fresh.asrBaseURL
|
||||
asrModel = fresh.asrModel
|
||||
modeId = fresh.modeId
|
||||
localeId = fresh.localeId
|
||||
engineMode = fresh.engineMode
|
||||
@@ -341,6 +407,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
isSyncingProviderAPIKey = true
|
||||
apiKey = fresh.apiKey
|
||||
isSyncingProviderAPIKey = false
|
||||
isSyncingASRProviderAPIKey = true
|
||||
asrApiKey = fresh.asrApiKey
|
||||
isSyncingASRProviderAPIKey = false
|
||||
isApplyingConfiguration = false
|
||||
}
|
||||
|
||||
@@ -370,6 +439,23 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
persistConfiguration()
|
||||
}
|
||||
|
||||
public func applyAsr(preset: LLMProvider) {
|
||||
isApplyingConfiguration = true
|
||||
asrProviderId = preset.id
|
||||
if !preset.defaultBaseURL.isEmpty {
|
||||
asrBaseURL = preset.defaultBaseURL
|
||||
}
|
||||
asrModel = CloudASRModelCatalog.defaultModel(for: preset.id)
|
||||
configuration.asrProviderId = asrProviderId
|
||||
configuration.asrBaseURL = asrBaseURL
|
||||
configuration.asrModel = asrModel
|
||||
isSyncingASRProviderAPIKey = true
|
||||
asrApiKey = configuration.asrApiKey
|
||||
isSyncingASRProviderAPIKey = false
|
||||
isApplyingConfiguration = false
|
||||
persistConfiguration()
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
isApplyingConfiguration = true
|
||||
let preset = LLMProvider.provider(id: "openai")
|
||||
@@ -377,12 +463,19 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
baseURL = preset.defaultBaseURL
|
||||
apiKey = ""
|
||||
model = preset.defaultModel
|
||||
asrProviderId = preset.id
|
||||
asrBaseURL = preset.defaultBaseURL
|
||||
asrModel = CloudASRModelCatalog.defaultModel(for: preset.id)
|
||||
asrApiKey = ""
|
||||
handednessPreference = .left
|
||||
localASRCustomLanguageModelEnabled = true
|
||||
hasAcknowledgedCloudSharing = false
|
||||
configuration.providerId = preset.id
|
||||
configuration.baseURL = preset.defaultBaseURL
|
||||
configuration.model = preset.defaultModel
|
||||
configuration.asrProviderId = preset.id
|
||||
configuration.asrBaseURL = preset.defaultBaseURL
|
||||
configuration.asrModel = CloudASRModelCatalog.defaultModel(for: preset.id)
|
||||
configuration.handednessPreference = .left
|
||||
configuration.localASRCustomLanguageModelEnabled = true
|
||||
configuration.hasAcknowledgedCloudSharing = false
|
||||
|
||||
@@ -14,6 +14,10 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
public var providerId: SyncedField<String>
|
||||
public var baseURL: SyncedField<String>
|
||||
public var model: SyncedField<String>
|
||||
/// Cloud ASR provider — independent from polish `providerId`.
|
||||
public var asrProviderId: SyncedField<String>
|
||||
public var asrBaseURL: SyncedField<String>
|
||||
public var asrModel: SyncedField<String>
|
||||
public var modeId: SyncedField<String>
|
||||
public var localeId: SyncedField<String>
|
||||
public var engineMode: SyncedField<String>
|
||||
@@ -31,6 +35,9 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
providerId: SyncedField<String>,
|
||||
baseURL: SyncedField<String>,
|
||||
model: SyncedField<String>,
|
||||
asrProviderId: SyncedField<String>,
|
||||
asrBaseURL: SyncedField<String>,
|
||||
asrModel: SyncedField<String>,
|
||||
modeId: SyncedField<String>,
|
||||
localeId: SyncedField<String>,
|
||||
engineMode: SyncedField<String>,
|
||||
@@ -47,6 +54,9 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
self.providerId = providerId
|
||||
self.baseURL = baseURL
|
||||
self.model = model
|
||||
self.asrProviderId = asrProviderId
|
||||
self.asrBaseURL = asrBaseURL
|
||||
self.asrModel = asrModel
|
||||
self.modeId = modeId
|
||||
self.localeId = localeId
|
||||
self.engineMode = engineMode
|
||||
@@ -60,12 +70,88 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
self.flowInactivityDuration = flowInactivityDuration
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion
|
||||
case providerId
|
||||
case baseURL
|
||||
case model
|
||||
case asrProviderId
|
||||
case asrBaseURL
|
||||
case asrModel
|
||||
case modeId
|
||||
case localeId
|
||||
case engineMode
|
||||
case hasAcknowledgedCloudSharing
|
||||
case uiLanguage
|
||||
case translationTargetLocaleId
|
||||
case handednessPreference
|
||||
case cursorDragNavigationEnabled
|
||||
case polishIntensity
|
||||
case flowSkipAppSwitch
|
||||
case flowInactivityDuration
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
schemaVersion = try container.decode(Int.self, forKey: .schemaVersion)
|
||||
providerId = try container.decode(SyncedField<String>.self, forKey: .providerId)
|
||||
baseURL = try container.decode(SyncedField<String>.self, forKey: .baseURL)
|
||||
model = try container.decode(SyncedField<String>.self, forKey: .model)
|
||||
modeId = try container.decode(SyncedField<String>.self, forKey: .modeId)
|
||||
localeId = try container.decode(SyncedField<String>.self, forKey: .localeId)
|
||||
engineMode = try container.decode(SyncedField<String>.self, forKey: .engineMode)
|
||||
hasAcknowledgedCloudSharing = try container.decode(SyncedField<Bool>.self, forKey: .hasAcknowledgedCloudSharing)
|
||||
uiLanguage = try container.decode(SyncedField<AppUILanguage>.self, forKey: .uiLanguage)
|
||||
translationTargetLocaleId = try container.decode(
|
||||
SyncedField<String>.self,
|
||||
forKey: .translationTargetLocaleId
|
||||
)
|
||||
handednessPreference = try container.decode(
|
||||
SyncedField<HandednessPreference>.self,
|
||||
forKey: .handednessPreference
|
||||
)
|
||||
cursorDragNavigationEnabled = try container.decode(
|
||||
SyncedField<Bool>.self,
|
||||
forKey: .cursorDragNavigationEnabled
|
||||
)
|
||||
polishIntensity = try container.decode(SyncedField<PolishIntensity>.self, forKey: .polishIntensity)
|
||||
flowSkipAppSwitch = try container.decode(SyncedField<Bool>.self, forKey: .flowSkipAppSwitch)
|
||||
flowInactivityDuration = try container.decode(
|
||||
SyncedField<FlowInactivityDuration>.self,
|
||||
forKey: .flowInactivityDuration
|
||||
)
|
||||
|
||||
if let asrProvider = try container.decodeIfPresent(SyncedField<String>.self, forKey: .asrProviderId) {
|
||||
asrProviderId = asrProvider
|
||||
} else {
|
||||
asrProviderId = providerId
|
||||
}
|
||||
if let asrURL = try container.decodeIfPresent(SyncedField<String>.self, forKey: .asrBaseURL) {
|
||||
asrBaseURL = asrURL
|
||||
} else {
|
||||
asrBaseURL = baseURL
|
||||
}
|
||||
if let asrModelField = try container.decodeIfPresent(SyncedField<String>.self, forKey: .asrModel) {
|
||||
asrModel = asrModelField
|
||||
} else {
|
||||
let fallbackModel = CloudASRModelCatalog.defaultModel(for: providerId.value)
|
||||
asrModel = SyncedField(
|
||||
value: fallbackModel,
|
||||
updatedAt: model.updatedAt,
|
||||
deviceID: model.deviceID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Monotonic stamp used for `settingsCloudUpdatedAt` bookkeeping.
|
||||
public var latestUpdatedAt: Date {
|
||||
[
|
||||
providerId.updatedAt,
|
||||
baseURL.updatedAt,
|
||||
model.updatedAt,
|
||||
asrProviderId.updatedAt,
|
||||
asrBaseURL.updatedAt,
|
||||
asrModel.updatedAt,
|
||||
modeId.updatedAt,
|
||||
localeId.updatedAt,
|
||||
engineMode.updatedAt,
|
||||
@@ -99,6 +185,9 @@ public extension SyncedAppSettingsV2 {
|
||||
providerId: field(configuration.providerId),
|
||||
baseURL: field(configuration.baseURL),
|
||||
model: field(configuration.model),
|
||||
asrProviderId: field(configuration.asrProviderId),
|
||||
asrBaseURL: field(configuration.asrBaseURL),
|
||||
asrModel: field(configuration.asrModel),
|
||||
modeId: field(configuration.modeId),
|
||||
localeId: field(configuration.localeId),
|
||||
engineMode: field(configuration.engineMode),
|
||||
@@ -119,10 +208,14 @@ public extension SyncedAppSettingsV2 {
|
||||
func field<T>(_ value: T) -> SyncedField<T> {
|
||||
SyncedField(value: value, updatedAt: stamp, deviceID: deviceID)
|
||||
}
|
||||
let provider = field(legacy.providerId)
|
||||
return SyncedAppSettingsV2(
|
||||
providerId: field(legacy.providerId),
|
||||
providerId: provider,
|
||||
baseURL: field(legacy.baseURL),
|
||||
model: field(legacy.model),
|
||||
asrProviderId: provider,
|
||||
asrBaseURL: field(legacy.baseURL),
|
||||
asrModel: field(CloudASRModelCatalog.defaultModel(for: legacy.providerId)),
|
||||
modeId: field(legacy.modeId),
|
||||
localeId: field(legacy.localeId),
|
||||
engineMode: field(legacy.engineMode),
|
||||
@@ -142,6 +235,9 @@ public extension SyncedAppSettingsV2 {
|
||||
providerId: .merge(local: local.providerId, remote: remote.providerId),
|
||||
baseURL: .merge(local: local.baseURL, remote: remote.baseURL),
|
||||
model: .merge(local: local.model, remote: remote.model),
|
||||
asrProviderId: .merge(local: local.asrProviderId, remote: remote.asrProviderId),
|
||||
asrBaseURL: .merge(local: local.asrBaseURL, remote: remote.asrBaseURL),
|
||||
asrModel: .merge(local: local.asrModel, remote: remote.asrModel),
|
||||
modeId: .merge(local: local.modeId, remote: remote.modeId),
|
||||
localeId: .merge(local: local.localeId, remote: remote.localeId),
|
||||
engineMode: .merge(local: local.engineMode, remote: remote.engineMode),
|
||||
@@ -172,6 +268,9 @@ public extension SyncedAppSettingsV2 {
|
||||
configuration.providerId = providerId.value
|
||||
configuration.baseURL = baseURL.value
|
||||
configuration.model = model.value
|
||||
configuration.asrProviderId = asrProviderId.value
|
||||
configuration.asrBaseURL = asrBaseURL.value
|
||||
configuration.asrModel = asrModel.value
|
||||
configuration.modeId = modeId.value
|
||||
configuration.localeId = localeId.value
|
||||
configuration.engineMode = engineMode.value
|
||||
@@ -195,6 +294,9 @@ public extension SyncedAppSettingsV2 {
|
||||
patch(©.providerId, value: configuration.providerId)
|
||||
patch(©.baseURL, value: configuration.baseURL)
|
||||
patch(©.model, value: configuration.model)
|
||||
patch(©.asrProviderId, value: configuration.asrProviderId)
|
||||
patch(©.asrBaseURL, value: configuration.asrBaseURL)
|
||||
patch(©.asrModel, value: configuration.asrModel)
|
||||
patch(©.modeId, value: configuration.modeId)
|
||||
patch(©.localeId, value: configuration.localeId)
|
||||
patch(©.engineMode, value: configuration.engineMode)
|
||||
@@ -221,6 +323,9 @@ public extension SyncedAppSettingsV2 {
|
||||
touch(©.providerId, value: configuration.providerId)
|
||||
touch(©.baseURL, value: configuration.baseURL)
|
||||
touch(©.model, value: configuration.model)
|
||||
touch(©.asrProviderId, value: configuration.asrProviderId)
|
||||
touch(©.asrBaseURL, value: configuration.asrBaseURL)
|
||||
touch(©.asrModel, value: configuration.asrModel)
|
||||
touch(©.modeId, value: configuration.modeId)
|
||||
touch(©.localeId, value: configuration.localeId)
|
||||
touch(©.engineMode, value: configuration.engineMode)
|
||||
|
||||
@@ -16,11 +16,36 @@ public struct SyncedField<T: Codable & Equatable & Sendable>: Codable, Equatable
|
||||
self.deviceID = deviceID
|
||||
}
|
||||
|
||||
/// A remote timestamp may be at most this far in OUR future before we
|
||||
/// stop trusting it. Wall-clock LWW breaks down when one device's clock
|
||||
/// runs fast: its edits would win every merge forever, silently
|
||||
/// discarding later edits from correct-clock devices. Anything beyond
|
||||
/// this skew is a broken clock, not a newer edit.
|
||||
public static var maxTrustedFutureSkew: TimeInterval { 6 * 60 * 60 }
|
||||
|
||||
/// Pick the field with the newer `updatedAt`; ties break lexicographically on `deviceID`.
|
||||
///
|
||||
/// Broken-clock containment: comparing with clamped stamps alone is not
|
||||
/// enough — a far-future stamp stored in the winner would keep beating
|
||||
/// every later genuine edit (whose stamps are merely "now") until that
|
||||
/// wall-clock date actually arrived. So when the winner carries an
|
||||
/// untrusted future stamp, the stamp itself is REWRITTEN to "now" in the
|
||||
/// merged result: from then on any real edit, made later, outranks it.
|
||||
public static func merge(local: SyncedField<T>, remote: SyncedField<T>) -> SyncedField<T> {
|
||||
if remote.updatedAt > local.updatedAt { return remote }
|
||||
if local.updatedAt > remote.updatedAt { return local }
|
||||
return remote.deviceID >= local.deviceID ? remote : local
|
||||
let now = Date()
|
||||
let horizon = now.addingTimeInterval(maxTrustedFutureSkew)
|
||||
let remoteAt = remote.updatedAt > horizon ? now : remote.updatedAt
|
||||
let localAt = local.updatedAt > horizon ? now : local.updatedAt
|
||||
let winner: SyncedField<T>
|
||||
if remoteAt > localAt {
|
||||
winner = remote
|
||||
} else if localAt > remoteAt {
|
||||
winner = local
|
||||
} else {
|
||||
winner = remote.deviceID >= local.deviceID ? remote : local
|
||||
}
|
||||
guard winner.updatedAt > horizon else { return winner }
|
||||
return SyncedField(value: winner.value, updatedAt: now, deviceID: winner.deviceID)
|
||||
}
|
||||
|
||||
public static func make(value: T, deviceID: String) -> SyncedField<T> {
|
||||
|
||||
@@ -12,7 +12,16 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
public static let legacyKVSKey = "speechHistory.v1"
|
||||
public static let maxEntries = 300
|
||||
/// Tombstones older than this window may be pruned during merge.
|
||||
public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60
|
||||
/// Tombstones guard against deleted entries "resurrecting" when a
|
||||
/// long-offline device rejoins and re-merges them. A short wall-clock
|
||||
/// retention re-opened that window after only 90 days; a year keeps the
|
||||
/// window closed for any realistically dormant device while staying tiny
|
||||
/// on the wire (a tombstone is ~60 bytes of JSON), and the count cap
|
||||
/// bounds the worst case regardless of clock.
|
||||
public static let tombstoneRetention: TimeInterval = 365 * 24 * 60 * 60
|
||||
/// Hard cap independent of wall clock — the oldest tombstones are
|
||||
/// dropped first once exceeded.
|
||||
public static let maxTombstones = 500
|
||||
|
||||
public var schemaVersion: Int
|
||||
public var updatedAt: Date
|
||||
@@ -107,15 +116,19 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
clearedAt: Date?
|
||||
) -> [UUID: Date] {
|
||||
let cutoff = Date().addingTimeInterval(-tombstoneRetention)
|
||||
return tombstones.filter { _, deletedAt in
|
||||
if deletedAt < cutoff {
|
||||
return false
|
||||
}
|
||||
if let clearedAt, deletedAt <= clearedAt {
|
||||
return false
|
||||
}
|
||||
var kept = tombstones.filter { _, deletedAt in
|
||||
if deletedAt < cutoff { return false }
|
||||
if let clearedAt, deletedAt <= clearedAt { return false }
|
||||
return true
|
||||
}
|
||||
// Enforce the count cap that makes the 365-day retention safe on the
|
||||
// KVS byte budget: keep the NEWEST tombstones (dropping an old one
|
||||
// early only re-opens the resurrection window for that one entry).
|
||||
if kept.count > maxTombstones {
|
||||
let newest = kept.sorted { $0.value > $1.value }.prefix(maxTombstones)
|
||||
kept = Dictionary(uniqueKeysWithValues: newest.map { ($0.key, $0.value) })
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
private static func later(of lhs: Date?, and rhs: Date?) -> Date? {
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"recommendedLocales": ["zh-CN", "en-US"],
|
||||
"supportsHotwords": true,
|
||||
"hotwordMode": "recognizerScoped",
|
||||
"badgeKey": "mac.localASR.badge.balanced",
|
||||
"installKind": "archive",
|
||||
"installRelativePath": "models/sherpa-qwen3-0.6b-int8",
|
||||
"archiveBaseName": "sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25",
|
||||
@@ -71,6 +72,7 @@
|
||||
"recommendedLocales": ["zh-CN", "en-US"],
|
||||
"supportsHotwords": true,
|
||||
"hotwordMode": "recognizerScoped",
|
||||
"badgeKey": "mac.localASR.badge.quality",
|
||||
"installKind": "repository",
|
||||
"installRelativePath": "models/sherpa-qwen3-1.7b-int8",
|
||||
"archiveBaseName": "sherpa-onnx-qwen3-asr-1.7B-int8",
|
||||
@@ -113,30 +115,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sherpa-paraformer-zh-int8",
|
||||
"displayName": "Paraformer Large",
|
||||
"backend": "sherpaParaformer",
|
||||
"runtimePlatform": "macos",
|
||||
"sizeBytes": 220000000,
|
||||
"recommendedLocales": ["zh-CN", "en-US"],
|
||||
"supportsHotwords": false,
|
||||
"hotwordMode": "none",
|
||||
"installKind": "archive",
|
||||
"installRelativePath": "models/sherpa-paraformer-zh-int8",
|
||||
"archiveBaseName": "sherpa-onnx-paraformer-zh-int8-2025-10-07",
|
||||
"layout": {
|
||||
"paraformerModel": "model.int8.onnx",
|
||||
"tokens": "tokens.txt"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "github",
|
||||
"priority": 1,
|
||||
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-paraformer-zh-int8-2025-10-07.tar.bz2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sherpa-sensevoice-small-int8",
|
||||
"displayName": "SenseVoice Small",
|
||||
@@ -146,6 +124,7 @@
|
||||
"recommendedLocales": ["zh-CN", "en-US", "ja-JP", "ko-KR"],
|
||||
"supportsHotwords": false,
|
||||
"hotwordMode": "none",
|
||||
"badgeKey": "mac.localASR.badge.fastest",
|
||||
"installKind": "archive",
|
||||
"installRelativePath": "models/sherpa-sensevoice-small-int8",
|
||||
"archiveBaseName": "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// ASRChunkTranscribing.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Minimal ASR surface for pipelined utterance chunking. Keeps
|
||||
// `ChunkedUtterancePipeline` independent of iOS-only `SpeechAnalyzer`.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ASRChunkResult: Sendable, Equatable {
|
||||
case success(String)
|
||||
case failure(String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
/// One-shot chunk transcription used by `ChunkedUtterancePipeline`.
|
||||
public protocol ASRChunkTranscribing: Sendable {
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult
|
||||
func cancel()
|
||||
func resetForNewUtterance()
|
||||
}
|
||||
|
||||
extension ASRChunkTranscribing {
|
||||
public func cancel() {}
|
||||
public func resetForNewUtterance() {}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ extension AVAudioPCMBuffer: @unchecked @retroactive Sendable {}
|
||||
|
||||
// MARK: - Protocol
|
||||
|
||||
public protocol ASRService: Sendable {
|
||||
public protocol ASRService: ASRChunkTranscribing, Sendable {
|
||||
/// Start a transcription session. The returned stream emits `.partial`
|
||||
/// updates and exactly one `.final` (or `.error`) before finishing.
|
||||
/// `SpeechAnalyzer` is always fully on-device, so there is no
|
||||
@@ -54,12 +54,6 @@ public protocol ASRService: Sendable {
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult
|
||||
}
|
||||
|
||||
public enum ASRChunkResult: Sendable, Equatable {
|
||||
case success(String)
|
||||
case failure(String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
extension ASRService {
|
||||
public func resetForNewUtterance() {}
|
||||
|
||||
|
||||
@@ -53,6 +53,10 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
public var baseURL: String { configuration.baseURL }
|
||||
public var apiKey: String { configuration.apiKey }
|
||||
public var model: String { configuration.model }
|
||||
public var asrProviderId: String { configuration.asrProviderId }
|
||||
public var asrBaseURL: String { configuration.resolvedASRBaseURL }
|
||||
public var asrApiKey: String { configuration.asrApiKey }
|
||||
public var asrModel: String { configuration.resolvedASRModel }
|
||||
public var modeId: String { configuration.modeId }
|
||||
public var localeId: String { configuration.localeId }
|
||||
public var engineMode: String { configuration.engineMode }
|
||||
@@ -91,6 +95,12 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
config.baseURL = openAI.defaultBaseURL
|
||||
config.model = openAI.defaultModel
|
||||
}
|
||||
if mode == "cloud", config.asrProviderId == "deepseek" {
|
||||
let openAI = LLMProvider.provider(id: "openai")
|
||||
config.asrProviderId = openAI.id
|
||||
config.asrBaseURL = openAI.defaultBaseURL
|
||||
config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id)
|
||||
}
|
||||
}
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
@@ -67,13 +67,13 @@ private actor ChunkWorkQueue {
|
||||
}
|
||||
|
||||
public actor ChunkedUtterancePipeline {
|
||||
private let asr: ASRService
|
||||
private let asr: any ASRChunkTranscribing
|
||||
private let locale: Locale
|
||||
private let config: FlowUtteranceChunkConfig
|
||||
private var cancelled = false
|
||||
|
||||
public init(
|
||||
asr: ASRService,
|
||||
asr: any ASRChunkTranscribing,
|
||||
locale: Locale,
|
||||
config: FlowUtteranceChunkConfig = .flowDefault
|
||||
) {
|
||||
|
||||
@@ -17,31 +17,35 @@ public protocol CloudASRTranscribing: Sendable {
|
||||
|
||||
public enum CloudASRClientFactory {
|
||||
public static func make(store: any ConfigurationStore, session: URLSession = .shared) -> CloudASRTranscribing {
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
|
||||
let providerId = store.asrProviderId
|
||||
let strategy = CloudASRModelCatalog.strategy(for: providerId)
|
||||
let asrModel = store.asrModel.isEmpty
|
||||
? CloudASRModelCatalog.defaultModel(for: providerId)
|
||||
: store.asrModel
|
||||
switch strategy {
|
||||
case .zhipuHotwords:
|
||||
return ZhipuCloudASRClient(
|
||||
apiKey: store.apiKey,
|
||||
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
|
||||
apiKey: store.asrApiKey,
|
||||
model: asrModel,
|
||||
session: session
|
||||
)
|
||||
case .alibabaVocabulary:
|
||||
return AlibabaFunASRClient(
|
||||
apiKey: store.apiKey,
|
||||
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
|
||||
apiKey: store.asrApiKey,
|
||||
model: asrModel,
|
||||
persistence: store.cloudASRPersistence,
|
||||
session: session
|
||||
)
|
||||
case .prompt:
|
||||
return PromptCloudASRClient(
|
||||
providerId: store.providerId,
|
||||
baseURL: store.baseURL,
|
||||
apiKey: store.apiKey,
|
||||
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
|
||||
providerId: providerId,
|
||||
baseURL: store.asrBaseURL,
|
||||
apiKey: store.asrApiKey,
|
||||
model: asrModel,
|
||||
session: session
|
||||
)
|
||||
case .localFallback:
|
||||
return UnsupportedCloudASRClient(providerId: store.providerId)
|
||||
return UnsupportedCloudASRClient(providerId: providerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func bindClientIfNeeded() {
|
||||
let providerId = store.providerId
|
||||
let providerId = store.asrProviderId
|
||||
let strategy = CloudASRModelCatalog.strategy(for: providerId)
|
||||
lock.withLock {
|
||||
guard boundProviderId != providerId else { return }
|
||||
|
||||
@@ -70,7 +70,9 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
/// Fire-and-forget preparation for the host app. Safe to call repeatedly.
|
||||
/// Retries after exponential backoff when a prior attempt failed.
|
||||
public func prepareInBackgroundIfNeeded() {
|
||||
#if os(iOS)
|
||||
guard AppGroup.isAvailable else { return }
|
||||
#endif
|
||||
|
||||
let shouldStart = lock.withLock { () -> Bool in
|
||||
if case .preparing = state { return false }
|
||||
@@ -167,8 +169,8 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
throw PrepareError.missingPreparedArtifacts
|
||||
}
|
||||
|
||||
AppGroup.defaultsIfAvailable?.set(fingerprint, forKey: Storage.fingerprintKey)
|
||||
AppGroup.defaultsIfAvailable?.set(Date().timeIntervalSince1970, forKey: Storage.preparedAtKey)
|
||||
Self.persistenceDefaults.set(fingerprint, forKey: Storage.fingerprintKey)
|
||||
Self.persistenceDefaults.set(Date().timeIntervalSince1970, forKey: Storage.preparedAtKey)
|
||||
Self.clearRetryState()
|
||||
|
||||
lock.withLock {
|
||||
@@ -180,8 +182,9 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
return configuration
|
||||
}
|
||||
|
||||
// MARK: - DictationTranscriber factory
|
||||
// MARK: - DictationTranscriber factory (iOS host app)
|
||||
|
||||
#if os(iOS)
|
||||
public static func makeDictationTranscriber(
|
||||
locale: Locale,
|
||||
lmConfiguration: SFSpeechLanguageModel.Configuration?
|
||||
@@ -202,6 +205,34 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
attributeOptions: preset.attributeOptions
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Legacy Speech request (macOS Apple Speech fallback)
|
||||
|
||||
/// Up to 100 short phrases for `SFSpeechRecognitionRequest.contextualStrings`.
|
||||
public static func contextualStringsForRecognition(
|
||||
bias: LocalASRBiasPayload?,
|
||||
maxCount: Int = 100
|
||||
) -> [String] {
|
||||
guard let bias, !bias.hardHotwords.isEmpty else { return [] }
|
||||
return Array(bias.hardHotwords.prefix(max(1, maxCount)))
|
||||
}
|
||||
|
||||
/// Applies bundled CLM + optional contextual strings to a legacy on-device request.
|
||||
public static func applyCustomLanguageModel(
|
||||
to request: SFSpeechURLRecognitionRequest,
|
||||
locale: Locale,
|
||||
bias: LocalASRBiasPayload?
|
||||
) {
|
||||
request.requiresOnDeviceRecognition = true
|
||||
if let configuration = shared.configurationForTranscription(locale: locale) {
|
||||
request.customizedLanguageModel = configuration
|
||||
}
|
||||
let phrases = contextualStringsForRecognition(bias: bias)
|
||||
if !phrases.isEmpty {
|
||||
request.contextualStrings = phrases
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bundle / disk helpers
|
||||
|
||||
@@ -238,14 +269,28 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
}
|
||||
|
||||
static func preparedDirectoryURL() -> URL? {
|
||||
guard let container = FileManager.default.containerURL(
|
||||
if let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: AppGroup.identifier
|
||||
) else {
|
||||
) {
|
||||
let directory = container.appendingPathComponent(Storage.subdirectory, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
#if os(macOS)
|
||||
guard let appSupport = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first else {
|
||||
return nil
|
||||
}
|
||||
let directory = container.appendingPathComponent(Storage.subdirectory, isDirectory: true)
|
||||
let directory = appSupport
|
||||
.appendingPathComponent("OSGKeyboard", isDirectory: true)
|
||||
.appendingPathComponent(Storage.subdirectory, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
static func loadCachedConfigurationFromDisk() -> SFSpeechLanguageModel.Configuration? {
|
||||
@@ -279,7 +324,7 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private static func storedFingerprint() -> String? {
|
||||
AppGroup.defaultsIfAvailable?.string(forKey: Storage.fingerprintKey)
|
||||
persistenceDefaults.string(forKey: Storage.fingerprintKey)
|
||||
}
|
||||
|
||||
private static func removeItemIfExists(at url: URL) throws {
|
||||
@@ -310,26 +355,28 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
|
||||
// MARK: - Retry / backoff
|
||||
|
||||
private static var persistenceDefaults: UserDefaults {
|
||||
AppGroup.defaultsIfAvailable ?? .standard
|
||||
}
|
||||
|
||||
private static func storedAttemptCount() -> Int {
|
||||
AppGroup.defaultsIfAvailable?.integer(forKey: Storage.attemptCountKey) ?? 0
|
||||
persistenceDefaults.integer(forKey: Storage.attemptCountKey)
|
||||
}
|
||||
|
||||
private static func storedLastFailureAt() -> TimeInterval? {
|
||||
let value = AppGroup.defaultsIfAvailable?.double(forKey: Storage.lastFailureAtKey) ?? 0
|
||||
let value = persistenceDefaults.double(forKey: Storage.lastFailureAtKey)
|
||||
return value > 0 ? value : nil
|
||||
}
|
||||
|
||||
private static func recordFailure() {
|
||||
guard let defaults = AppGroup.defaultsIfAvailable else { return }
|
||||
let nextAttempt = storedAttemptCount() + 1
|
||||
defaults.set(nextAttempt, forKey: Storage.attemptCountKey)
|
||||
defaults.set(Date().timeIntervalSince1970, forKey: Storage.lastFailureAtKey)
|
||||
persistenceDefaults.set(nextAttempt, forKey: Storage.attemptCountKey)
|
||||
persistenceDefaults.set(Date().timeIntervalSince1970, forKey: Storage.lastFailureAtKey)
|
||||
}
|
||||
|
||||
private static func clearRetryState() {
|
||||
guard let defaults = AppGroup.defaultsIfAvailable else { return }
|
||||
defaults.removeObject(forKey: Storage.attemptCountKey)
|
||||
defaults.removeObject(forKey: Storage.lastFailureAtKey)
|
||||
persistenceDefaults.removeObject(forKey: Storage.attemptCountKey)
|
||||
persistenceDefaults.removeObject(forKey: Storage.lastFailureAtKey)
|
||||
}
|
||||
|
||||
/// Returns false when retry budget is exhausted or backoff has not elapsed.
|
||||
|
||||
@@ -159,28 +159,78 @@ private final class FlowAudioProofStore: @unchecked Sendable {
|
||||
/// incoming buffer's format actually changes, so downsampling to the ASR target
|
||||
/// rate is always valid regardless of route churn.
|
||||
private final class AdaptiveDownsampler: @unchecked Sendable {
|
||||
// `AVAudioConverter` / `AVAudioFormat` are not `Sendable`, so the state and
|
||||
// the returned converter are guarded manually via the unchecked lock APIs.
|
||||
private let lock = OSAllocatedUnfairLock<(converter: AVAudioConverter, source: AVAudioFormat)?>(uncheckedState: nil)
|
||||
// `AVAudioConverter` / `AVAudioFormat` / `AVAudioPCMBuffer` are not
|
||||
// `Sendable`, so the state is guarded manually via the unchecked lock
|
||||
// APIs. The scratch output buffer is REUSED across tap callbacks —
|
||||
// allocating on the realtime audio thread risks priority inversion, and
|
||||
// taps on one bus are serialized, so a single scratch is safe as long as
|
||||
// callers copy its contents out before returning (AudioBufferSnapshot
|
||||
// does exactly that).
|
||||
private struct State {
|
||||
var converter: AVAudioConverter
|
||||
var source: AVAudioFormat
|
||||
var scratch: AVAudioPCMBuffer
|
||||
}
|
||||
|
||||
private let lock = OSAllocatedUnfairLock<State?>(uncheckedState: nil)
|
||||
let targetFormat: AVAudioFormat
|
||||
|
||||
/// Frame headroom for the reusable output buffer. Taps deliver ≤4096
|
||||
/// input frames; output frames = input × (16k / hardwareRate), which
|
||||
/// exceeds input only for sub-16 kHz hardware (rare telephony routes),
|
||||
/// so 2× the tap size covers every realistic ratio.
|
||||
private static let scratchCapacity: AVAudioFrameCount = 8_192
|
||||
|
||||
init(targetFormat: AVAudioFormat) {
|
||||
self.targetFormat = targetFormat
|
||||
}
|
||||
|
||||
/// Returns a converter valid for `sourceFormat`, rebuilding it lazily when
|
||||
/// the hardware route (and thus the buffer format) changes.
|
||||
func converter(for sourceFormat: AVAudioFormat) -> AVAudioConverter? {
|
||||
lock.withLockUnchecked { state in
|
||||
if let state, state.source == sourceFormat {
|
||||
return state.converter
|
||||
/// Downsamples `buffer` into the reusable scratch buffer and returns it,
|
||||
/// rebuilding the converter lazily when the hardware route (and thus the
|
||||
/// source format) changes. The returned buffer is only valid until the
|
||||
/// next call — copy its samples out synchronously.
|
||||
func convertReusingScratch(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
||||
let sourceFormat = buffer.format
|
||||
guard sourceFormat.sampleRate > 0 else { return nil }
|
||||
return lock.withLockUnchecked { state -> AVAudioPCMBuffer? in
|
||||
if state == nil || state!.source != sourceFormat {
|
||||
guard let converter = AVAudioConverter(from: sourceFormat, to: targetFormat),
|
||||
let scratch = AVAudioPCMBuffer(
|
||||
pcmFormat: targetFormat,
|
||||
frameCapacity: Self.scratchCapacity
|
||||
) else {
|
||||
state = nil
|
||||
return nil
|
||||
}
|
||||
state = State(converter: converter, source: sourceFormat, scratch: scratch)
|
||||
}
|
||||
guard let converter = AVAudioConverter(from: sourceFormat, to: targetFormat) else {
|
||||
state = nil
|
||||
return nil
|
||||
guard let current = state else { return nil }
|
||||
|
||||
let wanted = AVAudioFrameCount(
|
||||
Double(buffer.frameLength) * targetFormat.sampleRate / sourceFormat.sampleRate
|
||||
)
|
||||
guard wanted > 0, wanted <= current.scratch.frameCapacity else { return nil }
|
||||
current.scratch.frameLength = 0
|
||||
|
||||
// ONE-SHOT input: the converter keeps pulling until the output
|
||||
// buffer's frameCapacity is full, and the scratch is deliberately
|
||||
// oversized — feeding the same tap buffer on every pull would
|
||||
// duplicate the audio ~6× (stuttering ASR input). After the
|
||||
// single feed we report "ran dry", so the expected status is
|
||||
// `.inputRanDry` (output not full), not `.haveData`.
|
||||
var provided = false
|
||||
var error: NSError?
|
||||
let status = current.converter.convert(to: current.scratch, error: &error) { _, outStatus in
|
||||
if provided {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
provided = true
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
state = (converter, sourceFormat)
|
||||
return converter
|
||||
guard status != .error, error == nil, current.scratch.frameLength > 0 else { return nil }
|
||||
return current.scratch
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,6 +290,10 @@ public final class FlowContinuousCapture {
|
||||
private var didInstallTap = false
|
||||
private var isRunning = false
|
||||
private var isRebuilding = false
|
||||
private var interrupted = false
|
||||
/// When the engine last (re)activated — a freshly started engine has
|
||||
/// produced no frames yet and must not be misclassified as a zombie.
|
||||
private var lastActivationAt = Date.distantPast
|
||||
|
||||
private var routeObserver: NSObjectProtocol?
|
||||
private var interruptionObserver: NSObjectProtocol?
|
||||
@@ -250,6 +304,11 @@ public final class FlowContinuousCapture {
|
||||
|
||||
public var running: Bool { isRunning }
|
||||
|
||||
/// True between interruption `.began` and `.ended` (phone call, Siri).
|
||||
/// While set, `setActive(true)` is guaranteed to fail — owners should
|
||||
/// wait for `.ended` (which rebuilds the engine) instead of retrying.
|
||||
public var isInterrupted: Bool { interrupted }
|
||||
|
||||
/// True when the capture session flag, tap, and audio engine are all live.
|
||||
public var engineIsLive: Bool {
|
||||
isRunning && didInstallTap && audioEngine.isRunning
|
||||
@@ -264,9 +323,33 @@ public final class FlowContinuousCapture {
|
||||
/// Called on the main actor when `engineIsLive` may have changed.
|
||||
public var onEngineLiveChanged: ((Bool) -> Void)?
|
||||
|
||||
/// Called on the main actor when the system interrupted capture (phone
|
||||
/// call, Siri). The session owner should fail any mic-open utterance —
|
||||
/// audio frames stop arriving, so continuing to "record" only captures
|
||||
/// a silence gap the user cannot see.
|
||||
public var onInterruptionBegan: (() -> Void)?
|
||||
|
||||
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
|
||||
///
|
||||
/// Idempotent: "already running and healthy" is a warm-start fast path,
|
||||
/// while "already running but producing no audio" is a zombie state
|
||||
/// (force-quit relaunch, failed cold start, mediaserverd reset) that is
|
||||
/// torn down and rebuilt in place. It must never be a silent no-op —
|
||||
/// a `guard !isRunning` early-return here turned every cold-start retry
|
||||
/// into a guaranteed audio-proof timeout.
|
||||
public func start() throws {
|
||||
guard !isRunning else { return }
|
||||
if isRunning {
|
||||
let startedMomentsAgo = Date().timeIntervalSince(lastActivationAt) < 2
|
||||
if engineIsLive && (engineHasRecentAudio(maxAge: 2) || startedMomentsAgo) {
|
||||
// Healthy warm engine — or one so fresh it simply hasn't
|
||||
// produced its first frame yet (interleaved start attempts
|
||||
// land here; rebuilding a 100 ms-old engine only multiplies
|
||||
// audio-session churn in the fragile post-relaunch window).
|
||||
return
|
||||
}
|
||||
log.info("start(): zombie engine detected (running but no live audio) — forcing rebuild")
|
||||
stop()
|
||||
}
|
||||
audioProofStore.reset()
|
||||
try activateEngine()
|
||||
isRunning = true
|
||||
@@ -353,6 +436,7 @@ public final class FlowContinuousCapture {
|
||||
} catch {
|
||||
throw StartError.engineStartFailed(error.localizedDescription)
|
||||
}
|
||||
lastActivationAt = Date()
|
||||
}
|
||||
|
||||
/// Tear down the engine and release the audio session.
|
||||
@@ -371,6 +455,7 @@ public final class FlowContinuousCapture {
|
||||
audioEngine.stop()
|
||||
}
|
||||
isRunning = false
|
||||
interrupted = false
|
||||
audioProofStore.reset()
|
||||
downsampler = nil
|
||||
targetFormat = nil
|
||||
@@ -384,6 +469,13 @@ public final class FlowContinuousCapture {
|
||||
|
||||
/// Re-activate capture after returning from background without
|
||||
/// reinstalling the tap (iOS may deactivate the audio session).
|
||||
///
|
||||
/// Doubles as the interruption-recovery probe: `setActive(true)` FAILS
|
||||
/// while a call/Siri interruption is live and succeeds once it ends, so a
|
||||
/// successful reassert proves the interruption is over. iOS does not
|
||||
/// guarantee delivery of `.ended` (commonly dropped when the app was
|
||||
/// suspended during the call), so this is the only reliable way to clear
|
||||
/// the `interrupted` latch in that case.
|
||||
@discardableResult
|
||||
public func reassertIfRunning() -> Bool {
|
||||
guard isRunning else { return false }
|
||||
@@ -395,6 +487,7 @@ public final class FlowContinuousCapture {
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
|
||||
)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
interrupted = false
|
||||
if !audioEngine.isRunning {
|
||||
try audioEngine.start()
|
||||
}
|
||||
@@ -415,7 +508,14 @@ public final class FlowContinuousCapture {
|
||||
if engineHasRecentAudio(maxAge: recentFrameMaxAge) {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
} catch {
|
||||
// Cancelled — bail out instead of busy-spinning the main
|
||||
// actor for the rest of the window (a cancelled Task.sleep
|
||||
// returns immediately, starving concurrent start attempts).
|
||||
return false
|
||||
}
|
||||
}
|
||||
return engineHasRecentAudio(maxAge: recentFrameMaxAge)
|
||||
}
|
||||
@@ -497,8 +597,11 @@ public final class FlowContinuousCapture {
|
||||
switch type {
|
||||
case .began:
|
||||
log.info("Audio interruption began")
|
||||
interrupted = true
|
||||
notifyEngineLiveChanged()
|
||||
onInterruptionBegan?()
|
||||
case .ended:
|
||||
interrupted = false
|
||||
guard isRunning else { return }
|
||||
let shouldResume: Bool
|
||||
if let optionsRaw {
|
||||
@@ -627,26 +730,12 @@ public final class FlowContinuousCapture {
|
||||
audioProofStore.markFrameReceived()
|
||||
levelStore.update(from: buffer, barCount: FlowCaptureConstants.levelBarCount)
|
||||
|
||||
// Derive the converter from the *live* buffer format so a mid-session
|
||||
// route change (e.g. 48 kHz → 24 kHz) is handled transparently.
|
||||
let sourceFormat = buffer.format
|
||||
let targetFormat = downsampler.targetFormat
|
||||
guard sourceFormat.sampleRate > 0,
|
||||
let converter = downsampler.converter(for: sourceFormat) else { return }
|
||||
|
||||
let outFrames = AVAudioFrameCount(
|
||||
Double(buffer.frameLength) * targetFormat.sampleRate / sourceFormat.sampleRate
|
||||
)
|
||||
guard outFrames > 0,
|
||||
let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outFrames)
|
||||
else { return }
|
||||
|
||||
var error: NSError?
|
||||
let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
guard status == .haveData, error == nil, outBuffer.frameLength > 0 else { return }
|
||||
// The downsampler derives its converter from the *live* buffer
|
||||
// format (mid-session route changes handled transparently) and
|
||||
// returns a REUSED scratch buffer — no per-callback allocation
|
||||
// on the realtime thread. The snapshot below copies the samples
|
||||
// out before the next tap callback can overwrite the scratch.
|
||||
guard let outBuffer = downsampler.convertReusingScratch(buffer) else { return }
|
||||
|
||||
let snapshot = AudioBufferSnapshot(buffer: outBuffer)
|
||||
guard !snapshot.samples.isEmpty else { return }
|
||||
|
||||
@@ -130,6 +130,12 @@ public struct FlowReadySnapshot: Codable, Equatable, Sendable {
|
||||
public let localeId: String
|
||||
public let busyUtteranceId: UUID?
|
||||
public let sessionExpiresAt: TimeInterval?
|
||||
/// Host process generation that wrote this snapshot. A snapshot whose
|
||||
/// generation no longer matches `FlowSessionKeys.hostGeneration` was
|
||||
/// written by a dead process and is void immediately — no need to wait
|
||||
/// out the heartbeat-zombie window. Optional for wire compatibility with
|
||||
/// snapshots written before this field existed.
|
||||
public let hostGeneration: String?
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = 1,
|
||||
@@ -142,7 +148,8 @@ public struct FlowReadySnapshot: Codable, Equatable, Sendable {
|
||||
engineMode: String,
|
||||
localeId: String,
|
||||
busyUtteranceId: UUID? = nil,
|
||||
sessionExpiresAt: TimeInterval? = nil
|
||||
sessionExpiresAt: TimeInterval? = nil,
|
||||
hostGeneration: String? = nil
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
@@ -155,6 +162,7 @@ public struct FlowReadySnapshot: Codable, Equatable, Sendable {
|
||||
self.localeId = localeId
|
||||
self.busyUtteranceId = busyUtteranceId
|
||||
self.sessionExpiresAt = sessionExpiresAt
|
||||
self.hostGeneration = hostGeneration
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,13 +274,27 @@ public enum FlowSessionBridge {
|
||||
store.set(readyAt, forKey: FlowSessionKeys.flowHostReadyAt)
|
||||
}
|
||||
} else {
|
||||
// Keep the not-ready payload. The keyboard needs `reason`
|
||||
// (recording / processing / waitingForAudioProof / …) to tell
|
||||
// "host is busy" apart from "host is still starting". Deleting
|
||||
// the payload here forced every mid-utterance ready=false into
|
||||
// a permanent orange `preparingSession` state.
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
|
||||
}
|
||||
if let expires = snapshot.sessionExpiresAt {
|
||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||
}
|
||||
store.set(snapshot.heartbeatAt, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
// Only a genuinely live host — ready, or actively serving an
|
||||
// utterance — may refresh the heartbeat here. A host stuck in a
|
||||
// failed cold start would otherwise keep "reviving" itself on every
|
||||
// engine-state flap, flickering the keyboard between reachable and
|
||||
// dead and postponing zombie-state cleanup indefinitely.
|
||||
let provesHostAlive = snapshot.ready
|
||||
|| snapshot.reason == .recording
|
||||
|| snapshot.reason == .processing
|
||||
if provesHostAlive {
|
||||
store.set(snapshot.heartbeatAt, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
}
|
||||
flush(store)
|
||||
FlowSessionDarwin.postHostReadyChanged()
|
||||
}
|
||||
@@ -309,7 +331,8 @@ public enum FlowSessionBridge {
|
||||
heartbeatAt: now,
|
||||
engineMode: AppGroupConfiguration.load(fromAvailable: store).engineMode,
|
||||
localeId: AppGroupConfiguration.load(fromAvailable: store).localeId,
|
||||
sessionExpiresAt: expires
|
||||
sessionExpiresAt: expires,
|
||||
hostGeneration: store.string(forKey: FlowSessionKeys.hostGeneration)
|
||||
)
|
||||
if let data = encode(snapshot) {
|
||||
store.set(data, forKey: FlowSessionKeys.flowReadyPayload)
|
||||
@@ -418,6 +441,54 @@ public enum FlowSessionBridge {
|
||||
return staleness <= FlowSessionKeys.heartbeatStaleInterval
|
||||
}
|
||||
|
||||
// MARK: - Host process generation
|
||||
|
||||
/// Host app: rotate the per-process generation token. Call exactly once,
|
||||
/// as early as possible in the host launch path. Returns the previous
|
||||
/// generation (nil on first-ever launch) so the caller can log it.
|
||||
///
|
||||
/// Rationale: `applicationWillTerminate` is best-effort — it never runs
|
||||
/// when a *suspended* app is force-quit (the common case after a failed
|
||||
/// cold start). Instead of anchoring cleanup on a termination callback
|
||||
/// that may not fire, each launch proves the previous process is dead and
|
||||
/// voids whatever session state it left behind.
|
||||
@discardableResult
|
||||
public static func rotateHostGeneration(defaults: UserDefaults? = nil) -> String? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let previous = store.string(forKey: FlowSessionKeys.hostGeneration)
|
||||
store.set(UUID().uuidString, forKey: FlowSessionKeys.hostGeneration)
|
||||
flush(store)
|
||||
return previous
|
||||
}
|
||||
|
||||
public static func currentHostGeneration(defaults: UserDefaults? = nil) -> String? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
return store.string(forKey: FlowSessionKeys.hostGeneration)
|
||||
}
|
||||
|
||||
/// Host launch reconciliation: clear every piece of persisted session
|
||||
/// state a previous (dead) generation left behind. Unlike
|
||||
/// `clearFlowState()` this keeps `pendingHostBundleId` — on a keyboard
|
||||
/// `startflow` cold launch the scene delegate stores the host bundle id
|
||||
/// *before* the SwiftUI hierarchy (and thus the session manager) exists,
|
||||
/// and wiping it here would break the return-to-host affordance.
|
||||
public static func clearFlowStateOnHostLaunch(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.flowCommandPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
|
||||
clearTranscription(defaults: store)
|
||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
// MARK: - Host ready contract (host app → keyboard)
|
||||
|
||||
/// Host app: publish whether Flow can accept a new utterance right now.
|
||||
@@ -446,6 +517,13 @@ public enum FlowSessionBridge {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let snapshot = readySnapshot(defaults: store) {
|
||||
guard snapshot.ready else { return false }
|
||||
// Snapshot written by a dead host generation → void immediately,
|
||||
// without waiting out the heartbeat-zombie window.
|
||||
if let snapshotGeneration = snapshot.hostGeneration,
|
||||
let currentGeneration = store.string(forKey: FlowSessionKeys.hostGeneration),
|
||||
snapshotGeneration != currentGeneration {
|
||||
return false
|
||||
}
|
||||
guard isHostReachable(defaults: store) else { return false }
|
||||
if let readyAt = snapshot.readyAt {
|
||||
let skew = abs(snapshot.heartbeatAt - readyAt)
|
||||
|
||||
@@ -33,6 +33,11 @@ public enum FlowSessionKeys {
|
||||
public static let pendingHostBundleId = "flow.pendingHostBundleId"
|
||||
/// Wall-clock timestamp of the last utterance completion or session start.
|
||||
public static let lastActivityAt = "flow.lastActivityAt"
|
||||
/// One-shot token rotated by every host-process launch. State written by
|
||||
/// a previous generation is void by definition — a fresh launch proves the
|
||||
/// previous process is dead, whether or not its `applicationWillTerminate`
|
||||
/// cleanup ever ran (it does NOT run when a suspended app is force-quit).
|
||||
public static let hostGeneration = "flow.hostGeneration.v1"
|
||||
|
||||
/// Heartbeat older than this → host is not actively reachable for recording.
|
||||
public static let heartbeatStaleInterval: TimeInterval = 3
|
||||
@@ -59,14 +64,23 @@ public enum FlowSessionKeys {
|
||||
public static let localASRWaitTimeout: TimeInterval = 120
|
||||
public static let cloudASRWaitTimeout: TimeInterval = 120
|
||||
|
||||
/// Keyboard watchdog after the user stops recording (not utterance max length).
|
||||
/// Must cover worst-case post-stop backlog: remaining SpeechAnalyzer chunks
|
||||
/// plus cloud LLM polish (see `PolishingService.effectiveTimeout` cap).
|
||||
/// Hard cap on a single LLM polish request. `PolishingService`'s scaled
|
||||
/// per-request timeout clamps to this value, so it participates in the
|
||||
/// keyboard-watchdog budget below.
|
||||
public static let maxPolishTimeout: TimeInterval = 120
|
||||
|
||||
/// Extra slack for result serialization, cross-process propagation, and
|
||||
/// the host's own polling cadence.
|
||||
public static let resultDeliveryMargin: TimeInterval = 20
|
||||
|
||||
/// Keyboard watchdog after the user stops recording (not utterance max
|
||||
/// length). Derived from the host-side budget so it always outlasts the
|
||||
/// host's worst case (ASR drain wait + polish cap + margin) — hand-tuned
|
||||
/// constants drifted below the real host maximum, making the keyboard
|
||||
/// report a timeout for transcriptions that were still going to succeed.
|
||||
public static func keyboardResultTimeout(engineMode: String) -> TimeInterval {
|
||||
if engineMode == "local" {
|
||||
return 180
|
||||
}
|
||||
return 240
|
||||
let asrWait = engineMode == "local" ? localASRWaitTimeout : cloudASRWaitTimeout
|
||||
return asrWait + maxPolishTimeout + resultDeliveryMargin
|
||||
}
|
||||
|
||||
public enum RecordingState: String, Sendable, Equatable {
|
||||
|
||||
@@ -39,20 +39,48 @@ public final class AppCloudSync {
|
||||
?? SpeechHistoryCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults)
|
||||
}
|
||||
|
||||
/// Serializes external-change pulls: KVS posts change notifications in
|
||||
/// bursts (one per key at times), and overlapping pull-merge-apply runs
|
||||
/// can interleave their read/write phases. `wantsAnotherPull` coalesces
|
||||
/// every burst into at most one trailing re-pull.
|
||||
private var isPulling = false
|
||||
private var wantsAnotherPull = false
|
||||
|
||||
public func startObservingExternalChanges() {
|
||||
guard externalChangeObserver == nil else { return }
|
||||
externalChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
) { [weak self] note in
|
||||
guard let self else { return }
|
||||
// Distinguish WHY the store changed. `.accountChange` means the
|
||||
// user switched iCloud accounts — the incoming values belong to a
|
||||
// DIFFERENT account and must not be merged into this one's data
|
||||
// (deleted-entry resurrection, foreign history, wrong settings).
|
||||
let reason = note.userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
||||
if reason == NSUbiquitousKeyValueStoreAccountChange {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
await self.pullAllIfEnabled()
|
||||
await self.pullAllCoalesced()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pullAllCoalesced() async {
|
||||
guard !isPulling else {
|
||||
wantsAnotherPull = true
|
||||
return
|
||||
}
|
||||
isPulling = true
|
||||
defer { isPulling = false }
|
||||
repeat {
|
||||
wantsAnotherPull = false
|
||||
await pullAllIfEnabled()
|
||||
} while wantsAnotherPull
|
||||
}
|
||||
|
||||
public func stopObservingExternalChanges() {
|
||||
if let externalChangeObserver {
|
||||
NotificationCenter.default.removeObserver(externalChangeObserver)
|
||||
@@ -79,18 +107,27 @@ public final class AppCloudSync {
|
||||
}
|
||||
|
||||
/// Low-risk manual sync: pull remote changes, merge, then push local state.
|
||||
/// Each push runs independently — one payload failing must not abort the
|
||||
/// others (a too-large history would otherwise also kill the dictionary
|
||||
/// push). The first error is rethrown after every push has been tried.
|
||||
public func syncNow() async throws {
|
||||
let store = makeStore()
|
||||
await pullAllIfEnabled()
|
||||
|
||||
var firstError: Error?
|
||||
func attempt(_ body: () async throws -> Void) async {
|
||||
do { try await body() } catch { if firstError == nil { firstError = error } }
|
||||
}
|
||||
|
||||
if store.settingsICloudSyncEnabled {
|
||||
try await settingsSync.pushLocalIfEnabled()
|
||||
try await usageStatisticsSync.pushLocalIfEnabled()
|
||||
try await speechHistorySync.pushLocalIfEnabled()
|
||||
await attempt { try await settingsSync.pushLocalIfEnabled() }
|
||||
await attempt { try await usageStatisticsSync.pushLocalIfEnabled() }
|
||||
await attempt { try await speechHistorySync.pushLocalIfEnabled() }
|
||||
}
|
||||
if store.personalDictionaryICloudSyncEnabled {
|
||||
try await dictionarySync.pushLocalIfEnabled(store.personalDictionary)
|
||||
await attempt { try await dictionarySync.pushLocalIfEnabled(store.personalDictionary) }
|
||||
}
|
||||
if let firstError { throw firstError }
|
||||
}
|
||||
|
||||
public var settingsSyncService: SettingsCloudSync { settingsSync }
|
||||
|
||||
@@ -24,8 +24,12 @@ public final class SpeechHistoryCloudSync {
|
||||
|
||||
public static let kvsKey = SyncedSpeechHistory.kvsKey
|
||||
public static let legacyKVSKey = SyncedSpeechHistory.legacyKVSKey
|
||||
/// Stay below the ~1 MB per-key KVS limit.
|
||||
public static let maxPayloadBytes = 900_000
|
||||
/// The 1 MB iCloud KVS quota is for the WHOLE store, not per key.
|
||||
/// History and the personal dictionary must fit together (plus settings
|
||||
/// and usage stats) — once the store exceeds 1 MB, KVS rejects writes
|
||||
/// for ALL keys with `QuotaViolation` and every sync silently stops.
|
||||
/// Budget: ~400 KB history + ~400 KB dictionary + headroom for the rest.
|
||||
public static let maxPayloadBytes = 400_000
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
@@ -50,8 +54,16 @@ public final class SpeechHistoryCloudSync {
|
||||
public func pushLocalIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
let local = SpeechHistoryStorage.load(from: historyDefaults())
|
||||
try push(local)
|
||||
// Read-merge-write: pushing the local view verbatim would overwrite
|
||||
// entries another device added since our last pull (KVS is
|
||||
// last-writer-wins with no server-side merge).
|
||||
let defaults = historyDefaults()
|
||||
let local = SpeechHistoryStorage.load(from: defaults)
|
||||
let merged = loadRemote().map { SyncedSpeechHistory.merge(local: local, remote: $0) } ?? local
|
||||
if merged != local {
|
||||
apply(merged, to: defaults, postNotification: true)
|
||||
}
|
||||
try push(merged)
|
||||
}
|
||||
|
||||
/// Called when settings sync is first enabled to union local + remote history.
|
||||
@@ -81,11 +93,34 @@ public final class SpeechHistoryCloudSync {
|
||||
}
|
||||
|
||||
public func push(_ history: SyncedSpeechHistory) throws {
|
||||
let data = try encode(history)
|
||||
let data = try encodeFittingBudget(history)
|
||||
kvs.set(data, forKey: Self.kvsKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
/// Encode, dropping the oldest entries until the payload fits the KVS
|
||||
/// budget. Without this, a history that once fit under the old 900 KB
|
||||
/// cap (300 long dictations easily exceed 400 KB) would make EVERY push
|
||||
/// throw forever — automatic pushes are fire-and-forget, so sync would
|
||||
/// just silently die with no way back short of clearing all history.
|
||||
/// Only the *uploaded* copy is trimmed; local history keeps its full
|
||||
/// 300 entries.
|
||||
func encodeFittingBudget(_ history: SyncedSpeechHistory) throws -> Data {
|
||||
var payload = history
|
||||
while true {
|
||||
do {
|
||||
return try encode(payload)
|
||||
} catch SpeechHistoryCloudSyncError.payloadTooLarge {
|
||||
guard payload.entries.count > 1 else { throw SpeechHistoryCloudSyncError.payloadTooLarge(byteCount: 0) }
|
||||
// Drop the oldest ~10% per pass; entries are kept
|
||||
// newest-first by the store, so trim from the tail.
|
||||
let sorted = payload.entries.sorted { $0.createdAt > $1.createdAt }
|
||||
let keep = max(1, sorted.count - max(1, sorted.count / 10))
|
||||
payload.entries = Array(sorted.prefix(keep))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedSpeechHistory? {
|
||||
if let data = kvs.data(forKey: Self.kvsKey) {
|
||||
return try? decode(data)
|
||||
|
||||
@@ -45,8 +45,16 @@ public final class UsageStatisticsCloudSync {
|
||||
public func pushLocalIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
// Read-merge-write: this fires after every utterance, so pushing the
|
||||
// local view verbatim would clobber counter slices another device
|
||||
// advanced since our last pull (KVS is last-writer-wins). The
|
||||
// G-Counter merge makes the push commutative instead.
|
||||
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
|
||||
try push(local)
|
||||
let merged = loadRemote().map { SyncedUsageStatisticsV2.merge(local: local, remote: $0) } ?? local
|
||||
if merged != local {
|
||||
apply(merged, to: store.defaults, postNotification: true)
|
||||
}
|
||||
try push(merged)
|
||||
}
|
||||
|
||||
/// Called when settings sync is first enabled to union local + remote totals.
|
||||
|
||||
@@ -91,8 +91,11 @@ public final class KeyboardState: ObservableObject {
|
||||
@Published public var micDisabled: Bool = false
|
||||
/// One-line helper shown above the mic while `micDisabled == true`.
|
||||
@Published public var micDisabledHint: String = ""
|
||||
/// "local" → on-device ASR only. "cloud" → ASR + LLM polish.
|
||||
@Published public var engineMode: String = "cloud"
|
||||
/// "local" → on-device ASR only. "cloud" → cloud ASR + LLM polish.
|
||||
/// Boot value must match the privacy-safe app default (`local`) so the
|
||||
/// keyboard never assumes the audio-uploading engine before the App
|
||||
/// Group config has been read.
|
||||
@Published public var engineMode: String = "local"
|
||||
/// v0.2.1 follow-up: derived — translation is on iff a target
|
||||
/// locale has been selected (mirrors `ProviderConfig.translationEnabled`
|
||||
/// so the chip / pipeline read the same source of truth).
|
||||
@@ -148,6 +151,46 @@ public final class KeyboardState: ObservableObject {
|
||||
case openSettings
|
||||
}
|
||||
|
||||
// MARK: - Temporary Flow debug (remove after orange-mic investigation)
|
||||
|
||||
/// Mirrored from `KeyboardFlowCoordinator` for the on-screen debug panel.
|
||||
@Published public var debugPendingFlowStart: Bool = false
|
||||
@Published public var debugFlowRecording: Bool = false
|
||||
@Published public var debugAwaitingFlowResult: Bool = false
|
||||
@Published public var debugHasFullAccess: Bool = false
|
||||
|
||||
/// Snapshot for the keyboard debug panel.
|
||||
public func makeFlowDebugRows(hasFullAccess: Bool) -> [FlowDebugRow] {
|
||||
debugHasFullAccess = hasFullAccess
|
||||
let micLabel: String = {
|
||||
switch micVoiceAvailability {
|
||||
case .ready: return "ready"
|
||||
case .recording: return "recording"
|
||||
case .processing: return "processing"
|
||||
case .unavailable(let reason):
|
||||
switch reason {
|
||||
case .hostNotReady: return "unavailable(hostNotReady)"
|
||||
case .preparingSession: return "unavailable(preparingSession)"
|
||||
case .noFullAccess: return "unavailable(noFullAccess)"
|
||||
case .appGroupUnavailable: return "unavailable(appGroupUnavailable)"
|
||||
case .missingAPIKey: return "unavailable(missingAPIKey)"
|
||||
}
|
||||
}
|
||||
}()
|
||||
let localRows: [FlowDebugRow] = [
|
||||
FlowDebugRow("mic", micLabel),
|
||||
FlowDebugRow("phase", String(describing: phase)),
|
||||
FlowDebugRow("pendingStart", debugPendingFlowStart ? "1" : "0"),
|
||||
FlowDebugRow("kb.recording", debugFlowRecording ? "1" : "0"),
|
||||
FlowDebugRow("kb.awaiting", debugAwaitingFlowResult ? "1" : "0"),
|
||||
FlowDebugRow("fullAccess", hasFullAccess ? "1" : "0"),
|
||||
FlowDebugRow("micDisabled", micDisabled ? "1" : "0"),
|
||||
FlowDebugRow("flowSessionPub", flowSessionActive ? "1" : "0"),
|
||||
FlowDebugRow("engine", engineMode)
|
||||
]
|
||||
return localRows + FlowDebugAppGroupSnapshot.rows()
|
||||
}
|
||||
|
||||
// Action hooks — injected by the view controller at install time.
|
||||
public var beginRecording: () -> Void = {}
|
||||
public var endRecording: () -> Void = {}
|
||||
|
||||
@@ -22,12 +22,29 @@ public enum Keychain: @unchecked Sendable {
|
||||
private static let legacyAccount = "current"
|
||||
private static let defaultProviderId = "openai"
|
||||
|
||||
private static func account(for providerId: String) -> String {
|
||||
private static func normalizedProviderId(_ providerId: String) -> String {
|
||||
let trimmed = providerId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = trimmed.isEmpty ? defaultProviderId : trimmed.lowercased()
|
||||
return "provider.\(normalized)"
|
||||
return trimmed.isEmpty ? defaultProviderId : trimmed.lowercased()
|
||||
}
|
||||
|
||||
/// LLM polish credentials (`provider.<id>`).
|
||||
private static func account(for providerId: String) -> String {
|
||||
"provider.\(normalizedProviderId(providerId))"
|
||||
}
|
||||
|
||||
/// Cloud ASR credentials (`asr.<id>`), independent from polish keys.
|
||||
private static func asrAccount(for providerId: String) -> String {
|
||||
"asr.\(normalizedProviderId(providerId))"
|
||||
}
|
||||
|
||||
// NOTE on kSecAttrAccessGroup: we deliberately rely on the DEFAULT
|
||||
// access group (the first entry in each target's keychain-access-groups,
|
||||
// which project.yml pins to `$(AppIdentifierPrefix)com.osgkeyboard.shared`
|
||||
// for every target). Setting the attribute explicitly would require the
|
||||
// team-prefixed string at runtime, which is not portably available
|
||||
// without injecting TeamID through the build system. If a SECOND access
|
||||
// group is ever added to any target, revisit this — reordered groups
|
||||
// would silently change which store these queries hit.
|
||||
private static func baseQuery(providerId: String, synchronizable: Bool) -> [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
@@ -43,6 +60,130 @@ public enum Keychain: @unchecked Sendable {
|
||||
|
||||
// MARK: - Read
|
||||
|
||||
// MARK: - ASR keys
|
||||
|
||||
public static func asrApiKey(for providerId: String, preferICloudSync: Bool = false) -> String? {
|
||||
if preferICloudSync, let synced = readASRKey(providerId: providerId, synchronizable: true) {
|
||||
return synced
|
||||
}
|
||||
if let local = readASRKey(providerId: providerId, synchronizable: false) {
|
||||
return local
|
||||
}
|
||||
if preferICloudSync {
|
||||
return readASRKey(providerId: providerId, synchronizable: true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func asrApiKeyOutcome(
|
||||
for providerId: String,
|
||||
preferICloudSync: Bool = false
|
||||
) -> ReadOutcome {
|
||||
let first = readASRKeyOutcome(providerId: providerId, synchronizable: preferICloudSync)
|
||||
if case .found = first { return first }
|
||||
let second = readASRKeyOutcome(providerId: providerId, synchronizable: !preferICloudSync)
|
||||
if case .found = second { return second }
|
||||
if case .unavailable = first { return first }
|
||||
if case .unavailable = second { return second }
|
||||
return .notFound
|
||||
}
|
||||
|
||||
public static func setASRAPIKey(_ key: String, for providerId: String, useICloudSync: Bool = false) throws {
|
||||
if key.isEmpty {
|
||||
try deleteASRAPIKey(for: providerId, useICloudSync: useICloudSync)
|
||||
return
|
||||
}
|
||||
if useICloudSync {
|
||||
try writeASRKey(key, providerId: providerId, synchronizable: true)
|
||||
try? deleteASRKey(providerId: providerId, synchronizable: false)
|
||||
} else {
|
||||
try writeASRKey(key, providerId: providerId, synchronizable: false)
|
||||
}
|
||||
}
|
||||
|
||||
public static func deleteASRAPIKey(for providerId: String, useICloudSync: Bool = false) throws {
|
||||
try deleteASRKey(providerId: providerId, synchronizable: false)
|
||||
if useICloudSync {
|
||||
try deleteASRKey(providerId: providerId, synchronizable: true)
|
||||
}
|
||||
}
|
||||
|
||||
private static func readASRKey(providerId: String, synchronizable: Bool) -> String? {
|
||||
if case .found(let value) = readASRKeyOutcome(providerId: providerId, synchronizable: synchronizable) {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func readASRKeyOutcome(providerId: String, synchronizable: Bool) -> ReadOutcome {
|
||||
var query = baseASRQuery(providerId: providerId, synchronizable: synchronizable)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
guard let data = result as? Data,
|
||||
let str = String(data: data, encoding: .utf8) else {
|
||||
return .notFound
|
||||
}
|
||||
return .found(str)
|
||||
case errSecItemNotFound:
|
||||
// Pre-split installs stored one key under `provider.<id>` for both stages.
|
||||
return readKeyOutcome(providerId: providerId, synchronizable: synchronizable)
|
||||
default:
|
||||
#if DEBUG
|
||||
print("⚠️ [OSGKeyboard] ASR Keychain read returned OSStatus \(status); reporting unavailable.")
|
||||
#endif
|
||||
return .unavailable(status)
|
||||
}
|
||||
}
|
||||
|
||||
private static func baseASRQuery(providerId: String, synchronizable: Bool) -> [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: asrAccount(for: providerId),
|
||||
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!,
|
||||
]
|
||||
#if os(macOS)
|
||||
query[kSecUseDataProtectionKeychain as String] = true
|
||||
#endif
|
||||
return query
|
||||
}
|
||||
|
||||
private static func writeASRKey(_ key: String, providerId: String, synchronizable: Bool) throws {
|
||||
let data = Data(key.utf8)
|
||||
var baseQuery = baseASRQuery(providerId: providerId, synchronizable: synchronizable)
|
||||
let updateAttrs: [String: Any] = [kSecValueData as String: data]
|
||||
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary)
|
||||
switch updateStatus {
|
||||
case errSecSuccess:
|
||||
return
|
||||
case errSecItemNotFound:
|
||||
baseQuery[kSecValueData as String] = data
|
||||
baseQuery[kSecAttrAccessible as String] = synchronizable
|
||||
? kSecAttrAccessibleAfterFirstUnlock
|
||||
: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(baseQuery as CFDictionary, nil)
|
||||
if addStatus != errSecSuccess {
|
||||
throw KeychainError.unexpectedStatus(addStatus)
|
||||
}
|
||||
default:
|
||||
throw KeychainError.unexpectedStatus(updateStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private static func deleteASRKey(providerId: String, synchronizable: Bool) throws {
|
||||
let query = baseASRQuery(providerId: providerId, synchronizable: synchronizable)
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status != errSecSuccess, status != errSecItemNotFound {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LLM keys
|
||||
|
||||
public static func apiKey(for providerId: String, preferICloudSync: Bool = false) -> String? {
|
||||
if preferICloudSync, let synced = readKey(providerId: providerId, synchronizable: true) {
|
||||
return synced
|
||||
@@ -60,7 +201,43 @@ public enum Keychain: @unchecked Sendable {
|
||||
apiKey(for: defaultProviderId)
|
||||
}
|
||||
|
||||
/// Distinguishes "no key stored" from "keychain temporarily unreadable".
|
||||
public enum ReadOutcome: Equatable {
|
||||
case found(String)
|
||||
case notFound
|
||||
/// The keychain could not be read (e.g. `errSecInteractionNotAllowed`
|
||||
/// while the device is locked before first unlock). NOT the same as
|
||||
/// "no key configured" — telling the user to re-enter their key in
|
||||
/// this state would be wrong; the read succeeds once unlocked.
|
||||
case unavailable(OSStatus)
|
||||
}
|
||||
|
||||
/// Like `apiKey(for:)`, but reports WHY a key was not returned so
|
||||
/// callers can distinguish a missing key (user action needed) from a
|
||||
/// transiently locked keychain (retry later).
|
||||
public static func apiKeyOutcome(
|
||||
for providerId: String,
|
||||
preferICloudSync: Bool = false
|
||||
) -> ReadOutcome {
|
||||
let first = readKeyOutcome(providerId: providerId, synchronizable: preferICloudSync)
|
||||
if case .found = first { return first }
|
||||
let second = readKeyOutcome(providerId: providerId, synchronizable: !preferICloudSync)
|
||||
if case .found = second { return second }
|
||||
// Neither store had it: surface "unavailable" when either read was
|
||||
// blocked, since the key may well exist behind the lock.
|
||||
if case .unavailable = first { return first }
|
||||
if case .unavailable = second { return second }
|
||||
return .notFound
|
||||
}
|
||||
|
||||
private static func readKey(providerId: String, synchronizable: Bool) -> String? {
|
||||
if case .found(let value) = readKeyOutcome(providerId: providerId, synchronizable: synchronizable) {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func readKeyOutcome(providerId: String, synchronizable: Bool) -> ReadOutcome {
|
||||
var query = baseQuery(providerId: providerId, synchronizable: synchronizable)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
@@ -70,16 +247,16 @@ public enum Keychain: @unchecked Sendable {
|
||||
case errSecSuccess:
|
||||
guard let data = result as? Data,
|
||||
let str = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
return .notFound
|
||||
}
|
||||
return str
|
||||
return .found(str)
|
||||
case errSecItemNotFound:
|
||||
return nil
|
||||
return .notFound
|
||||
default:
|
||||
#if DEBUG
|
||||
print("⚠️ [OSGKeyboard] Keychain read returned OSStatus \(status); treating as no key.")
|
||||
print("⚠️ [OSGKeyboard] Keychain read returned OSStatus \(status); reporting unavailable.")
|
||||
#endif
|
||||
return nil
|
||||
return .unavailable(status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +370,13 @@ public enum Keychain: @unchecked Sendable {
|
||||
try? writeKey(local, providerId: provider.id, synchronizable: true)
|
||||
try? deleteKey(providerId: provider.id, synchronizable: false)
|
||||
}
|
||||
for provider in LLMProvider.asrSelectablePresets {
|
||||
guard let local = readASRKey(providerId: provider.id, synchronizable: false), !local.isEmpty else {
|
||||
continue
|
||||
}
|
||||
try? writeASRKey(local, providerId: provider.id, synchronizable: true)
|
||||
try? deleteASRKey(providerId: provider.id, synchronizable: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding completion (reboot-durable flag)
|
||||
|
||||
+14
-3
@@ -26,8 +26,12 @@ public final class PersonalDictionaryCloudSync {
|
||||
|
||||
public static let kvsKey = PersonalDictionary.kvsKeyV2
|
||||
public static let legacyKVSKey = PersonalDictionary.legacyKVSKey
|
||||
/// Stay below the ~1 MB per-key KVS limit.
|
||||
public static let maxPayloadBytes = 900_000
|
||||
/// The 1 MB iCloud KVS quota covers the WHOLE store, not one key —
|
||||
/// this payload shares it with speech history, settings, and usage
|
||||
/// stats. Exceeding the total quota makes KVS reject writes for ALL
|
||||
/// keys (`QuotaViolation`), silently stopping every sync.
|
||||
/// Budget: ~400 KB dictionary + ~400 KB history + headroom.
|
||||
public static let maxPayloadBytes = 400_000
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
@@ -75,7 +79,14 @@ public final class PersonalDictionaryCloudSync {
|
||||
public func pushLocalIfEnabled(_ dictionary: PersonalDictionary) async throws {
|
||||
let store = makeStore()
|
||||
guard store.personalDictionaryICloudSyncEnabled else { return }
|
||||
try push(dictionary)
|
||||
// Read-merge-write: KVS is last-writer-wins; uploading the local
|
||||
// view verbatim would drop entries another device added since our
|
||||
// last pull. Tombstones in `merge` keep deletions intact.
|
||||
let merged = loadRemote().map { PersonalDictionary.merge(local: dictionary, remote: $0) } ?? dictionary
|
||||
if merged != dictionary {
|
||||
store.setPersonalDictionary(merged)
|
||||
}
|
||||
try push(merged)
|
||||
}
|
||||
|
||||
/// Enable sync: merge local + remote, persist locally, then upload.
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
// English dictation while halving the network round-trip.
|
||||
//
|
||||
// Engine matrix:
|
||||
// - `engineMode == "cloud"` → provider cloud ASR + user's cloud LLM
|
||||
// - `engineMode == "local"` → on-device ASR + built-in DeepSeek
|
||||
// - `engineMode == "cloud"` → user's cloud ASR + user's cloud LLM (independent)
|
||||
// - `engineMode == "local"` → on-device ASR + user's LLM (or built-in DeepSeek)
|
||||
// - Ultra-short, structure-free utterances skip the LLM entirely
|
||||
// - Cloud without API key → raw + `.missingAPIKey` warning
|
||||
// - Local without build key → raw + `.missingAPIKey` warning
|
||||
@@ -37,6 +37,10 @@ public actor PolishingService {
|
||||
/// Local engine DeepSeek step: `PreconfiguredKeys.deepseek` is
|
||||
/// still the repo placeholder, or cloud engine Keychain is empty.
|
||||
case missingAPIKey
|
||||
/// The keychain was unreadable (device locked before first unlock)
|
||||
/// — the key likely EXISTS; treat as transient, never as "please
|
||||
/// re-enter your API key".
|
||||
case keychainLocked
|
||||
}
|
||||
|
||||
/// v0.2.1: what the LLM should do with the raw transcript. The
|
||||
@@ -95,8 +99,13 @@ public actor PolishingService {
|
||||
return TranscriptPostProcessor.localClean(trimmed)
|
||||
}
|
||||
|
||||
if store.engineMode == "cloud", injectedClient == nil {
|
||||
guard !store.apiKey.isEmpty else {
|
||||
if injectedClient == nil {
|
||||
let providerId = Self.resolvedProviderId(store: store, providerIdOverride: providerIdOverride)
|
||||
let hasPolishKey = Self.hasPolishAPIKey(store: store, providerId: providerId)
|
||||
guard hasPolishKey else {
|
||||
if case .unavailable = Keychain.apiKeyOutcome(for: providerId, preferICloudSync: true) {
|
||||
throw PolishError.keychainLocked
|
||||
}
|
||||
throw PolishError.missingAPIKey
|
||||
}
|
||||
}
|
||||
@@ -150,10 +159,14 @@ public actor PolishingService {
|
||||
)
|
||||
let apiKey: String
|
||||
if effectiveProviderId == "deepseek" {
|
||||
guard PreconfiguredKeys.isDeepseekConfigured else {
|
||||
let userKey = store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !userKey.isEmpty {
|
||||
apiKey = userKey
|
||||
} else if PreconfiguredKeys.isDeepseekConfigured {
|
||||
apiKey = PreconfiguredKeys.deepseek
|
||||
} else {
|
||||
throw PolishError.missingAPIKey
|
||||
}
|
||||
apiKey = PreconfiguredKeys.deepseek
|
||||
} else {
|
||||
apiKey = store.apiKey
|
||||
}
|
||||
@@ -356,7 +369,11 @@ public actor PolishingService {
|
||||
/// (unpolished, unsegmented) ASR text.
|
||||
internal func effectiveTimeout(for text: String) -> TimeInterval {
|
||||
let scaled = timeout + (Double(text.count) / 100.0) * 10.0
|
||||
return min(max(scaled, timeout), 120)
|
||||
// The cap participates in the keyboard-watchdog budget — see
|
||||
// `FlowSessionKeys.keyboardResultTimeout`. Raising it here without
|
||||
// going through that constant would silently break the invariant
|
||||
// "keyboard timeout > host worst case".
|
||||
return min(max(scaled, timeout), FlowSessionKeys.maxPolishTimeout)
|
||||
}
|
||||
|
||||
internal static func resolvedProviderId(
|
||||
@@ -366,11 +383,25 @@ public actor PolishingService {
|
||||
if let providerIdOverride {
|
||||
return providerIdOverride
|
||||
}
|
||||
if store.engineMode == "local" {
|
||||
let id = store.providerId
|
||||
// Local installs without a user LLM key keep using the built-in DeepSeek path.
|
||||
if store.engineMode == "local",
|
||||
id != "deepseek",
|
||||
store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
PreconfiguredKeys.isDeepseekConfigured {
|
||||
return "deepseek"
|
||||
}
|
||||
let id = store.providerId
|
||||
return id == "deepseek" ? "openai" : id
|
||||
return id == "deepseek" && store.engineMode == "cloud" ? "openai" : id
|
||||
}
|
||||
|
||||
internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool {
|
||||
if !store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return true
|
||||
}
|
||||
if providerId == "deepseek", PreconfiguredKeys.isDeepseekConfigured {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
internal static func resolveLLMEndpoint(
|
||||
@@ -396,6 +427,8 @@ extension PolishingService.PolishError: LocalizedError {
|
||||
return "LLM polish timed out."
|
||||
case .missingAPIKey:
|
||||
return "Missing API key (cloud: Settings API key; local: build configuration)."
|
||||
case .keychainLocked:
|
||||
return "API key unavailable while the device is locked — will work after unlock."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ public final class SpeechHistoryStore: ObservableObject {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
rebaseOnPersistedStateBeforeMutation()
|
||||
let entry = SpeechHistoryEntry(text: trimmed, engineMode: engineMode)
|
||||
payload.entries.insert(entry, at: 0)
|
||||
payload.trimEntries()
|
||||
@@ -42,6 +43,7 @@ public final class SpeechHistoryStore: ObservableObject {
|
||||
}
|
||||
|
||||
public func delete(id: UUID) {
|
||||
rebaseOnPersistedStateBeforeMutation()
|
||||
guard payload.entries.contains(where: { $0.id == id }) else { return }
|
||||
payload.deletedEntryIDs[id] = Date()
|
||||
payload.entries.removeAll { $0.id == id }
|
||||
@@ -51,12 +53,24 @@ public final class SpeechHistoryStore: ObservableObject {
|
||||
}
|
||||
|
||||
public func clearAll() {
|
||||
rebaseOnPersistedStateBeforeMutation()
|
||||
payload.recordClearAll()
|
||||
payload.updatedAt = Date()
|
||||
payload.pruneTombstonesIfNeeded()
|
||||
applyPayload(postCloudPush: true)
|
||||
}
|
||||
|
||||
/// Cloud pulls write the merged history to disk but only *schedule* the
|
||||
/// in-memory reload (the notification observer hops through a Task).
|
||||
/// Mutating a stale snapshot and saving it wholesale would erase whatever
|
||||
/// that merge just brought in — always rebase on the persisted state
|
||||
/// before mutating.
|
||||
private func rebaseOnPersistedStateBeforeMutation() {
|
||||
let disk = SpeechHistoryStorage.load(from: defaults)
|
||||
guard disk != payload else { return }
|
||||
payload = SyncedSpeechHistory.merge(local: payload, remote: disk)
|
||||
}
|
||||
|
||||
public func snapshot() -> SyncedSpeechHistory {
|
||||
payload
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// TranscriptionPolishFallback.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Shared polish-failure handling: conservative raw ASR cleanup plus
|
||||
// bilingual user-visible warnings (iOS Flow + macOS dictation).
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TranscriptionPolishFallback: Sendable {
|
||||
|
||||
public static func makeDelivery(
|
||||
rawText: String,
|
||||
error: Error,
|
||||
engineMode: String,
|
||||
chunkWarning: String?
|
||||
) -> TranscriptionDelivery {
|
||||
let fallbackText = TranscriptPostProcessor.cleanRawASRFallback(rawText)
|
||||
let warning = warning(for: error, engineMode: engineMode)
|
||||
?? degradedWarning()
|
||||
?? chunkWarning
|
||||
return TranscriptionDelivery(text: fallbackText, polishWarning: warning)
|
||||
}
|
||||
|
||||
public static func warning(for error: Error, engineMode: String) -> String? {
|
||||
if let polishError = error as? PolishingService.PolishError {
|
||||
switch polishError {
|
||||
case .missingAPIKey:
|
||||
if engineMode == "local" {
|
||||
return SharedL10n.string("flow.warning.localPolishUnavailable")
|
||||
}
|
||||
return SharedL10n.string("flow.warning.cloudPolishMissingKey")
|
||||
case .timeout, .keychainLocked:
|
||||
return degradedWarning()
|
||||
case .noTranscript:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if error is LLMError {
|
||||
return degradedWarning()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func degradedWarning() -> String? {
|
||||
SharedL10n.string("flow.warning.polishDegraded")
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,27 @@ public enum DictationTextComposer {
|
||||
return isCJK(last) && isCJK(first)
|
||||
}
|
||||
|
||||
/// Separator to place between existing document text and an inserted
|
||||
/// transcript. Inserting at a cursor that sits right after "Hello" must
|
||||
/// produce "Hello world", not "Helloworld" — but CJK, whitespace, and
|
||||
/// opening-punctuation boundaries take no space.
|
||||
public static func insertionSeparator(previousContext: String?, insertion: String) -> String {
|
||||
guard let previousContext,
|
||||
let last = previousContext.unicodeScalars.last,
|
||||
let first = insertion.unicodeScalars.first else {
|
||||
return ""
|
||||
}
|
||||
if CharacterSet.whitespacesAndNewlines.contains(last) { return "" }
|
||||
if isCJK(last) || isCJK(first) { return "" }
|
||||
// No space after opening brackets/quotes ("(", "[", "「", """…).
|
||||
if CharacterSet(charactersIn: "([{\u{201C}\u{2018}\u{300C}\u{300E}\u{3010}\u{FF08}").contains(last) {
|
||||
return ""
|
||||
}
|
||||
// No space before closing/clause punctuation (".", ",", ")", "!"…).
|
||||
if CharacterSet.punctuationCharacters.contains(first) { return "" }
|
||||
return " "
|
||||
}
|
||||
|
||||
static func normalizeForOverlap(_ text: String) -> String {
|
||||
text.unicodeScalars.filter {
|
||||
!CharacterSet.whitespacesAndNewlines.contains($0)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// FlowDebugPanel.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// TEMPORARY debug overlay for cross-process Flow state. Remove after the
|
||||
// orange-mic investigation. Shows the same App Group contract fields on both
|
||||
// the host app and the keyboard extension so we can see where they diverge.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// One labeled row in the temporary Flow debug panel.
|
||||
public struct FlowDebugRow: Equatable, Sendable {
|
||||
public let label: String
|
||||
public let value: String
|
||||
|
||||
public init(_ label: String, _ value: String) {
|
||||
self.label = label
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the App Group half of the debug snapshot (readable from both processes).
|
||||
public enum FlowDebugAppGroupSnapshot {
|
||||
public static func rows(defaults: UserDefaults? = nil) -> [FlowDebugRow] {
|
||||
FlowSessionBridge.reloadFromDisk(defaults: defaults)
|
||||
let snapshot = FlowSessionBridge.readySnapshot(defaults: defaults)
|
||||
let staleness = FlowSessionBridge.heartbeatStaleness(defaults: defaults)
|
||||
let generation = FlowSessionBridge.currentHostGeneration(defaults: defaults)
|
||||
let shortGen: String = {
|
||||
guard let generation, generation.count >= 8 else { return generation ?? "nil" }
|
||||
return String(generation.prefix(8))
|
||||
}()
|
||||
let snapGen: String = {
|
||||
guard let g = snapshot?.hostGeneration, g.count >= 8 else {
|
||||
return snapshot?.hostGeneration ?? "nil"
|
||||
}
|
||||
return String(g.prefix(8))
|
||||
}()
|
||||
let expires: String = {
|
||||
guard let ts = FlowSessionBridge.sessionExpiresAt(defaults: defaults) else { return "nil" }
|
||||
let remaining = ts - Date().timeIntervalSince1970
|
||||
return String(format: "%.0fs", remaining)
|
||||
}()
|
||||
|
||||
return [
|
||||
FlowDebugRow("sessionActive", FlowSessionBridge.isSessionActive(defaults: defaults) ? "1" : "0"),
|
||||
FlowDebugRow("expiresIn", expires),
|
||||
FlowDebugRow("hostReachable", FlowSessionBridge.isHostReachable(defaults: defaults) ? "1" : "0"),
|
||||
FlowDebugRow("hostReady", FlowSessionBridge.isHostReady(defaults: defaults) ? "1" : "0"),
|
||||
FlowDebugRow("hostStale", FlowSessionBridge.isHostStale(defaults: defaults) ? "1" : "0"),
|
||||
FlowDebugRow("hbStale", staleness.map { String(format: "%.1fs", $0) } ?? "nil"),
|
||||
FlowDebugRow("snap.ready", snapshot.map { $0.ready ? "1" : "0" } ?? "nil"),
|
||||
FlowDebugRow("snap.reason", snapshot?.reason.rawValue ?? "nil"),
|
||||
FlowDebugRow("snap.session", shortUUID(snapshot?.sessionId)),
|
||||
FlowDebugRow("gen.now", shortGen),
|
||||
FlowDebugRow("gen.snap", snapGen),
|
||||
FlowDebugRow("gen.match", {
|
||||
guard let a = snapshot?.hostGeneration,
|
||||
let b = generation else { return "n/a" }
|
||||
return a == b ? "1" : "0"
|
||||
}()),
|
||||
FlowDebugRow("pendingHost", FlowSessionBridge.pendingHostBundleId(defaults: defaults) ?? "nil"),
|
||||
FlowDebugRow("recState", FlowSessionBridge.recordingState(defaults: defaults).rawValue),
|
||||
FlowDebugRow("appGroup", AppGroup.isAvailable ? "1" : "0")
|
||||
]
|
||||
}
|
||||
|
||||
private static func shortUUID(_ id: UUID?) -> String {
|
||||
guard let id else { return "nil" }
|
||||
return String(id.uuidString.prefix(8))
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapsible monospaced status panel. Temporary — for investigation only.
|
||||
public struct FlowDebugPanel: View {
|
||||
public let title: String
|
||||
public let rows: [FlowDebugRow]
|
||||
@Binding public var isExpanded: Bool
|
||||
public var maxContentHeight: CGFloat
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
rows: [FlowDebugRow],
|
||||
isExpanded: Binding<Bool>,
|
||||
maxContentHeight: CGFloat = 180
|
||||
) {
|
||||
self.title = title
|
||||
self.rows = rows
|
||||
self._isExpanded = isExpanded
|
||||
self.maxContentHeight = maxContentHeight
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Button {
|
||||
isExpanded.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(isExpanded ? "▼" : "▶")
|
||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||
Text(title)
|
||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||
Spacer(minLength: 0)
|
||||
Text(summaryChip)
|
||||
.font(.system(size: 10, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(summaryColor)
|
||||
}
|
||||
.foregroundStyle(Color.primary)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text(row.label)
|
||||
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(Color.secondary)
|
||||
.frame(width: 92, alignment: .leading)
|
||||
Text(row.value)
|
||||
.font(.system(size: 10, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(Color.primary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: maxContentHeight)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(Color.orange.opacity(0.7), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var summaryChip: String {
|
||||
let hostReady = rows.first(where: { $0.label == "hostReady" })?.value
|
||||
?? rows.first(where: { $0.label == "bridgeReady" })?.value
|
||||
?? "?"
|
||||
let mic = rows.first(where: { $0.label == "mic" })?.value
|
||||
if let mic {
|
||||
return "mic=\(shortMic(mic)) hr=\(hostReady)"
|
||||
}
|
||||
let active = rows.first(where: { $0.label == "isActive" })?.value ?? "?"
|
||||
return "active=\(active) hr=\(hostReady)"
|
||||
}
|
||||
|
||||
private var summaryColor: Color {
|
||||
let hostReady = rows.first(where: { $0.label == "hostReady" })?.value
|
||||
?? rows.first(where: { $0.label == "bridgeReady" })?.value
|
||||
if hostReady == "1" { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
private func shortMic(_ value: String) -> String {
|
||||
if value.hasPrefix("ready") { return "ready" }
|
||||
if value.contains("preparing") { return "prep" }
|
||||
if value.contains("hostNotReady") { return "notReady" }
|
||||
if value.contains("recording") { return "rec" }
|
||||
if value.contains("processing") { return "proc" }
|
||||
if value.contains("noFullAccess") { return "noFA" }
|
||||
if value.contains("appGroup") { return "noAG" }
|
||||
if value.contains("missingAPIKey") { return "noKey" }
|
||||
return String(value.prefix(12))
|
||||
}
|
||||
}
|
||||
@@ -101,18 +101,25 @@
|
||||
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
|
||||
|
||||
/* macOS app */
|
||||
"mac.section.dashboard" = "Dashboard";
|
||||
"mac.section.dashboard" = "Home";
|
||||
"mac.section.history" = "History";
|
||||
"mac.section.dictionary" = "Personal Dictionary";
|
||||
"mac.section.dictionary" = "Dictionary";
|
||||
"mac.section.settings" = "Settings";
|
||||
"mac.brand.subtitle" = "AI DICTATION";
|
||||
"mac.brand.tagline" = "Speak it. It’s typed.";
|
||||
"mac.brand.tagline.subtitle" = "Local-first · Cross-device · One-tap dictation";
|
||||
"mac.devices" = "Devices";
|
||||
"mac.status.ready" = "Ready to dictate…";
|
||||
"mac.page.history.subtitle" = "Every dictation, kept in order.";
|
||||
"mac.page.dictionary.subtitle" = "Words that teach recognition your voice.";
|
||||
"mac.page.settings.subtitle" = "Engine, shortcuts, and appearance.";
|
||||
"mac.status.ready" = "Ready when you are…";
|
||||
"mac.status.listening" = "Listening…";
|
||||
"mac.status.transcribing" = "Transcribing…";
|
||||
"mac.status.polishing" = "Polishing…";
|
||||
"mac.status.copied" = "Copied to clipboard";
|
||||
"mac.status.pasted" = "Inserted into front app";
|
||||
"mac.status.copiedAndPasted" = "Copied and inserted";
|
||||
"mac.status.deliveryWithNote" = "%@ — %@";
|
||||
"mac.stat.dictationTime" = "Dictation Time";
|
||||
"mac.stat.words" = "Dictation Chars";
|
||||
"mac.stat.translation" = "Translation Chars";
|
||||
@@ -123,6 +130,12 @@
|
||||
"mac.stat.customTerms" = "Custom terms";
|
||||
"mac.status.chipReady" = "Ready";
|
||||
"mac.status.chipProcessing" = "Processing";
|
||||
"mac.overlay.listening" = "Listening";
|
||||
"mac.overlay.preparing" = "Starting…";
|
||||
"mac.overlay.transcribing" = "Transcribing";
|
||||
"mac.overlay.polishing" = "Polishing";
|
||||
"mac.overlay.live" = "Live";
|
||||
"mac.overlay.done" = "Done";
|
||||
"mac.record.start" = "Record";
|
||||
"mac.record.stop" = "Stop";
|
||||
"mac.record.pressStop" = "Press Stop";
|
||||
@@ -133,8 +146,8 @@
|
||||
"mac.mode.local" = "Local Mode";
|
||||
"mac.connected" = "Connected";
|
||||
"mac.offline" = "Offline";
|
||||
"mac.history.recent" = "Recent";
|
||||
"mac.history.empty" = "No voice transcripts yet.";
|
||||
"mac.history.empty" = "No dictations yet";
|
||||
"mac.history.emptyBody" = "Hold Option anywhere to speak — transcripts land here.";
|
||||
"mac.history.select" = "Select a dictation";
|
||||
"mac.history.clearTitle" = "Clear all history?";
|
||||
"mac.history.clearMessage" = "This cannot be undone.";
|
||||
@@ -151,6 +164,12 @@
|
||||
"mac.dict.deleteMessage" = "This cannot be undone.";
|
||||
"mac.hint.holdOption" = "Hold Option to dictate";
|
||||
"mac.settings.cloudProvider" = "CLOUD PROVIDER";
|
||||
"mac.settings.polishProvider" = "TEXT POLISH (LLM)";
|
||||
"mac.settings.asrProvider" = "SPEECH RECOGNITION (ASR)";
|
||||
"mac.settings.asrService" = "ASR service";
|
||||
"mac.settings.asrApiKey" = "ASR API key";
|
||||
"mac.settings.asrModel" = "ASR model";
|
||||
"mac.settings.baseURL" = "Base URL";
|
||||
"mac.settings.service" = "Service";
|
||||
"mac.settings.apiKey" = "API Key";
|
||||
"mac.settings.model" = "Model";
|
||||
@@ -174,6 +193,15 @@
|
||||
"mac.settings.autoPasteDesc" = "Simulate ⌘V in the front app after transcription (requires Accessibility).";
|
||||
"mac.settings.hotkey" = "Global shortcut";
|
||||
"mac.settings.hotkeyDesc" = "Hold Option (⌥) to dictate from any app.";
|
||||
"mac.settings.hotkeyTrigger" = "Shortcut key";
|
||||
"mac.settings.hotkeyTriggerDesc" = "Which Option (⌥) key starts dictation when held.";
|
||||
"mac.hotkeyTrigger.rightOption" = "Right Option (⌥)";
|
||||
"mac.hotkeyTrigger.leftOption" = "Left Option (⌥)";
|
||||
"mac.hotkeyTrigger.eitherOption" = "Either Option key";
|
||||
"mac.hint.hold.rightOption" = "Hold right Option (⌥) to dictate";
|
||||
"mac.hint.hold.leftOption" = "Hold left Option (⌥) to dictate";
|
||||
"mac.hint.hold.eitherOption" = "Hold either Option (⌥) to dictate";
|
||||
"mac.hint.holdOption" = "Hold Option to dictate";
|
||||
"mac.settings.qwen3Model" = "Qwen3 model folder";
|
||||
"mac.settings.qwen3ModelDesc" = "Folder with config.json, model.safetensors, vocab.json, and merges.txt.";
|
||||
"mac.settings.qwen3Browse" = "Choose folder…";
|
||||
@@ -241,6 +269,9 @@
|
||||
"mac.localASR.installed" = "Installed";
|
||||
"mac.localASR.notInstalled" = "Not installed";
|
||||
"mac.localASR.personalDictionaryTag" = "Personal dictionary";
|
||||
"mac.localASR.badge.fastest" = "Fastest";
|
||||
"mac.localASR.badge.balanced" = "Most balanced";
|
||||
"mac.localASR.badge.quality" = "Best quality";
|
||||
"mac.localASR.hotwordsYes" = "Hotwords";
|
||||
"mac.localASR.hotwordsNo" = "No hotwords";
|
||||
"mac.localASR.catalogMissing" = "Local ASR catalog is missing from the app bundle.";
|
||||
|
||||
@@ -101,18 +101,25 @@
|
||||
"keyboard.translation.a11yHint" = "切换翻译或更改目标语言。";
|
||||
|
||||
/* macOS 应用 */
|
||||
"mac.section.dashboard" = "仪表盘";
|
||||
"mac.section.dashboard" = "首页";
|
||||
"mac.section.history" = "历史";
|
||||
"mac.section.dictionary" = "个性词库";
|
||||
"mac.section.dictionary" = "词库";
|
||||
"mac.section.settings" = "设置";
|
||||
"mac.brand.subtitle" = "AI 听写";
|
||||
"mac.brand.tagline" = "开口即文字。";
|
||||
"mac.brand.tagline.subtitle" = "本地优先 · 跨端同步 · 一键听写";
|
||||
"mac.devices" = "设备";
|
||||
"mac.status.ready" = "准备听写…";
|
||||
"mac.status.listening" = "录音中…";
|
||||
"mac.page.history.subtitle" = "每一次听写,按时间妥善保存。";
|
||||
"mac.page.dictionary.subtitle" = "让识别更懂你的用词。";
|
||||
"mac.page.settings.subtitle" = "引擎、快捷键与外观。";
|
||||
"mac.status.ready" = "准备好了,随时开口…";
|
||||
"mac.status.listening" = "正在聆听…";
|
||||
"mac.status.transcribing" = "识别中…";
|
||||
"mac.status.polishing" = "润色中…";
|
||||
"mac.status.copied" = "已复制到剪贴板";
|
||||
"mac.status.pasted" = "已插入前台应用";
|
||||
"mac.status.copiedAndPasted" = "已复制并插入";
|
||||
"mac.status.deliveryWithNote" = "%@ — %@";
|
||||
"mac.stat.dictationTime" = "听写时长";
|
||||
"mac.stat.words" = "听写字数";
|
||||
"mac.stat.translation" = "翻译字数";
|
||||
@@ -123,6 +130,12 @@
|
||||
"mac.stat.customTerms" = "自定义词条";
|
||||
"mac.status.chipReady" = "就绪";
|
||||
"mac.status.chipProcessing" = "处理中";
|
||||
"mac.overlay.listening" = "聆听中";
|
||||
"mac.overlay.preparing" = "启动中…";
|
||||
"mac.overlay.transcribing" = "识别中";
|
||||
"mac.overlay.polishing" = "润色中";
|
||||
"mac.overlay.live" = "实时";
|
||||
"mac.overlay.done" = "已完成";
|
||||
"mac.record.start" = "开始录音";
|
||||
"mac.record.stop" = "停止";
|
||||
"mac.record.pressStop" = "点击停止";
|
||||
@@ -133,8 +146,8 @@
|
||||
"mac.mode.local" = "本地模式";
|
||||
"mac.connected" = "已连接";
|
||||
"mac.offline" = "离线";
|
||||
"mac.history.recent" = "最近";
|
||||
"mac.history.empty" = "还没有语音识别记录。";
|
||||
"mac.history.empty" = "还没有听写记录";
|
||||
"mac.history.emptyBody" = "在任意应用按住 Option 开口说话,记录会出现在这里。";
|
||||
"mac.history.select" = "选择一条记录";
|
||||
"mac.history.clearTitle" = "清空全部历史?";
|
||||
"mac.history.clearMessage" = "此操作无法撤销。";
|
||||
@@ -151,6 +164,12 @@
|
||||
"mac.dict.deleteMessage" = "此操作无法撤销。";
|
||||
"mac.hint.holdOption" = "长按 Option 开始听写";
|
||||
"mac.settings.cloudProvider" = "云端服务商";
|
||||
"mac.settings.polishProvider" = "文本润色(LLM)";
|
||||
"mac.settings.asrProvider" = "语音转写(ASR)";
|
||||
"mac.settings.asrService" = "转写服务";
|
||||
"mac.settings.asrApiKey" = "转写 API 密钥";
|
||||
"mac.settings.asrModel" = "转写模型";
|
||||
"mac.settings.baseURL" = "接口地址";
|
||||
"mac.settings.service" = "服务商";
|
||||
"mac.settings.apiKey" = "API 密钥";
|
||||
"mac.settings.model" = "模型";
|
||||
@@ -174,6 +193,15 @@
|
||||
"mac.settings.autoPasteDesc" = "转写完成后向前台应用模拟 ⌘V(需辅助功能权限)。";
|
||||
"mac.settings.hotkey" = "全局快捷键";
|
||||
"mac.settings.hotkeyDesc" = "按住 Option (⌥) 键即可从任意应用开始听写。";
|
||||
"mac.settings.hotkeyTrigger" = "快捷键按键";
|
||||
"mac.settings.hotkeyTriggerDesc" = "按住哪个 Option (⌥) 键开始听写。";
|
||||
"mac.hotkeyTrigger.rightOption" = "右 Option (⌥)";
|
||||
"mac.hotkeyTrigger.leftOption" = "左 Option (⌥)";
|
||||
"mac.hotkeyTrigger.eitherOption" = "任一 Option 键";
|
||||
"mac.hint.hold.rightOption" = "长按右 Option(⌥)开始听写";
|
||||
"mac.hint.hold.leftOption" = "长按左 Option(⌥)开始听写";
|
||||
"mac.hint.hold.eitherOption" = "长按任一 Option(⌥)开始听写";
|
||||
"mac.hint.holdOption" = "长按 Option 开始听写";
|
||||
"mac.settings.qwen3Model" = "Qwen3 模型目录";
|
||||
"mac.settings.qwen3ModelDesc" = "需包含 config.json、model.safetensors、vocab.json 与 merges.txt。";
|
||||
"mac.settings.qwen3Browse" = "选择文件夹…";
|
||||
@@ -241,6 +269,9 @@
|
||||
"mac.localASR.installed" = "已安装";
|
||||
"mac.localASR.notInstalled" = "未安装";
|
||||
"mac.localASR.personalDictionaryTag" = "个性词库";
|
||||
"mac.localASR.badge.fastest" = "速度最快";
|
||||
"mac.localASR.badge.balanced" = "最平衡";
|
||||
"mac.localASR.badge.quality" = "质量最好";
|
||||
"mac.localASR.hotwordsYes" = "支持热词";
|
||||
"mac.localASR.hotwordsNo" = "无热词";
|
||||
"mac.localASR.catalogMissing" = "应用包内缺少本地 ASR 模型目录。";
|
||||
|
||||
Reference in New Issue
Block a user