From a798087759a25cdd6cefb59ae926e5bcee4108f3 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Tue, 25 Feb 2025 19:44:16 +0545 Subject: [PATCH] Update PolarService and license management functionality --- VoiceInk/Services/PolarService.swift | 178 +++++++++++++++++++-- VoiceInk/ViewModels/LicenseViewModel.swift | 107 +++++++++++-- VoiceInk/Views/LicenseManagementView.swift | 12 +- 3 files changed, 275 insertions(+), 22 deletions(-) diff --git a/VoiceInk/Services/PolarService.swift b/VoiceInk/Services/PolarService.swift index 88f4bac..ec3d79f 100644 --- a/VoiceInk/Services/PolarService.swift +++ b/VoiceInk/Services/PolarService.swift @@ -1,22 +1,81 @@ import Foundation +import IOKit class PolarService { - private let organizationId = "Org" - private let apiToken = "Polar" + private let organizationId = "L" + private let apiToken = "S" private let baseURL = "https://api.polar.sh" struct LicenseValidationResponse: Codable { let status: String + let limit_activations: Int? + let id: String? + let activation: ActivationResponse? } - func validateLicenseKey(_ key: String) async throws -> Bool { - let url = URL(string: "\(baseURL)/v1/users/license-keys/validate")! + struct ActivationResponse: Codable { + let id: String + } + + struct ActivationRequest: Codable { + let key: String + let organization_id: String + let label: String + let meta: [String: String] + } + + struct ActivationResult: Codable { + let id: String + let license_key: LicenseKeyInfo + } + + struct LicenseKeyInfo: Codable { + let limit_activations: Int + let status: String + } + + // Generate a unique device identifier + private func getDeviceIdentifier() -> String { + // Use the macOS serial number or a generated UUID that persists + if let serialNumber = getMacSerialNumber() { + return serialNumber + } + + // Fallback to a stored UUID if we can't get the serial number + let defaults = UserDefaults.standard + if let storedId = defaults.string(forKey: "VoiceInkDeviceIdentifier") { + return storedId + } + + // Create and store a new UUID if none exists + let newId = UUID().uuidString + defaults.set(newId, forKey: "VoiceInkDeviceIdentifier") + return newId + } + + // Try to get the Mac serial number + private func getMacSerialNumber() -> String? { + let platformExpert = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice")) + if platformExpert == 0 { return nil } + + defer { IOObjectRelease(platformExpert) } + + if let serialNumber = IORegistryEntryCreateCFProperty(platformExpert, "IOPlatformSerialNumber" as CFString, kCFAllocatorDefault, 0) { + return (serialNumber.takeRetainedValue() as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + return nil + } + + // Check if a license key requires activation + func checkLicenseRequiresActivation(_ key: String) async throws -> (isValid: Bool, requiresActivation: Bool, activationsLimit: Int?) { + let url = URL(string: "\(baseURL)/v1/customer-portal/license-keys/validate")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") - let body: [String: String] = [ + let body: [String: Any] = [ "key": key, "organization_id": organizationId ] @@ -25,15 +84,114 @@ class PolarService { let (data, httpResponse) = try await URLSession.shared.data(for: request) - if let httpResponse = httpResponse as? HTTPURLResponse, - !(200...299).contains(httpResponse.statusCode) { - print("HTTP Status Code: \(httpResponse.statusCode)") - if let errorString = String(data: data, encoding: .utf8) { - print("Error Response: \(errorString)") + if let httpResponse = httpResponse as? HTTPURLResponse { + if !(200...299).contains(httpResponse.statusCode) { + if let errorString = String(data: data, encoding: .utf8) { + print("Error Response: \(errorString)") + } + throw LicenseError.validationFailed + } + } + + let validationResponse = try JSONDecoder().decode(LicenseValidationResponse.self, from: data) + let isValid = validationResponse.status == "granted" + + // If limit_activations is nil or 0, the license doesn't require activation + let requiresActivation = (validationResponse.limit_activations ?? 0) > 0 + + return (isValid: isValid, requiresActivation: requiresActivation, activationsLimit: validationResponse.limit_activations) + } + + // Activate a license key on this device + func activateLicenseKey(_ key: String) async throws -> (activationId: String, activationsLimit: Int) { + let url = URL(string: "\(baseURL)/v1/customer-portal/license-keys/activate")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") + + let deviceId = getDeviceIdentifier() + let hostname = Host.current().localizedName ?? "Unknown Mac" + + let activationRequest = ActivationRequest( + key: key, + organization_id: organizationId, + label: hostname, + meta: ["device_id": deviceId] + ) + + request.httpBody = try JSONEncoder().encode(activationRequest) + + let (data, httpResponse) = try await URLSession.shared.data(for: request) + + if let httpResponse = httpResponse as? HTTPURLResponse { + if !(200...299).contains(httpResponse.statusCode) { + print("HTTP Status Code: \(httpResponse.statusCode)") + if let errorString = String(data: data, encoding: .utf8) { + print("Error Response: \(errorString)") + + // Check for specific error messages + if errorString.contains("License key does not require activation") { + throw LicenseError.activationNotRequired + } + } + throw LicenseError.activationFailed + } + } + + let activationResult = try JSONDecoder().decode(ActivationResult.self, from: data) + return (activationId: activationResult.id, activationsLimit: activationResult.license_key.limit_activations) + } + + // Validate a license key with an activation ID + func validateLicenseKeyWithActivation(_ key: String, activationId: String) async throws -> Bool { + let url = URL(string: "\(baseURL)/v1/customer-portal/license-keys/validate")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") + + let body: [String: Any] = [ + "key": key, + "organization_id": organizationId, + "activation_id": activationId + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, httpResponse) = try await URLSession.shared.data(for: request) + + if let httpResponse = httpResponse as? HTTPURLResponse { + if !(200...299).contains(httpResponse.statusCode) { + print("HTTP Status Code: \(httpResponse.statusCode)") + if let errorString = String(data: data, encoding: .utf8) { + print("Error Response: \(errorString)") + } + throw LicenseError.validationFailed } } let validationResponse = try JSONDecoder().decode(LicenseValidationResponse.self, from: data) return validationResponse.status == "granted" } +} + +enum LicenseError: Error, LocalizedError { + case activationFailed + case validationFailed + case activationLimitReached + case activationNotRequired + + var errorDescription: String? { + switch self { + case .activationFailed: + return "Failed to activate license on this device." + case .validationFailed: + return "License validation failed." + case .activationLimitReached: + return "This license has reached its maximum number of activations." + case .activationNotRequired: + return "This license does not require activation." + } + } } diff --git a/VoiceInk/ViewModels/LicenseViewModel.swift b/VoiceInk/ViewModels/LicenseViewModel.swift index e36d3cb..a37d835 100644 --- a/VoiceInk/ViewModels/LicenseViewModel.swift +++ b/VoiceInk/ViewModels/LicenseViewModel.swift @@ -13,6 +13,7 @@ class LicenseViewModel: ObservableObject { @Published var licenseKey: String = "" @Published var isValidating = false @Published var validationMessage: String? + @Published private(set) var activationsLimit: Int = 0 private let trialPeriodDays = 7 private let polarService = PolarService() @@ -35,7 +36,35 @@ class LicenseViewModel: ObservableObject { // Check for existing license key if let licenseKey = userDefaults.licenseKey { self.licenseKey = licenseKey - licenseState = .licensed + + // Check if this license requires activation + if userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") { + // If we have an activation ID, we need to validate it + if let activationId = userDefaults.activationId { + Task { + do { + let isValid = try await polarService.validateLicenseKeyWithActivation(licenseKey, activationId: activationId) + if isValid { + licenseState = .licensed + } else { + // If validation fails, we'll need to reactivate + userDefaults.activationId = nil + licenseState = .trialExpired + } + } catch { + // If there's an error, we'll need to reactivate + userDefaults.activationId = nil + licenseState = .trialExpired + } + } + } else { + // We have a license key but no activation ID, so we need to activate + licenseState = .licensed + } + } else { + // This license doesn't require activation (unlimited devices) + licenseState = .licensed + } return } @@ -87,17 +116,67 @@ class LicenseViewModel: ObservableObject { isValidating = true do { - let isValid = try await polarService.validateLicenseKey(licenseKey) - if isValid { - userDefaults.licenseKey = licenseKey - licenseState = .licensed - validationMessage = "License activated successfully!" - NotificationCenter.default.post(name: .licenseStatusChanged, object: nil) - } else { + // First, check if the license is valid and if it requires activation + let licenseCheck = try await polarService.checkLicenseRequiresActivation(licenseKey) + + if !licenseCheck.isValid { validationMessage = "Invalid license key" + isValidating = false + return } + + // Store the license key + userDefaults.licenseKey = licenseKey + + // Handle based on whether activation is required + if licenseCheck.requiresActivation { + // If we already have an activation ID, validate with it + if let activationId = userDefaults.activationId { + let isValid = try await polarService.validateLicenseKeyWithActivation(licenseKey, activationId: activationId) + if isValid { + // Existing activation is valid + licenseState = .licensed + validationMessage = "License activated successfully!" + NotificationCenter.default.post(name: .licenseStatusChanged, object: nil) + isValidating = false + return + } + } + + // Need to create a new activation + let (activationId, limit) = try await polarService.activateLicenseKey(licenseKey) + + // Store activation details + userDefaults.activationId = activationId + userDefaults.set(true, forKey: "VoiceInkLicenseRequiresActivation") + self.activationsLimit = limit + + } else { + // This license doesn't require activation (unlimited devices) + userDefaults.activationId = nil + userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation") + self.activationsLimit = licenseCheck.activationsLimit ?? 0 + } + + // Update the license state + licenseState = .licensed + validationMessage = "License activated successfully!" + NotificationCenter.default.post(name: .licenseStatusChanged, object: nil) + + } catch LicenseError.activationLimitReached { + validationMessage = "This license has reached its maximum number of activations." + } catch LicenseError.activationNotRequired { + // This is actually a success case for unlimited licenses + userDefaults.licenseKey = licenseKey + userDefaults.activationId = nil + userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation") + self.activationsLimit = 0 + + licenseState = .licensed + validationMessage = "License activated successfully!" + NotificationCenter.default.post(name: .licenseStatusChanged, object: nil) } catch { - validationMessage = "Error validating license" + validationMessage = "Error validating license: \(error.localizedDescription)" } isValidating = false @@ -106,6 +185,8 @@ class LicenseViewModel: ObservableObject { func removeLicense() { // Remove both license key and trial data userDefaults.licenseKey = nil + userDefaults.activationId = nil + userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation") userDefaults.trialStartDate = nil userDefaults.set(false, forKey: "VoiceInkHasLaunchedBefore") // Allow trial to restart @@ -120,3 +201,11 @@ class LicenseViewModel: ObservableObject { extension Notification.Name { static let licenseStatusChanged = Notification.Name("licenseStatusChanged") } + +// Add UserDefaults extensions for storing activation ID +extension UserDefaults { + var activationId: String? { + get { string(forKey: "VoiceInkActivationId") } + set { set(newValue, forKey: "VoiceInkActivationId") } + } +} diff --git a/VoiceInk/Views/LicenseManagementView.swift b/VoiceInk/Views/LicenseManagementView.swift index 75f90f6..fe3da0c 100644 --- a/VoiceInk/Views/LicenseManagementView.swift +++ b/VoiceInk/Views/LicenseManagementView.swift @@ -203,9 +203,15 @@ struct LicenseManagementView: View { Divider() - Text("You can use VoiceInk Pro on all your personal devices") - .font(.subheadline) - .foregroundStyle(.secondary) + if licenseViewModel.activationsLimit > 0 { + Text("This license can be activated on up to \(licenseViewModel.activationsLimit) devices") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + Text("You can use VoiceInk Pro on all your personal devices") + .font(.subheadline) + .foregroundStyle(.secondary) + } } .padding(32) .background(Color(.windowBackgroundColor).opacity(0.4))