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:
@@ -70,3 +70,6 @@ __pycache__/
|
|||||||
.flow-oslog-capture.log
|
.flow-oslog-capture.log
|
||||||
.flow-*.log
|
.flow-*.log
|
||||||
.osg_*.png
|
.osg_*.png
|
||||||
|
|
||||||
|
# Standalone experiment: 灵动岛录音机制验证 demo (throwaway)
|
||||||
|
AudioIntentProbe/
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Force-quit mic release**: on termination the host app now synchronously stops `AVAudioEngine`, deactivates `AVAudioSession`, and ends Live Activities (Dynamic Island + Lock Screen) in `applicationWillTerminate`, reducing “microphone in use” errors after reopening. / **强杀麦克风释放**:进程终止时在 `applicationWillTerminate` 内同步停止 `AVAudioEngine`、释放 `AVAudioSession` 并结束 Live Activity(灵动岛 + 锁屏),降低强杀后重开提示麦克风被占用的概率。
|
||||||
|
|
||||||
## [0.5.2] - 2026-07-09
|
## [0.5.2] - 2026-07-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ final class AppURLHandler: NSObject, UIApplicationDelegate {
|
|||||||
configuration.delegateClass = AppSceneDelegate.self
|
configuration.delegateClass = AppSceneDelegate.self
|
||||||
return configuration
|
return configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 后台音频会话仍 running 时,用户强杀常会进入此回调(约 5 秒清理窗口)。
|
||||||
|
/// 同步释放麦克风 + 结束 Live Activity,避免重开后「麦克风被占用」。
|
||||||
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
FlowTerminationCoordinator.performSynchronousTerminationCleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
|
final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||||
|
|||||||
@@ -116,4 +116,21 @@ enum FlowLiveActivityController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `applicationWillTerminate` 专用:阻塞到所有 `end` 完成,避免进程先退出而锁屏卡片残留。
|
||||||
|
nonisolated static func endAllSynchronouslyOnTerminate() {
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
Task.detached(priority: .userInitiated) {
|
||||||
|
let activities = Activity<FlowActivityAttributes>.activities
|
||||||
|
let count = activities.count
|
||||||
|
for activity in activities {
|
||||||
|
await activity.end(activity.content, dismissalPolicy: .immediate)
|
||||||
|
}
|
||||||
|
FlowDiagnostics.log("Live Activity ended synchronously on terminate (count=\(count))")
|
||||||
|
semaphore.signal()
|
||||||
|
}
|
||||||
|
semaphore.wait()
|
||||||
|
currentPhase = .idle
|
||||||
|
currentActivity = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
capture.onEngineLiveChanged = { [weak self] _ in
|
capture.onEngineLiveChanged = { [weak self] _ in
|
||||||
self?.refreshHostReady()
|
self?.refreshHostReady()
|
||||||
}
|
}
|
||||||
|
FlowTerminationCoordinator.register(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
@@ -210,6 +211,73 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
AppPermissions.openSystemSettings()
|
AppPermissions.openSystemSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 强杀专用同步 teardown:不等待 ASR/LLM;Live Activity 由
|
||||||
|
/// `FlowTerminationCoordinator` 同步 `end`。
|
||||||
|
func prepareForProcessTermination() {
|
||||||
|
debug("prepareForProcessTermination")
|
||||||
|
|
||||||
|
coldStartContext = nil
|
||||||
|
isColdStartHandoff = false
|
||||||
|
coldStartRecoveryTask?.cancel()
|
||||||
|
coldStartRecoveryTask = nil
|
||||||
|
startTask?.cancel()
|
||||||
|
startTask = nil
|
||||||
|
commandObserver = nil
|
||||||
|
pollingTask?.cancel()
|
||||||
|
pollingTask = nil
|
||||||
|
heartbeatTask?.cancel()
|
||||||
|
heartbeatTask = nil
|
||||||
|
expiryTask?.cancel()
|
||||||
|
expiryTask = nil
|
||||||
|
levelTask?.cancel()
|
||||||
|
levelTask = nil
|
||||||
|
finalizeTask?.cancel()
|
||||||
|
finalizeTask = nil
|
||||||
|
utteranceSafetyTask?.cancel()
|
||||||
|
utteranceSafetyTask = nil
|
||||||
|
asrTask?.cancel()
|
||||||
|
asrTask = nil
|
||||||
|
chunkedPipeline = nil
|
||||||
|
|
||||||
|
if isUtteranceRecording || isUtteranceProcessing {
|
||||||
|
capture.cancelUtterance()
|
||||||
|
asr.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
capture.cancelUtterance()
|
||||||
|
if capture.running {
|
||||||
|
capture.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
endBackgroundKeepAlive()
|
||||||
|
ScreenWakeLock.release()
|
||||||
|
|
||||||
|
sessionASR?.cancel()
|
||||||
|
sessionASR = nil
|
||||||
|
sessionASREngineMode = nil
|
||||||
|
sessionASRWarmedLocaleID = nil
|
||||||
|
|
||||||
|
if isActive || FlowSessionBridge.isSessionActive() {
|
||||||
|
FlowSessionBridge.markSessionInactive()
|
||||||
|
FlowSessionDarwin.postSessionChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSessionId = nil
|
||||||
|
currentUtteranceId = nil
|
||||||
|
currentCommandSeq = 0
|
||||||
|
lastHandledCommandSeq = 0
|
||||||
|
isUtteranceRecording = false
|
||||||
|
isUtteranceProcessing = false
|
||||||
|
isActive = false
|
||||||
|
isStarting = false
|
||||||
|
sessionExpiresAt = nil
|
||||||
|
sessionWarning = nil
|
||||||
|
currentPartial = ""
|
||||||
|
lastFinal = ""
|
||||||
|
chunkWarnings = []
|
||||||
|
FlowSessionBridge.setHostReady(false)
|
||||||
|
}
|
||||||
|
|
||||||
func endSession() {
|
func endSession() {
|
||||||
guard isActive else { return }
|
guard isActive else { return }
|
||||||
debug("Flow session ended")
|
debug("Flow session ended")
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// FlowTerminationCoordinator.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// 桥接 `UIApplicationDelegate.applicationWillTerminate` 与 `FlowSessionManager`。
|
||||||
|
// SwiftUI 里 `FlowSessionManager` 是 `@StateObject`,AppDelegate 无法直接持有;
|
||||||
|
// 此处用弱引用在进程退出窗口(约 5 秒)内同步释放麦克风与 Live Activity。
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum FlowTerminationCoordinator {
|
||||||
|
private static weak var sessionManager: FlowSessionManager?
|
||||||
|
|
||||||
|
/// `FlowSessionManager.init()` 注册当前实例。
|
||||||
|
static func register(_ manager: FlowSessionManager) {
|
||||||
|
sessionManager = manager
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 强杀 / 系统终止时调用。必须在主线程执行(`applicationWillTerminate` 保证)。
|
||||||
|
static func performSynchronousTerminationCleanup() {
|
||||||
|
sessionManager?.prepareForProcessTermination()
|
||||||
|
FlowLiveActivityController.endAllSynchronouslyOnTerminate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,11 +34,15 @@ struct DashboardView: View {
|
|||||||
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
|
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
statGrid
|
statGrid
|
||||||
dictationCanvas
|
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)
|
BottomDictationBar(viewModel: viewModel)
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
@@ -91,6 +95,7 @@ struct DashboardView: View {
|
|||||||
|
|
||||||
private var dictationCanvas: some View {
|
private var dictationCanvas: some View {
|
||||||
MacCard(padding: Spacing.lg) {
|
MacCard(padding: Spacing.lg) {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
if viewModel.transcript.isEmpty {
|
if viewModel.transcript.isEmpty {
|
||||||
Text(
|
Text(
|
||||||
viewModel.isRecording
|
viewModel.isRecording
|
||||||
@@ -99,16 +104,22 @@ struct DashboardView: View {
|
|||||||
)
|
)
|
||||||
.font(.system(size: 26, weight: .light))
|
.font(.system(size: 26, weight: .light))
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
|
.contentTransition(.opacity)
|
||||||
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
|
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
|
||||||
|
.transition(.opacity)
|
||||||
} else {
|
} else {
|
||||||
Text(viewModel.transcript)
|
Text(viewModel.transcript)
|
||||||
.font(.system(size: 22, weight: .regular))
|
.font(.system(size: 22, weight: .regular))
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
|
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
|
||||||
|
.animation(Motion.quick, value: viewModel.isRecording)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Floating record bar
|
// MARK: - Floating record bar
|
||||||
@@ -132,12 +143,11 @@ struct BottomDictationBar: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, Spacing.md)
|
.padding(.horizontal, Spacing.md)
|
||||||
.padding(.vertical, Spacing.sm)
|
.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(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||||
)
|
)
|
||||||
.shadow(color: palette.textPrimary.opacity(0.12), radius: 18, y: 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var readinessChip: some View {
|
private var readinessChip: some View {
|
||||||
@@ -152,10 +162,12 @@ struct BottomDictationBar: View {
|
|||||||
)
|
)
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.contentTransition(.opacity)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Spacing.sm)
|
.padding(.horizontal, Spacing.sm)
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 5)
|
||||||
.background(palette.surfaceElevated, in: Capsule())
|
.background(palette.surfaceElevated, in: Capsule())
|
||||||
|
.animation(Motion.quick, value: viewModel.isProcessing)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var translationPicker: some View {
|
private var translationPicker: some View {
|
||||||
@@ -195,8 +207,10 @@ struct BottomDictationBar: View {
|
|||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
.offset(y: -22)
|
.offset(y: -22)
|
||||||
|
.transition(.opacity.combined(with: .offset(y: 6)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.animation(Motion.quick, value: viewModel.isRecording)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var recordButton: some View {
|
private var recordButton: some View {
|
||||||
@@ -205,11 +219,11 @@ struct BottomDictationBar: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.fill(viewModel.isRecording ? palette.recordRed : palette.accent)
|
.fill(viewModel.isRecording ? palette.recordRed : palette.accent)
|
||||||
.frame(width: 52, height: 52)
|
.frame(width: 52, height: 52)
|
||||||
.macGlassSurface(in: Circle(), fillOpacity: 0.2)
|
|
||||||
.shadow(
|
.shadow(
|
||||||
color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.5),
|
color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.35),
|
||||||
radius: pulse ? 14 : 6
|
radius: pulse ? 10 : 5
|
||||||
)
|
)
|
||||||
|
Group {
|
||||||
if viewModel.isRecording {
|
if viewModel.isRecording {
|
||||||
// 与 iOS 一致:录音时在红色按钮内部显示实时波形
|
// 与 iOS 一致:录音时在红色按钮内部显示实时波形
|
||||||
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
|
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
|
||||||
@@ -219,11 +233,17 @@ struct BottomDictationBar: View {
|
|||||||
.foregroundStyle(palette.textOnAccent)
|
.foregroundStyle(palette.textOnAccent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.7)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(viewModel.isProcessing)
|
.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 {
|
.onAppear {
|
||||||
withAnimation(.easeInOut(duration: 1.1).repeatForever(autoreverses: true)) {
|
withAnimation(Motion.breath) {
|
||||||
pulse = true
|
pulse = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,20 +38,17 @@ private struct MacGlassSurface<S: Shape>: ViewModifier {
|
|||||||
let fillOpacity: Double
|
let fillOpacity: Double
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
if #available(macOS 26.0, *) {
|
// 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
|
content
|
||||||
.background(palette.surface.opacity(fillOpacity), in: shape)
|
.background(palette.surface.opacity(fillOpacity), in: shape)
|
||||||
.glassEffect(.regular, in: shape)
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
.background(palette.surface.opacity(fillOpacity), in: shape)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
/// Applies Liquid Glass on macOS 26 while keeping the same semantic
|
/// Applies a flat semantic surface fill (no drop shadow) behind `content`.
|
||||||
/// surface colour on older systems.
|
|
||||||
func macGlassSurface<S: Shape>(
|
func macGlassSurface<S: Shape>(
|
||||||
in shape: S,
|
in shape: S,
|
||||||
fillOpacity: Double = 0.72
|
fillOpacity: Double = 0.72
|
||||||
@@ -97,7 +94,7 @@ struct MacCard<Content: View>: View {
|
|||||||
|
|
||||||
content()
|
content()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.macGlassSurface(in: shape)
|
.macGlassSurface(in: shape, fillOpacity: 1)
|
||||||
.overlay(
|
.overlay(
|
||||||
shape
|
shape
|
||||||
.stroke(palette.divider, lineWidth: 0.5)
|
.stroke(palette.divider, lineWidth: 0.5)
|
||||||
@@ -135,6 +132,8 @@ struct StatCard: View {
|
|||||||
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.7)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
.animation(Motion.soft, value: value)
|
||||||
Text(caption)
|
Text(caption)
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
@@ -165,7 +164,7 @@ struct MiniWaveform: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 22)
|
.frame(height: 22)
|
||||||
.animation(.easeOut(duration: 0.12), value: level)
|
.animation(Motion.instant, value: level)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) {
|
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) {
|
||||||
phase = 1
|
phase = 1
|
||||||
@@ -214,12 +213,14 @@ struct MacStatusFooter: View {
|
|||||||
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
|
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
|
||||||
)
|
)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.contentTransition(.opacity)
|
||||||
|
|
||||||
Label(
|
Label(
|
||||||
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
|
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
|
||||||
systemImage: "translate"
|
systemImage: "translate"
|
||||||
)
|
)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.contentTransition(.opacity)
|
||||||
|
|
||||||
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
|
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
|
||||||
.foregroundStyle(palette.accent)
|
.foregroundStyle(palette.accent)
|
||||||
@@ -228,5 +229,7 @@ struct MacStatusFooter: View {
|
|||||||
.labelStyle(.titleAndIcon)
|
.labelStyle(.titleAndIcon)
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
.padding(.vertical, Spacing.xs)
|
.padding(.vertical, Spacing.xs)
|
||||||
|
.animation(Motion.quick, value: viewModel.isCloudMode)
|
||||||
|
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ struct MacContentView: View {
|
|||||||
Text(viewModel.statusMessage)
|
Text(viewModel.statusMessage)
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.contentTransition(.opacity)
|
||||||
|
|
||||||
if !viewModel.transcript.isEmpty {
|
if !viewModel.transcript.isEmpty {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -34,6 +35,7 @@ struct MacContentView: View {
|
|||||||
.frame(maxHeight: 120)
|
.frame(maxHeight: 120)
|
||||||
.padding(Spacing.xs)
|
.padding(Spacing.xs)
|
||||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous))
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous))
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider().overlay(palette.divider)
|
Divider().overlay(palette.divider)
|
||||||
@@ -43,6 +45,8 @@ struct MacContentView: View {
|
|||||||
}
|
}
|
||||||
.padding(Spacing.md)
|
.padding(Spacing.md)
|
||||||
.background(palette.background)
|
.background(palette.background)
|
||||||
|
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
|
||||||
|
.animation(Motion.quick, value: viewModel.statusMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusRow: some View {
|
private var statusRow: some View {
|
||||||
@@ -103,20 +107,24 @@ struct MacContentView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
if viewModel.isRecording {
|
if viewModel.isRecording {
|
||||||
MiniWaveform(level: viewModel.audioLevel, barCount: 4)
|
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 {
|
private var recordButton: some View {
|
||||||
Button(action: viewModel.toggleRecording) {
|
Button(action: viewModel.toggleRecording) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill")
|
Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill")
|
||||||
|
.contentTransition(.symbolEffect(.replace))
|
||||||
Text(
|
Text(
|
||||||
viewModel.isRecording
|
viewModel.isRecording
|
||||||
? MacL10n.string("mac.record.stop", language: lang)
|
? MacL10n.string("mac.record.stop", language: lang)
|
||||||
: MacL10n.string("mac.record.start", language: lang)
|
: MacL10n.string("mac.record.start", language: lang)
|
||||||
)
|
)
|
||||||
.font(TypeStyle.bodyEmph)
|
.font(TypeStyle.bodyEmph)
|
||||||
|
.contentTransition(.opacity)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, minHeight: 40)
|
.frame(maxWidth: .infinity, minHeight: 40)
|
||||||
.background(
|
.background(
|
||||||
@@ -127,6 +135,9 @@ struct MacContentView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(viewModel.isProcessing)
|
.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 {
|
private var footer: some View {
|
||||||
|
|||||||
@@ -101,25 +101,11 @@ final class MacDictationViewModel: ObservableObject {
|
|||||||
func onAppear() async {
|
func onAppear() async {
|
||||||
await MacICloudSyncBootstrap.pullIfEnabled()
|
await MacICloudSyncBootstrap.pullIfEnabled()
|
||||||
refreshForegroundAppName()
|
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() {
|
func reloadConfigFromCloud() {
|
||||||
config.reloadFromPersistedStorage()
|
config.reloadFromPersistedStorage()
|
||||||
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
|
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
|
||||||
warmUpQwen3IfNeeded()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshDictionaryFromCloud() {
|
func refreshDictionaryFromCloud() {
|
||||||
@@ -159,11 +145,9 @@ final class MacDictationViewModel: ObservableObject {
|
|||||||
|
|
||||||
var localModelReady: Bool {
|
var localModelReady: Bool {
|
||||||
_ = localModelRevision
|
_ = localModelRevision
|
||||||
if let model = MacLocalASRService.selectedModelDefinition() {
|
guard let model = MacLocalASRService.selectedModelDefinition() else { return false }
|
||||||
return MacLocalASRService.isModelInstalled(model)
|
return MacLocalASRService.isModelInstalled(model)
|
||||||
}
|
}
|
||||||
return MacLocalASRPreferences.qwen3ModelIsInstalled()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Context-aware warning when local engine is selected but the active model is not ready.
|
/// Context-aware warning when local engine is selected but the active model is not ready.
|
||||||
var localModelWarningMessage: String? {
|
var localModelWarningMessage: String? {
|
||||||
@@ -173,9 +157,6 @@ final class MacDictationViewModel: ObservableObject {
|
|||||||
guard let model = MacLocalASRService.selectedModelDefinition() else {
|
guard let model = MacLocalASRService.selectedModelDefinition() else {
|
||||||
return MacL10n.string("mac.settings.localModelFallbackApple", language: config.uiLanguage)
|
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(
|
return MacL10n.format(
|
||||||
"mac.settings.selectedModelMissing",
|
"mac.settings.selectedModelMissing",
|
||||||
language: config.uiLanguage,
|
language: config.uiLanguage,
|
||||||
@@ -190,10 +171,6 @@ final class MacDictationViewModel: ObservableObject {
|
|||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
var qwen3ModelInstalled: Bool {
|
|
||||||
MacLocalASRPreferences.qwen3ModelIsInstalled()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Preferences
|
// MARK: - Preferences
|
||||||
|
|
||||||
func setAutoPasteEnabled(_ enabled: Bool) {
|
func setAutoPasteEnabled(_ enabled: Bool) {
|
||||||
@@ -210,7 +187,6 @@ final class MacDictationViewModel: ObservableObject {
|
|||||||
|
|
||||||
func setEngineMode(_ mode: String) {
|
func setEngineMode(_ mode: String) {
|
||||||
config.engineMode = mode
|
config.engineMode = mode
|
||||||
if mode == "local" { warmUpQwen3IfNeeded() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Recording
|
// MARK: - Recording
|
||||||
|
|||||||
@@ -48,12 +48,15 @@ struct MacDictionaryView: View {
|
|||||||
Group {
|
Group {
|
||||||
if entries.isEmpty {
|
if entries.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
|
.transition(.opacity)
|
||||||
} else {
|
} else {
|
||||||
form
|
form
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(palette.background)
|
.background(palette.background)
|
||||||
|
.animation(Motion.soft, value: entries.isEmpty)
|
||||||
.task {
|
.task {
|
||||||
await MacICloudSyncBootstrap.dictionarySync.pullAndMergeIfEnabled()
|
await MacICloudSyncBootstrap.dictionarySync.pullAndMergeIfEnabled()
|
||||||
viewModel.refreshDictionaryFromCloud()
|
viewModel.refreshDictionaryFromCloud()
|
||||||
@@ -86,6 +89,7 @@ struct MacDictionaryView: View {
|
|||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(palette.background)
|
.background(palette.background)
|
||||||
|
.animation(Motion.soft, value: query)
|
||||||
.safeAreaInset(edge: .top, spacing: 0) { centeredSearchField }
|
.safeAreaInset(edge: .top, spacing: 0) { centeredSearchField }
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
MacL10n.string("mac.dict.deleteTitle", language: lang),
|
MacL10n.string("mac.dict.deleteTitle", language: lang),
|
||||||
@@ -93,7 +97,9 @@ struct MacDictionaryView: View {
|
|||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button(MacL10n.string("mac.delete", language: lang), role: .destructive) {
|
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
|
entryPendingDeletion = nil
|
||||||
}
|
}
|
||||||
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {
|
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {
|
||||||
@@ -215,12 +221,13 @@ private struct MacDictionaryRow: View {
|
|||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(isHovering ? palette.danger : palette.textTertiary)
|
||||||
.opacity(isHovering ? 1 : 0)
|
.opacity(isHovering ? 1 : 0)
|
||||||
.accessibilityLabel(MacL10n.string("mac.delete", language: language))
|
.accessibilityLabel(MacL10n.string("mac.delete", language: language))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
.animation(Motion.quick, value: isHovering)
|
||||||
.onHover { isHovering = $0 }
|
.onHover { isHovering = $0 }
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button(action: copy) {
|
Button(action: copy) {
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ struct MacHistoryView: View {
|
|||||||
Group {
|
Group {
|
||||||
if historyStore.entries.isEmpty {
|
if historyStore.entries.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
|
.transition(.opacity)
|
||||||
} else {
|
} else {
|
||||||
form
|
form
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(palette.background)
|
.background(palette.background)
|
||||||
|
.animation(Motion.soft, value: historyStore.entries.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Grouped cards
|
// MARK: - Grouped cards
|
||||||
@@ -64,7 +67,7 @@ struct MacHistoryView: View {
|
|||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button(MacL10n.string("mac.history.clearConfirm", language: lang), role: .destructive) {
|
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) {}
|
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
@@ -95,7 +98,7 @@ struct MacHistoryView: View {
|
|||||||
time: Self.timeFormatter.string(from: entry.createdAt),
|
time: Self.timeFormatter.string(from: entry.createdAt),
|
||||||
language: lang,
|
language: lang,
|
||||||
copy: { viewModel.copyToClipboard(entry.text) },
|
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)
|
.frame(width: 24, height: 24)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(isHovering ? palette.danger : palette.textTertiary)
|
||||||
.opacity(isHovering ? 1 : 0)
|
.opacity(isHovering ? 1 : 0)
|
||||||
.accessibilityLabel(MacL10n.string("mac.delete", language: language))
|
.accessibilityLabel(MacL10n.string("mac.delete", language: language))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
.animation(Motion.quick, value: isHovering)
|
||||||
.onHover { isHovering = $0 }
|
.onHover { isHovering = $0 }
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button(action: copy) {
|
Button(action: copy) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// MacLocalASRModelSettingsView.swift
|
||||||
// OSGKeyboard · Mac
|
// 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 AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -29,9 +29,11 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject {
|
|||||||
catalog = try? LocalASRModelCatalog.loadBundled()
|
catalog = try? LocalASRModelCatalog.loadBundled()
|
||||||
if let catalog {
|
if let catalog {
|
||||||
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
|
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
|
||||||
selectedModelId = manifest.selectedModelId.isEmpty
|
selectedModelId = MacLocalASRPreferences.migratedModelId(
|
||||||
|
manifest.selectedModelId.isEmpty
|
||||||
? MacLocalASRPreferences.selectedModelId
|
? MacLocalASRPreferences.selectedModelId
|
||||||
: manifest.selectedModelId
|
: manifest.selectedModelId
|
||||||
|
)
|
||||||
}
|
}
|
||||||
diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load()
|
diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load()
|
||||||
onLocalModelStateChanged?()
|
onLocalModelStateChanged?()
|
||||||
@@ -46,7 +48,7 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func installedDiskUsage(_ model: LocalASRModelDefinition) -> String? {
|
func installedDiskUsage(_ model: LocalASRModelDefinition) -> String? {
|
||||||
guard model.installKind == .archive,
|
guard model.installKind == .archive || model.installKind == .repository,
|
||||||
let relative = model.installRelativePath,
|
let relative = model.installRelativePath,
|
||||||
isInstalled(model) else { return nil }
|
isInstalled(model) else { return nil }
|
||||||
let dir = LocalASRModelInstallState.installDirectory(for: relative)
|
let dir = LocalASRModelInstallState.installDirectory(for: relative)
|
||||||
@@ -140,15 +142,6 @@ final class MacLocalASRModelSettingsViewModel: ObservableObject {
|
|||||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
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() {
|
func revealStorageRoot() {
|
||||||
let url = LocalASRModelInstallState.rootDirectory()
|
let url = LocalASRModelInstallState.rootDirectory()
|
||||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
@@ -212,7 +205,6 @@ struct MacLocalASRModelSettingsView: View {
|
|||||||
Group {
|
Group {
|
||||||
if let catalog = modelVM.catalog {
|
if let catalog = modelVM.catalog {
|
||||||
modelPickerSection(catalog: catalog)
|
modelPickerSection(catalog: catalog)
|
||||||
runtimeSection(catalog: catalog)
|
|
||||||
} else {
|
} else {
|
||||||
Text(MacL10n.string("mac.localASR.catalogMissing", language: lang))
|
Text(MacL10n.string("mac.localASR.catalogMissing", language: lang))
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
@@ -226,6 +218,16 @@ struct MacLocalASRModelSettingsView: View {
|
|||||||
|
|
||||||
private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View {
|
private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View {
|
||||||
Section {
|
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
|
ForEach(catalog.models) { model in
|
||||||
modelRow(model)
|
modelRow(model)
|
||||||
}
|
}
|
||||||
@@ -251,31 +253,6 @@ struct MacLocalASRModelSettingsView: View {
|
|||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text(MacL10n.string("mac.localASR.models", language: lang))
|
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) {
|
HStack(spacing: Spacing.sm) {
|
||||||
Image(systemName: selected ? "largecircle.fill.circle" : "circle")
|
Image(systemName: selected ? "largecircle.fill.circle" : "circle")
|
||||||
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
|
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
|
||||||
|
.contentTransition(.symbolEffect(.replace))
|
||||||
|
.animation(Motion.quick, value: selected)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: Spacing.xs) {
|
||||||
Text(model.displayName)
|
Text(model.displayName)
|
||||||
.foregroundStyle(palette.textPrimary)
|
.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))
|
Text(modelSubtitle(model, installed: installed))
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
@@ -308,6 +298,8 @@ struct MacLocalASRModelSettingsView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
modelRowActions(model: model, installed: installed, installing: installing)
|
modelRowActions(model: model, installed: installed, installing: installing)
|
||||||
|
.animation(Motion.soft, value: installing)
|
||||||
|
.animation(Motion.soft, value: installed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.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 {
|
} else if installed {
|
||||||
Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) {
|
Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) {
|
||||||
modelVM.deleteModel(model)
|
modelVM.deleteModel(model)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
|
.tint(palette.danger)
|
||||||
} else {
|
} else {
|
||||||
Button(MacL10n.string("mac.localASR.download", language: lang)) {
|
Button(MacL10n.string("mac.localASR.download", language: lang)) {
|
||||||
modelVM.installModel(model)
|
modelVM.installModel(model)
|
||||||
@@ -382,7 +369,7 @@ struct MacLocalASRModelSettingsView: View {
|
|||||||
.trim(from: 0, to: fraction)
|
.trim(from: 0, to: fraction)
|
||||||
.stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
.stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
.animation(.linear(duration: 0.15), value: fraction)
|
.animation(Motion.instant, value: fraction)
|
||||||
if modelVM.installProgress.phase == .paused {
|
if modelVM.installProgress.phase == .paused {
|
||||||
Image(systemName: "pause.fill")
|
Image(systemName: "pause.fill")
|
||||||
.font(.system(size: 10, weight: .bold))
|
.font(.system(size: 10, weight: .bold))
|
||||||
@@ -399,16 +386,10 @@ struct MacLocalASRModelSettingsView: View {
|
|||||||
|
|
||||||
private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String {
|
private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String {
|
||||||
let size = modelVM.formattedSize(model.sizeBytes)
|
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) {
|
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 {
|
private var diagnosticsSection: some View {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
// OSGKeyboard · Mac
|
// OSGKeyboard · Mac
|
||||||
//
|
//
|
||||||
// On-device ASR for macOS. Routes through the bundled local ASR catalog:
|
// 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
|
import Foundation
|
||||||
|
|
||||||
enum MacLocalASRBackend: String, Sendable, CaseIterable {
|
enum MacLocalASRBackend: String, Sendable, CaseIterable {
|
||||||
case qwen3MLX
|
|
||||||
case sherpaQwen3
|
case sherpaQwen3
|
||||||
|
case sherpaParaformer
|
||||||
case sherpaSenseVoice
|
case sherpaSenseVoice
|
||||||
case appleSpeech
|
case appleSpeech
|
||||||
}
|
}
|
||||||
@@ -42,48 +42,37 @@ enum MacLocalASRError: Error, LocalizedError {
|
|||||||
enum MacLocalASRPreferences {
|
enum MacLocalASRPreferences {
|
||||||
static let backendKey = "mac.localASR.backend"
|
static let backendKey = "mac.localASR.backend"
|
||||||
static let selectedModelIdKey = LocalASRPreferenceKeys.selectedModelId
|
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 let qwen3ModelRelativePath = "models/qwen3-asr-1.7b-mlx"
|
||||||
|
|
||||||
static var selectedModelId: String {
|
static var selectedModelId: String {
|
||||||
get {
|
get {
|
||||||
if let raw = UserDefaults.standard.string(forKey: selectedModelIdKey), !raw.isEmpty {
|
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) }
|
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 {
|
static var legacyBackend: MacLocalASRBackend {
|
||||||
guard let raw = UserDefaults.standard.string(forKey: backendKey),
|
guard let raw = UserDefaults.standard.string(forKey: backendKey),
|
||||||
let value = MacLocalASRBackend(rawValue: raw) else {
|
let value = MacLocalASRBackend(rawValue: raw) else {
|
||||||
return .qwen3MLX
|
return .sherpaQwen3
|
||||||
}
|
}
|
||||||
|
if raw == "qwen3MLX" { return .sherpaQwen3 }
|
||||||
return value
|
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 {
|
enum MacLocalASRService {
|
||||||
@@ -97,7 +86,7 @@ enum MacLocalASRService {
|
|||||||
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
|
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
|
||||||
let selectedId = manifest.selectedModelId.isEmpty
|
let selectedId = manifest.selectedModelId.isEmpty
|
||||||
? MacLocalASRPreferences.selectedModelId
|
? MacLocalASRPreferences.selectedModelId
|
||||||
: manifest.selectedModelId
|
: MacLocalASRPreferences.migratedModelId(manifest.selectedModelId)
|
||||||
if selectedId == "apple-speech-fallback" { return nil }
|
if selectedId == "apple-speech-fallback" { return nil }
|
||||||
return LocalASRModelCatalog.model(selectedId, in: catalog)
|
return LocalASRModelCatalog.model(selectedId, in: catalog)
|
||||||
?? LocalASRModelCatalog.model(catalog.defaultModelId, in: catalog)
|
?? LocalASRModelCatalog.model(catalog.defaultModelId, in: catalog)
|
||||||
@@ -116,34 +105,19 @@ enum MacLocalASRService {
|
|||||||
static func isModelInstalled(_ model: LocalASRModelDefinition) -> Bool {
|
static func isModelInstalled(_ model: LocalASRModelDefinition) -> Bool {
|
||||||
LocalASRModelInstallState.isInstalled(
|
LocalASRModelInstallState.isInstalled(
|
||||||
model,
|
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(
|
static func transcribe(
|
||||||
samples: [Float],
|
samples: [Float],
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
bias: LocalASRBiasPayload? = nil
|
bias: LocalASRBiasPayload? = nil
|
||||||
) async throws -> String {
|
) async throws -> String {
|
||||||
if let model = selectedModelDefinition(), isModelInstalled(model) {
|
if let model = selectedModelDefinition(), isModelInstalled(model) {
|
||||||
do {
|
|
||||||
return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
|
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 MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
|
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
|
||||||
@@ -157,14 +131,8 @@ enum MacLocalASRService {
|
|||||||
) async throws -> String {
|
) async throws -> String {
|
||||||
switch model.backend {
|
switch model.backend {
|
||||||
case .mlx:
|
case .mlx:
|
||||||
return try await MacQwen3LocalASR.transcribe(
|
throw MacLocalASRError.qwen3ModelMissing
|
||||||
samples: samples,
|
case .sherpaQwen3, .sherpaSenseVoice, .sherpaParaformer:
|
||||||
sampleRate: 16_000,
|
|
||||||
locale: locale,
|
|
||||||
modelPath: MacLocalASRPreferences.qwen3ModelPath,
|
|
||||||
bias: bias
|
|
||||||
)
|
|
||||||
case .sherpaQwen3, .sherpaSenseVoice:
|
|
||||||
return try await MacSherpaLocalASR.transcribe(
|
return try await MacSherpaLocalASR.transcribe(
|
||||||
samples: samples,
|
samples: samples,
|
||||||
sampleRate: 16_000,
|
sampleRate: 16_000,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ enum MacQwen3LocalASR {
|
|||||||
modelPath: String,
|
modelPath: String,
|
||||||
bias: LocalASRBiasPayload? = nil
|
bias: LocalASRBiasPayload? = nil
|
||||||
) async throws -> String {
|
) async throws -> String {
|
||||||
guard MacLocalASRPreferences.qwen3ModelIsInstalled(at: modelPath) else {
|
guard modelDirectoryIsInstalled(at: modelPath) else {
|
||||||
throw MacLocalASRError.qwen3ModelMissing
|
throw MacLocalASRError.qwen3ModelMissing
|
||||||
}
|
}
|
||||||
guard sampleRate == 16_000 else {
|
guard sampleRate == 16_000 else {
|
||||||
@@ -40,4 +40,19 @@ enum MacQwen3LocalASR {
|
|||||||
throw MacLocalASRError.qwen3InferenceFailed(error.localizedDescription)
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ struct MacRootView: View {
|
|||||||
brandHeader
|
brandHeader
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
ForEach(MacSection.allCases) { section in
|
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)
|
.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
|
/// Brand mark pinned above the nav list. Top padding clears the traffic
|
||||||
/// lights that now float over the borderless sidebar.
|
/// lights that now float over the borderless sidebar.
|
||||||
private var brandHeader: some View {
|
private var brandHeader: some View {
|
||||||
@@ -123,8 +108,49 @@ struct MacRootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.id(viewModel.selectedSection)
|
||||||
|
.transition(.opacity)
|
||||||
MacStatusFooter(viewModel: viewModel)
|
MacStatusFooter(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
.background(palette.background)
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ struct MacSettingsView: View {
|
|||||||
|
|
||||||
@AppStorage(MacAppearancePreference.storageKey)
|
@AppStorage(MacAppearancePreference.storageKey)
|
||||||
private var appearanceRaw = MacAppearancePreference.system.rawValue
|
private var appearanceRaw = MacAppearancePreference.system.rawValue
|
||||||
|
@AppStorage(MacOnboardingState.storageKey)
|
||||||
|
private var hasCompletedMacOnboarding = true
|
||||||
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||||
@State private var showProviderPicker = false
|
@State private var showProviderPicker = false
|
||||||
|
|
||||||
@@ -30,22 +32,26 @@ struct MacSettingsView: View {
|
|||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
generalSection
|
generalSection
|
||||||
recognitionSection
|
recognitionSection
|
||||||
if viewModel.config.engineMode == "cloud" {
|
if viewModel.config.engineMode == "cloud" {
|
||||||
providerSection
|
providerSection
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
if viewModel.config.engineMode == "local" {
|
if viewModel.config.engineMode == "local" {
|
||||||
MacLocalASRModelSettingsView(viewModel: viewModel)
|
MacLocalASRModelSettingsView(viewModel: viewModel)
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
inputSection
|
inputSection
|
||||||
syncSection
|
legalSection
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.tint(palette.accent)
|
.tint(palette.accent)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(palette.background)
|
.background(palette.background)
|
||||||
|
}
|
||||||
.onAppear { refreshAccessibilityState() }
|
.onAppear { refreshAccessibilityState() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +76,7 @@ struct MacSettingsView: View {
|
|||||||
Text(localeLabel(locale)).tag(locale.id)
|
Text(localeLabel(locale)).tag(locale.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - iCloud
|
|
||||||
|
|
||||||
private var syncSection: some View {
|
|
||||||
Section("iCloud") {
|
|
||||||
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
|
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
|
||||||
MacDictionaryICloudSyncRow(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),
|
subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang),
|
||||||
systemImage: "cloud",
|
systemImage: "cloud",
|
||||||
selected: viewModel.config.engineMode == "cloud"
|
selected: viewModel.config.engineMode == "cloud"
|
||||||
) { viewModel.setEngineMode("cloud") }
|
) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } }
|
||||||
|
|
||||||
methodRow(
|
methodRow(
|
||||||
title: MacL10n.string("mac.settings.localEngine", language: lang),
|
title: MacL10n.string("mac.settings.localEngine", language: lang),
|
||||||
subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang),
|
subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang),
|
||||||
systemImage: "cpu",
|
systemImage: "cpu",
|
||||||
selected: viewModel.config.engineMode == "local"
|
selected: viewModel.config.engineMode == "local"
|
||||||
) { viewModel.setEngineMode("local") }
|
) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +176,8 @@ struct MacSettingsView: View {
|
|||||||
)
|
)
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
|
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
|
||||||
|
.contentTransition(.opacity)
|
||||||
|
.animation(Motion.quick, value: accessibilityTrusted)
|
||||||
|
|
||||||
Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) {
|
Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) {
|
||||||
openAccessibilitySettings()
|
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
|
// MARK: - Row helpers
|
||||||
|
|
||||||
@@ -228,7 +250,9 @@ struct MacSettingsView: View {
|
|||||||
Spacer(minLength: Spacing.sm)
|
Spacer(minLength: Spacing.sm)
|
||||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||||
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
|
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
|
||||||
|
.contentTransition(.symbolEffect(.replace))
|
||||||
}
|
}
|
||||||
|
.animation(Motion.quick, value: selected)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ enum MacSherpaLocalASR {
|
|||||||
layout: layout,
|
layout: layout,
|
||||||
runtimeBinary: binary
|
runtimeBinary: binary
|
||||||
)
|
)
|
||||||
|
case .sherpaParaformer:
|
||||||
|
return try await MacSherpaONNXRunner.transcribeParaformer(
|
||||||
|
samples: samples,
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
modelRoot: modelRoot,
|
||||||
|
layout: layout,
|
||||||
|
runtimeBinary: binary
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
throw MacLocalASRError.qwen3InferenceFailed("Unsupported Sherpa backend")
|
throw MacLocalASRError.qwen3InferenceFailed("Unsupported Sherpa backend")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,33 @@ enum MacSherpaONNXRunner {
|
|||||||
return try await run(binary: runtimeBinary, arguments: arguments)
|
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
|
// MARK: - Private
|
||||||
|
|
||||||
private static func writeTemporaryWAV(samples: [Float], sampleRate: Int) throws -> URL {
|
private static func writeTemporaryWAV(samples: [Float], sampleRate: Int) throws -> URL {
|
||||||
|
|||||||
@@ -3,47 +3,108 @@
|
|||||||
//
|
//
|
||||||
// System-native colour palette for the desktop app. Instead of the custom
|
// 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
|
// near-black brand palette, the Mac app maps every design token onto AppKit
|
||||||
// semantic colours (`Color(nsColor:)`), which adapt to light / dark on their
|
// semantic colours, resolved to a concrete value for the *active* appearance.
|
||||||
// own. The brand green is kept only as the accent. This gives the app the
|
// The brand green is kept only as the accent. Light mode uses a warm,
|
||||||
// same zero-colour-difference, System-Settings / Notes look on both
|
// iOS-matched surface set (the default `windowBackgroundColor` reads cold
|
||||||
// appearances while reusing every existing `palette.X` call site.
|
// grey on macOS); Dark mode keeps the native AppKit semantic colours.
|
||||||
|
|
||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum MacSystemPalette {
|
enum MacSystemPalette {
|
||||||
/// A `ThemePalette` whose surfaces and text resolve to AppKit semantic
|
/// Returns the palette for the given colour scheme. Because the two
|
||||||
/// colours. Because those colours are dynamic, a single value renders
|
/// palettes hold *concrete* (already-resolved) colours, the value changes
|
||||||
/// correctly under both light and dark (driven by `preferredColorScheme`).
|
/// identity when the scheme flips — so injecting it via `@Environment`
|
||||||
static let palette = ThemePalette(
|
/// (see `macSystemPalette()`) reliably re-renders every dependent view the
|
||||||
background: Color(nsColor: .windowBackgroundColor),
|
/// instant the appearance changes, instead of lagging until the next
|
||||||
surface: Color(nsColor: .controlBackgroundColor),
|
/// view rebuild.
|
||||||
surfaceElevated: Color(nsColor: .unemphasizedSelectedContentBackgroundColor),
|
static func palette(for scheme: ColorScheme) -> ThemePalette {
|
||||||
surfaceMuted: Color(nsColor: .underPageBackgroundColor),
|
scheme == .dark ? darkPalette : lightPalette
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let lightPalette = makePalette(dark: false)
|
||||||
|
private static let darkPalette = makePalette(dark: true)
|
||||||
|
|
||||||
|
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),
|
||||||
|
|
||||||
accent: Palette.accent,
|
accent: Palette.accent,
|
||||||
accentMuted: Palette.accent.opacity(0.16),
|
accentMuted: Palette.accent.opacity(0.16),
|
||||||
accentGlow: Palette.accent.opacity(0.35),
|
accentGlow: Palette.accent.opacity(0.35),
|
||||||
|
|
||||||
danger: Color(nsColor: .systemRed),
|
danger: resolved(.systemRed, dark: dark),
|
||||||
success: Palette.accent,
|
success: Palette.accent,
|
||||||
warning: Color(nsColor: .systemOrange),
|
warning: resolved(.systemOrange, dark: dark),
|
||||||
|
|
||||||
textPrimary: Color(nsColor: .labelColor),
|
textPrimary: resolved(.labelColor, dark: dark),
|
||||||
textSecondary: Color(nsColor: .secondaryLabelColor),
|
textSecondary: resolved(.secondaryLabelColor, dark: dark),
|
||||||
textTertiary: Color(nsColor: .tertiaryLabelColor),
|
textTertiary: resolved(.tertiaryLabelColor, dark: dark),
|
||||||
textOnAccent: Color.white,
|
textOnAccent: Color.white,
|
||||||
|
|
||||||
divider: Color(nsColor: .separatorColor),
|
divider: resolved(.separatorColor, dark: dark),
|
||||||
dividerStrong: Color(nsColor: .separatorColor),
|
dividerStrong: resolved(.separatorColor, dark: dark),
|
||||||
|
|
||||||
recordRed: Color(nsColor: .systemRed)
|
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 (systemGray6→4 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 {
|
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 {
|
func macSystemPalette() -> some View {
|
||||||
environment(\.themePalette, MacSystemPalette.palette)
|
modifier(MacSystemPaletteModifier())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct OSGKeyboardMacApp: App {
|
|||||||
// Mac-local appearance preference. Drives both the SwiftUI colour scheme
|
// Mac-local appearance preference. Drives both the SwiftUI colour scheme
|
||||||
// and — via `applyToApp` — the AppKit window chrome / popover.
|
// and — via `applyToApp` — the AppKit window chrome / popover.
|
||||||
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
|
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
|
||||||
|
@AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = false
|
||||||
|
|
||||||
private var appearance: MacAppearancePreference {
|
private var appearance: MacAppearancePreference {
|
||||||
MacAppearancePreference(rawValue: appearanceRaw) ?? .system
|
MacAppearancePreference(rawValue: appearanceRaw) ?? .system
|
||||||
@@ -23,7 +24,16 @@ struct OSGKeyboardMacApp: App {
|
|||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
Window("OSGKeyboard", id: "main") {
|
Window("OSGKeyboard", id: "main") {
|
||||||
|
Group {
|
||||||
|
if hasCompletedMacOnboarding {
|
||||||
MacRootView(viewModel: viewModel)
|
MacRootView(viewModel: viewModel)
|
||||||
|
} else {
|
||||||
|
MacOnboardingView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
hasCompletedOnboarding: $hasCompletedMacOnboarding
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
.macSystemPalette()
|
.macSystemPalette()
|
||||||
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
||||||
.preferredColorScheme(appearance.colorScheme)
|
.preferredColorScheme(appearance.colorScheme)
|
||||||
@@ -177,12 +187,41 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
private struct MacMenuBarPopover: View {
|
private struct MacMenuBarPopover: View {
|
||||||
@ObservedObject private var viewModel = MacDictationViewModel.shared
|
@ObservedObject private var viewModel = MacDictationViewModel.shared
|
||||||
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
|
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
|
||||||
|
@AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if hasCompletedMacOnboarding {
|
||||||
MacContentView(viewModel: viewModel)
|
MacContentView(viewModel: viewModel)
|
||||||
|
} else {
|
||||||
|
onboardingPrompt
|
||||||
|
}
|
||||||
|
}
|
||||||
.frame(width: 340)
|
.frame(width: 340)
|
||||||
.macSystemPalette()
|
.macSystemPalette()
|
||||||
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
||||||
.preferredColorScheme(MacAppearancePreference(rawValue: appearanceRaw)?.colorScheme ?? nil)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,4 +79,13 @@ public struct LocalASRCapabilities: Sendable, Equatable {
|
|||||||
supportsStreaming: false,
|
supportsStreaming: false,
|
||||||
hotwordReloadCost: .none
|
hotwordReloadCost: .none
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/// FunASR Paraformer (Sherpa offline) — no project hotword API.
|
||||||
|
public static let sherpaParaformer = LocalASRCapabilities(
|
||||||
|
hotwordMode: .none,
|
||||||
|
maxHotwordCount: 0,
|
||||||
|
maxPromptCharacters: 0,
|
||||||
|
supportsStreaming: false,
|
||||||
|
hotwordReloadCost: .none
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,19 +9,41 @@ public enum LocalASRModelBackend: String, Codable, Sendable, Equatable {
|
|||||||
case mlx
|
case mlx
|
||||||
case sherpaQwen3
|
case sherpaQwen3
|
||||||
case sherpaSenseVoice
|
case sherpaSenseVoice
|
||||||
|
case sherpaParaformer
|
||||||
case appleSpeech
|
case appleSpeech
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum LocalASRInstallKind: String, Codable, Sendable, Equatable {
|
public enum LocalASRInstallKind: String, Codable, Sendable, Equatable {
|
||||||
case manual
|
case manual
|
||||||
case archive
|
case archive
|
||||||
|
/// Multi-file install from a remote repository (ModelScope / HuggingFace file API).
|
||||||
|
case repository
|
||||||
case runtime
|
case runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct LocalASRDownloadFile: Codable, Sendable, Equatable {
|
||||||
|
public let remotePath: String
|
||||||
|
public let localPath: String
|
||||||
|
public let sizeBytes: Int?
|
||||||
|
}
|
||||||
|
|
||||||
public struct LocalASRDownloadSource: Codable, Sendable, Equatable {
|
public struct LocalASRDownloadSource: Codable, Sendable, Equatable {
|
||||||
public let type: String
|
public let type: String
|
||||||
public let priority: Int
|
public let priority: Int
|
||||||
|
/// Full URL for a single archive download.
|
||||||
public let url: String
|
public let url: String
|
||||||
|
/// Base URL template for repository installs; must contain `{path}`.
|
||||||
|
public let baseURL: String?
|
||||||
|
public let files: [LocalASRDownloadFile]?
|
||||||
|
|
||||||
|
public var isRepository: Bool {
|
||||||
|
guard let files, !files.isEmpty else { return false }
|
||||||
|
return baseURL?.contains("{path}") == true
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isArchive: Bool {
|
||||||
|
!url.isEmpty && !isRepository
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct LocalASRModelLayout: Codable, Sendable, Equatable {
|
public struct LocalASRModelLayout: Codable, Sendable, Equatable {
|
||||||
@@ -30,6 +52,7 @@ public struct LocalASRModelLayout: Codable, Sendable, Equatable {
|
|||||||
public var decoder: String?
|
public var decoder: String?
|
||||||
public var tokenizer: String?
|
public var tokenizer: String?
|
||||||
public var senseVoiceModel: String?
|
public var senseVoiceModel: String?
|
||||||
|
public var paraformerModel: String?
|
||||||
public var tokens: String?
|
public var tokens: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +114,8 @@ public enum LocalASRModelCatalog {
|
|||||||
return .sherpaQwen3
|
return .sherpaQwen3
|
||||||
case .sherpaSenseVoice:
|
case .sherpaSenseVoice:
|
||||||
return .sherpaSenseVoice
|
return .sherpaSenseVoice
|
||||||
|
case .sherpaParaformer:
|
||||||
|
return .sherpaParaformer
|
||||||
case .appleSpeech:
|
case .appleSpeech:
|
||||||
return .appleSpeech
|
return .appleSpeech
|
||||||
}
|
}
|
||||||
@@ -117,6 +142,42 @@ public enum LocalASRModelCatalog {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Region-aware ordering for local ASR model download mirrors.
|
||||||
|
public enum LocalASRDownloadSourceSorter {
|
||||||
|
|
||||||
|
/// `true` when the system region is mainland China (`CN`).
|
||||||
|
public static func isChinaMainland(region: Locale.Region? = Locale.current.region) -> Bool {
|
||||||
|
region?.identifier == "CN"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower rank = tried earlier. CN: ModelScope → HuggingFace → GitHub; elsewhere: HF → GitHub → ModelScope.
|
||||||
|
public static func typeRank(_ type: String, chinaFirst: Bool) -> Int {
|
||||||
|
switch type.lowercased() {
|
||||||
|
case "modelscope":
|
||||||
|
return chinaFirst ? 0 : 2
|
||||||
|
case "huggingface":
|
||||||
|
return chinaFirst ? 1 : 0
|
||||||
|
case "github":
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func sorted(
|
||||||
|
_ sources: [LocalASRDownloadSource],
|
||||||
|
region: Locale.Region? = Locale.current.region
|
||||||
|
) -> [LocalASRDownloadSource] {
|
||||||
|
let chinaFirst = isChinaMainland(region: region)
|
||||||
|
return sources.sorted { lhs, rhs in
|
||||||
|
let leftRank = typeRank(lhs.type, chinaFirst: chinaFirst)
|
||||||
|
let rightRank = typeRank(rhs.type, chinaFirst: chinaFirst)
|
||||||
|
if leftRank != rightRank { return leftRank < rightRank }
|
||||||
|
return lhs.priority < rhs.priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public enum LocalASRModelCatalogError: Error, LocalizedError {
|
public enum LocalASRModelCatalogError: Error, LocalizedError {
|
||||||
case missingBundledCatalog
|
case missingBundledCatalog
|
||||||
case modelNotFound(String)
|
case modelNotFound(String)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"defaultModelId": "qwen3-mlx-1.7b",
|
"defaultModelId": "sherpa-qwen3-0.6b-int8",
|
||||||
"runtimes": [
|
"runtimes": [
|
||||||
{
|
{
|
||||||
"id": "sherpa-onnx-1.13.4-macos-arm64",
|
"id": "sherpa-onnx-1.13.4-macos-arm64",
|
||||||
@@ -36,21 +36,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"models": [
|
"models": [
|
||||||
{
|
|
||||||
"id": "qwen3-mlx-1.7b",
|
|
||||||
"displayName": "Qwen3-ASR 1.7B (MLX)",
|
|
||||||
"backend": "mlx",
|
|
||||||
"sizeBytes": 1400000000,
|
|
||||||
"recommendedLocales": ["zh-CN", "en-US"],
|
|
||||||
"supportsHotwords": true,
|
|
||||||
"hotwordMode": "promptOnly",
|
|
||||||
"installKind": "manual",
|
|
||||||
"installRelativePath": "models/qwen3-asr-1.7b-mlx",
|
|
||||||
"requiredRelativeFiles": ["config.json", "model.safetensors", "vocab.json", "merges.txt"]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "sherpa-qwen3-0.6b-int8",
|
"id": "sherpa-qwen3-0.6b-int8",
|
||||||
"displayName": "Qwen3-ASR 0.6B (Sherpa · hotwords)",
|
"displayName": "Qwen3-ASR 0.6B",
|
||||||
"backend": "sherpaQwen3",
|
"backend": "sherpaQwen3",
|
||||||
"runtimePlatform": "macos",
|
"runtimePlatform": "macos",
|
||||||
"sizeBytes": 650000000,
|
"sizeBytes": 650000000,
|
||||||
@@ -74,9 +62,84 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "sherpa-qwen3-1.7b-int8",
|
||||||
|
"displayName": "Qwen3-ASR 1.7B",
|
||||||
|
"backend": "sherpaQwen3",
|
||||||
|
"runtimePlatform": "macos",
|
||||||
|
"sizeBytes": 1900000000,
|
||||||
|
"recommendedLocales": ["zh-CN", "en-US"],
|
||||||
|
"supportsHotwords": true,
|
||||||
|
"hotwordMode": "recognizerScoped",
|
||||||
|
"installKind": "repository",
|
||||||
|
"installRelativePath": "models/sherpa-qwen3-1.7b-int8",
|
||||||
|
"archiveBaseName": "sherpa-onnx-qwen3-asr-1.7B-int8",
|
||||||
|
"layout": {
|
||||||
|
"convFrontend": "conv_frontend.onnx",
|
||||||
|
"encoder": "encoder.int8.onnx",
|
||||||
|
"decoder": "decoder.int8.onnx",
|
||||||
|
"tokenizer": "tokenizer"
|
||||||
|
},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "modelscope",
|
||||||
|
"priority": 1,
|
||||||
|
"url": "",
|
||||||
|
"baseURL": "https://www.modelscope.cn/models/zengshuishui/Qwen3-ASR-onnx/resolve/master/{path}",
|
||||||
|
"files": [
|
||||||
|
{ "remotePath": "model_1.7B/conv_frontend.onnx", "localPath": "conv_frontend.onnx", "sizeBytes": 12000000 },
|
||||||
|
{ "remotePath": "model_1.7B/encoder.int8.onnx", "localPath": "encoder.int8.onnx", "sizeBytes": 900000000 },
|
||||||
|
{ "remotePath": "model_1.7B/decoder.int8.onnx", "localPath": "decoder.int8.onnx", "sizeBytes": 700000000 },
|
||||||
|
{ "remotePath": "model_1.7B/tokenizer/merges.txt", "localPath": "tokenizer/merges.txt", "sizeBytes": 500000 },
|
||||||
|
{ "remotePath": "model_1.7B/tokenizer/vocab.json", "localPath": "tokenizer/vocab.json", "sizeBytes": 3000000 },
|
||||||
|
{ "remotePath": "model_1.7B/tokenizer/tokenizer.json", "localPath": "tokenizer/tokenizer.json", "sizeBytes": 7000000 },
|
||||||
|
{ "remotePath": "model_1.7B/tokenizer/tokenizer_config.json", "localPath": "tokenizer/tokenizer_config.json", "sizeBytes": 10000 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "huggingface",
|
||||||
|
"priority": 1,
|
||||||
|
"url": "",
|
||||||
|
"baseURL": "https://huggingface.co/zengshuishui/Qwen3-ASR-onnx/resolve/main/{path}",
|
||||||
|
"files": [
|
||||||
|
{ "remotePath": "model_1.7B/conv_frontend.onnx", "localPath": "conv_frontend.onnx", "sizeBytes": 12000000 },
|
||||||
|
{ "remotePath": "model_1.7B/encoder.int8.onnx", "localPath": "encoder.int8.onnx", "sizeBytes": 900000000 },
|
||||||
|
{ "remotePath": "model_1.7B/decoder.int8.onnx", "localPath": "decoder.int8.onnx", "sizeBytes": 700000000 },
|
||||||
|
{ "remotePath": "model_1.7B/tokenizer/merges.txt", "localPath": "tokenizer/merges.txt", "sizeBytes": 500000 },
|
||||||
|
{ "remotePath": "model_1.7B/tokenizer/vocab.json", "localPath": "tokenizer/vocab.json", "sizeBytes": 3000000 },
|
||||||
|
{ "remotePath": "model_1.7B/tokenizer/tokenizer.json", "localPath": "tokenizer/tokenizer.json", "sizeBytes": 7000000 },
|
||||||
|
{ "remotePath": "model_1.7B/tokenizer/tokenizer_config.json", "localPath": "tokenizer/tokenizer_config.json", "sizeBytes": 10000 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sherpa-paraformer-zh-int8",
|
||||||
|
"displayName": "Paraformer Large",
|
||||||
|
"backend": "sherpaParaformer",
|
||||||
|
"runtimePlatform": "macos",
|
||||||
|
"sizeBytes": 220000000,
|
||||||
|
"recommendedLocales": ["zh-CN", "en-US"],
|
||||||
|
"supportsHotwords": false,
|
||||||
|
"hotwordMode": "none",
|
||||||
|
"installKind": "archive",
|
||||||
|
"installRelativePath": "models/sherpa-paraformer-zh-int8",
|
||||||
|
"archiveBaseName": "sherpa-onnx-paraformer-zh-int8-2025-10-07",
|
||||||
|
"layout": {
|
||||||
|
"paraformerModel": "model.int8.onnx",
|
||||||
|
"tokens": "tokens.txt"
|
||||||
|
},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"priority": 1,
|
||||||
|
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-paraformer-zh-int8-2025-10-07.tar.bz2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "sherpa-sensevoice-small-int8",
|
"id": "sherpa-sensevoice-small-int8",
|
||||||
"displayName": "SenseVoice Small (Sherpa)",
|
"displayName": "SenseVoice Small",
|
||||||
"backend": "sherpaSenseVoice",
|
"backend": "sherpaSenseVoice",
|
||||||
"runtimePlatform": "macos",
|
"runtimePlatform": "macos",
|
||||||
"sizeBytes": 250000000,
|
"sizeBytes": 250000000,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public enum LocalASRModelInstallState {
|
|||||||
let base = URL(fileURLWithPath: manualMLXPath ?? "", isDirectory: true)
|
let base = URL(fileURLWithPath: manualMLXPath ?? "", isDirectory: true)
|
||||||
guard fileManager.fileExists(atPath: base.path) else { return false }
|
guard fileManager.fileExists(atPath: base.path) else { return false }
|
||||||
return required.allSatisfy { fileManager.fileExists(atPath: base.appendingPathComponent($0).path) }
|
return required.allSatisfy { fileManager.fileExists(atPath: base.appendingPathComponent($0).path) }
|
||||||
case .archive:
|
case .archive, .repository:
|
||||||
guard let relative = model.installRelativePath,
|
guard let relative = model.installRelativePath,
|
||||||
let layout = model.layout,
|
let layout = model.layout,
|
||||||
let baseName = model.archiveBaseName else { return false }
|
let baseName = model.archiveBaseName else { return false }
|
||||||
@@ -41,7 +41,7 @@ public enum LocalASRModelInstallState {
|
|||||||
_ model: LocalASRModelDefinition,
|
_ model: LocalASRModelDefinition,
|
||||||
fileManager: FileManager = .default
|
fileManager: FileManager = .default
|
||||||
) -> URL? {
|
) -> URL? {
|
||||||
guard model.installKind == .archive,
|
guard model.installKind == .archive || model.installKind == .repository,
|
||||||
let relative = model.installRelativePath,
|
let relative = model.installRelativePath,
|
||||||
let baseName = model.archiveBaseName else { return nil }
|
let baseName = model.archiveBaseName else { return nil }
|
||||||
return installDirectory(for: relative, fileManager: fileManager)
|
return installDirectory(for: relative, fileManager: fileManager)
|
||||||
@@ -98,6 +98,11 @@ public enum LocalASRModelInstallState {
|
|||||||
let tokens = layout.tokens else { return false }
|
let tokens = layout.tokens else { return false }
|
||||||
return fileManager.fileExists(atPath: root.appendingPathComponent(onnx).path)
|
return fileManager.fileExists(atPath: root.appendingPathComponent(onnx).path)
|
||||||
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path)
|
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path)
|
||||||
|
case .sherpaParaformer:
|
||||||
|
guard let paraformer = layout.paraformerModel,
|
||||||
|
let tokens = layout.tokens else { return false }
|
||||||
|
return fileManager.fileExists(atPath: root.appendingPathComponent(paraformer).path)
|
||||||
|
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,9 +184,7 @@ public actor LocalASRModelManager {
|
|||||||
_ model: LocalASRModelDefinition,
|
_ model: LocalASRModelDefinition,
|
||||||
catalog: LocalASRCatalogDocument
|
catalog: LocalASRCatalogDocument
|
||||||
) async throws {
|
) async throws {
|
||||||
guard model.installKind == .archive,
|
guard let relative = model.installRelativePath,
|
||||||
let relative = model.installRelativePath,
|
|
||||||
let baseName = model.archiveBaseName,
|
|
||||||
let sources = model.sources,
|
let sources = model.sources,
|
||||||
!sources.isEmpty else {
|
!sources.isEmpty else {
|
||||||
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
||||||
@@ -198,12 +196,19 @@ public actor LocalASRModelManager {
|
|||||||
message: model.displayName,
|
message: model.displayName,
|
||||||
activeItemId: model.id
|
activeItemId: model.id
|
||||||
)
|
)
|
||||||
if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice {
|
if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice || model.backend == .sherpaParaformer {
|
||||||
try await ensureRuntimeInstalled(catalog: catalog)
|
try await ensureRuntimeInstalled(catalog: catalog)
|
||||||
}
|
}
|
||||||
let sortedSources = sources.sorted { $0.priority < $1.priority }
|
|
||||||
|
let sortedSources = LocalASRDownloadSourceSorter.sorted(sources)
|
||||||
var lastError: Error?
|
var lastError: Error?
|
||||||
for source in sortedSources {
|
|
||||||
|
switch model.installKind {
|
||||||
|
case .archive:
|
||||||
|
guard let baseName = model.archiveBaseName else {
|
||||||
|
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
||||||
|
}
|
||||||
|
for source in sortedSources where source.isArchive {
|
||||||
do {
|
do {
|
||||||
try await installArchive(
|
try await installArchive(
|
||||||
from: source.url,
|
from: source.url,
|
||||||
@@ -213,12 +218,7 @@ public actor LocalASRModelManager {
|
|||||||
itemId: model.id,
|
itemId: model.id,
|
||||||
displayName: model.displayName
|
displayName: model.displayName
|
||||||
)
|
)
|
||||||
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
|
try markModelInstalled(model, catalog: catalog)
|
||||||
if !manifest.installedModelIDs.contains(model.id) {
|
|
||||||
manifest.installedModelIDs.append(model.id)
|
|
||||||
}
|
|
||||||
manifest.updatedAt = Date()
|
|
||||||
try saveManifest(manifest)
|
|
||||||
progress = LocalASRModelInstallProgress(
|
progress = LocalASRModelInstallProgress(
|
||||||
phase: .completed,
|
phase: .completed,
|
||||||
fraction: 1,
|
fraction: 1,
|
||||||
@@ -230,6 +230,36 @@ public actor LocalASRModelManager {
|
|||||||
lastError = error
|
lastError = error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case .repository:
|
||||||
|
guard let baseName = model.archiveBaseName else {
|
||||||
|
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
||||||
|
}
|
||||||
|
for source in sortedSources where source.isRepository {
|
||||||
|
do {
|
||||||
|
try await installRepository(
|
||||||
|
source: source,
|
||||||
|
installRelativePath: relative,
|
||||||
|
archiveBaseName: baseName,
|
||||||
|
layoutModel: model,
|
||||||
|
itemId: model.id,
|
||||||
|
displayName: model.displayName
|
||||||
|
)
|
||||||
|
try markModelInstalled(model, catalog: catalog)
|
||||||
|
progress = LocalASRModelInstallProgress(
|
||||||
|
phase: .completed,
|
||||||
|
fraction: 1,
|
||||||
|
message: model.displayName,
|
||||||
|
activeItemId: model.id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
||||||
|
}
|
||||||
|
|
||||||
progress = LocalASRModelInstallProgress(
|
progress = LocalASRModelInstallProgress(
|
||||||
phase: .failed,
|
phase: .failed,
|
||||||
fraction: 0,
|
fraction: 0,
|
||||||
@@ -238,11 +268,24 @@ public actor LocalASRModelManager {
|
|||||||
throw lastError ?? LocalASRModelManagerError.downloadFailed("All mirrors failed")
|
throw lastError ?? LocalASRModelManagerError.downloadFailed("All mirrors failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func markModelInstalled(
|
||||||
|
_ model: LocalASRModelDefinition,
|
||||||
|
catalog: LocalASRCatalogDocument
|
||||||
|
) throws {
|
||||||
|
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
|
||||||
|
if !manifest.installedModelIDs.contains(model.id) {
|
||||||
|
manifest.installedModelIDs.append(model.id)
|
||||||
|
}
|
||||||
|
manifest.updatedAt = Date()
|
||||||
|
try saveManifest(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
public func installRuntime(
|
public func installRuntime(
|
||||||
_ runtime: LocalASRRuntimeDefinition,
|
_ runtime: LocalASRRuntimeDefinition,
|
||||||
catalog: LocalASRCatalogDocument
|
catalog: LocalASRCatalogDocument
|
||||||
) async throws {
|
) async throws {
|
||||||
guard let source = runtime.sources.sorted(by: { $0.priority < $1.priority }).first else {
|
let sortedSources = LocalASRDownloadSourceSorter.sorted(runtime.sources)
|
||||||
|
guard !sortedSources.isEmpty else {
|
||||||
throw LocalASRModelManagerError.downloadFailed("No runtime source configured.")
|
throw LocalASRModelManagerError.downloadFailed("No runtime source configured.")
|
||||||
}
|
}
|
||||||
progress = LocalASRModelInstallProgress(
|
progress = LocalASRModelInstallProgress(
|
||||||
@@ -251,6 +294,9 @@ public actor LocalASRModelManager {
|
|||||||
message: runtime.displayName,
|
message: runtime.displayName,
|
||||||
activeItemId: runtime.id
|
activeItemId: runtime.id
|
||||||
)
|
)
|
||||||
|
var lastError: Error?
|
||||||
|
for source in sortedSources where source.isArchive {
|
||||||
|
do {
|
||||||
try await installArchive(
|
try await installArchive(
|
||||||
from: source.url,
|
from: source.url,
|
||||||
installRelativePath: runtime.installRelativePath,
|
installRelativePath: runtime.installRelativePath,
|
||||||
@@ -270,6 +316,12 @@ public actor LocalASRModelManager {
|
|||||||
manifest.updatedAt = Date()
|
manifest.updatedAt = Date()
|
||||||
try saveManifest(manifest)
|
try saveManifest(manifest)
|
||||||
progress = LocalASRModelInstallProgress(phase: .completed, fraction: 1, message: runtime.displayName)
|
progress = LocalASRModelInstallProgress(phase: .completed, fraction: 1, message: runtime.displayName)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?? LocalASRModelManagerError.downloadFailed("All runtime mirrors failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func modelRootURL(_ model: LocalASRModelDefinition) -> URL? {
|
public func modelRootURL(_ model: LocalASRModelDefinition) -> URL? {
|
||||||
@@ -285,7 +337,8 @@ public actor LocalASRModelManager {
|
|||||||
_ model: LocalASRModelDefinition,
|
_ model: LocalASRModelDefinition,
|
||||||
catalog: LocalASRCatalogDocument
|
catalog: LocalASRCatalogDocument
|
||||||
) throws {
|
) throws {
|
||||||
guard model.installKind == .archive, let relative = model.installRelativePath else { return }
|
guard model.installKind == .archive || model.installKind == .repository,
|
||||||
|
let relative = model.installRelativePath else { return }
|
||||||
let dir = installDirectory(for: relative)
|
let dir = installDirectory(for: relative)
|
||||||
if fileManager.fileExists(atPath: dir.path) {
|
if fileManager.fileExists(atPath: dir.path) {
|
||||||
try fileManager.removeItem(at: dir)
|
try fileManager.removeItem(at: dir)
|
||||||
@@ -445,6 +498,115 @@ public actor LocalASRModelManager {
|
|||||||
try? fileManager.removeItem(at: archiveURL)
|
try? fileManager.removeItem(at: archiveURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func installRepository(
|
||||||
|
source: LocalASRDownloadSource,
|
||||||
|
installRelativePath: String,
|
||||||
|
archiveBaseName: String,
|
||||||
|
layoutModel: LocalASRModelDefinition,
|
||||||
|
itemId: String,
|
||||||
|
displayName: String
|
||||||
|
) async throws {
|
||||||
|
guard let baseURL = source.baseURL,
|
||||||
|
let files = source.files,
|
||||||
|
!files.isEmpty else {
|
||||||
|
throw LocalASRModelManagerError.downloadFailed("Invalid repository source.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let destinationRoot = installDirectory(for: installRelativePath)
|
||||||
|
.appendingPathComponent(archiveBaseName, isDirectory: true)
|
||||||
|
let stagingRoot = rootDirectory().appendingPathComponent("staging/\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
|
||||||
|
defer { try? fileManager.removeItem(at: stagingRoot) }
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: destinationRoot.path) {
|
||||||
|
try fileManager.removeItem(at: destinationRoot)
|
||||||
|
}
|
||||||
|
try fileManager.createDirectory(at: destinationRoot, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let totalBytes = files.reduce(Int64(0)) { partial, file in
|
||||||
|
partial + Int64(file.sizeBytes ?? 0)
|
||||||
|
}
|
||||||
|
var completedBytes: Int64 = 0
|
||||||
|
|
||||||
|
for (index, file) in files.enumerated() {
|
||||||
|
let remoteURLString = baseURL.replacingOccurrences(of: "{path}", with: file.remotePath)
|
||||||
|
guard let remoteURL = URL(string: remoteURLString) else {
|
||||||
|
throw LocalASRModelManagerError.downloadFailed("Invalid URL for \(file.remotePath)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let localURL = destinationRoot.appendingPathComponent(file.localPath)
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: localURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snapshot the running total so the progress closure captures an
|
||||||
|
// immutable value (avoids concurrent access to `completedBytes`).
|
||||||
|
let priorBytes = completedBytes
|
||||||
|
progress = LocalASRModelInstallProgress(
|
||||||
|
phase: .downloading,
|
||||||
|
fraction: 0.10 + (Double(index) / Double(files.count)) * 0.45,
|
||||||
|
message: displayName,
|
||||||
|
bytesReceived: priorBytes,
|
||||||
|
bytesTotal: totalBytes > 0 ? totalBytes : nil,
|
||||||
|
activeItemId: itemId
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let controller = LocalASRModelDownloadClient.makeController(destinationURL: localURL) { update in
|
||||||
|
Task {
|
||||||
|
let aggregateReceived = priorBytes + update.bytesReceived
|
||||||
|
let aggregateTotal = totalBytes > 0 ? totalBytes : update.bytesTotal
|
||||||
|
await LocalASRModelManager.shared.updateDownloadProgress(
|
||||||
|
itemId: itemId,
|
||||||
|
displayName: displayName,
|
||||||
|
update: LocalASRDownloadProgressUpdate(
|
||||||
|
bytesReceived: aggregateReceived,
|
||||||
|
bytesTotal: max(aggregateTotal, 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeDownloadController = controller
|
||||||
|
try await controller.download(from: remoteURL)
|
||||||
|
activeDownloadController = nil
|
||||||
|
pausedResumeData = nil
|
||||||
|
} catch {
|
||||||
|
activeDownloadController = nil
|
||||||
|
pausedResumeData = nil
|
||||||
|
throw LocalASRModelManagerError.downloadFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let size = file.sizeBytes {
|
||||||
|
completedBytes += Int64(size)
|
||||||
|
} else if let attrs = try? fileManager.attributesOfItem(atPath: localURL.path),
|
||||||
|
let size = attrs[.size] as? Int64 {
|
||||||
|
completedBytes += size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progress = LocalASRModelInstallProgress(
|
||||||
|
phase: .validating,
|
||||||
|
fraction: 0.82,
|
||||||
|
message: displayName,
|
||||||
|
activeItemId: itemId
|
||||||
|
)
|
||||||
|
guard LocalASRModelInstallState.isInstalled(
|
||||||
|
layoutModel,
|
||||||
|
manualMLXPath: nil,
|
||||||
|
fileManager: fileManager
|
||||||
|
) else {
|
||||||
|
throw LocalASRModelManagerError.validationFailed("Required model files missing after download.")
|
||||||
|
}
|
||||||
|
|
||||||
|
progress = LocalASRModelInstallProgress(
|
||||||
|
phase: .finalizing,
|
||||||
|
fraction: 0.95,
|
||||||
|
message: displayName,
|
||||||
|
activeItemId: itemId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
public func ensureRuntimeInstalled(catalog: LocalASRCatalogDocument) async throws {
|
public func ensureRuntimeInstalled(catalog: LocalASRCatalogDocument) async throws {
|
||||||
guard let runtime = LocalASRModelCatalog.runtime(
|
guard let runtime = LocalASRModelCatalog.runtime(
|
||||||
for: LocalASRModelCatalog.currentRuntimePlatform(),
|
for: LocalASRModelCatalog.currentRuntimePlatform(),
|
||||||
|
|||||||
@@ -162,6 +162,10 @@
|
|||||||
"mac.settings.localSpeechFallback" = "Local Recognition (Apple Speech)";
|
"mac.settings.localSpeechFallback" = "Local Recognition (Apple Speech)";
|
||||||
"mac.settings.localSpeechFallbackDesc" = "On-device Apple Speech when the Qwen3 model is not installed.";
|
"mac.settings.localSpeechFallbackDesc" = "On-device Apple Speech when the Qwen3 model is not installed.";
|
||||||
"mac.settings.about" = "About";
|
"mac.settings.about" = "About";
|
||||||
|
"mac.settings.privacyPolicy" = "Privacy Policy";
|
||||||
|
"mac.settings.thirdPartyLicenses" = "Third-Party Licenses";
|
||||||
|
"mac.settings.licenses.footer" = "Open-source components used by OSGKeyboard. License texts are reproduced from upstream repositories; model weights are downloaded at runtime and cached on device. OSGKeyboard itself is source-available — commercial licensing: rocky.hk@gmail.com.";
|
||||||
|
"mac.settings.restartOnboarding" = "Restart First-Run Setup";
|
||||||
"mac.settings.general" = "General";
|
"mac.settings.general" = "General";
|
||||||
"mac.settings.input" = "Input & Shortcuts";
|
"mac.settings.input" = "Input & Shortcuts";
|
||||||
"mac.settings.recognitionLanguage" = "Recognition Language";
|
"mac.settings.recognitionLanguage" = "Recognition Language";
|
||||||
@@ -185,14 +189,48 @@
|
|||||||
"mac.appearance.system" = "System";
|
"mac.appearance.system" = "System";
|
||||||
"mac.appearance.light" = "Light";
|
"mac.appearance.light" = "Light";
|
||||||
"mac.appearance.dark" = "Dark";
|
"mac.appearance.dark" = "Dark";
|
||||||
|
"mac.onboarding.progress" = "Step %lld of %lld";
|
||||||
|
"mac.onboarding.back" = "Back";
|
||||||
|
"mac.onboarding.next" = "Next";
|
||||||
|
"mac.onboarding.finish" = "Finish";
|
||||||
|
"mac.onboarding.skipForNow" = "Skip for now";
|
||||||
|
"mac.onboarding.welcome.title" = "Welcome to OSGKeyboard";
|
||||||
|
"mac.onboarding.welcome.subtitle" = "Set up dictation in a minute. Everything here can be changed later in Settings.";
|
||||||
|
"mac.onboarding.welcome.privacy" = "Your voice stays local unless you choose a cloud provider.";
|
||||||
|
"mac.onboarding.welcome.hotkey" = "Hold Option to dictate from any app.";
|
||||||
|
"mac.onboarding.welcome.local" = "Local Sherpa recognition can be prepared now or later.";
|
||||||
|
"mac.onboarding.microphone.title" = "Allow microphone access";
|
||||||
|
"mac.onboarding.microphone.subtitle" = "OSGKeyboard needs the microphone to capture your dictation audio.";
|
||||||
|
"mac.onboarding.microphone.allow" = "Allow Microphone";
|
||||||
|
"mac.onboarding.microphone.granted" = "Microphone access is granted.";
|
||||||
|
"mac.onboarding.microphone.needed" = "Microphone access is needed for recording.";
|
||||||
|
"mac.onboarding.accessibility.title" = "Enable Accessibility";
|
||||||
|
"mac.onboarding.accessibility.subtitle" = "Accessibility lets OSGKeyboard listen for the global shortcut and paste text into the front app. You can skip it and grant it later.";
|
||||||
|
"mac.onboarding.accessibility.open" = "Open System Settings";
|
||||||
|
"mac.onboarding.accessibility.granted" = "Accessibility permission is granted.";
|
||||||
|
"mac.onboarding.accessibility.needed" = "Accessibility is not enabled yet.";
|
||||||
|
"mac.onboarding.engine.title" = "Choose a recognition path";
|
||||||
|
"mac.onboarding.engine.subtitle" = "Start locally for privacy, or use your own cloud API for provider-based recognition and polish.";
|
||||||
|
"mac.onboarding.engine.localDesc" = "Private on-device recognition with Sherpa models. Falls back to Apple Speech until a model is installed.";
|
||||||
|
"mac.onboarding.engine.cloudDesc" = "Use your API key for cloud speech recognition and AI polishing.";
|
||||||
|
"mac.onboarding.cloud.title" = "Configure API access";
|
||||||
|
"mac.onboarding.cloud.subtitle" = "Pick a provider and add your key. This step is optional, so you can finish now and fill it later in Settings.";
|
||||||
|
"mac.onboarding.cloud.skipHint" = "You can leave these blank and finish setup; cloud mode will ask for a key before use.";
|
||||||
|
"mac.onboarding.model.title" = "Prepare local recognition";
|
||||||
|
"mac.onboarding.model.subtitle" = "Download the basic Sherpa runtime and default model now, or skip and continue with Apple Speech fallback.";
|
||||||
|
"mac.onboarding.model.download" = "Download Default Model";
|
||||||
|
"mac.onboarding.model.done" = "Local model is ready.";
|
||||||
|
"mac.onboarding.model.skipHint" = "Skipping is safe: Settings keeps the same download controls for later.";
|
||||||
|
"mac.onboarding.popover.title" = "Finish setup";
|
||||||
|
"mac.onboarding.popover.subtitle" = "Open the main window to choose permissions, API settings, and local model download.";
|
||||||
"mac.error.noAudio" = "No audio captured";
|
"mac.error.noAudio" = "No audio captured";
|
||||||
"mac.error.noCloudASR" = "Selected provider has no cloud ASR";
|
"mac.error.noCloudASR" = "Selected provider has no cloud ASR";
|
||||||
"mac.error.emptyTranscript" = "No speech recognized";
|
"mac.error.emptyTranscript" = "No speech recognized";
|
||||||
"mac.error.qwen3ModelMissing" = "Qwen3-ASR model not installed";
|
"mac.error.qwen3ModelMissing" = "Qwen3-ASR model not installed";
|
||||||
"mac.error.qwen3LoadFailed" = "Failed to load Qwen3 model: %@";
|
"mac.error.qwen3LoadFailed" = "Failed to load Qwen3 model: %@";
|
||||||
"mac.error.qwen3InferenceFailed" = "Qwen3 transcription failed: %@";
|
"mac.error.qwen3InferenceFailed" = "Qwen3 transcription failed: %@";
|
||||||
"mac.localASR.models" = "Local ASR Models";
|
"mac.localASR.models" = "Local ASR Engine & Models";
|
||||||
"mac.localASR.modelsDesc" = "Sherpa models download directly. For MLX Qwen3, drop your converted weights into the folder opened by “Open folder”. All three share one storage directory.";
|
"mac.localASR.modelsDesc" = "Models download directly. China uses ModelScope first; elsewhere Hugging Face first, then GitHub. Qwen3 1.7B downloads as multiple files.";
|
||||||
"mac.localASR.download" = "Download";
|
"mac.localASR.download" = "Download";
|
||||||
"mac.localASR.selectFolder" = "Choose folder";
|
"mac.localASR.selectFolder" = "Choose folder";
|
||||||
"mac.localASR.openFolder" = "Open folder";
|
"mac.localASR.openFolder" = "Open folder";
|
||||||
@@ -202,6 +240,7 @@
|
|||||||
"mac.localASR.installDone" = "Install completed.";
|
"mac.localASR.installDone" = "Install completed.";
|
||||||
"mac.localASR.installed" = "Installed";
|
"mac.localASR.installed" = "Installed";
|
||||||
"mac.localASR.notInstalled" = "Not installed";
|
"mac.localASR.notInstalled" = "Not installed";
|
||||||
|
"mac.localASR.personalDictionaryTag" = "Personal dictionary";
|
||||||
"mac.localASR.hotwordsYes" = "Hotwords";
|
"mac.localASR.hotwordsYes" = "Hotwords";
|
||||||
"mac.localASR.hotwordsNo" = "No hotwords";
|
"mac.localASR.hotwordsNo" = "No hotwords";
|
||||||
"mac.localASR.catalogMissing" = "Local ASR catalog is missing from the app bundle.";
|
"mac.localASR.catalogMissing" = "Local ASR catalog is missing from the app bundle.";
|
||||||
@@ -219,8 +258,8 @@
|
|||||||
"mac.localASR.redownload" = "Re-download";
|
"mac.localASR.redownload" = "Re-download";
|
||||||
"mac.localASR.revealInFinder" = "Reveal in Finder";
|
"mac.localASR.revealInFinder" = "Reveal in Finder";
|
||||||
"mac.localASR.openStorage" = "Open model storage folder";
|
"mac.localASR.openStorage" = "Open model storage folder";
|
||||||
"mac.localASR.runtime" = "Sherpa Runtime";
|
"mac.localASR.runtime" = "Local ASR runtime";
|
||||||
"mac.localASR.runtimeDesc" = "Required for Sherpa Qwen3 and SenseVoice models. Installed automatically with those models.";
|
"mac.localASR.runtimeDesc" = "Installed automatically with the models above; required for Qwen3, SenseVoice, and similar models.";
|
||||||
"mac.localASR.phase.downloading" = "Downloading";
|
"mac.localASR.phase.downloading" = "Downloading";
|
||||||
"mac.localASR.phase.paused" = "Paused";
|
"mac.localASR.phase.paused" = "Paused";
|
||||||
"mac.localASR.phase.extracting" = "Extracting";
|
"mac.localASR.phase.extracting" = "Extracting";
|
||||||
|
|||||||
@@ -162,6 +162,10 @@
|
|||||||
"mac.settings.localSpeechFallback" = "本地识别(Apple Speech)";
|
"mac.settings.localSpeechFallback" = "本地识别(Apple Speech)";
|
||||||
"mac.settings.localSpeechFallbackDesc" = "未安装 Qwen3 模型时,使用 Apple 本地语音识别。";
|
"mac.settings.localSpeechFallbackDesc" = "未安装 Qwen3 模型时,使用 Apple 本地语音识别。";
|
||||||
"mac.settings.about" = "关于";
|
"mac.settings.about" = "关于";
|
||||||
|
"mac.settings.privacyPolicy" = "隐私政策";
|
||||||
|
"mac.settings.thirdPartyLicenses" = "第三方许可";
|
||||||
|
"mac.settings.licenses.footer" = "以下为 OSGKeyboard 使用的开源组件。许可正文摘自上游仓库;模型权重在运行时下载并缓存在本机。OSGKeyboard 本身采用源码可见许可,商业授权请联系 rocky.hk@gmail.com。";
|
||||||
|
"mac.settings.restartOnboarding" = "重新开始首次引导";
|
||||||
"mac.settings.general" = "通用";
|
"mac.settings.general" = "通用";
|
||||||
"mac.settings.input" = "输入与快捷键";
|
"mac.settings.input" = "输入与快捷键";
|
||||||
"mac.settings.recognitionLanguage" = "识别语言";
|
"mac.settings.recognitionLanguage" = "识别语言";
|
||||||
@@ -185,14 +189,48 @@
|
|||||||
"mac.appearance.system" = "跟随系统";
|
"mac.appearance.system" = "跟随系统";
|
||||||
"mac.appearance.light" = "浅色";
|
"mac.appearance.light" = "浅色";
|
||||||
"mac.appearance.dark" = "深色";
|
"mac.appearance.dark" = "深色";
|
||||||
|
"mac.onboarding.progress" = "第 %lld 步,共 %lld 步";
|
||||||
|
"mac.onboarding.back" = "上一步";
|
||||||
|
"mac.onboarding.next" = "下一步";
|
||||||
|
"mac.onboarding.finish" = "完成";
|
||||||
|
"mac.onboarding.skipForNow" = "暂时跳过";
|
||||||
|
"mac.onboarding.welcome.title" = "欢迎使用 OSGKeyboard";
|
||||||
|
"mac.onboarding.welcome.subtitle" = "用一分钟完成听写基础配置。这里的选项之后都可以在设置里修改。";
|
||||||
|
"mac.onboarding.welcome.privacy" = "除非你选择云端服务商,语音会优先留在本机处理。";
|
||||||
|
"mac.onboarding.welcome.hotkey" = "按住 Option 键即可在任意应用开始听写。";
|
||||||
|
"mac.onboarding.welcome.local" = "Sherpa 本地识别可以现在准备,也可以稍后下载。";
|
||||||
|
"mac.onboarding.microphone.title" = "允许麦克风访问";
|
||||||
|
"mac.onboarding.microphone.subtitle" = "OSGKeyboard 需要麦克风来录制你的听写音频。";
|
||||||
|
"mac.onboarding.microphone.allow" = "允许麦克风";
|
||||||
|
"mac.onboarding.microphone.granted" = "麦克风权限已授权。";
|
||||||
|
"mac.onboarding.microphone.needed" = "录音需要麦克风权限。";
|
||||||
|
"mac.onboarding.accessibility.title" = "开启辅助功能";
|
||||||
|
"mac.onboarding.accessibility.subtitle" = "辅助功能用于全局快捷键和向前台应用粘贴文本。你可以先跳过,之后再授权。";
|
||||||
|
"mac.onboarding.accessibility.open" = "打开系统设置";
|
||||||
|
"mac.onboarding.accessibility.granted" = "辅助功能权限已授权。";
|
||||||
|
"mac.onboarding.accessibility.needed" = "尚未开启辅助功能权限。";
|
||||||
|
"mac.onboarding.engine.title" = "选择识别方式";
|
||||||
|
"mac.onboarding.engine.subtitle" = "默认可用本地识别以保护隐私,也可以配置自己的云端 API。";
|
||||||
|
"mac.onboarding.engine.localDesc" = "使用 Sherpa 本地模型进行私密转写;模型未安装前会回退到 Apple 语音识别。";
|
||||||
|
"mac.onboarding.engine.cloudDesc" = "使用你的 API Key 进行云端语音识别和 AI 润色。";
|
||||||
|
"mac.onboarding.cloud.title" = "配置 API 访问";
|
||||||
|
"mac.onboarding.cloud.subtitle" = "选择服务商并填写密钥。此步骤可跳过,之后可在设置中补齐。";
|
||||||
|
"mac.onboarding.cloud.skipHint" = "可以先留空并完成引导;云端模式使用前会提示需要 API Key。";
|
||||||
|
"mac.onboarding.model.title" = "准备本地识别";
|
||||||
|
"mac.onboarding.model.subtitle" = "现在下载基础 Sherpa runtime 和默认模型,或先跳过并使用 Apple Speech 兜底。";
|
||||||
|
"mac.onboarding.model.download" = "下载默认模型";
|
||||||
|
"mac.onboarding.model.done" = "本地模型已准备好。";
|
||||||
|
"mac.onboarding.model.skipHint" = "跳过不会影响使用:设置页保留同样的下载入口。";
|
||||||
|
"mac.onboarding.popover.title" = "完成首次配置";
|
||||||
|
"mac.onboarding.popover.subtitle" = "打开主窗口选择权限、API 设置和本地模型下载。";
|
||||||
"mac.error.noAudio" = "没有捕获到音频";
|
"mac.error.noAudio" = "没有捕获到音频";
|
||||||
"mac.error.noCloudASR" = "当前服务商不支持云端语音识别";
|
"mac.error.noCloudASR" = "当前服务商不支持云端语音识别";
|
||||||
"mac.error.emptyTranscript" = "没有识别到语音";
|
"mac.error.emptyTranscript" = "没有识别到语音";
|
||||||
"mac.error.qwen3ModelMissing" = "未安装 Qwen3-ASR 模型";
|
"mac.error.qwen3ModelMissing" = "未安装 Qwen3-ASR 模型";
|
||||||
"mac.error.qwen3LoadFailed" = "Qwen3 模型加载失败:%@";
|
"mac.error.qwen3LoadFailed" = "Qwen3 模型加载失败:%@";
|
||||||
"mac.error.qwen3InferenceFailed" = "Qwen3 转写失败:%@";
|
"mac.error.qwen3InferenceFailed" = "Qwen3 转写失败:%@";
|
||||||
"mac.localASR.models" = "本地 ASR 模型";
|
"mac.localASR.models" = "本地 ASR 引擎与模型";
|
||||||
"mac.localASR.modelsDesc" = "Sherpa 模型可直接下载;MLX Qwen3 请将转换好的权重放入「打开目录」指向的文件夹。三个模型共用同一存储目录。";
|
"mac.localASR.modelsDesc" = "模型可直接下载。中国大陆优先 ModelScope,其他地区优先 Hugging Face,再回退 GitHub。Qwen3 1.7B 以多文件方式下载。";
|
||||||
"mac.localASR.download" = "下载";
|
"mac.localASR.download" = "下载";
|
||||||
"mac.localASR.selectFolder" = "选择目录";
|
"mac.localASR.selectFolder" = "选择目录";
|
||||||
"mac.localASR.openFolder" = "打开目录";
|
"mac.localASR.openFolder" = "打开目录";
|
||||||
@@ -202,6 +240,7 @@
|
|||||||
"mac.localASR.installDone" = "安装完成。";
|
"mac.localASR.installDone" = "安装完成。";
|
||||||
"mac.localASR.installed" = "已安装";
|
"mac.localASR.installed" = "已安装";
|
||||||
"mac.localASR.notInstalled" = "未安装";
|
"mac.localASR.notInstalled" = "未安装";
|
||||||
|
"mac.localASR.personalDictionaryTag" = "个性词库";
|
||||||
"mac.localASR.hotwordsYes" = "支持热词";
|
"mac.localASR.hotwordsYes" = "支持热词";
|
||||||
"mac.localASR.hotwordsNo" = "无热词";
|
"mac.localASR.hotwordsNo" = "无热词";
|
||||||
"mac.localASR.catalogMissing" = "应用包内缺少本地 ASR 模型目录。";
|
"mac.localASR.catalogMissing" = "应用包内缺少本地 ASR 模型目录。";
|
||||||
@@ -219,8 +258,8 @@
|
|||||||
"mac.localASR.redownload" = "重新下载";
|
"mac.localASR.redownload" = "重新下载";
|
||||||
"mac.localASR.revealInFinder" = "在 Finder 中显示";
|
"mac.localASR.revealInFinder" = "在 Finder 中显示";
|
||||||
"mac.localASR.openStorage" = "打开模型存储目录";
|
"mac.localASR.openStorage" = "打开模型存储目录";
|
||||||
"mac.localASR.runtime" = "Sherpa 运行时";
|
"mac.localASR.runtime" = "本地识别运行时";
|
||||||
"mac.localASR.runtimeDesc" = "Sherpa Qwen3 与 SenseVoice 模型需要此运行时;下载上述模型时会自动安装。";
|
"mac.localASR.runtimeDesc" = "下载上述模型时会自动安装;Qwen3 与 SenseVoice 等模型需要此组件。";
|
||||||
"mac.localASR.phase.downloading" = "下载中";
|
"mac.localASR.phase.downloading" = "下载中";
|
||||||
"mac.localASR.phase.paused" = "已暂停";
|
"mac.localASR.phase.paused" = "已暂停";
|
||||||
"mac.localASR.phase.extracting" = "解压中";
|
"mac.localASR.phase.extracting" = "解压中";
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// LocalASRDownloadSourceSorterTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
|
||||||
|
final class LocalASRDownloadSourceSorterTests: XCTestCase {
|
||||||
|
|
||||||
|
private func source(_ type: String, priority: Int = 1) -> LocalASRDownloadSource {
|
||||||
|
LocalASRDownloadSource(
|
||||||
|
type: type,
|
||||||
|
priority: priority,
|
||||||
|
url: "https://example.com/\(type).tar.bz2",
|
||||||
|
baseURL: nil,
|
||||||
|
files: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChinaMainlandPrefersModelScope() {
|
||||||
|
let sources = [
|
||||||
|
source("github"),
|
||||||
|
source("huggingface"),
|
||||||
|
source("modelscope"),
|
||||||
|
]
|
||||||
|
let sorted = LocalASRDownloadSourceSorter.sorted(sources, region: Locale.Region("CN"))
|
||||||
|
XCTAssertEqual(sorted.map(\.type), ["modelscope", "huggingface", "github"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGlobalPrefersHuggingFace() {
|
||||||
|
let sources = [
|
||||||
|
source("github"),
|
||||||
|
source("huggingface"),
|
||||||
|
source("modelscope"),
|
||||||
|
]
|
||||||
|
let sorted = LocalASRDownloadSourceSorter.sorted(sources, region: Locale.Region("US"))
|
||||||
|
XCTAssertEqual(sorted.map(\.type), ["huggingface", "github", "modelscope"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSameTypeUsesPriority() {
|
||||||
|
let sources = [
|
||||||
|
source("github", priority: 2),
|
||||||
|
source("github", priority: 1),
|
||||||
|
]
|
||||||
|
let sorted = LocalASRDownloadSourceSorter.sorted(sources, region: Locale.Region("US"))
|
||||||
|
XCTAssertEqual(sorted.map(\.priority), [1, 2])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,19 @@ final class LocalASRModelCatalogTests: XCTestCase {
|
|||||||
func testBundledCatalogLoads() throws {
|
func testBundledCatalogLoads() throws {
|
||||||
let catalog = try LocalASRModelCatalog.loadBundled()
|
let catalog = try LocalASRModelCatalog.loadBundled()
|
||||||
XCTAssertEqual(catalog.schemaVersion, 1)
|
XCTAssertEqual(catalog.schemaVersion, 1)
|
||||||
XCTAssertFalse(catalog.models.isEmpty)
|
XCTAssertEqual(catalog.defaultModelId, "sherpa-qwen3-0.6b-int8")
|
||||||
XCTAssertTrue(catalog.models.contains { $0.id == "qwen3-mlx-1.7b" })
|
XCTAssertFalse(catalog.models.contains { $0.id == "qwen3-mlx-1.7b" })
|
||||||
XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-qwen3-0.6b-int8" })
|
XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-qwen3-0.6b-int8" })
|
||||||
|
XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-qwen3-1.7b-int8" })
|
||||||
|
XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-paraformer-zh-int8" })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSherpaQwen317BUsesRepositoryInstall() throws {
|
||||||
|
let catalog = try LocalASRModelCatalog.loadBundled()
|
||||||
|
let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-qwen3-1.7b-int8", in: catalog))
|
||||||
|
XCTAssertEqual(model.installKind, .repository)
|
||||||
|
XCTAssertTrue(model.sources?.contains(where: { $0.type == "modelscope" && $0.isRepository }) == true)
|
||||||
|
XCTAssertTrue(model.sources?.contains(where: { $0.type == "huggingface" && $0.isRepository }) == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCapabilitiesForSherpaQwen3() throws {
|
func testCapabilitiesForSherpaQwen3() throws {
|
||||||
@@ -22,6 +32,14 @@ final class LocalASRModelCatalogTests: XCTestCase {
|
|||||||
XCTAssertTrue(model.supportsHotwords)
|
XCTAssertTrue(model.supportsHotwords)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCapabilitiesForParaformer() throws {
|
||||||
|
let catalog = try LocalASRModelCatalog.loadBundled()
|
||||||
|
let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-paraformer-zh-int8", in: catalog))
|
||||||
|
let caps = LocalASRModelCatalog.capabilities(for: model)
|
||||||
|
XCTAssertEqual(caps.hotwordMode, .none)
|
||||||
|
XCTAssertFalse(model.supportsHotwords)
|
||||||
|
}
|
||||||
|
|
||||||
func testManifestRoundTrip() throws {
|
func testManifestRoundTrip() throws {
|
||||||
let manifest = LocalASRInstalledManifest(
|
let manifest = LocalASRInstalledManifest(
|
||||||
selectedModelId: "sherpa-qwen3-0.6b-int8",
|
selectedModelId: "sherpa-qwen3-0.6b-int8",
|
||||||
@@ -54,11 +72,11 @@ final class LocalASRModelCatalogTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
LocalASRBiasDiagnosticsStore.save(
|
LocalASRBiasDiagnosticsStore.save(
|
||||||
payload: payload,
|
payload: payload,
|
||||||
modelId: "qwen3-mlx-1.7b",
|
modelId: "sherpa-qwen3-0.6b-int8",
|
||||||
backendLabel: "MLX"
|
backendLabel: "Sherpa Qwen3"
|
||||||
)
|
)
|
||||||
let snapshot = LocalASRBiasDiagnosticsStore.load()
|
let snapshot = LocalASRBiasDiagnosticsStore.load()
|
||||||
XCTAssertEqual(snapshot?.modelId, "qwen3-mlx-1.7b")
|
XCTAssertEqual(snapshot?.modelId, "sherpa-qwen3-0.6b-int8")
|
||||||
XCTAssertEqual(snapshot?.diagnostics.userTermCount, 2)
|
XCTAssertEqual(snapshot?.diagnostics.userTermCount, 2)
|
||||||
XCTAssertEqual(snapshot?.hotwordCount, 1)
|
XCTAssertEqual(snapshot?.hotwordCount, 1)
|
||||||
LocalASRBiasDiagnosticsStore.clear()
|
LocalASRBiasDiagnosticsStore.clear()
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ targets:
|
|||||||
excludes:
|
excludes:
|
||||||
- "AppIcon.appiconset"
|
- "AppIcon.appiconset"
|
||||||
- path: OSGKeyboardMac
|
- path: OSGKeyboardMac
|
||||||
|
- path: OSGKeyboard/Services/OpenSourceLicenseCatalog.swift
|
||||||
- path: OSGKeyboardShared
|
- path: OSGKeyboardShared
|
||||||
excludes:
|
excludes:
|
||||||
- "en.lproj"
|
- "en.lproj"
|
||||||
@@ -432,6 +433,8 @@ targets:
|
|||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
- path: OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv
|
- path: OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
|
- path: OSGKeyboard/Resources/PrivacyPolicy.html
|
||||||
|
buildPhase: resources
|
||||||
- path: OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
|
- path: OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
entitlements:
|
entitlements:
|
||||||
|
|||||||
Reference in New Issue
Block a user