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:
Rocky
2026-07-10 12:39:41 +08:00
parent dcb66a9849
commit cdf833935a
104 changed files with 5794 additions and 853 deletions
@@ -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 560 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? {
+104 -11
View File
@@ -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(&copy.providerId, value: configuration.providerId)
patch(&copy.baseURL, value: configuration.baseURL)
patch(&copy.model, value: configuration.model)
patch(&copy.asrProviderId, value: configuration.asrProviderId)
patch(&copy.asrBaseURL, value: configuration.asrBaseURL)
patch(&copy.asrModel, value: configuration.asrModel)
patch(&copy.modeId, value: configuration.modeId)
patch(&copy.localeId, value: configuration.localeId)
patch(&copy.engineMode, value: configuration.engineMode)
@@ -221,6 +323,9 @@ public extension SyncedAppSettingsV2 {
touch(&copy.providerId, value: configuration.providerId)
touch(&copy.baseURL, value: configuration.baseURL)
touch(&copy.model, value: configuration.model)
touch(&copy.asrProviderId, value: configuration.asrProviderId)
touch(&copy.asrBaseURL, value: configuration.asrBaseURL)
touch(&copy.asrModel, value: configuration.asrModel)
touch(&copy.modeId, value: configuration.modeId)
touch(&copy.localeId, value: configuration.localeId)
touch(&copy.engineMode, value: configuration.engineMode)
+28 -3
View File
@@ -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() {}
}
+1 -7
View File
@@ -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.
+45 -2
View File
@@ -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 = {}
+192 -8
View File
@@ -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)
@@ -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))
}
}
+36 -5
View File
@@ -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. Its 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.";
+37 -6
View File
@@ -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 模型目录。";