// AppsXi Level 1 license helper for Mac apps (PRD 15.4, LIC-5). One file, no dependencies: // Foundation, CryptoKit, IOKit and AppKit. macOS 12 or later. // // let license = AppXiLicense(productKey: "pk_live_…", publicKeys: [""], // appSlug: "my-app") // let result = try await license.activate(key: "AXI-…") // once, online // if let e = license.current() { unlock(e) } // every launch, offline // if license.needsRefresh() { _ = try? await license.refresh() } // // Sparkle: return license.updateFeed()?.appcastURL from your SPUUpdaterDelegate's feedURLString(for:). import AppKit import CryptoKit import Foundation import IOKit public struct AppXiEntitlement: Equatable { public let status: String // "active" or "trial" public let license: String public let buyer: String public let environment: String // "live" or "sandbox" public let expiresAt: Date public let trialExpiresAt: Date? public let seats: Int } public enum AppXiValidation: Equatable { case valid(AppXiEntitlement?, seatsUsed: Int, seatsMax: Int) case invalid(reason: String) // seat_limit, revoked, expired, unknown_license, trial_used_on_device, … } public final class AppXiLicense { private let productKey: String private let publicKeys: [Curve25519.Signing.PublicKey] private let appSlug: String private let api: URL private let file: URL private struct Cache: Codable { var key: String var token: String? var trial_expires_at: String? var feed_base: String? var feed_token: String? } public init(productKey: String, publicKeys: [String], appSlug: String, storageDirectory: URL? = nil, apiBase: URL = URL(string: "https://appsxi.com")!) { self.productKey = productKey self.publicKeys = publicKeys.compactMap { b64url($0).flatMap { try? Curve25519.Signing.PublicKey(rawRepresentation: $0) } } self.appSlug = appSlug self.api = apiBase let dir = storageDirectory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent(Bundle.main.bundleIdentifier ?? appSlug, isDirectory: true) self.file = dir.appendingPathComponent("appxi-license.json") } /// SHA-256 of the Mac's hardware UUID with this app's product key and slug. Never the raw id. public func deviceID() -> String { var raw = "" let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")) if service != 0 { if let uuid = IORegistryEntryCreateCFProperty(service, "IOPlatformUUID" as CFString, kCFAllocatorDefault, 0)? .takeRetainedValue() as? String { raw = uuid } IOObjectRelease(service) } if raw.isEmpty { raw = Host.current().localizedName ?? "mac" } let digest = SHA256.hash(data: Data("\(raw):\(productKey.prefix(8)):\(appSlug)".utf8)) return digest.map { String(format: "%02x", $0) }.joined() } /// Validates a key online, takes a seat on this Mac, and caches the signed token. @discardableResult public func activate(key: String) async throws -> AppXiValidation { let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) var req = URLRequest(url: api.appendingPathComponent("api/v1/licenses/validate"), timeoutInterval: 15) req.httpMethod = "POST" req.setValue("Bearer \(productKey)", forHTTPHeaderField: "Authorization") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: [ "license_key": trimmed, "device_id": deviceID(), "device_name": String((Host.current().localizedName ?? "Mac").prefix(80)), "platform": "macos", ]) let (data, _) = try await URLSession.shared.data(for: req) let j = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:] guard j["valid"] as? Bool == true else { let reason = j["reason"] as? String ?? "invalid" if ["revoked", "expired", "unknown_license"].contains(reason) { forget() } return .invalid(reason: reason) } let updates = j["updates"] as? [String: Any] save(Cache(key: trimmed, token: j["token"] as? String, trial_expires_at: j["trial_expires_at"] as? String, feed_base: updates?["feed_base"] as? String, feed_token: updates?["feed_token"] as? String)) return .valid(current(), seatsUsed: j["seats_used"] as? Int ?? 0, seatsMax: j["seats_max"] as? Int ?? 0) } /// Validates the cached key again. Call when needsRefresh() is true and the network is up. public func refresh() async throws -> AppXiValidation { guard let c = load() else { return .invalid(reason: "no_license") } return try await activate(key: c.key) } /// The verified entitlement from the cached token, offline. Nil if missing, tampered or expired. public func current(now: Date = Date()) -> AppXiEntitlement? { guard let c = load(), let token = c.token, let claims = verify(token) else { return nil } guard let exp = claims["exp"] as? Double, exp > now.timeIntervalSince1970 else { return nil } guard let status = claims["status"] as? String, status == "active" || status == "trial" else { return nil } let env = productKey.hasPrefix("pk_test_") ? "sandbox" : "live" guard claims["env"] as? String == env else { return nil } let trialEnd = status == "trial" ? c.trial_expires_at.flatMap(parseDate) : nil return AppXiEntitlement(status: status, license: claims["license"] as? String ?? "", buyer: claims["sub"] as? String ?? "", environment: env, expiresAt: Date(timeIntervalSince1970: exp), trialExpiresAt: trialEnd, seats: (claims["seats"] as? [String: Any])?["max"] as? Int ?? 1) } /// True when the token is missing or has less than seven days left. public func needsRefresh(now: Date = Date()) -> Bool { guard let e = current(now: now) else { return true } return e.expiresAt.timeIntervalSince(now) < 7 * 86400 } public func trialStatus(now: Date = Date()) -> (inTrial: Bool, daysLeft: Int, expired: Bool) { if let e = current(now: now), e.status == "trial" { let end = e.trialExpiresAt ?? e.expiresAt return (true, max(0, Int(ceil(end.timeIntervalSince(now) / 86400))), end <= now) } let expired = current(now: now) == nil && (load()?.trial_expires_at.flatMap(parseDate).map { $0 <= now } ?? false) return (false, 0, expired) } public var checkoutURL: URL { api.appendingPathComponent("checkout").appendingPathComponent(appSlug) } /// Opens your AppsXi checkout in the browser. public func openCheckout() { NSWorkspace.shared.open(checkoutURL) } /// The license-bound update feed for paid apps (nil before activation). public func updateFeed(channel: String = "stable") -> (appcastURL: String, authorization: String)? { guard let c = load(), let base = c.feed_base, let token = c.feed_token else { return nil } return ("\(base)/macos/\(channel)/appcast.xml?token=\(token)", "Bearer \(token)") } /// Removes the cached key and token (sign out, or after a refund). public func forget() { try? Data("{}".utf8).write(to: file, options: .atomic) } // MARK: - token verification private func verify(_ token: String) -> [String: Any]? { let parts = token.split(separator: ".", omittingEmptySubsequences: false).map(String.init) guard parts.count == 3, let h = b64url(parts[0]), let p = b64url(parts[1]), let sig = b64url(parts[2]), let header = try? JSONSerialization.jsonObject(with: h) as? [String: Any], header["alg"] as? String == "EdDSA" else { return nil } let signed = Data("\(parts[0]).\(parts[1])".utf8) guard publicKeys.contains(where: { $0.isValidSignature(sig, for: signed) }) else { return nil } return try? JSONSerialization.jsonObject(with: p) as? [String: Any] } private func load() -> Cache? { guard let d = try? Data(contentsOf: file), let c = try? JSONDecoder().decode(Cache.self, from: d), !c.key.isEmpty else { return nil } return c } private func save(_ c: Cache) { try? FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) if let d = try? JSONEncoder().encode(c) { try? d.write(to: file, options: [.atomic]) try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) } } } private func b64url(_ s: String) -> Data? { var t = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") while t.count % 4 != 0 { t += "=" } return Data(base64Encoded: t) } private func parseDate(_ raw: String) -> Date? { // Postgres sends microseconds; ISO8601DateFormatter reads at most milliseconds. let s = raw.replacingOccurrences(of: #"(\.\d{3})\d+"#, with: "$1", options: .regularExpression) let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f.date(from: s) ?? ISO8601DateFormatter().date(from: s) }