Files
OSGKeyboard/OSGKeyboardShared/Services/LocalASRModelInstallState.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

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
}
}