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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (0…1), 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 0…1 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user