diff --git a/TCPViewer/App/AppDelegate.swift b/TCPViewer/App/AppDelegate.swift index 0d38ff3..e5e5c2d 100644 --- a/TCPViewer/App/AppDelegate.swift +++ b/TCPViewer/App/AppDelegate.swift @@ -428,6 +428,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } private func presentLicenseSheet(presentationMode: TCPViewerLicensePresentationMode, sender: Any?) { + TCPViewerLicenseService.shared.refreshLicense() // Reuse one sheet owner while allowing Trial and menu actions to open different license modes. guard let parentWindow = licenseSheetParentWindow() ?? createLicenseSheetParentWindow() else { NSApp.activate(ignoringOtherApps: true) diff --git a/TCPViewer/Features/License/Models/TCPViewerLicense.swift b/TCPViewer/Features/License/Models/TCPViewerLicense.swift index 20684e7..449c40e 100644 --- a/TCPViewer/Features/License/Models/TCPViewerLicense.swift +++ b/TCPViewer/Features/License/Models/TCPViewerLicense.swift @@ -11,10 +11,11 @@ enum TCPViewerLicenseType: String, Codable { case standardLicense = "standard_license" case comboLicense = "combo_license" case lifetimeLicense = "lifetime_license" + case teamLicense = "team_license" } struct TCPViewerLicense: Codable, Equatable { - private static let maximumOneYearUpdateWindowDays = 366 + private static let maximumLegacyUpdateWindowDays = 366 private enum CodingKeys: String, CodingKey { case signature @@ -23,14 +24,19 @@ struct TCPViewerLicense: Codable, Equatable { case purchaseAt case expiryDate = "expiryAt" case licenseType + case receipt, activationId, numberOfSeats, usedSeats } - let signature: String + var signature: String let deviceUUID: String let email: String let purchaseAt: String var expiryDate: String let licenseType: TCPViewerLicenseType + var receipt: TCPViewerLicenseReceipt? + var activationId: String? + var numberOfSeats: Int? + var usedSeats: Int? init( signature: String, @@ -38,7 +44,11 @@ struct TCPViewerLicense: Codable, Equatable { email: String, purchaseAt: String, expiryDate: String, - licenseType: TCPViewerLicenseType = .standardLicense + licenseType: TCPViewerLicenseType = .standardLicense, + receipt: TCPViewerLicenseReceipt? = nil, + activationId: String? = nil, + numberOfSeats: Int? = nil, + usedSeats: Int? = nil ) { self.signature = signature self.deviceUUID = deviceUUID @@ -46,6 +56,10 @@ struct TCPViewerLicense: Codable, Equatable { self.purchaseAt = purchaseAt self.expiryDate = expiryDate self.licenseType = licenseType + self.receipt = receipt + self.activationId = activationId + self.numberOfSeats = numberOfSeats + self.usedSeats = usedSeats } init(from decoder: Decoder) throws { @@ -56,6 +70,10 @@ struct TCPViewerLicense: Codable, Equatable { purchaseAt = try container.decode(String.self, forKey: .purchaseAt) expiryDate = try container.decode(String.self, forKey: .expiryDate) licenseType = try container.decodeIfPresent(TCPViewerLicenseType.self, forKey: .licenseType) ?? .standardLicense + receipt = try container.decodeIfPresent(TCPViewerLicenseReceipt.self, forKey: .receipt) + activationId = try container.decodeIfPresent(String.self, forKey: .activationId) + numberOfSeats = try container.decodeIfPresent(Int.self, forKey: .numberOfSeats) + usedSeats = try container.decodeIfPresent(Int.self, forKey: .usedSeats) } func encode(to encoder: Encoder) throws { @@ -66,6 +84,10 @@ struct TCPViewerLicense: Codable, Equatable { try container.encode(purchaseAt, forKey: .purchaseAt) try container.encode(expiryDate, forKey: .expiryDate) try container.encode(licenseType, forKey: .licenseType) + try container.encodeIfPresent(receipt, forKey: .receipt) + try container.encodeIfPresent(activationId, forKey: .activationId) + try container.encodeIfPresent(numberOfSeats, forKey: .numberOfSeats) + try container.encodeIfPresent(usedSeats, forKey: .usedSeats) } var remainingDays: Int? { @@ -84,20 +106,17 @@ struct TCPViewerLicense: Codable, Equatable { return remainingDays < 0 } - var hasOneYearUpdateWindow: Bool { - guard let updateWindowDays else { - return false - } - - return (0...Self.maximumOneYearUpdateWindowDays).contains(updateWindowDays) - } - var hasLifetimeUpdates: Bool { licenseType == .lifetimeLicense } - var hasValidUpdateEntitlement: Bool { - hasLifetimeUpdates || hasOneYearUpdateWindow + var hasValidLegacyUpdateEntitlement: Bool { + guard licenseType == .standardLicense || licenseType == .comboLicense, + let updateWindowDays else { + return hasLifetimeUpdates + } + + return (0...Self.maximumLegacyUpdateWindowDays).contains(updateWindowDays) } var formattedExpiryDate: String { diff --git a/TCPViewer/Features/License/Models/TCPViewerLicenseError.swift b/TCPViewer/Features/License/Models/TCPViewerLicenseError.swift index 78c806a..d6ff024 100644 --- a/TCPViewer/Features/License/Models/TCPViewerLicenseError.swift +++ b/TCPViewer/Features/License/Models/TCPViewerLicenseError.swift @@ -14,22 +14,53 @@ enum TCPViewerLicenseError: Error, Equatable, LocalizedError { case expired case couldNotGetDeviceUUID case noInternetConnection + case verificationRequired + case offlineVerificationRequired + case clockChanged + case invalidReceipt + case deviceRevoked + case licenseDisabled + case appUpdateRequired + case temporaryFailure case error(String) + var isTemporary: Bool { + switch self { + case .noInternetConnection, .temporaryFailure, .error: return true + default: return false + } + } + var errorDescription: String? { switch self { case .invalidLicense: - return "Invalid license key." + return "Check the license key in your purchase email. Contact support if you need help." case .outOfSeats: - return "Your license has no available device seats." + return "All seats are occupied. Free a seat in License Manager, or add seats to your Team license." case .renewalRequired: return "This TCP Viewer build was released after your license update window. Your license is still valid for builds released before the update expiry date; download an older build or renew to use this build." case .expired: - return "Your license is expired." + return "Updates do not cover this build. Covered releases remain usable; renew to use newer releases." case .couldNotGetDeviceUUID: return "Could not get this Mac's device identifier." case .noInternetConnection: return "No internet connection." + case .verificationRequired: + return "Connect to the internet to verify this license for this version of TCP Viewer." + case .offlineVerificationRequired: + return "Your Team license needs an online check every seven days. Reconnect and retry verification." + case .clockChanged: + return "Your Mac’s clock changed. Set the correct date and time, then retry verification online." + case .invalidReceipt: + return "The license receipt could not be verified. Reconnect and retry verification." + case .deviceRevoked: + return "This Mac was removed in License Manager. Activate the license again to use an available seat." + case .licenseDisabled: + return "This license has been disabled. Contact support for help." + case .appUpdateRequired: + return "Update TCP Viewer to activate this Team license." + case .temporaryFailure: + return "The license server is temporarily unavailable. Please retry shortly." case .error(let message): return message } diff --git a/TCPViewer/Features/License/Models/TCPViewerLicenseReceipt.swift b/TCPViewer/Features/License/Models/TCPViewerLicenseReceipt.swift new file mode 100644 index 0000000..d64c5f8 --- /dev/null +++ b/TCPViewer/Features/License/Models/TCPViewerLicenseReceipt.swift @@ -0,0 +1,83 @@ +// +// TCPViewerLicenseReceipt.swift +// TCPViewer +// +// Created by Proxyman LLC on 9/7/26. +// + +import CryptoKit +import Foundation + +struct TCPViewerLicenseReceipt: Codable, Equatable { + let version: Int + let keyId: String + let payload: String + let signature: String +} + +struct TCPViewerLicenseReceiptClaims: Codable, Equatable { + let activationId: String + let activationTokenHash: String + let productID: String + let device_uuid: String + let licenseType: TCPViewerLicenseType + let email: String + let purchaseAt: String + let expiryAt: String + let numberOfSeats: Int + let usedSeats: Int + let buildNumber: String + let issuedAt: TimeInterval + let offlineUntil: TimeInterval? +} + +struct TCPViewerLicenseReceiptVerifier { + let publicKeys: [String: Data] + + init(publicKeys: [String: Data] = TCPViewerLicenseSigningKeys.publicKeys) { + self.publicKeys = publicKeys + } + + // Authenticate the exact wire bytes before decoding; only signed fields become entitlements. + func verify(_ license: TCPViewerLicense, deviceMatches: (String) -> Bool, + buildNumber: String, now: Date) throws -> (TCPViewerLicense, TCPViewerLicenseReceiptClaims) { + guard let receipt = license.receipt else { throw TCPViewerLicenseError.verificationRequired } + guard receipt.version == 1, receipt.payload.count <= 16384, + let keyData = publicKeys[receipt.keyId], + let key = try? Curve25519.Signing.PublicKey(rawRepresentation: keyData), + let signature = Self.decodeBase64URL(receipt.signature), + let payload = Self.decodeBase64URL(receipt.payload), + key.isValidSignature(signature, for: Data("1.\(receipt.keyId).\(receipt.payload)".utf8)), + let claims = try? JSONDecoder().decode(TCPViewerLicenseReceiptClaims.self, from: payload) else { + throw TCPViewerLicenseError.invalidReceipt + } + let tokenHash = SHA256.hash(data: Data(license.signature.utf8)).map { String(format: "%02x", $0) }.joined() + guard claims.productID == "com.proxyman.TCPViewer", + !claims.activationId.isEmpty, claims.activationTokenHash == tokenHash, + deviceMatches(claims.device_uuid), claims.device_uuid == license.deviceUUID, + claims.numberOfSeats > 0, claims.usedSeats >= 0, claims.usedSeats <= claims.numberOfSeats, + let purchase = TCPViewerLicenseDateParser.date(from: claims.purchaseAt), + let expiry = TCPViewerLicenseDateParser.date(from: claims.expiryAt), expiry >= purchase, + claims.issuedAt > 0, claims.issuedAt <= now.timeIntervalSince1970 + 300 else { + throw TCPViewerLicenseError.invalidReceipt + } + guard claims.buildNumber == buildNumber else { throw TCPViewerLicenseError.verificationRequired } + if claims.licenseType == .teamLicense { + guard claims.numberOfSeats >= 5, let deadline = claims.offlineUntil, + deadline == claims.issuedAt + 7 * 86400 else { throw TCPViewerLicenseError.invalidReceipt } + guard now.timeIntervalSince1970 < deadline else { throw TCPViewerLicenseError.offlineVerificationRequired } + } else if claims.offlineUntil != nil { + throw TCPViewerLicenseError.invalidReceipt + } + let authenticated = TCPViewerLicense(signature: license.signature, deviceUUID: claims.device_uuid, + email: claims.email, purchaseAt: claims.purchaseAt, expiryDate: claims.expiryAt, + licenseType: claims.licenseType, receipt: receipt, activationId: claims.activationId, + numberOfSeats: claims.numberOfSeats, usedSeats: claims.usedSeats) + return (authenticated, claims) + } + + static func decodeBase64URL(_ value: String) -> Data? { + let base64 = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + return Data(base64Encoded: base64 + String(repeating: "=", count: (4 - base64.count % 4) % 4)) + } +} diff --git a/TCPViewer/Features/License/Services/TCPViewerLicenseNetworkClient.swift b/TCPViewer/Features/License/Services/TCPViewerLicenseNetworkClient.swift index 15b033c..fc7bdee 100644 --- a/TCPViewer/Features/License/Services/TCPViewerLicenseNetworkClient.swift +++ b/TCPViewer/Features/License/Services/TCPViewerLicenseNetworkClient.swift @@ -121,6 +121,7 @@ final class TCPViewerLicenseNetworkClient: TCPViewerLicenseNetworkClienting { "deviceUuid": deviceUUID, "licenseKey": licenseKey, "platform": "macos", + "receiptVersion": 1, "buildNumber": buildNumber, "appVersion": appVersion, "osVersion": osVersion, @@ -145,6 +146,7 @@ final class TCPViewerLicenseNetworkClient: TCPViewerLicenseNetworkClienting { "buildNumber": buildNumber, "signature": license.signature, "platform": "macos", + "receiptVersion": 1, "deviceUuid": deviceUUID, "appVersion": appVersion, "osVersion": osVersion, @@ -214,6 +216,8 @@ final class TCPViewerLicenseNetworkClient: TCPViewerLicenseNetworkClienting { } catch { completion(.failure(.error(error.localizedDescription))) } + case 429, 500...599: + completion(.failure(.temporaryFailure)) default: completion(.failure(Self.mapServerError(from: data))) } @@ -231,6 +235,8 @@ final class TCPViewerLicenseNetworkClient: TCPViewerLicenseNetworkClienting { } var request = URLRequest(url: url) request.httpMethod = method + request.timeoutInterval = 30 + request.cachePolicy = .reloadIgnoringLocalCacheData request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) return request @@ -255,6 +261,19 @@ final class TCPViewerLicenseNetworkClient: TCPViewerLicenseNetworkClienting { } private static func mapServerError(from data: Data?) -> TCPViewerLicenseError { + if let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let code = json["code"] as? String { + switch code { + case "out_of_seats": return .outOfSeats + case "renewal_required": return .renewalRequired + case "device_revoked": return .deviceRevoked + case "license_disabled": return .licenseDisabled + case "invalid_license", "invalid_activation": return .invalidLicense + case "app_update_required": return .appUpdateRequired + case "rate_limited", "temporary_failure", "release_unavailable": return .temporaryFailure + default: break + } + } guard let message = serverErrorMessage(from: data) else { return .error("Unknown license server error.") } diff --git a/TCPViewer/Features/License/Services/TCPViewerLicenseService.swift b/TCPViewer/Features/License/Services/TCPViewerLicenseService.swift index 8532efc..7ab1d46 100644 --- a/TCPViewer/Features/License/Services/TCPViewerLicenseService.swift +++ b/TCPViewer/Features/License/Services/TCPViewerLicenseService.swift @@ -5,6 +5,7 @@ // Created by Proxyman LLC on 4/5/26. // +import CryptoKit import Foundation import PcapPlusPlusCore @@ -12,11 +13,6 @@ final class TCPViewerLicenseService { static let shared = TCPViewerLicenseService() static let statusDidChangeNotification = Notification.Name("TCPViewerLicenseServiceStatusDidChange") - private enum Constants { - static let licenseVerificationIntervalHours = 12 - static let lastVerifyDefaultsKey = "TCPViewer.license.lastVerifyTime" - } - private let storage: any TCPViewerLicenseStoring private let networkClient: any TCPViewerLicenseNetworkClienting private let deviceProvider: any TCPViewerLicenseDeviceProviding @@ -25,7 +21,28 @@ final class TCPViewerLicenseService { private let appVersionProvider: () -> String private let osVersionProvider: () -> String private let workerQueue: DispatchQueue + private let verifier: TCPViewerLicenseReceiptVerifier + private let now: () -> Date + private let uptime: () -> TimeInterval private let storedStatus: Protected + private let queueKey = DispatchSpecificKey() + private var clock: TCPViewerLicenseClockState + private var clockAnchor: Date + private var uptimeAnchor: TimeInterval + private var lastClockSave: TimeInterval = 0 + private var generation = 0 + private var verifying = false + private var mutating = false + private var callbacks: [(TCPViewerLicenseStatus) -> Void] = [] + private var timer: DispatchSourceTimer? + private var nextAttempt: TimeInterval = 0 + private var sessionDenial: TCPViewerLicenseDenial? + private static let clockKey = "TCPViewer.license.verificationClock" + private static let denialKey = "TCPViewer.license.verificationDenial" + private static let legacyProofKey = "TCPViewer.license.legacyEntitlementProof" + private static let lastVerifyKey = "TCPViewer.license.lastVerifyTime" + private static let verificationInterval: TimeInterval = 12 * 3600 + private static let retryInterval: TimeInterval = 60 init( storage: any TCPViewerLicenseStoring = TCPViewerLicenseStorage(), @@ -35,7 +52,11 @@ final class TCPViewerLicenseService { buildNumberProvider: @escaping () -> String = { TCPViewerLicenseAppVersion.current.buildNumber }, appVersionProvider: @escaping () -> String = { TCPViewerLicenseAppVersion.current.appVersion }, osVersionProvider: @escaping () -> String = { TCPViewerLicenseAppVersion.current.osVersion }, - workerQueue: DispatchQueue = DispatchQueue(label: "com.proxyman.tcpviewer.LicenseService", qos: .utility) + workerQueue: DispatchQueue = DispatchQueue(label: "com.proxyman.tcpviewer.LicenseService", qos: .utility), + verifier: TCPViewerLicenseReceiptVerifier = TCPViewerLicenseReceiptVerifier(), + now: @escaping () -> Date = Date.init, + uptime: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + startTimer: Bool = true ) { self.storage = storage self.networkClient = networkClient @@ -45,97 +66,102 @@ final class TCPViewerLicenseService { self.appVersionProvider = appVersionProvider self.osVersionProvider = osVersionProvider self.workerQueue = workerQueue - self.storedStatus = Protected(Self.initialStatus(storage: storage, deviceProvider: deviceProvider)) - } - - var status: TCPViewerLicenseStatus { - storedStatus.wrappedValue + self.verifier = verifier + self.now = now + self.uptime = uptime + self.clockAnchor = now() + self.uptimeAnchor = uptime() + self.clock = defaults.data(forKey: Self.clockKey).flatMap { try? JSONDecoder().decode(TCPViewerLicenseClockState.self, from: $0) } + ?? TCPViewerLicenseClockState(maximumTime: now().timeIntervalSince1970, requiresVerification: false) + self.storedStatus = Protected(.unauthorized(.invalidLicense)) + workerQueue.setSpecific(key: queueKey, value: true) + workerQueue.sync { refreshLocalAuthorization(storage.readLicense()) } + if startTimer { + let timer = DispatchSource.makeTimerSource(queue: workerQueue) + timer.setEventHandler { [weak self] in self?.tick() } + self.timer = timer + workerQueue.sync { scheduleNextTimer() } + timer.resume() + } } - var isLicenseAuthorized: Bool { - status.isAuthorized - } + deinit { timer?.cancel() } - var currentLicense: TCPViewerLicense? { - status.license - } + var status: TCPViewerLicenseStatus { storedStatus.wrappedValue } + var isLicenseAuthorized: Bool { status.isAuthorized } + var currentLicense: TCPViewerLicense? { status.license } func activate(licenseKey: String, completion: @escaping (TCPViewerLicenseStatus) -> Void) { - let normalizedKey = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard normalizedKey.hasPrefix("TCPV-") else { + let key = licenseKey.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard key.hasPrefix("TCPV-"), key.count <= 255, key.count >= 8 else { completeOnMain(.unauthorized(.invalidLicense), completion) return } - guard let deviceUUID = deviceProvider.currentDeviceUUID() else { + guard let uuid = deviceProvider.currentDeviceUUID() else { completeOnMain(.unauthorized(.couldNotGetDeviceUUID), completion) return } - - networkClient.registerLicense( - licenseKey: normalizedKey, - deviceName: deviceProvider.deviceName(), - deviceUUID: deviceUUID, - buildNumber: buildNumberProvider(), - appVersion: appVersionProvider(), - osVersion: osVersionProvider() - ) { result in - self.workerQueue.async { - switch result { - case .success(let license): - do { - try self.storage.writeLicense(license) - self.updateLastVerifyLicenseTime() - let status = TCPViewerLicenseStatus.authorized(license) - self.setStatus(status) - self.completeOnMain(status, completion) - } catch { - self.completeOnMain(.unauthorized(.error(error.localizedDescription)), completion) + workerQueue.async { + self.invalidateRequests() + self.mutating = true + let generation = self.generation + self.networkClient.registerLicense(licenseKey: key, deviceName: self.deviceProvider.deviceName(), deviceUUID: uuid, + buildNumber: self.buildNumberProvider(), appVersion: self.appVersionProvider(), osVersion: self.osVersionProvider()) { result in + self.workerQueue.async { + guard generation == self.generation else { self.completeOnMain(self.status, completion); return } + self.mutating = false + let resultStatus: TCPViewerLicenseStatus + switch result { + case .success(let license): resultStatus = self.accept(license) + case .failure(let error): resultStatus = .unauthorized(error) } - case .failure(let error): - self.completeOnMain(.unauthorized(error), completion) + self.scheduleNextTimer() + self.completeOnMain(resultStatus, completion) } } } } - func verifyAtLaunch(completion: ((TCPViewerLicenseStatus) -> Void)? = nil) { - verifyStoredLicense(completion: completion) + func verifyAtLaunch(completion: ((TCPViewerLicenseStatus) -> Void)? = nil) { refreshLicense(completion: completion) } + + // Every presentation calls this, including reuse of the existing hosted license window. + func refreshLicense(completion: ((TCPViewerLicenseStatus) -> Void)? = nil) { + workerQueue.async { self.verifyStoredLicense(completion: completion) } } func verifyIfNeeded(completion: ((TCPViewerLicenseStatus) -> Void)? = nil) { - guard let lastVerifyDate = lastVerifyLicenseDate(), - let nextVerifyDate = Calendar.current.date( - byAdding: .hour, - value: Constants.licenseVerificationIntervalHours, - to: lastVerifyDate - ) else { - verifyStoredLicense(completion: completion) - return - } - - if Date() > nextVerifyDate { - verifyStoredLicense(completion: completion) - } else { - completeOnMain(status, completion) + workerQueue.async { + guard let license = self.storage.readLicense() else { + self.refreshLocalAuthorization(nil) + self.completeOnMain(self.status, completion) + return + } + self.refreshLocalAuthorization(license) + if self.verificationIsDue(for: license) { self.verifyStoredLicense(completion: completion, storedLicense: license) } + else { self.completeOnMain(self.status, completion) } } } func revokeCurrentDevice(completion: @escaping (Result) -> Void) { workerQueue.async { + self.invalidateRequests() guard let license = self.storage.readLicense() else { - self.clearLicense() + self.clearOnQueue() self.completeOnMain(.success(()), completion) return } - + self.mutating = true + let generation = self.generation self.networkClient.revokeLicense(license: license) { result in self.workerQueue.async { - // Clear locally only after License Manager accepted or already lost this device. + guard generation == self.generation else { self.completeOnMain(.failure(.temporaryFailure), completion); return } + self.mutating = false switch result { - case .success, .failure(.invalidLicense): - self.clearLicense() + case .success, .failure(.invalidLicense), .failure(.deviceRevoked): + self.clearOnQueue() self.completeOnMain(.success(()), completion) case .failure(let error): + self.scheduleNextTimer() self.completeOnMain(.failure(error), completion) } } @@ -144,148 +170,343 @@ final class TCPViewerLicenseService { } func clearLicense() { + if DispatchQueue.getSpecific(key: queueKey) != nil { clearOnQueue() } + else { workerQueue.sync { clearOnQueue() } } + } + + private func clearOnQueue() { + invalidateRequests() + mutating = false storage.removeLicense() - defaults.removeObject(forKey: Constants.lastVerifyDefaultsKey) + removeDenial() + defaults.removeObject(forKey: Self.legacyProofKey) + defaults.removeObject(forKey: Self.clockKey) + defaults.removeObject(forKey: Self.lastVerifyKey) setStatus(.unauthorized(.invalidLicense)) + scheduleNextTimer() } - private static func initialStatus( - storage: any TCPViewerLicenseStoring, - deviceProvider: any TCPViewerLicenseDeviceProviding - ) -> TCPViewerLicenseStatus { - guard let license = storage.readLicense(), - Self.locallyValidateStoredLicense(license, deviceProvider: deviceProvider) else { - return .unauthorized(.invalidLicense) - } + private func invalidateRequests() { + generation += 1 + verifying = false + finishCallbacks() + } - return .authorized(license) + // Decide whether this license needs an online check at the current wall-clock time. + private func verificationIsDue(for license: TCPViewerLicense) -> Bool { + guard license.receipt != nil else { + let lastVerifyTime = defaults.double(forKey: Self.lastVerifyKey) + return !status.isAuthorized || lastVerifyTime <= 0 + || now().timeIntervalSince1970 >= lastVerifyTime + Self.verificationInterval + } + guard let claims = receiptClaims(for: license) else { return true } + // The date here only schedules a request; authorization always verifies the signature separately. + return clock.requiresVerification || !status.isAuthorized + || now().timeIntervalSince1970 >= claims.issuedAt + Self.verificationInterval } - private func verifyStoredLicense(completion: ((TCPViewerLicenseStatus) -> Void)?) { - workerQueue.async { [weak self] in - guard let self else { return } - guard let license = storage.readLicense() else { - removeStoredLicenseAndComplete(completion) - return - } - guard locallyValidateStoredLicense(license) else { - removeStoredLicenseAndComplete(completion) - return - } + // Process only the next verification or offline deadline instead of polling stored credentials. + private func tick() { + guard let license = storage.readLicense() else { + if status.isAuthorized { setStatus(.unauthorized(.invalidReceipt)) } + scheduleNextTimer() + return + } + if verificationIsDue(for: license), uptime() >= nextAttempt { + verifyStoredLicense(completion: nil, storedLicense: license) + } else { + refreshLocalAuthorization(license) + scheduleNextTimer(for: license) + } + } - // The server checks the submitted UUID against the signed receipt payload. - networkClient.verifyLicense( - license: license, - deviceUUID: license.deviceUUID, - buildNumber: buildNumberProvider(), - appVersion: appVersionProvider(), - osVersion: osVersionProvider() - ) { result in - self.workerQueue.async { - switch result { - case .success(let updatedLicense): - do { - try self.storage.writeLicense(updatedLicense) - self.updateLastVerifyLicenseTime() - let status = TCPViewerLicenseStatus.authorized(updatedLicense) - self.setStatus(status) - self.completeOnMain(status, completion) - } catch { - self.completeOnMain(.unauthorized(.error(error.localizedDescription)), completion) + // Coalesce simultaneous launch, foreground, and paywall checks; generations reject obsolete callbacks. + private func verifyStoredLicense( + completion: ((TCPViewerLicenseStatus) -> Void)?, + storedLicense: TCPViewerLicense? = nil + ) { + let license = storedLicense ?? storage.readLicense() + refreshLocalAuthorization(license) + guard !mutating else { completeOnMain(status, completion); return } + if let completion { callbacks.append(completion) } + guard !verifying else { return } + guard let license else { finishCallbacks(); scheduleNextTimer(); return } + guard deviceProvider.isSameDeviceUUID(license.deviceUUID) else { + setStatus(.unauthorized(.invalidReceipt)); finishCallbacks(); scheduleNextTimer(for: license); return + } + verifying = true + nextAttempt = uptime() + Self.retryInterval + let generation = generation + networkClient.verifyLicense(license: license, deviceUUID: license.deviceUUID, + buildNumber: buildNumberProvider(), appVersion: appVersionProvider(), osVersion: osVersionProvider()) { result in + self.workerQueue.async { + guard generation == self.generation else { return } + self.verifying = false + switch result { + case .success(let updated): self.setStatus(self.accept(updated)) + case .failure(let error): + if error.isTemporary { + self.refreshLocalAuthorization(license) + } else { + // Keep renewal credentials, but never restore an explicitly denied receipt offline. + let denial = TCPViewerLicenseDenial(licenseIdentity: self.verificationIdentity(for: license), + renewalRequired: error == .renewalRequired || error == .expired) + self.saveDenial(denial) + if error == .deviceRevoked || error == .licenseDisabled || error == .invalidLicense { + self.storage.removeLicense() + self.defaults.removeObject(forKey: Self.legacyProofKey) } - case .failure(.noInternetConnection): - // Offline Macs keep their current receipt until the server can be reached. - self.completeOnMain(self.status, completion) - case .failure(let error): - self.removeStoredLicenseAndComplete(completion, error: error) + self.setStatus(.unauthorized(error)) } } + self.finishCallbacks() + self.scheduleNextTimer() } } } - private func locallyValidateStoredLicense(_ license: TCPViewerLicense) -> Bool { - Self.locallyValidateStoredLicense(license, deviceProvider: deviceProvider) + private func accept(_ license: TCPViewerLicense) -> TCPViewerLicenseStatus { + do { + let authenticated: TCPViewerLicense + let requiresSignedReceipt = license.receipt != nil || license.licenseType == .teamLicense + || license.signature.hasPrefix("TCPVA-") + if requiresSignedReceipt { + (authenticated, _) = try verifier.verify(license, deviceMatches: deviceProvider.isSameDeviceUUID, + buildNumber: buildNumberProvider(), now: now()) + } else { + guard hasValidLegacyLicenseShape(license) else { throw TCPViewerLicenseError.verificationRequired } + authenticated = license + } + try storage.writeLicense(authenticated) + if requiresSignedReceipt { + defaults.removeObject(forKey: Self.legacyProofKey) + } else { + saveLegacyProof(for: authenticated) + } + clock = TCPViewerLicenseClockState(maximumTime: now().timeIntervalSince1970, requiresVerification: false) + saveClock() + removeDenial() + clockAnchor = now() + uptimeAnchor = uptime() + defaults.set(now().timeIntervalSince1970, forKey: Self.lastVerifyKey) + let status = TCPViewerLicenseStatus.authorized(authenticated) + setStatus(status) + return status + } catch let error as TCPViewerLicenseError { return .unauthorized(error) } + catch { return .unauthorized(.error("Could not save the verified license. Please retry.")) } } - private static func locallyValidateStoredLicense( - _ license: TCPViewerLicense, - deviceProvider: any TCPViewerLicenseDeviceProviding - ) -> Bool { - // Bind the encrypted receipt to this Mac so copied Application Support files cannot unlock PRO. - guard deviceProvider.isSameDeviceUUID(license.deviceUUID) else { - return false + private func refreshLocalAuthorization(_ license: TCPViewerLicense?) { + guard let license else { + if status.isAuthorized { setStatus(.unauthorized(.invalidReceipt)) } + return } - guard license.signature.count >= 20 else { - return false + if let denial = currentDenial(), denial.licenseIdentity == verificationIdentity(for: license) { + setStatus(.unauthorized(denial.renewalRequired ? .renewalRequired : .verificationRequired)) + return } - guard license.hasValidUpdateEntitlement else { - return false + if license.receipt == nil { + setStatus(locallyValidateLegacyLicense(license) ? .authorized(license) : .unauthorized(.verificationRequired)) + return } - if license.hasLifetimeUpdates { - return true + let wallTime = now().timeIntervalSince1970 + let monotonicTime = clockAnchor.timeIntervalSince1970 + max(0, uptime() - uptimeAnchor) + let previousClockFailure = clock.requiresVerification + if wallTime + 5 < max(clock.maximumTime, monotonicTime) { clock.requiresVerification = true } + clock.maximumTime = max(clock.maximumTime, wallTime, monotonicTime) + if clock.requiresVerification != previousClockFailure || uptime() >= lastClockSave + 60 { + saveClock() + lastClockSave = uptime() } - guard let remainingDays = license.remainingDays else { + guard !clock.requiresVerification else { setStatus(.unauthorized(.clockChanged)); return } + do { + let (authenticated, _) = try verifier.verify(license, deviceMatches: deviceProvider.isSameDeviceUUID, + buildNumber: buildNumberProvider(), now: Date(timeIntervalSince1970: clock.maximumTime)) + setStatus(.authorized(authenticated)) + } catch let error as TCPViewerLicenseError { setStatus(.unauthorized(error)) } + catch { setStatus(.unauthorized(.invalidReceipt)) } + } + + // Keep old licenses offline, and use a local proof for legitimate multi-year renewals. + private func locallyValidateLegacyLicense(_ license: TCPViewerLicense) -> Bool { + guard hasValidLegacyLicenseShape(license) else { return false } + return canUseOriginalLegacyValidation(license) || storedLegacyProofMatches(license) + } + + // Reject malformed individual payloads before accepting a trusted legacy server response. + private func hasValidLegacyLicenseShape(_ license: TCPViewerLicense) -> Bool { + guard license.receipt == nil, + license.licenseType != .teamLicense, + !license.signature.hasPrefix("TCPVA-"), + deviceProvider.isSameDeviceUUID(license.deviceUUID), + license.signature.count >= 20, + let purchaseDate = TCPViewerLicenseDateParser.date(from: license.purchaseAt), + let expiryDate = TCPViewerLicenseDateParser.date(from: license.expiryDate) else { return false } - - // Also reject far-future expiry values that still fit a forged one-year window. - return remainingDays < 3000 + return expiryDate >= purchaseDate } - private func removeStoredLicenseAndComplete( - _ completion: ((TCPViewerLicenseStatus) -> Void)?, - error: TCPViewerLicenseError = .invalidLicense - ) { - storage.removeLicense() - defaults.removeObject(forKey: Constants.lastVerifyDefaultsKey) - let status = TCPViewerLicenseStatus.unauthorized(error) - setStatus(status) - completeOnMain(status, completion) + // Preserve the validation available to individual licenses stored by older app versions. + private func canUseOriginalLegacyValidation(_ license: TCPViewerLicense) -> Bool { + guard license.hasValidLegacyUpdateEntitlement else { return false } + return license.hasLifetimeUpdates || (license.remainingDays.map { $0 < 3000 } ?? false) } - private func setStatus(_ status: TCPViewerLicenseStatus) { - storedStatus.wrappedValue = status - TCPViewerLicenseService.postStatusDidChange(status) + // Bind a server-verified multi-year entitlement to its exact local license fields. + private func saveLegacyProof(for license: TCPViewerLicense) { + guard let data = try? JSONEncoder().encode(legacyProof(for: license)) else { return } + defaults.set(data, forKey: Self.legacyProofKey) } - private func lastVerifyLicenseDate() -> Date? { - let timestamp = defaults.double(forKey: Constants.lastVerifyDefaultsKey) - guard timestamp > 0 else { - return nil + // Require every locally loaded field to match the proof saved after online verification. + private func storedLegacyProofMatches(_ license: TCPViewerLicense) -> Bool { + guard let data = defaults.data(forKey: Self.legacyProofKey), + let proof = try? JSONDecoder().decode(TCPViewerLegacyLicenseProof.self, from: data) else { + return false } + return proof == legacyProof(for: license) + } - return Date(timeIntervalSince1970: timestamp) + // Hash the credential before putting the legacy entitlement proof in app preferences. + private func legacyProof(for license: TCPViewerLicense) -> TCPViewerLegacyLicenseProof { + TCPViewerLegacyLicenseProof( + credentialHash: sha256(license.signature), + deviceUUID: license.deviceUUID, + purchaseAt: license.purchaseAt, + expiryDate: license.expiryDate, + licenseType: license.licenseType, + activationId: license.activationId + ) } - private func updateLastVerifyLicenseTime() { - defaults.set(Date().timeIntervalSince1970, forKey: Constants.lastVerifyDefaultsKey) + // Use the server identity when available and a credential digest for older receipts. + private func verificationIdentity(for license: TCPViewerLicense) -> String { + if let activationId = license.activationId { return activationId } + return "legacy:" + sha256(license.signature) } - private func completeOnMain(_ value: T, _ completion: ((T) -> Void)?) { - guard let completion else { + // Return a stable lowercase digest for local identity comparisons. + private func sha256(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } + + // Persist server denials so restarting offline cannot restore rejected authorization. + private func saveDenial(_ denial: TCPViewerLicenseDenial) { + sessionDenial = denial + guard let data = try? JSONEncoder().encode(denial) else { return } + defaults.set(data, forKey: Self.denialKey) + } + + // Prefer the current session denial before reading the persisted value. + private func currentDenial() -> TCPViewerLicenseDenial? { + if let sessionDenial { return sessionDenial } + guard let data = defaults.data(forKey: Self.denialKey) else { return nil } + return try? JSONDecoder().decode(TCPViewerLicenseDenial.self, from: data) + } + + // Clear every persisted form of denial after removal or successful verification. + private func removeDenial() { + sessionDenial = nil + defaults.removeObject(forKey: Self.denialKey) + } + + // Save the clock state without invoking system credential UI. + private func saveClock() { + guard let data = try? JSONEncoder().encode(clock) else { return } + defaults.set(data, forKey: Self.clockKey) + } + + // Arm one timer for the next online check, retry, or signed offline deadline. + private func scheduleNextTimer(for storedLicense: TCPViewerLicense? = nil) { + guard let timer else { return } + guard !verifying, !mutating else { + timer.schedule(deadline: .distantFuture) + return + } + guard let license = storedLicense ?? storage.readLicense() else { + timer.schedule(deadline: .distantFuture) return } - Self.performOnMain { - completion(value) + let delay: TimeInterval + if verificationIsDue(for: license) { + let retryDelay = max(1, nextAttempt - uptime()) + if let deadline = signedOfflineDeadline(for: license) { + delay = max(1, min(retryDelay, deadline.timeIntervalSince(now()))) + } else { + delay = retryDelay + } + } else { + delay = max(1, nextScheduledDate(for: license).timeIntervalSince(now())) } + timer.schedule(deadline: .now() + delay, leeway: .milliseconds(500)) } - private static func postStatusDidChange(_ status: TCPViewerLicenseStatus) { - performOnMain { - NotificationCenter.default.post( - name: TCPViewerLicenseService.statusDidChangeNotification, - object: status - ) + // Return the next event encoded by the current legacy or signed receipt. + private func nextScheduledDate(for license: TCPViewerLicense) -> Date { + guard let claims = receiptClaims(for: license) else { + let lastVerifyTime = defaults.double(forKey: Self.lastVerifyKey) + return Date(timeIntervalSince1970: lastVerifyTime + Self.verificationInterval) } + + let verificationDate = Date(timeIntervalSince1970: claims.issuedAt + Self.verificationInterval) + guard let offlineUntil = claims.offlineUntil else { return verificationDate } + return min(verificationDate, Date(timeIntervalSince1970: offlineUntil)) } - private static func performOnMain(_ block: @escaping () -> Void) { - if Thread.isMainThread { - block() - } else { - DispatchQueue.main.async(execute: block) + // Return the signed cutoff that must preempt a later network retry. + private func signedOfflineDeadline(for license: TCPViewerLicense) -> Date? { + guard let offlineUntil = receiptClaims(for: license)?.offlineUntil else { return nil } + return Date(timeIntervalSince1970: offlineUntil) + } + + // Decode untrusted claims only for scheduling; authorization verifies their signature separately. + private func receiptClaims(for license: TCPViewerLicense) -> TCPViewerLicenseReceiptClaims? { + guard let receipt = license.receipt, + let data = TCPViewerLicenseReceiptVerifier.decodeBase64URL(receipt.payload) else { return nil } + return try? JSONDecoder().decode(TCPViewerLicenseReceiptClaims.self, from: data) + } + + private func finishCallbacks() { + let pending = callbacks + callbacks.removeAll() + pending.forEach { completeOnMain(status, $0) } + } + + private func setStatus(_ status: TCPViewerLicenseStatus) { + guard storedStatus.wrappedValue != status else { return } + storedStatus.wrappedValue = status + Self.performOnMain { + NotificationCenter.default.post(name: Self.statusDidChangeNotification, object: status) } } + + private func completeOnMain(_ value: T, _ completion: ((T) -> Void)?) { + guard let completion else { return } + Self.performOnMain { completion(value) } + } + + private static func performOnMain(_ block: @escaping () -> Void) { + if Thread.isMainThread { block() } else { DispatchQueue.main.async(execute: block) } + } +} + +private struct TCPViewerLicenseDenial: Codable { + let licenseIdentity: String + let renewalRequired: Bool +} + +private struct TCPViewerLicenseClockState: Codable { + var maximumTime: TimeInterval + var requiresVerification: Bool +} + +private struct TCPViewerLegacyLicenseProof: Codable, Equatable { + let credentialHash: String + let deviceUUID: String + let purchaseAt: String + let expiryDate: String + let licenseType: TCPViewerLicenseType + let activationId: String? } diff --git a/TCPViewer/Features/License/Support/TCPViewerLicenseSigningKeys.swift b/TCPViewer/Features/License/Support/TCPViewerLicenseSigningKeys.swift new file mode 100644 index 0000000..0cb5a04 --- /dev/null +++ b/TCPViewer/Features/License/Support/TCPViewerLicenseSigningKeys.swift @@ -0,0 +1,15 @@ +// +// TCPViewerLicenseSigningKeys.swift +// TCPViewer +// +// Created by Proxyman LLC on 9/7/26. +// + +import Foundation + +enum TCPViewerLicenseSigningKeys { + // Deploy the matching private key on the backend before shipping this public key. + static let publicKeys: [String: Data] = [ + "tcpviewer-2026-09-08": Data(base64Encoded: "907A+t0nMdjrOgoQZmzDZLEQIuiB2B+KTmdC3d3ohAo=")!, + ] +} diff --git a/TCPViewer/Features/License/Support/TCPViewerLicenseWebsiteService.swift b/TCPViewer/Features/License/Support/TCPViewerLicenseWebsiteService.swift index 5fb3bb5..f70cebf 100644 --- a/TCPViewer/Features/License/Support/TCPViewerLicenseWebsiteService.swift +++ b/TCPViewer/Features/License/Support/TCPViewerLicenseWebsiteService.swift @@ -11,7 +11,9 @@ import Foundation enum TCPViewerLicenseWebsiteService { enum WebsiteURL: String { case buyLicense = "https://tcpviewer.proxyman.com/pricing" - case renewLicense = "https://tcpviewer.proxyman.com/pricing#renew" + case renewLicense = "https://tcpviewer.proxyman.com/renew-license" + case addSeats = "https://tcpviewer.proxyman.com/extend-seats" + case updateApp = "https://tcpviewer.proxyman.com/#download" case licenseManager = "https://tcpviewer.proxyman.com/license-manager/access-link" case support = "mailto:tcpviewer@proxyman.com" } diff --git a/TCPViewer/Features/License/Views/TCPViewerLicenseView.swift b/TCPViewer/Features/License/Views/TCPViewerLicenseView.swift index b8cb5f1..5aaebea 100644 --- a/TCPViewer/Features/License/Views/TCPViewerLicenseView.swift +++ b/TCPViewer/Features/License/Views/TCPViewerLicenseView.swift @@ -30,12 +30,14 @@ struct TCPViewerLicenseView: View { Feature(systemImage: "list.bullet.rectangle.portrait", title: "Packet Inspection", detail: "Browse decoded packet details, bytes, and protocol fields."), Feature(systemImage: "magnifyingglass.circle", title: "libwireshark Protocol Details", detail: "Packet dissection is built on libwireshark, providing detailed fields across supported protocols."), Feature(systemImage: "line.3.horizontal.decrease.circle", title: "Focused Filtering", detail: "Use capture and packet workflows built for TCP/UDP investigation."), + Feature(systemImage: "sparkles", title: "TCP Viewer MCP", detail: "Connect Codex or another MCP client to query packets and control captures."), ] @State private var status: TCPViewerLicenseStatus @State private var statusObserver: NSObjectProtocol? @State private var isActivating = false @State private var isRevoking = false + @State private var isShowingRemoveLicenseConfirmation = false init( licenseService: TCPViewerLicenseService, @@ -76,6 +78,14 @@ struct TCPViewerLicenseView: View { .background(.regularMaterial) .onAppear(perform: startObservingStatus) .onDisappear(perform: stopObservingStatus) + .alert("Remove License?", isPresented: $isShowingRemoveLicenseConfirmation) { + Button("Remove License", role: .destructive) { + revokeLicense() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("TCP Viewer PRO will be deactivated on this Mac, and its seat will become available for another device.") + } } private func content(minColumnHeight: CGFloat) -> some View { @@ -83,7 +93,9 @@ struct TCPViewerLicenseView: View { VStack(alignment: .leading, spacing: 28) { header licenseState - primaryActionArea + if !status.isAuthorized { + primaryActionArea + } Spacer(minLength: 0) licenseManagementArea } @@ -134,14 +146,16 @@ struct TCPViewerLicenseView: View { switch status { case .authorized(let license): LicenseInfoPanel(license: license) - case .unauthorized: + case .unauthorized(let error): VStack(alignment: .leading, spacing: 5) { - Text(unauthorizedTitle) + Text(error == .invalidLicense ? unauthorizedTitle : "License needs attention") .font(.headline) .foregroundStyle(.orange) - Text(unauthorizedMessage) + Text(error == .invalidLicense ? unauthorizedMessage : (error.errorDescription ?? unauthorizedMessage)) .font(.system(size: 13)) .foregroundStyle(.secondary) + recoveryActions(for: error) + .padding(.top, 6) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) @@ -150,6 +164,30 @@ struct TCPViewerLicenseView: View { } } + @ViewBuilder + private func recoveryActions(for error: TCPViewerLicenseError) -> some View { + switch error { + case .outOfSeats: + HStack { + Button("License Manager") { TCPViewerLicenseWebsiteService.open(.licenseManager) } + Button("Add Seats") { TCPViewerLicenseWebsiteService.open(.addSeats) } + } + case .renewalRequired, .expired: + HStack { + Button("Renew License") { TCPViewerLicenseWebsiteService.open(.renewLicense) } + Button("Retry Verification") { licenseService.refreshLicense() } + } + case .deviceRevoked: + Button("Activate License") { showActivationAlert() } + case .licenseDisabled, .invalidLicense: + Button("Contact Support") { TCPViewerLicenseWebsiteService.open(.support) } + case .appUpdateRequired: + Button("Update TCP Viewer") { TCPViewerLicenseWebsiteService.open(.updateApp) } + default: + Button("Retry Verification") { licenseService.refreshLicense() } + } + } + private var primaryActionArea: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { @@ -187,7 +225,7 @@ struct TCPViewerLicenseView: View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Button { - revokeLicense() + isShowingRemoveLicenseConfirmation = true } label: { Label("Remove License", systemImage: "trash") } @@ -200,6 +238,12 @@ struct TCPViewerLicenseView: View { } } + if let license = status.license, license.licenseType == .teamLicense { + HStack { + Button("Renew License") { TCPViewerLicenseWebsiteService.open(.renewLicense) } + Button("Add Seats") { TCPViewerLicenseWebsiteService.open(.addSeats) } + } + } Text("Find, transfer, or revoke devices from License Manager.") .font(.system(size: 12)) .foregroundStyle(.tertiary) @@ -256,12 +300,12 @@ struct TCPViewerLicenseView: View { private var featureChecklist: some View { VStack(alignment: .leading, spacing: 8) { - ChecklistRow(title: "Simple perpetual license with 1 year of updates") - ChecklistRow(title: "Transfer seats through License Manager") - ChecklistRow(title: "Native macOS packet analyzer by Proxyman LLC") + ChecklistRow(title: "Perpetual licenses for individuals and teams") + ChecklistRow(title: "One year or lifetime updates, depending on your plan") + ChecklistRow(title: "Manage active Macs through License Manager") ChecklistLinkRow( - title: "Open source on GitHub: ProxymanApp/Packetry", - destination: URL(string: "https://github.com/ProxymanApp/Packetry")! + title: "Open source on GitHub: ProxymanApp/TCPViewer", + destination: URL(string: "https://github.com/ProxymanApp/TCPViewer")! ) } .font(.system(size: 13, weight: .medium)) @@ -304,6 +348,7 @@ struct TCPViewerLicenseView: View { case .authorized: showSuccessAlert() case .unauthorized(let error): + status = .unauthorized(error) handleActivationError(error) } } @@ -331,13 +376,14 @@ struct TCPViewerLicenseView: View { case .outOfSeats: let alert = NSAlert() alert.messageText = "No seats available" - alert.informativeText = "Your license is already used on the maximum number of devices. Open License Manager to revoke an old device, then try again." + alert.informativeText = error.errorDescription ?? "All seats are occupied." alert.alertStyle = .warning alert.addButton(withTitle: "License Manager") + alert.addButton(withTitle: "Add Seats") alert.addButton(withTitle: "Later") - if alert.runModal() == .alertFirstButtonReturn { - TCPViewerLicenseWebsiteService.open(.licenseManager) - } + let response = alert.runModal() + if response == .alertFirstButtonReturn { TCPViewerLicenseWebsiteService.open(.licenseManager) } + if response == .alertSecondButtonReturn { TCPViewerLicenseWebsiteService.open(.addSeats) } case .expired, .renewalRequired: let alert = NSAlert() alert.messageText = "This Build Is Not Covered" @@ -412,6 +458,10 @@ private struct LicenseInfoPanel: View { .font(.headline) } + if license.licenseType == .teamLicense { + Text("Team License · \(license.usedSeats ?? 0) of \(license.numberOfSeats ?? 0) seats used") + .font(.system(size: 13, weight: .medium)) + } Text(expiryText) .font(.system(size: 13)) .foregroundStyle(.secondary) diff --git a/TCPViewerTests/Features/License/Fixtures/license-receipt-v1.json b/TCPViewerTests/Features/License/Fixtures/license-receipt-v1.json new file mode 100644 index 0000000..e92563b --- /dev/null +++ b/TCPViewerTests/Features/License/Fixtures/license-receipt-v1.json @@ -0,0 +1,21 @@ +{ + "publicKey": "9VIxkVOWyCVl0DyCvOBIjjQHPfUs8+52kkVIyl6euPs=", + "now": 1788775200, + "license": { + "signature": "TCPVA-public-fixture-credential-not-valid-on-any-server", + "device_uuid": "fixture-mac", + "email": "fixture@example.com", + "purchaseAt": "2026-01-01T00:00:00.000Z", + "expiryAt": "2028-01-01T23:59:59.999Z", + "licenseType": "team_license", + "receipt": { + "version": 1, + "keyId": "cross-language-fixture", + "payload": "eyJhY3RpdmF0aW9uSWQiOiIzYzFjY2ZkMy03Yzg2LTQzMmYtYmFmYy0yOWZlZmI4NWM5OTciLCJhY3RpdmF0aW9uVG9rZW5IYXNoIjoiN2QyNTAwZWMxNjkzOTJjMDU4MjQ3OTBmYzg2OGE2MWIzNmQ4NTJiNjdlOTQ1MTYzNTQzNTQwZTA1ZjA2OWFmYiIsInByb2R1Y3RJRCI6ImNvbS5wcm94eW1hbi5UQ1BWaWV3ZXIiLCJkZXZpY2VfdXVpZCI6ImZpeHR1cmUtbWFjIiwibGljZW5zZVR5cGUiOiJ0ZWFtX2xpY2Vuc2UiLCJlbWFpbCI6ImZpeHR1cmVAZXhhbXBsZS5jb20iLCJwdXJjaGFzZUF0IjoiMjAyNi0wMS0wMVQwMDowMDowMC4wMDBaIiwiZXhwaXJ5QXQiOiIyMDI4LTAxLTAxVDIzOjU5OjU5Ljk5OVoiLCJudW1iZXJPZlNlYXRzIjo1LCJ1c2VkU2VhdHMiOjIsImJ1aWxkTnVtYmVyIjoiOTk5IiwiaXNzdWVkQXQiOjE3ODg3NzUyMDAsIm9mZmxpbmVVbnRpbCI6MTc4OTM4MDAwMH0", + "signature": "Cn-lE2ePMgNL2WiKCO5_XSYqIMy4aKcYmGhr7VkdhkwhLbHEEMvOX6Yx34VSKBdjtz3iJYIb7HV0UDP9o3zZAA" + }, + "activationId": "3c1ccfd3-7c86-432f-bafc-29fefb85c997", + "numberOfSeats": 5, + "usedSeats": 2 + } +} diff --git a/TCPViewerTests/Features/License/TCPViewerLicenseModelStorageTests.swift b/TCPViewerTests/Features/License/TCPViewerLicenseModelStorageTests.swift index 3dc88bc..dcc8a95 100644 --- a/TCPViewerTests/Features/License/TCPViewerLicenseModelStorageTests.swift +++ b/TCPViewerTests/Features/License/TCPViewerLicenseModelStorageTests.swift @@ -30,7 +30,6 @@ struct TCPViewerLicenseModelStorageTests { #expect(license.expiryDate == "2027-05-01T10:20:30.123Z") #expect(license.licenseType == .standardLicense) #expect(license.formattedExpiryDate.contains("2027")) - #expect(license.hasOneYearUpdateWindow) } @Test func decodesOldPayloadWithoutLicenseTypeAsStandard() throws { @@ -49,18 +48,6 @@ struct TCPViewerLicenseModelStorageTests { #expect(license.licenseType == .standardLicense) } - @Test func updateWindowRejectsReceiptsLongerThanOneYear() { - let license = TCPViewerLicense( - signature: "abcdefghijklmnopqrstuvwxyz", - deviceUUID: "device-1", - email: "ada@example.com", - purchaseAt: "2026-05-01T10:20:30.123Z", - expiryDate: "2028-05-01T10:20:30.123Z" - ) - - #expect(!license.hasOneYearUpdateWindow) - } - @Test func lifetimeLicenseAllowsUnlimitedUpdateWindow() { let license = TCPViewerLicense( signature: "abcdefghijklmnopqrstuvwxyz", @@ -71,8 +58,6 @@ struct TCPViewerLicenseModelStorageTests { licenseType: .lifetimeLicense ) - #expect(!license.hasOneYearUpdateWindow) - #expect(license.hasValidUpdateEntitlement) #expect(license.updateAvailabilityDescription == "Lifetime updates included") } diff --git a/TCPViewerTests/Features/License/TCPViewerLicenseNetworkClientTests.swift b/TCPViewerTests/Features/License/TCPViewerLicenseNetworkClientTests.swift index 0e20531..f49dd2b 100644 --- a/TCPViewerTests/Features/License/TCPViewerLicenseNetworkClientTests.swift +++ b/TCPViewerTests/Features/License/TCPViewerLicenseNetworkClientTests.swift @@ -102,6 +102,7 @@ struct TCPViewerLicenseNetworkClientTests { #expect(body["deviceName"] as? String == "Ada's Mac") #expect(body["deviceUuid"] as? String == "device-1") #expect(body["platform"] as? String == "macos") + #expect(body["receiptVersion"] as? Int == 1) #expect(body["buildNumber"] as? String == "123") #expect(body["appVersion"] as? String == "1.2.3") #expect(body["osVersion"] as? String == "macOS 15.6") @@ -131,6 +132,7 @@ struct TCPViewerLicenseNetworkClientTests { #expect(body["signature"] as? String == makeLicense().signature) #expect(body["deviceUuid"] as? String == "device-1") #expect(body["platform"] as? String == "macos") + #expect(body["receiptVersion"] as? Int == 1) #expect(body["buildNumber"] as? String == "456") #expect(body["appVersion"] as? String == "1.2.3") #expect(body["osVersion"] as? String == "macOS 15.6") @@ -225,6 +227,25 @@ struct TCPViewerLicenseNetworkClientTests { } } + @Test func mapsStableCodesAndTreats429AndServerFailuresAsTemporary() throws { + let cases: [(Int, String, TCPViewerLicenseError)] = [ + (409, "out_of_seats", .outOfSeats), (403, "renewal_required", .renewalRequired), + (403, "device_revoked", .deviceRevoked), (403, "license_disabled", .licenseDisabled), + (400, "app_update_required", .appUpdateRequired), (400, "invalid_license", .invalidLicense), + (429, "rate_limited", .temporaryFailure), (503, "unknown", .temporaryFailure), + ] + for (status, code, expected) in cases { + let transport = StubLicenseTransport() + let data = try JSONSerialization.data(withJSONObject: ["code": code, "message": "Server wording may change"]) + transport.nextResult = .success((data, makeResponse(statusCode: status))) + let client = TCPViewerLicenseNetworkClient(baseURL: URL(string: "https://example.com")!, transport: transport) + let result = waitForLicenseResult { + client.verifyLicense(license: makeLicense(), deviceUUID: "device-1", buildNumber: "999", appVersion: "1", osVersion: "26", completion: $0) + } + #expect(result == .failure(expected)) + } + } + private func makeLicense() -> TCPViewerLicense { TCPViewerLicense( signature: "abcdefghijklmnopqrstuvwxyz", diff --git a/TCPViewerTests/Features/License/TCPViewerLicenseReceiptTests.swift b/TCPViewerTests/Features/License/TCPViewerLicenseReceiptTests.swift new file mode 100644 index 0000000..425aa9d --- /dev/null +++ b/TCPViewerTests/Features/License/TCPViewerLicenseReceiptTests.swift @@ -0,0 +1,91 @@ +// +// TCPViewerLicenseReceiptTests.swift +// TCPViewer +// +// Created by Proxyman LLC on 9/7/26. +// + +import CryptoKit +import Foundation +import Testing +@testable import TCPViewer + +struct TCPViewerLicenseReceiptTests { + @Test func authenticatesSharedNodeFixture() throws { + struct Fixture: Decodable { let publicKey: String; let now: TimeInterval; let license: TCPViewerLicense } + let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent("Fixtures/license-receipt-v1.json") + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + let verifier = TCPViewerLicenseReceiptVerifier(publicKeys: ["cross-language-fixture": Data(base64Encoded: fixture.publicKey)!]) + let (license, claims) = try verifier.verify(fixture.license, deviceMatches: { $0 == "fixture-mac" }, buildNumber: "999", now: Date(timeIntervalSince1970: fixture.now)) + #expect(license.licenseType == .teamLicense) + #expect(claims.offlineUntil == fixture.now + 604800) + #expect(license.expiryDate.hasPrefix("2028-01-01")) + } + + @Test func rejectsTamperedSignaturePayloadVersionAndUnknownKey() throws { + let rig = LicenseTestRig(); let license = try rig.signed(); let receipt = try #require(license.receipt) + let variants = [ + TCPViewerLicenseReceipt(version: 1, keyId: "test", payload: receipt.payload + "x", signature: receipt.signature), + TCPViewerLicenseReceipt(version: 1, keyId: "test", payload: receipt.payload, signature: Data(repeating: 0, count: 64).licenseBase64URL), + TCPViewerLicenseReceipt(version: 2, keyId: "test", payload: receipt.payload, signature: receipt.signature), + TCPViewerLicenseReceipt(version: 1, keyId: "unknown", payload: receipt.payload, signature: receipt.signature), + ] + for receipt in variants { + var altered = license; altered.receipt = receipt + #expect(throws: TCPViewerLicenseError.invalidReceipt) { try verifier(rig).verify(altered, deviceMatches: { _ in true }, buildNumber: "999", now: rig.date) } + } + } + + @Test func rejectsWrongMacBuildAndActivationCredential() throws { + let rig = LicenseTestRig(); let license = try rig.signed() + #expect(throws: TCPViewerLicenseError.invalidReceipt) { try verifier(rig).verify(license, deviceMatches: { _ in false }, buildNumber: "999", now: rig.date) } + #expect(throws: TCPViewerLicenseError.verificationRequired) { try verifier(rig).verify(license, deviceMatches: { _ in true }, buildNumber: "1000", now: rig.date) } + var altered = license; altered.signature = "stolen-other-token" + #expect(throws: TCPViewerLicenseError.invalidReceipt) { try verifier(rig).verify(altered, deviceMatches: { _ in true }, buildNumber: "999", now: rig.date) } + } + + @Test func trustsOnlySignedFieldsAndEnforcesExactDeadline() throws { + let rig = LicenseTestRig(); var license = try rig.signed() + license.expiryDate = "2099-01-01T00:00:00Z"; license.numberOfSeats = 999 + let (verified, _) = try verifier(rig).verify(license, deviceMatches: { _ in true }, buildNumber: "999", now: rig.date) + #expect(verified.expiryDate == "2028-01-01T23:59:59.999Z"); #expect(verified.numberOfSeats == 5) + _ = try verifier(rig).verify(license, deviceMatches: { _ in true }, buildNumber: "999", now: rig.date.addingTimeInterval(604799)) + #expect(throws: TCPViewerLicenseError.offlineVerificationRequired) { + try verifier(rig).verify(license, deviceMatches: { _ in true }, buildNumber: "999", now: rig.date.addingTimeInterval(604800)) + } + } + + @Test func rejectsSignedReceiptsWithInvalidProductSeatBoundsAndOfflineTerms() throws { + let rig = LicenseTestRig(); let license = try rig.signed(); let receipt = try #require(license.receipt) + let original = try #require(TCPViewerLicenseReceiptVerifier.decodeBase64URL(receipt.payload)) + for (field, value) in [("productID", "another-app" as Any), ("numberOfSeats", 4), ("usedSeats", 6), ("offlineUntil", rig.date.timeIntervalSince1970 + 604801)] { + var claims = try #require(JSONSerialization.jsonObject(with: original) as? [String: Any]); claims[field] = value + let payload = try JSONSerialization.data(withJSONObject: claims).licenseBase64URL + let signature = try rig.key.signature(for: Data("1.test.\(payload)".utf8)).licenseBase64URL + var altered = license; altered.receipt = TCPViewerLicenseReceipt(version: 1, keyId: "test", payload: payload, signature: signature) + #expect(throws: TCPViewerLicenseError.invalidReceipt) { try verifier(rig).verify(altered, deviceMatches: { _ in true }, buildNumber: "999", now: rig.date) } + } + } + + @Test func signedStorageKeepsCredentialInsideEncryptedReceiptFile() throws { + let rig = LicenseTestRig(); let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("receipt.bin") + let cipher = TCPViewerLicenseCipher(secret: "test-only") + let storage = TCPViewerLicenseStorage(fileURL: url, cipher: cipher) + let license = try rig.signed(); try storage.writeLicense(license) + let encrypted = try Data(contentsOf: url) + let stored = try JSONDecoder().decode(TCPViewerLicense.self, from: cipher.decrypt(encrypted)) + #expect(stored.signature == license.signature) + #expect(!String(decoding: encrypted, as: UTF8.self).contains(license.signature)) + #expect(storage.readLicense() == license) + try storage.writeLicense(rig.legacy()) + #expect(storage.readLicense() == rig.legacy()) + storage.removeLicense() + #expect(storage.readLicense() == nil) + } + + private func verifier(_ rig: LicenseTestRig) -> TCPViewerLicenseReceiptVerifier { + TCPViewerLicenseReceiptVerifier(publicKeys: ["test": rig.key.publicKey.rawRepresentation]) + } +} diff --git a/TCPViewerTests/Features/License/TCPViewerLicenseServiceTests.swift b/TCPViewerTests/Features/License/TCPViewerLicenseServiceTests.swift index c6fdad4..46545e0 100644 --- a/TCPViewerTests/Features/License/TCPViewerLicenseServiceTests.swift +++ b/TCPViewerTests/Features/License/TCPViewerLicenseServiceTests.swift @@ -5,529 +5,369 @@ // Created by Proxyman LLC on 4/5/26. // +import CryptoKit import Foundation import Testing @testable import TCPViewer struct TCPViewerLicenseServiceTests { - @Test func activationSuccessStoresLicenseAndUpdatesStatus() throws { - let storage = try makeStorage() - let network = StubLicenseNetworkClient() - let license = makeLicense(email: "ada@example.com") - network.registerResult = .success(license) - let service = makeService(storage: storage, network: network) - - let status = waitForStatus { - service.activate(licenseKey: " TCPV-KEY \n", completion: $0) + @Test func activationAuthenticatesStoresAndCompletesOnMain() throws { + let rig = LicenseTestRig() + let license = try rig.signed() + rig.network.registerResult = .success(license) + let service = rig.service() + var main = false + let status = waitForLicenseStatus { finish in + service.activate(licenseKey: " tcpv-key\n") { value in main = Thread.isMainThread; finish(value) } } - - #expect(status == .authorized(license)) - #expect(service.currentLicense == license) - #expect(storage.readLicense() == license) - #expect(network.registeredLicenseKey == "TCPV-KEY") - #expect(network.registeredDeviceUUID == "device-1") - #expect(network.registeredBuildNumber == "999") - #expect(network.registeredAppVersion == "1.2.3") - #expect(network.registeredOSVersion == "macOS 15.6") - } - - @Test func activationCompletionRunsOnMainQueueAfterAsyncNetworkCallback() throws { - let storage = try makeStorage() - let network = StubLicenseNetworkClient() - let license = makeLicense() - network.registerResult = .success(license) - network.callbackQueue = DispatchQueue(label: "TCPViewerLicenseServiceTests.activationCallback") - let service = makeService(storage: storage, network: network) - var completedOnMain = false - - let status = waitForStatus { finish in - service.activate(licenseKey: "TCPV-KEY") { status in - completedOnMain = Thread.isMainThread - finish(status) - } - } - #expect(status == .authorized(license)) - #expect(completedOnMain) + #expect(rig.storage.license == license) + #expect(rig.network.registeredKey == "TCPV-KEY") + #expect(main) } - @Test func activationRejectsInvalidPrefixBeforeNetworkCall() throws { - let storage = try makeStorage() - let network = StubLicenseNetworkClient() - let service = makeService(storage: storage, network: network) - - let status = waitForStatus { - service.activate(licenseKey: "BAD-KEY", completion: $0) - } - + @Test func invalidKeyNeverCallsNetwork() { + let rig = LicenseTestRig(); let service = rig.service() + let status = waitForLicenseStatus { service.activate(licenseKey: "BAD", completion: $0) } #expect(status == .unauthorized(.invalidLicense)) - #expect(network.registeredLicenseKey == nil) - #expect(storage.readLicense() == nil) - } - - @Test func activationRejectsBuildOutsideUpdateWindowWithoutStoringReceipt() throws { - let storage = try makeStorage() - let network = StubLicenseNetworkClient() - network.registerResult = .failure(.renewalRequired) - let service = makeService(storage: storage, network: network) - - let status = waitForStatus { - service.activate(licenseKey: "TCPV-EXPIRED", completion: $0) - } - - #expect(status == .unauthorized(.renewalRequired)) - #expect(storage.readLicense() == nil) - #expect(network.registeredBuildNumber == "999") - #expect(network.registeredAppVersion == "1.2.3") - #expect(network.registeredOSVersion == "macOS 15.6") - } - - @Test func launchVerificationSuccessRefreshesStoredLicense() throws { - let storage = try makeStorage() - let oldLicense = makeLicense(email: "old@example.com") - let updatedLicense = makeLicense(email: "new@example.com") - try storage.writeLicense(oldLicense) - - let network = StubLicenseNetworkClient() - network.verifyResult = .success(updatedLicense) - let service = makeService(storage: storage, network: network) - - let status = waitForStatus { - service.verifyAtLaunch(completion: $0) - } - - #expect(status == .authorized(updatedLicense)) - #expect(storage.readLicense() == updatedLicense) - #expect(network.verifiedSignature == oldLicense.signature) - #expect(network.verifiedDeviceUUID == "device-1") - } - - @Test func verificationCompletionRunsOnMainQueueAfterAsyncNetworkCallback() throws { - let storage = try makeStorage() - let license = makeLicense() - try storage.writeLicense(license) - let network = StubLicenseNetworkClient() - network.verifyResult = .success(license) - network.callbackQueue = DispatchQueue(label: "TCPViewerLicenseServiceTests.verifyCallback") - let service = makeService(storage: storage, network: network) - var completedOnMain = false - - let status = waitForStatus { finish in - service.verifyAtLaunch { status in - completedOnMain = Thread.isMainThread - finish(status) - } - } - - #expect(status == .authorized(license)) - #expect(completedOnMain) - } - - @Test func launchVerificationUsesStoredFallbackDeviceUUID() throws { - let storage = try makeStorage() - let license = makeLicense(deviceUUID: "device-2") - try storage.writeLicense(license) - - let network = StubLicenseNetworkClient() - network.verifyResult = .success(license) - let deviceProvider = StubDeviceProvider(deviceIDs: ["device-1", "device-2"]) - let service = makeService(storage: storage, network: network, deviceProvider: deviceProvider) - - let status = waitForStatus { - service.verifyAtLaunch(completion: $0) - } - - #expect(status == .authorized(license)) - #expect(network.verifiedDeviceUUID == "device-2") - } - - @Test func launchVerificationKeepsStoredLicenseWhenOffline() throws { - let storage = try makeStorage() - let license = makeLicense() - try storage.writeLicense(license) - - let network = StubLicenseNetworkClient() - network.verifyResult = .failure(.noInternetConnection) - let service = makeService(storage: storage, network: network) - - let status = waitForStatus { - service.verifyAtLaunch(completion: $0) - } - - #expect(status == .authorized(license)) - #expect(storage.readLicense() == license) - } - - @Test func launchVerificationRemovesStoredLicenseWhenBuildNeedsRenewal() throws { - let storage = try makeStorage() - let license = makeLicense() - try storage.writeLicense(license) - - let network = StubLicenseNetworkClient() - network.verifyResult = .failure(.renewalRequired) - let service = makeService(storage: storage, network: network) - - let status = waitForStatus { - service.verifyAtLaunch(completion: $0) - } - - #expect(status == .unauthorized(.renewalRequired)) - #expect(storage.readLicense() == nil) - #expect(service.status == .unauthorized(.renewalRequired)) - } - - @Test func launchVerificationRejectsCopiedReceiptFromDifferentDevice() throws { - let storage = try makeStorage() - let license = makeLicense(deviceUUID: "device-1") - try storage.writeLicense(license) - - let network = StubLicenseNetworkClient() - let deviceProvider = StubDeviceProvider(deviceIDs: ["device-2"]) - let service = makeService(storage: storage, network: network, deviceProvider: deviceProvider) - - let status = waitForStatus { - service.verifyAtLaunch(completion: $0) - } - - #expect(status == .unauthorized(.invalidLicense)) - #expect(storage.readLicense() == nil) - #expect(network.verifiedSignature == nil) - } - - @Test func verifyIfNeededWithoutPreviousTimestampVerifiesStoredLicense() throws { - let storage = try makeStorage() - let oldLicense = makeLicense(email: "old@example.com") - let updatedLicense = makeLicense(email: "new@example.com") - try storage.writeLicense(oldLicense) - - let network = StubLicenseNetworkClient() - network.verifyResult = .success(updatedLicense) - let service = makeService(storage: storage, network: network) - - let status = waitForStatus { - service.verifyIfNeeded(completion: $0) - } - - #expect(status == .authorized(updatedLicense)) - #expect(network.verifiedSignature == oldLicense.signature) - #expect(network.verifiedAppVersion == "1.2.3") - #expect(network.verifiedOSVersion == "macOS 15.6") + #expect(rig.network.registeredKey == nil) } - @Test func verifyIfNeededSkipsFreshVerification() throws { - let storage = try makeStorage() - let license = makeLicense() - try storage.writeLicense(license) - let defaults = makeDefaults() - defaults.set(Date().timeIntervalSince1970, forKey: "TCPViewer.license.lastVerifyTime") - - let network = StubLicenseNetworkClient() - let service = makeService(storage: storage, network: network, defaults: defaults) - - let status = waitForStatus { - service.verifyIfNeeded(completion: $0) + @Test func individualActivationsUseTheExistingUnsignedFlow() { + for type in [TCPViewerLicenseType.standardLicense, .comboLicense, .lifetimeLicense] { + let rig = LicenseTestRig(); let license = rig.legacy(type: type); rig.network.registerResult = .success(license) + let service = rig.service() + #expect(waitForLicenseStatus { service.activate(licenseKey: "TCPV-KEY", completion: $0) } == .authorized(license)) + #expect(rig.storage.license == license) } - - #expect(status == .authorized(license)) - #expect(network.verifiedSignature == nil) } - @Test func onlineRevocationRemovesStoredLicenseOnNextVerification() throws { - let storage = try makeStorage() - let license = makeLicense() - try storage.writeLicense(license) - - let network = StubLicenseNetworkClient() - network.verifyResult = .failure(.invalidLicense) - let service = makeService(storage: storage, network: network) + @Test func renewedIndividualActivationsKeepTheirExtendedUpdateWindow() { + for type in [TCPViewerLicenseType.standardLicense, .comboLicense] { + let rig = LicenseTestRig() + rig.storage.license = rig.legacy(type: type) + let renewed = rig.legacy(type: type, expiry: "2027-01-01T00:00:00.000Z") + rig.network.verifyResult = .success(renewed) + let service = rig.service() - let status = waitForStatus { - service.verifyAtLaunch(completion: $0) + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) } == .authorized(renewed)) + #expect(rig.service().status == .authorized(renewed)) } - - #expect(status == .unauthorized(.invalidLicense)) - #expect(storage.readLicense() == nil) } - @Test func launchVerificationRejectsStoredLicenseLongerThanOneYear() throws { - let storage = try makeStorage() - let license = makeLicense(expiryDate: "2028-05-01T10:20:30.123Z") - try storage.writeLicense(license) - - let network = StubLicenseNetworkClient() - network.verifyResult = .success(license) - let service = makeService(storage: storage, network: network) - - #expect(service.status == .unauthorized(.invalidLicense)) - - let status = waitForStatus { - service.verifyAtLaunch(completion: $0) + @Test func unsignedTeamActivationsCannotAuthorize() { + let rig = LicenseTestRig(); rig.network.registerResult = .success(rig.legacy(type: .teamLicense)) + let service = rig.service() + #expect(waitForLicenseStatus { service.activate(licenseKey: "TCPV-KEY", completion: $0) } == .unauthorized(.verificationRequired)) + #expect(rig.storage.license == nil) + } + + @Test func existingIndividualActivationsStayOnTheUnsignedFlow() { + for type in [TCPViewerLicenseType.standardLicense, .comboLicense, .lifetimeLicense] { + let rig = LicenseTestRig(); let legacy = rig.legacy(type: type); rig.storage.license = legacy + rig.network.verifyResult = .failure(.noInternetConnection) + let service = rig.service() + #expect(service.isLicenseAuthorized) + #expect(waitForLicenseStatus { service.verifyAtLaunch(completion: $0) } == .authorized(legacy)) + #expect(rig.storage.license == legacy) + rig.network.verifyResult = .success(legacy) + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) } == .authorized(legacy)) + #expect(rig.network.verifiedSignature == legacy.signature) + #expect(rig.storage.license?.receipt == nil) } - - #expect(status == .unauthorized(.invalidLicense)) - #expect(storage.readLicense() == nil) - #expect(network.verifiedSignature == nil) } - @Test func launchVerificationAcceptsLifetimeLicenseLongerThanOneYear() throws { - let storage = try makeStorage() - let license = makeLicense( - expiryDate: "2036-05-01T10:20:30.123Z", - licenseType: .lifetimeLicense - ) - try storage.writeLicense(license) - - let network = StubLicenseNetworkClient() - network.verifyResult = .success(license) - let service = makeService(storage: storage, network: network) - - let status = waitForStatus { - service.verifyAtLaunch(completion: $0) + @Test func invalidLegacyFileCannotAuthorize() { + let rig = LicenseTestRig(); var legacy = rig.legacy(); legacy.signature = "short"; rig.storage.license = legacy + rig.network.verifyResult = .failure(.noInternetConnection) + let service = rig.service() + #expect(!service.isLicenseAuthorized) + #expect(waitForLicenseStatus { service.verifyAtLaunch(completion: $0) } == .unauthorized(.verificationRequired)) + #expect(rig.storage.license == legacy) + } + + @Test func legacyRenewalDenialPersistsWithoutDeletingCredential() { + let rig = LicenseTestRig(); let legacy = rig.legacy(); rig.storage.license = legacy + rig.network.verifyResult = .failure(.renewalRequired) + let service = rig.service() + #expect(service.isLicenseAuthorized) + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) } == .unauthorized(.renewalRequired)) + #expect(rig.storage.license == legacy) + #expect(rig.service().status == .unauthorized(.renewalRequired)) + } + + @Test func renewalsBeyondOneYearRemainAuthorizedAndExpiredCoverageDoesNotExpireCoveredBuild() throws { + let rig = LicenseTestRig(); let license = try rig.signed(type: .standardLicense, expiry: "2025-12-31T23:59:59.999Z") + rig.storage.license = license + let service = rig.service() + #expect(service.isLicenseAuthorized) + let renewed = try rig.signed(type: .standardLicense, expiry: "2030-12-31T23:59:59.999Z") + rig.network.verifyResult = .success(renewed) + #expect(waitForLicenseStatus { service.verifyAtLaunch(completion: $0) } == .authorized(renewed)) + } + + @Test func forcedPaywallRefreshIgnoresRecentVerificationAndCoalescesRequests() throws { + let rig = LicenseTestRig(); rig.storage.license = try rig.signed(); rig.network.holdVerification = true + let service = rig.service() + _ = waitForLicenseStatus { service.verifyIfNeeded(completion: $0) } + #expect(rig.network.verifyCount == 0) + let finished = DispatchSemaphore(value: 0) + service.refreshLicense { _ in finished.signal() } + service.refreshLicense { _ in finished.signal() } + rig.drain() + #expect(rig.network.verifyCount == 1) + rig.network.pendingVerification?(.success(try rig.signed())) + #expect(waitForLicenseSignal(finished)); #expect(waitForLicenseSignal(finished)) + rig.network.holdVerification = false + rig.network.verifyResult = .success(try rig.signed()) + _ = waitForLicenseStatus { service.refreshLicense(completion: $0) } + #expect(rig.network.verifyCount == 2) + } + + @Test func foregroundVerifiesAtTwelveHoursAndLaunchAlwaysVerifies() throws { + let rig = LicenseTestRig(); rig.storage.license = try rig.signed(type: .standardLicense) + rig.network.verifyResult = .success(try rig.signed(type: .standardLicense)) + let service = rig.service() + _ = waitForLicenseStatus { service.verifyAtLaunch(completion: $0) } + rig.advance(12 * 3600 - 1) + _ = waitForLicenseStatus { service.verifyIfNeeded(completion: $0) } + #expect(rig.network.verifyCount == 1) + rig.advance(1) + rig.network.verifyResult = .success(try rig.signed(type: .standardLicense)) + _ = waitForLicenseStatus { service.verifyIfNeeded(completion: $0) } + #expect(rig.network.verifyCount == 2) + } + + @Test func temporaryFailuresPreserveTeamUntilExactSevenDayBoundaryThenRecover() throws { + let rig = LicenseTestRig(); let original = try rig.signed(); rig.storage.license = original + let service = rig.service() + for error in [TCPViewerLicenseError.noInternetConnection, .temporaryFailure, .error("bad gateway")] { + rig.network.verifyResult = .failure(error) + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) }.isAuthorized) } - - #expect(status == .authorized(license)) - #expect(storage.readLicense() == license) - #expect(network.verifiedSignature == license.signature) - } - - @Test func localRevokeKeepsStoredLicenseWhenBackendFails() throws { - let storage = try makeStorage() - let license = makeLicense() - try storage.writeLicense(license) - - let network = StubLicenseNetworkClient() - network.revokeResult = .failure(.noInternetConnection) - let service = makeService(storage: storage, network: network) - - let result = waitForVoid { - service.revokeCurrentDevice(completion: $0) - } - - switch result { - case .failure(.noInternetConnection): - break - default: - Issue.record("Expected revoke failure to preserve the local receipt.") + rig.advance(7 * 86400 - 1) + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) }.isAuthorized) + rig.advance(1) + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) } == .unauthorized(.offlineVerificationRequired)) + #expect(rig.storage.license == original) + let refreshed = try rig.signed(); rig.network.verifyResult = .success(refreshed) + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) } == .authorized(refreshed)) + } + + @Test func timerEnforcesDeadlineWhileAppRemainsOpen() throws { + let rig = LicenseTestRig(); rig.storage.license = try rig.signed(); rig.network.verifyResult = .failure(.noInternetConnection) + rig.advance(7 * 86400 - 1) + let service = rig.service(timer: true) + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) }.isAuthorized) + rig.advance(1) + let deadline = Date().addingTimeInterval(3) + while service.isLicenseAuthorized && Date() < deadline { Thread.sleep(forTimeInterval: 0.01) } + #expect(service.status == .unauthorized(.offlineVerificationRequired)) + } + + @Test func timerDoesNotPollStoredLicenseBeforeNextDeadline() throws { + let rig = LicenseTestRig(); rig.storage.license = try rig.signed() + let service = rig.service(timer: true) + let readCount = rig.storage.readCount + + Thread.sleep(forTimeInterval: 1.2) + + #expect(service.isLicenseAuthorized) + #expect(rig.storage.readCount == readCount) + } + + @Test func individualPlansKeepOfflineAccessAfterSevenDays() throws { + for type in [TCPViewerLicenseType.standardLicense, .comboLicense, .lifetimeLicense] { + let rig = LicenseTestRig(); rig.storage.license = try rig.signed(type: type) + rig.network.verifyResult = .failure(.temporaryFailure) + let service = rig.service(); rig.advance(30 * 86400) + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) }.isAuthorized) } - #expect(storage.readLicense() == license) - #expect(service.status == .authorized(license)) - #expect(network.revokedSignature == license.signature) } - @Test func localRevokeClearsStoredLicenseWhenBackendAlreadyLostDevice() throws { - let storage = try makeStorage() - let license = makeLicense() - try storage.writeLicense(license) - - let network = StubLicenseNetworkClient() - network.revokeResult = .failure(.invalidLicense) - let service = makeService(storage: storage, network: network) - - let result = waitForVoid { - service.revokeCurrentDevice(completion: $0) + @Test func uncoveredBuildRetainsCredentialAndRefreshAfterRenewalRestoresAccess() throws { + let rig = LicenseTestRig(); let license = try rig.signed(build: "998"); rig.storage.license = license + rig.network.verifyResult = .failure(.renewalRequired) + let service = rig.service() + #expect(!service.isLicenseAuthorized) + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) } == .unauthorized(.renewalRequired)) + #expect(rig.storage.license == license) + #expect(rig.service().status == .unauthorized(.renewalRequired)) + rig.network.verifyResult = .failure(.temporaryFailure) + #expect(!waitForLicenseStatus { service.refreshLicense(completion: $0) }.isAuthorized) + let renewed = try rig.signed(expiry: "2030-01-01T23:59:59.999Z") + rig.network.verifyResult = .success(renewed) + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) } == .authorized(renewed)) + } + + @Test func remoteRevocationAndDisablingRemoveAuthorizationAndCredential() throws { + for error in [TCPViewerLicenseError.deviceRevoked, .licenseDisabled, .invalidLicense] { + let rig = LicenseTestRig(); rig.storage.license = try rig.signed() + rig.network.verifyResult = .failure(error); let service = rig.service() + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) } == .unauthorized(error)) + #expect(rig.storage.license == nil) + let reactivated = try rig.signed(token: "reactivated", activationId: UUID().uuidString) + rig.network.registerResult = .success(reactivated) + #expect(waitForLicenseStatus { service.activate(licenseKey: "TCPV-KEY", completion: $0) } == .authorized(reactivated)) } - - try result.get() - #expect(storage.readLicense() == nil) - #expect(service.status == .unauthorized(.invalidLicense)) - #expect(network.revokedSignature == license.signature) } - @Test func revokeCompletionRunsOnMainQueueAfterAsyncNetworkCallback() throws { - let storage = try makeStorage() - let license = makeLicense() - try storage.writeLicense(license) - let network = StubLicenseNetworkClient() - network.revokeResult = .success(()) - network.callbackQueue = DispatchQueue(label: "TCPViewerLicenseServiceTests.revokeCallback") - let service = makeService(storage: storage, network: network) - var completedOnMain = false - - let result = waitForVoid { finish in - service.revokeCurrentDevice { result in - completedOnMain = Thread.isMainThread - finish(result) - } + @Test func staleVerificationCannotRemoveNewActivation() throws { + let rig = LicenseTestRig(); rig.storage.license = try rig.signed(); rig.network.holdVerification = true + let service = rig.service(); service.refreshLicense(); rig.drain() + let oldCallback = rig.network.pendingVerification + let newer = try rig.signed(token: "new", activationId: UUID().uuidString) + rig.network.registerResult = .success(newer) + _ = waitForLicenseStatus { service.activate(licenseKey: "TCPV-NEW", completion: $0) } + oldCallback?(.failure(.deviceRevoked)); rig.drain() + #expect(service.currentLicense == newer) + #expect(rig.storage.license == newer) + } + + @Test func staleActivationCannotRestoreRemovedLicense() throws { + let rig = LicenseTestRig(); rig.network.holdRegistration = true + let service = rig.service(); let done = DispatchSemaphore(value: 0) + service.activate(licenseKey: "TCPV-KEY") { _ in done.signal() }; rig.drain() + service.clearLicense() + rig.network.pendingRegistration?(.success(try rig.signed())) + #expect(waitForLicenseSignal(done)); rig.drain() + #expect(!service.isLicenseAuthorized); #expect(rig.storage.license == nil) + } + + @Test func clockRollbackPersistsUntilSuccessfulOnlineVerification() throws { + let rig = LicenseTestRig(); rig.storage.license = try rig.signed(); rig.network.verifyResult = .failure(.noInternetConnection) + let service = rig.service(); rig.advance(3600) + _ = waitForLicenseStatus { service.verifyIfNeeded(completion: $0) } + rig.queue.sync { rig.date = rig.date.addingTimeInterval(-600) } + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) } == .unauthorized(.clockChanged)) + rig.queue.sync { rig.date = rig.date.addingTimeInterval(600) } + #expect(waitForLicenseStatus { service.refreshLicense(completion: $0) } == .unauthorized(.clockChanged)) + let restarted = rig.service() + #expect(restarted.status == .unauthorized(.clockChanged)) + rig.network.verifyResult = .success(try rig.signed()) + #expect(waitForLicenseStatus { restarted.refreshLicense(completion: $0) }.isAuthorized) + } + + @Test func missingReceiptCannotBypassTheOfflineDeadline() throws { + let rig = LicenseTestRig(); rig.storage.license = try rig.signed() + let service = rig.service() + rig.queue.sync { rig.storage.license = nil } + #expect(!waitForLicenseStatus { service.verifyIfNeeded(completion: $0) }.isAuthorized) + } + + @Test func localRemovalWaitsForServerAndRetainsAccessOnNetworkFailure() throws { + let rig = LicenseTestRig(); rig.storage.license = try rig.signed(); let service = rig.service() + rig.network.revokeResult = .failure(.noInternetConnection) + let done = DispatchSemaphore(value: 0) + service.revokeCurrentDevice { result in + if case .success = result { Issue.record("Network failure must not remove the seat locally") } + done.signal() } - - try result.get() - #expect(completedOnMain) - } - - private func makeService( - storage: TCPViewerLicenseStorage, - network: StubLicenseNetworkClient, - deviceProvider: any TCPViewerLicenseDeviceProviding = StubDeviceProvider(), - defaults: UserDefaults? = nil - ) -> TCPViewerLicenseService { - TCPViewerLicenseService( - storage: storage, - networkClient: network, - deviceProvider: deviceProvider, - defaults: defaults ?? makeDefaults(), - buildNumberProvider: { "999" }, - appVersionProvider: { "1.2.3" }, - osVersionProvider: { "macOS 15.6" }, - workerQueue: DispatchQueue(label: "TCPViewerLicenseServiceTests-\(UUID().uuidString)") - ) - } - - private func makeStorage() throws -> TCPViewerLicenseStorage { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("TCPViewerLicenseServiceTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return TCPViewerLicenseStorage( - fileURL: directory.appendingPathComponent("receipt.bin"), - cipher: TCPViewerLicenseCipher(secret: "service-tests-secret") - ) + #expect(waitForLicenseSignal(done)); #expect(service.isLicenseAuthorized) + rig.network.revokeResult = .success(()) + service.revokeCurrentDevice { _ in done.signal() } + #expect(waitForLicenseSignal(done)); #expect(!service.isLicenseAuthorized) + #expect(rig.storage.license == nil) } +} - private func makeDefaults() -> UserDefaults { - let suiteName = "TCPViewerLicenseServiceTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - return defaults - } +final class LicenseTestStorage: TCPViewerLicenseStoring { + var license: TCPViewerLicense? + private(set) var readCount = 0 + func readLicense() -> TCPViewerLicense? { readCount += 1; return license } + func writeLicense(_ license: TCPViewerLicense) throws { self.license = license } + func removeLicense() { license = nil } +} - private func makeLicense( - email: String = "ada@example.com", - deviceUUID: String = "device-1", - expiryDate: String = "2027-05-01T10:20:30.123Z", - licenseType: TCPViewerLicenseType = .standardLicense +final class LicenseTestRig { + let key = Curve25519.Signing.PrivateKey() + let storage = LicenseTestStorage() + let network = LicenseTestNetwork() + let queue = DispatchQueue(label: "LicenseTestRig.\(UUID().uuidString)") + let defaults = UserDefaults(suiteName: "LicenseTests.\(UUID().uuidString)")! + var date = Date(timeIntervalSince1970: 1788775200) + var elapsed: TimeInterval = 100 + + func service(timer: Bool = false) -> TCPViewerLicenseService { + TCPViewerLicenseService(storage: storage, networkClient: network, deviceProvider: LicenseTestDevice(), + defaults: defaults, buildNumberProvider: { "999" }, + appVersionProvider: { "1.0" }, osVersionProvider: { "26.0" }, workerQueue: queue, + verifier: TCPViewerLicenseReceiptVerifier(publicKeys: ["test": key.publicKey.rawRepresentation]), + now: { self.date }, uptime: { self.elapsed }, startTimer: timer) + } + func advance(_ seconds: TimeInterval) { queue.sync { date = date.addingTimeInterval(seconds); elapsed += seconds } } + func drain() { queue.sync {} } + func legacy( + type: TCPViewerLicenseType = .standardLicense, + expiry: String = "2025-01-01T00:00:00.000Z" ) -> TCPViewerLicense { - TCPViewerLicense( - signature: "abcdefghijklmnopqrstuvwxyz", - deviceUUID: deviceUUID, - email: email, - purchaseAt: "2026-05-01T10:20:30.123Z", - expiryDate: expiryDate, - licenseType: licenseType - ) - } - - private func waitForStatus( - _ start: (@escaping (TCPViewerLicenseStatus) -> Void) -> Void - ) -> TCPViewerLicenseStatus { - var status: TCPViewerLicenseStatus? - let semaphore = DispatchSemaphore(value: 0) - start { - status = $0 - semaphore.signal() - } - #expect(waitUntilSignaled(semaphore)) - return status ?? .unauthorized(.error("Missing callback")) - } - - private func waitForVoid( - _ start: (@escaping (Result) -> Void) -> Void - ) -> Result { - var result: Result? - let semaphore = DispatchSemaphore(value: 0) - start { - result = $0 - semaphore.signal() - } - #expect(waitUntilSignaled(semaphore)) - return result ?? .failure(.error("Missing callback")) - } - - private func waitUntilSignaled(_ semaphore: DispatchSemaphore) -> Bool { - let deadline = Date().addingTimeInterval(2) - if Thread.isMainThread { - while Date() < deadline { - if semaphore.wait(timeout: .now()) == .success { - return true - } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - return semaphore.wait(timeout: .now()) == .success - } - - return semaphore.wait(timeout: .now() + 2) == .success + TCPViewerLicense(signature: "legacy-activation-credential", deviceUUID: "device-1", email: "owner@example.com", + purchaseAt: "2024-01-01T00:00:00.000Z", expiryDate: expiry, licenseType: type) + } + func signed(type: TCPViewerLicenseType = .teamLicense, token: String = "credential", build: String = "999", + device: String = "device-1", activationId: String = "activation", expiry: String = "2028-01-01T23:59:59.999Z") throws -> TCPViewerLicense { + let claims = TCPViewerLicenseReceiptClaims(activationId: activationId, + activationTokenHash: SHA256.hash(data: Data(token.utf8)).map { String(format: "%02x", $0) }.joined(), + productID: "com.proxyman.TCPViewer", device_uuid: device, licenseType: type, email: "owner@example.com", + purchaseAt: "2024-01-01T00:00:00.000Z", expiryAt: expiry, numberOfSeats: 5, usedSeats: 1, + buildNumber: build, issuedAt: date.timeIntervalSince1970, + offlineUntil: type == .teamLicense ? date.timeIntervalSince1970 + 7 * 86400 : nil) + let payload = try JSONEncoder().encode(claims).licenseBase64URL + let signature = try key.signature(for: Data("1.test.\(payload)".utf8)).licenseBase64URL + return TCPViewerLicense(signature: token, deviceUUID: device, email: claims.email, purchaseAt: claims.purchaseAt, + expiryDate: expiry, licenseType: type, + receipt: TCPViewerLicenseReceipt(version: 1, keyId: "test", payload: payload, signature: signature), + activationId: activationId, numberOfSeats: 5, usedSeats: 1) } } -private struct StubDeviceProvider: TCPViewerLicenseDeviceProviding { - let deviceIDs: [String] - - init(deviceIDs: [String] = ["device-1"]) { - self.deviceIDs = deviceIDs - } - - func deviceName() -> String { - "Ada's Mac" - } - - func hashedDeviceIDs() -> [String] { - deviceIDs - } +private struct LicenseTestDevice: TCPViewerLicenseDeviceProviding { + func deviceName() -> String { "Test Mac" } + func hashedDeviceIDs() -> [String] { ["device-1", "device-2"] } } -private final class StubLicenseNetworkClient: TCPViewerLicenseNetworkClienting { +final class LicenseTestNetwork: TCPViewerLicenseNetworkClienting { var registerResult: Result = .failure(.invalidLicense) - var verifyResult: Result = .failure(.invalidLicense) + var verifyResult: Result = .failure(.noInternetConnection) var revokeResult: Result = .success(()) - var callbackQueue: DispatchQueue? - - var registeredLicenseKey: String? - var registeredDeviceUUID: String? - var registeredBuildNumber: String? - var registeredAppVersion: String? - var registeredOSVersion: String? + var holdVerification = false + var holdRegistration = false + var pendingVerification: ((Result) -> Void)? + var pendingRegistration: ((Result) -> Void)? + var verifyCount = 0 + var registeredKey: String? var verifiedSignature: String? - var verifiedDeviceUUID: String? - var verifiedAppVersion: String? - var verifiedOSVersion: String? - var revokedSignature: String? - - func registerLicense( - licenseKey: String, - deviceName: String, - deviceUUID: String, - buildNumber: String, - appVersion: String, - osVersion: String, - completion: @escaping (Result) -> Void - ) { - registeredLicenseKey = licenseKey - registeredDeviceUUID = deviceUUID - registeredBuildNumber = buildNumber - registeredAppVersion = appVersion - registeredOSVersion = osVersion - complete(registerResult, completion: completion) + func registerLicense(licenseKey: String, deviceName: String, deviceUUID: String, buildNumber: String, appVersion: String, osVersion: String, completion: @escaping (Result) -> Void) { + registeredKey = licenseKey + if holdRegistration { pendingRegistration = completion } else { completion(registerResult) } } - - func verifyLicense( - license: TCPViewerLicense, - deviceUUID: String, - buildNumber: String, - appVersion: String, - osVersion: String, - completion: @escaping (Result) -> Void - ) { - verifiedSignature = license.signature - verifiedDeviceUUID = deviceUUID - verifiedAppVersion = appVersion - verifiedOSVersion = osVersion - complete(verifyResult, completion: completion) + func verifyLicense(license: TCPViewerLicense, deviceUUID: String, buildNumber: String, appVersion: String, osVersion: String, completion: @escaping (Result) -> Void) { + verifiedSignature = license.signature; verifyCount += 1 + if holdVerification { pendingVerification = completion } else { completion(verifyResult) } } + func revokeLicense(license: TCPViewerLicense, completion: @escaping (Result) -> Void) { completion(revokeResult) } +} - func revokeLicense( - license: TCPViewerLicense, - completion: @escaping (Result) -> Void - ) { - revokedSignature = license.signature - complete(revokeResult, completion: completion) - } +extension Data { + var licenseBase64URL: String { base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") } +} - private func complete(_ value: T, completion: @escaping (T) -> Void) { - guard let callbackQueue else { - completion(value) - return - } +func waitForLicenseStatus(_ start: (@escaping (TCPViewerLicenseStatus) -> Void) -> Void) -> TCPViewerLicenseStatus { + var status: TCPViewerLicenseStatus? + let semaphore = DispatchSemaphore(value: 0) + start { status = $0; semaphore.signal() } + #expect(waitForLicenseSignal(semaphore)) + return status ?? .unauthorized(.error("Missing callback")) +} - callbackQueue.async { - completion(value) +func waitForLicenseSignal(_ semaphore: DispatchSemaphore) -> Bool { + if Thread.isMainThread { + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { + if semaphore.wait(timeout: .now()) == .success { return true } + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) } + return false } + return semaphore.wait(timeout: .now() + 3) == .success }