Move license key and trial date from UserDefaults to Keychain

This commit is contained in:
Beingpax 2026-01-05 23:06:46 +05:45
parent eadf889a15
commit aab096d252
4 changed files with 165 additions and 86 deletions

View File

@ -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") }

View 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
}
}

View File

@ -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)"
}
}

View File

@ -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) }
}
}
}