mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
Real model download; live Accessibility detection in onboarding
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
bb096bed7b
commit
e952eb441c
@ -1,9 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Manages the one-time download of the local LLM weights from Hugging Face.
|
/// Downloads the local LLM weights from Hugging Face on demand. This is a *real*
|
||||||
/// This is one of the only three network touchpoints in the product (model
|
/// download (the model files are fetched to disk with progress) — one of the
|
||||||
/// download, license activate/validate, update checks) — and it never sends
|
/// only three network touchpoints in the product, and it never sends user text.
|
||||||
/// user text.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ModelManager: ObservableObject {
|
final class ModelManager: ObservableObject {
|
||||||
enum State: Equatable {
|
enum State: Equatable {
|
||||||
@ -14,45 +13,116 @@ final class ModelManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Published private(set) var state: State = .notDownloaded
|
@Published private(set) var state: State = .notDownloaded
|
||||||
|
@Published private(set) var statusText: String = ""
|
||||||
@Published var selectedRepo: String = AppConfig.Model.defaultRepo
|
@Published var selectedRepo: String = AppConfig.Model.defaultRepo
|
||||||
|
|
||||||
/// Local cache directory for downloaded weights.
|
private var task: Task<Void, Never>?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if isModelPresent(repo: selectedRepo) { state = .ready }
|
||||||
|
}
|
||||||
|
|
||||||
var modelsDirectory: URL {
|
var modelsDirectory: URL {
|
||||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
return base.appendingPathComponent("GrammaGem/Models", isDirectory: true)
|
return base.appendingPathComponent("GrammaGem/Models", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isModelPresent(repo: String) -> Bool {
|
func modelDir(_ repo: String) -> URL {
|
||||||
let dir = modelsDirectory.appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"))
|
modelsDirectory.appendingPathComponent(repo.replacingOccurrences(of: "/", with: "_"), isDirectory: true)
|
||||||
return FileManager.default.fileExists(atPath: dir.path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TODO(real-integration): stream the model files from Hugging Face into
|
/// Considered present once the completion marker is written.
|
||||||
/// `modelsDirectory` (resumable, with progress), then hand the path to the
|
func isModelPresent(repo: String) -> Bool {
|
||||||
/// MLX runtime. This stub simulates the lifecycle so onboarding UI works.
|
FileManager.default.fileExists(atPath: modelDir(repo).appendingPathComponent(".complete").path)
|
||||||
func download(repo: String? = nil) async {
|
}
|
||||||
|
|
||||||
|
/// Kick off (or resume to) a real download. Safe to call from the UI.
|
||||||
|
func startDownload(repo: String? = nil) {
|
||||||
let target = repo ?? selectedRepo
|
let target = repo ?? selectedRepo
|
||||||
selectedRepo = target
|
selectedRepo = target
|
||||||
|
task?.cancel()
|
||||||
|
task = Task { await self.run(repo: target) }
|
||||||
|
}
|
||||||
|
|
||||||
if isModelPresent(repo: target) {
|
func cancel() {
|
||||||
state = .ready
|
task?.cancel()
|
||||||
return
|
task = nil
|
||||||
}
|
statusText = ""
|
||||||
|
state = isModelPresent(repo: selectedRepo) ? .ready : .notDownloaded
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
// MARK: - Download
|
||||||
try FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true)
|
|
||||||
} catch {
|
|
||||||
state = .failed(error.localizedDescription)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
state = .downloading(progress: 0)
|
||||||
for step in 1...10 {
|
statusText = "Preparing…"
|
||||||
try? await Task.sleep(nanoseconds: 120_000_000)
|
do {
|
||||||
state = .downloading(progress: Double(step) / 10.0)
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) }
|
HStack { Image(systemName: "lock.fill").foregroundStyle(GG.gold); Text("The larger model is available on the lifetime license.").font(.caption).foregroundStyle(.secondary) }
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
ModelDownloadControls().environmentObject(app)
|
ModelDownloadControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
Card {
|
Card {
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// First-run onboarding: explains why GrammaGem needs Accessibility, deep-links
|
/// 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 {
|
struct OnboardingView: View {
|
||||||
@EnvironmentObject private var app: AppState
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
@ -18,14 +26,18 @@ struct OnboardingView: View {
|
|||||||
number: 1,
|
number: 1,
|
||||||
title: "Grant Accessibility access",
|
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.",
|
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 {
|
if permissions.accessibilityTrusted {
|
||||||
Button("Open System Settings") { app.permissions.openAccessibilitySettings() }
|
Label("Permission granted", systemImage: "checkmark.circle.fill")
|
||||||
Button("Request permission") {
|
.foregroundStyle(.green)
|
||||||
app.permissions.requestAccessibility()
|
} else {
|
||||||
app.permissions.startPollingUntilGranted()
|
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,
|
number: 2,
|
||||||
title: "Download the on-device model (optional)",
|
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.",
|
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()
|
ModelDownloadControls()
|
||||||
.environmentObject(app)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
|
if permissions.accessibilityTrusted {
|
||||||
|
Label("You're all set", systemImage: "sparkles").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(app.permissions.accessibilityTrusted ? "Done" : "Continue without permission") {
|
Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") {
|
||||||
app.showOnboardingDismiss()
|
app.showOnboardingDismiss()
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
@ -51,7 +65,12 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
.padding(28)
|
.padding(28)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.onAppear { app.permissions.refresh() }
|
.onAppear {
|
||||||
|
permissions.startPollingUntilGranted()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
permissions.stopPolling()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stepCard<Controls: View>(
|
private func stepCard<Controls: View>(
|
||||||
@ -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 {
|
struct ModelDownloadControls: View {
|
||||||
@EnvironmentObject private var app: AppState
|
@ObservedObject private var model: ModelManager
|
||||||
|
|
||||||
|
init() {
|
||||||
|
_model = ObservedObject(wrappedValue: AppState.shared.model)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch app.model.state {
|
switch model.state {
|
||||||
case .notDownloaded:
|
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):
|
case .downloading(let progress):
|
||||||
ProgressView(value: progress) { Text("Downloading… \(Int(progress * 100))%") }
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
.frame(maxWidth: 260)
|
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:
|
case .ready:
|
||||||
Label("Model ready", systemImage: "checkmark.seal.fill")
|
Label("Model ready", systemImage: "checkmark.seal.fill").foregroundStyle(.green)
|
||||||
.foregroundStyle(.green)
|
|
||||||
case .failed(let message):
|
case .failed(let message):
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Label("Download failed", systemImage: "exclamationmark.triangle")
|
Label("Download failed", systemImage: "exclamationmark.triangle").foregroundStyle(.orange)
|
||||||
.foregroundStyle(.orange)
|
Text(message).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||||
Text(message).font(.caption).foregroundStyle(.secondary)
|
Button("Retry") { model.startDownload() }
|
||||||
Button("Retry") { Task { await app.model.download() } }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import ApplicationServices
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class Permissions: ObservableObject {
|
final class Permissions: ObservableObject {
|
||||||
@Published private(set) var accessibilityTrusted: Bool = false
|
@Published private(set) var accessibilityTrusted: Bool = false
|
||||||
|
private var pollTimer: Timer?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
refresh()
|
refresh()
|
||||||
@ -40,15 +41,23 @@ final class Permissions: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Poll until the user grants Accessibility (used by onboarding to advance
|
/// Poll until the user grants Accessibility (used by onboarding to advance
|
||||||
/// automatically once permission flips on). Cancels when `accessibilityTrusted`.
|
/// automatically once permission flips on — even if granted directly in
|
||||||
func startPollingUntilGranted(interval: TimeInterval = 1.0) {
|
/// System Settings). Idempotent; stops once granted.
|
||||||
guard !accessibilityTrusted else { return }
|
func startPollingUntilGranted(interval: TimeInterval = 0.8) {
|
||||||
Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] timer in
|
refresh()
|
||||||
|
guard !accessibilityTrusted else { stopPolling(); return }
|
||||||
|
guard pollTimer == nil else { return }
|
||||||
|
pollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self else { timer.invalidate(); return }
|
guard let self else { return }
|
||||||
self.refresh()
|
self.refresh()
|
||||||
if self.accessibilityTrusted { timer.invalidate() }
|
if self.accessibilityTrusted { self.stopPolling() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stopPolling() {
|
||||||
|
pollTimer?.invalidate()
|
||||||
|
pollTimer = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user