grammargem/Sources/GrammaGem/Licensing/LicenseManager.swift
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

123 lines
4.9 KiB
Swift

import Foundation
/// Owns license state and maps it to an `Entitlements` value the rest of the app
/// gates on. Implements the two gaps Lemon Squeezy leaves to us (spec §6):
/// device fingerprinting (via `DeviceFingerprint`) and **offline grace**.
@MainActor
final class LicenseManager: ObservableObject {
@Published private(set) var record: LicenseRecord?
@Published private(set) var lastError: String?
@Published private(set) var isWorking = false
private let client: LemonSqueezyClient
private let account = "license"
init(client: LemonSqueezyClient = LemonSqueezyClient()) {
self.client = client
self.record = Keychain.getObject(LicenseRecord.self, account: account)
}
/// Effective tier *right now*, honoring the 30-day offline grace: if the
/// device hasn't validated within the window, downgrade to Free rather than
/// hard-lock (writing features never need the network anyway).
var tier: Tier {
guard let record else { return .free }
let grace = TimeInterval(AppConfig.Limits.offlineGraceDays * 86_400)
if Date().timeIntervalSince(record.lastValidated) > grace {
return .free
}
return record.tier
}
var isLicensed: Bool { tier.isPaid }
var entitlements: Entitlements { Entitlements(tier: tier) }
// MARK: - Flows
/// Activate a pasted key against this device.
func activate(key: String) async {
let key = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { lastError = "Enter a license key."; return }
isWorking = true; lastError = nil
defer { isWorking = false }
do {
let instanceName = DeviceFingerprint.stableID()
let result = try await client.activate(key: key, instanceName: instanceName)
let rec = LicenseRecord(
key: key, instanceID: result.instanceID, tier: result.tier,
lastValidated: Date(), activatedAt: Date(),
deviceName: DeviceFingerprint.deviceName(),
activationUsage: result.usage, activationLimit: result.limit)
Keychain.setObject(rec, account: account)
record = rec
Log.licensing.info("Activated tier \(result.tier.rawValue, privacy: .public)")
} catch {
lastError = error.localizedDescription
}
}
/// On launch + every ~7 days. Network failures are non-fatal (offline grace).
func validateIfNeeded(force: Bool = false) async {
guard var rec = record else { return }
let interval = TimeInterval(AppConfig.Limits.validationIntervalDays * 86_400)
guard force || Date().timeIntervalSince(rec.lastValidated) > interval else { return }
do {
let result = try await client.validate(key: rec.key, instanceID: rec.instanceID)
if result.valid {
rec.lastValidated = Date()
rec.tier = result.tier
rec.activationUsage = result.usage
rec.activationLimit = result.limit
Keychain.setObject(rec, account: account)
record = rec
} else {
// Explicitly invalid (refunded / disabled) → drop the license.
Log.licensing.notice("License reported invalid by server; clearing.")
clearLocal()
}
} catch let e as LicenseError where Self.isServerConfirmedInvalid(e) {
// The server actively rejected the key (refunded/disabled/wrong product)
// — drop it now rather than honoring it for the 30-day grace window.
Log.licensing.notice("License rejected by server; clearing.")
clearLocal()
} catch {
// Offline / transient: keep the cached license under the grace window.
Log.licensing.notice("Validation deferred (offline). Cached license stands under grace.")
}
}
/// Distinguish a server-confirmed-invalid license from a transient/offline failure.
private static func isServerConfirmedInvalid(_ error: LicenseError) -> Bool {
switch error {
case .invalidKey, .wrongProduct:
return true
case .serverMessage(let m):
let s = m.lowercased()
return ["disabled", "expired", "inactive", "revoked", "refund"].contains { s.contains($0) }
case .network, .activationLimitReached:
return false
}
}
/// Free a device slot so the license can move to another Mac.
func deactivateThisDevice() async {
guard let rec = record else { return }
isWorking = true; lastError = nil
defer { isWorking = false }
do {
_ = try await client.deactivate(key: rec.key, instanceID: rec.instanceID)
clearLocal()
} catch {
lastError = error.localizedDescription
}
}
func clearLocal() {
Keychain.delete(account: account)
record = nil
}
}