Move license key and trial date from UserDefaults to Keychain
This commit is contained in:
parent
eadf889a15
commit
aab096d252
@ -8,44 +8,45 @@ class LicenseViewModel: ObservableObject {
|
||||
case trialExpired
|
||||
case licensed
|
||||
}
|
||||
|
||||
|
||||
@Published private(set) var licenseState: LicenseState = .trial(daysRemaining: 7) // Default to trial
|
||||
@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()
|
||||
private let userDefaults = UserDefaults.standard
|
||||
|
||||
private let licenseManager = LicenseManager.shared
|
||||
|
||||
init() {
|
||||
loadLicenseState()
|
||||
}
|
||||
|
||||
|
||||
func startTrial() {
|
||||
// Only set trial start date if it hasn't been set before
|
||||
if userDefaults.trialStartDate == nil {
|
||||
userDefaults.trialStartDate = Date()
|
||||
if licenseManager.trialStartDate == nil {
|
||||
licenseManager.trialStartDate = Date()
|
||||
licenseState = .trial(daysRemaining: trialPeriodDays)
|
||||
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func loadLicenseState() {
|
||||
// Check for existing license key
|
||||
if let licenseKey = userDefaults.licenseKey {
|
||||
self.licenseKey = licenseKey
|
||||
|
||||
if let storedLicenseKey = licenseManager.licenseKey {
|
||||
self.licenseKey = storedLicenseKey
|
||||
|
||||
// If we have a license key, trust that it's licensed
|
||||
// Skip server validation on startup
|
||||
if userDefaults.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") {
|
||||
if licenseManager.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") {
|
||||
licenseState = .licensed
|
||||
activationsLimit = userDefaults.activationsLimit
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check if this is first launch
|
||||
let hasLaunchedBefore = userDefaults.bool(forKey: "VoiceInkHasLaunchedBefore")
|
||||
if !hasLaunchedBefore {
|
||||
@ -54,11 +55,11 @@ class LicenseViewModel: ObservableObject {
|
||||
startTrial()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Only check trial if not licensed and not first launch
|
||||
if let trialStartDate = userDefaults.trialStartDate {
|
||||
if let trialStartDate = licenseManager.trialStartDate {
|
||||
let daysSinceTrialStart = Calendar.current.dateComponents([.day], from: trialStartDate, to: Date()).day ?? 0
|
||||
|
||||
|
||||
if daysSinceTrialStart >= trialPeriodDays {
|
||||
licenseState = .trialExpired
|
||||
} else {
|
||||
@ -104,13 +105,13 @@ class LicenseViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
// Store the license key
|
||||
userDefaults.licenseKey = licenseKey
|
||||
|
||||
licenseManager.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 let existingActivationId = licenseManager.activationId {
|
||||
let isValid = try await polarService.validateLicenseKeyWithActivation(licenseKey, activationId: existingActivationId)
|
||||
if isValid {
|
||||
// Existing activation is valid
|
||||
licenseState = .licensed
|
||||
@ -120,23 +121,23 @@ class LicenseViewModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Need to create a new activation
|
||||
let (activationId, limit) = try await polarService.activateLicenseKey(licenseKey)
|
||||
|
||||
let (newActivationId, limit) = try await polarService.activateLicenseKey(licenseKey)
|
||||
|
||||
// Store activation details
|
||||
userDefaults.activationId = activationId
|
||||
licenseManager.activationId = newActivationId
|
||||
userDefaults.set(true, forKey: "VoiceInkLicenseRequiresActivation")
|
||||
self.activationsLimit = limit
|
||||
userDefaults.activationsLimit = limit
|
||||
|
||||
|
||||
} else {
|
||||
// This license doesn't require activation (unlimited devices)
|
||||
userDefaults.activationId = nil
|
||||
licenseManager.activationId = nil
|
||||
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
|
||||
self.activationsLimit = licenseCheck.activationsLimit ?? 0
|
||||
userDefaults.activationsLimit = licenseCheck.activationsLimit ?? 0
|
||||
|
||||
|
||||
// Update the license state for unlimited license
|
||||
licenseState = .licensed
|
||||
validationMessage = "License validated successfully!"
|
||||
@ -154,12 +155,12 @@ class LicenseViewModel: ObservableObject {
|
||||
validationMessage = "Activation limit reached: \(details)"
|
||||
} catch LicenseError.activationNotRequired {
|
||||
// This is actually a success case for unlimited licenses
|
||||
userDefaults.licenseKey = licenseKey
|
||||
userDefaults.activationId = nil
|
||||
licenseManager.licenseKey = licenseKey
|
||||
licenseManager.activationId = nil
|
||||
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
|
||||
self.activationsLimit = 0
|
||||
userDefaults.activationsLimit = 0
|
||||
|
||||
|
||||
licenseState = .licensed
|
||||
validationMessage = "License activated successfully!"
|
||||
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
|
||||
@ -171,15 +172,14 @@ class LicenseViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func removeLicense() {
|
||||
// Remove both license key and trial data
|
||||
userDefaults.licenseKey = nil
|
||||
userDefaults.activationId = nil
|
||||
// Remove all license data from Keychain
|
||||
licenseManager.removeAll()
|
||||
|
||||
// Reset UserDefaults flags
|
||||
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
|
||||
userDefaults.trialStartDate = nil
|
||||
userDefaults.set(false, forKey: "VoiceInkHasLaunchedBefore") // Allow trial to restart
|
||||
|
||||
userDefaults.activationsLimit = 0
|
||||
|
||||
|
||||
licenseState = .trial(daysRemaining: trialPeriodDays) // Reset to trial state
|
||||
licenseKey = ""
|
||||
validationMessage = nil
|
||||
@ -190,13 +190,8 @@ class LicenseViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
|
||||
// Add UserDefaults extensions for storing activation ID
|
||||
// UserDefaults extension for non-sensitive license settings
|
||||
extension UserDefaults {
|
||||
var activationId: String? {
|
||||
get { string(forKey: "VoiceInkActivationId") }
|
||||
set { set(newValue, forKey: "VoiceInkActivationId") }
|
||||
}
|
||||
|
||||
var activationsLimit: Int {
|
||||
get { integer(forKey: "VoiceInkActivationsLimit") }
|
||||
set { set(newValue, forKey: "VoiceInkActivationsLimit") }
|
||||
|
||||
125
VoiceInk/Services/LicenseManager.swift
Normal file
125
VoiceInk/Services/LicenseManager.swift
Normal file
@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Manages license data using secure Keychain storage (non-syncable, device-local).
|
||||
final class LicenseManager {
|
||||
static let shared = LicenseManager()
|
||||
|
||||
private let keychain = KeychainService.shared
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "LicenseManager")
|
||||
|
||||
private let licenseKeyIdentifier = "voiceink.license.key"
|
||||
private let trialStartDateIdentifier = "voiceink.license.trialStartDate"
|
||||
private let activationIdIdentifier = "voiceink.license.activationId"
|
||||
private let migrationCompletedKey = "LicenseKeychainMigrationCompleted"
|
||||
|
||||
private init() {
|
||||
migrateFromUserDefaultsIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - License Key
|
||||
|
||||
var licenseKey: String? {
|
||||
get { keychain.getString(forKey: licenseKeyIdentifier, syncable: false) }
|
||||
set {
|
||||
if let value = newValue {
|
||||
keychain.save(value, forKey: licenseKeyIdentifier, syncable: false)
|
||||
} else {
|
||||
keychain.delete(forKey: licenseKeyIdentifier, syncable: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trial Start Date
|
||||
|
||||
var trialStartDate: Date? {
|
||||
get {
|
||||
guard let data = keychain.getData(forKey: trialStartDateIdentifier, syncable: false),
|
||||
let timestamp = String(data: data, encoding: .utf8),
|
||||
let timeInterval = Double(timestamp) else {
|
||||
return nil
|
||||
}
|
||||
return Date(timeIntervalSince1970: timeInterval)
|
||||
}
|
||||
set {
|
||||
if let date = newValue {
|
||||
let timestamp = String(date.timeIntervalSince1970)
|
||||
keychain.save(timestamp, forKey: trialStartDateIdentifier, syncable: false)
|
||||
} else {
|
||||
keychain.delete(forKey: trialStartDateIdentifier, syncable: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Activation ID
|
||||
|
||||
var activationId: String? {
|
||||
get { keychain.getString(forKey: activationIdIdentifier, syncable: false) }
|
||||
set {
|
||||
if let value = newValue {
|
||||
keychain.save(value, forKey: activationIdIdentifier, syncable: false)
|
||||
} else {
|
||||
keychain.delete(forKey: activationIdIdentifier, syncable: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Migration
|
||||
|
||||
private func migrateFromUserDefaultsIfNeeded() {
|
||||
guard !userDefaults.bool(forKey: migrationCompletedKey) else { return }
|
||||
|
||||
// Migrate license key
|
||||
if let oldLicenseKey = userDefaults.string(forKey: "VoiceInkLicense"), !oldLicenseKey.isEmpty {
|
||||
licenseKey = oldLicenseKey
|
||||
userDefaults.removeObject(forKey: "VoiceInkLicense")
|
||||
logger.info("Migrated license key to Keychain")
|
||||
}
|
||||
|
||||
// Migrate trial start date (from obfuscated storage)
|
||||
if let oldTrialDate = getObfuscatedTrialStartDate() {
|
||||
trialStartDate = oldTrialDate
|
||||
clearObfuscatedTrialStartDate()
|
||||
logger.info("Migrated trial start date to Keychain")
|
||||
}
|
||||
|
||||
// Migrate activation ID
|
||||
if let oldActivationId = userDefaults.string(forKey: "VoiceInkActivationId"), !oldActivationId.isEmpty {
|
||||
activationId = oldActivationId
|
||||
userDefaults.removeObject(forKey: "VoiceInkActivationId")
|
||||
logger.info("Migrated activation ID to Keychain")
|
||||
}
|
||||
|
||||
userDefaults.set(true, forKey: migrationCompletedKey)
|
||||
logger.info("License migration completed")
|
||||
}
|
||||
|
||||
/// Reads the old obfuscated trial start date from UserDefaults.
|
||||
private func getObfuscatedTrialStartDate() -> Date? {
|
||||
let salt = Obfuscator.getDeviceIdentifier()
|
||||
let obfuscatedKey = Obfuscator.encode("VoiceInkTrialStartDate", salt: salt)
|
||||
|
||||
guard let obfuscatedValue = userDefaults.string(forKey: obfuscatedKey),
|
||||
let decodedValue = Obfuscator.decode(obfuscatedValue, salt: salt),
|
||||
let timestamp = Double(decodedValue) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Date(timeIntervalSince1970: timestamp)
|
||||
}
|
||||
|
||||
/// Clears the old obfuscated trial start date from UserDefaults.
|
||||
private func clearObfuscatedTrialStartDate() {
|
||||
let salt = Obfuscator.getDeviceIdentifier()
|
||||
let obfuscatedKey = Obfuscator.encode("VoiceInkTrialStartDate", salt: salt)
|
||||
userDefaults.removeObject(forKey: obfuscatedKey)
|
||||
}
|
||||
|
||||
/// Removes all license data (for license removal/reset).
|
||||
func removeAll() {
|
||||
licenseKey = nil
|
||||
trialStartDate = nil
|
||||
activationId = nil
|
||||
}
|
||||
}
|
||||
@ -188,11 +188,11 @@ class SystemInfoService {
|
||||
}
|
||||
|
||||
private func getLicenseStatus() -> String {
|
||||
let userDefaults = UserDefaults.standard
|
||||
let licenseManager = LicenseManager.shared
|
||||
|
||||
// Check for existing license key and activation
|
||||
if let _ = userDefaults.licenseKey {
|
||||
if userDefaults.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") {
|
||||
if licenseManager.licenseKey != nil {
|
||||
if licenseManager.activationId != nil || !UserDefaults.standard.bool(forKey: "VoiceInkLicenseRequiresActivation") {
|
||||
return "Licensed (Pro)"
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,51 +2,10 @@ import Foundation
|
||||
|
||||
extension UserDefaults {
|
||||
enum Keys {
|
||||
static let licenseKey = "VoiceInkLicense"
|
||||
static let trialStartDate = "VoiceInkTrialStartDate"
|
||||
static let audioInputMode = "audioInputMode"
|
||||
static let selectedAudioDeviceUID = "selectedAudioDeviceUID"
|
||||
static let prioritizedDevices = "prioritizedDevices"
|
||||
static let affiliatePromotionDismissed = "VoiceInkAffiliatePromotionDismissed"
|
||||
|
||||
// Obfuscated keys for license-related data
|
||||
enum License {
|
||||
static let trialStartDate = "VoiceInkTrialStartDate"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - License Key
|
||||
var licenseKey: String? {
|
||||
get { string(forKey: Keys.licenseKey) }
|
||||
set { setValue(newValue, forKey: Keys.licenseKey) }
|
||||
}
|
||||
|
||||
// MARK: - Trial Start Date (Obfuscated)
|
||||
var trialStartDate: Date? {
|
||||
get {
|
||||
let salt = Obfuscator.getDeviceIdentifier()
|
||||
let obfuscatedKey = Obfuscator.encode(Keys.License.trialStartDate, salt: salt)
|
||||
|
||||
guard let obfuscatedValue = string(forKey: obfuscatedKey),
|
||||
let decodedValue = Obfuscator.decode(obfuscatedValue, salt: salt),
|
||||
let timestamp = Double(decodedValue) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Date(timeIntervalSince1970: timestamp)
|
||||
}
|
||||
set {
|
||||
let salt = Obfuscator.getDeviceIdentifier()
|
||||
let obfuscatedKey = Obfuscator.encode(Keys.License.trialStartDate, salt: salt)
|
||||
|
||||
if let date = newValue {
|
||||
let timestamp = String(date.timeIntervalSince1970)
|
||||
let obfuscatedValue = Obfuscator.encode(timestamp, salt: salt)
|
||||
setValue(obfuscatedValue, forKey: obfuscatedKey)
|
||||
} else {
|
||||
removeObject(forKey: obfuscatedKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio Input Mode
|
||||
@ -72,4 +31,4 @@ extension UserDefaults {
|
||||
get { bool(forKey: Keys.affiliatePromotionDismissed) }
|
||||
set { setValue(newValue, forKey: Keys.affiliatePromotionDismissed) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user