import AppKit import SwiftUI // Needed for TabView and materials // MARK: - Settings Window UI (Nieuwe structuur met TabView) struct SettingsTabView: View { @State private var selectedTab: Int = 0 @ObservedObject private var settings = SettingsManager.shared // State voor Folder Path (omdat het via een panel wordt gezet) @State private var folderPathDisplay: String // Temporary state voor alle settings - only applied on Apply @State private var tempThumbnailTimer: Double @State private var tempFilenamePrefix: String @State private var tempFilenameFormatPreset: FilenameFormatPreset @State private var tempFilenameCustomFormat: String @State private var tempThumbnailFixedSize: ThumbnailFixedSize @State private var tempShowFolderButton: Bool @State private var tempCloseAfterDrag: Bool @State private var tempCloseAfterSave: Bool @State private var tempPlaySoundOnCapture: Bool // ๐Ÿ”Š NEW: Sound settings temporary state @State private var tempScreenshotSoundVolume: Float @State private var tempScreenshotSoundType: ScreenshotSoundType // ๐ŸŽจ NEW: BGR method preference temporary state @State private var tempPreferredBackgroundRemovalMethod: BackgroundRemovalMethod // ๐Ÿ—‚๏ธ NEW: Cache management temporary state @State private var tempCacheRetentionTime: CacheRetentionTime @State private var cacheSize: Double = 0.0 @State private var cacheFileCount: Int = 0 @State private var showClearCacheConfirmation = false @State private var tempAutoSaveScreenshot: Bool @State private var tempActionOrder: [ActionType] @State private var tempIsRenameActionEnabled: Bool @State private var tempIsStashActionEnabled: Bool @State private var tempIsOCRActionEnabled: Bool @State private var tempIsClipboardActionEnabled: Bool @State private var tempIsBackgroundRemoveActionEnabled: Bool @State private var tempIsCancelActionEnabled: Bool @State private var tempIsRemoveActionEnabled: Bool @State private var tempScreenshotFolder: String? @State private var tempThumbnailDisplayScreen: ThumbnailDisplayScreen @State private var tempStashAlwaysOnTop: Bool @State private var tempPersistentStash: Bool // ๐Ÿ”„ UPDATE SETTINGS TEMP VARS @State private var tempHideDesktopIconsDuringScreenshot: Bool @State private var tempHideDesktopWidgetsDuringScreenshot: Bool @State private var tempStashPreviewSize: StashPreviewSize // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE STASH GRID TEMP VARIABLES! โšก๐Ÿ’ฅ๐Ÿ”ฅ @State private var tempStashGridMode: StashGridMode @State private var tempStashMaxColumns: Int @State private var tempStashMaxRows: Int // NEW: Temporary state for custom modifier settings // NEW: animatie-state voor de Apply-knop @State private var showApplyConfirmation = false @State private var showUnsavedChangesAlert = false @State private var forceClose = false // Flag to bypass unsaved changes check @State private var showLoginInstructionsPopup = false // NEW: For login instructions popup // AI Models state @State private var isDownloadingModel = false @State private var downloadProgress: Double = 0.0 @State private var showDownloadConfirmation = false @State private var showRemoveConfirmation = false @State private var isRMBGModelInstalled = false private let originalSettings: SettingsSnapshot private let sampleThumbnailImage = NSImage(contentsOf: URL(fileURLWithPath: "/Volumes/External HD/Users/nick/Desktop/ss 1.0/ScreenShot/image/ChatGPT Image May 9, 2025, 11_51_47 AM.png")) init() { _folderPathDisplay = State(initialValue: SettingsManager.shared.screenshotFolder ?? "Not selected") let timerVal = SettingsManager.shared.thumbnailTimer _tempThumbnailTimer = State(initialValue: (timerVal == 0) ? 31.0 : max(5.0, Double(timerVal))) _tempFilenamePrefix = State(initialValue: SettingsManager.shared.filenamePrefix) _tempFilenameFormatPreset = State(initialValue: SettingsManager.shared.filenameFormatPreset) _tempFilenameCustomFormat = State(initialValue: SettingsManager.shared.filenameCustomFormat) _tempThumbnailFixedSize = State(initialValue: SettingsManager.shared.thumbnailFixedSize) _tempShowFolderButton = State(initialValue: SettingsManager.shared.showFolderButton) _tempCloseAfterDrag = State(initialValue: SettingsManager.shared.closeAfterDrag) _tempCloseAfterSave = State(initialValue: SettingsManager.shared.closeAfterSave) _tempPlaySoundOnCapture = State(initialValue: SettingsManager.shared.playSoundOnCapture) _tempScreenshotSoundVolume = State(initialValue: SettingsManager.shared.screenshotSoundVolume) _tempScreenshotSoundType = State(initialValue: SettingsManager.shared.screenshotSoundType) _tempPreferredBackgroundRemovalMethod = State(initialValue: SettingsManager.shared.preferredBackgroundRemovalMethod) _tempCacheRetentionTime = State(initialValue: SettingsManager.shared.cacheRetentionTime) _tempAutoSaveScreenshot = State(initialValue: SettingsManager.shared.autoSaveScreenshot) _tempActionOrder = State(initialValue: SettingsManager.shared.actionOrder) _tempIsRenameActionEnabled = State(initialValue: SettingsManager.shared.isRenameActionEnabled) _tempIsStashActionEnabled = State(initialValue: SettingsManager.shared.isStashActionEnabled) _tempIsOCRActionEnabled = State(initialValue: SettingsManager.shared.isOCRActionEnabled) _tempIsClipboardActionEnabled = State(initialValue: SettingsManager.shared.isClipboardActionEnabled) _tempIsBackgroundRemoveActionEnabled = State(initialValue: SettingsManager.shared.isBackgroundRemoveActionEnabled) _tempIsCancelActionEnabled = State(initialValue: SettingsManager.shared.isCancelActionEnabled) _tempIsRemoveActionEnabled = State(initialValue: SettingsManager.shared.isRemoveActionEnabled) _tempScreenshotFolder = State(initialValue: SettingsManager.shared.screenshotFolder) _tempThumbnailDisplayScreen = State(initialValue: SettingsManager.shared.thumbnailDisplayScreen) _tempStashAlwaysOnTop = State(initialValue: SettingsManager.shared.stashAlwaysOnTop) _tempPersistentStash = State(initialValue: SettingsManager.shared.persistentStash) // ๐Ÿ”„ UPDATE SETTINGS INIT _tempHideDesktopIconsDuringScreenshot = State(initialValue: SettingsManager.shared.hideDesktopIconsDuringScreenshot) _tempHideDesktopWidgetsDuringScreenshot = State(initialValue: SettingsManager.shared.hideDesktopWidgetsDuringScreenshot) _tempStashPreviewSize = State(initialValue: SettingsManager.shared.stashPreviewSize) // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE STASH GRID INIT! โšก๐Ÿ’ฅ๐Ÿ”ฅ _tempStashGridMode = State(initialValue: SettingsManager.shared.stashGridMode) _tempStashMaxColumns = State(initialValue: SettingsManager.shared.stashMaxColumns) _tempStashMaxRows = State(initialValue: SettingsManager.shared.stashMaxRows) // Initialize RMBG model status _isRMBGModelInstalled = State(initialValue: Self.isModelInstalled()) // Initialize temporary custom modifier settings self.originalSettings = SettingsSnapshot.captureCurrent() } private var hasUnsavedChanges: Bool { let currentSettings = SettingsManager.shared return tempThumbnailTimer != ((currentSettings.thumbnailTimer == 0) ? 31.0 : max(5.0, Double(currentSettings.thumbnailTimer))) || tempFilenamePrefix != currentSettings.filenamePrefix || tempFilenameFormatPreset != currentSettings.filenameFormatPreset || tempFilenameCustomFormat != currentSettings.filenameCustomFormat || tempThumbnailFixedSize != currentSettings.thumbnailFixedSize || tempShowFolderButton != currentSettings.showFolderButton || tempCloseAfterDrag != currentSettings.closeAfterDrag || tempCloseAfterSave != currentSettings.closeAfterSave || tempPlaySoundOnCapture != currentSettings.playSoundOnCapture || tempScreenshotSoundVolume != currentSettings.screenshotSoundVolume || tempScreenshotSoundType != currentSettings.screenshotSoundType || tempPreferredBackgroundRemovalMethod != currentSettings.preferredBackgroundRemovalMethod || tempCacheRetentionTime != currentSettings.cacheRetentionTime || tempAutoSaveScreenshot != currentSettings.autoSaveScreenshot || tempActionOrder != currentSettings.actionOrder || tempIsRenameActionEnabled != currentSettings.isRenameActionEnabled || tempIsStashActionEnabled != currentSettings.isStashActionEnabled || tempIsOCRActionEnabled != currentSettings.isOCRActionEnabled || tempIsClipboardActionEnabled != currentSettings.isClipboardActionEnabled || tempIsBackgroundRemoveActionEnabled != currentSettings.isBackgroundRemoveActionEnabled || tempIsCancelActionEnabled != currentSettings.isCancelActionEnabled || tempIsRemoveActionEnabled != currentSettings.isRemoveActionEnabled || tempScreenshotFolder != currentSettings.screenshotFolder || tempThumbnailDisplayScreen != currentSettings.thumbnailDisplayScreen || tempStashAlwaysOnTop != currentSettings.stashAlwaysOnTop || tempPersistentStash != currentSettings.persistentStash || tempHideDesktopIconsDuringScreenshot != currentSettings.hideDesktopIconsDuringScreenshot || tempHideDesktopWidgetsDuringScreenshot != currentSettings.hideDesktopWidgetsDuringScreenshot || tempStashPreviewSize != currentSettings.stashPreviewSize || // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE GRID CHANGE DETECTION! โšก๐Ÿ’ฅ๐Ÿ”ฅ tempStashGridMode != currentSettings.stashGridMode || tempStashMaxColumns != currentSettings.stashMaxColumns || tempStashMaxRows != currentSettings.stashMaxRows } private func moveActionInTemp(_ action: ActionType, direction: Int) { guard let currentIndex = tempActionOrder.firstIndex(of: action) else { return } let newIndex = currentIndex + direction guard newIndex >= 0 && newIndex < tempActionOrder.count else { return } tempActionOrder.swapAt(currentIndex, newIndex) } private func applyAllChanges() { let settings = SettingsManager.shared // Apply all temporary changes to actual settings settings.screenshotFolder = tempScreenshotFolder settings.filenamePrefix = tempFilenamePrefix settings.filenameFormatPreset = tempFilenameFormatPreset settings.filenameCustomFormat = tempFilenameCustomFormat settings.thumbnailTimer = Int(tempThumbnailTimer.rounded()) >= 31 ? 0 : Int(tempThumbnailTimer.rounded()) settings.thumbnailFixedSize = tempThumbnailFixedSize settings.showFolderButton = tempShowFolderButton settings.closeAfterDrag = tempCloseAfterDrag settings.closeAfterSave = tempCloseAfterSave settings.playSoundOnCapture = tempPlaySoundOnCapture settings.screenshotSoundVolume = tempScreenshotSoundVolume settings.screenshotSoundType = tempScreenshotSoundType settings.preferredBackgroundRemovalMethod = tempPreferredBackgroundRemovalMethod settings.cacheRetentionTime = tempCacheRetentionTime settings.autoSaveScreenshot = tempAutoSaveScreenshot settings.actionOrder = tempActionOrder settings.isRenameActionEnabled = tempIsRenameActionEnabled settings.isStashActionEnabled = tempIsStashActionEnabled settings.isOCRActionEnabled = tempIsOCRActionEnabled settings.isClipboardActionEnabled = tempIsClipboardActionEnabled settings.isBackgroundRemoveActionEnabled = tempIsBackgroundRemoveActionEnabled settings.isCancelActionEnabled = tempIsCancelActionEnabled settings.isRemoveActionEnabled = tempIsRemoveActionEnabled settings.thumbnailDisplayScreen = tempThumbnailDisplayScreen settings.stashAlwaysOnTop = tempStashAlwaysOnTop settings.persistentStash = tempPersistentStash settings.hideDesktopIconsDuringScreenshot = tempHideDesktopIconsDuringScreenshot settings.hideDesktopWidgetsDuringScreenshot = tempHideDesktopWidgetsDuringScreenshot // ๐Ÿ”„ UPDATE SETTINGS settings.stashPreviewSize = tempStashPreviewSize // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE GRID SETTINGS APPLY! โšก๐Ÿ’ฅ๐Ÿ”ฅ settings.stashGridMode = tempStashGridMode settings.stashMaxColumns = tempStashMaxColumns settings.stashMaxRows = tempStashMaxRows // Update preview and other UI components ScreenshotApp.sharedInstance?.updatePreviewSize() // ๐ŸŽฏ NEW: Restore thumbnail if settings changes may have closed it ScreenshotApp.sharedInstance?.restoreCurrentThumbnailIfNeeded() print("SettingsTabView: All changes applied successfully") } private func resetToCurrentSettings() { let settings = SettingsManager.shared let timerVal = settings.thumbnailTimer tempThumbnailTimer = (timerVal == 0) ? 31.0 : max(5.0, Double(timerVal)) tempFilenamePrefix = settings.filenamePrefix tempFilenameFormatPreset = settings.filenameFormatPreset tempFilenameCustomFormat = settings.filenameCustomFormat tempThumbnailFixedSize = settings.thumbnailFixedSize tempShowFolderButton = settings.showFolderButton tempCloseAfterDrag = settings.closeAfterDrag tempCloseAfterSave = settings.closeAfterSave tempPlaySoundOnCapture = settings.playSoundOnCapture tempScreenshotSoundVolume = settings.screenshotSoundVolume tempScreenshotSoundType = settings.screenshotSoundType tempPreferredBackgroundRemovalMethod = settings.preferredBackgroundRemovalMethod tempCacheRetentionTime = settings.cacheRetentionTime tempAutoSaveScreenshot = settings.autoSaveScreenshot tempActionOrder = settings.actionOrder tempIsRenameActionEnabled = settings.isRenameActionEnabled tempIsStashActionEnabled = settings.isStashActionEnabled tempIsOCRActionEnabled = settings.isOCRActionEnabled tempIsClipboardActionEnabled = settings.isClipboardActionEnabled tempIsBackgroundRemoveActionEnabled = settings.isBackgroundRemoveActionEnabled tempIsCancelActionEnabled = settings.isCancelActionEnabled tempIsRemoveActionEnabled = settings.isRemoveActionEnabled tempScreenshotFolder = settings.screenshotFolder tempThumbnailDisplayScreen = settings.thumbnailDisplayScreen tempStashAlwaysOnTop = settings.stashAlwaysOnTop tempPersistentStash = settings.persistentStash tempHideDesktopIconsDuringScreenshot = settings.hideDesktopIconsDuringScreenshot tempHideDesktopWidgetsDuringScreenshot = settings.hideDesktopWidgetsDuringScreenshot // ๐Ÿ”„ UPDATE SETTINGS RESET tempStashPreviewSize = settings.stashPreviewSize // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE GRID RESET TO CURRENT! โšก๐Ÿ’ฅ๐Ÿ”ฅ tempStashGridMode = settings.stashGridMode tempStashMaxColumns = settings.stashMaxColumns tempStashMaxRows = settings.stashMaxRows folderPathDisplay = settings.screenshotFolder ?? "Not selected" } private func formatTimerDisplay(_ value: Double) -> String { if value >= 31 { // 31 and above represents "No timeout" return "No timeout" } else { return "\(Int(value))s" } } private func getAvailableScreenOptions() -> [ThumbnailDisplayScreen] { let screenCount = NSScreen.screens.count var options: [ThumbnailDisplayScreen] = [.automatic] // Add screen options based on available screens (up to 5 screens) if screenCount >= 1 { options.append(.screen1) } if screenCount >= 2 { options.append(.screen2) } if screenCount >= 3 { options.append(.screen3) } if screenCount >= 4 { options.append(.screen4) } if screenCount >= 5 { options.append(.screen5) } return options } private func getScreenDisplayName(for option: ThumbnailDisplayScreen) -> String { switch option { case .automatic: return "Automatic (where mouse is)" case .screen1, .screen2, .screen3, .screen4, .screen5: let screenIndex: Int switch option { case .screen1: screenIndex = 0 case .screen2: screenIndex = 1 case .screen3: screenIndex = 2 case .screen4: screenIndex = 3 case .screen5: screenIndex = 4 default: screenIndex = 0 } if screenIndex < NSScreen.screens.count { let screen = NSScreen.screens[screenIndex] let screenName = screen.localizedName return option.getDisplayName(for: screenIndex, screenName: screenName) } else { return option.description + " (not available)" } } } private func updateFilenameExample() -> String { let prefix = tempFilenamePrefix let preset = tempFilenameFormatPreset var example = prefix let now = Date() let dateFormatter = DateFormatter() switch preset { case .macOSStyle: dateFormatter.dateFormat = "yyyy-MM-dd HH.mm.ss" let dateString = dateFormatter.string(from: now) example += (prefix.isEmpty ? "" : " ") + "at " + dateString case .compactDateTime: dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" example += (prefix.isEmpty ? "" : "_") + dateFormatter.string(from: now) case .superCompactDateTime: dateFormatter.dateFormat = "yyyyMMdd_HHmmss" example += (prefix.isEmpty ? "" : "_") + dateFormatter.string(from: now) case .timestamp: example += (prefix.isEmpty ? "" : "_") + String(Int(now.timeIntervalSince1970)) case .prefixOnly: if example.isEmpty { example = "filename" } case .custom: var custom = tempFilenameCustomFormat // Simple replacement for preview, the real logic is in ScreenshotApp dateFormatter.dateFormat = "yyyy"; custom = custom.replacingOccurrences(of: "{YYYY}", with: dateFormatter.string(from: now)) dateFormatter.dateFormat = "yy"; custom = custom.replacingOccurrences(of: "{YY}", with: dateFormatter.string(from: now)) dateFormatter.dateFormat = "MM"; custom = custom.replacingOccurrences(of: "{MM}", with: dateFormatter.string(from: now)) dateFormatter.dateFormat = "dd"; custom = custom.replacingOccurrences(of: "{DD}", with: dateFormatter.string(from: now)) dateFormatter.dateFormat = "HH"; custom = custom.replacingOccurrences(of: "{hh}", with: dateFormatter.string(from: now)) dateFormatter.dateFormat = "mm"; custom = custom.replacingOccurrences(of: "{mm}", with: dateFormatter.string(from: now)) dateFormatter.dateFormat = "ss"; custom = custom.replacingOccurrences(of: "{ss}", with: dateFormatter.string(from: now)) dateFormatter.dateFormat = "SSS"; custom = custom.replacingOccurrences(of: "{ms}", with: dateFormatter.string(from: now)) if custom.isEmpty && prefix.isEmpty { example = "custom_example" } else { example += (prefix.isEmpty || custom.starts(with: ["_", "-", " "]) ? "" : "_") + custom } } return example + ".png" } // MARK: - AI Model Management private static func getModelPath() -> String { // Use Application Support directory instead of app bundle (which is read-only) let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let appDirectory = appSupportURL.appendingPathComponent("ShotScreen") // Create directory if it doesn't exist try? FileManager.default.createDirectory(at: appDirectory, withIntermediateDirectories: true) return appDirectory.appendingPathComponent("bria-rmbg-coreml.mlpackage").path } private func getModelPath() -> String { return Self.getModelPath() } private static func isModelInstalled() -> Bool { return FileManager.default.fileExists(atPath: getModelPath()) } private func isModelInstalled() -> Bool { return Self.isModelInstalled() } private func downloadModel() { guard !isDownloadingModel else { return } isDownloadingModel = true downloadProgress = 0.0 let downloadURL = AppConfig.modelDownloadURL guard let url = URL(string: downloadURL) else { print("โŒ Invalid download URL") isDownloadingModel = false return } // Create secure URLRequest with security headers var request = URLRequest(url: url) // Add all security headers for (key, value) in AppConfig.secureHeaders { request.setValue(value, forHTTPHeaderField: key) } // Set secure connection properties request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.timeoutInterval = 60.0 // Try two approaches: downloadTask first, then dataTask as fallback print("๐Ÿš€ Starting model download with downloadTask approach...") let task = URLSession.shared.downloadTask(with: request) { [self] tempURL, response, error in // Process the file IMMEDIATELY in the completion handler (not async) var permanentURL: URL? if let error = error { print("โŒ DownloadTask failed, trying dataTask approach: \(error.localizedDescription)") DispatchQueue.main.async { self.downloadModelWithDataTask(url: url) } return } guard let tempURL = tempURL else { print("โŒ No temporary file URL, trying dataTask approach") DispatchQueue.main.async { self.downloadModelWithDataTask(url: url) } return } // SYNCHRONOUSLY copy het tijdelijke bestand BEFORE going to main queue do { let permanentLocation = FileManager.default.temporaryDirectory .appendingPathComponent("rmbg_download_\(UUID().uuidString).tmp") try FileManager.default.copyItem(at: tempURL, to: permanentLocation) permanentURL = permanentLocation print("๐Ÿ“‹ Copied temp file to permanent location: \(permanentLocation.path)") } catch { print("โŒ Failed to copy temp file IMMEDIATELY: \(error.localizedDescription)") print("๐Ÿ”„ Trying dataTask approach as fallback...") DispatchQueue.main.async { self.downloadModelWithDataTask(url: url) } return } // Now go to main queue with the permanent file DispatchQueue.main.async { self.isDownloadingModel = false if let permanentURL = permanentURL { self.installModel(from: permanentURL) } } } // Progress tracking let _ = task.progress.observe(\.fractionCompleted) { progress, _ in DispatchQueue.main.async { self.downloadProgress = progress.fractionCompleted } } task.resume() } // MARK: - Security File Validation private func validateDownloadedFile(at url: URL, fileSize: Int64) -> Bool { print("๐Ÿ”’ SECURITY: Validating downloaded file...") // ๐Ÿค– Special handling for ML models (less strict validation) if isMLModelDownload(fileName: url.lastPathComponent, fileSize: fileSize) { return validateMLModel(at: url, fileSize: fileSize) } // 1. File size validation (between 1MB and 200MB) let minSize: Int64 = 1 * 1024 * 1024 // 1MB minimum let maxSize: Int64 = AppConfig.maxDownloadSize // 200MB maximum if fileSize < minSize { print("โŒ SECURITY: File too small (\(fileSize) bytes) - possible malicious file") return false } if fileSize > maxSize { print("โŒ SECURITY: File too large (\(fileSize) bytes) - exceeds safe limit") return false } // 2. File extension validation (basic check) let fileName = url.lastPathComponent.lowercased() let allowedExtensions = ["zip", "mlpackage", "tmp"] let hasValidExtension = allowedExtensions.contains { fileName.hasSuffix($0) } if !hasValidExtension { print("โŒ SECURITY: Suspicious file extension: \(fileName)") return false } // 3. Magic byte validation do { let fileHandle = try FileHandle(forReadingFrom: url) defer { fileHandle.closeFile() } let headerData = fileHandle.readData(ofLength: 16) // Check for known safe file types if let isValid = validateFileHeader(headerData, fileName: fileName) { if !isValid { print("โŒ SECURITY: Invalid file header for type") return false } } // 4. Basic content scan for suspicious patterns if !performBasicContentScan(fileHandle: fileHandle, fileName: fileName) { return false } } catch { print("โŒ SECURITY: Could not read file for validation: \(error)") return false } print("โœ… SECURITY: File validation passed") return true } // MARK: - ML Model Specific Validation (Less Strict) private func isMLModelDownload(fileName: String, fileSize: Int64) -> Bool { let lowerFileName = fileName.lowercased() // Check for known ML model patterns let mlModelPatterns = [ "bria-rmbg", "rmbg", "coreml", "mlpackage", "model", "neural", "ai" ] // Check if filename contains ML model indicators let isMLModel = mlModelPatterns.contains { pattern in lowerFileName.contains(pattern) } // Check if file size is in reasonable ML model range (10MB - 150MB) let mlModelMinSize: Int64 = 10 * 1024 * 1024 // 10MB let mlModelMaxSize: Int64 = 150 * 1024 * 1024 // 150MB let isSizeReasonable = fileSize >= mlModelMinSize && fileSize <= mlModelMaxSize return isMLModel && isSizeReasonable } private func validateMLModel(at url: URL, fileSize: Int64) -> Bool { print("๐Ÿค– DEBUG: ML Model validation starting (less strict)...") print("๐Ÿค– DEBUG: File: \(url.lastPathComponent), Size: \(fileSize) bytes") // 1. Size validation for ML models (10MB - 150MB) let mlModelMinSize: Int64 = 10 * 1024 * 1024 // 10MB let mlModelMaxSize: Int64 = 150 * 1024 * 1024 // 150MB if fileSize < mlModelMinSize { print("โŒ DEBUG: ML model too small (\(fileSize) bytes, min: \(mlModelMinSize))") return false } if fileSize > mlModelMaxSize { print("โŒ DEBUG: ML model too large (\(fileSize) bytes, max: \(mlModelMaxSize))") return false } print("โœ… DEBUG: ML model size validation passed (\(fileSize) bytes)") // 2. Basic file header check (allow ZIP or unknown formats) do { let fileHandle = try FileHandle(forReadingFrom: url) defer { fileHandle.closeFile() } let headerData = fileHandle.readData(ofLength: 16) print("๐Ÿ” DEBUG: ML model header: \(headerData.map { String(format: "0x%02X", $0) }.joined(separator: " "))") // For ML models, be more permissive with headers if headerData.count >= 2 { // Allow ZIP files (most common for ML models) if headerData[0] == 0x50 && headerData[1] == 0x4B { print("โœ… DEBUG: ML model ZIP format detected") return true } // Allow other binary formats (MLPackage can have various headers) print("โ„น๏ธ DEBUG: ML model with unknown header - allowing (likely MLPackage)") return true } } catch { print("โš ๏ธ DEBUG: Could not read ML model header (allowing anyway): \(error)") // For ML models, be permissive if we can't read the header return true } print("โœ… DEBUG: ML model validation passed") return true } private func validateFileHeader(_ headerData: Data, fileName: String) -> Bool? { guard headerData.count >= 4 else { return nil } // ZIP file validation (PK signature) if headerData[0] == 0x50 && headerData[1] == 0x4B { // Valid ZIP signatures: PK\x03\x04, PK\x05\x06, PK\x07\x08 let signature = (UInt16(headerData[2]) << 8) | UInt16(headerData[3]) let validZipSignatures: [UInt16] = [0x0304, 0x0506, 0x0708] if validZipSignatures.contains(signature) { print("โœ… SECURITY: Valid ZIP file header") return true } else { print("โŒ SECURITY: Invalid ZIP signature") return false } } // For .mlpackage (usually a directory, but could be packed differently) if fileName.contains("mlpackage") { // MLPackage can have various formats, allow more flexibility print("โ„น๏ธ SECURITY: MLPackage file - allowing flexible header") return true } // Unknown file type - allow but log print("โš ๏ธ SECURITY: Unknown file type - proceeding with caution") return nil } private func performBasicContentScan(fileHandle: FileHandle, fileName: String) -> Bool { print("๐Ÿ” SECURITY: Performing basic content scan...") // ๐Ÿค– Skip intensive scanning for ML models (they naturally have high entropy) let lowerFileName = fileName.lowercased() let isMLModel = ["bria-rmbg", "rmbg", "coreml", "mlpackage", "model"].contains { pattern in lowerFileName.contains(pattern) } if isMLModel { print("๐Ÿค– SECURITY: Skipping intensive content scan for ML model") return true } // Read a larger sample for content analysis (first 1KB) fileHandle.seek(toFileOffset: 0) let sampleData = fileHandle.readData(ofLength: 1024) // Convert to string for text analysis (if possible) if let sampleText = String(data: sampleData, encoding: .utf8) { // Check for suspicious script patterns let suspiciousPatterns = [ "#!/bin/sh", "#!/bin/bash", "cmd.exe", "powershell", " 7.5 { // High entropy could indicate encryption/obfuscation print("โš ๏ธ SECURITY: High entropy detected (\(String(format: "%.2f", entropy))) - possible obfuscated content") // Don't fail on high entropy alone, just log it } print("โœ… SECURITY: Content scan passed") return true } private func calculateEntropy(data: Data) -> Double { var frequency = [UInt8: Int]() // Count byte frequencies for byte in data { frequency[byte, default: 0] += 1 } // Calculate Shannon entropy let dataLength = Double(data.count) var entropy: Double = 0.0 for count in frequency.values { let probability = Double(count) / dataLength if probability > 0 { entropy -= probability * log2(probability) } } return entropy } private func downloadModelWithDataTask(url: URL) { print("๐Ÿ”„ Falling back to dataTask approach...") // Reset progress for new attempt downloadProgress = 0.0 var request = URLRequest(url: url) // Add all security headers for (key, value) in AppConfig.secureHeaders { request.setValue(value, forHTTPHeaderField: key) } // Set secure connection properties request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.timeoutInterval = 60.0 let task = URLSession.shared.dataTask(with: request) { [self] data, response, error in DispatchQueue.main.async { self.isDownloadingModel = false if let error = error { print("โŒ DataTask download error: \(error.localizedDescription)") self.downloadProgress = 0.0 return } guard let data = data else { print("โŒ No data received") self.downloadProgress = 0.0 return } print("๐Ÿ“Š Downloaded data size: \(data.count) bytes") // Write data to temporary file do { let tempURL = FileManager.default.temporaryDirectory .appendingPathComponent("rmbg_datadownload_\(UUID().uuidString).tmp") try data.write(to: tempURL) print("๐Ÿ“ Wrote data to temp file: \(tempURL.path)") self.installModel(from: tempURL) } catch { print("โŒ Failed to write downloaded data: \(error.localizedDescription)") self.downloadProgress = 0.0 } } } task.resume() } private func installModel(from tempURL: URL) { do { print("๐Ÿ” DEBUG: Starting model installation from: \(tempURL.path)") // Check if the file exists and get its size let fileAttributes = try FileManager.default.attributesOfItem(atPath: tempURL.path) let fileSize = fileAttributes[.size] as? Int64 ?? 0 print("๐Ÿ“Š DEBUG: Downloaded file size: \(fileSize) bytes") // ๐Ÿ”’ SECURITY: Basic file validation print("๐Ÿ”’ DEBUG: Starting file validation...") if !validateDownloadedFile(at: tempURL, fileSize: fileSize) { print("โŒ DEBUG: File validation failed - removing malicious file") try? FileManager.default.removeItem(at: tempURL) DispatchQueue.main.async { self.isDownloadingModel = false self.downloadProgress = 0.0 } return } print("โœ… DEBUG: File validation passed") let destinationPath = getModelPath() let destinationURL = URL(fileURLWithPath: destinationPath) // Remove existing model if present if FileManager.default.fileExists(atPath: destinationPath) { try FileManager.default.removeItem(at: destinationURL) } // Check if the downloaded file is a zip archive let fileHandle = try FileHandle(forReadingFrom: tempURL) let header = fileHandle.readData(ofLength: 4) fileHandle.closeFile() // ZIP file magic numbers: PK\x03\x04 or PK\x05\x06 or PK\x07\x08 let isPKZip = header.count >= 2 && header[0] == 0x50 && header[1] == 0x4B print("๐Ÿ” DEBUG: isPKZip = \(isPKZip), header = \(header.map { String(format: "0x%02X", $0) }.joined(separator: " "))") if isPKZip { print("๐Ÿ“ฆ DEBUG: File appears to be a ZIP archive, extracting...") // Create temporary directory for extraction let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) print("๐Ÿ“ DEBUG: Created temp extraction directory: \(tempDir.path)") // Unzip the downloaded file let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") process.arguments = ["-q", tempURL.path, "-d", tempDir.path] try process.run() process.waitUntilExit() print("๐Ÿ“ฆ DEBUG: Unzip exit code: \(process.terminationStatus)") if process.terminationStatus != 0 { print("โŒ DEBUG: Failed to unzip model with exit code \(process.terminationStatus)") return } // Find the .mlpackage file let contents = try FileManager.default.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil) print("๐Ÿ“ DEBUG: Extracted contents: \(contents.map { $0.lastPathComponent })") guard let mlpackageURL = contents.first(where: { $0.pathExtension == "mlpackage" || $0.lastPathComponent.contains("bria-rmbg-coreml") }) else { print("โŒ DEBUG: No .mlpackage found in downloaded file") print("๐Ÿ“ DEBUG: All contents: \(contents.map { "\($0.lastPathComponent) (ext: \($0.pathExtension))" })") // Cleanup temp directory try? FileManager.default.removeItem(at: tempDir) return } print("๐Ÿ“ฆ DEBUG: Found mlpackage: \(mlpackageURL.lastPathComponent)") // Copy the extracted model try FileManager.default.copyItem(at: mlpackageURL, to: destinationURL) print("๐Ÿ“ฆ DEBUG: Successfully copied model to: \(destinationURL.path)") // Cleanup try FileManager.default.removeItem(at: tempDir) print("๐Ÿ—‘ DEBUG: Cleaned up temp directory") } else { print("๐Ÿ“ฑ DEBUG: File appears to be a direct .mlpackage, copying directly...") // Assume it's already a .mlpackage directory and copy directly try FileManager.default.copyItem(at: tempURL, to: destinationURL) print("๐Ÿ“ฑ DEBUG: Successfully copied direct model to: \(destinationURL.path)") } print("โœ… DEBUG: Model installed successfully") // Check if model actually exists at destination let finalModelExists = FileManager.default.fileExists(atPath: destinationPath) print("๐Ÿ” DEBUG: Model exists at destination: \(finalModelExists) (\(destinationPath))") // Cleanup: Remove the downloaded temp file try? FileManager.default.removeItem(at: tempURL) print("๐Ÿ—‘ DEBUG: Cleaned up downloaded temp file") // Refresh the UI to show the model is now available DispatchQueue.main.async { // Update the local state to refresh the Settings UI print("๐Ÿ”„ DEBUG: Updating UI state - setting isRMBGModelInstalled = true") self.isRMBGModelInstalled = true self.isDownloadingModel = false self.downloadProgress = 0.0 // Notify background remove windows that model is now available NotificationCenter.default.post( name: NSNotification.Name("RMBGModelDownloadCompleted"), object: nil ) print("๐Ÿ“ก DEBUG: Sent RMBGModelDownloadCompleted notification") } } catch { print("โŒ DEBUG: Installation error: \(error.localizedDescription)") print("โŒ DEBUG: Full error: \(error)") // Cleanup: Remove the downloaded temp file on error try? FileManager.default.removeItem(at: tempURL) print("๐Ÿ—‘ DEBUG: Cleaned up temp file after error") // Reset download state on error DispatchQueue.main.async { print("๐Ÿ”„ DEBUG: Resetting download state due to error") self.isDownloadingModel = false self.downloadProgress = 0.0 } } } private func removeModel() { let modelPath = getModelPath() do { if FileManager.default.fileExists(atPath: modelPath) { try FileManager.default.removeItem(atPath: modelPath) print("โœ… Model removed successfully") // Update UI state DispatchQueue.main.async { self.isRMBGModelInstalled = false // Notify other parts of the app that model was removed NotificationCenter.default.post( name: NSNotification.Name("RMBGModelRemoved"), object: nil ) } } else { print("โš ๏ธ Model file not found at path: \(modelPath)") } } catch { print("โŒ Error removing model: \(error.localizedDescription)") } } var body: some View { VStack(spacing: 0) { ScrollView { VStack(spacing: 0) { // Add minimal top padding since traffic lights are hidden Spacer() .frame(height: 20) VStack(spacing: 24) { // Title Section VStack(spacing: 8) { Text("ShotScreen Settings") .font(.largeTitle) .fontWeight(.bold) .foregroundColor(.primary) .multilineTextAlignment(.center) Text("Configure your screenshot experience") .font(.subheadline) .foregroundColor(.secondary) .multilineTextAlignment(.center) } .padding(.top, 10) .padding(.bottom, 20) // Thumbnail Section VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("Thumbnail") .font(.title2) .fontWeight(.semibold) .foregroundColor(.primary) Divider() } HStack { Text("Visibility duration:") Slider(value: $tempThumbnailTimer, in: 5...31, step: 1) { Text("") } Text(formatTimerDisplay(tempThumbnailTimer)) .frame(width: 80, alignment: .trailing) } HStack { Text("Thumbnail size:") .frame(width: 110, alignment: .leading) Picker("", selection: $tempThumbnailFixedSize) { ForEach(ThumbnailFixedSize.allCases, id: \.self) { size in Text(size.displayName).tag(size) } } .labelsHidden() } HStack { Text("Show on screen:") .frame(width: 110, alignment: .leading) Picker("", selection: $tempThumbnailDisplayScreen) { ForEach(getAvailableScreenOptions(), id: \.self) { option in Text(getScreenDisplayName(for: option)).tag(option) } } .labelsHidden() } Toggle("Close thumbnail after dragging", isOn: $tempCloseAfterDrag) Toggle("Close thumbnail after saving", isOn: $tempCloseAfterSave) Divider() // Storage Subsection VStack(alignment: .leading, spacing: 12) { Text("Storage") .font(.headline) .foregroundColor(.primary) // Folder settings HStack { TextField("Folder path", text: .constant(folderPathDisplay)) .disabled(true) Button("Choose...") { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = false panel.message = "Select folder to save screenshots" panel.prompt = "Choose" if panel.runModal() == .OK, let url = panel.url { tempScreenshotFolder = url.path folderPathDisplay = url.path } } } VStack(alignment: .leading, spacing: 8) { HStack { Text("Save Mode:") .frame(width: 110, alignment: .leading) Picker("", selection: Binding( get: { tempAutoSaveScreenshot ? ScreenshotSaveMode.automatic : ScreenshotSaveMode.manual }, set: { tempAutoSaveScreenshot = ($0 == .automatic) } )) { ForEach(ScreenshotSaveMode.allCases) { mode in Text(mode.description).tag(mode) } } .labelsHidden() } // Warning text voor manual mode if !tempAutoSaveScreenshot { Text("Recommending to set timer to No Timeout, otherwise the screenshot will close and is lost") .font(.caption) .foregroundColor(.red) .padding(.top, 4) } } Toggle("Show folder button in thumbnail", isOn: $tempShowFolderButton) } Divider() // Cache Management Subsection VStack(alignment: .leading, spacing: 12) { Text("Cache Management") .font(.headline) .foregroundColor(.primary) // Cache info display VStack(alignment: .leading, spacing: 6) { HStack { Text("Cache size:") .frame(width: 110, alignment: .leading) Text("\(String(format: "%.1f", cacheSize)) MB (\(cacheFileCount) files)") .foregroundColor(.secondary) .font(.caption) Spacer() Button("Open Folder") { openCacheFolder() } .buttonStyle(.bordered) .controlSize(.small) Button("Refresh") { updateCacheInfo() } .buttonStyle(.bordered) .controlSize(.small) } VStack(alignment: .leading, spacing: 2) { Text("Cache contains temporary screenshots for thumbnail restoration and recovery") .font(.caption) .foregroundColor(.secondary) } } // Cache retention time setting HStack { Text("Keep for:") .frame(width: 110, alignment: .leading) Picker("", selection: $tempCacheRetentionTime) { ForEach(CacheRetentionTime.allCases, id: \.self) { time in Text(time.displayName).tag(time) } } .labelsHidden() } // Clear cache button HStack { Button("Clear Cache") { showClearCacheConfirmation = true } .buttonStyle(.bordered) .foregroundColor(.red) Spacer() Text("Removes old screenshots but preserves active thumbnails") .font(.caption) .foregroundColor(.secondary) } } } .padding(20) .background(Color.primary.opacity(0.03)) .cornerRadius(12) .onAppear { updateCacheInfo() } // Screenshot Section VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("Screenshot") .font(.title2) .fontWeight(.semibold) .foregroundColor(.primary) Divider() } Toggle("Hide desktop icons during screenshots", isOn: $tempHideDesktopIconsDuringScreenshot) .help("When enabled, desktop icons will be temporarily hidden during screenshot capture for a cleaner look") Toggle("Hide desktop widgets during screenshots", isOn: $tempHideDesktopWidgetsDuringScreenshot) .help("When enabled, desktop widgets (like weather widgets, clock widgets, etc.) will be temporarily hidden during screenshot capture for a cleaner look") Divider() VStack(alignment: .leading, spacing: 12) { Text("Screenshot Shortcut") .font(.headline) .foregroundColor(.primary) Toggle("Use Custom Shortcut", isOn: $settings.useCustomShortcut) if settings.useCustomShortcut { HStack { Text("Custom Shortcut:") .frame(minWidth: 120, alignment: .leading) ShortcutRecorder( modifiers: $settings.customShortcutModifiers, keyCode: $settings.customShortcutKey, placeholder: "Click to record shortcut" ) .frame(height: 28) } Text("Current: \(formatCurrentShortcut())") .font(.caption) .foregroundColor(.secondary) } else { HStack { Text("Using Default:") .frame(minWidth: 120, alignment: .leading) Text("โŒ˜โ‡ง4") .font(.system(size: 16, design: .monospaced)) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.gray.opacity(0.2)) .cornerRadius(4) } } HStack { Button("Open macOS Screenshot Settings") { openMacOSScreenshotSettings() } .buttonStyle(.bordered) Spacer() } Text("Note: Disable the macOS Cmd+Shift+4 shortcut in System Preferences to avoid conflicts.") .font(.caption) .foregroundColor(.secondary) } Divider() VStack(alignment: .leading, spacing: 8) { Text("Screenshot Modes") .font(.headline) .foregroundColor(.primary) let currentShortcut = getCurrentShortcutDisplay() Text("โ€ข \(currentShortcut) once: Drag to select area or click for full screen") .font(.caption) .foregroundColor(.secondary) Text("โ€ข \(currentShortcut) twice: Capture whole screen under cursor") .font(.caption) .foregroundColor(.secondary) Text("โ€ข \(currentShortcut) three times: Capture all screens combined") .font(.caption) .foregroundColor(.secondary) Text("โ€ข During selection: Press Space for window capture") .font(.caption) .foregroundColor(.secondary) } } .padding(20) .background(Color.primary.opacity(0.03)) .cornerRadius(12) // Filename Format Section VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("Filename Format") .font(.title2) .fontWeight(.semibold) .foregroundColor(.primary) Divider() } TextField("Prefix:", text: $tempFilenamePrefix) HStack { Text("Format Preset:") .frame(width: 110, alignment: .leading) Picker("", selection: $tempFilenameFormatPreset) { ForEach(FilenameFormatPreset.allCases, id: \.self) { preset in Text(preset.description).tag(preset) } } .labelsHidden() } if tempFilenameFormatPreset == .custom { TextField("Custom Format:", text: $tempFilenameCustomFormat) Text("Use: {YYYY}, {MM}, {DD}, {hh}, {mm}, {ss}, {ms}") .font(.caption) .foregroundColor(.gray) } Text("Example: \(updateFilenameExample())") .font(.caption) .foregroundColor(.gray) } .padding() .background(Color.primary.opacity(0.03)) .cornerRadius(12) // Action Bar Section VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { HStack { Text("Action Bar") .font(.title2) .fontWeight(.semibold) .foregroundColor(.primary) Spacer() Button("Reset order") { tempActionOrder = ActionType.allCases } .buttonStyle(.bordered) } Divider() } ForEach(tempActionOrder) { action in HStack { Toggle("\(action.displayName)", isOn: Binding( get: { switch action { case .rename: return tempIsRenameActionEnabled case .stash: return tempIsStashActionEnabled case .ocr: return tempIsOCRActionEnabled case .clipboard: return tempIsClipboardActionEnabled case .backgroundRemove: return tempIsBackgroundRemoveActionEnabled case .cancel: return tempIsCancelActionEnabled case .remove: return tempIsRemoveActionEnabled } }, set: { newValue in switch action { case .rename: tempIsRenameActionEnabled = newValue case .stash: tempIsStashActionEnabled = newValue case .ocr: tempIsOCRActionEnabled = newValue case .clipboard: tempIsClipboardActionEnabled = newValue case .backgroundRemove: tempIsBackgroundRemoveActionEnabled = newValue case .cancel: tempIsCancelActionEnabled = newValue case .remove: tempIsRemoveActionEnabled = newValue } } )) Spacer() HStack(spacing: 4) { Button(action: { moveActionInTemp(action, direction: -1) }) { Image(systemName: "arrow.up") .frame(width: 20, height: 20) } .buttonStyle(.borderless) .disabled(tempActionOrder.first == action) Button(action: { moveActionInTemp(action, direction: 1) }) { Image(systemName: "arrow.down") .frame(width: 20, height: 20) } .buttonStyle(.borderless) .disabled(tempActionOrder.last == action) } } } } .padding() .background(Color.primary.opacity(0.03)) .cornerRadius(12) // Stash Section VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { HStack { Text("Stash") .font(.title2) .fontWeight(.semibold) .foregroundColor(.primary) Spacer() Button("Show Stash") { openStash() } .buttonStyle(.bordered) } Divider() } VStack(alignment: .leading, spacing: 12) { Text("Window Behavior") .font(.headline) .foregroundColor(.primary) Toggle("Always on top", isOn: $tempStashAlwaysOnTop) .help("Keep the Stash window visible above all other applications") VStack(alignment: .leading, spacing: 4) { Toggle("Persistent storage", isOn: $tempPersistentStash) .help("Keep stash images available when you close and reopen the window") Text("When enabled, your stashed screenshots remain available after closing the Stash window. When disabled, stash is cleared each time you close it.") .font(.caption) .foregroundColor(.secondary) } } Divider() VStack(alignment: .leading, spacing: 12) { Text("Grid Layout") .font(.headline) .foregroundColor(.primary) HStack { Text("Layout direction:") .frame(width: 110, alignment: .leading) Picker("", selection: $tempStashGridMode) { ForEach(StashGridMode.allCases, id: \.self) { mode in Text(mode.displayName).tag(mode) } } .labelsHidden() } HStack { Text(tempStashGridMode == .fixedColumns ? "Max columns:" : "Max rows:") Stepper(value: tempStashGridMode == .fixedColumns ? $tempStashMaxColumns : $tempStashMaxRows, in: 1...5) { Text("\(tempStashGridMode == .fixedColumns ? tempStashMaxColumns : tempStashMaxRows)") .foregroundColor(.blue) .frame(minWidth: 20) } } Text(tempStashGridMode == .fixedColumns ? "Grid expands vertically as you add more screenshots" : "Grid expands horizontally as you add more screenshots") .font(.caption) .foregroundColor(.secondary) } Divider() VStack(alignment: .leading, spacing: 12) { Text("Preview Settings") .font(.headline) .foregroundColor(.primary) HStack { Text("Preview size:") .frame(width: 110, alignment: .leading) Picker("", selection: $tempStashPreviewSize) { ForEach(StashPreviewSize.allCases, id: \.self) { size in Text(size.displayName).tag(size) } } .labelsHidden() .help("Choose the size of preview windows when hovering over thumbnails") } } } .padding(20) .background(Color.primary.opacity(0.03)) .cornerRadius(12) // Background Removal Section VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("Background Removal") .font(.title2) .fontWeight(.semibold) .foregroundColor(.primary) Divider() } VStack(alignment: .leading, spacing: 12) { HStack { VStack(alignment: .leading, spacing: 4) { Text("Vision Framework") .font(.headline) Text("Apple's built-in background removal - Always available") .font(.caption) .foregroundColor(.secondary) } Spacer() Text("โœ… Available") .font(.caption) .foregroundColor(.green) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.green.opacity(0.1)) .cornerRadius(8) .frame(minWidth: 80) } Divider() HStack { VStack(alignment: .leading, spacing: 4) { Text("RMBG-1.4") .font(.headline) Text("Better background removal tool - Additional download required") .font(.caption) .foregroundColor(.secondary) } Spacer() HStack(spacing: 8) { if isRMBGModelInstalled { Button(action: { showRemoveConfirmation = true }) { Text("๐Ÿ—‘") .font(.caption) .foregroundColor(.red) .padding(.horizontal, 8) .padding(.vertical, 6) .background(Color.red.opacity(0.1)) .cornerRadius(8) } .buttonStyle(.borderless) .help("Remove RMBG-1.4 model") } if isRMBGModelInstalled { Text("โœ… Available") .font(.caption) .foregroundColor(.green) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.green.opacity(0.1)) .cornerRadius(8) .frame(minWidth: 80) } else { Button(action: { showDownloadConfirmation = true }) { if isDownloadingModel { HStack(spacing: 6) { ProgressView() .scaleEffect(0.7) Text("\(Int(downloadProgress * 100))%") .font(.caption) } .padding(.horizontal, 12) .padding(.vertical, 6) .frame(minWidth: 80) } else { Text("๐Ÿ“ฅ Download") .font(.caption) .padding(.horizontal, 12) .padding(.vertical, 6) .frame(minWidth: 80) } } .disabled(isDownloadingModel) .buttonStyle(.borderedProminent) } } } if isDownloadingModel { ProgressView(value: downloadProgress) .progressViewStyle(LinearProgressViewStyle()) } } .padding(.vertical, 8) Divider() VStack(alignment: .leading, spacing: 12) { Text("Preferred Method") .font(.headline) .foregroundColor(.primary) VStack(alignment: .leading, spacing: 8) { ForEach(BackgroundRemovalMethod.allCases) { method in HStack { Button(action: { tempPreferredBackgroundRemovalMethod = method }) { HStack { Image(systemName: tempPreferredBackgroundRemovalMethod == method ? "largecircle.fill.circle" : "circle") .foregroundColor(tempPreferredBackgroundRemovalMethod == method ? .blue : .secondary) VStack(alignment: .leading, spacing: 2) { Text(method.displayName) .font(.body) .foregroundColor(.primary) Text(method.description) .font(.caption) .foregroundColor(.secondary) } Spacer() } .padding(.vertical, 4) } .buttonStyle(.plain) .disabled(method == .rmbg && !isRMBGModelInstalled) .opacity(method == .rmbg && !isRMBGModelInstalled ? 0.5 : 1.0) } if method == .rmbg && !isRMBGModelInstalled { Text("โš ๏ธ RMBG-1.4 model not installed - this option will not work") .font(.caption) .foregroundColor(.orange) .padding(.leading, 24) } } } } } .padding() .background(Color.primary.opacity(0.03)) .cornerRadius(12) // Other Section VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("Other") .font(.title2) .fontWeight(.semibold) .foregroundColor(.primary) Divider() } VStack(alignment: .leading, spacing: 12) { Text("Audio & System") .font(.headline) .foregroundColor(.primary) Toggle("Play sound on screenshot", isOn: $tempPlaySoundOnCapture) if tempPlaySoundOnCapture { VStack(alignment: .leading, spacing: 8) { HStack { Text("Sound type:") .frame(width: 110, alignment: .leading) Picker("", selection: $tempScreenshotSoundType) { ForEach(ScreenshotSoundType.allCases, id: \.self) { soundType in Text(soundType.displayName).tag(soundType) } } .labelsHidden() Spacer() Button("Test") { testSound() } .buttonStyle(.bordered) .controlSize(.small) } HStack { Text("Volume:") .frame(width: 110, alignment: .leading) Slider(value: $tempScreenshotSoundVolume, in: 0.0...1.0, step: 0.05) Text("\(Int(tempScreenshotSoundVolume * 100))%") .frame(width: 40, alignment: .trailing) .font(.caption) .foregroundColor(.secondary) } } .padding(.leading, 16) .transition(.opacity.combined(with: .move(edge: .top))) } } VStack(alignment: .leading, spacing: 12) { Text("Startup & Integration") .font(.headline) .foregroundColor(.primary) HStack { VStack(alignment: .leading, spacing: 4) { Text("Start ShotScreen automatically at login") .font(.body) Text("Configure ShotScreen to launch automatically when you log in to your Mac") .font(.caption) .foregroundColor(.secondary) } Spacer() Button("Setup Instructions") { showLoginInstructionsPopup = true } .buttonStyle(.bordered) } } } .padding(20) .background(Color.primary.opacity(0.03)) .cornerRadius(12) // LICENSE SECTION LicenseSection() // ๐Ÿ”„ UPDATES SECTION VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { HStack { Text("Updates") .font(.title2) .fontWeight(.semibold) .foregroundColor(.primary) Spacer() Button("Check for Updates") { if let app = NSApp.delegate as? ScreenshotApp { app.checkForUpdates() } } .buttonStyle(.bordered) .disabled(!UpdateManager.shared.isUpdaterAvailable) } Divider() } VStack(alignment: .leading, spacing: 12) { Text("Current Version") .font(.headline) .foregroundColor(.primary) HStack { Text("ShotScreen") .font(.subheadline) .foregroundColor(.secondary) Text(getCurrentVersionInfo()) .font(.subheadline) .fontWeight(.semibold) .foregroundColor(.primary) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.blue.opacity(0.1)) .cornerRadius(6) Spacer() } } Divider() VStack(alignment: .leading, spacing: 12) { Text("Contact Developer") .font(.headline) .foregroundColor(.primary) HStack { VStack(alignment: .leading, spacing: 4) { Text("Need help or have feedback?") .font(.body) .foregroundColor(.primary) Text("Get in touch with questions, feature requests, or bug reports") .font(.caption) .foregroundColor(.secondary) } Spacer() Button("Contact Developer") { openDeveloperEmail() } .buttonStyle(.bordered) } HStack { Image(systemName: "envelope") .foregroundColor(.secondary) .frame(width: 16, height: 16) Text("info@shotscreen.app") .font(.caption) .foregroundColor(.secondary) .textSelection(.enabled) Spacer() } } } .padding(20) .background(Color.primary.opacity(0.03)) .cornerRadius(12) Spacer() } .padding() } } Divider() // Save / Cancel buttons HStack { // "Apply" knop met visuele feedback - alleen actief als er wijzigingen zijn Button(action: { applyAllChanges() // Start animatie withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { showApplyConfirmation = true } // Na 1,2 s verdwijnt de animatie weer DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { withAnimation(.easeOut) { showApplyConfirmation = false } } }) { if showApplyConfirmation { Label("Applied", systemImage: "checkmark.circle.fill") .foregroundColor(.green) .transition(.scale.combined(with: .opacity)) } else { Text("Apply") } } .keyboardShortcut(.defaultAction) // Return-toets = Apply .disabled(!hasUnsavedChanges) // Alleen actief als er wijzigingen zijn Spacer() Button("Cancel") { resetToCurrentSettings() } .disabled(!hasUnsavedChanges) // Alleen actief als er wijzigingen zijn Button("Close") { if hasUnsavedChanges && !forceClose { showUnsavedChangesAlert = true } else { closeWindow() } } } .padding() } .frame(minWidth: 550, idealWidth: 650, minHeight: 800, idealHeight: 800) .alert("Unsaved Changes", isPresented: $showUnsavedChangesAlert) { Button("Save & Close") { applyAllChanges() forceClose = true DispatchQueue.main.async { closeWindow() } } Button("Don't Save", role: .destructive) { resetToCurrentSettings() forceClose = true DispatchQueue.main.async { closeWindow() } } Button("Cancel", role: .cancel) { // Do nothing, just dismiss alert } } message: { Text("You have unsaved changes. Do you want to save them before closing?") } .alert("Download RMBG-1.4 Model", isPresented: $showDownloadConfirmation) { Button("Download") { downloadModel() } Button("Cancel", role: .cancel) { // Do nothing } } message: { Text("The RMBG-1.4 model is approximately 90MB in size. Do you want to download and install it?") } .alert("Remove RMBG-1.4 Model", isPresented: $showRemoveConfirmation) { Button("Remove", role: .destructive) { removeModel() } Button("Cancel", role: .cancel) { // Do nothing } } message: { Text("Are you sure you want to remove the RMBG-1.4 model? You'll need to download it again to use this feature.") } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RMBGModelDownloadCompleted"))) { _ in // Update the model status when notified from external sources isRMBGModelInstalled = Self.isModelInstalled() } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RMBGModelRemoved"))) { _ in // Update the model status when model is removed isRMBGModelInstalled = false } // Toegevoegd .onReceive voor hideDesktopIconsSettingChanged .onReceive(NotificationCenter.default.publisher(for: .hideDesktopIconsSettingChanged)) { _ in print("SettingsTabView: Received hideDesktopIconsSettingChanged notification in backup.") self.tempHideDesktopIconsDuringScreenshot = SettingsManager.shared.hideDesktopIconsDuringScreenshot } .sheet(isPresented: $showLoginInstructionsPopup) { LoginInstructionsPopupView(openSettingsAction: openLoginItemsSettings) } .alert("Clear Cache", isPresented: $showClearCacheConfirmation) { Button("Clear", role: .destructive) { clearCache() } Button("Cancel", role: .cancel) { // Do nothing } } message: { Text("Are you sure you want to clear \(String(format: "%.1f", cacheSize)) MB of cached screenshots? Active thumbnails will be preserved.") } } // Helper to close the containing window private func closeWindow() { NSApp.keyWindow?.close() } // NIEUW: Helper methods for shortcut management private func formatCurrentShortcut() -> String { let modifiers = settings.customShortcutModifiers let keyCode = settings.customShortcutKey if modifiers == 0 && keyCode == 0 { return "No shortcut set" } var parts: [String] = [] if modifiers & (1 << 3) != 0 { parts.append("โŒƒ") } // Control if modifiers & (1 << 2) != 0 { parts.append("โŒฅ") } // Option if modifiers & (1 << 1) != 0 { parts.append("โ‡ง") } // Shift if modifiers & (1 << 0) != 0 { parts.append("โŒ˜") } // Command if let keyName = keyCodeToString(keyCode) { parts.append(keyName) } return parts.joined() } private func keyCodeToString(_ keyCode: UInt16) -> String? { // Same mapping as in ShortcutRecorderView switch keyCode { case 0: return "A" case 1: return "S" case 2: return "D" case 3: return "F" case 4: return "H" case 5: return "G" case 6: return "Z" case 7: return "X" case 8: return "C" case 9: return "V" case 11: return "B" case 12: return "Q" case 13: return "W" case 14: return "E" case 15: return "R" case 16: return "Y" case 17: return "T" case 18: return "1" case 19: return "2" case 20: return "3" case 21: return "4" case 22: return "6" case 23: return "5" case 24: return "=" case 25: return "9" case 26: return "7" case 27: return "-" case 28: return "8" case 29: return "0" case 30: return "]" case 31: return "O" case 32: return "U" case 33: return "[" case 34: return "I" case 35: return "P" case 37: return "L" case 38: return "J" case 39: return "'" case 40: return "K" case 41: return ";" case 42: return "\\" case 43: return "," case 44: return "/" case 45: return "N" case 46: return "M" case 47: return "." case 50: return "`" case 36: return "โ†ฉ" // Return case 48: return "โ‡ฅ" // Tab case 49: return "Space" case 51: return "โŒซ" // Delete case 53: return "โŽ‹" // Escape case 122: return "F1" case 120: return "F2" case 99: return "F3" case 118: return "F4" case 96: return "F5" case 97: return "F6" case 98: return "F7" case 100: return "F8" case 101: return "F9" case 109: return "F10" case 103: return "F11" case 111: return "F12" default: return "\(keyCode)" } } private func openMacOSScreenshotSettings() { // Open macOS Keyboard > Screenshots settings if let url = URL(string: "x-apple.systempreferences:com.apple.preference.keyboard?Shortcuts") { NSWorkspace.shared.open(url) } } private func openStash() { // Open Stash window via the main app delegate if let app = NSApp.delegate as? ScreenshotApp { app.showStash(nil) } } private func openLoginItemsSettings() { print("๐Ÿ”ง Opening Login Items Settings...") // Voor macOS 13+ - gebruik eenvoudige URL if #available(macOS 13.0, *) { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.users") { NSWorkspace.shared.open(url) return } } // Voor macOS 12 en ouder if let url = URL(string: "x-apple.systempreferences:com.apple.preference.users") { NSWorkspace.shared.open(url) } else { print("โŒ Could not open Login Items Settings") } } private func getCurrentVersionInfo() -> String { if let infoDictionary = Bundle.main.infoDictionary { let version = infoDictionary["CFBundleShortVersionString"] as? String ?? "Unknown" return "v\(version)" } return "Unknown" } private func getCurrentShortcutDisplay() -> String { let settings = SettingsManager.shared if settings.useCustomShortcut && settings.customShortcutModifiers != 0 && settings.customShortcutKey != 0 { // Use custom shortcut return formatCurrentShortcut() } else { // Use default shortcut return "โŒ˜โ‡ง4" } } private func openDeveloperEmail() { let subject = "ShotScreen \(getCurrentVersionInfo()) - Feedback" let body = "Hi,\n\nI have a question/feedback about ShotScreen:\n\n" // URL encode the subject and body guard let encodedSubject = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let encodedBody = body.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { print("โŒ Failed to encode email parameters") return } let mailtoString = "mailto:info@shotscreen.app?subject=\(encodedSubject)&body=\(encodedBody)" if let url = URL(string: mailtoString) { NSWorkspace.shared.open(url) print("๐Ÿ“ง Opened default email client to contact developer") } else { print("โŒ Failed to create mailto URL") // Fallback: copy email to clipboard let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString("info@shotscreen.app", forType: .string) print("๐Ÿ“‹ Copied email address to clipboard as fallback") } } // ๐Ÿ”Š NEW: Test sound function private func testSound() { if let sound = NSSound(named: tempScreenshotSoundType.systemSoundName) { sound.volume = tempScreenshotSoundVolume sound.play() } else { // Fallback to Pop if selected sound doesn't exist if let fallbackSound = NSSound(named: "Pop") { fallbackSound.volume = tempScreenshotSoundVolume fallbackSound.play() } } } // ๐Ÿ—‚๏ธ NEW: Cache management functions private func updateCacheInfo() { DispatchQueue.global(qos: .background).async { let size = CacheManager.shared.getCacheSize() let count = CacheManager.shared.getCacheFileCount() DispatchQueue.main.async { self.cacheSize = size self.cacheFileCount = count } } } private func clearCache() { DispatchQueue.global(qos: .background).async { let result = CacheManager.shared.clearCache(preserveActiveThumbnails: true) DispatchQueue.main.async { print("โœ… Cache cleared: \(result.deletedFiles) files, \(String(format: "%.1f", result.savedSpace)) MB freed") // Update cache info after clearing self.updateCacheInfo() } } } private func openCacheFolder() { let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let shotScreenDirectory = appSupportDirectory.appendingPathComponent("ShotScreen") let thumbnailsDirectory = shotScreenDirectory.appendingPathComponent("Thumbnails") // Create directory if it doesn't exist try? FileManager.default.createDirectory(at: thumbnailsDirectory, withIntermediateDirectories: true, attributes: nil) // Open in Finder NSWorkspace.shared.open(thumbnailsDirectory) print("๐Ÿ“ Opened cache folder: \(thumbnailsDirectory.path)") } private func openPurchaseURL() { // ShotScreen Gumroad license product if let url = URL(string: "https://roodenrijs.gumroad.com/l/uxexr") { NSWorkspace.shared.open(url) } } } class SettingsWindow: NSWindow { private weak var screenshotDelegate: ScreenshotApp? private var visualEffectViewContainer: NSView? init(currentFolder: URL?, timerValue: Int, delegate: ScreenshotApp) { self.screenshotDelegate = delegate super.init(contentRect: NSMakeRect(0, 0, 600, 800), styleMask: [.titled, .closable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) self.title = "ShotScreen Settings" self.isReleasedWhenClosed = false self.center() // Ensure the window appears in front and can be brought to front self.level = .normal self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] self.isOpaque = false self.backgroundColor = .clear self.titlebarAppearsTransparent = true self.titleVisibility = .hidden self.isMovableByWindowBackground = true // Hide traffic lights (red, yellow, green buttons) to prevent content overlap self.standardWindowButton(.closeButton)?.isHidden = true self.standardWindowButton(.miniaturizeButton)?.isHidden = true self.standardWindowButton(.zoomButton)?.isHidden = true let settingsTabView = SettingsTabView() let hostingView = NSHostingView(rootView: settingsTabView) let visualEffectView = NSVisualEffectView() visualEffectView.blendingMode = .behindWindow visualEffectView.material = .hudWindow // krachtigste systeemblur visualEffectView.state = .active visualEffectView.alphaValue = 1.0 visualEffectView.autoresizingMask = [.width, .height] // ---------- NIEUW: extra blurlaag ---------- let extraBlurView = NSVisualEffectView() extraBlurView.blendingMode = .behindWindow // zelfde type blur extraBlurView.material = .hudWindow extraBlurView.state = .active extraBlurView.alphaValue = 0.6 // half transparant โ†’ optisch meer blur extraBlurView.autoresizingMask = [.width, .height] // ------------------------------------------- let newRootContentView = NSView(frame: self.contentRect(forFrameRect: self.frame)) // volgorde: sterkste blur onderaan, halve blur daarboven, SwiftUI-content erboven visualEffectView.frame = newRootContentView.bounds extraBlurView.frame = newRootContentView.bounds newRootContentView.addSubview(visualEffectView) // laag 0 newRootContentView.addSubview(extraBlurView) // laag 1 (versterking) newRootContentView.addSubview(hostingView) // laag 2 (UI) self.contentView = newRootContentView self.visualEffectViewContainer = newRootContentView hostingView.wantsLayer = true hostingView.layer?.backgroundColor = NSColor.clear.cgColor hostingView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ hostingView.topAnchor.constraint(equalTo: newRootContentView.topAnchor), hostingView.bottomAnchor.constraint(equalTo: newRootContentView.bottomAnchor), hostingView.leadingAnchor.constraint(equalTo: newRootContentView.leadingAnchor), hostingView.trailingAnchor.constraint(equalTo: newRootContentView.trailingAnchor) ]) // Forceer een layout pass na het opzetten van de hierarchy self.contentView?.layoutSubtreeIfNeeded() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc override func close() { super.close() } } // Helper om toegang te krijgen tot de ScreenshotApp instantie voor UI updates // Dit is een beetje een hack; idealiter zou de SettingsManager zelf de updates afhandelen // of zou er een meer formele delegate/callback structuur zijn. extension ScreenshotApp { static var sharedInstance: ScreenshotApp? { return NSApp.delegate as? ScreenshotApp } } struct VisualEffectView: NSViewRepresentable { let material: NSVisualEffectView.Material let blendingMode: NSVisualEffectView.BlendingMode func makeNSView(context: Context) -> NSVisualEffectView { let view = NSVisualEffectView() view.material = material view.blendingMode = blendingMode view.state = .active return view } func updateNSView(_ nsView: NSVisualEffectView, context: Context) { nsView.material = material nsView.blendingMode = blendingMode } } // MARK: - Shortcut Recorder Component struct ShortcutRecorder: NSViewRepresentable { @Binding var modifiers: UInt @Binding var keyCode: UInt16 let placeholder: String func makeNSView(context: Context) -> ShortcutRecorderView { let view = ShortcutRecorderView() view.placeholder = placeholder view.onShortcutChange = { modifiers, keyCode in self.modifiers = modifiers self.keyCode = keyCode } return view } func updateNSView(_ nsView: ShortcutRecorderView, context: Context) { nsView.modifiers = modifiers nsView.keyCode = keyCode nsView.placeholder = placeholder } } class ShortcutRecorderView: NSView { var placeholder: String = "Click to record shortcut" { didSet { needsDisplay = true } } var modifiers: UInt = 0 { didSet { needsDisplay = true } } var keyCode: UInt16 = 0 { didSet { needsDisplay = true } } var onShortcutChange: ((UInt, UInt16) -> Void)? private var isRecording = false private var localEventMonitor: Any? private var globalEventMonitor: Any? override init(frame frameRect: NSRect) { super.init(frame: frameRect) setup() } required init?(coder: NSCoder) { super.init(coder: coder) setup() } private func setup() { wantsLayer = true layer?.cornerRadius = 8 layer?.borderWidth = 2 layer?.borderColor = NSColor.separatorColor.cgColor layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor // Make this view focusable and always accept first responder needsDisplay = true } override var acceptsFirstResponder: Bool { return true } override var canBecomeKeyView: Bool { return true } // Force focus when clicked override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() if result { layer?.borderColor = NSColor.systemBlue.cgColor needsDisplay = true } return result } override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() layer?.borderColor = NSColor.separatorColor.cgColor needsDisplay = true return result } override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) // Draw background with recording state if isRecording { NSColor.controlAccentColor.withAlphaComponent(0.1).setFill() bounds.fill() } let text: String if modifiers == 0 && keyCode == 0 { text = isRecording ? "โŒจ๏ธ Press shortcut keys... (ESC to cancel)" : placeholder } else { text = formatShortcut(modifiers: modifiers, keyCode: keyCode) } let fontSize: CGFloat = isRecording ? 12 : 13 let attrs: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: fontSize, weight: isRecording ? .medium : .regular), .foregroundColor: isRecording ? NSColor.systemBlue : NSColor.controlTextColor ] let attributedString = NSAttributedString(string: text, attributes: attrs) let textRect = attributedString.boundingRect(with: bounds.size, options: .usesLineFragmentOrigin) let centeredRect = NSRect( x: (bounds.width - textRect.width) / 2, y: (bounds.height - textRect.height) / 2, width: textRect.width, height: textRect.height ) attributedString.draw(in: centeredRect) // Draw recording indicator if isRecording { let dotSize: CGFloat = 6 let dotRect = NSRect( x: bounds.width - dotSize - 8, y: bounds.height - dotSize - 8, width: dotSize, height: dotSize ) NSColor.systemRed.setFill() NSBezierPath(ovalIn: dotRect).fill() } } override func mouseDown(with event: NSEvent) { // Force focus first, then start recording if window?.makeFirstResponder(self) == true { // Small delay to ensure focus is properly established DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.startRecording() } } else { startRecording() } } override func keyDown(with event: NSEvent) { if isRecording { let modifierFlags = event.modifierFlags.intersection([.command, .shift, .option, .control]) let newModifiers = convertModifiers(modifierFlags) let newKeyCode = event.keyCode print("๐ŸŽน ShortcutRecorderView: keyDown - modifiers: \(newModifiers), keyCode: \(newKeyCode)") // Require at least one modifier if newModifiers != 0 { self.modifiers = newModifiers self.keyCode = newKeyCode onShortcutChange?(newModifiers, newKeyCode) stopRecording() } else if event.keyCode == 53 { // ESC key print("๐ŸŽน ShortcutRecorderView: ESC pressed, stopping recording") stopRecording() } } } private func startRecording() { guard !isRecording else { return } print("๐ŸŽน ShortcutRecorderView: Starting recording...") isRecording = true needsDisplay = true // Force the window to be key and this view to be first responder window?.makeKey() window?.makeFirstResponder(self) // Add both local and global event monitors for maximum coverage localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in guard let self = self, self.isRecording else { return event } print("๐ŸŽน Local monitor: Event type: \(event.type.rawValue), keyCode: \(event.keyCode)") if event.type == .keyDown { self.keyDown(with: event) return nil // Consume the event } return event } // Global monitor as backup to catch events even if focus is lost globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown]) { [weak self] event in guard let self = self, self.isRecording else { return } print("๐ŸŽน Global monitor: keyDown - keyCode: \(event.keyCode)") let modifierFlags = event.modifierFlags.intersection([.command, .shift, .option, .control]) let newModifiers = self.convertModifiers(modifierFlags) let newKeyCode = event.keyCode // Require at least one modifier if newModifiers != 0 { DispatchQueue.main.async { self.modifiers = newModifiers self.keyCode = newKeyCode self.onShortcutChange?(newModifiers, newKeyCode) self.stopRecording() } } else if event.keyCode == 53 { // ESC key DispatchQueue.main.async { self.stopRecording() } } } print("๐ŸŽน ShortcutRecorderView: Recording started, monitors added") } private func stopRecording() { guard isRecording else { return } print("๐ŸŽน ShortcutRecorderView: Stopping recording...") isRecording = false needsDisplay = true // Remove both event monitors if let monitor = localEventMonitor { NSEvent.removeMonitor(monitor) localEventMonitor = nil } if let monitor = globalEventMonitor { NSEvent.removeMonitor(monitor) globalEventMonitor = nil } // Don't immediately resign first responder - let user click elsewhere print("๐ŸŽน ShortcutRecorderView: Recording stopped, monitors removed") } // Add cleanup when view is removed deinit { stopRecording() print("๐ŸŽน ShortcutRecorderView: deinit") } private func convertModifiers(_ flags: NSEvent.ModifierFlags) -> UInt { var result: UInt = 0 if flags.contains(.command) { result |= 1 << 0 } if flags.contains(.shift) { result |= 1 << 1 } if flags.contains(.option) { result |= 1 << 2 } if flags.contains(.control) { result |= 1 << 3 } return result } private func formatShortcut(modifiers: UInt, keyCode: UInt16) -> String { var parts: [String] = [] if modifiers & (1 << 3) != 0 { parts.append("โŒƒ") } // Control if modifiers & (1 << 2) != 0 { parts.append("โŒฅ") } // Option if modifiers & (1 << 1) != 0 { parts.append("โ‡ง") } // Shift if modifiers & (1 << 0) != 0 { parts.append("โŒ˜") } // Command if let keyName = keyCodeToString(keyCode) { parts.append(keyName) } return parts.joined() } private func keyCodeToString(_ keyCode: UInt16) -> String? { // Map common key codes to readable strings switch keyCode { case 0: return "A" case 1: return "S" case 2: return "D" case 3: return "F" case 4: return "H" case 5: return "G" case 6: return "Z" case 7: return "X" case 8: return "C" case 9: return "V" case 11: return "B" case 12: return "Q" case 13: return "W" case 14: return "E" case 15: return "R" case 16: return "Y" case 17: return "T" case 18: return "1" case 19: return "2" case 20: return "3" case 21: return "4" case 22: return "6" case 23: return "5" case 24: return "=" case 25: return "9" case 26: return "7" case 27: return "-" case 28: return "8" case 29: return "0" case 30: return "]" case 31: return "O" case 32: return "U" case 33: return "[" case 34: return "I" case 35: return "P" case 37: return "L" case 38: return "J" case 39: return "'" case 40: return "K" case 41: return ";" case 42: return "\\" case 43: return "," case 44: return "/" case 45: return "N" case 46: return "M" case 47: return "." case 50: return "`" case 36: return "โ†ฉ" // Return case 48: return "โ‡ฅ" // Tab case 49: return "Space" case 51: return "โŒซ" // Delete case 53: return "โŽ‹" // Escape case 122: return "F1" case 120: return "F2" case 99: return "F3" case 118: return "F4" case 96: return "F5" case 97: return "F6" case 98: return "F7" case 100: return "F8" case 101: return "F9" case 109: return "F10" case 103: return "F11" case 111: return "F12" default: return "\(keyCode)" } } } // MARK: - Login Instructions Popup View struct LoginInstructionsPopupView: View { @Environment(\.presentationMode) var presentationMode let openSettingsAction: () -> Void var body: some View { VStack(spacing: 20) { // Header HStack { Text("How to Add ShotScreen to Login Items") .font(.title2) .fontWeight(.bold) Spacer() Button("Close") { presentationMode.wrappedValue.dismiss() } .buttonStyle(.bordered) .controlSize(.small) } .padding(.top, 20) .padding(.horizontal, 20) // Instructions text (no image needed for login items) VStack(alignment: .leading, spacing: 12) { Text("Follow these steps:") .font(.headline) .fontWeight(.medium) .padding(.bottom, 4) VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 8) { Text("1.") .font(.body) .fontWeight(.medium) .foregroundColor(.blue) .frame(width: 20) Text("Click 'Open Settings' below to open System Settings") .font(.body) } HStack(alignment: .top, spacing: 8) { Text("2.") .font(.body) .fontWeight(.medium) .foregroundColor(.blue) .frame(width: 20) Text("Navigate to General โ†’ Login Items") .font(.body) } HStack(alignment: .top, spacing: 8) { Text("3.") .font(.body) .fontWeight(.medium) .foregroundColor(.blue) .frame(width: 20) Text("Click the + button to add a new login item") .font(.body) } HStack(alignment: .top, spacing: 8) { Text("4.") .font(.body) .fontWeight(.medium) .foregroundColor(.blue) .frame(width: 20) Text("Find and select ShotScreen from Applications folder") .font(.body) } HStack(alignment: .top, spacing: 8) { Text("5.") .font(.body) .fontWeight(.medium) .foregroundColor(.blue) .frame(width: 20) Text("Click 'Add' to enable automatic startup") .font(.body) } } .padding(.leading, 8) } .padding(.horizontal, 24) .frame(maxWidth: .infinity, alignment: .leading) Spacer() // Bottom buttons HStack(spacing: 12) { Button("Open Settings") { print("๐Ÿ”ง Opening Login Items Settings from popup...") openSettingsAction() // Don't dismiss popup - let user read instructions while configuring } .buttonStyle(.borderedProminent) Button("Got it!") { presentationMode.wrappedValue.dismiss() } .buttonStyle(.bordered) } .padding(.bottom, 20) } .frame(width: 500, height: 400) .background(Color(NSColor.windowBackgroundColor)) } } // MARK: - License Section struct LicenseSection: View { @ObservedObject private var licenseManager = LicenseManager.shared @State private var showLicenseWindow = false var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { HStack { Text("License") .font(.title2) .fontWeight(.semibold) .foregroundColor(.primary) Spacer() Button("Manage License") { showLicenseDialog() } .buttonStyle(.bordered) } Divider() } VStack(alignment: .leading, spacing: 12) { // License Status Display switch licenseManager.licenseStatus { case .checking: HStack { ProgressView() .scaleEffect(0.8) Text("Checking license status...") .foregroundColor(.secondary) } case .trial(let daysLeft): VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "clock.fill") .foregroundColor(.orange) Text("Free Trial Active") .font(.headline) .foregroundColor(.orange) } Text("\(daysLeft) days remaining") .font(.subheadline) .foregroundColor(.secondary) Text("Enjoy your free trial! Purchase a license before it expires to continue using ShotScreen.") .font(.caption) .foregroundColor(.secondary) HStack(spacing: 12) { Button("Purchase License") { openPurchaseURL() } .buttonStyle(.borderedProminent) Button("Enter License Key") { showLicenseDialog() } .buttonStyle(.bordered) } } case .licensed(let userName, let email): VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) Text("Licensed") .font(.headline) .foregroundColor(.green) } VStack(alignment: .leading, spacing: 4) { Text("Licensed to: \(userName)") .font(.subheadline) .foregroundColor(.primary) if !email.isEmpty { Text("Email: \(email)") .font(.caption) .foregroundColor(.secondary) } } HStack { Button("Change License") { showLicenseDialog() } .buttonStyle(.bordered) .controlSize(.small) } } case .testLicense(let userName, let email): VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "flask.fill") .foregroundColor(.blue) Text("Test License") .font(.headline) .foregroundColor(.blue) } VStack(alignment: .leading, spacing: 4) { Text("Test license for: \(userName)") .font(.subheadline) .foregroundColor(.primary) if !email.isEmpty { Text("Email: \(email)") .font(.caption) .foregroundColor(.secondary) } Text("๐Ÿงช This is a development/test license") .font(.caption) .foregroundColor(.blue) .fontWeight(.medium) } HStack { Button("Change License") { showLicenseDialog() } .buttonStyle(.bordered) .controlSize(.small) } } case .expired: VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.red) Text("Trial Expired") .font(.headline) .foregroundColor(.red) } Text("Your trial has expired. Please purchase a license to continue using ShotScreen.") .font(.subheadline) .foregroundColor(.secondary) HStack(spacing: 12) { Button("Purchase License") { openPurchaseURL() } .buttonStyle(.borderedProminent) Button("Enter License Key") { showLicenseDialog() } .buttonStyle(.bordered) } } case .invalid: VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "xmark.circle.fill") .foregroundColor(.red) Text("Invalid License") .font(.headline) .foregroundColor(.red) } Text("Your license is invalid or has been revoked. Please contact support or purchase a new license.") .font(.subheadline) .foregroundColor(.secondary) HStack(spacing: 12) { Button("Purchase License") { openPurchaseURL() } .buttonStyle(.borderedProminent) Button("Enter License Key") { showLicenseDialog() } .buttonStyle(.bordered) } } } } } .padding(20) .background(Color.primary.opacity(0.03)) .cornerRadius(12) .sheet(isPresented: $showLicenseWindow) { LicenseEntryView() } .onReceive(licenseManager.$showLicenseEntry) { shouldShow in showLicenseWindow = shouldShow } } private func showLicenseDialog() { LicenseManager.shared.showLicenseEntryDialog() } private func openPurchaseURL() { // ShotScreen Gumroad license product if let url = URL(string: "https://roodenrijs.gumroad.com/l/uxexr") { NSWorkspace.shared.open(url) } } }