feat: sync force-quit Flow teardown and polish macOS local ASR UX

End Live Activities and release the audio session synchronously on
applicationWillTerminate, and continue macOS local-model install,
onboarding, and settings polish on this branch.
This commit is contained in:
Rocky
2026-07-09 17:13:07 +08:00
parent 200265fbd6
commit dcb66a9849
34 changed files with 1936 additions and 340 deletions
+48 -28
View File
@@ -34,11 +34,15 @@ struct DashboardView: View {
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.transition(.opacity)
}
statGrid
dictationCanvas
}
.padding(Spacing.lg)
.animation(Motion.soft, value: viewModel.foregroundAppName)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.lg)
}
BottomDictationBar(viewModel: viewModel)
.padding(.horizontal, Spacing.lg)
@@ -91,23 +95,30 @@ struct DashboardView: View {
private var dictationCanvas: some View {
MacCard(padding: Spacing.lg) {
if viewModel.transcript.isEmpty {
Text(
viewModel.isRecording
? MacL10n.string("mac.status.listening", language: lang)
: MacL10n.string("mac.status.ready", language: lang)
)
.font(.system(size: 26, weight: .light))
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
} else {
Text(viewModel.transcript)
.font(.system(size: 22, weight: .regular))
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
ZStack(alignment: .topLeading) {
if viewModel.transcript.isEmpty {
Text(
viewModel.isRecording
? MacL10n.string("mac.status.listening", language: lang)
: MacL10n.string("mac.status.ready", language: lang)
)
.font(.system(size: 26, weight: .light))
.foregroundStyle(palette.textTertiary)
.contentTransition(.opacity)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
.transition(.opacity)
} else {
Text(viewModel.transcript)
.font(.system(size: 22, weight: .regular))
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
.transition(.opacity)
}
}
}
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
.animation(Motion.quick, value: viewModel.isRecording)
}
}
@@ -132,12 +143,11 @@ struct BottomDictationBar: View {
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 0.78)
.macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 1)
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
.shadow(color: palette.textPrimary.opacity(0.12), radius: 18, y: 8)
}
private var readinessChip: some View {
@@ -152,10 +162,12 @@ struct BottomDictationBar: View {
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 5)
.background(palette.surfaceElevated, in: Capsule())
.animation(Motion.quick, value: viewModel.isProcessing)
}
private var translationPicker: some View {
@@ -195,8 +207,10 @@ struct BottomDictationBar: View {
.foregroundStyle(palette.textTertiary)
.fixedSize()
.offset(y: -22)
.transition(.opacity.combined(with: .offset(y: 6)))
}
}
.animation(Motion.quick, value: viewModel.isRecording)
}
private var recordButton: some View {
@@ -205,25 +219,31 @@ struct BottomDictationBar: View {
Circle()
.fill(viewModel.isRecording ? palette.recordRed : palette.accent)
.frame(width: 52, height: 52)
.macGlassSurface(in: Circle(), fillOpacity: 0.2)
.shadow(
color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.5),
radius: pulse ? 14 : 6
color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.35),
radius: pulse ? 10 : 5
)
if viewModel.isRecording {
// iOS
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
} else {
Image(systemName: "mic.fill")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(palette.textOnAccent)
Group {
if viewModel.isRecording {
// iOS
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
} else {
Image(systemName: "mic.fill")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(palette.textOnAccent)
}
}
.transition(.opacity.combined(with: .scale(scale: 0.7)))
}
}
.buttonStyle(.plain)
.disabled(viewModel.isProcessing)
.opacity(viewModel.isProcessing ? 0.55 : 1)
.scaleEffect(viewModel.isRecording ? 1.06 : 1)
.animation(Motion.soft, value: viewModel.isRecording)
.animation(Motion.quick, value: viewModel.isProcessing)
.onAppear {
withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
withAnimation(Motion.breath) {
pulse = true
}
}
+15 -12
View File
@@ -38,20 +38,17 @@ private struct MacGlassSurface<S: Shape>: ViewModifier {
let fillOpacity: Double
func body(content: Content) -> some View {
if #available(macOS 26.0, *) {
content
.background(palette.surface.opacity(fillOpacity), in: shape)
.glassEffect(.regular, in: shape)
} else {
content
.background(palette.surface.opacity(fillOpacity), in: shape)
}
// Flat, shadowless surface fill. We deliberately avoid `glassEffect`
// here: on macOS 26 Liquid Glass adds a raised drop shadow to every
// card, which reads as visual noise for content containers. Hierarchy
// is carried by the surface colour + hairline border instead.
content
.background(palette.surface.opacity(fillOpacity), in: shape)
}
}
extension View {
/// Applies Liquid Glass on macOS 26 while keeping the same semantic
/// surface colour on older systems.
/// Applies a flat semantic surface fill (no drop shadow) behind `content`.
func macGlassSurface<S: Shape>(
in shape: S,
fillOpacity: Double = 0.72
@@ -97,7 +94,7 @@ struct MacCard<Content: View>: View {
content()
.padding(padding)
.macGlassSurface(in: shape)
.macGlassSurface(in: shape, fillOpacity: 1)
.overlay(
shape
.stroke(palette.divider, lineWidth: 0.5)
@@ -135,6 +132,8 @@ struct StatCard: View {
.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)
@@ -165,7 +164,7 @@ struct MiniWaveform: View {
}
}
.frame(height: 22)
.animation(.easeOut(duration: 0.12), value: level)
.animation(Motion.instant, value: level)
.onAppear {
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) {
phase = 1
@@ -214,12 +213,14 @@ struct MacStatusFooter: View {
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
Label(
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
systemImage: "translate"
)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
.foregroundStyle(palette.accent)
@@ -228,5 +229,7 @@ struct MacStatusFooter: View {
.labelStyle(.titleAndIcon)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.animation(Motion.quick, value: viewModel.isCloudMode)
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId)
}
}
+11
View File
@@ -22,6 +22,7 @@ struct MacContentView: View {
Text(viewModel.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
if !viewModel.transcript.isEmpty {
ScrollView {
@@ -34,6 +35,7 @@ struct MacContentView: View {
.frame(maxHeight: 120)
.padding(Spacing.xs)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous))
.transition(.opacity.combined(with: .move(edge: .top)))
}
Divider().overlay(palette.divider)
@@ -43,6 +45,8 @@ struct MacContentView: View {
}
.padding(Spacing.md)
.background(palette.background)
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
.animation(Motion.quick, value: viewModel.statusMessage)
}
private var statusRow: some View {
@@ -103,20 +107,24 @@ struct MacContentView: View {
Spacer()
if viewModel.isRecording {
MiniWaveform(level: viewModel.audioLevel, barCount: 4)
.transition(.opacity.combined(with: .scale(scale: 0.7)))
}
}
.animation(Motion.quick, value: viewModel.isRecording)
}
private var recordButton: some View {
Button(action: viewModel.toggleRecording) {
HStack {
Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill")
.contentTransition(.symbolEffect(.replace))
Text(
viewModel.isRecording
? MacL10n.string("mac.record.stop", language: lang)
: MacL10n.string("mac.record.start", language: lang)
)
.font(TypeStyle.bodyEmph)
.contentTransition(.opacity)
}
.frame(maxWidth: .infinity, minHeight: 40)
.background(
@@ -127,6 +135,9 @@ struct MacContentView: View {
}
.buttonStyle(.plain)
.disabled(viewModel.isProcessing)
.opacity(viewModel.isProcessing ? 0.55 : 1)
.animation(Motion.soft, value: viewModel.isRecording)
.animation(Motion.quick, value: viewModel.isProcessing)
}
private var footer: some View {
+2 -26
View File
@@ -101,25 +101,11 @@ final class MacDictationViewModel: ObservableObject {
func onAppear() async {
await MacICloudSyncBootstrap.pullIfEnabled()
refreshForegroundAppName()
warmUpQwen3IfNeeded()
}
/// Pre-load MLX weights + Metal shaders so the first dictation is fast.
func warmUpQwen3IfNeeded() {
guard config.engineMode == "local",
let model = MacLocalASRService.selectedModelDefinition(),
model.backend == .mlx,
MacLocalASRService.isModelInstalled(model) else { return }
let path = MacLocalASRPreferences.qwen3ModelPath
Task.detached(priority: .utility) {
_ = try? await MacQwen3ASREngine.shared.prepareIfNeeded(modelPath: path)
}
}
func reloadConfigFromCloud() {
config.reloadFromPersistedStorage()
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
warmUpQwen3IfNeeded()
}
func refreshDictionaryFromCloud() {
@@ -159,10 +145,8 @@ final class MacDictationViewModel: ObservableObject {
var localModelReady: Bool {
_ = localModelRevision
if let model = MacLocalASRService.selectedModelDefinition() {
return MacLocalASRService.isModelInstalled(model)
}
return MacLocalASRPreferences.qwen3ModelIsInstalled()
guard let model = MacLocalASRService.selectedModelDefinition() else { return false }
return MacLocalASRService.isModelInstalled(model)
}
/// Context-aware warning when local engine is selected but the active model is not ready.
@@ -173,9 +157,6 @@ final class MacDictationViewModel: ObservableObject {
guard let model = MacLocalASRService.selectedModelDefinition() else {
return MacL10n.string("mac.settings.localModelFallbackApple", language: config.uiLanguage)
}
if model.installKind == .manual {
return MacL10n.string("mac.settings.mlxModelMissing", language: config.uiLanguage)
}
return MacL10n.format(
"mac.settings.selectedModelMissing",
language: config.uiLanguage,
@@ -190,10 +171,6 @@ final class MacDictationViewModel: ObservableObject {
objectWillChange.send()
}
var qwen3ModelInstalled: Bool {
MacLocalASRPreferences.qwen3ModelIsInstalled()
}
// MARK: - Preferences
func setAutoPasteEnabled(_ enabled: Bool) {
@@ -210,7 +187,6 @@ final class MacDictationViewModel: ObservableObject {
func setEngineMode(_ mode: String) {
config.engineMode = mode
if mode == "local" { warmUpQwen3IfNeeded() }
}
// MARK: - Recording
+9 -2
View File
@@ -48,12 +48,15 @@ struct MacDictionaryView: View {
Group {
if entries.isEmpty {
emptyState
.transition(.opacity)
} else {
form
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(palette.background)
.animation(Motion.soft, value: entries.isEmpty)
.task {
await MacICloudSyncBootstrap.dictionarySync.pullAndMergeIfEnabled()
viewModel.refreshDictionaryFromCloud()
@@ -86,6 +89,7 @@ struct MacDictionaryView: View {
.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),
@@ -93,7 +97,9 @@ struct MacDictionaryView: View {
titleVisibility: .visible
) {
Button(MacL10n.string("mac.delete", language: lang), role: .destructive) {
if let entry = entryPendingDeletion { delete(entry) }
if let entry = entryPendingDeletion {
withAnimation(Motion.soft) { delete(entry) }
}
entryPendingDeletion = nil
}
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {
@@ -215,12 +221,13 @@ private struct MacDictionaryRow: View {
.frame(width: 24, height: 24)
}
.buttonStyle(.borderless)
.foregroundStyle(palette.textTertiary)
.foregroundStyle(isHovering ? palette.danger : palette.textTertiary)
.opacity(isHovering ? 1 : 0)
.accessibilityLabel(MacL10n.string("mac.delete", language: language))
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.animation(Motion.quick, value: isHovering)
.onHover { isHovering = $0 }
.contextMenu {
Button(action: copy) {
+7 -3
View File
@@ -34,12 +34,15 @@ struct MacHistoryView: 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
@@ -64,7 +67,7 @@ struct MacHistoryView: View {
titleVisibility: .visible
) {
Button(MacL10n.string("mac.history.clearConfirm", language: lang), role: .destructive) {
historyStore.clearAll()
withAnimation(Motion.soft) { historyStore.clearAll() }
}
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {}
} message: {
@@ -95,7 +98,7 @@ struct MacHistoryView: View {
time: Self.timeFormatter.string(from: entry.createdAt),
language: lang,
copy: { viewModel.copyToClipboard(entry.text) },
delete: { historyStore.delete(id: entry.id) }
delete: { withAnimation(Motion.soft) { historyStore.delete(id: entry.id) } }
)
}
@@ -144,12 +147,13 @@ private struct MacHistoryRow: View {
.frame(width: 24, height: 24)
}
.buttonStyle(.borderless)
.foregroundStyle(palette.textTertiary)
.foregroundStyle(isHovering ? palette.danger : palette.textTertiary)
.opacity(isHovering ? 1 : 0)
.accessibilityLabel(MacL10n.string("mac.delete", language: language))
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.animation(Motion.quick, value: isHovering)
.onHover { isHovering = $0 }
.contextMenu {
Button(action: copy) {
+110
View File
@@ -0,0 +1,110 @@
// MacLegalSettingsViews.swift
// OSGKeyboard · Mac
//
// Privacy policy and third-party license screens (mirrors iOS Settings footer).
import SwiftUI
struct MacPrivacyPolicyView: View {
let uiLanguage: AppUILanguage
@Environment(\.themePalette) private var palette
var body: some View {
MacLegalWebView(
resourceName: "PrivacyPolicy",
scrollToAnchor: privacyScrollAnchor
)
.background(palette.background)
.navigationTitle(MacL10n.string("mac.settings.privacyPolicy", language: uiLanguage))
}
private var privacyScrollAnchor: String? {
switch uiLanguage {
case .chinese:
return "zh"
case .english:
return "top"
case .auto:
return uiLanguage.resolvedLanguageCode().hasPrefix("zh") ? "zh" : "top"
}
}
}
struct MacOpenSourceLicensesView: View {
let uiLanguage: AppUILanguage
@Environment(\.themePalette) private var palette
var body: some View {
List {
Section {
Text(MacL10n.string("mac.settings.licenses.footer", language: uiLanguage))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.listRowBackground(Color.clear)
}
Section {
ForEach(OpenSourceLicenseCatalog.entries) { entry in
NavigationLink {
MacOpenSourceLicenseDetailView(entry: entry, uiLanguage: uiLanguage)
} label: {
HStack {
Text(entry.name)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: Spacing.sm)
Text(entry.licenseName)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
}
}
}
}
.scrollContentBackground(.hidden)
.background(palette.background)
.navigationTitle(MacL10n.string("mac.settings.thirdPartyLicenses", language: uiLanguage))
}
}
private struct MacOpenSourceLicenseDetailView: View {
let entry: OpenSourceLicenseCatalog.Entry
let uiLanguage: AppUILanguage
@Environment(\.themePalette) private var palette
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.sm) {
if let url = entry.url {
Link(destination: url) {
HStack(spacing: Spacing.xs) {
Text(url.absoluteString)
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
Image(systemName: "arrow.up.right.square")
.font(.system(size: 12))
.foregroundStyle(palette.textTertiary)
}
}
}
Text(entry.purpose)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Text(entry.licenseText)
.font(TypeStyle.monoSmall)
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(Spacing.lg)
}
.background(palette.background)
.navigationTitle(entry.name)
}
}
+48
View File
@@ -0,0 +1,48 @@
// MacLegalWebView.swift
// OSGKeyboard · Mac
//
// In-app HTML viewer for bundled legal documents (privacy policy).
import SwiftUI
import WebKit
struct MacLegalWebView: NSViewRepresentable {
let resourceName: String
var scrollToAnchor: String?
func makeCoordinator() -> Coordinator {
Coordinator(scrollToAnchor: scrollToAnchor)
}
func makeNSView(context: Context) -> WKWebView {
let webView = WKWebView(frame: .zero)
webView.setValue(false, forKey: "drawsBackground")
webView.navigationDelegate = context.coordinator
context.coordinator.webView = webView
guard let url = Bundle.main.url(forResource: resourceName, withExtension: "html") else {
return webView
}
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
return webView
}
func updateNSView(_ nsView: WKWebView, context: Context) {
context.coordinator.scrollToAnchor = scrollToAnchor
}
final class Coordinator: NSObject, WKNavigationDelegate {
var scrollToAnchor: String?
weak var webView: WKWebView?
init(scrollToAnchor: String?) {
self.scrollToAnchor = scrollToAnchor
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
guard let anchor = scrollToAnchor, !anchor.isEmpty else { return }
let escaped = anchor.replacingOccurrences(of: "'", with: "\\'")
webView.evaluateJavaScript("location.hash = '#\(escaped)';") { _, _ in }
}
}
}
@@ -1,7 +1,7 @@
// MacLocalASRModelSettingsView.swift
// OSGKeyboard · Mac
//
// Local ASR model catalog, download progress, MLX path, and bias diagnostics.
// Local ASR model catalog, download progress, and bias diagnostics.
import AppKit
import SwiftUI
@@ -29,9 +29,11 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject {
catalog = try? LocalASRModelCatalog.loadBundled()
if let catalog {
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
selectedModelId = manifest.selectedModelId.isEmpty
? MacLocalASRPreferences.selectedModelId
: manifest.selectedModelId
selectedModelId = MacLocalASRPreferences.migratedModelId(
manifest.selectedModelId.isEmpty
? MacLocalASRPreferences.selectedModelId
: manifest.selectedModelId
)
}
diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load()
onLocalModelStateChanged?()
@@ -46,7 +48,7 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject {
}
func installedDiskUsage(_ model: LocalASRModelDefinition) -> String? {
guard model.installKind == .archive,
guard model.installKind == .archive || model.installKind == .repository,
let relative = model.installRelativePath,
isInstalled(model) else { return nil }
let dir = LocalASRModelInstallState.installDirectory(for: relative)
@@ -140,15 +142,6 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject {
NSWorkspace.shared.activateFileViewerSelecting([url])
}
/// Opens (creating if needed) the model's shared subfolder so the user can
/// drop in manually-converted weights (used by the MLX model).
func revealModelFolder(_ model: LocalASRModelDefinition) {
guard let relative = model.installRelativePath else { return }
let url = LocalASRModelInstallState.installDirectory(for: relative)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
NSWorkspace.shared.open(url)
}
func revealStorageRoot() {
let url = LocalASRModelInstallState.rootDirectory()
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
@@ -212,7 +205,6 @@ struct MacLocalASRModelSettingsView: View {
Group {
if let catalog = modelVM.catalog {
modelPickerSection(catalog: catalog)
runtimeSection(catalog: catalog)
} else {
Text(MacL10n.string("mac.localASR.catalogMissing", language: lang))
.foregroundStyle(palette.textSecondary)
@@ -226,6 +218,16 @@ struct MacLocalASRModelSettingsView: View {
private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View {
Section {
if let runtime = modelVM.currentRuntime(in: catalog) {
LabeledContent(runtime.displayName) {
Text(
modelVM.isRuntimeInstalled(runtime)
? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang)
)
}
}
ForEach(catalog.models) { model in
modelRow(model)
}
@@ -251,31 +253,6 @@ struct MacLocalASRModelSettingsView: View {
}
} header: {
Text(MacL10n.string("mac.localASR.models", language: lang))
} footer: {
Text(MacL10n.string("mac.localASR.modelsDesc", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
}
private func runtimeSection(catalog: LocalASRCatalogDocument) -> some View {
Group {
if let runtime = modelVM.currentRuntime(in: catalog) {
Section {
LabeledContent(runtime.displayName) {
Text(
modelVM.isRuntimeInstalled(runtime)
? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang)
)
}
Text(MacL10n.string("mac.localASR.runtimeDesc", language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
} header: {
Text(MacL10n.string("mac.localASR.runtime", language: lang))
}
}
}
}
@@ -293,9 +270,22 @@ struct MacLocalASRModelSettingsView: View {
HStack(spacing: Spacing.sm) {
Image(systemName: selected ? "largecircle.fill.circle" : "circle")
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
.contentTransition(.symbolEffect(.replace))
.animation(Motion.quick, value: selected)
VStack(alignment: .leading, spacing: 2) {
Text(model.displayName)
.foregroundStyle(palette.textPrimary)
HStack(spacing: Spacing.xs) {
Text(model.displayName)
.foregroundStyle(palette.textPrimary)
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())
}
}
Text(modelSubtitle(model, installed: installed))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
@@ -308,6 +298,8 @@ struct MacLocalASRModelSettingsView: View {
Spacer()
modelRowActions(model: model, installed: installed, installing: installing)
.animation(Motion.soft, value: installing)
.animation(Motion.soft, value: installed)
}
}
.padding(.vertical, 2)
@@ -344,18 +336,13 @@ struct MacLocalASRModelSettingsView: View {
)
}
}
} else if model.installKind == .manual {
Button(MacL10n.string("mac.localASR.openFolder", language: lang)) {
modelVM.revealModelFolder(model)
}
.buttonStyle(.bordered)
.controlSize(.small)
} else if installed {
Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) {
modelVM.deleteModel(model)
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(palette.danger)
} else {
Button(MacL10n.string("mac.localASR.download", language: lang)) {
modelVM.installModel(model)
@@ -382,7 +369,7 @@ struct MacLocalASRModelSettingsView: View {
.trim(from: 0, to: fraction)
.stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 0.15), value: fraction)
.animation(Motion.instant, value: fraction)
if modelVM.installProgress.phase == .paused {
Image(systemName: "pause.fill")
.font(.system(size: 10, weight: .bold))
@@ -399,16 +386,10 @@ struct MacLocalASRModelSettingsView: View {
private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String {
let size = modelVM.formattedSize(model.sizeBytes)
let hotword = model.supportsHotwords
? MacL10n.string("mac.localASR.hotwordsYes", language: lang)
: MacL10n.string("mac.localASR.hotwordsNo", language: lang)
let state = installed
? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang)
if let usage = modelVM.installedDiskUsage(model) {
return "\(size) · \(hotword) · \(state) · \(usage)"
return "\(size) · \(usage)"
}
return "\(size) · \(hotword) · \(state)"
return size
}
private var diagnosticsSection: some View {
+24 -56
View File
@@ -2,13 +2,13 @@
// OSGKeyboard · Mac
//
// On-device ASR for macOS. Routes through the bundled local ASR catalog:
// Qwen3 MLX (default), Sherpa Qwen3 hotwords POC, SenseVoice, Apple Speech fallback.
// Sherpa Qwen3 (default), Paraformer, SenseVoice, Apple Speech fallback.
import Foundation
enum MacLocalASRBackend: String, Sendable, CaseIterable {
case qwen3MLX
case sherpaQwen3
case sherpaParaformer
case sherpaSenseVoice
case appleSpeech
}
@@ -42,48 +42,37 @@ enum MacLocalASRError: Error, LocalizedError {
enum MacLocalASRPreferences {
static let backendKey = "mac.localASR.backend"
static let selectedModelIdKey = LocalASRPreferenceKeys.selectedModelId
/// Shared managed subfolder for the manually-provided MLX weights.
/// Legacy MLX path key retained for migration only.
static let qwen3ModelRelativePath = "models/qwen3-asr-1.7b-mlx"
static var selectedModelId: String {
get {
if let raw = UserDefaults.standard.string(forKey: selectedModelIdKey), !raw.isEmpty {
return raw
return migratedModelId(raw)
}
return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "qwen3-mlx-1.7b"
return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "sherpa-qwen3-0.6b-int8"
}
set { UserDefaults.standard.set(newValue, forKey: selectedModelIdKey) }
}
/// Maps removed catalog entries to the current default Sherpa model.
static func migratedModelId(_ id: String) -> String {
switch id {
case "qwen3-mlx-1.7b":
return "sherpa-qwen3-0.6b-int8"
default:
return id
}
}
static var legacyBackend: MacLocalASRBackend {
guard let raw = UserDefaults.standard.string(forKey: backendKey),
let value = MacLocalASRBackend(rawValue: raw) else {
return .qwen3MLX
return .sherpaQwen3
}
if raw == "qwen3MLX" { return .sherpaQwen3 }
return value
}
/// Fixed location inside the shared managed model storage root. All three
/// catalog models live under the same directory, so MLX no longer needs a
/// per-model folder picker the user drops converted weights here.
static var qwen3ModelPath: String {
LocalASRModelInstallState.installDirectory(for: qwen3ModelRelativePath).path
}
static func qwen3ModelIsInstalled(at path: String = qwen3ModelPath) -> Bool {
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else {
return false
}
let fm = FileManager.default
let config = (path as NSString).appendingPathComponent("config.json")
let weights = (path as NSString).appendingPathComponent("model.safetensors")
guard fm.fileExists(atPath: config), fm.fileExists(atPath: weights) else {
return false
}
let names = (try? fm.contentsOfDirectory(atPath: path)) ?? []
return names.contains("vocab.json") && names.contains("merges.txt")
}
}
enum MacLocalASRService {
@@ -97,7 +86,7 @@ enum MacLocalASRService {
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
let selectedId = manifest.selectedModelId.isEmpty
? MacLocalASRPreferences.selectedModelId
: manifest.selectedModelId
: MacLocalASRPreferences.migratedModelId(manifest.selectedModelId)
if selectedId == "apple-speech-fallback" { return nil }
return LocalASRModelCatalog.model(selectedId, in: catalog)
?? LocalASRModelCatalog.model(catalog.defaultModelId, in: catalog)
@@ -116,34 +105,19 @@ enum MacLocalASRService {
static func isModelInstalled(_ model: LocalASRModelDefinition) -> Bool {
LocalASRModelInstallState.isInstalled(
model,
manualMLXPath: MacLocalASRPreferences.qwen3ModelPath
manualMLXPath: nil,
fileManager: FileManager.default
)
}
/// Transcribe using the selected catalog model, with MLX Apple Speech fallback.
/// Transcribe using the selected catalog model, falling back to Apple Speech.
static func transcribe(
samples: [Float],
locale: Locale,
bias: LocalASRBiasPayload? = nil
) async throws -> String {
if let model = selectedModelDefinition(), isModelInstalled(model) {
do {
return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
} catch {
if model.backend != .mlx {
throw error
}
}
}
if MacLocalASRPreferences.qwen3ModelIsInstalled() {
return try await MacQwen3LocalASR.transcribe(
samples: samples,
sampleRate: 16_000,
locale: locale,
modelPath: MacLocalASRPreferences.qwen3ModelPath,
bias: bias
)
return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
}
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
@@ -157,14 +131,8 @@ enum MacLocalASRService {
) async throws -> String {
switch model.backend {
case .mlx:
return try await MacQwen3LocalASR.transcribe(
samples: samples,
sampleRate: 16_000,
locale: locale,
modelPath: MacLocalASRPreferences.qwen3ModelPath,
bias: bias
)
case .sherpaQwen3, .sherpaSenseVoice:
throw MacLocalASRError.qwen3ModelMissing
case .sherpaQwen3, .sherpaSenseVoice, .sherpaParaformer:
return try await MacSherpaLocalASR.transcribe(
samples: samples,
sampleRate: 16_000,
+699
View File
@@ -0,0 +1,699 @@
// MacOnboardingView.swift
// OSGKeyboard · Mac
//
// A short first-run setup for the macOS app. It is intentionally separate
// from iOS onboarding because Mac needs Accessibility and optional Sherpa setup.
//
// Visual language mirrors the iOS onboarding: an ambient top gradient, a
// glowing hero icon, a large title block, and elongated capsule progress
// dots all carried by whitespace and a single accent colour.
import AppKit
import AVFoundation
import SwiftUI
enum MacOnboardingState {
static let storageKey = "mac.hasCompletedOnboarding"
}
private enum MacOnboardingStep: Int, CaseIterable {
case welcome
case microphone
case accessibility
case engine
case cloudAPI
case localModel
var systemImage: String {
switch self {
case .welcome: return "sparkles"
case .microphone: return "mic.fill"
case .accessibility: return "accessibility"
case .engine: return "switch.2"
case .cloudAPI: return "key.fill"
case .localModel: return "arrow.down.circle.fill"
}
}
}
@MainActor
private final class MacOnboardingViewModel: ObservableObject {
@Published var step: MacOnboardingStep = .welcome
@Published var micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
@Published var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
@Published var catalog: LocalASRCatalogDocument?
@Published var installProgress = LocalASRModelInstallProgress.idle
@Published var isInstalling = false
@Published var statusMessage = ""
private let manager = LocalASRModelManager.shared
private var progressPollTask: Task<Void, Never>?
deinit {
progressPollTask?.cancel()
}
var defaultModel: LocalASRModelDefinition? {
guard let catalog else { return nil }
return catalog.models.first { $0.id == catalog.defaultModelId }
}
var isDefaultModelInstalled: Bool {
guard let defaultModel else { return false }
return MacLocalASRService.isModelInstalled(defaultModel)
}
func reload() {
catalog = try? LocalASRModelCatalog.loadBundled()
micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
}
func requestMicrophone() {
AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in
Task { @MainActor in
self?.micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
}
}
}
func openAccessibilitySettings() {
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
NSWorkspace.shared.open(url)
}
refreshAccessibilitySoon()
}
func refreshAccessibilitySoon() {
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
}
}
func installDefaultModel() {
guard let catalog, let model = defaultModel, !isInstalling else { return }
statusMessage = ""
isInstalling = true
startProgressPolling()
Task {
do {
try await manager.installModel(model, catalog: catalog)
installProgress = await manager.currentProgress()
selectInstalledModel(model.id, catalog: catalog)
statusMessage = MacL10n.string("mac.onboarding.model.done")
} catch {
installProgress = await manager.currentProgress()
statusMessage = error.localizedDescription
}
isInstalling = false
stopProgressPolling()
reload()
}
}
func progressLabel(language: AppUILanguage) -> String {
let phase: String
switch installProgress.phase {
case .idle: return installProgress.message
case .downloading: phase = MacL10n.string("mac.localASR.phase.downloading", language: language)
case .paused: phase = MacL10n.string("mac.localASR.phase.paused", language: language)
case .extracting: phase = MacL10n.string("mac.localASR.phase.extracting", language: language)
case .validating: phase = MacL10n.string("mac.localASR.phase.validating", language: language)
case .finalizing: phase = MacL10n.string("mac.localASR.phase.finalizing", language: language)
case .failed: phase = MacL10n.string("mac.localASR.phase.failed", language: language)
case .completed: phase = MacL10n.string("mac.localASR.phase.completed", language: language)
}
guard !installProgress.message.isEmpty else { return phase }
return "\(phase) · \(installProgress.message)"
}
private func selectInstalledModel(_ modelId: String, catalog: LocalASRCatalogDocument) {
MacLocalASRPreferences.selectedModelId = modelId
var manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
manifest.selectedModelId = modelId
manifest.updatedAt = Date()
try? LocalASRInstalledManifestIO.save(manifest)
}
private func startProgressPolling() {
progressPollTask?.cancel()
progressPollTask = Task { [weak self] in
while !Task.isCancelled {
let current = await LocalASRModelManager.shared.currentProgress()
await MainActor.run { self?.installProgress = current }
try? await Task.sleep(nanoseconds: 120_000_000)
}
}
}
private func stopProgressPolling() {
progressPollTask?.cancel()
progressPollTask = nil
}
}
// MARK: - Root
struct MacOnboardingView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Binding var hasCompletedOnboarding: Bool
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
@StateObject private var model = MacOnboardingViewModel()
@State private var contentAppeared = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private var visibleSteps: [MacOnboardingStep] {
if viewModel.config.engineMode == "cloud" {
return [.welcome, .microphone, .accessibility, .engine, .cloudAPI]
}
return [.welcome, .microphone, .accessibility, .engine, .localModel]
}
var body: some View {
GeometryReader { geo in
ZStack(alignment: .top) {
background(height: geo.size.height)
VStack(spacing: 0) {
Spacer(minLength: Spacing.xl)
hero
.id(model.step)
.transition(stepTransition)
Spacer(minLength: Spacing.lg)
progressDots
.padding(.bottom, Spacing.lg)
bottomBar
.padding(.horizontal, Spacing.xxxl)
.padding(.bottom, Spacing.xxl)
}
.frame(maxWidth: .infinity)
}
}
.frame(minWidth: 860, minHeight: 600)
.onAppear {
applyDefaults()
model.reload()
withAnimation(.spring(response: 0.7, dampingFraction: 0.85)) {
contentAppeared = true
}
}
}
// MARK: Background
private func background(height: CGFloat) -> some View {
ZStack(alignment: .top) {
palette.background.ignoresSafeArea()
LinearGradient(
colors: [
palette.accent.opacity(0.12),
palette.accent.opacity(0.03),
palette.background.opacity(0)
],
startPoint: .top,
endPoint: .bottom
)
.frame(height: height * 0.42)
.ignoresSafeArea(edges: .top)
.allowsHitTesting(false)
}
}
// MARK: Hero + content
private var hero: some View {
VStack(spacing: Spacing.lg) {
heroIcon
VStack(spacing: Spacing.sm) {
Text(title)
.font(TypeStyle.title2)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(subtitle)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: 460)
}
stepContent
.frame(maxWidth: 460)
.padding(.top, Spacing.xs)
}
.padding(.horizontal, Spacing.xxl)
.opacity(contentAppeared ? 1 : 0)
.offset(y: contentAppeared ? 0 : 12)
}
@ViewBuilder
private var heroIcon: some View {
if model.step == .welcome {
Image("OSGBrandMark")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 128, height: 128)
.foregroundStyle(colorScheme == .dark ? Color.white : palette.accent)
.accessibilityLabel("OSGKeyboard")
} else {
ZStack {
Circle()
.fill(palette.accentGlow)
.frame(width: 116, height: 116)
.blur(radius: 26)
Circle()
.fill(palette.accentMuted)
.frame(width: 92, height: 92)
.overlay(Circle().stroke(palette.accent.opacity(0.25), lineWidth: 1))
Image(systemName: model.step.systemImage)
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(palette.accent)
.symbolRenderingMode(.hierarchical)
}
}
}
@ViewBuilder
private var stepContent: some View {
switch model.step {
case .welcome:
featureList
case .microphone:
permissionCard(
isGranted: model.micStatus == .authorized,
grantedText: MacL10n.string("mac.onboarding.microphone.granted", language: lang),
neededText: MacL10n.string("mac.onboarding.microphone.needed", language: lang)
)
case .accessibility:
permissionCard(
isGranted: model.accessibilityTrusted,
grantedText: MacL10n.string("mac.onboarding.accessibility.granted", language: lang),
neededText: MacL10n.string("mac.onboarding.accessibility.needed", language: lang)
)
case .engine:
enginePicker
case .cloudAPI:
cloudAPIFields
case .localModel:
localModelPanel
}
}
private var featureList: some View {
VStack(spacing: Spacing.sm) {
featureRow("lock.shield.fill", MacL10n.string("mac.onboarding.welcome.privacy", language: lang))
featureRow("option", MacL10n.string("mac.onboarding.welcome.hotkey", language: lang))
featureRow("cpu", MacL10n.string("mac.onboarding.welcome.local", language: lang))
}
}
private func featureRow(_ icon: String, _ text: String) -> some View {
HStack(spacing: Spacing.md) {
Image(systemName: icon)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 26, height: 26)
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
Text(text)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
}
.padding(.vertical, Spacing.xs)
.padding(.horizontal, Spacing.md)
.frame(maxWidth: .infinity)
.background(cardShape.fill(palette.surface))
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
}
private func permissionCard(isGranted: Bool, grantedText: String, neededText: String) -> some View {
HStack(spacing: Spacing.sm) {
Image(systemName: isGranted ? "checkmark.seal.fill" : "exclamationmark.circle.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(isGranted ? palette.accent : palette.warning)
Text(isGranted ? grantedText : neededText)
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: 0)
}
.padding(Spacing.md)
.frame(maxWidth: .infinity)
.background(cardShape.fill(palette.surface))
.overlay(cardShape.stroke((isGranted ? palette.accent : palette.warning).opacity(0.25), lineWidth: 1))
}
private var enginePicker: some View {
VStack(spacing: Spacing.sm) {
engineRow(
title: MacL10n.string("mac.settings.localEngine", language: lang),
subtitle: MacL10n.string("mac.onboarding.engine.localDesc", language: lang),
systemImage: "cpu",
selected: viewModel.config.engineMode == "local"
) { setEngine("local") }
engineRow(
title: MacL10n.string("mac.settings.cloudEngine", language: lang),
subtitle: MacL10n.string("mac.onboarding.engine.cloudDesc", language: lang),
systemImage: "cloud.fill",
selected: viewModel.config.engineMode == "cloud"
) { setEngine("cloud") }
}
}
private func engineRow(
title: String,
subtitle: String,
systemImage: String,
selected: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: Spacing.md) {
Image(systemName: systemImage)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
.frame(width: 30)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Text(subtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: Spacing.sm)
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 18))
.foregroundStyle(selected ? palette.accent : palette.textTertiary.opacity(0.6))
}
.padding(Spacing.md)
.frame(maxWidth: .infinity)
.background(cardShape.fill(selected ? palette.accentMuted : palette.surface))
.overlay(cardShape.stroke(selected ? palette.accent.opacity(0.5) : palette.divider, lineWidth: selected ? 1 : 0.5))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var cloudAPIFields: some View {
VStack(alignment: .leading, spacing: Spacing.md) {
Picker(MacL10n.string("mac.settings.service", language: lang), selection: providerBinding) {
ForEach(viewModel.selectableProviders) { provider in
Text(provider.name).tag(provider.id)
}
}
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
.labelsHidden()
.macFieldStyle()
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.model", language: lang))
}
.labelsHidden()
.macFieldStyle()
Label(MacL10n.string("mac.onboarding.cloud.skipHint", language: lang), systemImage: "info.circle")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.padding(Spacing.md)
.frame(maxWidth: .infinity)
.background(cardShape.fill(palette.surface))
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
}
private var localModelPanel: some View {
VStack(alignment: .leading, spacing: Spacing.md) {
HStack(spacing: Spacing.sm) {
Image(systemName: model.isDefaultModelInstalled ? "checkmark.circle.fill" : "shippingbox.fill")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(model.isDefaultModelInstalled ? palette.accent : palette.textTertiary)
VStack(alignment: .leading, spacing: 2) {
Text(model.defaultModel?.displayName ?? MacL10n.string("mac.localASR.catalogMissing", language: lang))
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Text(localModelSubtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
Spacer(minLength: Spacing.sm)
if !model.isDefaultModelInstalled, !model.isInstalling {
Button(MacL10n.string("mac.onboarding.model.download", language: lang)) {
model.installDefaultModel()
}
.buttonStyle(.borderedProminent)
.tint(palette.accent)
.disabled(model.defaultModel == nil)
}
}
if model.isInstalling || model.installProgress.phase != .idle {
ProgressView(value: model.installProgress.fraction)
.tint(palette.accent)
Text(model.progressLabel(language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
if !model.statusMessage.isEmpty {
Text(model.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(model.isDefaultModelInstalled ? palette.accent : palette.warning)
}
Label(MacL10n.string("mac.onboarding.model.skipHint", language: lang), systemImage: "info.circle")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.padding(Spacing.md)
.frame(maxWidth: .infinity)
.background(cardShape.fill(palette.surface))
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
}
// MARK: Progress dots
private var progressDots: some View {
HStack(spacing: 6) {
ForEach(Array(visibleSteps.enumerated()), id: \.offset) { index, _ in
Capsule()
.fill(index == currentStepIndex ? palette.accent : palette.textTertiary.opacity(0.28))
.frame(width: index == currentStepIndex ? 22 : 6, height: 6)
}
}
.animation(Motion.quick, value: currentStepIndex)
}
// MARK: Bottom bar
private var bottomBar: some View {
HStack(spacing: Spacing.sm) {
if canGoBack {
secondaryButton(MacL10n.string("mac.onboarding.back", language: lang)) { goBack() }
}
if canSkipCurrentStep {
secondaryButton(MacL10n.string("mac.onboarding.skipForNow", language: lang)) {
if isLastStep { finish() } else { goForward() }
}
}
Spacer(minLength: 0)
primaryButton(primaryButtonTitle, disabled: model.isInstalling && model.step == .localModel) {
primaryAction()
}
}
.frame(maxWidth: 520)
.frame(maxWidth: .infinity)
}
private func primaryButton(_ titleText: String, disabled: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(titleText)
.font(TypeStyle.headline)
.foregroundStyle(disabled ? palette.textSecondary : palette.textOnAccent)
.padding(.horizontal, Spacing.xxl)
.frame(minWidth: 150, minHeight: 44)
.background(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.fill(disabled ? palette.surfaceElevated : palette.accent)
)
}
.buttonStyle(.plain)
.disabled(disabled)
}
private func secondaryButton(_ titleText: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(titleText)
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, Spacing.lg)
.frame(minHeight: 44)
.background(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
}
// MARK: Copy
private var title: String {
switch model.step {
case .welcome: return MacL10n.string("mac.onboarding.welcome.title", language: lang)
case .microphone: return MacL10n.string("mac.onboarding.microphone.title", language: lang)
case .accessibility: return MacL10n.string("mac.onboarding.accessibility.title", language: lang)
case .engine: return MacL10n.string("mac.onboarding.engine.title", language: lang)
case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.title", language: lang)
case .localModel: return MacL10n.string("mac.onboarding.model.title", language: lang)
}
}
private var subtitle: String {
switch model.step {
case .welcome: return MacL10n.string("mac.onboarding.welcome.subtitle", language: lang)
case .microphone: return MacL10n.string("mac.onboarding.microphone.subtitle", language: lang)
case .accessibility: return MacL10n.string("mac.onboarding.accessibility.subtitle", language: lang)
case .engine: return MacL10n.string("mac.onboarding.engine.subtitle", language: lang)
case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.subtitle", language: lang)
case .localModel: return MacL10n.string("mac.onboarding.model.subtitle", language: lang)
}
}
private var primaryButtonTitle: String {
switch model.step {
case .microphone where model.micStatus != .authorized:
return MacL10n.string("mac.onboarding.microphone.allow", language: lang)
case .accessibility where !model.accessibilityTrusted:
return MacL10n.string("mac.onboarding.accessibility.open", language: lang)
case .cloudAPI:
return MacL10n.string("mac.onboarding.finish", language: lang)
case .localModel:
return MacL10n.string(model.isDefaultModelInstalled ? "mac.onboarding.finish" : "mac.onboarding.skipForNow", language: lang)
default:
return isLastStep ? MacL10n.string("mac.onboarding.finish", language: lang) : MacL10n.string("mac.onboarding.next", language: lang)
}
}
private var localModelSubtitle: String {
if model.isDefaultModelInstalled {
return MacL10n.string("mac.localASR.installed", language: lang)
}
guard let model = model.defaultModel else { return "" }
return ByteCountFormatter.string(fromByteCount: Int64(model.sizeBytes), countStyle: .file)
}
private var providerBinding: Binding<String> {
Binding(
get: { viewModel.config.providerId },
set: { newId in
guard let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) else { return }
viewModel.selectProvider(provider)
}
)
}
// MARK: Derived
private var cardShape: RoundedRectangle {
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
}
private var stepTransition: AnyTransition {
.asymmetric(
insertion: .opacity.combined(with: .offset(y: 10)),
removal: .opacity.combined(with: .offset(y: -10))
)
}
private var currentStepIndex: Int {
visibleSteps.firstIndex(of: model.step) ?? 0
}
private var canGoBack: Bool {
currentStepIndex > 0 && !model.isInstalling
}
private var canSkipCurrentStep: Bool {
model.step != .welcome && !model.isInstalling
}
private var isLastStep: Bool {
currentStepIndex == visibleSteps.count - 1
}
// MARK: Actions
private func setEngine(_ mode: String) {
withAnimation(Motion.quick) { viewModel.setEngineMode(mode) }
}
private func primaryAction() {
switch model.step {
case .microphone where model.micStatus != .authorized:
model.requestMicrophone()
case .accessibility where !model.accessibilityTrusted:
model.openAccessibilitySettings()
default:
if isLastStep { finish() } else { goForward() }
}
}
private func goForward() {
let nextIndex = min(currentStepIndex + 1, visibleSteps.count - 1)
withAnimation(Motion.soft) { model.step = visibleSteps[nextIndex] }
}
private func goBack() {
let previousIndex = max(currentStepIndex - 1, 0)
withAnimation(Motion.soft) { model.step = visibleSteps[previousIndex] }
}
private func finish() {
viewModel.selectedSection = .dashboard
hasCompletedOnboarding = true
}
private func applyDefaults() {
guard !hasCompletedOnboarding else { return }
if viewModel.config.apiKey.isEmpty, viewModel.config.engineMode == "cloud" {
viewModel.setEngineMode("local")
}
}
}
+16 -1
View File
@@ -15,7 +15,7 @@ enum MacQwen3LocalASR {
modelPath: String,
bias: LocalASRBiasPayload? = nil
) async throws -> String {
guard MacLocalASRPreferences.qwen3ModelIsInstalled(at: modelPath) else {
guard modelDirectoryIsInstalled(at: modelPath) else {
throw MacLocalASRError.qwen3ModelMissing
}
guard sampleRate == 16_000 else {
@@ -40,4 +40,19 @@ enum MacQwen3LocalASR {
throw MacLocalASRError.qwen3InferenceFailed(error.localizedDescription)
}
}
private static func modelDirectoryIsInstalled(at path: String) -> Bool {
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else {
return false
}
let fm = FileManager.default
let config = (path as NSString).appendingPathComponent("config.json")
let weights = (path as NSString).appendingPathComponent("model.safetensors")
guard fm.fileExists(atPath: config), fm.fileExists(atPath: weights) else {
return false
}
let names = (try? fm.contentsOfDirectory(atPath: path)) ?? []
return names.contains("vocab.json") && names.contains("merges.txt")
}
}
+48 -22
View File
@@ -49,7 +49,13 @@ struct MacRootView: View {
brandHeader
VStack(spacing: 4) {
ForEach(MacSection.allCases) { section in
sidebarRow(section)
MacSidebarRow(
section: section,
isSelected: viewModel.selectedSection == section,
language: uiLanguage
) {
withAnimation(Motion.soft) { viewModel.selectedSection = section }
}
}
}
.padding(.horizontal, MacMetrics.sidebarInset)
@@ -58,27 +64,6 @@ struct MacRootView: View {
}
}
private func sidebarRow(_ section: MacSection) -> some View {
let isSelected = viewModel.selectedSection == section
return Button {
viewModel.selectedSection = section
} label: {
Label(section.title(language: uiLanguage), systemImage: section.systemImage)
.font(.system(size: 13))
.foregroundStyle(isSelected ? palette.textOnAccent : palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.background(
isSelected ? palette.accent : Color.clear,
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
/// Brand mark pinned above the nav list. Top padding clears the traffic
/// lights that now float over the borderless sidebar.
private var brandHeader: some View {
@@ -123,8 +108,49 @@ struct MacRootView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(viewModel.selectedSection)
.transition(.opacity)
MacStatusFooter(viewModel: viewModel)
}
.background(palette.background)
}
}
// MARK: - Sidebar row
/// A navigation row with an animated hover highlight and selection state,
/// matching the macOS System Settings feel.
private struct MacSidebarRow: View {
let section: MacSection
let isSelected: Bool
let language: AppUILanguage
let action: () -> Void
@Environment(\.themePalette) private var palette
@State private var isHovering = false
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)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.background(
rowBackground,
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.animation(Motion.quick, value: isSelected)
.animation(Motion.quick, value: isHovering)
.onHover { isHovering = $0 }
}
private var rowBackground: Color {
if isSelected { return palette.accent }
return isHovering ? palette.textPrimary.opacity(0.06) : .clear
}
}
+47 -23
View File
@@ -16,6 +16,8 @@ struct MacSettingsView: View {
@AppStorage(MacAppearancePreference.storageKey)
private var appearanceRaw = MacAppearancePreference.system.rawValue
@AppStorage(MacOnboardingState.storageKey)
private var hasCompletedMacOnboarding = true
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
@State private var showProviderPicker = false
@@ -30,22 +32,26 @@ struct MacSettingsView: View {
]
var body: some View {
Form {
generalSection
recognitionSection
if viewModel.config.engineMode == "cloud" {
providerSection
NavigationStack {
Form {
generalSection
recognitionSection
if viewModel.config.engineMode == "cloud" {
providerSection
.transition(.opacity)
}
if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity)
}
inputSection
legalSection
}
if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel)
}
inputSection
syncSection
.formStyle(.grouped)
.tint(palette.accent)
.scrollContentBackground(.hidden)
.background(palette.background)
}
.formStyle(.grouped)
.tint(palette.accent)
.scrollContentBackground(.hidden)
.background(palette.background)
.onAppear { refreshAccessibilityState() }
}
@@ -70,13 +76,7 @@ struct MacSettingsView: View {
Text(localeLabel(locale)).tag(locale.id)
}
}
}
}
// MARK: - iCloud
private var syncSection: some View {
Section("iCloud") {
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang)
}
@@ -139,14 +139,14 @@ struct MacSettingsView: View {
subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang),
systemImage: "cloud",
selected: viewModel.config.engineMode == "cloud"
) { viewModel.setEngineMode("cloud") }
) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } }
methodRow(
title: MacL10n.string("mac.settings.localEngine", language: lang),
subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang),
systemImage: "cpu",
selected: viewModel.config.engineMode == "local"
) { viewModel.setEngineMode("local") }
) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } }
}
}
@@ -176,6 +176,8 @@ struct MacSettingsView: View {
)
.font(TypeStyle.caption)
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
.contentTransition(.opacity)
.animation(Motion.quick, value: accessibilityTrusted)
Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) {
openAccessibilitySettings()
@@ -190,7 +192,27 @@ struct MacSettingsView: View {
}
}
// MARK: - Qwen3 model path (legacy see MacLocalASRModelSettingsView)
// MARK: - Legal
private var legalSection: some View {
Section(MacL10n.string("mac.settings.about", language: lang)) {
NavigationLink {
MacPrivacyPolicyView(uiLanguage: lang)
} label: {
Text(MacL10n.string("mac.settings.privacyPolicy", language: lang))
}
NavigationLink {
MacOpenSourceLicensesView(uiLanguage: lang)
} label: {
Text(MacL10n.string("mac.settings.thirdPartyLicenses", language: lang))
}
Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) {
hasCompletedMacOnboarding = false
}
}
}
// MARK: - Row helpers
@@ -228,7 +250,9 @@ struct MacSettingsView: View {
Spacer(minLength: Spacing.sm)
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
.contentTransition(.symbolEffect(.replace))
}
.animation(Motion.quick, value: selected)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
+8
View File
@@ -49,6 +49,14 @@ enum MacSherpaLocalASR {
layout: layout,
runtimeBinary: binary
)
case .sherpaParaformer:
return try await MacSherpaONNXRunner.transcribeParaformer(
samples: samples,
sampleRate: sampleRate,
modelRoot: modelRoot,
layout: layout,
runtimeBinary: binary
)
default:
throw MacLocalASRError.qwen3InferenceFailed("Unsupported Sherpa backend")
}
+27
View File
@@ -77,6 +77,33 @@ enum MacSherpaONNXRunner {
return try await run(binary: runtimeBinary, arguments: arguments)
}
static func transcribeParaformer(
samples: [Float],
sampleRate: Int,
modelRoot: URL,
layout: LocalASRModelLayout,
runtimeBinary: URL
) async throws -> String {
guard sampleRate == 16_000 else {
throw MacLocalASRError.qwen3InferenceFailed("Sherpa expects 16 kHz audio")
}
guard let paraformer = layout.paraformerModel,
let tokens = layout.tokens else {
throw MacLocalASRError.qwen3InferenceFailed("Incomplete Paraformer layout")
}
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: sampleRate)
defer { try? FileManager.default.removeItem(at: wavURL) }
let arguments = [
"--tokens=\(modelRoot.appendingPathComponent(tokens).path)",
"--paraformer=\(modelRoot.appendingPathComponent(paraformer).path)",
"--num-threads=2",
wavURL.path,
]
return try await run(binary: runtimeBinary, arguments: arguments)
}
// MARK: - Private
private static func writeTemporaryWAV(samples: [Float], sampleRate: Int) throws -> URL {
+89 -28
View File
@@ -3,47 +3,108 @@
//
// System-native colour palette for the desktop app. Instead of the custom
// near-black brand palette, the Mac app maps every design token onto AppKit
// semantic colours (`Color(nsColor:)`), which adapt to light / dark on their
// own. The brand green is kept only as the accent. This gives the app the
// same zero-colour-difference, System-Settings / Notes look on both
// appearances while reusing every existing `palette.X` call site.
// 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.
import AppKit
import SwiftUI
enum MacSystemPalette {
/// A `ThemePalette` whose surfaces and text resolve to AppKit semantic
/// colours. Because those colours are dynamic, a single value renders
/// correctly under both light and dark (driven by `preferredColorScheme`).
static let palette = ThemePalette(
background: Color(nsColor: .windowBackgroundColor),
surface: Color(nsColor: .controlBackgroundColor),
surfaceElevated: Color(nsColor: .unemphasizedSelectedContentBackgroundColor),
surfaceMuted: Color(nsColor: .underPageBackgroundColor),
/// Returns the palette for the given colour scheme. Because the two
/// palettes hold *concrete* (already-resolved) colours, the value changes
/// identity when the scheme flips so injecting it via `@Environment`
/// (see `macSystemPalette()`) reliably re-renders every dependent view the
/// instant the appearance changes, instead of lagging until the next
/// view rebuild.
static func palette(for scheme: ColorScheme) -> ThemePalette {
scheme == .dark ? darkPalette : lightPalette
}
accent: Palette.accent,
accentMuted: Palette.accent.opacity(0.16),
accentGlow: Palette.accent.opacity(0.35),
private static let lightPalette = makePalette(dark: false)
private static let darkPalette = makePalette(dark: true)
danger: Color(nsColor: .systemRed),
success: Palette.accent,
warning: Color(nsColor: .systemOrange),
private static func makePalette(dark: Bool) -> ThemePalette {
ThemePalette(
background: resolved(dark ? darkBackground : warmBackground, dark: dark),
surface: resolved(dark ? darkSurface : warmSurface, dark: dark),
surfaceElevated: resolved(dark ? darkElevated : warmElevated, dark: dark),
surfaceMuted: resolved(dark ? darkMuted : warmMuted, dark: dark),
textPrimary: Color(nsColor: .labelColor),
textSecondary: Color(nsColor: .secondaryLabelColor),
textTertiary: Color(nsColor: .tertiaryLabelColor),
textOnAccent: Color.white,
accent: Palette.accent,
accentMuted: Palette.accent.opacity(0.16),
accentGlow: Palette.accent.opacity(0.35),
divider: Color(nsColor: .separatorColor),
dividerStrong: Color(nsColor: .separatorColor),
danger: resolved(.systemRed, dark: dark),
success: Palette.accent,
warning: resolved(.systemOrange, dark: dark),
recordRed: Color(nsColor: .systemRed)
)
textPrimary: resolved(.labelColor, dark: dark),
textSecondary: resolved(.secondaryLabelColor, dark: dark),
textTertiary: resolved(.tertiaryLabelColor, dark: dark),
textOnAccent: Color.white,
divider: resolved(.separatorColor, dark: dark),
dividerStrong: resolved(.separatorColor, dark: dark),
recordRed: resolved(.systemRed, dark: dark)
)
}
// MARK: - Warm Light-mode surfaces (matched to iOS `Palette.light`)
/// #F2F1EE warm gray page background.
private static let warmBackground = NSColor(srgbRed: 0.949, green: 0.945, blue: 0.933, alpha: 1)
/// #FCFBF9 warm off-white card/control surface.
private static let warmSurface = NSColor(srgbRed: 0.988, green: 0.984, blue: 0.976, alpha: 1)
/// #EBEAE7 slightly recessed elevated surface.
private static let warmElevated = NSColor(srgbRed: 0.922, green: 0.918, blue: 0.906, alpha: 1)
/// #EEEDE9 muted fill between background and surface.
private static let warmMuted = NSColor(srgbRed: 0.933, green: 0.929, blue: 0.918, alpha: 1)
// MARK: - Dark-mode surfaces (Apple standard elevated grays)
//
// AppKit's `controlBackgroundColor` is *darker* than `windowBackgroundColor`
// in Dark Aqua, so cards using it recede into the page. Instead we step the
// surfaces explicitly (systemGray64 equivalents) so every card reads as
// clearly elevated above the background mirroring the iOS dark palette.
/// #1C1C1E page background.
private static let darkBackground = NSColor(srgbRed: 0.110, green: 0.110, blue: 0.118, alpha: 1)
/// #2C2C2E card / control surface, clearly lighter than the background.
private static let darkSurface = NSColor(srgbRed: 0.173, green: 0.173, blue: 0.180, alpha: 1)
/// #3A3A3C elevated fill for selected / raised chrome.
private static let darkElevated = NSColor(srgbRed: 0.227, green: 0.227, blue: 0.235, alpha: 1)
/// #242426 muted fill between background and surface.
private static let darkMuted = NSColor(srgbRed: 0.141, green: 0.141, blue: 0.149, alpha: 1)
/// Resolves a (possibly dynamic) AppKit colour to its concrete value under
/// the requested appearance, so the two static palettes differ by value.
private static func resolved(_ nsColor: NSColor, dark: Bool) -> Color {
guard let appearance = NSAppearance(named: dark ? .darkAqua : .aqua) else {
return Color(nsColor: nsColor)
}
var result = nsColor
appearance.performAsCurrentDrawingAppearance {
result = nsColor.usingColorSpace(.sRGB) ?? nsColor
}
return Color(nsColor: result)
}
}
private struct MacSystemPaletteModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content.environment(\.themePalette, MacSystemPalette.palette(for: colorScheme))
}
}
extension View {
/// Injects the system-native palette used across the macOS app.
/// Injects the system-native palette used across the macOS app, refreshed
/// automatically whenever the effective colour scheme changes.
func macSystemPalette() -> some View {
environment(\.themePalette, MacSystemPalette.palette)
modifier(MacSystemPaletteModifier())
}
}
+41 -2
View File
@@ -16,6 +16,7 @@ struct OSGKeyboardMacApp: App {
// Mac-local appearance preference. Drives both the SwiftUI colour scheme
// and via `applyToApp` the AppKit window chrome / popover.
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
@AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = false
private var appearance: MacAppearancePreference {
MacAppearancePreference(rawValue: appearanceRaw) ?? .system
@@ -23,7 +24,16 @@ struct OSGKeyboardMacApp: App {
var body: some Scene {
Window("OSGKeyboard", id: "main") {
MacRootView(viewModel: viewModel)
Group {
if hasCompletedMacOnboarding {
MacRootView(viewModel: viewModel)
} else {
MacOnboardingView(
viewModel: viewModel,
hasCompletedOnboarding: $hasCompletedMacOnboarding
)
}
}
.macSystemPalette()
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
.preferredColorScheme(appearance.colorScheme)
@@ -177,12 +187,41 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
private struct MacMenuBarPopover: View {
@ObservedObject private var viewModel = MacDictationViewModel.shared
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
@AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = false
var body: some View {
MacContentView(viewModel: viewModel)
Group {
if hasCompletedMacOnboarding {
MacContentView(viewModel: viewModel)
} else {
onboardingPrompt
}
}
.frame(width: 340)
.macSystemPalette()
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
.preferredColorScheme(MacAppearancePreference(rawValue: appearanceRaw)?.colorScheme ?? nil)
}
private var onboardingPrompt: some View {
VStack(spacing: Spacing.md) {
Image(systemName: "sparkles")
.font(.system(size: 30, weight: .semibold))
.foregroundStyle(.accent)
Text(MacL10n.string("mac.onboarding.popover.title", language: viewModel.config.uiLanguage))
.font(TypeStyle.headline)
Text(MacL10n.string("mac.onboarding.popover.subtitle", language: viewModel.config.uiLanguage))
.font(TypeStyle.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button(MacL10n.string("mac.openWindow", language: viewModel.config.uiLanguage)) {
MacMainWindow.open()
}
.buttonStyle(.borderedProminent)
}
.padding(Spacing.lg)
}
}