import Combine import Security import Foundation import CryptoKit @MainActor final class LicensingManager: ObservableObject { enum State: Equatable { case licensed(LicensePayload) case trial(daysRemaining: Int, expiresAt: Date) case expired case invalid(String) var canUseApp: Bool { switch self { case .licensed, .trial: true case .expired, .invalid: false } } } struct LicensePayload: Codable, Equatable { let licenseID: String let customerName: String? let customerEmail: String? let edition: String let installationID: String let issuedAt: Date let expiresAt: Date? let maxMajorVersion: Int? var displayOwner: String { if let customerName, !customerName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return customerName } if let customerEmail, !customerEmail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return customerEmail } return "Licensed User" } } struct ActivationResponse: Decodable { let licenseToken: String } private struct ActivationRequest: Encodable { let licenseKey: String let installationID: String let appVersion: String let platform: String } private struct DeactivationRequest: Encodable { let licenseToken: String let installationID: String } private struct ValidationRequest: Encodable { let licenseToken: String let installationID: String let appVersion: String let platform: String } private struct ValidationResponse: Decodable { let active: Bool let licenseToken: String } enum LicensingError: LocalizedError { case configuration(String) case invalidLicense(String) case server(String) case remoteInactive(String) var errorDescription: String? { switch self { case .configuration(let message), .invalidLicense(let message), .server(let message), .remoteInactive(let message): message } } } static let trialLengthDays = 7 static let remoteValidationInterval: TimeInterval = 24 * 60 * 60 static let offlineGracePeriod: TimeInterval = 3 * 24 * 60 * 60 @Published private(set) var state: State = .trial(daysRemaining: trialLengthDays, expiresAt: Date().addingTimeInterval(TimeInterval(trialLengthDays * 86_400))) @Published private(set) var installationID = "" @Published private(set) var isWorking = false @Published var lastMessage: String? private let calendar = Calendar(identifier: .gregorian) private let firstLaunchAccount = "licensing.trial.firstLaunch" private let lastSeenAccount = "licensing.trial.lastSeen" private let installationAccount = "licensing.installationID" private let tokenAccount = "licensing.token" private let lastRemoteValidationAccount = "licensing.remoteValidation.lastSuccess" private let remoteBlockMessageAccount = "licensing.remoteValidation.blockMessage" private var revalidationInFlight = false init() { refresh() } var canUseApp: Bool { state.canUseApp } var statusTitle: String { switch state { case .licensed: "Licensed" case .trial: "Free Trial" case .expired: "Trial Expired" case .invalid: "Licence Problem" } } var statusDetail: String { switch state { case .licensed(let payload): let expiry = payload.expiresAt.map { " · expires \($0.formatted(date: .abbreviated, time: .omitted))" } ?? " · perpetual" return "\(payload.edition) · \(payload.displayOwner)\(expiry)" case .trial(let days, let expiresAt): return "\(days) day\(days == 1 ? "" : "s") remaining · ends \(expiresAt.formatted(date: .abbreviated, time: .omitted))" case .expired: return "The 7-day evaluation period has ended. Activate a licence to continue using sync jobs." case .invalid(let message): return message } } func refresh(now: Date = Date()) { installationID = loadOrCreateInstallationID() if let token = storedLicenseToken, !token.isEmpty { do { let payload = try verify(token: token, now: now) state = .licensed(payload) recordLastSeen(now) return } catch { state = .invalid(error.localizedDescription) recordLastSeen(now) return } } let blockedMessage = KeychainStore.string(for: remoteBlockMessageAccount) if !blockedMessage.isEmpty { state = .invalid(blockedMessage) return } state = trialState(now: now) } /// Revalidates only against the Rsync Studio licensing server. This endpoint reads /// our own database and never calls Paddle, so routine validation has no Paddle API cost. func revalidateIfNeeded(force: Bool = false, now: Date = Date()) async { guard !revalidationInFlight else { return } installationID = loadOrCreateInstallationID() guard let token = storedLicenseToken, !token.isEmpty else { refresh(now: now) return } let localPayload: LicensePayload do { localPayload = try verify(token: token, now: now) } catch { state = .invalid(error.localizedDescription) return } if !force, let lastSuccess = Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount)), now.timeIntervalSince(lastSuccess) < Self.remoteValidationInterval { state = .licensed(localPayload) return } guard let baseURL = configuredAPIBaseURL else { applyOfflineGrace(localPayload: localPayload, now: now) return } // Existing installations created before remote validation was added get a // three-day migration grace period if the first network check cannot complete. if Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount)) == nil { _ = KeychainStore.set(Self.formatDate(now), for: lastRemoteValidationAccount) } revalidationInFlight = true defer { revalidationInFlight = false } do { let response = try await validateRemotely(token: token, baseURL: baseURL) guard response.active else { throw LicensingError.remoteInactive("This Mac has been deactivated from the licence portal.") } let refreshedPayload = try verify(token: response.licenseToken, now: now) guard KeychainStore.set(response.licenseToken, for: tokenAccount) == errSecSuccess else { throw LicensingError.invalidLicense("Rsync Studio could not update the licence securely in Keychain.") } _ = KeychainStore.set(Self.formatDate(now), for: lastRemoteValidationAccount) KeychainStore.delete(remoteBlockMessageAccount) state = .licensed(refreshedPayload) recordLastSeen(now) } catch LicensingError.remoteInactive(let message) { KeychainStore.delete(tokenAccount) KeychainStore.delete(lastRemoteValidationAccount) _ = KeychainStore.set(message, for: remoteBlockMessageAccount) state = .invalid(message) lastMessage = message } catch { applyOfflineGrace(localPayload: localPayload, now: now) } } func installSignedToken(_ token: String) throws { let clean = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !clean.isEmpty else { throw LicensingError.invalidLicense("Paste a signed Rsync Studio licence token.") } let payload = try verify(token: clean, now: Date()) guard KeychainStore.set(clean, for: tokenAccount) == errSecSuccess else { throw LicensingError.invalidLicense("Rsync Studio could not save the licence securely in Keychain.") } KeychainStore.delete(remoteBlockMessageAccount) state = .licensed(payload) lastMessage = "Licence installed successfully." } func activate(licenseKey: String) async throws { let key = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { throw LicensingError.invalidLicense("Enter your licence key.") } guard let baseURL = configuredAPIBaseURL else { throw LicensingError.configuration("Online activation is not configured in this build. A signed licence token can still be installed manually.") } isWorking = true defer { isWorking = false } let requestBody = ActivationRequest( licenseKey: key, installationID: installationID, appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown", platform: "macOS" ) let response: ActivationResponse = try await post(path: "v1/activate", body: requestBody, baseURL: baseURL) try installSignedToken(response.licenseToken) _ = KeychainStore.set(Self.formatDate(Date()), for: lastRemoteValidationAccount) KeychainStore.delete(remoteBlockMessageAccount) lastMessage = "Rsync Studio has been activated on this Mac." } func deactivate() async throws { guard let token = storedLicenseToken, !token.isEmpty else { KeychainStore.delete(tokenAccount) refresh() return } isWorking = true defer { isWorking = false } if let baseURL = configuredAPIBaseURL { let body = DeactivationRequest(licenseToken: token, installationID: installationID) let _: EmptyResponse = try await post(path: "v1/deactivate", body: body, baseURL: baseURL) } guard KeychainStore.delete(tokenAccount) == errSecSuccess else { throw LicensingError.invalidLicense("Rsync Studio could not remove the local licence from Keychain.") } KeychainStore.delete(lastRemoteValidationAccount) KeychainStore.delete(remoteBlockMessageAccount) refresh() lastMessage = "Licence removed from this Mac." } func removeInvalidLocalLicence() { KeychainStore.delete(tokenAccount) KeychainStore.delete(lastRemoteValidationAccount) KeychainStore.delete(remoteBlockMessageAccount) refresh() lastMessage = "Invalid local licence removed." } private var storedLicenseToken: String? { let token = KeychainStore.string(for: tokenAccount) return token.isEmpty ? nil : token } private var configuredPublicKey: String? { guard let value = Bundle.main.infoDictionary?["RsyncStudioLicensePublicKey"] as? String else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private var configuredAPIBaseURL: URL? { guard let value = Bundle.main.infoDictionary?["RsyncStudioLicenseAPIBaseURL"] as? String else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let url = URL(string: trimmed), url.scheme?.lowercased() == "https" else { return nil } return url } private func loadOrCreateInstallationID() -> String { let existing = KeychainStore.string(for: installationAccount) if UUID(uuidString: existing) != nil { return existing } let value = UUID().uuidString.lowercased() _ = KeychainStore.set(value, for: installationAccount) return value } private func trialState(now: Date) -> State { let firstLaunch: Date let storedFirst = KeychainStore.string(for: firstLaunchAccount) if let parsed = Self.parseDate(storedFirst) { firstLaunch = parsed } else { firstLaunch = now _ = KeychainStore.set(Self.formatDate(now), for: firstLaunchAccount) } if let lastSeen = Self.parseDate(KeychainStore.string(for: lastSeenAccount)), now < lastSeen.addingTimeInterval(-300) { recordLastSeen(lastSeen) return .invalid("The system clock appears to have moved backwards. Correct the date and time, then reopen Rsync Studio.") } recordLastSeen(now) guard let expiresAt = calendar.date(byAdding: .day, value: Self.trialLengthDays, to: firstLaunch) else { return .expired } if now >= expiresAt { return .expired } let startOfToday = calendar.startOfDay(for: now) let expiryDay = calendar.startOfDay(for: expiresAt) let days = max(1, calendar.dateComponents([.day], from: startOfToday, to: expiryDay).day ?? 1) return .trial(daysRemaining: days, expiresAt: expiresAt) } private func recordLastSeen(_ date: Date) { let previous = Self.parseDate(KeychainStore.string(for: lastSeenAccount)) if previous == nil || date > previous! { _ = KeychainStore.set(Self.formatDate(date), for: lastSeenAccount) } } private func verify(token: String, now: Date) throws -> LicensePayload { guard let publicKeyText = configuredPublicKey else { throw LicensingError.configuration("Licence verification is not configured in this build. Add RsyncStudioLicensePublicKey to the signed app configuration.") } let parts = token.split(separator: ".", omittingEmptySubsequences: false) guard parts.count == 3, parts[0] == "RS3" else { throw LicensingError.invalidLicense("The licence token format is invalid.") } guard let payloadData = Self.base64URLDecode(String(parts[1])), let signature = Self.base64URLDecode(String(parts[2])), let keyData = Data(base64Encoded: publicKeyText) ?? Self.base64URLDecode(publicKeyText) else { throw LicensingError.invalidLicense("The licence token or embedded public key is not valid Base64.") } let publicKey: Curve25519.Signing.PublicKey do { publicKey = try Curve25519.Signing.PublicKey(rawRepresentation: keyData) } catch { throw LicensingError.configuration("The embedded Rsync Studio licence public key is invalid.") } guard publicKey.isValidSignature(signature, for: payloadData) else { throw LicensingError.invalidLicense("The licence signature is invalid.") } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let payload: LicensePayload do { payload = try decoder.decode(LicensePayload.self, from: payloadData) } catch { throw LicensingError.invalidLicense("The signed licence payload is invalid.") } guard payload.installationID.caseInsensitiveCompare(installationID) == .orderedSame else { throw LicensingError.invalidLicense("This licence was activated for a different Rsync Studio installation.") } if let expiresAt = payload.expiresAt, now >= expiresAt { throw LicensingError.invalidLicense("This licence expired on \(expiresAt.formatted(date: .abbreviated, time: .omitted)).") } if let maxMajor = payload.maxMajorVersion, let currentMajor = Self.currentMajorVersion, currentMajor > maxMajor { throw LicensingError.invalidLicense("This licence covers Rsync Studio through major version \(maxMajor).") } return payload } private func validateRemotely(token: String, baseURL: URL) async throws -> ValidationResponse { let url = baseURL.appending(path: "v1/validate") guard url.scheme?.lowercased() == "https" else { throw LicensingError.configuration("Licence API must use HTTPS.") } let body = ValidationRequest( licenseToken: token, installationID: installationID, appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown", platform: "macOS" ) var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.httpBody = try JSONEncoder().encode(body) do { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw LicensingError.server("The licence server returned an invalid response.") } if http.statusCode == 403 { let message = (try? JSONDecoder().decode(ServerError.self, from: data).message) ?? "This licence is no longer active on this Mac." throw LicensingError.remoteInactive(message) } guard (200..<300).contains(http.statusCode) else { let message = (try? JSONDecoder().decode(ServerError.self, from: data).message) ?? "Licence server returned HTTP \(http.statusCode)." throw LicensingError.server(message) } do { return try JSONDecoder().decode(ValidationResponse.self, from: data) } catch { throw LicensingError.server("The licence server returned malformed validation data.") } } catch let error as LicensingError { throw error } catch { throw LicensingError.server(error.localizedDescription) } } private func applyOfflineGrace(localPayload: LicensePayload, now: Date) { let lastSuccess = Self.parseDate(KeychainStore.string(for: lastRemoteValidationAccount)) if let lastSuccess, now.timeIntervalSince(lastSuccess) <= Self.offlineGracePeriod { state = .licensed(localPayload) return } state = .invalid("Rsync Studio has been unable to verify this licence for more than 3 days. Connect to the internet and reopen the app.") } private func post(path: String, body: Request, baseURL: URL) async throws -> Response { let url = baseURL.appending(path: path) guard url.scheme?.lowercased() == "https" else { throw LicensingError.configuration("Licence API must use HTTPS.") } var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 20 request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.httpBody = try JSONEncoder().encode(body) let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw LicensingError.server("The licence server returned an invalid response.") } guard (200..<300).contains(http.statusCode) else { let serverMessage = (try? JSONDecoder().decode(ServerError.self, from: data).message) ?? "Activation server returned HTTP \(http.statusCode)." throw LicensingError.server(serverMessage) } if Response.self == EmptyResponse.self, data.isEmpty { return EmptyResponse() as! Response } do { return try JSONDecoder().decode(Response.self, from: data) } catch { throw LicensingError.server("The licence server returned malformed data.") } } private struct ServerError: Decodable { let message: String } private struct EmptyResponse: Codable { init() {} } private static var currentMajorVersion: Int? { guard let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return nil } return Int(version.split(separator: ".").first ?? "") } private static func formatDate(_ date: Date) -> String { ISO8601DateFormatter().string(from: date) } private static func parseDate(_ value: String) -> Date? { value.isEmpty ? nil : ISO8601DateFormatter().date(from: value) } private static func base64URLDecode(_ value: String) -> Data? { var text = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") let remainder = text.count % 4 if remainder != 0 { text += String(repeating: "=", count: 4 - remainder) } return Data(base64Encoded: text) } }