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 trialExpired
|
||||||
case licensed
|
case licensed
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published private(set) var licenseState: LicenseState = .trial(daysRemaining: 7) // Default to trial
|
@Published private(set) var licenseState: LicenseState = .trial(daysRemaining: 7) // Default to trial
|
||||||
@Published var licenseKey: String = ""
|
@Published var licenseKey: String = ""
|
||||||
@Published var isValidating = false
|
@Published var isValidating = false
|
||||||
@Published var validationMessage: String?
|
@Published var validationMessage: String?
|
||||||
@Published private(set) var activationsLimit: Int = 0
|
@Published private(set) var activationsLimit: Int = 0
|
||||||
|
|
||||||
private let trialPeriodDays = 7
|
private let trialPeriodDays = 7
|
||||||
private let polarService = PolarService()
|
private let polarService = PolarService()
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
|
private let licenseManager = LicenseManager.shared
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
loadLicenseState()
|
loadLicenseState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startTrial() {
|
func startTrial() {
|
||||||
// Only set trial start date if it hasn't been set before
|
// Only set trial start date if it hasn't been set before
|
||||||
if userDefaults.trialStartDate == nil {
|
if licenseManager.trialStartDate == nil {
|
||||||
userDefaults.trialStartDate = Date()
|
licenseManager.trialStartDate = Date()
|
||||||
licenseState = .trial(daysRemaining: trialPeriodDays)
|
licenseState = .trial(daysRemaining: trialPeriodDays)
|
||||||
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
|
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadLicenseState() {
|
private func loadLicenseState() {
|
||||||
// Check for existing license key
|
// Check for existing license key
|
||||||
if let licenseKey = userDefaults.licenseKey {
|
if let storedLicenseKey = licenseManager.licenseKey {
|
||||||
self.licenseKey = licenseKey
|
self.licenseKey = storedLicenseKey
|
||||||
|
|
||||||
// If we have a license key, trust that it's licensed
|
// If we have a license key, trust that it's licensed
|
||||||
// Skip server validation on startup
|
// Skip server validation on startup
|
||||||
if userDefaults.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") {
|
if licenseManager.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") {
|
||||||
licenseState = .licensed
|
licenseState = .licensed
|
||||||
activationsLimit = userDefaults.activationsLimit
|
activationsLimit = userDefaults.activationsLimit
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is first launch
|
// Check if this is first launch
|
||||||
let hasLaunchedBefore = userDefaults.bool(forKey: "VoiceInkHasLaunchedBefore")
|
let hasLaunchedBefore = userDefaults.bool(forKey: "VoiceInkHasLaunchedBefore")
|
||||||
if !hasLaunchedBefore {
|
if !hasLaunchedBefore {
|
||||||
@ -54,11 +55,11 @@ class LicenseViewModel: ObservableObject {
|
|||||||
startTrial()
|
startTrial()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check trial if not licensed and not first launch
|
// 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
|
let daysSinceTrialStart = Calendar.current.dateComponents([.day], from: trialStartDate, to: Date()).day ?? 0
|
||||||
|
|
||||||
if daysSinceTrialStart >= trialPeriodDays {
|
if daysSinceTrialStart >= trialPeriodDays {
|
||||||
licenseState = .trialExpired
|
licenseState = .trialExpired
|
||||||
} else {
|
} else {
|
||||||
@ -104,13 +105,13 @@ class LicenseViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store the license key
|
// Store the license key
|
||||||
userDefaults.licenseKey = licenseKey
|
licenseManager.licenseKey = licenseKey
|
||||||
|
|
||||||
// Handle based on whether activation is required
|
// Handle based on whether activation is required
|
||||||
if licenseCheck.requiresActivation {
|
if licenseCheck.requiresActivation {
|
||||||
// If we already have an activation ID, validate with it
|
// If we already have an activation ID, validate with it
|
||||||
if let activationId = userDefaults.activationId {
|
if let existingActivationId = licenseManager.activationId {
|
||||||
let isValid = try await polarService.validateLicenseKeyWithActivation(licenseKey, activationId: activationId)
|
let isValid = try await polarService.validateLicenseKeyWithActivation(licenseKey, activationId: existingActivationId)
|
||||||
if isValid {
|
if isValid {
|
||||||
// Existing activation is valid
|
// Existing activation is valid
|
||||||
licenseState = .licensed
|
licenseState = .licensed
|
||||||
@ -120,23 +121,23 @@ class LicenseViewModel: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Need to create a new activation
|
// 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
|
// Store activation details
|
||||||
userDefaults.activationId = activationId
|
licenseManager.activationId = newActivationId
|
||||||
userDefaults.set(true, forKey: "VoiceInkLicenseRequiresActivation")
|
userDefaults.set(true, forKey: "VoiceInkLicenseRequiresActivation")
|
||||||
self.activationsLimit = limit
|
self.activationsLimit = limit
|
||||||
userDefaults.activationsLimit = limit
|
userDefaults.activationsLimit = limit
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// This license doesn't require activation (unlimited devices)
|
// This license doesn't require activation (unlimited devices)
|
||||||
userDefaults.activationId = nil
|
licenseManager.activationId = nil
|
||||||
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
|
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
|
||||||
self.activationsLimit = licenseCheck.activationsLimit ?? 0
|
self.activationsLimit = licenseCheck.activationsLimit ?? 0
|
||||||
userDefaults.activationsLimit = licenseCheck.activationsLimit ?? 0
|
userDefaults.activationsLimit = licenseCheck.activationsLimit ?? 0
|
||||||
|
|
||||||
// Update the license state for unlimited license
|
// Update the license state for unlimited license
|
||||||
licenseState = .licensed
|
licenseState = .licensed
|
||||||
validationMessage = "License validated successfully!"
|
validationMessage = "License validated successfully!"
|
||||||
@ -154,12 +155,12 @@ class LicenseViewModel: ObservableObject {
|
|||||||
validationMessage = "Activation limit reached: \(details)"
|
validationMessage = "Activation limit reached: \(details)"
|
||||||
} catch LicenseError.activationNotRequired {
|
} catch LicenseError.activationNotRequired {
|
||||||
// This is actually a success case for unlimited licenses
|
// This is actually a success case for unlimited licenses
|
||||||
userDefaults.licenseKey = licenseKey
|
licenseManager.licenseKey = licenseKey
|
||||||
userDefaults.activationId = nil
|
licenseManager.activationId = nil
|
||||||
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
|
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
|
||||||
self.activationsLimit = 0
|
self.activationsLimit = 0
|
||||||
userDefaults.activationsLimit = 0
|
userDefaults.activationsLimit = 0
|
||||||
|
|
||||||
licenseState = .licensed
|
licenseState = .licensed
|
||||||
validationMessage = "License activated successfully!"
|
validationMessage = "License activated successfully!"
|
||||||
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
|
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
|
||||||
@ -171,15 +172,14 @@ class LicenseViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeLicense() {
|
func removeLicense() {
|
||||||
// Remove both license key and trial data
|
// Remove all license data from Keychain
|
||||||
userDefaults.licenseKey = nil
|
licenseManager.removeAll()
|
||||||
userDefaults.activationId = nil
|
|
||||||
|
// Reset UserDefaults flags
|
||||||
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
|
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
|
||||||
userDefaults.trialStartDate = nil
|
|
||||||
userDefaults.set(false, forKey: "VoiceInkHasLaunchedBefore") // Allow trial to restart
|
userDefaults.set(false, forKey: "VoiceInkHasLaunchedBefore") // Allow trial to restart
|
||||||
|
|
||||||
userDefaults.activationsLimit = 0
|
userDefaults.activationsLimit = 0
|
||||||
|
|
||||||
licenseState = .trial(daysRemaining: trialPeriodDays) // Reset to trial state
|
licenseState = .trial(daysRemaining: trialPeriodDays) // Reset to trial state
|
||||||
licenseKey = ""
|
licenseKey = ""
|
||||||
validationMessage = nil
|
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 {
|
extension UserDefaults {
|
||||||
var activationId: String? {
|
|
||||||
get { string(forKey: "VoiceInkActivationId") }
|
|
||||||
set { set(newValue, forKey: "VoiceInkActivationId") }
|
|
||||||
}
|
|
||||||
|
|
||||||
var activationsLimit: Int {
|
var activationsLimit: Int {
|
||||||
get { integer(forKey: "VoiceInkActivationsLimit") }
|
get { integer(forKey: "VoiceInkActivationsLimit") }
|
||||||
set { set(newValue, 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 {
|
private func getLicenseStatus() -> String {
|
||||||
let userDefaults = UserDefaults.standard
|
let licenseManager = LicenseManager.shared
|
||||||
|
|
||||||
// Check for existing license key and activation
|
// Check for existing license key and activation
|
||||||
if let _ = userDefaults.licenseKey {
|
if licenseManager.licenseKey != nil {
|
||||||
if userDefaults.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") {
|
if licenseManager.activationId != nil || !UserDefaults.standard.bool(forKey: "VoiceInkLicenseRequiresActivation") {
|
||||||
return "Licensed (Pro)"
|
return "Licensed (Pro)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,51 +2,10 @@ import Foundation
|
|||||||
|
|
||||||
extension UserDefaults {
|
extension UserDefaults {
|
||||||
enum Keys {
|
enum Keys {
|
||||||
static let licenseKey = "VoiceInkLicense"
|
|
||||||
static let trialStartDate = "VoiceInkTrialStartDate"
|
|
||||||
static let audioInputMode = "audioInputMode"
|
static let audioInputMode = "audioInputMode"
|
||||||
static let selectedAudioDeviceUID = "selectedAudioDeviceUID"
|
static let selectedAudioDeviceUID = "selectedAudioDeviceUID"
|
||||||
static let prioritizedDevices = "prioritizedDevices"
|
static let prioritizedDevices = "prioritizedDevices"
|
||||||
static let affiliatePromotionDismissed = "VoiceInkAffiliatePromotionDismissed"
|
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
|
// MARK: - Audio Input Mode
|
||||||
@ -72,4 +31,4 @@ extension UserDefaults {
|
|||||||
get { bool(forKey: Keys.affiliatePromotionDismissed) }
|
get { bool(forKey: Keys.affiliatePromotionDismissed) }
|
||||||
set { setValue(newValue, forKey: Keys.affiliatePromotionDismissed) }
|
set { setValue(newValue, forKey: Keys.affiliatePromotionDismissed) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user