🚀 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.
605 lines
22 KiB
Swift
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")
|
|
} |