feat(macos): add macOS menu-bar app and harden cross-device iCloud sync

Introduce a standalone macOS menu-bar app (OSGKeyboardMac) that reuses the
platform-agnostic OSGKeyboardShared core: record -> cloud/local ASR -> polish
-> insert. Local mode uses Qwen3-ASR via mlx-swift-asr (macOS 15+, Apple
Silicon); iOS targets stay zero-SPM.

Harden iCloud sync for multi-device correctness:
- Per-field settings merge (appSettings.v2) so concurrent edits no longer
  clobber each other's unrelated fields.
- Per-device usage statistics (G-Counter) that sum instead of max().
- Tombstoned dictionary/history merge so deletes propagate and entries can't
  resurrect.
- API keys replicate via iCloud Keychain, never iCloud KVS JSON; pulling a
  legacy blob without key fields no longer wipes local Keychain entries.
- Add a low-risk "Sync Now" action in Settings.

Fix Flow keyboard mic state: stay orange until the host publishes a real ready
contract, share a single MicVoiceAvailability gate, and self-heal stale
cross-process heartbeat jitter instead of getting stuck.

Extract shared storage (SpeechHistoryStore/UsageStatisticsStore,
ConfigurationStore) into OSGKeyboardShared and add tests for the new
sync/merge logic.
This commit is contained in:
Rocky
2026-07-08 18:13:56 +08:00
parent 128aab1b02
commit c2f07bd8d2
99 changed files with 6735 additions and 740 deletions
+232
View File
@@ -0,0 +1,232 @@
// DashboardView.swift
// OSGKeyboard · Mac
//
// Primary workspace: session stats, dictation canvas, floating record bar.
import SwiftUI
struct DashboardView: View {
@ObservedObject var viewModel: MacDictationViewModel
@ObservedObject private var stats: UsageStatisticsStore
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
init(viewModel: MacDictationViewModel) {
self.viewModel = viewModel
// Observe through the view model's store instance a bare
// `UsageStatisticsStore.shared` on @ObservedObject often misses
// post-sync @Published updates on macOS.
self._stats = ObservedObject(wrappedValue: viewModel.usageStatistics)
}
// Four equal-width columns same metrics as the iOS home stats card.
private let columns = Array(
repeating: GridItem(.flexible(minimum: 120), spacing: Spacing.md),
count: 4
)
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.lg) {
if let appName = viewModel.foregroundAppName {
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
}
statGrid
dictationCanvas
}
.padding(Spacing.lg)
}
BottomDictationBar(viewModel: viewModel)
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.sm)
}
.onAppear { stats.reloadFromDisk() }
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
stats.reloadFromDisk()
}
}
private var statGrid: some View {
LazyVGrid(columns: columns, spacing: Spacing.md) {
StatCard(
title: MacL10n.string("mac.stat.dictationTime", language: lang),
value: UsageStatisticsStore.formatDuration(
stats.dictationDurationSeconds,
language: lang
),
caption: MacL10n.string("mac.stat.cumulativeDuration", language: lang),
systemImage: "waveform",
accent: true
)
StatCard(
title: MacL10n.string("mac.stat.words", language: lang),
value: UsageStatisticsStore.formatCount(
stats.dictationCharacterCount,
language: lang
),
caption: MacL10n.string("mac.stat.transcribed", language: lang),
systemImage: "text.alignleft"
)
StatCard(
title: MacL10n.string("mac.stat.translation", language: lang),
value: UsageStatisticsStore.formatCount(
stats.translationCharacterCount,
language: lang
),
caption: MacL10n.string("mac.stat.cumulativeTranslation", language: lang),
systemImage: "character.bubble"
)
StatCard(
title: MacL10n.string("mac.stat.dictionary", language: lang),
value: "\(viewModel.dictionaryTermCount)",
caption: MacL10n.string("mac.stat.customTerms", language: lang),
systemImage: "character.book.closed"
)
}
}
private var dictationCanvas: some View {
MacCard(padding: Spacing.lg) {
if viewModel.transcript.isEmpty {
Text(
viewModel.isRecording
? MacL10n.string("mac.status.listening", language: lang)
: MacL10n.string("mac.status.ready", language: lang)
)
.font(.system(size: 26, weight: .light))
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
} else {
Text(viewModel.transcript)
.font(.system(size: 22, weight: .regular))
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
}
}
}
}
// MARK: - Floating record bar
struct BottomDictationBar: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@State private var pulse = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
ZStack {
HStack {
translationPicker
Spacer()
readinessChip
}
recordControl
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 0.78)
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
.shadow(color: palette.textPrimary.opacity(0.12), radius: 18, y: 8)
}
private var readinessChip: some View {
HStack(spacing: 6) {
Circle()
.fill(viewModel.isProcessing ? palette.warning : palette.accent)
.frame(width: 7, height: 7)
Text(
viewModel.isProcessing
? MacL10n.string("mac.status.chipProcessing", language: lang)
: MacL10n.string("mac.status.chipReady", language: lang)
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 5)
.background(palette.surfaceElevated, in: Capsule())
}
private var translationPicker: some View {
Menu {
ForEach(TranslationLanguageCatalog.all) { language in
Button(translationLabel(for: language)) {
viewModel.config.translationTargetLocaleId = language.id
}
}
} label: {
HStack(spacing: 6) {
Image(systemName: "translate")
Text(currentTranslationLabel)
.lineLimit(1)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.macGlassSurface(in: Capsule(), fillOpacity: 0.66)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
private var recordControl: some View {
HStack(spacing: Spacing.sm) {
if viewModel.isRecording {
MiniWaveform(level: viewModel.audioLevel)
}
Button(action: viewModel.toggleRecording) {
ZStack {
Circle()
.fill(viewModel.isRecording ? palette.recordRed : palette.accent)
.frame(width: 52, height: 52)
.macGlassSurface(in: Circle(), fillOpacity: 0.2)
.shadow(
color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.5),
radius: pulse ? 14 : 6
)
Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(palette.textOnAccent)
}
}
.buttonStyle(.plain)
.disabled(viewModel.isProcessing)
.onAppear {
withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
pulse = true
}
}
if viewModel.isRecording {
Text(MacL10n.string("mac.record.pressStop", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
}
}
}
private var currentTranslationLabel: String {
let current = TranslationLanguageCatalog.resolve(viewModel.config.translationTargetLocaleId)
return translationLabel(for: current)
}
private func translationLabel(for language: TranslationLanguage) -> String {
if TranslationLanguageCatalog.isOff(language.id) {
return MacL10n.string("keyboard.translation.offMenu", language: lang)
}
return language.nativeName
}
}
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>OSGKeyboard</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh-Hans</string>
</array>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSHumanReadableCopyright</key>
<string>OSGKeyboard</string>
<key>NSMicrophoneUsageDescription</key>
<string>OSGKeyboard uses the microphone to transcribe your voice via your configured cloud speech provider.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>OSGKeyboard uses on-device speech recognition for local dictation mode.</string>
</dict>
</plist>
+86
View File
@@ -0,0 +1,86 @@
// MacAppContextService.swift
// OSGKeyboard · Mac
//
// Unlike the iOS keyboard extension, macOS can read the frontmost app's
// bundle ID via NSWorkspace and map it to `AppContext` for polish prompts.
import AppKit
import Foundation
enum MacAppContextService {
/// Bundle IDs coarse polish context (macOS + cross-platform).
private static let contextByBundleId: [String: AppContext] = [
// Code / dev
"com.apple.dt.Xcode": .code,
"com.microsoft.VSCode": .code,
"com.google.android.studio": .code,
"com.jetbrains.intellij": .code,
"com.jetbrains.AppCode": .code,
"com.sublimetext.4": .code,
"com.github.GitHubClient": .code,
"com.apple.Terminal": .code,
"com.googlecode.iterm2": .code,
"dev.warp.Warp-Stable": .code,
// Email
"com.apple.mail": .email,
"com.microsoft.Outlook": .email,
"com.google.Gmail": .email,
"com.readdle.smartemail": .email,
// Chat / IM
"com.tencent.xinWeChat": .chat,
"com.tencent.qq": .chat,
"com.tencent.wework": .chat,
"com.tinyspeck.slackmacgap": .chat,
"com.hnc.Discord": .chat,
"com.microsoft.teams": .chat,
"com.microsoft.teams2": .chat,
"ru.keepcoder.Telegram": .chat,
"net.whatsapp.WhatsApp": .chat,
"com.apple.MobileSMS": .chat,
"com.facebook.archon": .chat,
"com.laiwang.DingTalk": .chat,
"com.bytedance.feishu": .chat,
// Documents / notes
"com.apple.Notes": .document,
"com.apple.iWork.Pages": .document,
"notion.id": .document,
"md.obsidian": .document,
"net.shinyfrog.bear": .document,
"com.agiletortoise.Drafts-OSX": .document,
"com.microsoft.Word": .document,
"com.google.GoogleDocs": .document,
"com.evernote.Evernote": .document,
"com.microsoft.onenote.mac": .document,
]
/// Chat-style apps from the shared host registry (iOS bundle IDs often
/// match Mac counterparts for cross-platform IM).
private static let chatBundleIdsFromRegistry: Set<String> = {
Set(HostAppURLRegistry.entries.map(\.bundleId))
}()
static func frontmostApplicationName() -> String? {
NSWorkspace.shared.frontmostApplication?.localizedName
}
static func frontmostBundleIdentifier() -> String? {
NSWorkspace.shared.frontmostApplication?.bundleIdentifier
}
static func detectContext() -> AppContext {
guard let bundleId = frontmostBundleIdentifier() else { return .unknown }
if let mapped = contextByBundleId[bundleId] { return mapped }
if chatBundleIdsFromRegistry.contains(bundleId) { return .chat }
if bundleId.hasPrefix("com.apple.Safari") || bundleId.contains("chrome") {
return .document
}
return .unknown
}
/// Persist detected context into the shared configuration store so
/// `PolishingService` reads the same signal as on iOS.
static func captureAndPersist(to store: AppGroupStore) {
let context = detectContext()
store.setDetectedAppContext(context)
}
}
+75
View File
@@ -0,0 +1,75 @@
// MacAppearance.swift
// OSGKeyboard · Mac
//
// User-facing light / dark preference for the desktop app. Stored locally
// (Mac-only switch for now); can be promoted into `SyncedAppSettings` later
// if iPad / cross-device appearance sync is wanted.
import AppKit
import SwiftUI
/// How the macOS app resolves its colour scheme.
enum MacAppearancePreference: String, CaseIterable, Identifiable {
case system
case light
case dark
var id: String { rawValue }
/// `nil` means "follow the system", matching SwiftUI's convention where
/// `preferredColorScheme(nil)` defers to the environment.
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
/// The matching AppKit appearance so window chrome, traffic lights and
/// the menu-bar popover follow the same choice as the SwiftUI content.
var nsAppearance: NSAppearance? {
switch self {
case .system: return nil
case .light: return NSAppearance(named: .aqua)
case .dark: return NSAppearance(named: .darkAqua)
}
}
var labelKey: String {
switch self {
case .system: return "mac.appearance.system"
case .light: return "mac.appearance.light"
case .dark: return "mac.appearance.dark"
}
}
var systemImage: String {
switch self {
case .system: return "circle.lefthalf.filled"
case .light: return "sun.max"
case .dark: return "moon"
}
}
/// `@AppStorage` key shared by the app shell and the settings switch.
static let storageKey = "mac.appearancePreference"
static var current: MacAppearancePreference {
MacAppearancePreference(
rawValue: UserDefaults.standard.string(forKey: storageKey) ?? ""
) ?? .system
}
/// Push the preference into AppKit so non-SwiftUI chrome (title bar,
/// popover) tracks it too. Safe to call on the main actor at any time.
@MainActor
static func applyToApp(_ preference: MacAppearancePreference) {
NSApp?.appearance = preference.nsAppearance
NSApp?.windows.forEach { window in
window.appearance = preference.nsAppearance
window.contentView?.needsDisplay = true
window.contentView?.subviews.forEach { $0.needsDisplay = true }
}
}
}
+112
View File
@@ -0,0 +1,112 @@
// MacAudioRecorder.swift
// OSGKeyboard · Mac
//
// Captures microphone audio via AVAudioEngine and resamples it to the
// 16 kHz mono Float32 buffer the cloud ASR clients expect. The tap
// callback runs on the audio render thread, so sample accumulation is
// guarded by a lock and the type is `@unchecked Sendable`.
@preconcurrency import AVFoundation
final class MacAudioRecorder: @unchecked Sendable {
enum RecorderError: Error, LocalizedError {
case converterUnavailable
var errorDescription: String? {
switch self {
case .converterUnavailable:
return "无法初始化音频转换器 / Failed to initialize audio converter"
}
}
}
private let engine = AVAudioEngine()
private let targetFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 16_000,
channels: 1,
interleaved: false
)!
private var converter: AVAudioConverter?
private let lock = NSLock()
private var samples: [Float] = []
private var isRunning = false
private var smoothedLevel: Float = 0
/// One-shot flag for the converter pull block. Taps are serialized per
/// bus, so a plain instance property (not a captured local) is safe here.
private var didProvideInput = false
/// Normalised input level (01), smoothed for a calm waveform.
/// Read from the main thread by a polling timer while recording.
func level() -> Float {
lock.withLock { smoothedLevel }
}
func start() throws {
lock.withLock { samples.removeAll(keepingCapacity: true) }
let input = engine.inputNode
let inputFormat = input.outputFormat(forBus: 0)
guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
throw RecorderError.converterUnavailable
}
self.converter = converter
input.installTap(onBus: 0, bufferSize: 4_096, format: inputFormat) { [weak self] buffer, _ in
self?.appendResampled(buffer)
}
engine.prepare()
try engine.start()
isRunning = true
}
/// Stops capture and returns the accumulated 16 kHz mono samples.
func stop() -> [Float] {
guard isRunning else { return [] }
engine.inputNode.removeTap(onBus: 0)
engine.stop()
isRunning = false
return lock.withLock {
let out = samples
samples.removeAll(keepingCapacity: false)
return out
}
}
private func appendResampled(_ buffer: AVAudioPCMBuffer) {
guard let converter else { return }
let ratio = targetFormat.sampleRate / buffer.format.sampleRate
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 1_024
guard let output = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
didProvideInput = false
var conversionError: NSError?
converter.convert(to: output, error: &conversionError) { [self] _, statusPointer in
if didProvideInput {
statusPointer.pointee = .noDataNow
return nil
}
didProvideInput = true
statusPointer.pointee = .haveData
return buffer
}
guard conversionError == nil, let channel = output.floatChannelData else { return }
let frameCount = Int(output.frameLength)
guard frameCount > 0 else { return }
let chunk = Array(UnsafeBufferPointer(start: channel[0], count: frameCount))
// RMS rough 01 level with an attack/decay smoothing so the UI
// waveform breathes rather than jitters.
var sumSquares: Float = 0
for sample in chunk { sumSquares += sample * sample }
let rms = (sumSquares / Float(frameCount)).squareRoot()
let normalized = min(1, max(0, rms * 12))
lock.withLock {
samples.append(contentsOf: chunk)
let factor: Float = normalized > smoothedLevel ? 0.5 : 0.15
smoothedLevel += (normalized - smoothedLevel) * factor
}
}
}
+232
View File
@@ -0,0 +1,232 @@
// MacComponents.swift
// OSGKeyboard · Mac
//
// Reusable macOS UI pieces styled with the shared design tokens and the
// system-native palette (see MacTheme.swift). Kept as plain SwiftUI (no
// AppKit) so they can be reused on iPadOS. Settings / History / Dictionary
// now use the native grouped `Form`, so the old hand-rolled card/row types
// were removed what remains here is used by the Dashboard, the status
// footer and the menu-bar popover.
import SwiftUI
// MARK: - Shared layout metrics
/// Fixed metrics that keep every desktop surface on the same grid.
enum MacMetrics {
/// Uniform max width for trailing text controls (API key / model field).
static let controlWidth: CGFloat = 240
/// Sidebar width and the horizontal inset shared by brand, nav and footer.
static let sidebarWidth: CGFloat = 240
/// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo
/// adds `Spacing.sm` on top of this so its left edge lines up with the
/// SF Symbol in each nav `Label`.
static let sidebarInset: CGFloat = Spacing.md
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
/// Reading width for single-column content.
static let contentMaxWidth: CGFloat = 720
/// Top inset that clears the window traffic-light buttons now that the
/// title bar is hidden.
static let trafficLightInset: CGFloat = 28
}
// MARK: - Liquid Glass
private struct MacGlassSurface<S: Shape>: ViewModifier {
@Environment(\.themePalette) private var palette
let shape: S
let fillOpacity: Double
func body(content: Content) -> some View {
if #available(macOS 26.0, *) {
content
.background(palette.surface.opacity(fillOpacity), in: shape)
.glassEffect(.regular, in: shape)
} else {
content
.background(palette.surface.opacity(fillOpacity), in: shape)
}
}
}
extension View {
/// Applies Liquid Glass on macOS 26 while keeping the same semantic
/// surface colour on older systems.
func macGlassSurface<S: Shape>(
in shape: S,
fillOpacity: Double = 0.72
) -> some View {
modifier(MacGlassSurface(shape: shape, fillOpacity: fillOpacity))
}
}
// MARK: - Text field styling
/// Text-field chrome aligned with native macOS Form controls: compact
/// height, text-background fill, hairline border.
private struct MacFieldStyleModifier: ViewModifier {
@Environment(\.themePalette) private var palette
func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: 6, style: .continuous)
content
.textFieldStyle(.plain)
.font(TypeStyle.footnote)
.padding(.horizontal, 8)
.frame(height: 22)
.background(Color(nsColor: .textBackgroundColor), in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
}
}
extension View {
/// Theme-aware text-field styling for the settings inputs.
func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) }
}
// MARK: - Card container
/// Elevated surface used for stat tiles and the dictation canvas.
struct MacCard<Content: View>: View {
@Environment(\.themePalette) private var palette
var padding: CGFloat = Spacing.md
@ViewBuilder var content: () -> Content
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
content()
.padding(padding)
.macGlassSurface(in: shape)
.overlay(
shape
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
// MARK: - Stat tile
struct StatCard: View {
@Environment(\.themePalette) private var palette
let title: String
let value: String
let caption: String
var systemImage: String?
var accent: Bool = false
var body: some View {
MacCard {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack {
Text(title.uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Spacer()
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
}
}
Text(value)
.font(TypeStyle.title2)
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(caption)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
// MARK: - Live waveform
/// Compact bar visualiser reacting to the input level while recording.
struct MiniWaveform: View {
@Environment(\.themePalette) private var palette
let level: Float
var barCount: Int = 5
/// Pass nil to inherit the palette accent automatically.
var tint: Color?
@State private var phase: CGFloat = 0
var body: some View {
HStack(spacing: 3) {
ForEach(0..<barCount, id: \.self) { index in
Capsule()
.fill(tint ?? palette.accent)
.frame(width: 3, height: barHeight(index))
}
}
.frame(height: 22)
.animation(.easeOut(duration: 0.12), value: level)
.onAppear {
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) {
phase = 1
}
}
}
private func barHeight(_ index: Int) -> CGFloat {
let base = CGFloat(level) * 22
let wobble = sin((phase * .pi * 2) + CGFloat(index)) * 4 + 4
return max(4, min(22, base * (0.6 + CGFloat(index % 2) * 0.4) + wobble))
}
}
// MARK: - Translation display helper
/// Shared label logic for the translation control so the dashboard chip,
/// the status footer and the menu-bar popover all read identically.
enum MacTranslationDisplay {
static func label(for targetLocaleId: String, language: AppUILanguage) -> String {
let resolved = TranslationLanguageCatalog.resolve(targetLocaleId)
if TranslationLanguageCatalog.isOff(resolved.id) {
return MacL10n.string("keyboard.translation.offMenu", language: language)
}
return resolved.nativeName
}
}
// MARK: - Status footer
/// Bottom status strip: engine mode (cloud/local), translation target, and
/// the connection state icons and wording mirror the dashboard record bar.
struct MacStatusFooter: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
HStack(spacing: Spacing.md) {
Spacer()
Label(
viewModel.isCloudMode
? MacL10n.string("mac.mode.cloud", language: lang)
: MacL10n.string("mac.mode.local", language: lang),
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
)
.foregroundStyle(palette.textSecondary)
Label(
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
systemImage: "translate"
)
.foregroundStyle(palette.textSecondary)
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
.foregroundStyle(palette.accent)
}
.font(TypeStyle.caption)
.labelStyle(.titleAndIcon)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
}
}
+145
View File
@@ -0,0 +1,145 @@
// MacContentView.swift
// OSGKeyboard · Mac
//
// Compact menu-bar popover for quick dictation.
import SwiftUI
struct MacContentView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
header
recordButton
Text(MacL10n.string("mac.hint.holdOption", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
Text(viewModel.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
if !viewModel.transcript.isEmpty {
ScrollView {
Text(viewModel.transcript)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 120)
.padding(Spacing.xs)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous))
}
Divider().overlay(palette.divider)
statusRow
Divider().overlay(palette.divider)
footer
}
.padding(Spacing.md)
.background(palette.background)
}
private var statusRow: some View {
HStack(spacing: Spacing.sm) {
Label(
viewModel.isCloudMode
? MacL10n.string("mac.mode.cloud", language: lang)
: MacL10n.string("mac.mode.local", language: lang),
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
)
.foregroundStyle(palette.textSecondary)
Spacer()
translationMenu
Spacer()
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
.foregroundStyle(palette.accent)
}
.font(TypeStyle.caption)
.labelStyle(.titleAndIcon)
}
private var translationMenu: some View {
Menu {
ForEach(TranslationLanguageCatalog.all) { language in
Button(MacTranslationDisplay.label(for: language.id, language: lang)) {
viewModel.config.translationTargetLocaleId = language.id
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "translate")
Text(MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang))
.lineLimit(1)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8, weight: .semibold))
}
.foregroundStyle(palette.textSecondary)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
private var header: some View {
HStack(spacing: Spacing.xs) {
Image("OSGBrandMark")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(palette.accent)
Text("OSGKeyboard")
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Spacer()
if viewModel.isRecording {
MiniWaveform(level: viewModel.audioLevel, barCount: 4)
}
}
}
private var recordButton: some View {
Button(action: viewModel.toggleRecording) {
HStack {
Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill")
Text(
viewModel.isRecording
? MacL10n.string("mac.record.stop", language: lang)
: MacL10n.string("mac.record.start", language: lang)
)
.font(TypeStyle.bodyEmph)
}
.frame(maxWidth: .infinity, minHeight: 40)
.background(
(viewModel.isRecording ? palette.recordRed : palette.accent),
in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous)
)
.foregroundStyle(palette.textOnAccent)
}
.buttonStyle(.plain)
.disabled(viewModel.isProcessing)
}
private var footer: some View {
HStack {
Button(MacL10n.string("mac.openWindow", language: lang)) { MacMainWindow.open() }
.buttonStyle(.plain)
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
Spacer()
Button(MacL10n.string("mac.quit", language: lang)) { NSApplication.shared.terminate(nil) }
.buttonStyle(.plain)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
// MacDictationPipeline.swift
// OSGKeyboard · Mac
//
// Dictation pipeline: samples ASR (cloud or local) polish.
// Cloud path reuses `CloudASRClientFactory`; local path uses Qwen3-ASR (MLX)
// with Apple Speech fallback when weights are missing.
import Foundation
enum MacDictationError: Error, LocalizedError {
case noAudio
case providerHasNoCloudASR
case emptyTranscript
var errorDescription: String? {
switch self {
case .noAudio:
return MacL10n.string("mac.error.noAudio")
case .providerHasNoCloudASR:
return MacL10n.string("mac.error.noCloudASR")
case .emptyTranscript:
return MacL10n.string("mac.error.emptyTranscript")
}
}
}
enum MacDictationPipeline {
/// Runs ASR then best-effort polish. Polish failures fall back to raw text.
static func run(samples: [Float], store: AppGroupStore) async throws -> String {
guard !samples.isEmpty else { throw MacDictationError.noAudio }
let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
let raw: String
if store.engineMode == "local" {
raw = try await MacLocalASRService.transcribe(samples: samples, locale: locale)
} else {
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR }
let client = CloudASRClientFactory.make(store: store)
try? await client.prepare(dictionary: store.personalDictionary)
raw = try await client.transcribe(
samples: samples,
sampleRate: 16_000,
locale: locale,
dictionary: store.personalDictionary
)
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw MacDictationError.emptyTranscript }
if let polished = try? await PolishingService(store: store).polish(
trimmed,
mode: store.polishModeForPipeline
),
!polished.isEmpty {
return polished
}
return trimmed
}
}
+300
View File
@@ -0,0 +1,300 @@
// MacDictationViewModel.swift
// OSGKeyboard · Mac
//
// Drives the whole macOS window: navigation, recording, hotkey, iCloud-backed
// settings, foreground-app context, and text insertion.
import AppKit
import Combine
import SwiftUI
/// Top-level navigation destinations, mirroring the iOS app's tabs.
enum MacSection: String, CaseIterable, Identifiable {
case dashboard
case history
case dictionary
case settings
var id: String { rawValue }
func title(language: AppUILanguage) -> String {
switch self {
case .dashboard: return MacL10n.string("mac.section.dashboard", language: language)
case .history: return MacL10n.string("mac.section.history", language: language)
case .dictionary: return MacL10n.string("mac.section.dictionary", language: language)
case .settings: return MacL10n.string("mac.section.settings", language: language)
}
}
var systemImage: String {
switch self {
case .dashboard: return "square.grid.2x2"
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .settings: return "gearshape"
}
}
}
@MainActor
final class MacDictationViewModel: ObservableObject {
/// Shared instance so the SwiftUI window and the AppKit menu-bar popover
/// (see `MacAppDelegate`) drive the exact same recording / settings state.
static let shared = MacDictationViewModel()
@Published var selectedSection: MacSection = .dashboard
@Published var isRecording = false
@Published var isProcessing = false
@Published var transcript = ""
@Published var statusMessage = ""
@Published var audioLevel: Float = 0
@Published var sessionSeconds: Int = 0
@Published var foregroundAppName: String?
@Published var dictionaryRevision = 0
@Published var autoPasteEnabled: Bool
@Published var hotkeyEnabled: Bool
@Published var config: ProviderConfig
let defaults: UserDefaults
private let recorder = MacAudioRecorder()
private let hotkeyService = MacHotkeyService()
private var levelTimer: Timer?
private var sessionTimer: Timer?
private var cancellables = Set<AnyCancellable>()
let usageStatistics: UsageStatisticsStore
let speechHistory = SpeechHistoryStore.shared
private enum StoredKeys {
static let autoPaste = "mac.autoPasteEnabled"
static let hotkey = "mac.hotkeyEnabled"
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.config = ProviderConfig(defaults: defaults)
self.usageStatistics = UsageStatisticsStore(defaults: defaults)
self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true
self.hotkeyEnabled = defaults.object(forKey: StoredKeys.hotkey) as? Bool ?? true
MacICloudSyncBootstrap.configure(defaults: defaults)
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
wireHotkeyService()
forwardNestedObjectChanges()
}
/// `config` is a nested `ObservableObject`; without forwarding its
/// `objectWillChange`, SwiftUI views that observe only the view model
/// won't refresh when settings (e.g. engine mode / provider) change.
private func forwardNestedObjectChanges() {
config.objectWillChange
.sink { [weak self] in self?.objectWillChange.send() }
.store(in: &cancellables)
usageStatistics.objectWillChange
.sink { [weak self] in self?.objectWillChange.send() }
.store(in: &cancellables)
}
func onAppear() async {
await MacICloudSyncBootstrap.pullIfEnabled()
refreshForegroundAppName()
warmUpQwen3IfNeeded()
}
/// Pre-load MLX weights + Metal shaders so the first dictation is fast.
func warmUpQwen3IfNeeded() {
guard config.engineMode == "local",
MacLocalASRPreferences.backend == .qwen3MLX,
MacLocalASRPreferences.qwen3ModelIsInstalled() else { return }
let path = MacLocalASRPreferences.qwen3ModelPath
Task.detached(priority: .utility) {
_ = try? await MacQwen3ASREngine.shared.prepareIfNeeded(modelPath: path)
}
}
func reloadConfigFromCloud() {
config.reloadFromPersistedStorage()
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
warmUpQwen3IfNeeded()
}
func refreshDictionaryFromCloud() {
dictionaryRevision += 1
}
// MARK: - Derived
var selectableProviders: [LLMProvider] {
LLMProvider.presets.filter {
$0.isUserSelectable && $0.cloudASRStrategy != .localFallback
}
}
var dictionaryTermCount: Int {
_ = dictionaryRevision
return AppGroupStore(defaults: defaults).personalDictionary.entries.count
}
var currentWordCount: Int {
transcript.split { $0 == " " || $0 == "\n" || $0 == "\t" }.count
}
var isCloudMode: Bool { config.engineMode == "cloud" }
var languageLabel: String {
let id = config.localeId.isEmpty ? "zh-CN" : config.localeId
return Locale.current.localizedString(forIdentifier: id) ?? id
}
var sessionTimeLabel: String {
let minutes = sessionSeconds / 60
let seconds = sessionSeconds % 60
if minutes > 0 { return "\(minutes)m \(seconds)s" }
return "\(seconds)s"
}
var qwen3ModelInstalled: Bool {
MacLocalASRPreferences.qwen3ModelIsInstalled()
}
// MARK: - Preferences
func setAutoPasteEnabled(_ enabled: Bool) {
autoPasteEnabled = enabled
defaults.set(enabled, forKey: StoredKeys.autoPaste)
}
func setHotkeyEnabled(_ enabled: Bool) {
hotkeyEnabled = enabled
defaults.set(enabled, forKey: StoredKeys.hotkey)
hotkeyService.setEnabled(enabled)
if enabled { hotkeyService.start() } else { hotkeyService.stop() }
}
func setEngineMode(_ mode: String) {
config.engineMode = mode
if mode == "local" { warmUpQwen3IfNeeded() }
}
// MARK: - Recording
func toggleRecording() {
if isRecording { finishRecording() } else { beginRecording() }
}
func beginRecording() {
guard !isProcessing else { return }
let store = AppGroupStore(defaults: defaults)
MacAppContextService.captureAndPersist(to: store)
refreshForegroundAppName()
do {
try recorder.start()
isRecording = true
transcript = ""
statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage)
startTimers()
} catch {
statusMessage = error.localizedDescription
}
}
func finishRecording() {
guard isRecording else { return }
isRecording = false
isProcessing = true
statusMessage = MacL10n.string("mac.status.transcribing", language: config.uiLanguage)
stopTimers()
audioLevel = 0
let samples = recorder.stop()
let store = AppGroupStore(defaults: defaults)
Task { [weak self] in
guard let self else { return }
do {
let text = try await MacDictationPipeline.run(samples: samples, store: store)
self.transcript = text
let pasted = try self.deliver(text)
self.recordUsage(for: text)
self.speechHistory.append(text: text)
self.statusMessage = self.statusAfterDelivery(pasted: pasted)
} catch {
self.statusMessage = error.localizedDescription
}
self.isProcessing = false
}
}
private func deliver(_ text: String) throws -> Bool {
try MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
}
private func statusAfterDelivery(pasted: Bool) -> String {
let lang = config.uiLanguage
if autoPasteEnabled, pasted {
return MacL10n.string("mac.status.copiedAndPasted", language: lang)
}
if autoPasteEnabled, !pasted {
return MacL10n.string("mac.status.copied", language: lang)
}
return MacL10n.string("mac.status.copied", language: lang)
}
private func wireHotkeyService() {
hotkeyService.onPressBegan = { [weak self] in
self?.beginRecording()
}
hotkeyService.onPressEnded = { [weak self] in
self?.finishRecording()
}
if hotkeyEnabled { hotkeyService.start() }
}
private func startTimers() {
sessionSeconds = 0
let levelTimer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in self.audioLevel = self.recorder.level() }
}
let sessionTimer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
Task { @MainActor in self.sessionSeconds += 1 }
}
RunLoop.main.add(levelTimer, forMode: .common)
RunLoop.main.add(sessionTimer, forMode: .common)
self.levelTimer = levelTimer
self.sessionTimer = sessionTimer
}
private func stopTimers() {
levelTimer?.invalidate()
sessionTimer?.invalidate()
levelTimer = nil
sessionTimer = nil
}
private func recordUsage(for text: String) {
usageStatistics.recordUtterance(
text: text,
duration: TimeInterval(sessionSeconds),
wasTranslation: config.isTranslationEffective
)
}
func copyToClipboard(_ text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
func selectProvider(_ provider: LLMProvider) {
config.apply(preset: provider)
}
func refreshForegroundAppName() {
foregroundAppName = MacAppContextService.frontmostApplicationName()
}
}
+234
View File
@@ -0,0 +1,234 @@
// MacDictionaryView.swift
// OSGKeyboard · Mac
//
// Personal dictionary synced via iCloud KVS with the iOS app. Read-only on
// the desktop (words are authored on iPhone / iPad): grouped cards (native
// `Form`) with search, matching the Settings and History card style.
import SwiftUI
struct MacDictionaryView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@State private var query = ""
@State private var entryPendingDeletion: PersonalDictionary.Entry?
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private var entries: [PersonalDictionary.Entry] {
_ = viewModel.dictionaryRevision
return AppGroupStore(defaults: viewModel.defaults).personalDictionary.entries
}
/// Entries filtered by the search field, grouped by category and sorted
/// (most-used first) mirrors the iOS Personal Dictionary tab.
private var sections: [(category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry])] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let filtered: [PersonalDictionary.Entry]
if trimmed.isEmpty {
filtered = entries
} else {
filtered = entries.filter { entry in
entry.term.lowercased().contains(trimmed)
|| entry.aliases.contains { $0.lowercased().contains(trimmed) }
}
}
let grouped = Dictionary(grouping: filtered, by: { $0.category })
return PersonalDictionary.Entry.Category.allCases.compactMap { category in
guard let bucket = grouped[category], !bucket.isEmpty else { return nil }
let sorted = bucket.sorted {
if $0.usageCount != $1.usageCount { return $0.usageCount > $1.usageCount }
return $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending
}
return (category, sorted)
}
}
var body: some View {
Group {
if entries.isEmpty {
emptyState
} else {
form
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(palette.background)
.task {
await MacICloudSyncBootstrap.dictionarySync.pullAndMergeIfEnabled()
viewModel.refreshDictionaryFromCloud()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
viewModel.refreshDictionaryFromCloud()
}
}
// MARK: - Grouped cards
private var form: some View {
Form {
if sections.isEmpty {
Section {
Text(MacL10n.string("mac.dict.noMatch", language: lang))
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
}
} else {
ForEach(sections, id: \.category) { section in
Section(MacL10n.string(section.category.labelKey, language: lang)) {
ForEach(section.items) { entry in
row(entry)
}
}
}
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(palette.background)
.safeAreaInset(edge: .top, spacing: 0) { centeredSearchField }
.confirmationDialog(
MacL10n.string("mac.dict.deleteTitle", language: lang),
isPresented: deletionDialogBinding,
titleVisibility: .visible
) {
Button(MacL10n.string("mac.delete", language: lang), role: .destructive) {
if let entry = entryPendingDeletion { delete(entry) }
entryPendingDeletion = nil
}
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {
entryPendingDeletion = nil
}
} message: {
Text(MacL10n.string("mac.dict.deleteMessage", language: lang))
}
}
private var deletionDialogBinding: Binding<Bool> {
Binding(
get: { entryPendingDeletion != nil },
set: { if !$0 { entryPendingDeletion = nil } }
)
}
private var centeredSearchField: some View {
HStack {
Spacer()
HStack(spacing: Spacing.xs) {
Image(systemName: "magnifyingglass")
.foregroundStyle(palette.textTertiary)
TextField(MacL10n.string("mac.dict.search", language: lang), text: $query)
.textFieldStyle(.plain)
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.frame(width: 240)
.macGlassSurface(in: Capsule(), fillOpacity: 0.72)
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
Spacer()
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.background(palette.background)
}
private func row(_ entry: PersonalDictionary.Entry) -> some View {
MacDictionaryRow(
entry: entry,
subtitle: subtitle(for: entry),
language: lang,
copy: { viewModel.copyToClipboard(entry.term) },
delete: { entryPendingDeletion = entry }
)
}
/// "Manual · ×3 · k8s / kube" same metadata line as iOS.
private func subtitle(for entry: PersonalDictionary.Entry) -> String? {
var parts: [String] = [MacL10n.string(entry.source.labelKey, language: lang)]
if entry.usageCount > 1 {
parts.append("×\(entry.usageCount)")
}
if !entry.aliases.isEmpty {
parts.append(entry.aliases.joined(separator: " / "))
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
// MARK: - Empty state
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Image(systemName: "character.book.closed")
.font(.system(size: 34))
.foregroundStyle(palette.textTertiary.opacity(0.6))
Text(MacL10n.string("mac.dict.empty", language: lang))
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
Text(MacL10n.string("mac.dict.emptyBody", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: 360)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, Spacing.xl)
}
private func delete(_ entry: PersonalDictionary.Entry) {
let store = AppGroupStore(defaults: viewModel.defaults)
store.deletePersonalDictionaryEntry(id: entry.id)
viewModel.refreshDictionaryFromCloud()
Task {
try? await MacICloudSyncBootstrap.dictionarySync.pushLocalIfEnabled(store.personalDictionary)
}
}
}
private struct MacDictionaryRow: View {
let entry: PersonalDictionary.Entry
let subtitle: String?
let language: AppUILanguage
let copy: () -> Void
let delete: () -> Void
@Environment(\.themePalette) private var palette
@State private var isHovering = false
var body: some View {
HStack(alignment: .top, spacing: Spacing.sm) {
VStack(alignment: .leading, spacing: 2) {
Text(entry.term)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.lineLimit(1)
}
}
Spacer(minLength: Spacing.sm)
Button(action: delete) {
Image(systemName: "trash")
.font(.system(size: 13, weight: .medium))
.frame(width: 24, height: 24)
}
.buttonStyle(.borderless)
.foregroundStyle(palette.textTertiary)
.opacity(isHovering ? 1 : 0)
.accessibilityLabel(MacL10n.string("mac.delete", language: language))
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onHover { isHovering = $0 }
.contextMenu {
Button(action: copy) {
Label(MacL10n.string("mac.copy", language: language), systemImage: "doc.on.doc")
}
Button(role: .destructive, action: delete) {
Label(MacL10n.string("mac.delete", language: language), systemImage: "trash")
}
}
}
}
+163
View File
@@ -0,0 +1,163 @@
// MacHistoryView.swift
// OSGKeyboard · Mac
//
// Single-column, day-grouped transcript log rendered as grouped cards (the
// same native `Form` container as Settings). Every entry shows its full text
// inline no master/detail split, so content never pushes the sidebar out.
import SwiftUI
struct MacHistoryView: View {
@ObservedObject var viewModel: MacDictationViewModel
@ObservedObject private var historyStore = SpeechHistoryStore.shared
@Environment(\.themePalette) private var palette
@State private var showClearConfirmation = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private static let dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
return f
}()
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .none
f.timeStyle = .short
return f
}()
var body: some View {
Group {
if historyStore.entries.isEmpty {
emptyState
} else {
form
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(palette.background)
}
// MARK: - Grouped cards
private var form: some View {
Form {
ForEach(historyStore.groupedByDay, id: \.day) { group in
Section(Self.dayFormatter.string(from: group.day)) {
ForEach(group.items) { entry in
row(entry)
}
}
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(palette.background)
.safeAreaInset(edge: .top, spacing: 0) { toolbar }
.confirmationDialog(
MacL10n.string("mac.history.clearTitle", language: lang),
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button(MacL10n.string("mac.history.clearConfirm", language: lang), role: .destructive) {
historyStore.clearAll()
}
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {}
} message: {
Text(MacL10n.string("mac.history.clearMessage", language: lang))
}
}
private var toolbar: some View {
HStack {
Spacer()
Button {
showClearConfirmation = true
} label: {
Label(MacL10n.string("mac.history.clearConfirm", language: lang), systemImage: "trash")
.font(TypeStyle.caption)
}
.buttonStyle(.borderless)
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.background(palette.background)
}
private func row(_ entry: SpeechHistoryEntry) -> some View {
MacHistoryRow(
entry: entry,
time: Self.timeFormatter.string(from: entry.createdAt),
language: lang,
copy: { viewModel.copyToClipboard(entry.text) },
delete: { historyStore.delete(id: entry.id) }
)
}
// MARK: - Empty state
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Image(systemName: "text.bubble")
.font(.system(size: 34))
.foregroundStyle(palette.textTertiary.opacity(0.6))
Text(MacL10n.string("mac.history.empty", language: lang))
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct MacHistoryRow: View {
let entry: SpeechHistoryEntry
let time: String
let language: AppUILanguage
let copy: () -> Void
let delete: () -> Void
@Environment(\.themePalette) private var palette
@State private var isHovering = false
var body: some View {
HStack(alignment: .top, spacing: Spacing.sm) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(time)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.monospacedDigit()
Text(entry.text)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: Spacing.sm)
Button(action: delete) {
Image(systemName: "trash")
.font(.system(size: 13, weight: .medium))
.frame(width: 24, height: 24)
}
.buttonStyle(.borderless)
.foregroundStyle(palette.textTertiary)
.opacity(isHovering ? 1 : 0)
.accessibilityLabel(MacL10n.string("mac.delete", language: language))
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onHover { isHovering = $0 }
.contextMenu {
Button(action: copy) {
Label(MacL10n.string("mac.copy", language: language), systemImage: "doc.on.doc")
}
Button(role: .destructive, action: delete) {
Label(MacL10n.string("mac.delete", language: language), systemImage: "trash")
}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
// MacHotkeyService.swift
// OSGKeyboard · Mac
//
// Global hold-to-talk: while Option () is held, dictation runs. Mirrors
// Typeless / SayIt push-to-talk from any foreground app.
import AppKit
import Foundation
@MainActor
final class MacHotkeyService {
var onPressBegan: (() -> Void)?
var onPressEnded: (() -> Void)?
private var globalFlagsMonitor: Any?
private var localFlagsMonitor: Any?
private var optionHeld = false
private var isEnabled = true
func setEnabled(_ enabled: Bool) {
isEnabled = enabled
if !enabled, optionHeld {
optionHeld = false
onPressEnded?()
}
}
func start() {
guard globalFlagsMonitor == nil else { return }
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
globalFlagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
Task { @MainActor in self?.handleFlagsChanged(event) }
}
localFlagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
Task { @MainActor in self?.handleFlagsChanged(event) }
return event
}
}
func stop() {
if let globalFlagsMonitor {
NSEvent.removeMonitor(globalFlagsMonitor)
self.globalFlagsMonitor = nil
}
if let localFlagsMonitor {
NSEvent.removeMonitor(localFlagsMonitor)
self.localFlagsMonitor = nil
}
if optionHeld {
optionHeld = false
onPressEnded?()
}
}
private func handleFlagsChanged(_ event: NSEvent) {
guard isEnabled else { return }
let optionDown = event.modifierFlags.contains(.option)
if optionDown, !optionHeld {
optionHeld = true
onPressBegan?()
} else if !optionDown, optionHeld {
optionHeld = false
onPressEnded?()
}
}
}
@@ -0,0 +1,40 @@
// MacICloudSyncBootstrap.swift
// OSGKeyboard · Mac
//
// Wires the shared iCloud KVS sync layer to macOS UserDefaults so settings
// and personal dictionary stay aligned with the iOS app.
import Foundation
@MainActor
enum MacICloudSyncBootstrap {
private static var configured = false
private static var cloudSync: AppCloudSync?
static func configure(defaults: UserDefaults) {
guard !configured else { return }
configured = true
let makeStore = { AppGroupStore(defaults: defaults) }
cloudSync = AppCloudSync(makeStore: makeStore, historyDefaults: { defaults })
cloudSync?.startObservingExternalChanges()
}
static func pullIfEnabled() async {
await cloudSync?.pullAllIfEnabled()
}
static var settingsSync: SettingsCloudSync {
if let cloudSync {
return cloudSync.settingsSyncService
}
return SettingsCloudSync(makeStore: { AppGroupStore(defaults: .standard) })
}
static var dictionarySync: PersonalDictionaryCloudSync {
cloudSync?.dictionarySyncService ?? PersonalDictionaryCloudSync(makeStore: { AppGroupStore(defaults: .standard) })
}
static var appCloudSync: AppCloudSync {
cloudSync ?? AppCloudSync.shared
}
}
+180
View File
@@ -0,0 +1,180 @@
// MacICloudSyncRows.swift
// OSGKeyboard · Mac
//
// iCloud sync toggles for settings and personal dictionary same KVS
// keys and merge rules as the iOS settings page. Rendered as native Form
// rows (Toggle + optional action / error) so they sit inside a grouped
// `Form` section and match System Settings exactly.
import SwiftUI
struct MacSettingsICloudSyncRow: View {
let defaults: UserDefaults
let language: AppUILanguage
@Environment(\.themePalette) private var palette
@State private var isEnabled = false
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
@State private var isSyncingNow = false
var body: some View {
Toggle(isOn: toggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(MacL10n.string("mac.sync.settingsTitle", language: language))
Text(MacL10n.string("mac.sync.settingsSubtitle", language: language))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(palette.accent)
.disabled(isApplyingToggle)
.onAppear { reloadFromStore() }
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
reloadFromStore()
}
if isEnabled {
Button {
syncNow()
} label: {
HStack(spacing: Spacing.xs) {
if isSyncingNow { ProgressView().controlSize(.small) }
Text(MacL10n.string("mac.sync.syncNow", language: language))
}
}
.disabled(isSyncingNow || isApplyingToggle)
}
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.danger)
}
}
private var toggleBinding: Binding<Bool> {
Binding(
get: { isEnabled },
set: { newValue in
guard newValue != isEnabled else { return }
newValue ? enableSync() : disableSync()
}
)
}
private func reloadFromStore() {
isEnabled = AppGroupStore(defaults: defaults).settingsICloudSyncEnabled
}
private func enableSync() {
isApplyingToggle = true
syncErrorMessage = nil
Task {
do {
try await MacICloudSyncBootstrap.settingsSync.enableSync()
reloadFromStore()
} catch {
isEnabled = false
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
}
}
private func disableSync() {
MacICloudSyncBootstrap.settingsSync.disableSync()
isEnabled = false
syncErrorMessage = nil
}
private func syncNow() {
isSyncingNow = true
syncErrorMessage = nil
Task {
do {
try await MacICloudSyncBootstrap.appCloudSync.syncNow()
} catch {
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isSyncingNow = false
}
}
}
struct MacDictionaryICloudSyncRow: View {
let defaults: UserDefaults
let language: AppUILanguage
@Environment(\.themePalette) private var palette
@State private var isEnabled = false
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
var body: some View {
Toggle(isOn: toggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(MacL10n.string("mac.sync.dictTitle", language: language))
Text(MacL10n.string("mac.sync.dictSubtitle", language: language))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(palette.accent)
.disabled(isApplyingToggle)
.onAppear { reloadFromStore() }
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
reloadFromStore()
}
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.danger)
}
}
private var toggleBinding: Binding<Bool> {
Binding(
get: { isEnabled },
set: { newValue in
guard newValue != isEnabled else { return }
newValue ? enableSync() : disableSync()
}
)
}
private func reloadFromStore() {
isEnabled = AppGroupStore(defaults: defaults).personalDictionaryICloudSyncEnabled
}
private func enableSync() {
isApplyingToggle = true
syncErrorMessage = nil
Task {
do {
try await MacICloudSyncBootstrap.dictionarySync.enableSync()
reloadFromStore()
} catch let error as PersonalDictionaryCloudSyncError {
isEnabled = false
if case .payloadTooLarge = error {
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
} else {
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
} catch {
isEnabled = false
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
}
}
private func disableSync() {
MacICloudSyncBootstrap.dictionarySync.disableSync()
isEnabled = false
syncErrorMessage = nil
}
}
+17
View File
@@ -0,0 +1,17 @@
// MacL10n.swift
// OSGKeyboard · Mac
//
// Bilingual UI strings for the macOS app. Reuses Shared.strings and
// respects the same `AppUILanguage` override as the iOS settings page.
import Foundation
enum MacL10n {
static func string(_ key: String, language: AppUILanguage? = nil) -> String {
SharedL10n.string(key, language: language)
}
static func format(_ key: String, language: AppUILanguage? = nil, _ args: CVarArg...) -> String {
SharedL10n.format(key, language: language, args)
}
}
+103
View File
@@ -0,0 +1,103 @@
// MacLocalASRService.swift
// OSGKeyboard · Mac
//
// On-device ASR for macOS. Primary: Qwen3-ASR-1.7B (MLX via mlx-swift-asr).
// Falls back to Apple Speech when Qwen3 weights are absent or backend is Apple Speech.
import Foundation
enum MacLocalASRBackend: String, Sendable, CaseIterable {
case qwen3MLX
case appleSpeech
}
enum MacLocalASRError: Error, LocalizedError {
case qwen3ModelMissing
case qwen3LoadFailed(String)
case qwen3InferenceFailed(String)
case speechDenied
case speechFailed(String)
case emptyTranscript
var errorDescription: String? {
switch self {
case .qwen3ModelMissing:
return MacL10n.string("mac.error.qwen3ModelMissing")
case .qwen3LoadFailed(let detail):
return MacL10n.format("mac.error.qwen3LoadFailed", detail)
case .qwen3InferenceFailed(let detail):
return MacL10n.format("mac.error.qwen3InferenceFailed", detail)
case .speechDenied:
return "Speech recognition permission denied"
case .speechFailed(let detail):
return detail
case .emptyTranscript:
return MacL10n.string("mac.error.emptyTranscript")
}
}
}
enum MacLocalASRPreferences {
static let backendKey = "mac.localASR.backend"
static let qwen3ModelPathKey = "mac.localASR.qwen3ModelPath"
static var backend: MacLocalASRBackend {
get {
guard let raw = UserDefaults.standard.string(forKey: backendKey),
let value = MacLocalASRBackend(rawValue: raw) else {
return .qwen3MLX
}
return value
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: backendKey) }
}
static var qwen3ModelPath: String {
get { UserDefaults.standard.string(forKey: qwen3ModelPathKey) ?? defaultQwen3ModelPath }
set { UserDefaults.standard.set(newValue, forKey: qwen3ModelPathKey) }
}
/// Default install location for MLX-converted Qwen3-ASR weights.
static var defaultQwen3ModelPath: String {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return appSupport.appendingPathComponent("OSGKeyboard/models/qwen3-asr-1.7b-mlx", isDirectory: true).path
}
static func qwen3ModelIsInstalled(at path: String = qwen3ModelPath) -> Bool {
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else {
return false
}
let fm = FileManager.default
let config = (path as NSString).appendingPathComponent("config.json")
let weights = (path as NSString).appendingPathComponent("model.safetensors")
guard fm.fileExists(atPath: config), fm.fileExists(atPath: weights) else {
return false
}
let names = (try? fm.contentsOfDirectory(atPath: path)) ?? []
return names.contains("vocab.json") && names.contains("merges.txt")
}
}
enum MacLocalASRService {
/// Transcribe using the user's preferred local backend with automatic
/// fallback to Apple Speech when Qwen3 weights are not present.
static func transcribe(samples: [Float], locale: Locale) async throws -> String {
let preferQwen3 = MacLocalASRPreferences.backend == .qwen3MLX
if preferQwen3, MacLocalASRPreferences.qwen3ModelIsInstalled() {
do {
return try await MacQwen3LocalASR.transcribe(
samples: samples,
sampleRate: 16_000,
locale: locale,
modelPath: MacLocalASRPreferences.qwen3ModelPath
)
} catch MacLocalASRError.qwen3ModelMissing {
// Fall through to Apple Speech when weights are absent.
} catch {
throw error
}
}
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
}
}
+94
View File
@@ -0,0 +1,94 @@
// MacQwen3ASREngine.swift
// OSGKeyboard · Mac
//
// Singleton actor that loads, warms up, and runs Qwen3-ASR via mlx-swift-asr.
// Model load + Metal JIT warmup take several seconds call `prepareIfNeeded`
// at launch so the first dictation is fast.
import Foundation
import MLXASR
/// Lifecycle of the on-disk MLX model inside the app process.
enum MacQwen3EnginePhase: Sendable, Equatable {
case idle
case loading
case ready
case failed(String)
}
actor MacQwen3ASREngine {
static let shared = MacQwen3ASREngine()
private var stt: Qwen3ASRSTT?
private var loadedModelPath: String?
private(set) var phase: MacQwen3EnginePhase = .idle
private init() {}
/// Load and warm up the model when the path changes or nothing is loaded yet.
func prepareIfNeeded(modelPath: String) async throws {
if loadedModelPath == modelPath, stt != nil, phase == .ready { return }
phase = .loading
stt = nil
loadedModelPath = nil
let directory = URL(fileURLWithPath: modelPath, isDirectory: true)
do {
let instance = try await Qwen3ASRSTT.loadWithWarmup(from: directory)
stt = instance
loadedModelPath = modelPath
phase = .ready
} catch {
let detail = error.localizedDescription
phase = .failed(detail)
throw MacLocalASRError.qwen3LoadFailed(detail)
}
}
/// Transcribe mono 16 kHz float PCM. Ensures the model is loaded first.
func transcribe(
samples: [Float],
language: String?,
modelPath: String
) async throws -> String {
try await prepareIfNeeded(modelPath: modelPath)
guard let stt else {
throw MacLocalASRError.qwen3LoadFailed("Engine not initialized")
}
let result = try await stt.transcribe(audio: samples, language: language)
let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
throw MacLocalASRError.emptyTranscript
}
return text
}
/// Drop cached weights (e.g. after the user changes the model folder).
func unload() {
stt = nil
loadedModelPath = nil
phase = .idle
}
}
enum MacQwen3LanguageHint {
/// Map persisted BCP-47 locale ids to Qwen3 prompt language names.
/// Returns `nil` for auto-detect.
static func from(locale: Locale) -> String? {
let raw = locale.identifier.lowercased()
if raw.isEmpty || raw == "auto" { return nil }
if raw.hasPrefix("zh") { return "Chinese" }
if raw.hasPrefix("en") { return "English" }
if raw.hasPrefix("ja") { return "Japanese" }
if raw.hasPrefix("ko") { return "Korean" }
if raw.hasPrefix("fr") { return "French" }
if raw.hasPrefix("de") { return "German" }
if raw.hasPrefix("es") { return "Spanish" }
if raw.hasPrefix("pt") { return "Portuguese" }
if raw.hasPrefix("ru") { return "Russian" }
if raw.hasPrefix("ar") { return "Arabic" }
return nil
}
}
+39
View File
@@ -0,0 +1,39 @@
// MacQwen3LocalASR.swift
// OSGKeyboard · Mac
//
// Qwen3-ASR-1.7B (MLX) via mlx-swift-asr. Expects a converted model directory
// containing config.json, model.safetensors, and tokenizer files.
import Foundation
enum MacQwen3LocalASR {
/// Transcribe with Qwen3-ASR MLX weights at `modelPath`.
static func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
modelPath: String
) async throws -> String {
guard MacLocalASRPreferences.qwen3ModelIsInstalled(at: modelPath) else {
throw MacLocalASRError.qwen3ModelMissing
}
guard sampleRate == 16_000 else {
throw MacLocalASRError.qwen3InferenceFailed(
"Qwen3-ASR expects 16 kHz audio (got \(sampleRate) Hz)"
)
}
let language = MacQwen3LanguageHint.from(locale: locale)
do {
return try await MacQwen3ASREngine.shared.transcribe(
samples: samples,
language: language,
modelPath: modelPath
)
} catch let error as MacLocalASRError {
throw error
} catch {
throw MacLocalASRError.qwen3InferenceFailed(error.localizedDescription)
}
}
}
+131
View File
@@ -0,0 +1,131 @@
// MacRootView.swift
// OSGKeyboard · Mac
//
// Window shell built on the native `NavigationSplitView` so the desktop app
// reads like macOS System Settings: traffic lights float over a borderless
// sidebar, no separate title bar. The same structure lifts cleanly onto
// iPadOS later (NavigationSplitView is cross-platform).
import SwiftUI
struct MacRootView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@Environment(\.openWindow) private var openWindow
@State private var columnVisibility: NavigationSplitViewVisibility = .all
private var uiLanguage: AppUILanguage { viewModel.config.uiLanguage }
/// `List` selection is optional; keep the view model's non-optional section
/// in sync without letting a nil selection blank the detail pane.
private var selection: Binding<MacSection?> {
Binding(
get: { viewModel.selectedSection },
set: { if let new = $0 { viewModel.selectedSection = new } }
)
}
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
sidebar
.navigationSplitViewColumnWidth(MacMetrics.sidebarWidth)
} detail: {
detail
}
.navigationSplitViewStyle(.balanced)
.frame(minWidth: 860, minHeight: 600)
.onAppear {
// Let the AppKit status-bar popover reopen this window on demand.
MacWindowBridge.shared.open = { openWindow(id: "main") }
}
}
// MARK: - Sidebar
private var sidebar: some View {
VStack(spacing: 0) {
brandHeader
VStack(spacing: 4) {
ForEach(MacSection.allCases) { section in
sidebarRow(section)
}
}
.padding(.horizontal, MacMetrics.sidebarInset)
Spacer()
devicesFooter
}
.background(palette.surfaceMuted)
}
private func sidebarRow(_ section: MacSection) -> some View {
let isSelected = viewModel.selectedSection == section
return Button {
viewModel.selectedSection = section
} label: {
Label(section.title(language: uiLanguage), systemImage: section.systemImage)
.font(.system(size: 13))
.foregroundStyle(isSelected ? palette.textOnAccent : palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.background(
isSelected ? palette.accent : Color.clear,
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
/// Brand mark pinned above the nav list. Top padding clears the traffic
/// lights that now float over the borderless sidebar.
private var brandHeader: some View {
HStack {
Image("OSGLogoWide")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(height: 30)
.foregroundStyle(palette.accent)
.accessibilityLabel("OSGKeyboard")
Spacer()
}
.padding(.leading, MacMetrics.sidebarContentInset)
.padding(.trailing, MacMetrics.sidebarInset)
.padding(.top, Spacing.lg)
.padding(.bottom, Spacing.lg)
}
private var devicesFooter: some View {
Label(
MacL10n.string("mac.devices", language: uiLanguage),
systemImage: "laptopcomputer.and.iphone"
)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, MacMetrics.sidebarInset)
.padding(.vertical, Spacing.sm)
}
// MARK: - Detail
private var detail: some View {
VStack(spacing: 0) {
Group {
switch viewModel.selectedSection {
case .dashboard: DashboardView(viewModel: viewModel)
case .history: MacHistoryView(viewModel: viewModel)
case .dictionary: MacDictionaryView(viewModel: viewModel)
case .settings: MacSettingsView(viewModel: viewModel)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
MacStatusFooter(viewModel: viewModel)
}
.background(palette.background)
}
}
+431
View File
@@ -0,0 +1,431 @@
// MacSettingsView.swift
// OSGKeyboard · Mac
//
// Settings built on the native grouped `Form` the same container macOS
// System Settings uses. This gives system-accurate cards, dividers, insets
// and right-aligned controls for free, on both light and dark.
import SwiftUI
#if os(macOS)
import AppKit
#endif
struct MacSettingsView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@AppStorage(MacAppearancePreference.storageKey)
private var appearanceRaw = MacAppearancePreference.system.rawValue
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
@State private var showProviderPicker = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private let recognitionLocales: [(id: String, key: String, fallback: String)] = [
("auto", "locale.auto", "Auto"),
("zh-Hans", "locale.zh-Hans", "Chinese (Simplified)"),
("zh-Hant", "locale.zh-Hant", "Chinese (Traditional)"),
("en-US", "locale.en-US", "English (US)"),
("ja-JP", "locale.ja-JP", "Japanese"),
("ko-KR", "locale.ko-KR", "Korean")
]
var body: some View {
Form {
generalSection
recognitionSection
if viewModel.config.engineMode == "cloud" {
providerSection
}
if viewModel.config.engineMode == "local" {
qwen3Section
}
inputSection
syncSection
}
.formStyle(.grouped)
.tint(palette.accent)
.scrollContentBackground(.hidden)
.background(palette.background)
.onAppear { refreshAccessibilityState() }
}
// MARK: - General
private var generalSection: some View {
Section(MacL10n.string("mac.settings.general", language: lang)) {
Picker(MacL10n.string("mac.settings.appearance", language: lang), selection: $appearanceRaw) {
ForEach(MacAppearancePreference.allCases) { pref in
Text(MacL10n.string(pref.labelKey, language: lang)).tag(pref.rawValue)
}
}
Picker(MacL10n.string("mac.settings.interfaceLanguage", language: lang), selection: interfaceLanguageBinding) {
ForEach(AppUILanguage.allCases, id: \.self) { language in
Text(MacL10n.string(language.labelKey, language: lang)).tag(language.rawValue)
}
}
Picker(MacL10n.string("mac.settings.recognitionLanguage", language: lang), selection: recognitionLanguageBinding) {
ForEach(recognitionLocales, id: \.id) { locale in
Text(localeLabel(locale)).tag(locale.id)
}
}
}
}
// MARK: - iCloud
private var syncSection: some View {
Section("iCloud") {
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang)
}
}
// MARK: - Cloud provider
private var providerSection: some View {
Section(MacL10n.string("mac.settings.cloudProvider", language: lang)) {
LabeledContent(MacL10n.string("mac.settings.service", language: lang)) {
Button {
showProviderPicker = true
} label: {
HStack(spacing: 6) {
providerLogo(currentProvider.id)
Text(currentProvider.name)
.foregroundStyle(palette.textPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: $showProviderPicker, arrowEdge: .bottom) {
providerPickerList
}
}
LabeledContent {
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-…")) {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
LabeledContent {
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.model", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.model", language: lang))
}
}
}
// MARK: - Recognition method
private var recognitionSection: some View {
Section(MacL10n.string("mac.settings.recognition", language: lang)) {
methodRow(
title: MacL10n.string("mac.settings.cloudEngine", language: lang),
subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang),
systemImage: "cloud",
selected: viewModel.config.engineMode == "cloud"
) { viewModel.setEngineMode("cloud") }
methodRow(
title: MacL10n.string("mac.settings.localEngine", language: lang),
subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang),
systemImage: "cpu",
selected: viewModel.config.engineMode == "local"
) { viewModel.setEngineMode("local") }
if viewModel.config.engineMode == "local", !viewModel.qwen3ModelInstalled {
Label(MacL10n.string("mac.settings.qwen3Missing", language: lang), systemImage: "exclamationmark.triangle")
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
}
}
}
// MARK: - Hotkey / paste
private var inputSection: some View {
Section(MacL10n.string("mac.settings.input", language: lang)) {
Toggle(isOn: hotkeyBinding) {
rowLabel(
MacL10n.string("mac.settings.hotkey", language: lang),
subtitle: MacL10n.string("mac.settings.hotkeyDesc", language: lang)
)
}
Toggle(isOn: autoPasteBinding) {
rowLabel(
MacL10n.string("mac.settings.autoPaste", language: lang),
subtitle: MacL10n.string("mac.settings.autoPasteDesc", language: lang)
)
}
LabeledContent {
HStack(spacing: Spacing.sm) {
Label(
accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded,
systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle"
)
.font(TypeStyle.caption)
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) {
openAccessibilitySettings()
}
}
} label: {
rowLabel(
MacL10n.string("mac.settings.accessibility", language: lang),
subtitle: MacL10n.string("mac.settings.accessibilityDesc", language: lang)
)
}
}
}
// MARK: - Qwen3 model path
private var qwen3Section: some View {
Section {
HStack(spacing: Spacing.sm) {
TextField("", text: qwen3PathBinding, prompt: Text(verbatim: "~/Models/Qwen3-ASR"))
.macFieldStyle()
Button(MacL10n.string("mac.settings.qwen3Browse", language: lang)) {
pickQwen3Folder()
}
}
} header: {
Text(MacL10n.string("mac.settings.qwen3Model", language: lang))
} footer: {
Text(MacL10n.string("mac.settings.qwen3ModelDesc", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
}
// MARK: - Row helpers
private func rowLabel(_ title: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
Text(subtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func methodRow(
title: String,
subtitle: String,
systemImage: String,
selected: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: Spacing.sm) {
Image(systemName: systemImage)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
.frame(width: 26)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.foregroundStyle(palette.textPrimary)
Text(subtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: Spacing.sm)
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var currentProvider: LLMProvider {
viewModel.selectableProviders.first { $0.id == viewModel.config.providerId }
?? viewModel.selectableProviders.first
?? LLMProvider.presets[0]
}
/// Custom dropdown list shown in a popover. SwiftUI's `Menu` label / items
/// silently drop bundled (non-SF-Symbol) images on macOS, so we render the
/// brand marks in a plain view stack instead.
private var providerPickerList: some View {
VStack(spacing: 0) {
ForEach(viewModel.selectableProviders) { provider in
Button {
viewModel.selectProvider(provider)
showProviderPicker = false
} label: {
HStack(spacing: Spacing.sm) {
providerLogo(provider.id)
Text(provider.name)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: Spacing.md)
if provider.id == currentProvider.id {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.accent)
}
}
.padding(.horizontal, Spacing.md)
.frame(height: 34)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.padding(.vertical, Spacing.xs)
.frame(width: 260)
}
/// Brand mark tinted to the current label colour (black on light / white
/// on dark). Template rendering + an explicit frame make the vector assets
/// resolve at a text-matched size inside the pop-up menu without a size
/// hint they collapse to zero and disappear.
@ViewBuilder
private func providerLogo(_ providerId: String) -> some View {
if let asset = ProviderLogo.assetName(for: providerId) {
Image(asset)
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(palette.textPrimary)
}
}
@ViewBuilder
private func providerLabel(_ provider: LLMProvider) -> some View {
Label {
Text(provider.name)
} icon: {
providerLogo(provider.id)
}
}
// MARK: - Bindings
private var interfaceLanguageBinding: Binding<String> {
Binding(
get: { viewModel.config.uiLanguage.rawValue },
set: { viewModel.config.uiLanguage = AppUILanguage(rawValue: $0) ?? .auto }
)
}
private var providerBinding: Binding<String> {
Binding(
get: { viewModel.config.providerId },
set: { newId in
if let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) {
viewModel.selectProvider(provider)
}
}
)
}
private var recognitionLanguageBinding: Binding<String> {
Binding(
get: {
let current = viewModel.config.localeId
return current.isEmpty ? "auto" : current
},
set: { newValue in
viewModel.config.localeId = newValue == "auto" ? "" : newValue
}
)
}
private var hotkeyBinding: Binding<Bool> {
Binding(
get: { viewModel.hotkeyEnabled },
set: { viewModel.setHotkeyEnabled($0) }
)
}
private var autoPasteBinding: Binding<Bool> {
Binding(
get: { viewModel.autoPasteEnabled },
set: { viewModel.setAutoPasteEnabled($0) }
)
}
private var qwen3PathBinding: Binding<String> {
Binding(
get: { MacLocalASRPreferences.qwen3ModelPath },
set: { newPath in
MacLocalASRPreferences.qwen3ModelPath = newPath
Task { await MacQwen3ASREngine.shared.unload() }
viewModel.warmUpQwen3IfNeeded()
}
)
}
// MARK: - AppKit actions (macOS only)
private func openAccessibilitySettings() {
#if os(macOS)
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
NSWorkspace.shared.open(url)
}
refreshAccessibilityState()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
refreshAccessibilityState()
}
#endif
}
private func refreshAccessibilityState() {
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
}
private func localeLabel(_ locale: (id: String, key: String, fallback: String)) -> String {
let resolved = AppUILanguage.localizedString(
locale.key,
tableName: nil,
bundle: .main,
language: lang
)
return resolved == locale.key ? locale.fallback : resolved
}
private var accessibilityStatusGranted: String {
lang.resolvedLanguageCode().hasPrefix("zh") ? "已授权" : "Granted"
}
private var accessibilityStatusNeeded: String {
lang.resolvedLanguageCode().hasPrefix("zh") ? "未授权" : "Needed"
}
private func pickQwen3Folder() {
#if os(macOS)
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.begin { response in
guard response == .OK, let url = panel.url else { return }
MacLocalASRPreferences.qwen3ModelPath = url.path
Task { await MacQwen3ASREngine.shared.unload() }
viewModel.warmUpQwen3IfNeeded()
}
#endif
}
}
+61
View File
@@ -0,0 +1,61 @@
// MacSpeechLocalASR.swift
// OSGKeyboard · Mac
//
// Apple Speech framework fallback for local engine mode. Writes PCM to a
// temp WAV and runs `SFSpeechURLRecognitionRequest`.
import AVFoundation
import Foundation
import Speech
enum MacSpeechLocalASR {
static func transcribe(samples: [Float], locale: Locale) async throws -> String {
let auth = await requestAuthorization()
guard auth == .authorized else { throw MacLocalASRError.speechDenied }
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: 16_000)
defer { try? FileManager.default.removeItem(at: wavURL) }
let recognizer = SFSpeechRecognizer(locale: locale) ?? SFSpeechRecognizer()
guard let recognizer, recognizer.isAvailable else {
throw MacLocalASRError.speechFailed("Speech recognizer unavailable")
}
return try await withCheckedThrowingContinuation { continuation in
let request = SFSpeechURLRecognitionRequest(url: wavURL)
request.shouldReportPartialResults = false
request.requiresOnDeviceRecognition = true
recognizer.recognitionTask(with: request) { result, error in
if let error {
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
return
}
guard let result, result.isFinal else { return }
let text = result.bestTranscription.formattedString
.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty {
continuation.resume(throwing: MacLocalASRError.emptyTranscript)
} else {
continuation.resume(returning: text)
}
}
}
}
private static func requestAuthorization() async -> SFSpeechRecognizerAuthorizationStatus {
await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { status in
continuation.resume(returning: status)
}
}
}
private static func writeTemporaryWAV(samples: [Float], sampleRate: Int) throws -> URL {
let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("osg-mac-asr-\(UUID().uuidString).wav")
try wav.write(to: url)
return url
}
}
@@ -0,0 +1,61 @@
// MacTextInsertionService.swift
// OSGKeyboard · Mac
//
// Inserts transcribed text into the frontmost app: clipboard first, then
// a synthetic V (SayIt / Typeless-style). Requires Accessibility trust.
import AppKit
@preconcurrency import ApplicationServices
import Carbon
import Foundation
enum MacTextInsertionService {
enum InsertionError: Error, LocalizedError {
case accessibilityNotGranted
var errorDescription: String? {
MacL10n.string("mac.error.accessibilityRequired")
}
}
static var isAccessibilityTrusted: Bool {
AXIsProcessTrusted()
}
@discardableResult
static func requestAccessibilityIfNeeded() -> Bool {
if AXIsProcessTrusted() { return true }
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
let options = [promptKey: true] as CFDictionary
return AXIsProcessTrustedWithOptions(options)
}
/// Copy to pasteboard and optionally simulate V in the front app.
static func insert(
_ text: String,
autoPaste: Bool
) throws -> Bool {
guard !text.isEmpty else { return false }
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
guard autoPaste else { return false }
guard AXIsProcessTrusted() else { throw InsertionError.accessibilityNotGranted }
Thread.sleep(forTimeInterval: 0.08)
postCommandV()
return true
}
private static func postCommandV() {
let source = CGEventSource(stateID: .combinedSessionState)
let keyCode = CGKeyCode(kVK_ANSI_V)
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true)
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
keyDown?.flags = CGEventFlags.maskCommand
keyUp?.flags = CGEventFlags.maskCommand
keyDown?.post(tap: CGEventTapLocation.cghidEventTap)
keyUp?.post(tap: CGEventTapLocation.cghidEventTap)
}
}
+49
View File
@@ -0,0 +1,49 @@
// MacTheme.swift
// OSGKeyboard · Mac
//
// System-native colour palette for the desktop app. Instead of the custom
// near-black brand palette, the Mac app maps every design token onto AppKit
// semantic colours (`Color(nsColor:)`), which adapt to light / dark on their
// own. The brand green is kept only as the accent. This gives the app the
// same zero-colour-difference, System-Settings / Notes look on both
// appearances while reusing every existing `palette.X` call site.
import AppKit
import SwiftUI
enum MacSystemPalette {
/// A `ThemePalette` whose surfaces and text resolve to AppKit semantic
/// colours. Because those colours are dynamic, a single value renders
/// correctly under both light and dark (driven by `preferredColorScheme`).
static let palette = ThemePalette(
background: Color(nsColor: .windowBackgroundColor),
surface: Color(nsColor: .controlBackgroundColor),
surfaceElevated: Color(nsColor: .unemphasizedSelectedContentBackgroundColor),
surfaceMuted: Color(nsColor: .underPageBackgroundColor),
accent: Palette.accent,
accentMuted: Palette.accent.opacity(0.16),
accentGlow: Palette.accent.opacity(0.35),
danger: Color(nsColor: .systemRed),
success: Palette.accent,
warning: Color(nsColor: .systemOrange),
textPrimary: Color(nsColor: .labelColor),
textSecondary: Color(nsColor: .secondaryLabelColor),
textTertiary: Color(nsColor: .tertiaryLabelColor),
textOnAccent: Color.white,
divider: Color(nsColor: .separatorColor),
dividerStrong: Color(nsColor: .separatorColor),
recordRed: Color(nsColor: .systemRed)
)
}
extension View {
/// Injects the system-native palette used across the macOS app.
func macSystemPalette() -> some View {
environment(\.themePalette, MacSystemPalette.palette)
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.osgkeyboard.shared</string>
</array>
</dict>
</plist>
+145
View File
@@ -0,0 +1,145 @@
// OSGKeyboardMacApp.swift
// OSGKeyboard · Mac
//
// Entry point. A borderless, System-Settings-style main window plus a
// rock-solid AppKit status-bar item (NSStatusItem) with a dictation popover.
// Light / dark follows the user's Appearance preference (Settings General).
import AppKit
import SwiftUI
@main
struct OSGKeyboardMacApp: App {
@NSApplicationDelegateAdaptor(MacAppDelegate.self) private var appDelegate
@StateObject private var viewModel = MacDictationViewModel.shared
// Mac-local appearance preference. Drives both the SwiftUI colour scheme
// and via `applyToApp` the AppKit window chrome / popover.
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
private var appearance: MacAppearancePreference {
MacAppearancePreference(rawValue: appearanceRaw) ?? .system
}
var body: some Scene {
Window("OSGKeyboard", id: "main") {
MacRootView(viewModel: viewModel)
.macSystemPalette()
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
.preferredColorScheme(appearance.colorScheme)
.task { await viewModel.onAppear() }
.onAppear { MacAppearancePreference.applyToApp(appearance) }
.onChange(of: appearanceRaw) { MacAppearancePreference.applyToApp(appearance) }
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
viewModel.reloadConfigFromCloud()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
viewModel.refreshDictionaryFromCloud()
}
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
viewModel.usageStatistics.reloadFromDisk()
}
.onReceive(NotificationCenter.default.publisher(for: .speechHistoryDidSyncFromCloud)) { _ in
viewModel.speechHistory.reloadFromDisk()
}
}
// Borderless titlebar content (sidebar + traffic lights) runs to the
// very top, matching macOS System Settings.
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentMinSize)
.defaultSize(width: 1_024, height: 720)
}
}
// MARK: - Reopening the main window from AppKit
/// Bridges SwiftUI's `openWindow` action out to AppKit code (the status-bar
/// popover) that has no access to the scene environment.
@MainActor
final class MacWindowBridge {
static let shared = MacWindowBridge()
var open: (() -> Void)?
private init() {}
}
@MainActor
enum MacMainWindow {
/// Bring the app forward and show the main window, recreating it if the
/// user had closed it.
static func open() {
NSApp.activate(ignoringOtherApps: true)
MacWindowBridge.shared.open?()
}
}
// MARK: - Status-bar item (AppKit)
/// Owns the menu-bar `NSStatusItem` and its dictation popover. Implemented in
/// AppKit rather than SwiftUI's `MenuBarExtra` because the latter is flaky
/// when combined with a primary `Window` scene (the icon can silently vanish).
@MainActor
final class MacAppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
private let popover = NSPopover()
func applicationDidFinishLaunching(_ notification: Notification) {
MacAppearancePreference.applyToApp(.current)
configurePopover()
configureStatusItem()
}
/// Keep the app alive after the last window closes it lives in the menu bar.
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
}
private func configureStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = item.button {
// Prefer the brand mark; fall back to an SF Symbol so the item is
// never invisible even if the asset fails to resolve.
let image = NSImage(named: "OSGBrandMark")
?? NSImage(systemSymbolName: "mic.circle.fill", accessibilityDescription: "OSGKeyboard")
image?.isTemplate = true
image?.size = NSSize(width: 18, height: 18)
button.image = image
button.image?.accessibilityDescription = "OSGKeyboard"
button.action = #selector(togglePopover(_:))
button.target = self
}
statusItem = item
}
private func configurePopover() {
popover.behavior = .transient
popover.animates = true
popover.contentSize = NSSize(width: 340, height: 420)
popover.contentViewController = NSHostingController(rootView: MacMenuBarPopover())
}
@objc private func togglePopover(_ sender: Any?) {
guard let button = statusItem?.button else { return }
if popover.isShown {
popover.performClose(sender)
} else {
NSApp.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}
}
/// SwiftUI content hosted inside the status-bar popover. Shares the single
/// view model and follows the same appearance preference as the main window.
private struct MacMenuBarPopover: View {
@ObservedObject private var viewModel = MacDictationViewModel.shared
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
var body: some View {
MacContentView(viewModel: viewModel)
.frame(width: 340)
.macSystemPalette()
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
.preferredColorScheme(MacAppearancePreference(rawValue: appearanceRaw)?.colorScheme ?? nil)
}
}