Squashes the working tree that had accumulated on cloud-agent. Two strands of work, kept in one commit at the maintainer's request. Crash and stability (this session): - Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics are recorded on device. Keyboard-extension memory kills are the most likely way a user loses the keyboard mid-sentence and iOS does not classify them as crashes, so they never reach App Store Connect. - Upload those reports to POST /v1/analytics/diagnostics from internal builds only (local Debug and TestFlight), reusing the analytics installation identifier and skipping entirely when analytics are off. App Store builds keep everything on device. - Shed the Chinese engine, English lexicon, and in-flight pipeline work at the 40 MiB and 48 MiB budget thresholds instead of waiting for a system memory warning that jetsam often preempts. - Install audio taps with the input node's live format through one shared adaptive resampler, so an audio-route change mid-recording no longer raises an uncatchable format-mismatch exception. Covers the dictation preview and the Mac recorder, which still used the pattern the Flow capture path had already moved away from. - Harden the librime bridge: never pass a nil string across a nonnull boundary when a user dictionary holds malformed UTF-8, null-check the engine API vector, and distrust a candidate count without its array. - Guard the expanded candidate grid against a list that shrinks while the collection view is still laying out against the previous count. - Resolve localized text without the App Group so the screen shown when that container is missing renders its message instead of crashing on launch, which is the one situation it exists to handle. - Gate CI on the App Group and Keychain wiring that the keyboard extension hard-depends on, with a self-test proving the check fails. Clipboard, reply, and semantics (pre-existing working tree): - Unify the reply center, refresh keyboard AI features, and add native event and reminder exporters. - Expand clipboard semantic routing with authored corpora and a product-blind holdout, plus the scripts that build and validate them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
669 lines
25 KiB
Swift
669 lines
25 KiB
Swift
// LocalASRModelManager.swift
|
||
// OSGKeyboard · Shared
|
||
//
|
||
// Manages macOS local-ASR model files under Application Support. Qwen3 MLX
|
||
// is the current runtime; Sherpa runtime IDs and install records remain only
|
||
// for legacy catalog and persisted-state compatibility. 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 SharedL10n.format("error.localASR.downloadFailed", detail)
|
||
case .extractFailed(let detail):
|
||
return SharedL10n.format("error.localASR.extractFailed", detail)
|
||
case .validationFailed(let detail):
|
||
return SharedL10n.format("error.localASR.validationFailed", detail)
|
||
case .runtimeMissing:
|
||
return SharedL10n.string("error.localASR.runtimeMissing")
|
||
case .binaryMissing:
|
||
return SharedL10n.string("error.localASR.binaryMissing")
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
preferredSource: LocalASRDownloadSourcePreference = .auto
|
||
) async throws {
|
||
guard let relative = model.installRelativePath,
|
||
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
|
||
)
|
||
let sortedSources = LocalASRDownloadSourceSorter.sorted(sources, preferred: preferredSource)
|
||
var lastError: Error?
|
||
|
||
switch model.installKind {
|
||
case .archive:
|
||
guard let baseName = model.archiveBaseName else {
|
||
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
||
}
|
||
for source in sortedSources where source.isArchive {
|
||
do {
|
||
try await installArchive(
|
||
from: source.url,
|
||
installRelativePath: relative,
|
||
archiveBaseName: baseName,
|
||
layoutModel: model,
|
||
itemId: model.id,
|
||
displayName: model.displayName
|
||
)
|
||
try markModelInstalled(model, catalog: catalog)
|
||
progress = LocalASRModelInstallProgress(
|
||
phase: .completed,
|
||
fraction: 1,
|
||
message: model.displayName,
|
||
activeItemId: model.id
|
||
)
|
||
return
|
||
} catch {
|
||
lastError = error
|
||
}
|
||
}
|
||
case .repository:
|
||
guard let baseName = model.archiveBaseName else {
|
||
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
||
}
|
||
for source in sortedSources where source.isRepository {
|
||
do {
|
||
try await installRepository(
|
||
source: source,
|
||
installRelativePath: relative,
|
||
archiveBaseName: baseName,
|
||
layoutModel: model,
|
||
itemId: model.id,
|
||
displayName: model.displayName
|
||
)
|
||
try markModelInstalled(model, catalog: catalog)
|
||
progress = LocalASRModelInstallProgress(
|
||
phase: .completed,
|
||
fraction: 1,
|
||
message: model.displayName,
|
||
activeItemId: model.id
|
||
)
|
||
return
|
||
} catch {
|
||
lastError = error
|
||
}
|
||
}
|
||
default:
|
||
throw LocalASRModelManagerError.validationFailed("Model is not downloadable.")
|
||
}
|
||
|
||
progress = LocalASRModelInstallProgress(
|
||
phase: .failed,
|
||
fraction: 0,
|
||
message: lastError?.localizedDescription ?? "Download failed"
|
||
)
|
||
throw lastError ?? LocalASRModelManagerError.downloadFailed("All mirrors failed")
|
||
}
|
||
|
||
private func markModelInstalled(
|
||
_ model: LocalASRModelDefinition,
|
||
catalog: LocalASRCatalogDocument
|
||
) throws {
|
||
var manifest = loadManifest(defaultModelId: catalog.defaultModelId)
|
||
if !manifest.installedModelIDs.contains(model.id) {
|
||
manifest.installedModelIDs.append(model.id)
|
||
}
|
||
manifest.updatedAt = Date()
|
||
try saveManifest(manifest)
|
||
}
|
||
|
||
public func installRuntime(
|
||
_ runtime: LocalASRRuntimeDefinition,
|
||
catalog: LocalASRCatalogDocument
|
||
) async throws {
|
||
let sortedSources = LocalASRDownloadSourceSorter.sorted(runtime.sources)
|
||
guard !sortedSources.isEmpty else {
|
||
throw LocalASRModelManagerError.downloadFailed("No runtime source configured.")
|
||
}
|
||
progress = LocalASRModelInstallProgress(
|
||
phase: .downloading,
|
||
fraction: 0.05,
|
||
message: runtime.displayName,
|
||
activeItemId: runtime.id
|
||
)
|
||
var lastError: Error?
|
||
for source in sortedSources where source.isArchive {
|
||
do {
|
||
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)
|
||
return
|
||
} catch {
|
||
lastError = error
|
||
}
|
||
}
|
||
throw lastError ?? LocalASRModelManagerError.downloadFailed("All runtime mirrors failed")
|
||
}
|
||
|
||
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 || model.installKind == .repository,
|
||
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)
|
||
}
|
||
|
||
private func installRepository(
|
||
source: LocalASRDownloadSource,
|
||
installRelativePath: String,
|
||
archiveBaseName: String,
|
||
layoutModel: LocalASRModelDefinition,
|
||
itemId: String,
|
||
displayName: String
|
||
) async throws {
|
||
guard let baseURL = source.baseURL,
|
||
let files = source.files,
|
||
!files.isEmpty else {
|
||
throw LocalASRModelManagerError.downloadFailed("Invalid repository source.")
|
||
}
|
||
|
||
let destinationRoot = installDirectory(for: installRelativePath)
|
||
.appendingPathComponent(archiveBaseName, isDirectory: true)
|
||
let stagingRoot = rootDirectory().appendingPathComponent("staging/\(UUID().uuidString)", isDirectory: true)
|
||
try fileManager.createDirectory(at: stagingRoot, withIntermediateDirectories: true)
|
||
defer { try? fileManager.removeItem(at: stagingRoot) }
|
||
|
||
// Do NOT wipe an existing destination: files land here only after a full
|
||
// download completes (partials stay in URLSession's temp dir), so already
|
||
// present files are complete and can be reused when a prior attempt failed
|
||
// partway or we fall back to another mirror.
|
||
try fileManager.createDirectory(at: destinationRoot, withIntermediateDirectories: true)
|
||
|
||
let totalBytes = files.reduce(Int64(0)) { partial, file in
|
||
partial + Int64(file.sizeBytes ?? 0)
|
||
}
|
||
var completedBytes: Int64 = 0
|
||
|
||
for (index, file) in files.enumerated() {
|
||
let localURL = destinationRoot.appendingPathComponent(file.localPath)
|
||
|
||
// Skip files a previous attempt already finished (non-empty on disk).
|
||
if fileManager.fileExists(atPath: localURL.path),
|
||
let attrs = try? fileManager.attributesOfItem(atPath: localURL.path),
|
||
let size = attrs[.size] as? Int64, size > 0 {
|
||
completedBytes += Int64(file.sizeBytes ?? Int(size))
|
||
continue
|
||
}
|
||
|
||
let remoteURLString = baseURL.replacingOccurrences(of: "{path}", with: file.remotePath)
|
||
guard let remoteURL = URL(string: remoteURLString) else {
|
||
throw LocalASRModelManagerError.downloadFailed("Invalid URL for \(file.remotePath)")
|
||
}
|
||
|
||
try fileManager.createDirectory(
|
||
at: localURL.deletingLastPathComponent(),
|
||
withIntermediateDirectories: true
|
||
)
|
||
|
||
// Snapshot the running total so the progress closure captures an
|
||
// immutable value (avoids concurrent access to `completedBytes`).
|
||
let priorBytes = completedBytes
|
||
progress = LocalASRModelInstallProgress(
|
||
phase: .downloading,
|
||
fraction: 0.10 + (Double(index) / Double(files.count)) * 0.45,
|
||
message: displayName,
|
||
bytesReceived: priorBytes,
|
||
bytesTotal: totalBytes > 0 ? totalBytes : nil,
|
||
activeItemId: itemId
|
||
)
|
||
|
||
do {
|
||
let controller = LocalASRModelDownloadClient.makeController(destinationURL: localURL) { update in
|
||
Task {
|
||
let aggregateReceived = priorBytes + update.bytesReceived
|
||
let aggregateTotal = totalBytes > 0 ? totalBytes : update.bytesTotal
|
||
await LocalASRModelManager.shared.updateDownloadProgress(
|
||
itemId: itemId,
|
||
displayName: displayName,
|
||
update: LocalASRDownloadProgressUpdate(
|
||
bytesReceived: aggregateReceived,
|
||
bytesTotal: max(aggregateTotal, 1)
|
||
)
|
||
)
|
||
}
|
||
}
|
||
activeDownloadController = controller
|
||
try await controller.download(from: remoteURL)
|
||
activeDownloadController = nil
|
||
pausedResumeData = nil
|
||
} catch {
|
||
activeDownloadController = nil
|
||
pausedResumeData = nil
|
||
throw LocalASRModelManagerError.downloadFailed(error.localizedDescription)
|
||
}
|
||
|
||
if let size = file.sizeBytes {
|
||
completedBytes += Int64(size)
|
||
} else if let attrs = try? fileManager.attributesOfItem(atPath: localURL.path),
|
||
let size = attrs[.size] as? Int64 {
|
||
completedBytes += size
|
||
}
|
||
}
|
||
|
||
progress = LocalASRModelInstallProgress(
|
||
phase: .validating,
|
||
fraction: 0.82,
|
||
message: displayName,
|
||
activeItemId: itemId
|
||
)
|
||
guard LocalASRModelInstallState.isInstalled(
|
||
layoutModel,
|
||
manualMLXPath: nil,
|
||
fileManager: fileManager
|
||
) else {
|
||
throw LocalASRModelManagerError.validationFailed("Required model files missing after download.")
|
||
}
|
||
|
||
progress = LocalASRModelInstallProgress(
|
||
phase: .finalizing,
|
||
fraction: 0.95,
|
||
message: displayName,
|
||
activeItemId: itemId
|
||
)
|
||
}
|
||
|
||
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
|
||
}
|