feat: harden Flow cold-start/force-quit and polish macOS dictation UX
Fix cold-start overlay recursion that overflowed the main-thread stack when recording began while the ready overlay was still up; also remove temporary on-screen Flow DEBUG panels after the orange-mic investigation, and land the macOS overlay/catalog/layout polish plus related Flow recovery hardening.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
// DashboardView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Primary workspace: session stats, dictation canvas, floating record bar.
|
||||
// Primary workspace: brand voice, asymmetric stats, dictation stage, and
|
||||
// the record bar. History lives on its own page — no duplicate list here.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -20,33 +21,24 @@ struct DashboardView: View {
|
||||
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)
|
||||
.transition(.opacity)
|
||||
}
|
||||
statGrid
|
||||
dictationCanvas
|
||||
}
|
||||
.animation(Motion.soft, value: viewModel.foregroundAppName)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.top, Spacing.sm)
|
||||
.padding(.bottom, Spacing.lg)
|
||||
VStack(alignment: .leading, spacing: Spacing.lg) {
|
||||
heroHeader
|
||||
statCluster
|
||||
dictationStage
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
.padding(.top, Spacing.sm)
|
||||
|
||||
// Leftover window height splits evenly above / below the mic bar
|
||||
// so spacing stays balanced at any window size.
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
BottomDictationBar(viewModel: viewModel)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
}
|
||||
.onAppear { stats.reloadFromDisk() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
|
||||
@@ -54,18 +46,41 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
// MARK: - Hero
|
||||
|
||||
private var heroHeader: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text(MacL10n.string("mac.brand.tagline", language: lang))
|
||||
.font(TypeStyle.pageTitle)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(MacL10n.string("mac.brand.tagline.subtitle", language: lang))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
|
||||
if let appName = viewModel.foregroundAppName {
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.45))
|
||||
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.transition(.opacity)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.animation(Motion.soft, value: viewModel.foregroundAppName)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - Stats (hero word count, full width but content-height —
|
||||
// never stretched to match a taller sibling and left with dead air)
|
||||
|
||||
private var statCluster: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
StatCard(
|
||||
title: MacL10n.string("mac.stat.words", language: lang),
|
||||
value: UsageStatisticsStore.formatCount(
|
||||
@@ -73,28 +88,44 @@ struct DashboardView: View {
|
||||
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"
|
||||
systemImage: "text.alignleft",
|
||||
accent: true,
|
||||
prominent: true
|
||||
)
|
||||
|
||||
HStack(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"
|
||||
)
|
||||
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) {
|
||||
// MARK: - Dictation stage
|
||||
|
||||
private var dictationStage: some View {
|
||||
MacCard(padding: Spacing.md, cornerRadius: Radius.large) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if viewModel.transcript.isEmpty {
|
||||
Text(
|
||||
@@ -102,24 +133,37 @@ struct DashboardView: View {
|
||||
? MacL10n.string("mac.status.listening", language: lang)
|
||||
: MacL10n.string("mac.status.ready", language: lang)
|
||||
)
|
||||
.font(.system(size: 26, weight: .light))
|
||||
.font(.system(size: 22, weight: .light))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.contentTransition(.opacity)
|
||||
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: MacMetrics.dictationCanvasMinHeight,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
Text(viewModel.transcript)
|
||||
.font(.system(size: 22, weight: .regular))
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineSpacing(4)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: MacMetrics.dictationCanvasMinHeight,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: MacMetrics.dictationCanvasMinHeight, maxHeight: 160)
|
||||
}
|
||||
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
|
||||
.animation(Motion.quick, value: viewModel.isRecording)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Floating record bar
|
||||
@@ -142,12 +186,10 @@ struct BottomDictationBar: View {
|
||||
recordControl
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 1)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
// No surface fill — the mic bar sits on the page background so Home
|
||||
// stays flat and the canvas above can stay shorter without a second
|
||||
// floating card competing for height.
|
||||
}
|
||||
|
||||
private var readinessChip: some View {
|
||||
@@ -189,15 +231,14 @@ struct BottomDictationBar: View {
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 7)
|
||||
.macGlassSurface(in: Capsule(), fillOpacity: 0.66)
|
||||
.background(palette.surfaceElevated, in: Capsule())
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
// 麦克风按钮始终居中固定:录音时的波形放进按钮内部,
|
||||
// “按停止”提示作为浮层显示在按钮上方,二者均不参与布局,
|
||||
// 因此按下 Option 触发录音时按钮位置不会发生偏移。
|
||||
// Mic stays geometrically centred: waveform lives inside the button,
|
||||
// “press stop” floats above — neither participates in layout.
|
||||
private var recordControl: some View {
|
||||
recordButton
|
||||
.overlay(alignment: .top) {
|
||||
@@ -225,7 +266,6 @@ struct BottomDictationBar: View {
|
||||
)
|
||||
Group {
|
||||
if viewModel.isRecording {
|
||||
// 与 iOS 一致:录音时在红色按钮内部显示实时波形
|
||||
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
|
||||
} else {
|
||||
Image(systemName: "mic.fill")
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
final class MacAudioRecorder: @unchecked Sendable {
|
||||
enum RecorderError: Error, LocalizedError {
|
||||
case converterUnavailable
|
||||
case microphoneAccessDenied
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .converterUnavailable:
|
||||
return "无法初始化音频转换器 / Failed to initialize audio converter"
|
||||
case .microphoneAccessDenied:
|
||||
return "麦克风权限被拒绝——请在「系统设置 → 隐私与安全性 → 麦克风」中启用"
|
||||
+ " / Microphone access denied — enable it in System Settings"
|
||||
+ " → Privacy & Security → Microphone"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +35,20 @@ final class MacAudioRecorder: @unchecked Sendable {
|
||||
private var converter: AVAudioConverter?
|
||||
private let lock = NSLock()
|
||||
private var samples: [Float] = []
|
||||
private var snapshotContinuation: AsyncStream<AudioBufferSnapshot>.Continuation?
|
||||
private var isRunning = false
|
||||
/// Hard cap on accumulated audio: 10 minutes @16 kHz ≈ 38 MB of Float32.
|
||||
/// Recording is push-to-talk, but a stuck hotkey (or a latched Option
|
||||
/// key) would otherwise grow this buffer without bound; past the cap we
|
||||
/// keep the newest audio (drop from the front) so the take still ends
|
||||
/// with what the user last said.
|
||||
private static let maxSampleCount = 10 * 60 * 16_000
|
||||
/// Trim hysteresis: dropping from the front is an O(n) memmove of the
|
||||
/// whole ~38 MB buffer, done under the same lock the UI's level poll
|
||||
/// takes — doing it on EVERY tap callback once capped would stall the
|
||||
/// render thread ~12×/s. Let the buffer overshoot by 30 s and trim the
|
||||
/// whole excess in one move instead.
|
||||
private static let trimHysteresisSamples = 30 * 16_000
|
||||
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.
|
||||
@@ -42,8 +60,51 @@ final class MacAudioRecorder: @unchecked Sendable {
|
||||
lock.withLock { smoothedLevel }
|
||||
}
|
||||
|
||||
func start() throws {
|
||||
lock.withLock { samples.removeAll(keepingCapacity: true) }
|
||||
/// Resolves microphone authorization before capture. Prompts on first
|
||||
/// use; throws `microphoneAccessDenied` once the user has declined so
|
||||
/// failures surface as a permission problem, not an empty transcription.
|
||||
private static func ensureMicrophoneAccess() async throws {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
return
|
||||
case .notDetermined:
|
||||
guard await AVCaptureDevice.requestAccess(for: .audio) else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
@unknown default:
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
try await Self.ensureMicrophoneAccess()
|
||||
try startEngine()
|
||||
}
|
||||
|
||||
/// Live 16 kHz mono snapshots for streaming ASR while the mic is open.
|
||||
/// The stream is finished automatically in `stop()`.
|
||||
func makeSnapshotStream() -> AsyncStream<AudioBufferSnapshot> {
|
||||
AsyncStream { continuation in
|
||||
lock.withLock {
|
||||
snapshotContinuation?.finish()
|
||||
snapshotContinuation = continuation
|
||||
}
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
self?.lock.withLock {
|
||||
self?.snapshotContinuation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startEngine() throws {
|
||||
lock.withLock {
|
||||
samples.removeAll(keepingCapacity: true)
|
||||
snapshotContinuation?.finish()
|
||||
snapshotContinuation = nil
|
||||
}
|
||||
|
||||
let input = engine.inputNode
|
||||
let inputFormat = input.outputFormat(forBus: 0)
|
||||
@@ -67,6 +128,8 @@ final class MacAudioRecorder: @unchecked Sendable {
|
||||
engine.stop()
|
||||
isRunning = false
|
||||
return lock.withLock {
|
||||
snapshotContinuation?.finish()
|
||||
snapshotContinuation = nil
|
||||
let out = samples
|
||||
samples.removeAll(keepingCapacity: false)
|
||||
return out
|
||||
@@ -105,8 +168,14 @@ final class MacAudioRecorder: @unchecked Sendable {
|
||||
|
||||
lock.withLock {
|
||||
samples.append(contentsOf: chunk)
|
||||
if samples.count > Self.maxSampleCount + Self.trimHysteresisSamples {
|
||||
samples.removeFirst(samples.count - Self.maxSampleCount)
|
||||
}
|
||||
let factor: Float = normalized > smoothedLevel ? 0.5 : 0.15
|
||||
smoothedLevel += (normalized - smoothedLevel) * factor
|
||||
snapshotContinuation?.yield(
|
||||
AudioBufferSnapshot(samples: chunk, sampleRate: 16_000)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// MacCloudASRChunkAdapter.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Adapts configured cloud ASR clients to the shared chunked utterance pipeline.
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
final class MacCloudASRChunkAdapter: ASRChunkTranscribing, @unchecked Sendable {
|
||||
private let store: AppGroupStore
|
||||
private let client: CloudASRTranscribing
|
||||
private let cancelled = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
init(store: AppGroupStore) throws {
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
|
||||
guard strategy != .localFallback else {
|
||||
throw MacDictationError.providerHasNoCloudASR
|
||||
}
|
||||
self.store = store
|
||||
self.client = CloudASRClientFactory.make(store: store)
|
||||
}
|
||||
|
||||
func prepare() async throws {
|
||||
try await client.prepare(dictionary: store.personalDictionary)
|
||||
}
|
||||
|
||||
func resetForNewUtterance() {
|
||||
cancelled.withLock { $0 = false }
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
cancelled.withLock { $0 = true }
|
||||
}
|
||||
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
let isCancelled = cancelled.withLock { $0 }
|
||||
if isCancelled || Task.isCancelled { return .cancelled }
|
||||
guard !samples.isEmpty else { return .success("") }
|
||||
|
||||
do {
|
||||
let text = try await client.transcribe(
|
||||
samples: samples,
|
||||
sampleRate: 16_000,
|
||||
locale: locale,
|
||||
dictionary: store.personalDictionary
|
||||
)
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return .success(trimmed)
|
||||
} catch is CancellationError {
|
||||
return .cancelled
|
||||
} catch {
|
||||
return .failure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,22 @@ enum MacMetrics {
|
||||
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
|
||||
/// Reading width for single-column content.
|
||||
static let contentMaxWidth: CGFloat = 720
|
||||
/// Horizontal inset for page titles and scroll *content* (cards).
|
||||
/// ScrollViews / Forms stay full-bleed so the scrollbar sits on the
|
||||
/// window edge; only the content inside is inset.
|
||||
/// Doubled from `Spacing.lg` so title + cards breathe from the edges.
|
||||
static let pageHorizontalInset: CGFloat = Spacing.lg * 2
|
||||
/// Built-in horizontal inset macOS grouped `Form` adds around its section
|
||||
/// cards, on top of any padding we apply. Subtracted from
|
||||
/// `pageHorizontalInset` on the Settings Form so its card outer edge lands
|
||||
/// on `pageHorizontalInset` — matching the History page and the page title.
|
||||
static let groupedFormSectionInset: CGFloat = Spacing.lg
|
||||
/// Default (= minimum) main-window size. Opening the app uses this size;
|
||||
/// the window cannot shrink below it.
|
||||
static let windowMinWidth: CGFloat = 860
|
||||
static let windowMinHeight: CGFloat = 600
|
||||
/// Compact dictation-canvas height so Home fits the min window without scrolling.
|
||||
static let dictationCanvasMinHeight: CGFloat = 120
|
||||
/// Top inset that clears the window traffic-light buttons now that the
|
||||
/// title bar is hidden.
|
||||
static let trafficLightInset: CGFloat = 28
|
||||
@@ -81,16 +97,67 @@ extension View {
|
||||
func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) }
|
||||
}
|
||||
|
||||
// MARK: - Page header
|
||||
|
||||
/// Page title for History / Dictionary / Settings. Applies the shared
|
||||
/// `pageHorizontalInset` so its left edge matches inset card content below.
|
||||
/// Type size matches Home's brand line (`TypeStyle.pageTitle`).
|
||||
struct MacPageHeader<Trailing: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
let title: String
|
||||
var subtitle: String?
|
||||
@ViewBuilder var trailing: () -> Trailing
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
@ViewBuilder trailing: @escaping () -> Trailing
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.trailing = trailing
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(title)
|
||||
.font(TypeStyle.pageTitle)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
trailing()
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
.padding(.top, Spacing.sm)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
}
|
||||
}
|
||||
|
||||
extension MacPageHeader where Trailing == EmptyView {
|
||||
init(title: String, subtitle: String? = nil) {
|
||||
self.init(title: title, subtitle: subtitle) { EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
var cornerRadius: CGFloat = Radius.medium
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
|
||||
content()
|
||||
.padding(padding)
|
||||
@@ -111,34 +178,82 @@ struct StatCard: View {
|
||||
let caption: String
|
||||
var systemImage: String?
|
||||
var accent: Bool = false
|
||||
/// Hero metric: wide horizontal layout that uses full-card width without
|
||||
/// stretching to fill dead vertical space — used for the primary word count.
|
||||
var prominent: 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)
|
||||
}
|
||||
MacCard(padding: prominent ? Spacing.md : Spacing.md) {
|
||||
if prominent {
|
||||
prominentBody
|
||||
} else {
|
||||
compactBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactBody: some View {
|
||||
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)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
Text(value)
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: value)
|
||||
}
|
||||
Text(value)
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: value)
|
||||
Text(caption)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
/// Wide "hero bar" layout: icon badge + title/caption on the left, the
|
||||
/// big number anchored right — fills the full card width edge-to-edge
|
||||
/// instead of a tall card with empty space below a small number.
|
||||
private var prominentBody: some View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
if let systemImage {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(palette.accentMuted)
|
||||
.frame(width: 44, height: 44)
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title.uppercased())
|
||||
.font(TypeStyle.caption2)
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(caption)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Spacer(minLength: Spacing.md)
|
||||
Text(value)
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,30 +267,39 @@ struct MiniWaveform: View {
|
||||
var barCount: Int = 5
|
||||
/// Pass nil to inherit the palette accent automatically.
|
||||
var tint: Color?
|
||||
/// Peak bar height; overlay HUD uses a taller meter than the mic button.
|
||||
var maxBarHeight: CGFloat = 22
|
||||
var barWidth: CGFloat = 3
|
||||
var barSpacing: CGFloat = 3
|
||||
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
HStack(spacing: barSpacing) {
|
||||
ForEach(0..<barCount, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(tint ?? palette.accent)
|
||||
.frame(width: 3, height: barHeight(index))
|
||||
.frame(width: barWidth, height: barHeight(index))
|
||||
}
|
||||
}
|
||||
.frame(height: 22)
|
||||
.frame(height: maxBarHeight)
|
||||
.animation(Motion.instant, value: level)
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) {
|
||||
withAnimation(.linear(duration: 0.75).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))
|
||||
// Stronger level coupling + staggered phase so the meter reads as
|
||||
// "alive" even at modest mic levels.
|
||||
let boosted = min(1, CGFloat(level) * 1.35 + 0.08)
|
||||
let base = boosted * maxBarHeight
|
||||
let wobble = sin((phase * .pi * 2) + CGFloat(index) * 0.85) * (maxBarHeight * 0.22)
|
||||
+ (maxBarHeight * 0.12)
|
||||
let parity = 0.55 + CGFloat(index % 3) * 0.2
|
||||
return max(maxBarHeight * 0.18, min(maxBarHeight, base * parity + wobble))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +319,8 @@ enum MacTranslationDisplay {
|
||||
|
||||
// MARK: - Status footer
|
||||
|
||||
/// Bottom status strip: engine mode (cloud/local), translation target, and
|
||||
/// the connection state — icons and wording mirror the dashboard record bar.
|
||||
/// Bottom status strip: engine mode, translation target, connection —
|
||||
/// kept visually quiet so it never competes with the record bar.
|
||||
struct MacStatusFooter: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
@@ -204,7 +328,7 @@ struct MacStatusFooter: View {
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Spacer()
|
||||
Label(
|
||||
viewModel.isCloudMode
|
||||
@@ -212,24 +336,47 @@ struct MacStatusFooter: View {
|
||||
: MacL10n.string("mac.mode.local", language: lang),
|
||||
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
|
||||
)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.contentTransition(.opacity)
|
||||
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
|
||||
Label(
|
||||
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
|
||||
systemImage: "translate"
|
||||
)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.contentTransition(.opacity)
|
||||
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
|
||||
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
|
||||
.foregroundStyle(palette.accent)
|
||||
.foregroundStyle(palette.accent.opacity(0.85))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.animation(Motion.quick, value: viewModel.isCloudMode)
|
||||
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Form alignment
|
||||
|
||||
private struct MacFormPageAlignModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
// Form stays full-bleed (scrollbar on the window edge). Section
|
||||
// cards are inset to match `MacPageHeader`.
|
||||
content
|
||||
.contentMargins(.horizontal, MacMetrics.pageHorizontalInset, for: .scrollContent)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Insets grouped-`Form` section cards to `pageHorizontalInset`.
|
||||
func macFormPageAligned() -> some View {
|
||||
modifier(MacFormPageAlignModifier())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct MacContentView: View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
header
|
||||
recordButton
|
||||
Text(MacL10n.string("mac.hint.holdOption", language: lang))
|
||||
Text(MacL10n.string(viewModel.hotkeyTrigger.hintKey, language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// MacDictationOverlayController.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Owns a borderless, non-activating floating NSPanel that hosts the
|
||||
// dictation HUD. Shown for any recording path (hotkey, menu bar, main
|
||||
// window) and dismissed after a short success beat when processing ends.
|
||||
|
||||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class MacDictationOverlayController {
|
||||
static let shared = MacDictationOverlayController()
|
||||
|
||||
private var panel: NSPanel?
|
||||
private var hosting: NSHostingView<AnyView>?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var hideWorkItem: DispatchWorkItem?
|
||||
/// Keeps the pill visible briefly after a successful delivery.
|
||||
private var showingCompletion = false
|
||||
private var wasBusy = false
|
||||
|
||||
private let bottomMargin: CGFloat = 36
|
||||
private let fallbackSize = NSSize(width: 400, height: 52)
|
||||
|
||||
private init() {}
|
||||
|
||||
func start(observing viewModel: MacDictationViewModel) {
|
||||
guard cancellables.isEmpty else { return }
|
||||
|
||||
Publishers.CombineLatest3(
|
||||
viewModel.$isRecording,
|
||||
viewModel.$isPreparingToRecord,
|
||||
viewModel.$isProcessing
|
||||
)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] recording, preparing, processing in
|
||||
self?.handleBusyChange(
|
||||
recording: recording,
|
||||
preparing: preparing,
|
||||
processing: processing,
|
||||
viewModel: viewModel
|
||||
)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Keep waveform / app name / copy fresh while visible.
|
||||
viewModel.objectWillChange
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self, self.panel?.isVisible == true else { return }
|
||||
self.refreshContent(viewModel: viewModel)
|
||||
self.resizeToFit()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in self?.reposition() }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func handleBusyChange(
|
||||
recording: Bool,
|
||||
preparing: Bool,
|
||||
processing: Bool,
|
||||
viewModel: MacDictationViewModel
|
||||
) {
|
||||
let busy = recording || preparing || processing
|
||||
|
||||
if busy {
|
||||
hideWorkItem?.cancel()
|
||||
hideWorkItem = nil
|
||||
showingCompletion = false
|
||||
wasBusy = true
|
||||
present(viewModel: viewModel)
|
||||
return
|
||||
}
|
||||
|
||||
// Transition: busy → idle. Flash a short "done" state, then hide.
|
||||
if wasBusy {
|
||||
wasBusy = false
|
||||
showingCompletion = true
|
||||
present(viewModel: viewModel)
|
||||
scheduleHide()
|
||||
return
|
||||
}
|
||||
|
||||
if !showingCompletion {
|
||||
hideImmediately()
|
||||
}
|
||||
}
|
||||
|
||||
private func present(viewModel: MacDictationViewModel) {
|
||||
ensurePanel(viewModel: viewModel)
|
||||
refreshContent(viewModel: viewModel)
|
||||
resizeToFit()
|
||||
reposition()
|
||||
|
||||
guard let panel else { return }
|
||||
if panel.isVisible {
|
||||
// Already up — still bump to front in case another space stole it.
|
||||
panel.orderFrontRegardless()
|
||||
return
|
||||
}
|
||||
|
||||
panel.alphaValue = 0
|
||||
panel.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.22
|
||||
panel.animator().alphaValue = 1
|
||||
}
|
||||
}
|
||||
|
||||
private func ensurePanel(viewModel: MacDictationViewModel) {
|
||||
if panel != nil { return }
|
||||
|
||||
let host = NSHostingView(rootView: makeRoot(viewModel: viewModel))
|
||||
host.frame = NSRect(origin: .zero, size: fallbackSize)
|
||||
hosting = host
|
||||
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(origin: .zero, size: fallbackSize),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
panel.contentView = host
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = false
|
||||
// Above normal floating windows so the HUD stays visible over browsers /
|
||||
// full-screen apps, without going as high as the screen saver.
|
||||
panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.floatingWindow)) + 1)
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
||||
panel.isMovableByWindowBackground = false
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.ignoresMouseEvents = false
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
self.panel = panel
|
||||
}
|
||||
|
||||
private func refreshContent(viewModel: MacDictationViewModel) {
|
||||
hosting?.rootView = makeRoot(viewModel: viewModel)
|
||||
}
|
||||
|
||||
private func makeRoot(viewModel: MacDictationViewModel) -> AnyView {
|
||||
AnyView(
|
||||
MacDictationOverlayView(viewModel: viewModel)
|
||||
.macSystemPalette()
|
||||
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
||||
.preferredColorScheme(MacAppearancePreference.current.colorScheme)
|
||||
)
|
||||
}
|
||||
|
||||
private func resizeToFit() {
|
||||
guard let panel, let hosting else { return }
|
||||
hosting.layoutSubtreeIfNeeded()
|
||||
let fitting = hosting.fittingSize
|
||||
let width = fitting.width.isFinite && fitting.width > 1
|
||||
? min(max(fitting.width, 300), 520)
|
||||
: fallbackSize.width
|
||||
let height = fitting.height.isFinite && fitting.height > 1
|
||||
? max(fitting.height, fallbackSize.height)
|
||||
: fallbackSize.height
|
||||
var frame = panel.frame
|
||||
let midX = frame.midX
|
||||
frame.size = NSSize(width: width, height: height)
|
||||
if midX.isFinite {
|
||||
frame.origin.x = midX - width / 2
|
||||
}
|
||||
panel.setFrame(frame, display: true)
|
||||
hosting.frame = NSRect(origin: .zero, size: frame.size)
|
||||
}
|
||||
|
||||
private func reposition() {
|
||||
guard let panel else { return }
|
||||
let screen = NSScreen.main ?? NSScreen.screens.first
|
||||
guard let visible = screen?.visibleFrame else { return }
|
||||
let size = panel.frame.size
|
||||
let origin = NSPoint(
|
||||
x: visible.midX - size.width / 2,
|
||||
y: visible.minY + bottomMargin
|
||||
)
|
||||
panel.setFrameOrigin(origin)
|
||||
}
|
||||
|
||||
private func scheduleHide() {
|
||||
hideWorkItem?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.showingCompletion = false
|
||||
self?.hideAnimated()
|
||||
}
|
||||
hideWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.15, execute: work)
|
||||
}
|
||||
|
||||
private func hideImmediately() {
|
||||
hideWorkItem?.cancel()
|
||||
hideWorkItem = nil
|
||||
showingCompletion = false
|
||||
panel?.orderOut(nil)
|
||||
panel?.alphaValue = 1
|
||||
}
|
||||
|
||||
private func hideAnimated() {
|
||||
guard let panel, panel.isVisible else {
|
||||
hideImmediately()
|
||||
return
|
||||
}
|
||||
NSAnimationContext.runAnimationGroup({ ctx in
|
||||
ctx.duration = 0.2
|
||||
panel.animator().alphaValue = 0
|
||||
}, completionHandler: { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.panel?.orderOut(nil)
|
||||
self?.panel?.alphaValue = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// MacDictationOverlayView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Compact bottom-of-screen HUD shown while dictating. Lives inside a
|
||||
// non-activating NSPanel so it never steals focus from the front app.
|
||||
// One-line layout: status / live transcript preview + waveform + stop.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacDictationOverlayView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
private var isBusy: Bool {
|
||||
viewModel.isRecording || viewModel.isPreparingToRecord || viewModel.isProcessing
|
||||
}
|
||||
|
||||
/// Trimmed live / final transcript for the single-line preview.
|
||||
private var previewText: String {
|
||||
viewModel.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var hasPreview: Bool { !previewText.isEmpty }
|
||||
|
||||
private var showsLiveBadge: Bool {
|
||||
viewModel.isRecording && viewModel.isStreamingPartial
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
statusDot
|
||||
primaryLine
|
||||
Spacer(minLength: Spacing.xs)
|
||||
trailingControl
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, 11)
|
||||
.frame(minWidth: 300, idealWidth: 400, maxWidth: 520)
|
||||
.fixedSize(horizontal: true, vertical: true)
|
||||
.background(palette.surface, in: Capsule(style: .continuous))
|
||||
.overlay(
|
||||
Capsule(style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.22), radius: 14, y: 5)
|
||||
.padding(2)
|
||||
.animation(Motion.soft, value: hasPreview)
|
||||
.animation(Motion.quick, value: viewModel.isRecording)
|
||||
.animation(Motion.quick, value: viewModel.isStreamingPartial)
|
||||
}
|
||||
|
||||
// MARK: - Primary line (status or one-line transcript)
|
||||
|
||||
@ViewBuilder
|
||||
private var primaryLine: some View {
|
||||
if hasPreview {
|
||||
HStack(spacing: 6) {
|
||||
if showsLiveBadge {
|
||||
liveBadge
|
||||
}
|
||||
Text(previewText)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
.frame(maxWidth: showsLiveBadge ? 280 : 320, alignment: .leading)
|
||||
.contentTransition(.opacity)
|
||||
.animation(Motion.quick, value: previewText)
|
||||
.accessibilityLabel(previewText)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 6) {
|
||||
Text(statusText)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.contentTransition(.opacity)
|
||||
if let appName = viewModel.foregroundAppName, isBusy {
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.45))
|
||||
Text(appName)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.animation(Motion.quick, value: statusText)
|
||||
}
|
||||
}
|
||||
|
||||
private var liveBadge: some View {
|
||||
Text(MacL10n.string("mac.overlay.live", language: lang))
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(palette.recordRed)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(palette.recordRed.opacity(0.12), in: Capsule(style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
private var statusDot: some View {
|
||||
Circle()
|
||||
.fill(dotColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.animation(Motion.quick, value: viewModel.isRecording)
|
||||
.animation(Motion.quick, value: viewModel.isProcessing)
|
||||
.animation(Motion.quick, value: viewModel.isPreparingToRecord)
|
||||
.animation(Motion.quick, value: viewModel.isStreamingPartial)
|
||||
}
|
||||
|
||||
private var dotColor: Color {
|
||||
if viewModel.isRecording {
|
||||
return viewModel.isStreamingPartial ? palette.accent : palette.recordRed
|
||||
}
|
||||
if viewModel.isPreparingToRecord || viewModel.isProcessing { return palette.warning }
|
||||
return palette.accent
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if viewModel.isRecording {
|
||||
return MacL10n.string("mac.overlay.listening", language: lang)
|
||||
}
|
||||
if viewModel.isPreparingToRecord {
|
||||
return MacL10n.string("mac.overlay.preparing", language: lang)
|
||||
}
|
||||
if viewModel.isProcessing {
|
||||
if viewModel.isStreamingPartial {
|
||||
return MacL10n.string("mac.overlay.polishing", language: lang)
|
||||
}
|
||||
return MacL10n.string("mac.overlay.transcribing", language: lang)
|
||||
}
|
||||
return MacL10n.string("mac.overlay.done", language: lang)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trailingControl: some View {
|
||||
if viewModel.isRecording {
|
||||
MiniWaveform(
|
||||
level: viewModel.audioLevel,
|
||||
barCount: 7,
|
||||
tint: (viewModel.isStreamingPartial ? palette.accent : palette.recordRed)
|
||||
.opacity(0.9),
|
||||
maxBarHeight: 28,
|
||||
barWidth: 3.5,
|
||||
barSpacing: 2.5
|
||||
)
|
||||
stopButton
|
||||
} else if viewModel.isPreparingToRecord || viewModel.isProcessing {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
}
|
||||
|
||||
private var stopButton: some View {
|
||||
Button(action: viewModel.toggleRecording) {
|
||||
Image(systemName: "stop.fill")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(palette.recordRed, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(MacL10n.string("mac.record.stop", language: lang))
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
// 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.
|
||||
// Dictation pipeline: samples → ASR (cloud or local, chunked when long) → polish.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -24,39 +22,60 @@ enum MacDictationError: Error, LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of ASR that ran while the microphone was still open.
|
||||
struct MacLiveASRCaptureResult: Sendable {
|
||||
let raw: String
|
||||
let chunkWarning: String?
|
||||
let localBias: LocalASRBiasPayload?
|
||||
/// When true, callers should fall back to batch ASR on the recorded samples.
|
||||
let shouldFallbackToBatch: Bool
|
||||
}
|
||||
|
||||
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 {
|
||||
/// First-chunk threshold: longer local utterances use pipelined chunk ASR.
|
||||
private static let chunkedLocalThresholdSamples = Int(
|
||||
FlowUtteranceChunkConfig.flowDefault.maxChunkDurationSeconds(forChunkIndex: 0) * 16_000
|
||||
)
|
||||
|
||||
/// Whether the active engine can surface `onPartial` text while recording.
|
||||
static func supportsLivePartials(store: AppGroupStore) -> Bool {
|
||||
if store.engineMode == "local" { return true }
|
||||
return CloudASRModelCatalog.strategy(for: store.asrProviderId) != .localFallback
|
||||
}
|
||||
|
||||
/// Runs ASR then polish. Polish failures return cleaned raw ASR plus a warning.
|
||||
static func run(
|
||||
samples: [Float],
|
||||
store: AppGroupStore,
|
||||
onPartial: (@Sendable (String) -> Void)? = nil
|
||||
) async throws -> MacDictationResult {
|
||||
guard !samples.isEmpty else { throw MacDictationError.noAudio }
|
||||
|
||||
let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
|
||||
let locale = resolvedLocale(store: store)
|
||||
var chunkWarning: String?
|
||||
let raw: String
|
||||
var localBias: LocalASRBiasPayload?
|
||||
|
||||
if store.engineMode == "local" {
|
||||
MacAppContextService.captureAndPersist(to: store)
|
||||
let capabilities = MacLocalASRService.currentCapabilities()
|
||||
let bias = LocalASRBiasAdapter.adapt(
|
||||
LocalASRBiasRequest(
|
||||
dictionary: store.personalDictionary,
|
||||
localBias = resolveLocalBias(store: store, locale: locale)
|
||||
if samples.count > chunkedLocalThresholdSamples {
|
||||
let chunked = try await transcribeLocalChunked(
|
||||
samples: samples,
|
||||
locale: locale,
|
||||
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
|
||||
capabilities: capabilities
|
||||
bias: localBias,
|
||||
onPartial: onPartial
|
||||
)
|
||||
)
|
||||
localBias = bias
|
||||
LocalASRBiasDiagnosticsStore.save(
|
||||
payload: bias,
|
||||
modelId: MacLocalASRService.selectedModelDefinition()?.id,
|
||||
backendLabel: MacLocalASRService.currentBackendLabel()
|
||||
)
|
||||
raw = try await MacLocalASRService.transcribe(
|
||||
samples: samples,
|
||||
locale: locale,
|
||||
bias: bias
|
||||
)
|
||||
raw = chunked.text
|
||||
chunkWarning = chunked.chunkWarning
|
||||
} else {
|
||||
raw = try await MacLocalASRService.transcribe(
|
||||
samples: samples,
|
||||
locale: locale,
|
||||
bias: localBias
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
|
||||
guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR }
|
||||
|
||||
let client = CloudASRClientFactory.make(store: store)
|
||||
@@ -69,6 +88,85 @@ enum MacDictationPipeline {
|
||||
)
|
||||
}
|
||||
|
||||
return try await polishCapturedASR(
|
||||
raw: raw,
|
||||
store: store,
|
||||
localBias: localBias,
|
||||
chunkWarning: chunkWarning
|
||||
)
|
||||
}
|
||||
|
||||
/// Consumes a live mic snapshot stream until finished; yields stitched partials.
|
||||
static func captureLive(
|
||||
stream: AsyncStream<AudioBufferSnapshot>,
|
||||
store: AppGroupStore,
|
||||
onPartial: @escaping @Sendable (String) -> Void
|
||||
) async -> MacLiveASRCaptureResult {
|
||||
let locale = resolvedLocale(store: store)
|
||||
let localBias: LocalASRBiasPayload?
|
||||
if store.engineMode == "local" {
|
||||
localBias = resolveLocalBias(store: store, locale: locale)
|
||||
} else {
|
||||
localBias = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let adapter = try makeChunkASRAdapter(
|
||||
store: store,
|
||||
locale: locale,
|
||||
bias: localBias
|
||||
)
|
||||
if let cloudAdapter = adapter as? MacCloudASRChunkAdapter {
|
||||
try? await cloudAdapter.prepare()
|
||||
}
|
||||
|
||||
let pipeline = ChunkedUtterancePipeline(
|
||||
asr: adapter,
|
||||
locale: locale,
|
||||
config: .flowDefault
|
||||
)
|
||||
let outcome = await pipeline.transcribe(stream: stream, onPartial: onPartial)
|
||||
|
||||
switch outcome {
|
||||
case .success(let success):
|
||||
return MacLiveASRCaptureResult(
|
||||
raw: success.text,
|
||||
chunkWarning: success.chunkWarnings.first,
|
||||
localBias: localBias,
|
||||
shouldFallbackToBatch: false
|
||||
)
|
||||
case .failure:
|
||||
return MacLiveASRCaptureResult(
|
||||
raw: "",
|
||||
chunkWarning: nil,
|
||||
localBias: localBias,
|
||||
shouldFallbackToBatch: true
|
||||
)
|
||||
case .cancelled:
|
||||
return MacLiveASRCaptureResult(
|
||||
raw: "",
|
||||
chunkWarning: nil,
|
||||
localBias: localBias,
|
||||
shouldFallbackToBatch: true
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return MacLiveASRCaptureResult(
|
||||
raw: "",
|
||||
chunkWarning: nil,
|
||||
localBias: localBias,
|
||||
shouldFallbackToBatch: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Polish-only step after live or batch ASR has produced raw text.
|
||||
static func polishCapturedASR(
|
||||
raw: String,
|
||||
store: AppGroupStore,
|
||||
localBias: LocalASRBiasPayload?,
|
||||
chunkWarning: String?
|
||||
) async throws -> MacDictationResult {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw MacDictationError.emptyTranscript }
|
||||
|
||||
@@ -91,14 +189,127 @@ enum MacDictationPipeline {
|
||||
polishContext = nil
|
||||
}
|
||||
|
||||
if let polished = try? await PolishingService(store: store).polish(
|
||||
postASR,
|
||||
mode: store.polishModeForPipeline,
|
||||
context: polishContext
|
||||
),
|
||||
!polished.isEmpty {
|
||||
return polished
|
||||
do {
|
||||
let polished = try await PolishingService(store: store).polish(
|
||||
postASR,
|
||||
mode: store.polishModeForPipeline,
|
||||
context: polishContext
|
||||
)
|
||||
guard !polished.isEmpty else {
|
||||
throw PolishingService.PolishError.noTranscript
|
||||
}
|
||||
return MacDictationResult(
|
||||
text: polished,
|
||||
polishWarning: nil,
|
||||
chunkWarning: chunkWarning
|
||||
)
|
||||
} catch {
|
||||
let delivery = TranscriptionPolishFallback.makeDelivery(
|
||||
rawText: postASR,
|
||||
error: error,
|
||||
engineMode: store.engineMode,
|
||||
chunkWarning: chunkWarning
|
||||
)
|
||||
return MacDictationResult(
|
||||
text: delivery.text,
|
||||
polishWarning: delivery.polishWarning,
|
||||
chunkWarning: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chunked local ASR
|
||||
|
||||
private struct ChunkedLocalResult {
|
||||
let text: String
|
||||
let chunkWarning: String?
|
||||
}
|
||||
|
||||
private static func resolvedLocale(store: AppGroupStore) -> Locale {
|
||||
Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
|
||||
}
|
||||
|
||||
private static func resolveLocalBias(
|
||||
store: AppGroupStore,
|
||||
locale: Locale
|
||||
) -> LocalASRBiasPayload? {
|
||||
MacAppContextService.captureAndPersist(to: store)
|
||||
let capabilities = MacLocalASRService.currentCapabilities()
|
||||
let bias = LocalASRBiasAdapter.adapt(
|
||||
LocalASRBiasRequest(
|
||||
dictionary: store.personalDictionary,
|
||||
locale: locale,
|
||||
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
|
||||
capabilities: capabilities
|
||||
)
|
||||
)
|
||||
LocalASRBiasDiagnosticsStore.save(
|
||||
payload: bias,
|
||||
modelId: MacLocalASRService.selectedModelDefinition()?.id,
|
||||
backendLabel: MacLocalASRService.currentBackendLabel()
|
||||
)
|
||||
return bias
|
||||
}
|
||||
|
||||
private static func makeChunkASRAdapter(
|
||||
store: AppGroupStore,
|
||||
locale: Locale,
|
||||
bias: LocalASRBiasPayload?
|
||||
) throws -> any ASRChunkTranscribing {
|
||||
if store.engineMode == "local" {
|
||||
return MacLocalASRChunkAdapter(locale: locale, bias: bias)
|
||||
}
|
||||
return try MacCloudASRChunkAdapter(store: store)
|
||||
}
|
||||
|
||||
private static func transcribeLocalChunked(
|
||||
samples: [Float],
|
||||
locale: Locale,
|
||||
bias: LocalASRBiasPayload?,
|
||||
onPartial: (@Sendable (String) -> Void)?
|
||||
) async throws -> ChunkedLocalResult {
|
||||
let adapter = MacLocalASRChunkAdapter(locale: locale, bias: bias)
|
||||
let pipeline = ChunkedUtterancePipeline(
|
||||
asr: adapter,
|
||||
locale: locale,
|
||||
config: .flowDefault
|
||||
)
|
||||
let outcome = await pipeline.transcribe(
|
||||
stream: audioStream(from: samples),
|
||||
onPartial: { partial in
|
||||
onPartial?(partial)
|
||||
}
|
||||
)
|
||||
|
||||
switch outcome {
|
||||
case .success(let success):
|
||||
let warning = success.chunkWarnings.first
|
||||
return ChunkedLocalResult(text: success.text, chunkWarning: warning)
|
||||
case .failure(let message):
|
||||
throw MacLocalASRError.qwen3InferenceFailed(message)
|
||||
case .cancelled:
|
||||
throw MacLocalASRError.qwen3InferenceFailed("Cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds recorded PCM into the chunker as if it arrived incrementally.
|
||||
private static func audioStream(
|
||||
from samples: [Float],
|
||||
sliceSamples: Int = 8_000
|
||||
) -> AsyncStream<AudioBufferSnapshot> {
|
||||
AsyncStream { continuation in
|
||||
var offset = 0
|
||||
while offset < samples.count {
|
||||
let end = min(offset + sliceSamples, samples.count)
|
||||
continuation.yield(
|
||||
AudioBufferSnapshot(
|
||||
samples: Array(samples[offset..<end]),
|
||||
sampleRate: 16_000
|
||||
)
|
||||
)
|
||||
offset = end
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
return postASR
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// MacDictationResult.swift
|
||||
// OSGKeyboard · Mac
|
||||
|
||||
import Foundation
|
||||
|
||||
struct MacDictationResult: Sendable, Equatable {
|
||||
let text: String
|
||||
/// Shown when DeepSeek / cloud polish failed but raw ASR was delivered.
|
||||
let polishWarning: String?
|
||||
/// Non-fatal per-chunk ASR issues from long utterance chunking.
|
||||
let chunkWarning: String?
|
||||
}
|
||||
@@ -28,7 +28,7 @@ enum MacSection: String, CaseIterable, Identifiable {
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .dashboard: return "house"
|
||||
case .history: return "clock.arrow.circlepath"
|
||||
case .dictionary: return "character.book.closed"
|
||||
case .settings: return "gearshape"
|
||||
@@ -45,7 +45,13 @@ final class MacDictationViewModel: ObservableObject {
|
||||
@Published var selectedSection: MacSection = .dashboard
|
||||
|
||||
@Published var isRecording = false
|
||||
/// True while microphone permission / engine start is in flight.
|
||||
/// Drives the overlay so the HUD appears on Option-down immediately,
|
||||
/// instead of waiting for the async `beginRecording` to finish.
|
||||
@Published private(set) var isPreparingToRecord = false
|
||||
@Published var isProcessing = false
|
||||
/// True once live ASR has surfaced at least one partial during this take.
|
||||
@Published private(set) var isStreamingPartial = false
|
||||
@Published var transcript = ""
|
||||
@Published var statusMessage = ""
|
||||
@Published var audioLevel: Float = 0
|
||||
@@ -55,6 +61,7 @@ final class MacDictationViewModel: ObservableObject {
|
||||
|
||||
@Published var autoPasteEnabled: Bool
|
||||
@Published var hotkeyEnabled: Bool
|
||||
@Published var hotkeyTrigger: MacHotkeyTrigger
|
||||
|
||||
@Published var config: ProviderConfig
|
||||
|
||||
@@ -64,6 +71,12 @@ final class MacDictationViewModel: ObservableObject {
|
||||
private var levelTimer: Timer?
|
||||
private var sessionTimer: Timer?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
/// In-flight `beginRecording` started by the hotkey — cancelled if the
|
||||
/// key is released before the engine is ready (avoids a stuck session).
|
||||
private var hotkeyBeginTask: Task<Void, Never>?
|
||||
/// Live chunked ASR while recording (cloud / supported local paths).
|
||||
/// Finished in `finishRecording` so partials can become the final draft.
|
||||
private var liveCaptureTask: Task<MacLiveASRCaptureResult, Never>?
|
||||
|
||||
let usageStatistics: UsageStatisticsStore
|
||||
let speechHistory = SpeechHistoryStore.shared
|
||||
@@ -71,6 +84,7 @@ final class MacDictationViewModel: ObservableObject {
|
||||
private enum StoredKeys {
|
||||
static let autoPaste = "mac.autoPasteEnabled"
|
||||
static let hotkey = "mac.hotkeyEnabled"
|
||||
static let hotkeyTrigger = MacHotkeyTrigger.storageKey
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
@@ -79,6 +93,9 @@ final class MacDictationViewModel: ObservableObject {
|
||||
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
|
||||
self.hotkeyTrigger = MacHotkeyTrigger(
|
||||
rawValue: defaults.string(forKey: StoredKeys.hotkeyTrigger) ?? ""
|
||||
) ?? .rightOption
|
||||
|
||||
MacICloudSyncBootstrap.configure(defaults: defaults)
|
||||
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
|
||||
@@ -114,10 +131,16 @@ final class MacDictationViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Derived
|
||||
|
||||
var polishSelectableProviders: [LLMProvider] {
|
||||
LLMProvider.userSelectablePresets
|
||||
}
|
||||
|
||||
var asrSelectableProviders: [LLMProvider] {
|
||||
LLMProvider.asrSelectablePresets
|
||||
}
|
||||
|
||||
var selectableProviders: [LLMProvider] {
|
||||
LLMProvider.presets.filter {
|
||||
$0.isUserSelectable && $0.cloudASRStrategy != .localFallback
|
||||
}
|
||||
asrSelectableProviders
|
||||
}
|
||||
|
||||
var dictionaryTermCount: Int {
|
||||
@@ -185,6 +208,12 @@ final class MacDictationViewModel: ObservableObject {
|
||||
if enabled { hotkeyService.start() } else { hotkeyService.stop() }
|
||||
}
|
||||
|
||||
func setHotkeyTrigger(_ trigger: MacHotkeyTrigger) {
|
||||
hotkeyTrigger = trigger
|
||||
defaults.set(trigger.rawValue, forKey: StoredKeys.hotkeyTrigger)
|
||||
hotkeyService.trigger = trigger
|
||||
}
|
||||
|
||||
func setEngineMode(_ mode: String) {
|
||||
config.engineMode = mode
|
||||
}
|
||||
@@ -192,23 +221,45 @@ final class MacDictationViewModel: ObservableObject {
|
||||
// MARK: - Recording
|
||||
|
||||
func toggleRecording() {
|
||||
if isRecording { finishRecording() } else { beginRecording() }
|
||||
if isRecording || isPreparingToRecord {
|
||||
cancelOrFinishRecording()
|
||||
} else {
|
||||
Task { await beginRecording() }
|
||||
}
|
||||
}
|
||||
|
||||
func beginRecording() {
|
||||
guard !isProcessing else { return }
|
||||
func beginRecording() async {
|
||||
guard !isProcessing, !isRecording, !isPreparingToRecord else { return }
|
||||
isPreparingToRecord = true
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
MacAppContextService.captureAndPersist(to: store)
|
||||
refreshForegroundAppName()
|
||||
|
||||
do {
|
||||
try recorder.start()
|
||||
try await recorder.start()
|
||||
// Hotkey may have been released while we awaited mic permission /
|
||||
// engine start — abandon cleanly instead of latching a stuck session.
|
||||
isPreparingToRecord = false
|
||||
if Task.isCancelled {
|
||||
_ = recorder.stop()
|
||||
return
|
||||
}
|
||||
isRecording = true
|
||||
transcript = ""
|
||||
isStreamingPartial = false
|
||||
statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage)
|
||||
startTimers()
|
||||
startLiveCaptureIfSupported(store: store)
|
||||
// Tiny race: Option released between the cancel check and
|
||||
// `isRecording = true`. Treat it as end-of-hold and finish.
|
||||
if Task.isCancelled {
|
||||
finishRecording()
|
||||
}
|
||||
} catch {
|
||||
statusMessage = error.localizedDescription
|
||||
isPreparingToRecord = false
|
||||
if !Task.isCancelled {
|
||||
statusMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,49 +267,166 @@ final class MacDictationViewModel: ObservableObject {
|
||||
guard isRecording else { return }
|
||||
isRecording = false
|
||||
isProcessing = true
|
||||
statusMessage = MacL10n.string("mac.status.transcribing", language: config.uiLanguage)
|
||||
let hadLivePartial = isStreamingPartial
|
||||
statusMessage = MacL10n.string(
|
||||
hadLivePartial ? "mac.status.polishing" : "mac.status.transcribing",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
stopTimers()
|
||||
audioLevel = 0
|
||||
let samples = recorder.stop()
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
let liveTask = liveCaptureTask
|
||||
liveCaptureTask = nil
|
||||
|
||||
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)
|
||||
let result: MacDictationResult
|
||||
if let liveTask {
|
||||
let capture = await liveTask.value
|
||||
let trimmedLive = capture.raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !capture.shouldFallbackToBatch, !trimmedLive.isEmpty {
|
||||
if self.transcript.isEmpty {
|
||||
self.transcript = trimmedLive
|
||||
}
|
||||
result = try await MacDictationPipeline.polishCapturedASR(
|
||||
raw: capture.raw,
|
||||
store: store,
|
||||
localBias: capture.localBias,
|
||||
chunkWarning: capture.chunkWarning
|
||||
)
|
||||
} else {
|
||||
result = try await MacDictationPipeline.run(
|
||||
samples: samples,
|
||||
store: store,
|
||||
onPartial: { [weak self] partial in
|
||||
Task { @MainActor in
|
||||
self?.transcript = partial
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
result = try await MacDictationPipeline.run(
|
||||
samples: samples,
|
||||
store: store,
|
||||
onPartial: { [weak self] partial in
|
||||
Task { @MainActor in
|
||||
self?.transcript = partial
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
self.transcript = result.text
|
||||
let pasted = try await self.deliver(result.text)
|
||||
self.recordUsage(for: result.text)
|
||||
self.speechHistory.append(text: result.text)
|
||||
self.statusMessage = self.statusAfterDelivery(
|
||||
pasted: pasted,
|
||||
polishWarning: result.polishWarning,
|
||||
chunkWarning: result.chunkWarning
|
||||
)
|
||||
} catch {
|
||||
self.statusMessage = error.localizedDescription
|
||||
}
|
||||
self.isStreamingPartial = false
|
||||
self.isProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
private func deliver(_ text: String) throws -> Bool {
|
||||
try MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
|
||||
private func startLiveCaptureIfSupported(store: AppGroupStore) {
|
||||
guard MacDictationPipeline.supportsLivePartials(store: store) else { return }
|
||||
let stream = recorder.makeSnapshotStream()
|
||||
liveCaptureTask = Task { [weak self] in
|
||||
await MacDictationPipeline.captureLive(
|
||||
stream: stream,
|
||||
store: store,
|
||||
onPartial: { [weak self] partial in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
guard self.isRecording || self.isProcessing else { return }
|
||||
let trimmed = partial.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.transcript = partial
|
||||
self.isStreamingPartial = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func statusAfterDelivery(pasted: Bool) -> String {
|
||||
private func cancelLiveCapture() {
|
||||
liveCaptureTask?.cancel()
|
||||
liveCaptureTask = nil
|
||||
isStreamingPartial = false
|
||||
}
|
||||
|
||||
/// Stops an in-flight prepare, or finishes an active recording.
|
||||
private func cancelOrFinishRecording() {
|
||||
if isRecording {
|
||||
finishRecording()
|
||||
return
|
||||
}
|
||||
if isPreparingToRecord {
|
||||
hotkeyBeginTask?.cancel()
|
||||
hotkeyBeginTask = nil
|
||||
// If the button-triggered prepare wasn't tracked by hotkeyBeginTask,
|
||||
// still clear the preparing flag and stop any engine that raced in.
|
||||
isPreparingToRecord = false
|
||||
cancelLiveCapture()
|
||||
_ = recorder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private func deliver(_ text: String) async throws -> Bool {
|
||||
try await MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
|
||||
}
|
||||
|
||||
private func statusAfterDelivery(
|
||||
pasted: Bool,
|
||||
polishWarning: String? = nil,
|
||||
chunkWarning: String? = nil
|
||||
) -> String {
|
||||
let lang = config.uiLanguage
|
||||
let base: String
|
||||
if autoPasteEnabled, pasted {
|
||||
return MacL10n.string("mac.status.copiedAndPasted", language: lang)
|
||||
base = MacL10n.string("mac.status.copiedAndPasted", language: lang)
|
||||
} else if autoPasteEnabled, !pasted {
|
||||
base = MacL10n.string("mac.status.copied", language: lang)
|
||||
} else {
|
||||
base = MacL10n.string("mac.status.copied", language: lang)
|
||||
}
|
||||
if autoPasteEnabled, !pasted {
|
||||
return MacL10n.string("mac.status.copied", language: lang)
|
||||
|
||||
if let polishWarning, !polishWarning.isEmpty {
|
||||
return MacL10n.format("mac.status.deliveryWithNote", language: lang, base, polishWarning)
|
||||
}
|
||||
return MacL10n.string("mac.status.copied", language: lang)
|
||||
if let chunkWarning, !chunkWarning.isEmpty {
|
||||
return MacL10n.format("mac.status.deliveryWithNote", language: lang, base, chunkWarning)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
private func wireHotkeyService() {
|
||||
hotkeyService.trigger = hotkeyTrigger
|
||||
hotkeyService.onPressBegan = { [weak self] in
|
||||
self?.beginRecording()
|
||||
guard let self else { return }
|
||||
self.hotkeyBeginTask?.cancel()
|
||||
self.hotkeyBeginTask = Task { [weak self] in
|
||||
await self?.beginRecording()
|
||||
}
|
||||
}
|
||||
hotkeyService.onPressEnded = { [weak self] in
|
||||
self?.finishRecording()
|
||||
guard let self else { return }
|
||||
// Cancel a still-preparing start so a quick Option tap never
|
||||
// latches recording. If recording already began, finish it.
|
||||
if self.isRecording {
|
||||
self.hotkeyBeginTask = nil
|
||||
self.finishRecording()
|
||||
} else {
|
||||
self.hotkeyBeginTask?.cancel()
|
||||
self.hotkeyBeginTask = nil
|
||||
}
|
||||
}
|
||||
if hotkeyEnabled { hotkeyService.start() }
|
||||
}
|
||||
@@ -304,6 +472,10 @@ final class MacDictationViewModel: ObservableObject {
|
||||
config.apply(preset: provider)
|
||||
}
|
||||
|
||||
func selectAsrProvider(_ provider: LLMProvider) {
|
||||
config.applyAsr(preset: provider)
|
||||
}
|
||||
|
||||
func refreshForegroundAppName() {
|
||||
foregroundAppName = MacAppContextService.frontmostApplicationName()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// 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.
|
||||
// Personal dictionary synced via iCloud KVS. ScrollView is full-bleed
|
||||
// (scrollbar on the window edge); title + cards share `pageHorizontalInset`.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -45,16 +44,27 @@ struct MacDictionaryView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entries.isEmpty {
|
||||
emptyState
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
form
|
||||
.transition(.opacity)
|
||||
VStack(spacing: 0) {
|
||||
MacPageHeader(
|
||||
title: MacL10n.string("mac.section.dictionary", language: lang),
|
||||
subtitle: MacL10n.string("mac.page.dictionary.subtitle", language: lang)
|
||||
) {
|
||||
if !entries.isEmpty {
|
||||
searchField
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if entries.isEmpty {
|
||||
emptyState
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
list
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(palette.background)
|
||||
.animation(Motion.soft, value: entries.isEmpty)
|
||||
.task {
|
||||
@@ -64,33 +74,6 @@ struct MacDictionaryView: View {
|
||||
.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)
|
||||
.animation(Motion.soft, value: query)
|
||||
.safeAreaInset(edge: .top, spacing: 0) { centeredSearchField }
|
||||
.confirmationDialog(
|
||||
MacL10n.string("mac.dict.deleteTitle", language: lang),
|
||||
isPresented: deletionDialogBinding,
|
||||
@@ -110,6 +93,55 @@ struct MacDictionaryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
|
||||
private var list: some View {
|
||||
// Full-bleed ScrollView → scrollbar on the detail pane's right edge.
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Spacing.md) {
|
||||
if sections.isEmpty {
|
||||
MacCard {
|
||||
Text(MacL10n.string("mac.dict.noMatch", language: lang))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
} else {
|
||||
ForEach(sections, id: \.category) { section in
|
||||
categorySection(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
.padding(.bottom, Spacing.md)
|
||||
.animation(Motion.soft, value: query)
|
||||
}
|
||||
}
|
||||
|
||||
private func categorySection(
|
||||
_ section: (category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry])
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text(MacL10n.string(section.category.labelKey, language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
|
||||
MacCard(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(section.items.enumerated()), id: \.element.id) { index, entry in
|
||||
row(entry)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
if index < section.items.count - 1 {
|
||||
// Full-bleed like macOS list rows (not iOS inset separators).
|
||||
Divider()
|
||||
.overlay(palette.divider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var deletionDialogBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { entryPendingDeletion != nil },
|
||||
@@ -117,25 +149,19 @@ struct MacDictionaryView: View {
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
private var searchField: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
TextField(MacL10n.string("mac.dict.search", language: lang), text: $query)
|
||||
.textFieldStyle(.plain)
|
||||
.font(TypeStyle.footnote)
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.background(palette.background)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 6)
|
||||
.frame(width: 220)
|
||||
.background(palette.surface, in: Capsule())
|
||||
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
private func row(_ entry: PersonalDictionary.Entry) -> some View {
|
||||
@@ -165,19 +191,19 @@ struct MacDictionaryView: View {
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "character.book.closed")
|
||||
.font(.system(size: 34))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.6))
|
||||
.font(.system(size: 34, weight: .light))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.55))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text(MacL10n.string("mac.dict.empty", language: lang))
|
||||
.font(TypeStyle.body)
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Text(MacL10n.string("mac.dict.emptyBody", language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 360)
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
}
|
||||
|
||||
private func delete(_ entry: PersonalDictionary.Entry) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// 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.
|
||||
// Day-grouped transcript log. ScrollView is full-bleed (scrollbar on the
|
||||
// window edge); title + cards share `pageHorizontalInset` on their content.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -31,36 +30,39 @@ struct MacHistoryView: View {
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if historyStore.entries.isEmpty {
|
||||
emptyState
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
form
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(palette.background)
|
||||
.animation(Motion.soft, value: historyStore.entries.isEmpty)
|
||||
}
|
||||
|
||||
// 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)
|
||||
VStack(spacing: 0) {
|
||||
MacPageHeader(
|
||||
title: MacL10n.string("mac.section.history", language: lang),
|
||||
subtitle: MacL10n.string("mac.page.history.subtitle", language: lang)
|
||||
) {
|
||||
if !historyStore.entries.isEmpty {
|
||||
Button {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
Label(
|
||||
MacL10n.string("mac.history.clearConfirm", language: lang),
|
||||
systemImage: "trash"
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if historyStore.entries.isEmpty {
|
||||
emptyState
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
list
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.safeAreaInset(edge: .top, spacing: 0) { toolbar }
|
||||
.animation(Motion.soft, value: historyStore.entries.isEmpty)
|
||||
.confirmationDialog(
|
||||
MacL10n.string("mac.history.clearTitle", language: lang),
|
||||
isPresented: $showClearConfirmation,
|
||||
@@ -75,21 +77,43 @@ struct MacHistoryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbar: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
Label(MacL10n.string("mac.history.clearConfirm", language: lang), systemImage: "trash")
|
||||
.font(TypeStyle.caption)
|
||||
// MARK: - List
|
||||
|
||||
private var list: some View {
|
||||
// Full-bleed ScrollView → scrollbar on the detail pane's right edge.
|
||||
// Horizontal inset lives on the content so cards align with the title.
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Spacing.md) {
|
||||
ForEach(historyStore.groupedByDay, id: \.day) { group in
|
||||
daySection(group)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
.padding(.bottom, Spacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
private func daySection(_ group: (day: Date, items: [SpeechHistoryEntry])) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text(Self.dayFormatter.string(from: group.day))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
|
||||
MacCard(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(group.items.enumerated()), id: \.element.id) { index, entry in
|
||||
row(entry)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
if index < group.items.count - 1 {
|
||||
// Full-bleed like macOS list rows (not iOS inset separators).
|
||||
Divider()
|
||||
.overlay(palette.divider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.background(palette.background)
|
||||
}
|
||||
|
||||
private func row(_ entry: SpeechHistoryEntry) -> some View {
|
||||
@@ -107,11 +131,17 @@ struct MacHistoryView: View {
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "text.bubble")
|
||||
.font(.system(size: 34))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.6))
|
||||
.font(.system(size: 34, weight: .light))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.55))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text(MacL10n.string("mac.history.empty", language: lang))
|
||||
.font(TypeStyle.body)
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Text(MacL10n.string("mac.history.emptyBody", language: lang))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,93 @@
|
||||
// MacHotkeyService.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Global hold-to-talk: while Option (⌥) is held, dictation runs. Mirrors
|
||||
// Typeless / SayIt push-to-talk from any foreground app.
|
||||
// Global hold-to-talk: while the configured Option (⌥) key is held, dictation
|
||||
// runs. Mirrors Typeless / SayIt push-to-talk from any foreground app.
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// Which physical Option (⌥) key triggers global hold-to-talk.
|
||||
///
|
||||
/// Right Option is the default: the left key is a routine typing modifier
|
||||
/// (special characters, app shortcuts), so firing on any Option press
|
||||
/// constantly misfires during normal typing.
|
||||
enum MacHotkeyTrigger: String, CaseIterable, Identifiable {
|
||||
case rightOption
|
||||
case leftOption
|
||||
case eitherOption
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var labelKey: String {
|
||||
switch self {
|
||||
case .rightOption: return "mac.hotkeyTrigger.rightOption"
|
||||
case .leftOption: return "mac.hotkeyTrigger.leftOption"
|
||||
case .eitherOption: return "mac.hotkeyTrigger.eitherOption"
|
||||
}
|
||||
}
|
||||
|
||||
/// Main-window hint under the record button — must follow the picker,
|
||||
/// or the UI tells left-Option users to hold the right key.
|
||||
var hintKey: String {
|
||||
switch self {
|
||||
case .rightOption: return "mac.hint.hold.rightOption"
|
||||
case .leftOption: return "mac.hint.hold.leftOption"
|
||||
case .eitherOption: return "mac.hint.hold.eitherOption"
|
||||
}
|
||||
}
|
||||
|
||||
/// `@AppStorage`-compatible key; persisted via the view model's defaults.
|
||||
static let storageKey = "mac.hotkeyTrigger"
|
||||
|
||||
/// Device-dependent modifier bits (IOKit `NX_DEVICELALTKEYMASK` /
|
||||
/// `NX_DEVICERALTKEYMASK`) that `.flagsChanged` events carry alongside the
|
||||
/// device-independent `.option` flag, telling left and right apart.
|
||||
private static let leftOptionMask: UInt = 0x20
|
||||
private static let rightOptionMask: UInt = 0x40
|
||||
|
||||
/// Whether this trigger's key is currently down in a `.flagsChanged` event.
|
||||
func isPressed(in event: NSEvent) -> Bool {
|
||||
guard event.modifierFlags.contains(.option) else { return false }
|
||||
let raw = event.modifierFlags.rawValue
|
||||
switch self {
|
||||
case .rightOption: return raw & Self.rightOptionMask != 0
|
||||
case .leftOption: return raw & Self.leftOptionMask != 0
|
||||
case .eitherOption: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MacHotkeyService {
|
||||
/// How long the trigger key must stay held before recording begins.
|
||||
/// Filters out quick ⌥-taps and ⌥+key combos (special characters, app
|
||||
/// shortcuts) that would otherwise start and immediately abort dictation.
|
||||
private static let holdDebounce: Duration = .milliseconds(150)
|
||||
|
||||
var onPressBegan: (() -> Void)?
|
||||
var onPressEnded: (() -> Void)?
|
||||
var trigger: MacHotkeyTrigger = .rightOption
|
||||
|
||||
private var globalFlagsMonitor: Any?
|
||||
private var localFlagsMonitor: Any?
|
||||
private var optionHeld = false
|
||||
/// The trigger key is physically down (debounce may still be pending).
|
||||
private var triggerKeyDown = false
|
||||
/// `onPressBegan` has fired and `onPressEnded` is owed.
|
||||
private var pressActive = false
|
||||
private var pendingBegin: Task<Void, Never>?
|
||||
private var isEnabled = true
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
isEnabled = enabled
|
||||
if !enabled, optionHeld {
|
||||
optionHeld = false
|
||||
onPressEnded?()
|
||||
}
|
||||
if !enabled { cancelPress() }
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard globalFlagsMonitor == nil else { return }
|
||||
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
|
||||
guard globalFlagsMonitor == nil, localFlagsMonitor == nil else { return }
|
||||
// Global monitors require Accessibility; without it the call returns
|
||||
// nil and Option-hold never fires outside our own windows.
|
||||
let trusted = MacTextInsertionService.requestAccessibilityIfNeeded()
|
||||
|
||||
globalFlagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
Task { @MainActor in self?.handleFlagsChanged(event) }
|
||||
@@ -36,6 +96,12 @@ final class MacHotkeyService {
|
||||
Task { @MainActor in self?.handleFlagsChanged(event) }
|
||||
return event
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if !trusted || globalFlagsMonitor == nil {
|
||||
NSLog("[OSGKeyboard] Hotkey global monitor unavailable — grant Accessibility in System Settings")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func stop() {
|
||||
@@ -47,20 +113,47 @@ final class MacHotkeyService {
|
||||
NSEvent.removeMonitor(localFlagsMonitor)
|
||||
self.localFlagsMonitor = nil
|
||||
}
|
||||
if optionHeld {
|
||||
optionHeld = false
|
||||
onPressEnded?()
|
||||
}
|
||||
cancelPress()
|
||||
}
|
||||
|
||||
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
|
||||
let triggerDown = trigger.isPressed(in: event)
|
||||
if triggerDown, !triggerKeyDown {
|
||||
triggerKeyDown = true
|
||||
scheduleBegin()
|
||||
} else if !triggerDown, triggerKeyDown {
|
||||
triggerKeyDown = false
|
||||
pendingBegin?.cancel()
|
||||
pendingBegin = nil
|
||||
if pressActive {
|
||||
pressActive = false
|
||||
onPressEnded?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Debounce: begin only after the key has stayed held for `holdDebounce`.
|
||||
/// Releasing the key first cancels the pending start, so a quick
|
||||
/// Option+key combo never triggers recording.
|
||||
private func scheduleBegin() {
|
||||
pendingBegin?.cancel()
|
||||
pendingBegin = Task { [weak self] in
|
||||
try? await Task.sleep(for: Self.holdDebounce)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.pendingBegin = nil
|
||||
guard self.isEnabled, self.triggerKeyDown, !self.pressActive else { return }
|
||||
self.pressActive = true
|
||||
self.onPressBegan?()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPress() {
|
||||
pendingBegin?.cancel()
|
||||
pendingBegin = nil
|
||||
triggerKeyDown = false
|
||||
if pressActive {
|
||||
pressActive = false
|
||||
onPressEnded?()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// MacLocalASRChunkAdapter.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Adapts macOS local ASR to the shared chunked utterance pipeline.
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
final class MacLocalASRChunkAdapter: ASRChunkTranscribing, @unchecked Sendable {
|
||||
private let locale: Locale
|
||||
private let bias: LocalASRBiasPayload?
|
||||
private let cancelled = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
init(locale: Locale, bias: LocalASRBiasPayload?) {
|
||||
self.locale = locale
|
||||
self.bias = bias
|
||||
}
|
||||
|
||||
func resetForNewUtterance() {
|
||||
cancelled.withLock { $0 = false }
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
cancelled.withLock { $0 = true }
|
||||
}
|
||||
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
let isCancelled = cancelled.withLock { $0 }
|
||||
if isCancelled || Task.isCancelled { return .cancelled }
|
||||
guard !samples.isEmpty else { return .success("") }
|
||||
|
||||
do {
|
||||
let text = try await MacLocalASRService.transcribe(
|
||||
samples: samples,
|
||||
locale: locale,
|
||||
bias: bias
|
||||
)
|
||||
return .success(text)
|
||||
} catch {
|
||||
return .failure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,14 +276,17 @@ struct MacLocalASRModelSettingsView: View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text(model.displayName)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
if let badgeKey = model.badgeKey {
|
||||
modelBadge(
|
||||
MacL10n.string(badgeKey, language: lang),
|
||||
emphasized: true
|
||||
)
|
||||
}
|
||||
if model.supportsHotwords {
|
||||
Text(MacL10n.string("mac.localASR.personalDictionaryTag", language: lang))
|
||||
.font(TypeStyle.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(palette.accent.opacity(0.15))
|
||||
.foregroundStyle(palette.accent)
|
||||
.clipShape(Capsule())
|
||||
modelBadge(
|
||||
MacL10n.string("mac.localASR.personalDictionaryTag", language: lang),
|
||||
emphasized: false
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(modelSubtitle(model, installed: installed))
|
||||
@@ -305,6 +308,20 @@ struct MacLocalASRModelSettingsView: View {
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private func modelBadge(_ title: String, emphasized: Bool) -> some View {
|
||||
Text(title)
|
||||
.font(TypeStyle.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
emphasized
|
||||
? palette.accent.opacity(0.18)
|
||||
: palette.textTertiary.opacity(0.12)
|
||||
)
|
||||
.foregroundStyle(emphasized ? palette.accent : palette.textSecondary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func modelRowActions(
|
||||
model: LocalASRModelDefinition,
|
||||
|
||||
@@ -58,7 +58,7 @@ enum MacLocalASRPreferences {
|
||||
/// Maps removed catalog entries to the current default Sherpa model.
|
||||
static func migratedModelId(_ id: String) -> String {
|
||||
switch id {
|
||||
case "qwen3-mlx-1.7b":
|
||||
case "qwen3-mlx-1.7b", "sherpa-paraformer-zh-int8":
|
||||
return "sherpa-qwen3-0.6b-int8"
|
||||
default:
|
||||
return id
|
||||
@@ -120,7 +120,7 @@ enum MacLocalASRService {
|
||||
return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
|
||||
}
|
||||
|
||||
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
|
||||
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale, bias: bias)
|
||||
}
|
||||
|
||||
private static func transcribeWithModel(
|
||||
@@ -141,7 +141,7 @@ enum MacLocalASRService {
|
||||
bias: bias
|
||||
)
|
||||
case .appleSpeech:
|
||||
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
|
||||
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale, bias: bias)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ struct MacOnboardingView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 860, minHeight: 600)
|
||||
.frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
|
||||
.onAppear {
|
||||
applyDefaults()
|
||||
model.reload()
|
||||
|
||||
@@ -18,15 +18,6 @@ struct MacRootView: View {
|
||||
|
||||
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
|
||||
@@ -35,7 +26,7 @@ struct MacRootView: View {
|
||||
detail
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.frame(minWidth: 860, minHeight: 600)
|
||||
.frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
|
||||
.onAppear {
|
||||
// Let the AppKit status-bar popover reopen this window on demand.
|
||||
MacWindowBridge.shared.open = { openWindow(id: "main") }
|
||||
@@ -72,7 +63,7 @@ struct MacRootView: View {
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 30)
|
||||
.frame(height: 28)
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityLabel("OSGKeyboard")
|
||||
Spacer()
|
||||
@@ -80,7 +71,7 @@ struct MacRootView: View {
|
||||
.padding(.leading, MacMetrics.sidebarContentInset)
|
||||
.padding(.trailing, MacMetrics.sidebarInset)
|
||||
.padding(.top, Spacing.lg)
|
||||
.padding(.bottom, Spacing.lg)
|
||||
.padding(.bottom, Spacing.md)
|
||||
}
|
||||
|
||||
private var devicesFooter: some View {
|
||||
@@ -88,10 +79,10 @@ struct MacRootView: View {
|
||||
MacL10n.string("mac.devices", language: uiLanguage),
|
||||
systemImage: "laptopcomputer.and.iphone"
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, MacMetrics.sidebarInset)
|
||||
.padding(.horizontal, MacMetrics.sidebarInset + Spacing.sm)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
|
||||
@@ -118,8 +109,8 @@ struct MacRootView: View {
|
||||
|
||||
// MARK: - Sidebar row
|
||||
|
||||
/// A navigation row with an animated hover highlight and selection state,
|
||||
/// matching the macOS System Settings feel.
|
||||
/// Navigation row with a restrained selected state: muted accent fill +
|
||||
/// accent label (not a solid green pill), matching System Settings polish.
|
||||
private struct MacSidebarRow: View {
|
||||
let section: MacSection
|
||||
let isSelected: Bool
|
||||
@@ -132,8 +123,8 @@ private struct MacSidebarRow: View {
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label(section.title(language: language), systemImage: section.systemImage)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(isSelected ? palette.textOnAccent : palette.textPrimary)
|
||||
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 7)
|
||||
@@ -150,7 +141,7 @@ private struct MacSidebarRow: View {
|
||||
}
|
||||
|
||||
private var rowBackground: Color {
|
||||
if isSelected { return palette.accent }
|
||||
return isHovering ? palette.textPrimary.opacity(0.06) : .clear
|
||||
if isSelected { return palette.accentMuted }
|
||||
return isHovering ? palette.textPrimary.opacity(0.05) : .clear
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// 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.
|
||||
// Settings uses native grouped `Form` for correct control layout (Picker /
|
||||
// Toggle / LabeledContent). Title and Form share the same plain
|
||||
// `pageHorizontalInset` padding (Form scroll margins are zeroed first) so
|
||||
// card chrome lines up with the page title.
|
||||
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
@@ -20,6 +21,7 @@ struct MacSettingsView: View {
|
||||
private var hasCompletedMacOnboarding = true
|
||||
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
@State private var showProviderPicker = false
|
||||
@State private var showAsrProviderPicker = false
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
private let recognitionLocales: [(id: String, key: String, fallback: String)] = [
|
||||
@@ -33,23 +35,43 @@ struct MacSettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
generalSection
|
||||
recognitionSection
|
||||
if viewModel.config.engineMode == "cloud" {
|
||||
providerSection
|
||||
.transition(.opacity)
|
||||
VStack(spacing: 0) {
|
||||
MacPageHeader(
|
||||
title: MacL10n.string("mac.section.settings", language: lang),
|
||||
subtitle: MacL10n.string("mac.page.settings.subtitle", language: lang)
|
||||
)
|
||||
|
||||
Form {
|
||||
generalSection
|
||||
recognitionSection
|
||||
polishProviderSection
|
||||
if viewModel.config.engineMode == "cloud" {
|
||||
asrProviderSection
|
||||
.transition(.opacity)
|
||||
}
|
||||
if viewModel.config.engineMode == "local" {
|
||||
MacLocalASRModelSettingsView(viewModel: viewModel)
|
||||
.transition(.opacity)
|
||||
}
|
||||
inputSection
|
||||
legalSection
|
||||
}
|
||||
if viewModel.config.engineMode == "local" {
|
||||
MacLocalASRModelSettingsView(viewModel: viewModel)
|
||||
.transition(.opacity)
|
||||
}
|
||||
inputSection
|
||||
legalSection
|
||||
.formStyle(.grouped)
|
||||
// Zero Form's own scroll margins, then inset via padding so the
|
||||
// section cards line up with MacPageHeader (contentMargins alone
|
||||
// does not match plain padding on macOS).
|
||||
//
|
||||
// grouped Form adds its own built-in section inset on top of our
|
||||
// padding, so cards sat ~`groupedFormSectionInset` wider than the
|
||||
// History page. Subtract that inset here so the card OUTER edge
|
||||
// lands on `pageHorizontalInset` (40pt), matching History and the
|
||||
// page title's left edge.
|
||||
.contentMargins(.horizontal, 0, for: .scrollContent)
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset - MacMetrics.groupedFormSectionInset)
|
||||
.tint(palette.accent)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tint(palette.accent)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
}
|
||||
.onAppear { refreshAccessibilityState() }
|
||||
@@ -82,27 +104,21 @@ struct MacSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cloud provider
|
||||
// MARK: - Polish LLM
|
||||
|
||||
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
|
||||
private var polishProviderSection: some View {
|
||||
Section(MacL10n.string("mac.settings.polishProvider", language: lang)) {
|
||||
providerPickerRow(
|
||||
title: MacL10n.string("mac.settings.service", language: lang),
|
||||
provider: currentPolishProvider,
|
||||
isPresented: $showProviderPicker
|
||||
) {
|
||||
providerPickerList(
|
||||
providers: viewModel.polishSelectableProviders,
|
||||
selectedId: viewModel.config.providerId
|
||||
) { provider in
|
||||
viewModel.selectProvider(provider)
|
||||
showProviderPicker = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +133,17 @@ struct MacSettingsView: View {
|
||||
Text(MacL10n.string("mac.settings.apiKey", language: lang))
|
||||
}
|
||||
|
||||
LabeledContent {
|
||||
TextField(text: $viewModel.config.baseURL, prompt: Text(verbatim: "")) {
|
||||
Text(MacL10n.string("mac.settings.baseURL", language: lang))
|
||||
}
|
||||
.labelsHidden()
|
||||
.macFieldStyle()
|
||||
.frame(maxWidth: MacMetrics.controlWidth)
|
||||
} label: {
|
||||
Text(MacL10n.string("mac.settings.baseURL", language: lang))
|
||||
}
|
||||
|
||||
LabeledContent {
|
||||
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
|
||||
Text(MacL10n.string("mac.settings.model", language: lang))
|
||||
@@ -130,6 +157,86 @@ struct MacSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cloud ASR
|
||||
|
||||
private var asrProviderSection: some View {
|
||||
Section(MacL10n.string("mac.settings.asrProvider", language: lang)) {
|
||||
providerPickerRow(
|
||||
title: MacL10n.string("mac.settings.asrService", language: lang),
|
||||
provider: currentAsrProvider,
|
||||
isPresented: $showAsrProviderPicker
|
||||
) {
|
||||
providerPickerList(
|
||||
providers: viewModel.asrSelectableProviders,
|
||||
selectedId: viewModel.config.asrProviderId
|
||||
) { provider in
|
||||
viewModel.selectAsrProvider(provider)
|
||||
showAsrProviderPicker = false
|
||||
}
|
||||
}
|
||||
|
||||
LabeledContent {
|
||||
SecureField(text: $viewModel.config.asrApiKey, prompt: Text(verbatim: "sk-…")) {
|
||||
Text(MacL10n.string("mac.settings.apiKey", language: lang))
|
||||
}
|
||||
.labelsHidden()
|
||||
.macFieldStyle()
|
||||
.frame(maxWidth: MacMetrics.controlWidth)
|
||||
} label: {
|
||||
Text(MacL10n.string("mac.settings.asrApiKey", language: lang))
|
||||
}
|
||||
|
||||
if CloudASRModelCatalog.strategy(for: viewModel.config.asrProviderId) == .prompt {
|
||||
LabeledContent {
|
||||
TextField(text: $viewModel.config.asrBaseURL, prompt: Text(verbatim: "")) {
|
||||
Text(MacL10n.string("mac.settings.baseURL", language: lang))
|
||||
}
|
||||
.labelsHidden()
|
||||
.macFieldStyle()
|
||||
.frame(maxWidth: MacMetrics.controlWidth)
|
||||
} label: {
|
||||
Text(MacL10n.string("mac.settings.baseURL", language: lang))
|
||||
}
|
||||
}
|
||||
|
||||
LabeledContent {
|
||||
TextField(text: $viewModel.config.asrModel, prompt: Text(verbatim: "")) {
|
||||
Text(MacL10n.string("mac.settings.asrModel", language: lang))
|
||||
}
|
||||
.labelsHidden()
|
||||
.macFieldStyle()
|
||||
.frame(maxWidth: MacMetrics.controlWidth)
|
||||
} label: {
|
||||
Text(MacL10n.string("mac.settings.asrModel", language: lang))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func providerPickerRow<Content: View>(
|
||||
title: String,
|
||||
provider: LLMProvider,
|
||||
isPresented: Binding<Bool>,
|
||||
@ViewBuilder picker: @escaping () -> Content
|
||||
) -> some View {
|
||||
LabeledContent(title) {
|
||||
Button {
|
||||
isPresented.wrappedValue = true
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
providerLogo(provider.id)
|
||||
Text(provider.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: isPresented, arrowEdge: .bottom, content: picker)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recognition method
|
||||
|
||||
private var recognitionSection: some View {
|
||||
@@ -161,6 +268,19 @@ struct MacSettingsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
Picker(selection: hotkeyTriggerBinding) {
|
||||
ForEach(MacHotkeyTrigger.allCases) { trigger in
|
||||
Text(MacL10n.string(trigger.labelKey, language: lang))
|
||||
.tag(trigger.rawValue)
|
||||
}
|
||||
} label: {
|
||||
rowLabel(
|
||||
MacL10n.string("mac.settings.hotkeyTrigger", language: lang),
|
||||
subtitle: MacL10n.string("mac.settings.hotkeyTriggerDesc", language: lang)
|
||||
)
|
||||
}
|
||||
.disabled(!viewModel.hotkeyEnabled)
|
||||
|
||||
Toggle(isOn: autoPasteBinding) {
|
||||
rowLabel(
|
||||
MacL10n.string("mac.settings.autoPaste", language: lang),
|
||||
@@ -258,28 +378,34 @@ struct MacSettingsView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var currentProvider: LLMProvider {
|
||||
viewModel.selectableProviders.first { $0.id == viewModel.config.providerId }
|
||||
?? viewModel.selectableProviders.first
|
||||
private var currentPolishProvider: LLMProvider {
|
||||
viewModel.polishSelectableProviders.first { $0.id == viewModel.config.providerId }
|
||||
?? viewModel.polishSelectableProviders.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 {
|
||||
private var currentAsrProvider: LLMProvider {
|
||||
viewModel.asrSelectableProviders.first { $0.id == viewModel.config.asrProviderId }
|
||||
?? viewModel.asrSelectableProviders.first
|
||||
?? LLMProvider.presets[0]
|
||||
}
|
||||
|
||||
private func providerPickerList(
|
||||
providers: [LLMProvider],
|
||||
selectedId: String,
|
||||
onSelect: @escaping (LLMProvider) -> Void
|
||||
) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(viewModel.selectableProviders) { provider in
|
||||
ForEach(providers) { provider in
|
||||
Button {
|
||||
viewModel.selectProvider(provider)
|
||||
showProviderPicker = false
|
||||
onSelect(provider)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
providerLogo(provider.id)
|
||||
Text(provider.name)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.md)
|
||||
if provider.id == currentProvider.id {
|
||||
if provider.id == selectedId {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
@@ -359,6 +485,13 @@ struct MacSettingsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var hotkeyTriggerBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { viewModel.hotkeyTrigger.rawValue },
|
||||
set: { viewModel.setHotkeyTrigger(MacHotkeyTrigger(rawValue: $0) ?? .rightOption) }
|
||||
)
|
||||
}
|
||||
|
||||
private var autoPasteBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { viewModel.autoPasteEnabled },
|
||||
|
||||
@@ -165,16 +165,69 @@ enum MacSherpaONNXRunner {
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
for line in lines.reversed() {
|
||||
if line.hasPrefix("{"), let data = line.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let text = object["text"] as? String {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
if line.hasPrefix("{") {
|
||||
// Sherpa's JSON result line (`{"text": ..., "lang": ..., ...}`).
|
||||
// Trust only its `text` field — including when it's empty
|
||||
// (silence/no-speech) — and never fall through to the raw
|
||||
// JSON below, or the JSON blob itself gets inserted as text.
|
||||
if let data = line.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let text = object["text"] as? String {
|
||||
return sanitizeTranscript(text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isMetadataNoiseLine(line) { continue }
|
||||
if !line.hasPrefix("/"), !line.hasPrefix("--"), line.count > 1 {
|
||||
return line
|
||||
let cleaned = sanitizeTranscript(line)
|
||||
if !cleaned.isEmpty { return cleaned }
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// Qwen3-ASR (via sherpa-onnx) often prefixes the transcript with a
|
||||
/// scaffold such as `language Chinese<asr_text>…`. Older runtimes leave
|
||||
/// that intact in `result.text`; incomplete generations can even stop at
|
||||
/// the bare word `language`. Strip the scaffold so only spoken text remains.
|
||||
private static func sanitizeTranscript(_ raw: String) -> String {
|
||||
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.isEmpty { return "" }
|
||||
|
||||
// Prefer the payload after the last `<asr_text>` marker.
|
||||
if let marker = text.range(of: "<asr_text>", options: .backwards) {
|
||||
text = String(text[marker.upperBound...])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} else if let match = text.range(
|
||||
of: #"^language\s+\S+\s*"#,
|
||||
options: [.regularExpression, .caseInsensitive]
|
||||
) {
|
||||
// Fallback when the marker token was lost but the language prefix remains.
|
||||
text = String(text[match.upperBound...])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// Drop leftover control tokens / bare scaffold words.
|
||||
if isMetadataNoiseLine(text) { return "" }
|
||||
return text
|
||||
}
|
||||
|
||||
/// Lines that are sherpa/Qwen metadata rather than spoken content.
|
||||
private static func isMetadataNoiseLine(_ line: String) -> Bool {
|
||||
let lowered = line.lowercased()
|
||||
switch lowered {
|
||||
case "language", "emotion", "event", "text",
|
||||
"<asr_text>", "</asr_text>", "<|im_end|>":
|
||||
return true
|
||||
default:
|
||||
// Exact scaffold with no spoken payload, e.g. "language Chinese".
|
||||
if lowered.range(
|
||||
of: #"^language(\s+\S+)?$"#,
|
||||
options: .regularExpression
|
||||
) != nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,65 @@ import Foundation
|
||||
import Speech
|
||||
|
||||
enum MacSpeechLocalASR {
|
||||
static func transcribe(samples: [Float], locale: Locale) async throws -> String {
|
||||
/// Shared resume-once state for one recognition run. The recognizer
|
||||
/// callback (delivered on an arbitrary Speech queue) and the timeout task
|
||||
/// race to finish, and a `CheckedContinuation` must resume exactly once,
|
||||
/// so both go through this lock-guarded gate. It also retains the
|
||||
/// `SFSpeechRecognitionTask` so the losing/failing path can cancel it.
|
||||
private final class RecognitionSession: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var isResumed = false
|
||||
private var task: SFSpeechRecognitionTask?
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
|
||||
func retain(_ task: SFSpeechRecognitionTask) {
|
||||
lock.lock()
|
||||
self.task = task
|
||||
let alreadyResumed = isResumed
|
||||
lock.unlock()
|
||||
// Timeout won the race before the task handle was stored.
|
||||
if alreadyResumed { task.cancel() }
|
||||
}
|
||||
|
||||
func retainTimeout(_ task: Task<Void, Never>) {
|
||||
lock.lock()
|
||||
timeoutTask = task
|
||||
let alreadyResumed = isResumed
|
||||
lock.unlock()
|
||||
// Recognition finished before the handle landed — stop the timer.
|
||||
if alreadyResumed { task.cancel() }
|
||||
}
|
||||
|
||||
/// Returns `true` exactly once across all callers; the winner may
|
||||
/// resume the continuation. Pass `cancellingTask: true` on failure
|
||||
/// paths so the in-flight recognition stops doing work. The winner
|
||||
/// also cancels the timeout task so it doesn't keep the session (and
|
||||
/// continuation captures) alive for the rest of its sleep.
|
||||
func claimResume(cancellingTask: Bool) -> Bool {
|
||||
lock.lock()
|
||||
guard !isResumed else {
|
||||
lock.unlock()
|
||||
return false
|
||||
}
|
||||
isResumed = true
|
||||
let task = self.task
|
||||
let timeout = timeoutTask
|
||||
lock.unlock()
|
||||
if cancellingTask { task?.cancel() }
|
||||
timeout?.cancel()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
static func transcribe(samples: [Float], locale: Locale, bias: LocalASRBiasPayload? = nil) async throws -> String {
|
||||
let auth = await requestAuthorization()
|
||||
guard auth == .authorized else { throw MacLocalASRError.speechDenied }
|
||||
|
||||
if Self.isChineseLocale(locale) {
|
||||
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
|
||||
_ = try? await CustomLanguageModelManager.shared.prepareIfNeeded()
|
||||
}
|
||||
|
||||
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: 16_000)
|
||||
defer { try? FileManager.default.removeItem(at: wavURL) }
|
||||
|
||||
@@ -20,18 +75,43 @@ enum MacSpeechLocalASR {
|
||||
guard let recognizer, recognizer.isAvailable else {
|
||||
throw MacLocalASRError.speechFailed("Speech recognizer unavailable")
|
||||
}
|
||||
// The request below sets `requiresOnDeviceRecognition = true`, which
|
||||
// fails (or worse, never produces a final result) when the on-device
|
||||
// model for the locale is missing — fail fast with a clear error.
|
||||
guard recognizer.supportsOnDeviceRecognition else {
|
||||
throw MacLocalASRError.speechFailed(
|
||||
"On-device speech recognition is not available for \(recognizer.locale.identifier). Download the language in System Settings → Keyboard → Dictation."
|
||||
)
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
// Overall deadline: recognition of a file is normally much faster than
|
||||
// realtime, so 2× audio length with a 30 s floor is generous. Without
|
||||
// it, empty audio / cancellation / a missing model can leave the
|
||||
// callback silent forever and the continuation never resumes.
|
||||
let audioSeconds = Double(samples.count) / 16_000
|
||||
let timeoutSeconds = max(30.0, audioSeconds * 2)
|
||||
let session = RecognitionSession()
|
||||
|
||||
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String, Error>) in
|
||||
let request = SFSpeechURLRecognitionRequest(url: wavURL)
|
||||
request.shouldReportPartialResults = false
|
||||
request.requiresOnDeviceRecognition = true
|
||||
CustomLanguageModelManager.applyCustomLanguageModel(
|
||||
to: request,
|
||||
locale: locale,
|
||||
bias: bias
|
||||
)
|
||||
|
||||
recognizer.recognitionTask(with: request) { result, error in
|
||||
let task = recognizer.recognitionTask(with: request) { result, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
|
||||
if session.claimResume(cancellingTask: true) {
|
||||
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
|
||||
}
|
||||
return
|
||||
}
|
||||
// Non-final callbacks carry no usable transcript yet; if a
|
||||
// final result never arrives, the timeout below resumes us.
|
||||
guard let result, result.isFinal else { return }
|
||||
guard session.claimResume(cancellingTask: false) else { return }
|
||||
let text = result.bestTranscription.formattedString
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.isEmpty {
|
||||
@@ -40,6 +120,16 @@ enum MacSpeechLocalASR {
|
||||
continuation.resume(returning: text)
|
||||
}
|
||||
}
|
||||
session.retain(task)
|
||||
|
||||
let timeout = Task {
|
||||
try? await Task.sleep(for: .seconds(timeoutSeconds))
|
||||
guard !Task.isCancelled else { return }
|
||||
if session.claimResume(cancellingTask: true) {
|
||||
continuation.resume(throwing: MacLocalASRError.speechFailed("Speech recognition timed out"))
|
||||
}
|
||||
}
|
||||
session.retainTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,4 +148,8 @@ enum MacSpeechLocalASR {
|
||||
try wav.write(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
private static func isChineseLocale(_ locale: Locale) -> Bool {
|
||||
locale.identifier(.bcp47).lowercased().hasPrefix("zh")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// MacTextInsertionService.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Inserts transcribed text into the frontmost app: clipboard first, then
|
||||
// Inserts transcribed text into the target app: clipboard first, then
|
||||
// a synthetic ⌘V (SayIt / Typeless-style). Requires Accessibility trust.
|
||||
// Re-activates the app the user was dictating into (the popover steals
|
||||
// focus) and restores the original clipboard once the paste has landed.
|
||||
|
||||
import AppKit
|
||||
@preconcurrency import ApplicationServices
|
||||
@@ -30,32 +32,158 @@ enum MacTextInsertionService {
|
||||
return AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
/// Copy to pasteboard and optionally simulate ⌘V in the front app.
|
||||
// MARK: - Paste-target tracking
|
||||
|
||||
/// Start observing app activations early (app launch) so a paste target
|
||||
/// can still be recovered while OSGKeyboard itself is frontmost — e.g.
|
||||
/// when a recording is started from the menu-bar popover.
|
||||
@MainActor
|
||||
static func beginTrackingFrontmostApp() {
|
||||
_ = FrontmostAppTracker.shared
|
||||
}
|
||||
|
||||
/// The app a synthesized ⌘V should land in: the current frontmost app,
|
||||
/// or — when OSGKeyboard is frontmost because the popover has key
|
||||
/// focus — the app that was active immediately before it.
|
||||
@MainActor
|
||||
static func captureTargetApplication() -> NSRunningApplication? {
|
||||
let selfPid = NSRunningApplication.current.processIdentifier
|
||||
if let front = NSWorkspace.shared.frontmostApplication,
|
||||
front.processIdentifier != selfPid {
|
||||
return front
|
||||
}
|
||||
return FrontmostAppTracker.shared.lastExternalApp
|
||||
}
|
||||
|
||||
// MARK: - Insertion
|
||||
|
||||
/// Copy to pasteboard and optionally simulate ⌘V in the target app.
|
||||
/// Returns `true` only if the ⌘V event was actually synthesized. After a
|
||||
/// successful paste the user's original clipboard is put back, so
|
||||
/// dictation never permanently clobbers it.
|
||||
@MainActor
|
||||
static func insert(
|
||||
_ text: String,
|
||||
autoPaste: Bool
|
||||
) throws -> Bool {
|
||||
autoPaste: Bool,
|
||||
targetApp: NSRunningApplication? = nil
|
||||
) async throws -> Bool {
|
||||
guard !text.isEmpty else { return false }
|
||||
let pasteboard = NSPasteboard.general
|
||||
let snapshot = snapshotItems(of: pasteboard)
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
|
||||
guard autoPaste else { return false }
|
||||
guard AXIsProcessTrusted() else { throw InsertionError.accessibilityNotGranted }
|
||||
|
||||
Thread.sleep(forTimeInterval: 0.08)
|
||||
postCommandV()
|
||||
// Make sure ⌘V lands in the app the user was dictating into, not in
|
||||
// OSGKeyboard's own popover / window.
|
||||
if let targetApp { await activate(targetApp) }
|
||||
try? await Task.sleep(nanoseconds: 80_000_000)
|
||||
guard postCommandV() else { return false }
|
||||
|
||||
// Give the target app time to read the transcript off the
|
||||
// pasteboard, then restore whatever the user had on it.
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
restoreItems(snapshot, to: pasteboard)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func postCommandV() {
|
||||
/// Brings `app` forward and waits (up to ~1 s) until it is frontmost so
|
||||
/// the synthesized keystroke isn't swallowed mid-switch.
|
||||
@MainActor
|
||||
private static func activate(_ app: NSRunningApplication) async {
|
||||
func isFront() -> Bool {
|
||||
NSWorkspace.shared.frontmostApplication?.processIdentifier == app.processIdentifier
|
||||
}
|
||||
guard !isFront() else { return }
|
||||
app.activate()
|
||||
var attempts = 0
|
||||
while !isFront(), attempts < 20 {
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
attempts += 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pasteboard preservation
|
||||
|
||||
/// Every representation of every pasteboard item, so restore round-trips
|
||||
/// rich content (images, files, multiple flavours) losslessly.
|
||||
private static func snapshotItems(
|
||||
of pasteboard: NSPasteboard
|
||||
) -> [[NSPasteboard.PasteboardType: Data]] {
|
||||
(pasteboard.pasteboardItems ?? []).map { item in
|
||||
item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { flavours, type in
|
||||
flavours[type] = item.data(forType: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func restoreItems(
|
||||
_ items: [[NSPasteboard.PasteboardType: Data]],
|
||||
to pasteboard: NSPasteboard
|
||||
) {
|
||||
guard !items.isEmpty else { return }
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects(items.map { flavours in
|
||||
let item = NSPasteboardItem()
|
||||
for (type, data) in flavours { item.setData(data, forType: type) }
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `false` when the CGEvents could not be created — in that case
|
||||
/// nothing was pasted and callers must not report success.
|
||||
private static func postCommandV() -> Bool {
|
||||
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)
|
||||
guard
|
||||
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
|
||||
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
|
||||
else { return false }
|
||||
keyDown.flags = CGEventFlags.maskCommand
|
||||
keyUp.flags = CGEventFlags.maskCommand
|
||||
keyDown.post(tap: CGEventTapLocation.cghidEventTap)
|
||||
keyUp.post(tap: CGEventTapLocation.cghidEventTap)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Frontmost-app tracker
|
||||
|
||||
/// Remembers the most recent non-OSGKeyboard frontmost app. Needed because
|
||||
/// the menu-bar popover activates OSGKeyboard, hiding the real paste target
|
||||
/// from `NSWorkspace.frontmostApplication`.
|
||||
@MainActor
|
||||
private final class FrontmostAppTracker: NSObject {
|
||||
static let shared = FrontmostAppTracker()
|
||||
|
||||
private(set) var lastExternalApp: NSRunningApplication?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
// Seed with whatever is frontmost now (usually not us at launch).
|
||||
if let front = NSWorkspace.shared.frontmostApplication,
|
||||
front.processIdentifier != NSRunningApplication.current.processIdentifier {
|
||||
lastExternalApp = front
|
||||
}
|
||||
NSWorkspace.shared.notificationCenter.addObserver(
|
||||
self,
|
||||
selector: #selector(appDidActivate(_:)),
|
||||
name: NSWorkspace.didActivateApplicationNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NSWorkspace.shared.notificationCenter.removeObserver(self)
|
||||
}
|
||||
|
||||
@objc private func appDidActivate(_ notification: Notification) {
|
||||
guard
|
||||
let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
||||
app.processIdentifier != NSRunningApplication.current.processIdentifier
|
||||
else { return }
|
||||
lastExternalApp = app
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
// semantic colours, resolved to a concrete value for the *active* appearance.
|
||||
// The brand green is kept only as the accent. Light mode uses a warm,
|
||||
// iOS-matched surface set (the default `windowBackgroundColor` reads cold
|
||||
// grey on macOS); Dark mode keeps the native AppKit semantic colours.
|
||||
// grey on macOS); Dark mode uses stepped elevated greys so cards stay
|
||||
// readable against the page background.
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
@@ -33,8 +34,8 @@ enum MacSystemPalette {
|
||||
surfaceMuted: resolved(dark ? darkMuted : warmMuted, dark: dark),
|
||||
|
||||
accent: Palette.accent,
|
||||
accentMuted: Palette.accent.opacity(0.16),
|
||||
accentGlow: Palette.accent.opacity(0.35),
|
||||
accentMuted: Palette.accent.opacity(dark ? 0.22 : 0.14),
|
||||
accentGlow: Palette.accent.opacity(dark ? 0.40 : 0.32),
|
||||
|
||||
danger: resolved(.systemRed, dark: dark),
|
||||
success: Palette.accent,
|
||||
@@ -45,8 +46,12 @@ enum MacSystemPalette {
|
||||
textTertiary: resolved(.tertiaryLabelColor, dark: dark),
|
||||
textOnAccent: Color.white,
|
||||
|
||||
divider: resolved(.separatorColor, dark: dark),
|
||||
dividerStrong: resolved(.separatorColor, dark: dark),
|
||||
divider: dark
|
||||
? Color.white.opacity(0.08)
|
||||
: Color.black.opacity(0.06),
|
||||
dividerStrong: dark
|
||||
? Color.white.opacity(0.12)
|
||||
: Color.black.opacity(0.10),
|
||||
|
||||
recordRed: resolved(.systemRed, dark: dark)
|
||||
)
|
||||
|
||||
@@ -57,7 +57,9 @@ struct OSGKeyboardMacApp: App {
|
||||
// very top, matching macOS System Settings.
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentMinSize)
|
||||
.defaultSize(width: 1_024, height: 720)
|
||||
// Open at the minimum size — same as `MacMetrics.windowMin*`, so the
|
||||
// first launch already matches the smallest allowed window.
|
||||
.defaultSize(width: MacMetrics.windowMinWidth, height: MacMetrics.windowMinHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +96,11 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
MacAppearancePreference.applyToApp(.current)
|
||||
MacTextInsertionService.beginTrackingFrontmostApp()
|
||||
configurePopover()
|
||||
configureStatusItem()
|
||||
MacDictationOverlayController.shared.start(observing: MacDictationViewModel.shared)
|
||||
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
|
||||
|
||||
// The menu bar always follows the *system* appearance, so the status
|
||||
// item must ignore the app's forced light/dark override. Re-pin the
|
||||
|
||||
Reference in New Issue
Block a user