import Foundation import Combine import AppKit import Security // MARK: - Keychain Helper class KeychainHelper { static let shared = KeychainHelper() private let serviceName = "com.shotscreen.app" private init() {} // MARK: - Generic Keychain Operations func save(_ data: Data, forKey key: String) -> Bool { // Delete any existing item first delete(forKey: key) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly ] let status = SecItemAdd(query as CFDictionary, nil) return status == errSecSuccess } func load(forKey key: String) -> Data? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true ] var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess else { return nil } return result as? Data } func delete(forKey key: String) -> Bool { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key ] let status = SecItemDelete(query as CFDictionary) return status == errSecSuccess || status == errSecItemNotFound } // MARK: - String Convenience Methods func saveString(_ string: String, forKey key: String) -> Bool { guard let data = string.data(using: .utf8) else { return false } return save(data, forKey: key) } func loadString(forKey key: String) -> String? { guard let data = load(forKey: key) else { return nil } return String(data: data, encoding: .utf8) } // MARK: - Date Convenience Methods func saveDate(_ date: Date, forKey key: String) -> Bool { let data = date.timeIntervalSince1970.description.data(using: .utf8)! return save(data, forKey: key) } func loadDate(forKey key: String) -> Date? { guard let string = loadString(forKey: key), let timeInterval = Double(string) else { return nil } return Date(timeIntervalSince1970: timeInterval) } // MARK: - Bool Convenience Methods func saveBool(_ bool: Bool, forKey key: String) -> Bool { let data = bool.description.data(using: .utf8)! return save(data, forKey: key) } func loadBool(forKey key: String) -> Bool? { guard let string = loadString(forKey: key) else { return nil } return Bool(string) } // MARK: - Migration from UserDefaults func migrateFromUserDefaults() { print("๐Ÿ” KEYCHAIN: Migrating sensitive data from UserDefaults to Keychain...") let keysToMigrate = [ "ShotScreen_LicenseKey", "ShotScreen_UserName", "ShotScreen_UserEmail", "ShotScreen_TrialStartDate", "ShotScreen_LastVerification", "ShotScreen_IsTestLicense" ] var migratedCount = 0 for key in keysToMigrate { // Check if already exists in Keychain if load(forKey: key) != nil { continue // Already migrated } // Try to get from UserDefaults if let string = UserDefaults.standard.string(forKey: key) { if saveString(string, forKey: key) { UserDefaults.standard.removeObject(forKey: key) migratedCount += 1 print("โœ… KEYCHAIN: Migrated \(key)") } } else if let date = UserDefaults.standard.object(forKey: key) as? Date { if saveDate(date, forKey: key) { UserDefaults.standard.removeObject(forKey: key) migratedCount += 1 print("โœ… KEYCHAIN: Migrated \(key)") } } else if UserDefaults.standard.object(forKey: key) != nil { let bool = UserDefaults.standard.bool(forKey: key) if saveBool(bool, forKey: key) { UserDefaults.standard.removeObject(forKey: key) migratedCount += 1 print("โœ… KEYCHAIN: Migrated \(key)") } } } if migratedCount > 0 { print("๐Ÿ” KEYCHAIN: Successfully migrated \(migratedCount) items to secure storage") } } } // MARK: - License Models struct GumroadVerifyResponse: Codable { let success: Bool let message: String? let uses: Int? let purchase: GumroadPurchase? } struct GumroadPurchase: Codable { let seller_id: String? let product_id: String? let product_name: String? let email: String? let price: Int? let currency: String? let sale_timestamp: String? let refunded: Bool? let disputed: Bool? let chargebacked: Bool? let license_key: String? let id: String? let test: Bool? // Test purchase detection } enum LicenseStatus { case checking case trial(daysLeft: Int) case licensed(userName: String, email: String) case testLicense(userName: String, email: String) // Test purchase status case expired case invalid } // MARK: - License Manager class LicenseManager: ObservableObject { static let shared = LicenseManager() @Published var licenseStatus: LicenseStatus = .checking @Published var isLicenseValid: Bool = false @Published var showLicenseEntry: Bool = false // Window management - strong reference to prevent deallocation private var licenseEntryWindow: LicenseEntryWindow? private var windowCloseObserver: NSObjectProtocol? // Configuration - jouw Gumroad product info private let productID = "338g8-ukf3N_mZ-c-XddFw==" // ShotScreen Product ID van Gumroad private let gumroadAPIURL = "https://api.gumroad.com/v2/licenses/verify" // Secure Keychain keys (instead of UserDefaults) private let licenseKeyKey = "ShotScreen_LicenseKey" private let trialStartDateKey = "ShotScreen_TrialStartDate" private let userNameKey = "ShotScreen_UserName" private let userEmailKey = "ShotScreen_UserEmail" private let lastVerificationKey = "ShotScreen_LastVerification" private let isTestLicenseKey = "ShotScreen_IsTestLicense" private let trialDuration: TimeInterval = 7 * 24 * 60 * 60 // 7 dagen private let verificationInterval: TimeInterval = 24 * 60 * 60 // 1 dag private init() { // Check if we're in development mode (to avoid constant Keychain prompts) if !isInDevelopmentMode() { // Migrate existing data from UserDefaults to Keychain KeychainHelper.shared.migrateFromUserDefaults() } checkLicenseStatus() } // MARK: - Development Mode Helper private func isInDevelopmentMode() -> Bool { return ProcessInfo.processInfo.environment["SHOTSCREEN_DEV_MODE"] == "true" } // MARK: - Development-Safe Storage Helpers private func saveString(_ value: String, forKey key: String) { if isInDevelopmentMode() { UserDefaults.standard.set(value, forKey: key) print("๐Ÿ”ง DEV MODE: Saved \(key) to UserDefaults") } else { _ = KeychainHelper.shared.saveString(value, forKey: key) } } private func loadString(forKey key: String) -> String? { if isInDevelopmentMode() { return UserDefaults.standard.string(forKey: key) } else { return KeychainHelper.shared.loadString(forKey: key) } } private func saveDate(_ value: Date, forKey key: String) { if isInDevelopmentMode() { UserDefaults.standard.set(value, forKey: key) print("๐Ÿ”ง DEV MODE: Saved \(key) to UserDefaults") } else { _ = KeychainHelper.shared.saveDate(value, forKey: key) } } private func loadDate(forKey key: String) -> Date? { if isInDevelopmentMode() { return UserDefaults.standard.object(forKey: key) as? Date } else { return KeychainHelper.shared.loadDate(forKey: key) } } private func saveBool(_ value: Bool, forKey key: String) { if isInDevelopmentMode() { UserDefaults.standard.set(value, forKey: key) print("๐Ÿ”ง DEV MODE: Saved \(key) to UserDefaults") } else { _ = KeychainHelper.shared.saveBool(value, forKey: key) } } private func loadBool(forKey key: String) -> Bool? { if isInDevelopmentMode() { return UserDefaults.standard.object(forKey: key) != nil ? UserDefaults.standard.bool(forKey: key) : nil } else { return KeychainHelper.shared.loadBool(forKey: key) } } private func deleteKey(_ key: String) { if isInDevelopmentMode() { UserDefaults.standard.removeObject(forKey: key) print("๐Ÿ”ง DEV MODE: Deleted \(key) from UserDefaults") } else { _ = KeychainHelper.shared.delete(forKey: key) } } deinit { cleanupLicenseWindow() print("๐Ÿ—‘๏ธ LicenseManager deinitialized") } // MARK: - Public Methods func checkLicenseStatus() { print("๐Ÿ” LICENSE: Checking license status...") // Check if we have a stored license key (with dev mode support) let licenseKey = loadString(forKey: licenseKeyKey) if isInDevelopmentMode() { print("๐Ÿ”ง DEV MODE: Using UserDefaults for license storage") } if let licenseKey = licenseKey, !licenseKey.isEmpty { print("๐Ÿ” LICENSE: Found stored license key, verifying...") Task { await verifyStoredLicense(licenseKey: licenseKey) } return } // No license found - check trial status checkTrialStatus() } func enterLicenseKey(_ licenseKey: String, userName: String) async -> Bool { print("๐Ÿ” LICENSE: Attempting to verify license key for user: \(userName)") let success = await verifyLicenseWithGumroad(licenseKey: licenseKey, userName: userName) if success { // Store license info securely (Keychain or UserDefaults in dev mode) saveString(licenseKey, forKey: licenseKeyKey) saveString(userName, forKey: userNameKey) saveDate(Date(), forKey: lastVerificationKey) await MainActor.run { let isTestLicense = self.loadBool(forKey: self.isTestLicenseKey) ?? false let email = self.getStoredEmail() if isTestLicense { self.licenseStatus = .testLicense(userName: userName, email: email) print("๐Ÿงช LICENSE: Test license activated for \(userName)") } else { self.licenseStatus = .licensed(userName: userName, email: email) print("โœ… LICENSE: Production license activated for \(userName)") } self.isLicenseValid = true self.showLicenseEntry = false } print("โœ… LICENSE: License activated successfully for \(userName)") } else { print("โŒ LICENSE: License verification failed") } return success } func startTrial() { // In freemium model, trial starts automatically on first launch // This method should not restart an expired trial if loadDate(forKey: trialStartDateKey) != nil { print("โš ๏ธ LICENSE: Trial already started - cannot restart trial in freemium model") return } print("๐ŸŽ‰ LICENSE: Starting trial period...") saveDate(Date(), forKey: trialStartDateKey) checkTrialStatus() } func getTrialDaysLeft() -> Int { guard let trialStartDate = loadDate(forKey: trialStartDateKey) else { return 7 // No trial started yet } let elapsed = Date().timeIntervalSince(trialStartDate) let daysElapsed = Int(elapsed / (24 * 60 * 60)) return max(0, 7 - daysElapsed) } func showLicenseEntryDialog() { print("๐Ÿ” LICENSE: Showing license entry dialog...") // Close existing window if any closeLicenseEntryWindow() // Create and show new window with proper reference management DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.licenseEntryWindow = LicenseEntryWindow() // Set up completion handler for when window is closed if let window = self.licenseEntryWindow { // Add notification observer for window closing self.windowCloseObserver = NotificationCenter.default.addObserver( forName: NSWindow.willCloseNotification, object: window, queue: .main ) { [weak self] _ in print("๐ŸชŸ License window will close - cleaning up reference") self?.cleanupLicenseWindow() } window.makeKeyAndOrderFront(nil) print("๐Ÿ” LICENSE: License entry window created and shown") } } } func closeLicenseEntryWindow() { if let window = licenseEntryWindow { print("๐ŸชŸ Closing existing license entry window") window.close() } cleanupLicenseWindow() } private func cleanupLicenseWindow() { // Remove notification observer if let observer = windowCloseObserver { NotificationCenter.default.removeObserver(observer) windowCloseObserver = nil print("๐Ÿ—‘๏ธ Window close observer removed") } // Clear window reference licenseEntryWindow = nil print("๐Ÿ—‘๏ธ License window reference cleared") } func isTrialExpired() -> Bool { guard let trialStartDate = loadDate(forKey: trialStartDateKey) else { return false // No trial started } let elapsed = Date().timeIntervalSince(trialStartDate) return elapsed > trialDuration } func hasValidLicense() -> Bool { return isLicenseValid } // MARK: - Private Methods private func checkTrialStatus() { // Check if trial has already been started if let trialStartDate = loadDate(forKey: trialStartDateKey) { let elapsed = Date().timeIntervalSince(trialStartDate) if elapsed > trialDuration { print("โŒ LICENSE: Trial period expired (\(Int(elapsed / (24 * 60 * 60))) days ago)") DispatchQueue.main.async { self.licenseStatus = .expired self.isLicenseValid = false } } else { let daysLeft = max(0, 7 - Int(elapsed / (24 * 60 * 60))) print("โฐ LICENSE: Trial active, \(daysLeft) days remaining") DispatchQueue.main.async { self.licenseStatus = .trial(daysLeft: daysLeft) self.isLicenseValid = true } } } else { // First launch - automatically start trial print("๐ŸŽ‰ LICENSE: First launch detected - automatically starting 7-day trial") let now = Date() saveDate(now, forKey: trialStartDateKey) DispatchQueue.main.async { self.licenseStatus = .trial(daysLeft: 7) self.isLicenseValid = true } } } private func verifyStoredLicense(licenseKey: String) async { // Check if we need to reverify (not more than once per day) if let lastVerification = loadDate(forKey: lastVerificationKey) { let timeSinceLastCheck = Date().timeIntervalSince(lastVerification) if timeSinceLastCheck < verificationInterval { print("๐Ÿ” LICENSE: Using cached verification") await MainActor.run { let userName = self.getStoredUserName() let email = self.getStoredEmail() let isTestLicense = self.loadBool(forKey: self.isTestLicenseKey) ?? false if isTestLicense { self.licenseStatus = .testLicense(userName: userName, email: email) print("๐Ÿงช LICENSE: Using cached test license") } else { self.licenseStatus = .licensed(userName: userName, email: email) print("๐Ÿ” LICENSE: Using cached production license") } self.isLicenseValid = true } return } } // Re-verify with Gumroad let userName = getStoredUserName() let success = await verifyLicenseWithGumroad(licenseKey: licenseKey, userName: userName) await MainActor.run { if success { let isTestLicense = self.loadBool(forKey: self.isTestLicenseKey) ?? false let email = self.getStoredEmail() if isTestLicense { self.licenseStatus = .testLicense(userName: userName, email: email) print("๐Ÿงช LICENSE: Test license verified on startup") } else { self.licenseStatus = .licensed(userName: userName, email: email) print("โœ… LICENSE: Production license verified on startup") } self.isLicenseValid = true self.saveDate(Date(), forKey: self.lastVerificationKey) } else { // License might be revoked, disputed, etc. self.licenseStatus = .invalid self.isLicenseValid = false // Clear stored license self.deleteKey(self.licenseKeyKey) self.deleteKey(self.userNameKey) self.deleteKey(self.userEmailKey) self.deleteKey(self.isTestLicenseKey) } } } private func verifyLicenseWithGumroad(licenseKey: String, userName: String) async -> Bool { guard let url = URL(string: gumroadAPIURL) else { print("โŒ LICENSE: Invalid Gumroad API URL") return false } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") // Prepare form data var formData = [String]() formData.append("product_id=\(productID)") formData.append("license_key=\(licenseKey)") formData.append("increment_uses_count=false") // Don't increment on every check let bodyString = formData.joined(separator: "&") request.httpBody = bodyString.data(using: .utf8) do { let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { print("๐Ÿ” LICENSE: Gumroad API response status: \(httpResponse.statusCode)") } let verifyResponse = try JSONDecoder().decode(GumroadVerifyResponse.self, from: data) if verifyResponse.success { // Additional checks if let purchase = verifyResponse.purchase { // ๐Ÿงช Check if this is a test purchase let isTestPurchase = purchase.test == true if isTestPurchase { print("๐Ÿงช LICENSE: Test purchase detected!") print("๐Ÿงช LICENSE: Product: \(purchase.product_name ?? "Unknown")") print("๐Ÿงช LICENSE: Email: \(purchase.email ?? "Unknown")") print("๐Ÿงช LICENSE: Price: \(purchase.price ?? 0) \(purchase.currency ?? "USD")") print("๐Ÿงช LICENSE: This is a development/test license") } // Check if refunded, disputed, or chargebacked if purchase.refunded == true || purchase.disputed == true || purchase.chargebacked == true { print("โŒ LICENSE: License is refunded, disputed, or chargebacked") return false } // Store additional info if let email = purchase.email { saveString(email, forKey: userEmailKey) } // Store test status for UI display saveBool(isTestPurchase, forKey: isTestLicenseKey) print("โœ… LICENSE: Gumroad verification successful") print("๐Ÿ” LICENSE: Product: \(purchase.product_name ?? "Unknown")") print("๐Ÿ” LICENSE: Email: \(purchase.email ?? "Unknown")") print("๐Ÿ” LICENSE: License Type: \(isTestPurchase ? "TEST" : "PRODUCTION")") return true } } else { print("โŒ LICENSE: Gumroad verification failed: \(verifyResponse.message ?? "Unknown error")") } } catch { print("โŒ LICENSE: Gumroad API error: \(error.localizedDescription)") } return false } private func getStoredUserName() -> String { return loadString(forKey: userNameKey) ?? "Unknown User" } private func getStoredEmail() -> String { return loadString(forKey: userEmailKey) ?? "" } } // MARK: - Notification Names extension Notification.Name { static let licenseStatusChanged = Notification.Name("licenseStatusChanged") }