diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 65df22fd0..02568a163 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -122,6 +122,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { + // Start license periodic validation + Task { @MainActor in + LicenseManager.shared.startPeriodicValidation() + } + // Configure windows after app launch configureWelcomeWindow() diff --git a/TablePro/Core/Services/LicenseAPIClient.swift b/TablePro/Core/Services/LicenseAPIClient.swift new file mode 100644 index 000000000..5268c087e --- /dev/null +++ b/TablePro/Core/Services/LicenseAPIClient.swift @@ -0,0 +1,128 @@ +// +// LicenseAPIClient.swift +// TablePro +// +// URLSession-based HTTP client for license activation, validation, and deactivation +// + +import Foundation +import os + +/// HTTP client for the TablePro license API +final class LicenseAPIClient { + static let shared = LicenseAPIClient() + + private static let logger = Logger(subsystem: "com.TablePro", category: "LicenseAPIClient") + + // swiftlint:disable:next force_unwrapping + private let baseURL = URL(string: "https://license.tablepro.app/api/v1/license")! + + private let session: URLSession + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + return decoder + }() + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + self.session = URLSession(configuration: config) + } + + // MARK: - Public API + + /// Activate a license key on this machine + func activate(request: LicenseActivationRequest) async throws -> SignedLicensePayload { + let url = baseURL.appendingPathComponent("activate") + return try await post(url: url, body: request) + } + + /// Validate an existing activation (periodic re-validation) + func validate(request: LicenseValidationRequest) async throws -> SignedLicensePayload { + let url = baseURL.appendingPathComponent("validate") + return try await post(url: url, body: request) + } + + /// Deactivate a license key from this machine + func deactivate(request: LicenseDeactivationRequest) async throws { + let url = baseURL.appendingPathComponent("deactivate") + let _: SignedLicensePayload? = try? await post(url: url, body: request) + // Deactivation succeeds as long as we don't get an error + } + + // MARK: - Private + + private func post(url: URL, body: T) async throws -> R { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try encoder.encode(body) + + let data: Data + let response: URLResponse + + do { + (data, response) = try await session.data(for: request) + } catch { + Self.logger.error("Network request failed: \(error.localizedDescription)") + throw LicenseError.networkError(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw LicenseError.networkError( + URLError(.badServerResponse) + ) + } + + switch httpResponse.statusCode { + case 200...299: + do { + return try decoder.decode(R.self, from: data) + } catch { + Self.logger.error("Failed to decode response: \(error.localizedDescription)") + throw LicenseError.decodingError(error) + } + + case 404: + throw LicenseError.invalidKey + + case 409: + // Conflict — activation limit reached + throw LicenseError.activationLimitReached + + case 403: + // Parse error message to determine specific error + if let errorResponse = try? decoder.decode(LicenseAPIErrorResponse.self, from: data) { + let msg = errorResponse.message.lowercased() + if msg.contains("suspend") { + throw LicenseError.licenseSuspended + } else if msg.contains("expir") { + throw LicenseError.licenseExpired + } else if msg.contains("not activated") || msg.contains("not found") { + throw LicenseError.notActivated + } + throw LicenseError.serverError(403, errorResponse.message) + } + throw LicenseError.serverError(403, "Forbidden") + + default: + let message: String + if let errorResponse = try? decoder.decode(LicenseAPIErrorResponse.self, from: data) { + message = errorResponse.message + } else { + message = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode) + } + Self.logger.error("Server error \(httpResponse.statusCode): \(message)") + throw LicenseError.serverError(httpResponse.statusCode, message) + } + } +} diff --git a/TablePro/Core/Services/LicenseManager.swift b/TablePro/Core/Services/LicenseManager.swift new file mode 100644 index 000000000..2507dcab3 --- /dev/null +++ b/TablePro/Core/Services/LicenseManager.swift @@ -0,0 +1,277 @@ +// +// LicenseManager.swift +// TablePro +// +// Orchestrates license activation, offline verification, and periodic re-validation +// + +import Combine +import Foundation +import os + +/// Manages the app's license state with offline-first verification +@MainActor +final class LicenseManager: ObservableObject { + static let shared = LicenseManager() + + private static let logger = Logger(subsystem: "com.TablePro", category: "LicenseManager") + + /// Current cached license (nil = unlicensed) + @Published private(set) var license: License? + + /// Current license status + @Published private(set) var status: LicenseStatus = .unlicensed + + /// Whether a network operation is in progress + @Published private(set) var isValidating: Bool = false + + /// Last error from an operation (cleared on success) + @Published private(set) var lastError: LicenseError? + + private let storage = LicenseStorage.shared + private let apiClient = LicenseAPIClient.shared + private let verifier = LicenseSignatureVerifier.shared + + /// Re-validation interval: 7 days + private let revalidationInterval: TimeInterval = 7 * 24 * 60 * 60 + + /// Grace period: 30 days without server contact before forcing re-validation + private let gracePeriodDays = 30 + + private var revalidationTimer: Timer? + + private init() { + loadCachedLicense() + } + + // MARK: - Startup + + /// Load cached license from storage and re-verify its signature offline + private func loadCachedLicense() { + guard let cached = storage.loadLicense() else { + status = .unlicensed + return + } + + // Verify license belongs to this machine (prevents backup/restore cross-machine use) + guard cached.machineId == storage.machineId else { + Self.logger.warning("Cached license machineId mismatch, clearing") + storage.clearAll() + status = .unlicensed + return + } + + // Re-verify signature offline with embedded public key + do { + _ = try verifier.verify(payload: cached.signedPayload) + + license = cached + evaluateStatus() + + Self.logger.debug("Loaded cached license for \(cached.email)") + } catch { + // Signature invalid — clear everything + Self.logger.error("Cached license signature invalid, clearing") + storage.clearAll() + license = nil + status = .unlicensed + } + } + + /// Start periodic re-validation timer. Call from AppDelegate.applicationDidFinishLaunching. + func startPeriodicValidation() { + // Check if revalidation is needed right now + if let license, license.daysSinceLastValidation >= Int(revalidationInterval / 86_400) { + Task { + await revalidate() + } + } + + // Schedule periodic timer + revalidationTimer?.invalidate() + revalidationTimer = Timer.scheduledTimer( + withTimeInterval: revalidationInterval, + repeats: true + ) { [weak self] _ in + Task { @MainActor [weak self] in + await self?.revalidate() + } + } + } + + // MARK: - Activation + + /// Activate a license key on this machine + func activate(licenseKey: String) async throws { + let trimmedKey = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard !trimmedKey.isEmpty else { + throw LicenseError.invalidKey + } + + isValidating = true + lastError = nil + defer { isValidating = false } + + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" + let osVersion = ProcessInfo.processInfo.operatingSystemVersionString + + let request = LicenseActivationRequest( + licenseKey: trimmedKey, + machineId: storage.machineId, + machineName: storage.machineName, + appVersion: appVersion, + osVersion: osVersion + ) + + do { + // Call server + let signedPayload = try await apiClient.activate(request: request) + + // Verify signature + let payloadData = try verifier.verify(payload: signedPayload) + + // Build and store license + let newLicense = License.from( + payload: payloadData, + signedPayload: signedPayload, + machineId: storage.machineId + ) + + storage.saveLicenseKey(trimmedKey) + storage.saveLicense(newLicense) + + license = newLicense + evaluateStatus() + + NotificationCenter.default.post(name: .licenseStatusDidChange, object: nil) + Self.logger.info("License activated for \(payloadData.email)") + } catch let error as LicenseError { + lastError = error + throw error + } catch { + let licenseError = LicenseError.networkError(error) + lastError = licenseError + throw licenseError + } + } + + // MARK: - Deactivation + + /// Deactivate the license on this machine + func deactivate() async throws { + guard let license else { return } + + isValidating = true + lastError = nil + defer { isValidating = false } + + let request = LicenseDeactivationRequest( + licenseKey: license.key, + machineId: storage.machineId + ) + + do { + try await apiClient.deactivate(request: request) + } catch { + // Log but don't block — clear local state regardless. + // By design, deactivation always clears local data even if the API call fails. + // The user will need their license key to reactivate. + Self.logger.warning("Deactivation API call failed: \(error.localizedDescription)") + } + + // Clear local state (Keychain key + UserDefaults payload) + storage.clearAll() + self.license = nil + status = .deactivated + + revalidationTimer?.invalidate() + revalidationTimer = nil + + NotificationCenter.default.post(name: .licenseStatusDidChange, object: nil) + Self.logger.info("License deactivated") + } + + // MARK: - Re-validation + + /// Periodic re-validation: refresh license from server, fall back to offline grace period + private func revalidate() async { + guard let license else { return } + + isValidating = true + defer { isValidating = false } + + let request = LicenseValidationRequest( + licenseKey: license.key, + machineId: storage.machineId + ) + + do { + let signedPayload = try await apiClient.validate(request: request) + let payloadData = try verifier.verify(payload: signedPayload) + + // Update cached license with fresh data + let updatedLicense = License.from( + payload: payloadData, + signedPayload: signedPayload, + machineId: storage.machineId + ) + + storage.saveLicense(updatedLicense) + self.license = updatedLicense + evaluateStatus() + + Self.logger.debug("License re-validated successfully") + } catch { + // Network failure — use grace period + Self.logger.warning("Re-validation failed: \(error.localizedDescription)") + + if license.daysSinceLastValidation > gracePeriodDays { + // Grace period exceeded — mark as validation failed + self.status = .validationFailed + Self.logger.error("Grace period exceeded (\(license.daysSinceLastValidation) days)") + } + // Otherwise keep using cached license (still within grace period) + } + + NotificationCenter.default.post(name: .licenseStatusDidChange, object: nil) + } + + // MARK: - Status Evaluation + + /// Evaluate current license status based on expiration, grace period, and signature validity + private func evaluateStatus() { + guard let license else { + status = .unlicensed + return + } + + // Check server-reported status + switch license.status { + case .suspended: + status = .suspended + return + case .expired: + status = .expired + return + case .deactivated: + status = .deactivated + return + default: + break + } + + // Check local expiration + if license.isExpired { + status = .expired + return + } + + // Check grace period + if license.daysSinceLastValidation > gracePeriodDays { + status = .validationFailed + return + } + + status = .active + } +} diff --git a/TablePro/Core/Services/LicenseSignatureVerifier.swift b/TablePro/Core/Services/LicenseSignatureVerifier.swift new file mode 100644 index 000000000..93cf644f7 --- /dev/null +++ b/TablePro/Core/Services/LicenseSignatureVerifier.swift @@ -0,0 +1,90 @@ +// +// LicenseSignatureVerifier.swift +// TablePro +// +// RSA-SHA256 signature verification using Security framework + embedded public key +// + +import Foundation +import Security + +/// Verifies RSA-SHA256 signatures on license payloads using the embedded public key +final class LicenseSignatureVerifier { + static let shared = LicenseSignatureVerifier() + + private let publicKey: SecKey + + private init() { + guard let key = Self.loadPublicKey() else { + fatalError("Failed to load license public key from app bundle") + } + self.publicKey = key + } + + // MARK: - Public API + + /// Verify a signed license payload and return the decoded data if valid. + /// Throws `LicenseError.signatureInvalid` if the signature doesn't match. + func verify(payload: SignedLicensePayload) throws -> LicensePayloadData { + // Encode the data portion as canonical JSON (same as server) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let dataJSON = try encoder.encode(payload.data) + + // Decode the base64 signature + guard let signatureData = Data(base64Encoded: payload.signature) else { + throw LicenseError.signatureInvalid + } + + // Verify RSA-SHA256 signature + let isValid = SecKeyVerifySignature( + publicKey, + .rsaSignatureMessagePKCS1v15SHA256, + dataJSON as CFData, + signatureData as CFData, + nil + ) + + guard isValid else { + throw LicenseError.signatureInvalid + } + + return payload.data + } + + // MARK: - Key Loading + + /// Load the RSA public key from the app bundle's PEM file + private static func loadPublicKey() -> SecKey? { + guard let url = Bundle.main.url(forResource: "license_public", withExtension: "pem"), + let pemString = try? String(contentsOf: url, encoding: .utf8) + else { + return nil + } + + return createSecKey(fromPEM: pemString) + } + + /// Parse a PEM-encoded public key into a SecKey + private static func createSecKey(fromPEM pem: String) -> SecKey? { + // Strip PEM headers/footers and whitespace + let stripped = pem + .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "") + .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespaces) + + guard let keyData = Data(base64Encoded: stripped) else { + return nil + } + + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeyClass as String: kSecAttrKeyClassPublic, + kSecAttrKeySizeInBits as String: 2_048, + ] + + return SecKeyCreateWithData(keyData as CFData, attributes as CFDictionary, nil) + } +} diff --git a/TablePro/Core/Storage/LicenseStorage.swift b/TablePro/Core/Storage/LicenseStorage.swift new file mode 100644 index 000000000..011fe6d3c --- /dev/null +++ b/TablePro/Core/Storage/LicenseStorage.swift @@ -0,0 +1,161 @@ +// +// LicenseStorage.swift +// TablePro +// +// Keychain + UserDefaults persistence for license data, machine ID via IOKit +// + +import Foundation +import IOKit +import os +import Security + +/// Persists license data using Keychain (secrets) and UserDefaults (metadata) +final class LicenseStorage { + static let shared = LicenseStorage() + + private static let logger = Logger(subsystem: "com.TablePro", category: "LicenseStorage") + + private let defaults = UserDefaults.standard + + private enum Keys { + static let keychainLicenseKey = "com.TablePro.license.key" + static let licensePayload = "com.TablePro.license.payload" + } + + private init() {} + + // MARK: - License Key (Keychain) + + /// Save license key to Keychain + func saveLicenseKey(_ key: String) { + let account = Keys.keychainLicenseKey + + // Delete existing + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + ] + SecItemDelete(deleteQuery as CFDictionary) + + guard let data = key.data(using: .utf8) else { return } + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, + ] + + SecItemAdd(addQuery as CFDictionary, nil) + } + + /// Load license key from Keychain + func loadLicenseKey() -> String? { + let account = Keys.keychainLicenseKey + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let key = String(data: data, encoding: .utf8) + else { + return nil + } + + return key + } + + /// Delete license key from Keychain + func deleteLicenseKey() { + let account = Keys.keychainLicenseKey + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + ] + + SecItemDelete(query as CFDictionary) + } + + // MARK: - Signed Payload (UserDefaults) + + /// Save cached license (including signed payload) to UserDefaults + func saveLicense(_ license: License) { + do { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(license) + defaults.set(data, forKey: Keys.licensePayload) + } catch { + Self.logger.error("Failed to encode license: \(error.localizedDescription)") + } + } + + /// Load cached license from UserDefaults + func loadLicense() -> License? { + guard let data = defaults.data(forKey: Keys.licensePayload) else { + return nil + } + + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(License.self, from: data) + } catch { + Self.logger.error("Failed to decode license: \(error.localizedDescription)") + return nil + } + } + + /// Clear all license data (Keychain + UserDefaults) + func clearAll() { + deleteLicenseKey() + defaults.removeObject(forKey: Keys.licensePayload) + } + + // MARK: - Machine Identification + + /// Hardware UUID from IOKit, SHA256-hashed for privacy. + /// Stable across OS reinstalls (tied to hardware). + var machineId: String { + let platformExpert = IOServiceGetMatchingService( + kIOMainPortDefault, + IOServiceMatching("IOPlatformExpertDevice") + ) + defer { IOObjectRelease(platformExpert) } + + guard platformExpert != 0, + let uuidCF = IORegistryEntryCreateCFProperty( + platformExpert, + kIOPlatformUUIDKey as CFString, + kCFAllocatorDefault, + 0 + )?.takeRetainedValue() as? String + else { + // Fallback: use a persistent UUID stored in UserDefaults + let fallbackKey = "com.TablePro.license.fallbackMachineId" + if let existing = defaults.string(forKey: fallbackKey) { + return existing.sha256 + } + let newId = UUID().uuidString + defaults.set(newId, forKey: fallbackKey) + return newId.sha256 + } + + return uuidCF.sha256 + } + + /// Human-readable machine name (e.g., "John's MacBook Pro") + var machineName: String { + Host.current().localizedName ?? "Unknown Mac" + } +} diff --git a/TablePro/Extensions/String+SHA256.swift b/TablePro/Extensions/String+SHA256.swift new file mode 100644 index 000000000..73900d758 --- /dev/null +++ b/TablePro/Extensions/String+SHA256.swift @@ -0,0 +1,26 @@ +// +// String+SHA256.swift +// TablePro +// +// SHA256 hashing helper using CryptoKit +// + +import CryptoKit +import Foundation + +extension String { + /// Returns the SHA256 hash of this string as a lowercase hex string + var sha256: String { + let data = Data(utf8) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } +} + +extension Data { + /// Returns the SHA256 hash of this data as a lowercase hex string + var sha256Hex: String { + let digest = SHA256.hash(data: self) + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/TablePro/Models/License.swift b/TablePro/Models/License.swift new file mode 100644 index 000000000..46b78ee47 --- /dev/null +++ b/TablePro/Models/License.swift @@ -0,0 +1,221 @@ +// +// License.swift +// TablePro +// +// License model, signed payload types, and error definitions +// + +import Foundation + +// MARK: - License Status + +/// Represents the current license state in the app +enum LicenseStatus: String, Codable { + case unlicensed + case active + case expired + case suspended + case deactivated + case validationFailed + + var displayName: String { + switch self { + case .unlicensed: return "Unlicensed" + case .active: return "Active" + case .expired: return "Expired" + case .suspended: return "Suspended" + case .deactivated: return "Deactivated" + case .validationFailed: return "Validation Failed" + } + } + + var isValid: Bool { + self == .active + } +} + +// MARK: - Server Response Types + +/// The `data` portion of the signed license payload from the server +struct LicensePayloadData: Codable, Equatable { + let licenseKey: String + let email: String + let status: String + let expiresAt: String? + let issuedAt: String + + private enum CodingKeys: String, CodingKey { + case licenseKey = "license_key" + case email + case status + case expiresAt = "expires_at" + case issuedAt = "issued_at" + } + + /// Custom encode to explicitly write null for nil optionals. + /// The auto-synthesized Codable uses encodeIfPresent which omits nil keys, + /// but PHP's json_encode includes "expires_at":null — the signed JSON must match exactly. + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(licenseKey, forKey: .licenseKey) + try container.encode(email, forKey: .email) + try container.encode(status, forKey: .status) + if let expiresAt { + try container.encode(expiresAt, forKey: .expiresAt) + } else { + try container.encodeNil(forKey: .expiresAt) + } + try container.encode(issuedAt, forKey: .issuedAt) + } +} + +/// Signed license payload returned by the server (data + RSA signature) +struct SignedLicensePayload: Codable, Equatable { + let data: LicensePayloadData + let signature: String +} + +// MARK: - API Request/Response Types + +/// Request body for license activation +struct LicenseActivationRequest: Codable { + let licenseKey: String + let machineId: String + let machineName: String + let appVersion: String + let osVersion: String + + private enum CodingKeys: String, CodingKey { + case licenseKey = "license_key" + case machineId = "machine_id" + case machineName = "machine_name" + case appVersion = "app_version" + case osVersion = "os_version" + } +} + +/// Request body for license validation +struct LicenseValidationRequest: Codable { + let licenseKey: String + let machineId: String + + private enum CodingKeys: String, CodingKey { + case licenseKey = "license_key" + case machineId = "machine_id" + } +} + +/// Request body for license deactivation +struct LicenseDeactivationRequest: Codable { + let licenseKey: String + let machineId: String + + private enum CodingKeys: String, CodingKey { + case licenseKey = "license_key" + case machineId = "machine_id" + } +} + +/// Wrapper for API error responses +struct LicenseAPIErrorResponse: Codable { + let message: String +} + +// MARK: - Cached License + +/// Local cached license with metadata for offline use +struct License: Codable, Equatable { + var key: String + var email: String + var status: LicenseStatus + var expiresAt: Date? + var lastValidatedAt: Date + var machineId: String + var signedPayload: SignedLicensePayload + + /// Whether the license has expired based on expiration date + var isExpired: Bool { + guard let expiresAt else { return false } + return expiresAt < Date() + } + + /// Days since last successful server validation + var daysSinceLastValidation: Int { + Calendar.current.dateComponents([.day], from: lastValidatedAt, to: Date()).day ?? 0 + } + + private static let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + /// Create a License from a verified server payload + static func from( + payload: LicensePayloadData, + signedPayload: SignedLicensePayload, + machineId: String + ) -> License { + let expiresAt = payload.expiresAt.flatMap { iso8601Formatter.date(from: $0) } + let status: LicenseStatus = switch payload.status { + case "active": .active + case "expired": .expired + case "suspended": .suspended + default: .validationFailed + } + + return License( + key: payload.licenseKey, + email: payload.email, + status: status, + expiresAt: expiresAt, + lastValidatedAt: Date(), + machineId: machineId, + signedPayload: signedPayload + ) + } +} + +// MARK: - License Error + +/// Errors that can occur during license operations +enum LicenseError: LocalizedError { + case invalidKey + case signatureInvalid + case publicKeyNotFound + case publicKeyInvalid + case activationLimitReached + case licenseExpired + case licenseSuspended + case notActivated + case networkError(Error) + case serverError(Int, String) + case decodingError(Error) + + var errorDescription: String? { + switch self { + case .invalidKey: + return "The license key is invalid." + case .signatureInvalid: + return "License signature verification failed." + case .publicKeyNotFound: + return "License public key not found in app bundle." + case .publicKeyInvalid: + return "License public key is invalid." + case .activationLimitReached: + return "Maximum number of activations reached." + case .licenseExpired: + return "The license has expired." + case .licenseSuspended: + return "The license has been suspended." + case .notActivated: + return "This machine is not activated." + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .serverError(let code, let message): + return "Server error (\(code)): \(message)" + case .decodingError(let error): + return "Failed to parse server response: \(error.localizedDescription)" + } + } +} diff --git a/TablePro/OpenTableApp.swift b/TablePro/OpenTableApp.swift index 012ab6497..31d193d53 100644 --- a/TablePro/OpenTableApp.swift +++ b/TablePro/OpenTableApp.swift @@ -453,6 +453,9 @@ extension Notification.Name { // Window lifecycle notifications static let mainWindowWillClose = Notification.Name("mainWindowWillClose") static let openMainWindow = Notification.Name("openMainWindow") + + // License notifications + static let licenseStatusDidChange = Notification.Name("licenseStatusDidChange") } // MARK: - Open Window Handler diff --git a/TablePro/Resources/license_public.pem b/TablePro/Resources/license_public.pem new file mode 100644 index 000000000..e72d8fb7e --- /dev/null +++ b/TablePro/Resources/license_public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxjWFYYuLeIkiF8LdsV2h +7zN/nItAYs8Zfve7QlBIbghkz1gU2v7E510aaVCGeGBcOv6OWdVfl7qQCNxmVxyy +kn5s4nsdWLYt7z8Y5rYC8qF/NWyu+uzyofXH5matfm7uOvJYyh8jetmoOTQBhoZX +2TVjt76DV04Nwd3z+VsckEcZUgezVQKACcP9IjoT9qX7xv0XNK7Ycvi1IibXLHMX +TnUAJKE2RQTWZzGhVT7+YZVSXowFccMF0InK5oZq8RcM3z6enxsY2h04bx/3F+Gg +ukWwL61y8lxo1MerOzABXiA2h8Nqki9wwuKo2RlJyGLUfkrY49PlkW5eKOsn7dhX +MQIDAQAB +-----END PUBLIC KEY----- diff --git a/TablePro/Views/Settings/LicenseSettingsView.swift b/TablePro/Views/Settings/LicenseSettingsView.swift new file mode 100644 index 000000000..1697ad897 --- /dev/null +++ b/TablePro/Views/Settings/LicenseSettingsView.swift @@ -0,0 +1,133 @@ +// +// LicenseSettingsView.swift +// TablePro +// +// License settings tab: status display, activation form, and deactivation +// + +import AppKit +import SwiftUI + +struct LicenseSettingsView: View { + @StateObject private var licenseManager = LicenseManager.shared + + @State private var licenseKeyInput = "" + @State private var isActivating = false + + var body: some View { + Form { + if let license = licenseManager.license { + licensedSection(license) + } else { + unlicensedSection + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + } + + // MARK: - Licensed State + + @ViewBuilder + private func licensedSection(_ license: License) -> some View { + Section("License") { + LabeledContent("Email:", value: license.email) + + LabeledContent("License Key:") { + Text(maskedKey(license.key)) + .textSelection(.enabled) + } + } + + Section("Maintenance") { + HStack { + Text("Remove license from this machine") + Spacer() + Button("Deactivate...") { + Task { @MainActor in + let confirmed = await AlertHelper.confirmDestructive( + title: "Deactivate License?", + message: "This will remove the license from this machine. You can reactivate later.", + confirmButton: "Deactivate", + cancelButton: "Cancel" + ) + + if confirmed { + await deactivate() + } + } + } + .disabled(licenseManager.isValidating) + } + } + } + + // MARK: - Unlicensed State + + private var unlicensedSection: some View { + Section("License") { + TextField("License Key:", text: $licenseKeyInput) + .font(.system(.body, design: .monospaced)) + .disableAutocorrection(true) + .onSubmit { Task { await activate() } } + + HStack { + Spacer() + if isActivating { + ProgressView() + .controlSize(.small) + } else { + Button("Activate") { + Task { await activate() } + } + .disabled(licenseKeyInput.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + } + } + + // MARK: - Helpers + + private func maskedKey(_ key: String) -> String { + let parts = key.split(separator: "-") + guard parts.count == 5 else { return key } + let first = String(parts[0]) + let masked = Array(repeating: "*****", count: 4).joined(separator: "-") + return "\(first)-\(masked)" + } + + // MARK: - Actions + + private func activate() async { + isActivating = true + defer { isActivating = false } + + do { + try await licenseManager.activate(licenseKey: licenseKeyInput) + licenseKeyInput = "" + } catch { + AlertHelper.showErrorSheet( + title: "Activation Failed", + message: error.localizedDescription, + window: NSApp.keyWindow + ) + } + } + + private func deactivate() async { + do { + try await licenseManager.deactivate() + } catch { + AlertHelper.showErrorSheet( + title: "Deactivation Failed", + message: error.localizedDescription, + window: NSApp.keyWindow + ) + } + } +} + +#Preview("Unlicensed") { + LicenseSettingsView() + .frame(width: 450, height: 300) +} diff --git a/TablePro/Views/Settings/SettingsView.swift b/TablePro/Views/Settings/SettingsView.swift index 0983e6a5c..76b0bc72c 100644 --- a/TablePro/Views/Settings/SettingsView.swift +++ b/TablePro/Views/Settings/SettingsView.swift @@ -37,6 +37,11 @@ struct SettingsView: View { .tabItem { Label("History", systemImage: "clock") } + + LicenseSettingsView() + .tabItem { + Label("License", systemImage: "key") + } } .frame(width: 500, height: 400) }