feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish

Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
Rocky
2026-08-13 01:00:51 +08:00
parent fd6e0d3e7e
commit 9f308fadd2
202 changed files with 10897 additions and 5962 deletions
+164
View File
@@ -0,0 +1,164 @@
// AIHintModels.swift
// OSGKeyboard · Shared
//
// Hint cards for the AI-mode idle carousel. Remote packs use `text`; the
// host compresses that into `displayText` before writing the ready pack.
import Foundation
public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
public let id: String
/// One-line carousel label (after host keyword pass, or local catalog).
public var displayText: String
/// Full user message sent to the AI question LLM on tap.
public var prompt: String
public var category: String
public var priority: Int
public var source: String
public var locale: String
public var conditions: [String]
public init(
id: String,
displayText: String,
prompt: String,
category: String,
priority: Int = 50,
source: String = "local",
locale: String = "zh",
conditions: [String] = []
) {
self.id = id
self.displayText = displayText
self.prompt = prompt
self.category = category
self.priority = priority
self.source = source
self.locale = locale
self.conditions = conditions
}
public var requiresClipboard30s: Bool {
conditions.contains("clipboard_30s") || category == "clipboard"
}
enum CodingKeys: String, CodingKey {
case id, displayText, text, prompt, category, priority, source, locale, conditions
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
prompt = try container.decode(String.self, forKey: .prompt)
category = try container.decodeIfPresent(String.self, forKey: .category) ?? "general"
priority = try container.decodeIfPresent(Int.self, forKey: .priority) ?? 50
source = try container.decodeIfPresent(String.self, forKey: .source) ?? "remote"
locale = try container.decodeIfPresent(String.self, forKey: .locale) ?? "zh"
conditions = try container.decodeIfPresent([String].self, forKey: .conditions) ?? []
if let display = try container.decodeIfPresent(String.self, forKey: .displayText),
!display.isEmpty {
displayText = display
} else {
displayText = try container.decodeIfPresent(String.self, forKey: .text) ?? ""
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(displayText, forKey: .displayText)
try container.encode(prompt, forKey: .prompt)
try container.encode(category, forKey: .category)
try container.encode(priority, forKey: .priority)
try container.encode(source, forKey: .source)
try container.encode(locale, forKey: .locale)
try container.encode(conditions, forKey: .conditions)
}
}
public struct AIHintPack: Codable, Equatable, Sendable {
public var locale: String
public var generatedAt: String?
public var expiresAt: String?
public var version: Int
public var cards: [AIHintCard]
/// Wall-clock when the host last successfully wrote this ready pack.
public var refreshedAt: Date?
public init(
locale: String,
generatedAt: String? = nil,
expiresAt: String? = nil,
version: Int = 1,
cards: [AIHintCard] = [],
refreshedAt: Date? = nil
) {
self.locale = locale
self.generatedAt = generatedAt
self.expiresAt = expiresAt
self.version = version
self.cards = cards
self.refreshedAt = refreshedAt
}
}
public struct AIHintManifest: Codable, Equatable, Sendable {
public var generatedAt: String?
public var expiresAt: String?
public var intervalHours: Int?
public var locales: [String]?
public var files: [String: String?]?
public init(
generatedAt: String? = nil,
expiresAt: String? = nil,
intervalHours: Int? = nil,
locales: [String]? = nil,
files: [String: String?]? = nil
) {
self.generatedAt = generatedAt
self.expiresAt = expiresAt
self.intervalHours = intervalHours
self.locales = locales
self.files = files
}
}
public enum AIHintFeedEndpoints {
public static let baseURL = URL(string: "https://key.osglab.com/hints")!
public static let manifestURL = baseURL.appendingPathComponent("manifest.json")
/// Packs the app fetches and the keyboard can resolve.
public static let supportedLocales = ["zh", "en"]
public static func packURL(locale: String) -> URL {
baseURL.appendingPathComponent("hints-\(locale).json")
}
}
public enum AIHintLocaleResolver {
/// Only `zh-Hans` uses the Chinese pack; everything else uses English.
public static func packLocale(
preferredLanguages: [String] = Locale.preferredLanguages
) -> String {
let primary = (preferredLanguages.first ?? "").lowercased()
if primary == "zh-hans" || primary.hasPrefix("zh-hans-") || primary.hasPrefix("zh-hans_") {
return "zh"
}
return "en"
}
}
public enum AIHintAppGroupKeys {
public static let readyPackPrefix = "hints.ready."
public static let lastSuccessPrefix = "hints.meta.lastSuccessAt."
public static let lastAttemptAt = "hints.meta.lastAttemptAt"
public static func readyPackKey(locale: String) -> String {
readyPackPrefix + locale
}
/// Freshness is tracked per locale so a zh success cannot mask an en failure.
public static func lastSuccessKey(locale: String) -> String {
lastSuccessPrefix + locale
}
}
@@ -49,7 +49,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let polishStyleCatalog = "config.polishStyles.v1"
public static let activePolishStyleId = "config.activePolishStyleId"
public static let polishStylesMigrated = "config.polishStyles.migrated"
/// Keys used by the removed pre-v0.3 manual scenario implementation.
/// Legacy keys from the removed manual scenario implementation.
public static let legacyPolishScenarioId = "config.polishScenarioId"
public static let legacySystemPrompt = "config.systemPrompt"
/// When true, the main app mirrors the personal dictionary via iCloud KVS.
@@ -230,7 +230,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
return load(fromAvailable: store)
}
/// Loads configuration from a known-available UserDefaults suite.
/// Loads and idempotently migrates a known-available suite; this is not a
/// pure read. Missing defaults distinguish upgrades (legacy cloud engine)
/// from fresh installs (local engine) before being persisted.
public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration {
let storedProviderId = defaults.string(forKey: Keys.providerId)
var config = AppGroupConfiguration(
@@ -336,6 +338,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
// Legacy qwen cloud ASR bailian realtime (HTTP Flash path removed).
if config.asrProviderId == "qwen" {
do {
try Keychain.copyQwenASRKeyToBailian(
useICloudSync: config.settingsICloudSyncEnabled
)
} catch {
OSGLog.config.warning(
"qwen ASR credential migration deferred: \(String(describing: error), privacy: .public)"
)
}
let bailian = LLMProvider.provider(id: "bailian")
config.asrProviderId = "bailian"
config.asrBaseURL = bailian.defaultBaseURL
@@ -508,7 +519,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
}
/// Read the API key from the Keychain, falling back to a one-time migration from UserDefaults.
/// Resolves the provider-scoped Keychain item, then the legacy `current`
/// account, then plaintext defaults. Legacy sources are removed after the
/// selected local or synchronizable target is read back exactly.
static func resolveAPIKey(
defaults: UserDefaults?,
providerId: String,
@@ -518,20 +531,40 @@ public struct AppGroupConfiguration: Sendable, Equatable {
return stored
}
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
try? Keychain.setAPIKey(legacyKeychain, for: providerId, useICloudSync: preferICloudSync)
try? Keychain.deleteLegacyAPIKey()
do {
try Keychain.migrateLegacyAPIKey(
to: providerId,
useICloudSync: preferICloudSync
)
} catch {
OSGLog.config.warning(
"legacy Keychain credential migration deferred: \(String(describing: error), privacy: .public)"
)
}
return legacyKeychain
}
if let defaults,
let legacy = defaults.string(forKey: Keys.apiKeyLegacy),
!legacy.isEmpty {
try? Keychain.setAPIKey(legacy, for: providerId, useICloudSync: preferICloudSync)
defaults.removeObject(forKey: Keys.apiKeyLegacy)
do {
try Keychain.copyAPIKeyToSelectedStorage(
legacy,
providerId: providerId,
useICloudSync: preferICloudSync
)
defaults.removeObject(forKey: Keys.apiKeyLegacy)
} catch {
OSGLog.config.warning(
"legacy defaults credential migration deferred: \(String(describing: error), privacy: .public)"
)
}
return legacy
}
return ""
}
/// Resolves the ASR-scoped account first, then falls back to the matching
/// polish-provider account used before ASR credentials were split.
static func resolveASRAPIKey(
defaults: UserDefaults?,
providerId: String,
@@ -540,6 +573,23 @@ public struct AppGroupConfiguration: Sendable, Equatable {
if let stored = Keychain.asrApiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty {
return stored
}
if providerId == "bailian" {
try? Keychain.copyQwenASRKeyToBailian(useICloudSync: preferICloudSync)
if let migrated = Keychain.asrApiKey(
for: providerId,
preferICloudSync: preferICloudSync
), !migrated.isEmpty {
return migrated
}
// Keep a compatibility read while older signed installs may still
// hold the DashScope credential under qwen accounts.
if let legacyQwen = Keychain.asrApiKey(
for: "qwen",
preferICloudSync: preferICloudSync
), !legacyQwen.isEmpty {
return legacyQwen
}
}
// Pre-split installs: one shared key under `provider.<id>`.
return resolveAPIKey(defaults: defaults, providerId: providerId, preferICloudSync: preferICloudSync)
}
@@ -60,7 +60,8 @@ public struct EditableInputReference: Codable, Equatable, Sendable {
now >= expiresAt
}
/// Rebuilt extensions must prove the entire insertion is still at the caret.
/// Rebuilt extensions must match the complete inserted string at the caret
/// and, when captured, the same field fingerprint; a suffix sample is insufficient.
public func isFullyVerified(
contextBeforeInput: String?,
fieldFingerprint: String?
@@ -75,6 +76,9 @@ public struct EditableInputReference: Codable, Equatable, Sendable {
}
}
/// App Group cache shared across extension instances. Loads evict references
/// after their TTL; callers must never save secure-field text because this is
/// cross-process persistence, not protected credential storage.
public enum EditableInputReferenceStore {
private static let key = "editLastInput.reference.v1"
@@ -0,0 +1,43 @@
// FlowAck.swift
// OSGKeyboard · Shared
import Foundation
/// Keyboard delivery acknowledgement. It carries the echoed result identity,
/// generation, and revision; matching identity/revision releases that terminal result.
public struct FlowAck: Codable, Equatable, Sendable {
public enum DeliveryOutcome: String, Codable, Sendable {
case replaced
case appended
case rejected
}
public let protocolVersion: Int
public let sessionId: UUID
public let utteranceId: UUID
public let commandSeq: Int64
public let hostGeneration: String?
public let revision: Int64?
public let deliveryOutcome: DeliveryOutcome?
public let consumedAt: TimeInterval
public init(
protocolVersion: Int = 1,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64,
hostGeneration: String? = nil,
revision: Int64? = nil,
deliveryOutcome: DeliveryOutcome? = nil,
consumedAt: TimeInterval = Date().timeIntervalSince1970
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.utteranceId = utteranceId
self.commandSeq = commandSeq
self.hostGeneration = hostGeneration
self.revision = revision
self.deliveryOutcome = deliveryOutcome
self.consumedAt = consumedAt
}
}
@@ -0,0 +1,87 @@
// FlowCommand.swift
// OSGKeyboard · Shared
import Foundation
public struct FlowCommand: Codable, Equatable, Sendable {
public enum Action: String, Codable, Sendable {
case startRecording
case stopRecording
case abort
/// Light warm-up: ASR locale/assets only no mic capture.
case prewarm
/// User has touched the mic; prime capture before tap/hold resolves.
case primeAudio
/// Touch ended without an utterance adopting the primed capture.
case cancelPrimeAudio
/// Remove one temporary AI conversation from host memory.
case endAIConversation
/// AI mode: submit a prefilled question and skip ASR.
case submitAIQuestion
}
/// Wire version that includes submitAIQuestion + aiQuestionText.
public static let currentProtocolVersion = 5
public let protocolVersion: Int
public let sessionId: UUID
public let utteranceId: UUID
public let commandSeq: Int64
public let action: Action
public let localeId: String
public let createdAt: TimeInterval
public let fieldContext: FlowFieldContext?
/// Dictation (default) vs explicit edit mode. Absent on legacy v1 dictation.
public let utteranceMode: FlowUtteranceMode?
/// Verified source for explicit last-input editing.
public let editSourceText: String?
public let sourceHistoryEntryID: UUID?
public let sourceHistoryEntryRevision: Int64?
/// Host-memory conversation used only by `.aiQuestion`.
public let aiConversationID: UUID?
/// Prefilled question used only by `.submitAIQuestion`.
public let aiQuestionText: String?
/// Absolute wall-clock deadlines survive extension reconstruction.
public let startDeadlineAt: TimeInterval?
public let processingDeadlineAt: TimeInterval?
public init(
protocolVersion: Int = FlowCommand.currentProtocolVersion,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64,
action: Action,
localeId: String,
createdAt: TimeInterval = Date().timeIntervalSince1970,
fieldContext: FlowFieldContext? = nil,
utteranceMode: FlowUtteranceMode? = nil,
editSourceText: String? = nil,
sourceHistoryEntryID: UUID? = nil,
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
startDeadlineAt: TimeInterval? = nil,
processingDeadlineAt: TimeInterval? = nil
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.utteranceId = utteranceId
self.commandSeq = commandSeq
self.action = action
self.localeId = localeId
self.createdAt = createdAt
self.fieldContext = fieldContext
self.utteranceMode = utteranceMode
self.editSourceText = editSourceText
self.sourceHistoryEntryID = sourceHistoryEntryID
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.startDeadlineAt = startDeadlineAt
self.processingDeadlineAt = processingDeadlineAt
}
public var resolvedUtteranceMode: FlowUtteranceMode {
utteranceMode ?? .dictation
}
}
@@ -0,0 +1,43 @@
// FlowFieldContext.swift
// OSGKeyboard · Shared
import Foundation
public struct FlowFieldContext: Codable, Equatable, Sendable {
public let precedingText: String?
public let followingText: String?
public let keyboardType: String?
public let returnKeyType: String?
public let isSecureEntry: Bool
/// Distinguishes a known-empty field from unavailable document context.
public let isEmptyField: Bool
public let isContextAvailable: Bool
public init(
precedingText: String? = nil,
followingText: String? = nil,
keyboardType: String? = nil,
returnKeyType: String? = nil,
isSecureEntry: Bool = false,
isEmptyField: Bool = false,
isContextAvailable: Bool = false
) {
self.precedingText = isSecureEntry ? nil : precedingText
self.followingText = isSecureEntry ? nil : followingText
self.keyboardType = keyboardType
self.returnKeyType = returnKeyType
self.isSecureEntry = isSecureEntry
self.isEmptyField = isSecureEntry ? false : isEmptyField
self.isContextAvailable = isSecureEntry ? false : isContextAvailable
}
public var deliveryFingerprint: String? {
guard !isSecureEntry else { return nil }
return [
keyboardType ?? "",
returnKeyType ?? "",
precedingText.map { String($0.suffix(80)) } ?? "",
followingText.map { String($0.prefix(40)) } ?? "",
].joined(separator: "|")
}
}
@@ -0,0 +1,67 @@
// FlowReadySnapshot.swift
// OSGKeyboard · Shared
import Foundation
public struct FlowReadySnapshot: Codable, Equatable, Sendable {
public enum Reason: String, Codable, Sendable {
case ready
case noSession
case starting
case audioEngineNotLive
case waitingForAudioProof
case recording
case processing
case awaitingDelivery
case permissionMissing
case appGroupUnavailable
case hostLost
case error
}
public let protocolVersion: Int
public let sessionId: UUID?
public let ready: Bool
public let reason: Reason
public let heartbeatAt: TimeInterval
public let readyAt: TimeInterval?
public let audioProofAt: TimeInterval?
public let engineMode: String
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,
sessionId: UUID?,
ready: Bool,
reason: Reason,
heartbeatAt: TimeInterval = Date().timeIntervalSince1970,
readyAt: TimeInterval? = nil,
audioProofAt: TimeInterval? = nil,
engineMode: String,
localeId: String,
busyUtteranceId: UUID? = nil,
sessionExpiresAt: TimeInterval? = nil,
hostGeneration: String? = nil
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.ready = ready
self.reason = reason
self.heartbeatAt = heartbeatAt
self.readyAt = readyAt
self.audioProofAt = audioProofAt
self.engineMode = engineMode
self.localeId = localeId
self.busyUtteranceId = busyUtteranceId
self.sessionExpiresAt = sessionExpiresAt
self.hostGeneration = hostGeneration
}
}
@@ -0,0 +1,88 @@
// FlowResult.swift
// OSGKeyboard · Shared
import Foundation
public struct FlowResult: Codable, Equatable, Sendable {
public enum Status: String, Codable, Sendable {
case partial
case rawReady
/// AI-mode LLM answer draft (not ASR). Non-terminal.
case streaming
case final
case error
case aborted
case timeout
}
public let protocolVersion: Int
public let sessionId: UUID
public let utteranceId: UUID
public let commandSeq: Int64
public let status: Status
public let text: String?
public let warning: String?
public let errorKind: FlowSessionKeys.TranscriptionErrorKind?
/// Raw ASR survives polish/network failure and host process churn.
public let rawText: String?
public let hostGeneration: String?
/// Monotonically increases within one session/utterance; readers may discard
/// an equal or lower non-nil revision as a stale or duplicate delivery.
public let revision: Int64?
public let fieldFingerprint: String?
public let createdAt: TimeInterval
/// Echo of the command mode so the extension can skip raw fallback.
public let utteranceMode: FlowUtteranceMode?
/// History row created by normal dictation, or edited by edit mode.
public let historyEntryID: UUID?
public let historyEntryRevision: Int64?
/// Echoed for AI result validation; absent for dictation and edit.
public let aiConversationID: UUID?
public init(
protocolVersion: Int = FlowCommand.currentProtocolVersion,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64,
status: Status,
text: String? = nil,
warning: String? = nil,
errorKind: FlowSessionKeys.TranscriptionErrorKind? = nil,
rawText: String? = nil,
hostGeneration: String? = nil,
revision: Int64? = nil,
fieldFingerprint: String? = nil,
createdAt: TimeInterval = Date().timeIntervalSince1970,
utteranceMode: FlowUtteranceMode? = nil,
historyEntryID: UUID? = nil,
historyEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.utteranceId = utteranceId
self.commandSeq = commandSeq
self.status = status
self.text = text
self.warning = warning
self.errorKind = errorKind
self.rawText = rawText
self.hostGeneration = hostGeneration
self.revision = revision
self.fieldFingerprint = fieldFingerprint
self.createdAt = createdAt
self.utteranceMode = utteranceMode
self.historyEntryID = historyEntryID
self.historyEntryRevision = historyEntryRevision
self.aiConversationID = aiConversationID
}
public var resolvedUtteranceMode: FlowUtteranceMode {
utteranceMode ?? .dictation
}
/// Instruction deliveries must never insert raw ASR into the field.
public var allowsRawFallback: Bool {
resolvedUtteranceMode == .dictation
}
}
@@ -0,0 +1,33 @@
// FlowStartTransaction.swift
// OSGKeyboard · Shared
import Foundation
public struct FlowStartTransaction: Codable, Equatable, Sendable {
public enum Phase: String, Codable, Sendable {
case issued
case starting
case recording
case terminal
}
public let sessionID: UUID
public let utteranceID: UUID
public let deadlineAt: TimeInterval
public let phase: Phase
public let updatedAt: TimeInterval
public init(
sessionID: UUID,
utteranceID: UUID,
deadlineAt: TimeInterval,
phase: Phase,
updatedAt: TimeInterval = Date().timeIntervalSince1970
) {
self.sessionID = sessionID
self.utteranceID = utteranceID
self.deadlineAt = deadlineAt
self.phase = phase
self.updatedAt = updatedAt
}
}
@@ -0,0 +1,14 @@
// FlowTranscriptionError.swift
// OSGKeyboard · Shared
import Foundation
public struct FlowTranscriptionError: Equatable, Sendable {
public let message: String
public let kind: FlowSessionKeys.TranscriptionErrorKind
public init(message: String, kind: FlowSessionKeys.TranscriptionErrorKind) {
self.message = message
self.kind = kind
}
}
@@ -11,6 +11,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public let sourceHistoryEntryID: UUID?
public let sourceHistoryEntryRevision: Int64?
public let aiConversationID: UUID?
/// When set with `.aiQuestion`, host skips ASR and answers this text.
public let aiQuestionText: String?
public static let dictation = FlowUtteranceRequest(mode: .dictation)
@@ -19,13 +21,15 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
editSourceText: String? = nil,
sourceHistoryEntryID: UUID? = nil,
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil
) {
self.mode = mode
self.editSourceText = editSourceText
self.sourceHistoryEntryID = sourceHistoryEntryID
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
}
public static func editLastInput(
@@ -42,10 +46,14 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
public var isEdit: Bool { mode == .editLastInput }
public var isAIQuestion: Bool { mode == .aiQuestion }
public static func aiQuestion(conversationID: UUID) -> FlowUtteranceRequest {
public static func aiQuestion(
conversationID: UUID,
prefilledQuestion: String? = nil
) -> FlowUtteranceRequest {
FlowUtteranceRequest(
mode: .aiQuestion,
aiConversationID: conversationID
aiConversationID: conversationID,
aiQuestionText: prefilledQuestion
)
}
}
+2 -3
View File
@@ -90,10 +90,9 @@ public struct LLMRequest: Codable, Sendable {
var cjkCount = 0
var nonCJKCount = 0
for scalar in text.unicodeScalars {
switch scalar.value {
case 0x4E00...0x9FFF, 0x3400...0x4DBF, 0xF900...0xFAFF:
if HanScript.isIdeograph(scalar) {
cjkCount += 1
default:
} else {
nonCJKCount += 1
}
}
@@ -1,8 +1,9 @@
// LocalASRCapabilities.swift
// OSGKeyboard · Shared
//
// Declares what each on-device ASR backend can accept for vocabulary bias.
// Callers must consult capabilities before building a `LocalASRBiasPayload`.
// Declares vocabulary-bias capabilities for the current macOS Qwen3 MLX
// runtime and Apple Speech fallback. Sherpa entries remain only to interpret
// legacy backend identifiers and install state.
import Foundation
@@ -62,7 +63,7 @@ public struct LocalASRCapabilities: Sendable, Equatable {
hotwordReloadCost: .none
)
/// Sherpa Qwen3 hard hotwords via `--qwen3-asr-hotwords`.
/// Legacy Sherpa Qwen3 capability retained for persisted backend compatibility.
public static let sherpaQwen3 = LocalASRCapabilities(
hotwordMode: .recognizerScoped,
maxHotwordCount: 100,
@@ -71,7 +72,7 @@ public struct LocalASRCapabilities: Sendable, Equatable {
hotwordReloadCost: .recognizerReload
)
/// Sherpa SenseVoice fast Chinese baseline without hotwords.
/// Legacy Sherpa SenseVoice capability retained for persisted backend compatibility.
public static let sherpaSenseVoice = LocalASRCapabilities(
hotwordMode: .none,
maxHotwordCount: 0,
@@ -80,7 +81,7 @@ public struct LocalASRCapabilities: Sendable, Equatable {
hotwordReloadCost: .none
)
/// FunASR Paraformer (Sherpa offline) no project hotword API.
/// Legacy Sherpa Paraformer capability retained for persisted backend compatibility.
public static let sherpaParaformer = LocalASRCapabilities(
hotwordMode: .none,
maxHotwordCount: 0,
@@ -1,7 +1,9 @@
// LocalASRModelCatalog.swift
// OSGKeyboard · Shared
//
// Bundled catalog of downloadable / manual local ASR models and Sherpa runtimes.
// Bundled macOS local-ASR model catalog. Qwen3 MLX is the active default and
// Apple Speech is the fallback; Sherpa backend/runtime identifiers remain for
// decoding legacy catalog and persisted install state, not current recognition.
import Foundation
@@ -260,7 +260,7 @@ extension PersonalDictionary {
guard !term.isEmpty else { return false }
var hasLatinLetter = false
for scalar in term.unicodeScalars {
if isCJKIdeograph(scalar) { return false }
if HanScript.isIdeograph(scalar) { return false }
if scalar.isASCII, CharacterSet.letters.contains(scalar) {
hasLatinLetter = true
}
@@ -268,15 +268,6 @@ extension PersonalDictionary {
return hasLatinLetter
}
private static func isCJKIdeograph(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x3400...0x4DBF, 0x4E00...0x9FFF, 0xF900...0xFAFF:
return true
default:
return false
}
}
/// Case-insensitive lookup by canonical term.
public func entry(matchingTerm term: String) -> Entry? {
let key = term.lowercased()
+15 -2
View File
@@ -12,6 +12,9 @@
import Foundation
import Combine
/// UI-owned ObservableObject; construct and mutate it on the main thread.
/// `@unchecked Sendable` does not make `@Published` thread-safe. Credential
/// observers write only Keychain, while non-secret configuration uses App Group defaults.
public final class ProviderConfig: ObservableObject, @unchecked Sendable {
public static let shared = ProviderConfig()
@@ -32,6 +35,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
persistConfiguration()
}
}
/// Its observer updates only the provider-scoped Keychain item; the value
/// must never enter `configuration` or App Group UserDefaults.
@Published public var apiKey: String {
didSet {
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
@@ -42,6 +47,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
useICloudSync: configuration.settingsICloudSyncEnabled
)
} catch {
isSyncingProviderAPIKey = true
apiKey = oldValue
isSyncingProviderAPIKey = false
OSGLog.config.warning("Keychain write failed: \(error.localizedDescription, privacy: .public)")
}
}
@@ -80,6 +88,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
useICloudSync: configuration.settingsICloudSyncEnabled
)
} catch {
isSyncingASRProviderAPIKey = true
asrApiKey = oldValue
isSyncingASRProviderAPIKey = false
OSGLog.config.warning("ASR Keychain write failed: \(error.localizedDescription, privacy: .public)")
}
}
@@ -167,7 +178,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
persistConfiguration()
}
}
/// v0.2.1: whether to translate the transcript into
/// Whether to translate the transcript into
/// `translationTargetLocaleId` before insertion. **Derived**
/// translation is on iff the user has selected a target locale
/// (i.e. the persisted id is anything other than
@@ -175,7 +186,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
public var translationEnabled: Bool {
configuration.translationEnabled
}
/// v0.2.1: BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the
/// BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the
/// translate-and-polish prompt should produce. Default `"off"`
/// translation is opt-in. Persisted in the App Group so the keyboard
/// extension can honour it (and so the chip on the keyboard reflects
@@ -349,6 +360,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
private let defaults: UserDefaults
private var configuration: AppGroupConfiguration
/// Suppresses `@Published` observer persistence while a complete snapshot
/// or preset is applied, preventing reentrant writes of partial state.
private var isApplyingConfiguration = false
private var isSyncingProviderAPIKey = false
private var isSyncingASRProviderAPIKey = false
@@ -31,8 +31,6 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
public var aiResponseLength: SyncedField<AIResponseLength>
public var activePolishStyleId: SyncedField<String>
public var llmThinkingEnabled: SyncedField<Bool>
public var clipboardHistoryEnabled: SyncedField<Bool>
public var clipboardCandidateBarEnabled: SyncedField<Bool>
public var flowSkipAppSwitch: SyncedField<Bool>
public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
@@ -90,16 +88,10 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
)
self.activePolishStyleId = activePolishStyleId
self.llmThinkingEnabled = llmThinkingEnabled
self.clipboardHistoryEnabled = clipboardHistoryEnabled ?? SyncedField(
value: false,
updatedAt: llmThinkingEnabled.updatedAt,
deviceID: llmThinkingEnabled.deviceID
)
self.clipboardCandidateBarEnabled = clipboardCandidateBarEnabled ?? SyncedField(
value: false,
updatedAt: llmThinkingEnabled.updatedAt,
deviceID: llmThinkingEnabled.deviceID
)
// Kept as optional parameters so old call sites and payload fixtures
// remain source-compatible. Clipboard consent is device-local.
_ = clipboardHistoryEnabled
_ = clipboardCandidateBarEnabled
self.flowSkipAppSwitch = flowSkipAppSwitch
self.flowInactivityDuration = flowInactivityDuration
}
@@ -198,21 +190,13 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
updatedAt: keyboardHapticIntensity.updatedAt,
deviceID: keyboardHapticIntensity.deviceID
)
clipboardHistoryEnabled = try container.decodeIfPresent(
_ = try container.decodeIfPresent(
SyncedField<Bool>.self,
forKey: .clipboardHistoryEnabled
) ?? SyncedField(
value: false,
updatedAt: keyboardHapticIntensity.updatedAt,
deviceID: keyboardHapticIntensity.deviceID
)
clipboardCandidateBarEnabled = try container.decodeIfPresent(
_ = try container.decodeIfPresent(
SyncedField<Bool>.self,
forKey: .clipboardCandidateBarEnabled
) ?? SyncedField(
value: false,
updatedAt: keyboardHapticIntensity.updatedAt,
deviceID: keyboardHapticIntensity.deviceID
)
flowSkipAppSwitch = try container.decode(SyncedField<Bool>.self, forKey: .flowSkipAppSwitch)
flowInactivityDuration = try container.decode(
@@ -269,12 +253,36 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
aiResponseLength.updatedAt,
activePolishStyleId.updatedAt,
llmThinkingEnabled.updatedAt,
clipboardHistoryEnabled.updatedAt,
clipboardCandidateBarEnabled.updatedAt,
flowSkipAppSwitch.updatedAt,
flowInactivityDuration.updatedAt,
].max() ?? .distantPast
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(schemaVersion, forKey: .schemaVersion)
try container.encode(providerId, forKey: .providerId)
try container.encode(baseURL, forKey: .baseURL)
try container.encode(model, forKey: .model)
try container.encode(asrProviderId, forKey: .asrProviderId)
try container.encode(asrBaseURL, forKey: .asrBaseURL)
try container.encode(asrModel, forKey: .asrModel)
try container.encode(modeId, forKey: .modeId)
try container.encode(localeId, forKey: .localeId)
try container.encode(engineMode, forKey: .engineMode)
try container.encode(hasAcknowledgedCloudSharing, forKey: .hasAcknowledgedCloudSharing)
try container.encode(uiLanguage, forKey: .uiLanguage)
try container.encode(translationTargetLocaleId, forKey: .translationTargetLocaleId)
try container.encode(handednessPreference, forKey: .handednessPreference)
try container.encode(cursorDragNavigationEnabled, forKey: .cursorDragNavigationEnabled)
try container.encode(keyboardHapticIntensity, forKey: .keyboardHapticIntensity)
try container.encode(polishIntensity, forKey: .polishIntensity)
try container.encode(aiResponseLength, forKey: .aiResponseLength)
try container.encode(activePolishStyleId, forKey: .activePolishStyleId)
try container.encode(llmThinkingEnabled, forKey: .llmThinkingEnabled)
try container.encode(flowSkipAppSwitch, forKey: .flowSkipAppSwitch)
try container.encode(flowInactivityDuration, forKey: .flowInactivityDuration)
}
}
public extension SyncedAppSettingsV2 {
@@ -311,8 +319,6 @@ public extension SyncedAppSettingsV2 {
aiResponseLength: field(configuration.aiResponseLength),
activePolishStyleId: field(configuration.activePolishStyleId),
llmThinkingEnabled: field(configuration.llmThinkingEnabled),
clipboardHistoryEnabled: field(configuration.clipboardHistoryEnabled),
clipboardCandidateBarEnabled: field(configuration.clipboardCandidateBarEnabled),
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
flowInactivityDuration: field(configuration.flowInactivityDuration)
)
@@ -345,8 +351,6 @@ public extension SyncedAppSettingsV2 {
aiResponseLength: field(AIResponseLength.default),
activePolishStyleId: field(PolishStylePackCatalog.defaultID),
llmThinkingEnabled: field(false),
clipboardHistoryEnabled: field(false),
clipboardCandidateBarEnabled: field(false),
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
flowInactivityDuration: field(legacy.flowInactivityDuration)
)
@@ -394,14 +398,6 @@ public extension SyncedAppSettingsV2 {
remote: remote.activePolishStyleId
),
llmThinkingEnabled: .merge(local: local.llmThinkingEnabled, remote: remote.llmThinkingEnabled),
clipboardHistoryEnabled: .merge(
local: local.clipboardHistoryEnabled,
remote: remote.clipboardHistoryEnabled
),
clipboardCandidateBarEnabled: .merge(
local: local.clipboardCandidateBarEnabled,
remote: remote.clipboardCandidateBarEnabled
),
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
flowInactivityDuration: .merge(
local: local.flowInactivityDuration,
@@ -430,8 +426,6 @@ public extension SyncedAppSettingsV2 {
configuration.aiResponseLength = aiResponseLength.value
configuration.activePolishStyleId = activePolishStyleId.value
configuration.llmThinkingEnabled = llmThinkingEnabled.value
configuration.clipboardHistoryEnabled = clipboardHistoryEnabled.value
configuration.clipboardCandidateBarEnabled = clipboardCandidateBarEnabled.value
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
configuration.flowInactivityDuration = flowInactivityDuration.value
}
@@ -462,8 +456,6 @@ public extension SyncedAppSettingsV2 {
patch(&copy.aiResponseLength, value: configuration.aiResponseLength)
patch(&copy.activePolishStyleId, value: configuration.activePolishStyleId)
patch(&copy.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
patch(&copy.clipboardHistoryEnabled, value: configuration.clipboardHistoryEnabled)
patch(&copy.clipboardCandidateBarEnabled, value: configuration.clipboardCandidateBarEnabled)
patch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
patch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy
@@ -497,8 +489,6 @@ public extension SyncedAppSettingsV2 {
touch(&copy.aiResponseLength, value: configuration.aiResponseLength)
touch(&copy.activePolishStyleId, value: configuration.activePolishStyleId)
touch(&copy.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
touch(&copy.clipboardHistoryEnabled, value: configuration.clipboardHistoryEnabled)
touch(&copy.clipboardCandidateBarEnabled, value: configuration.clipboardCandidateBarEnabled)
touch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
touch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy
@@ -4,7 +4,7 @@
// Catalog of target languages the translation feature can produce.
//
// Kept deliberately small (~10 entries) to match the kind of choices
// the user makes in the Settings picker / keyboard chip. We don't try
// the user makes in the Settings picker / keyboard menu. We don't try
// to expose every BCP-47 locale the prompt just needs a target
// language name, and a curated list reads better than a 100-row scroll.
//
@@ -30,8 +30,7 @@ public struct TranslationLanguage: Identifiable, Hashable, Sendable {
public enum TranslationLanguageCatalog {
/// Sentinel id for "don't translate" the default selection in the
/// picker. Picked over an `Optional<TranslationLanguage>` so the
/// single-row `Picker` binding stays a plain `String` (and the same
/// code path also works for the `TranslationChip` Menu).
/// single-row `Picker` binding and keyboard menu stay a plain `String`.
public static let offLocaleId = "off"
/// Default target language id used on fresh installs when translation
/// is enabled. The picker still defaults to `offLocaleId` this is
@@ -39,7 +38,7 @@ public enum TranslationLanguageCatalog {
/// recovered without a remembered target.
public static let defaultLocaleId = "en"
/// Curated set. Order matters the picker / chip render top-to-
/// Curated set. Order matters the picker / menu render top-to-
/// bottom, with `offLocaleId` ("") at the very top so the
/// "turn off" action is one tap away from any enabled state.
public static let all: [TranslationLanguage] = [
@@ -41,6 +41,38 @@ public enum TypingInputSchema: String, CaseIterable, Identifiable, Codable, Send
}
}
/// Default keyboard open mode when "remember last" is off.
public enum DefaultInputMode: String, CaseIterable, Identifiable, Codable, Sendable {
case voice
case pinyin
case english
public var id: String { rawValue }
public var labelKey: String {
switch self {
case .voice: return "settings.typingInput.default.mode.voice"
case .pinyin: return "settings.typingInput.default.mode.pinyin"
case .english: return "settings.typingInput.default.mode.english"
}
}
public var surface: KeyboardState.Surface {
switch self {
case .voice: return .voice
case .pinyin, .english: return .typing
}
}
public var typingLanguage: TypingInputLanguage? {
switch self {
case .voice: return nil
case .pinyin: return .chinese
case .english: return .english
}
}
}
public enum PinyinFuzzyPair: String, CaseIterable, Identifiable, Codable, Sendable {
case zhZ
case chC
@@ -84,9 +116,12 @@ public final class TypingInputConfiguration: ObservableObject {
private enum Key {
static let schema = "typing.input.schema"
static let fuzzyPairs = "typing.input.fuzzyPairs"
/// Legacy bool; migrated into `defaultInputMode` (true pinyin).
static let defaultToTyping = "typing.input.defaultToTyping"
static let defaultInputMode = "typing.input.defaultInputMode"
static let rememberLastSurface = "typing.input.rememberLastSurface"
static let lastSurface = "typing.input.lastSurface"
static let lastTypingLanguage = "typing.input.lastTypingLanguage"
static let resourceVersion = "typing.rime.resourceVersion"
static let personalDictionaryFingerprint = "typing.rime.personalDictionaryFingerprint"
}
@@ -102,13 +137,13 @@ public final class TypingInputConfiguration: ObservableObject {
didSet { persistIfReady() }
}
/// Selects the text keyboard whenever the extension becomes visible.
/// Ignored when `rememberLastSurface` is on and a prior surface was saved.
@Published public var defaultToTyping: Bool {
/// Static open preference when `rememberLastSurface` is off.
/// Ignored when remembering and a prior surface was saved.
@Published public var defaultInputMode: DefaultInputMode {
didSet { persistIfReady() }
}
/// When on, reopen on the voice/typing surface left at the last dismiss.
/// When on, reopen on the voice/typing/AI surface (and typing language) left last time.
@Published public var rememberLastSurface: Bool {
didSet { persistIfReady() }
}
@@ -119,7 +154,7 @@ public final class TypingInputConfiguration: ObservableObject {
schema = TypingInputSchema(rawValue: schemaId) ?? .fullPinyin
let fuzzyIds = self.defaults.stringArray(forKey: Key.fuzzyPairs) ?? []
fuzzyPairs = Set(fuzzyIds.compactMap(PinyinFuzzyPair.init(rawValue:)))
defaultToTyping = self.defaults.bool(forKey: Key.defaultToTyping)
defaultInputMode = Self.resolveDefaultInputMode(from: self.defaults)
rememberLastSurface = self.defaults.bool(forKey: Key.rememberLastSurface)
isHydrating = false
}
@@ -142,16 +177,16 @@ public final class TypingInputConfiguration: ObservableObject {
?? .fullPinyin
let fuzzyIds = defaults.stringArray(forKey: Key.fuzzyPairs) ?? []
fuzzyPairs = Set(fuzzyIds.compactMap(PinyinFuzzyPair.init(rawValue:)))
defaultToTyping = defaults.bool(forKey: Key.defaultToTyping)
defaultInputMode = Self.resolveDefaultInputMode(from: defaults)
rememberLastSurface = defaults.bool(forKey: Key.rememberLastSurface)
isHydrating = false
}
/// Legacy helper for the default-to-typing toggle only (not full open policy).
/// Legacy helper: true when the static default opens on the typing surface.
nonisolated public static func prefersTypingOnOpen(
defaults: UserDefaults? = nil
) -> Bool {
(defaults ?? AppGroup.defaultsIfAvailable)?.bool(forKey: Key.defaultToTyping) ?? false
resolveDefaultInputMode(from: defaults ?? AppGroup.defaultsIfAvailable).surface == .typing
}
nonisolated public static func remembersLastSurface(
@@ -161,26 +196,43 @@ public final class TypingInputConfiguration: ObservableObject {
}
/// Surface to show on the first frame of a keyboard presentation.
/// Prefer last-left surface when remembering; otherwise default-to-typing.
/// Prefer last-left surface when remembering; otherwise default input mode.
nonisolated public static func preferredSurfaceOnOpen(
defaults: UserDefaults? = nil
) -> KeyboardState.Surface {
preferredOpenPreference(defaults: defaults).surface
}
/// Typing language to apply when opening onto the typing surface.
nonisolated public static func preferredTypingLanguageOnOpen(
defaults: UserDefaults? = nil
) -> TypingInputLanguage? {
preferredOpenPreference(defaults: defaults).typingLanguage
}
nonisolated public static func preferredOpenPreference(
defaults: UserDefaults? = nil
) -> (surface: KeyboardState.Surface, typingLanguage: TypingInputLanguage?) {
let store = defaults ?? AppGroup.defaultsIfAvailable
guard let store else { return .voice }
guard let store else { return (.voice, nil) }
// AI is an explicit product surface. Restore it as an empty temporary
// conversation even when the general "remember surface" toggle is off.
if store.string(forKey: Key.lastSurface) == KeyboardState.Surface.ai.rawValue {
return .ai
return (.ai, nil)
}
if store.bool(forKey: Key.rememberLastSurface),
let raw = store.string(forKey: Key.lastSurface),
let surface = KeyboardState.Surface(rawValue: raw) {
return surface
let language: TypingInputLanguage? = surface == .typing
? persistedTypingLanguage(defaults: store) ?? .chinese
: nil
return (surface, language)
}
return store.bool(forKey: Key.defaultToTyping) ? .typing : .voice
let mode = resolveDefaultInputMode(from: store)
return (mode.surface, mode.typingLanguage)
}
/// Persist the surface present when the keyboard leaves the screen.
@@ -191,6 +243,15 @@ public final class TypingInputConfiguration: ObservableObject {
(defaults ?? AppGroup.defaultsIfAvailable)?.set(surface.rawValue, forKey: Key.lastSurface)
}
/// Persist the typing language left on the typing surface.
nonisolated public static func persistLastTypingLanguage(
_ language: TypingInputLanguage,
defaults: UserDefaults? = nil
) {
(defaults ?? AppGroup.defaultsIfAvailable)?
.set(language.rawValue, forKey: Key.lastTypingLanguage)
}
nonisolated public static func installedResourceVersion(
defaults: UserDefaults? = nil
) -> String? {
@@ -219,11 +280,32 @@ public final class TypingInputConfiguration: ObservableObject {
.set(value, forKey: Key.personalDictionaryFingerprint)
}
nonisolated private static func resolveDefaultInputMode(
from defaults: UserDefaults?
) -> DefaultInputMode {
guard let defaults else { return .voice }
if let raw = defaults.string(forKey: Key.defaultInputMode),
let mode = DefaultInputMode(rawValue: raw) {
return mode
}
// Migrate legacy toggle: on pinyin, off voice.
return defaults.bool(forKey: Key.defaultToTyping) ? .pinyin : .voice
}
nonisolated private static func persistedTypingLanguage(
defaults: UserDefaults
) -> TypingInputLanguage? {
guard let raw = defaults.string(forKey: Key.lastTypingLanguage) else { return nil }
return TypingInputLanguage(rawValue: raw)
}
private func persistIfReady() {
guard !isHydrating else { return }
defaults.set(schema.rawValue, forKey: Key.schema)
defaults.set(fuzzyPairs.map(\.rawValue).sorted(), forKey: Key.fuzzyPairs)
defaults.set(defaultToTyping, forKey: Key.defaultToTyping)
defaults.set(defaultInputMode.rawValue, forKey: Key.defaultInputMode)
// Keep legacy bool in sync for any older readers still checking it.
defaults.set(defaultInputMode.surface == .typing, forKey: Key.defaultToTyping)
defaults.set(rememberLastSurface, forKey: Key.rememberLastSurface)
AppGroupConfigDarwin.postConfigChanged()
}
@@ -26,7 +26,7 @@ public struct VolcengineASRFields: Sendable, Equatable {
public static let fixedResourceID = CloudASRModelCatalog.volcengineDefaultResourceID
public init(
authMode: VolcengineASRAuthMode = .appToken,
authMode: VolcengineASRAuthMode = .apiKey,
appID: String = "",
accessToken: String = "",
apiKeyCredential: String = ""
@@ -148,7 +148,7 @@ public struct VolcengineASRFields: Sendable, Equatable {
// Legacy JSON without auth_mode: prefer app-token when present.
if hasAppToken { return .appToken }
if hasAPIKey { return .apiKey }
return .appToken
return .apiKey
}
private static func string(_ json: [String: Any], keys: [String]) -> String? {