mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
Wave 1 (correctness/trust): - Fix paid large-model dead path: engine reads the persisted selected repo via a thread-safe ModelManager.activeRepo(), so download/load/isReady now agree. - Lemon Squeezy store/product check fails closed in release builds — the placeholder-ID 'any key unlocks paid' bypass can no longer ship. - Replace force-unwrapped Hugging Face URLs with safe URLComponents construction. - Never read/replace/scan secure (password) fields via AX.isSecureTextField. Wave 2 (compounding seam): - Extract AIPromptKit (prompts/temperature/maxTokens/cleanup) out of MLXEngine so every backend produces identical prompts and output. - AIEngine gains protectedTerms (personal dictionary now actually injected into the LLM), warmup(), and runStreaming(); add makeAIEngine() factory. - Honor WritingMode.lengthCap for maxTokens; warm the model on launch/after download. - Guard custom modes against empty prompts and duplicate names. Wave 3: OllamaEngine — a URLSession-only AIEngine backend (cross-platform, opt-in via GRAMMARGEM_AI_BACKEND=ollama). Wave 4: LocalEngineServer — loopback-only, token-authed HTTP bridge (Network.framework, no new deps), off by default, exposing Harper + the LLM + the entitlement gate to a browser extension. Tests: 6 new unit tests for the AIPrompts seam and custom-mode guard (19 pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
6.2 KiB
Swift
140 lines
6.2 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 GrammarGem + 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 GrammarGem.
|
|
private func verifyAndResolveTier(meta: LSMeta?, licenseKey: LSLicenseKey?) throws -> Tier {
|
|
guard let meta else { throw LicenseError.wrongProduct }
|
|
|
|
// Store + product check. With real numeric IDs configured this is a hard
|
|
// gate that prevents a key from another Lemon Squeezy product unlocking
|
|
// GrammarGem. If AppConfig still holds placeholder (non-numeric) IDs the
|
|
// gate can't run — allow the bypass ONLY in DEBUG so local stub flows work;
|
|
// in a release build FAIL CLOSED so the "any key unlocks paid" bypass can
|
|
// never ship. TODO(real-integration): set real IDs in AppConfig.LemonSqueezy.
|
|
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 {
|
|
#if DEBUG
|
|
Log.licensing.warning("Skipping store/product hard-verify — placeholder IDs in AppConfig (DEBUG only).")
|
|
#else
|
|
Log.licensing.error("Refusing to verify license: placeholder Lemon Squeezy IDs in a release build.")
|
|
throw LicenseError.wrongProduct
|
|
#endif
|
|
}
|
|
|
|
// 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: "&")
|
|
}
|
|
}
|