vOOice/VoiceInk/Models/LicenseViewModel.swift
Jake Shore de1c1e51aa Add hybrid streaming transcription for improved accuracy
- Implement real-time streaming preview using Parakeet EOU (160ms chunks)
- Add batch transcription on completion for accurate final result
- Prefer Whisper large-v3-turbo (2.7% WER) over Parakeet (6.05% WER) when available
- Remove audio preprocessing that hurts ASR accuracy (gain control, noise reduction)
- Add streaming audio callback support in Recorder and CoreAudioRecorder
- Raw audio passthrough - SDK handles resampling internally

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 07:35:53 -05:00

205 lines
7.7 KiB
Swift

import Foundation
import AppKit
@MainActor
class LicenseViewModel: ObservableObject {
enum LicenseState: Equatable {
case trial(daysRemaining: Int)
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
private var isInitializing = true
init() {
loadLicenseState()
isInitializing = false
}
func startTrial() {
// Only set trial start date if it hasn't been set before
if licenseManager.trialStartDate == nil {
licenseManager.trialStartDate = Date()
licenseState = .trial(daysRemaining: trialPeriodDays)
// Don't post notification during initialization to prevent recursive loop
if !isInitializing {
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
}
}
}
private func loadLicenseState() {
// Check for existing license key
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 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 {
// First launch - start trial automatically
userDefaults.set(true, forKey: "VoiceInkHasLaunchedBefore")
startTrial()
return
}
// Only check trial if not licensed and not first launch
if let trialStartDate = licenseManager.trialStartDate {
let daysSinceTrialStart = Calendar.current.dateComponents([.day], from: trialStartDate, to: Date()).day ?? 0
if daysSinceTrialStart >= trialPeriodDays {
licenseState = .trialExpired
} else {
licenseState = .trial(daysRemaining: trialPeriodDays - daysSinceTrialStart)
}
} else {
// No trial has been started yet - start it now
startTrial()
}
}
var canUseApp: Bool {
switch licenseState {
case .licensed, .trial:
return true
case .trialExpired:
return false
}
}
func openPurchaseLink() {
if let url = URL(string: "https://tryvoiceink.com/buy") {
NSWorkspace.shared.open(url)
}
}
func validateLicense() async {
guard !licenseKey.isEmpty else {
validationMessage = "Please enter a license key"
return
}
isValidating = true
do {
// 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
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 existingActivationId = licenseManager.activationId {
let isValid = try await polarService.validateLicenseKeyWithActivation(licenseKey, activationId: existingActivationId)
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 (newActivationId, limit) = try await polarService.activateLicenseKey(licenseKey)
// Store activation details
licenseManager.activationId = newActivationId
userDefaults.set(true, forKey: "VoiceInkLicenseRequiresActivation")
self.activationsLimit = limit
userDefaults.activationsLimit = limit
} else {
// This license doesn't require activation (unlimited devices)
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!"
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
isValidating = false
return
}
// Update the license state for activated license
licenseState = .licensed
validationMessage = "License activated successfully!"
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
} catch LicenseError.activationLimitReached(let details) {
validationMessage = "Activation limit reached: \(details)"
} catch LicenseError.activationNotRequired {
// This is actually a success case for unlimited licenses
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)
} catch {
validationMessage = error.localizedDescription
}
isValidating = false
}
func removeLicense() {
// Remove all license data from Keychain
licenseManager.removeAll()
// Reset UserDefaults flags
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
userDefaults.set(false, forKey: "VoiceInkHasLaunchedBefore") // Allow trial to restart
userDefaults.activationsLimit = 0
licenseState = .trial(daysRemaining: trialPeriodDays) // Reset to trial state
licenseKey = ""
validationMessage = nil
activationsLimit = 0
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
loadLicenseState()
}
}
// UserDefaults extension for non-sensitive license settings
extension UserDefaults {
var activationsLimit: Int {
get { integer(forKey: "VoiceInkActivationsLimit") }
set { set(newValue, forKey: "VoiceInkActivationsLimit") }
}
}