feat(macos): local ASR model manager, menu-bar polish, and release 0.5.2

Adds a bundled local ASR model catalog for the macOS app with one-click
Sherpa Qwen3 / SenseVoice downloads (pause/resume, inline actions) and a
shared model storage directory used by MLX Qwen3. Fixes the light-mode
sidebar material and makes the menu-bar icon follow the system appearance
with a refreshed status mark. Renames the built product to OSGKeyboard.app.

Bumps version to 0.5.2 (build 19).
This commit is contained in:
Rocky
2026-07-09 08:55:37 +08:00
parent c2f07bd8d2
commit 200265fbd6
50 changed files with 4666 additions and 266 deletions
@@ -0,0 +1,492 @@
// LocalASRModelManager.swift
// OSGKeyboard · Shared
//
// Installs local ASR model archives and Sherpa runtimes under Application Support.
// Catalog is bundled; installed state is persisted in `installed-manifest.json`.
import Foundation
public struct LocalASRInstalledManifest: Codable, Sendable, Equatable {
public var schemaVersion: Int
public var selectedModelId: String
public var installedModelIDs: [String]
public var installedRuntimeIDs: [String]
public var updatedAt: Date
public init(
schemaVersion: Int = 1,
selectedModelId: String,
installedModelIDs: [String] = [],
installedRuntimeIDs: [String] = [],
updatedAt: Date = Date()
) {
self.schemaVersion = schemaVersion
self.selectedModelId = selectedModelId
self.installedModelIDs = installedModelIDs
self.installedRuntimeIDs = installedRuntimeIDs
self.updatedAt = updatedAt
}
}
public enum LocalASRModelInstallPhase: String, Sendable, Equatable {
case idle
case downloading
case paused
case extracting
case validating
case finalizing
case failed
case completed
}
public struct LocalASRModelInstallProgress: Sendable, Equatable {
public var phase: LocalASRModelInstallPhase
public var fraction: Double
public var message: String
public var bytesReceived: Int64?
public var bytesTotal: Int64?
public var activeItemId: String?
public init(
phase: LocalASRModelInstallPhase,
fraction: Double,
message: String,
bytesReceived: Int64? = nil,
bytesTotal: Int64? = nil,
activeItemId: String? = nil
) {
self.phase = phase
self.fraction = fraction
self.message = message
self.bytesReceived = bytesReceived
self.bytesTotal = bytesTotal
self.activeItemId = activeItemId
}
public static let idle = LocalASRModelInstallProgress(phase: .idle, fraction: 0, message: "")
}
public enum LocalASRModelManagerError: Error, LocalizedError {
case downloadFailed(String)
case extractFailed(String)
case validationFailed(String)
case runtimeMissing
case binaryMissing
public var errorDescription: String? {
switch self {
case .downloadFailed(let detail): return "Download failed: \(detail)"
case .extractFailed(let detail): return "Extract failed: \(detail)"
case .validationFailed(let detail): return "Validation failed: \(detail)"
case .runtimeMissing: return "Sherpa runtime is not installed."
case .binaryMissing: return "Sherpa binary not found in runtime bundle."
}
}
}
public actor LocalASRModelManager {
public static let shared = LocalASRModelManager()
private let fileManager = FileManager.default
private var progress = LocalASRModelInstallProgress.idle
#if os(macOS)
private var activeDownloadController: LocalASRModelDownloadController?
private var pausedResumeData: Data?
#endif
private init() {}
public func currentProgress() -> LocalASRModelInstallProgress {
progress
}
#if os(macOS)
public func pauseDownload() async throws {
guard progress.phase == .downloading, let controller = activeDownloadController else { return }
let resumeData = try await controller.pause()
pausedResumeData = resumeData
progress = LocalASRModelInstallProgress(
phase: .paused,
fraction: progress.fraction,
message: progress.message,
bytesReceived: progress.bytesReceived,
bytesTotal: progress.bytesTotal,
activeItemId: progress.activeItemId
)
}
public func resumeDownload() async throws {
guard progress.phase == .paused,
let resumeData = pausedResumeData,
let controller = activeDownloadController else {
throw LocalASRModelManagerError.downloadFailed("No paused download to resume.")
}
pausedResumeData = nil
progress = LocalASRModelInstallProgress(
phase: .downloading,
fraction: progress.fraction,
message: progress.message,
bytesReceived: progress.bytesReceived,
bytesTotal: progress.bytesTotal,
activeItemId: progress.activeItemId
)
controller.resumeFromPause(resumeData)
}
public func isDownloadPaused() -> Bool {
progress.phase == .paused
}
#endif
public func rootDirectory() -> URL {
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return appSupport.appendingPathComponent("OSGKeyboard/LocalASRModels", isDirectory: true)
}
public func manifestURL() -> URL {
LocalASRInstalledManifestIO.manifestURL(fileManager: fileManager)
}
public func loadManifest(defaultModelId: String) -> LocalASRInstalledManifest {
LocalASRInstalledManifestIO.load(defaultModelId: defaultModelId, fileManager: fileManager)
}
public func saveManifest(_ manifest: LocalASRInstalledManifest) throws {
try LocalASRInstalledManifestIO.save(manifest, fileManager: fileManager)
}
public func setSelectedModelId(_ modelId: String, catalog: LocalASRCatalogDocument) throws {
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
manifest.selectedModelId = modelId
manifest.updatedAt = Date()
try saveManifest(manifest)
}
public func installDirectory(for relativePath: String) -> URL {
rootDirectory().appendingPathComponent(relativePath, isDirectory: true)
}
public func isModelInstalled(_ model: LocalASRModelDefinition, manualMLXPath: String?) -> Bool {
LocalASRModelInstallState.isInstalled(model, manualMLXPath: manualMLXPath, fileManager: fileManager)
}
public func isRuntimeInstalled(_ runtime: LocalASRRuntimeDefinition) -> Bool {
LocalASRModelInstallState.isRuntimeInstalled(runtime, fileManager: fileManager)
}
#if os(macOS)
public func resolveRuntimeBinary(runtime: LocalASRRuntimeDefinition) -> URL? {
LocalASRModelInstallState.resolveRuntimeBinary(runtime: runtime, fileManager: fileManager)
}
public func installModel(
_ model: LocalASRModelDefinition,
catalog: LocalASRCatalogDocument
) async throws {
guard model.installKind == .archive,
let relative = model.installRelativePath,
let baseName = model.archiveBaseName,
let sources = model.sources,
!sources.isEmpty else {
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
}
progress = LocalASRModelInstallProgress(
phase: .downloading,
fraction: 0.05,
message: model.displayName,
activeItemId: model.id
)
if model.backend == .sherpaQwen3 || model.backend == .sherpaSenseVoice {
try await ensureRuntimeInstalled(catalog: catalog)
}
let sortedSources = sources.sorted { $0.priority < $1.priority }
var lastError: Error?
for source in sortedSources {
do {
try await installArchive(
from: source.url,
installRelativePath: relative,
archiveBaseName: baseName,
layoutModel: model,
itemId: model.id,
displayName: model.displayName
)
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
if !manifest.installedModelIDs.contains(model.id) {
manifest.installedModelIDs.append(model.id)
}
manifest.updatedAt = Date()
try saveManifest(manifest)
progress = LocalASRModelInstallProgress(
phase: .completed,
fraction: 1,
message: model.displayName,
activeItemId: model.id
)
return
} catch {
lastError = error
}
}
progress = LocalASRModelInstallProgress(
phase: .failed,
fraction: 0,
message: lastError?.localizedDescription ?? "Download failed"
)
throw lastError ?? LocalASRModelManagerError.downloadFailed("All mirrors failed")
}
public func installRuntime(
_ runtime: LocalASRRuntimeDefinition,
catalog: LocalASRCatalogDocument
) async throws {
guard let source = runtime.sources.sorted(by: { $0.priority < $1.priority }).first else {
throw LocalASRModelManagerError.downloadFailed("No runtime source configured.")
}
progress = LocalASRModelInstallProgress(
phase: .downloading,
fraction: 0.05,
message: runtime.displayName,
activeItemId: runtime.id
)
try await installArchive(
from: source.url,
installRelativePath: runtime.installRelativePath,
archiveBaseName: runtime.installRelativePath.split(separator: "/").last.map(String.init) ?? runtime.id,
layoutModel: nil,
expectedBinaryCandidates: runtime.binaryCandidates,
itemId: runtime.id,
displayName: runtime.displayName
)
guard isRuntimeInstalled(runtime) else {
throw LocalASRModelManagerError.binaryMissing
}
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
if !manifest.installedRuntimeIDs.contains(runtime.id) {
manifest.installedRuntimeIDs.append(runtime.id)
}
manifest.updatedAt = Date()
try saveManifest(manifest)
progress = LocalASRModelInstallProgress(phase: .completed, fraction: 1, message: runtime.displayName)
}
public func modelRootURL(_ model: LocalASRModelDefinition) -> URL? {
LocalASRModelInstallState.modelRootURL(model, fileManager: fileManager)
}
public func installDirectoryURL(for model: LocalASRModelDefinition) -> URL? {
guard let relative = model.installRelativePath else { return nil }
return installDirectory(for: relative)
}
public func deleteModel(
_ model: LocalASRModelDefinition,
catalog: LocalASRCatalogDocument
) throws {
guard model.installKind == .archive, let relative = model.installRelativePath else { return }
let dir = installDirectory(for: relative)
if fileManager.fileExists(atPath: dir.path) {
try fileManager.removeItem(at: dir)
}
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
manifest.installedModelIDs.removeAll { $0 == model.id }
if manifest.selectedModelId == model.id {
manifest.selectedModelId = catalog.defaultModelId
UserDefaults.standard.set(catalog.defaultModelId, forKey: LocalASRPreferenceKeys.selectedModelId)
}
manifest.updatedAt = Date()
try saveManifest(manifest)
if progress.activeItemId == model.id {
progress = .idle
}
}
public func deleteRuntime(
_ runtime: LocalASRRuntimeDefinition,
catalog: LocalASRCatalogDocument
) throws {
let dir = installDirectory(for: runtime.installRelativePath)
if fileManager.fileExists(atPath: dir.path) {
try fileManager.removeItem(at: dir)
}
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
manifest.installedRuntimeIDs.removeAll { $0 == runtime.id }
manifest.updatedAt = Date()
try saveManifest(manifest)
}
private func setProgress(_ update: LocalASRModelInstallProgress) {
progress = update
}
func updateDownloadProgress(
itemId: String,
displayName: String,
update: LocalASRDownloadProgressUpdate
) {
reportDownloadProgress(itemId: itemId, displayName: displayName, update: update)
}
private func reportDownloadProgress(
itemId: String,
displayName: String,
update: LocalASRDownloadProgressUpdate
) {
// Download phase occupies 10%55% of the overall install bar.
let mapped = 0.10 + update.fraction * 0.45
progress = LocalASRModelInstallProgress(
phase: .downloading,
fraction: mapped,
message: displayName,
bytesReceived: update.bytesReceived,
bytesTotal: update.bytesTotal,
activeItemId: itemId
)
}
#endif
// MARK: - Private
#if os(macOS)
private func installArchive(
from urlString: String,
installRelativePath: String,
archiveBaseName: String,
layoutModel: LocalASRModelDefinition?,
expectedBinaryCandidates: [String]? = nil,
itemId: String,
displayName: String
) async throws {
guard let remoteURL = URL(string: urlString) else {
throw LocalASRModelManagerError.downloadFailed("Invalid URL")
}
let stagingRoot = rootDirectory().appendingPathComponent("staging/\(UUID().uuidString)", isDirectory: true)
let destinationParent = installDirectory(for: installRelativePath)
try fileManager.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: stagingRoot) }
let archiveURL = stagingRoot.appendingPathComponent(remoteURL.lastPathComponent)
progress = LocalASRModelInstallProgress(
phase: .downloading,
fraction: 0.10,
message: displayName,
activeItemId: itemId
)
do {
let controller = LocalASRModelDownloadClient.makeController(destinationURL: archiveURL) { update in
Task {
await LocalASRModelManager.shared.updateDownloadProgress(
itemId: itemId,
displayName: displayName,
update: update
)
}
}
activeDownloadController = controller
try await controller.download(from: remoteURL)
activeDownloadController = nil
pausedResumeData = nil
} catch {
activeDownloadController = nil
pausedResumeData = nil
throw LocalASRModelManagerError.downloadFailed(error.localizedDescription)
}
progress = LocalASRModelInstallProgress(
phase: .extracting,
fraction: 0.58,
message: displayName,
activeItemId: itemId
)
try fileManager.createDirectory(at: destinationParent, withIntermediateDirectories: true)
let extractOK = try await extractTarBz2(archiveURL: archiveURL, destination: destinationParent)
guard extractOK else {
throw LocalASRModelManagerError.extractFailed("tar extraction failed")
}
progress = LocalASRModelInstallProgress(
phase: .validating,
fraction: 0.82,
message: displayName,
activeItemId: itemId
)
if let layoutModel {
guard LocalASRModelInstallState.isInstalled(
layoutModel,
manualMLXPath: nil,
fileManager: fileManager
) else {
throw LocalASRModelManagerError.validationFailed("Required model files missing after extract.")
}
}
if let expectedBinaryCandidates {
let runtimeRoot = destinationParent
let found = expectedBinaryCandidates.contains { candidate in
let direct = runtimeRoot.appendingPathComponent(candidate)
if fileManager.isExecutableFile(atPath: direct.path) { return true }
let name = (candidate as NSString).lastPathComponent
return findExecutable(named: name, under: runtimeRoot) != nil
}
guard found else {
throw LocalASRModelManagerError.binaryMissing
}
}
progress = LocalASRModelInstallProgress(
phase: .finalizing,
fraction: 0.95,
message: displayName,
activeItemId: itemId
)
try? fileManager.removeItem(at: archiveURL)
}
public func ensureRuntimeInstalled(catalog: LocalASRCatalogDocument) async throws {
guard let runtime = LocalASRModelCatalog.runtime(
for: LocalASRModelCatalog.currentRuntimePlatform(),
in: catalog
) else {
throw LocalASRModelManagerError.runtimeMissing
}
if isRuntimeInstalled(runtime) { return }
try await installRuntime(runtime, catalog: catalog)
}
private func findExecutable(named name: String, under root: URL) -> URL? {
guard let enumerator = fileManager.enumerator(
at: root,
includingPropertiesForKeys: [.isExecutableKey],
options: [.skipsHiddenFiles]
) else { return nil }
for case let url as URL in enumerator {
guard url.lastPathComponent == name else { continue }
if fileManager.isExecutableFile(atPath: url.path) {
return url
}
}
return nil
}
private func extractTarBz2(archiveURL: URL, destination: URL) async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
process.arguments = ["-xjf", archiveURL.path, "-C", destination.path]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
process.terminationHandler = { proc in
continuation.resume(returning: proc.terminationStatus == 0)
}
do {
try process.run()
} catch {
continuation.resume(throwing: LocalASRModelManagerError.extractFailed(error.localizedDescription))
}
}
}
#endif
}