Files
OSGKeyboard/OSGKeyboardShared/Services/LocalASRModelManager.swift
T
Rocky 200265fbd6 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).
2026-07-09 08:55:37 +08:00

493 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}