BigMarc 35ae2065b3 Fix 22 audited bugs: stop hangs/crashes/text-corruption; auto-install model
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>
2026-06-17 16:08:35 +02:00

102 lines
3.4 KiB
Swift

import Foundation
/// The runtime gate every paid path goes through. Combines current `Entitlements`
/// (from the license) with a local daily counter for the free tier's AI allowance.
@MainActor
final class FeatureGate: ObservableObject {
enum Decision: Equatable {
case allowed
case denied(String)
}
private let license: LicenseManager
private let counter = DailyCounter()
/// Bumped to nudge SwiftUI when the counter changes (it's stored externally).
@Published private(set) var usageTick = 0
init(license: LicenseManager) {
self.license = license
}
var entitlements: Entitlements { license.entitlements }
// MARK: - AI gating (rewrite / tone / Ask / translate / paid Modes share this)
func authorizeAI(_ action: AIAction) -> Decision {
let ent = entitlements
// Paid Modes are unavailable on free (only Polish ships free).
if case .applyMode(let mode) = action, mode.isPaid, !ent.allModes {
return .denied("“\(mode.name)” mode is part of the lifetime upgrade.")
}
if ent.unlimitedAIActions { return .allowed }
guard remainingAIActionsToday > 0 else {
return .denied("You've used today's \(ent.dailyAIActionCap) free AI actions. Upgrade for unlimited.")
}
return .allowed
}
func recordAIActionUsed() {
guard !entitlements.unlimitedAIActions else { return }
counter.increment()
usageTick &+= 1
}
var aiActionsUsedToday: Int { counter.countToday() }
var remainingAIActionsToday: Int {
let ent = entitlements
if ent.unlimitedAIActions { return Int.max }
return max(0, ent.dailyAIActionCap - counter.countToday())
}
// MARK: - Other gates
func availableModes() -> [WritingMode] {
ModeRegistry.available(for: license.tier)
}
var appAwareEnabled: Bool { entitlements.appAwareSwitching }
var snippetsEnabled: Bool { entitlements.snippets }
/// Can the user add another personal-dictionary entry given the current count?
func canAddDictionaryEntry(currentCount: Int) -> Decision {
if currentCount < entitlements.dictionaryCap { return .allowed }
return .denied("Free dictionary holds \(entitlements.dictionaryCap) entries. Upgrade for unlimited.")
}
}
/// Free-tier AI action counter. Persists `{date, count}` in UserDefaults and
/// resets at local midnight (compares the stored local date string to today's).
final class DailyCounter {
private let key = "GrammaGem.aiActions.daily"
private let defaults = UserDefaults.standard
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = .autoupdatingCurrent // tracks the live local zone (travel/TZ change)
return f
}()
private func today() -> String { Self.formatter.string(from: Date()) }
func countToday() -> Int {
guard let stored = defaults.dictionary(forKey: key),
let date = stored["date"] as? String, date == today()
else { return 0 }
return stored["count"] as? Int ?? 0
}
func increment() {
let next = countToday() + 1
defaults.set(["date": today(), "count": next], forKey: key)
}
func reset() { defaults.removeObject(forKey: key) }
}