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).
139 lines
5.5 KiB
Swift
139 lines
5.5 KiB
Swift
// LocalASRModelInstallState.swift
|
|
// OSGKeyboard · Shared
|
|
|
|
import Foundation
|
|
|
|
public enum LocalASRModelInstallState {
|
|
|
|
public static func rootDirectory(fileManager: FileManager = .default) -> URL {
|
|
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
|
return appSupport.appendingPathComponent("OSGKeyboard/LocalASRModels", isDirectory: true)
|
|
}
|
|
|
|
public static func installDirectory(for relativePath: String, fileManager: FileManager = .default) -> URL {
|
|
rootDirectory(fileManager: fileManager).appendingPathComponent(relativePath, isDirectory: true)
|
|
}
|
|
|
|
public static func isInstalled(
|
|
_ model: LocalASRModelDefinition,
|
|
manualMLXPath: String?,
|
|
fileManager: FileManager = .default
|
|
) -> Bool {
|
|
switch model.installKind {
|
|
case .manual:
|
|
guard let required = model.requiredRelativeFiles, !required.isEmpty else { return false }
|
|
let base = URL(fileURLWithPath: manualMLXPath ?? "", isDirectory: true)
|
|
guard fileManager.fileExists(atPath: base.path) else { return false }
|
|
return required.allSatisfy { fileManager.fileExists(atPath: base.appendingPathComponent($0).path) }
|
|
case .archive:
|
|
guard let relative = model.installRelativePath,
|
|
let layout = model.layout,
|
|
let baseName = model.archiveBaseName else { return false }
|
|
let root = installDirectory(for: relative, fileManager: fileManager)
|
|
.appendingPathComponent(baseName, isDirectory: true)
|
|
return validateArchiveModel(at: root, model: model, layout: layout, fileManager: fileManager)
|
|
case .runtime:
|
|
return false
|
|
}
|
|
}
|
|
|
|
public static func modelRootURL(
|
|
_ model: LocalASRModelDefinition,
|
|
fileManager: FileManager = .default
|
|
) -> URL? {
|
|
guard model.installKind == .archive,
|
|
let relative = model.installRelativePath,
|
|
let baseName = model.archiveBaseName else { return nil }
|
|
return installDirectory(for: relative, fileManager: fileManager)
|
|
.appendingPathComponent(baseName, isDirectory: true)
|
|
}
|
|
|
|
public static func resolveRuntimeBinary(
|
|
runtime: LocalASRRuntimeDefinition,
|
|
fileManager: FileManager = .default
|
|
) -> URL? {
|
|
let root = installDirectory(for: runtime.installRelativePath, fileManager: fileManager)
|
|
for candidate in runtime.binaryCandidates {
|
|
let direct = root.appendingPathComponent(candidate)
|
|
if fileManager.isExecutableFile(atPath: direct.path) {
|
|
return direct
|
|
}
|
|
}
|
|
for candidate in runtime.binaryCandidates {
|
|
let name = (candidate as NSString).lastPathComponent
|
|
if let found = findExecutable(named: name, under: root, fileManager: fileManager) {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public static func isRuntimeInstalled(
|
|
_ runtime: LocalASRRuntimeDefinition,
|
|
fileManager: FileManager = .default
|
|
) -> Bool {
|
|
resolveRuntimeBinary(runtime: runtime, fileManager: fileManager) != nil
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private static func validateArchiveModel(
|
|
at root: URL,
|
|
model: LocalASRModelDefinition,
|
|
layout: LocalASRModelLayout,
|
|
fileManager: FileManager
|
|
) -> Bool {
|
|
switch model.backend {
|
|
case .sherpaQwen3:
|
|
guard let conv = layout.convFrontend,
|
|
let encoder = layout.encoder,
|
|
let decoder = layout.decoder,
|
|
let tokenizer = layout.tokenizer else { return false }
|
|
return fileManager.fileExists(atPath: root.appendingPathComponent(conv).path)
|
|
&& fileManager.fileExists(atPath: root.appendingPathComponent(encoder).path)
|
|
&& fileManager.fileExists(atPath: root.appendingPathComponent(decoder).path)
|
|
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokenizer, isDirectory: true).path)
|
|
case .sherpaSenseVoice:
|
|
guard let onnx = layout.senseVoiceModel,
|
|
let tokens = layout.tokens else { return false }
|
|
return fileManager.fileExists(atPath: root.appendingPathComponent(onnx).path)
|
|
&& fileManager.fileExists(atPath: root.appendingPathComponent(tokens).path)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func findExecutable(
|
|
named name: String,
|
|
under root: URL,
|
|
fileManager: FileManager
|
|
) -> 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
|
|
}
|
|
|
|
public static func directoryByteCount(at url: URL, fileManager: FileManager = .default) -> Int64 {
|
|
guard let enumerator = fileManager.enumerator(
|
|
at: url,
|
|
includingPropertiesForKeys: [.fileSizeKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else { return 0 }
|
|
var total: Int64 = 0
|
|
for case let fileURL as URL in enumerator {
|
|
let size = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
|
total += Int64(size)
|
|
}
|
|
return total
|
|
}
|
|
}
|