From e952eb441ca8a6dbf4b8ca51c09ce5f90fe8e822 Mon Sep 17 00:00:00 2001 From: BigMarc Date: Tue, 16 Jun 2026 15:29:22 +0200 Subject: [PATCH] Real model download; live Accessibility detection in onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModelManager now performs a real Hugging Face download (file list + sizes via the tree API, sequential download with progress, completion marker, cancel). - Onboarding observes Permissions/ModelManager directly and auto-polls, so the step + button flip to "granted/Done" the moment Accessibility is enabled — even when toggled directly in System Settings. Polling is idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/GrammaGem/AI/ModelManager.swift | 126 ++++++++++++++---- .../App/MainWindow/SystemViews.swift | 2 +- Sources/GrammaGem/App/OnboardingView.swift | 81 +++++++---- Sources/GrammaGem/System/Permissions.swift | 21 ++- 4 files changed, 171 insertions(+), 59 deletions(-) diff --git a/Sources/GrammaGem/AI/ModelManager.swift b/Sources/GrammaGem/AI/ModelManager.swift index 9cdbe5a..0585109 100644 --- a/Sources/GrammaGem/AI/ModelManager.swift +++ b/Sources/GrammaGem/AI/ModelManager.swift @@ -1,9 +1,8 @@ import Foundation -/// Manages the one-time download of the local LLM weights from Hugging Face. -/// This is one of the only three network touchpoints in the product (model -/// download, license activate/validate, update checks) — and it never sends -/// user text. +/// Downloads the local LLM weights from Hugging Face on demand. This is a *real* +/// download (the model files are fetched to disk with progress) — one of the +/// only three network touchpoints in the product, and it never sends user text. @MainActor final class ModelManager: ObservableObject { enum State: Equatable { @@ -14,45 +13,116 @@ final class ModelManager: ObservableObject { } @Published private(set) var state: State = .notDownloaded + @Published private(set) var statusText: String = "" @Published var selectedRepo: String = AppConfig.Model.defaultRepo - /// Local cache directory for downloaded weights. + private var task: Task? + + init() { + if isModelPresent(repo: selectedRepo) { state = .ready } + } + var modelsDirectory: URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] return base.appendingPathComponent("GrammaGem/Models", isDirectory: true) } - func isModelPresent(repo: String) -> Bool { - let dir = modelsDirectory.appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_")) - return FileManager.default.fileExists(atPath: dir.path) + func modelDir(_ repo: String) -> URL { + modelsDirectory.appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true) } - /// TODO(real-integration): stream the model files from Hugging Face into - /// `modelsDirectory` (resumable, with progress), then hand the path to the - /// MLX runtime. This stub simulates the lifecycle so onboarding UI works. - func download(repo: String? = nil) async { + /// Considered present once the completion marker is written. + func isModelPresent(repo: String) -> Bool { + FileManager.default.fileExists(atPath: modelDir(repo).appendingPathComponent(".complete").path) + } + + /// Kick off (or resume to) a real download. Safe to call from the UI. + func startDownload(repo: String? = nil) { let target = repo ?? selectedRepo selectedRepo = target + task?.cancel() + task = Task { await self.run(repo: target) } + } - if isModelPresent(repo: target) { - state = .ready - return - } + func cancel() { + task?.cancel() + task = nil + statusText = "" + state = isModelPresent(repo: selectedRepo) ? .ready : .notDownloaded + } - do { - try FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true) - } catch { - state = .failed(error.localizedDescription) - return - } + // MARK: - Download - // Simulated progress (replace with real URLSession download tasks). + private func run(repo: String) async { + if isModelPresent(repo: repo) { state = .ready; return } + + let dir = modelDir(repo) state = .downloading(progress: 0) - for step in 1...10 { - try? await Task.sleep(nanoseconds: 120_000_000) - state = .downloading(progress: Double(step) / 10.0) + statusText = "Preparing…" + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let files = try await fetchFileList(repo: repo) + guard !files.isEmpty else { throw ModelError.empty } + let total = max(files.reduce(Int64(0)) { $0 + $1.size }, 1) + var done: Int64 = 0 + + for file in files { + try Task.checkCancellation() + statusText = "Downloading \(file.path) (\(byteString(file.size)))…" + let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/\(repo)/resolve/main/\(file.path)")! + let (tmp, response) = try await URLSession.shared.download(from: url) + if let http = response as? HTTPURLResponse, http.statusCode != 200 { + throw ModelError.http(http.statusCode, file.path) + } + let dest = dir.appendingPathComponent(file.path) + try FileManager.default.createDirectory( + at: dest.deletingLastPathComponent(), withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.removeItem(at: dest) + } + try FileManager.default.moveItem(at: tmp, to: dest) + done += file.size + state = .downloading(progress: min(1, Double(done) / Double(total))) + } + + try Data().write(to: dir.appendingPathComponent(".complete")) + statusText = "" + state = .ready + Log.ai.info("Model downloaded: \(repo, privacy: .public)") + } catch is CancellationError { + statusText = "" + state = isModelPresent(repo: repo) ? .ready : .notDownloaded + } catch { + statusText = "" + state = .failed(error.localizedDescription) + Log.ai.error("Model download failed: \(error.localizedDescription, privacy: .public)") + } + } + + /// List the repo's files (+ sizes) from the Hugging Face tree API. + private func fetchFileList(repo: String) async throws -> [(path: String, size: Int64)] { + let url = URL(string: "\(AppConfig.Model.huggingFaceBase)/api/models/\(repo)/tree/main?recursive=1")! + let (data, response) = try await URLSession.shared.data(from: url) + if let http = response as? HTTPURLResponse, http.statusCode != 200 { + throw ModelError.http(http.statusCode, "file list") + } + struct Entry: Decodable { let type: String; let path: String; let size: Int64? } + let entries = try JSONDecoder().decode([Entry].self, from: data) + return entries.filter { $0.type == "file" }.map { ($0.path, $0.size ?? 0) } + } + + private func byteString(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + + enum ModelError: LocalizedError { + case empty + case http(Int, String) + var errorDescription: String? { + switch self { + case .empty: return "No model files were found for this repository." + case .http(let code, let what): return "Download failed (\(code)) while fetching \(what)." + } } - Log.ai.info("Model marked ready (stubbed download): \(target, privacy: .public)") - state = .ready } } diff --git a/Sources/GrammaGem/App/MainWindow/SystemViews.swift b/Sources/GrammaGem/App/MainWindow/SystemViews.swift index 9ee64af..41b5d08 100644 --- a/Sources/GrammaGem/App/MainWindow/SystemViews.swift +++ b/Sources/GrammaGem/App/MainWindow/SystemViews.swift @@ -105,7 +105,7 @@ struct ModelView: View { HStack { Image(systemName: "lock.fill").foregroundStyle(GG.gold); Text("The larger model is available on the lifetime license.").font(.caption).foregroundStyle(.secondary) } } Divider() - ModelDownloadControls().environmentObject(app) + ModelDownloadControls() } Card { diff --git a/Sources/GrammaGem/App/OnboardingView.swift b/Sources/GrammaGem/App/OnboardingView.swift index 5c819cc..65a6286 100644 --- a/Sources/GrammaGem/App/OnboardingView.swift +++ b/Sources/GrammaGem/App/OnboardingView.swift @@ -1,9 +1,17 @@ import SwiftUI /// First-run onboarding: explains why GrammaGem needs Accessibility, deep-links -/// to System Settings, and advances automatically once permission is granted. +/// to System Settings, and advances automatically the moment permission is +/// granted (even if granted directly in System Settings). struct OnboardingView: View { @EnvironmentObject private var app: AppState + @ObservedObject private var permissions: Permissions + @ObservedObject private var model: ModelManager + + init() { + _permissions = ObservedObject(wrappedValue: AppState.shared.permissions) + _model = ObservedObject(wrappedValue: AppState.shared.model) + } var body: some View { VStack(alignment: .leading, spacing: 18) { @@ -18,14 +26,18 @@ struct OnboardingView: View { number: 1, title: "Grant Accessibility access", body: "GrammaGem reads the text you select and writes the correction back. macOS requires Accessibility permission for this. Your text is processed on-device and never leaves your Mac.", - granted: app.permissions.accessibilityTrusted + granted: permissions.accessibilityTrusted ) { - HStack { - Button("Open System Settings") { app.permissions.openAccessibilitySettings() } - Button("Request permission") { - app.permissions.requestAccessibility() - app.permissions.startPollingUntilGranted() + if permissions.accessibilityTrusted { + Label("Permission granted", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } else { + HStack { + Button("Open System Settings") { permissions.openAccessibilitySettings() } + Button("Request permission") { permissions.requestAccessibility() } } + Text("This updates automatically once you flip GrammaGem on in Accessibility.") + .font(.caption).foregroundStyle(.secondary) } } @@ -33,17 +45,19 @@ struct OnboardingView: View { number: 2, title: "Download the on-device model (optional)", body: "Grammar & spelling work instantly with no download. The AI rewrite/tone/Ask features use a small model downloaded once from Hugging Face — then everything runs offline.", - granted: app.model.state == .ready + granted: model.state == .ready ) { ModelDownloadControls() - .environmentObject(app) } Spacer() HStack { + if permissions.accessibilityTrusted { + Label("You're all set", systemImage: "sparkles").foregroundStyle(.secondary) + } Spacer() - Button(app.permissions.accessibilityTrusted ? "Done" : "Continue without permission") { + Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") { app.showOnboardingDismiss() } .keyboardShortcut(.defaultAction) @@ -51,7 +65,12 @@ struct OnboardingView: View { } .padding(28) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .onAppear { app.permissions.refresh() } + .onAppear { + permissions.startPollingUntilGranted() + } + .onDisappear { + permissions.stopPolling() + } } private func stepCard( @@ -79,26 +98,40 @@ struct OnboardingView: View { } } -/// Model download progress + trigger, shared by onboarding and settings. +/// Real model download progress + controls, shared by onboarding and settings. struct ModelDownloadControls: View { - @EnvironmentObject private var app: AppState + @ObservedObject private var model: ModelManager + + init() { + _model = ObservedObject(wrappedValue: AppState.shared.model) + } var body: some View { - switch app.model.state { + switch model.state { case .notDownloaded: - Button("Download model") { Task { await app.model.download() } } + VStack(alignment: .leading, spacing: 4) { + Button("Download model") { model.startDownload() } + Text("Downloads the model files from Hugging Face (about 1–2 GB). One time, then offline.") + .font(.caption).foregroundStyle(.secondary) + } case .downloading(let progress): - ProgressView(value: progress) { Text("Downloading… \(Int(progress * 100))%") } - .frame(maxWidth: 260) + VStack(alignment: .leading, spacing: 6) { + ProgressView(value: progress) { + Text("Downloading… \(Int(progress * 100))%") + } + .frame(maxWidth: 320) + if !model.statusText.isEmpty { + Text(model.statusText).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Button("Cancel") { model.cancel() }.controlSize(.small) + } case .ready: - Label("Model ready", systemImage: "checkmark.seal.fill") - .foregroundStyle(.green) + Label("Model ready", systemImage: "checkmark.seal.fill").foregroundStyle(.green) case .failed(let message): - VStack(alignment: .leading) { - Label("Download failed", systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) - Text(message).font(.caption).foregroundStyle(.secondary) - Button("Retry") { Task { await app.model.download() } } + VStack(alignment: .leading, spacing: 4) { + Label("Download failed", systemImage: "exclamationmark.triangle").foregroundStyle(.orange) + Text(message).font(.caption).foregroundStyle(.secondary).lineLimit(2) + Button("Retry") { model.startDownload() } } } } diff --git a/Sources/GrammaGem/System/Permissions.swift b/Sources/GrammaGem/System/Permissions.swift index ee349cf..044f44d 100644 --- a/Sources/GrammaGem/System/Permissions.swift +++ b/Sources/GrammaGem/System/Permissions.swift @@ -8,6 +8,7 @@ import ApplicationServices @MainActor final class Permissions: ObservableObject { @Published private(set) var accessibilityTrusted: Bool = false + private var pollTimer: Timer? init() { refresh() @@ -40,15 +41,23 @@ final class Permissions: ObservableObject { } /// Poll until the user grants Accessibility (used by onboarding to advance - /// automatically once permission flips on). Cancels when `accessibilityTrusted`. - func startPollingUntilGranted(interval: TimeInterval = 1.0) { - guard !accessibilityTrusted else { return } - Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] timer in + /// automatically once permission flips on — even if granted directly in + /// System Settings). Idempotent; stops once granted. + func startPollingUntilGranted(interval: TimeInterval = 0.8) { + refresh() + guard !accessibilityTrusted else { stopPolling(); return } + guard pollTimer == nil else { return } + pollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in Task { @MainActor in - guard let self else { timer.invalidate(); return } + guard let self else { return } self.refresh() - if self.accessibilityTrusted { timer.invalidate() } + if self.accessibilityTrusted { self.stopPolling() } } } } + + func stopPolling() { + pollTimer?.invalidate() + pollTimer = nil + } }