🚀 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.
2868 lines
124 KiB
Swift
2868 lines
124 KiB
Swift
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)
|
||
}
|
||
}
|
||
} |