Update PolarService and license management functionality
This commit is contained in:
parent
34459f6621
commit
a798087759
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user