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

132 lines
5.7 KiB
Swift

import Foundation
/// Talks to the **Lemon Squeezy License API** directly from the app — no server
/// of ours, no database, no per-user cost (spec §6). Exposes activate / validate
/// / deactivate and hard-verifies that a key belongs to GrammaGem + the claimed tier.
final class LemonSqueezyClient {
private let session: URLSession
private let base = AppConfig.LemonSqueezy.apiBase
init(session: URLSession = .shared) {
self.session = session
}
// MARK: - Endpoints
func activate(key: String, instanceName: String) async throws -> ActivationResult {
let res: LSActivateResponse = try await post(
"licenses/activate",
params: ["license_key": key, "instance_name": instanceName])
if let err = res.error { throw mapServerError(err, limit: res.license_key?.activation_limit) }
guard res.activated, let instance = res.instance else {
throw LicenseError.serverMessage("Activation failed.")
}
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key)
return ActivationResult(
instanceID: instance.id, tier: tier,
usage: res.license_key?.activation_usage ?? 1,
limit: res.license_key?.activation_limit ?? tier.deviceCap)
}
func validate(key: String, instanceID: String?) async throws -> ValidationResult {
var params = ["license_key": key]
if let instanceID { params["instance_id"] = instanceID }
let res: LSValidateResponse = try await post("licenses/validate", params: params)
if let err = res.error { throw mapServerError(err, limit: res.license_key?.activation_limit) }
let tier = try verifyAndResolveTier(meta: res.meta, licenseKey: res.license_key)
return ValidationResult(
valid: res.valid, tier: tier,
usage: res.license_key?.activation_usage ?? 0,
limit: res.license_key?.activation_limit ?? tier.deviceCap)
}
@discardableResult
func deactivate(key: String, instanceID: String) async throws -> Bool {
let res: LSDeactivateResponse = try await post(
"licenses/deactivate",
params: ["license_key": key, "instance_id": instanceID])
if let err = res.error { throw mapServerError(err) }
return res.deactivated
}
// MARK: - Hard verification (spec §6)
/// Confirm the key is for OUR store/product, then resolve which tier its
/// variant corresponds to. Prevents a key from another LS product unlocking GrammaGem.
private func verifyAndResolveTier(meta: LSMeta?, licenseKey: LSLicenseKey?) throws -> Tier {
guard let meta else { throw LicenseError.wrongProduct }
// Store + product check. If AppConfig still holds placeholder (non-numeric)
// IDs, we log and skip the check so the stub flow runs — TODO(real-integration):
// once real numeric IDs are set, this becomes a hard gate.
if let expectedStore = Int(AppConfig.LemonSqueezy.expectedStoreID),
let expectedProduct = Int(AppConfig.LemonSqueezy.expectedProductID) {
guard meta.store_id == expectedStore, meta.product_id == expectedProduct else {
throw LicenseError.wrongProduct
}
} else {
Log.licensing.warning("Skipping store/product hard-verify — placeholder IDs in AppConfig.")
}
// Resolve tier from the variant id (reverse-lookup the configured map).
if let variantID = meta.variant_id {
for (tier, idString) in AppConfig.LemonSqueezy.variantID {
if Int(idString) == variantID { return tier }
}
}
// Fall back to whatever the variant name hints at, else Solo.
switch meta.variant_name?.lowercased() {
case let v? where v.contains("studio"): return .studio
case let v? where v.contains("personal"): return .personal
default: return .solo
}
}
private func mapServerError(_ message: String, limit: Int? = nil) -> LicenseError {
let m = message.lowercased()
if m.contains("activation limit") {
return .activationLimitReached(limit ?? 0)
}
if m.contains("not found") || m.contains("invalid") {
return .invalidKey
}
return .serverMessage(message)
}
// MARK: - Transport
private func post<T: Decodable>(_ path: String, params: [String: String]) async throws -> T {
var request = URLRequest(url: base.appendingPathComponent(path))
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = formEncode(params).data(using: .utf8)
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw LicenseError.network("No HTTP response")
}
// LS returns 200 + {activated/valid:false} for soft failures, and 4xx
// with a JSON `error`. Try to decode the body either way.
if let decoded = try? JSONDecoder().decode(T.self, from: data) {
return decoded
}
throw LicenseError.network("Unexpected response (\(http.statusCode))")
} catch let e as LicenseError {
throw e
} catch {
throw LicenseError.network(error.localizedDescription)
}
}
private func formEncode(_ params: [String: String]) -> String {
params.map { key, value in
let v = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
return "\(key)=\(v)"
}.joined(separator: "&")
}
}