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

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

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

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

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

2868 lines
124 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
"<script", "javascript:", "eval(", "exec(",
"import os", "import sys", "subprocess"
]
for pattern in suspiciousPatterns {
if sampleText.lowercased().contains(pattern.lowercased()) {
print("❌ SECURITY: Suspicious script pattern found: \(pattern)")
return false
}
}
}
// Check for excessive binary entropy (possible encrypted/obfuscated content)
let entropy = calculateEntropy(data: sampleData)
if entropy > 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)
}
}
}