Files
shotscreen/ShotScreen/Sources/LicenseManager.swift
Nick Roodenrijs 0dabed11d2 🎉 ShotScreen v1.0 - Initial Release
🚀 First official release of ShotScreen with complete feature set:

 Core Features:
- Advanced screenshot capture system
- Multi-monitor support
- Professional UI/UX design
- Automated update system with Sparkle
- Apple notarized & code signed

🛠 Technical Excellence:
- Native Swift macOS application
- Professional build & deployment pipeline
- Comprehensive error handling
- Memory optimized performance

📦 Distribution Ready:
- Professional DMG packaging
- Apple notarization complete
- No security warnings for users
- Ready for public distribution

This is the foundation release that establishes ShotScreen as a premium screenshot tool for macOS.
2025-06-28 16:15:15 +02:00

605 lines
22 KiB
Swift

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