mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
Stability (the "super buggy" feel): - Move the Accessibility-tree traversal OFF the main thread with a per-message timeout, so a busy/wedged target app can't beachball or hang GrammaGem. - Replace every unchecked `as! AXUIElement` force-cast with a CFTypeID-checked safe cast (AXSupport), eliminating crashes from misbehaving AX servers. - Never scan GrammaGem's own windows (self-PID exclusion). - Generation tokens + in-flight guard so superseded scans/downloads can't clobber state. Text safety: - Live fixes now read the element's CURRENT value before writing (and only apply a span edit when the field still matches the snapshot), so edits made since the last scan are never overwritten. Cap per-field check length. - Stable per-field identity (AX id / position) so the Live Check list no longer churns. - Include focus + identity in change-detection so tabbing updates the focused field. Other audited fixes: - Page blocker: conservative skip when a browser's URL can't be read. - Model download: validate on-disk size (reject HTML/truncated 200s), clean up partials on cancel/failure, handle URLError.cancelled. - Licensing: clear a server-confirmed-invalid license instead of honoring grace; show the real device limit instead of "0 Macs". - Hotkey re-register falls back to the default if a chosen combo is taken. - Daily free-tier counter tracks the live timezone. - "Fix focused field" ignores non-text controls. Feature: auto-install the on-device model in the background on first launch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
139 lines
5.7 KiB
Swift
139 lines
5.7 KiB
Swift
import SwiftUI
|
||
|
||
/// First-run onboarding: explains why GrammaGem needs Accessibility, deep-links
|
||
/// 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) {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("Welcome to GrammaGem")
|
||
.font(.largeTitle.bold())
|
||
Text("A private, on-device writing assistant that works in every app.")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
stepCard(
|
||
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: permissions.accessibilityTrusted
|
||
) {
|
||
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)
|
||
}
|
||
}
|
||
|
||
stepCard(
|
||
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: model.state == .ready
|
||
) {
|
||
ModelDownloadControls()
|
||
}
|
||
|
||
Spacer()
|
||
|
||
HStack {
|
||
if permissions.accessibilityTrusted {
|
||
Label("You're all set", systemImage: "sparkles").foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Button(permissions.accessibilityTrusted ? "Done" : "Continue without permission") {
|
||
app.showOnboardingDismiss()
|
||
}
|
||
.keyboardShortcut(.defaultAction)
|
||
}
|
||
}
|
||
.padding(28)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
.onAppear {
|
||
permissions.startPollingUntilGranted()
|
||
}
|
||
.onDisappear {
|
||
permissions.stopPolling()
|
||
}
|
||
}
|
||
|
||
private func stepCard<Controls: View>(
|
||
number: Int, title: String, body: String, granted: Bool,
|
||
@ViewBuilder controls: () -> Controls
|
||
) -> some View {
|
||
HStack(alignment: .top, spacing: 14) {
|
||
ZStack {
|
||
Circle().fill(granted ? Color.green : Color.accentColor.opacity(0.15))
|
||
.frame(width: 30, height: 30)
|
||
if granted {
|
||
Image(systemName: "checkmark").foregroundStyle(.white).font(.caption.bold())
|
||
} else {
|
||
Text("\(number)").font(.callout.bold()).foregroundStyle(.tint)
|
||
}
|
||
}
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(title).font(.headline)
|
||
Text(body).font(.callout).foregroundStyle(.secondary)
|
||
controls()
|
||
}
|
||
}
|
||
.padding(16)
|
||
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12))
|
||
}
|
||
}
|
||
|
||
/// Real model download progress + controls, shared by onboarding and settings.
|
||
struct ModelDownloadControls: View {
|
||
@ObservedObject private var model: ModelManager
|
||
|
||
init() {
|
||
_model = ObservedObject(wrappedValue: AppState.shared.model)
|
||
}
|
||
|
||
var body: some View {
|
||
switch model.state {
|
||
case .notDownloaded:
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Button("Download model now") { model.startDownload() }
|
||
Text("Installs automatically in the background on first launch (about 1–2 GB from Hugging Face). One time, then fully offline.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
case .downloading(let progress):
|
||
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)
|
||
case .failed(let message):
|
||
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() }
|
||
}
|
||
}
|
||
}
|
||
}
|