grammargem/Sources/GrammarGem/Licensing/LemonSqueezyClient.swift
BigMarc 3d749e8139 feat: Implement LiveMonitor for real-time grammar and spelling checks across applications
- Added LiveMonitor class to scan text areas in the frontmost window for grammar and spelling issues.
- Introduced DetectedField struct to represent text areas with issues, including suggestions for corrections.
- Implemented functionality to apply corrections and dismiss suggestions.
- Added Permissions class to manage Accessibility permissions required for monitoring.
- Created SnippetStore for managing text-expansion snippets, including persistence to UserDefaults.
- Developed TextCapture class to read and write text from the frontmost application using Accessibility API and clipboard fallback.
- Implemented TextFieldScanner to traverse the Accessibility tree and collect editable text elements.
- Added TextReplacementCoordinator to orchestrate the text correction process.
- Integrated UpdaterManager for handling application updates using Sparkle.
- Created comprehensive unit tests for various components, including grammar corrections and entitlement checks.
2026-06-18 16:49:55 +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: "&")
}
}