200265fbd6
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).
493 lines
18 KiB
Swift
493 lines
18 KiB
Swift
// 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
|
||
}
|