SPB Git

spb/zyquo-local Public MIT

Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.

Swift 97.2% Shell 1.8% Makefile 1%
15.1 KB · 371 lines swift
Raw Blame History
1//2//  DownloadManager.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import Foundation10import Observation1112/// Queued, resumable model downloads.13///14/// - Files download to `<name>.partial` and are moved into place atomically;15///   a model is valid only when every file is present and size-verified.16/// - Resume uses HTTP Range from the partial file's size — surviving pause,17///   cancel-restart, and app relaunch (manifest + partials persist on disk).18/// - Max 2 concurrent file downloads per model.19@MainActor20@Observable21final class DownloadManager {22    private(set) var tasks: [DownloadTask] = []23    /// Live transfer speed per repoID, bytes/sec.24    private(set) var speeds: [String: Double] = [:]2526    var hub: HubService27    private let store: ModelStore28    private var workers: [String: Task<Void, Never>] = [:]29    /// Speed window per repoID: last sample time and cumulative bytes.30    private var speedSamples: [String: (time: ContinuousClock.Instant, bytes: Int64)] = [:]3132    /// Active + queued downloads (for the sidebar badge).33    var activeCount: Int {34        tasks.filter { $0.state == .downloading || $0.state == .queued }.count35    }3637    var overallFraction: Double {38        let active = tasks.filter { $0.state == .downloading || $0.state == .paused }39        guard !active.isEmpty else { return 0 }40        return active.reduce(0.0) { $0 + $1.fractionCompleted } / Double(active.count)41    }4243    init(hub: HubService, store: ModelStore) {44        self.hub = hub45        self.store = store46        restoreManifest()47    }4849    enum DownloadError: LocalizedError {50        case insufficientDiskSpace(needed: Int64, available: Int64)51        case sizeMismatch(file: String)52        case interrupted5354        var errorDescription: String? {55            switch self {56            case .insufficientDiskSpace(let needed, let available):57                "Not enough disk space: \(ByteCountFormatter.string(fromByteCount: needed, countStyle: .file)) needed, \(ByteCountFormatter.string(fromByteCount: available, countStyle: .file)) available."58            case .sizeMismatch(let file):59                "Downloaded file \(file) does not match its expected size. The download will retry from scratch for this file."60            case .interrupted:61                "The download was interrupted. It can be resumed from where it left off."62            }63        }64    }6566    // MARK: - Public API6768    /// Starts (or restarts) downloading all required files of a repo.69    func download(repoID: String) async {70        if let existing = task(for: repoID),71            existing.state == .downloading || existing.state == .queued72        {73            return74        }75        do {76            let (files, totalBytes) = try await hub.requiredFiles(of: repoID)77            try checkDiskSpace(needed: totalBytes)78            let dir = store.directory(for: repoID)79            try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)8081            var progress: [DownloadTask.FileProgress] = files.map { file in82                let partial = dir.appendingPathComponent(file.path + ".partial")83                let final = dir.appendingPathComponent(file.path)84                let already: Int6485                let done: Bool86                if FileManager.default.fileExists(atPath: final.path) {87                    already = file.size ?? 088                    done = true89                } else {90                    already = (try? FileManager.default.attributesOfItem(atPath: partial.path)[.size] as? Int64) ?? 091                    done = false92                }93                return DownloadTask.FileProgress(94                    path: file.path,95                    totalBytes: file.size ?? 0,96                    receivedBytes: already,97                    sha256: file.lfs?.oid,98                    completed: done99                )100            }101            progress.sort { $0.totalBytes < $1.totalBytes }  // small config files first102            upsert(DownloadTask(103                repoID: repoID, state: .downloading, files: progress,104                errorDescription: nil, startedAt: Date()105            ))106            startWorker(repoID: repoID)107        } catch {108            upsert(DownloadTask(109                repoID: repoID, state: .failed, files: task(for: repoID)?.files ?? [],110                errorDescription: error.localizedDescription, startedAt: Date()111            ))112        }113    }114115    func pause(repoID: String) {116        workers[repoID]?.cancel()117        workers[repoID] = nil118        mutate(repoID) { $0.state = .paused }119    }120121    func resume(repoID: String) {122        guard var t = task(for: repoID), t.state == .paused || t.state == .failed else { return }123        t.state = .downloading124        t.errorDescription = nil125        upsert(t)126        startWorker(repoID: repoID)127    }128129    /// Cancels and removes all partial data.130    func cancel(repoID: String) {131        workers[repoID]?.cancel()132        workers[repoID] = nil133        let dir = store.directory(for: repoID)134        if let t = task(for: repoID) {135            for f in t.files where !f.completed {136                try? FileManager.default.removeItem(at: dir.appendingPathComponent(f.path + ".partial"))137            }138        }139        // Remove the directory when nothing complete remains in it.140        if let contents = try? FileManager.default.contentsOfDirectory(atPath: dir.path), contents.isEmpty {141            try? FileManager.default.removeItem(at: dir)142        }143        tasks.removeAll { $0.repoID == repoID }144        speeds[repoID] = nil145        speedSamples[repoID] = nil146        persistManifest()147    }148149    func task(for repoID: String) -> DownloadTask? {150        tasks.first { $0.repoID == repoID }151    }152153    /// ETA in seconds based on current speed.154    func eta(for repoID: String) -> TimeInterval? {155        guard let t = task(for: repoID), let speed = speeds[t.repoID], speed > 1 else { return nil }156        return Double(t.totalBytes - t.receivedBytes) / speed157    }158159    // MARK: - Worker160161    private func startWorker(repoID: String) {162        workers[repoID]?.cancel()163        workers[repoID] = Task { [weak self] in164            await self?.runWorker(repoID: repoID)165        }166    }167168    private func runWorker(repoID: String) async {169        let dir = store.directory(for: repoID)170        guard let snapshot = task(for: repoID) else { return }171        let pending = snapshot.files.filter { !$0.completed }172173        do {174            try await withThrowingTaskGroup(of: Void.self) { group in175                var iterator = pending.makeIterator()176                var inFlight = 0177                func addNext(_ group: inout ThrowingTaskGroup<Void, Error>) {178                    guard let file = iterator.next() else { return }179                    inFlight += 1180                    group.addTask { [weak self] in181                        try await self?.downloadFile(repoID: repoID, file: file, directory: dir)182                    }183                }184                addNext(&group)185                addNext(&group)  // 2 concurrent file downloads max186                while inFlight > 0 {187                    try await group.next()188                    inFlight -= 1189                    addNext(&group)190                }191            }192            mutate(repoID) { $0.state = .verifying }193            try verify(repoID: repoID, directory: dir)194            mutate(repoID) { $0.state = .completed }195            speeds[repoID] = nil196            store.rescan()197        } catch is CancellationError {198            // pause() already set the state; on app quit partials just persist.199        } catch {200            if Task.isCancelled { return }201            mutate(repoID) {202                $0.state = .failed203                $0.errorDescription = error.localizedDescription204            }205            speeds[repoID] = nil206        }207        workers[repoID] = nil208    }209210    /// Downloads one file with Range-resume into `<path>.partial`, then moves211    /// it into place. Retries transient network drops from the partial offset.212    /// Throws CancellationError on pause.213    private func downloadFile(repoID: String, file: DownloadTask.FileProgress, directory: URL) async throws {214        let partial = directory.appendingPathComponent(file.path + ".partial")215        let final = directory.appendingPathComponent(file.path)216        try FileManager.default.createDirectory(217            at: partial.deletingLastPathComponent(), withIntermediateDirectories: true)218        if !FileManager.default.fileExists(atPath: partial.path) {219            FileManager.default.createFile(atPath: partial.path, contents: nil)220        }221222        var request = URLRequest(url: HubService.resolveURL(repoID: repoID, path: file.path))223        request.setValue("ZyquoLocal/1.0.0", forHTTPHeaderField: "User-Agent")224        if let token = hub.token, !token.isEmpty {225            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")226        }227228        let path = file.path229        let transfer = FileTransfer(partial: partial) { [weak self] received in230            Task { @MainActor [weak self] in231                self?.updateProgress(repoID: repoID, path: path, received: received)232            }233        }234235        var attempts = 0236        while true {237            attempts += 1238            let offset: Int64 =239                (try? FileManager.default.attributesOfItem(atPath: partial.path)[.size] as? Int64) ?? 0240            do {241                _ = try await transfer.run(request: request, offset: offset)242                break243            } catch let error as FileTransfer.TransferError {244                switch error {245                case .badStatus(401), .badStatus(403):246                    throw HubService.HubError.gatedOrMissing(repoID)247                case .badStatus(416):248                    // Range beyond EOF: complete if the size matches, else restart.249                    let size = (try? FileManager.default.attributesOfItem(atPath: partial.path)[.size] as? Int64) ?? 0250                    if size == file.totalBytes { break }251                    try? FileManager.default.removeItem(at: partial)252                    FileManager.default.createFile(atPath: partial.path, contents: nil)253                    if attempts >= 3 { throw DownloadError.sizeMismatch(file: file.path) }254                    continue255                case .badStatus(let code):256                    throw HubService.HubError.http(code, repoID)257                }258            } catch is CancellationError {259                throw CancellationError()260            } catch {261                // Transient drop (connection lost, timeout…): retry with backoff262                // from whatever the partial already holds.263                guard attempts < 4 else { throw error }264                try await Task.sleep(for: .seconds(Double(attempts)))265                try Task.checkCancellation()266                continue267            }268        }269        try finishFile(partial: partial, final: final, repoID: repoID, path: file.path, expected: file.totalBytes)270    }271272    /// Size check + atomic move into place.273    private func finishFile(partial: URL, final: URL, repoID: String, path: String, expected: Int64) throws {274        let size = (try? FileManager.default.attributesOfItem(atPath: partial.path)[.size] as? Int64) ?? -1275        guard expected == 0 || size == expected else {276            try? FileManager.default.removeItem(at: partial)277            updateProgress(repoID: repoID, path: path, received: 0)278            throw DownloadError.sizeMismatch(file: path)279        }280        if FileManager.default.fileExists(atPath: final.path) {281            try FileManager.default.removeItem(at: final)282        }283        try FileManager.default.moveItem(at: partial, to: final)284        mutate(repoID) { t in285            if let i = t.files.firstIndex(where: { $0.path == path }) {286                t.files[i].completed = true287                t.files[i].receivedBytes = t.files[i].totalBytes288            }289        }290    }291292    /// Final integrity pass: every required file present with expected size.293    private func verify(repoID: String, directory: URL) throws {294        guard let t = task(for: repoID) else { return }295        for f in t.files {296            let url = directory.appendingPathComponent(f.path)297            let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? -1298            guard f.totalBytes == 0 || size == f.totalBytes else {299                throw DownloadError.sizeMismatch(file: f.path)300            }301        }302    }303304    private func checkDiskSpace(needed: Int64) throws {305        let values = try? store.modelsRoot.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])306        let available = values?.volumeAvailableCapacityForImportantUsage ?? 0307        // 5 % headroom so a download never fills the disk completely.308        if available < Int64(Double(needed) * 1.05) {309            throw DownloadError.insufficientDiskSpace(needed: needed, available: available)310        }311    }312313    // MARK: - State & manifest314315    private func upsert(_ t: DownloadTask) {316        if let i = tasks.firstIndex(where: { $0.repoID == t.repoID }) {317            tasks[i] = t318        } else {319            tasks.append(t)320        }321        persistManifest()322    }323324    private func mutate(_ repoID: String, _ change: (inout DownloadTask) -> Void) {325        guard let i = tasks.firstIndex(where: { $0.repoID == repoID }) else { return }326        change(&tasks[i])327        persistManifest()328    }329330    private func updateProgress(repoID: String, path: String, received: Int64) {331        guard let i = tasks.firstIndex(where: { $0.repoID == repoID }) else { return }332        if let j = tasks[i].files.firstIndex(where: { $0.path == path }) {333            tasks[i].files[j].receivedBytes = received334        }335        // Smoothed speed over a sliding window of the whole repo's bytes.336        let totalReceived = tasks[i].receivedBytes337        let now = ContinuousClock.now338        if let sample = speedSamples[repoID] {339            let d = sample.time.duration(to: now)340            let elapsed = Double(d.components.seconds) + Double(d.components.attoseconds) * 1e-18341            if elapsed >= 1.0 {342                let instant = Double(totalReceived - sample.bytes) / elapsed343                let previous = speeds[repoID] ?? instant344                speeds[repoID] = previous * 0.6 + instant * 0.4345                speedSamples[repoID] = (now, totalReceived)346            }347        } else {348            speedSamples[repoID] = (now, totalReceived)349        }350    }351352    private var manifestURL: URL {353        PersistenceService.appSupportDirectory.appendingPathComponent("downloads.json")354    }355356    private func persistManifest() {357        let persistable = tasks.filter { $0.state != .completed && $0.state != .cancelled }358        try? JSONEncoder().encode(persistable).write(to: manifestURL, options: .atomic)359    }360361    /// Restores paused/interrupted downloads after relaunch.362    private func restoreManifest() {363        guard let data = try? Data(contentsOf: manifestURL),364            var restored = try? JSONDecoder().decode([DownloadTask].self, from: data)365        else { return }366        for i in restored.indices where restored[i].state == .downloading || restored[i].state == .queued {367            restored[i].state = .paused368        }369        tasks = restored370    }371}