commit 0dabed11d288bc22e8b34215104650675af5efd1 Author: Nick Roodenrijs Date: Sat Jun 28 16:15:15 2025 +0200 ๐ŸŽ‰ ShotScreen v1.0 - Initial Release ๐Ÿš€ First official release of ShotScreen with complete feature set: โœจ Core Features: - Advanced screenshot capture system - Multi-monitor support - Professional UI/UX design - Automated update system with Sparkle - Apple notarized & code signed ๐Ÿ›  Technical Excellence: - Native Swift macOS application - Professional build & deployment pipeline - Comprehensive error handling - Memory optimized performance ๐Ÿ“ฆ Distribution Ready: - Professional DMG packaging - Apple notarization complete - No security warnings for users - Ready for public distribution This is the foundation release that establishes ShotScreen as a premium screenshot tool for macOS. diff --git a/AppIcon.icns b/AppIcon.icns new file mode 100644 index 0000000..de21e75 Binary files /dev/null and b/AppIcon.icns differ diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..fd861e5 --- /dev/null +++ b/Info.plist @@ -0,0 +1,67 @@ + + + + + CFBundleDisplayName + ShotScreen + CFBundleExecutable + ShotScreen + CFBundleGetInfoString + ShotScreen - Professional Screenshot Tool + CFBundleIconFile + AppIcon + CFBundleIdentifier + com.shotscreen.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ShotScreen + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0 + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionMinimumTLSVersion + TLSv1.3 + NSRequiresCertificateTransparency + + + NSApplicationDescription + Professional screenshot and screen capture utility for macOS + NSCameraUsageDescription + ShotScreen needs camera access to capture screenshots. + NSDesktopFolderUsageDescription + ShotScreen needs access to save screenshots to your Desktop. + NSDocumentsFolderUsageDescription + ShotScreen needs access to save screenshots to your Documents folder. + NSHighResolutionCapable + + NSHumanReadableCopyright + ยฉ 2025 ShotScreen. All rights reserved. + NSScreenCaptureUsageDescription + ShotScreen is a professional screenshot tool that captures images of your screen and windows to help you save and organize screenshots efficiently. + NSSupportsAutomaticGraphicsSwitching + + SUAutomaticallyUpdate + + SUCheckAtStartup + + SUFeedURL + https://git.plet.i234.me/Nick/shotscreen/raw/branch/main/appcast.xml + SUPublicEDKey + q0Ia/obtuDugqhwa1aSZsQAqZxQsdgX3y4K9wuqkemM= + SUScheduledCheckInterval + 86400 + + diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..6a79eeb --- /dev/null +++ b/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "hotkey", + "kind" : "remoteSourceControl", + "location" : "https://github.com/soffes/HotKey", + "state" : { + "revision" : "a3cf605d7a96f6ff50e04fcb6dea6e2613cfcbe4", + "version" : "0.2.1" + } + }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "df074165274afaa39539c05d57b0832620775b11", + "version" : "2.7.1" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100755 index 0000000..03b7be3 --- /dev/null +++ b/Package.swift @@ -0,0 +1,59 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "ShotScreen", + platforms: [ + .macOS(.v13) + ], + dependencies: [ + .package(url: "https://github.com/soffes/HotKey", from: "0.1.4"), + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0") + ], + targets: [ + .executableTarget( + name: "ShotScreen", + dependencies: [ + .product(name: "HotKey", package: "HotKey"), + .product(name: "Sparkle", package: "Sparkle") + ], + path: "ShotScreen/Sources", + sources: [ + "BackgroundRemover.swift", + "ButtonHoverExtension.swift", + "Config.swift", + "CrosshairViews.swift", + "DesktopIconManager.swift", + "DraggableImageView.swift", + "EventCapture.swift", + "FeedbackBubblePanel.swift", + "FinderWindowManager.swift", + "FirstLaunchWizard.swift", + "GridActionManager.swift", + "GridCellView.swift", + "GridComponents.swift", + "IntegratedGalleryView.swift", + "LicenseEntryView.swift", + "LicenseManager.swift", + "MenuManager.swift", + "MultiMonitorSystem.swift", + "OverlayComponents.swift", + "PreviewManager.swift", + "RenameActionHandler.swift", + "ScreenCaptureKitProvider.swift", + "SettingsManager.swift", + "SettingsModels.swift", + "SettingsUI.swift", + "StashDraggableImageView.swift", + "SwiftUIViews.swift", + "ThemeManager.swift", + "UpdateManager.swift", + "WindowCaptureManager.swift", + "main.swift" + ], + resources: [ + .process("images") + ] + ) + ] +) \ No newline at end of file diff --git a/Pixelmator images/BalloonS.pxd b/Pixelmator images/BalloonS.pxd new file mode 100644 index 0000000..2f0e744 Binary files /dev/null and b/Pixelmator images/BalloonS.pxd differ diff --git a/Pixelmator images/Banner.pxd b/Pixelmator images/Banner.pxd new file mode 100644 index 0000000..36b976d Binary files /dev/null and b/Pixelmator images/Banner.pxd differ diff --git a/Pixelmator images/Logo.pxd b/Pixelmator images/Logo.pxd new file mode 100644 index 0000000..e012f45 Binary files /dev/null and b/Pixelmator images/Logo.pxd differ diff --git a/Pixelmator images/Logo_200x200px.pxd b/Pixelmator images/Logo_200x200px.pxd new file mode 100644 index 0000000..1914948 Binary files /dev/null and b/Pixelmator images/Logo_200x200px.pxd differ diff --git a/Pixelmator images/MenuBarIcon.pxd b/Pixelmator images/MenuBarIcon.pxd new file mode 100644 index 0000000..c3b3d18 Binary files /dev/null and b/Pixelmator images/MenuBarIcon.pxd differ diff --git a/Pixelmator images/Thumbnail_Met_Achtergrond.pxd b/Pixelmator images/Thumbnail_Met_Achtergrond.pxd new file mode 100644 index 0000000..817a3e7 Binary files /dev/null and b/Pixelmator images/Thumbnail_Met_Achtergrond.pxd differ diff --git a/ShotScreen/.DS_Store b/ShotScreen/.DS_Store new file mode 100644 index 0000000..b4db831 Binary files /dev/null and b/ShotScreen/.DS_Store differ diff --git a/ShotScreen/.build/build.db b/ShotScreen/.build/build.db new file mode 100755 index 0000000..bd394fb Binary files /dev/null and b/ShotScreen/.build/build.db differ diff --git a/ShotScreen/.build/debug b/ShotScreen/.build/debug new file mode 100755 index 0000000..ef80fcc --- /dev/null +++ b/ShotScreen/.build/debug @@ -0,0 +1,5 @@ +XSym +0024 +65d970057a31dc065e5b25921548a548 +arm64-apple-macosx/debug + \ No newline at end of file diff --git a/ShotScreen/.build/debug.yaml b/ShotScreen/.build/debug.yaml new file mode 100755 index 0000000..dc4bd49 --- /dev/null +++ b/ShotScreen/.build/debug.yaml @@ -0,0 +1,100 @@ +client: + name: basic + file-system: device-agnostic +tools: {} +targets: + "HotKey-arm64-apple-macosx15.0-debug.module": [""] + "PackageStructure": [""] + "ScreenShot-arm64-apple-macosx15.0-debug.exe": [""] + "ScreenShot-arm64-apple-macosx15.0-debug.module": [""] + "main": ["",""] + "test": ["",""] +default: "main" +nodes: + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/": + is-directory-structure: true + content-exclusion-patterns: [".git",".build"] + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot": + is-mutated: true +commands: + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/sources": + tool: write-auxiliary-file + inputs: ["","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/HotKey.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/HotKeysController.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/Key.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/KeyCombo+System.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/KeyCombo.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/NSEventModifierFlags+HotKey.swift"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/sources"] + description: "Write auxiliary file /Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/sources" + + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot-entitlement.plist": + tool: write-auxiliary-file + inputs: ["",""] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot-entitlement.plist"] + description: "Write auxiliary file /Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot-entitlement.plist" + + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/sources": + tool: write-auxiliary-file + inputs: ["","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/GridViewManager.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/RenameActionHandler.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/Settings.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/main.swift"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/sources"] + description: "Write auxiliary file /Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/sources" + + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.product/Objects.LinkFileList": + tool: write-auxiliary-file + inputs: ["","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKeysController.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/Key.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo+System.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/NSEventModifierFlags+HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/GridViewManager.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/RenameActionHandler.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/Settings.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/main.swift.o"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.product/Objects.LinkFileList"] + description: "Write auxiliary file /Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.product/Objects.LinkFileList" + + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/swift-version--2F68581710C0F3BB.txt": + tool: write-auxiliary-file + inputs: ["","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/swift-version--2F68581710C0F3BB.txt"] + always-out-of-date: "true" + description: "Write auxiliary file /Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/swift-version--2F68581710C0F3BB.txt" + + "": + tool: phony + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKeysController.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/Key.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo+System.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/NSEventModifierFlags+HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/HotKey.swiftmodule"] + outputs: [""] + + "": + tool: phony + inputs: [""] + outputs: [""] + + "": + tool: phony + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/GridViewManager.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/RenameActionHandler.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/Settings.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/main.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/ScreenShot.swiftmodule"] + outputs: [""] + + "C.HotKey-arm64-apple-macosx15.0-debug.module": + tool: shell + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/HotKey.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/HotKeysController.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/Key.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/KeyCombo+System.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/KeyCombo.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/NSEventModifierFlags+HotKey.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/swift-version--2F68581710C0F3BB.txt","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/sources"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKeysController.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/Key.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo+System.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/NSEventModifierFlags+HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/HotKey.swiftmodule"] + description: "Compiling Swift Module 'HotKey' (6 sources)" + args: ["/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc","-module-name","HotKey","-emit-dependencies","-emit-module","-emit-module-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/HotKey.swiftmodule","-output-file-map","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/output-file-map.json","-parse-as-library","-incremental","-c","@/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/sources","-I","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules","-target","arm64-apple-macosx10.13","-enable-batch-mode","-index-store-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/index/store","-Onone","-enable-testing","-j10","-DSWIFT_PACKAGE","-DDEBUG","-module-cache-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ModuleCache","-parseable-output","-parse-as-library","-emit-objc-header","-emit-objc-header-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKey-Swift.h","-color-diagnostics","-swift-version","5","-sdk","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.4.sdk","-F","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks","-F","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/PrivateFrameworks","-I","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib","-L","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib","-g","-Xcc","-isysroot","-Xcc","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.4.sdk","-Xcc","-F","-Xcc","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks","-Xcc","-F","-Xcc","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/PrivateFrameworks","-Xcc","-fPIC","-Xcc","-g","-suppress-warnings"] + + "C.ScreenShot-arm64-apple-macosx15.0-debug.exe": + tool: shell + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKeysController.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/Key.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo+System.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/NSEventModifierFlags+HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/GridViewManager.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/RenameActionHandler.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/Settings.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/main.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.product/Objects.LinkFileList"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot"] + description: "Linking ./.build/arm64-apple-macosx/debug/ScreenShot" + args: ["/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc","-L","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug","-o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot","-module-name","ScreenShot","-Xlinker","-no_warn_duplicate_libraries","-emit-executable","-Xlinker","-alias","-Xlinker","_ScreenShot_main","-Xlinker","_main","-Xlinker","-rpath","-Xlinker","@loader_path","@/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.product/Objects.LinkFileList","-target","arm64-apple-macosx13.0","-Xlinker","-add_ast_path","-Xlinker","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/HotKey.swiftmodule","-Xlinker","-add_ast_path","-Xlinker","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/ScreenShot.swiftmodule","-sdk","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.4.sdk","-F","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks","-F","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/PrivateFrameworks","-I","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib","-L","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib","-g"] + + "C.ScreenShot-arm64-apple-macosx15.0-debug.exe-entitlements": + tool: shell + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot-entitlement.plist"] + outputs: [""] + description: "Applying debug entitlements to ./.build/arm64-apple-macosx/debug/ScreenShot" + args: ["codesign","--force","--sign","-","--entitlements","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot-entitlement.plist","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot"] + + "C.ScreenShot-arm64-apple-macosx15.0-debug.module": + tool: shell + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/GridViewManager.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/RenameActionHandler.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/Settings.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/main.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/swift-version--2F68581710C0F3BB.txt","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/HotKey.swiftmodule","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/sources"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/GridViewManager.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/RenameActionHandler.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/Settings.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/main.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/ScreenShot.swiftmodule"] + description: "Compiling Swift Module 'ScreenShot' (4 sources)" + args: ["/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc","-module-name","ScreenShot","-emit-dependencies","-emit-module","-emit-module-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/ScreenShot.swiftmodule","-output-file-map","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/output-file-map.json","-incremental","-c","@/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/sources","-I","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules","-target","arm64-apple-macosx13.0","-enable-batch-mode","-index-store-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/index/store","-Onone","-enable-testing","-j10","-DSWIFT_PACKAGE","-DDEBUG","-module-cache-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ModuleCache","-parseable-output","-Xfrontend","-entry-point-function-name","-Xfrontend","ScreenShot_main","-color-diagnostics","-swift-version","5","-sdk","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.4.sdk","-F","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks","-F","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/PrivateFrameworks","-I","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib","-L","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib","-g","-Xcc","-isysroot","-Xcc","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.4.sdk","-Xcc","-F","-Xcc","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks","-Xcc","-F","-Xcc","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/PrivateFrameworks","-Xcc","-fPIC","-Xcc","-g","-package-name","screenshot"] + + "PackageStructure": + tool: package-structure-tool + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Package.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Package.resolved"] + outputs: [""] + description: "Planning build" + allow-missing-inputs: true + diff --git a/ShotScreen/.build/plugin-tools.yaml b/ShotScreen/.build/plugin-tools.yaml new file mode 100755 index 0000000..dc4bd49 --- /dev/null +++ b/ShotScreen/.build/plugin-tools.yaml @@ -0,0 +1,100 @@ +client: + name: basic + file-system: device-agnostic +tools: {} +targets: + "HotKey-arm64-apple-macosx15.0-debug.module": [""] + "PackageStructure": [""] + "ScreenShot-arm64-apple-macosx15.0-debug.exe": [""] + "ScreenShot-arm64-apple-macosx15.0-debug.module": [""] + "main": ["",""] + "test": ["",""] +default: "main" +nodes: + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/": + is-directory-structure: true + content-exclusion-patterns: [".git",".build"] + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot": + is-mutated: true +commands: + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/sources": + tool: write-auxiliary-file + inputs: ["","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/HotKey.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/HotKeysController.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/Key.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/KeyCombo+System.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/KeyCombo.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/NSEventModifierFlags+HotKey.swift"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/sources"] + description: "Write auxiliary file /Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/sources" + + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot-entitlement.plist": + tool: write-auxiliary-file + inputs: ["",""] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot-entitlement.plist"] + description: "Write auxiliary file /Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot-entitlement.plist" + + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/sources": + tool: write-auxiliary-file + inputs: ["","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/GridViewManager.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/RenameActionHandler.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/Settings.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/main.swift"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/sources"] + description: "Write auxiliary file /Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/sources" + + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.product/Objects.LinkFileList": + tool: write-auxiliary-file + inputs: ["","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKeysController.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/Key.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo+System.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/NSEventModifierFlags+HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/GridViewManager.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/RenameActionHandler.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/Settings.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/main.swift.o"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.product/Objects.LinkFileList"] + description: "Write auxiliary file /Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.product/Objects.LinkFileList" + + "/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/swift-version--2F68581710C0F3BB.txt": + tool: write-auxiliary-file + inputs: ["","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/swift-version--2F68581710C0F3BB.txt"] + always-out-of-date: "true" + description: "Write auxiliary file /Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/swift-version--2F68581710C0F3BB.txt" + + "": + tool: phony + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKeysController.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/Key.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo+System.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/NSEventModifierFlags+HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/HotKey.swiftmodule"] + outputs: [""] + + "": + tool: phony + inputs: [""] + outputs: [""] + + "": + tool: phony + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/GridViewManager.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/RenameActionHandler.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/Settings.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/main.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/ScreenShot.swiftmodule"] + outputs: [""] + + "C.HotKey-arm64-apple-macosx15.0-debug.module": + tool: shell + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/HotKey.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/HotKeysController.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/Key.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/KeyCombo+System.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/KeyCombo.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/checkouts/HotKey/Sources/HotKey/NSEventModifierFlags+HotKey.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/swift-version--2F68581710C0F3BB.txt","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/sources"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKeysController.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/Key.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo+System.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/NSEventModifierFlags+HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/HotKey.swiftmodule"] + description: "Compiling Swift Module 'HotKey' (6 sources)" + args: ["/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc","-module-name","HotKey","-emit-dependencies","-emit-module","-emit-module-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/HotKey.swiftmodule","-output-file-map","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/output-file-map.json","-parse-as-library","-incremental","-c","@/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/sources","-I","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules","-target","arm64-apple-macosx10.13","-enable-batch-mode","-index-store-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/index/store","-Onone","-enable-testing","-j10","-DSWIFT_PACKAGE","-DDEBUG","-module-cache-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ModuleCache","-parseable-output","-parse-as-library","-emit-objc-header","-emit-objc-header-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKey-Swift.h","-color-diagnostics","-swift-version","5","-sdk","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.4.sdk","-F","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks","-F","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/PrivateFrameworks","-I","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib","-L","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib","-g","-Xcc","-isysroot","-Xcc","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.4.sdk","-Xcc","-F","-Xcc","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks","-Xcc","-F","-Xcc","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/PrivateFrameworks","-Xcc","-fPIC","-Xcc","-g","-suppress-warnings"] + + "C.ScreenShot-arm64-apple-macosx15.0-debug.exe": + tool: shell + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/HotKeysController.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/Key.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo+System.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/KeyCombo.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/HotKey.build/NSEventModifierFlags+HotKey.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/GridViewManager.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/RenameActionHandler.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/Settings.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/main.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.product/Objects.LinkFileList"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot"] + description: "Linking ./.build/arm64-apple-macosx/debug/ScreenShot" + args: ["/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc","-L","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug","-o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot","-module-name","ScreenShot","-Xlinker","-no_warn_duplicate_libraries","-emit-executable","-Xlinker","-alias","-Xlinker","_ScreenShot_main","-Xlinker","_main","-Xlinker","-rpath","-Xlinker","@loader_path","@/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.product/Objects.LinkFileList","-target","arm64-apple-macosx13.0","-Xlinker","-add_ast_path","-Xlinker","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/HotKey.swiftmodule","-Xlinker","-add_ast_path","-Xlinker","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/ScreenShot.swiftmodule","-sdk","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.4.sdk","-F","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks","-F","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/PrivateFrameworks","-I","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib","-L","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib","-g"] + + "C.ScreenShot-arm64-apple-macosx15.0-debug.exe-entitlements": + tool: shell + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot-entitlement.plist"] + outputs: [""] + description: "Applying debug entitlements to ./.build/arm64-apple-macosx/debug/ScreenShot" + args: ["codesign","--force","--sign","-","--entitlements","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot-entitlement.plist","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot"] + + "C.ScreenShot-arm64-apple-macosx15.0-debug.module": + tool: shell + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/GridViewManager.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/RenameActionHandler.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/Settings.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/main.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/swift-version--2F68581710C0F3BB.txt","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/HotKey.swiftmodule","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/sources"] + outputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/GridViewManager.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/RenameActionHandler.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/Settings.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/main.swift.o","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/ScreenShot.swiftmodule"] + description: "Compiling Swift Module 'ScreenShot' (4 sources)" + args: ["/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc","-module-name","ScreenShot","-emit-dependencies","-emit-module","-emit-module-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules/ScreenShot.swiftmodule","-output-file-map","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/output-file-map.json","-incremental","-c","@/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ScreenShot.build/sources","-I","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/Modules","-target","arm64-apple-macosx13.0","-enable-batch-mode","-index-store-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/index/store","-Onone","-enable-testing","-j10","-DSWIFT_PACKAGE","-DDEBUG","-module-cache-path","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/.build/arm64-apple-macosx/debug/ModuleCache","-parseable-output","-Xfrontend","-entry-point-function-name","-Xfrontend","ScreenShot_main","-color-diagnostics","-swift-version","5","-sdk","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.4.sdk","-F","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks","-F","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/PrivateFrameworks","-I","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib","-L","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/usr/lib","-g","-Xcc","-isysroot","-Xcc","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.4.sdk","-Xcc","-F","-Xcc","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Frameworks","-Xcc","-F","-Xcc","/Volumes/External HD/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/PrivateFrameworks","-Xcc","-fPIC","-Xcc","-g","-package-name","screenshot"] + + "PackageStructure": + tool: package-structure-tool + inputs: ["/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Sources/","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Package.swift","/Volumes/homes/Nick Roodenrijs/ScreenShot 7/ScreenShot/Package.resolved"] + outputs: [""] + description: "Planning build" + allow-missing-inputs: true + diff --git a/ShotScreen/Sources/BackgroundRemover.swift b/ShotScreen/Sources/BackgroundRemover.swift new file mode 100644 index 0000000..03e1602 --- /dev/null +++ b/ShotScreen/Sources/BackgroundRemover.swift @@ -0,0 +1,1615 @@ +import Foundation +import AppKit +import Vision +import CoreImage +import CoreImage.CIFilterBuiltins +import CoreML +import SwiftUI +import Darwin // For utsname system info + +// MARK: - Background Removal Manager +class BackgroundRemover { + static let shared = BackgroundRemover() + + enum ProcessingMethod { + case visionFramework + case coreMLRMBG14 + } + + private init() {} + + // MARK: - Public Interface + func removeBackground(from image: NSImage, method: ProcessingMethod, completion: @escaping (NSImage?) -> Void) { + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("โŒ BackgroundRemover: Could not convert NSImage to CGImage") + completion(nil) + return + } + + DispatchQueue.global(qos: .userInitiated).async { + switch method { + case .visionFramework: + self.processWithVisionFramework(cgImage: cgImage, completion: completion) + case .coreMLRMBG14: + self.processWithCoreMLModelWithFallback(cgImage: cgImage, completion: completion) + } + } + } + + // ๐ŸŽฏ NEW: Process with user's preferred method from settings (with smart fallback) + func processWithPreferredMethod(from image: NSImage, completion: @escaping (NSImage?) -> Void) { + let preferredMethod = SettingsManager.shared.preferredBackgroundRemovalMethod + + switch preferredMethod { + case .auto: + // Auto mode: try RMBG first, fallback to Vision + processWithCoreMLModelWithFallback(from: image, completion: completion) + case .rmbg: + // RMBG only (no fallback) + processWithCoreMLRMBGOnly(from: image, completion: completion) + case .vision: + // Vision Framework only + processWithVisionFramework(from: image, completion: completion) + } + } + + // ๐ŸŽฏ NEW: Process with CoreML model and fallback to Vision + private func processWithCoreMLModelWithFallback(from image: NSImage, completion: @escaping (NSImage?) -> Void) { + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("โŒ BackgroundRemover: Could not convert NSImage to CGImage") + completion(nil) + return + } + + DispatchQueue.global(qos: .userInitiated).async { + self.processWithCoreMLModelWithFallback(cgImage: cgImage, completion: completion) + } + } + + // ๐ŸŽฏ NEW: Process with Vision Framework only + private func processWithVisionFramework(from image: NSImage, completion: @escaping (NSImage?) -> Void) { + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("โŒ BackgroundRemover: Could not convert NSImage to CGImage") + completion(nil) + return + } + + DispatchQueue.global(qos: .userInitiated).async { + self.processWithVisionFramework(cgImage: cgImage, completion: completion) + } + } + + // ๐ŸŽฏ NEW: Process with RMBG only (no fallback) + private func processWithCoreMLRMBGOnly(from image: NSImage, completion: @escaping (NSImage?) -> Void) { + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("โŒ BackgroundRemover: Could not convert NSImage to CGImage") + completion(nil) + return + } + + DispatchQueue.global(qos: .userInitiated).async { + self.processWithCoreMLRMBGOnly(cgImage: cgImage, completion: completion) + } + } + + // MARK: - Smart E5RT Issue Detection + private func shouldSkipRMBGDueToIssues() -> Bool { + // Check if we have persistent E5RT cache issues that suggest we should avoid RMBG + let issueThreshold = 3 // Skip after 3 attempts with issues + let recentFailures = UserDefaults.standard.integer(forKey: "ShotScreen_E5RT_FailureCount") + + if recentFailures >= issueThreshold { + let lastSkip = UserDefaults.standard.double(forKey: "ShotScreen_E5RT_LastSkip") + let timeSinceSkip = Date().timeIntervalSince1970 - lastSkip + + // Reset after 1 hour, in case the issue was temporary + if timeSinceSkip > 3600 { + UserDefaults.standard.set(0, forKey: "ShotScreen_E5RT_FailureCount") + UserDefaults.standard.removeObject(forKey: "ShotScreen_E5RT_LastSkip") + return false + } + + return true + } + + return false + } + + private func recordE5RTIssue() { + let currentCount = UserDefaults.standard.integer(forKey: "ShotScreen_E5RT_FailureCount") + UserDefaults.standard.set(currentCount + 1, forKey: "ShotScreen_E5RT_FailureCount") + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "ShotScreen_E5RT_LastSkip") + print("๐Ÿ”ง E5RT Issue recorded: Total count = \(currentCount + 1)") + + // If we're getting too many E5RT issues, disable Neural Engine more aggressively + if currentCount >= 3 { + print("๐Ÿ”ง TOO MANY E5RT ISSUES: Disabling Neural Engine for 5 minutes") + UserDefaults.standard.set(true, forKey: "ShotScreen_ForceCPU_TempMode") + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "ShotScreen_ForceCPU_StartTime_Extended") + } + } + + // ๐ŸŽฏ NEW: RMBG-only processing (no fallback) + private func processWithCoreMLRMBGOnly(cgImage: CGImage, completion: @escaping (NSImage?) -> Void) { + if tryLoadAndProcessModel(cgImage: cgImage, modelName: "bria-rmbg-coreml", displayName: "RMBG-1.4", completion: completion) { + // RMBG-1.4 processing initiated successfully + return + } + + // Failed to load RMBG model + print("โŒ RMBG-1.4 failed and no fallback allowed by user setting") + DispatchQueue.main.async { + completion(nil) + } + } + + + + // MARK: - Model Availability + private func getModelPath() -> String { + let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let appDirectory = appSupportURL.appendingPathComponent("ShotScreen") + return appDirectory.appendingPathComponent("bria-rmbg-coreml.mlpackage").path + } + + func isRMBGModelAvailable() -> Bool { + return FileManager.default.fileExists(atPath: getModelPath()) + } + + // MARK: - Public Cache Management + func clearCoreMLCachePublic() { + print("๐Ÿงน PUBLIC: User requested Core ML cache clearing...") + + // Do this on background thread to avoid blocking UI + DispatchQueue.global(qos: .utility).async { + self.clearCoreMLCache() + + // Reset failure count since user manually cleared cache + UserDefaults.standard.set(0, forKey: "ShotScreen_E5RT_FailureCount") + UserDefaults.standard.removeObject(forKey: "ShotScreen_E5RT_LastSkip") + + DispatchQueue.main.async { + print("โœ… Cache clearing completed successfully!") + } + } + } + + + + // MARK: - Cache Management (ULTRA AGGRESSIVE E5RT fix) + private func clearCoreMLCache() { + print("๐Ÿงน ULTRA AGGRESSIVE: Nuking ALL Core ML E5RT caches...") + + // STRATEGY 1: Direct file removal (fastest) + ultraFastCacheClear() + + // STRATEGY 2: Force CPU-only mode temporarily + temporarilyForceCPUMode() + + // STRATEGY 3: Model precompilation with fresh cache + forceModelRecompilation() + + print("๐Ÿงน ULTRA AGGRESSIVE: E5RT cache nuking completed") + } + + private func ultraFastCacheClear() { + // Use rm -rf for maximum speed (faster than FileManager) + let cachePaths = [ + "~/Library/Caches/com.apple.e5rt.e5bundlecache", + "~/Library/Caches/com.apple.CoreML", + "~/Library/Caches/com.apple.mlcompute", + "~/Library/Caches/ShotScreen" + ] + + for path in cachePaths { + let expandedPath = NSString(string: path).expandingTildeInPath + let command = "rm -rf '\(expandedPath)' 2>/dev/null" + + let process = Process() + process.launchPath = "/bin/sh" + process.arguments = ["-c", command] + + do { + try process.run() + process.waitUntilExit() + print("โœ… NUKED: \(path)") + } catch { + print("โš ๏ธ Could not nuke: \(path)") + } + } + } + + private func temporarilyForceCPUMode() { + // Set flag to force CPU mode for next few loads + UserDefaults.standard.set(true, forKey: "ShotScreen_ForceCPU_TempMode") + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "ShotScreen_ForceCPU_StartTime") + print("๐Ÿ”ง TEMPORARY: Forcing CPU-only mode for next 60 seconds") + } + + private func forceModelRecompilation() { + // Delete any existing compiled model to force fresh compilation + let modelPath = getModelPath() + let modelURL = URL(fileURLWithPath: modelPath) + + // Remove compiled version if it exists + do { + let compiledURL = try MLModel.compileModel(at: modelURL) + if FileManager.default.fileExists(atPath: compiledURL.path) { + try FileManager.default.removeItem(at: compiledURL) + print("โœ… FORCED: Model recompilation") + } + } catch { + // Ignore errors - we just want to force recompilation + } + } + + private func clearWildcardCachePath(_ pattern: String) { + // Handle /var/folders/*/com.apple.e5rt.e5bundlecache patterns + let components = pattern.components(separatedBy: "*") + guard components.count == 2 else { return } + + let prefix = components[0] + let suffix = components[1] + + do { + let prefixURL = URL(fileURLWithPath: prefix) + let contents = try FileManager.default.contentsOfDirectory( + at: prefixURL, + includingPropertiesForKeys: nil + ) + + for item in contents { + let targetPath = item.appendingPathComponent(String(suffix.dropFirst())) + if FileManager.default.fileExists(atPath: targetPath.path) { + try FileManager.default.removeItem(at: targetPath) + print("โœ… Cleared system cache: \(targetPath.path)") + } + } + } catch { + print("โš ๏ธ Could not clear wildcard cache \(pattern): \(error.localizedDescription)") + } + } + + private func clearCompiledModelCache() { + // Try to find and clear any compiled versions of our model + let modelPath = getModelPath() + let modelURL = URL(fileURLWithPath: modelPath) + + do { + // Force recompilation by removing any existing compiled versions + let compiledURL = try MLModel.compileModel(at: modelURL) + if FileManager.default.fileExists(atPath: compiledURL.path) { + try FileManager.default.removeItem(at: compiledURL) + print("โœ… Cleared compiled model cache") + } + } catch { + print("โš ๏ธ Could not clear compiled model cache: \(error.localizedDescription)") + } + } + + // MARK: - Optimized Model Loading with Proactive Cache Management + private func loadModelWithRetry(at url: URL, retryCount: Int = 0) throws -> MLModel { + // Proactively clear cache on first attempt if we've seen issues before + if retryCount == 0 && hasRecentCacheIssues() { + print("๐Ÿงน Proactively clearing E5RT cache due to recent issues...") + clearCoreMLCache() + Thread.sleep(forTimeInterval: 0.5) + } + + do { + let config = MLModelConfiguration() + config.computeUnits = getOptimalComputeUnits() + + // Add cache preferences to avoid E5RT issues + if #available(macOS 14.0, *) { + config.allowLowPrecisionAccumulationOnGPU = true + } + + return try MLModel(contentsOf: url, configuration: config) + } catch let error where retryCount < 2 && (error.localizedDescription.contains("resources.bin") || error.localizedDescription.contains("E5RT")) { + print("๐Ÿ”„ E5RT cache error detected, clearing cache and retrying... (attempt \(retryCount + 1)/3)") + clearCoreMLCache() + + // Wait longer for cache to properly clear + Thread.sleep(forTimeInterval: 1.5) + + return try loadModelWithRetry(at: url, retryCount: retryCount + 1) + } + } + + // MARK: - Cache Issue Detection + private func hasRecentCacheIssues() -> Bool { + // Check if we've seen cache issues recently by looking for E5RT cache directories + let problemIndicators = [ + "~/Library/Caches/ShotScreen/com.apple.e5rt.e5bundlecache", + "~/Library/Caches/com.apple.e5rt.e5bundlecache" + ] + + for location in problemIndicators { + let expandedPath = NSString(string: location).expandingTildeInPath + if FileManager.default.fileExists(atPath: expandedPath) { + // Check if directory is non-empty (has cached data that might be corrupt) + do { + let contents = try FileManager.default.contentsOfDirectory(atPath: expandedPath) + if !contents.isEmpty { + print("๐Ÿ” Found existing E5RT cache, potential for issues: \(location)") + return true + } + } catch { + // If we can't read it, it might be corrupt + return true + } + } + } + return false + } + + // MARK: - CPU-only Model Loading + private func loadModelWithCPUOnly(at url: URL, retryCount: Int = 0) throws -> MLModel { + do { + let config = MLModelConfiguration() + config.computeUnits = .cpuOnly + + return try MLModel(contentsOf: url, configuration: config) + } catch let error where retryCount < 2 && error.localizedDescription.contains("resources.bin") { + print("๐Ÿ”„ E5RT cache error in CPU mode, clearing cache and retrying... (attempt \(retryCount + 1)/3)") + clearCoreMLCache() + + // Wait a moment for cache to clear + Thread.sleep(forTimeInterval: 1.0) + + return try loadModelWithCPUOnly(at: url, retryCount: retryCount + 1) + } + } + + // MARK: - Architecture Detection & Compute Unit Optimization + private func getOptimalComputeUnits() -> MLComputeUnits { + // Check if we're in temporary CPU-only mode to avoid E5RT issues + if isInTemporaryCPUMode() { + print("๐Ÿ”ง TEMPORARY CPU MODE: Using CPU-only to avoid E5RT issues") + return .cpuOnly + } + + // Detect CPU architecture for optimal Core ML performance + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(validatingUTF8: $0) + } + } + + let machineString = machine ?? "unknown" + print("๐Ÿ–ฅ๏ธ Detected machine architecture: \(machineString)") + + // Check for Apple Silicon (M1, M2, M3, etc.) + if machineString.contains("arm64") || machineString.hasPrefix("arm") { + print("๐Ÿš€ Apple Silicon detected - using all compute units (Neural Engine + GPU + CPU)") + return .all + } else { + // Intel Mac - use CPU and GPU only (no Neural Engine available) + print("โšก Intel Mac detected - using CPU and GPU only (no Neural Engine)") + return .cpuAndGPU + } + } + + private func isInTemporaryCPUMode() -> Bool { + guard UserDefaults.standard.bool(forKey: "ShotScreen_ForceCPU_TempMode") else { + return false + } + + let startTime = UserDefaults.standard.double(forKey: "ShotScreen_ForceCPU_StartTime") + let currentTime = Date().timeIntervalSince1970 + let elapsed = currentTime - startTime + + // Force CPU mode for 60 seconds after cache clear + if elapsed > 60 { + // Temp mode expired, clear flags + UserDefaults.standard.removeObject(forKey: "ShotScreen_ForceCPU_TempMode") + UserDefaults.standard.removeObject(forKey: "ShotScreen_ForceCPU_StartTime") + print("๐Ÿ”ง TEMPORARY CPU MODE: Expired, returning to normal mode") + return false + } + + print("๐Ÿ”ง TEMPORARY CPU MODE: Still active (\(Int(60-elapsed))s remaining)") + return true + } + + + + // MARK: - Vision Framework Processing (OPTIMIZED) + private func processWithVisionFramework(cgImage: CGImage, completion: @escaping (NSImage?) -> Void) { + guard #available(macOS 14.0, *) else { + print("โŒ Vision framework background removal requires macOS 14.0 or higher") + DispatchQueue.main.async { completion(nil) } + return + } + + print("๐Ÿš€ VISION FAST TRACK: Using optimized Vision Framework") + + let request = VNGenerateForegroundInstanceMaskRequest { request, error in + if let error = error { + print("โŒ Vision error: \(error)") + DispatchQueue.main.async { completion(nil) } + return + } + + guard let results = request.results, !results.isEmpty else { + print("โŒ No foreground found with Vision Framework") + DispatchQueue.main.async { completion(nil) } + return + } + + guard let result = results.first as? VNInstanceMaskObservation else { + print("โŒ Invalid result from Vision Framework") + DispatchQueue.main.async { completion(nil) } + return + } + + print("โœ… VISION FAST: Completed in ~1-2 seconds! (\(results.count) results)") + self.applyMask(mask: result, to: cgImage, completion: completion) + } + + // OPTIMIZATION: Configure Vision Framework for maximum speed + request.revision = VNGenerateForegroundInstanceMaskRequest.defaultRevision + + // Create handler with optimized options + let handlerOptions: [VNImageOption: Any] = [ + .ciContext: CIContext(options: [ + .useSoftwareRenderer: false, // Use hardware acceleration + .priorityRequestLow: false // High priority processing + ]) + ] + + let handler = VNImageRequestHandler(cgImage: cgImage, options: handlerOptions) + + // Perform on high-priority queue for faster processing + DispatchQueue.global(qos: .userInitiated).async { + do { + try handler.perform([request]) + } catch { + print("โŒ Vision handler error: \(error)") + DispatchQueue.main.async { completion(nil) } + } + } + } + + @available(macOS 14.0, *) + private func applyMask(mask: VNInstanceMaskObservation, to image: CGImage, completion: @escaping (NSImage?) -> Void) { + // OPTIMIZATION: Run mask processing on background queue for better performance + DispatchQueue.global(qos: .userInitiated).async { + do { + print("๐Ÿš€ FAST MASK: Starting optimized mask generation...") + + // BOTTLENECK 1: Optimize mask generation with performance options + let imageHandler = VNImageRequestHandler(cgImage: image, options: [ + .ciContext: self.getOptimizedCIContext() + ]) + + let maskImage = try mask.generateScaledMaskForImage( + forInstances: mask.allInstances, + from: imageHandler + ) + + print("๐Ÿš€ FAST MASK: Mask generated, applying to image...") + + // OPTIMIZATION: Use same optimized CIContext for all operations + let optimizedContext = self.getOptimizedCIContext() + + let ciImage = CIImage(cgImage: image) + let ciMask = CIImage(cvPixelBuffer: maskImage) + + let filter = CIFilter.blendWithMask() + filter.inputImage = ciImage + filter.backgroundImage = CIImage.empty() + filter.maskImage = ciMask + + guard let outputImage = filter.outputImage else { + print("โŒ Error applying mask") + DispatchQueue.main.async { completion(nil) } + return + } + + // BOTTLENECK 2: Use optimized context instead of creating new one + guard let cgResult = optimizedContext.createCGImage(outputImage, from: outputImage.extent) else { + print("โŒ Error creating result image") + DispatchQueue.main.async { completion(nil) } + return + } + + let resultImage = NSImage(cgImage: cgResult, size: NSSize(width: cgResult.width, height: cgResult.height)) + + DispatchQueue.main.async { + print("โœ… VISION OPTIMIZED: Background removed in ~1-2 seconds!") + completion(resultImage) + } + + } catch { + print("โŒ Error processing mask: \(error.localizedDescription)") + DispatchQueue.main.async { completion(nil) } + } + } + } + + // MARK: - Optimized CIContext (PERFORMANCE CRITICAL) + private var _optimizedCIContext: CIContext? + private func getOptimizedCIContext() -> CIContext { + if let existingContext = _optimizedCIContext { + return existingContext + } + + // Create high-performance CIContext with optimized settings + let context = CIContext(options: [ + .useSoftwareRenderer: false, // Force hardware acceleration + .priorityRequestLow: false, // High priority processing + .cacheIntermediates: true // Cache for better performance + ]) + + _optimizedCIContext = context + print("๐Ÿš€ FAST CONTEXT: Created optimized CIContext for Vision Framework") + return context + } + + // MARK: - Core ML Processing with Fallback + private func processWithCoreMLModelWithFallback(cgImage: CGImage, completion: @escaping (NSImage?) -> Void) { + // Try RMBG-1.4 first, fallback to Vision Framework + print("๐Ÿค– Attempting RMBG-1.4 Core ML model...") + + if tryLoadAndProcessModel(cgImage: cgImage, modelName: "bria-rmbg-coreml", displayName: "RMBG-1.4", completion: completion) { + // RMBG-1.4 succeeded + return + } + + // Fallback to Vision Framework + print("๐Ÿ”„ RMBG-1.4 failed, falling back to Vision Framework...") + processWithVisionFramework(cgImage: cgImage, completion: completion) + } + + private func tryLoadAndProcessModel(cgImage: CGImage, modelName: String, displayName: String, completion: @escaping (NSImage?) -> Void) -> Bool { + // Check if model is available in Application Support directory + let modelPath = getModelPath() + guard FileManager.default.fileExists(atPath: modelPath) else { + print("โŒ \(displayName) model not found at: \(modelPath)") + return false + } + + let finalModelURL = URL(fileURLWithPath: modelPath) + + do { + print("๐Ÿ“ฆ Trying to load \(displayName) model from: \(finalModelURL.lastPathComponent)") + + // First try to compile the model if it's not compiled + let compiledModelURL: URL + if finalModelURL.pathExtension == "mlpackage" { + print("๐Ÿ”ง Compiling \(displayName) model...") + compiledModelURL = try MLModel.compileModel(at: finalModelURL) + print("โœ… Model compiled successfully") + } else { + compiledModelURL = finalModelURL + } + + // Try to load the Core ML model with retry logic for E5RT issues + let model = try loadModelWithRetry(at: compiledModelURL) + let visionModel = try VNCoreMLModel(for: model) + + print("โœ… \(displayName) model successfully loaded") + + // Create the request with E5RT monitoring + let request = VNCoreMLRequest(model: visionModel) { request, error in + if let error = error { + print("โŒ Core ML request error for \(displayName): \(error)") + + // Check if this is an E5RT related error + if error.localizedDescription.contains("resources.bin") || + error.localizedDescription.contains("E5RT") { + self.recordE5RTIssue() + } + + DispatchQueue.main.async { completion(nil) } + return + } + + guard let results = request.results, + let pixelBufferObservation = results.first as? VNPixelBufferObservation else { + print("โŒ No valid result from \(displayName) model") + DispatchQueue.main.async { completion(nil) } + return + } + + // Convert the result to NSImage + if let resultImage = self.convertPixelBufferToNSImage(pixelBufferObservation.pixelBuffer, originalImage: cgImage) { + print("โœ… \(displayName) processing successful") + DispatchQueue.main.async { completion(resultImage) } + } else { + print("โŒ Could not convert \(displayName) result") + DispatchQueue.main.async { completion(nil) } + } + } + + // Configure request + request.imageCropAndScaleOption = .scaleFill + + // Perform the request + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + try handler.perform([request]) + + return true // Success + + } catch { + print("โŒ \(displayName) model error: \(error)") + let errorMessage = error.localizedDescription + + if errorMessage.contains("resources.bin") { + print("๐Ÿ”ง E5RT cache corruption detected - resources.bin missing") + print("๐Ÿš€ FAST FALLBACK: Immediately switching to Vision Framework") + + // Record this issue and clear cache for next time + recordE5RTIssue() + clearCoreMLCache() + + // Instead of retrying RMBG, immediately fall back to Vision + DispatchQueue.global(qos: .userInitiated).async { + self.processWithVisionFramework(cgImage: cgImage, completion: completion) + } + return true // We're handling it with Vision fallback + + } else if errorMessage.contains("MPSGraphExecutable") || errorMessage.contains("E5RT") { + print("๐Ÿ”ง Neural Engine compilation problem detected") + print("๐Ÿš€ FAST FALLBACK: Immediately switching to Vision Framework") + + // Same fast fallback strategy + recordE5RTIssue() + clearCoreMLCache() + + DispatchQueue.global(qos: .userInitiated).async { + self.processWithVisionFramework(cgImage: cgImage, completion: completion) + } + return true // We're handling it with Vision fallback + + } else if errorMessage.contains("compute") || errorMessage.contains("Neural") { + print("๐Ÿ”ง Intel Mac detected - trying CPU-only fallback") + // Try CPU-only as final fallback for Intel Macs + return tryLoadModelWithCPUOnly(cgImage: cgImage, modelName: modelName, displayName: displayName, completion: completion) + } + return false // Failed + } + } + + private func convertPixelBufferToNSImage(_ pixelBuffer: CVPixelBuffer, originalImage: CGImage) -> NSImage? { + print("๐Ÿš€ FAST RMBG: Starting optimized pixel buffer conversion...") + + // Convert the grayscale mask to a proper background-removed image + let maskCIImage = CIImage(cvPixelBuffer: pixelBuffer) + let originalCIImage = CIImage(cgImage: originalImage) + + // Resize mask to match original image size + let scaleX = originalCIImage.extent.width / maskCIImage.extent.width + let scaleY = originalCIImage.extent.height / maskCIImage.extent.height + + let scaledMask = maskCIImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY)) + + // Apply the mask to remove background + let maskFilter = CIFilter.blendWithMask() + maskFilter.inputImage = originalCIImage + maskFilter.backgroundImage = CIImage.empty() + maskFilter.maskImage = scaledMask + + guard let outputImage = maskFilter.outputImage else { + print("โŒ Mask filter failed") + return nil + } + + // OPTIMIZATION: Use same optimized context instead of creating new one + let optimizedContext = getOptimizedCIContext() + guard let cgResult = optimizedContext.createCGImage(outputImage, from: outputImage.extent) else { + print("โŒ CGImage creation failed") + return nil + } + + print("โœ… RMBG OPTIMIZED: Pixel buffer converted efficiently!") + return NSImage(cgImage: cgResult, size: NSSize(width: cgResult.width, height: cgResult.height)) + } + + // MARK: - Intel Mac CPU-Only Fallback + private func tryLoadModelWithCPUOnly(cgImage: CGImage, modelName: String, displayName: String, completion: @escaping (NSImage?) -> Void) -> Bool { + let modelPath = getModelPath() + guard FileManager.default.fileExists(atPath: modelPath) else { + print("โŒ \(displayName) model not found for CPU fallback") + return false + } + + let finalModelURL = URL(fileURLWithPath: modelPath) + + do { + print("๐Ÿ”ง Trying CPU-only mode for \(displayName) on Intel Mac...") + + // Compile model if needed + let compiledModelURL: URL + if finalModelURL.pathExtension == "mlpackage" { + compiledModelURL = try MLModel.compileModel(at: finalModelURL) + } else { + compiledModelURL = finalModelURL + } + + // CPU-only configuration for Intel Macs with retry logic + print("โšก Using CPU-only compute units for Intel Mac compatibility") + + let model = try loadModelWithCPUOnly(at: compiledModelURL) + let visionModel = try VNCoreMLModel(for: model) + + print("โœ… \(displayName) model loaded successfully in CPU-only mode") + + // Create the request + let request = VNCoreMLRequest(model: visionModel) { request, error in + if let error = error { + print("โŒ CPU-only Core ML request error for \(displayName): \(error)") + DispatchQueue.main.async { completion(nil) } + return + } + + guard let results = request.results, + let pixelBufferObservation = results.first as? VNPixelBufferObservation else { + print("โŒ No valid result from \(displayName) model (CPU-only)") + DispatchQueue.main.async { completion(nil) } + return + } + + // Convert the result to NSImage + if let resultImage = self.convertPixelBufferToNSImage(pixelBufferObservation.pixelBuffer, originalImage: cgImage) { + print("โœ… \(displayName) processing successful (CPU-only mode)") + DispatchQueue.main.async { completion(resultImage) } + } else { + print("โŒ Could not convert \(displayName) result (CPU-only)") + DispatchQueue.main.async { completion(nil) } + } + } + + // Configure request + request.imageCropAndScaleOption = .scaleFill + + // Perform the request + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + try handler.perform([request]) + + return true // Success + + } catch { + print("โŒ CPU-only fallback failed for \(displayName): \(error)") + return false // Failed + } + } +} + +// MARK: - Drag & Drop Image View +class DragDropImageView: NSImageView, NSFilePromiseProviderDelegate, NSDraggingSource { + weak var dragDropDelegate: ImageDragDropDelegate? + var enableDragOut: Bool = false // Enable dragging images out of this view + + override func awakeFromNib() { + super.awakeFromNib() + setupDragDrop() + } + + private func setupDragDrop() { + registerForDraggedTypes([.fileURL]) + } + + // MARK: - Drag Out Functionality + override func mouseDown(with event: NSEvent) { + guard enableDragOut, let image = self.image else { + super.mouseDown(with: event) + return + } + + // Start drag operation with smaller thumbnail + let dragItem = NSDraggingItem(pasteboardWriter: image) + + // Create smaller drag frame (e.g., 120x120 max) + let maxDragSize: CGFloat = 120 + let imageSize = image.size + let aspectRatio = imageSize.width / imageSize.height + + var dragWidth: CGFloat + var dragHeight: CGFloat + + if aspectRatio > 1 { + // Landscape + dragWidth = min(maxDragSize, imageSize.width) + dragHeight = dragWidth / aspectRatio + } else { + // Portrait or square + dragHeight = min(maxDragSize, imageSize.height) + dragWidth = dragHeight * aspectRatio + } + + // Center the drag frame within the view + let dragFrame = NSRect( + x: (self.bounds.width - dragWidth) / 2, + y: (self.bounds.height - dragHeight) / 2, + width: dragWidth, + height: dragHeight + ) + + dragItem.setDraggingFrame(dragFrame, contents: image) + + // Create temporary file for dragging + if let tiffData = image.tiffRepresentation, + let bitmapRep = NSBitmapImageRep(data: tiffData), + let pngData = bitmapRep.representation(using: .png, properties: [:]) { + + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("background_removed_\(UUID().uuidString).png") + + do { + try pngData.write(to: tempURL) + let filePromise = NSFilePromiseProvider(fileType: "public.png", delegate: self) + filePromise.userInfo = ["tempURL": tempURL] + + let fileDragItem = NSDraggingItem(pasteboardWriter: filePromise) + fileDragItem.setDraggingFrame(dragFrame, contents: image) + + beginDraggingSession(with: [fileDragItem], event: event, source: self) + + print("๐ŸŽฏ Started dragging background-removed image") + } catch { + print("โŒ Failed to create temp file for dragging: \(error)") + super.mouseDown(with: event) + } + } else { + super.mouseDown(with: event) + } + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + let pasteboard = sender.draggingPasteboard + guard let types = pasteboard.types else { return [] } + + if types.contains(.fileURL) { + if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] { + let imageTypes = ["png", "jpg", "jpeg", "gif", "tiff", "bmp", "heic", "heif"] + for url in urls { + if imageTypes.contains(url.pathExtension.lowercased()) { + // Visual feedback - use adaptive color + self.layer?.borderWidth = 2 + self.layer?.borderColor = ThemeManager.shared.buttonTintColor.cgColor + return .copy + } + } + } + } + return [] + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + // Remove visual feedback + self.layer?.borderWidth = 0 + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let pasteboard = sender.draggingPasteboard + guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] else { + return false + } + + let imageTypes = ["png", "jpg", "jpeg", "gif", "tiff", "bmp", "heic", "heif"] + for url in urls { + if imageTypes.contains(url.pathExtension.lowercased()) { + if let image = NSImage(contentsOf: url) { + dragDropDelegate?.didDropImage(image, from: url.path) + // Remove visual feedback + self.layer?.borderWidth = 0 + return true + } + } + } + return false + } + + // MARK: - NSFilePromiseProviderDelegate + func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, fileNameForType fileType: String) -> String { + return "background_removed_\(UUID().uuidString).png" + } + + func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, writePromiseTo url: URL, completionHandler: @escaping (Error?) -> Void) { + guard let userInfo = filePromiseProvider.userInfo as? [String: Any], + let tempURL = userInfo["tempURL"] as? URL else { + completionHandler(NSError(domain: "DragDropImageView", code: 1, userInfo: [NSLocalizedDescriptionKey: "No temp URL found"])) + return + } + + do { + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + try FileManager.default.copyItem(at: tempURL, to: url) + completionHandler(nil) + print("โœ… Successfully wrote dragged image to: \(url.lastPathComponent)") + } catch { + completionHandler(error) + print("โŒ Failed to write dragged image: \(error)") + } + } + + // MARK: - NSDraggingSource + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + return .copy + } + + func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) { + print("๐ŸŽฏ Drag session beginning at \(screenPoint)") + } + + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + print("๐ŸŽฏ Drag session ended with operation: \(operation.rawValue)") + } +} + +protocol ImageDragDropDelegate: AnyObject { + func didDropImage(_ image: NSImage, from path: String) +} + +// MARK: - Card Style Button (for method selection) +class CardStyleButton: NSView { + private let titleLabel: NSTextField = NSTextField(labelWithString: "") + private let subtitleLabel: NSTextField = NSTextField(labelWithString: "") + private let iconImageView: NSImageView = NSImageView() + private var iconBackground: NSView! + private var cardBackground: NSView! + private var symbolName: String = "" + + var isSelected: Bool = false { + didSet { updateSelectedState(animated: true) } + } + + var isEnabled: Bool = true { + didSet { updateEnabledState() } + } + + // Button action properties + var target: AnyObject? + var action: Selector? + + init(frame frameRect: NSRect, title: String, subtitle: String, symbolName: String) { + self.symbolName = symbolName + super.init(frame: frameRect) + setupCard(title: title, subtitle: subtitle, symbolName: symbolName) + + // Setup theme change observer + ThemeManager.shared.observeThemeChanges { [weak self] in + DispatchQueue.main.async { + self?.updateThemeColors() + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupCard(title: String, subtitle: String, symbolName: String) { + wantsLayer = true + layer?.cornerRadius = 12 + layer?.masksToBounds = false + + // Mouse tracking + updateTrackingAreas() + + // Card background with blur effect - adaptive colors + cardBackground = NSView() + cardBackground.wantsLayer = true + cardBackground.translatesAutoresizingMaskIntoConstraints = false + cardBackground.layer?.cornerRadius = 12 + cardBackground.layer?.backgroundColor = ThemeManager.shared.secondaryContainerBackground.withAlphaComponent(0.3).cgColor + cardBackground.layer?.borderWidth = 1 + cardBackground.layer?.borderColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.2).cgColor + addSubview(cardBackground) + + // Icon background - adaptive colors + iconBackground = NSView() + iconBackground.wantsLayer = true + iconBackground.translatesAutoresizingMaskIntoConstraints = false + iconBackground.layer?.cornerRadius = 20 + iconBackground.layer?.backgroundColor = ThemeManager.shared.gridCellIconBackground.cgColor + cardBackground.addSubview(iconBackground) + + // SF Symbol icon + let baseImage = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) + let configuredImage = baseImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 20, weight: .semibold)) + iconImageView.image = configuredImage + iconImageView.contentTintColor = ThemeManager.shared.primaryTextColor + iconImageView.translatesAutoresizingMaskIntoConstraints = false + iconImageView.imageScaling = .scaleProportionallyDown + iconImageView.imageAlignment = .alignCenter + iconBackground.addSubview(iconImageView) + + // Title label - adaptive colors + titleLabel.stringValue = title + titleLabel.font = NSFont.systemFont(ofSize: 12, weight: .bold) + titleLabel.textColor = ThemeManager.shared.primaryTextColor + titleLabel.alignment = .left + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.usesSingleLineMode = true + cardBackground.addSubview(titleLabel) + + // Subtitle label - adaptive colors + subtitleLabel.stringValue = subtitle + subtitleLabel.font = NSFont.systemFont(ofSize: 9, weight: .medium) + subtitleLabel.textColor = ThemeManager.shared.secondaryTextColor + subtitleLabel.alignment = .left + subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + subtitleLabel.usesSingleLineMode = true + cardBackground.addSubview(subtitleLabel) + + // Layout constraints + NSLayoutConstraint.activate([ + // Card background fills the entire view + cardBackground.topAnchor.constraint(equalTo: topAnchor), + cardBackground.leadingAnchor.constraint(equalTo: leadingAnchor), + cardBackground.trailingAnchor.constraint(equalTo: trailingAnchor), + cardBackground.bottomAnchor.constraint(equalTo: bottomAnchor), + + // Icon background + iconBackground.leadingAnchor.constraint(equalTo: cardBackground.leadingAnchor, constant: 12), + iconBackground.centerYAnchor.constraint(equalTo: cardBackground.centerYAnchor), + iconBackground.widthAnchor.constraint(equalToConstant: 40), + iconBackground.heightAnchor.constraint(equalToConstant: 40), + + // Icon image + iconImageView.centerXAnchor.constraint(equalTo: iconBackground.centerXAnchor), + iconImageView.centerYAnchor.constraint(equalTo: iconBackground.centerYAnchor), + iconImageView.widthAnchor.constraint(equalToConstant: 24), + iconImageView.heightAnchor.constraint(equalToConstant: 24), + + // Title label + titleLabel.leadingAnchor.constraint(equalTo: iconBackground.trailingAnchor, constant: 12), + titleLabel.trailingAnchor.constraint(equalTo: cardBackground.trailingAnchor, constant: -12), + titleLabel.topAnchor.constraint(equalTo: cardBackground.centerYAnchor, constant: -12), + + // Subtitle label + subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + subtitleLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2) + ]) + + // Initial shadow - adaptive colors + cardBackground.layer?.shadowColor = ThemeManager.shared.shadowColor.cgColor + cardBackground.layer?.shadowOffset = CGSize(width: 0, height: 2) + cardBackground.layer?.shadowRadius = 8 + cardBackground.layer?.shadowOpacity = 0.0 + + // Set anchor points for smooth scaling + cardBackground.layer?.anchorPoint = CGPoint(x: 0.5, y: 0.5) + iconBackground.layer?.anchorPoint = CGPoint(x: 0.5, y: 0.5) + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + for trackingArea in trackingAreas { + removeTrackingArea(trackingArea) + } + + let trackingArea = NSTrackingArea(rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways], + owner: self, + userInfo: nil) + addTrackingArea(trackingArea) + } + + private func updateSelectedState(animated: Bool) { + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.6 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + + if isSelected { + // Selected state: brighter, glowing - adaptive colors + cardBackground.animator().layer?.backgroundColor = ThemeManager.shared.secondaryContainerBackground.withAlphaComponent(0.6).cgColor + cardBackground.animator().layer?.borderColor = ThemeManager.shared.buttonTintColor.withAlphaComponent(0.8).cgColor + cardBackground.animator().layer?.shadowOpacity = ThemeManager.shared.shadowOpacity + iconBackground.animator().layer?.backgroundColor = ThemeManager.shared.buttonTintColor.withAlphaComponent(0.6).cgColor + titleLabel.animator().textColor = ThemeManager.shared.primaryTextColor + subtitleLabel.animator().textColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.9) + } else { + // Unselected state: dimmer - adaptive colors + cardBackground.animator().layer?.backgroundColor = ThemeManager.shared.secondaryContainerBackground.withAlphaComponent(0.3).cgColor + cardBackground.animator().layer?.borderColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.2).cgColor + cardBackground.animator().layer?.shadowOpacity = 0.0 + iconBackground.animator().layer?.backgroundColor = ThemeManager.shared.gridCellIconBackground.cgColor + titleLabel.animator().textColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.8) + subtitleLabel.animator().textColor = ThemeManager.shared.secondaryTextColor + } + } + + // Separate animation for icon bounce + if isSelected { + let bounceAnimation = CAKeyframeAnimation(keyPath: "transform.scale") + bounceAnimation.values = [1.0, 1.2, 1.0] + bounceAnimation.keyTimes = [0.0, 0.3, 1.0] + bounceAnimation.duration = 0.5 + bounceAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + iconBackground.layer?.add(bounceAnimation, forKey: "bounce") + } + } else { + // Immediate update without animation - adaptive colors + if isSelected { + cardBackground.layer?.backgroundColor = ThemeManager.shared.secondaryContainerBackground.withAlphaComponent(0.6).cgColor + cardBackground.layer?.borderColor = ThemeManager.shared.buttonTintColor.withAlphaComponent(0.8).cgColor + cardBackground.layer?.shadowOpacity = ThemeManager.shared.shadowOpacity + iconBackground.layer?.backgroundColor = ThemeManager.shared.buttonTintColor.withAlphaComponent(0.6).cgColor + titleLabel.textColor = ThemeManager.shared.primaryTextColor + subtitleLabel.textColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.9) + } else { + cardBackground.layer?.backgroundColor = ThemeManager.shared.secondaryContainerBackground.withAlphaComponent(0.3).cgColor + cardBackground.layer?.borderColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.2).cgColor + cardBackground.layer?.shadowOpacity = 0.0 + iconBackground.layer?.backgroundColor = ThemeManager.shared.gridCellIconBackground.cgColor + titleLabel.textColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.8) + subtitleLabel.textColor = ThemeManager.shared.secondaryTextColor + } + } + } + + private func updateEnabledState() { + if isEnabled { + alphaValue = 1.0 + cardBackground.layer?.backgroundColor = ThemeManager.shared.secondaryContainerBackground.withAlphaComponent(0.3).cgColor + cardBackground.layer?.borderColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.2).cgColor + titleLabel.textColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.8) + subtitleLabel.textColor = ThemeManager.shared.secondaryTextColor + iconBackground.layer?.backgroundColor = ThemeManager.shared.gridCellIconBackground.cgColor + iconImageView.contentTintColor = ThemeManager.shared.primaryTextColor + } else { + alphaValue = 0.5 + cardBackground.layer?.backgroundColor = ThemeManager.shared.secondaryContainerBackground.withAlphaComponent(0.1).cgColor + cardBackground.layer?.borderColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.1).cgColor + titleLabel.textColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.4) + subtitleLabel.textColor = ThemeManager.shared.secondaryTextColor.withAlphaComponent(0.5) + iconBackground.layer?.backgroundColor = ThemeManager.shared.gridCellIconBackground.withAlphaComponent(0.5).cgColor + iconImageView.contentTintColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.5) + } + updateTrackingAreas() + } + + override func mouseEntered(with event: NSEvent) { + guard isEnabled else { return } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + + // Hover effect: slight scale and enhanced glow + cardBackground.animator().layer?.transform = CATransform3DMakeScale(1.02, 1.02, 1.0) + + if isSelected { + cardBackground.animator().layer?.shadowOpacity = ThemeManager.shared.shadowOpacity * 1.5 + } else { + cardBackground.animator().layer?.shadowOpacity = ThemeManager.shared.shadowOpacity * 0.8 + cardBackground.animator().layer?.backgroundColor = ThemeManager.shared.secondaryContainerBackground.withAlphaComponent(0.4).cgColor + } + } + + // Icon hover animation + let pulseAnimation = CABasicAnimation(keyPath: "transform.scale") + pulseAnimation.fromValue = 1.0 + pulseAnimation.toValue = 1.1 + pulseAnimation.duration = 0.15 + pulseAnimation.autoreverses = true + iconBackground.layer?.add(pulseAnimation, forKey: "pulse") + } + + override func mouseExited(with event: NSEvent) { + guard isEnabled else { return } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + + // Reset hover effect + cardBackground.animator().layer?.transform = CATransform3DIdentity + + if isSelected { + cardBackground.animator().layer?.shadowOpacity = ThemeManager.shared.shadowOpacity + } else { + cardBackground.animator().layer?.shadowOpacity = 0.0 + cardBackground.animator().layer?.backgroundColor = ThemeManager.shared.secondaryContainerBackground.withAlphaComponent(0.3).cgColor + } + } + + iconBackground.layer?.removeAnimation(forKey: "pulse") + } + + override func mouseDown(with event: NSEvent) { + guard isEnabled else { + // Show a subtle "disabled" feedback + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.1 + self.animator().alphaValue = 0.3 + }) { + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.1 + self.animator().alphaValue = 0.5 + }) + } + return + } + + // Click animation + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.1 + cardBackground.animator().layer?.transform = CATransform3DMakeScale(0.98, 0.98, 1.0) + }) { + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.1 + self.cardBackground.animator().layer?.transform = CATransform3DMakeScale(1.02, 1.02, 1.0) + }) + } + + // Handle click action + if let target = target, let action = action { + _ = target.perform(action, with: self) + } + } + + // MARK: - Theme Management + private func updateThemeColors() { + // Update card background + if isSelected { + cardBackground.layer?.backgroundColor = ThemeManager.shared.secondaryContainerBackground.withAlphaComponent(0.6).cgColor + cardBackground.layer?.borderColor = ThemeManager.shared.buttonTintColor.withAlphaComponent(0.8).cgColor + iconBackground.layer?.backgroundColor = ThemeManager.shared.buttonTintColor.withAlphaComponent(0.6).cgColor + titleLabel.textColor = ThemeManager.shared.primaryTextColor + subtitleLabel.textColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.9) + } else { + cardBackground.layer?.backgroundColor = ThemeManager.shared.secondaryContainerBackground.withAlphaComponent(0.3).cgColor + cardBackground.layer?.borderColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.2).cgColor + iconBackground.layer?.backgroundColor = ThemeManager.shared.gridCellIconBackground.cgColor + titleLabel.textColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.8) + subtitleLabel.textColor = ThemeManager.shared.secondaryTextColor + } + + // Update icon color + iconImageView.contentTintColor = ThemeManager.shared.primaryTextColor + + // Update shadow color + cardBackground.layer?.shadowColor = ThemeManager.shared.shadowColor.cgColor + + // Update enabled state colors if disabled + if !isEnabled { + updateEnabledState() + } + } +} + +// MARK: - Action Style Button (matches GridCellView style) +class ActionStyleButton: NSView { + private let label: NSTextField = NSTextField(labelWithString: "") + private let iconImageView: NSImageView = NSImageView() + private var iconBackground: NSView! + private var iconCenterConstraint: NSLayoutConstraint! + private var iconWidthConstraint: NSLayoutConstraint! + private var iconHeightConstraint: NSLayoutConstraint! + private var iconImageWidthConstraint: NSLayoutConstraint! + private var iconImageHeightConstraint: NSLayoutConstraint! + private let iconStartOffset: CGFloat = 30 + private var isHovered: Bool = false + private var symbolName: String = "" + var isSmallButton: Bool = false { + didSet { updateForSmallButton() } + } + + var isEnabled: Bool = true { + didSet { updateEnabledState() } + } + + // Button action properties + var target: AnyObject? + var action: Selector? + + init(frame frameRect: NSRect, text: String, symbolName: String) { + self.symbolName = symbolName + super.init(frame: frameRect) + setupButton(text: text, symbolName: symbolName) + + // Setup theme change observer + ThemeManager.shared.observeThemeChanges { [weak self] in + DispatchQueue.main.async { + self?.updateThemeColors() + } + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupButton(text: String, symbolName: String) { + wantsLayer = true + // layer?.backgroundColor = NSColor.red.withAlphaComponent(0.3).cgColor // DEBUG BG + + // Mouse tracking for hover effects - will be updated in updateTrackingAreas + updateTrackingAreas() + + layer?.cornerRadius = 0 + layer?.masksToBounds = false + + // Label setup - adaptive colors + label.stringValue = text + label.font = NSFont.systemFont(ofSize: 12, weight: .semibold) + label.textColor = ThemeManager.shared.primaryTextColor + label.alignment = .left + label.alphaValue = text.isEmpty ? 0 : 0.3 // Hidden for small buttons + label.translatesAutoresizingMaskIntoConstraints = false + label.usesSingleLineMode = true + label.lineBreakMode = .byTruncatingTail + addSubview(label) + + // Circular icon background - adaptive colors + let iconBackground = NSView() + iconBackground.wantsLayer = true + iconBackground.translatesAutoresizingMaskIntoConstraints = false + iconBackground.layer?.cornerRadius = 18 // 36ร—36 circle (will be adjusted for small buttons) + iconBackground.layer?.backgroundColor = ThemeManager.shared.gridCellIconBackground.cgColor + addSubview(iconBackground) + + // SF Symbol icon + let baseImage = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) + // Adjusted pointSize for normal state for better proportion within the 18x18 constraint + let configuredImage = baseImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold)) + iconImageView.image = configuredImage + iconImageView.contentTintColor = ThemeManager.shared.primaryTextColor + iconImageView.translatesAutoresizingMaskIntoConstraints = false + iconImageView.imageScaling = .scaleProportionallyDown + iconImageView.imageAlignment = .alignCenter + iconImageView.wantsLayer = true + iconBackground.addSubview(iconImageView) + + // Store reference to icon background for small button adjustments + self.iconBackground = iconBackground + + // Layout constraints + iconCenterConstraint = iconBackground.centerXAnchor.constraint(equalTo: centerXAnchor, constant: iconStartOffset) + iconWidthConstraint = iconBackground.widthAnchor.constraint(equalToConstant: 36) + iconHeightConstraint = iconBackground.heightAnchor.constraint(equalTo: iconBackground.widthAnchor) + + // Constraints for the iconImageView itself, to ensure it's centered and sized within iconBackground + iconImageWidthConstraint = iconImageView.widthAnchor.constraint(equalToConstant: 18) // Initial size for normal buttons + iconImageHeightConstraint = iconImageView.heightAnchor.constraint(equalToConstant: 18) // Initial size for normal buttons + + NSLayoutConstraint.activate([ + iconCenterConstraint, + iconBackground.centerYAnchor.constraint(equalTo: centerYAnchor), + iconWidthConstraint, + iconHeightConstraint, + + iconImageView.centerXAnchor.constraint(equalTo: iconBackground.centerXAnchor), + iconImageView.centerYAnchor.constraint(equalTo: iconBackground.centerYAnchor), + iconImageWidthConstraint, // Activate new constraint + iconImageHeightConstraint, // Activate new constraint + + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + label.trailingAnchor.constraint(equalTo: iconBackground.leadingAnchor, constant: -8), + label.centerYAnchor.constraint(equalTo: iconBackground.centerYAnchor) + ]) + + // Initial transform + layer?.anchorPoint = CGPoint(x: 0.5, y: 0.5) + layer?.transform = CATransform3DIdentity + + // Set anchor point for icon background to center for proper scaling + // This must be done BEFORE adding constraints to prevent position jumping + iconBackground.layer?.anchorPoint = CGPoint(x: 0.5, y: 0.5) + + // Add subtle glow to label - adaptive colors + let shadow = NSShadow() + shadow.shadowColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.4) + shadow.shadowBlurRadius = 4 + shadow.shadowOffset = NSSize(width: 0, height: -1) + label.shadow = shadow + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + // Remove existing tracking areas + for trackingArea in trackingAreas { + removeTrackingArea(trackingArea) + } + + // Add tracking area covering the entire button bounds with some extra margin for easier hovering + let expandedRect = bounds.insetBy(dx: -5, dy: -5) + let trackingArea = NSTrackingArea(rect: expandedRect, + options: [.mouseEnteredAndExited, .activeAlways], + owner: self, + userInfo: nil) + addTrackingArea(trackingArea) + } + + private func updateForSmallButton() { + if isSmallButton { + // For small buttons (info, close), center the icon and make it smaller + iconCenterConstraint.constant = 0 + label.alphaValue = 0 + iconWidthConstraint.constant = 24 // iconBackground size + iconBackground.layer?.cornerRadius = 12 + iconBackground.alphaValue = 0.8 + + // Smaller icon for small buttons + let baseImage = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) + // pointSize 10 for 12x12 constraint seems reasonable. + let configuredImage = baseImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 10, weight: .semibold)) + iconImageView.image = configuredImage + iconImageWidthConstraint.constant = 12 // Update constraint for icon image + iconImageHeightConstraint.constant = 12 // Update constraint for icon image + + } else { // This is the NORMAL/ENABLED state for the 5 action buttons + iconCenterConstraint.constant = iconStartOffset + label.alphaValue = label.stringValue.isEmpty ? 0 : 0.3 + iconWidthConstraint.constant = 36 // iconBackground size + iconBackground.layer?.cornerRadius = 18 + iconBackground.alphaValue = 1.0 + + // Reset icon for normal buttons + let baseImage = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) + // Adjusted pointSize for normal state for better proportion + let configuredImage = baseImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 16, weight: .semibold)) + iconImageView.image = configuredImage + iconImageWidthConstraint.constant = 18 // Reset constraint for icon image + iconImageHeightConstraint.constant = 18 // Reset constraint for icon image + } + + // Update tracking areas when button type changes + updateTrackingAreas() + } + + private func updateEnabledState() { + // Update tracking areas based on enabled state + updateTrackingAreas() + } + + func setIconScale(_ scale: CGFloat) { + // Scale the icon background (which contains the icon) with animation + NSAnimationContext.runAnimationGroup { context in + context.duration = 2.0 // <<<< INCREASED DURATION HERE + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + self.iconBackground.animator().layer?.transform = CATransform3DMakeScale(scale, scale, 1.0) + } + } + + func setIconScaleImmediate(_ scale: CGFloat) { + // Scale the icon background immediately without animation + self.iconBackground.layer?.transform = CATransform3DMakeScale(scale, scale, 1.0) + } + + override func mouseEntered(with event: NSEvent) { + guard isEnabled else { return } + + if isSmallButton { + // Zoom effect for small buttons (info, close) - anchor point already set in setupButton + + // Create scale animation + let scaleAnimation = CABasicAnimation(keyPath: "transform.scale") + scaleAnimation.fromValue = 1.0 + scaleAnimation.toValue = 1.2 + scaleAnimation.duration = 0.2 + scaleAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) + scaleAnimation.fillMode = .forwards + scaleAnimation.isRemovedOnCompletion = false + + iconBackground.layer?.add(scaleAnimation, forKey: "scaleUp") + iconBackground.layer?.transform = CATransform3DMakeScale(1.2, 1.2, 1.0) + + // Alpha animation + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.2 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + self.iconBackground.animator().alphaValue = 1.0 + } + } else { + // Slide effect for regular buttons + let shift = (self.bounds.width / 2) - 18 - 2 + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.15 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + self.iconCenterConstraint.animator().constant = shift + self.label.animator().alphaValue = 1.0 + } + } + } + + override func mouseExited(with event: NSEvent) { + guard isEnabled else { return } + + if isSmallButton { + // Reset zoom for small buttons + iconBackground.layer?.removeAnimation(forKey: "scaleUp") + + let scaleAnimation = CABasicAnimation(keyPath: "transform.scale") + scaleAnimation.fromValue = 1.2 + scaleAnimation.toValue = 1.0 + scaleAnimation.duration = 0.2 + scaleAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) + scaleAnimation.fillMode = .forwards + scaleAnimation.isRemovedOnCompletion = false + + iconBackground.layer?.add(scaleAnimation, forKey: "scaleDown") + iconBackground.layer?.transform = CATransform3DIdentity + + // Alpha animation + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + self.iconBackground.animator().alphaValue = 0.8 + } + } else { + // Reset slide for regular buttons + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + self.iconCenterConstraint.animator().constant = self.iconStartOffset + self.label.animator().alphaValue = 0.3 + } + } + } + + override func mouseDown(with event: NSEvent) { + guard isEnabled else { return } + + // Visual feedback for click + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.1 + if isSmallButton { + self.iconBackground.layer?.transform = CATransform3DMakeScale(0.9, 0.9, 1.0) + } else { + self.animator().alphaValue = 0.8 + } + }) { + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.1 + if self.isSmallButton { + self.iconBackground.layer?.transform = CATransform3DMakeScale(1.2, 1.2, 1.0) + } else { + self.animator().alphaValue = 1.0 + } + }) + } + + // Handle click action + if let target = target, let action = action { + print("๐Ÿ”˜ ActionStyleButton clicked: \(symbolName)") + _ = target.perform(action, with: self) + } + } + + func setIconDisabledLook(disabled: Bool) { + if disabled { + iconImageView.alphaValue = 0.5 // Make the SF Symbol itself more transparent + } else { + iconImageView.alphaValue = 1.0 // Normal visibility for the icon + } + } + + // MARK: - Theme Management + private func updateThemeColors() { + // Update label color + label.textColor = ThemeManager.shared.primaryTextColor + + // Update icon background color + iconBackground.layer?.backgroundColor = ThemeManager.shared.gridCellIconBackground.cgColor + + // Update icon tint color + iconImageView.contentTintColor = ThemeManager.shared.primaryTextColor + + // Update label shadow + let shadow = NSShadow() + shadow.shadowColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.4) + shadow.shadowBlurRadius = 4 + shadow.shadowOffset = NSSize(width: 0, height: -1) + label.shadow = shadow + + // Update enabled state if disabled + if !isEnabled { + updateEnabledState() + } + } +} + +// MARK: - Legacy code removed - use thumbnail-based BGR workflow instead + diff --git a/ShotScreen/Sources/ButtonHoverExtension.swift b/ShotScreen/Sources/ButtonHoverExtension.swift new file mode 100644 index 0000000..401a3a2 --- /dev/null +++ b/ShotScreen/Sources/ButtonHoverExtension.swift @@ -0,0 +1,236 @@ +import AppKit + +// Extension voor NSButton hover effecten +extension NSButton { + private struct AssociatedKeys { + static var hoverEffectScale: UInt8 = 0 + static var originalTransform: UInt8 = 1 + static var originalAnchorPoint: UInt8 = 2 + static var originalPosition: UInt8 = 3 + static var trackingArea: UInt8 = 4 + } + + private var hoverEffectScale: CGFloat? { + get { + objc_getAssociatedObject(self, &AssociatedKeys.hoverEffectScale) as? CGFloat + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.hoverEffectScale, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private var originalTransform: CATransform3D? { + get { + guard let value = objc_getAssociatedObject(self, &AssociatedKeys.originalTransform) as? NSValue else { return nil } + var transform = CATransform3DIdentity + value.getValue(&transform) + return transform + } + set { + var valueToSet: NSValue? = nil + if let transform = newValue { + valueToSet = NSValue(caTransform3D: transform) + } + objc_setAssociatedObject(self, &AssociatedKeys.originalTransform, valueToSet, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private var originalAnchorPointForHover: CGPoint? { + get { + objc_getAssociatedObject(self, &AssociatedKeys.originalAnchorPoint) as? CGPoint + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.originalAnchorPoint, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private var originalPositionForHover: CGPoint? { + get { + objc_getAssociatedObject(self, &AssociatedKeys.originalPosition) as? CGPoint + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.originalPosition, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private var hoverTrackingArea: NSTrackingArea? { + get { + objc_getAssociatedObject(self, &AssociatedKeys.trackingArea) as? NSTrackingArea + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.trackingArea, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + func addHoverEffect(scale: CGFloat = 1.2) { + self.wantsLayer = true + self.hoverEffectScale = scale + + if self.originalTransform == nil, let layer = self.layer { + self.originalTransform = layer.transform + } + + if self.window != nil { + self.updateTrackingAreas() + } + } + + override open func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + + // Check if this button uses the color change effect (our new simple hover) + if objc_getAssociatedObject(self, "useZoomColorEffect") != nil { + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + context.allowsImplicitAnimation = true + self.animator().contentTintColor = NSColor.white // Bright white on hover + }) + return + } + + // Check if this button uses the new hover effect + if objc_getAssociatedObject(self, "useNewHoverEffect") != nil { + // Use new hover effect with zoom and color change + if let hoverHandler = objc_getAssociatedObject(self, "hoverHandler") as? ButtonHoverHandler { + hoverHandler.mouseEntered(with: event) + } + return + } + + // ONLY process buttons that explicitly have hover effects enabled (old zoom system) + guard let scale = self.hoverEffectScale else { + return + } + + guard let layer = self.layer else { + return + } + + // Extra safety: Check if window is still valid + guard self.window != nil else { + return + } + + if self.originalTransform == nil { + self.originalTransform = layer.transform + } + if self.originalAnchorPointForHover == nil { + self.originalAnchorPointForHover = layer.anchorPoint + } + if self.originalPositionForHover == nil { + self.originalPositionForHover = layer.position + } + + layer.anchorPoint = CGPoint(x: 0.5, y: 0.5) + if let oPos = self.originalPositionForHover, let oAP = self.originalAnchorPointForHover { + layer.position = CGPoint(x: oPos.x + (layer.anchorPoint.x - oAP.x) * layer.bounds.width, + y: oPos.y + (layer.anchorPoint.y - oAP.y) * layer.bounds.height) + } + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + context.allowsImplicitAnimation = true + layer.transform = CATransform3DScale(self.originalTransform ?? CATransform3DIdentity, scale, scale, 1) + }, completionHandler: nil) + } + + override open func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + + // Check if this button uses the color change effect (our new simple hover) + if objc_getAssociatedObject(self, "useZoomColorEffect") != nil { + let originalColor = objc_getAssociatedObject(self, "originalColor") as? NSColor ?? NSColor(white: 0.8, alpha: 1.0) + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.25 + context.allowsImplicitAnimation = true + self.animator().contentTintColor = originalColor // Restore original color + }) + return + } + + // Check if this button uses the new hover effect + if objc_getAssociatedObject(self, "useNewHoverEffect") != nil { + // Use new hover effect with zoom and color change + if let hoverHandler = objc_getAssociatedObject(self, "hoverHandler") as? ButtonHoverHandler { + hoverHandler.mouseExited(with: event) + } + return + } + + // ONLY process buttons that explicitly have hover effects enabled (old zoom system) + guard self.hoverEffectScale != nil else { + return + } + + guard let layer = self.layer else { + return + } + + // Extra safety: Check if window is still valid + guard self.window != nil else { + return + } + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.25 + context.allowsImplicitAnimation = true + layer.transform = self.originalTransform ?? CATransform3DIdentity + }, completionHandler: { [weak self] in + // Extra safety in completion handler + guard let self = self, + let safeLayer = self.layer, + self.window != nil else { + return + } + + if let oAP = self.originalAnchorPointForHover { + safeLayer.anchorPoint = oAP + } + if let oPos = self.originalPositionForHover { + safeLayer.position = oPos + } + }) + } + + override open func viewWillMove(toSuperview newSuperview: NSView?) { + super.viewWillMove(toSuperview: newSuperview) + if newSuperview == nil, let trackingArea = self.hoverTrackingArea { + self.removeTrackingArea(trackingArea) + self.hoverTrackingArea = nil + } + } + + override open func updateTrackingAreas() { + super.updateTrackingAreas() + + // Check if this button uses the new hover effect + let hasNewHoverEffect = objc_getAssociatedObject(self, "useNewHoverEffect") + + if hasNewHoverEffect != nil { + // Don't interfere with custom tracking areas for new hover effect + return + } + + // Check if this button uses the color change effect + let hasColorEffect = objc_getAssociatedObject(self, "useZoomColorEffect") + + // Process buttons that have ANY hover effect (old system OR new color effect) + guard self.hoverEffectScale != nil || hasColorEffect != nil else { + return + } + + if let existingTrackingArea = self.hoverTrackingArea { + self.removeTrackingArea(existingTrackingArea) + } + guard self.bounds != .zero else { + return + } + + let trackingArea = NSTrackingArea(rect: self.bounds, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil) + self.addTrackingArea(trackingArea) + self.hoverTrackingArea = trackingArea + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/Config.swift b/ShotScreen/Sources/Config.swift new file mode 100644 index 0000000..b1da3e2 --- /dev/null +++ b/ShotScreen/Sources/Config.swift @@ -0,0 +1,88 @@ +import Foundation + +struct AppConfig { + // MARK: - PicoShare Configuration (Synology Setup) + static let picoshareBaseURL = "ps.plet.i234.me" // Je Synology domein + static let useHTTPS = true + + // MARK: - PicoShare URL (Direct Access - nginx protected) + static var modelDownloadURL: String { + // Environment variable voor development/testing + if let envURL = ProcessInfo.processInfo.environment["SHOTSCREEN_MODEL_URL"] { + return envURL + } + + // Direct URL (nginx User-Agent filtering provides security) + return "https://\(picoshareBaseURL)/-2xXXWuMFfW" + } + + private static func generatePicoShareURL() -> String { + // Tijd-component (verandert elk uur voor security) + let hourComponent = Int(Date().timeIntervalSince1970) / 3600 + + // Base64 encoded delen - โœ… CONFIGURED VOOR JOUW PICOSHARE + let serverPart = "aHR0cHM6Ly9wcy5wbGV0LmkyMzQubWUv" // https://ps.plet.i234.me/ + let filePart = "LTJ4WFhXdU1GZlc=" // -2xXXWuMFfW (jouw PicoShare ID) + + guard let serverData = Data(base64Encoded: serverPart), + let fileData = Data(base64Encoded: filePart), + let serverURL = String(data: serverData, encoding: .utf8), + let fileName = String(data: fileData, encoding: .utf8) else { + // Fallback naar directe URL als obfuscation faalt + return "https://\(picoshareBaseURL)/-2xXXWuMFfW" + } + + // XOR obfuscation met tijd-component (verandert elk uur) + let timeKey = UInt8(hourComponent % 256) + let xorKey: UInt8 = 0x5A ^ timeKey // Combineer met tijd + + let obfuscatedFileName = fileName.map { char in + let ascii = char.asciiValue ?? 0 + let xored = ascii ^ xorKey + // Zorg dat het een geldig karakter blijft + if let validChar = UnicodeScalar(Int(xored)), validChar.isASCII { + return String(Character(validChar)) + } else { + return String(char) // Behoud origineel als XOR ongeldig + } + }.joined() + + print("๐Ÿ” Generated time-based URL for hour: \(hourComponent)") + return serverURL + obfuscatedFileName + } + + // MARK: - Enhanced Security Headers for PicoShare + static var secureHeaders: [String: String] { + let timestamp = String(Int(Date().timeIntervalSince1970)) + let requestId = UUID().uuidString.prefix(12) + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" + + return [ + "User-Agent": "ShotScreen/\(version) (Build \(build); macOS; Synology)", + "X-App-Version": version, + "X-Request-ID": String(requestId), + "X-Timestamp": timestamp, + "X-Client-Type": "ShotScreen-Official", + "X-Device-Type": "macOS", + "Accept": "application/octet-stream", + "Cache-Control": "no-cache" + ] + } + + // MARK: - Network Security + static var userAgent: String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" + return "ShotScreen/\(version) (Build \(build); macOS; Synology)" + } + + static var maxDownloadSize: Int64 { + return 200 * 1024 * 1024 // 200MB max + } + + // MARK: - PicoShare URL Helper (voor handmatige setup) + static func generateDirectPicoShareURL(fileID: String) -> String { + return "https://\(picoshareBaseURL)/\(fileID)" + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/CrosshairViews.swift b/ShotScreen/Sources/CrosshairViews.swift new file mode 100644 index 0000000..0f1b44d --- /dev/null +++ b/ShotScreen/Sources/CrosshairViews.swift @@ -0,0 +1,379 @@ +import AppKit + +// MARK: - Custom Crosshair View +class CrosshairView: NSView { + private var crosshairPosition: NSPoint = NSPoint.zero + private let crosshairSize: CGFloat = 20 + private let lineWidth: CGFloat = 2 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.wantsLayer = true + self.layer?.backgroundColor = NSColor.clear.cgColor + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateCrosshairPosition(_ position: NSPoint) { + crosshairPosition = position + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + guard let context = NSGraphicsContext.current?.cgContext else { return } + + // Clear the view + context.clear(bounds) + + // Set crosshair color (white with black outline for visibility) + context.setStrokeColor(NSColor.white.cgColor) + context.setLineWidth(lineWidth + 1) + + // Draw black outline + context.setStrokeColor(NSColor.black.cgColor) + drawCrosshair(in: context, at: crosshairPosition, size: crosshairSize + 2) + + // Draw white crosshair + context.setStrokeColor(NSColor.white.cgColor) + context.setLineWidth(lineWidth) + drawCrosshair(in: context, at: crosshairPosition, size: crosshairSize) + } + + private func drawCrosshair(in context: CGContext, at position: NSPoint, size: CGFloat) { + let halfSize = size / 2 + + // Horizontal line + context.move(to: CGPoint(x: position.x - halfSize, y: position.y)) + context.addLine(to: CGPoint(x: position.x + halfSize, y: position.y)) + + // Vertical line + context.move(to: CGPoint(x: position.x, y: position.y - halfSize)) + context.addLine(to: CGPoint(x: position.x, y: position.y + halfSize)) + + context.strokePath() + } +} + +class CrosshairCursorView: NSView { + private var trackingArea: NSTrackingArea? + private var cursorSize: CGFloat = 24.0 + private var mouseLocation: NSPoint = .zero + + // ๐Ÿ”ง NEW: Event-driven approach instead of timer-based + private var globalMouseMonitor: Any? + private var localMouseMonitor: Any? + private var isTrackingActive: Bool = false + private var lastUpdateTime: Date = Date() + private var healthCheckTimer: Timer? + + // NIEUW: Enum voor crosshair modus + enum CrosshairMode { + case normal + case allScreensActive + } + + // NIEUW: Property om de huidige modus bij te houden + var currentMode: CrosshairMode = .normal { + didSet { + if oldValue != currentMode { + DispatchQueue.main.async { // Zorg dat UI updates op main thread gebeuren + self.needsDisplay = true + } + } + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.wantsLayer = true + + // Make view transparent to mouse events + self.isHidden = true // Start hidden + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + // ๐Ÿ”ง CRITICAL: Clean up on deallocation + print("๐Ÿงน DEBUG: CrosshairCursorView deinit - cleaning up") + stopTracking() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + if let existingTrackingArea = trackingArea { + self.removeTrackingArea(existingTrackingArea) + } + + // Track the entire window, not just this view + if let window = self.window { + let options: NSTrackingArea.Options = [.mouseMoved, .activeAlways, .mouseEnteredAndExited] + trackingArea = NSTrackingArea(rect: window.contentView?.bounds ?? self.bounds, + options: options, + owner: self, + userInfo: nil) + + if let trackingArea = trackingArea { + window.contentView?.addTrackingArea(trackingArea) + } + } + } + + // ๐Ÿ”ง NEW: Event-driven tracking system - much more reliable! + func startTracking() { + // Stop any existing tracking first + stopTracking() + + print("๐ŸŽฏ DEBUG: Starting event-driven crosshair tracking") + + // ๐Ÿ”ง CRITICAL FIX: Only start if not already active + guard !isTrackingActive else { + print("โš ๏ธ DEBUG: Tracking already active, skipping start") + return + } + + isTrackingActive = true + self.isHidden = false + + // ๐Ÿ”ง SOLUTION: Use global mouse monitoring for INSTANT response + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged]) { [weak self] event in + guard let self = self, self.isTrackingActive else { return } + DispatchQueue.main.async { + self.handleMouseEvent(event) + } + } + + // ๐Ÿ”ง SOLUTION: Also monitor local events for when mouse is over the app + localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged]) { [weak self] event in + guard let self = self, self.isTrackingActive else { return event } + DispatchQueue.main.async { + self.handleMouseEvent(event) + } + return event + } + + // ๐Ÿ”ง SOLUTION: Force initial position update + DispatchQueue.main.async { [weak self] in + self?.updateToCurrentMousePosition() + } + + // ๐Ÿ”ง NEW: Start health check timer + startHealthCheck() + + print("โœ… DEBUG: Event-driven tracking started successfully") + } + + private func startHealthCheck() { + // Stop any existing health check + healthCheckTimer?.invalidate() + + // Start new health check every 0.5 seconds + healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + guard let self = self else { return } + + let timeSinceLastUpdate = Date().timeIntervalSince(self.lastUpdateTime) + if timeSinceLastUpdate > 1.0 && self.isTrackingActive { + print("โš ๏ธ DEBUG: Crosshair hasn't updated in \(timeSinceLastUpdate)s, forcing position update") + DispatchQueue.main.async { + self.updateToCurrentMousePosition() + } + } + } + } + + // ๐Ÿ”ง NEW: Simplified fallback position update + private func updatePositionFallback(screenLocation: NSPoint) { + // Use screen coordinates with basic offset (less precise but always works) + let targetLocation = NSPoint(x: screenLocation.x - 100, y: screenLocation.y - 100) + updatePosition(location: targetLocation) + } + + func stopTracking() { + print("๐Ÿ›‘ DEBUG: Stopping event-driven crosshair tracking") + + // ๐Ÿ”ง CRITICAL FIX: Only stop if actually tracking + guard isTrackingActive else { + print("โš ๏ธ DEBUG: Tracking not active, skipping stop") + return + } + + isTrackingActive = false + + // ๐Ÿ”ง SOLUTION: Remove event monitors cleanly + if let globalMonitor = globalMouseMonitor { + NSEvent.removeMonitor(globalMonitor) + globalMouseMonitor = nil + print("โœ… DEBUG: Global monitor removed") + } + + if let localMonitor = localMouseMonitor { + NSEvent.removeMonitor(localMonitor) + localMouseMonitor = nil + print("โœ… DEBUG: Local monitor removed") + } + + self.isHidden = true + + // ๐Ÿ”ง NEW: Stop health check timer + healthCheckTimer?.invalidate() + healthCheckTimer = nil + + print("โœ… DEBUG: Event monitors removed successfully") + } + + // ๐Ÿ”ง NEW: Event handler for mouse movements + private func handleMouseEvent(_ event: NSEvent) { + guard isTrackingActive else { return } + + // ๐Ÿ”ง ROBUSTNESS: Check if we still have a valid window + guard self.window != nil else { + print("โš ๏ธ DEBUG: No window available, stopping tracking") + stopTracking() + return + } + + updateToCurrentMousePosition() + } + + // ๐Ÿ”ง NEW: Direct mouse position update - much more reliable + private func updateToCurrentMousePosition() { + guard isTrackingActive else { return } + + // ๐Ÿ”ง ROBUSTNESS: Ensure we're on main thread + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.updateToCurrentMousePosition() + } + return + } + + // Get current mouse position in screen coordinates + let mouseLocationScreen = NSEvent.mouseLocation + + // Convert to our coordinate system + if let targetLocation = convertScreenToViewCoordinates(mouseLocationScreen) { + updatePosition(location: targetLocation) + } else { + print("โš ๏ธ DEBUG: Failed to convert coordinates, using fallback") + updatePositionFallback(screenLocation: mouseLocationScreen) + } + } + + // ๐Ÿ”ง NEW: Robust coordinate conversion with fallback + private func convertScreenToViewCoordinates(_ screenLocation: NSPoint) -> NSPoint? { + guard let window = self.window else { + print("โš ๏ธ DEBUG: No window available for coordinate conversion") + return nil + } + + // ๐Ÿ”ง ROBUSTNESS: Validate window is visible + guard window.isVisible else { + print("โš ๏ธ DEBUG: Window not visible for coordinate conversion") + return nil + } + + let windowLocation = window.convertPoint(fromScreen: screenLocation) + + if let superview = self.superview { + return superview.convert(windowLocation, from: nil) + } else { + return windowLocation + } + } + + func updatePosition(location: NSPoint) { + // ๐Ÿ”ง SIMPLIFIED: Basic validation and direct update + guard location.x.isFinite && location.y.isFinite else { + return + } + + // ๐Ÿ”ง SIMPLIFIED: Check if position changed significantly + let tolerance: CGFloat = 1.0 + if abs(self.mouseLocation.x - location.x) < tolerance && + abs(self.mouseLocation.y - location.y) < tolerance { + return + } + + self.mouseLocation = location + self.lastUpdateTime = Date() // Update timestamp + + // ๐Ÿ”ง SIMPLIFIED: Direct frame update (we're already on main thread from event handler) + CATransaction.begin() + CATransaction.setDisableActions(true) + + let newFrame = NSRect(x: location.x - cursorSize/2, + y: location.y - cursorSize/2, + width: cursorSize, + height: cursorSize) + + if newFrame.width > 0 && newFrame.height > 0 { + self.frame = newFrame + self.needsDisplay = true + } + + CATransaction.commit() + } + + override func mouseMoved(with event: NSEvent) { + // ๐Ÿ”ง SIMPLIFIED: Use new event-driven system + handleMouseEvent(event) + } + + override func mouseEntered(with event: NSEvent) { + // ๐Ÿ”ง SIMPLIFIED: Use new event-driven system + handleMouseEvent(event) + } + + override func mouseDragged(with event: NSEvent) { + // ๐Ÿ”ง SIMPLIFIED: Use new event-driven system + handleMouseEvent(event) + } + + override func draw(_ dirtyRect: NSRect) { + // Draw a crosshair cursor + guard let context = NSGraphicsContext.current?.cgContext else { return } + + // Standaard crosshair (wit met zwarte outline) + context.setStrokeColor(NSColor.black.cgColor) + context.setLineWidth(1.0) // Outline dikte + // Horizontale lijn (outline boven) + context.move(to: CGPoint(x: 0, y: cursorSize/2 - 1.5)) + context.addLine(to: CGPoint(x: cursorSize, y: cursorSize/2 - 1.5)) + // Horizontale lijn (outline onder) + context.move(to: CGPoint(x: 0, y: cursorSize/2 + 1.5)) + context.addLine(to: CGPoint(x: cursorSize, y: cursorSize/2 + 1.5)) + // Verticale lijn (outline links) + context.move(to: CGPoint(x: cursorSize/2 - 1.5, y: 0)) + context.addLine(to: CGPoint(x: cursorSize/2 - 1.5, y: cursorSize)) + // Verticale lijn (outline rechts) + context.move(to: CGPoint(x: cursorSize/2 + 1.5, y: 0)) + context.addLine(to: CGPoint(x: cursorSize/2 + 1.5, y: cursorSize)) + context.strokePath() + + context.setStrokeColor(NSColor.white.cgColor) + context.setLineWidth(2.0) // Dikte van witte crosshair + // Horizontale lijn (wit) + context.move(to: CGPoint(x: 0, y: cursorSize/2)) + context.addLine(to: CGPoint(x: cursorSize, y: cursorSize/2)) + // Verticale lijn (wit) + context.move(to: CGPoint(x: cursorSize/2, y: 0)) + context.addLine(to: CGPoint(x: cursorSize/2, y: cursorSize)) + context.strokePath() + + // Teken extra elementen gebaseerd op modus + if currentMode == .allScreensActive { + context.setStrokeColor(NSColor.systemBlue.withAlphaComponent(0.8).cgColor) // Lichtblauwe kleur + context.setLineWidth(2.0) + let circleRect = NSRect(x: 1, y: 1, width: cursorSize - 2, height: cursorSize - 2) // Iets kleiner dan de crosshair bounds + context.addEllipse(in: circleRect) + context.strokePath() + } + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/DesktopIconManager.swift b/ShotScreen/Sources/DesktopIconManager.swift new file mode 100644 index 0000000..b881d0c --- /dev/null +++ b/ShotScreen/Sources/DesktopIconManager.swift @@ -0,0 +1,186 @@ +// +// DesktopIconManager.swift +// ScreenShot +// +// Created by [Your Name/AI] on [Current Date]. +// Copyright ยฉ 2025 [Your Name/AI]. All rights reserved. +// + +import Foundation +import ScreenCaptureKit + +@available(macOS 12.3, *) +class DesktopIconManager { + + static let shared = DesktopIconManager() + + private init() { + // Private constructor for singleton + } + + func logFinderWindowDetails(windows: [SCWindow]) { + NSLog("๐Ÿ•ต๏ธโ€โ™‚๏ธ DesktopIconManager: Logging details for Finder windows...") + let finderWindows = windows.filter { $0.owningApplication?.bundleIdentifier == "com.apple.finder" } + + if finderWindows.isEmpty { + NSLog("๐Ÿ•ต๏ธโ€โ™‚๏ธ No Finder windows found in the provided list.") + return + } + + NSLog("๐Ÿ•ต๏ธโ€โ™‚๏ธ Found \(finderWindows.count) Finder windows:") + for (index, window) in finderWindows.enumerated() { + let appName = window.owningApplication?.applicationName ?? "Unknown" + let bundleID = window.owningApplication?.bundleIdentifier ?? "Unknown" + + NSLog(" --------------------------------------------------") + NSLog(" ๐Ÿ•ต๏ธโ€โ™‚๏ธ Finder Window [\(index)] Details:") + NSLog(" --------------------------------------------------") + NSLog(" Title: '\(window.title ?? "N/A")'") + NSLog(" App Name: \(appName)") + NSLog(" Bundle ID: \(bundleID)") + NSLog(" Window ID: \(window.windowID)") + NSLog(" Layer: \(window.windowLayer)") + NSLog(" Frame (SCK Coords): \(NSStringFromRect(window.frame))") + NSLog(" Is OnScreen: \(window.isOnScreen)") + if #available(macOS 13.1, *) { + NSLog(" Is Active: \(window.isActive)") + } else { + NSLog(" Is Active: (N/A on this macOS version)") + } + NSLog(" --------------------------------------------------") + } + } + + /// Checks if desktop icon hiding is currently enabled in user settings + /// - Returns: true if desktop icons should be hidden during screenshots + func isDesktopIconHidingEnabled() -> Bool { + return SettingsManager.shared.hideDesktopIconsDuringScreenshot + } + + /// Checks if desktop widget hiding is currently enabled in user settings + /// - Returns: true if desktop widgets should be hidden during screenshots + func isDesktopWidgetHidingEnabled() -> Bool { + return SettingsManager.shared.hideDesktopWidgetsDuringScreenshot + } + + /// Detects desktop widgets from available windows + /// This function identifies widgets based on various criteria such as bundle identifiers and window characteristics + /// - Parameter windows: Array of SCWindow objects to analyze + /// - Returns: Array of SCWindow objects that are likely desktop widgets + func detectDesktopWidgets(from windows: [SCWindow]) -> [SCWindow] { + guard isDesktopWidgetHidingEnabled() else { + NSLog("๐Ÿ”„ DesktopIconManager: Widget hiding is disabled, returning empty array") + return [] + } + + NSLog("๐Ÿ” DesktopIconManager: Starting widget detection...") + var potentialWidgets: [SCWindow] = [] + + // Known widget bundle identifiers (based on research) + let knownWidgetBundleIdentifiers = [ + "com.apple.controlcenter", + "com.apple.notificationcenter", + "com.apple.dashboard", + "com.apple.widget", + "com.apple.WidgetKit", + "com.apple.widgets" + ] + + // Widget-like window characteristics + let widgetWindowTitles = [ + "Widget", + "Dashboard", + "Control Center", + "Notification", + "Today" + ] + + for window in windows { + let bundleId = window.owningApplication?.bundleIdentifier ?? "" + let appName = window.owningApplication?.applicationName ?? "" + let windowTitle = window.title ?? "" + let windowLayer = window.windowLayer + + var isWidget = false + var detectionReason = "" + + // Check 1: Known widget bundle identifiers + if knownWidgetBundleIdentifiers.contains(where: { bundleId.contains($0) }) { + isWidget = true + detectionReason = "Known widget bundle ID: \(bundleId)" + } + + // Check 2: Widget-like window titles + if !isWidget && widgetWindowTitles.contains(where: { windowTitle.contains($0) }) { + isWidget = true + detectionReason = "Widget-like window title: '\(windowTitle)'" + } + + // Check 3: Window layer characteristics (widgets often appear on specific layers) + // Widgets may appear on desktop-level layers (often higher than normal windows) + if !isWidget && windowLayer > 25 && windowLayer < 1000 { + // Additional checks for this layer range + if appName.lowercased().contains("widget") || + bundleId.lowercased().contains("widget") || + windowTitle.lowercased().contains("widget") { + isWidget = true + detectionReason = "High window layer (\(windowLayer)) with widget-related naming" + } + } + + // Check 4: Window size characteristics (many widgets are small, square-ish windows) + let frame = window.frame + if !isWidget && frame.width > 0 && frame.height > 0 { + let aspectRatio = frame.width / frame.height + let area = frame.width * frame.height + + // Small to medium sized windows with square-ish aspect ratios + if area < 50000 && aspectRatio >= 0.5 && aspectRatio <= 2.0 { + if bundleId.contains("apple") && ( + windowTitle.isEmpty || + windowTitle.count < 3 || + bundleId.contains("notification") || + bundleId.contains("control") + ) { + isWidget = true + detectionReason = "Small Apple window with widget characteristics (area: \(Int(area)), ratio: \(String(format: "%.2f", aspectRatio)))" + } + } + } + + if isWidget { + potentialWidgets.append(window) + NSLog("๐Ÿ“ฑ Widget detected: \(detectionReason)") + NSLog(" App: \(appName) (\(bundleId))") + NSLog(" Title: '\(windowTitle)'") + NSLog(" Layer: \(windowLayer)") + NSLog(" Frame: \(NSStringFromRect(frame))") + } + } + + NSLog("๐Ÿ” DesktopIconManager: Found \(potentialWidgets.count) potential desktop widgets") + return potentialWidgets + } + + /// Legacy placeholder for future window filtering functionality + /// Note: The main desktop icon filtering logic is now handled in ScreenCaptureKitProvider.getDesktopIconWindows() + /// which respects the hideDesktopIconsDuringScreenshot setting from SettingsManager + func shouldExcludeWindowForDesktopIconHiding(_ window: SCWindow) -> Bool { + // Desktop icon filtering is now handled in ScreenCaptureKitProvider.getDesktopIconWindows() + // This method is kept for potential future advanced filtering logic + return false + } + + /// Determines if a window should be excluded for widget hiding + /// - Parameter window: The window to check + /// - Returns: true if the window should be excluded (i.e., it's a widget and widget hiding is enabled) + func shouldExcludeWindowForWidgetHiding(_ window: SCWindow) -> Bool { + guard isDesktopWidgetHidingEnabled() else { + return false + } + + // Use the widget detection logic to determine if this single window is a widget + let widgets = detectDesktopWidgets(from: [window]) + return !widgets.isEmpty + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/DraggableImageView.swift b/ShotScreen/Sources/DraggableImageView.swift new file mode 100644 index 0000000..1f8ae95 --- /dev/null +++ b/ShotScreen/Sources/DraggableImageView.swift @@ -0,0 +1,213 @@ +import AppKit + +// MARK: - DraggableImageView Protocol +protocol DraggableImageViewClickHandler: AnyObject { + func thumbnailWasClicked(image: NSImage) +} + +// MARK: - DraggableImageView +class DraggableImageView: NSImageView { + var onDragStart: (() -> Void)? + weak var appDelegate: ScreenshotApp? + private var mouseDownEvent: NSEvent? + private let dragThreshold: CGFloat = 3.0 + private var isPerformingDrag: Bool = false + + // ๐ŸŽจ NEW: Track the file URL for the current image (for BGR mode) + var imageURL: URL? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.imageScaling = .scaleProportionallyUpOrDown + self.imageAlignment = .alignCenter + self.animates = true + self.imageFrameStyle = .none + self.registerForDraggedTypes([.fileURL, .URL, .tiff, .png]) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + self.imageScaling = .scaleProportionallyUpOrDown + self.imageAlignment = .alignCenter + self.animates = true + self.imageFrameStyle = .none + self.registerForDraggedTypes([.fileURL, .URL, .tiff, .png]) + } + + override func mouseDown(with event: NSEvent) { + self.mouseDownEvent = event + self.isPerformingDrag = false + } + + override func mouseDragged(with event: NSEvent) { + guard let mouseDownEvent = self.mouseDownEvent else { + super.mouseDragged(with: event) + return + } + + if !isPerformingDrag { + let dragThreshold: CGFloat = 3.0 + let deltaX = abs(event.locationInWindow.x - mouseDownEvent.locationInWindow.x) + let deltaY = abs(event.locationInWindow.y - mouseDownEvent.locationInWindow.y) + + if deltaX > dragThreshold || deltaY > dragThreshold { + isPerformingDrag = true + self.mouseDownEvent = nil + + guard let unwrappedAppDelegate = appDelegate else { + isPerformingDrag = false + return + } + + // ๐ŸŽจ FIXED: Check for available URL (BGR mode or normal mode) before proceeding + let sourceURLForDrag = self.imageURL ?? unwrappedAppDelegate.tempURL + + guard let finalSourceURL = sourceURLForDrag else { + print("โŒ DraggableImageView: No valid URL available for dragging (imageURL: \(imageURL?.path ?? "nil"), tempURL: \(unwrappedAppDelegate.tempURL?.path ?? "nil"))") + isPerformingDrag = false + return + } + + print("๐ŸŽฏ DraggableImageView: Starting drag with URL: \(finalSourceURL.path)") + + if let preview = unwrappedAppDelegate.activePreviewWindow, preview.isVisible { + preview.orderOut(nil as Any?) + } + unwrappedAppDelegate.gridViewManager?.showGrid(previewFrame: self.window?.frame) + + // NIEUW: Start drag session voor proximity monitoring + unwrappedAppDelegate.gridViewManager?.startDragSession() + + let fileItem = NSDraggingItem(pasteboardWriter: finalSourceURL as NSURL) + + if let imageToDrag = self.image { + let fullFrame = convert(bounds, to: nil) + + let scale: CGFloat = 0.05 + let yOffset: CGFloat = 30 + + let scaledFrame = NSRect( + x: fullFrame.midX - fullFrame.width * scale / 2, + y: fullFrame.midY - fullFrame.height * scale / 2 - yOffset, + width: fullFrame.width * scale, + height: fullFrame.height * scale + ) + fileItem.setDraggingFrame(scaledFrame, contents: imageToDrag) + } + let items: [NSDraggingItem] = [fileItem] + beginDraggingSession(with: items, event: event, source: self) + } else { + return + } + } + } + + override func mouseUp(with event: NSEvent) { + if !isPerformingDrag { + if let image = self.image, let appDelegate = self.appDelegate { + appDelegate.thumbnailWasClicked(image: image) + } + super.mouseUp(with: event) + } + self.mouseDownEvent = nil + self.isPerformingDrag = false + } +} + +// MARK: - NSDraggingSource +extension DraggableImageView: NSDraggingSource { + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + return .copy + } + + func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) { + // Drag session beginning + } + + func draggingSession(_ session: NSDraggingSession, movedTo screenPoint: NSPoint) { + guard let appDel = self.appDelegate, + let gridManager = appDel.gridViewManager, + let gridWindow = gridManager.gridWindow else { return } + + let gridFrame = gridWindow.frame + + let distanceToGrid = min( + abs(screenPoint.x - gridFrame.minX), + abs(screenPoint.x - gridFrame.maxX) + ) + + // Update visual feedback based on proximity + if Int(distanceToGrid) % 50 == 0 { + let minScale: CGFloat = 0.05 + let maxScale: CGFloat = 0.35 + let maxDistance: CGFloat = 300 + + if distanceToGrid < maxDistance { + let progress = distanceToGrid / maxDistance + _ = minScale + (progress * (maxScale - minScale)) + } + } + } + + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + isPerformingDrag = false + + // NIEUW: Stop drag session direct na drag end + appDelegate?.gridViewManager?.stopDragSession() + + DispatchQueue.main.async { [weak self] in + guard let self = self, let appDel = self.appDelegate else { + return + } + + let didDropOnGridAction = appDel.didGridHandleDrop + let didDropOnStashGridAction = appDel.didStashGridHandleDrop + let didDropOnAnyGridAction = didDropOnGridAction || didDropOnStashGridAction + let closeAfterDragSetting = SettingsManager.shared.closeAfterDrag + + if !didDropOnAnyGridAction && appDel.gridViewManager?.gridWindow != nil { + appDel.gridViewManager?.hideGrid(monitorForReappear: false) + } + + if didDropOnAnyGridAction { + // Drop handled by grid action (main or stash). Preview management deferred to grid action handler. + print("๐Ÿ”„ DraggableImageView: Grid action detected (main: \(didDropOnGridAction), stash: \(didDropOnStashGridAction))") + + // ๐Ÿ”ง CRITICAL FIX: Handle ALL grid actions (both main and stash) with closeAfterDrag setting + print("๐Ÿ”„ Grid action completed - applying closeAfterDrag setting: \(closeAfterDragSetting)") + if closeAfterDragSetting { + print("๐Ÿ”„ Closing thumbnail after grid action due to closeAfterDrag setting") + appDel.closePreviewWithAnimation(immediate: false, preserveTempFile: false) + } else { + print("๐Ÿ”„ Keeping thumbnail visible after grid action (closeAfterDrag is OFF)") + appDel.ensurePreviewVisible() + } + } else { + if operation != [] { + if closeAfterDragSetting { + appDel.closePreviewWithAnimation(immediate: true) + } else { + appDel.ensurePreviewVisible() + } + } else { + appDel.ensurePreviewVisible() + } + } + + // Reset both flags + appDel.didGridHandleDrop = false + appDel.didStashGridHandleDrop = false + } + } +} + +// MARK: - NSImage Extension for PNG Data +extension NSImage { + func pngData() -> Data? { + guard let tiffData = self.tiffRepresentation, + let bitmapRep = NSBitmapImageRep(data: tiffData) else { + return nil + } + return bitmapRep.representation(using: .png, properties: [:]) + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/EventCapture.swift b/ShotScreen/Sources/EventCapture.swift new file mode 100644 index 0000000..9891a83 --- /dev/null +++ b/ShotScreen/Sources/EventCapture.swift @@ -0,0 +1,280 @@ +import AppKit + +// MARK: - Event Capture Window +class EventCaptureWindow: NSWindow { + override var canBecomeKey: Bool { + return true + } + + override var canBecomeMain: Bool { + return true + } + + override func sendEvent(_ event: NSEvent) { + // During screenshot selection, we want to capture ALL events + // and prevent them from reaching other windows + switch event.type { + case .leftMouseDown, .leftMouseUp, .leftMouseDragged, + .rightMouseDown, .rightMouseUp, .rightMouseDragged, + .otherMouseDown, .otherMouseUp, .otherMouseDragged, + .mouseMoved, .mouseEntered, .mouseExited, + .scrollWheel, .keyDown, .keyUp: + // Process the event normally through our view hierarchy + super.sendEvent(event) + // DO NOT pass the event to other windows - this prevents + // other windows from being dragged or interacted with + return + default: + // For other event types, allow normal processing + super.sendEvent(event) + } + } +} + +// MARK: - Event Capture View +class EventCaptureView: NSView { + weak var screenshotApp: ScreenshotApp? + var shouldDisplayAllScreensActiveText: Bool = false // Nieuwe property + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + private func setupView() { + // This view will handle mouse events - no special setup needed + self.wantsLayer = true // Zorg ervoor dat de view een layer heeft voor efficiรซnt tekenen + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) // Belangrijk voor standaard teken gedrag + + if shouldDisplayAllScreensActiveText { + // Teken "Alle Schermen Actief" tekst-overlay bovenaan het scherm van deze view + let instructionText = "All Screens Mode, Click to Capture, ESC to Cancel" + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 18, weight: .medium), + .foregroundColor: NSColor.white.withAlphaComponent(0.9), + .strokeColor: NSColor.black.withAlphaComponent(0.5), // Tekst outline + .strokeWidth: -2.0, // Negatief voor outline binnen de letters + .paragraphStyle: { + let style = NSMutableParagraphStyle() + style.alignment = .center + return style + }() + ] + let attributedString = NSAttributedString(string: instructionText, attributes: attributes) + let textSize = attributedString.size() + + // self.bounds is de grootte van deze EventCaptureView, die het hele scherm beslaat. + let viewBounds = self.bounds + + let textRect = NSRect(x: (viewBounds.width - textSize.width) / 2, + y: viewBounds.height - textSize.height - 30, // 30px van de top van het scherm + width: textSize.width, + height: textSize.height) + + let backgroundPadding: CGFloat = 10 + let backgroundRect = NSRect( + x: textRect.origin.x - backgroundPadding, + y: textRect.origin.y - backgroundPadding, + width: textRect.width + (2 * backgroundPadding), + height: textRect.height + (2 * backgroundPadding) + ) + let BORDER_RADIUS: CGFloat = 10 + let textBackgroundPath = NSBezierPath(roundedRect: backgroundRect, xRadius: BORDER_RADIUS, yRadius: BORDER_RADIUS) + NSColor.black.withAlphaComponent(0.4).setFill() + textBackgroundPath.fill() + + attributedString.draw(in: textRect) + // NSLog("๐ŸŽจ EventCaptureView: Drew 'Alle Schermen Actief' text.") + } + } + + override func mouseDown(with event: NSEvent) { + guard let app = screenshotApp else { + // Even if app is nil, consume the event to prevent it from reaching other windows + return + } + + if app.isMultiMonitorSelectionActive && !app.isDragging { + let globalLocation = NSEvent.mouseLocation + print("๐ŸŽฏ Event capture - starting selection at: \(globalLocation)") + + // SET MOUSE TRACKING VARIABLES FOR SINGLE CLICK DETECTION + let allScreenModifier: UInt = (1 << 0) // Command key + app.isAllScreenModifierPressed = app.isModifierPressed(event.modifierFlags, modifier: allScreenModifier) + app.mouseDownLocation = globalLocation + app.mouseDownTime = CACurrentMediaTime() + app.hasMouseMoved = false + + + + // Hide crosshairs during drag to prevent stuck crosshair issue + hideCrosshairs() + + app.startDragSelection(at: globalLocation) + } + // DO NOT call super.mouseDown - this prevents the event from propagating + } + + override func mouseDragged(with event: NSEvent) { + guard let app = screenshotApp else { + // Even if app is nil, consume the event to prevent it from reaching other windows + return + } + + if app.isMultiMonitorSelectionActive && app.isDragging { + let globalLocation = NSEvent.mouseLocation + app.hasMouseMoved = true // MARK THAT MOUSE HAS MOVED + app.updateDragSelection(to: globalLocation) + } + // DO NOT call super.mouseDragged - this prevents the event from propagating + } + + override func mouseUp(with event: NSEvent) { + guard let app = screenshotApp else { + // Even if app is nil, consume the event to prevent it from reaching other windows + return + } + + if app.isMultiMonitorSelectionActive && app.isDragging { + let globalLocation = NSEvent.mouseLocation + + // CHECK FOR SINGLE CLICK FIRST + let timeSinceMouseDown = CACurrentMediaTime() - app.mouseDownTime + let distanceMoved = sqrt(pow(globalLocation.x - app.mouseDownLocation.x, 2) + pow(globalLocation.y - app.mouseDownLocation.y, 2)) + + print("๐ŸŽฏ EventCaptureView - mouse up at: \(globalLocation)") + print("๐ŸŽฏ Time since down: \(timeSinceMouseDown)s, distance: \(distanceMoved)px, moved: \(app.hasMouseMoved), allScreenModifierPressed: \(app.isAllScreenModifierPressed), allScreensToggled: \(app.isAllScreensCaptureToggledOn)") + + + + // 1. NIEUW: Als "alle schermen" modus is GETOGGLED AAN, en het is een klik (geen drag) + if app.isAllScreensCaptureToggledOn && !app.hasMouseMoved && distanceMoved < 5.0 && timeSinceMouseDown < 0.5 { + print("๐ŸŽฏ Click detected (toggle ON) - capturing all screens") + app.deactivateMultiMonitorSelection() + app.captureAllScreens() // captureAllScreens reset zelf de toggle + app.resetTrackingVariables() + return + } + + // 2. Prioriteit: Single click voor huidige scherm (als toggle NIET aanstaat) + if !app.hasMouseMoved && distanceMoved < 5.0 && timeSinceMouseDown < 0.5 && !app.isAllScreenModifierPressed { + print("๐ŸŽฏ Single click detected (toggle OFF) - capturing current screen") + app.deactivateMultiMonitorSelection() + app.captureCurrentScreen(at: globalLocation) + app.resetTrackingVariables() // RESET for clean state + return + } + + // 4. Anders, normale selectie beรซindigen + print("๐ŸŽฏ Ending drag selection normally") + app.endDragSelection(at: globalLocation) + + // Show crosshairs again after drag ends + showCrosshairs() + } + // DO NOT call super.mouseUp - this prevents the event from propagating + } + + override func rightMouseDown(with event: NSEvent) { + // Handle right-click to cancel selection + guard let app = screenshotApp else { return } + + if app.isMultiMonitorSelectionActive { + print("๐ŸŽฏ Event capture - right-click, canceling selection") + app.cancelMultiMonitorSelection() + } + // DO NOT call super.rightMouseDown - this prevents the event from propagating + } + + override func rightMouseUp(with event: NSEvent) { + // Consume right mouse up events during selection + // DO NOT call super.rightMouseUp - this prevents the event from propagating + } + + override func otherMouseDown(with event: NSEvent) { + // Consume other mouse button events during selection + // DO NOT call super.otherMouseDown - this prevents the event from propagating + } + + override func otherMouseUp(with event: NSEvent) { + // Consume other mouse button events during selection + // DO NOT call super.otherMouseUp - this prevents the event from propagating + } + + override func scrollWheel(with event: NSEvent) { + // Consume scroll wheel events during selection + // DO NOT call super.scrollWheel - this prevents the event from propagating + } + + override func keyDown(with event: NSEvent) { + // Only handle ESC key if multi-monitor selection is active + if event.keyCode == 53 && screenshotApp?.isMultiMonitorSelectionActive == true { // ESC key + screenshotApp?.cancelMultiMonitorSelection() + return // Consume event + } + + // The Command-key handling has been moved to the flagsChanged handler + // in MultiMonitorSystem.swift to implement the toggle correctly. + + super.keyDown(with: event) + } + + override func keyUp(with event: NSEvent) { + // ... existing code ... + super.keyUp(with: event) + } + + override var acceptsFirstResponder: Bool { + return true + } + + override func becomeFirstResponder() -> Bool { + print("โŒจ๏ธ EventCaptureView becoming first responder") + return super.becomeFirstResponder() + } + + private func hideCrosshairs() { + // Find all crosshair views in the window hierarchy and hide them + if let window = self.window { + findAndHideCrosshairs(in: window.contentView) + } + } + + private func showCrosshairs() { + // Find all crosshair views in the window hierarchy and show them + if let window = self.window { + findAndShowCrosshairs(in: window.contentView) + } + } + + private func findAndHideCrosshairs(in view: NSView?) { + guard let view = view else { return } + + for subview in view.subviews { + if let crosshair = subview as? CrosshairCursorView { + crosshair.stopTracking() + } + findAndHideCrosshairs(in: subview) + } + } + + private func findAndShowCrosshairs(in view: NSView?) { + guard let view = view else { return } + + for subview in view.subviews { + if let crosshair = subview as? CrosshairCursorView { + crosshair.isHidden = false + crosshair.startTracking() + } + findAndShowCrosshairs(in: subview) + } + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/FeedbackBubblePanel.swift b/ShotScreen/Sources/FeedbackBubblePanel.swift new file mode 100644 index 0000000..2cbac01 --- /dev/null +++ b/ShotScreen/Sources/FeedbackBubblePanel.swift @@ -0,0 +1,163 @@ +import AppKit + +class FeedbackBubblePanel: NSPanel { + private var messageLabel: NSTextField! + private var animationStartFrame: NSRect? + private var autoCloseTimer: Timer? + + // MARK: - Initialization + init(contentRect: NSRect, text: String) { + super.init(contentRect: contentRect, styleMask: [.borderless, .utilityWindow, .hudWindow, .nonactivatingPanel], backing: .buffered, defer: false) + + self.isFloatingPanel = true + self.level = .floating + 3 + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + self.isOpaque = false + self.backgroundColor = .clear + self.hasShadow = false + self.animationBehavior = .utilityWindow + self.hidesOnDeactivate = false // Belangrijk voor interactie met andere UI tijdens tonen + self.becomesKeyOnlyIfNeeded = true + + setupVisualEffectView() + setupMessageLabel(with: text) + setupLayoutConstraints() + } + + private func setupVisualEffectView() { + let visualEffectView = NSVisualEffectView() + visualEffectView.blendingMode = .behindWindow + visualEffectView.material = .hudWindow + visualEffectView.state = .active + visualEffectView.wantsLayer = true + visualEffectView.layer?.cornerRadius = 12.0 + visualEffectView.translatesAutoresizingMaskIntoConstraints = false + self.contentView = visualEffectView + } + + private func setupMessageLabel(with text: String) { + messageLabel = NSTextField(wrappingLabelWithString: text) + messageLabel.translatesAutoresizingMaskIntoConstraints = false + messageLabel.isBezeled = false + messageLabel.isEditable = false + messageLabel.isSelectable = false + messageLabel.backgroundColor = .clear + messageLabel.textColor = NSColor(white: 0.95, alpha: 1.0) + messageLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium) + messageLabel.alignment = .center + messageLabel.maximumNumberOfLines = 0 // Allow multiple lines + (self.contentView as? NSVisualEffectView)?.addSubview(messageLabel) + } + + private func setupLayoutConstraints() { + guard let contentView = self.contentView as? NSVisualEffectView, messageLabel != nil else { return } + + let horizontalPadding: CGFloat = 18 + let verticalPadding: CGFloat = 10 + + // Pas de grootte van het paneel aan op basis van de tekst, met padding + let preferredSize = messageLabel.sizeThatFits(NSSize(width: 250 - 2 * horizontalPadding, height: CGFloat.greatestFiniteMagnitude)) + let panelWidth = preferredSize.width + 2 * horizontalPadding + let panelHeight = preferredSize.height + 2 * verticalPadding + self.setContentSize(NSSize(width: panelWidth, height: panelHeight)) + + // Herbereken de constraints van de visualEffectView indien nodig + contentView.leadingAnchor.constraint(equalTo: (self.contentView!).leadingAnchor).isActive = true + contentView.trailingAnchor.constraint(equalTo: (self.contentView!).trailingAnchor).isActive = true + contentView.topAnchor.constraint(equalTo: (self.contentView!).topAnchor).isActive = true + contentView.bottomAnchor.constraint(equalTo: (self.contentView!).bottomAnchor).isActive = true + + NSLayoutConstraint.activate([ + messageLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: horizontalPadding), + messageLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -horizontalPadding), + messageLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: verticalPadding), + messageLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -verticalPadding) + ]) + } + + // MARK: - Show and Close Animations + + /// Shows the panel, animating from the grid. + func show(aroundGridFrame gridFrame: NSRect, text: String, autoCloseAfter: TimeInterval?, onAutoCloseCompletion: (() -> Void)? = nil) { + self.messageLabel.stringValue = text + // Herbereken layout voor nieuwe tekst + setupLayoutConstraints() + + self.animationStartFrame = gridFrame + let panelSize = self.frame.size + + // Startpositie: gecentreerd op de grid (vergelijkbaar met RenamePanel) + let initialX = gridFrame.midX - panelSize.width / 2 + let initialY = gridFrame.midY - panelSize.height / 2 + self.setFrameOrigin(NSPoint(x: initialX, y: initialY)) + self.alphaValue = 0.0 + + // Eindpositie: links van de grid (vergelijkbaar met RenamePanel) + let spacing: CGFloat = 20 + var finalFrame = self.frame + finalFrame.origin.x = gridFrame.origin.x - panelSize.width - spacing + finalFrame.origin.y = gridFrame.origin.y + (gridFrame.height - panelSize.height) / 2 + + // Screen bounds check (vereenvoudigd, neem aan dat het past voor nu) + if let screen = NSScreen.screens.first(where: { $0.frame.intersects(gridFrame) }) ?? NSScreen.main { + let screenVisibleFrame = screen.visibleFrame + if finalFrame.origin.x < screenVisibleFrame.origin.x { + finalFrame.origin.x = gridFrame.maxX + spacing // Aan de andere kant als het niet past + } + finalFrame.origin.y = max(screenVisibleFrame.minY + 10, min(finalFrame.origin.y, screenVisibleFrame.maxY - panelSize.height - 10)) + } + + self.orderFront(nil) + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.35 // Iets langzamer dan rename panel (0.3) + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + self.animator().alphaValue = 1.0 + self.animator().setFrame(finalFrame, display: true) + }, completionHandler: { + if let closeDelay = autoCloseAfter, closeDelay > 0 { + self.autoCloseTimer?.invalidate() + self.autoCloseTimer = Timer.scheduledTimer(withTimeInterval: closeDelay, repeats: false) { [weak self] _ in + self?.closeWithAnimation(completion: onAutoCloseCompletion) + } + } + }) + } + + /// Closes the panel, animating back towards the grid. + func closeWithAnimation(completion: (() -> Void)?) { + autoCloseTimer?.invalidate() + autoCloseTimer = nil + + guard let startFrame = self.animationStartFrame else { + self.orderOut(nil) + completion?() + return + } + + // Doel animatie: terug naar midden van grid, met huidige grootte (geen zoom/krimp) + let currentPanelSize = self.frame.size + let endOriginX = startFrame.midX - currentPanelSize.width / 2 + let endOriginY = startFrame.midY - currentPanelSize.height / 2 + // We animeren alleen de origin en alpha, niet de size. + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.3 // Snelheid van terug animeren + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + self.animator().alphaValue = 0 + // Alleen de origin animeren, niet de frame size. + self.animator().setFrameOrigin(NSPoint(x: endOriginX, y: endOriginY)) + }, completionHandler: { + self.orderOut(nil) + self.alphaValue = 1 // Reset alpha voor volgende keer + // Belangrijk: Reset ook de frame origin/size naar iets zinnigs voor het geval het direct opnieuw getoond wordt + // zonder dat de init opnieuw doorlopen wordt, hoewel dat hier minder waarschijnlijk is. + // Voor nu laten we dit, aangezien een nieuwe .show() de frame opnieuw instelt. + completion?() + }) + } + + deinit { + autoCloseTimer?.invalidate() + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/FinderWindowManager.swift b/ShotScreen/Sources/FinderWindowManager.swift new file mode 100644 index 0000000..76b1a51 --- /dev/null +++ b/ShotScreen/Sources/FinderWindowManager.swift @@ -0,0 +1,64 @@ +import AppKit +import Foundation + +// MARK: - Finder Window Manager +class FinderWindowManager { + + // MARK: - Data Structures + struct FinderWindowInfo { + let path: String + let bounds: NSRect + let viewType: String + let sortColumn: String + let reversed: Bool + let windowIndex: Int + + init(path: String, bounds: NSRect, viewType: String = "icon view", sortColumn: String = "name", reversed: Bool = false, windowIndex: Int = 0) { + self.path = path + self.bounds = bounds + self.viewType = viewType + self.sortColumn = sortColumn + self.reversed = reversed + self.windowIndex = windowIndex + } + } + + // MARK: - Properties + private var savedWindows: [FinderWindowInfo] = [] + private var isCurrentlyRestoring: Bool = false + + // MARK: - Public Interface + + /// Save all currently open Finder windows and their properties + /// NOTE: Currently disabled - functionality removed for simplicity + func saveOpenFinderWindows() { + // Feature disabled - no window saving functionality + return + } + + /// Restore previously saved Finder windows as individual windows + /// NOTE: Currently disabled - functionality removed for simplicity + func restoreFinderWindows() { + // Feature disabled - no window restoration functionality + return + } + + /// Clear saved window data + func cleanup() { + savedWindows.removeAll() + isCurrentlyRestoring = false + print("๐Ÿงน FinderWindowManager cleaned up") + } + + /// Force cleanup - only call when app is terminating + func forceCleanup() { + cleanup() + print("๐Ÿงน FinderWindowManager force cleaned up (app terminating)") + } + + /// Update saved positions without full cleanup - useful for position tracking + func updateCurrentPositions() { + // This will refresh the saved positions with current window states + saveOpenFinderWindows() + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/FirstLaunchWizard.swift b/ShotScreen/Sources/FirstLaunchWizard.swift new file mode 100644 index 0000000..c9cbdea --- /dev/null +++ b/ShotScreen/Sources/FirstLaunchWizard.swift @@ -0,0 +1,606 @@ +import AppKit +import SwiftUI +import ServiceManagement + +// MARK: - Environment Key for Window Access +private struct WindowKey: EnvironmentKey { + static let defaultValue: FirstLaunchWizard? = nil +} + +extension EnvironmentValues { + var window: FirstLaunchWizard? { + get { self[WindowKey.self] } + set { self[WindowKey.self] = newValue } + } +} + +// MARK: - PreferenceKey for Button Width Sync +struct ButtonWidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +// MARK: - First Launch Wizard Window +class FirstLaunchWizard: NSWindow { + private var hostingView: NSHostingView? + private var isClosing = false + + // Perfect fit window size - made smaller + static let windowSize = NSSize(width: 420, height: 520) + + init() { + let windowRect = NSRect(origin: .zero, size: FirstLaunchWizard.windowSize) + + super.init( + contentRect: windowRect, + styleMask: [.titled, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + setupWindow() + setupContent() + } + + private func setupWindow() { + title = "Welcome to ShotScreen" + isOpaque = false + backgroundColor = .clear + hasShadow = true + titleVisibility = .hidden + titlebarAppearsTransparent = true + isMovable = true + isMovableByWindowBackground = true + level = .normal + isReleasedWhenClosed = true + + // Center the window + center() + } + + private func setupContent() { + let wizardView = SinglePageWizardView { [unowned self] in + // Completion handler - mark first launch as completed + SettingsManager.shared.hasCompletedFirstLaunch = true + print("โœ… First launch wizard completed - closing window") + print("๐Ÿ” DEBUG: About to call safeClose(), self exists: \(self)") + self.safeClose() + } + + hostingView = NSHostingView(rootView: AnyView(wizardView.environment(\.window, self))) + contentView = hostingView + } + + private func safeClose() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.safeClose() + } + return + } + + guard !isClosing else { + print("๐Ÿ” DEBUG: Already closing, skipping") + return + } + isClosing = true + + print("๐ŸŽฏ Safely closing wizard...") + + // Force close immediately without async + orderOut(nil) + close() + } + + override func close() { + print("๐ŸŽ‰ FirstLaunchWizard: Window closing") + + guard !isClosing else { + print("๐Ÿ” DEBUG: Close() called but already closing") + return + } + isClosing = true + + // First hide the window immediately + orderOut(nil) + + // Clean up hosting view + if let hosting = hostingView { + hosting.rootView = AnyView(EmptyView()) + hosting.removeFromSuperview() + hostingView = nil + } + + contentView = nil + + // Force the superclass close + super.close() + + print("๐ŸŽ‰ FirstLaunchWizard: Window actually closed") + } + + deinit { + print("๐ŸŽ‰ FirstLaunchWizard: Deallocated") + } +} + +// MARK: - Single Page Wizard View +struct SinglePageWizardView: View { + let onComplete: () -> Void + @Environment(\.window) private var window + + // State for permission checks + @State private var hasScreenRecordingPermission = false + @State private var isLaunchAtStartupEnabled = false + @State private var hasCompletedScreenshotSettings = false + + var body: some View { + VStack(spacing: 0) { + // Header with logo and title + CompactHeaderView() + .padding(.top, 20) + .padding(.bottom, 20) + + // All content perfectly fitted without scrolling + VStack(spacing: 16) { + // Setup section + SetupSection( + hasScreenRecordingPermission: hasScreenRecordingPermission, + hasCompletedScreenshotSettings: hasCompletedScreenshotSettings, + onScreenshotSettingsCompleted: { + hasCompletedScreenshotSettings = true + } + ) + + // Configuration section + ConfigurationSection( + isLaunchAtStartupEnabled: isLaunchAtStartupEnabled + ) + } + .padding(.horizontal, 20) + + Spacer() + + // Bottom action buttons + HStack(spacing: 10) { + Button("Skip Setup") { + print("๐Ÿ”ด Skip Setup button pressed") + onComplete() + } + .buttonStyle(.bordered) + .foregroundColor(.secondary) + .controlSize(.small) + + Spacer() + + Button("Finished!") { + print("๐ŸŸข Finished! button pressed") + onComplete() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + .padding(.top, 20) + .padding(.bottom, 20) + .padding(.horizontal, 20) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + VisualEffectBackground(material: .hudWindow, blending: .behindWindow) + .cornerRadius(16) + ) + .padding(14) + .onAppear(perform: checkPermissions) + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + print("App became active, re-checking permissions...") + checkPermissions() + } + } + + private func checkPermissions() { + // Check Screen Recording Permission + let hasPermission = CGPreflightScreenCaptureAccess() + if !hasPermission { + CGRequestScreenCaptureAccess() + } + self.hasScreenRecordingPermission = hasPermission + + // Check Launch at Startup Status + self.isLaunchAtStartupEnabled = SMAppService.mainApp.status == .enabled + + print("โœ… Permissions check: ScreenRecording=\(self.hasScreenRecordingPermission), LaunchAtStartup=\(self.isLaunchAtStartupEnabled)") + } +} + +// MARK: - Compact Header View +struct CompactHeaderView: View { + var body: some View { + VStack(spacing: 8) { + // App logo - now using AppIcon instead of Slothhead + Group { + if let nsImage = NSImage(named: "AppIcon") { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + } else if let bundle = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"), + let nsImage = NSImage(contentsOf: bundle) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 40, height: 40) + } else { + Image(systemName: "camera.viewfinder") + .font(.system(size: 40)) + .foregroundColor(.blue) + } + } + + VStack(spacing: 2) { + Text("Welcome to ShotScreen") + .font(.title3) + .fontWeight(.bold) + .foregroundColor(.primary) + + Text("Set up your screenshot tool") + .font(.caption) + .foregroundColor(.secondary) + } + } + } +} + +// MARK: - Setup Section +struct SetupSection: View { + var hasScreenRecordingPermission: Bool + var hasCompletedScreenshotSettings: Bool + var onScreenshotSettingsCompleted: () -> Void + + var body: some View { + VStack(spacing: 16) { + // macOS Screenshot Settings with Instructions + SimpleScreenshotCardWithInstructions( + action: openMacOSScreenshotSettings, + isCompleted: hasCompletedScreenshotSettings, + onCompleted: onScreenshotSettingsCompleted + ) + + // Privacy Permissions + SinglePageCard( + icon: "shield.checkerboard", + title: "Grant Screen Recording Permission", + subtitle: "Required for screenshot capture", + actionTitle: "Open Privacy", + action: openSystemPreferences, + isCompleted: hasScreenRecordingPermission + ) + } + } + + private func openMacOSScreenshotSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.keyboard?Shortcuts") { + NSWorkspace.shared.open(url) + } else { + print("โŒ Could not open macOS Screenshot Settings") + } + } + + private func openSystemPreferences() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + NSWorkspace.shared.open(url) + } else { + print("โŒ Could not open System Preferences") + } + } +} + +// MARK: - Simple Screenshot Card with Instructions +struct SimpleScreenshotCardWithInstructions: View { + let action: () -> Void + var isCompleted: Bool + var onCompleted: () -> Void + @State private var showingInstructions = false + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "gear") + .font(.system(size: 20)) + .foregroundColor(.blue) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text("Disable macOS Screenshot Shortcut") + .fontWeight(.medium) + + if isCompleted { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.subheadline) + } + } + + Text("Click Instructions for step-by-step guide") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Button("Instructions") { + print("๐Ÿ“– Instructions button pressed") + showingInstructions = true + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(NSColor.controlBackgroundColor)) + .shadow(color: .black.opacity(0.05), radius: 1, x: 0, y: 1) + ) + .sheet(isPresented: $showingInstructions) { + InstructionsPopupView(openSettingsAction: action, isCompleted: isCompleted, onCompleted: onCompleted) + } + } +} + +// MARK: - Instructions Popup View +struct InstructionsPopupView: View { + @Environment(\.presentationMode) var presentationMode + let openSettingsAction: () -> Void + var isCompleted: Bool + var onCompleted: () -> Void + @State private var imageSize: CGSize = CGSize(width: 600, height: 400) + + var body: some View { + VStack(spacing: 20) { + // Header + HStack { + Text("How to Disable macOS Screenshot Shortcut") + .font(.title2) + .fontWeight(.bold) + + Spacer() + + Button("Close") { + presentationMode.wrappedValue.dismiss() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.top, 20) + .padding(.horizontal, 20) + + // Instruction image + Group { + if let bundle = Bundle.main.url(forResource: "images/Wizard_TurnOffSceenShot", withExtension: "png"), + let nsImage = NSImage(contentsOf: bundle) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .onAppear { + // Get actual image size and scale it appropriately + let maxWidth: CGFloat = 800 + let maxHeight: CGFloat = 600 + let aspectRatio = nsImage.size.width / nsImage.size.height + + if nsImage.size.width > maxWidth { + imageSize = CGSize(width: maxWidth, height: maxWidth / aspectRatio) + } else if nsImage.size.height > maxHeight { + imageSize = CGSize(width: maxHeight * aspectRatio, height: maxHeight) + } else { + imageSize = nsImage.size + } + } + .frame(width: imageSize.width, height: imageSize.height) + .background(Color.white) + .cornerRadius(8) + .shadow(radius: 2) + } else if let nsImage = NSImage(named: "Wizard_TurnOffSceenShot") { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .onAppear { + // Get actual image size and scale it appropriately + let maxWidth: CGFloat = 800 + let maxHeight: CGFloat = 600 + let aspectRatio = nsImage.size.width / nsImage.size.height + + if nsImage.size.width > maxWidth { + imageSize = CGSize(width: maxWidth, height: maxWidth / aspectRatio) + } else if nsImage.size.height > maxHeight { + imageSize = CGSize(width: maxHeight * aspectRatio, height: maxHeight) + } else { + imageSize = nsImage.size + } + } + .frame(width: imageSize.width, height: imageSize.height) + .background(Color.white) + .cornerRadius(8) + .shadow(radius: 2) + } else { + // Development fallback: try to load from source directory + let developmentImagePaths = [ + "ScreenShot/Sources/images/Wizard_TurnOffSceenShot.png", + "./ScreenShot/Sources/images/Wizard_TurnOffSceenShot.png", + "../ScreenShot/Sources/images/Wizard_TurnOffSceenShot.png" + ] + + if let workingImagePath = developmentImagePaths.first(where: { FileManager.default.fileExists(atPath: $0) }), + let nsImage = NSImage(contentsOfFile: workingImagePath) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .onAppear { + // Get actual image size and scale it appropriately + let maxWidth: CGFloat = 800 + let maxHeight: CGFloat = 600 + let aspectRatio = nsImage.size.width / nsImage.size.height + + if nsImage.size.width > maxWidth { + imageSize = CGSize(width: maxWidth, height: maxWidth / aspectRatio) + } else if nsImage.size.height > maxHeight { + imageSize = CGSize(width: maxHeight * aspectRatio, height: maxHeight) + } else { + imageSize = nsImage.size + } + } + .frame(width: imageSize.width, height: imageSize.height) + .background(Color.white) + .cornerRadius(8) + .shadow(radius: 2) + } else { + // Final fallback if image is not found anywhere + VStack(spacing: 10) { + Image(systemName: "photo") + .font(.system(size: 50)) + .foregroundColor(.gray) + + Text("Instructions Image") + .font(.caption) + .foregroundColor(.secondary) + + Text("Image will be available in release build") + .font(.caption2) + .foregroundColor(.orange) + } + .frame(width: imageSize.width, height: imageSize.height) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + } + } + .padding(.horizontal, 20) + + // Instructions text + VStack(alignment: .leading, spacing: 8) { + Text("Follow these steps:") + .font(.headline) + + Text("1. Click 'Open Settings' below to open macOS System Preferences") + Text("2. Navigate to Keyboard > Shortcuts > Screenshots") + Text("3. Uncheck 'Save picture of selected area as a file' (โŒ˜+โ‡ง+4)") + Text("4. This prevents conflicts with ShotScreen") + } + .font(.body) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer() + + // Bottom buttons + HStack(spacing: 12) { + Button("Open Settings") { + print("๐Ÿ”ง Opening macOS Screenshot Settings from popup...") + openSettingsAction() + // Don't dismiss popup - let user read instructions while configuring + } + .buttonStyle(.borderedProminent) + + Button("Got it!") { + print("โœ… User completed screenshot settings configuration") + onCompleted() + presentationMode.wrappedValue.dismiss() + } + .buttonStyle(.bordered) + } + .padding(.bottom, 20) + } + .frame(width: max(imageSize.width + 40, 400), height: imageSize.height + 260) + .background(Color(NSColor.windowBackgroundColor)) + } +} + +// MARK: - Configuration Section +struct ConfigurationSection: View { + var isLaunchAtStartupEnabled: Bool + + var body: some View { + // Launch at Startup - now as a button + SinglePageCard( + icon: "power", + title: "Launch at Startup", + subtitle: "Start automatically when you log in", + actionTitle: "Open Login Items", + action: openLoginItemsSettings, + isCompleted: isLaunchAtStartupEnabled + ) + } + + 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") + } + } +} + +// MARK: - Single Page Card with Action +struct SinglePageCard: View { + let icon: String + let title: String + let subtitle: String + let actionTitle: String + let action: () -> Void + var isCompleted: Bool + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: icon) + .font(.system(size: 20)) + .foregroundColor(.blue) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(title) + .fontWeight(.medium) + + if isCompleted { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.subheadline) + } + } + + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Button(actionTitle) { + print("๐Ÿ”ง Action button pressed: \(actionTitle)") + action() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(NSColor.controlBackgroundColor)) + .shadow(color: .black.opacity(0.05), radius: 1, x: 0, y: 1) + ) + } +} + +// MARK: - Visual Effect Background Helper +// Note: VisualEffectBackground is defined in IntegratedGalleryView.swift \ No newline at end of file diff --git a/ShotScreen/Sources/GridActionManager.swift b/ShotScreen/Sources/GridActionManager.swift new file mode 100644 index 0000000..72a7b36 --- /dev/null +++ b/ShotScreen/Sources/GridActionManager.swift @@ -0,0 +1,443 @@ +import AppKit +import Vision +import SwiftUI + +// MARK: - Grid Action Manager +protocol GridActionManagerDelegate: AnyObject { + func showOrUpdateStash(with imageURL: URL) + func closePreviewWithAnimation(immediate: Bool) + func ensurePreviewVisible() + func getLastImage() -> NSImage? + func getTempURL() -> URL? + func getActivePreviewWindow() -> NSWindow? + func getRenameActionHandler() -> RenameActionHandler + func flashPreviewBorder() + func disableMonitoring() + func enableMonitoring() + func gridViewManagerHideGrid(monitorForReappear: Bool) + func getGridCurrentFrame() -> NSRect? + // ๐ŸŽจ NEW: Background removal thumbnail workflow with original URL + func showBackgroundRemovalThumbnail(with image: NSImage, originalURL: URL) +} + +class GridActionManager { + weak var delegate: GridActionManagerDelegate? + + init(delegate: GridActionManagerDelegate) { + self.delegate = delegate + } + + // MARK: - Grid Action Handler + func handleGridAction(imageURL: URL, cellIndex: Int, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) { + print("โœ… GridActionManager: Handling action for cell \(cellIndex)") + + // Get the action type based on the dynamic action order + let actionOrder = SettingsManager.shared.actionOrder + guard cellIndex < actionOrder.count else { + print("โ„น๏ธ Cell index \(cellIndex) out of range for action order.") + completion(false, false, false) + return + } + + let actionType = actionOrder[cellIndex] + + switch actionType { + case .rename: + print("๐Ÿ“ Rename action triggered for \(imageURL.path)") + handleRenameAction(imageURL: imageURL, completion: completion) + case .stash: + print("โœจ Stash action triggered for \(imageURL.path)") + handleStashAction(imageURL: imageURL, completion: completion) + case .ocr: + print("๐Ÿ“‘ OCR action triggered for \(imageURL.path)") + handleOCRAction(imageURL: imageURL, dropPoint: dropPoint, gridWindow: gridWindow, completion: completion) + case .clipboard: + print("๐Ÿ“‹ Clipboard action triggered for \(imageURL.path)") + handleClipboardAction(imageURL: imageURL, dropPoint: dropPoint, gridWindow: gridWindow, completion: completion) + case .backgroundRemove: + print("๐ŸŽจ Background Remove action triggered for \(imageURL.path)") + handleBackgroundRemoveAction(imageURL: imageURL, dropPoint: dropPoint, gridWindow: gridWindow, completion: completion) + case .cancel: + print("๐Ÿšซ Cancel action triggered") + handleCancelAction(completion: completion) + case .remove: + print("๐Ÿ—‘ Remove action triggered") + handleRemoveAction(completion: completion) + } + } + + // MARK: - Individual Action Handlers + + private func handleRenameAction(imageURL: URL, completion: @escaping (Bool, Bool, Bool) -> Void) { + print("๐Ÿ” DEBUG: handleRenameAction called with URL: \(imageURL)") + print("๐Ÿ” DEBUG: File exists at URL: \(FileManager.default.fileExists(atPath: imageURL.path))") + + delegate?.getRenameActionHandler().promptAndRename(originalURL: imageURL) { responseForRename in + let successfulAction = (responseForRename != .alertThirdButtonReturn) + let saveToFolderTriggered = (responseForRename == .alertSecondButtonReturn) + print("๐Ÿ” DEBUG: Rename response: \(responseForRename), successful: \(successfulAction)") + completion(successfulAction, saveToFolderTriggered, false) + } + } + + private func handleStashAction(imageURL: URL, completion: @escaping (Bool, Bool, Bool) -> Void) { + print("โœจ Stash action triggered for \(imageURL.path)") + + // CRITICALLY IMPORTANT: Close main grid immediately after stash action + print("๐Ÿ”ถ STASH ACTION: Closing main grid and disabling proximity monitoring") + delegate?.gridViewManagerHideGrid(monitorForReappear: false) + + delegate?.showOrUpdateStash(with: imageURL) + completion(true, false, true) // Pass true for isStashAction + } + + private func handleOCRAction(imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) { + delegate?.disableMonitoring() + performOcrAndCopy(from: imageURL, dropPoint: dropPoint, gridWindow: gridWindow) { success in + // Completion for performOcrAndCopy is now async after panel closes + // The actual success of OCR (text found vs. no text) is handled by the message in the panel. + // For the grid action itself, we consider it 'successful' if the process ran. + self.delegate?.enableMonitoring() + self.delegate?.gridViewManagerHideGrid(monitorForReappear: false) + completion(true, false, false) // OCR action always 'succeeds' in terms of grid interaction + } + } + + private func handleClipboardAction(imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) { + delegate?.disableMonitoring() + copyImageToClipboard(from: imageURL, dropPoint: dropPoint, gridWindow: gridWindow) { + self.delegate?.enableMonitoring() + self.delegate?.gridViewManagerHideGrid(monitorForReappear: false) + completion(true, false, false) + } + } + + private func handleBackgroundRemoveAction(imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) { + performBackgroundRemove(from: imageURL, dropPoint: dropPoint, gridWindow: gridWindow) + completion(true, false, false) + } + + private func handleCancelAction(completion: @escaping (Bool, Bool, Bool) -> Void) { + // Handle cancel action through delegate callback mechanism + NotificationCenter.default.post(name: .gridActionCancelRequested, object: nil) + completion(false, false, false) + } + + private func handleRemoveAction(completion: @escaping (Bool, Bool, Bool) -> Void) { + // Handle remove action through delegate callback mechanism + NotificationCenter.default.post(name: .gridActionRemoveRequested, object: nil) + completion(false, false, false) + } + + // MARK: - OCR Implementation + + private func performOcrAndCopy(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool) -> Void) { + // Intel Mac compatible Vision framework OCR + guard #available(macOS 10.15, *) else { + print("โŒ OCR requires macOS 10.15 or later") + if let gridFrame = delegate?.getGridCurrentFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error") + panel.show(aroundGridFrame: gridFrame, text: "OCR requires macOS 10.15+", autoCloseAfter: 1.8, onAutoCloseCompletion: { + completion(false) + }) + } else { + completion(false) + } + return + } + guard let nsImage = NSImage(contentsOf: imageURL) else { + print("โŒ OCR: Could not load image from URL: \(imageURL.path)") + if let gridFrame = delegate?.getGridCurrentFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error") + panel.show(aroundGridFrame: gridFrame, text: "Image load failed for OCR", autoCloseAfter: 1.8, onAutoCloseCompletion: { + completion(false) + }) + } else { + completion(false) + } + return + } + + guard let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("โŒ OCR: Could not obtain CGImage") + if let gridFrame = delegate?.getGridCurrentFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error") + panel.show(aroundGridFrame: gridFrame, text: "Could not get image data for OCR", autoCloseAfter: 1.8, onAutoCloseCompletion: { + completion(false) + }) + } else { + completion(false) + } + return + } + + let request = VNRecognizeTextRequest { [weak self] request, error in + guard let self = self else { completion(false); return } + + var message: String + var ocrDidFindText = false + + if let err = error { + print("โŒ OCR error: \(err.localizedDescription)") + message = "OCR error: \(err.localizedDescription)" + } else { + let observations = request.results as? [VNRecognizedTextObservation] ?? [] + let recognizedText = observations.compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\n") + if recognizedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + message = "No text found" + } else { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(recognizedText, forType: .string) + message = "Text copied to clipboard!" + ocrDidFindText = true + } + } + + DispatchQueue.main.async { + if let gridFrame = self.delegate?.getGridCurrentFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Processing...") + panel.show(aroundGridFrame: gridFrame, text: message, autoCloseAfter: 1.8, onAutoCloseCompletion: { + completion(ocrDidFindText) + }) + } else { + completion(ocrDidFindText) // Call completion if gridFrame is nil + } + } + } + request.recognitionLevel = VNRequestTextRecognitionLevel.accurate + request.usesLanguageCorrection = true + + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + DispatchQueue.global(qos: .userInitiated).async { + do { + try handler.perform([request]) + } catch { + print("โŒ OCR: Failed to perform request โ€“ \(error.localizedDescription)") + DispatchQueue.main.async { + self.showToast(message: "OCR failed", near: dropPoint, gridWindow: gridWindow) + } + } + } + } + + // MARK: - Clipboard Implementation + + private func copyImageToClipboard(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping () -> Void) { + guard let nsImage = NSImage(contentsOf: imageURL) else { + print("โŒ Clipboard: Could not load image from URL: \(imageURL.path)") + // Consider showing error with FeedbackBubblePanel if gridFrame is available + if let gridFrame = delegate?.getGridCurrentFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error") + panel.show(aroundGridFrame: gridFrame, text: "Image load failed for Clipboard", autoCloseAfter: 2.0) + panel.closeWithAnimation { completion() } + } else { + completion() + } + return + } + + NSPasteboard.general.clearContents() + NSPasteboard.general.writeObjects([nsImage]) + + if let gridFrame = delegate?.getGridCurrentFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Copied!") + panel.show(aroundGridFrame: gridFrame, text: "Copied to clipboard!", autoCloseAfter: nil) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + panel.closeWithAnimation { + completion() + } + } + } else { + print("โš ๏ธ Could not get grid frame to show feedback bubble for clipboard.") + completion() + } + } + + // MARK: - Background Remove Implementation + + private func performBackgroundRemove(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?) { + print("๐ŸŽจ Background Remove action triggered for \(imageURL.path)") + + // ๐Ÿ”„ ENHANCED: Better error handling and recovery for image loading + var finalImageURL = imageURL + var originalImage: NSImage? = nil + + // First attempt: Direct load + originalImage = NSImage(contentsOf: finalImageURL) + + // If failed, try recovery strategies + if originalImage == nil { + print("โš ๏ธ Initial image load failed, attempting recovery...") + + // Strategy 1: Check if file exists but has load issues + if FileManager.default.fileExists(atPath: imageURL.path) { + print("๐Ÿ” File exists but NSImage can't load it, trying alternative loading...") + + // Try loading with different methods + if let imageData = try? Data(contentsOf: imageURL) { + originalImage = NSImage(data: imageData) + if originalImage != nil { + print("โœ… RECOVERED: Successfully loaded image via Data method") + } + } + } + + // Strategy 2: Check temp directory for file + if originalImage == nil { + let tempDir = FileManager.default.temporaryDirectory + let fileName = imageURL.lastPathComponent + let tempURL = tempDir.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: tempURL.path) { + originalImage = NSImage(contentsOf: tempURL) + if originalImage != nil { + finalImageURL = tempURL + print("โœ… RECOVERED: Found image in temp directory: \(tempURL.path)") + } + } + } + + // Strategy 3: Check if it's a cache file issue and try alternative cache locations + if originalImage == nil { + let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let shotScreenDir = supportDir.appendingPathComponent("ShotScreen/Thumbnails") + let cacheName = imageURL.lastPathComponent + let cacheURL = shotScreenDir.appendingPathComponent(cacheName) + + if FileManager.default.fileExists(atPath: cacheURL.path) { + originalImage = NSImage(contentsOf: cacheURL) + if originalImage != nil { + finalImageURL = cacheURL + print("โœ… RECOVERED: Found image in cache: \(cacheURL.path)") + } + } + } + } + + // Final check + guard let finalImage = originalImage else { + print("โŒ Could not load image for background removal after all recovery attempts") + print("๐Ÿ“ Attempted URLs:") + print(" - Original: \(imageURL.path)") + print(" - File exists: \(FileManager.default.fileExists(atPath: imageURL.path))") + + showToast(message: "Image load failed", near: dropPoint, gridWindow: gridWindow) + return + } + + print("โœ… Successfully loaded image for BGR: \(finalImageURL.lastPathComponent)") + print("๐ŸŽจ Background Remove thumbnail UI starting...") + + // ๐ŸŽฏ NEW: Use thumbnail-based BGR workflow via delegate with recovered URL + delegate?.showBackgroundRemovalThumbnail(with: finalImage, originalURL: finalImageURL) + } + + // MARK: - Toast Notification System + + private func showToast(message: String, near point: NSPoint, gridWindow: NSWindow?) { + let toastWidth: CGFloat = 180 + let toastHeight: CGFloat = 40 + + // Kies vaste positie links (anders rechts) naast de grid + let margin: CGFloat = 5 + var screenPoint: NSPoint + if let gw = gridWindow { + let gridFrame = gw.frame + let screenVisible = gw.screen?.visibleFrame ?? NSScreen.main!.visibleFrame + var x = gridFrame.minX - toastWidth - margin // links van grid + if x < screenVisible.minX + margin { // niet genoeg ruimte links โ†’ rechts + x = gridFrame.maxX + margin + } + let y = gridFrame.midY - toastHeight / 2 + screenPoint = NSPoint(x: x, y: y) + } else { + // Fallback: gebruik muislocatie zoals voorheen + screenPoint = NSEvent.mouseLocation + screenPoint.x -= toastWidth / 2 + screenPoint.y += 20 + } + + let toastWindow = NSWindow(contentRect: NSRect(x: screenPoint.x, y: screenPoint.y, width: toastWidth, height: toastHeight), + styleMask: [.borderless], backing: .buffered, defer: false) + toastWindow.isOpaque = false + toastWindow.backgroundColor = .clear + toastWindow.level = .floating + 5 + toastWindow.hasShadow = true + + // Opbouw content + let container = NSView(frame: NSRect(x: 0, y: 0, width: toastWidth, height: toastHeight)) + let blur = NSVisualEffectView(frame: container.bounds) + blur.blendingMode = .behindWindow + blur.material = .hudWindow + blur.state = .active + blur.alphaValue = 0.9 + blur.wantsLayer = true + blur.layer?.cornerRadius = 12 + blur.layer?.masksToBounds = true + + let label = NSTextField(labelWithString: message) + label.textColor = .white + label.alignment = .center + label.font = .systemFont(ofSize: 13, weight: .medium) + label.frame = NSRect(x: 0, y: (toastHeight - 20) / 2, width: toastWidth, height: 20) + + container.addSubview(blur) + container.addSubview(label) + toastWindow.contentView = container + toastWindow.alphaValue = 0 + toastWindow.makeKeyAndOrderFront(nil as Any?) + + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.35 + toastWindow.animator().alphaValue = 1 + }, completionHandler: { + let flyUp: CGFloat = 30 + let targetOrigin = NSPoint(x: toastWindow.frame.origin.x, y: toastWindow.frame.origin.y + flyUp) + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 2 + toastWindow.animator().alphaValue = 0 + toastWindow.animator().setFrameOrigin(targetOrigin) + }, completionHandler: { + toastWindow.orderOut(nil as Any?) + }) + }) + } + + // MARK: - Action Completion Handler + + func handleActionCompletion(actionCompletedSuccessfully: Bool, wasSaveToFolder: Bool, isStashAction: Bool) { + print("โ„น๏ธ Action panel completion: Success - \(actionCompletedSuccessfully), WasSaveToFolder - \(wasSaveToFolder), IsStashAction - \(isStashAction)") + + if actionCompletedSuccessfully { + if wasSaveToFolder { + // Save to folder actions handle their own preview closing via closeAfterSave setting. + print("โ„น๏ธ Save to folder action: Preview management handled by save logic.") + } else if isStashAction { + // CRITICAL: Ensure main grid stays closed after stash action + print("๐Ÿ”ถ STASH ACTION COMPLETION: Ensuring main grid stays closed - no proximity monitoring") + delegate?.gridViewManagerHideGrid(monitorForReappear: false) + + // FIXED: For stash action, preview is already properly closed in main.swift _showOrUpdateStash + // No need to close again here or check tempURL - it's already moved to stash directory + print("โ„น๏ธ Stash action: Preview management handled by stash logic in main.swift") + } else { + if delegate?.getTempURL() != nil { + print("โ„น๏ธ Other successful grid action (not save/stash): Ensuring preview is visible.") + delegate?.ensurePreviewVisible() + } + } + } else { + if delegate?.getTempURL() != nil { + print("โ„น๏ธ Action not successful or cancelled: Ensuring preview is visible.") + delegate?.ensurePreviewVisible() + } + } + } +} + +// MARK: - Notification Names +extension Notification.Name { + static let gridActionCancelRequested = Notification.Name("gridActionCancelRequested") + static let gridActionRemoveRequested = Notification.Name("gridActionRemoveRequested") +} \ No newline at end of file diff --git a/ShotScreen/Sources/GridCellView.swift b/ShotScreen/Sources/GridCellView.swift new file mode 100644 index 0000000..31e98ac --- /dev/null +++ b/ShotScreen/Sources/GridCellView.swift @@ -0,0 +1,194 @@ +import AppKit + +// MARK: - Grid Cell View for Action Grid +class GridCellView: NSView { + let index: Int + private let label: NSTextField = NSTextField(labelWithString: "") + private let iconImageView: NSImageView = NSImageView() + private var iconCenterConstraint: NSLayoutConstraint! + private let originalBackgroundColor = NSColor.clear + private let highlightBackgroundColor = NSColor.clear // geen highlight + private var isHovered: Bool = false + private let iconStartOffset: CGFloat // <-- maak property + + init(frame frameRect: NSRect, index: Int, text: String) { + self.index = index + self.iconStartOffset = 30 // <-- property initialiseren + super.init(frame: frameRect) + wantsLayer = true + + // Setup theme change observer + ThemeManager.shared.observeThemeChanges { [weak self] in + DispatchQueue.main.async { + self?.updateThemeColors() + } + } + + // Voeg mouse tracking toe + let trackingArea = NSTrackingArea(rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways], + owner: self, + userInfo: nil) + addTrackingArea(trackingArea) + + // Glas-achtergrond (dubbele blur) + #if false + let blur1 = NSVisualEffectView() + blur1.blendingMode = .behindWindow + blur1.material = .hudWindow + blur1.state = .active + blur1.frame = bounds + blur1.autoresizingMask = [.width, .height] + let blur2 = NSVisualEffectView() + blur2.blendingMode = .behindWindow + blur2.material = .hudWindow + blur2.state = .active + blur2.alphaValue = 0.6 + blur2.frame = bounds + blur2.autoresizingMask = [.width, .height] + addSubview(blur1, positioned: .below, relativeTo: nil) + addSubview(blur2, positioned: .below, relativeTo: nil) + #endif + + layer?.cornerRadius = 0 + layer?.masksToBounds = false + + label.stringValue = text + label.font = NSFont.systemFont(ofSize: 12, weight: .semibold) + label.textColor = ThemeManager.shared.gridCellTextColor(isHovered: false) + label.alignment = .left + label.alphaValue = 1.0 // Use theme opacity directly + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + + // Cirkel-achtergrond voor het icoon + let iconBackground = NSView() + iconBackground.wantsLayer = true + iconBackground.translatesAutoresizingMaskIntoConstraints = false + iconBackground.layer?.cornerRadius = 18 // voor 36ร—36 cirkel + iconBackground.layer?.backgroundColor = ThemeManager.shared.gridCellIconBackground.cgColor + addSubview(iconBackground) + + // Configuratie icon based on text content + let symbolName: String + if text.contains("Rename") { + symbolName = "pencil" + } else if text.contains("Stash") { + symbolName = "archivebox" + } else if text.contains("Text") { + symbolName = "text.viewfinder" + } else if text.contains("Clipboard") { + symbolName = "doc.on.clipboard" + } else if text.contains("Remove BG") { + symbolName = "person.and.background.dotted" + } else if text.contains("Cancel") { + symbolName = "xmark" + } else if text.contains("Remove") { + symbolName = "trash" + } else { + symbolName = "square" + } + let baseImage = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) + let configuredImage = baseImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)) + iconImageView.image = configuredImage + iconImageView.contentTintColor = ThemeManager.shared.primaryTextColor + iconImageView.translatesAutoresizingMaskIntoConstraints = false + iconBackground.addSubview(iconImageView) + + // AutoLayout (start meer naar rechts) + let iconStartOffset: CGFloat = 30 // Pas dit getal aan voor meer/minder ruimte links + iconCenterConstraint = iconBackground.centerXAnchor.constraint(equalTo: centerXAnchor, constant: iconStartOffset) + + NSLayoutConstraint.activate([ + iconCenterConstraint, + iconBackground.centerYAnchor.constraint(equalTo: centerYAnchor), + iconBackground.widthAnchor.constraint(equalToConstant: 36), + iconBackground.heightAnchor.constraint(equalTo: iconBackground.widthAnchor), + + iconImageView.centerXAnchor.constraint(equalTo: iconBackground.centerXAnchor), + iconImageView.centerYAnchor.constraint(equalTo: iconBackground.centerYAnchor), + + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + label.trailingAnchor.constraint(equalTo: iconBackground.leadingAnchor, constant: -8), + label.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + + // Zet initiรซle transform + layer?.anchorPoint = CGPoint(x: 0.5, y: 0.5) + layer?.transform = CATransform3DIdentity + + // Voeg subtiele gloed toe aan het label + let shadow = NSShadow() + shadow.shadowColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.4) + shadow.shadowBlurRadius = 4 + shadow.shadowOffset = NSSize(width: 0, height: -1) + label.shadow = shadow + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func mouseEntered(with event: NSEvent) { + let shift = (self.bounds.width / 2) - 18 - 2 + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.15 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + self.iconCenterConstraint.animator().constant = shift + self.label.animator().textColor = ThemeManager.shared.gridCellTextColor(isHovered: true) + } + } + + override func mouseExited(with event: NSEvent) { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + // Icoon terug naar startpositie + self.iconCenterConstraint.animator().constant = self.iconStartOffset + // Text color terug naar normal + self.label.animator().textColor = ThemeManager.shared.gridCellTextColor(isHovered: false) + } + } + + func setHovered(_ hovered: Bool) { + guard hovered != isHovered else { return } + isHovered = hovered + let shift = (self.bounds.width / 2) - 18 - 5 + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.15 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + if hovered { + self.iconCenterConstraint.animator().constant = shift + self.label.animator().textColor = ThemeManager.shared.gridCellTextColor(isHovered: true) + } else { + self.iconCenterConstraint.animator().constant = self.iconStartOffset // Terug naar startpositie + self.label.animator().textColor = ThemeManager.shared.gridCellTextColor(isHovered: false) + } + } + } + + func setHighlighted(_ highlighted: Bool) { + // Do nothing (no color change) + } + + // MARK: - Theme Management + private func updateThemeColors() { + // Update text color + label.textColor = ThemeManager.shared.gridCellTextColor(isHovered: isHovered) + + // Update icon background + if let iconBackground = iconImageView.superview { + iconBackground.layer?.backgroundColor = ThemeManager.shared.gridCellIconBackground.cgColor + } + + // Update icon tint color + iconImageView.contentTintColor = ThemeManager.shared.primaryTextColor + + // Update shadow color + let shadow = NSShadow() + shadow.shadowColor = ThemeManager.shared.primaryTextColor.withAlphaComponent(0.4) + shadow.shadowBlurRadius = 4 + shadow.shadowOffset = NSSize(width: 0, height: -1) + label.shadow = shadow + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/GridComponents.swift b/ShotScreen/Sources/GridComponents.swift new file mode 100644 index 0000000..f1b145a --- /dev/null +++ b/ShotScreen/Sources/GridComponents.swift @@ -0,0 +1,515 @@ +import AppKit + +// MARK: - Grid Window for Action Selection +class GridWindow: NSWindow, NSDraggingDestination { + var cellViews: [GridCellView] = [] + weak var gridViewManagerDelegate: GridViewManagerDelegate? + weak var manager: GridViewManager? + private var currentlyHighlightedCell: GridCellView? + private var fadeTimer: Timer? + private let fadeStart: CGFloat = 50 // afstand in px waarbij fading start + private let fadeEnd: CGFloat = 300 // afstand waarbij minimale alpha bereikt is + private let minAlpha: CGFloat = 0 // minimale zichtbaarheid + var isInitialFadingIn: Bool = false + var isPerformingProgrammaticHide: Bool = false // NIEUWE FLAG + + init(screen: NSScreen, cellsPerRowInput: Int = 2, manager: GridViewManager, previewFrame: NSRect?) { + self.manager = manager + let settings = SettingsManager.shared + + var activeActions: [(index: Int, text: String)] = [] + + // Build actions based on settings actionOrder to respect user's preferred order + for (gridIndex, actionType) in settings.actionOrder.enumerated() { + let isEnabled: Bool + let displayText: String + + switch actionType { + case .rename: + isEnabled = settings.isRenameActionEnabled + displayText = "Rename" + case .stash: + isEnabled = settings.isStashActionEnabled + displayText = "Stash" + case .ocr: + isEnabled = settings.isOCRActionEnabled + displayText = "Text" + case .clipboard: + isEnabled = settings.isClipboardActionEnabled + displayText = "Clipboard" + case .backgroundRemove: + isEnabled = settings.isBackgroundRemoveActionEnabled + if BackgroundRemover.shared.isRMBGModelAvailable() { + displayText = "Remove BG" + } else { + displayText = "Remove BG" + } + case .cancel: + isEnabled = settings.isCancelActionEnabled + displayText = "Cancel" + case .remove: + isEnabled = settings.isRemoveActionEnabled + displayText = "Remove" + } + + if isEnabled { + // Use gridIndex as the cellIndex, which will map to actionOrder position + activeActions.append((gridIndex, displayText)) + } + } + + let numberOfActiveActions = activeActions.count + guard numberOfActiveActions > 0 else { + // Geen acties actief, maak een leeg/onzichtbaar window of handle anders + // Voor nu, een heel klein, onzichtbaar venster en return vroeg. + // Dit voorkomt een crash als numberOfCells 0 is. + super.init(contentRect: .zero, styleMask: .borderless, backing: .buffered, defer: false) + self.isOpaque = false + self.backgroundColor = .clear + self.ignoresMouseEvents = true // Belangrijk + // Roep manager.hideGrid() aan omdat er geen grid is om te tonen + DispatchQueue.main.async { manager.hideGrid() } + return + } + print("๐Ÿ”ท GridWindow init: Number of active actions = \(numberOfActiveActions)") // Print na guard + + // Verticale ActionBar: altijd 1 kolom + let cellsPerRow = 1 + let numberOfRows = Int(ceil(Double(numberOfActiveActions) / Double(cellsPerRow))) + + let spacing: CGFloat = 8 + let fixedCellHeight: CGFloat = 40.0 + let fixedCellWidth: CGFloat = 160.0 + + let calculatedGridWidth = (fixedCellWidth * CGFloat(cellsPerRow)) + (spacing * (CGFloat(cellsPerRow) + 1)) + let calculatedGridHeight = (fixedCellHeight * CGFloat(numberOfRows)) + (spacing * (CGFloat(numberOfRows) + 1)) + + var xPosition: CGFloat + var yPosition: CGFloat + + // Oude, incorrecte positionering verwijderd + if let pFrame = previewFrame { + let screenVisibleFrame = screen.visibleFrame + yPosition = pFrame.maxY + spacing + xPosition = screenVisibleFrame.maxX - calculatedGridWidth + 10 + // Bounds checking + if xPosition < screenVisibleFrame.minX + spacing { xPosition = screenVisibleFrame.minX + spacing } + if xPosition + calculatedGridWidth > screenVisibleFrame.maxX - spacing { xPosition = screenVisibleFrame.maxX - calculatedGridWidth - spacing } + if yPosition + calculatedGridHeight > screenVisibleFrame.maxY - spacing { yPosition = pFrame.minY - calculatedGridHeight - spacing } + if yPosition < screenVisibleFrame.minY + spacing { yPosition = screenVisibleFrame.minY + spacing } + } else { + let effectiveScreenFrame = screen.visibleFrame + xPosition = (effectiveScreenFrame.width - calculatedGridWidth) / 2 + effectiveScreenFrame.origin.x + yPosition = (effectiveScreenFrame.height - calculatedGridHeight) / 2 + effectiveScreenFrame.origin.y + } + + let contentRect = NSRect(x: xPosition, y: yPosition, width: calculatedGridWidth, height: calculatedGridHeight) + super.init(contentRect: contentRect, styleMask: [.borderless], backing: .buffered, defer: false) + self.level = .floating + 2 + print("๐Ÿ”ท GridWindow init: Calculated contentRect = \(contentRect)") // Print na super.init + self.isOpaque = false + self.backgroundColor = .clear + self.hasShadow = false // Was false, houd consistent + self.ignoresMouseEvents = false + self.acceptsMouseMovedEvents = true // BELANGRIJK: Voor hover events in subviews + + let containerView = NSView(frame: NSRect(origin: .zero, size: contentRect.size)) + containerView.wantsLayer = true + // Gedeelde achtergrond voor alle iconen (dubbele blur) + let barBlur1 = NSVisualEffectView() + barBlur1.blendingMode = .behindWindow + barBlur1.material = .hudWindow + barBlur1.state = .active + barBlur1.frame = containerView.bounds + barBlur1.autoresizingMask = [.width, .height] + let barBlur2 = NSVisualEffectView() + barBlur2.blendingMode = .behindWindow + barBlur2.material = .hudWindow + barBlur2.state = .active + barBlur2.alphaValue = 0.6 + barBlur2.frame = containerView.bounds + barBlur2.autoresizingMask = [.width, .height] + containerView.addSubview(barBlur1, positioned: .below, relativeTo: nil) + containerView.addSubview(barBlur2, positioned: .below, relativeTo: nil) + + containerView.layer?.cornerRadius = 12 + containerView.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] // Alle hoeken afgerond + containerView.layer?.masksToBounds = true + self.contentView = containerView + + // Maak cellen alleen voor actieve acties + for (gridIndex, action) in activeActions.enumerated() { + let col = gridIndex % cellsPerRow + let row = gridIndex / cellsPerRow + + let cellX = spacing + CGFloat(col) * (fixedCellWidth + spacing) + // Y-positie berekend van boven naar beneden voor de grid + let cellY = calculatedGridHeight - spacing - CGFloat(row + 1) * fixedCellHeight - CGFloat(row) * spacing + + let cellFrame = NSRect(x: cellX, y: cellY, width: fixedCellWidth, height: fixedCellHeight) + // Gebruik de 'index' van de actie (0 voor Rename, 1 voor Stash, etc.) voor de delegate + let cellView = GridCellView(frame: cellFrame, index: action.index, text: action.text) + containerView.addSubview(cellView) + cellViews.append(cellView) + } + + print("๐Ÿ”ท GridWindow init: Number of cellViews created = \(cellViews.count)") // Print na cell creatie + registerForDraggedTypes([.fileURL]) + + // Start timer voor dynamische transparantie + fadeTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true, block: { [weak self] _ in + self?.updateAlphaBasedOnCursor() + }) + } + + deinit { + fadeTimer?.invalidate() + } + + private func updateAlphaBasedOnCursor() { + guard !isPerformingProgrammaticHide else { return } // NIEUWE CHECK + + // NIEUW: Als monitoring uitgeschakeld is (rename actief), houd grid vol zichtbaar + if let manager = self.manager, manager.isMonitoringDisabled { + if abs(self.alphaValue - 1.0) > 0.01 { + self.alphaValue = 1.0 // Forceer volle alpha + } + return // Stop verdere alpha berekening + } + + guard !isInitialFadingIn else { return } // <-- CHECK DE FLAG + guard let screenPoint = NSEvent.mouseLocation as NSPoint? else { return } + let windowFrame = self.frame + + let distance: CGFloat + if windowFrame.contains(screenPoint) { + distance = 0 + } else { + // Bereken kortste afstand van punt naar rechthoek + let dx: CGFloat + if screenPoint.x < windowFrame.minX { dx = windowFrame.minX - screenPoint.x } + else if screenPoint.x > windowFrame.maxX { dx = screenPoint.x - windowFrame.maxX } else { dx = 0 } + + let dy: CGFloat + if screenPoint.y < windowFrame.minY { dy = windowFrame.minY - screenPoint.y } + else if screenPoint.y > windowFrame.maxY { dy = screenPoint.y - windowFrame.maxY } else { dy = 0 } + + distance = sqrt(dx*dx + dy*dy) + } + + let newAlpha: CGFloat + if distance <= fadeStart { newAlpha = 1 } + else if distance >= fadeEnd { newAlpha = minAlpha } + else { + let ratio = (distance - fadeStart) / (fadeEnd - fadeStart) + newAlpha = 1 - ratio * (1 - minAlpha) + } + + // Stel alpha direct in zonder animator voor snellere respons + if abs(self.alphaValue - newAlpha) > 0.01 { + self.alphaValue = newAlpha + } + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + // MARK: - NSDraggingDestination Methods (HERSTEL DE IMPLEMENTATIES) + + func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + // Geef aan dat we een kopieer-operatie accepteren + return .copy + } + + func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + let dropLocationInScreen = sender.draggingLocation + // Converteer naar coรถrdinaten binnen de content view van het grid window + guard let dropLocationInContent = self.contentView?.convert(dropLocationInScreen, from: nil) else { + // Als conversie faalt, doe niets (of reset highlight) + currentlyHighlightedCell?.setHighlighted(false) + currentlyHighlightedCell = nil + return [] + } + + var foundCell: GridCellView? = nil + // Zoek de cel onder de cursor + for cell in cellViews { + if cell.frame.contains(dropLocationInContent) { + foundCell = cell + break + } + } + + // Update highlighting alleen als de cel verandert + if currentlyHighlightedCell !== foundCell { + currentlyHighlightedCell?.setHighlighted(false) + currentlyHighlightedCell?.setHovered(false) + foundCell?.setHighlighted(true) + foundCell?.setHovered(true) + currentlyHighlightedCell = foundCell + } + + // Geef aan dat we nog steeds een kopieer-operatie accepteren + return .copy + } + + func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + // Haal de bestands URL op van het gesleepte item + guard let pasteboard = sender.draggingPasteboard.propertyList(forType: NSPasteboard.PasteboardType(rawValue: "NSFilenamesPboardType")) as? NSArray, + let path = pasteboard[0] as? String else { + manager?.hideGrid(monitorForReappear: true) // Verberg grid als we de data niet kunnen lezen + return false + } + let imageURL = URL(fileURLWithPath: path) + let dropLocationInContent = self.contentView?.convert(sender.draggingLocation, from: nil) ?? .zero + + // Verwijder highlight + currentlyHighlightedCell?.setHighlighted(false) + currentlyHighlightedCell = nil + + // Zoek de cel waarop gedropt is + for cell in cellViews { + if cell.frame.contains(dropLocationInContent) { + // Roep de delegate aan als deze bestaat + if let manager = self.manager, let delegate = manager.delegate { + print("โœ… GridWindow: Detected drop on cell \(cell.index). Calling delegate...") + delegate.gridViewManager(manager, didDropImage: imageURL, ontoCell: cell.index, at: dropLocationInContent) + // Het verbergen van de grid wordt nu afgehandeld door de delegate completion! + return true // Succesvolle drop + } else { + print("โŒ GridWindow: Manager or delegate is nil for drop on cell \(cell.index)!") + } + // Als manager of delegate nil is, faalt de operatie voor deze cel + return false + } + } + + // Als er niet op een cel is gedropt + manager?.hideGrid(monitorForReappear: true) + return false // Geen succesvolle drop + } + + func draggingExited(_ sender: NSDraggingInfo?) { + // Verwijder highlight en verberg grid als de cursor het window verlaat + currentlyHighlightedCell?.setHighlighted(false) + currentlyHighlightedCell?.setHovered(false) + currentlyHighlightedCell = nil + // Grid blijft zichtbaar; verbergen gebeurt elders als drag eindigt. + } + + // mouseUp override (om grid te sluiten bij klikken buiten cellen) + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + if !event.isARepeat { + // NIEUW: Controleer of monitoring is uitgeschakeld (rename actief) + guard let manager = self.manager, !manager.isMonitoringDisabled else { + print("๐Ÿ”ถ GridWindow: MouseUp ignored, monitoring is disabled (rename active).") + return + } + + let locationInWindow = self.contentView?.convert(event.locationInWindow, from: nil) ?? .zero + let cellUnderMouse = cellViews.first { $0.frame.contains(locationInWindow) } + if cellUnderMouse == nil { + manager.hideGrid(monitorForReappear: true) + } + } + } +} + +// MARK: - Grid View Manager +class GridViewManager { + var gridWindow: GridWindow? + weak var delegate: GridViewManagerDelegate? + + // Laatste bekende positie waarop de grid rond de preview werd getoond + private var lastPreviewFrame: NSRect? + private var reappearanceTimer: Timer? + + // Flag om monitoring uit te schakelen tijdens rename operaties (internal access) + var isMonitoringDisabled: Bool = false + + // GEFIXT: Explicit false initialization + reset in init + private var isDragSessionActive: Bool = false + + // NIEUW: Initializer om state correct te resetten + init() { + // CRITICAL: Reset all state to ensure consistent behavior between swift run and .app + self.isDragSessionActive = false + self.isMonitoringDisabled = false + self.lastPreviewFrame = nil + print("๐Ÿ”ถ GridViewManager: Initialized with clean state - isDragSessionActive: \(self.isDragSessionActive)") + } + + // MARK: - Show Grid + func showGrid(previewFrame: NSRect?) { + print("๐Ÿ”ถ MAIN DEBUG: GridViewManager: showGrid called for MAIN THUMBNAIL") + print("๐Ÿ”ถ MAIN DEBUG: This is MAIN app grid (NOT stash grid)") + print("๐Ÿ”ถ MAIN DEBUG: PreviewFrame: \(String(describing: previewFrame))") + + // Annuleer eventueel lopende timers โ€“ grid is alweer zichtbaar + reappearanceTimer?.invalidate(); reappearanceTimer = nil + + self.lastPreviewFrame = previewFrame + + if let existingWindow = gridWindow { + print("๐Ÿ”ถ MAIN DEBUG: Closing existing main grid window") + existingWindow.isPerformingProgrammaticHide = false // Reset voor het geval het vastzat + existingWindow.orderOut(nil as Any?) + self.gridWindow = nil + } + + // Bepaal het juiste scherm op basis van waar de thumbnail zich bevindt + let screen: NSScreen + if let pFrame = previewFrame { + // Zoek het scherm dat de thumbnail bevat + let thumbnailCenter = NSPoint(x: pFrame.midX, y: pFrame.midY) + if let thumbnailScreen = NSScreen.screens.first(where: { $0.frame.contains(thumbnailCenter) }) { + screen = thumbnailScreen + print("๐Ÿ”ถ MAIN DEBUG: Using thumbnail screen for main grid: \(thumbnailScreen.localizedName)") + } else { + // Fallback naar hoofdscherm als thumbnail scherm niet gevonden + screen = NSScreen.main ?? NSScreen.screens.first! + print("๐Ÿ”ถ MAIN DEBUG: Thumbnail screen not found, using fallback: \(screen.localizedName)") + } + } else { + // Geen preview frame, gebruik hoofdscherm + screen = NSScreen.main ?? NSScreen.screens.first! + print("๐Ÿ”ถ MAIN DEBUG: No preview frame, using main screen: \(screen.localizedName)") + } + + print("๐Ÿ”ถ MAIN DEBUG: Creating NEW GridWindow (main app grid, not stash)") + gridWindow = GridWindow(screen: screen, manager: self, previewFrame: previewFrame) + gridWindow?.gridViewManagerDelegate = self.delegate + gridWindow?.alphaValue = 0 + gridWindow?.makeKeyAndOrderFront(nil as Any?) // Zorg ervoor dat het key window wordt voor events + gridWindow?.orderFrontRegardless() + gridWindow?.isInitialFadingIn = true // <-- ZET FLAG VOOR ANIMATIE + + print("๐Ÿ”ถ MAIN DEBUG: Animating main grid appearance") + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 1.0 // AANGEPAST van 0.2 naar 1.0 + self.gridWindow?.animator().alphaValue = 1 + } completionHandler: { // <-- COMPLETION HANDLER TOEGEVOEGD + self.gridWindow?.isInitialFadingIn = false // <-- RESET FLAG NA ANIMATIE + } + } + + // MARK: - Hide Grid + func hideGrid(monitorForReappear: Bool = false) { + print("๐Ÿ”ถ MAIN DEBUG: GridViewManager: hideGrid called for MAIN THUMBNAIL") + print("๐Ÿ”ถ MAIN DEBUG: monitorForReappear = \(monitorForReappear)") + guard let window = gridWindow else { + print("๐Ÿ”ถ MAIN DEBUG: No main grid window to hide") + return + } + + window.isPerformingProgrammaticHide = true // ZET FLAG VOOR ANIMATIE + + // Stop een bestaande timer zodat we niet meerdere tegelijk hebben + if !monitorForReappear { + reappearanceTimer?.invalidate(); reappearanceTimer = nil + } + + print("๐Ÿ”ถ MAIN DEBUG: Hiding main grid window") + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.8 // Teruggezet naar 0.8s + window.animator().alphaValue = 0 + }, completionHandler: { [weak self] in + guard let self = self else { return } + print("๐Ÿ”ถ MAIN DEBUG: Main grid hide animation complete") + window.orderOut(nil as Any?) + if self.gridWindow === window { + self.gridWindow = nil + } + // Reset flag na animatie en orderOut, maar VOOR potentiรซle startReappearanceMonitor + // Echter, window referentie is nu mogelijk nil als self.gridWindow === window was. + // Het is veiliger om de flag te resetten via een referentie die nog geldig is, of de logica herzien. + // Voor nu: als window nog bestaat (niet de self.gridWindow was die nil werd), reset het. + // Maar de window instance zelf wordt niet direct nil. We kunnen het nog steeds gebruiken. + window.isPerformingProgrammaticHide = false + + // Start monitor na het verbergen ALLEEN als monitoring niet uitgeschakeld is + if monitorForReappear && !self.isMonitoringDisabled { + self.startReappearanceMonitor() + } + }) + } + + // NIEUW: Methodes om monitoring te controleren + func disableMonitoring() { + print("๐Ÿ”ถ GridViewManager: Monitoring disabled (e.g., during rename)") + isMonitoringDisabled = true + // NIEUW: Stop de reappearance timer volledig + reappearanceTimer?.invalidate() + reappearanceTimer = nil + print("๐Ÿ”ถ GridViewManager: Reappearance timer stopped and invalidated") + } + + func enableMonitoring() { + print("๐Ÿ”ถ GridViewManager: Monitoring enabled") + isMonitoringDisabled = false + // GEEN automatische herstart van timer hier - alleen als grid opnieuw wordt verborgen + } + + // NIEUW: Start drag session - schakelt proximity monitoring in + func startDragSession() { + print("๐Ÿ”ถ GridViewManager: Drag session started - enabling proximity monitoring") + isDragSessionActive = true + } + + // NIEUW: Stop drag session - schakelt proximity monitoring uit + func stopDragSession() { + print("๐Ÿ”ถ GridViewManager: Drag session ended - disabling proximity monitoring") + isDragSessionActive = false + // Stop proximity timer als er geen drag actief is + reappearanceTimer?.invalidate() + reappearanceTimer = nil + } + + // MARK: - Monitoring Logic + private func startReappearanceMonitor() { + // GEFIXT: Check of monitoring uitgeschakeld is EN of er een actieve drag sessie is + // CRITICAL: Only start proximity monitoring during active drag sessions + guard !isMonitoringDisabled && isDragSessionActive else { + print("๐Ÿ”ถ GridViewManager: Skipping reappearance monitor - monitoring disabled: \(isMonitoringDisabled), drag active: \(isDragSessionActive)") + print("๐Ÿ”ถ GridViewManager: This prevents grid from triggering without actual drag") + return + } + + // Safety: invalideer vorige timer + reappearanceTimer?.invalidate() + + guard let targetFrame = lastPreviewFrame else { + print("๐Ÿ”ถ GridViewManager: No previewFrame โ€“ skipping reappearance monitor.") + return + } + + print("๐Ÿ”ถ GridViewManager: Starting proximity monitoring (only during active drag)") + reappearanceTimer = Timer.scheduledTimer(withTimeInterval: 0.12, repeats: true) { [weak self] _ in + self?.evaluateMouseProximity(to: targetFrame) + } + RunLoop.main.add(reappearanceTimer!, forMode: .common) + } + + private func evaluateMouseProximity(to frame: NSRect) { + // GEFIXT: Check of monitoring uitgeschakeld is EN of er een actieve drag sessie is + // CRITICAL: Only evaluate proximity during active drag sessions + guard !isMonitoringDisabled && isDragSessionActive else { + print("๐Ÿ”ถ GridViewManager: Skipping proximity evaluation - monitoring disabled: \(isMonitoringDisabled), drag active: \(isDragSessionActive)") + // NIEUW: Stop timer when drag session is not active + reappearanceTimer?.invalidate() + reappearanceTimer = nil + return + } + + // Bereken een vergrote zone (200 px marge) rondom de preview + let expansion: CGFloat = 200 + let enlarged = frame.insetBy(dx: -expansion, dy: -expansion) + + let currentLoc = NSEvent.mouseLocation + if enlarged.contains(currentLoc) { + // Cursor is weer in de buurt โ€“ toon grid opnieuw + print("๐Ÿ”ถ GridViewManager: Mouse near preview โ€“ showing grid again.") + self.showGrid(previewFrame: self.lastPreviewFrame) + } + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/IntegratedGalleryView.swift b/ShotScreen/Sources/IntegratedGalleryView.swift new file mode 100644 index 0000000..1466002 --- /dev/null +++ b/ShotScreen/Sources/IntegratedGalleryView.swift @@ -0,0 +1,2666 @@ +import SwiftUI +import UniformTypeIdentifiers // Nodig voor UTType.image +import Vision // Voor OCR functionaliteit + +// MARK: - Image Store (ObservableObject) +class GalleryImageStore: ObservableObject { + @Published var images: [IdentifiableImage] = [] + + // NIEUW: Permanent stash directory + private static let stashDirectoryName = "StashItems" + + // NIEUW: Initialization flag + private var hasInitialized = false + + // NIEUW: Set om bij te houden welke afbeeldingen momenteel worden toegevoegd + private var processingIdentifiers = Set() + + init() { + // Cleanup oude stash bestanden bij opstarten + if !hasInitialized { + cleanupStashDirectory() + hasInitialized = true + } + } + + private var stashDirectory: URL { + let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let appDirectory = appSupportURL.appendingPathComponent("ShotScreen") + let stashDir = appDirectory.appendingPathComponent(Self.stashDirectoryName) + + // Zorg ervoor dat de directory bestaat + try? FileManager.default.createDirectory(at: stashDir, withIntermediateDirectories: true, attributes: nil) + + return stashDir + } + + // NIEUW: Public accessor voor stash directory + var publicStashDirectory: URL { + return self.stashDirectory + } + + // NIEUW: Helper functie om een unieke copy naam te genereren + func generateUniqueCopyName(baseName: String) -> String { + let existingNames = Set(images.compactMap { item in + item.fileURL?.deletingPathExtension().lastPathComponent ?? item.customName + }) + + // Probeer eerst zonder nummer + let firstTry = "\(baseName) Copy" + if !existingNames.contains(firstTry) { + return firstTry + } + + // Als die bestaat, probeer met nummers + var counter = 2 + while counter <= 999 { // Voorkom oneindige loop + let attempt = "\(baseName) Copy \(counter)" + if !existingNames.contains(attempt) { + return attempt + } + counter += 1 + } + + // Fallback als er teveel copies zijn + return "\(baseName) Copy \(Int(Date().timeIntervalSince1970) % 10000)" + } + + func addImage(_ nsImage: NSImage, fileURL: URL? = nil, suggestedName: String? = nil, skipDuplicateCheck: Bool = false) { + let callId = UUID().uuidString.prefix(4) + print("๐Ÿ–ผ๏ธ [\(callId)] addImage START. fileURL: \(fileURL?.lastPathComponent ?? "nil"), suggestedName: '\(suggestedName ?? "nil")', nsImage size: \(nsImage.size), skipDuplicateCheck: \(skipDuplicateCheck)") + + // Step 1: Generate unique identifier + let imageIdentifier = generateImageIdentifier(from: nsImage, fileURL: fileURL, suggestedName: suggestedName, callId: String(callId)) + + // Step 2: Check if already processing + guard !processingIdentifiers.contains(imageIdentifier) else { + print("๐Ÿ–ผ๏ธ [\(callId)] GalleryImageStore: Afbeelding met identifier '\(imageIdentifier.prefix(50))...' wordt AL VERWERKT. Toevoegen overgeslagen.") + return + } + + // Step 3: Mark as processing + processingIdentifiers.insert(imageIdentifier) + print("โณ [\(callId)] GalleryImageStore: Identifier '\(imageIdentifier.prefix(50))...' toegevoegd aan processing set.") + + // Step 4: Process on main queue + DispatchQueue.main.async { + self.processImageAddition(nsImage: nsImage, fileURL: fileURL, suggestedName: suggestedName, + skipDuplicateCheck: skipDuplicateCheck, imageIdentifier: imageIdentifier, callId: String(callId)) + } + } + + // MARK: - Private Helper Methods for addImage + + private func generateImageIdentifier(from nsImage: NSImage, fileURL: URL?, suggestedName: String?, callId: String) -> String { + if let url = fileURL, url.path.contains(self.stashDirectory.path) { + let identifier = url.path + print("๐Ÿ–ผ๏ธ [\(callId)] Generated imageIdentifier (from stash path): \(identifier)") + return identifier + } else if let tiffData = nsImage.tiffRepresentation { + let identifier = "tiff_\(tiffData.count)_\(suggestedName ?? "no_name")_\(fileURL?.lastPathComponent ?? "no_url")" + print("๐Ÿ–ผ๏ธ [\(callId)] Generated imageIdentifier (from TIFF data): \(identifier)") + return identifier + } else { + print("โš ๏ธ [\(callId)] GalleryImageStore: Kan geen unieke identifier voor afbeelding genereren. Toevoegen overgeslagen.") + return "failed_\(callId)" + } + } + + private func processImageAddition(nsImage: NSImage, fileURL: URL?, suggestedName: String?, + skipDuplicateCheck: Bool, imageIdentifier: String, callId: String) { + print("๐Ÿ–ผ๏ธ [\(callId)] DispatchQueue.main.async - START check images.contains") + + // Step 1: Check for duplicates (unless skipped) + let alreadyInStore = checkForDuplicates(nsImage: nsImage, fileURL: fileURL, + skipDuplicateCheck: skipDuplicateCheck, callId: callId) + + if alreadyInStore { + print("๐Ÿ–ผ๏ธ [\(callId)] Afbeelding BESTAAT AL in de store. Verwijder van processing set.") + self.processingIdentifiers.remove(imageIdentifier) + print("โŒ› [\(callId)] Identifier '\(imageIdentifier.prefix(50))...' verwijderd van processing set (bestond al).") + return + } + + // Step 2: Create permanent URL if needed + guard let permanentURL = ensurePermanentURL(nsImage: nsImage, fileURL: fileURL, + suggestedName: suggestedName, callId: callId) else { + print("โŒ [\(callId)] GalleryImageStore: Kon geen permanente URL verkrijgen/maken. Verwijder van processing set.") + self.processingIdentifiers.remove(imageIdentifier) + print("โŒ› [\(callId)] Identifier '\(imageIdentifier.prefix(50))...' verwijderd van processing set (URL error).") + return + } + + // Step 3: Create and add the new item + let newItem = IdentifiableImage(nsImage: nsImage, fileURL: permanentURL, customName: suggestedName) + print("๐Ÿ–ผ๏ธ [\(callId)] Nieuw IdentifiableImage aangemaakt: ID \(newItem.id.uuidString.prefix(4)), URL: \(newItem.fileURL?.lastPathComponent ?? "nil"), Name: \(newItem.customName ?? "nil")") + + self.images.append(newItem) + print("๐Ÿ–ผ๏ธ [\(callId)] Afbeelding toegevoegd. URL: \(permanentURL.lastPathComponent). Nieuwe count: \(self.images.count). Verwijder van processing set.") + self.processingIdentifiers.remove(imageIdentifier) + print("โŒ› [\(callId)] Identifier '\(imageIdentifier.prefix(50))...' verwijderd van processing set (succesvol toegevoegd).") + } + + private func checkForDuplicates(nsImage: NSImage, fileURL: URL?, skipDuplicateCheck: Bool, callId: String) -> Bool { + if skipDuplicateCheck { + print("๐Ÿ”ฅ [\(callId)] ULTRA MODE: Skipping duplicate check for intentional duplicate") + return false + } + + return self.images.contains { existingItem in + print("๐Ÿ–ผ๏ธ [\(callId)] Checking existingItem: ID \(existingItem.id.uuidString.prefix(4)), URL: \(existingItem.fileURL?.lastPathComponent ?? "nil"), Name: \(existingItem.customName ?? "nil")") + + // Check URL match + if let existingURL = existingItem.fileURL, let newURL = fileURL, + existingURL.standardizedFileURL == newURL.standardizedFileURL { + print("๐Ÿ–ผ๏ธ [\(callId)] Match on standardizedFileURL: \(newURL.path)") + return true + } + + // Check TIFF data match + if let d1 = existingItem.nsImage.tiffRepresentation, + let d2 = nsImage.tiffRepresentation, d1 == d2 { + print("๐Ÿ–ผ๏ธ [\(callId)] Match on TIFF data.") + return true + } + + return false + } + } + + private func ensurePermanentURL(nsImage: NSImage, fileURL: URL?, suggestedName: String?, callId: String) -> URL? { + var permanentURLToUse: URL? = fileURL + + if permanentURLToUse == nil || + !FileManager.default.fileExists(atPath: permanentURLToUse!.path) || + !permanentURLToUse!.path.contains(self.stashDirectory.path) { + + var finalSuggestedName = suggestedName + if finalSuggestedName == nil || finalSuggestedName?.isEmpty == true { + if let providedURL = fileURL { + finalSuggestedName = providedURL.deletingPathExtension().lastPathComponent + } else { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd_HH.mm.ss.SSS" + finalSuggestedName = "Image_\(dateFormatter.string(from: Date()))" + } + print("๐Ÿ–ผ๏ธ [\(callId)] Geen/ongeldige naam of URL, gebruik (herleide) naam: '\(finalSuggestedName ?? "default_name")'") + } + + let nameForURLCreation = finalSuggestedName ?? "default_for_url_\(callId)" + print("๐Ÿ–ผ๏ธ [\(callId)] Maak permanente URL met effective name: '\(nameForURLCreation)'") + permanentURLToUse = self.createPermanentStashURL(for: nsImage, suggestedName: nameForURLCreation, useOriginalName: true) + } + + return permanentURLToUse + } + + // NIEUW: Maak een permanente URL voor stash items + private func createPermanentStashURL(for nsImage: NSImage, suggestedName: String?, useOriginalName: Bool) -> URL? { + guard let tiffRepresentation = nsImage.tiffRepresentation, + let bitmapImageRep = NSBitmapImageRep(data: tiffRepresentation), + let pngData = bitmapImageRep.representation(using: .png, properties: [:]) else { + print("โŒ Could not create permanent stash URL: image conversion failed") + return nil + } + + // FIXED: Gebruik originele naam als useOriginalName true is en suggestedName bestaat + let filename: String + if useOriginalName, let originalName = suggestedName, !originalName.isEmpty { + // Gebruik originele naam met .png extensie + let baseFilename = "\(originalName).png" + + // Check voor duplicate namen + var finalFilename = baseFilename + var counter = 1 + while FileManager.default.fileExists(atPath: stashDirectory.appendingPathComponent(finalFilename).path) { + finalFilename = "\(originalName) (\(counter)).png" + counter += 1 + if counter > 100 { + finalFilename = baseFilename + break + } + } + + filename = finalFilename + } else { + // Default naar timestamp-based filename + let timestamp = Int(Date().timeIntervalSince1970) + let uuid = UUID().uuidString.prefix(8) + filename = "StashItem_\(timestamp)_\(uuid).png" + } + + let permanentURL = stashDirectory.appendingPathComponent(filename) + + do { + try pngData.write(to: permanentURL) + print("โœ… Created permanent stash URL: \(permanentURL.lastPathComponent)") + return permanentURL + } catch { + print("โŒ Could not create permanent stash URL: \(error)") + return nil + } + } + + // Helper om de store leeg te maken en een initiรซle afbeelding in te stellen (optioneel) + func setInitialImage(_ nsImage: NSImage?) { + DispatchQueue.main.async { + self.images.removeAll() + if let img = nsImage { + // AANGEPAST: Ook initiรซle image krijgt permanente URL + let permanentURL = self.createPermanentStashURL(for: img, suggestedName: nil, useOriginalName: true) + self.images.append(IdentifiableImage(nsImage: img, fileURL: permanentURL)) + } + print("๐Ÿ–ผ๏ธ GalleryImageStore: Initial image set. Count: \(self.images.count)") + } + } + + func removeImage(_ imageItem: IdentifiableImage) { + // NIEUW: Verwijder ook het permanente bestand + if let fileURL = imageItem.fileURL { + try? FileManager.default.removeItem(at: fileURL) + print("๐Ÿ—‘๏ธ Removed permanent stash file: \(fileURL.lastPathComponent)") + } + + self.images.removeAll { $0.id == imageItem.id } + print("๐Ÿ–ผ๏ธ GalleryImageStore: Image removed. New count: \(self.images.count)") + } + + func renameImage(item: IdentifiableImage, newName: String) -> Bool { + guard let index = images.firstIndex(where: { $0.id == item.id }) else { + return false + } + + // NIEUW: Update ook de filename van het permanente bestand + if let oldURL = item.fileURL { + let newFilename = "\(newName).png" + let newURL = oldURL.deletingLastPathComponent().appendingPathComponent(newFilename) + + do { + try FileManager.default.moveItem(at: oldURL, to: newURL) + // Update het item met de nieuwe URL en naam + images[index].fileURL = newURL + images[index].customName = newName + print("โœ… Renamed stash file from \(oldURL.lastPathComponent) to \(newURL.lastPathComponent)") + return true + } catch { + print("โŒ Failed to rename stash file: \(error)") + // Als bestand hernoemen faalt, update alleen de customName + images[index].customName = newName + print("โ„น๏ธ Updated display name only (file rename failed)") + return true + } + } else { + // Geen fileURL, update alleen de customName + images[index].customName = newName + print("โ„น๏ธ Updated display name only (no file URL)") + return true + } + } + + // Methode om een item te verversen (bijv. na inline bewerking zoals BG remove) + func refreshItem(_ item: IdentifiableImage) { + // Dit is een placeholder. Afhankelijk van hoe IdentifiableImage en SwiftUI updates werken, + // kan het zijn dat je het object moet vervangen of simpelweg de array moet publiceren. + // Als IdentifiableImage een class is en @Published wordt gebruikt, kan een simpele property change voldoende zijn. + // Als het een struct is, moet je het item in de 'images' array vervangen. + if let index = images.firstIndex(where: { $0.id == item.id }) { + // Forceer een update door het object (of een kopie met dezelfde id) opnieuw toe te wijzen + // Dit is een beetje een hack, een beter ObservableObject-patroon is aanbevolen. + let updatedItem = images[index] // Neem aan dat het een class is en de interne state is gewijzigd + images.remove(at: index) + images.insert(updatedItem, at: index) + print("๐Ÿ”„ GalleryImageStore: Refreshed item \(item.id)") + } + } + + // NIEUW: Cleanup functie om oude stash bestanden te verwijderen + func cleanupStashDirectory() { + do { + let contents = try FileManager.default.contentsOfDirectory(at: stashDirectory, includingPropertiesForKeys: [.creationDateKey]) + + // Verwijder bestanden die niet meer in de images array staan + let activeURLs = Set(images.compactMap { $0.fileURL }) + + for fileURL in contents { + if !activeURLs.contains(fileURL) { + try? FileManager.default.removeItem(at: fileURL) + print("๐Ÿงน Cleaned up orphaned stash file: \(fileURL.lastPathComponent)") + } + } + } catch { + print("โš ๏ธ Could not cleanup stash directory: \(error)") + } + } +} + +// Hulpstructuur om NSImage identificeerbaar te maken voor de ForEach +struct IdentifiableImage: Identifiable { + let id = UUID() + let nsImage: NSImage + var fileURL: URL? = nil // AANGEPAST: nu altijd een permanente URL voor stash items + var customName: String? = nil // huidige naam (kan via rename wijzigen) +} + +// MARK: - Window Dragging Support +// Deze NSViewRepresentable host onze CustomDragNSView +struct WindowDragView: NSViewRepresentable { + func makeNSView(context: Context) -> CustomDragNSView { + return CustomDragNSView() + } + + func updateNSView(_ nsView: CustomDragNSView, context: Context) { + // Geen updates nodig voor nu + } +} + +// Deze NSView vangt muis events af om het venster te kunnen slepen +class CustomDragNSView: NSView { + private var mouseDownScreenLocation: NSPoint? + private var initialWindowOrigin: NSPoint? + + override func mouseDown(with event: NSEvent) { + guard let window = self.window else { return } + self.mouseDownScreenLocation = NSEvent.mouseLocation + self.initialWindowOrigin = window.frame.origin + } + + override func mouseDragged(with event: NSEvent) { + guard let window = self.window, + let mouseDownScreenLoc = self.mouseDownScreenLocation, + let initialWinOrigin = self.initialWindowOrigin else { return } + + let currentMouseScreenLocation = NSEvent.mouseLocation + let deltaX = currentMouseScreenLocation.x - mouseDownScreenLoc.x + let deltaY = currentMouseScreenLocation.y - mouseDownScreenLoc.y + + let newWindowOriginX = initialWinOrigin.x + deltaX + let newWindowOriginY = initialWinOrigin.y + deltaY + + window.setFrameOrigin(NSPoint(x: newWindowOriginX, y: newWindowOriginY)) + } + + override func mouseUp(with event: NSEvent) { + self.mouseDownScreenLocation = nil + self.initialWindowOrigin = nil + } +} + +// Representable voor NSVisualEffectView zodat we hem in SwiftUI kunnen gebruiken +struct VisualEffectBackground: NSViewRepresentable { + var material: NSVisualEffectView.Material = .hudWindow + var blending: NSVisualEffectView.BlendingMode = .behindWindow + var alpha: CGFloat = 1.0 + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blending + view.state = .active + view.alphaValue = alpha + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blending + nsView.alphaValue = alpha + } +} + +struct IntegratedGalleryView: View { + @ObservedObject var imageStore: GalleryImageStore + let initialImage: NSImage? + let initialImageURL: URL? + let initialImageName: String? + let hostingWindow: NSWindow? + let closeAction: () -> Void + + // MARK: - State Variables + @State private var showPreview = false + @State private var hoveredImageID: UUID? = nil + @State private var targetedImageID: UUID? = nil // <-- TOEGEVOEGD VOOR PRECISIE + @State private var isTargeted = false + @State private var windowWidth: CGFloat = 200 // Default, wordt bijgewerkt + @State private var windowHeight: CGFloat = 300 // Default, wordt bijgewerkt + @State private var refreshTrigger = 0 + @State private var isLayoutDebugEnabled = false + private let debugUpdateInterval = 1.0 + @State private var timer: Timer? + @State private var refreshPreviewTimer: Timer? + + // ๐Ÿ”ฅ NIEUW: Drop Zone fade state + @State private var dropZoneOpacity: Double = 1.0 + @State private var dropZoneFadeTimer: Timer? + + // MARK: - More State Variables + @State private var currentPreviewName: String = "Unknown" // TURBO FIX: Instant naam tracking + @State private var previewNameRefreshTimer: Timer? // โฐ TIMER FORCE REFRESH + @State private var imageForPreview: NSImage? + @State private var isPreviewStable: Bool = false + @State private var hoverWorkItem: DispatchWorkItem? + + // State voor het preview venster + @State private var isClosingGracefully = false + @State private var previewWindowController: NSWindowController? = nil + + // NIEUW: Stable state management to prevent excessive re-rendering + @State private var renderIsolationID = UUID() + + @State private var didGridHandleDrop: Bool = false + + // ID voor de view + @State private var _galleryID = UUID() + var galleryID: UUID { _galleryID } + + // NIEUW: Cache voor StashDragDelegate instances + @State private var dragDelegateCache: [UUID: StashDragDelegate] = [:] + + // CRITICAL FIX: Stable delegate cache that persists across renders + @State private var stableDelegateCache: [UUID: StashDragDelegate] = [:] + + // CRITICAL FIX: Prevent cascade re-renders with render isolation + @State private var lastImageCount: Int = 0 + + // Instellingen voor de grid en venstergrootte + private let cellSpacing: CGFloat = 8 + private let paddingAroundGrid: CGFloat = 5 + private let titleBarHeight: CGFloat = 18 + private let defaultEmptyWidth: CGFloat = 100 + private let defaultEmptyHeight: CGFloat = 100 + + private var calculatedThumbnailSize: CGFloat { + let baseSize: CGFloat = 70 + return baseSize + } + private let thumbnailCornerRadius: CGFloat = 4 + private let hoverScaleEffect: CGFloat = 1.03 + + // Haal border width op uit SettingsManager (wordt nu automatisch geรผpdatet door @ObservedObject) + // private var stashBorderWidth: CGFloat { settings.stashWindowBorderWidth } // Deze kan weg of blijven + // private var stashBorderColor: Color { Color.gray.opacity(0.5) } // Deze kan weg of blijven + + private var gridColumns: [GridItem] { + Array(repeating: GridItem(.flexible(), spacing: cellSpacing), count: actualNumberOfColumns()) + } + + // Initializer om de store te ontvangen + // De initialImage parameter is voor het gemak om de store direct te vullen bij creatie. + init(imageStore: GalleryImageStore, initialImage: NSImage? = nil, initialImageURL: URL? = nil, initialImageName: String? = nil, hostingWindow: NSWindow?, closeAction: @escaping () -> Void) { + let callId = UUID().uuidString.prefix(4) + print("๐ŸŽจ [\(callId)] IntegratedGalleryView.INIT CALLED.") + print("๐ŸŽจ [\(callId)] imageStore.images.count: \(imageStore.images.count)") + print("๐ŸŽจ [\(callId)] initialImage isNil: \(initialImage == nil)") + if let img = initialImage { print("๐ŸŽจ [\(callId)] initialImage size: \(img.size)") } + print("๐ŸŽจ [\(callId)] initialImageURL: \(initialImageURL?.lastPathComponent ?? "nil")") + print("๐ŸŽจ [\(callId)] initialImageName: '\(initialImageName ?? "nil")'") + print("๐ŸŽจ [\(callId)] hostingWindow isNil: \(hostingWindow == nil)") + + self.imageStore = imageStore + self.initialImage = initialImage + self.initialImageURL = initialImageURL + self.initialImageName = initialImageName + self.hostingWindow = hostingWindow + self.closeAction = closeAction + + if let imgToAdd = initialImage { + let imageNameForLog = initialImageName ?? initialImageURL?.lastPathComponent ?? "Unnamed InitialImage (from init)" + print("๐ŸŽจ [\(callId)] Overweegt initialImage '\(imageNameForLog)' voor toevoeging.") + + var alreadyExistsReason = "None" + let alreadyExists = imageStore.images.contains { existingItem in + print("๐ŸŽจ [\(callId)] INIT: Comparing with existingItem ID \(existingItem.id.uuidString.prefix(4)), URL: \(existingItem.fileURL?.lastPathComponent ?? "nil"), Name: \(existingItem.customName ?? "nil")") + if let newURL = initialImageURL, newURL.isFileURL, + let existingURL = existingItem.fileURL, existingURL.isFileURL, + newURL.standardizedFileURL == existingURL.standardizedFileURL { + if FileManager.default.fileExists(atPath: newURL.path) { + print("๐ŸŽจ [\(callId)] INIT: Match on standardizedFileURL: \(newURL.path)") + alreadyExistsReason = "Matched standardizedFileURL: \(newURL.path)" + return true + } + } + if let newTIFF = imgToAdd.tiffRepresentation, let existingTIFF = existingItem.nsImage.tiffRepresentation, newTIFF == existingTIFF { + print("๐ŸŽจ [\(callId)] INIT: Match on TIFF data.") + alreadyExistsReason = "Matched TIFF data" + return true + } + return false + } + print("๐ŸŽจ [\(callId)] INIT: alreadyExists check result: \(alreadyExists). Reason: \(alreadyExistsReason)") + + if !alreadyExists { + print("โœ… [\(callId)] INIT: InitialImage '\(imageNameForLog)' wordt toegevoegd aan store.") + // ๐Ÿ”ฅ CRITICAL FIX: Skip duplicate check for initial stash images to allow BGR duplicates + imageStore.addImage(imgToAdd, fileURL: initialImageURL, suggestedName: initialImageName, skipDuplicateCheck: true) + } else { + print("โ„น๏ธ [\(callId)] INIT: InitialImage '\(imageNameForLog)' overgeslagen - bestaat al (Reason: \(alreadyExistsReason)).") + } + } else { + print("โ„น๏ธ [\(callId)] INIT: Geen initialImage meegegeven.") + } + print("๐ŸŽจ [\(callId)] IntegratedGalleryView.INIT COMPLETED. imageStore.images.count: \(self.imageStore.images.count)") + } + + var body: some View { + // CRITICAL FIX: Render isolation - only log significant changes + let currentImageCount = imageStore.images.count + let shouldLogRender = currentImageCount != lastImageCount + let _ = shouldLogRender ? print("๐ŸŽจ IntegratedGalleryView.BODY: SIGNIFICANT CHANGE - Image count: \(currentImageCount) -> \(lastImageCount)") : () + + ZStack { // Root ZStack + if isClosingGracefully { + EmptyView() + } else { + VStack(spacing: 0) { + // Custom Title Bar - ULTRA FIX: Altijd zichtbaar voor consistente layout + HStack { + Button(action: { + self.closeAction() + }) { + Image(systemName: "xmark") + .foregroundColor(Color.adaptivePrimaryText) + .font(.system(size: 10, weight: .bold)) + } + .buttonStyle(.plain) + .padding(.horizontal, 10) + + Spacer() + + // ๐ŸŽฏ NIEUW: Shake Test Button - NU RECHTS UITGELIJND + Button(action: { + testShakeAnimation() + }) { + Image(systemName: "bolt.fill") + .foregroundColor(.yellow) + .font(.system(size: 10, weight: .bold)) + } + .buttonStyle(.plain) + .padding(.horizontal, 10) + .help("Test Shake Animation") + } + .frame(height: titleBarHeight) + .background( + ZStack { + VisualEffectBackground(alpha: 0.8) + .cornerRadius(8) + VisualEffectBackground(alpha: 0.4) + .cornerRadius(8) + WindowDragView() + } + ) + .transition(.opacity) + + // Content Area - ULTRA FIX: Altijd dezelfde layout + VStack(spacing: 0) { + consistentImageGridView + permanentFileDropZone + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .frame(width: effectiveWindowWidth(), height: effectiveWindowHeight()) + .background(Color.clear) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.clear, lineWidth: 0) + ) + .animation(.easeInOut(duration: 0.2), value: imageStore.images.isEmpty) + .onChange(of: showPreview) { newValue in + handlePreviewWindowState(shouldShow: newValue) + } + .onChange(of: imageForPreview) { newImage in + if showPreview, let _ = newImage { + handlePreviewWindowState(shouldShow: true) + } + } + .onChange(of: imageStore.images.count) { newCount in + // CRITICAL FIX: Update tracking variables + DispatchQueue.main.async { + if self.lastImageCount != newCount { + self.lastImageCount = newCount + self.renderIsolationID = UUID() + print("๐Ÿ”„ CRITICAL: Image count changed to \(newCount), updated render isolation") + + // ๐Ÿ”ฅ MEGA ULTRA FIX: If count increased and we're showing preview, update to show newest item + if newCount > self.lastImageCount && self.showPreview && self.imageStore.images.count > 0 { + if let newestItem = self.imageStore.images.last { + let newestName = newestItem.fileURL?.deletingPathExtension().lastPathComponent ?? "Unknown" + print("๐Ÿ”ฅ MEGA: New item detected during preview! Updating name to: '\(newestName)'") + self.currentPreviewName = newestName + + // Force immediate preview update + self.forceRefreshPreviewName() + } + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in + closePreviewWindow() + } + .onReceive(NotificationCenter.default.publisher(for: .stashPreviewSizeChanged)) { _ in + // ๐Ÿ”ฅ๐Ÿ’Ž MEGA LIVE UPDATE VOOR PREVIEW SIZE! ๐Ÿ’Ž๐Ÿ”ฅ + print("๐Ÿ”ฅ STASH: Preview size setting changed - will apply on next hover") + // De nieuwe size wordt automatisch toegepast bij de volgende hover omdat we + // SettingsManager.shared.stashPreviewSize.percentage gebruiken in de size calculations + } + .onReceive(NotificationCenter.default.publisher(for: .stashGridModeChanged)) { _ in + // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE GRID MODE LIVE UPDATE! โšก๐Ÿ’ฅ๐Ÿ”ฅ + print("๐Ÿ”ฅ HYPERMODE: Grid mode changed - triggering layout update") + DispatchQueue.main.async { + self.renderIsolationID = UUID() // Force render update + } + } + .onReceive(NotificationCenter.default.publisher(for: .stashGridConfigChanged)) { _ in + // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE GRID CONFIG LIVE UPDATE! โšก๐Ÿ’ฅ๐Ÿ”ฅ + print("๐Ÿ”ฅ HYPERMODE: Grid configuration changed - triggering layout update") + DispatchQueue.main.async { + self.renderIsolationID = UUID() // Force render update + // Force window size recalculation by updating hosting window + if let window = self.hostingWindow { + let newWidth = self.effectiveWindowWidth() + let newHeight = self.effectiveWindowHeight() + let newSize = NSSize(width: newWidth, height: newHeight) + print("๐Ÿ”ฅ HYPERMODE: Updating window size to \(newWidth) x \(newHeight)") + window.setContentSize(newSize) + } + } + } + .onAppear { + self.lastImageCount = imageStore.images.count + print("๐Ÿ”ถ IntegratedGalleryView: onAppear - initialized with \(imageStore.images.count) images") + } + .onDisappear { + closePreviewWindow() + stopPreviewNameRefreshTimer() // โฐ Stop timer on disappear + dropZoneFadeTimer?.invalidate() // ๐Ÿ”ฅ CLEANUP FADE TIMER + imageStore.cleanupStashDirectory() + } + .padding(2) + .onChange(of: hoveredImageID) { _ in + // ๐ŸŽ๏ธ SUPER FAST HOVER FIX: Whenever hoveredImageID changes while preview is visible, force an immediate update. + if showPreview { + print("โšก FAST HOVER: hoveredImageID changed, forcing preview update") + handlePreviewWindowState(shouldShow: true) + } + } + } + + // MARK: - Subviews + + // NIEUW: Permanente file drop zone die altijd zichtbaar is + FADE EFFECT + private var permanentFileDropZone: some View { + HStack { + Spacer() + + Text("Drop Zone") + .font(.caption) + .fontWeight(.medium) + .italic() // Cursief + .foregroundColor(.secondary) // TERUG NAAR WIT voor contrast + + Spacer() + } + .background(isTargeted ? Color.blue.opacity(0.5) : Color.blue.opacity(0.4)) // Donkerblauw in plaats van grijs + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 0, + bottomLeadingRadius: 6, + bottomTrailingRadius: 6, + topTrailingRadius: 0 + ) + ) + .opacity(dropZoneOpacity) // ๐Ÿ”ฅ FADE EFFECT + .onDrop(of: [UTType.fileURL], isTargeted: .constant(false)) { providers -> Bool in + handleFileOnlyDrop(providers: providers) + return true + } + .onAppear { + print("โฐ Drop Zone: permanentFileDropZone.onAppear TRIGGERED!") + startDropZoneFadeTimer() + } + // VERWIJDERD: Alle padding weggehaald voor directe verbinding met thumbnails + } + + private var dropZoneView: some View { + VStack(spacing: 12) { + // NIEWE: Speciale file drop zone die originele namen behoudt + VStack(spacing: 6) { + Image(systemName: "doc.on.doc") + .font(.system(size: 20)) + .foregroundColor(.blue.opacity(0.4)) // Veel donkerder blauw + + Text("Sleep bestanden hier") + .font(.caption2) + .fontWeight(.medium) + + Text("Behoudt originele naam") + .font(.caption2) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .frame(height: 60) // Veel lager van 80 naar 60 + .background(isTargeted ? Color.blue.opacity(0.4) : Color.blue.opacity(0.3)) // Veel donkerdere achtergrond + .cornerRadius(8) + .onDrop(of: [UTType.fileURL], isTargeted: .constant(false)) { providers -> Bool in + handleFileOnlyDrop(providers: providers) + return true + } + + Text("of") + .font(.caption2) + .foregroundColor(.secondary) + + // Bestaande algemene drop zone + VStack(spacing: 8) { + Image(systemName: "photo.on.rectangle") + .font(.system(size: 20)) + .foregroundColor(.gray) + + Text("Sleep afbeeldingen hier") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .frame(height: 60) + .background(isTargeted ? Color.gray.opacity(0.15) : Color.gray.opacity(0.05)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(isTargeted ? Color.gray.opacity(0.5) : Color.gray.opacity(0.3), style: StrokeStyle(lineWidth: 1, dash: [4])) + ) + .onDrop(of: [UTType.image], isTargeted: $isTargeted) { providers -> Bool in + handleDrop(providers: providers) + return true + } + } + .padding(paddingAroundGrid) + } + + private var consistentImageGridView: some View { + ScrollView(.vertical, showsIndicators: false) { + // CRITICAL FIX: Only log when count actually changes + let currentCount = imageStore.images.count + let _ = currentCount != lastImageCount ? print("๐ŸŽจ GRID: Actual count change detected: \(lastImageCount) -> \(currentCount)") : () + + LazyVGrid(columns: gridColumns, spacing: cellSpacing) { + if imageStore.images.isEmpty { + // ULTRA FIX: Placeholder that looks like a thumbnail for consistent layout + emptyStatePlaceholder + } else { + ForEach(imageStore.images) { imgItem in + OptimizedStashItemView( + imageItem: imgItem, + calculatedThumbnailSize: calculatedThumbnailSize, + thumbnailCornerRadius: thumbnailCornerRadius, + hoverScaleEffect: hoverScaleEffect, + hoveredImageID: $hoveredImageID, + currentPreviewName: $currentPreviewName, + imageForPreview: $imageForPreview, + showPreview: $showPreview, + isPreviewStable: $isPreviewStable, + hoverWorkItem: $hoverWorkItem, + stableDelegateCache: $stableDelegateCache, + imageStore: imageStore, + onRemove: { removedItem in + stableDelegateCache.removeValue(forKey: removedItem.id) + imageStore.removeImage(removedItem) + } + ) + } + } + } + .padding(.horizontal, paddingAroundGrid) + .padding(.top, paddingAroundGrid) + } + .coordinateSpace(name: "GalleryScrollViewSpace") + } + + // ULTRA FIX: Placeholder die er precies zo uitziet als een echte thumbnail + private var emptyStatePlaceholder: some View { + // ULTRA FIX: Helemaal leeg, 100% transparant + Rectangle() + .fill(Color.clear) + .frame(width: calculatedThumbnailSize, height: calculatedThumbnailSize) + .background(Color.clear) // 100% transparant zoals gevraagd + .cornerRadius(thumbnailCornerRadius) + .onDrop(of: [UTType.fileURL], isTargeted: $isTargeted) { providers -> Bool in + // ULTRA FIX: Alleen handleFileOnlyDrop voor consistente naamgeving (net als onderste dropzone) + handleFileOnlyDrop(providers: providers) + return true + } + } + + // AANGEPAST: Veel eenvoudiger omdat stash items nu permanente URLs hebben + private func createTempURLForStashItem(_ imgItem: IdentifiableImage) -> URL? { + // REMOVED: This function is now handled by OptimizedStashItemView + return imgItem.fileURL + } + + // NIEUW: Open image in system app + private func openImageInSystemApp(_ imgItem: IdentifiableImage, tempURL: URL) { + // REMOVED: This function is now handled by OptimizedStashItemView + NSWorkspace.shared.open(tempURL) + } + + // NIEUW: Get display name for stash item + private func displayNameForStashItem(_ imgItem: IdentifiableImage) -> String? { + // REMOVED: This function is now handled by OptimizedStashItemView + if let customName = imgItem.customName, !customName.isEmpty { + return customName + } + + if let fileURL = imgItem.fileURL { + return fileURL.deletingPathExtension().lastPathComponent + } + + return nil + } + + // MARK: - Layout Calculation Functions (gebruiken nu imageStore.images.count etc.) + private func titleBarHeightIfVisible() -> CGFloat { + // ULTRA FIX: Titel bar is nu altijd zichtbaar voor consistente layout + return titleBarHeight + } + + private func actualNumberOfColumns() -> Int { + // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE FLEX GRID SYSTEM! โšก๐Ÿ’ฅ๐Ÿ”ฅ + let settings = SettingsManager.shared + let itemCount = max(1, imageStore.images.count) // Minimaal 1 voor consistente layout + + switch settings.stashGridMode { + case .fixedColumns: + // Fixed columns mode: max X columns, auto rows (original system) + let maxCols = settings.stashMaxColumns + return min(max(1, itemCount), maxCols) + + case .fixedRows: + // Fixed rows mode: max Y rows, auto columns (horizontal growth!) + let maxRows = settings.stashMaxRows + return Int(ceil(Double(itemCount) / Double(maxRows))) + } + } + + private func totalNumberOfRowsForAllItems() -> Int { + // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE FLEX GRID ROWS CALCULATION! โšก๐Ÿ’ฅ๐Ÿ”ฅ + let settings = SettingsManager.shared + let itemCount = max(1, imageStore.images.count) // Minimaal 1 voor consistente layout + + switch settings.stashGridMode { + case .fixedColumns: + // Fixed columns mode: calculate rows based on columns + let numCols = actualNumberOfColumns() + guard numCols > 0 else { return 1 } + return Int(ceil(Double(itemCount) / Double(numCols))) + + case .fixedRows: + // Fixed rows mode: fixed Y rows, never more! + let maxRows = settings.stashMaxRows + return min(maxRows, itemCount) // Never exceed max rows setting + } + } + + private func effectiveWindowWidth() -> CGFloat { + // ULTRA FIX: Gebruik altijd grid calculations, ook voor empty state + let numCols = CGFloat(actualNumberOfColumns()) + let thumbSize = self.calculatedThumbnailSize + let totalImageWidth = numCols * thumbSize + let totalSpacingWidth = max(0, numCols - 1) * cellSpacing + return totalImageWidth + totalSpacingWidth + (2 * paddingAroundGrid) + } + + private func effectiveWindowHeight() -> CGFloat { + // ULTRA FIX: Gebruik altijd grid calculations, ook voor empty state + let numRows = CGFloat(totalNumberOfRowsForAllItems()) + let thumbSize = self.calculatedThumbnailSize + let totalImageHeight = numRows * thumbSize + let totalSpacingHeight = max(0, numRows - 1) * cellSpacing + let dropZoneHeight: CGFloat = 18.0 // AANGEPAST: Slanke dropzone hoogte (tekst ~16px + kleine marge) + + // AANGEPAST: Gebruik echte padding waarden van imageGridView + let topPadding = paddingAroundGrid // 5px + let bottomGridPadding: CGFloat = 0 // 0px - imageGridView heeft geen bottom padding meer + let dropZoneBottomPadding: CGFloat = 0 // 0px - Geen padding meer op dropzone + let totalPadding = topPadding + bottomGridPadding + dropZoneBottomPadding // 5 + 0 + 0 = 5px (was 10px) + + return totalImageHeight + totalSpacingHeight + totalPadding + titleBarHeightIfVisible() + dropZoneHeight + } + + // MARK: - Drop Handling + + // NIEUW: Speciale file-only drop handler die originele namen behoudt + private func handleFileOnlyDrop(providers: [NSItemProvider]) { + print("๐ŸŽฏ handleFileOnlyDrop called with \(providers.count) providers - FILENAME PRESERVATION MODE") + + for provider in providers { + // Alleen file URLs accepteren + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + print("๐ŸŽฏ Found fileURL type in file-only drop") + + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { (item, error) in + if let error = error { + print("โŒ Error loading file URL: \(error)") + return + } + + print("๐ŸŽฏ Got item type: \(type(of: item)), value: \(String(describing: item))") + + var fileURL: URL? = nil + var filename: String? = nil + + // VERBETERDE: Verschillende manieren om URL en naam te krijgen (same as handleDrop) + if let url = item as? URL { + print("๐Ÿ”„ Direct URL: \(url.lastPathComponent)") + fileURL = url + filename = url.deletingPathExtension().lastPathComponent + } else if let data = item as? Data { + print("๐Ÿ” Got Data (\(data.count) bytes), trying to decode...") + + // Probeer als URL data + if let url = URL(dataRepresentation: data, relativeTo: nil) { + print("๐Ÿ”„ Decoded URL from data: \(url.lastPathComponent)") + fileURL = url + filename = url.deletingPathExtension().lastPathComponent + } else if let string = String(data: data, encoding: .utf8) { + print("๐Ÿ” Data as string: '\(string)'") + + // Probeer string als file path + let url = URL(string: string) ?? URL(fileURLWithPath: string) + print("๐Ÿ”„ Decoded URL from string: \(url.lastPathComponent)") + fileURL = url + filename = url.deletingPathExtension().lastPathComponent + } + } else if let string = item as? String { + print("๐Ÿ” Got string: '\(string)'") + let url = URL(string: string) ?? URL(fileURLWithPath: string) + print("๐Ÿ”„ URL from string: \(url.lastPathComponent)") + fileURL = url + filename = url.deletingPathExtension().lastPathComponent + } else if let array = item as? [String] { + print("๐Ÿ” Got string array: \(array)") + if let firstPath = array.first { + let url = URL(string: firstPath) ?? URL(fileURLWithPath: firstPath) + print("๐Ÿ”„ URL from array: \(url.lastPathComponent)") + fileURL = url + filename = url.deletingPathExtension().lastPathComponent + } + } else if let nsArray = item as? NSArray { + print("๐Ÿ” Got NSArray: \(nsArray)") + if let firstPath = nsArray.firstObject as? String { + let url = URL(string: firstPath) ?? URL(fileURLWithPath: firstPath) + print("๐Ÿ”„ URL from NSArray: \(url.lastPathComponent)") + fileURL = url + filename = url.deletingPathExtension().lastPathComponent + } + } else { + print("โŒ Unknown item type for file-only drop: \(type(of: item))") + return + } + + // Als we een file URL hebben, verwerk het bestand + if let url = fileURL, let name = filename { + print("โœ… Successfully extracted file URL and name: \(name)") + + // Check of het een image bestand is + let imageExtensions = ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp", "heic"] + let fileExtension = url.pathExtension.lowercased() + + guard imageExtensions.contains(fileExtension) else { + print("โš ๏ธ Not an image file: \(fileExtension)") + return + } + + DispatchQueue.main.async { + self.copyFileToStash(fileURL: url, preservedName: name, shouldUpdateUI: true) + } + } else { + print("โŒ Could not extract valid file URL and name from item") + } + } + } + } + } + + // UNIFIED: Single method for all stash file operations (replaces copyFileDirectlyToStashAndAdd) + private func copyFileToStash(fileURL: URL, preservedName: String? = nil, shouldUpdateUI: Bool = false) { + let finalName = preservedName ?? fileURL.deletingPathExtension().lastPathComponent + print("๐Ÿ”„ Copying file to stash: \(fileURL.lastPathComponent) as '\(finalName)' (updateUI: \(shouldUpdateUI))") + + // ULTRA FIX: Check if this is already a stash item being dragged within stash + let stashDir = imageStore.publicStashDirectory + if fileURL.path.contains(stashDir.path) { + print("๐Ÿ”ฅ ULTRA MODE: Detected INTERNAL stash drag - creating duplicate instead of copy") + + // Find the existing stash item + if let existingStashItem = imageStore.images.first(where: { $0.fileURL?.path == fileURL.path }) { + // Create duplicate with unique name + let uniqueName = imageStore.generateUniqueCopyName(baseName: finalName) + + if shouldUpdateUI { + // Direct add behavior: immediate UI updates for file promise drops + print("๐Ÿ”ฅ DEBUG: About to create duplicate with name: '\(uniqueName)'") + print("๐Ÿ”ฅ DEBUG: Current image count BEFORE: \(imageStore.images.count)") + + let oldCount = imageStore.images.count + imageStore.addImage(existingStashItem.nsImage, fileURL: nil, suggestedName: uniqueName, skipDuplicateCheck: true) + + print("๐Ÿ”ฅ DEBUG: Setting currentPreviewName IMMEDIATELY to: '\(uniqueName)'") + self.currentPreviewName = uniqueName + + // Wait for the new item to be added, then update hoveredImageID + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + print("๐Ÿ”ฅ DEBUG: Async check - image count NOW: \(self.imageStore.images.count)") + if self.imageStore.images.count > oldCount { + if let newDuplicate = self.imageStore.images.last { + print("๐Ÿ”ฅ MEGA FIX: Updating hoveredImageID to new duplicate: \(newDuplicate.id)") + print("๐Ÿ”ฅ DEBUG: New duplicate name: '\(newDuplicate.fileURL?.deletingPathExtension().lastPathComponent ?? "unknown")'") + self.hoveredImageID = newDuplicate.id + } + } + } + } else { + // Handle drop behavior: different UI update pattern for regular drops + let oldCount = imageStore.images.count + imageStore.addImage(existingStashItem.nsImage, fileURL: nil, suggestedName: uniqueName, skipDuplicateCheck: true) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if self.imageStore.images.count > oldCount { + if let newDuplicate = self.imageStore.images.last { + print("๐Ÿ”ฅ MEGA FIX: Updating hoveredImageID to new duplicate: \(newDuplicate.id) via handleDrop") + self.hoveredImageID = newDuplicate.id + self.currentPreviewName = uniqueName + } + } + } + } + + print("๐Ÿ”ฅ ULTRA MODE: Created internal stash duplicate with name: '\(uniqueName)'") + return + } + } + + let fileExtension = fileURL.pathExtension + + // Generate unique filename + var finalFilename = "\(finalName).\(fileExtension)" + var counter = 1 + while FileManager.default.fileExists(atPath: stashDir.appendingPathComponent(finalFilename).path) { + finalFilename = "\(finalName) (\(counter)).\(fileExtension)" + counter += 1 + if counter > 100 { break } + } + + let destinationURL = stashDir.appendingPathComponent(finalFilename) + + do { + // Copy the file to stash directory + try FileManager.default.copyItem(at: fileURL, to: destinationURL) + print("โœ… File copied to stash: \(finalFilename) (preserved name: \(finalName))") + + // Load image and add to store + if let nsImage = NSImage(contentsOf: destinationURL) { + // ๐Ÿ”ฅ ALWAYS skip duplicate check for external files to allow duplicates from BGR thumbnails + imageStore.addImage(nsImage, fileURL: destinationURL, suggestedName: finalName, skipDuplicateCheck: true) + print("โœ… Added copied file to stash store: \(finalName) (duplicate check skipped - allows BGR duplicates)") + } + } catch { + print("โŒ Failed to copy file to stash: \(error)") + + // Fallback: use NSImage loading + if let nsImage = NSImage(contentsOf: fileURL) { + // ๐Ÿ”ฅ ALWAYS skip duplicate check for fallback external files too + imageStore.addImage(nsImage, fileURL: nil, suggestedName: finalName, skipDuplicateCheck: true) + print("๐ŸŽฏ Fallback: Added image with name '\(finalName)' (duplicate check skipped - allows BGR duplicates)") + } + } + } + + private func handleDrop(providers: [NSItemProvider]) { + print("๐Ÿ” handleDrop called with \(providers.count) providers") + + for provider in providers { + processDropProvider(provider) + } + } + + // MARK: - Private Helper Methods for handleDrop + + private func processDropProvider(_ provider: NSItemProvider) { + print("๐Ÿ” Provider: \(provider)") + print("๐Ÿ” Registered types: \(provider.registeredTypeIdentifiers)") + + // Try file URL types first + if tryProcessFileURLProvider(provider) { + return + } + + // Fallback to NSImage if no file URL found + processNSImageProvider(provider) + } + + private func tryProcessFileURLProvider(_ provider: NSItemProvider) -> Bool { + // Check for NSFilePromiseProvider metadata first + if provider.hasItemConformingToTypeIdentifier("com.apple.NSFilePromiseItemMetaData") { + print("๐Ÿ” Found NSFilePromiseProvider metadata") + processFilePromiseMetadata(provider) + } + + // Try different file URL types + let fileURLTypes = [ + "NSFilenamesPboardType", // Oudere Finder drag + "public.file-url", // Standaard file URL + "com.apple.finder.noderef", // Finder node reference + "com.apple.finder.node", // Finder node + "public.url", // Algemene URL + "CorePasteboardFlavorType 0x666C7574" // Oude legacy type + ] + + for urlType in fileURLTypes { + if provider.hasItemConformingToTypeIdentifier(urlType) { + print("๐Ÿ” Found file URL with type: \(urlType)") + processFileURLType(provider, urlType: urlType) + return true + } + } + + return false + } + + private func processFilePromiseMetadata(_ provider: NSItemProvider) { + provider.loadItem(forTypeIdentifier: "com.apple.NSFilePromiseItemMetaData", options: nil) { (item, error) in + print("๐Ÿ” NSFilePromiseProvider metadata: \(String(describing: item)), error: \(String(describing: error))") + + if let metadata = item as? [String: Any] { + print("๐Ÿ” Metadata content: \(metadata)") + if let filename = metadata["filename"] as? String { + print("๐Ÿ” Found filename in metadata: \(filename)") + } + } + } + } + + private func processFileURLType(_ provider: NSItemProvider, urlType: String) { + provider.loadItem(forTypeIdentifier: urlType, options: nil) { (item, error) in + print("๐Ÿ” File URL callback for \(urlType) - item type: \(type(of: item)), error: \(String(describing: error))") + + let (fileURL, filename) = self.extractFileURLAndName(from: item) + + if let url = fileURL, let name = filename { + print("โœ… Successfully extracted file URL and name: \(name)") + DispatchQueue.main.async { + self.copyFileToStash(fileURL: url, preservedName: name, shouldUpdateUI: true) + } + } + } + } + + private func extractFileURLAndName(from item: Any?) -> (URL?, String?) { + var fileURL: URL? = nil + var filename: String? = nil + + if let url = item as? URL { + print("๐Ÿ”„ Direct URL: \(url.lastPathComponent)") + fileURL = url + filename = url.deletingPathExtension().lastPathComponent + } else if let data = item as? Data { + print("๐Ÿ” Got Data (\(data.count) bytes), trying to decode...") + (fileURL, filename) = extractFromData(data) + } else if let string = item as? String { + print("๐Ÿ” Got string: '\(string)'") + let url = URL(string: string) ?? URL(fileURLWithPath: string) + print("๐Ÿ”„ URL from string: \(url.lastPathComponent)") + fileURL = url + filename = url.deletingPathExtension().lastPathComponent + } else if let array = item as? [String] { + print("๐Ÿ” Got string array: \(array)") + if let firstPath = array.first { + let url = URL(string: firstPath) ?? URL(fileURLWithPath: firstPath) + print("๐Ÿ”„ URL from array: \(url.lastPathComponent)") + fileURL = url + filename = url.deletingPathExtension().lastPathComponent + } + } else if let nsArray = item as? NSArray { + print("๐Ÿ” Got NSArray: \(nsArray)") + if let firstPath = nsArray.firstObject as? String { + let url = URL(string: firstPath) ?? URL(fileURLWithPath: firstPath) + print("๐Ÿ”„ URL from NSArray: \(url.lastPathComponent)") + fileURL = url + filename = url.deletingPathExtension().lastPathComponent + } + } + + return (fileURL, filename) + } + + private func extractFromData(_ data: Data) -> (URL?, String?) { + // Try as URL data + if let url = URL(dataRepresentation: data, relativeTo: nil) { + print("๐Ÿ”„ Decoded URL from data: \(url.lastPathComponent)") + return (url, url.deletingPathExtension().lastPathComponent) + } else if let string = String(data: data, encoding: .utf8) { + print("๐Ÿ” Data as string: '\(string)'") + let url = URL(string: string) ?? URL(fileURLWithPath: string) + print("๐Ÿ”„ Decoded URL from string: \(url.lastPathComponent)") + return (url, url.deletingPathExtension().lastPathComponent) + } + + return (nil, nil) + } + + private func processNSImageProvider(_ provider: NSItemProvider) { + guard provider.canLoadObject(ofClass: NSImage.self) else { + print("Kan object niet laden als NSImage.") + return + } + + print("๐Ÿ” Can load NSImage, checking for better name sources...") + + // Try to extract name from various sources + let nameForStore = extractNameFromProvider(provider) + + _ = provider.loadObject(ofClass: NSImage.self) { image, error in + if let nsImage = image as? NSImage { + print("๐Ÿ” Loaded NSImage successfully") + print("๐Ÿ” About to add to store with name: '\(nameForStore ?? "nil")'") + + DispatchQueue.main.async { + // Skip duplicate check for external NSImage drops + self.imageStore.addImage(nsImage, fileURL: nil, suggestedName: nameForStore, skipDuplicateCheck: true) + } + } else { + if let error = error { + print("Fout bij het laden van afbeelding: \(error.localizedDescription)") + } else { + print("Kon afbeelding niet laden, onbekende fout.") + } + } + } + } + + private func extractNameFromProvider(_ provider: NSItemProvider) -> String? { + // 1. Provider suggested name + if let suggestedNameFromProvider = provider.suggestedName, !suggestedNameFromProvider.isEmpty { + let name = (suggestedNameFromProvider as NSString).deletingPathExtension + print("๐Ÿ” Provider suggested name: \(suggestedNameFromProvider) -> \(name)") + return name + } + + // 2. Try via pasteboard types + for typeId in provider.registeredTypeIdentifiers { + print("๐Ÿ” Examining type identifier: \(typeId)") + + // Sometimes name is in the type identifier + if typeId.contains(".") && !typeId.hasPrefix("public.") && !typeId.hasPrefix("com.apple") { + if let extractedName = typeId.components(separatedBy: ".").last, + extractedName.count > 3 && extractedName.count < 50 { + print("๐Ÿ” Extracted name from type identifier: \(extractedName)") + return extractedName + } + } + } + + return nil + } + + // NIEUW: Helper functie om file URL te verwerken + private func processFileURL(_ url: URL) { + // Check of het een image bestand is + let imageExtensions = ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp", "heic"] + let fileExtension = url.pathExtension.lowercased() + + if imageExtensions.contains(fileExtension) { + DispatchQueue.main.async { + let filename = url.deletingPathExtension().lastPathComponent + self.copyFileToStash(fileURL: url, preservedName: filename) + } + } else { + print("โš ๏ธ Not an image file: \(fileExtension)") + } + } + + // AANGEPAST: Kopieer origineel bestand naar stash directory met preserved name + + + // ๐Ÿ”ฅ NIEUW: Drop Zone fade timer functie + private func startDropZoneFadeTimer() { + print("โฐ Drop Zone: startDropZoneFadeTimer() CALLED! dropZoneOpacity: \(dropZoneOpacity)") + + // Reset als er al een timer loopt + dropZoneFadeTimer?.invalidate() + + // Na 3 seconden starten met uitfaden over 10 seconden + dropZoneFadeTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in + print("๐ŸŒ… Drop Zone: Starting 10-second fade out...") + + // Gebruik SwiftUI animatie voor smooth fade over 10 seconden + withAnimation(.linear(duration: 10.0)) { + dropZoneOpacity = 0.0 + } + + // Log completion + DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { + print("๐ŸŒ™ Drop Zone: Fade out completed (fully transparent)") + } + } + + print("โฐ Drop Zone: 3-second delay timer started, will fade over 10 seconds") + } + + // ๐ŸŽฏ NIEUW: Test shake animation functie - GEFIXT MET CAKeyframeAnimation! + private func testShakeAnimation() { + guard let window = hostingWindow else { + print("๐Ÿšจ Shake test: No hosting window available!") + return + } + + print("๐Ÿ”ฅ MEGA SHAKE: Starting CAKeyframeAnimation shake!") + + // PROVEN WORKING TECHNIQUE from Eric Dolecki blog + let numberOfShakes = 4 + let durationOfShake = 0.3 + let vigourOfShake: CGFloat = 0.03 // Percentage of window width + let frame = window.frame + + print("๐Ÿ”ฅ MEGA SHAKE: Parameters - shakes: \(numberOfShakes), duration: \(durationOfShake)s, vigour: \(vigourOfShake)") + print("๐Ÿ”ฅ MEGA SHAKE: Window frame: \(frame)") + + // Create CAKeyframeAnimation + let shakeAnimation = CAKeyframeAnimation() + + // Create shake path + let shakePath = CGMutablePath() + shakePath.move(to: CGPoint(x: frame.minX, y: frame.minY)) + + for _ in 0..= previewScreen.visibleFrame.minY { + previewY = stashWindowFrame.minY - (spacing + 25) - previewHeight + print("๐Ÿ”ฝ VULCANO: Positioning preview BELOW stash window with EXTRA spacing!") + } + // Fallback: rechtsboven op het scherm + else { + previewY = previewScreen.visibleFrame.maxY - previewHeight - 20 + print("๐ŸŒ‹ VULCANO: No space above/below - fallback to top of screen") + } + } + } else { + // โ—€๏ธโ–ถ๏ธ FIXED COLUMNS = VERTICALE STRIP โ†’ PREVIEW LINKS/RECHTS! โ–ถ๏ธโ—€๏ธ + print("๐ŸŒ‹ VULCANO: Fixed Columns mode - positioning preview LEFT/RIGHT of stash") + previewY = stashWindowFrame.midY - previewHeight / 2 + + // ๐Ÿ”ฅ MEGA GRID FORCED POSITIONING FOR COLUMNS! ๐Ÿ”ฅ + if gridColumn == 1 { + // Left half: ALWAYS position preview RIGHT + previewX = stashWindowFrame.maxX + spacing + print("๐Ÿ”ฅ MEGA GRID: Column 1 (LEFT HALF) - FORCING preview RIGHT!") + } else { + // Right half: ALWAYS position preview LEFT + previewX = stashWindowFrame.minX - (spacing + 15) - previewWidth + print("๐Ÿ”ฅ MEGA GRID: Column 2 (RIGHT HALF) - FORCING preview LEFT!") + } + } + + let finalPreviewOrigin = NSPoint( + x: max(previewScreen.visibleFrame.minX, min(previewX, previewScreen.visibleFrame.maxX - previewWidth)), + y: max(previewScreen.visibleFrame.minY, min(previewY, previewScreen.visibleFrame.maxY - previewHeight)) + ) + print("๐ŸŒ‹ VULCANO: Final preview position: \(finalPreviewOrigin)") + + // --- VERBETERDE logica voor het creรซren/updaten van het venster --- + if previewWindowController == nil { + print("๐Ÿ–ผ๏ธ IntegratedGalleryView: previewWindowController is nil. Creating new preview window.") + createNewPreviewWindow(imageToDisplayInPreview, at: finalPreviewOrigin, size: NSSize(width: previewWidth, height: previewHeight), screen: previewScreen) + } else { + print("๐Ÿ–ผ๏ธ IntegratedGalleryView: previewWindowController exists. Updating existing window.") + updateExistingPreviewWindow(imageToDisplayInPreview, at: finalPreviewOrigin, size: NSSize(width: previewWidth, height: previewHeight)) + } + } else { + print("๐Ÿ–ผ๏ธ IntegratedGalleryView: handlePreviewWindowState - Condition NOT met to show preview OR shouldHide. Closing.") + + // โฐ STOP TIMER FORCE REFRESH! + stopPreviewNameRefreshTimer() + + // NIEUW: Minder aggressive sluiten + if let wc = self.previewWindowController, let pWindow = wc.window, pWindow.isVisible { + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.15 // Kortere fade out + pWindow.animator().alphaValue = 0 + }, completionHandler: { + self.closePreviewWindow() + }) + } else { + closePreviewWindow() + } + } + } + + // NIEUW: Separate functie voor nieuwe preview window + private func createNewPreviewWindow(_ image: NSImage, at origin: NSPoint, size: NSSize, screen: NSScreen) { + // ๐Ÿ”ฅ๐Ÿ’Ž MEGA TITANIUM GLASS EFFECT PREVIEW! ๐Ÿ’Ž๐Ÿ”ฅ + let previewContentSwiftUIView = VStack(spacing: 4) { + Image(nsImage: image) + .resizable() + .scaledToFit() + .cornerRadius(8) + .clipped() + .shadow(radius: 3, y: 2) + + // ๐Ÿš€๐Ÿ’ฅ TURBO INSTANT NAAM LABEL! ๐Ÿ’ฅ๐Ÿš€ + Text(currentPreviewName) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundColor(Color.adaptivePrimaryText) // ๐Ÿ”ฅ ADAPTIVE text voor light/dark mode + .opacity(0.9) // ๐Ÿ”ฅ Hoge opacity voor zichtbaarheid + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 8) + .onAppear { + print("๐Ÿ”ฅ ULTRA DEBUG: Preview text appeared with INSTANT name: '\(currentPreviewName)'") + } + } + .padding(8) // ๐Ÿ”ฅ EXTRA PADDING voor schaduw ruimte + .background( + ZStack { + // ๐Ÿ”ฅ๐Ÿ’Ž MEGA GLASS EFFECT ZOALS STASH! ๐Ÿ’Ž๐Ÿ”ฅ + VisualEffectBackground(material: .hudWindow, blending: .behindWindow, alpha: 0.95) + .cornerRadius(12) + + // ๐Ÿ”ฅ Extra glas laag voor diepte + VisualEffectBackground(material: .popover, blending: .withinWindow, alpha: 0.3) + .cornerRadius(12) + } + ) + .cornerRadius(12) + .shadow(color: .black.opacity(0.4), radius: 8, x: 0, y: 3) // ๐Ÿ”ฅ Zachte schaduw die past in window + .padding(16) // ๐Ÿ”ฅ CRITICAL: Extra padding voor schaduw ruimte + + let hostingController = NSHostingController(rootView: previewContentSwiftUIView) + let previewWindow = NSWindow(contentViewController: hostingController) + + // ๐Ÿ”ฅ MEGA FIX: Extra grote window size voor schaduw ruimte (shadow radius 8 + padding 16 = 24px extra aan alle kanten) + let shadowPadding: CGFloat = 32 // Extra ruimte voor schaduw en padding + let previewSize = CGSize( + width: size.width + shadowPadding + 20, // Original + shadow space + name space + height: size.height + shadowPadding + 40 // Original + shadow space + name space + ) + let adjustedOrigin = CGPoint( + x: origin.x - shadowPadding/2, // Compenseer voor shadow padding + y: origin.y - shadowPadding/2 - 10 // Compenseer voor shadow padding + 10px hoger + ) + + previewWindow.setFrame(NSRect(origin: adjustedOrigin, size: previewSize), display: false) + previewWindow.styleMask = [.borderless] // ๐Ÿ”ฅ BLIJFT borderless + previewWindow.backgroundColor = NSColor.clear // ๐Ÿ”ฅ Volledig transparant + previewWindow.isOpaque = false // ๐Ÿ”ฅ Niet opaque + previewWindow.hasShadow = false // ๐Ÿ”ฅ NATIVE window shadow UIT - we gebruiken SwiftUI shadow + previewWindow.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow))) + previewWindow.ignoresMouseEvents = true + + let windowController = NSWindowController(window: previewWindow) + self.previewWindowController = windowController + + previewWindow.orderFront(nil) + previewWindow.alphaValue = 1.0 + + print("๐Ÿ–ผ๏ธ IntegratedGalleryView: Positioned NEW SHADOW-FRIENDLY stash hover preview at (\(adjustedOrigin.x), \(adjustedOrigin.y)) with size \(previewSize)") + print("๐Ÿ–ผ๏ธ IntegratedGalleryView: Showing new BORDERLESS preview window with embedded shadows") + } + + // ULTRA HELPER FUNCTIE: Real-time naam lookup + private func getCurrentHoveredImageName() -> String { + if let hoveredID = hoveredImageID, + let hoveredItem = imageStore.images.first(where: { $0.id == hoveredID }) { + let name = hoveredItem.fileURL?.deletingPathExtension().lastPathComponent ?? "Unknown" + print("๐Ÿ”ฅ ULTRA DEBUG: getCurrentHoveredImageName() -> '\(name)' for ID \(hoveredID)") + return name + } + print("๐Ÿ”ฅ ULTRA DEBUG: getCurrentHoveredImageName() -> 'Unknown' (no hovered ID)") + return "Unknown" + } + + // NIEUW: Separate functie voor update van bestaande preview window + private func updateExistingPreviewWindow(_ image: NSImage, at origin: NSPoint, size: NSSize) { + guard let wc = self.previewWindowController, let pWindow = wc.window else { + print("๐Ÿ–ผ๏ธ IntegratedGalleryView: ERROR - previewWindowController or its window is nil in update path.") + return + } + + print("๐Ÿ”ฅ ULTRA DEBUG: updateExistingPreviewWindow - INSTANT name: '\(currentPreviewName)'") + + // ๐Ÿ”ฅ๐Ÿ’Ž MEGA TITANIUM GLASS EFFECT UPDATE! ๐Ÿ’Ž๐Ÿ”ฅ + let updatedPreviewContentSwiftUIView = VStack(spacing: 4) { + Image(nsImage: image) + .resizable() + .scaledToFit() + .cornerRadius(8) + .clipped() + .shadow(radius: 3, y: 2) + + // ๐Ÿš€๐Ÿ’ฅ TURBO INSTANT NAAM LABEL! ๐Ÿ’ฅ๐Ÿš€ + Text(currentPreviewName) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundColor(Color.adaptivePrimaryText) // ๐Ÿ”ฅ ADAPTIVE text voor light/dark mode + .opacity(0.9) // ๐Ÿ”ฅ Hoge opacity voor zichtbaarheid + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 8) + .onAppear { + print("๐Ÿ”ฅ ULTRA DEBUG: Updated preview text with INSTANT name: '\(currentPreviewName)'") + } + } + .padding(8) // ๐Ÿ”ฅ EXTRA PADDING voor schaduw ruimte + .background( + ZStack { + // ๐Ÿ”ฅ๐Ÿ’Ž MEGA GLASS EFFECT ZOALS STASH! ๐Ÿ’Ž๐Ÿ”ฅ + VisualEffectBackground(material: .hudWindow, blending: .behindWindow, alpha: 0.95) + .cornerRadius(12) + + // ๐Ÿ”ฅ Extra glas laag voor diepte + VisualEffectBackground(material: .popover, blending: .withinWindow, alpha: 0.3) + .cornerRadius(12) + } + ) + .cornerRadius(12) + .shadow(color: .black.opacity(0.4), radius: 8, x: 0, y: 3) // ๐Ÿ”ฅ Zachte schaduw die past in window + .padding(16) // ๐Ÿ”ฅ CRITICAL: Extra padding voor schaduw ruimte + + let updatedHostingController = NSHostingController(rootView: updatedPreviewContentSwiftUIView) + pWindow.contentViewController = updatedHostingController + + // ๐Ÿ”ฅ๐Ÿ’Ž MEGA DYNAMIC SIZE UPDATE! ๐Ÿ’Ž๐Ÿ”ฅ + let originalSize = image.size + let setting = SettingsManager.shared.stashPreviewSize + + // ๐Ÿ”ฅ MEGA INTELLIGENTE SIZE CALCULATION! ๐Ÿ”ฅ + let targetSizes: [StashPreviewSize: CGSize] = [ + .xSmall: CGSize(width: 150, height: 112), + .small: CGSize(width: 240, height: 180), + .medium: CGSize(width: 360, height: 270), + .large: CGSize(width: 480, height: 360), + .xLarge: CGSize(width: 600, height: 450) + ] + + let targetSize = targetSizes[setting] ?? targetSizes[.medium]! + let widthScale = targetSize.width / originalSize.width + let heightScale = targetSize.height / originalSize.height + let scale = min(widthScale, heightScale) + + var dynamicWidth = originalSize.width * scale + var dynamicHeight = originalSize.height * scale + + // ๐Ÿ”ฅ ABSOLUTE MIN/MAX LIMIETEN! ๐Ÿ”ฅ + dynamicWidth = max(80, min(dynamicWidth, 1000)) + dynamicHeight = max(60, min(dynamicHeight, 750)) + + // ๐Ÿ”ฅ MEGA FIX: Update position and size with shadow-friendly dimensions + let shadowPadding: CGFloat = 32 // Extra ruimte voor schaduw en padding + let previewSize = CGSize( + width: dynamicWidth + shadowPadding + 20, // Original + shadow space + name space + height: dynamicHeight + shadowPadding + 40 // Original + shadow space + name space + ) + let adjustedOrigin = CGPoint( + x: origin.x - shadowPadding/2, // Compenseer voor shadow padding + y: origin.y - shadowPadding/2 - 10 // Compenseer voor shadow padding + 10px hoger + ) + pWindow.setFrame(NSRect(origin: adjustedOrigin, size: previewSize), display: true) + + print("๐Ÿ–ผ๏ธ IntegratedGalleryView: Positioned UPDATED SHADOW-FRIENDLY stash hover preview at (\(adjustedOrigin.x), \(adjustedOrigin.y)) with dynamic size \(dynamicWidth)x\(dynamicHeight)") + } + + private func closePreviewWindow() { + stopPreviewNameRefreshTimer() // โฐ Stop timer when closing + previewWindowController?.window?.orderOut(nil) + previewWindowController?.close() + previewWindowController = nil + } + + // โฐ TIMER FORCE REFRESH FUNCTIONS + private func startPreviewNameRefreshTimer() { + // Stop existing timer first + stopPreviewNameRefreshTimer() + + print("โฐ TIMER: Starting 0.2s refresh timer for preview name") + previewNameRefreshTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in + DispatchQueue.main.async { [self] in + self.forceRefreshPreviewName() + } + } + } + + private func stopPreviewNameRefreshTimer() { + if previewNameRefreshTimer != nil { + print("โฐ TIMER: Stopping refresh timer") + previewNameRefreshTimer?.invalidate() + previewNameRefreshTimer = nil + } + } + + private func forceRefreshPreviewName() { + guard showPreview else { + print("โฐ TIMER DEBUG: Not showing preview, skipping refresh") + return + } + + print("โฐ TIMER DEBUG: forceRefreshPreviewName called") + print("โฐ TIMER DEBUG: hoveredImageID: \(hoveredImageID?.uuidString ?? "nil")") + print("โฐ TIMER DEBUG: isTargeted: \(isTargeted)") + print("โฐ TIMER DEBUG: imageStore.images.count: \(imageStore.images.count)") + + // ๐Ÿ”ฅ MEGA ULTRA FIX: Check if we're hovering over the dropzone + // If the hoveredImageID is nil but we're showing preview, it means we're hovering over dropzone + // In that case, show the LAST item (newest duplicate) + let itemToShow: IdentifiableImage? + + if let hoveredID = hoveredImageID { + // Normal case: hovering over a specific thumbnail + itemToShow = imageStore.images.first(where: { $0.id == hoveredID }) + print("โฐ TIMER: Using hovered item with ID: \(hoveredID)") + } else if isTargeted && !imageStore.images.isEmpty { + // ๐Ÿ”ฅ SPECIAL CASE: Hovering over dropzone - show LAST item (newest) + itemToShow = imageStore.images.last + print("โฐ TIMER: DROPZONE HOVER DETECTED - Using LAST item (newest duplicate)") + } else { + itemToShow = nil + print("โฐ TIMER DEBUG: No item to show") + } + + // Get current item name + if let item = itemToShow { + let newName = item.fileURL?.deletingPathExtension().lastPathComponent ?? "Unknown" + print("โฐ TIMER DEBUG: Item found, name: '\(newName)', current preview name: '\(currentPreviewName)'") + + // Only update if name actually changed to avoid unnecessary updates + if newName != currentPreviewName { + currentPreviewName = newName + print("โฐ TIMER: Force refreshed preview name to '\(newName)'") + + // Force update the preview window if it exists + if previewWindowController != nil, let image = imageForPreview { + print("โฐ TIMER DEBUG: Updating preview window with new name") + let stashWindowFrame = hostingWindow?.frame ?? NSRect.zero + let previewScreen = hostingWindow?.screen ?? NSScreen.main ?? NSScreen.screens.first! + + // ๐Ÿ”ฅ๐Ÿ’Ž MEGA DYNAMIC SIZE FOR TIMER UPDATE TOO! ๐Ÿ’Ž๐Ÿ”ฅ + let originalSize = image.size + let setting = SettingsManager.shared.stashPreviewSize + + // ๐Ÿ”ฅ MEGA INTELLIGENTE SIZE CALCULATION! ๐Ÿ”ฅ + let targetSizes: [StashPreviewSize: CGSize] = [ + .xSmall: CGSize(width: 150, height: 112), + .small: CGSize(width: 240, height: 180), + .medium: CGSize(width: 360, height: 270), + .large: CGSize(width: 480, height: 360), + .xLarge: CGSize(width: 600, height: 450) + ] + + let targetSize = targetSizes[setting] ?? targetSizes[.medium]! + let widthScale = targetSize.width / originalSize.width + let heightScale = targetSize.height / originalSize.height + let scale = min(widthScale, heightScale) + + var previewWidth = originalSize.width * scale + var previewHeight = originalSize.height * scale + + // ๐Ÿ”ฅ ABSOLUTE MIN/MAX LIMIETEN! ๐Ÿ”ฅ + previewWidth = max(80, min(previewWidth, 1000)) + previewHeight = max(60, min(previewHeight, 750)) + + let spacing: CGFloat = 10 + var previewX: CGFloat + let previewY = stashWindowFrame.midY - previewHeight / 2 + + if stashWindowFrame.maxX + spacing + previewWidth <= previewScreen.visibleFrame.maxX { + previewX = stashWindowFrame.maxX + spacing + } else if stashWindowFrame.minX - (spacing + 15) - previewWidth >= previewScreen.visibleFrame.minX { + previewX = stashWindowFrame.minX - (spacing + 15) - previewWidth + } else { + previewX = previewScreen.visibleFrame.maxX - previewWidth - 20 + } + + let finalPreviewOrigin = NSPoint(x: previewX, y: max(previewScreen.visibleFrame.minY, min(previewY, previewScreen.visibleFrame.maxY - previewHeight))) + + updateExistingPreviewWindow(image, at: finalPreviewOrigin, size: NSSize(width: previewWidth, height: previewHeight)) + } else { + print("โฐ TIMER DEBUG: No preview window controller to update") + } + } else { + print("โฐ TIMER DEBUG: Name unchanged, skipping update") + } + } + } + + // MARK: - Save dropped thumbnail + private func saveDroppedURL(_ sourceURL: URL, fileName: String) { + guard let destFolder = SettingsManager.shared.screenshotFolder, !destFolder.isEmpty else { + print("โŒ Save failed: default folder not set") + return + } + let destURL = URL(fileURLWithPath: destFolder).appendingPathComponent(fileName) + if FileManager.default.fileExists(atPath: destURL.path) { + print("โš ๏ธ File already exists, skipping save") + return + } + do { + try FileManager.default.copyItem(at: sourceURL, to: destURL) + print("โœ… Saved stash thumbnail to \(destURL.path)") + } catch { + print("โŒ Error saving stash thumbnail: \(error)") + } + } + + // NIEUW: Helper functie om gecachte delegate te krijgen + private func getDragDelegate(for item: IdentifiableImage) -> StashDragDelegate { + if let cachedDelegate = dragDelegateCache[item.id] { + return cachedDelegate + } else { + let newDelegate = StashDragDelegate(imageItem: item, imageStore: imageStore) + dragDelegateCache[item.id] = newDelegate + + // Set the stash grid manager reference ONCE when creating the delegate + // For now, we'll set it later when the actual drag starts since the stash grid manager + // might not be created yet when the delegate is first created + + return newDelegate + } + } + + // ULTRA REACTIVE: Computed property die automatisch update wanneer hoveredImageID verandert + private var currentHoveredImageName: String { + guard let hoveredID = hoveredImageID, + let hoveredItem = imageStore.images.first(where: { $0.id == hoveredID }) else { + return "Unknown" + } + let name = hoveredItem.fileURL?.deletingPathExtension().lastPathComponent ?? "Unknown" + print("๐Ÿ”ฅ ULTRA DEBUG: currentHoveredImageName computed - hoveredID: \(hoveredID), name: '\(name)'") + return name + } +} + +// MARK: - StashDragDelegate Class +class StashDragDelegate: StashDraggableImageViewDelegate { + let imageItem: IdentifiableImage + let imageStore: GalleryImageStore + var stashGridActionDelegate: StashGridActionDelegate? // Strong reference to prevent deallocation during operations + + init(imageItem: IdentifiableImage, imageStore: GalleryImageStore) { + self.imageItem = imageItem + self.imageStore = imageStore + + // Create a completely independent StashGridActionDelegate + // BELANGRIJK: Houd sterke reference om deallocation tijdens rename te voorkomen + self.stashGridActionDelegate = StashGridActionDelegate(imageStore: imageStore) + + // NIEUW: Set the presenting window for proper dialog positioning + DispatchQueue.main.async { [weak self] in + // Find the stash window + for window in NSApp.windows { + if window.title.contains("Stash") || window.contentView?.identifier?.rawValue.contains("stash") == true { + self?.stashGridActionDelegate?.presentingWindow = window + break + } + } + + // Fallback: use any visible window + if self?.stashGridActionDelegate?.presentingWindow == nil { + self?.stashGridActionDelegate?.presentingWindow = NSApp.windows.first { $0.isVisible } + } + } + } + + // NIEUW: Set stash grid manager reference for proper grid frame positioning + func setStashGridManager(_ manager: StashGridManager?) { + // Only set if not already set to avoid excessive logging + guard stashGridActionDelegate?.stashGridManager !== manager else { return } + + stashGridActionDelegate?.stashGridManager = manager + + // KRITIEKE REPARATIE: Stel stashGridActionDelegate in als delegate van de manager + if let manager = manager, let actionDelegate = stashGridActionDelegate { + manager.delegate = actionDelegate + } + } + + func stashImageDidStartDrag(imageURL: URL, from view: StashDraggableNSImageView) { + print("๐ŸŽฏ StashDragDelegate: Drag started for \(imageURL.lastPathComponent)") + print("๐ŸŽฏ StashDragDelegate: Grid delegate is \(stashGridActionDelegate != nil ? "SET" : "NIL")") + } + + func stashImageDragDidEnd(imageURL: URL, operation: NSDragOperation, from view: StashDraggableNSImageView) { + print("๐ŸŽฏ StashDragDelegate: Drag ended for \(imageURL.lastPathComponent) with operation \(operation.rawValue)") + print("๐ŸŽฏ StashDragDelegate: Grid delegate is \(stashGridActionDelegate != nil ? "SET" : "NIL") after drag end") + } +} + +// MARK: - Stash Grid Action Delegate +class StashGridActionDelegate: StashGridDelegate, RenameActionHandlerDelegate { + let imageStore: GalleryImageStore + weak var presentingWindow: NSWindow? + private var renameActionHandler: RenameActionHandler? + + // NIEUW: Reference naar stash grid manager voor correcte grid frame + weak var stashGridManager: StashGridManager? + + init(imageStore: GalleryImageStore) { + self.imageStore = imageStore + self.renameActionHandler = RenameActionHandler(delegate: self) + } + + func stashGridDidDropImage(at cellIndex: Int, stashItem: IdentifiableImage, imageURL: URL) { + print("โœ… StashGridActionDelegate: Independent action for cell \(cellIndex), stash item \(stashItem.id)") + + // ๐Ÿ”„ NEW: Set stash grid action flag for DraggableImageView post-action handling + if let appDelegate = NSApp.delegate as? ScreenshotApp { + appDelegate.didStashGridHandleDrop = true + print("๐Ÿ”„ Set didStashGridHandleDrop = true for stash action") + } + + // CRITICAL FIX: Use dynamic action mapping instead of hardcoded cellIndex + let settings = SettingsManager.shared + + // Get the active actions in the same order as StashGridWindow creates them + var activeActionTypes: [ActionType] = [] + for actionType in settings.actionOrder { + let isEnabled: Bool + switch actionType { + case .rename: isEnabled = settings.isRenameActionEnabled + case .stash: isEnabled = settings.isStashActionEnabled + case .ocr: isEnabled = settings.isOCRActionEnabled + case .clipboard: isEnabled = settings.isClipboardActionEnabled + case .backgroundRemove: isEnabled = settings.isBackgroundRemoveActionEnabled + case .cancel: isEnabled = settings.isCancelActionEnabled + case .remove: isEnabled = settings.isRemoveActionEnabled + } + + if isEnabled { + activeActionTypes.append(actionType) + } + } + + // Map cellIndex to actual ActionType + guard cellIndex >= 0 && cellIndex < activeActionTypes.count else { + print("โš ๏ธ StashGridActionDelegate: Invalid cell index \(cellIndex), available actions: \(activeActionTypes.count)") + return + } + + let actionType = activeActionTypes[cellIndex] + print("๐ŸŽฏ StashGridActionDelegate: Cell \(cellIndex) maps to action: \(actionType)") + + // Execute action based on ActionType instead of cellIndex + switch actionType { + case .rename: + handleStashRename(stashItem: stashItem, imageURL: imageURL) + case .stash: + handleStashDuplicate(stashItem: stashItem, imageURL: imageURL) // Stash = duplicate for stash items + case .clipboard: + handleStashClipboard(stashItem: stashItem, imageURL: imageURL) + case .ocr: + handleStashOCR(stashItem: stashItem, imageURL: imageURL) + case .backgroundRemove: + handleStashBackgroundRemove(stashItem: stashItem, imageURL: imageURL) + case .remove: + handleStashRemove(stashItem: stashItem, imageURL: imageURL) + case .cancel: + handleStashClose() + } + } + + // MARK: - Independent Stash Actions + + private func handleStashRename(stashItem: IdentifiableImage, imageURL: URL) { + print("๐Ÿ“ StashGridActionDelegate: Using real RenameActionHandler for stash item \(stashItem.id)") + + // Store current stash item for completion handling + self.currentStashItem = stashItem + + // Use the real RenameActionHandler just like the main thumbnail + renameActionHandler?.promptAndRename(originalURL: imageURL) { [self] response in + print("๐Ÿ” DEBUG: Stash rename response: \(response), successful: \(response != .cancel)") + + // Handle Save to Folder action (second button) + if response == .continue { + print("๐Ÿ” DEBUG: Processing Save to Folder for stash item") + if let updatedURL = self.currentStashItem?.fileURL ?? imageURL as URL? { + self.saveToFolder(fileURL: updatedURL) { success in + print("๐Ÿ” DEBUG: Save to Folder result: \(success)") + } + } + } + + // Reset current stash item ALLEEN als de response succesvol was + if response != .cancel { + print("๐Ÿ” DEBUG: Resetting currentStashItem after successful rename") + } + self.currentStashItem = nil + } + } + + // Store current stash item for rename completion + private var currentStashItem: IdentifiableImage? + + // MARK: - RenameActionHandlerDelegate Implementation + + func getScreenshotFolder() -> String? { + return SettingsManager.shared.screenshotFolder + } + + func renameActionHandler(_ handler: RenameActionHandler, didRenameFileFrom oldURL: URL, to newURL: URL) { + print("โœ… Stash item renamed from \(oldURL.lastPathComponent) to \(newURL.lastPathComponent)") + + // Update the current stash item in GalleryImageStore + guard let stashItem = currentStashItem else { + print("โš ๏ธ No current stash item to update after rename") + return + } + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + if let index = self.imageStore.images.firstIndex(where: { $0.id == stashItem.id }) { + let newName = newURL.deletingPathExtension().lastPathComponent + self.imageStore.images[index].customName = newName + self.imageStore.images[index].fileURL = newURL + print("โœ… Updated stash item in store with customName: '\(newName)'") + + // NIEUW: Force UI update door de hele array te 'verversen' + self.imageStore.objectWillChange.send() + } + } + } + + // NIEUW: Implement Save to Folder functionality for stash items + func saveToFolder(fileURL: URL, completion: @escaping (Bool) -> Void) { + guard let destinationFolder = SettingsManager.shared.screenshotFolder, !destinationFolder.isEmpty else { + print("โŒ No screenshot folder set for stash save") + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Folder Not Set" + alert.informativeText = "Please set a default save folder in Settings." + alert.addButton(withTitle: "OK") + alert.runModal() + completion(false) + } + return + } + + let destinationURL = URL(fileURLWithPath: destinationFolder).appendingPathComponent(fileURL.lastPathComponent) + + do { + // Check if file already exists + if FileManager.default.fileExists(atPath: destinationURL.path) { + print("โš ๏ธ File already exists at destination: \(destinationURL.lastPathComponent)") + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "File Exists" + alert.informativeText = "A file with the name '\(destinationURL.lastPathComponent)' already exists in the destination folder." + alert.addButton(withTitle: "OK") + alert.runModal() + completion(false) + } + return + } + + // Copy file to destination + try FileManager.default.copyItem(at: fileURL, to: destinationURL) + print("โœ… Stash item saved to folder: \(destinationURL.path)") + + DispatchQueue.main.async { + completion(true) + } + } catch { + print("โŒ Failed to save stash item to folder: \(error)") + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Save Failed" + alert.informativeText = "Could not save the file: \(error.localizedDescription)" + alert.addButton(withTitle: "OK") + alert.runModal() + completion(false) + } + } + } + + func findFilenameLabel(in window: NSWindow?) -> NSTextField? { + // For stash items, we don't have a filename label like the main preview + return nil + } + + func setTempFileURL(_ url: URL?) { + // For stash items, update the current item's fileURL + guard let stashItem = currentStashItem, let newURL = url else { return } + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + if let index = self.imageStore.images.firstIndex(where: { $0.id == stashItem.id }) { + self.imageStore.images[index].fileURL = newURL + } + } + } + + func getActivePreviewWindow() -> NSWindow? { + return presentingWindow + } + + func closePreviewWithAnimation(immediate: Bool, preserveTempFile: Bool = false) { + // For stash, we don't close the entire stash window during rename + print("๐Ÿ” Stash: closePreviewWithAnimation called (no action needed)") + } + + func getGridWindowFrame() -> NSRect? { + // FIXED: Return the actual stash grid frame instead of entire stash window + if let stashGridFrame = stashGridManager?.gridWindow?.frame { + print("๐Ÿ” DEBUG: Using actual stash grid frame: \(stashGridFrame)") + return stashGridFrame + } else { + print("๐Ÿ” DEBUG: No stash grid window available, using presenting window frame") + return presentingWindow?.frame + } + } + + func hideGrid() { + print("๐Ÿ” Stash: hideGrid called") + // The stash grid is handled by StashGridManager + } + + func disableGridMonitoring() { + print("๐Ÿ” Stash: disableGridMonitoring called") + // Stash grid doesn't have proximity monitoring + } + + func enableGridMonitoring() { + print("๐Ÿ” Stash: enableGridMonitoring called") + // Stash grid doesn't have proximity monitoring + } + + private func handleStashDuplicate(stashItem: IdentifiableImage, imageURL: URL) { + print("โž• StashGridActionDelegate: Independent duplicate for stash item \(stashItem.id)") + + // Create a new stash item (duplicate) + let newStashItem = IdentifiableImage( + nsImage: stashItem.nsImage, + fileURL: nil, // Will get permanent URL when added + customName: stashItem.customName + ) + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + let baseName = stashItem.customName ?? "Image" + let uniqueName = self.imageStore.generateUniqueCopyName(baseName: baseName) + + self.imageStore.addImage(newStashItem.nsImage, fileURL: nil, suggestedName: uniqueName, skipDuplicateCheck: true) + print("โœ… Created duplicate stash item with name: '\(uniqueName)'") + + // ๐Ÿ”ฅ ULTRA FIX: Show balloon feedback like hoofdgrid + if let gridFrame = self.getGridWindowFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Duplicated!") + panel.show(aroundGridFrame: gridFrame, text: "Duplicated!", autoCloseAfter: nil) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + panel.closeWithAnimation(completion: nil) + } + } + } + } + + private func handleStashClipboard(stashItem: IdentifiableImage, imageURL: URL) { + print("๐Ÿ“‹ StashGridActionDelegate: Independent clipboard for stash item \(stashItem.id)") + + guard let image = NSImage(contentsOf: imageURL) else { + print("โŒ Could not load stash image for clipboard") + // ๐Ÿ”ฅ ULTRA FIX: Show error balloon + if let gridFrame = getGridWindowFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error") + panel.show(aroundGridFrame: gridFrame, text: "Image load failed for Clipboard", autoCloseAfter: 2.0) + } + return + } + + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.writeObjects([image]) + print("โœ… Stash image copied to clipboard") + + // ๐Ÿ”ฅ ULTRA FIX: Show success balloon like hoofdgrid + if let gridFrame = getGridWindowFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Copied!") + panel.show(aroundGridFrame: gridFrame, text: "Copied to clipboard!", autoCloseAfter: nil) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + panel.closeWithAnimation(completion: nil) + } + } + } + + private func handleStashOCR(stashItem: IdentifiableImage, imageURL: URL) { + print("๐Ÿง StashGridActionDelegate: Independent OCR for stash item \(stashItem.id)") + + guard let nsImage = NSImage(contentsOf: imageURL), + let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + print("โŒ Could not load stash image for OCR") + // ๐Ÿ”ฅ ULTRA FIX: Show error balloon + if let gridFrame = getGridWindowFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error") + panel.show(aroundGridFrame: gridFrame, text: "Image load failed for OCR", autoCloseAfter: 2.0) + } + return + } + + // ๐Ÿ”ฅ ULTRA FIX: Show processing balloon like hoofdgrid + let processingPanel: FeedbackBubblePanel? + if let gridFrame = getGridWindowFrame() { + processingPanel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Processing...") + processingPanel?.show(aroundGridFrame: gridFrame, text: "Processing...", autoCloseAfter: nil) + } else { + processingPanel = nil + } + + let request = VNRecognizeTextRequest { request, error in + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // ๐Ÿ”ฅ ULTRA FIX: Close processing balloon first + processingPanel?.closeWithAnimation(completion: nil) + + var message = "" + var ocrDidFindText = false + + if let error = error { + print("โŒ OCR failed: \(error.localizedDescription)") + message = "OCR error: \(error.localizedDescription)" + } else { + guard let observations = request.results as? [VNRecognizedTextObservation] else { + print("โŒ No OCR results") + message = "No text found" + // ๐Ÿ”ฅ ULTRA FIX: Show result balloon + if let gridFrame = self.getGridWindowFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: message) + panel.show(aroundGridFrame: gridFrame, text: message, autoCloseAfter: 1.8, onAutoCloseCompletion: nil) + } + return + } + + let recognizedText = observations.compactMap { observation in + observation.topCandidates(1).first?.string + }.joined(separator: "\n") + + if recognizedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + print("๐Ÿ“ OCR found no readable text") + message = "No text found" + } else { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(recognizedText, forType: .string) + print("โœ… OCR text copied to clipboard: \(recognizedText.prefix(50))...") + message = "Text copied to clipboard!" + ocrDidFindText = true + } + } + + // ๐Ÿ”ฅ ULTRA FIX: Always show result balloon + if let gridFrame = self.getGridWindowFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: message) + panel.show(aroundGridFrame: gridFrame, text: message, autoCloseAfter: 1.8, onAutoCloseCompletion: nil) + } + } + } + + request.recognitionLevel = VNRequestTextRecognitionLevel.accurate + request.usesLanguageCorrection = true + + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + DispatchQueue.global(qos: .userInitiated).async { + do { + try handler.perform([request]) + } catch { + print("โŒ OCR handler failed: \(error.localizedDescription)") + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // ๐Ÿ”ฅ ULTRA FIX: Close processing balloon first + processingPanel?.closeWithAnimation(completion: nil) + + if let gridFrame = self.getGridWindowFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "OCR failed") + panel.show(aroundGridFrame: gridFrame, text: "OCR failed", autoCloseAfter: 2.0) + } + } + } + } + } + + private func handleStashBackgroundRemove(stashItem: IdentifiableImage, imageURL: URL) { + print("๐Ÿช„ StashGridActionDelegate: BGR thumbnail workflow for stash item \(stashItem.id)") + + guard let image = NSImage(contentsOf: imageURL) else { + print("โŒ Could not load stash image for background removal") + return + } + + // ๐ŸŽจ NEW: Use new BGR thumbnail workflow instead of old BGR window + if let appDelegate = NSApp.delegate as? ScreenshotApp { + print("๐ŸŽจ Starting BGR thumbnail workflow for stash item") + appDelegate.showBackgroundRemovalThumbnail(with: image, originalURL: imageURL) + print("โœ… Started BGR thumbnail workflow for stash item") + } else { + print("โŒ Could not get app delegate for BGR thumbnail workflow") + } + } + + private func handleStashRemove(stashItem: IdentifiableImage, imageURL: URL) { + print("๐Ÿ—‘๏ธ StashGridActionDelegate: Independent remove for stash item \(stashItem.id)") + + // Remove from file system + do { + try FileManager.default.removeItem(at: imageURL) + + // Remove from GalleryImageStore + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.imageStore.images.removeAll { $0.id == stashItem.id } + print("โœ… Removed stash item from store") + + // ๐Ÿ”ฅ ULTRA FIX: Show success balloon like hoofdgrid + if let gridFrame = self.getGridWindowFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Removed!") + panel.show(aroundGridFrame: gridFrame, text: "Item removed!", autoCloseAfter: 1.5) + } + } + } catch { + print("โŒ Failed to remove stash item: \(error)") + // ๐Ÿ”ฅ ULTRA FIX: Show error balloon + if let gridFrame = getGridWindowFrame() { + let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error") + panel.show(aroundGridFrame: gridFrame, text: "Failed to remove item", autoCloseAfter: 2.0) + } + } + } + + private func handleStashClose() { + print("โŒ StashGridActionDelegate: Independent close stash") + + // Find and close the stash window + DispatchQueue.main.async { + for window in NSApp.windows { + if window.title.contains("Stash") { + window.close() + break + } + } + } + } +} + +#if DEBUG +struct IntegratedGalleryView_Previews: PreviewProvider { + static var previews: some View { + let store = GalleryImageStore() + let sampleImage = NSImage(systemSymbolName: "photo", accessibilityDescription: nil) ?? NSImage() + return IntegratedGalleryView(imageStore: store, initialImage: sampleImage, initialImageURL: nil, initialImageName: nil, hostingWindow: nil, closeAction: { print("Preview close action") }) + .frame(width: 250, height: 300) + } +} +#endif + +extension IntegratedGalleryView: Identifiable { + var id: UUID { + return galleryID + } + // _galleryID is al gedefinieerd in de struct. +} + +// MARK: - OptimizedStashItemView +struct OptimizedStashItemView: View { + let imageItem: IdentifiableImage + let calculatedThumbnailSize: CGFloat + let thumbnailCornerRadius: CGFloat + let hoverScaleEffect: CGFloat + + @Binding var hoveredImageID: UUID? + @Binding var currentPreviewName: String // TURBO: Instant naam binding + @Binding var imageForPreview: NSImage? + @Binding var showPreview: Bool + @Binding var isPreviewStable: Bool + @Binding var hoverWorkItem: DispatchWorkItem? + @Binding var stableDelegateCache: [UUID: StashDragDelegate] + + let imageStore: GalleryImageStore + let onRemove: (IdentifiableImage) -> Void + + // CRITICAL FIX: Stable state to prevent re-renders + @State private var tempURL: URL? + @State private var isInitialized = false + + var body: some View { + ZStack(alignment: .topTrailing) { + // Main draggable image view + if let url = tempURL { + StashDraggableImageView( + nsImage: imageItem.nsImage, + imageURL: url, + suggestedName: displayNameForStashItem(imageItem), + stashItem: imageItem, + delegate: getStableDelegate() + ) + .frame(width: calculatedThumbnailSize, height: calculatedThumbnailSize) + .clipShape(RoundedRectangle(cornerRadius: thumbnailCornerRadius)) + .scaleEffect(hoveredImageID == imageItem.id ? hoverScaleEffect : 1.0) + .animation(.easeInOut(duration: 0.08), value: hoveredImageID == imageItem.id) + .zIndex(hoveredImageID == imageItem.id ? 1 : 0) + .allowsHitTesting(true) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + openImageInSystemApp(imageItem, tempURL: url) + } + } else { + // Fallback image + Image(nsImage: imageItem.nsImage) + .resizable() + .scaledToFit() + .cornerRadius(thumbnailCornerRadius) + .frame(width: calculatedThumbnailSize, height: calculatedThumbnailSize) + .clipped() + .scaleEffect(hoveredImageID == imageItem.id ? hoverScaleEffect : 1.0) + .animation(.easeInOut(duration: 0.08), value: hoveredImageID == imageItem.id) + .zIndex(hoveredImageID == imageItem.id ? 1 : 0) + .allowsHitTesting(true) + .contentShape(Rectangle()) + } + + // Remove button (X) + Button(action: { + onRemove(imageItem) + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.black) + .background(Color.white.opacity(0.6)) + .clipShape(Circle()) + .font(.system(size: 13)) + } + .buttonStyle(.plain) + .offset(x: 3, y: -3) + .opacity(hoveredImageID == imageItem.id ? 1 : 0) + .animation(.easeInOut(duration: 0.08), value: hoveredImageID == imageItem.id) + .zIndex(2) + .allowsHitTesting(hoveredImageID == imageItem.id) + } + .frame(width: calculatedThumbnailSize, height: calculatedThumbnailSize) + .background(Color.clear) + .contentShape(Rectangle()) + .onHover { isHovering in + // OPTIMIZED: Minimal state changes + hoverWorkItem?.cancel() + + if isHovering { + self.hoveredImageID = imageItem.id + // ๐Ÿš€ TURBO INSTANT UPDATE: Zet naam METEEN zonder delay! + self.currentPreviewName = imageItem.fileURL?.deletingPathExtension().lastPathComponent ?? "Unknown" + print("๐Ÿ” ULTRA DEBUG: onHover START - set hoveredImageID to \(imageItem.id) with INSTANT name: '\(self.currentPreviewName)' from item: \(imageItem.fileURL?.lastPathComponent ?? "nil")") + self.imageForPreview = imageItem.nsImage + self.showPreview = true + self.isPreviewStable = true + } else { + if self.hoveredImageID == imageItem.id { + print("๐Ÿ” ULTRA DEBUG: onHover END - clearing hover for \(imageItem.id)") + let workItem = DispatchWorkItem { + if self.hoveredImageID == imageItem.id { + self.hoveredImageID = nil + self.currentPreviewName = "Unknown" // Reset naam ook + self.imageForPreview = nil + self.showPreview = false + self.isPreviewStable = false + } + } + self.hoverWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) + } + } + } + .onAppear { + // CRITICAL FIX: Initialize only once + if !isInitialized { + initializeTempURL() + isInitialized = true + } + } + } + + // CRITICAL FIX: Stable delegate getter that prevents recreation + private func getStableDelegate() -> StashDragDelegate { + if let existingDelegate = stableDelegateCache[imageItem.id] { + return existingDelegate + } else { + let newDelegate = StashDragDelegate(imageItem: imageItem, imageStore: imageStore) + stableDelegateCache[imageItem.id] = newDelegate + return newDelegate + } + } + + private func initializeTempURL() { + if let existingURL = imageItem.fileURL { + tempURL = existingURL + } else { + tempURL = createTempURLForStashItem(imageItem) + } + } + + private func createTempURLForStashItem(_ imgItem: IdentifiableImage) -> URL? { + if let existingURL = imgItem.fileURL { + return existingURL + } + + guard let tiffRepresentation = imgItem.nsImage.tiffRepresentation, + let bitmapImageRep = NSBitmapImageRep(data: tiffRepresentation), + let pngData = bitmapImageRep.representation(using: .png, properties: [:]) else { + return nil + } + + let tempDirectoryURL = FileManager.default.temporaryDirectory + let tempFilename = "\(imgItem.id.uuidString).png" + let tempFileURL = tempDirectoryURL.appendingPathComponent(tempFilename) + + do { + try pngData.write(to: tempFileURL) + return tempFileURL + } catch { + return nil + } + } + + private func openImageInSystemApp(_ imgItem: IdentifiableImage, tempURL: URL) { + NSWorkspace.shared.open(tempURL) + } + + private func displayNameForStashItem(_ imgItem: IdentifiableImage) -> String? { + if let customName = imgItem.customName, !customName.isEmpty { + return customName + } + + if let fileURL = imgItem.fileURL { + return fileURL.deletingPathExtension().lastPathComponent + } + + return nil + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/LicenseEntryView.swift b/ShotScreen/Sources/LicenseEntryView.swift new file mode 100644 index 0000000..dd41438 --- /dev/null +++ b/ShotScreen/Sources/LicenseEntryView.swift @@ -0,0 +1,473 @@ +import SwiftUI +import AppKit + +struct LicenseEntryView: View { + @ObservedObject private var licenseManager = LicenseManager.shared + @Environment(\.presentationMode) var presentationMode + + @State private var licenseKey = "" + @State private var userName = "" + @State private var isVerifying = false + @State private var errorMessage = "" + @State private var showError = false + + // Add closure for window dismissal + var onDismiss: (() -> Void)? + + init(onDismiss: (() -> Void)? = nil) { + self.onDismiss = onDismiss + } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 24) { + // Add minimal top padding since traffic lights are hidden + Spacer() + .frame(height: 20) + + VStack(spacing: 24) { + // Header Section - similar to SettingsTabView + VStack(spacing: 16) { + // Replace key icon with ShotScreen icon at native 200x200 resolution + Group { + if let shotScreenIcon = NSImage(named: "ShotScreenIcon_200x200") { + Image(nsImage: shotScreenIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 200, height: 200) + .cornerRadius(20) + } else if let bundle = Bundle.main.url(forResource: "images/ShotScreenIcon_200x200", withExtension: "png"), + let nsImage = NSImage(contentsOf: bundle) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 200, height: 200) + .cornerRadius(20) + } else { + // Fallback to system icon if ShotScreenIcon_200x200 not found + Image(systemName: "key.fill") + .font(.system(size: 48)) + .foregroundColor(.blue) + } + } + + VStack(spacing: 8) { + Text(headerTitle) + .font(.largeTitle) + .fontWeight(.bold) + .foregroundColor(.primary) + .multilineTextAlignment(.center) + + Text(headerSubtitle) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + .padding(.top, 10) + .padding(.bottom, 10) + + // Trial Status Section + switch licenseManager.licenseStatus { + case .trial(let daysLeft): + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Trial Status") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + Divider() + } + + HStack { + Image(systemName: "clock.fill") + .foregroundColor(.orange) + Text("\(daysLeft) days remaining in your free trial") + .foregroundColor(.secondary) + } + + Text("Already purchased? Enter your license key below to activate the full version.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(20) + .background(Color.primary.opacity(0.03)) + .cornerRadius(12) + + case .expired: + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Trial Expired") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.red) + Divider() + } + + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text("Your 7-day trial has expired") + .foregroundColor(.secondary) + } + + Text("Purchase a license or enter your license key to continue using ShotScreen.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(20) + .background(Color.red.opacity(0.05)) + .cornerRadius(12) + + default: + EmptyView() + } + + // License Entry Form Section + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("License Information") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + Divider() + } + + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Your Name") + .font(.headline) + .foregroundColor(.primary) + TextField("Enter your full name", text: $userName) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .disabled(isVerifying) + } + + VStack(alignment: .leading, spacing: 8) { + Text("License Key") + .font(.headline) + .foregroundColor(.primary) + TextField("XXXX-XXXX-XXXX-XXXX", text: $licenseKey) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .font(.system(.body, design: .monospaced)) + .disabled(isVerifying) + } + + if showError { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(errorMessage) + .foregroundColor(.red) + .font(.caption) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + } + } + .padding(20) + .background(Color.primary.opacity(0.03)) + .cornerRadius(12) + + Spacer() + } + .padding() + } + } + + Divider() + + // Bottom Button Section - matching SettingsTabView style + HStack { + Button(action: activateLicense) { + HStack { + if isVerifying { + ProgressView() + .scaleEffect(0.7) + } + Text(isVerifying ? "Verifying..." : "Activate License") + } + } + .buttonStyle(.borderedProminent) + .disabled(!canActivate || isVerifying) + .keyboardShortcut(.defaultAction) // Return key = Activate + + Spacer() + + Button("Buy License") { + openPurchaseURL() + } + .buttonStyle(.bordered) + + Button("Cancel") { + dismissWindow() + } + .buttonStyle(.bordered) + } + .padding() + } + .frame(minWidth: 600, idealWidth: 650, minHeight: 700, idealHeight: 750) + .background(Color.clear) + } + + private var canActivate: Bool { + !licenseKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !userName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var headerTitle: String { + switch licenseManager.licenseStatus { + case .trial(_): + return "Activate ShotScreen" + case .expired: + return "Unlock ShotScreen" + default: + return "Activate ShotScreen" + } + } + + private var headerSubtitle: String { + switch licenseManager.licenseStatus { + case .trial(_): + return "Already have a license? Enter your license key below." + case .expired: + return "Your free trial has ended. Enter your license key to continue using ShotScreen." + default: + return "Enter your license key to unlock the full version" + } + } + + private func activateLicense() { + guard canActivate else { return } + + isVerifying = true + showError = false + errorMessage = "" + + Task { + let success = await licenseManager.enterLicenseKey( + licenseKey.trimmingCharacters(in: .whitespacesAndNewlines), + userName: userName.trimmingCharacters(in: .whitespacesAndNewlines) + ) + + await MainActor.run { + isVerifying = false + + if success { + dismissWindow() + } else { + showError = true + errorMessage = "Invalid license key or verification failed. Please check your license key and internet connection." + } + } + } + } + + private func openPurchaseURL() { + // ShotScreen Gumroad license product + if let url = URL(string: "https://roodenrijs.gumroad.com/l/uxexr") { + NSWorkspace.shared.open(url) + } + } + + private func dismissWindow() { + onDismiss?() + presentationMode.wrappedValue.dismiss() + } +} + +// MARK: - License Entry Window +class LicenseEntryWindow: NSWindow { + private var visualEffectViewContainer: NSView? + + override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) { + super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag) + setupWindow() + } + + convenience init() { + self.init( + contentRect: NSRect(x: 0, y: 0, width: 650, height: 750), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + } + + private func setupWindow() { + title = "ShotScreen License" + isReleasedWhenClosed = false + center() + + // Ensure the window appears in front and can be brought to front + level = .normal + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + // Apply same blur style as SettingsWindow + isOpaque = false + backgroundColor = .clear + titlebarAppearsTransparent = true + titleVisibility = .hidden + isMovableByWindowBackground = true + + // Hide traffic lights (red, yellow, green buttons) to prevent content overlap + standardWindowButton(.closeButton)?.isHidden = true + standardWindowButton(.miniaturizeButton)?.isHidden = true + standardWindowButton(.zoomButton)?.isHidden = true + + // Create content view with dismissal callback + let licenseView = LicenseEntryView(onDismiss: { [weak self] in + self?.close() + }) + let hostingView = NSHostingView(rootView: licenseView) + + // Apply same visual effect as SettingsWindow + let visualEffectView = NSVisualEffectView() + visualEffectView.blendingMode = .behindWindow + visualEffectView.material = .hudWindow // Same strong system blur + visualEffectView.state = .active + visualEffectView.alphaValue = 1.0 + visualEffectView.autoresizingMask = [.width, .height] + + // Extra blur layer for enhanced effect + let extraBlurView = NSVisualEffectView() + extraBlurView.blendingMode = .behindWindow + extraBlurView.material = .hudWindow + extraBlurView.state = .active + extraBlurView.alphaValue = 0.6 // Half transparent for optical blur enhancement + extraBlurView.autoresizingMask = [.width, .height] + + let newRootContentView = NSView(frame: contentRect(forFrameRect: frame)) + + // Layer order: strongest blur at bottom, half blur above, SwiftUI content on top + visualEffectView.frame = newRootContentView.bounds + extraBlurView.frame = newRootContentView.bounds + + newRootContentView.addSubview(visualEffectView) // Layer 0 + newRootContentView.addSubview(extraBlurView) // Layer 1 (enhancement) + newRootContentView.addSubview(hostingView) // Layer 2 (UI) + + contentView = newRootContentView + 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) + ]) + + // Force a layout pass after setting up the hierarchy + contentView?.layoutSubtreeIfNeeded() + + // Window appearance + delegate = self + + // Better window management - bring to front + orderFrontRegardless() + makeKeyAndOrderFront(nil) + } + + deinit { + print("๐Ÿ—‘๏ธ LicenseEntryWindow deinitialized safely") + } +} + +// MARK: - Window Delegate +extension LicenseEntryWindow: NSWindowDelegate { + func windowShouldClose(_ sender: NSWindow) -> Bool { + // Allow closing + return true + } + + func windowWillClose(_ notification: Notification) { + // Clean up when window is about to close + contentView = nil + delegate = nil + print("๐ŸชŸ License window closing gracefully") + } +} + +// MARK: - Trial Expired View +struct TrialExpiredView: View { + @ObservedObject private var licenseManager = LicenseManager.shared + + var body: some View { + VStack(spacing: 24) { + // Replace with ShotScreen icon using 200x200 source at smaller display size + Group { + if let shotScreenIcon = NSImage(named: "ShotScreenIcon_200x200") { + Image(nsImage: shotScreenIcon) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + .cornerRadius(16) + } else if let bundle = Bundle.main.url(forResource: "images/ShotScreenIcon_200x200", withExtension: "png"), + let nsImage = NSImage(contentsOf: bundle) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + .cornerRadius(16) + } else { + // Fallback icon + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 64)) + .foregroundColor(.orange) + } + } + + // Title + Text("Trial Expired") + .font(.largeTitle) + .fontWeight(.bold) + + // Message + Text("Your 7-day trial of ShotScreen has expired.\nPlease purchase a license to continue using the app.") + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + + // Buttons - matching new style + VStack(spacing: 12) { + Button("Purchase License") { + openPurchaseURL() + } + .buttonStyle(.borderedProminent) + + Button("Enter License Key") { + licenseManager.showLicenseEntryDialog() + } + .buttonStyle(.bordered) + + Button("Quit ShotScreen") { + NSApplication.shared.terminate(nil) + } + .buttonStyle(.bordered) + .foregroundColor(.red) + } + } + .padding(40) + .frame(width: 400, height: 350) + .background(Color(NSColor.windowBackgroundColor)) + } + + private func openPurchaseURL() { + // ShotScreen Gumroad license product + if let url = URL(string: "https://roodenrijs.gumroad.com/l/uxexr") { + NSWorkspace.shared.open(url) + } + } +} + +#Preview { + LicenseEntryView() +} \ No newline at end of file diff --git a/ShotScreen/Sources/LicenseManager.swift b/ShotScreen/Sources/LicenseManager.swift new file mode 100644 index 0000000..a137ef9 --- /dev/null +++ b/ShotScreen/Sources/LicenseManager.swift @@ -0,0 +1,605 @@ +import Foundation +import Combine +import AppKit +import Security + +// MARK: - Keychain Helper +class KeychainHelper { + static let shared = KeychainHelper() + private let serviceName = "com.shotscreen.app" + + private init() {} + + // MARK: - Generic Keychain Operations + func save(_ data: Data, forKey key: String) -> Bool { + // Delete any existing item first + delete(forKey: key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + func load(forKey key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + return nil + } + + return result as? Data + } + + func delete(forKey key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } + + // MARK: - String Convenience Methods + func saveString(_ string: String, forKey key: String) -> Bool { + guard let data = string.data(using: .utf8) else { return false } + return save(data, forKey: key) + } + + func loadString(forKey key: String) -> String? { + guard let data = load(forKey: key) else { return nil } + return String(data: data, encoding: .utf8) + } + + // MARK: - Date Convenience Methods + func saveDate(_ date: Date, forKey key: String) -> Bool { + let data = date.timeIntervalSince1970.description.data(using: .utf8)! + return save(data, forKey: key) + } + + func loadDate(forKey key: String) -> Date? { + guard let string = loadString(forKey: key), + let timeInterval = Double(string) else { return nil } + return Date(timeIntervalSince1970: timeInterval) + } + + // MARK: - Bool Convenience Methods + func saveBool(_ bool: Bool, forKey key: String) -> Bool { + let data = bool.description.data(using: .utf8)! + return save(data, forKey: key) + } + + func loadBool(forKey key: String) -> Bool? { + guard let string = loadString(forKey: key) else { return nil } + return Bool(string) + } + + // MARK: - Migration from UserDefaults + func migrateFromUserDefaults() { + print("๐Ÿ” KEYCHAIN: Migrating sensitive data from UserDefaults to Keychain...") + + let keysToMigrate = [ + "ShotScreen_LicenseKey", + "ShotScreen_UserName", + "ShotScreen_UserEmail", + "ShotScreen_TrialStartDate", + "ShotScreen_LastVerification", + "ShotScreen_IsTestLicense" + ] + + var migratedCount = 0 + + for key in keysToMigrate { + // Check if already exists in Keychain + if load(forKey: key) != nil { + continue // Already migrated + } + + // Try to get from UserDefaults + if let string = UserDefaults.standard.string(forKey: key) { + if saveString(string, forKey: key) { + UserDefaults.standard.removeObject(forKey: key) + migratedCount += 1 + print("โœ… KEYCHAIN: Migrated \(key)") + } + } else if let date = UserDefaults.standard.object(forKey: key) as? Date { + if saveDate(date, forKey: key) { + UserDefaults.standard.removeObject(forKey: key) + migratedCount += 1 + print("โœ… KEYCHAIN: Migrated \(key)") + } + } else if UserDefaults.standard.object(forKey: key) != nil { + let bool = UserDefaults.standard.bool(forKey: key) + if saveBool(bool, forKey: key) { + UserDefaults.standard.removeObject(forKey: key) + migratedCount += 1 + print("โœ… KEYCHAIN: Migrated \(key)") + } + } + } + + if migratedCount > 0 { + print("๐Ÿ” KEYCHAIN: Successfully migrated \(migratedCount) items to secure storage") + } + } +} + +// MARK: - License Models +struct GumroadVerifyResponse: Codable { + let success: Bool + let message: String? + let uses: Int? + let purchase: GumroadPurchase? +} + +struct GumroadPurchase: Codable { + let seller_id: String? + let product_id: String? + let product_name: String? + let email: String? + let price: Int? + let currency: String? + let sale_timestamp: String? + let refunded: Bool? + let disputed: Bool? + let chargebacked: Bool? + let license_key: String? + let id: String? + let test: Bool? // Test purchase detection +} + +enum LicenseStatus { + case checking + case trial(daysLeft: Int) + case licensed(userName: String, email: String) + case testLicense(userName: String, email: String) // Test purchase status + case expired + case invalid +} + +// MARK: - License Manager +class LicenseManager: ObservableObject { + static let shared = LicenseManager() + + @Published var licenseStatus: LicenseStatus = .checking + @Published var isLicenseValid: Bool = false + @Published var showLicenseEntry: Bool = false + + // Window management - strong reference to prevent deallocation + private var licenseEntryWindow: LicenseEntryWindow? + private var windowCloseObserver: NSObjectProtocol? + + // Configuration - jouw Gumroad product info + private let productID = "338g8-ukf3N_mZ-c-XddFw==" // ShotScreen Product ID van Gumroad + private let gumroadAPIURL = "https://api.gumroad.com/v2/licenses/verify" + + // Secure Keychain keys (instead of UserDefaults) + private let licenseKeyKey = "ShotScreen_LicenseKey" + private let trialStartDateKey = "ShotScreen_TrialStartDate" + private let userNameKey = "ShotScreen_UserName" + private let userEmailKey = "ShotScreen_UserEmail" + private let lastVerificationKey = "ShotScreen_LastVerification" + private let isTestLicenseKey = "ShotScreen_IsTestLicense" + + private let trialDuration: TimeInterval = 7 * 24 * 60 * 60 // 7 dagen + private let verificationInterval: TimeInterval = 24 * 60 * 60 // 1 dag + + private init() { + // Check if we're in development mode (to avoid constant Keychain prompts) + if !isInDevelopmentMode() { + // Migrate existing data from UserDefaults to Keychain + KeychainHelper.shared.migrateFromUserDefaults() + } + checkLicenseStatus() + } + + // MARK: - Development Mode Helper + private func isInDevelopmentMode() -> Bool { + return ProcessInfo.processInfo.environment["SHOTSCREEN_DEV_MODE"] == "true" + } + + // MARK: - Development-Safe Storage Helpers + private func saveString(_ value: String, forKey key: String) { + if isInDevelopmentMode() { + UserDefaults.standard.set(value, forKey: key) + print("๐Ÿ”ง DEV MODE: Saved \(key) to UserDefaults") + } else { + _ = KeychainHelper.shared.saveString(value, forKey: key) + } + } + + private func loadString(forKey key: String) -> String? { + if isInDevelopmentMode() { + return UserDefaults.standard.string(forKey: key) + } else { + return KeychainHelper.shared.loadString(forKey: key) + } + } + + private func saveDate(_ value: Date, forKey key: String) { + if isInDevelopmentMode() { + UserDefaults.standard.set(value, forKey: key) + print("๐Ÿ”ง DEV MODE: Saved \(key) to UserDefaults") + } else { + _ = KeychainHelper.shared.saveDate(value, forKey: key) + } + } + + private func loadDate(forKey key: String) -> Date? { + if isInDevelopmentMode() { + return UserDefaults.standard.object(forKey: key) as? Date + } else { + return KeychainHelper.shared.loadDate(forKey: key) + } + } + + private func saveBool(_ value: Bool, forKey key: String) { + if isInDevelopmentMode() { + UserDefaults.standard.set(value, forKey: key) + print("๐Ÿ”ง DEV MODE: Saved \(key) to UserDefaults") + } else { + _ = KeychainHelper.shared.saveBool(value, forKey: key) + } + } + + private func loadBool(forKey key: String) -> Bool? { + if isInDevelopmentMode() { + return UserDefaults.standard.object(forKey: key) != nil ? UserDefaults.standard.bool(forKey: key) : nil + } else { + return KeychainHelper.shared.loadBool(forKey: key) + } + } + + private func deleteKey(_ key: String) { + if isInDevelopmentMode() { + UserDefaults.standard.removeObject(forKey: key) + print("๐Ÿ”ง DEV MODE: Deleted \(key) from UserDefaults") + } else { + _ = KeychainHelper.shared.delete(forKey: key) + } + } + + deinit { + cleanupLicenseWindow() + print("๐Ÿ—‘๏ธ LicenseManager deinitialized") + } + + // MARK: - Public Methods + + func checkLicenseStatus() { + print("๐Ÿ” LICENSE: Checking license status...") + + // Check if we have a stored license key (with dev mode support) + let licenseKey = loadString(forKey: licenseKeyKey) + if isInDevelopmentMode() { + print("๐Ÿ”ง DEV MODE: Using UserDefaults for license storage") + } + + if let licenseKey = licenseKey, !licenseKey.isEmpty { + print("๐Ÿ” LICENSE: Found stored license key, verifying...") + Task { + await verifyStoredLicense(licenseKey: licenseKey) + } + return + } + + // No license found - check trial status + checkTrialStatus() + } + + func enterLicenseKey(_ licenseKey: String, userName: String) async -> Bool { + print("๐Ÿ” LICENSE: Attempting to verify license key for user: \(userName)") + + let success = await verifyLicenseWithGumroad(licenseKey: licenseKey, userName: userName) + + if success { + // Store license info securely (Keychain or UserDefaults in dev mode) + saveString(licenseKey, forKey: licenseKeyKey) + saveString(userName, forKey: userNameKey) + saveDate(Date(), forKey: lastVerificationKey) + + await MainActor.run { + let isTestLicense = self.loadBool(forKey: self.isTestLicenseKey) ?? false + let email = self.getStoredEmail() + + if isTestLicense { + self.licenseStatus = .testLicense(userName: userName, email: email) + print("๐Ÿงช LICENSE: Test license activated for \(userName)") + } else { + self.licenseStatus = .licensed(userName: userName, email: email) + print("โœ… LICENSE: Production license activated for \(userName)") + } + + self.isLicenseValid = true + self.showLicenseEntry = false + } + + print("โœ… LICENSE: License activated successfully for \(userName)") + } else { + print("โŒ LICENSE: License verification failed") + } + + return success + } + + func startTrial() { + // In freemium model, trial starts automatically on first launch + // This method should not restart an expired trial + if loadDate(forKey: trialStartDateKey) != nil { + print("โš ๏ธ LICENSE: Trial already started - cannot restart trial in freemium model") + return + } + + print("๐ŸŽ‰ LICENSE: Starting trial period...") + saveDate(Date(), forKey: trialStartDateKey) + checkTrialStatus() + } + + func getTrialDaysLeft() -> Int { + guard let trialStartDate = loadDate(forKey: trialStartDateKey) else { + return 7 // No trial started yet + } + + let elapsed = Date().timeIntervalSince(trialStartDate) + let daysElapsed = Int(elapsed / (24 * 60 * 60)) + return max(0, 7 - daysElapsed) + } + + func showLicenseEntryDialog() { + print("๐Ÿ” LICENSE: Showing license entry dialog...") + + // Close existing window if any + closeLicenseEntryWindow() + + // Create and show new window with proper reference management + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + self.licenseEntryWindow = LicenseEntryWindow() + + // Set up completion handler for when window is closed + if let window = self.licenseEntryWindow { + // Add notification observer for window closing + self.windowCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] _ in + print("๐ŸชŸ License window will close - cleaning up reference") + self?.cleanupLicenseWindow() + } + + window.makeKeyAndOrderFront(nil) + print("๐Ÿ” LICENSE: License entry window created and shown") + } + } + } + + func closeLicenseEntryWindow() { + if let window = licenseEntryWindow { + print("๐ŸชŸ Closing existing license entry window") + window.close() + } + cleanupLicenseWindow() + } + + private func cleanupLicenseWindow() { + // Remove notification observer + if let observer = windowCloseObserver { + NotificationCenter.default.removeObserver(observer) + windowCloseObserver = nil + print("๐Ÿ—‘๏ธ Window close observer removed") + } + + // Clear window reference + licenseEntryWindow = nil + print("๐Ÿ—‘๏ธ License window reference cleared") + } + + func isTrialExpired() -> Bool { + guard let trialStartDate = loadDate(forKey: trialStartDateKey) else { + return false // No trial started + } + + let elapsed = Date().timeIntervalSince(trialStartDate) + return elapsed > trialDuration + } + + func hasValidLicense() -> Bool { + return isLicenseValid + } + + // MARK: - Private Methods + + private func checkTrialStatus() { + // Check if trial has already been started + if let trialStartDate = loadDate(forKey: trialStartDateKey) { + let elapsed = Date().timeIntervalSince(trialStartDate) + + if elapsed > trialDuration { + print("โŒ LICENSE: Trial period expired (\(Int(elapsed / (24 * 60 * 60))) days ago)") + DispatchQueue.main.async { + self.licenseStatus = .expired + self.isLicenseValid = false + } + } else { + let daysLeft = max(0, 7 - Int(elapsed / (24 * 60 * 60))) + print("โฐ LICENSE: Trial active, \(daysLeft) days remaining") + DispatchQueue.main.async { + self.licenseStatus = .trial(daysLeft: daysLeft) + self.isLicenseValid = true + } + } + } else { + // First launch - automatically start trial + print("๐ŸŽ‰ LICENSE: First launch detected - automatically starting 7-day trial") + let now = Date() + saveDate(now, forKey: trialStartDateKey) + + DispatchQueue.main.async { + self.licenseStatus = .trial(daysLeft: 7) + self.isLicenseValid = true + } + } + } + + private func verifyStoredLicense(licenseKey: String) async { + // Check if we need to reverify (not more than once per day) + if let lastVerification = loadDate(forKey: lastVerificationKey) { + let timeSinceLastCheck = Date().timeIntervalSince(lastVerification) + if timeSinceLastCheck < verificationInterval { + print("๐Ÿ” LICENSE: Using cached verification") + await MainActor.run { + let userName = self.getStoredUserName() + let email = self.getStoredEmail() + let isTestLicense = self.loadBool(forKey: self.isTestLicenseKey) ?? false + + if isTestLicense { + self.licenseStatus = .testLicense(userName: userName, email: email) + print("๐Ÿงช LICENSE: Using cached test license") + } else { + self.licenseStatus = .licensed(userName: userName, email: email) + print("๐Ÿ” LICENSE: Using cached production license") + } + + self.isLicenseValid = true + } + return + } + } + + // Re-verify with Gumroad + let userName = getStoredUserName() + let success = await verifyLicenseWithGumroad(licenseKey: licenseKey, userName: userName) + + await MainActor.run { + if success { + let isTestLicense = self.loadBool(forKey: self.isTestLicenseKey) ?? false + let email = self.getStoredEmail() + + if isTestLicense { + self.licenseStatus = .testLicense(userName: userName, email: email) + print("๐Ÿงช LICENSE: Test license verified on startup") + } else { + self.licenseStatus = .licensed(userName: userName, email: email) + print("โœ… LICENSE: Production license verified on startup") + } + + self.isLicenseValid = true + self.saveDate(Date(), forKey: self.lastVerificationKey) + } else { + // License might be revoked, disputed, etc. + self.licenseStatus = .invalid + self.isLicenseValid = false + // Clear stored license + self.deleteKey(self.licenseKeyKey) + self.deleteKey(self.userNameKey) + self.deleteKey(self.userEmailKey) + self.deleteKey(self.isTestLicenseKey) + } + } + } + + private func verifyLicenseWithGumroad(licenseKey: String, userName: String) async -> Bool { + guard let url = URL(string: gumroadAPIURL) else { + print("โŒ LICENSE: Invalid Gumroad API URL") + return false + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + // Prepare form data + var formData = [String]() + formData.append("product_id=\(productID)") + formData.append("license_key=\(licenseKey)") + formData.append("increment_uses_count=false") // Don't increment on every check + + let bodyString = formData.joined(separator: "&") + request.httpBody = bodyString.data(using: .utf8) + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + if let httpResponse = response as? HTTPURLResponse { + print("๐Ÿ” LICENSE: Gumroad API response status: \(httpResponse.statusCode)") + } + + let verifyResponse = try JSONDecoder().decode(GumroadVerifyResponse.self, from: data) + + if verifyResponse.success { + // Additional checks + if let purchase = verifyResponse.purchase { + // ๐Ÿงช Check if this is a test purchase + let isTestPurchase = purchase.test == true + + if isTestPurchase { + print("๐Ÿงช LICENSE: Test purchase detected!") + print("๐Ÿงช LICENSE: Product: \(purchase.product_name ?? "Unknown")") + print("๐Ÿงช LICENSE: Email: \(purchase.email ?? "Unknown")") + print("๐Ÿงช LICENSE: Price: \(purchase.price ?? 0) \(purchase.currency ?? "USD")") + print("๐Ÿงช LICENSE: This is a development/test license") + } + + // Check if refunded, disputed, or chargebacked + if purchase.refunded == true || purchase.disputed == true || purchase.chargebacked == true { + print("โŒ LICENSE: License is refunded, disputed, or chargebacked") + return false + } + + // Store additional info + if let email = purchase.email { + saveString(email, forKey: userEmailKey) + } + + // Store test status for UI display + saveBool(isTestPurchase, forKey: isTestLicenseKey) + + print("โœ… LICENSE: Gumroad verification successful") + print("๐Ÿ” LICENSE: Product: \(purchase.product_name ?? "Unknown")") + print("๐Ÿ” LICENSE: Email: \(purchase.email ?? "Unknown")") + print("๐Ÿ” LICENSE: License Type: \(isTestPurchase ? "TEST" : "PRODUCTION")") + return true + } + } else { + print("โŒ LICENSE: Gumroad verification failed: \(verifyResponse.message ?? "Unknown error")") + } + + } catch { + print("โŒ LICENSE: Gumroad API error: \(error.localizedDescription)") + } + + return false + } + + private func getStoredUserName() -> String { + return loadString(forKey: userNameKey) ?? "Unknown User" + } + + private func getStoredEmail() -> String { + return loadString(forKey: userEmailKey) ?? "" + } +} + +// MARK: - Notification Names +extension Notification.Name { + static let licenseStatusChanged = Notification.Name("licenseStatusChanged") +} \ No newline at end of file diff --git a/ShotScreen/Sources/MenuManager.swift b/ShotScreen/Sources/MenuManager.swift new file mode 100644 index 0000000..787a36b --- /dev/null +++ b/ShotScreen/Sources/MenuManager.swift @@ -0,0 +1,783 @@ +import AppKit + +// MARK: - Menu Management +class MenuManager { + weak var delegate: MenuManagerDelegate? + private var statusItem: NSStatusItem? + + init(delegate: MenuManagerDelegate) { + self.delegate = delegate + + // Add observer for shortcut changes to update menu + NotificationCenter.default.addObserver( + self, + selector: #selector(updateShortcuts), + name: .shortcutSettingChanged, + object: nil + ) + + // Add observer for desktop icons setting changes + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDesktopIconsSettingChanged), + name: .hideDesktopIconsSettingChanged, + object: nil + ) + + // Add observer for desktop widgets setting changes + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDesktopWidgetsSettingChanged), + name: .hideDesktopWidgetsSettingChanged, + object: nil + ) + } + + func setupMenu() { + setupMainMenu() + setupStatusBarMenu() + + // Set initial shortcuts + updateShortcuts() + } + + private func setupMainMenu() { + // Create main menu + let mainMenu = NSMenu() + + // Application menu (first menu) + let appMenu = NSMenu() + let appName = "ShotScreen" + + // About item with info icon + let aboutItem = NSMenuItem(title: "About \(appName)", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + if let infoIcon = NSImage(systemSymbolName: "info.circle", accessibilityDescription: "About") { + infoIcon.size = NSSize(width: 16, height: 16) + aboutItem.image = infoIcon + } + appMenu.addItem(aboutItem) + appMenu.addItem(NSMenuItem.separator()) + + // Settings item with gear icon + let settingsItem = NSMenuItem(title: "Settings", action: #selector(MenuManager.openSettings), keyEquivalent: ",") + settingsItem.target = self + if let gearIcon = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings") { + gearIcon.size = NSSize(width: 16, height: 16) + settingsItem.image = gearIcon + } + appMenu.addItem(settingsItem) + + // Show Stash item with folder icon + let showStashItem = NSMenuItem(title: "Show Stash", action: #selector(MenuManager.showStash), keyEquivalent: "s") + showStashItem.target = self + if let folderIcon = NSImage(systemSymbolName: "folder", accessibilityDescription: "Folder") { + folderIcon.size = NSSize(width: 16, height: 16) + showStashItem.image = folderIcon + } + appMenu.addItem(showStashItem) + + // NIEUW: Reset First Launch Wizard (for testing) + appMenu.addItem(NSMenuItem.separator()) + let resetWizardItem = NSMenuItem(title: "Reset First Launch Wizard", action: #selector(MenuManager.resetFirstLaunchWizard), keyEquivalent: "") + resetWizardItem.target = self + if let resetIcon = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Reset") { + resetIcon.size = NSSize(width: 16, height: 16) + resetWizardItem.image = resetIcon + } + appMenu.addItem(resetWizardItem) + + appMenu.addItem(NSMenuItem.separator()) + + // Quit item with power icon + let quitItem = NSMenuItem(title: "Quit \(appName)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + if let powerIcon = NSImage(systemSymbolName: "power", accessibilityDescription: "Quit") { + powerIcon.size = NSSize(width: 16, height: 16) + quitItem.image = powerIcon + } + appMenu.addItem(quitItem) + + // Add app menu to main menu + let appMenuItem = NSMenuItem(title: appName, action: nil, keyEquivalent: "") + appMenuItem.submenu = appMenu + mainMenu.addItem(appMenuItem) + + // Set the menu + NSApplication.shared.mainMenu = mainMenu + } + + private func setupStatusBarMenu() { + // Create status item in menu bar + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem?.button { + // Use MenuIcon for status bar instead of AppIcon + if let bundle = Bundle.main.url(forResource: "MenuIcon", withExtension: "png", subdirectory: "images"), + let menuIcon = NSImage(contentsOf: bundle) { + menuIcon.size = NSSize(width: 16, height: 16) + button.image = menuIcon + } else if let menuIcon = NSImage(named: "MenuIcon") { + menuIcon.size = NSSize(width: 16, height: 16) + button.image = menuIcon + } else if let screenshotIcon = NSImage(systemSymbolName: "camera.aperture", accessibilityDescription: "ShotScreen") { + // Fallback to aperture icon if menu icon not available + screenshotIcon.size = NSSize(width: 16, height: 16) + button.image = screenshotIcon + } else { + // Final fallback to green dot + button.image = NSImage(named: "NSStatusAvailable") + } + + // Debug: Print all status bar items + debugPrintStatusBarItems() + + // Create a menu for the status item + let statusMenu = NSMenu() + + // Take Screenshot menu item with camera icon + let screenshotItem = NSMenuItem(title: "Take Screenshot", action: #selector(MenuManager.triggerScreenshot), keyEquivalent: "") + screenshotItem.target = self + if let cameraIcon = NSImage(systemSymbolName: "camera", accessibilityDescription: "Camera") { + cameraIcon.size = NSSize(width: 16, height: 16) + screenshotItem.image = cameraIcon + } + updateScreenshotShortcut(screenshotItem) // Set initial shortcut + statusMenu.addItem(screenshotItem) + + // NIEUW: Capture Whole Screen (Double Hotkey) menu item with monitor icon + let wholeScreenItem = NSMenuItem(title: "Capture This Screen", action: #selector(MenuManager.triggerWholeScreenCapture), keyEquivalent: "") + wholeScreenItem.target = self + if let monitorIcon = NSImage(systemSymbolName: "display", accessibilityDescription: "Monitor") { + monitorIcon.size = NSSize(width: 16, height: 16) + wholeScreenItem.image = monitorIcon + } + updateWholeScreenCaptureShortcut(wholeScreenItem) // Set initial shortcut + statusMenu.addItem(wholeScreenItem) + + // NIEUW: Capture All Screens menu item + let allScreensItem = NSMenuItem(title: "Capture All Screens", action: #selector(MenuManager.triggerAllScreensCapture), keyEquivalent: "") + allScreensItem.target = self + if let screensIcon = NSImage(systemSymbolName: "rectangle.3.group", accessibilityDescription: "All Screens") { + screensIcon.size = NSSize(width: 16, height: 16) + allScreensItem.image = screensIcon + } + updateAllScreensCaptureShortcut(allScreensItem) // Set initial shortcut + statusMenu.addItem(allScreensItem) + + // NIEUW: Simple Window Capture menu item (native macOS) + let windowCaptureItem = NSMenuItem(title: "Capture Window", action: #selector(MenuManager.triggerNativeWindowCapture), keyEquivalent: "") + windowCaptureItem.target = self + if let windowIcon = NSImage(systemSymbolName: "macwindow", accessibilityDescription: "Window") { + windowIcon.size = NSSize(width: 16, height: 16) + windowCaptureItem.image = windowIcon + } + updateWindowCaptureShortcut(windowCaptureItem) // Set initial shortcut display + statusMenu.addItem(windowCaptureItem) + + // Add separator between screenshot actions and other options + statusMenu.addItem(NSMenuItem.separator()) + + // NIEUW: Toggle Desktop Icons menu item + let hideIconsItem = NSMenuItem(title: "", action: #selector(MenuManager.toggleDesktopIcons), keyEquivalent: "") + hideIconsItem.target = self + updateDesktopIconsMenuItem(hideIconsItem) // Set initial title and icon + statusMenu.addItem(hideIconsItem) + print("โž• Desktop Icons menu item added with initial state") + + // NIEUW: Toggle Desktop Widgets menu item + let hideWidgetsItem = NSMenuItem(title: "", action: #selector(MenuManager.toggleDesktopWidgets), keyEquivalent: "") + hideWidgetsItem.target = self + updateDesktopWidgetsMenuItem(hideWidgetsItem) // Set initial title and icon + statusMenu.addItem(hideWidgetsItem) + print("โž• Desktop Widgets menu item added with initial state") + + // Add separator between screenshot actions and other options + statusMenu.addItem(NSMenuItem.separator()) + + // Show Stash menu item with folder icon + let stashItem = NSMenuItem(title: "Show Stash", action: #selector(MenuManager.showStash), keyEquivalent: "") + stashItem.target = self + if let folderIcon = NSImage(systemSymbolName: "folder", accessibilityDescription: "Folder") { + folderIcon.size = NSSize(width: 16, height: 16) + stashItem.image = folderIcon + } + statusMenu.addItem(stashItem) + + + + // ๐Ÿ”„ UPDATE: Check for Updates menu item with arrow icon + let updatesItem = NSMenuItem(title: "Check for Updates", action: #selector(MenuManager.checkForUpdates), keyEquivalent: "") + updatesItem.target = self + updatesItem.isEnabled = UpdateManager.shared.isUpdaterAvailable + if let updateIcon = NSImage(systemSymbolName: "arrow.clockwise.circle", accessibilityDescription: "Check for Updates") { + updateIcon.size = NSSize(width: 16, height: 16) + updatesItem.image = updateIcon + } + + + statusMenu.addItem(NSMenuItem.separator()) + + // NIEUW: Reset First Launch Wizard (for testing) - also in status bar menu + let resetWizardItem = NSMenuItem(title: "Reset First Launch Wizard", action: #selector(MenuManager.resetFirstLaunchWizard), keyEquivalent: "") + resetWizardItem.target = self + if let resetIcon = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Reset") { + resetIcon.size = NSSize(width: 16, height: 16) + resetWizardItem.image = resetIcon + } + statusMenu.addItem(resetWizardItem) + + statusMenu.addItem(NSMenuItem.separator()) + // Settings menu item with gear icon + let settingsItem = NSMenuItem(title: "Settings", action: #selector(MenuManager.openSettings), keyEquivalent: "") + settingsItem.target = self + if let gearIcon = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings") { + gearIcon.size = NSSize(width: 16, height: 16) + settingsItem.image = gearIcon + } + statusMenu.addItem(settingsItem) + // Quit menu item with power icon + let quitItem = NSMenuItem(title: "Quit ShotScreen", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "") + if let powerIcon = NSImage(systemSymbolName: "power", accessibilityDescription: "Quit") { + powerIcon.size = NSSize(width: 16, height: 16) + quitItem.image = powerIcon + } + statusMenu.addItem(quitItem) + + statusItem?.menu = statusMenu + } + } + + // MARK: - Menu Actions + @objc func triggerScreenshot() { + delegate?.triggerScreenshot() + } + + @objc func triggerWholeScreenCapture() { + delegate?.triggerWholeScreenCapture() + } + + @objc func triggerNativeWindowCapture() { + delegate?.triggerNativeWindowCapture() + } + + @objc func triggerAllScreensCapture() { + delegate?.triggerAllScreensCapture() + } + + @objc func openSettings() { + delegate?.openSettings() + } + + @objc func showStash() { + delegate?.showStash() + } + + @objc func resetFirstLaunchWizard() { + delegate?.resetFirstLaunchWizard() + } + + @objc func toggleDesktopIcons() { + delegate?.toggleDesktopIcons() + } + + @objc func toggleDesktopWidgets() { + delegate?.toggleDesktopWidgets() + } + + @objc func checkForUpdates() { + delegate?.checkForUpdates() + } + + // MARK: - Dynamic Version Helper + private func getDynamicAboutTitle() -> String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + return "ShotScreen v\(version)" + } + + @objc func showAbout() { + delegate?.showAbout() + } + + @objc func exitApp() { + delegate?.shouldTerminate = true + NSApplication.shared.terminate(self) + } + + // MARK: - Menu Item Updates + private func updateDesktopIconsMenuItem(_ menuItem: NSMenuItem) { + let isHidden = SettingsManager.shared.hideDesktopIconsDuringScreenshot + + if isHidden { + menuItem.title = "Show Desktop Icons" + // Try specific icon first, fallback to generic folder icon + if let icon = NSImage(systemSymbolName: "folder.badge.minus", accessibilityDescription: "Desktop Icons Hidden") { + icon.size = NSSize(width: 16, height: 16) + menuItem.image = icon + } else if let fallbackIcon = NSImage(systemSymbolName: "folder", accessibilityDescription: "Folder") { + fallbackIcon.size = NSSize(width: 16, height: 16) + menuItem.image = fallbackIcon + } else { + menuItem.image = nil + } + } else { + menuItem.title = "Hide Desktop Icons" + // Try specific icon first, fallback to generic folder icon + if let icon = NSImage(systemSymbolName: "folder.badge.plus", accessibilityDescription: "Desktop Icons Visible") { + icon.size = NSSize(width: 16, height: 16) + menuItem.image = icon + } else if let fallbackIcon = NSImage(systemSymbolName: "folder", accessibilityDescription: "Folder") { + fallbackIcon.size = NSSize(width: 16, height: 16) + menuItem.image = fallbackIcon + } else { + menuItem.image = nil + } + } + + print("๐Ÿ–ฅ๏ธ Desktop Icons menu item updated: '\(menuItem.title)' (Hidden: \(isHidden))") + } + + private func updateDesktopWidgetsMenuItem(_ menuItem: NSMenuItem) { + let isHidden = SettingsManager.shared.hideDesktopWidgetsDuringScreenshot + + if isHidden { + menuItem.title = "Show Desktop Widgets" + // Try specific widget icons first, fallback to simpler rectangle icon + if let icon = NSImage(systemSymbolName: "rectangle.3.group.bubble", accessibilityDescription: "Desktop Widgets Hidden") { + icon.size = NSSize(width: 16, height: 16) + menuItem.image = icon + } else if let fallbackIcon = NSImage(systemSymbolName: "rectangle.3.group", accessibilityDescription: "Widgets") { + fallbackIcon.size = NSSize(width: 16, height: 16) + menuItem.image = fallbackIcon + } else if let simpleIcon = NSImage(systemSymbolName: "rectangle.grid.2x2", accessibilityDescription: "Grid") { + simpleIcon.size = NSSize(width: 16, height: 16) + menuItem.image = simpleIcon + } else { + menuItem.image = nil + } + } else { + menuItem.title = "Hide Desktop Widgets" + // Try specific widget icons first, fallback to simpler rectangle icon + if let icon = NSImage(systemSymbolName: "rectangle.3.group.bubble.fill", accessibilityDescription: "Desktop Widgets Visible") { + icon.size = NSSize(width: 16, height: 16) + menuItem.image = icon + } else if let fallbackIcon = NSImage(systemSymbolName: "rectangle.3.group.fill", accessibilityDescription: "Widgets") { + fallbackIcon.size = NSSize(width: 16, height: 16) + menuItem.image = fallbackIcon + } else if let simpleIcon = NSImage(systemSymbolName: "rectangle.grid.2x2.fill", accessibilityDescription: "Grid") { + simpleIcon.size = NSSize(width: 16, height: 16) + menuItem.image = simpleIcon + } else { + menuItem.image = nil + } + } + + print("๐Ÿ“ฑ Desktop Widgets menu item updated: '\(menuItem.title)' (Hidden: \(isHidden))") + } + + // Public method to refresh menu when settings change + func refreshDesktopIconsMenuItem() { + print("๐Ÿ”„ Refreshing desktop icons menu item...") + if let menu = statusItem?.menu { + // Find the desktop icons menu item (it should be the one with our action) + var foundItem = false + for item in menu.items { + if item.action == #selector(MenuManager.toggleDesktopIcons) { + updateDesktopIconsMenuItem(item) + foundItem = true + break + } + } + if !foundItem { + print("โš ๏ธ Desktop icons menu item not found!") + } + } else { + print("โš ๏ธ Status item menu not available!") + } + } + + // Public method to refresh widgets menu when settings change + func refreshDesktopWidgetsMenuItem() { + print("๐Ÿ”„ Refreshing desktop widgets menu item...") + if let menu = statusItem?.menu { + // Find the desktop widgets menu item (it should be the one with our action) + var foundItem = false + for item in menu.items { + if item.action == #selector(MenuManager.toggleDesktopWidgets) { + updateDesktopWidgetsMenuItem(item) + foundItem = true + break + } + } + if !foundItem { + print("โš ๏ธ Desktop widgets menu item not found!") + } + } else { + print("โš ๏ธ Status item menu not available!") + } + } + + // MARK: - Settings Change Handlers + @objc private func handleDesktopIconsSettingChanged() { + print("๐Ÿ“ข Notification received: Desktop Icons setting changed") + DispatchQueue.main.async { [weak self] in + self?.refreshDesktopIconsMenuItem() + } + } + + @objc private func handleDesktopWidgetsSettingChanged() { + print("๐Ÿ“ข Notification received: Desktop Widgets setting changed") + DispatchQueue.main.async { [weak self] in + self?.refreshDesktopWidgetsMenuItem() + } + } + + // MARK: - Shortcut Management + @objc func updateShortcuts() { + // Update shortcuts in both main menu and status bar menu + updateMenuShortcuts() + } + + private func updateMenuShortcuts() { + // Update status bar menu shortcuts + if let menu = statusItem?.menu { + for item in menu.items { + if item.action == #selector(MenuManager.triggerScreenshot) { + updateScreenshotShortcut(item) + } else if item.action == #selector(MenuManager.triggerWholeScreenCapture) { + updateWholeScreenCaptureShortcut(item) + } else if item.action == #selector(MenuManager.triggerNativeWindowCapture) { + updateWindowCaptureShortcut(item) + } else if item.action == #selector(MenuManager.triggerAllScreensCapture) { + updateAllScreensCaptureShortcut(item) + } + } + } + + // Update main menu shortcuts if needed + updateMainMenuShortcuts() + } + + private func updateMainMenuShortcuts() { + // Update shortcuts in the main application menu + if let mainMenu = NSApplication.shared.mainMenu { + // Find the application menu (first submenu) + if let appMenuItem = mainMenu.items.first, + let appSubmenu = appMenuItem.submenu { + for item in appSubmenu.items { + if item.action == #selector(MenuManager.openSettings) { + // Settings already has "," shortcut, keep it + break + } + } + } + } + } + + private func updateScreenshotShortcut(_ menuItem: NSMenuItem) { + let settings = SettingsManager.shared + + if settings.useCustomShortcut && settings.customShortcutModifiers != 0 && settings.customShortcutKey != 0 { + // Use custom shortcut - display only, same as others + let shortcutText = getShortcutDisplay(modifiers: settings.customShortcutModifiers, keyCode: settings.customShortcutKey) + setMenuItemWithRightAlignedShortcut(menuItem, title: "Take Area Screenshot", shortcut: shortcutText) + } else { + // Use default Cmd+Shift+4 - display only, positioned slightly to the left + setMenuItemWithRightAlignedShortcut(menuItem, title: "Take Area Screenshot", shortcut: "โŒ˜โ‡ง4") + } + + // Clear actual key equivalents since these are display-only + menuItem.keyEquivalent = "" + menuItem.keyEquivalentModifierMask = [] + } + + private func updateWholeScreenCaptureShortcut(_ menuItem: NSMenuItem) { + let settings = SettingsManager.shared + let baseShortcut: String + + if settings.useCustomShortcut && settings.customShortcutModifiers != 0 && settings.customShortcutKey != 0 { + // Use custom shortcut + baseShortcut = getShortcutDisplay(modifiers: settings.customShortcutModifiers, keyCode: settings.customShortcutKey) + } else { + // Use default shortcut + baseShortcut = "โŒ˜โ‡ง4" + } + + // Display shortcut as: 2 x [shortcut] (double hotkey for whole screen) + setMenuItemWithRightAlignedShortcut(menuItem, title: "Capture Screen", shortcut: "2 x \(baseShortcut)") + + // Clear actual key equivalents since these are display-only + menuItem.keyEquivalent = "" + menuItem.keyEquivalentModifierMask = [] + } + + private func updateWindowCaptureShortcut(_ menuItem: NSMenuItem) { + let settings = SettingsManager.shared + let baseShortcut: String + + if settings.useCustomShortcut && settings.customShortcutModifiers != 0 && settings.customShortcutKey != 0 { + // Use custom shortcut + baseShortcut = getShortcutDisplay(modifiers: settings.customShortcutModifiers, keyCode: settings.customShortcutKey) + } else { + // Use default shortcut + baseShortcut = "โŒ˜โ‡ง4" + } + + // Display shortcut as: [shortcut] โ†’ โฃ (indicating spacebar during selection) + setMenuItemWithRightAlignedShortcut(menuItem, title: "Capture Window", shortcut: "\(baseShortcut) โ†’ โฃ") + + // Clear actual key equivalents since these are display-only + menuItem.keyEquivalent = "" + menuItem.keyEquivalentModifierMask = [] + } + + private func updateAllScreensCaptureShortcut(_ menuItem: NSMenuItem) { + let settings = SettingsManager.shared + let baseShortcut: String + + if settings.useCustomShortcut && settings.customShortcutModifiers != 0 && settings.customShortcutKey != 0 { + // Use custom shortcut + baseShortcut = getShortcutDisplay(modifiers: settings.customShortcutModifiers, keyCode: settings.customShortcutKey) + } else { + // Use default shortcut + baseShortcut = "โŒ˜โ‡ง4" + } + + // Triple hotkey for all screens capture + setMenuItemWithRightAlignedShortcut(menuItem, title: "Capture All Screens", shortcut: "3 x \(baseShortcut)") + + // Clear actual key equivalents since these are display-only + menuItem.keyEquivalent = "" + menuItem.keyEquivalentModifierMask = [] + } + + private func setMenuItemWithRightAlignedShortcut(_ menuItem: NSMenuItem, title: String, shortcut: String) { + // Create attributed string with proper tab stop for right alignment + let attributedTitle = NSMutableAttributedString() + + // Create paragraph style with right-aligned tab stop + let paragraphStyle = NSMutableParagraphStyle() + + // Calculate the right tab stop position (approximately 250 points from left margin) + // This gives us consistent right alignment for all shortcuts + let tabStopPosition: CGFloat = 250.0 + + // Create a right-aligned tab stop + let tabStop = NSTextTab(textAlignment: .right, location: tabStopPosition, options: [:]) + paragraphStyle.tabStops = [tabStop] + + // Add the main title with normal styling + let titleString = NSAttributedString(string: title, attributes: [ + .font: NSFont.menuFont(ofSize: 0), + .paragraphStyle: paragraphStyle + ]) + attributedTitle.append(titleString) + + // Add tab character to move to the tab stop + let tabString = NSAttributedString(string: "\t", attributes: [ + .paragraphStyle: paragraphStyle + ]) + attributedTitle.append(tabString) + + // Add the shortcut with different styling at the tab stop (right-aligned) + let shortcutString = NSAttributedString(string: shortcut, attributes: [ + .foregroundColor: NSColor.secondaryLabelColor, + .font: NSFont.menuFont(ofSize: 0), + .paragraphStyle: paragraphStyle + ]) + attributedTitle.append(shortcutString) + + menuItem.attributedTitle = attributedTitle + } + + private func getShortcutDisplay(modifiers: UInt, keyCode: UInt16) -> String { + var result = "" + + // Add modifier symbols in correct order: Control, Option, Shift, Command + if modifiers & (1 << 3) != 0 { result += "โŒƒ" } // Control + if modifiers & (1 << 2) != 0 { result += "โŒฅ" } // Option + if modifiers & (1 << 1) != 0 { result += "โ‡ง" } // Shift + if modifiers & (1 << 0) != 0 { result += "โŒ˜" } // Command + + let keyString = keyCodeToDisplayString(keyCode) + result += keyString + + return result + } + + private func keyCodeToDisplayString(_ keyCode: UInt16) -> String { + // Convert keyCode to display string (uppercase for better readability) + 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 "`" + default: return "" + } + } + + private func getModifierSymbols(_ modifier: UInt) -> String { + var symbols: [String] = [] + + if modifier & (1 << 3) != 0 { symbols.append("โŒƒ") } // Control + if modifier & (1 << 2) != 0 { symbols.append("โฃ") } // Option (now Space symbol) + if modifier & (1 << 1) != 0 { symbols.append("โ‡ง") } // Shift + if modifier & (1 << 0) != 0 { symbols.append("โŒ˜") } // Command + + return symbols.joined() + } + + // MARK: - License Management + func setAppEnabled(_ enabled: Bool) { + print("๐Ÿ” LICENSE: Setting app menu enabled state to: \(enabled)") + + guard let menu = statusItem?.menu else { + print("โš ๏ธ LICENSE: Status item menu not available!") + return + } + + // Disable/enable screenshot-related menu items + for item in menu.items { + if item.action == #selector(MenuManager.triggerScreenshot) || + item.action == #selector(MenuManager.triggerWholeScreenCapture) || + item.action == #selector(MenuManager.triggerNativeWindowCapture) || + item.action == #selector(MenuManager.triggerAllScreensCapture) || + item.action == #selector(MenuManager.showStash) || + item.action == #selector(MenuManager.toggleDesktopIcons) || + item.action == #selector(MenuManager.toggleDesktopWidgets) { + item.isEnabled = enabled + } + // Keep Settings, Updates, About, and Quit always enabled + } + + // Update menu icon to indicate license status + if !enabled { + // Show different icon for trial/expired - use lock icon + if let lockIcon = NSImage(systemSymbolName: "lock.fill", accessibilityDescription: "License Required") { + lockIcon.size = NSSize(width: 16, height: 16) + statusItem?.button?.image = lockIcon + } + statusItem?.button?.toolTip = "ShotScreen - License Required" + } else { + // Restore normal menu icon for licensed + if let bundle = Bundle.main.url(forResource: "MenuIcon", withExtension: "png", subdirectory: "images"), + let menuIcon = NSImage(contentsOf: bundle) { + menuIcon.size = NSSize(width: 16, height: 16) + statusItem?.button?.image = menuIcon + } else if let menuIcon = NSImage(named: "MenuIcon") { + menuIcon.size = NSSize(width: 16, height: 16) + statusItem?.button?.image = menuIcon + } else if let screenshotIcon = NSImage(systemSymbolName: "camera.aperture", accessibilityDescription: "ShotScreen") { + screenshotIcon.size = NSSize(width: 16, height: 16) + statusItem?.button?.image = screenshotIcon + } + statusItem?.button?.toolTip = "ShotScreen" + } + + // Clear any title to prevent duplicate icons + statusItem?.button?.title = "" + } + + // MARK: - Cleanup + func cleanup() { + print("๐Ÿงน MenuManager: Cleaning up...") + NotificationCenter.default.removeObserver(self) + + if let item = statusItem { + NSStatusBar.system.removeStatusItem(item) + print("๐Ÿงน STATUS: Removed status bar item successfully") + } + statusItem = nil + } + + deinit { + print("๐Ÿ—‘๏ธ MenuManager: Deinitializing and cleaning up") + cleanup() + } + + private func debugPrintStatusBarItems() { + print("๐Ÿ” DEBUG: Checking all status bar items...") + + // Try to get information about other status bar items + let statusBar = NSStatusBar.system + print("๐Ÿ” DEBUG: System status bar available: \(statusBar)") + + // Check if there might be multiple ShotScreen processes running + let runningApps = NSWorkspace.shared.runningApplications + let shotScreenApps = runningApps.filter { $0.bundleIdentifier?.contains("ShotScreen") == true || $0.localizedName?.contains("ShotScreen") == true } + + print("๐Ÿ” DEBUG: Found \(shotScreenApps.count) ShotScreen-related processes:") + for app in shotScreenApps { + print(" - \(app.localizedName ?? "Unknown") (PID: \(app.processIdentifier))") + } + + // Check for screenshot-related apps + let screenshotApps = runningApps.filter { + let name = $0.localizedName?.lowercased() ?? "" + let bundle = $0.bundleIdentifier?.lowercased() ?? "" + return name.contains("screenshot") || name.contains("capture") || name.contains("screen") || + bundle.contains("screenshot") || bundle.contains("capture") || bundle.contains("screen") + } + + print("๐Ÿ” DEBUG: Found \(screenshotApps.count) screenshot/capture-related apps:") + for app in screenshotApps { + print(" - \(app.localizedName ?? "Unknown"): \(app.bundleIdentifier ?? "No Bundle ID")") + } + } +} + +// MARK: - MenuManager Delegate Protocol +protocol MenuManagerDelegate: AnyObject { + func triggerScreenshot() + func triggerWholeScreenCapture() + func triggerNativeWindowCapture() + func triggerAllScreensCapture() + func openSettings() + func showStash() + func resetFirstLaunchWizard() + func toggleDesktopIcons() + func toggleDesktopWidgets() + func checkForUpdates() + func showAbout() + var shouldTerminate: Bool { get set } +} \ No newline at end of file diff --git a/ShotScreen/Sources/MultiMonitorSystem.swift b/ShotScreen/Sources/MultiMonitorSystem.swift new file mode 100644 index 0000000..458c2c9 --- /dev/null +++ b/ShotScreen/Sources/MultiMonitorSystem.swift @@ -0,0 +1,1263 @@ +import AppKit +import SwiftUI + +// MARK: - Multi-Monitor Screenshot System +extension ScreenshotApp { + + // MARK: - Global Mouse Tracking Setup + func setupGlobalMouseTracking() { + // Global monitors (for events outside our app) + globalMouseDownMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in + self?.handleGlobalMouseDown(event) + } + + globalMouseDragMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDragged]) { [weak self] event in + self?.handleGlobalMouseDragged(event) + } + + globalMouseUpMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseUp]) { [weak self] event in + self?.handleGlobalMouseUp(event) + } + + // Local monitors (for events within our app) - these can consume events + localMouseDownMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in + guard let self = self else { return event } + + if self.isMultiMonitorSelectionActive && !self.isDragging { + let globalLocation = NSEvent.mouseLocation + + // SET MOUSE TRACKING VARIABLES FOR SINGLE CLICK DETECTION + let allScreenModifier: UInt = (1 << 0) // Command key + self.isAllScreenModifierPressed = self.isModifierPressed(event.modifierFlags, modifier: allScreenModifier) + self.mouseDownLocation = globalLocation + self.mouseDownTime = CACurrentMediaTime() + self.hasMouseMoved = false + + let modifierName = self.getModifierName(allScreenModifier) + print("๐ŸŽฏ Local mouse down - starting selection at: \(globalLocation), \(modifierName): \(self.isAllScreenModifierPressed)") + self.startDragSelection(at: globalLocation) + return nil // Consume the event to prevent other apps from processing it + } + + return event // Let other apps handle it normally + } + + localMouseDragMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDragged]) { [weak self] event in + guard let self = self else { return event } + + if self.isMultiMonitorSelectionActive && self.isDragging { + let globalLocation = NSEvent.mouseLocation + self.hasMouseMoved = true // MARK THAT MOUSE HAS MOVED + self.updateDragSelection(to: globalLocation) + return nil // LOCAL monitors return NSEvent? + } + + return event + } + + localMouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseUp]) { [weak self] event in + guard let self = self else { return event } + + if self.isMultiMonitorSelectionActive && self.isDragging { + let globalLocation = NSEvent.mouseLocation + + // CHECK FOR SINGLE CLICK FIRST + let timeSinceMouseDown = CACurrentMediaTime() - self.mouseDownTime + let distanceMoved = sqrt(pow(globalLocation.x - self.mouseDownLocation.x, 2) + pow(globalLocation.y - self.mouseDownLocation.y, 2)) + + print("๐ŸŽฏ Local mouse up at: \(globalLocation)") + print("๐ŸŽฏ Time since down: \(timeSinceMouseDown)s, Distance: \(distanceMoved)px, HasMouseMoved: \(self.hasMouseMoved)") + print("๐ŸŽฏ isAllScreensCaptureToggledOn: \(self.isAllScreensCaptureToggledOn), isAllScreenModifierPressed (at mouseDown): \(self.isAllScreenModifierPressed), isWindowCaptureMode: \(self.isWindowCaptureMode)") + + // Was it a click (not a drag)? + let isClick = !self.hasMouseMoved && distanceMoved < 5.0 && timeSinceMouseDown < 0.5 + print("๐ŸŽฏ Evaluated isClick: \(isClick)") + + if isClick { + // 1. If "all screens" mode is GETOGGLED ON + if self.isAllScreensCaptureToggledOn { + print("๐ŸŽฏ Condition MET: isAllScreensCaptureToggledOn is TRUE.") + print("๐ŸŽฏ Local Click detected (toggle ON) - capturing all screens") + self.deactivateMultiMonitorSelection() + self.captureAllScreens() // captureAllScreens resets de toggle + self.resetTrackingVariables() + return event // Return event since this is a local monitor + } + // 2. If it was a single click (no toggle, no CMD modifier pressed) + else if !self.isAllScreenModifierPressed { + // IMPORTANT: Don't capture screen if we're in window capture mode + if self.isWindowCaptureMode { + print("๐ŸŽฏ Condition SKIPPED (isAllScreensCaptureToggledOn=false): Single click in window capture mode - ignoring screen capture") + return event // Let event continue in window capture mode + } + + print("๐ŸŽฏ Condition MET (isAllScreensCaptureToggledOn=false, !isAllScreenModifierPressed): Single click detected (toggle OFF) - capturing current screen") + self.deactivateMultiMonitorSelection() + self.captureCurrentScreen(at: globalLocation) + self.resetTrackingVariables() + return nil // Consume event + } + // 3. If it was CMD+click (modifier held, setting enabled) + else if self.isAllScreenModifierPressed { + let allScreenModifier: UInt = (1 << 0) // Command key + let modifierName = self.getModifierName(allScreenModifier) + print("๐ŸŽฏ Condition MET (isAllScreensCaptureToggledOn=false, isAllScreenModifierPressed): \(modifierName)+click detected - capturing all screens") + self.deactivateMultiMonitorSelection() + self.captureAllScreens() + self.resetTrackingVariables() + return nil // Consume event + } + else { + print("๐ŸŽฏ Condition NOT MET for any click type when isClick=true. isAllScreensCaptureToggledOn=\(self.isAllScreensCaptureToggledOn), isAllScreenModifierPressed=\(self.isAllScreenModifierPressed)") + } + } + else { + print("๐ŸŽฏ Evaluated isClick as FALSE. Proceeding with drag logic or unhandled mouse up.") + } + + // If it wasn't a click handled above, or it was a drag completion + print("๐ŸŽฏ Local mouse up - ending drag selection normally or unhandled click.") + self.endDragSelection(at: globalLocation) + // For drag completion, endDragSelection handles deactivation and capture. + // We can return nil here as well, as endDragSelection is the final action for a drag. + // Or return event if subsequent handlers (like EventCaptureView.mouseUp) are desired for drag completion. + // Let's return nil to make localMouseUpMonitor authoritative for drags too. + return nil + } + + return event + } + + // ESC key monitor for canceling selection + NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in + if event.keyCode == 53 { // ESC key + if self?.isMultiMonitorSelectionActive == true { + print("โŒจ๏ธ ESC pressed - canceling multi-monitor selection") + self?.cancelMultiMonitorSelection() + return nil // LOCAL monitors return NSEvent? + } + } + return event + } + + print("๐ŸŽฏ Global mouse tracking setup complete") + } + + // MARK: - Global Mouse Event Handlers + func handleGlobalMouseDown(_ event: NSEvent) { + // Only start selection if multi-monitor selection mode is active and we're not already dragging + guard isMultiMonitorSelectionActive && !isDragging else { return } + + let globalLocation = NSEvent.mouseLocation + + print("โœ… Starting selection at: \(globalLocation)") + // Start drag selection at click location + startDragSelection(at: globalLocation) + } + + func handleGlobalMouseDragged(_ event: NSEvent) { + guard isMultiMonitorSelectionActive && isDragging else { return } + + let globalLocation = NSEvent.mouseLocation + + updateDragSelection(to: globalLocation) + } + + func handleGlobalMouseUp(_ event: NSEvent) { + guard isMultiMonitorSelectionActive && isDragging else { return } + + let globalLocation = NSEvent.mouseLocation + endDragSelection(at: globalLocation) + } + + // MARK: - Global Event Monitors + func setupGlobalKeyMonitor() { + // Remove existing monitor if any + removeGlobalKeyMonitor() + + // Add global key monitor for ESC key and custom modifier keys + globalKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in + guard let self = self else { return } + + // Handle ESC key only during multi-monitor selection + if event.type == .keyDown && event.keyCode == 53 { // ESC key + if self.isMultiMonitorSelectionActive { + print("โŒจ๏ธ Global ESC key detected, canceling selection") + self.cancelMultiMonitorSelection() + } + return + } + + // Handle custom modifier keys for window capture mode toggle - ALWAYS active during screenshot mode + if event.type == .flagsChanged && self.isMultiMonitorSelectionActive { + let currentFlags = event.modifierFlags + let previousFlags = self.lastKnownModifierFlags + + // --- Command Toggle Logic (Uses lastKnownModifierFlags) --- + let commandFlag = NSEvent.ModifierFlags.command + if currentFlags.contains(commandFlag) && !previousFlags.contains(commandFlag) { + print("โŒจ๏ธ Command key JUST PRESSED (via flagsChanged) - Toggling All Screens Mode.") + self.toggleAllScreensCaptureMode() + } + // BELANGRIJK: De Command-toggle is nu onafhankelijk en zou de Option-logica niet moeten blokkeren. + + // --- Window Capture Logic removed (simplification) --- + // Window capture functionality is now handled through spacebar during drag selection + self.lastKnownModifierFlags = currentFlags // Update voor de volgende Command-toggle check + } + } + print("โŒจ๏ธ Global key monitor setup for ESC and custom modifier keys") + + // Also setup global mouse monitor to intercept ALL mouse events + setupGlobalMouseMonitor() + } + + func setupGlobalMouseMonitor() { + // Remove existing monitor if any + removeGlobalMouseMonitor() + + // IMPORTANT: Don't setup mouse monitors if we're in window capture mode + // Let the WindowCaptureManager handle mouse events instead + if isWindowCaptureMode { + print("๐ŸŽฏ Skipping global mouse monitor setup - in window capture mode") + return + } + + // Add BOTH global and local monitors for maximum coverage + let globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [ + .leftMouseDown, .leftMouseUp, .leftMouseDragged, + .rightMouseDown, .rightMouseUp, .rightMouseDragged, + .otherMouseDown, .otherMouseUp, .otherMouseDragged, + .mouseMoved, .mouseEntered, .mouseExited, .scrollWheel + ]) { [weak self] event in + guard let self = self, self.isMultiMonitorSelectionActive else { return } + + // IMPORTANT: Don't handle mouse events if in window capture mode + if self.isWindowCaptureMode { + return + } + + print("๐ŸŽฏ Global monitor intercepted: \(event.type.rawValue)") + // During selection, intercept and handle all mouse events ourselves + let globalLocation = NSEvent.mouseLocation + + switch event.type { + case .leftMouseDown: + if !self.isDragging { + // Check if Command key is pressed for all-screen capture + let allScreenModifier: UInt = (1 << 0) // Command key + self.isAllScreenModifierPressed = self.isModifierPressed(event.modifierFlags, modifier: allScreenModifier) + + self.mouseDownLocation = globalLocation + self.mouseDownTime = CACurrentMediaTime() + self.hasMouseMoved = false + + let modifierName = self.getModifierName(allScreenModifier) + print("๐ŸŽฏ Global mouse monitor - mouse down at: \(globalLocation), \(modifierName): \(self.isAllScreenModifierPressed)") + + // If all-screen modifier+click and setting is enabled, capture all screens immediately + if self.isAllScreenModifierPressed { + print("๐ŸŽฏ \(modifierName)+click detected - capturing all screens") + self.deactivateMultiMonitorSelection() + self.captureAllScreens() + self.resetTrackingVariables() // RESET for clean state + return + } + + // Otherwise start potential drag selection + self.startDragSelection(at: globalLocation) + } + case .leftMouseDragged: + if self.isDragging { + self.hasMouseMoved = true + self.updateDragSelection(to: globalLocation) + } + case .leftMouseUp: + if self.isDragging { + let timeSinceMouseDown = CACurrentMediaTime() - self.mouseDownTime + let distanceMoved = sqrt(pow(globalLocation.x - self.mouseDownLocation.x, 2) + pow(globalLocation.y - self.mouseDownLocation.y, 2)) + + print("๐ŸŽฏ Global mouse monitor - mouse up at: \(globalLocation)") + print("๐ŸŽฏ Time since down: \(timeSinceMouseDown)s, distance: \(distanceMoved)px, moved: \(self.hasMouseMoved)") + + // If it was a single click (no significant movement and quick) + if !self.hasMouseMoved && distanceMoved < 5.0 && timeSinceMouseDown < 0.5 && !self.isAllScreenModifierPressed { + // IMPORTANT: Don't capture screen if we're in window capture mode + if self.isWindowCaptureMode { + print("๐ŸŽฏ Single click in window capture mode - ignoring screen capture") + return // Global monitors return Void + } + + print("๐ŸŽฏ Single click detected - capturing current screen") + self.deactivateMultiMonitorSelection() + self.captureCurrentScreen(at: globalLocation) + self.resetTrackingVariables() // RESET for clean state + return // Global monitors return Void + } + + // Otherwise end drag selection normally + print("๐ŸŽฏ Global mouse monitor - ending drag selection at: \(globalLocation)") + self.endDragSelection(at: globalLocation) + } + // Global monitors return Void + case .rightMouseDown, .rightMouseUp: + print("๐ŸŽฏ Global mouse monitor - right-click, canceling selection") + self.cancelMultiMonitorSelection() + default: + // For all other mouse events during selection, just consume them + break + } + } + + // Add LOCAL monitor to intercept events within our app and BLOCK them from propagating + let localMonitor = NSEvent.addLocalMonitorForEvents(matching: [ + .leftMouseDown, .leftMouseUp, .leftMouseDragged, + .rightMouseDown, .rightMouseUp, .rightMouseDragged, + .otherMouseDown, .otherMouseUp, .otherMouseDragged, + .mouseMoved, .mouseEntered, .mouseExited, .scrollWheel + ]) { [weak self] event in + guard let self = self, self.isMultiMonitorSelectionActive else { return event } + + // Only print for non-mouseMoved events to reduce spam + if event.type != .mouseMoved { + print("๐ŸŽฏ Local monitor intercepted: \(event.type.rawValue)") + } + // During selection, handle the event ourselves and RETURN NIL to block propagation + let globalLocation = NSEvent.mouseLocation + + switch event.type { + case .leftMouseDown: + if !self.isDragging { + // Check if Command key is pressed for all-screen capture + let allScreenModifier: UInt = (1 << 0) // Command key + self.isAllScreenModifierPressed = self.isModifierPressed(event.modifierFlags, modifier: allScreenModifier) + + self.mouseDownLocation = globalLocation + self.mouseDownTime = CACurrentMediaTime() + self.hasMouseMoved = false + + let modifierName = self.getModifierName(allScreenModifier) + print("๐ŸŽฏ Local mouse monitor - mouse down at: \(globalLocation), \(modifierName): \(self.isAllScreenModifierPressed)") + + // If all-screen modifier+click and setting is enabled, capture all screens immediately + if self.isAllScreenModifierPressed { + print("๐ŸŽฏ \(modifierName)+click detected - capturing all screens") + self.deactivateMultiMonitorSelection() + self.captureAllScreens() + self.resetTrackingVariables() // RESET for clean state + return nil + } + + // Otherwise start potential drag selection + self.startDragSelection(at: globalLocation) + } + return nil // BLOCK the event from propagating + case .leftMouseDragged: + if self.isDragging { + self.hasMouseMoved = true + self.updateDragSelection(to: globalLocation) + } + return nil // BLOCK the event from propagating + case .leftMouseUp: + if self.isDragging { + let timeSinceMouseDown = CACurrentMediaTime() - self.mouseDownTime + let distanceMoved = sqrt(pow(globalLocation.x - self.mouseDownLocation.x, 2) + pow(globalLocation.y - self.mouseDownLocation.y, 2)) + + print("๐ŸŽฏ Local mouse monitor - mouse up at: \(globalLocation)") + print("๐ŸŽฏ Time since down: \(timeSinceMouseDown)s, distance: \(distanceMoved)px, moved: \(self.hasMouseMoved)") + + // If it was a single click (no significant movement and quick) + if !self.hasMouseMoved && distanceMoved < 5.0 && timeSinceMouseDown < 0.5 && !self.isAllScreenModifierPressed { + // IMPORTANT: Don't capture screen if we're in window capture mode + if self.isWindowCaptureMode { + print("๐ŸŽฏ Single click in window capture mode - ignoring screen capture") + return event // Let event continue in window capture mode + } + + print("๐ŸŽฏ Single click detected - capturing current screen") + self.deactivateMultiMonitorSelection() + self.captureCurrentScreen(at: globalLocation) + self.resetTrackingVariables() // RESET for clean state + return event // Let event pass through after reset + } + + // Otherwise end drag selection normally + print("๐ŸŽฏ Local mouse monitor - ending drag selection at: \(globalLocation)") + self.endDragSelection(at: globalLocation) + } + return nil // BLOCK the event from propagating + case .rightMouseDown, .rightMouseUp: + print("๐ŸŽฏ Local mouse monitor - right-click, canceling selection") + self.cancelMultiMonitorSelection() + return nil // BLOCK the event from propagating + case .mouseMoved: + // ๐Ÿ”ง FIX: Let mouseMoved events pass through for crosshair tracking + return event + default: + // For all other mouse events during selection, BLOCK them + return nil + } + } + + // Store both monitors + globalMouseMonitor = [globalMonitor as Any, localMonitor as Any] + print("๐ŸŽฏ Both global and local mouse monitors setup to intercept all mouse events") + } + + func removeGlobalKeyMonitor() { + if let monitor = globalKeyMonitor { + NSEvent.removeMonitor(monitor) + globalKeyMonitor = nil + print("โŒจ๏ธ Global key monitor removed") + } + removeGlobalMouseMonitor() + } + + func removeGlobalMouseMonitor() { + if let monitors = globalMouseMonitor { + if let monitorArray = monitors as? [Any] { + // Multiple monitors stored as array + for monitor in monitorArray { + NSEvent.removeMonitor(monitor) + } + print("๐ŸŽฏ Both global and local mouse monitors removed") + } else { + // Single monitor (fallback) + NSEvent.removeMonitor(monitors) + print("๐ŸŽฏ Single mouse monitor removed") + } + globalMouseMonitor = nil + } + } + + // MARK: - Custom Crosshair Overlay System + func hideCursor() { + // Try to hide cursor using CGDisplayHideCursor (may require private API) + // Alternative: use invisible cursor + let invisibleCursor = NSCursor() + invisibleCursor.set() + print("๐Ÿ™ˆ System cursor hidden") + } + + func showCursor() { + // Restore normal cursor + NSCursor.arrow.set() + print("๐Ÿ‘๏ธ System cursor restored") + } + + func setupCustomCrosshairOverlay() { + // Remove existing crosshair windows + removeCustomCrosshairOverlay() + + // Create crosshair windows for each screen + for screen in NSScreen.screens { + let crosshairWindow = createCrosshairWindow(for: screen) + crosshairWindows.append(crosshairWindow) + } + + // Setup mouse tracking to update crosshair position + crosshairTrackingMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged]) { [weak self] event in + self?.updateCrosshairPosition() + } + + // Also add local monitor for events within our app + let localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged]) { [weak self] event in + self?.updateCrosshairPosition() + return event + } + + if let localMonitor = localMonitor { + // Store both monitors (we'll need to clean up both) + crosshairTrackingMonitor = [crosshairTrackingMonitor as Any, localMonitor] + } + + // IMPORTANT: Set initial crosshair position to current mouse location + updateCrosshairPosition() + + print("๐ŸŽฏ Custom crosshair overlay setup for all screens") + } + + func createCrosshairWindow(for screen: NSScreen) -> NSWindow { + let window = NSWindow( + contentRect: screen.frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + + window.backgroundColor = NSColor.clear + window.isOpaque = false + window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.screenSaverWindow)) + 2) + window.ignoresMouseEvents = true + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + + // Create crosshair view + let crosshairView = CrosshairView(frame: window.contentView!.bounds) + window.contentView = crosshairView + + window.orderFront(nil) + return window + } + + func updateCrosshairPosition() { + guard isMultiMonitorSelectionActive else { return } + + let mouseLocation = NSEvent.mouseLocation + + for window in crosshairWindows { + if let crosshairView = window.contentView as? CrosshairView { + // Convert global mouse location to window coordinates + let windowLocation = window.convertPoint(fromScreen: mouseLocation) + crosshairView.updateCrosshairPosition(windowLocation) + } + } + } + + func removeCustomCrosshairOverlay() { + // Remove all crosshair windows + for window in crosshairWindows { + window.orderOut(nil) + } + crosshairWindows.removeAll() + + // Remove mouse tracking monitor + if let monitor = crosshairTrackingMonitor { + if let monitorArray = monitor as? [Any] { + // Multiple monitors stored as array + for mon in monitorArray { + NSEvent.removeMonitor(mon) + } + } else { + // Single monitor + NSEvent.removeMonitor(monitor) + } + crosshairTrackingMonitor = nil + } + + print("๐ŸŽฏ Custom crosshair overlay removed") + } + + // MARK: - Multi-Monitor Screenshot Selection Logic + func startDragSelection(at point: NSPoint) { + isDragging = true + startPoint = point + currentEndPoint = point + + print("๐Ÿ–ฑ๏ธ Start multi-monitor drag selection at: \(point)") + + // Create initial overlay windows + createOverlayWindows(from: startPoint, to: currentEndPoint) + } + + func updateDragSelection(to point: NSPoint) { + guard isDragging else { return } + + // Throttle updates for performance + let currentTime = CACurrentMediaTime() + guard currentTime - lastUpdateTime >= updateThrottle else { return } + lastUpdateTime = currentTime + + currentEndPoint = point + + // Update overlay windows to show current selection + updateOverlayWindows(from: startPoint, to: currentEndPoint) + } + + func endDragSelection(at point: NSPoint) { + guard isDragging else { return } + + isDragging = false + currentEndPoint = point + + print("๐Ÿ–ฑ๏ธ End multi-monitor drag selection at: \(point)") + + // Calculate final selection rectangle + let selectionRect = calculateSelectionRect(from: startPoint, to: currentEndPoint) + + // Hide overlay windows + hideMultiMonitorOverlayWindows() + + // Deactivate selection mode + deactivateMultiMonitorSelection() + + // Only capture if selection is meaningful (at least 2x2 pixels) + if selectionRect.width >= 2 && selectionRect.height >= 2 { + print("๐Ÿ“ธ Capturing multi-monitor screenshot of area: \(selectionRect)") + captureMultiMonitorScreenshot(in: selectionRect) + } else { + print("โŒ Selection too small, canceling multi-monitor screenshot") + } + } + + func cancelMultiMonitorSelection() { + isDragging = false + isMultiMonitorSelectionActive = false + isWindowCaptureMode = false // Reset window capture mode + + // Hide overlay windows + hideMultiMonitorOverlayWindows() + + // Remove event capture window + removeEventCaptureWindow() + + // Remove global key monitor + removeGlobalKeyMonitor() + + // Remove custom crosshair overlay + removeCustomCrosshairOverlay() + + // Show cursor again + showCursor() + + // Reset tracking variables for clean state + resetTrackingVariables() + + // Deactivate window capture if active + if #available(macOS 12.3, *) { + windowCaptureManager?.deactivateWindowSelectionMode() + } + + print("โŒ Multi-monitor selection canceled") + } + + func deactivateMultiMonitorSelection() { + isMultiMonitorSelectionActive = false + isWindowCaptureMode = false // Reset window capture mode + + // Remove event capture window + removeEventCaptureWindow() + + // Remove global key monitor + removeGlobalKeyMonitor() + + // Remove custom crosshair overlay + removeCustomCrosshairOverlay() + + // Show cursor again + showCursor() + + // Reset tracking variables for clean state + resetTrackingVariables() + + // Deactivate window capture if active + if #available(macOS 12.3, *) { + windowCaptureManager?.deactivateWindowSelectionMode() + } + + print("๐Ÿ”„ Multi-monitor selection mode deactivated") + } + + func showSelectionModeNotification() { + // Create a temporary notification window + let notificationWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 80), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + + notificationWindow.backgroundColor = NSColor.clear + notificationWindow.isOpaque = false + notificationWindow.level = .floating + notificationWindow.ignoresMouseEvents = true + + // Create notification view + let notificationView = NSView(frame: notificationWindow.frame) + notificationView.wantsLayer = true + + // Background with blur effect + let blurView = NSVisualEffectView(frame: notificationView.bounds) + blurView.blendingMode = NSVisualEffectView.BlendingMode.behindWindow + blurView.material = NSVisualEffectView.Material.hudWindow + blurView.state = NSVisualEffectView.State.active + blurView.layer?.cornerRadius = 12 + blurView.layer?.masksToBounds = true + notificationView.addSubview(blurView) + + // Text label + let label = NSTextField(labelWithString: "๐Ÿ“ธ Multi-Monitor Screenshot Mode\nClick on empty desktop space to start") + label.font = NSFont.systemFont(ofSize: 13, weight: .medium) + label.textColor = .white + label.alignment = .center + label.frame = NSRect(x: 20, y: 20, width: 260, height: 40) + notificationView.addSubview(label) + + notificationWindow.contentView = notificationView + + // Position at top center of main screen + if let mainScreen = NSScreen.main { + let screenFrame = mainScreen.frame + let x = screenFrame.midX - notificationWindow.frame.width / 2 + let y = screenFrame.maxY - 100 + notificationWindow.setFrameOrigin(NSPoint(x: x, y: y)) + } + + // Show with animation + notificationWindow.alphaValue = 0 + notificationWindow.orderFront(nil) + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + notificationWindow.animator().alphaValue = 1.0 + } + + // Auto-hide after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.3 + notificationWindow.animator().alphaValue = 0 + }) { + notificationWindow.orderOut(nil as Any?) + } + } + } + + // MARK: - Multi-Window Overlay System + func createOverlayWindows(from start: NSPoint, to end: NSPoint) { + let selectionRect = calculateSelectionRect(from: start, to: end) + + // Clear any existing windows + hideMultiMonitorOverlayWindows() + + // Get all screens that the selection intersects + let intersectingScreens = NSScreen.screens.filter { screen in + selectionRect.intersects(screen.frame) + } + + // Create a separate window for each intersecting screen + for screen in intersectingScreens { + let intersection = selectionRect.intersection(screen.frame) + + // Only create window if intersection is meaningful + if intersection.width > 1 && intersection.height > 1 { + createSingleOverlayWindow(for: intersection, on: screen) + } + } + } + + func createSingleOverlayWindow(for rect: NSRect, on screen: NSScreen) { + let window = NSPanel( + contentRect: rect, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + // Configure window properties for multi-monitor support + window.level = .screenSaver // Below event capture window but above everything else + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = false + window.ignoresMouseEvents = true // Don't interfere with mouse tracking + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle, .stationary] + + // Disable automatic positioning + window.setFrameAutosaveName("") + window.isMovableByWindowBackground = false + window.isMovable = false + window.animationBehavior = .none + window.displaysWhenScreenProfileChanges = true + + // Panel-specific settings + let panel = window + panel.isFloatingPanel = true + panel.becomesKeyOnlyIfNeeded = false + panel.hidesOnDeactivate = false + + // Create and set content view with multi-monitor selection overlay + let contentView = MultiMonitorSelectionOverlayView() + let hostingView = NSHostingView(rootView: contentView) + + // Create a container view that will hold both the SwiftUI view and the crosshair + let containerView = NSView(frame: rect) + containerView.addSubview(hostingView) + hostingView.frame = containerView.bounds + hostingView.autoresizingMask = [.width, .height] + + // Add crosshair view on top + let crosshairView = CrosshairCursorView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) + containerView.addSubview(crosshairView) + crosshairView.startTracking() + + window.contentView = containerView + + // Set frame and show + window.setFrame(rect, display: false, animate: false) + window.makeKeyAndOrderFront(nil as Any?) + + // Add to our collection + overlayWindows.append(window) + windowScreenMap[window] = screen + + print("๐ŸชŸ Created multi-monitor overlay window on \(screen.localizedName) at \(rect)") + } + + func updateOverlayWindows(from start: NSPoint, to end: NSPoint) { + let selectionRect = calculateSelectionRect(from: start, to: end) + + // Get all screens that the selection intersects + let intersectingScreens = NSScreen.screens.filter { screen in + selectionRect.intersects(screen.frame) + } + + // Smart update: reuse existing windows when possible + updateExistingWindows(for: intersectingScreens, selectionRect: selectionRect) + } + + func updateExistingWindows(for screens: [NSScreen], selectionRect: NSRect) { + // Track which screens we've updated + var updatedScreens: Set = [] + + // Update existing windows that are still relevant + overlayWindows = overlayWindows.compactMap { window in + guard let associatedScreen = windowScreenMap[window] else { + window.close() + return nil + } + + // Check if this screen still intersects with the selection + let intersection = selectionRect.intersection(associatedScreen.frame) + if intersection.width > 1 && intersection.height > 1 { + // Update the existing window smoothly + updateWindowFrame(window, to: intersection) + updatedScreens.insert(associatedScreen) + return window + } else { + // This screen no longer intersects, remove the window + window.close() + windowScreenMap.removeValue(forKey: window) + return nil + } + } + + // Create new windows for screens that don't have one yet + for screen in screens { + if !updatedScreens.contains(screen) { + let intersection = selectionRect.intersection(screen.frame) + if intersection.width > 1 && intersection.height > 1 { + createSingleOverlayWindow(for: intersection, on: screen) + } + } + } + } + + func updateWindowFrame(_ window: NSWindow, to rect: NSRect) { + // Fast frame update without recreation + window.setFrame(rect, display: true, animate: false) + } + + func hideMultiMonitorOverlayWindows() { + overlayWindows.forEach { $0.close() } + overlayWindows.removeAll() + windowScreenMap.removeAll() + print("๐ŸชŸ All multi-monitor overlay windows hidden") + } + + // MARK: - Screen Mapping and Calculation + func calculateSelectionRect(from start: NSPoint, to end: NSPoint) -> NSRect { + let minX = min(start.x, end.x) + let maxX = max(start.x, end.x) + let minY = min(start.y, end.y) + let maxY = max(start.y, end.y) + + return NSRect( + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + ) + } + + func findWindowForScreen(_ screen: NSScreen) -> NSWindow? { + return overlayWindows.first { window in + windowScreenMap[window] == screen + } + } + + // MARK: - Multi-Monitor Screenshot Capture + func captureMultiMonitorScreenshot(in rect: NSRect) { + print("๐Ÿ“ธ Capturing multi-monitor screenshot...") + print("๐Ÿ“ Multi-monitor capture area: x=\(rect.origin.x), y=\(rect.origin.y), width=\(rect.width), height=\(rect.height)") + + // Convert from global screen coordinates to proper capture coordinates + let convertedRect = convertGlobalToScreenCoordinates(rect) + print("๐ŸŽฏ Converted capture area: x=\(convertedRect.origin.x), y=\(convertedRect.origin.y), width=\(convertedRect.width), height=\(convertedRect.height)") + + // Use the existing capture method with converted coordinates and multi-monitor flag + capture(rect: convertedRect, isMultiMonitor: true) + } + + func convertGlobalToScreenCoordinates(_ globalRect: NSRect) -> NSRect { + // NSEvent.mouseLocation and CGWindowListCreateImage both use the same coordinate system: + // - (0,0) is at the bottom-left of the main screen + // - Y increases upward + // - No conversion needed! + + print("๐Ÿ”„ Coordinate conversion (SIMPLIFIED):") + print(" Input rect: \(globalRect)") + print(" Output rect: \(globalRect) (NO CONVERSION)") + + // Diagnostic: Show which screens this rect intersects + let intersectingScreens = NSScreen.screens.filter { screen in + globalRect.intersects(screen.frame) + } + + print(" ๐Ÿ“บ Intersecting screens:") + for screen in intersectingScreens { + let intersection = globalRect.intersection(screen.frame) + print(" - \(screen.localizedName): intersection \(intersection)") + } + + return globalRect + } + + // MARK: - Screen Information + func listAvailableScreens() { + print("๐Ÿ“บ Available screens for multi-monitor support:") + + let screens = NSScreen.screens + let mainScreen = NSScreen.main + + for (index, screen) in screens.enumerated() { + let frame = screen.frame + let isMain = screen == mainScreen + let _ = screen.deviceDescription + let displayName = screen.localizedName + + print(" \(index): \(displayName)") + print(" Frame: x=\(frame.origin.x), y=\(frame.origin.y), w=\(frame.width), h=\(frame.height)") + print(" Scale: \(screen.backingScaleFactor)x") + + if isMain { + print(" โญ Main screen (primary display)") + } + + // Check for potential issues + if frame.origin.x != 0 && frame.origin.y != 0 { + print(" ๐Ÿ“ Positioned screen (not at origin)") + } + + // Show relative position + if frame.origin.y > 0 { + print(" โฌ†๏ธ Screen positioned ABOVE main screen") + } else if frame.origin.y < 0 { + print(" โฌ‡๏ธ Screen positioned BELOW main screen") + } + + if frame.origin.x > 0 { + print(" โžก๏ธ Screen positioned RIGHT of main screen") + } else if frame.origin.x < 0 { + print(" โฌ…๏ธ Screen positioned LEFT of main screen") + } + } + + // Calculate and display total desktop bounds + let totalBounds = getAllScreensBounds() + print("๐ŸŒ Total desktop bounds: x=\(totalBounds.origin.x) to \(totalBounds.maxX), y=\(totalBounds.origin.y) to \(totalBounds.maxY)") + print("๐Ÿ“ Total desktop size: \(totalBounds.width) ร— \(totalBounds.height)") + + // Show coordinate system info + print("๐Ÿงญ macOS Coordinate System Info:") + print(" - (0,0) is at bottom-left of main screen") + print(" - Y increases upward") + print(" - Screens above main have positive Y") + print(" - Screens below main have negative Y") + + // App Store compatibility check + if screens.count > 2 { + print("๐Ÿ” Multi-screen setup detected (\(screens.count) screens) - testing compatibility") + } + + // Check for unusual configurations + let hasNegativeCoordinates = screens.contains { $0.frame.origin.x < 0 || $0.frame.origin.y < 0 } + if hasNegativeCoordinates { + print("โš ๏ธ Negative coordinates detected - using advanced coordinate conversion") + } + } + + // MARK: - Cleanup + func cleanupMultiMonitorResources() { + // Remove all event monitors + if let monitor = globalMouseDownMonitor { + NSEvent.removeMonitor(monitor) + globalMouseDownMonitor = nil + } + if let monitor = globalMouseDragMonitor { + NSEvent.removeMonitor(monitor) + globalMouseDragMonitor = nil + } + if let monitor = globalMouseUpMonitor { + NSEvent.removeMonitor(monitor) + globalMouseUpMonitor = nil + } + if let monitor = localMouseDownMonitor { + NSEvent.removeMonitor(monitor) + localMouseDownMonitor = nil + } + if let monitor = localMouseDragMonitor { + NSEvent.removeMonitor(monitor) + localMouseDragMonitor = nil + } + if let monitor = localMouseUpMonitor { + NSEvent.removeMonitor(monitor) + localMouseUpMonitor = nil + } + + // Clean up windows + hideMultiMonitorOverlayWindows() + + // Remove global key monitor + removeGlobalKeyMonitor() + + // Remove custom crosshair overlay + removeCustomCrosshairOverlay() + + print("๐Ÿงน Multi-monitor resources cleaned up") + } + + // MARK: - Multi-Monitor Event Capture Window Management + func createEventCaptureWindow() { + // Remove any existing capture windows + removeEventCaptureWindow() + + // Create a separate event capture window for each screen + for (index, screen) in NSScreen.screens.enumerated() { + let captureWindow = EventCaptureWindow( + contentRect: screen.frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + + // Configure the window to be invisible but capture ALL events + captureWindow.backgroundColor = NSColor.clear + captureWindow.isOpaque = false + captureWindow.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.assistiveTechHighWindow)) + 10) // Even higher level + captureWindow.ignoresMouseEvents = false + captureWindow.acceptsMouseMovedEvents = true + captureWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle, .stationary] + + // CRITICAL: Prevent other windows from receiving events + captureWindow.hidesOnDeactivate = false + captureWindow.isMovableByWindowBackground = false + + // Force the window to stay on top and capture all events + captureWindow.sharingType = .none + captureWindow.isExcludedFromWindowsMenu = true + + // Create a container view for both event capture and crosshair + let containerView = NSView(frame: NSRect(origin: .zero, size: screen.frame.size)) + + // Create a custom view that handles mouse events + let captureView = EventCaptureView() + captureView.screenshotApp = self + captureView.frame = containerView.bounds + containerView.addSubview(captureView) + + // Add crosshair view on top + let crosshairView = CrosshairCursorView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) + containerView.addSubview(crosshairView) + crosshairView.startTracking() + + captureWindow.contentView = containerView + + // Position the window exactly on this screen + captureWindow.setFrame(screen.frame, display: false) + + // Make sure this window is always on top + captureWindow.orderFrontRegardless() + + // Store the window + eventCaptureWindows.append(captureWindow) + + print("๐ŸŽฏ Event capture window \(index) created for screen: \(screen.localizedName) at \(screen.frame)") + } + + // Make the first window key to receive keyboard events + if let firstWindow = eventCaptureWindows.first { + firstWindow.makeKey() + + // CRITICAL: Force our app to become active and prevent other apps from receiving events + NSApp.activate(ignoringOtherApps: true) + firstWindow.makeKeyAndOrderFront(nil as Any?) + + // Make the capture view the first responder for keyboard events + DispatchQueue.main.async { + if let captureView = firstWindow.contentView?.subviews.first(where: { $0 is EventCaptureView }) { + firstWindow.makeFirstResponder(captureView) + print("โŒจ๏ธ Made EventCaptureView first responder on primary capture window") + } + + // Additional activation to ensure we're really on top + NSApp.activate(ignoringOtherApps: true) + } + } + + print("๐Ÿ”’ Created \(eventCaptureWindows.count) event capture windows for multi-monitor setup") + print("๐Ÿ”’ App activated to capture all events") + } + + func removeEventCaptureWindow() { + for window in eventCaptureWindows { + window.orderOut(nil as Any?) + } + eventCaptureWindows.removeAll() + + // Also remove the legacy single window if it exists + if let captureWindow = eventCaptureWindow { + captureWindow.orderOut(nil as Any?) + eventCaptureWindow = nil + } + + print("๐Ÿ—‘๏ธ All event capture windows removed") + } + + func getAllScreensBounds() -> NSRect { + guard !NSScreen.screens.isEmpty else { + return NSRect(x: 0, y: 0, width: 1920, height: 1080) // Fallback + } + + print("๐Ÿ–ฅ๏ธ Detecting screens for event capture:") + for (index, screen) in NSScreen.screens.enumerated() { + print(" Screen \(index): \(screen.localizedName) - Frame: \(screen.frame)") + } + + var minX: CGFloat = CGFloat.greatestFiniteMagnitude + var minY: CGFloat = CGFloat.greatestFiniteMagnitude + var maxX: CGFloat = -CGFloat.greatestFiniteMagnitude + var maxY: CGFloat = -CGFloat.greatestFiniteMagnitude + + for screen in NSScreen.screens { + let frame = screen.frame + minX = min(minX, frame.minX) + minY = min(minY, frame.minY) + maxX = max(maxX, frame.maxX) + maxY = max(maxY, frame.maxY) + } + + let combinedBounds = NSRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY) + print("๐ŸŽฏ Combined screen bounds for event capture: \(combinedBounds)") + + return combinedBounds + } + + // MARK: - Window Capture Mode State + var isWindowCaptureMode: Bool { + get { + return objc_getAssociatedObject(self, &ScreenshotApp.isWindowCaptureModeKey) as? Bool ?? false + } + set { + objc_setAssociatedObject(self, &ScreenshotApp.isWindowCaptureModeKey, newValue, .OBJC_ASSOCIATION_ASSIGN) + } + } + + private static var isWindowCaptureModeKey: UInt8 = 0 + + // MARK: - Window Capture Mode Switching + func switchToWindowCaptureMode() { + guard !isWindowCaptureMode else { return } + + print("๐ŸชŸ Switching to window capture mode") + isWindowCaptureMode = true + + // IMPORTANT: Remove normal screenshot event capture windows + removeEventCaptureWindow() + removeCustomCrosshairOverlay() + + // CRITICAL: Also remove all mouse and key monitors that interfere with window capture + removeGlobalKeyMonitor() // This also calls removeGlobalMouseMonitor() + + // CRITICAL: Also clean up the local mouse tracking from setupGlobalMouseTracking() + if let monitor = localMouseDownMonitor { + NSEvent.removeMonitor(monitor) + localMouseDownMonitor = nil + } + if let monitor = localMouseDragMonitor { + NSEvent.removeMonitor(monitor) + localMouseDragMonitor = nil + } + if let monitor = localMouseUpMonitor { + NSEvent.removeMonitor(monitor) + localMouseUpMonitor = nil + } + if let monitor = globalMouseDownMonitor { + NSEvent.removeMonitor(monitor) + globalMouseDownMonitor = nil + } + if let monitor = globalMouseDragMonitor { + NSEvent.removeMonitor(monitor) + globalMouseDragMonitor = nil + } + if let monitor = globalMouseUpMonitor { + NSEvent.removeMonitor(monitor) + globalMouseUpMonitor = nil + } + print("๐ŸŽฏ All multi-monitor mouse tracking disabled for window capture mode") + + // Activate window capture functionality + if #available(macOS 12.3, *) { + windowCaptureManager?.activateWindowSelectionMode() + } else { + print("โš ๏ธ Window capture requires macOS 12.3 or later") + isWindowCaptureMode = false + // Restore normal screenshot mode if window capture fails + createEventCaptureWindow() + setupCustomCrosshairOverlay() + setupGlobalKeyMonitor() // Restore monitors + setupGlobalMouseTracking() // Restore local mouse tracking + } + } + + func switchToNormalScreenshotMode() { + guard isWindowCaptureMode else { return } + + print("๐Ÿ“ธ Switching back to normal screenshot mode") + isWindowCaptureMode = false + + // Deactivate window capture functionality + if #available(macOS 12.3, *) { + windowCaptureManager?.deactivateWindowSelectionMode() + } + + // IMPORTANT: Restore normal screenshot event capture and crosshair + createEventCaptureWindow() + setupCustomCrosshairOverlay() + + // CRITICAL: Restore the global key monitor for normal screenshot mode + setupGlobalKeyMonitor() + + // CRITICAL: Restore the local mouse tracking for normal screenshot mode + setupGlobalMouseTracking() + print("๐ŸŽฏ All multi-monitor mouse tracking restored for normal screenshot mode") + } + + // MARK: - Helper Functions for Custom Modifiers + func isModifierPressed(_ flags: NSEvent.ModifierFlags, modifier: UInt) -> Bool { + if modifier & (1 << 0) != 0 && !flags.contains(.command) { return false } + if modifier & (1 << 1) != 0 && !flags.contains(.shift) { return false } + if modifier & (1 << 2) != 0 && !flags.contains(.option) { return false } + if modifier & (1 << 3) != 0 && !flags.contains(.control) { return false } + + // Check that only the required modifiers are pressed + let requiredCommand = modifier & (1 << 0) != 0 + let requiredShift = modifier & (1 << 1) != 0 + let requiredOption = modifier & (1 << 2) != 0 + let requiredControl = modifier & (1 << 3) != 0 + + return flags.contains(.command) == requiredCommand && + flags.contains(.shift) == requiredShift && + flags.contains(.option) == requiredOption && + flags.contains(.control) == requiredControl + } + + func getModifierName(_ modifier: UInt) -> String { + var parts: [String] = [] + + if modifier & (1 << 3) != 0 { parts.append("Control") } + if modifier & (1 << 2) != 0 { parts.append("Option") } + if modifier & (1 << 1) != 0 { parts.append("Shift") } + if modifier & (1 << 0) != 0 { parts.append("Command") } + + return parts.joined(separator: "+") + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/OverlayComponents.swift b/ShotScreen/Sources/OverlayComponents.swift new file mode 100644 index 0000000..9dfa09b --- /dev/null +++ b/ShotScreen/Sources/OverlayComponents.swift @@ -0,0 +1,253 @@ +import AppKit + +// MARK: - Overlay Window for Screenshot Selection +class OverlayWindow: NSWindow { + var crosshairView: CrosshairCursorView? + var onCancelRequested: (() -> Void)? + + init(screen: NSScreen) { + super.init(contentRect: screen.frame, + styleMask: [.borderless], + backing: .buffered, + defer: false) + self.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.mainMenuWindow)) + 1) + self.isOpaque = false + self.backgroundColor = NSColor.clear + self.ignoresMouseEvents = false + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + // Try to hide the system cursor + CGDisplayHideCursor(CGMainDisplayID()) + + // Create an empty content view first + let contentContainer = NSView(frame: screen.frame) + contentContainer.wantsLayer = true + self.contentView = contentContainer + + // Make this window visible + self.makeKeyAndOrderFront(nil as Any?) + self.orderFrontRegardless() + + // Add our custom crosshair view at the top level + crosshairView = CrosshairCursorView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) + if let crosshairView = crosshairView { + contentContainer.addSubview(crosshairView) + + // Get current mouse position and update crosshair initial position + let mouseLoc = NSEvent.mouseLocation + let windowLoc = self.convertPoint(fromScreen: mouseLoc) + crosshairView.frame = NSRect(x: windowLoc.x - 12, y: windowLoc.y - 12, width: 24, height: 24) + + // Start tracking timer + crosshairView.startTracking() + } + } + + deinit { + // Show the cursor again when window is destroyed + CGDisplayShowCursor(CGMainDisplayID()) + } + + // Forceer dat het window key events kan ontvangen, zelfs zonder title bar + override var canBecomeKey: Bool { + return true + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { // 53 is Escape + print("ESC key pressed in OverlayWindow - keyDown") + + // Roep alleen de callback aan + onCancelRequested?() + + // Sluit het venster NIET HIER + } else { + // Voor andere toetsen, stuur door naar super + super.keyDown(with: event) + } + } + + override func rightMouseDown(with event: NSEvent) { + print("๐Ÿ–ฑ Right mouse down in OverlayWindow") + // Roep alleen de callback aan + onCancelRequested?() + // Roep super niet aan, we handelen dit af. + } + + override func close() { + // Show the cursor before closing + CGDisplayShowCursor(CGMainDisplayID()) + super.close() + } +} + +// MARK: - Selection View for Drawing Selection Rectangle +class SelectionView: NSView { + var startPoint: NSPoint? + var endPoint: NSPoint? + var selectionLayer = CAShapeLayer() + var onSelectionComplete: ((NSRect) -> Void)? + weak var parentWindow: OverlayWindow? + private var isSelecting = false + private var hasStartedSelection = false + + override var acceptsFirstResponder: Bool { true } + + // Add mouse event monitoring + private var eventMonitor: Any? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.wantsLayer = true + + // Listen to mouse events at the application level + setupEventMonitor() + } + + private func setupEventMonitor() { + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]) { [weak self] event in + guard let self = self else { return event } + + // Get the location in window coordinates + let locationInWindow = event.locationInWindow + + switch event.type { + case .leftMouseDown: + self.handleMouseDown(at: locationInWindow) + case .leftMouseDragged: + self.handleMouseDragged(at: locationInWindow) + case .leftMouseUp: + self.handleMouseUp(at: locationInWindow) + default: + break + } + + return event + } + } + + // Maak internal ipv private + func removeEventMonitor() { + if let eventMonitor = eventMonitor { + NSEvent.removeMonitor(eventMonitor) + self.eventMonitor = nil + } + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + parentWindow = window as? OverlayWindow + } + + override func removeFromSuperview() { + // Remove event monitor + removeEventMonitor() + + // Show cursor when removed + CGDisplayShowCursor(CGMainDisplayID()) + super.removeFromSuperview() + } + + func handleMouseDown(at location: NSPoint) { + isSelecting = true + hasStartedSelection = true + startPoint = location + endPoint = location + updateSelectionLayer() + print("๐Ÿ–ฑ Mouse down at: \(location)") + + // Update crosshair position + if let parentWindow = parentWindow, + let crosshairView = parentWindow.crosshairView { + crosshairView.updatePosition(location: location) + } + } + + func handleMouseDragged(at location: NSPoint) { + guard isSelecting && hasStartedSelection else { return } + + endPoint = location + updateSelectionLayer() + + // Update crosshair position + if let parentWindow = parentWindow, + let crosshairView = parentWindow.crosshairView { + crosshairView.updatePosition(location: location) + } + } + + func handleMouseUp(at location: NSPoint) { + guard isSelecting && hasStartedSelection else { return } + + isSelecting = false + + // Set the end point + endPoint = location + + guard let start = startPoint, let end = endPoint else { return } + + // Calculate the selection rectangle in screen coordinates + let rect: NSRect + + // If start and end are very close (within 5 pixels), assume user wants the full screen + if abs(start.x - end.x) < 5 && abs(start.y - end.y) < 5 { + rect = self.bounds + print("๐Ÿ“บ Full screen capture requested") + } else { + rect = NSRect(x: min(start.x, end.x), + y: min(start.y, end.y), + width: abs(start.x - end.x), + height: abs(start.y - end.y)) + print("โœ‚๏ธ Selection completed: \(rect)") + } + + // Show cursor before completing + CGDisplayShowCursor(CGMainDisplayID()) + + // Remove the event monitor + removeEventMonitor() + + onSelectionComplete?(rect) + removeFromSuperview() + window?.orderOut(nil as Any?) + } + + // Keep these for direct interactions with the view + override func mouseDown(with event: NSEvent) { + handleMouseDown(at: event.locationInWindow) + } + + override func mouseDragged(with event: NSEvent) { + handleMouseDragged(at: event.locationInWindow) + } + + override func mouseUp(with event: NSEvent) { + handleMouseUp(at: event.locationInWindow) + } + + func updateSelectionLayer() { + selectionLayer.removeFromSuperlayer() + + // Only create selection if we have valid start and end points + guard let start = startPoint, let end = endPoint, + abs(start.x - end.x) >= 1 || abs(start.y - end.y) >= 1 else { + return + } + + let rect = NSRect(x: min(start.x, end.x), + y: min(start.y, end.y), + width: max(1, abs(start.x - end.x)), + height: max(1, abs(start.y - end.y))) + + let path = CGPath(rect: rect, transform: nil) + selectionLayer.path = path + selectionLayer.fillColor = NSColor(calibratedWhite: 1, alpha: 0.3).cgColor + selectionLayer.strokeColor = NSColor.white.cgColor + selectionLayer.lineWidth = 1.0 + self.layer?.addSublayer(selectionLayer) + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/PreviewManager.swift b/ShotScreen/Sources/PreviewManager.swift new file mode 100644 index 0000000..72e9c87 --- /dev/null +++ b/ShotScreen/Sources/PreviewManager.swift @@ -0,0 +1,1804 @@ +import AppKit +import SwiftUI + + // MARK: - Button Hover Handler (glass-effect safe) +class ButtonHoverHandler: NSObject { + weak var button: NSButton? + private let zoomScale: CGFloat + + init(button: NSButton, zoomScale: CGFloat = 1.3) { + self.button = button + self.zoomScale = zoomScale + super.init() + } + + func mouseEntered(with event: NSEvent) { + guard let button = button else { return } + + print("๐ŸŽฏ HOVER: Mouse entered button - starting zoom (\(zoomScale)x) and color effect") + + let hoverColor = ThemeManager.shared.buttonHoverColor // Adaptive hover color + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + context.allowsImplicitAnimation = true + + // Color change + button.animator().contentTintColor = hoverColor + }) + + // Zoom effect using layer transform (separate from color animation) + if let layer = button.layer { + CATransaction.begin() + CATransaction.setAnimationDuration(0.2) + CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeOut)) + let scaleTransform = CATransform3DMakeScale(zoomScale, zoomScale, 1.0) + layer.transform = scaleTransform + CATransaction.commit() + print("๐ŸŽฏ HOVER: Applied \(zoomScale)x zoom scale") + } + } + + func mouseExited(with event: NSEvent) { + guard let button = button else { return } + + print("๐ŸŽฏ HOVER: Mouse exited button - restoring original size and color") + + // Get original color + let originalColor = objc_getAssociatedObject(button, "originalColor") as? NSColor ?? ThemeManager.shared.buttonOriginalColor + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.25 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + context.allowsImplicitAnimation = true + + // Restore original color + button.animator().contentTintColor = originalColor + }) + + // Restore original size (separate from color animation) + if let layer = button.layer { + CATransaction.begin() + CATransaction.setAnimationDuration(0.25) + CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut)) + layer.transform = CATransform3DIdentity + CATransaction.commit() + print("๐ŸŽฏ HOVER: Restored original scale") + } + } +} + +// MARK: - Preview Window Management +class PreviewManager: NSObject { + weak var delegate: PreviewManagerDelegate? + + // MARK: - Properties + var activePreviewWindow: NSWindow? + var currentImageView: DraggableImageView? + var previewDismissTimer: Timer? + var isClosing = false + var isPreviewUpdating = false + + // NIEUW: Loading indicator properties + var isShowingLoadingIndicator = false + var loadingOverlayWindow: NSWindow? + + // ๐ŸŽจ NEW: Background Removal Mode Properties + var isBackgroundRemovalMode = false + var isBGRTransition = false // ๐Ÿ”„ NEW: Flag to prevent BGR reset during BGR actions + var originalImage: NSImage? + var processedImage: NSImage? + var bgrResetButton: BGROverlayButton? + var bgrToggleButton: BGROverlayButton? + + // ๐ŸŽจ NEW: BGR File Management Properties + var originalImageURL: URL? // File URL for original image + var processedImageURL: URL? // File URL for processed image + var isShowingProcessedImage = false // Track which image is currently displayed + + // ๐ŸŽจ NEW: BGR Progress Bar Properties + var bgrProgressContainer: NSView? + var bgrProgressBar: NSProgressIndicator? + var bgrProgressLabel: NSTextField? + + // MARK: - ๐ŸŽ› TOOLBAR BUTTON AANPASSINGEN + + // ๐Ÿ“ BUTTON GROOTTES + let closeButtonSize: CGFloat = 9 // ๐Ÿ”ด Close button (X) - Klein + let saveButtonSize: CGFloat = 16 // ๐Ÿ’พ Save button (Map+Plus) + let folderButtonSize: CGFloat = 16 // ๐Ÿ“ Folder button (Map) + let settingsButtonSize: CGFloat = 14 // โš™๏ธ Settings button (Tandwiel) + + // ๐Ÿ“ BUTTON VERTICALE POSITIES (offset van center) + let closeButtonVerticalOffset: CGFloat = -0.5 // ๐Ÿ”ด Close button verticaal + let saveButtonVerticalOffset: CGFloat = 0 // ๐Ÿ’พ Save button verticaal + let folderButtonVerticalOffset: CGFloat = -0.5 // ๐Ÿ“ Folder button verticaal + let settingsButtonVerticalOffset: CGFloat = 0 // โš™๏ธ Settings button verticaal + + // โ†”๏ธ BUTTON HORIZONTALE POSITIES (extra offset) + let saveButtonHorizontalOffset: CGFloat = 0 // ๐Ÿ’พ Save button horizontaal verschuiving + + // ๐Ÿ”„ BUTTON INTERACTIE & STYLING + let buttonHoverZoomScale: CGFloat = 1.1 // ๐Ÿ” Hover zoom effect (1.0 = geen zoom) + let buttonSpacing: CGFloat = 15 // โ†”๏ธ Afstand tussen buttons + let toolbarStartPadding: CGFloat = 8 // โ†โ†’ Afstand van linkerrand tot eerste button + + // ๐Ÿท FILENAME LABEL INSTELLINGEN + let filenameLabelFontSize: CGFloat = 10 // ๐Ÿ”ค Tekst grootte bestandsnaam + let filenameLabelVerticalOffset: CGFloat = 1 // ๐Ÿ“ Verticale positie bestandsnaam + let filenameLabelTextOpacity: CGFloat = 0.7 // ๐ŸŒซ Tekst doorzichtigheid + + // ๐ŸŽจ WINDOW STYLING INSTELLINGEN + let windowCornerRadius: CGFloat = 8 // ๐ŸŽจ Ronde hoeken window + let toolbarHeight: CGFloat = 20 // ๐Ÿ“ Hoogte van toolbar + let containerPadding: CGFloat = 5 // ๐Ÿ“ฆ Binnenste padding + let shadowPadding: CGFloat = 12 // ๐ŸŒซ Ruimte voor schaduw + + init(delegate: PreviewManagerDelegate) { + self.delegate = delegate + super.init() + + // Setup theme change observer for live preview updates + ThemeManager.shared.observeThemeChanges { [weak self] in + DispatchQueue.main.async { + self?.updateActivePreviewTheme() + } + } + } + + // MARK: - Theme Management + private func updateActivePreviewTheme() { + // Update only if there's an active preview window + guard let window = activePreviewWindow else { + print("๐ŸŽจ THEME: No active preview window to update") + return + } + + print("๐ŸŽจ THEME: Updating active preview window colors for current theme") + updateWindowThemeColors(window) + } + + private func updateWindowThemeColors(_ window: NSWindow) { + // Find and update all theme-dependent views recursively + updateViewThemeColors(window.contentView) + } + + private func updateViewThemeColors(_ view: NSView?) { + guard let view = view else { return } + + // Update container background colors + if view.layer?.backgroundColor != nil && view.layer?.backgroundColor != NSColor.clear.cgColor { + view.layer?.backgroundColor = ThemeManager.shared.containerBackground.cgColor + print("๐ŸŽจ THEME: Updated container background") + } + + // Update shadow colors + if view.layer?.shadowColor != nil { + view.layer?.shadowColor = ThemeManager.shared.shadowColor.cgColor + view.layer?.shadowOpacity = ThemeManager.shared.shadowOpacity + print("๐ŸŽจ THEME: Updated shadow colors") + } + + // Update button colors + if let button = view as? NSButton { + button.contentTintColor = ThemeManager.shared.buttonTintColor + // Update stored original color for hover restoration + objc_setAssociatedObject(button, "originalColor", ThemeManager.shared.buttonTintColor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + print("๐ŸŽจ THEME: Updated button colors") + } + + // Update text field colors + if let textField = view as? NSTextField, textField.tag == 11001 { // Only filename labels + textField.textColor = ThemeManager.shared.filenameLabelTextColor + print("๐ŸŽจ THEME: Updated text field colors") + } + + // Recursively update subviews + for subview in view.subviews { + updateViewThemeColors(subview) + } + } + + // MARK: - Public Interface + func showPreview(image: NSImage) { + NSApp.activate(ignoringOtherApps: true) + print("๐Ÿ“ฆ Start showPreview") + print("๐Ÿ–ผ Afmeting: \(image.size)") + + // ๐Ÿ”„ SMART FIX: Only reset BGR mode for NEW screenshots, not BGR actions on existing thumbnails + if isBackgroundRemovalMode && !isBGRTransition { + print("๐Ÿ”„ FORCE RESET: Detected BGR mode active during NEW screenshot - forcing complete reset") + forceCompleteBGRReset() + } + + if let _ = self.activePreviewWindow { + print("๐Ÿ“ฆ Closing existing preview window before showing new one.") + // ๐Ÿ”ง CRITICAL FIX: Preserve tempURL when closing for new screenshot + self.closePreviewWithAnimation(immediate: true, preserveTempFile: true) + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) + } + + guard image.size.width > 0 && image.size.height > 0 else { + print("โŒ Lege afbeelding, preview wordt overgeslagen") + return + } + + delegate?.setLastImage(image) + + previewDismissTimer?.invalidate() + previewDismissTimer = nil + + guard let window = createNewThumbnailWindow(image: image) else { + print("โŒ Failed to create new thumbnail window in showPreview using createNewThumbnailWindow.") + if isPreviewUpdating { + DispatchQueue.main.async { self.isPreviewUpdating = false } + } + return + } + activePreviewWindow = window + + window.alphaValue = 1.0 + window.makeKeyAndOrderFront(nil as Any?) + + // Force window activation to ensure tracking areas work immediately + window.level = .floating + NSApp.activate(ignoringOtherApps: true) + + // Ensure window accepts mouse events + window.acceptsMouseMovedEvents = true + + print("DEBUG: showPreview FINISHED - Window: \(window), Visible: \(window.isVisible), Alpha: \(window.alphaValue), Frame: \(window.frame)") + + // Voeg automatisch sluiten timer toe volgens instellingen + let timerValue = SettingsManager.shared.thumbnailTimer + if timerValue > 0 { + previewDismissTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(timerValue), repeats: false) { [weak self] _ in + self?.closePreviewWithAnimation() + } + RunLoop.main.add(previewDismissTimer!, forMode: .common) + print("โฑ Scheduled auto-dismiss in \(timerValue) seconds") + } else { + print("โฑ Auto-dismiss disabled (thumbnailTimer = 0)") + } + } + + func updatePreviewSize() { + guard let lastImage = delegate?.getLastImage() else { return } + + if isClosing || isPreviewUpdating { return } + + isPreviewUpdating = true + + // Invalidate timer *before* closing, as closing might be asynchronous or delayed slightly + previewDismissTimer?.invalidate() + previewDismissTimer = nil + + closePreviewWithAnimation(immediate: true) + showPreview(image: lastImage) + + DispatchQueue.main.async { + self.isPreviewUpdating = false + } + } + + func closePreviewWithAnimation(immediate: Bool = false, preserveTempFile: Bool = false) { + // Invalidate timer at the very beginning of any close operation + previewDismissTimer?.invalidate() + previewDismissTimer = nil + + guard let window = activePreviewWindow, !isClosing else { + if activePreviewWindow == nil && isPreviewUpdating { + DispatchQueue.main.async { self.isPreviewUpdating = false } + } + return + } + + isClosing = true + + let cleanup = { [weak self] in + guard let self = self else { return } + window.orderOut(nil as Any?) + if self.activePreviewWindow === window { + print("๐Ÿงผ Cleaning up preview resources...") + self.activePreviewWindow = nil + self.currentImageView = nil + if !preserveTempFile { + self.delegate?.clearTempFile() + } else { + print("๐Ÿ’ฐ Preserving tempURL as requested.") + } + } + self.isClosing = false + } + + if immediate { + cleanup() + } else { + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.4 + window.animator().alphaValue = 0 + + // Voeg "hupje" animatie toe: beweeg venster iets omhoog + let currentFrame = window.frame + let bounceHeight: CGFloat = 20 // hoogte van het hupje + let finalFrame = NSRect(x: currentFrame.origin.x, + y: currentFrame.origin.y + bounceHeight, + width: currentFrame.width, + height: currentFrame.height) + window.animator().setFrame(finalFrame, display: true) + + }, completionHandler: cleanup) + } + } + + @objc func closePreviewWindow() { + previewDismissTimer?.invalidate() + previewDismissTimer = nil + closePreviewWithAnimation() + } + + func getActivePreviewWindow() -> NSWindow? { + return activePreviewWindow + } + + func ensurePreviewVisible() { + guard let preview = self.activePreviewWindow else { + if let img = delegate?.getLastImage() { + self.showPreview(image: img) + } + return + } + if !preview.isVisible { + preview.alphaValue = 0 + preview.orderFront(nil) + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 2.0 + preview.animator().alphaValue = 1 + } + } + } + + // MARK: - Visual Effects + func flashPreviewBorder() { + guard let window = activePreviewWindow, let _ = window.contentView else { + print("โš ๏ธ FlashEffect: No active preview window or outerContainer (contentView).") + return + } + + guard let currentImgView = self.currentImageView, let imageContainer = currentImgView.superview else { + print("โš ๏ธ FlashEffect: Could not find imageContainer via currentImageView.superview.") + return + } + + let flashView = NSView(frame: imageContainer.bounds) + flashView.wantsLayer = true + flashView.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.50).cgColor + flashView.layer?.cornerRadius = imageContainer.layer?.cornerRadius ?? 10 + flashView.alphaValue = 0 + + imageContainer.addSubview(flashView) + + print("โœจ FlashEffect: Starting white flash animation on imageContainer.") + + NSAnimationContext.runAnimationGroup({ + context in + context.duration = 0.08 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + flashView.animator().alphaValue = 1.0 + }, completionHandler: { + NSAnimationContext.runAnimationGroup({ + context in + context.duration = 0.8 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + flashView.animator().alphaValue = 0.0 + }, completionHandler: { + flashView.removeFromSuperview() + print("โœจ FlashEffect: Animation complete, flashView removed.") + }) + }) + } + + // MARK: - Screen Selection for Thumbnail + func getTargetScreenForThumbnail() -> NSScreen? { + let setting = SettingsManager.shared.thumbnailDisplayScreen + + switch setting { + case .automatic: + let mouseLocation = NSEvent.mouseLocation + for screen in NSScreen.screens { + if screen.frame.contains(mouseLocation) { + print("๐Ÿ“บ Thumbnail scherm: Automatisch - muis op \(screen.localizedName)") + return screen + } + } + print("๐Ÿ“บ Thumbnail scherm: Automatisch fallback naar eerste scherm") + return NSScreen.screens.first ?? NSScreen.main + + case .screen1: + if NSScreen.screens.count >= 1 { + let screen = NSScreen.screens[0] + print("๐Ÿ“บ Thumbnail scherm: Scherm 1 (\(screen.localizedName))") + return screen + } + print("๐Ÿ“บ Thumbnail scherm: Scherm 1 niet beschikbaar, fallback naar eerste scherm") + return NSScreen.screens.first ?? NSScreen.main + + case .screen2: + if NSScreen.screens.count >= 2 { + let screen = NSScreen.screens[1] + print("๐Ÿ“บ Thumbnail scherm: Scherm 2 (\(screen.localizedName))") + return screen + } + print("๐Ÿ“บ Thumbnail scherm: Scherm 2 niet beschikbaar, fallback naar eerste scherm") + return NSScreen.screens.first ?? NSScreen.main + + case .screen3: + if NSScreen.screens.count >= 3 { + let screen = NSScreen.screens[2] + print("๐Ÿ“บ Thumbnail scherm: Scherm 3 (\(screen.localizedName))") + return screen + } + print("๐Ÿ“บ Thumbnail scherm: Scherm 3 niet beschikbaar, fallback naar eerste scherm") + return NSScreen.screens.first ?? NSScreen.main + + case .screen4: + if NSScreen.screens.count >= 4 { + let screen = NSScreen.screens[3] + print("๐Ÿ“บ Thumbnail scherm: Scherm 4 (\(screen.localizedName))") + return screen + } + print("๐Ÿ“บ Thumbnail scherm: Scherm 4 niet beschikbaar, fallback naar eerste scherm") + return NSScreen.screens.first ?? NSScreen.main + + case .screen5: + if NSScreen.screens.count >= 5 { + let screen = NSScreen.screens[4] + print("๐Ÿ“บ Thumbnail scherm: Scherm 5 (\(screen.localizedName))") + return screen + } + print("๐Ÿ“บ Thumbnail scherm: Scherm 5 niet beschikbaar, fallback naar eerste scherm") + return NSScreen.screens.first ?? NSScreen.main + } + } + + // MARK: - Cleanup + func cleanup() { + // Clean up BGR mode if active + if isBackgroundRemovalMode { + exitBackgroundRemovalMode() + } + + previewDismissTimer?.invalidate() + previewDismissTimer = nil + activePreviewWindow = nil + currentImageView = nil + } + + // MARK: - Window Creation + private func createNewThumbnailWindow(image: NSImage) -> NSWindow? { + if isPreviewUpdating { + print("DEBUG: createNewThumbnailWindow - isPreviewUpdating is true, returning nil early") + return nil + } + print("DEBUG: createNewThumbnailWindow called with image: \(image.size)") + + // Debug current theme + print("๐ŸŽจ THUMBNAIL: Creating new thumbnail window with current theme:") + ThemeManager.shared.printCurrentTheme() + + // 0. Basisinstellingen en afmetingen + let actualImageWidth = SettingsManager.shared.thumbnailFixedSize.dimensions.width + let actualImageHeight = SettingsManager.shared.thumbnailFixedSize.dimensions.height + + // 1. NIEUWE UNIFIED LAYOUT - รฉรฉn container voor alles (gebruik constants) + let fixedToolbarHeight: CGFloat = toolbarHeight // ๐ŸŽ› Gebruik constant + let spacingBelowToolbar: CGFloat = 8 + + // Corner Radii - gebruik constants + let mainContainerCornerRadius: CGFloat = windowCornerRadius // ๐ŸŽจ Gebruik constant + let imageCornerRadius: CGFloat = windowCornerRadius - 4 // ๐ŸŽจ Iets kleiner dan container + + // 2. Bereken afmetingen - UNIFIED + let imageWidth = actualImageWidth + let imageHeight = actualImageHeight + + // Main container bevat: padding + toolbar + spacing + image + padding + let mainContainerWidth = imageWidth + (2 * containerPadding) + let mainContainerHeight = containerPadding + fixedToolbarHeight + spacingBelowToolbar + imageHeight + containerPadding + + // 3. GLASS EFFECT CONTAINER - FIXED CORNER RADIUS + let shadowContainerFrame = NSRect(x: shadowPadding, + y: shadowPadding, + width: mainContainerWidth, + height: mainContainerHeight) + let mainContainerFrame = NSRect(x: 0, y: 0, width: mainContainerWidth, height: mainContainerHeight) + let mainContainer = NSVisualEffectView(frame: mainContainerFrame) + mainContainer.material = ThemeManager.shared.glassEffectMaterial + mainContainer.blendingMode = ThemeManager.shared.glassEffectBlending + mainContainer.state = .active + mainContainer.alphaValue = ThemeManager.shared.glassEffectAlpha + mainContainer.wantsLayer = true + + // ๐Ÿ”ฅ CRITICAL FIX: NSVisualEffectView corner radius + mainContainer.layer?.cornerRadius = mainContainerCornerRadius + mainContainer.layer?.masksToBounds = true // ๐Ÿ”ฅ MOET TRUE zijn voor corner radius! + + // ๐Ÿ”ฅ EXTRA FIX: Shadow op PARENT container, niet op visual effect view + let shadowContainer = NSView(frame: shadowContainerFrame) + shadowContainer.wantsLayer = true + shadowContainer.layer?.shadowColor = ThemeManager.shared.shadowColor.cgColor + shadowContainer.layer?.shadowOpacity = ThemeManager.shared.shadowOpacity + shadowContainer.layer?.shadowRadius = 6 + shadowContainer.layer?.shadowOffset = CGSize(width: 0, height: -2) + shadowContainer.layer?.cornerRadius = mainContainerCornerRadius + shadowContainer.layer?.masksToBounds = false // Shadow container mag wel shadow buiten bounds + + // 4. Toolbar BINNEN de main container (ONDERAAN) + let toolbarFrame = NSRect(x: containerPadding, + y: containerPadding, + width: mainContainerWidth - (2 * containerPadding), + height: fixedToolbarHeight) + let toolbarView = NSView(frame: toolbarFrame) + toolbarView.wantsLayer = false // Geen aparte layer, gebruik parent styling + + // Toolbar buttons + let symbolConfig = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular) + let closeSymbolConfig = NSImage.SymbolConfiguration(pointSize: 12.5, weight: .regular) // ๐ŸŽฏ Kleinere icoon voor close button + var currentXButton: CGFloat = toolbarStartPadding + + let closeButtonY = (fixedToolbarHeight - closeButtonSize) / 2 + closeButtonVerticalOffset + let closeButton = HoverButton(frame: NSRect(x: currentXButton, y: closeButtonY, width: closeButtonSize, height: closeButtonSize)) + closeButton.image = NSImage(systemSymbolName: "xmark.circle", accessibilityDescription: "Close")?.withSymbolConfiguration(closeSymbolConfig) + closeButton.isBordered = false; closeButton.bezelStyle = .shadowlessSquare; closeButton.contentTintColor = ThemeManager.shared.buttonTintColor + closeButton.action = #selector(closePreviewWindow); closeButton.target = self + closeButton.setupHover(zoomScale: buttonHoverZoomScale) + toolbarView.addSubview(closeButton) + currentXButton += closeButtonSize + buttonSpacing + + let saveButtonY = (fixedToolbarHeight - saveButtonSize) / 2 + saveButtonVerticalOffset + let saveButtonX = currentXButton + saveButtonHorizontalOffset + let saveButton = HoverButton(frame: NSRect(x: saveButtonX, y: saveButtonY, width: saveButtonSize, height: saveButtonSize)) + saveButton.image = NSImage(systemSymbolName: "folder.fill.badge.plus", accessibilityDescription: "Save")?.withSymbolConfiguration(symbolConfig) + saveButton.isBordered = false; saveButton.bezelStyle = .shadowlessSquare; saveButton.contentTintColor = ThemeManager.shared.buttonTintColor + saveButton.action = #selector(PreviewManager.saveFromPreview); saveButton.target = self + saveButton.setupHover(zoomScale: buttonHoverZoomScale) + toolbarView.addSubview(saveButton) + currentXButton += saveButtonSize + buttonSpacing + + var folderButton: HoverButton? + if SettingsManager.shared.showFolderButton { + let folderButtonY = (fixedToolbarHeight - folderButtonSize) / 2 + folderButtonVerticalOffset + folderButton = HoverButton(frame: NSRect(x: currentXButton, y: folderButtonY, width: folderButtonSize, height: folderButtonSize)) + folderButton!.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: "Open Folder")?.withSymbolConfiguration(symbolConfig) + folderButton!.isBordered = false; folderButton!.bezelStyle = .shadowlessSquare; folderButton!.contentTintColor = ThemeManager.shared.buttonTintColor + folderButton!.action = #selector(PreviewManager.openScreenshotFolder); folderButton!.target = self + folderButton!.setupHover(zoomScale: buttonHoverZoomScale) + toolbarView.addSubview(folderButton!) + currentXButton += folderButtonSize + buttonSpacing + } + + let settingsButtonX = (mainContainerWidth - (2 * containerPadding)) - settingsButtonSize - 8 + let settingsButtonY = (fixedToolbarHeight - settingsButtonSize) / 2 + settingsButtonVerticalOffset + let settingsButton = HoverButton(frame: NSRect(x: settingsButtonX, y: settingsButtonY, width: settingsButtonSize, height: settingsButtonSize)) + settingsButton.image = NSImage(systemSymbolName: "gearshape.fill", accessibilityDescription: "Settings")?.withSymbolConfiguration(symbolConfig) + settingsButton.isBordered = false; settingsButton.bezelStyle = .shadowlessSquare; settingsButton.contentTintColor = ThemeManager.shared.buttonTintColor + settingsButton.action = #selector(PreviewManager.openSettings); settingsButton.target = self + settingsButton.setupHover(zoomScale: buttonHoverZoomScale) + toolbarView.addSubview(settingsButton) + + let labelX = currentXButton + let labelWidth = settingsButtonX - labelX - 8 + let labelDesiredHeight: CGFloat = 15.0 + let labelYOffset = (fixedToolbarHeight - labelDesiredHeight) / 2.0 + filenameLabelVerticalOffset + let filenameLabel = NSTextField(frame: NSRect(x: labelX, y: labelYOffset, width: labelWidth, height: labelDesiredHeight)) + filenameLabel.isEditable = false; filenameLabel.isSelectable = true; filenameLabel.isBordered = false + filenameLabel.backgroundColor = NSColor.clear; filenameLabel.textColor = ThemeManager.shared.filenameLabelTextColor + filenameLabel.font = NSFont.systemFont(ofSize: filenameLabelFontSize) + filenameLabel.alignment = .center + filenameLabel.lineBreakMode = .byTruncatingMiddle; filenameLabel.tag = 11001 + if let url = delegate?.getTempURL() { + filenameLabel.stringValue = url.lastPathComponent; filenameLabel.toolTip = url.lastPathComponent + } else { + let fallbackName = "Schermafbeelding \(DateFormatter().string(from: Date())).png" + filenameLabel.stringValue = fallbackName; filenameLabel.toolTip = fallbackName + } + toolbarView.addSubview(filenameLabel) + + // 5. Image Container BINNEN de main container (BOVENAAN) + let imageFrame = NSRect(x: containerPadding, + y: containerPadding + fixedToolbarHeight + spacingBelowToolbar, + width: imageWidth, + height: imageHeight) + let imageContainer = NSView(frame: imageFrame) + imageContainer.wantsLayer = true + imageContainer.layer?.cornerRadius = imageCornerRadius + imageContainer.layer?.masksToBounds = true + imageContainer.layer?.backgroundColor = NSColor.clear.cgColor + + // 6. DraggableImageView DIRECT in image container + let imageView = DraggableImageView(frame: imageContainer.bounds) + imageView.wantsLayer = true + imageView.clipsToBounds = true + imageView.image = image + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.imageAlignment = .alignCenter + imageView.layer?.cornerRadius = imageCornerRadius + imageView.layer?.masksToBounds = true + imageView.layer?.backgroundColor = NSColor.clear.cgColor + imageView.appDelegate = delegate as? ScreenshotApp + + // ๐Ÿ”ง FIX: Set imageURL for dragging functionality + // In BGR mode, this is set elsewhere, but for normal screenshots we need the current tempURL + if !isBackgroundRemovalMode { + imageView.imageURL = delegate?.getTempURL() + print("๐Ÿ”ง DraggableImageView: Set imageURL for normal screenshot: \(imageView.imageURL?.lastPathComponent ?? "nil")") + } + + currentImageView = imageView + imageContainer.addSubview(imageView) + + // 7. Window Assembly - SIMPLIFIED + let totalWindowWidth = mainContainerWidth + (2 * shadowPadding) + let totalWindowHeight = mainContainerHeight + (2 * shadowPadding) + let windowFrame = NSRect(x: 0, y: 0, width: totalWindowWidth, height: totalWindowHeight) + + let window = NSWindow(contentRect: windowFrame, styleMask: [.borderless], backing: .buffered, defer: false) + window.level = .floating + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = false + + let rootView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + rootView.wantsLayer = true + rootView.layer?.masksToBounds = false + rootView.layer?.backgroundColor = NSColor.clear.cgColor + window.contentView = rootView + + // FIXED HIERARCHY: root -> shadowContainer -> mainContainer -> (toolbar + imageContainer) + shadowContainer.addSubview(mainContainer) + rootView.addSubview(shadowContainer) + mainContainer.addSubview(toolbarView) + mainContainer.addSubview(imageContainer) + + // Debug prints + print("DEBUG: Total Window Frame: \(windowFrame)") + print("DEBUG: Main Container Frame: \(mainContainer.frame)") + print("DEBUG: Toolbar Frame: \(toolbarView.frame)") + print("DEBUG: Image Container Frame: \(imageContainer.frame)") + + // 5. Positioneer het window op het juiste scherm + guard let screen = getTargetScreenForThumbnail() else { + print("ERROR: Kon doelscherm niet bepalen") + return nil + } + let screenVisibleFrame = screen.visibleFrame + let screenEdgePadding: CGFloat = 20 + let windowOriginX = screenVisibleFrame.maxX - totalWindowWidth - screenEdgePadding + let windowOriginY = screenVisibleFrame.minY + screenEdgePadding + window.setFrameOrigin(NSPoint(x: windowOriginX, y: windowOriginY)) + + return window + } + + // MARK: - Loading Indicator + func showLoadingIndicator() { + guard !isShowingLoadingIndicator else { return } + + print("๐Ÿ”„ ShowLoadingIndicator: Starting loading indicator...") + isShowingLoadingIndicator = true + + // Bepaal de positie en grootte op basis van de thumbnail locatie + guard let targetScreen = getTargetScreenForThumbnail() else { + print("โŒ Could not determine target screen for loading indicator") + return + } + + // AANGEPAST: Nieuwe afmetingen - breder en minder hoog + let loadingSize = NSSize(width: 200, height: 70) + let screenVisibleFrame = targetScreen.visibleFrame + let screenEdgePadding: CGFloat = 20 + + // Plaats de loading indicator op dezelfde positie als waar de thumbnail zou komen + let loadingOriginX = screenVisibleFrame.maxX - loadingSize.width - screenEdgePadding + let loadingOriginY = screenVisibleFrame.minY + screenEdgePadding + + let loadingFrame = NSRect(x: loadingOriginX, y: loadingOriginY, width: loadingSize.width, height: loadingSize.height) + + // Creรซer het loading overlay window + let loadingWindow = NSWindow( + contentRect: loadingFrame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + + loadingWindow.backgroundColor = NSColor.clear + loadingWindow.isOpaque = false + loadingWindow.hasShadow = true + loadingWindow.level = .floating + loadingWindow.ignoresMouseEvents = true + loadingWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + // AANGEPAST: Start met alpha 0 voor fade-in animatie + loadingWindow.alphaValue = 0 + + // Creรซer de loading content view + let loadingContentView = NSView(frame: NSRect(origin: .zero, size: loadingSize)) + loadingContentView.wantsLayer = true + loadingContentView.layer?.cornerRadius = 12 + loadingContentView.layer?.backgroundColor = ThemeManager.shared.containerBackground.cgColor + loadingContentView.layer?.shadowColor = ThemeManager.shared.shadowColor.cgColor + loadingContentView.layer?.shadowOpacity = ThemeManager.shared.shadowOpacity + loadingContentView.layer?.shadowRadius = 6 + loadingContentView.layer?.shadowOffset = CGSize(width: 0, height: -2) + + // AANGEPAST: Horizontale progress bar in plaats van spinning indicator + let progressBarWidth: CGFloat = 140 + let progressBarHeight: CGFloat = 6 + let progressBar = NSProgressIndicator(frame: NSRect( + x: (loadingSize.width - progressBarWidth) / 2, + y: 25, + width: progressBarWidth, + height: progressBarHeight + )) + progressBar.style = .bar + progressBar.isIndeterminate = true + progressBar.controlSize = .regular + progressBar.startAnimation(nil) + + // AANGEPAST: Label positionering aangepast voor nieuwe layout + let loadingLabel = NSTextField(frame: NSRect(x: 10, y: 40, width: loadingSize.width - 20, height: 20)) + loadingLabel.isEditable = false + loadingLabel.isSelectable = false + loadingLabel.isBordered = false + loadingLabel.backgroundColor = NSColor.clear + loadingLabel.textColor = ThemeManager.shared.primaryTextColor + loadingLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium) + loadingLabel.alignment = .center + loadingLabel.stringValue = "Stitching Screens..." + + loadingContentView.addSubview(progressBar) + loadingContentView.addSubview(loadingLabel) + loadingWindow.contentView = loadingContentView + + // Toon het window + loadingWindow.makeKeyAndOrderFront(nil) + loadingOverlayWindow = loadingWindow + + // NIEUW: Fade-in animatie + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.3 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + loadingWindow.animator().alphaValue = 1.0 + }, completionHandler: { + print("โœ… Loading indicator fade-in complete at \(loadingFrame)") + }) + } + + func hideLoadingIndicator() { + guard isShowingLoadingIndicator, let loadingWindow = loadingOverlayWindow else { return } + + print("๐Ÿ”„ HideLoadingIndicator: Hiding loading indicator...") + isShowingLoadingIndicator = false + + // AANGEPAST: Fade-out animatie + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.4 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + loadingWindow.animator().alphaValue = 0 + }, completionHandler: { [weak self] in + loadingWindow.orderOut(nil) + self?.loadingOverlayWindow = nil + print("โœ… Loading indicator fade-out complete and window closed") + }) + } + + // MARK: - Button Actions (delegate to main app) + @objc func saveFromPreview() { + delegate?.saveFromPreview(self) + } + + @objc func openScreenshotFolder() { + delegate?.openScreenshotFolder() + } + + @objc func openSettings() { + delegate?.openSettings(self) + } + + func closePreview() { + print("๐Ÿงผ Cleaning up preview resources...") + + // FIXED: Bewaar de huidige tempURL voordat we gaan clearen + let currentTempURL = delegate?.getTempURL() + + // Close window first + if let window = activePreviewWindow { + window.orderOut(nil) + window.close() + } + activePreviewWindow = nil + + // FIXED: Alleen de tempURL clearen als er geen nieuwe screenshot bezig is + // Dit voorkomt dat we de net gemaakte screenshot URL wegwissen + if let tempURL = currentTempURL { + let filename = tempURL.lastPathComponent + // Controleer of dit de oude screenshot is of een nieuwe + // Als de timestamp meer dan 5 seconden oud is, clean het op + let now = Date() + let fileCreationTime = (try? FileManager.default.attributesOfItem(atPath: tempURL.path)[.creationDate] as? Date) ?? now + let timeDifference = now.timeIntervalSince(fileCreationTime) + + if timeDifference > 5.0 { + // Dit is een oude screenshot, veilig om op te ruimen + delegate?.setTempFileURL(nil) + print("๐Ÿ—‘๏ธ Cleaned up old screenshot file: \(filename)") + } else { + // Dit is een nieuwe screenshot, NIET opruimen + print("โš ๏ธ Preserving recent screenshot file: \(filename) (created \(String(format: "%.1f", timeDifference))s ago)") + } + } + } +} + +// MARK: - Custom Tracking Area +private class HoverTrackingArea: NSTrackingArea { + // This will be used to identify our custom tracking areas +} + +// MARK: - PreviewManager Delegate Protocol +protocol PreviewManagerDelegate: AnyObject { + func getLastImage() -> NSImage? + func setLastImage(_ image: NSImage) + func clearTempFile() + func getTempURL() -> URL? + func findFilenameLabel(in window: NSWindow?) -> NSTextField? + func openScreenshotFolder() + func openSettings(_ sender: Any?) + func saveFromPreview(_ sender: Any) + func setTempFileURL(_ url: URL?) +} + +// MARK: - Custom Button with Built-in Hover +class HoverButton: NSButton { + private var hoverHandler: ButtonHoverHandler? + private var trackingArea: NSTrackingArea? + + override func awakeFromNib() { + super.awakeFromNib() + setupHover() + } + + func setupHover(zoomScale: CGFloat = 1.3) { + wantsLayer = true + + // Store current theme-aware color as original + let originalColor = ThemeManager.shared.buttonTintColor + + // Create hover handler with custom zoom scale + hoverHandler = ButtonHoverHandler(button: self, zoomScale: zoomScale) + + // Store theme-aware original color + objc_setAssociatedObject(self, "originalColor", originalColor, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + // Setup tracking area + updateTrackingAreas() + + print("๐ŸŽฏ SETUP: HoverButton setup complete with \(zoomScale)x zoom and theme-aware colors") + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + // Remove existing tracking area + if let existing = trackingArea { + removeTrackingArea(existing) + } + + // Create new tracking area + trackingArea = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + if let trackingArea = trackingArea { + addTrackingArea(trackingArea) + print("๐ŸŽฏ SETUP: HoverButton tracking area added") + } + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + print("๐ŸŽฏ HOVER: HoverButton mouseEntered") + hoverHandler?.mouseEntered(with: event) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + print("๐ŸŽฏ HOVER: HoverButton mouseExited") + hoverHandler?.mouseExited(with: event) + } +} + +// MARK: - BGR Overlay Button with Hover Tooltips +class BGROverlayButton: NSButton { + enum TooltipPosition { + case left, right + } + + var tooltipText: String = "" + var tooltipPosition: TooltipPosition = .left + weak var parentContainer: NSView? + + private var tooltipView: NSView? + private var trackingArea: NSTrackingArea? + + override func updateTrackingAreas() { + super.updateTrackingAreas() + + // Remove existing tracking area + if let existing = trackingArea { + removeTrackingArea(existing) + } + + // Create new tracking area + trackingArea = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + if let trackingArea = trackingArea { + addTrackingArea(trackingArea) + print("๐ŸŽฏ BGR Button: Tracking area added - bounds: \(bounds), tooltipText: '\(tooltipText)'") + } + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + print("๐ŸŽฏ BGR Button: Mouse entered - tooltipText: '\(tooltipText)'") + showTooltip() + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + print("๐ŸŽฏ BGR Button: Mouse exited") + hideTooltip() + } + + private func showTooltip() { + guard !tooltipText.isEmpty, + let container = parentContainer else { + print("๐Ÿšซ Tooltip: Missing text or container - text: '\(tooltipText)', container: \(parentContainer != nil)") + return + } + + // Force cleanup any existing tooltip first + if let existingTooltip = tooltipView { + print("๐Ÿงน Tooltip: Cleaning up existing tooltip before showing new one") + existingTooltip.removeFromSuperview() + tooltipView = nil + } + + print("โœจ Tooltip: Showing '\(tooltipText)' at position \(tooltipPosition)") + + // Create simple text tooltip (no button/container styling) + let tooltipLabel = NSTextField() + tooltipLabel.stringValue = tooltipText + tooltipLabel.textColor = .white + tooltipLabel.backgroundColor = .clear + tooltipLabel.font = NSFont.systemFont(ofSize: 11, weight: .medium) + tooltipLabel.alignment = .center + tooltipLabel.isBordered = false + tooltipLabel.isEditable = false + tooltipLabel.isSelectable = false + tooltipLabel.sizeToFit() + + // Add subtle text shadow for readability + tooltipLabel.wantsLayer = true + tooltipLabel.layer?.shadowColor = NSColor.black.cgColor + tooltipLabel.layer?.shadowOpacity = 0.8 + tooltipLabel.layer?.shadowRadius = 2 + tooltipLabel.layer?.shadowOffset = CGSize(width: 0, height: -1) + + // Position text tooltip relative to button in container coordinates + let buttonFrameInContainer = self.frame + let buttonCenterY = buttonFrameInContainer.midY + let tooltipY = buttonCenterY - (tooltipLabel.frame.height / 2) + + let tooltipX: CGFloat + let spacing: CGFloat = 8 + + switch tooltipPosition { + case .left: + tooltipX = buttonFrameInContainer.minX - tooltipLabel.frame.width - spacing + case .right: + tooltipX = buttonFrameInContainer.maxX + spacing + } + + tooltipLabel.frame = NSRect(x: tooltipX, y: tooltipY, width: tooltipLabel.frame.width, height: tooltipLabel.frame.height) + + print("๐Ÿ“ Tooltip: Button frame: \(buttonFrameInContainer), Tooltip frame: \(tooltipLabel.frame)") + + // Add directly to container (no wrapper view) + container.addSubview(tooltipLabel) + + // Fade in animation + tooltipLabel.alphaValue = 0 + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + tooltipLabel.animator().alphaValue = 1.0 + } + + self.tooltipView = tooltipLabel + print("โœ… Tooltip: Added to container with \(container.subviews.count) total subviews") + } + + private func hideTooltip() { + guard let tooltip = tooltipView else { + print("๐Ÿšซ Tooltip: No tooltip to hide") + return + } + + print("๐ŸŽฏ Tooltip: Hiding tooltip - current alpha: \(tooltip.alphaValue)") + + // Force immediate removal without animation to prevent hanging + tooltip.removeFromSuperview() + self.tooltipView = nil + print("โœ… Tooltip: Force removed from view") + } + + // ๐Ÿ”ง NEW: Public method to force hide tooltips (for cleanup) + func forceHideTooltip() { + hideTooltip() + } +} + +// MARK: - Background Removal Mode Extension +extension PreviewManager { + + // ๐ŸŽจ NEW: Show BGR preview with identical thumbnail UI + func showBackgroundRemovalPreview(originalImage: NSImage, originalURL: URL) { + print("๐ŸŽจ PreviewManager: Starting BGR preview with thumbnail UI") + + // ๐Ÿ”„ NEW: Set transition flag to prevent force reset during BGR action + isBGRTransition = true + + // Store the original image + self.originalImage = originalImage + self.processedImage = nil + self.isShowingProcessedImage = false + + // Store original image URL from parameter (not delegate which is cleared) + self.originalImageURL = originalURL + self.processedImageURL = nil + + print("๐ŸŽจ BGR: Stored original URL: \(originalURL.path)") + + // Enable BGR mode + isBackgroundRemovalMode = true + + // ๐Ÿ”ง CRITICAL FIX: Check if there's already an active preview window and user has closeAfterDrag OFF + let hasActivePreview = activePreviewWindow != nil + let shouldReusePreview = hasActivePreview && !SettingsManager.shared.closeAfterDrag + + if shouldReusePreview { + print("๐Ÿ”„ BGR: Reusing existing preview window (closeAfterDrag is OFF)") + + // Update existing window for BGR mode + if let window = activePreviewWindow { + window.level = .popUpMenu // Higher level than .floating to stay on top + print("๐ŸŽจ BGR: Set window level to .popUpMenu for always-on-top behavior") + } + + // Update existing image view + if let imageView = currentImageView { + imageView.image = originalImage + imageView.imageURL = originalImageURL + print("๐ŸŽจ BGR: Updated existing imageView with BGR image and URL") + } + + // Update filename label to show original filename in BGR mode + updateBGRFilenameLabel() + + } else { + print("๐Ÿ”„ BGR: Creating new preview window (closeAfterDrag is ON or no existing preview)") + + // Use existing thumbnail system to create new window + showPreview(image: originalImage) + + // ๐ŸŽจ NEW: Set higher window level for BGR mode to stay on top + if let window = activePreviewWindow { + window.level = .popUpMenu // Higher level than .floating to stay on top + print("๐ŸŽจ BGR: Set window level to .popUpMenu for always-on-top behavior") + } + + // ๐ŸŽจ NEW: Initialize DraggableImageView with original URL for BGR mode + if isBackgroundRemovalMode { + currentImageView?.imageURL = originalImageURL + print("๐ŸŽจ BGR: Set initial imageURL to original: \(originalImageURL?.path ?? "nil")") + } + + // ๐ŸŽจ NEW: Update filename label to show original filename in BGR mode + updateBGRFilenameLabel() + } + + // Add BGR overlay buttons (for both cases) + DispatchQueue.main.async { + self.addBGROverlayButtons() + + // Start automatic BGR processing + self.startBackgroundRemovalProcessing() + + // ๐Ÿ”„ NEW: Clear transition flag after BGR setup is complete + self.isBGRTransition = false + print("๐Ÿ”„ BGR transition flag cleared - normal state management resumed") + } + } + + // ๐ŸŽจ NEW: Add small overlay buttons IN the image area + private func addBGROverlayButtons() { + guard let _ = activePreviewWindow, + let imageView = currentImageView, + let imageContainer = imageView.superview else { + print("โŒ Cannot add BGR buttons: missing components") + return + } + + let buttonSize: CGFloat = 28 + let buttonSpacing: CGFloat = 8 + let bottomMargin: CGFloat = 12 + + // Position buttons at bottom center of image + let totalButtonsWidth = (buttonSize * 2) + buttonSpacing + let startX = (imageContainer.bounds.width - totalButtonsWidth) / 2 + let buttonY = bottomMargin + + // Reset button (โ†บ) + let resetButton = createBGROverlayButton( + frame: NSRect(x: startX, y: buttonY, width: buttonSize, height: buttonSize), + symbolName: "arrow.clockwise" + ) + resetButton.action = #selector(bgrResetButtonClicked) + resetButton.target = self + resetButton.isHidden = true // Initially hidden until processing is complete + resetButton.tooltipText = "Reset" + resetButton.tooltipPosition = .left + resetButton.parentContainer = imageContainer + + // Toggle button (๐Ÿ‘) + let toggleButton = createBGROverlayButton( + frame: NSRect(x: startX + buttonSize + buttonSpacing, y: buttonY, width: buttonSize, height: buttonSize), + symbolName: "eye" + ) + toggleButton.action = #selector(bgrToggleButtonClicked) + toggleButton.target = self + toggleButton.isHidden = true // Initially hidden until processing is complete + toggleButton.tooltipText = "Compare" + toggleButton.tooltipPosition = .right + toggleButton.parentContainer = imageContainer + + // Add to image container + imageContainer.addSubview(resetButton) + imageContainer.addSubview(toggleButton) + + // Store references + bgrResetButton = resetButton + bgrToggleButton = toggleButton + + print("๐ŸŽจ BGR overlay buttons added to image container") + } + + // ๐ŸŽจ NEW: Add BGR progress bar in center of image + private func addBGRProgressBar() { + guard let _ = activePreviewWindow, + let imageView = currentImageView, + let imageContainer = imageView.superview else { + print("โŒ Cannot add BGR progress bar: missing components") + return + } + + // Progress container dimensions + let containerWidth: CGFloat = 200 + let containerHeight: CGFloat = 60 + let containerX = (imageContainer.bounds.width - containerWidth) / 2 + let containerY = (imageContainer.bounds.height - containerHeight) / 2 + + // Create progress container with glass effect + let progressContainer = NSView(frame: NSRect(x: containerX, y: containerY, width: containerWidth, height: containerHeight)) + progressContainer.wantsLayer = true + progressContainer.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.7).cgColor + progressContainer.layer?.cornerRadius = 12 + progressContainer.layer?.masksToBounds = true + progressContainer.layer?.borderColor = NSColor.white.withAlphaComponent(0.2).cgColor + progressContainer.layer?.borderWidth = 1 + + // Create progress bar + let progressBarWidth: CGFloat = 160 + let progressBarHeight: CGFloat = 6 + let progressBarX = (containerWidth - progressBarWidth) / 2 + let progressBarY = containerHeight / 2 + 8 + + let progressBar = NSProgressIndicator(frame: NSRect(x: progressBarX, y: progressBarY, width: progressBarWidth, height: progressBarHeight)) + progressBar.style = .bar + progressBar.isIndeterminate = false + progressBar.minValue = 0 + progressBar.maxValue = 100 + progressBar.doubleValue = 0 + progressBar.wantsLayer = true + progressBar.layer?.cornerRadius = 3 + + // Create progress label + let progressLabel = NSTextField(frame: NSRect(x: 0, y: containerHeight / 2 - 20, width: containerWidth, height: 20)) + progressLabel.stringValue = "Removing background... 0%" + progressLabel.textColor = .white + progressLabel.backgroundColor = .clear + progressLabel.font = NSFont.systemFont(ofSize: 12, weight: .medium) + progressLabel.alignment = .center + progressLabel.isBordered = false + progressLabel.isEditable = false + progressLabel.isSelectable = false + + // Add subviews + progressContainer.addSubview(progressBar) + progressContainer.addSubview(progressLabel) + imageContainer.addSubview(progressContainer) + + // Store references + bgrProgressContainer = progressContainer + bgrProgressBar = progressBar + bgrProgressLabel = progressLabel + + // Fade in animation + progressContainer.alphaValue = 0 + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + progressContainer.animator().alphaValue = 1.0 + } + + print("๐ŸŽจ BGR progress bar added to image container") + } + + // ๐ŸŽจ NEW: Update BGR progress bar + private func updateBGRProgress(_ progress: Double, status: String) { + DispatchQueue.main.async { + self.bgrProgressBar?.doubleValue = progress + self.bgrProgressLabel?.stringValue = "\(status) \(Int(progress))%" + } + } + + // ๐ŸŽจ NEW: Hide BGR progress bar + private func hideBGRProgressBar() { + guard let progressContainer = bgrProgressContainer else { return } + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.4 + progressContainer.animator().alphaValue = 0 + }) { + progressContainer.removeFromSuperview() + self.bgrProgressContainer = nil + self.bgrProgressBar = nil + self.bgrProgressLabel = nil + } + } + + // ๐ŸŽจ NEW: Create clean BGR overlay button (icon only, no button styling) + private func createBGROverlayButton(frame: NSRect, symbolName: String) -> BGROverlayButton { + let button = BGROverlayButton(frame: frame) + + // Completely transparent button - no background or borders + button.wantsLayer = true + button.layer?.backgroundColor = NSColor.clear.cgColor + + // No button styling + button.isBordered = false + button.bezelStyle = .shadowlessSquare + + // SF Symbol icon with subtle shadow for visibility (20% smaller: 16pt -> 13pt) + let symbolConfig = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold) + button.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)?.withSymbolConfiguration(symbolConfig) + button.contentTintColor = .white + + // Add subtle shadow to icon for better visibility + button.layer?.shadowColor = NSColor.black.cgColor + button.layer?.shadowOpacity = 0.7 + button.layer?.shadowRadius = 2 + button.layer?.shadowOffset = CGSize(width: 0, height: -1) + + // Ensure tracking areas are set up + button.updateTrackingAreas() + + return button + } + + // ๐ŸŽจ NEW: Start BGR processing automatically + private func startBackgroundRemovalProcessing() { + guard let originalImg = originalImage else { + print("โŒ No original image for BGR processing") + return + } + + print("๐ŸŽจ Starting automatic BGR processing with user's preferred method...") + + // Show progress bar + addBGRProgressBar() + + // Store processing result to be accessed by progress checker + var processingResult: NSImage? = nil + var isProcessingComplete = false + + // Start actual background removal processing + BackgroundRemover.shared.processWithPreferredMethod(from: originalImg) { [weak self] result in + processingResult = result + isProcessingComplete = true + print("๐ŸŽจ BGR: Background removal processing completed with result: \(result != nil ? "success" : "failed")") + } + + // Start progress simulation that waits for actual completion + simulateBGRProgress( + checkProcessingComplete: { isProcessingComplete }, + getProcessingResult: { processingResult } + ) { [weak self] finalResult in + // This runs when both progress and processing are complete + DispatchQueue.main.async { + // Hide progress bar + self?.hideBGRProgressBar() + + if let processedImg = finalResult { + print("โœ… BGR processing completed successfully") + self?.processedImage = processedImg + + // ๐ŸŽจ NEW: Save processed image to file for dragging + self?.saveProcessedImageToFile(processedImg) + + // Update image view to show processed image + self?.currentImageView?.image = processedImg + self?.isShowingProcessedImage = true + + // ๐ŸŽจ NEW: Update DraggableImageView to use processed image URL + self?.updateDraggableImageURL() + + // ๐ŸŽจ NEW: Update filename label to show processed filename + self?.updateBGRFilenameLabel() + + // Show overlay buttons + self?.bgrResetButton?.isHidden = false + self?.bgrToggleButton?.isHidden = false + + // Add subtle animation to reveal buttons + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.5 + self?.bgrResetButton?.animator().alphaValue = 1.0 + self?.bgrToggleButton?.animator().alphaValue = 1.0 + } + + } else { + print("โŒ BGR processing failed") + // Keep original image showing, don't show overlay buttons + } + } + } + } + + // ๐ŸŽจ NEW: Smart BGR progress that adapts to actual processing speed + private func simulateBGRProgress( + checkProcessingComplete: @escaping () -> Bool, + getProcessingResult: @escaping () -> NSImage?, + completion: @escaping (NSImage?) -> Void + ) { + let processingStartTime = Date() + var currentProgress: Double = 0 + + // Start monitoring actual completion immediately + func checkForEarlyCompletion() { + let elapsedTime = Date().timeIntervalSince(processingStartTime) + + // Check if processing is already complete + if checkProcessingComplete() { + let result = getProcessingResult() + print("๐Ÿš€ FAST COMPLETION: BGR finished in \(String(format: "%.1f", elapsedTime)) seconds!") + + // Rapidly complete the progress bar + finishProgressBar(with: result, completion: completion) + return + } + + // Update progress based on elapsed time with smart acceleration + let targetProgress: Double + if elapsedTime < 1.0 { + // Very fast progress for Vision Framework + targetProgress = min(70.0, elapsedTime * 70.0) + updateBGRProgress(targetProgress, status: "Processing...") + } else if elapsedTime < 2.0 { + // Continue fast for Vision Framework + targetProgress = 70.0 + ((elapsedTime - 1.0) * 15.0) + updateBGRProgress(targetProgress, status: "Applying mask...") + } else if elapsedTime < 3.0 { + // Near completion for Vision Framework + targetProgress = 85.0 + ((elapsedTime - 2.0) * 5.0) + updateBGRProgress(targetProgress, status: "Finalizing...") + } else { + // Fallback for slower methods (RMBG with E5RT issues) + targetProgress = min(90.0, 90.0 + ((elapsedTime - 3.0) * 0.5)) + updateBGRProgress(targetProgress, status: "Processing...") + } + + currentProgress = targetProgress + + // Check again in 0.2 seconds for very responsive updates + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + checkForEarlyCompletion() + } + } + + // Start progress immediately + updateBGRProgress(5.0, status: "Starting...") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + checkForEarlyCompletion() + } + } + + // ๐ŸŽจ NEW: Rapidly finish progress bar when processing completes + private func finishProgressBar(with result: NSImage?, completion: @escaping (NSImage?) -> Void) { + // Smoothly animate to 100% + updateBGRProgress(95.0, status: "Complete!") + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + self.updateBGRProgress(100.0, status: "Complete!") + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + completion(result) + } + } + } + + // ๐ŸŽจ NEW: Wait for actual BGR processing to complete + private func waitForActualCompletion( + checkComplete: @escaping () -> Bool, + getResult: @escaping () -> NSImage?, + completion: @escaping (NSImage?) -> Void + ) { + // Check every 0.5 seconds if processing is actually done + func checkCompletion() { + if checkComplete() { + print("๐ŸŽจ Actual BGR processing detected as complete!") + let result = getResult() + completion(result) + } else { + // Keep waiting and checking + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + checkCompletion() + } + } + } + + checkCompletion() + } + + // ๐ŸŽจ NEW: Reset to main thumbnail (exit BGR mode completely) + @objc private func bgrResetButtonClicked() { + guard let originalImg = originalImage else { return } + + print("๐Ÿ”„ BGR Reset: Exiting BGR mode and returning to main thumbnail") + + // Smooth animation to original image first + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.3 + self.currentImageView?.animator().alphaValue = 0.7 + }) { + self.currentImageView?.image = originalImg + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.3 + self.currentImageView?.animator().alphaValue = 1.0 + }) { + // After animation completes, exit BGR mode completely + self.exitBackgroundRemovalMode() + + // Reset to normal thumbnail state + self.showNormalThumbnail(with: originalImg) + + // ๐Ÿ”„ NEW: Handle post-action completion just like main thumbnail + self.handleBGRActionCompletion(actionType: "reset") + } + } + + print("โœ… BGR Reset: Returned to main thumbnail - BGR buttons removed") + } + + // ๐ŸŽจ NEW: Show normal thumbnail (no BGR mode) + private func showNormalThumbnail(with image: NSImage) { + print("๐Ÿ”„ Restoring normal thumbnail state...") + + // Reset all BGR-specific properties (already done in exitBackgroundRemovalMode) + isBackgroundRemovalMode = false + isShowingProcessedImage = false + + // Reset window level to normal + if let window = activePreviewWindow { + window.level = .floating + print("๐ŸŽจ Reset window level to normal (.floating)") + } + + // ๐Ÿ”„ CRITICAL FIX: Enhanced URL validation and recovery + if let originalURL = originalImageURL { + print("๐Ÿ” Checking original URL validity: \(originalURL.path)") + + // Verify file accessibility + if FileManager.default.fileExists(atPath: originalURL.path) { + // File exists - set it properly + currentImageView?.imageURL = originalURL + print("โœ… DraggableImageView reset to verified original URL: \(originalURL.lastPathComponent)") + + // Double verify by trying to load the image + if NSImage(contentsOf: originalURL) != nil { + print("โœ… Original image file loads successfully") + } else { + print("โš ๏ธ WARNING: Original URL exists but image won't load") + } + + } else { + print("โŒ CRITICAL: Original file missing at reset time: \(originalURL.path)") + + // RECOVERY: Try multiple fallback strategies + var recoveredURL: URL? = nil + + // Strategy 1: Check if it's in temp directory + let tempDir = FileManager.default.temporaryDirectory + let fileName = originalURL.lastPathComponent + let tempURL = tempDir.appendingPathComponent(fileName) + if FileManager.default.fileExists(atPath: tempURL.path) { + recoveredURL = tempURL + print("๐Ÿ”„ RECOVERY: Found file in temp directory: \(tempURL.path)") + } + + // Strategy 2: Use DraggableImageView's current URL + if recoveredURL == nil, let fallbackURL = currentImageView?.imageURL { + if FileManager.default.fileExists(atPath: fallbackURL.path) { + recoveredURL = fallbackURL + print("๐Ÿ”„ RECOVERY: Using DraggableImageView fallback URL: \(fallbackURL.path)") + } + } + + // Strategy 3: Try to recreate the file from the current image + if recoveredURL == nil { + print("๐Ÿ”„ RECOVERY: Attempting to save current image to new temp file...") + let newTempURL = FileManager.default.temporaryDirectory.appendingPathComponent("recovered_\(UUID().uuidString).png") + + if let imageData = image.pngData() { + do { + try imageData.write(to: newTempURL) + recoveredURL = newTempURL + print("โœ… RECOVERY: Created new temp file: \(newTempURL.path)") + } catch { + print("โŒ RECOVERY: Failed to save recovery file: \(error)") + } + } + } + + // Apply the recovered URL + if let finalURL = recoveredURL { + originalImageURL = finalURL + currentImageView?.imageURL = finalURL + print("โœ… URL RECOVERED: Now using \(finalURL.lastPathComponent)") + } else { + print("โŒ RECOVERY FAILED: No valid URL available") + } + } + } else { + print("โŒ ERROR: No originalImageURL available for normal thumbnail!") + + // Last resort: Use DraggableImageView's current URL + if let fallbackURL = currentImageView?.imageURL { + originalImageURL = fallbackURL + print("๐Ÿ”„ EMERGENCY: Set originalImageURL from DraggableImageView: \(fallbackURL.lastPathComponent)") + } + } + + // Reset filename label to original + if let finalURL = originalImageURL, + let filenameLabel = delegate?.findFilenameLabel(in: activePreviewWindow) { + filenameLabel.stringValue = finalURL.lastPathComponent + filenameLabel.toolTip = finalURL.lastPathComponent + print("๐ŸŽจ Reset filename label to: \(finalURL.lastPathComponent)") + } + + print("โœ… Normal thumbnail state restored - ready for new actions") + } + + // ๐ŸŽจ NEW: Toggle between original and processed + @objc private func bgrToggleButtonClicked() { + guard let originalImg = originalImage, + let processedImg = processedImage else { return } + + // Determine current state and toggle + let nextImage = isShowingProcessedImage ? originalImg : processedImg + isShowingProcessedImage = !isShowingProcessedImage + let nextSymbol = isShowingProcessedImage ? "eye.slash" : "eye" + + print("๐Ÿ”„ BGR Toggle: Switching to \(isShowingProcessedImage ? "processed" : "original") image") + + // Update image with animation + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.3 + self.currentImageView?.animator().alphaValue = 0.7 + }) { + self.currentImageView?.image = nextImage + + // ๐ŸŽจ NEW: Update DraggableImageView URL for proper dragging + self.updateDraggableImageURL() + + // ๐ŸŽจ NEW: Update filename label to reflect current image + self.updateBGRFilenameLabel() + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + self.currentImageView?.animator().alphaValue = 1.0 + } + } + + // Update toggle button icon + let symbolConfig = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold) + bgrToggleButton?.image = NSImage(systemSymbolName: nextSymbol, accessibilityDescription: nil)?.withSymbolConfiguration(symbolConfig) + } + + // ๐ŸŽจ NEW: Exit BGR mode and return to normal thumbnail + func exitBackgroundRemovalMode() { + print("๐ŸŽจ Exiting BGR mode") + + // ๐Ÿ”„ PRESERVE original image and URL for normal thumbnail to continue working + let preservedOriginalImage = originalImage + let preservedOriginalURL = originalImageURL + + // Use the force reset to clean everything up + forceCompleteBGRReset() + + // ๐Ÿ”„ CRITICAL: Restore the preserved values for normal thumbnail functionality + originalImage = preservedOriginalImage + originalImageURL = preservedOriginalURL + + print("โœ… Preserved originalImage and originalImageURL for normal thumbnail: \(originalImageURL?.lastPathComponent ?? "nil")") + + // Verify the file still exists and report detailed status + if let originalURL = originalImageURL { + if FileManager.default.fileExists(atPath: originalURL.path) { + print("โœ… VERIFIED: Original file exists and is accessible: \(originalURL.path)") + } else { + print("โŒ CRITICAL: Original file missing after BGR reset: \(originalURL.path)") + + // Try to get alternative URL from DraggableImageView + if let fallbackURL = currentImageView?.imageURL { + print("๐Ÿ”„ Attempting fallback URL: \(fallbackURL.path)") + if FileManager.default.fileExists(atPath: fallbackURL.path) { + originalImageURL = fallbackURL + print("โœ… Using fallback URL: \(fallbackURL.path)") + } + } + } + } + } + + // ๐ŸŽจ NEW: Save processed image to file for dragging + private func saveProcessedImageToFile(_ image: NSImage) { + guard let originalURL = originalImageURL else { + print("โŒ No original image URL found to save processed image") + return + } + + // Create processed image filename + let originalFilename = originalURL.deletingPathExtension().lastPathComponent + let processedFilename = "\(originalFilename)_BGR.png" + let processedURL = originalURL.deletingLastPathComponent().appendingPathComponent(processedFilename) + + // Save processed image to file + do { + if let pngData = image.pngData() { + try pngData.write(to: processedURL) + self.processedImageURL = processedURL + print("โœ… Processed image saved to: \(processedURL.path)") + } else { + print("โŒ Failed to convert processed image to PNG data") + } + } catch { + print("โŒ Error saving processed image: \(error)") + } + } + + // ๐ŸŽจ NEW: Update DraggableImageView to use processed image URL + private func updateDraggableImageURL() { + guard isBackgroundRemovalMode else { return } + + // Use the correct URL based on which image is currently showing + let currentURL = isShowingProcessedImage ? processedImageURL : originalImageURL + + // Update DraggableImageView to use the correct URL + if let url = currentURL { + currentImageView?.imageURL = url + print("โœ… DraggableImageView updated to use \(isShowingProcessedImage ? "processed" : "original") image URL: \(url.lastPathComponent)") + } else { + print("โŒ No URL available for \(isShowingProcessedImage ? "processed" : "original") image") + } + } + + // ๐ŸŽจ NEW: Update filename label to show original filename in BGR mode + private func updateBGRFilenameLabel() { + guard let originalURL = originalImageURL else { + print("โŒ No original image URL found to update filename label") + return + } + + // Determine which filename to show based on current image + let displayURL: URL + if isShowingProcessedImage, let processedURL = processedImageURL { + displayURL = processedURL + } else { + displayURL = originalURL + } + + // Update filename label to show correct filename in BGR mode + if let filenameLabel = delegate?.findFilenameLabel(in: activePreviewWindow) { + filenameLabel.stringValue = displayURL.lastPathComponent + filenameLabel.toolTip = displayURL.lastPathComponent + print("๐ŸŽจ BGR: Updated filename label to: \(displayURL.lastPathComponent)") + } else { + print("โŒ No filename label found in active preview window") + } + } + + // ๐ŸŽจ NEW: Handle post-action completion just like main thumbnail + private func handleBGRActionCompletion(actionType: String) { + print("๐ŸŽจ BGR Action Completion: Handling '\(actionType)' action completion") + + // BGR reset action should behave like a "cancel" action - no specific closing behavior + // The user explicitly chose to go back to the main thumbnail, so keep it visible + print("โ„น๏ธ BGR reset action: Keeping thumbnail visible after reset") + } + + // ๐Ÿ”„ CRITICAL: Force complete BGR state reset for new screenshots + private func forceCompleteBGRReset() { + print("๐Ÿ”„ FORCE RESET: Starting complete BGR state reset...") + + // 1. Force hide any hanging tooltips immediately + bgrResetButton?.forceHideTooltip() + bgrToggleButton?.forceHideTooltip() + + // 2. Remove BGR overlay buttons immediately + bgrResetButton?.removeFromSuperview() + bgrToggleButton?.removeFromSuperview() + bgrResetButton = nil + bgrToggleButton = nil + + // 3. Remove BGR progress bar immediately + if let progressContainer = bgrProgressContainer { + progressContainer.removeFromSuperview() + bgrProgressContainer = nil + bgrProgressBar = nil + bgrProgressLabel = nil + } + + // 4. Clean up BGR file references + if let processedURL = processedImageURL { + try? FileManager.default.removeItem(at: processedURL) + print("๐Ÿ—‘๏ธ Force cleaned processed image file: \(processedURL.lastPathComponent)") + } + processedImageURL = nil + + // 5. Reset all BGR mode properties to clean state + isBackgroundRemovalMode = false + isBGRTransition = false // ๐Ÿ”„ NEW: Also reset transition flag + originalImage = nil + processedImage = nil + originalImageURL = nil + isShowingProcessedImage = false + + // 6. Reset window level if needed + if let window = activePreviewWindow { + window.level = .floating + print("๐Ÿ”„ Reset window level to normal (.floating)") + } + + print("โœ… FORCE RESET: Complete BGR state reset finished - ready for normal thumbnail") + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/RenameActionHandler.swift b/ShotScreen/Sources/RenameActionHandler.swift new file mode 100755 index 0000000..a570d72 --- /dev/null +++ b/ShotScreen/Sources/RenameActionHandler.swift @@ -0,0 +1,711 @@ +import AppKit +import UniformTypeIdentifiers + +// NIEUW: Voeg ConflictType enum definitie toe +enum ConflictType { + case renameOnly + case saveToFolder + // TODO: case moveToApplications // Als dit later nodig is +} + +// NIEUW: Custom TextFieldCell voor verticale aanpassing van tekst +class VerticallyAdjustedTextFieldCell: NSTextFieldCell { + var verticalOffset: CGFloat = 0 + // didSet is hier mogelijk niet effectief genoeg, we forceren de update in drawingRect + // { + // didSet { + // self.controlView?.needsDisplay = true + // } + // } + + override func drawingRect(forBounds rect: NSRect) -> NSRect { + var drawingRect = super.drawingRect(forBounds: rect) + // Een positieve offset verplaatst de tekst naar beneden + drawingRect.origin.y = drawingRect.origin.y + verticalOffset + if let controlView = self.controlView { + controlView.setNeedsDisplay(controlView.bounds) // Correcte aanroep met bounds + } + return drawingRect + } +} + +// NIEUW: Custom Button klasse met icoon en subtiele tekst +class IconTextButton: NSButton { + + convenience init(sfSymbolName: String, title: String, target: AnyObject?, action: Selector?) { + self.init() + + self.target = target + self.action = action + + let symbolConfig = NSImage.SymbolConfiguration(pointSize: 12, weight: .regular) + if let iconImage = NSImage(systemSymbolName: sfSymbolName, accessibilityDescription: title)?.withSymbolConfiguration(symbolConfig) { + self.image = iconImage + } + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor(white: 0.90, alpha: 0.85), + .font: NSFont.systemFont(ofSize: 10, weight: .medium), + .paragraphStyle: paragraphStyle + ] + self.attributedTitle = NSAttributedString(string: title, attributes: attributes) + + self.setButtonType(.momentaryPushIn) + self.isBordered = false + self.imagePosition = .imageLeading + self.imageHugsTitle = true + self.alignment = .center + + self.wantsLayer = true + self.layer?.backgroundColor = NSColor.clear.cgColor + self.layer?.cornerRadius = 5 + + (self.cell as? NSButtonCell)?.setAccessibilityLabel(title) + } + + private var trackingArea: NSTrackingArea? + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let existingTrackingArea = self.trackingArea { + self.removeTrackingArea(existingTrackingArea) + } + let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways, .inVisibleRect] + trackingArea = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) + if let ta = trackingArea { self.addTrackingArea(ta) } + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.15 + context.allowsImplicitAnimation = true + self.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.15).cgColor + }, completionHandler: nil) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + context.allowsImplicitAnimation = true + self.layer?.backgroundColor = NSColor.clear.cgColor + }, completionHandler: nil) + } + + override var intrinsicContentSize: NSSize { + var size = super.intrinsicContentSize + size.width += 16 // Extra padding: 8pt left, 8pt right + size.height = 24 + return size + } +} + +// Custom Panel for Rename Input +class RenamePromptPanel: NSPanel, NSTextFieldDelegate { + var textField: NSTextField! + var saveNameButton: IconTextButton! + var saveToFolderButton: IconTextButton! + var cancelButton: IconTextButton! + + var enteredName: String? { textField.stringValue } + let textFieldTag = 101 + + var onAction: ((NSApplication.ModalResponse) -> Void)? + var animationStartFrame: NSRect? + // private var keepKeyTimer: Timer? // Uitgecommentarieerd + + weak var actionHandlerDelegate: RenameActionHandlerDelegate? + + override var acceptsFirstResponder: Bool { + return true + } + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { // ESC key + closeRenamePanelAndCleanup(response: .cancel) + return + } + super.keyDown(with: event) + } + + private func closeRenamePanelAndCleanup(response: NSApplication.ModalResponse) { + print("๐Ÿงผ DEBUG: closeRenamePanelAndCleanup called with response: \(response)") + // keepKeyTimer?.invalidate() // Uitgecommentarieerd + // keepKeyTimer = nil // Uitgecommentarieerd + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.4 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + self.animator().alphaValue = 0 + if let startFrame = self.animationStartFrame { + let endFrame = NSRect(x: startFrame.midX - 25, y: startFrame.midY - 12.5, width: 50, height: 25) + self.animator().setFrame(endFrame, display: true) + } + }, completionHandler: { + self.orderOut(nil) + self.alphaValue = 1 + + self.actionHandlerDelegate?.enableGridMonitoring() + self.actionHandlerDelegate?.hideGrid() + + self.onAction?(response) + }) + } + + override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) { + super.init(contentRect: contentRect, styleMask: [.borderless, .utilityWindow, .hudWindow, .nonactivatingPanel], backing: backingStoreType, defer: flag) + + self.isFloatingPanel = true + self.level = .floating + 3 // Teruggezet + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + self.isOpaque = false + self.backgroundColor = NSColor.clear + self.hasShadow = false + self.animationBehavior = .utilityWindow + self.becomesKeyOnlyIfNeeded = true + self.hidesOnDeactivate = false + + let visualEffectView = NSVisualEffectView() + visualEffectView.blendingMode = .behindWindow + visualEffectView.material = .hudWindow + visualEffectView.state = .active + visualEffectView.wantsLayer = true + visualEffectView.layer?.cornerRadius = 12.0 + visualEffectView.layer?.masksToBounds = true + visualEffectView.translatesAutoresizingMaskIntoConstraints = false + self.contentView = visualEffectView + + let horizontalPadding: CGFloat = 10 + let verticalPadding: CGFloat = 15 + let textFieldHeight: CGFloat = 19 + let spacingBelowTextField: CGFloat = 12 + let buttonSpacing: CGFloat = 8 + let buttonHeight: CGFloat = 24 + + textField = NSTextField() + textField.tag = textFieldTag + textField.delegate = self + textField.translatesAutoresizingMaskIntoConstraints = false + visualEffectView.addSubview(textField) + textFieldStyling() + + saveNameButton = IconTextButton(sfSymbolName: "checkmark.circle", title: " Save", target: self, action: #selector(buttonClicked(_:))) + saveNameButton.tag = NSApplication.ModalResponse.OK.rawValue + saveNameButton.translatesAutoresizingMaskIntoConstraints = false + visualEffectView.addSubview(saveNameButton) + + saveToFolderButton = IconTextButton(sfSymbolName: "folder.badge.plus", title: " Save", target: self, action: #selector(buttonClicked(_:))) + saveToFolderButton.tag = NSApplication.ModalResponse.continue.rawValue + saveToFolderButton.translatesAutoresizingMaskIntoConstraints = false + visualEffectView.addSubview(saveToFolderButton) + + cancelButton = IconTextButton(sfSymbolName: "xmark.circle", title: " Cancel", target: self, action: #selector(buttonClicked(_:))) + cancelButton.tag = NSApplication.ModalResponse.cancel.rawValue + cancelButton.translatesAutoresizingMaskIntoConstraints = false + visualEffectView.addSubview(cancelButton) + + let buttons = [saveNameButton!, saveToFolderButton!, cancelButton!] + let buttonStackView = NSStackView(views: buttons) + buttonStackView.orientation = .horizontal + buttonStackView.alignment = .centerY + buttonStackView.spacing = buttonSpacing + buttonStackView.distribution = .fillProportionally + buttonStackView.translatesAutoresizingMaskIntoConstraints = false + visualEffectView.addSubview(buttonStackView) + + let minPanelWidth: CGFloat = 280 + let calculatedPanelHeight = verticalPadding + textFieldHeight + spacingBelowTextField + buttonHeight + verticalPadding + self.setContentSize(NSSize(width: minPanelWidth, height: calculatedPanelHeight)) + + NSLayoutConstraint.activate([ + visualEffectView.leadingAnchor.constraint(equalTo: (self.contentView!).leadingAnchor), + visualEffectView.trailingAnchor.constraint(equalTo: (self.contentView!).trailingAnchor), + visualEffectView.topAnchor.constraint(equalTo: (self.contentView!).topAnchor), + visualEffectView.bottomAnchor.constraint(equalTo: (self.contentView!).bottomAnchor), + + textField.topAnchor.constraint(equalTo: visualEffectView.topAnchor, constant: verticalPadding), + textField.leadingAnchor.constraint(equalTo: visualEffectView.leadingAnchor, constant: horizontalPadding), + textField.trailingAnchor.constraint(equalTo: visualEffectView.trailingAnchor, constant: -horizontalPadding), + textField.heightAnchor.constraint(equalToConstant: textFieldHeight), + + buttonStackView.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: spacingBelowTextField), + buttonStackView.centerXAnchor.constraint(equalTo: visualEffectView.centerXAnchor), + buttonStackView.heightAnchor.constraint(equalToConstant: buttonHeight), + buttonStackView.leadingAnchor.constraint(greaterThanOrEqualTo: visualEffectView.leadingAnchor, constant: horizontalPadding), + buttonStackView.trailingAnchor.constraint(lessThanOrEqualTo: visualEffectView.trailingAnchor, constant: -horizontalPadding), + buttonStackView.bottomAnchor.constraint(equalTo: visualEffectView.bottomAnchor, constant: -verticalPadding) + ]) + + let stackViewWidthConstraint = buttonStackView.widthAnchor.constraint(lessThanOrEqualToConstant: minPanelWidth - 2 * horizontalPadding) + stackViewWidthConstraint.priority = .defaultHigh + stackViewWidthConstraint.isActive = true + + // keepKeyTimer = Timer.scheduledTimer(timeInterval: 0.2, target: self, selector: #selector(ensureKeyWindow), userInfo: nil, repeats: true) // Uitgecommentarieerd + self.initialFirstResponder = textField + } + + private func textFieldStyling() { + textField.font = NSFont.systemFont(ofSize: 13) + textField.textColor = NSColor(white: 0.95, alpha: 1.0) + textField.isEditable = true + textField.isSelectable = true + textField.isBordered = false + textField.isBezeled = false + textField.backgroundColor = NSColor.clear + textField.focusRingType = .none + + textField.wantsLayer = true + textField.layer?.cornerRadius = 6.0 + textField.layer?.masksToBounds = true + textField.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.25).cgColor + textField.layer?.borderColor = NSColor.gray.withAlphaComponent(0.4).cgColor + textField.layer?.borderWidth = 0.5 + + let cell = textField.cell as? NSTextFieldCell + cell?.usesSingleLineMode = true + cell?.wraps = false + cell?.isScrollable = true + cell?.isEditable = true + cell?.isSelectable = true + + // Set alignment AFTER configuring the cell + textField.alignment = .center + if let standardCell = textField.cell as? NSTextFieldCell { // Controleer of het een NSTextFieldCell is + standardCell.alignment = .center + } + } + + @objc func ensureKeyWindow() { + if !self.isKeyWindow { + self.makeKeyAndOrderFront(nil) + DispatchQueue.main.async { + _ = self.makeFirstResponder(self.textField) + } + } + } + + @objc func buttonClicked(_ sender: NSButton) { + let response = NSApplication.ModalResponse(rawValue: sender.tag) + print("๐Ÿ” DEBUG: RenamePromptPanel button clicked with tag: \(sender.tag), mapped to response: \(response)") + closeRenamePanelAndCleanup(response: response) + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(insertNewline(_:)) { + if let okButton = saveNameButton { + buttonClicked(okButton) + } + return true + } + return false + } + + +} + +protocol RenameActionHandlerDelegate: AnyObject { + func getScreenshotFolder() -> String? + func renameActionHandler(_ handler: RenameActionHandler, didRenameFileFrom oldURL: URL, to newURL: URL) + func findFilenameLabel(in window: NSWindow?) -> NSTextField? + func setTempFileURL(_ url: URL?) + func getActivePreviewWindow() -> NSWindow? + func closePreviewWithAnimation(immediate: Bool, preserveTempFile: Bool) + func getGridWindowFrame() -> NSRect? + func hideGrid() + func disableGridMonitoring() + func enableGridMonitoring() +} + +class RenameActionHandler { + weak var delegate: RenameActionHandlerDelegate? + private var renamePanel: RenamePromptPanel? + + init(delegate: RenameActionHandlerDelegate) { + self.delegate = delegate + } + + func isRenamePanelActive() -> Bool { + return renamePanel?.isVisible == true + } + + func closeRenamePanelAndCleanup() { + if let panel = self.renamePanel, panel.isVisible { + print("โ„น๏ธ Closing existing rename panel due to new action.") + + if let panelCancelButton = panel.cancelButton { + panel.buttonClicked(panelCancelButton) + } else { + print("โš ๏ธ Could not find cancel button in rename panel to simulate click.") + self.renamePanel = nil + } + } + } + + private func sanitize(name: String) -> String { + var characterSet = CharacterSet.alphanumerics + characterSet.insert(charactersIn: "-_") + let sanitized = name.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: characterSet.inverted).joined() + return sanitized.isEmpty ? "screenshot" : sanitized + } + + func promptAndRename(originalURL: URL, completion: @escaping (NSApplication.ModalResponse) -> Void) { + print("๐Ÿ” DEBUG: promptAndRename called with URL: \(originalURL)") + print("๐Ÿ” DEBUG: Current working directory: \(FileManager.default.currentDirectoryPath)") + print("๐Ÿ” DEBUG: Original URL exists: \(FileManager.default.fileExists(atPath: originalURL.path))") + + if let existingPanel = self.renamePanel, existingPanel.isVisible { + print("โ„น๏ธ Rename panel already visible. Bringing to front.") + existingPanel.makeKeyAndOrderFront(nil) + completion(.cancel) + return + } + + guard let handlerDelegate = self.delegate else { + print("โŒ RenameActionHandler: handlerDelegate (for self) not set.") + completion(.cancel) + return + } + + let tempFilename = originalURL.deletingPathExtension().lastPathComponent + + let gridFrame = handlerDelegate.getGridWindowFrame() + let panel = RenamePromptPanel(animationStartFrame: gridFrame) + + panel.actionHandlerDelegate = handlerDelegate + panel.textField.stringValue = tempFilename + self.renamePanel = panel + + panel.onAction = { [weak self] response in + guard let self = self else { + print("๐Ÿ” DEBUG: self is nil in onAction callback") + completion(.cancel) + return + } + + print("๐Ÿ” DEBUG: RenamePanel onAction called with response: \(response)") + let enteredName = self.renamePanel?.enteredName ?? "" + print("๐Ÿ” DEBUG: enteredName: '\(enteredName)'") + var actionResponse = response + + if response == .OK { + print("๐Ÿ” DEBUG: Processing Save Name Only action") + _ = self.performRename(originalURL: originalURL, newNameInput: enteredName) + } else if response == .continue { + print("๐Ÿ” DEBUG: Processing Save to Folder action") + if let renamedURL = self.performRename(originalURL: originalURL, newNameInput: enteredName) { + self.saveToDesignatedFolder(fileURL: renamedURL, desiredName: enteredName) + } else { + print("Rename failed or was cancelled, cannot proceed to Save to Folder.") + actionResponse = .cancel + } + } else { + print("๐Ÿ” DEBUG: Processing Cancel action or unknown response: \(response)") + } + + print("๐Ÿ” DEBUG: Calling completion handler with actionResponse: \(actionResponse)") + completion(actionResponse) + if response != .OK && response != .continue { + self.renamePanel = nil + } + } + + let previewFrame = handlerDelegate.getActivePreviewWindow()?.frame + let panelSize = panel.frame.size + + var finalFrame = panel.frame + + if let currentGridFrame = gridFrame { + let spacing: CGFloat = 20 + finalFrame.origin.x = currentGridFrame.origin.x - panelSize.width - spacing + finalFrame.origin.y = currentGridFrame.origin.y + (currentGridFrame.height - panelSize.height) / 2 + finalFrame.size = panelSize + + let targetScreenForGrid = NSScreen.screens.first { $0.frame.intersects(currentGridFrame) } ?? NSScreen.main ?? NSScreen.screens.first! + let screenVisibleFrame = targetScreenForGrid.visibleFrame + + if finalFrame.origin.x < screenVisibleFrame.origin.x { + finalFrame.origin.x = currentGridFrame.maxX + spacing + } + if finalFrame.origin.y < screenVisibleFrame.origin.y { + finalFrame.origin.y = screenVisibleFrame.origin.y + 50 + } + if finalFrame.maxY > screenVisibleFrame.maxY { + finalFrame.origin.y = screenVisibleFrame.maxY - panelSize.height - 50 + } + } else { + let previewScreen = handlerDelegate.getActivePreviewWindow()?.screen + let mouseScreen = NSScreen.screenWithMouse() + let targetScreenForFallback = previewScreen ?? mouseScreen ?? NSScreen.main ?? NSScreen.screens.first! + let screenVisibleFrame = targetScreenForFallback.visibleFrame + finalFrame.origin.x = screenVisibleFrame.origin.x + (screenVisibleFrame.width - panelSize.width) / 2 + finalFrame.origin.y = screenVisibleFrame.origin.y + (screenVisibleFrame.height - panelSize.height) / 2 + finalFrame.size = panelSize + } + + panel.alphaValue = 0.0 + let startFrameForAnimation: NSRect + + if let currentGridFrame = gridFrame { + startFrameForAnimation = NSRect(x: currentGridFrame.midX - panelSize.width / 2, + y: currentGridFrame.midY - panelSize.height / 2, + width: panelSize.width, + height: panelSize.height) + } else if let previewFrame = previewFrame { + startFrameForAnimation = NSRect(x: previewFrame.midX - panelSize.width / 2, + y: previewFrame.midY - panelSize.height / 2, + width: panelSize.width, + height: panelSize.height) + } else { + startFrameForAnimation = finalFrame.offsetBy(dx: 0, dy: 50) + } + panel.setFrame(startFrameForAnimation, display: false) + panel.animationStartFrame = startFrameForAnimation + + panel.orderFront(nil) + handlerDelegate.disableGridMonitoring() + + NSAnimationContext.runAnimationGroup({ + context in + context.duration = 0.3 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().alphaValue = 1.0 + panel.animator().setFrame(finalFrame, display: true) + }, completionHandler: { + print("โœจ Panel animation complete. Current panel alpha: \(panel.alphaValue), isVisible: \(panel.isVisible), frame: \(panel.frame)") + panel.makeKey() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if panel.isVisible { + if panel.alphaValue < 0.1 { + print("โš ๏ธ Panel is visible but alpha is very low. Might be a ghost panel.") + } + } else { + print("โš ๏ธ Panel disappeared unexpectedly after animation.") + self.renamePanel = nil + completion(.cancel) + } + } + + DispatchQueue.main.async { + if let field = panel.textField { + if panel.firstResponder != field { + if panel.makeFirstResponder(field) { + field.currentEditor()?.selectedRange = NSRange(location: 0, length: field.stringValue.count) + print("โŒจ๏ธ DEBUG: TextField is first responder, text selected.") + } else { + print("โš ๏ธ DEBUG: Failed to make TextField first responder.") + } + } else { + print("โŒจ๏ธ DEBUG: TextField was already first responder.") + field.currentEditor()?.selectedRange = NSRange(location: 0, length: field.stringValue.count) + } + } + } + }) + } + + private func performRename(originalURL: URL, newNameInput: String) -> URL? { + guard let handlerDelegateRef = delegate else { return nil } + + let currentNameWithoutExt = originalURL.deletingPathExtension().lastPathComponent + let sanitizedNewName = self.sanitize(name: newNameInput) + + if newNameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || sanitizedNewName == currentNameWithoutExt.replacingOccurrences(of: "screenshot_", with: "").replacingOccurrences(of: ".", with: "_") { + print("โ„น๏ธ Name not changed or empty, using original: \(originalURL.lastPathComponent)") + handlerDelegateRef.setTempFileURL(originalURL) + DispatchQueue.main.async { + if let label = handlerDelegateRef.findFilenameLabel(in: handlerDelegateRef.getActivePreviewWindow()) { + label.stringValue = originalURL.lastPathComponent + label.toolTip = originalURL.lastPathComponent + } + } + return originalURL + } + + let fileManager = FileManager.default + let directory = originalURL.deletingLastPathComponent() + let newURL = directory.appendingPathComponent(sanitizedNewName).appendingPathExtension(originalURL.pathExtension.isEmpty ? "png" : originalURL.pathExtension) + + if fileManager.fileExists(atPath: newURL.path) { + print("โš ๏ธ Rename failed: File already exists at \(newURL.path)") + DispatchQueue.main.async { + let conflictAlert = NSAlert() + conflictAlert.messageText = "File Exists" + conflictAlert.informativeText = "A file named \"\(newURL.lastPathComponent)\" already exists. Please use a different name." + conflictAlert.addButton(withTitle: "OK") + if let panelWindow = self.renamePanel { + conflictAlert.beginSheetModal(for: panelWindow, completionHandler: nil) + } else { + conflictAlert.runModal() + } + } + return nil + } + + do { + try fileManager.moveItem(at: originalURL, to: newURL) + print("โœ… Temp File renamed from \(originalURL.lastPathComponent) to \(newURL.lastPathComponent)") + handlerDelegateRef.setTempFileURL(newURL) + DispatchQueue.main.async { + if let label = handlerDelegateRef.findFilenameLabel(in: handlerDelegateRef.getActivePreviewWindow()) { + label.stringValue = newURL.lastPathComponent + label.toolTip = newURL.lastPathComponent + } + } + return newURL + } catch { + print("โŒ Error renaming temp file: \(error)") + DispatchQueue.main.async { + let errorAlert = NSAlert(error: error) + if let panelWindow = self.renamePanel { + errorAlert.beginSheetModal(for: panelWindow, completionHandler: nil) + } else { + errorAlert.runModal() + } + } + return nil + } + } + + private func saveToDesignatedFolder(fileURL: URL, desiredName: String) { + guard let handlerDelegateRef = delegate else { return } + guard let destinationFolderStr = handlerDelegateRef.getScreenshotFolder(), + !destinationFolderStr.isEmpty else { + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Folder Not Set" + alert.informativeText = "Please set a default save folder in Settings." + alert.addButton(withTitle: "OK") + if let hostWindow = handlerDelegateRef.getActivePreviewWindow() ?? self.renamePanel { + alert.beginSheetModal(for: hostWindow, completionHandler: nil) + } else { + alert.runModal() + } + } + return + } + + let finalFilename = fileURL.lastPathComponent + + let fileManager = FileManager.default + let destinationFolderURL = URL(fileURLWithPath: destinationFolderStr) + let destinationURL = destinationFolderURL.appendingPathComponent(finalFilename) + + if fileManager.fileExists(atPath: destinationURL.path) { + print("โš ๏ธ Save failed: File already exists at \(destinationURL.path)") + DispatchQueue.main.async { + let conflictAlert = NSAlert() + conflictAlert.messageText = "File Exists in Destination" + conflictAlert.informativeText = "A file named \"\(destinationURL.lastPathComponent)\" already exists in the destination folder \"\(destinationFolderURL.lastPathComponent)\"." + conflictAlert.addButton(withTitle: "OK") + if let hostWindow = handlerDelegateRef.getActivePreviewWindow() ?? self.renamePanel { + conflictAlert.beginSheetModal(for: hostWindow, completionHandler: nil) + } else { + conflictAlert.runModal() + } + } + return + } + + do { + try fileManager.moveItem(at: fileURL, to: destinationURL) + print("โœ… Screenshot moved to: \(destinationURL.path)") + handlerDelegateRef.setTempFileURL(nil) + + DispatchQueue.main.async { + if !SettingsManager.shared.closeAfterSave { + print("RenameActionHandler: closeAfterSave is OFF, expliciet sluiten van preview na Save to Folder.") + handlerDelegateRef.closePreviewWithAnimation(immediate: false, preserveTempFile: false) + } + } + } catch { + print("โŒ Error moving file to destination folder: \(error)") + DispatchQueue.main.async { + let errorAlert = NSAlert(error: error) + if let hostWindow = handlerDelegateRef.getActivePreviewWindow() ?? self.renamePanel { + errorAlert.beginSheetModal(for: hostWindow, completionHandler: nil) + } else { + errorAlert.runModal() + } + } + } + } + + private func handleExistingFileConflict(for type: ConflictType, originalURL: URL, destinationURL: URL, destinationFolderURL: URL, completion: @escaping (NSApplication.ModalResponse) -> Void) { + let conflictAlert = NSAlert() + conflictAlert.messageText = "File Exists" + + let fileName = destinationURL.lastPathComponent + let folderName = destinationFolderURL.lastPathComponent + + if type == .renameOnly { + conflictAlert.informativeText = "A file named \"\(fileName)\" already exists. Please use a different name." + let okButton = conflictAlert.addButton(withTitle: "OK") + okButton.tag = NSApplication.ModalResponse.OK.rawValue + } else { + conflictAlert.informativeText = "A file named \"\(fileName)\" already exists in the destination folder \"\(folderName)\"." + let overwriteButton = conflictAlert.addButton(withTitle: "Overwrite") + overwriteButton.tag = NSApplication.ModalResponse.OK.rawValue + + let saveNewNameButton = conflictAlert.addButton(withTitle: "Save with New Name") + saveNewNameButton.tag = NSApplication.ModalResponse.continue.rawValue + + let cancelButton = conflictAlert.addButton(withTitle: "Cancel") + cancelButton.tag = NSApplication.ModalResponse.cancel.rawValue + } + + var response = conflictAlert.runModal() + + if type == .renameOnly && response == .OK { + response = .cancel + } + + completion(response) + } + + private func showRenameErrorAlert(message: String, informativeText: String, onOK: (() -> Void)? = nil) { + let alert = NSAlert() + alert.messageText = message + alert.informativeText = informativeText + let okButton = alert.addButton(withTitle: "OK") + okButton.tag = NSApplication.ModalResponse.OK.rawValue + + alert.beginSheetModal(for: self.renamePanel ?? NSApplication.shared.keyWindow ?? NSWindow()) { response in + if response == .OK { + onOK?() + } + } + } +} + +extension RenamePromptPanel { + convenience init(animationStartFrame: NSRect?) { + let initialWidth: CGFloat = 280 + let initialHeight: CGFloat = 130 + var initialRect = NSRect(x: 0, y: 0, width: initialWidth, height: initialHeight) + + if let startFrame = animationStartFrame, let mainScreen = NSScreen.main { + let screenFrame = mainScreen.visibleFrame + initialRect.origin.x = startFrame.midX - initialWidth / 2 + initialRect.origin.y = startFrame.midY - initialHeight / 2 + + if initialRect.maxX > screenFrame.maxX { initialRect.origin.x = screenFrame.maxX - initialWidth } + if initialRect.minX < screenFrame.minX { initialRect.origin.x = screenFrame.minX } + if initialRect.maxY > screenFrame.maxY { initialRect.origin.y = screenFrame.maxY - initialHeight } + if initialRect.minY < screenFrame.minY { initialRect.origin.y = screenFrame.minY } + } else if let mainScreen = NSScreen.main { + initialRect.origin.x = (mainScreen.visibleFrame.width - initialWidth) / 2 + mainScreen.visibleFrame.origin.x + initialRect.origin.y = (mainScreen.visibleFrame.height - initialHeight) / 2 + mainScreen.visibleFrame.origin.y + } + + self.init(contentRect: initialRect, styleMask: [.borderless, .utilityWindow, .hudWindow, .nonactivatingPanel], backing: .buffered, defer: false) + self.animationStartFrame = animationStartFrame + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/ScreenCaptureKitProvider.swift b/ShotScreen/Sources/ScreenCaptureKitProvider.swift new file mode 100644 index 0000000..0aa31ca --- /dev/null +++ b/ShotScreen/Sources/ScreenCaptureKitProvider.swift @@ -0,0 +1,318 @@ +import ScreenCaptureKit +import AppKit + +@available(macOS 12.3, *) +class ScreenCaptureKitProvider { + + func getDesktopIconWindows() async -> [SCWindow] { + // Check if desktop icon hiding is enabled in settings + guard SettingsManager.shared.hideDesktopIconsDuringScreenshot else { + // If setting is disabled, return empty array so no windows are excluded + return [] + } + + do { + let content = try await SCShareableContent.current + let windows = content.windows + + let desktopIconWindows = windows.filter { window in + guard let app = window.owningApplication, app.bundleIdentifier == "com.apple.finder" else { + return false + } + guard window.title == nil || window.title == "" else { + return false + } + // Layer for desktop icons on Sonoma/Sequoia seems to be -2147483603 + // This value might be kCGDesktopWindowLevel - 20 (or similar) + // kCGDesktopWindowLevel is (CGWindowLevel) (kCGBaseWindowLevel + kCGDesktopWindowLevelKey) + // kCGBaseWindowLevel is Int32.min + // kCGDesktopWindowLevelKey is 20 + // So kCGDesktopWindowLevel is Int32.min + 20 = -2147483648 + 20 = -2147483628 + // The observed value -2147483603 might be kCGDesktopWindowLevel + 25 or similar constant. + // Let's stick to the observed value for now as it seems consistent. + return window.windowLayer == -2147483603 && window.isOnScreen && window.frame.size.width > 0 && window.frame.size.height > 0 + } + return desktopIconWindows + } catch { + NSLog("Error fetching shareable content for desktop icons: \(error.localizedDescription)") + return [] + } + } + + func getDesktopWidgetWindows() async -> [SCWindow] { + // Check if desktop widget hiding is enabled in settings + guard SettingsManager.shared.hideDesktopWidgetsDuringScreenshot else { + // If setting is disabled, return empty array so no windows are excluded + return [] + } + + do { + let content = try await SCShareableContent.current + let windows = content.windows + + // Use DesktopIconManager to detect widgets + let detectedWidgets = DesktopIconManager.shared.detectDesktopWidgets(from: windows) + NSLog("๐Ÿ” ScreenCaptureKitProvider: Found \(detectedWidgets.count) desktop widgets to hide") + return detectedWidgets + } catch { + NSLog("Error fetching shareable content for desktop widgets: \(error.localizedDescription)") + return [] + } + } + + func getAllWindowsToExclude() async -> [SCWindow] { + // Combine both desktop icons and widgets into one exclusion list + async let iconWindows = getDesktopIconWindows() + async let widgetWindows = getDesktopWidgetWindows() + + let allWindows = await iconWindows + widgetWindows + NSLog("๐Ÿ” ScreenCaptureKitProvider: Total windows to exclude: \(allWindows.count) (icons + widgets)") + return allWindows + } + + func captureScreen(screen: NSScreen, excludingWindows: [SCWindow]? = nil) async -> NSImage? { + do { + let content = try await SCShareableContent.current + guard let display = content.displays.first(where: { $0.displayID == screen.displayID }) else { + NSLog("Error: Could not find SCDisplay matching NSScreen with ID: \(screen.displayID)") + return nil + } + + var windowsToExclude = excludingWindows ?? [] + if let ownBundleID = Bundle.main.bundleIdentifier { + let ownWindows = content.windows.filter { $0.owningApplication?.bundleIdentifier == ownBundleID && $0.isOnScreen } + windowsToExclude.append(contentsOf: ownWindows) + } + + let filter = SCContentFilter(display: display, excludingWindows: windowsToExclude) + let configuration = SCStreamConfiguration() + + configuration.width = display.width + configuration.height = display.height + configuration.showsCursor = SettingsManager.shared.windowCaptureIncludeCursor + configuration.capturesAudio = false + configuration.pixelFormat = kCVPixelFormatType_32BGRA + configuration.colorSpaceName = CGColorSpace.sRGB + + let stream = SCStream(filter: filter, configuration: configuration, delegate: nil) + try await stream.addStreamOutput(SingleFrameOutput.shared, type: .screen, sampleHandlerQueue: .main) + try await stream.startCapture() + // Wacht kort op een frame - dit is een placeholder, een betere synchronisatie is nodig + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconde + let image = await SingleFrameOutput.shared.retrieveFrame() + try await stream.stopCapture() + return image + } catch { + NSLog("Error capturing screen with SCStream: \(error.localizedDescription)") + return nil + } + } + + func captureSelection(selectionRectInPoints: CGRect, screen: NSScreen, excludingWindows: [SCWindow]? = nil) async -> NSImage? { + do { + let content = try await SCShareableContent.current + guard let display = content.displays.first(where: { $0.displayID == screen.displayID }) else { + NSLog("Error: Could not find SCDisplay matching NSScreen with ID: \(screen.displayID) for selection capture") + return nil + } + + let scale = screen.backingScaleFactor + + // selectionRectInPoints is de intersectie van de globale selectie met screen.frame, + // nog steeds in globale AppKit coรถrdinaten (Y-omhoog, oorsprong linksonder hoofdvenster). + + // Stap 1: Converteer globale AppKit selectie naar lokale AppKit punten voor dit scherm. + // (Y-omhoog, oorsprong linksonder DIT scherm). + let localOriginXInPoints = selectionRectInPoints.origin.x - screen.frame.origin.x + let localOriginYInPoints = selectionRectInPoints.origin.y - screen.frame.origin.y + // Breedte en hoogte (in punten) blijven hetzelfde als selectionRectInPoints.size.width/height + + // Stap 2: Converteer naar sourceRect in fysieke pixels, Y-omlaag, oorsprong linksboven DIT scherm. + // AANNAMES GEWIJZIGD: selectionRectInPoints en screen.frame lijken al in de 'pixel'-eenheid te zijn + // die SCDisplay verwacht, ondanks dat ze 'punten' worden genoemd. De 'scale' factor wordt hier dus niet gebruikt voor de conversie. + let sourceRectXPixels = localOriginXInPoints + let sourceRectYPixels = (screen.frame.size.height - (localOriginYInPoints + selectionRectInPoints.size.height)) + let sourceRectWidthPixels = selectionRectInPoints.size.width + let sourceRectHeightPixels = selectionRectInPoints.size.height + + var sourceRect = CGRect( + x: sourceRectXPixels, + y: sourceRectYPixels, + width: sourceRectWidthPixels, + height: sourceRectHeightPixels + ) + + // Rond af naar dichtstbijzijnde gehele pixel om mogelijke SCK API problemen te voorkomen + sourceRect = CGRect( + x: round(sourceRect.origin.x), + y: round(sourceRect.origin.y), + width: round(sourceRect.size.width), + height: round(sourceRect.size.height) + ) + + NSLog("๐ŸŽฏ CAPTURE SELECTION DEBUG V3 (Simplified V1 Logic):") + NSLog(" Input selectionRectInPoints (AppKit Global Y-up): \(selectionRectInPoints)") + NSLog(" Target NSScreen: \(screen.customLocalizedName), Frame (AppKit Y-up): \(screen.frame), Scale: \(scale)") + NSLog(" Calculated localOriginInPoints (AppKit Y-up, screen-local): x=\(localOriginXInPoints), y=\(localOriginYInPoints)") + NSLog(" SCDisplay: ID \(display.displayID), display.width (pixels): \(display.width), display.height (pixels): \(display.height)") + NSLog(" Calculated sourceRect (Physical Pixels, screen-local, Y-down from screen top-left, rounded): \(sourceRect)") + + // Basis validatie + guard sourceRect.width > 0 && sourceRect.height > 0 else { + NSLog("Error V3: Calculated sourceRect has zero or negative rounded width/height. sourceRect: \(sourceRect)") + return nil + } + + // Strikte grenscontrole en eventuele clipping + if !(sourceRect.origin.x >= 0 && + sourceRect.origin.y >= 0 && + sourceRect.maxX <= CGFloat(display.width) + 0.5 && + sourceRect.maxY <= CGFloat(display.height) + 0.5) { + + NSLog("Warning V3: Calculated sourceRect \(sourceRect) is out of bounds for the SCDisplay [W:\(display.width), H:\(display.height)]. Attempting to clip.") + // Log de individuele checks voor duidelijkheid + NSLog(" Check: sourceRect.origin.x (\(sourceRect.origin.x)) >= 0") + NSLog(" Check: sourceRect.origin.y (\(sourceRect.origin.y)) >= 0") + NSLog(" Check: sourceRect.maxX (\(sourceRect.maxX)) <= display.width (\(CGFloat(display.width) + 0.5))") + NSLog(" Check: sourceRect.maxY (\(sourceRect.maxY)) <= display.height (\(CGFloat(display.height) + 0.5))") + + let clippedRect = sourceRect.intersection(CGRect(x: 0, y: 0, width: CGFloat(display.width), height: CGFloat(display.height))) + + if clippedRect.width > 1 && clippedRect.height > 1 { + NSLog(" Successfully clipped sourceRect to: \(clippedRect)") + sourceRect = clippedRect + } else { + NSLog("Error V3: Clipping failed or resulted in too small rect: \(clippedRect). Aborting.") + return nil + } + } + + var windowsToExclude = excludingWindows ?? [] + if let ownBundleID = Bundle.main.bundleIdentifier { + let ownWindows = content.windows.filter { $0.owningApplication?.bundleIdentifier == ownBundleID && $0.isOnScreen } + windowsToExclude.append(contentsOf: ownWindows) + } + + let filter = SCContentFilter(display: display, excludingWindows: windowsToExclude) + let configuration = SCStreamConfiguration() + + configuration.sourceRect = sourceRect + configuration.width = Int(sourceRect.width) + configuration.height = Int(sourceRect.height) + configuration.showsCursor = SettingsManager.shared.windowCaptureIncludeCursor + configuration.capturesAudio = false + configuration.pixelFormat = kCVPixelFormatType_32BGRA + configuration.colorSpaceName = CGColorSpace.sRGB + + let stream = SCStream(filter: filter, configuration: configuration, delegate: nil) + try await stream.addStreamOutput(SingleFrameOutput.shared, type: .screen, sampleHandlerQueue: .main) + try await stream.startCapture() + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconde + let image = await SingleFrameOutput.shared.retrieveFrame() + try await stream.stopCapture() + return image + } catch { + NSLog("Error capturing selection with SCStream: \(error.localizedDescription)") + return nil + } + } + + func captureWindow(window: SCWindow) async -> NSImage? { + // Ensure the window is on screen and has a valid frame. + // SCWindow.frame is in SCK global coordinates (Y-down, origin top-left of main display usually). + // We need to ensure it has a non-zero size. + guard window.isOnScreen, window.frame.width > 0, window.frame.height > 0 else { + NSLog("Error: Window to capture is not on screen or has invalid frame: \(window.windowID), Title: \(window.title ?? "N/A"), Frame: \(window.frame)") + return nil + } + + // For capturing a single window, we don't need to exclude other windows explicitly in the filter, + // as the filter will be configured to only include this specific window. + // However, we DO need to exclude our own app's overlay windows if they happen to be on top of the target window. + var windowsToExclude: [SCWindow] = [] + if let ownBundleID = Bundle.main.bundleIdentifier { + do { + let content = try await SCShareableContent.current + let ownWindows = content.windows.filter { $0.owningApplication?.bundleIdentifier == ownBundleID && $0.isOnScreen && $0.windowID != window.windowID } + windowsToExclude.append(contentsOf: ownWindows) + } catch { + NSLog("Error fetching shareable content for own window exclusion: \(error.localizedDescription)") + } + } + + let filter = SCContentFilter(desktopIndependentWindow: window) + let configuration = SCStreamConfiguration() + + // The frame of SCWindow is already in pixels (SCK coordinates). + // The width and height should be set to the window's frame size. + configuration.width = Int(window.frame.width) + configuration.height = Int(window.frame.height) + configuration.showsCursor = SettingsManager.shared.windowCaptureIncludeCursor + configuration.capturesAudio = false + configuration.pixelFormat = kCVPixelFormatType_32BGRA + // For window capture, SCContentFilter is configured with a single window, so sourceRect is not needed. + // The scaleFactor and pointPixelConversion properties on SCWindow might be useful if further coordinate transformations were needed, + // but captureImage with a window filter typically handles this. + + do { + let stream = SCStream(filter: filter, configuration: configuration, delegate: nil) + try await stream.addStreamOutput(SingleFrameOutput.shared, type: .screen, sampleHandlerQueue: .main) + try await stream.startCapture() + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconde + let image = await SingleFrameOutput.shared.retrieveFrame() + try await stream.stopCapture() + return image + } catch { + NSLog("Error capturing window ID \(window.windowID) with SCStream: \(error.localizedDescription)") + return nil + } + } + + // We will add other screenshot methods here later +} + +// Helper class to capture a single frame from SCStream +@MainActor +class SingleFrameOutput: NSObject, SCStreamOutput { + static let shared = SingleFrameOutput() + private var capturedImage: NSImage? + private var continuation: CheckedContinuation? + + // MOET NONISOLATED ZIJN VANWEGE PROTOCOL + nonisolated func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { + // Moet terug naar MainActor voor UI updates/property access + Task { @MainActor in + guard type == .screen, CMSampleBufferIsValid(sampleBuffer), CMSampleBufferGetNumSamples(sampleBuffer) == 1 else { + return + } + + guard let cvPixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + return + } + + let ciImage = CIImage(cvPixelBuffer: cvPixelBuffer) + let rep = NSCIImageRep(ciImage: ciImage) + let nsImage = NSImage(size: rep.size) + nsImage.addRepresentation(rep) + + self.capturedImage = nsImage + self.continuation?.resume(returning: nsImage) + self.continuation = nil + } + } + + // Deze functie wordt aangeroepen vanaf een andere actor (degene die capture aanroept) + // maar interacteert met @MainActor properties via de continuation. + func retrieveFrame() async -> NSImage? { + if let image = capturedImage { // Lees direct als al beschikbaar (op MainActor) + self.capturedImage = nil + return image + } + return await withCheckedContinuation { continuation in + // De continuation zelf is Sendable. + // De .resume() wordt aangeroepen vanuit de (nonisolated) stream functie, + // maar de Task daarbinnen springt terug naar @MainActor voor de daadwerkelijke resume. + self.continuation = continuation + } + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/SettingsManager.swift b/ShotScreen/Sources/SettingsManager.swift new file mode 100644 index 0000000..1ebd4f9 --- /dev/null +++ b/ShotScreen/Sources/SettingsManager.swift @@ -0,0 +1,962 @@ +import AppKit +import SwiftUI // Needed for ObservableObject, Published, etc. +import Combine // Needed for objectWillChange + +class SettingsManager: ObservableObject { + static let shared = SettingsManager() + private var isInitializing = false // NIEUWE FLAG + + var screenshotFolder: String? { + get { UserDefaults.standard.string(forKey: SettingsKey.screenshotFolder) } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.screenshotFolder) + } + } + + var thumbnailTimer: Int { + get { UserDefaults.standard.integer(forKey: SettingsKey.thumbnailTimer) } + set { objectWillChange.send(); UserDefaults.standard.set(newValue, forKey: SettingsKey.thumbnailTimer) } + } + + @Published var closeAfterDrag: Bool = false { + didSet { + UserDefaults.standard.set(closeAfterDrag, forKey: SettingsKey.closeAfterDrag) + if !isInitializing { + NotificationCenter.default.post(name: .closeAfterDragSettingChanged, object: nil) + } + } + } + + var showFolderButton: Bool { + get { UserDefaults.standard.bool(forKey: SettingsKey.showFolderButton) } + set { objectWillChange.send(); UserDefaults.standard.set(newValue, forKey: SettingsKey.showFolderButton) } + } + + @Published var closeAfterSave: Bool = false { + didSet { + UserDefaults.standard.set(closeAfterSave, forKey: SettingsKey.closeAfterSave) + if !isInitializing { + NotificationCenter.default.post(name: .closeAfterSaveSettingChanged, object: nil) + } + } + } + + @Published var playSoundOnCapture: Bool = true { + didSet { + UserDefaults.standard.set(playSoundOnCapture, forKey: SettingsKey.playSoundOnCapture) + if !isInitializing { // CONTROLEER FLAG + NotificationCenter.default.post(name: .playSoundOnCaptureSettingChanged, object: nil) + } + } + } + + // NEW: Properties for filename format + var filenamePrefix: String { + get { UserDefaults.standard.string(forKey: SettingsKey.filenamePrefix) ?? "Schermafbeelding" } // Default prefix + set { objectWillChange.send(); UserDefaults.standard.set(newValue, forKey: SettingsKey.filenamePrefix) } + } + + var filenameFormatPreset: FilenameFormatPreset { + get { + let rawValue = UserDefaults.standard.integer(forKey: SettingsKey.filenameFormatPreset) + return FilenameFormatPreset(rawValue: rawValue) ?? .macOSStyle // Default naar macOS style + } + set { objectWillChange.send(); UserDefaults.standard.set(newValue.rawValue, forKey: SettingsKey.filenameFormatPreset) } + } + + var filenameCustomFormat: String { + get { UserDefaults.standard.string(forKey: SettingsKey.filenameCustomFormat) ?? "{YYYY}-{MM}-{DD}_{hh}.{mm}.{ss}" } // Default custom format + set { objectWillChange.send(); UserDefaults.standard.set(newValue, forKey: SettingsKey.filenameCustomFormat) } + } + + // NEW: Properties for action enables + var isRenameActionEnabled: Bool { + get { + let value = UserDefaults.standard.object(forKey: SettingsKey.isRenameActionEnabled) as? Bool ?? true + return value + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.isRenameActionEnabled) + } + } + var isStashActionEnabled: Bool { + get { + let value = UserDefaults.standard.object(forKey: SettingsKey.isStashActionEnabled) as? Bool ?? true + return value + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.isStashActionEnabled) + } + } + var isOCRActionEnabled: Bool { + get { + let value = UserDefaults.standard.object(forKey: SettingsKey.isOCRActionEnabled) as? Bool ?? true + return value + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.isOCRActionEnabled) + } + } + var isClipboardActionEnabled: Bool { + get { + let value = UserDefaults.standard.object(forKey: SettingsKey.isClipboardActionEnabled) as? Bool ?? true + return value + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.isClipboardActionEnabled) + } + } + var isBackgroundRemoveActionEnabled: Bool { + get { + let value = UserDefaults.standard.object(forKey: SettingsKey.isBackgroundRemoveActionEnabled) as? Bool ?? true + return value + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.isBackgroundRemoveActionEnabled) + } + } + var isCancelActionEnabled: Bool { + get { + let value = UserDefaults.standard.object(forKey: SettingsKey.isCancelActionEnabled) as? Bool ?? true + return value + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.isCancelActionEnabled) + } + } + var isRemoveActionEnabled: Bool { + get { + let value = UserDefaults.standard.object(forKey: SettingsKey.isRemoveActionEnabled) as? Bool ?? true + return value + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.isRemoveActionEnabled) + } + } + var isAction3Enabled: Bool { + get { + let value = UserDefaults.standard.object(forKey: SettingsKey.isAction3Enabled) as? Bool ?? false + return value + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.isAction3Enabled) + } + } + var isAction4Enabled: Bool { + get { + let value = UserDefaults.standard.object(forKey: SettingsKey.isAction4Enabled) as? Bool ?? false + return value + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.isAction4Enabled) + } + } + + // NEW: Properties for Delete and Cancel Drag actions + var isDeleteActionEnabled: Bool { + get { UserDefaults.standard.object(forKey: SettingsKey.isDeleteActionEnabled) as? Bool ?? true } // Default AAN + set { objectWillChange.send(); UserDefaults.standard.set(newValue, forKey: SettingsKey.isDeleteActionEnabled) } + } + var isCancelDragActionEnabled: Bool { + get { UserDefaults.standard.object(forKey: SettingsKey.isCancelDragActionEnabled) as? Bool ?? true } // Default AAN + set { objectWillChange.send(); UserDefaults.standard.set(newValue, forKey: SettingsKey.isCancelDragActionEnabled) } + } + + // ๐ŸŽจ Background Removal Method Preference + var preferredBackgroundRemovalMethod: BackgroundRemovalMethod { + get { + let rawValue = UserDefaults.standard.string(forKey: SettingsKey.preferredBackgroundRemovalMethod) ?? "auto" + return BackgroundRemovalMethod(rawValue: rawValue) ?? .auto + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue.rawValue, forKey: SettingsKey.preferredBackgroundRemovalMethod) + } + } + + // NEW: Property for automatic startup + @Published var startAppOnLogin: Bool = false { + didSet { + UserDefaults.standard.set(startAppOnLogin, forKey: SettingsKey.startAppOnLogin) + if !isInitializing { + NotificationCenter.default.post(name: .startAppOnLoginSettingChanged, object: nil) + } + } + } + + // NEW: Property for automatic screenshot saving + var autoSaveScreenshot: Bool { + get { UserDefaults.standard.object(forKey: SettingsKey.autoSaveScreenshot) as? Bool ?? false } // Default false (handmatig beslissen) + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.autoSaveScreenshot) + // Optioneel: post een notificatie als directe actie in ScreenshotApp nodig is + // NotificationCenter.default.post(name: .autoSaveScreenshotChanged, object: nil) + } + } + + // VOEG DEZE TOE + static let thumbnailFixedSizeKey = "thumbnailFixedSize" + @Published var thumbnailFixedSize: ThumbnailFixedSize = .medium { + didSet { + UserDefaults.standard.set(thumbnailFixedSize.rawValue, forKey: SettingsManager.thumbnailFixedSizeKey) + if !isInitializing { + NotificationCenter.default.post(name: .thumbnailSizeSettingChanged, object: nil) + } + } + } + + // NEW: Property for stash always on top + @Published var stashAlwaysOnTop: Bool = false { + didSet { + UserDefaults.standard.set(stashAlwaysOnTop, forKey: SettingsKey.stashAlwaysOnTop) + if !isInitializing { + NotificationCenter.default.post(name: .stashAlwaysOnTopSettingChanged, object: nil) + } + } + } + + // ๐Ÿ”ฅ๐Ÿ’Ž MEGA NIEUWE STASH PREVIEW SIZE SETTING! ๐Ÿ’Ž๐Ÿ”ฅ + @Published var stashPreviewSize: StashPreviewSize = .medium { + didSet { + UserDefaults.standard.set(stashPreviewSize.rawValue, forKey: SettingsKey.stashPreviewSize) + if !isInitializing { + NotificationCenter.default.post(name: .stashPreviewSizeChanged, object: nil) + } + } + } + + // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE STASH GRID CONFIGURATION! โšก๐Ÿ’ฅ๐Ÿ”ฅ + @Published var stashGridMode: StashGridMode = .fixedColumns { + didSet { + UserDefaults.standard.set(stashGridMode.rawValue, forKey: SettingsKey.stashGridMode) + if !isInitializing { + NotificationCenter.default.post(name: .stashGridModeChanged, object: nil) + NotificationCenter.default.post(name: .stashGridConfigChanged, object: nil) + } + } + } + + @Published var stashMaxColumns: Int = 2 { + didSet { + // Clamp to 1-5 range for HYPERMODE safety! + let clampedValue = max(1, min(5, stashMaxColumns)) + if clampedValue != stashMaxColumns { + stashMaxColumns = clampedValue + return + } + UserDefaults.standard.set(stashMaxColumns, forKey: SettingsKey.stashMaxColumns) + if !isInitializing { + NotificationCenter.default.post(name: .stashGridConfigChanged, object: nil) + } + } + } + + @Published var stashMaxRows: Int = 1 { + didSet { + // Clamp to 1-5 range for HYPERMODE safety! + let clampedValue = max(1, min(5, stashMaxRows)) + if clampedValue != stashMaxRows { + stashMaxRows = clampedValue + return + } + UserDefaults.standard.set(stashMaxRows, forKey: SettingsKey.stashMaxRows) + if !isInitializing { + NotificationCenter.default.post(name: .stashGridConfigChanged, object: nil) + } + } + } + + // ๐Ÿ”ฅ NIEUW: Persistent stash setting + @Published var persistentStash: Bool = false { + didSet { + UserDefaults.standard.set(persistentStash, forKey: SettingsKey.persistentStash) + if !isInitializing { + NotificationCenter.default.post(name: .persistentStashChanged, object: nil) + } + } + } + + // ๐Ÿ”„ UPDATE SETTINGS + @Published var automaticUpdates: Bool = true { + didSet { + UserDefaults.standard.set(automaticUpdates, forKey: SettingsKey.automaticUpdates) + } + } + + @Published var includePreReleases: Bool = false { + didSet { + UserDefaults.standard.set(includePreReleases, forKey: SettingsKey.includePreReleases) + } + } + + // NEW: Property for hiding desktop icons during screenshots + @Published var hideDesktopIconsDuringScreenshot: Bool = false { + didSet { + UserDefaults.standard.set(hideDesktopIconsDuringScreenshot, forKey: SettingsKey.hideDesktopIconsDuringScreenshot) + if !isInitializing { + NotificationCenter.default.post(name: .hideDesktopIconsSettingChanged, object: nil) + } + print("Setting updated: hideDesktopIconsDuringScreenshot = \(hideDesktopIconsDuringScreenshot)") + } + } + + // NEW: Property for hiding desktop widgets during screenshots + @Published var hideDesktopWidgetsDuringScreenshot: Bool = false { + didSet { + UserDefaults.standard.set(hideDesktopWidgetsDuringScreenshot, forKey: SettingsKey.hideDesktopWidgetsDuringScreenshot) + if !isInitializing { + NotificationCenter.default.post(name: .hideDesktopWidgetsSettingChanged, object: nil) + } + print("Setting updated: hideDesktopWidgetsDuringScreenshot = \(hideDesktopWidgetsDuringScreenshot)") + } + } + + // ๐Ÿ”Š NEW: Sound volume and type settings + @Published var screenshotSoundVolume: Float = 0.1 { + didSet { + UserDefaults.standard.set(screenshotSoundVolume, forKey: SettingsKey.screenshotSoundVolume) + } + } + + @Published var screenshotSoundType: ScreenshotSoundType = .pop { + didSet { + if let data = try? JSONEncoder().encode(screenshotSoundType) { + UserDefaults.standard.set(data, forKey: SettingsKey.screenshotSoundType) + } + } + } + + // ๐Ÿ—‚๏ธ NEW: Cache management settings + @Published var cacheRetentionTime: CacheRetentionTime = .oneWeek { + didSet { + if let data = try? JSONEncoder().encode(cacheRetentionTime) { + UserDefaults.standard.set(data, forKey: SettingsKey.cacheRetentionTime) + } + // ๐Ÿงช NIEUW: Send notification for cache retention time changes (except during initialization) + if !isInitializing { + NotificationCenter.default.post(name: NSNotification.Name("cacheRetentionTimeChanged"), object: nil) + } + } + } + + + + // NEW: Property for first launch completion + var hasCompletedFirstLaunch: Bool { + get { UserDefaults.standard.bool(forKey: SettingsKey.hasCompletedFirstLaunch) } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.hasCompletedFirstLaunch) + } + } + + var windowCaptureIncludeCursor: Bool { + get { UserDefaults.standard.bool(forKey: SettingsKey.windowCaptureIncludeCursor) } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue, forKey: SettingsKey.windowCaptureIncludeCursor) + if !isInitializing { + NotificationCenter.default.post(name: .windowCaptureSettingChanged, object: nil) + } + } + } + + // NEW: Property for clean desktop screenshots + // VERWIJDERD: cleanDesktopScreenshots property - feature disabled + + @Published var saveAfterEdit: Bool = false { + didSet { + UserDefaults.standard.set(saveAfterEdit, forKey: SettingsKey.saveAfterEdit) + NotificationCenter.default.post(name: .saveAfterEditSettingChanged, object: nil) + } + } + + var thumbnailDisplayScreen: ThumbnailDisplayScreen { + get { + let rawValue = UserDefaults.standard.string(forKey: SettingsKey.thumbnailDisplayScreen) ?? ThumbnailDisplayScreen.automatic.rawValue + return ThumbnailDisplayScreen(rawValue: rawValue) ?? .automatic + } + set { + objectWillChange.send() + UserDefaults.standard.set(newValue.rawValue, forKey: SettingsKey.thumbnailDisplayScreen) + } + } + + static let actionOrderKey = "actionOrder" + @Published var actionOrder: [ActionType] = [] { + didSet { + let orderStrings = actionOrder.map { $0.rawValue } + UserDefaults.standard.set(orderStrings, forKey: SettingsManager.actionOrderKey) + } + } + + // NEW: Keyboard shortcut properties + @Published var useCustomShortcut: Bool = false { + didSet { + UserDefaults.standard.set(useCustomShortcut, forKey: SettingsKey.useCustomShortcut) + if !isInitializing { + // Post notification for hotkey change + NotificationCenter.default.post(name: .shortcutSettingChanged, object: nil) + } + } + } + + @Published var customShortcutModifiers: UInt = 0 { + didSet { + UserDefaults.standard.set(customShortcutModifiers, forKey: SettingsKey.customShortcutModifiers) + if !isInitializing && useCustomShortcut { + NotificationCenter.default.post(name: .shortcutSettingChanged, object: nil) + } + } + } + + @Published var customShortcutKey: UInt16 = 0 { + didSet { + UserDefaults.standard.set(customShortcutKey, forKey: SettingsKey.customShortcutKey) + if !isInitializing && useCustomShortcut { + NotificationCenter.default.post(name: .shortcutSettingChanged, object: nil) + } + } + } + + + + private init() { + isInitializing = true // ZET FLAG + + // Laad ALLE properties hier uit UserDefaults + self.screenshotFolder = UserDefaults.standard.string(forKey: SettingsKey.screenshotFolder) // Kan nil zijn + self.filenamePrefix = UserDefaults.standard.string(forKey: SettingsKey.filenamePrefix) ?? "Schermafbeelding" + + let presetRaw = UserDefaults.standard.integer(forKey: SettingsKey.filenameFormatPreset) + self.filenameFormatPreset = FilenameFormatPreset(rawValue: presetRaw) ?? .macOSStyle + // Als de opgeslagen rawValue 0 was en .macOSStyle is 0, dan is dit onnodig dubbel. + // Overweeg: if UserDefaults.standard.object(forKey: SettingsKey.filenameFormatPreset) != nil { ... laden ... } else { default } + + self.filenameCustomFormat = UserDefaults.standard.string(forKey: SettingsKey.filenameCustomFormat) ?? "{YYYY}-{MM}-{DD}_{hh}.{mm}.{ss}" + self.saveAfterEdit = UserDefaults.standard.bool(forKey: SettingsKey.saveAfterEdit) // Default false als key niet bestaat + self.playSoundOnCapture = UserDefaults.standard.object(forKey: SettingsKey.playSoundOnCapture) as? Bool ?? true + self.thumbnailTimer = UserDefaults.standard.integer(forKey: SettingsKey.thumbnailTimer) + self.closeAfterDrag = UserDefaults.standard.bool(forKey: SettingsKey.closeAfterDrag) + + let thumbFixedSizeKeyString = SettingsManager.thumbnailFixedSizeKey + let loadedFixedSizeRawValue = UserDefaults.standard.string(forKey: thumbFixedSizeKeyString) + if let loadedSize = loadedFixedSizeRawValue.flatMap(ThumbnailFixedSize.init) { + if self.thumbnailFixedSize != loadedSize { + self.thumbnailFixedSize = loadedSize + } + } else { + UserDefaults.standard.set(ThumbnailFixedSize.medium.rawValue, forKey: thumbFixedSizeKeyString) + } + + self.showFolderButton = UserDefaults.standard.object(forKey: SettingsKey.showFolderButton) as? Bool ?? true + self.startAppOnLogin = UserDefaults.standard.object(forKey: SettingsKey.startAppOnLogin) as? Bool ?? false + self.autoSaveScreenshot = UserDefaults.standard.object(forKey: SettingsKey.autoSaveScreenshot) as? Bool ?? false + self.closeAfterSave = UserDefaults.standard.bool(forKey: SettingsKey.closeAfterSave) + + // Stash window border etc. ook hier initialiseren: + // self.stashWindowBorderWidth = UserDefaults.standard.object(forKey: SettingsKey.stashWindowBorderWidth) == nil ? 1.0 : CGFloat(UserDefaults.standard.float(forKey: SettingsKey.stashWindowBorderWidth)) + self.isRenameActionEnabled = UserDefaults.standard.object(forKey: SettingsKey.isRenameActionEnabled) as? Bool ?? true + self.isStashActionEnabled = UserDefaults.standard.object(forKey: SettingsKey.isStashActionEnabled) as? Bool ?? true + self.isOCRActionEnabled = UserDefaults.standard.object(forKey: SettingsKey.isOCRActionEnabled) as? Bool ?? true + self.isClipboardActionEnabled = UserDefaults.standard.object(forKey: SettingsKey.isClipboardActionEnabled) as? Bool ?? true + self.isBackgroundRemoveActionEnabled = UserDefaults.standard.object(forKey: SettingsKey.isBackgroundRemoveActionEnabled) as? Bool ?? true + self.isCancelActionEnabled = UserDefaults.standard.object(forKey: SettingsKey.isCancelActionEnabled) as? Bool ?? true + self.isRemoveActionEnabled = UserDefaults.standard.object(forKey: SettingsKey.isRemoveActionEnabled) as? Bool ?? true + self.isDeleteActionEnabled = UserDefaults.standard.object(forKey: SettingsKey.isDeleteActionEnabled) as? Bool ?? true + self.isCancelDragActionEnabled = UserDefaults.standard.object(forKey: SettingsKey.isCancelDragActionEnabled) as? Bool ?? true + self.stashAlwaysOnTop = UserDefaults.standard.object(forKey: SettingsKey.stashAlwaysOnTop) as? Bool ?? false + self.hideDesktopIconsDuringScreenshot = UserDefaults.standard.object(forKey: SettingsKey.hideDesktopIconsDuringScreenshot) as? Bool ?? false + self.hideDesktopWidgetsDuringScreenshot = UserDefaults.standard.object(forKey: SettingsKey.hideDesktopWidgetsDuringScreenshot) as? Bool ?? false + + + // ๐Ÿ”ฅ๐Ÿ’Ž MEGA NIEUWE STASH PREVIEW SIZE INIT! ๐Ÿ’Ž๐Ÿ”ฅ + let stashPreviewSizeRaw = UserDefaults.standard.string(forKey: SettingsKey.stashPreviewSize) ?? StashPreviewSize.medium.rawValue + self.stashPreviewSize = StashPreviewSize(rawValue: stashPreviewSizeRaw) ?? .medium + + // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE STASH GRID INIT! โšก๐Ÿ’ฅ๐Ÿ”ฅ + let stashGridModeRaw = UserDefaults.standard.string(forKey: SettingsKey.stashGridMode) ?? StashGridMode.fixedColumns.rawValue + self.stashGridMode = StashGridMode(rawValue: stashGridModeRaw) ?? .fixedColumns + + self.stashMaxColumns = UserDefaults.standard.object(forKey: SettingsKey.stashMaxColumns) as? Int ?? 2 + // HYPERMODE SAFETY: Clamp to 1-5 range! + self.stashMaxColumns = max(1, min(5, self.stashMaxColumns)) + + self.stashMaxRows = UserDefaults.standard.object(forKey: SettingsKey.stashMaxRows) as? Int ?? 1 + // HYPERMODE SAFETY: Clamp to 1-5 range! + self.stashMaxRows = max(1, min(5, self.stashMaxRows)) + + // ๐Ÿ”ฅ NIEUW: Persistent stash init + self.persistentStash = UserDefaults.standard.object(forKey: SettingsKey.persistentStash) as? Bool ?? false + + // ๐Ÿ”„ UPDATE SETTINGS INIT + self.automaticUpdates = UserDefaults.standard.object(forKey: SettingsKey.automaticUpdates) as? Bool ?? true + self.includePreReleases = UserDefaults.standard.object(forKey: SettingsKey.includePreReleases) as? Bool ?? false + + // ๐ŸŽน CUSTOM SHORTCUT SETTINGS INIT + self.useCustomShortcut = UserDefaults.standard.object(forKey: SettingsKey.useCustomShortcut) as? Bool ?? false + self.customShortcutModifiers = UserDefaults.standard.object(forKey: SettingsKey.customShortcutModifiers) as? UInt ?? 0 + self.customShortcutKey = UserDefaults.standard.object(forKey: SettingsKey.customShortcutKey) as? UInt16 ?? 0 + + // ๐Ÿ”Š SOUND SETTINGS INIT + self.screenshotSoundVolume = UserDefaults.standard.object(forKey: SettingsKey.screenshotSoundVolume) as? Float ?? 0.1 + + if let soundTypeData = UserDefaults.standard.data(forKey: SettingsKey.screenshotSoundType), + let soundType = try? JSONDecoder().decode(ScreenshotSoundType.self, from: soundTypeData) { + self.screenshotSoundType = soundType + } else { + self.screenshotSoundType = .pop + } + + // ๐Ÿ—‚๏ธ CACHE MANAGEMENT INIT + if let cacheRetentionData = UserDefaults.standard.data(forKey: SettingsKey.cacheRetentionTime), + let retentionTime = try? JSONDecoder().decode(CacheRetentionTime.self, from: cacheRetentionData) { + self.cacheRetentionTime = retentionTime + } else { + self.cacheRetentionTime = .oneWeek + } + + // Initialize thumbnailDisplayScreen + let _ = UserDefaults.standard.string(forKey: SettingsKey.thumbnailDisplayScreen) ?? ThumbnailDisplayScreen.automatic.rawValue + // No need to set it since it's a computed property + + // Load action order with migration for new actions + if let savedOrder = UserDefaults.standard.stringArray(forKey: SettingsManager.actionOrderKey) { + var loadedOrder = savedOrder.compactMap { ActionType(rawValue: $0) } + + // Migration: ensure all new actions are included + let allActions = ActionType.allCases + for action in allActions { + if !loadedOrder.contains(action) { + loadedOrder.append(action) + } + } + + actionOrder = loadedOrder + } else { + actionOrder = ActionType.allCases + } + + // Initialize hasCompletedFirstLaunch + self.hasCompletedFirstLaunch = UserDefaults.standard.object(forKey: SettingsKey.hasCompletedFirstLaunch) as? Bool ?? false + + isInitializing = false // RESET FLAG + } + + private enum CodingKeys: String, CodingKey { + case screenshotFolder + case filenamePrefix + case filenameFormatPreset + case filenameCustomFormat + case saveAfterEdit + case playSoundOnCapture + case thumbnailTimer + case closeAfterDrag + case thumbnailFixedSize + case showFolderButton + case startAppOnLogin + case autoSaveScreenshot + case closeAfterSave + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + screenshotFolder = try container.decodeIfPresent(String.self, forKey: .screenshotFolder) + filenamePrefix = try container.decodeIfPresent(String.self, forKey: .filenamePrefix) ?? "Screenshot" + filenameCustomFormat = try container.decodeIfPresent(String.self, forKey: .filenameCustomFormat) ?? "{YYYY}-{MM}-{DD}_{hh}.{mm}.{ss}" + saveAfterEdit = try container.decodeIfPresent(Bool.self, forKey: .saveAfterEdit) ?? false + playSoundOnCapture = try container.decodeIfPresent(Bool.self, forKey: .playSoundOnCapture) ?? true + thumbnailTimer = try container.decodeIfPresent(Int.self, forKey: .thumbnailTimer) ?? 0 + closeAfterDrag = try container.decodeIfPresent(Bool.self, forKey: .closeAfterDrag) ?? false + let presetRaw = try container.decodeIfPresent(Int.self, forKey: .filenameFormatPreset) ?? FilenameFormatPreset.macOSStyle.rawValue + filenameFormatPreset = FilenameFormatPreset(rawValue: presetRaw) ?? .macOSStyle + thumbnailFixedSize = try container.decodeIfPresent(ThumbnailFixedSize.self, forKey: .thumbnailFixedSize) ?? .medium + showFolderButton = try container.decodeIfPresent(Bool.self, forKey: .showFolderButton) ?? true + startAppOnLogin = try container.decodeIfPresent(Bool.self, forKey: .startAppOnLogin) ?? false + autoSaveScreenshot = try container.decodeIfPresent(Bool.self, forKey: .autoSaveScreenshot) ?? false + closeAfterSave = try container.decodeIfPresent(Bool.self, forKey: .closeAfterSave) ?? false + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(screenshotFolder, forKey: .screenshotFolder) + try container.encode(filenamePrefix, forKey: .filenamePrefix) + try container.encode(filenameFormatPreset.rawValue, forKey: .filenameFormatPreset) + try container.encode(filenameCustomFormat, forKey: .filenameCustomFormat) + try container.encode(saveAfterEdit, forKey: .saveAfterEdit) + try container.encode(playSoundOnCapture, forKey: .playSoundOnCapture) + try container.encode(thumbnailTimer, forKey: .thumbnailTimer) + try container.encode(closeAfterDrag, forKey: .closeAfterDrag) + try container.encode(thumbnailFixedSize, forKey: .thumbnailFixedSize) + try container.encode(showFolderButton, forKey: .showFolderButton) + try container.encode(startAppOnLogin, forKey: .startAppOnLogin) + try container.encode(autoSaveScreenshot, forKey: .autoSaveScreenshot) + try container.encode(closeAfterSave, forKey: .closeAfterSave) + } + + func resetToDefaults() { + objectWillChange.send() // Stuur eenmalig aan het begin + + // Wijs defaults direct toe aan de properties; de didSets zorgen voor opslaan en notificaties. + screenshotFolder = defaultScreenshotFolder() + filenamePrefix = "Schermafbeelding" + filenameFormatPreset = .macOSStyle + filenameCustomFormat = "{YYYY}-{MM}-{DD}_{hh}.{mm}.{ss}" + saveAfterEdit = false + playSoundOnCapture = true + thumbnailTimer = 0 + closeAfterDrag = false + thumbnailFixedSize = .medium + showFolderButton = true + startAppOnLogin = false + autoSaveScreenshot = false + closeAfterSave = false + isRenameActionEnabled = true + isStashActionEnabled = true + isOCRActionEnabled = true + isClipboardActionEnabled = true + isCancelActionEnabled = true + isRemoveActionEnabled = true + isDeleteActionEnabled = true + isCancelDragActionEnabled = true + stashAlwaysOnTop = false + hideDesktopIconsDuringScreenshot = false + + // ๐Ÿ”ฅ๐Ÿ’Ž MEGA NIEUWE RESET VOOR STASH PREVIEW SIZE! ๐Ÿ’Ž๐Ÿ”ฅ + stashPreviewSize = .medium + + // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE STASH GRID RESET! โšก๐Ÿ’ฅ๐Ÿ”ฅ + stashGridMode = .fixedColumns + stashMaxColumns = 2 + stashMaxRows = 1 + + // ๐Ÿ”ฅ NIEUW: Reset persistent stash + persistentStash = false + + // ๐Ÿ”„ RESET UPDATE SETTINGS + automaticUpdates = true + includePreReleases = false + + // ๐ŸŽน RESET CUSTOM SHORTCUT SETTINGS + useCustomShortcut = false + customShortcutModifiers = 0 + customShortcutKey = 0 + + + // Verwijder de saveSettings() aanroep. + // Verwijder de individuele NotificationCenter.default.post calls hier; didSets handelen dat af. + } + + private func defaultScreenshotFolder() -> String? { + // Voorbeeld: probeer ~/Pictures/Screenshots, anders nil + if let picturesURL = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first { + let screenshotsURL = picturesURL.appendingPathComponent("Screenshots") + // Maak de map als hij niet bestaat (optioneel) + // try? FileManager.default.createDirectory(at: screenshotsURL, withIntermediateDirectories: true, attributes: nil) + return screenshotsURL.path + } + return nil + } + + func resetActionOrder() { + actionOrder = ActionType.allCases + } + + func moveAction(_ action: ActionType, direction: Int) { + guard let currentIndex = actionOrder.firstIndex(of: action) else { return } + let newIndex = currentIndex + direction + + guard newIndex >= 0 && newIndex < actionOrder.count else { return } + + actionOrder.swapAt(currentIndex, newIndex) + } + + // saveSettings() is waarschijnlijk niet nodig als @Published didSets goed werken. + // Als je het toch wilt: + /* + func saveSettings() { + UserDefaults.standard.set(screenshotFolder, forKey: SettingsKey.screenshotFolder) + // ... etc. voor alle settings ... + UserDefaults.standard.synchronize() // Optioneel, gebeurt periodiek + } + */ +} + +// MARK: - Settings Window UI (Nieuwe structuur met TabView) + +struct SettingsSnapshot { + var screenshotFolder: String? + var thumbnailTimer: Int + var closeAfterDrag: Bool + var thumbnailFixedSize: ThumbnailFixedSize + var showFolderButton: Bool + var closeAfterSave: Bool + var playSoundOnCapture: Bool + var filenamePrefix: String + var filenameFormatPreset: FilenameFormatPreset + var filenameCustomFormat: String + var startAppOnLogin: Bool + var autoSaveScreenshot: Bool + var thumbnailDisplayScreen: ThumbnailDisplayScreen + var stashAlwaysOnTop: Bool + var hideDesktopIconsDuringScreenshot: Bool + + static func captureCurrent() -> SettingsSnapshot { + let s = SettingsManager.shared + return SettingsSnapshot( + screenshotFolder: s.screenshotFolder, + thumbnailTimer: s.thumbnailTimer, + closeAfterDrag: s.closeAfterDrag, + thumbnailFixedSize: s.thumbnailFixedSize, + showFolderButton: s.showFolderButton, + closeAfterSave: s.closeAfterSave, + playSoundOnCapture: s.playSoundOnCapture, + filenamePrefix: s.filenamePrefix, + filenameFormatPreset: s.filenameFormatPreset, + filenameCustomFormat: s.filenameCustomFormat, + startAppOnLogin: s.startAppOnLogin, + autoSaveScreenshot: s.autoSaveScreenshot, + thumbnailDisplayScreen: s.thumbnailDisplayScreen, + stashAlwaysOnTop: s.stashAlwaysOnTop, + hideDesktopIconsDuringScreenshot: s.hideDesktopIconsDuringScreenshot + ) + } + + func apply(to manager: SettingsManager = SettingsManager.shared) { + manager.screenshotFolder = screenshotFolder + manager.thumbnailTimer = thumbnailTimer + manager.closeAfterDrag = closeAfterDrag + manager.thumbnailFixedSize = thumbnailFixedSize + manager.showFolderButton = showFolderButton + manager.closeAfterSave = closeAfterSave + manager.playSoundOnCapture = playSoundOnCapture + manager.filenamePrefix = filenamePrefix + manager.filenameFormatPreset = filenameFormatPreset + manager.filenameCustomFormat = filenameCustomFormat + manager.startAppOnLogin = startAppOnLogin + manager.autoSaveScreenshot = autoSaveScreenshot + manager.thumbnailDisplayScreen = thumbnailDisplayScreen + manager.stashAlwaysOnTop = stashAlwaysOnTop + manager.hideDesktopIconsDuringScreenshot = hideDesktopIconsDuringScreenshot + } +} + +// MARK: - Cache Manager +struct CacheManager { + static let shared = CacheManager() + + private init() {} + + // Get thumbnail directory path + private var thumbnailDirectory: URL { + let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let shotScreenDirectory = appSupportDirectory.appendingPathComponent("ShotScreen") + let thumbnailsDirectory = shotScreenDirectory.appendingPathComponent("Thumbnails") + return thumbnailsDirectory + } + + // Calculate cache size in MB + func getCacheSize() -> Double { + do { + let contents = try FileManager.default.contentsOfDirectory(at: thumbnailDirectory, includingPropertiesForKeys: [.fileSizeKey], options: []) + + var totalSize: Int64 = 0 + for fileURL in contents { + // Skip thumbnail restoration directory from cache size calculation + if fileURL.lastPathComponent == "thumbnail_restoration" { + continue + } + + do { + let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey]) + if let fileSize = resourceValues.fileSize { + totalSize += Int64(fileSize) + } + } catch { + print("โš ๏ธ Error getting file size for \(fileURL.lastPathComponent): \(error)") + } + } + + // Convert bytes to MB + return Double(totalSize) / (1024 * 1024) + } catch { + print("โš ๏ธ Error calculating cache size: \(error)") + return 0.0 + } + } + + // Get cache file count + func getCacheFileCount() -> Int { + do { + let contents = try FileManager.default.contentsOfDirectory(at: thumbnailDirectory, includingPropertiesForKeys: [.isDirectoryKey], options: []) + + var fileCount = 0 + for fileURL in contents { + // Skip thumbnail restoration directory + if fileURL.lastPathComponent == "thumbnail_restoration" { + continue + } + + // Only count PNG files, not directories + let resourceValues = try? fileURL.resourceValues(forKeys: [.isDirectoryKey]) + let isDirectory = resourceValues?.isDirectory ?? false + + if !isDirectory && fileURL.pathExtension == "png" { + fileCount += 1 + } + } + + return fileCount + } catch { + print("โš ๏ธ Error getting cache file count: \(error)") + return 0 + } + } + + // Clear all cache (except active thumbnails) + func clearCache(preserveActiveThumbnails: Bool = true) -> (deletedFiles: Int, savedSpace: Double) { + do { + let contents = try FileManager.default.contentsOfDirectory(at: thumbnailDirectory, includingPropertiesForKeys: [.fileSizeKey, .creationDateKey], options: []) + + var deletedFiles = 0 + var savedSpace: Int64 = 0 + let activeThumbnailPath = getActiveThumbnailPath() + + for fileURL in contents { + // Skip if this is the active thumbnail and we want to preserve it + if preserveActiveThumbnails && fileURL.path == activeThumbnailPath { + print("๐Ÿ”’ Preserving active thumbnail: \(fileURL.lastPathComponent)") + continue + } + + // Skip thumbnail restoration directory completely + if fileURL.lastPathComponent == "thumbnail_restoration" { + print("๐Ÿ”’ Preserving thumbnail restoration directory: \(fileURL.lastPathComponent)") + continue + } + + // Skip thumbnail restoration backup files + if fileURL.lastPathComponent.contains("latest_backup") { + print("๐Ÿ”’ Preserving thumbnail restoration backup file: \(fileURL.lastPathComponent)") + continue + } + + do { + let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey]) + let fileSize = resourceValues.fileSize ?? 0 + + try FileManager.default.removeItem(at: fileURL) + deletedFiles += 1 + savedSpace += Int64(fileSize) + print("๐Ÿ—‘๏ธ Deleted cache file: \(fileURL.lastPathComponent)") + } catch { + print("โš ๏ธ Failed to delete \(fileURL.lastPathComponent): \(error)") + } + } + + let savedSpaceMB = Double(savedSpace) / (1024 * 1024) + print("โœ… Cache cleanup complete: \(deletedFiles) files deleted, \(String(format: "%.1f", savedSpaceMB)) MB freed") + return (deletedFiles, savedSpaceMB) + + } catch { + print("โŒ Error during cache cleanup: \(error)") + return (0, 0.0) + } + } + + // Clean cache based on retention time + func cleanupOldCache() { + let retentionTime = SettingsManager.shared.cacheRetentionTime + + // Don't cleanup if retention is set to forever + guard let maxAge = retentionTime.timeInterval else { + print("๐Ÿ—‚๏ธ Cache retention set to forever - no automatic cleanup") + return + } + + do { + let contents = try FileManager.default.contentsOfDirectory(at: thumbnailDirectory, includingPropertiesForKeys: [.creationDateKey, .fileSizeKey], options: []) + let cutoffDate = Date().addingTimeInterval(-maxAge) + let activeThumbnailPath = getActiveThumbnailPath() + + print("๐Ÿงช CLEANUP: Checking \(contents.count) files. Cutoff time: \(cutoffDate). Max age: \(Int(maxAge))s") + + var deletedFiles = 0 + var savedSpace: Int64 = 0 + var checkedFiles = 0 + + for fileURL in contents { + // Skip active thumbnail + if fileURL.path == activeThumbnailPath { + print("๐Ÿงช CLEANUP: Skipping active thumbnail: \(fileURL.lastPathComponent)") + continue + } + + // Skip thumbnail restoration directory completely + if fileURL.lastPathComponent == "thumbnail_restoration" { + continue + } + + // Skip thumbnail restoration backup files + if fileURL.lastPathComponent.contains("latest_backup") { + continue + } + + do { + let resourceValues = try fileURL.resourceValues(forKeys: [.creationDateKey, .fileSizeKey]) + + if let creationDate = resourceValues.creationDate { + checkedFiles += 1 + let age = Date().timeIntervalSince(creationDate) + print("๐Ÿงช CLEANUP: \(fileURL.lastPathComponent) - age: \(Int(age))s, created: \(creationDate)") + + if creationDate < cutoffDate { + let fileSize = resourceValues.fileSize ?? 0 + try FileManager.default.removeItem(at: fileURL) + deletedFiles += 1 + savedSpace += Int64(fileSize) + print("๐Ÿ—‘๏ธ DELETED: \(fileURL.lastPathComponent) (was \(Int(age))s old)") + } else { + print("โœ… KEEPING: \(fileURL.lastPathComponent) (only \(Int(age))s old)") + } + } + } catch { + print("โš ๏ธ Error processing \(fileURL.lastPathComponent): \(error)") + } + } + + if deletedFiles > 0 { + let savedSpaceMB = Double(savedSpace) / (1024 * 1024) + print("โœ… Auto cache cleanup: \(deletedFiles) old files deleted, \(String(format: "%.1f", savedSpaceMB)) MB freed") + } else if checkedFiles > 0 { + print("๐Ÿงช CLEANUP: No files old enough to delete (\(checkedFiles) files checked)") + } else { + print("๐Ÿงช CLEANUP: No cache files found to check") + } + + } catch { + print("โŒ Error during automatic cache cleanup: \(error)") + } + } + + // Get active thumbnail path (to avoid deleting currently open thumbnail) + private func getActiveThumbnailPath() -> String? { + // Try to get the active thumbnail URL from ScreenshotApp + if let app = NSApp.delegate as? ScreenshotApp, + let tempURL = app.getTempURL() { + return tempURL.path + } + return nil + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/SettingsModels.swift b/ShotScreen/Sources/SettingsModels.swift new file mode 100644 index 0000000..325de93 --- /dev/null +++ b/ShotScreen/Sources/SettingsModels.swift @@ -0,0 +1,413 @@ +import AppKit +import SwiftUI // Needed for CGSize, CaseIterable, Identifiable, Codable + +// Define Notification Names +extension Notification.Name { + static let closeAfterDragSettingChanged = Notification.Name("closeAfterDragSettingChanged") + static let closeAfterSaveSettingChanged = Notification.Name("closeAfterSaveSettingChanged") + static let playSoundOnCaptureSettingChanged = Notification.Name("playSoundOnCaptureSettingChanged") + static let startAppOnLoginSettingChanged = Notification.Name("startAppOnLoginSettingChanged") + static let thumbnailSizeSettingChanged = Notification.Name("thumbnailSizeSettingChanged") + static let screenshotFolderChanged = Notification.Name("screenshotFolderChanged") + static let filenameSettingsChanged = Notification.Name("filenameSettingsChanged") + static let saveAfterEditSettingChanged = Notification.Name("saveAfterEditSettingChanged") + static let thumbnailTimerSettingChanged = Notification.Name("thumbnailTimerSettingChanged") + static let thumbnailPositionSettingChanged = Notification.Name("thumbnailPositionSettingChanged") + static let showFolderButtonSettingChanged = Notification.Name("showFolderButtonSettingChanged") + static let autoSaveScreenshotSettingChanged = Notification.Name("autoSaveScreenshotSettingChanged") + static let stashAlwaysOnTopSettingChanged = Notification.Name("stashAlwaysOnTopSettingChanged") + static let shortcutSettingChanged = Notification.Name("shortcutSettingChanged") + static let windowCaptureSettingChanged = Notification.Name("windowCaptureSettingChanged") + + static let hideDesktopIconsSettingChanged = Notification.Name("hideDesktopIconsSettingChanged") + static let hideDesktopWidgetsSettingChanged = Notification.Name("hideDesktopWidgetsSettingChanged") + static let stashPreviewSizeChanged = Notification.Name("stashPreviewSizeChanged") + // ๐Ÿ”ฅ๐Ÿ’ฅ HYPERMODE GRID NOTIFICATIONS! ๐Ÿ’ฅ๐Ÿ”ฅ + static let stashGridModeChanged = Notification.Name("stashGridModeChanged") + static let stashGridConfigChanged = Notification.Name("stashGridConfigChanged") + // ๐Ÿ”ฅ NIEUW: Persistent stash notification + static let persistentStashChanged = Notification.Name("persistentStashChanged") + // Add more here if needed for other live updates +} + +// NEW: Enum for Screenshot Save Mode +enum ScreenshotSaveMode: Int, CaseIterable, Identifiable { + case automatic = 0 + case manual = 1 + + var id: Int { self.rawValue } + + var description: String { + switch self { + case .automatic: return "Always save screenshot to folder" + case .manual: return "Manually decide after screenshot" + } + } +} + +// Settings keys +enum SettingsKey { + static let screenshotFolder = "screenshotFolder" + static let thumbnailTimer = "thumbnailTimer" + static let closeAfterDrag = "closeAfterDrag" + static let thumbnailPosition = "thumbnailPosition" + static let showFolderButton = "showFolderButton" + static let closeAfterSave = "closeAfterSave" + static let playSoundOnCapture = "playSoundOnCapture" + static let filenamePrefix = "filenamePrefix" + static let filenameFormatPreset = "filenameFormatPreset" + static let filenameCustomFormat = "filenameCustomFormat" + static let stashWindowBorderWidth = "stashWindowBorderWidth" + static let isRenameActionEnabled = "isRenameActionEnabled" + static let isStashActionEnabled = "isStashActionEnabled" + static let isOCRActionEnabled = "isOCRActionEnabled" + static let isClipboardActionEnabled = "isClipboardActionEnabled" + static let isBackgroundRemoveActionEnabled = "isBackgroundRemoveActionEnabled" + static let isCancelActionEnabled = "isCancelActionEnabled" + static let isRemoveActionEnabled = "isRemoveActionEnabled" + static let isAction3Enabled = "isAction3Enabled" + static let isAction4Enabled = "isAction4Enabled" + static let isDeleteActionEnabled = "isDeleteActionEnabled" + static let isCancelDragActionEnabled = "isCancelDragActionEnabled" + static let startAppOnLogin = "startAppOnLogin" + static let autoSaveScreenshot = "autoSaveScreenshot" + static let saveAfterEdit = "saveAfterEdit" + static let thumbnailDisplayScreen = "thumbnailDisplayScreen" + static let stashAlwaysOnTop = "stashAlwaysOnTop" + static let openOnStartup = "openOnStartup" + static let hasCompletedFirstLaunch = "hasCompletedFirstLaunch" + static let customShortcutModifiers = "customShortcutModifiers" + static let customShortcutKey = "customShortcutKey" + static let useCustomShortcut = "useCustomShortcut" + static let windowCaptureIncludeChildWindows = "windowCaptureIncludeChildWindows" + static let windowCaptureIncludeCursor = "windowCaptureIncludeCursor" + static let windowCaptureHighResolution = "windowCaptureHighResolution" + static let windowCaptureShowSelectionUI = "windowCaptureShowSelectionUI" + + static let hideDesktopIconsDuringScreenshot = "hideDesktopIconsDuringScreenshot" + static let hideDesktopWidgetsDuringScreenshot = "hideDesktopWidgetsDuringScreenshot" + static let stashPreviewSize = "stashPreviewSize" + + // ๐Ÿ”ฅ๐Ÿ’ฅ HYPERMODE STASH GRID KEYS! ๐Ÿ’ฅ๐Ÿ”ฅ + static let stashGridMode = "stashGridMode" + static let stashMaxColumns = "stashMaxColumns" + static let stashMaxRows = "stashMaxRows" + + // ๐Ÿ”ฅ NIEUW: Persistent stash setting + static let persistentStash = "persistentStash" + + // ๐Ÿ”„ UPDATE SETTINGS + static let automaticUpdates = "automaticUpdates" + static let includePreReleases = "includePreReleases" + + // ๐Ÿ”Š SOUND SETTINGS + static let screenshotSoundVolume = "screenshotSoundVolume" + static let screenshotSoundType = "screenshotSoundType" + + // ๐Ÿ—‚๏ธ CACHE MANAGEMENT SETTINGS + static let cacheRetentionTime = "cacheRetentionTime" + + // ๐ŸŽจ BACKGROUND REMOVAL METHOD PREFERENCE + static let preferredBackgroundRemovalMethod = "preferredBackgroundRemovalMethod" + +} + +enum ThumbnailPosition: Int, CaseIterable, Codable { + case rightBottom = 0 + + var description: String { + switch self { + case .rightBottom: return "Bottom Right" + } + } +} + +enum ThumbnailDisplayScreen: String, CaseIterable, Codable { + case automatic = "automatic" + case screen1 = "screen1" + case screen2 = "screen2" + case screen3 = "screen3" + case screen4 = "screen4" + case screen5 = "screen5" + + var description: String { + switch self { + case .automatic: return "Automatic (where mouse is)" + case .screen1: return "Screen 1" + case .screen2: return "Screen 2" + case .screen3: return "Screen 3" + case .screen4: return "Screen 4" + case .screen5: return "Screen 5" + } + } + + func getDisplayName(for screenIndex: Int, screenName: String?) -> String { + switch self { + case .automatic: return "Automatic (where mouse is)" + case .screen1, .screen2, .screen3, .screen4, .screen5: + let screenNumber = screenIndex + 1 + if let name = screenName, !name.isEmpty { + return "Screen \(screenNumber) (\(name))" + } else { + return "Screen \(screenNumber)" + } + } + } +} + +// NEW: Enum for filename format presets +enum FilenameFormatPreset: Int, CaseIterable, Codable { + case macOSStyle = 0 // Screenshot {YYYY}-{MM}-{DD} at {hh}.{mm}.{ss} + case compactDateTime = 1 // {YYYY}-{MM}-{DD}_{hh}-{mm}-{ss} + case superCompactDateTime = 2 // {YYYYMMDD}_{hhmmss} + case timestamp = 3 // Unix timestamp + case prefixOnly = 4 // Prefix only + case custom = 5 // Custom format + + var description: String { + switch self { + case .macOSStyle: return "macOS Style (Screenshot YYYY-MM-DD at hh.mm.ss)" + case .compactDateTime: return "Compact (YYYY-MM-DD_hh-mm-ss)" + case .superCompactDateTime: return "Super Compact (YYYYMMDD_hhmmss)" + case .timestamp: return "Unix Timestamp" + case .prefixOnly: return "Prefix Only" + case .custom: return "Custom..." + } + } + + // Placeholder voor custom format string (alleen relevant voor .custom) + // De feitelijke custom string wordt apart opgeslagen. +} + +// VOEG DEZE ENUM TOE (ergens bovenaan, of logisch gegroepeerd) +enum ThumbnailFixedSize: String, CaseIterable, Codable { + case small, medium, large, xLarge, xxLarge, xxxLarge + + var dimensions: CGSize { + switch self { + case .small: return CGSize(width: 120, height: 90) + case .medium: return CGSize(width: 180, height: 135) + case .large: return CGSize(width: 240, height: 180) + case .xLarge: return CGSize(width: 300, height: 225) + case .xxLarge: return CGSize(width: 360, height: 270) + case .xxxLarge: return CGSize(width: 420, height: 315) + } + } + + var displayName: String { // Voor de Picker + switch self { + case .small: return "Small" + case .medium: return "Medium" + case .large: return "Large" + case .xLarge: return "X-Large" + case .xxLarge: return "XX-Large" + case .xxxLarge: return "XXX-Large" + } + } +} + +// ๐Ÿ”ฅ๐Ÿ’Ž MEGA NIEUWE STASH PREVIEW SIZE ENUM! ๐Ÿ’Ž๐Ÿ”ฅ +enum StashPreviewSize: String, CaseIterable, Codable { + case xSmall = "xSmall" // 25% van origineel + case small = "small" // 40% van origineel + case medium = "medium" // 60% van origineel + case large = "large" // 80% van origineel + case xLarge = "xLarge" // 100% van origineel (full size!) + + var percentage: CGFloat { + switch self { + case .xSmall: return 0.25 + case .small: return 0.40 + case .medium: return 0.60 + case .large: return 0.80 + case .xLarge: return 1.00 + } + } + + var displayName: String { + switch self { + case .xSmall: return "Extra Small" + case .small: return "Small" + case .medium: return "Medium" + case .large: return "Large" + case .xLarge: return "Extra Large" + } + } +} + +// ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE STASH GRID MODE ENUM! โšก๐Ÿ’ฅ๐Ÿ”ฅ +enum StashGridMode: String, CaseIterable, Codable, Identifiable { + case fixedColumns = "fixedColumns" // Max kolommen, auto rijen (huidige systeem) + case fixedRows = "fixedRows" // Max rijen, auto kolommen (horizontale strip!) + + var id: String { rawValue } + + var displayName: String { + switch self { + case .fixedColumns: return "Fixed Columns, Auto Rows" + case .fixedRows: return "Fixed Rows, Auto Columns" + } + } + + var description: String { + switch self { + case .fixedColumns: return "Grows vertically with more rows" + case .fixedRows: return "Grows horizontally with more columns" + } + } + + var iconName: String { + switch self { + case .fixedColumns: return "rectangle.split.1x3" // Verticale layout (1 kolom, 3 rijen = verticaal groeien) + case .fixedRows: return "rectangle.split.3x1" // Horizontale layout (3 kolommen, 1 rij = horizontaal groeien) + } + } +} + +// ๐Ÿ”Š SCREENSHOT SOUND TYPE ENUM +enum ScreenshotSoundType: String, CaseIterable, Codable, Identifiable { + case pop = "Pop" + case glass = "Glass" + case ping = "Ping" + case purr = "Purr" + case tink = "Tink" + case basso = "Basso" + case blow = "Blow" + case bottle = "Bottle" + case frog = "Frog" + case funk = "Funk" + case hero = "Hero" + case morse = "Morse" + case sosumi = "Sosumi" + case submarine = "Submarine" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .pop: return "Pop" + case .glass: return "Glass" + case .ping: return "Ping" + case .purr: return "Purr" + case .tink: return "Tink" + case .basso: return "Basso" + case .blow: return "Blow" + case .bottle: return "Bottle" + case .frog: return "Frog" + case .funk: return "Funk" + case .hero: return "Hero" + case .morse: return "Morse" + case .sosumi: return "Sosumi" + case .submarine: return "Submarine" + } + } + + var systemSoundName: String { + return rawValue + } +} + +// ๐Ÿ—‚๏ธ CACHE RETENTION TIME ENUM +enum CacheRetentionTime: String, CaseIterable, Codable, Identifiable { + case oneHour = "1h" + case sixHours = "6h" + case twelveHours = "12h" + case oneDay = "1d" + case threeDays = "3d" + case oneWeek = "1w" + case twoWeeks = "2w" + case oneMonth = "1m" + case forever = "forever" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .oneHour: return "1 Hour" + case .sixHours: return "6 Hours" + case .twelveHours: return "12 Hours" + case .oneDay: return "1 Day" + case .threeDays: return "3 Days" + case .oneWeek: return "1 Week" + case .twoWeeks: return "2 Weeks" + case .oneMonth: return "1 Month" + case .forever: return "Forever (Manual delete)" + } + } + + var timeInterval: TimeInterval? { + switch self { + case .oneHour: return 3600 + case .sixHours: return 3600 * 6 + case .twelveHours: return 3600 * 12 + case .oneDay: return 86400 + case .threeDays: return 86400 * 3 + case .oneWeek: return 86400 * 7 + case .twoWeeks: return 86400 * 14 + case .oneMonth: return 86400 * 30 + case .forever: return nil // Never delete + } + } +} + +// Add after the existing SettingsKey enum +enum ActionType: String, CaseIterable, Identifiable { + case rename = "Rename" + case stash = "Stash" + case ocr = "OCR" + case clipboard = "Clipboard" + case backgroundRemove = "BackgroundRemove" + case cancel = "Cancel" + case remove = "Remove" + + var id: String { self.rawValue } + + var settingsKey: String { + switch self { + case .rename: return SettingsKey.isRenameActionEnabled + case .stash: return SettingsKey.isStashActionEnabled + case .ocr: return SettingsKey.isOCRActionEnabled + case .clipboard: return SettingsKey.isClipboardActionEnabled + case .backgroundRemove: return SettingsKey.isBackgroundRemoveActionEnabled + case .cancel: return SettingsKey.isCancelActionEnabled + case .remove: return SettingsKey.isRemoveActionEnabled + } + } + + var displayName: String { + switch self { + case .rename: return "Rename" + case .stash: return "Stash" + case .ocr: return "Text Extract" + case .clipboard: return "Clipboard" + case .backgroundRemove: return "Remove Background" + case .cancel: return "Cancel" + case .remove: return "Remove" + } + } +} + +// MARK: - Background Removal Method Preference +enum BackgroundRemovalMethod: String, CaseIterable, Identifiable { + case auto = "auto" + case rmbg = "rmbg" + case vision = "vision" + + var id: String { self.rawValue } + + var displayName: String { + switch self { + case .auto: return "Auto (RMBG โ†’ Vision fallback)" + case .rmbg: return "RMBG-1.4 only" + case .vision: return "Vision Framework only" + } + } + + var description: String { + switch self { + case .auto: return "Tries RMBG-1.4 first, falls back to Vision if unavailable" + case .rmbg: return "Uses only RMBG-1.4 model (requires download)" + case .vision: return "Uses only Apple's Vision Framework (always available)" + } + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/SettingsUI.swift b/ShotScreen/Sources/SettingsUI.swift new file mode 100644 index 0000000..a850a8a --- /dev/null +++ b/ShotScreen/Sources/SettingsUI.swift @@ -0,0 +1,2868 @@ +import AppKit +import SwiftUI // Needed for TabView and materials + +// MARK: - Settings Window UI (Nieuwe structuur met TabView) + +struct SettingsTabView: View { + @State private var selectedTab: Int = 0 + @ObservedObject private var settings = SettingsManager.shared + + // State voor Folder Path (omdat het via een panel wordt gezet) + @State private var folderPathDisplay: String + + // Temporary state voor alle settings - only applied on Apply + @State private var tempThumbnailTimer: Double + @State private var tempFilenamePrefix: String + @State private var tempFilenameFormatPreset: FilenameFormatPreset + @State private var tempFilenameCustomFormat: String + @State private var tempThumbnailFixedSize: ThumbnailFixedSize + @State private var tempShowFolderButton: Bool + @State private var tempCloseAfterDrag: Bool + @State private var tempCloseAfterSave: Bool + @State private var tempPlaySoundOnCapture: Bool + + // ๐Ÿ”Š NEW: Sound settings temporary state + @State private var tempScreenshotSoundVolume: Float + @State private var tempScreenshotSoundType: ScreenshotSoundType + + // ๐ŸŽจ NEW: BGR method preference temporary state + @State private var tempPreferredBackgroundRemovalMethod: BackgroundRemovalMethod + + // ๐Ÿ—‚๏ธ NEW: Cache management temporary state + @State private var tempCacheRetentionTime: CacheRetentionTime + @State private var cacheSize: Double = 0.0 + @State private var cacheFileCount: Int = 0 + @State private var showClearCacheConfirmation = false + @State private var tempAutoSaveScreenshot: Bool + @State private var tempActionOrder: [ActionType] + @State private var tempIsRenameActionEnabled: Bool + @State private var tempIsStashActionEnabled: Bool + @State private var tempIsOCRActionEnabled: Bool + @State private var tempIsClipboardActionEnabled: Bool + @State private var tempIsBackgroundRemoveActionEnabled: Bool + @State private var tempIsCancelActionEnabled: Bool + @State private var tempIsRemoveActionEnabled: Bool + @State private var tempScreenshotFolder: String? + @State private var tempThumbnailDisplayScreen: ThumbnailDisplayScreen + @State private var tempStashAlwaysOnTop: Bool + @State private var tempPersistentStash: Bool + + // ๐Ÿ”„ UPDATE SETTINGS TEMP VARS + @State private var tempHideDesktopIconsDuringScreenshot: Bool + @State private var tempHideDesktopWidgetsDuringScreenshot: Bool + @State private var tempStashPreviewSize: StashPreviewSize + + // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE STASH GRID TEMP VARIABLES! โšก๐Ÿ’ฅ๐Ÿ”ฅ + @State private var tempStashGridMode: StashGridMode + @State private var tempStashMaxColumns: Int + @State private var tempStashMaxRows: Int + + // NEW: Temporary state for custom modifier settings + + + // NEW: animatie-state voor de Apply-knop + @State private var showApplyConfirmation = false + @State private var showUnsavedChangesAlert = false + @State private var forceClose = false // Flag to bypass unsaved changes check + @State private var showLoginInstructionsPopup = false // NEW: For login instructions popup + + // AI Models state + @State private var isDownloadingModel = false + @State private var downloadProgress: Double = 0.0 + @State private var showDownloadConfirmation = false + @State private var showRemoveConfirmation = false + @State private var isRMBGModelInstalled = false + + private let originalSettings: SettingsSnapshot + private let sampleThumbnailImage = NSImage(contentsOf: URL(fileURLWithPath: "/Volumes/External HD/Users/nick/Desktop/ss 1.0/ScreenShot/image/ChatGPT Image May 9, 2025, 11_51_47 AM.png")) + + init() { + _folderPathDisplay = State(initialValue: SettingsManager.shared.screenshotFolder ?? "Not selected") + + let timerVal = SettingsManager.shared.thumbnailTimer + _tempThumbnailTimer = State(initialValue: (timerVal == 0) ? 31.0 : max(5.0, Double(timerVal))) + _tempFilenamePrefix = State(initialValue: SettingsManager.shared.filenamePrefix) + _tempFilenameFormatPreset = State(initialValue: SettingsManager.shared.filenameFormatPreset) + _tempFilenameCustomFormat = State(initialValue: SettingsManager.shared.filenameCustomFormat) + _tempThumbnailFixedSize = State(initialValue: SettingsManager.shared.thumbnailFixedSize) + _tempShowFolderButton = State(initialValue: SettingsManager.shared.showFolderButton) + _tempCloseAfterDrag = State(initialValue: SettingsManager.shared.closeAfterDrag) + _tempCloseAfterSave = State(initialValue: SettingsManager.shared.closeAfterSave) + _tempPlaySoundOnCapture = State(initialValue: SettingsManager.shared.playSoundOnCapture) + _tempScreenshotSoundVolume = State(initialValue: SettingsManager.shared.screenshotSoundVolume) + _tempScreenshotSoundType = State(initialValue: SettingsManager.shared.screenshotSoundType) + _tempPreferredBackgroundRemovalMethod = State(initialValue: SettingsManager.shared.preferredBackgroundRemovalMethod) + _tempCacheRetentionTime = State(initialValue: SettingsManager.shared.cacheRetentionTime) + _tempAutoSaveScreenshot = State(initialValue: SettingsManager.shared.autoSaveScreenshot) + _tempActionOrder = State(initialValue: SettingsManager.shared.actionOrder) + _tempIsRenameActionEnabled = State(initialValue: SettingsManager.shared.isRenameActionEnabled) + _tempIsStashActionEnabled = State(initialValue: SettingsManager.shared.isStashActionEnabled) + _tempIsOCRActionEnabled = State(initialValue: SettingsManager.shared.isOCRActionEnabled) + _tempIsClipboardActionEnabled = State(initialValue: SettingsManager.shared.isClipboardActionEnabled) + _tempIsBackgroundRemoveActionEnabled = State(initialValue: SettingsManager.shared.isBackgroundRemoveActionEnabled) + _tempIsCancelActionEnabled = State(initialValue: SettingsManager.shared.isCancelActionEnabled) + _tempIsRemoveActionEnabled = State(initialValue: SettingsManager.shared.isRemoveActionEnabled) + _tempScreenshotFolder = State(initialValue: SettingsManager.shared.screenshotFolder) + _tempThumbnailDisplayScreen = State(initialValue: SettingsManager.shared.thumbnailDisplayScreen) + _tempStashAlwaysOnTop = State(initialValue: SettingsManager.shared.stashAlwaysOnTop) + _tempPersistentStash = State(initialValue: SettingsManager.shared.persistentStash) + + // ๐Ÿ”„ UPDATE SETTINGS INIT + _tempHideDesktopIconsDuringScreenshot = State(initialValue: SettingsManager.shared.hideDesktopIconsDuringScreenshot) + _tempHideDesktopWidgetsDuringScreenshot = State(initialValue: SettingsManager.shared.hideDesktopWidgetsDuringScreenshot) + _tempStashPreviewSize = State(initialValue: SettingsManager.shared.stashPreviewSize) + + // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE STASH GRID INIT! โšก๐Ÿ’ฅ๐Ÿ”ฅ + _tempStashGridMode = State(initialValue: SettingsManager.shared.stashGridMode) + _tempStashMaxColumns = State(initialValue: SettingsManager.shared.stashMaxColumns) + _tempStashMaxRows = State(initialValue: SettingsManager.shared.stashMaxRows) + + // Initialize RMBG model status + _isRMBGModelInstalled = State(initialValue: Self.isModelInstalled()) + + // Initialize temporary custom modifier settings + + + self.originalSettings = SettingsSnapshot.captureCurrent() + } + + private var hasUnsavedChanges: Bool { + let currentSettings = SettingsManager.shared + return tempThumbnailTimer != ((currentSettings.thumbnailTimer == 0) ? 31.0 : max(5.0, Double(currentSettings.thumbnailTimer))) || + tempFilenamePrefix != currentSettings.filenamePrefix || + tempFilenameFormatPreset != currentSettings.filenameFormatPreset || + tempFilenameCustomFormat != currentSettings.filenameCustomFormat || + tempThumbnailFixedSize != currentSettings.thumbnailFixedSize || + tempShowFolderButton != currentSettings.showFolderButton || + tempCloseAfterDrag != currentSettings.closeAfterDrag || + tempCloseAfterSave != currentSettings.closeAfterSave || + tempPlaySoundOnCapture != currentSettings.playSoundOnCapture || + tempScreenshotSoundVolume != currentSettings.screenshotSoundVolume || + tempScreenshotSoundType != currentSettings.screenshotSoundType || + tempPreferredBackgroundRemovalMethod != currentSettings.preferredBackgroundRemovalMethod || + tempCacheRetentionTime != currentSettings.cacheRetentionTime || + tempAutoSaveScreenshot != currentSettings.autoSaveScreenshot || + tempActionOrder != currentSettings.actionOrder || + tempIsRenameActionEnabled != currentSettings.isRenameActionEnabled || + tempIsStashActionEnabled != currentSettings.isStashActionEnabled || + tempIsOCRActionEnabled != currentSettings.isOCRActionEnabled || + tempIsClipboardActionEnabled != currentSettings.isClipboardActionEnabled || + tempIsBackgroundRemoveActionEnabled != currentSettings.isBackgroundRemoveActionEnabled || + tempIsCancelActionEnabled != currentSettings.isCancelActionEnabled || + tempIsRemoveActionEnabled != currentSettings.isRemoveActionEnabled || + tempScreenshotFolder != currentSettings.screenshotFolder || + tempThumbnailDisplayScreen != currentSettings.thumbnailDisplayScreen || + tempStashAlwaysOnTop != currentSettings.stashAlwaysOnTop || + tempPersistentStash != currentSettings.persistentStash || + tempHideDesktopIconsDuringScreenshot != currentSettings.hideDesktopIconsDuringScreenshot || + tempHideDesktopWidgetsDuringScreenshot != currentSettings.hideDesktopWidgetsDuringScreenshot || + tempStashPreviewSize != currentSettings.stashPreviewSize || + // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE GRID CHANGE DETECTION! โšก๐Ÿ’ฅ๐Ÿ”ฅ + tempStashGridMode != currentSettings.stashGridMode || + tempStashMaxColumns != currentSettings.stashMaxColumns || + tempStashMaxRows != currentSettings.stashMaxRows + } + + private func moveActionInTemp(_ action: ActionType, direction: Int) { + guard let currentIndex = tempActionOrder.firstIndex(of: action) else { return } + let newIndex = currentIndex + direction + + guard newIndex >= 0 && newIndex < tempActionOrder.count else { return } + + tempActionOrder.swapAt(currentIndex, newIndex) + } + + private func applyAllChanges() { + let settings = SettingsManager.shared + + // Apply all temporary changes to actual settings + settings.screenshotFolder = tempScreenshotFolder + settings.filenamePrefix = tempFilenamePrefix + settings.filenameFormatPreset = tempFilenameFormatPreset + settings.filenameCustomFormat = tempFilenameCustomFormat + settings.thumbnailTimer = Int(tempThumbnailTimer.rounded()) >= 31 ? 0 : Int(tempThumbnailTimer.rounded()) + settings.thumbnailFixedSize = tempThumbnailFixedSize + settings.showFolderButton = tempShowFolderButton + settings.closeAfterDrag = tempCloseAfterDrag + settings.closeAfterSave = tempCloseAfterSave + settings.playSoundOnCapture = tempPlaySoundOnCapture + settings.screenshotSoundVolume = tempScreenshotSoundVolume + settings.screenshotSoundType = tempScreenshotSoundType + settings.preferredBackgroundRemovalMethod = tempPreferredBackgroundRemovalMethod + settings.cacheRetentionTime = tempCacheRetentionTime + settings.autoSaveScreenshot = tempAutoSaveScreenshot + settings.actionOrder = tempActionOrder + settings.isRenameActionEnabled = tempIsRenameActionEnabled + settings.isStashActionEnabled = tempIsStashActionEnabled + settings.isOCRActionEnabled = tempIsOCRActionEnabled + settings.isClipboardActionEnabled = tempIsClipboardActionEnabled + settings.isBackgroundRemoveActionEnabled = tempIsBackgroundRemoveActionEnabled + settings.isCancelActionEnabled = tempIsCancelActionEnabled + settings.isRemoveActionEnabled = tempIsRemoveActionEnabled + settings.thumbnailDisplayScreen = tempThumbnailDisplayScreen + settings.stashAlwaysOnTop = tempStashAlwaysOnTop + settings.persistentStash = tempPersistentStash + settings.hideDesktopIconsDuringScreenshot = tempHideDesktopIconsDuringScreenshot + settings.hideDesktopWidgetsDuringScreenshot = tempHideDesktopWidgetsDuringScreenshot + + // ๐Ÿ”„ UPDATE SETTINGS + settings.stashPreviewSize = tempStashPreviewSize + // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE GRID SETTINGS APPLY! โšก๐Ÿ’ฅ๐Ÿ”ฅ + settings.stashGridMode = tempStashGridMode + settings.stashMaxColumns = tempStashMaxColumns + settings.stashMaxRows = tempStashMaxRows + + + // Update preview and other UI components + ScreenshotApp.sharedInstance?.updatePreviewSize() + + // ๐ŸŽฏ NEW: Restore thumbnail if settings changes may have closed it + ScreenshotApp.sharedInstance?.restoreCurrentThumbnailIfNeeded() + + print("SettingsTabView: All changes applied successfully") + } + + private func resetToCurrentSettings() { + let settings = SettingsManager.shared + let timerVal = settings.thumbnailTimer + + tempThumbnailTimer = (timerVal == 0) ? 31.0 : max(5.0, Double(timerVal)) + tempFilenamePrefix = settings.filenamePrefix + tempFilenameFormatPreset = settings.filenameFormatPreset + tempFilenameCustomFormat = settings.filenameCustomFormat + tempThumbnailFixedSize = settings.thumbnailFixedSize + tempShowFolderButton = settings.showFolderButton + tempCloseAfterDrag = settings.closeAfterDrag + tempCloseAfterSave = settings.closeAfterSave + tempPlaySoundOnCapture = settings.playSoundOnCapture + tempScreenshotSoundVolume = settings.screenshotSoundVolume + tempScreenshotSoundType = settings.screenshotSoundType + tempPreferredBackgroundRemovalMethod = settings.preferredBackgroundRemovalMethod + tempCacheRetentionTime = settings.cacheRetentionTime + tempAutoSaveScreenshot = settings.autoSaveScreenshot + tempActionOrder = settings.actionOrder + tempIsRenameActionEnabled = settings.isRenameActionEnabled + tempIsStashActionEnabled = settings.isStashActionEnabled + tempIsOCRActionEnabled = settings.isOCRActionEnabled + tempIsClipboardActionEnabled = settings.isClipboardActionEnabled + tempIsBackgroundRemoveActionEnabled = settings.isBackgroundRemoveActionEnabled + tempIsCancelActionEnabled = settings.isCancelActionEnabled + tempIsRemoveActionEnabled = settings.isRemoveActionEnabled + tempScreenshotFolder = settings.screenshotFolder + tempThumbnailDisplayScreen = settings.thumbnailDisplayScreen + tempStashAlwaysOnTop = settings.stashAlwaysOnTop + tempPersistentStash = settings.persistentStash + tempHideDesktopIconsDuringScreenshot = settings.hideDesktopIconsDuringScreenshot + tempHideDesktopWidgetsDuringScreenshot = settings.hideDesktopWidgetsDuringScreenshot + + // ๐Ÿ”„ UPDATE SETTINGS RESET + tempStashPreviewSize = settings.stashPreviewSize + // ๐Ÿ”ฅ๐Ÿ’ฅโšก HYPERMODE GRID RESET TO CURRENT! โšก๐Ÿ’ฅ๐Ÿ”ฅ + tempStashGridMode = settings.stashGridMode + tempStashMaxColumns = settings.stashMaxColumns + tempStashMaxRows = settings.stashMaxRows + folderPathDisplay = settings.screenshotFolder ?? "Not selected" + + } + + private func formatTimerDisplay(_ value: Double) -> String { + if value >= 31 { // 31 and above represents "No timeout" + return "No timeout" + } else { + return "\(Int(value))s" + } + } + + private func getAvailableScreenOptions() -> [ThumbnailDisplayScreen] { + let screenCount = NSScreen.screens.count + var options: [ThumbnailDisplayScreen] = [.automatic] + + // Add screen options based on available screens (up to 5 screens) + if screenCount >= 1 { options.append(.screen1) } + if screenCount >= 2 { options.append(.screen2) } + if screenCount >= 3 { options.append(.screen3) } + if screenCount >= 4 { options.append(.screen4) } + if screenCount >= 5 { options.append(.screen5) } + + return options + } + + private func getScreenDisplayName(for option: ThumbnailDisplayScreen) -> String { + switch option { + case .automatic: + return "Automatic (where mouse is)" + case .screen1, .screen2, .screen3, .screen4, .screen5: + let screenIndex: Int + switch option { + case .screen1: screenIndex = 0 + case .screen2: screenIndex = 1 + case .screen3: screenIndex = 2 + case .screen4: screenIndex = 3 + case .screen5: screenIndex = 4 + default: screenIndex = 0 + } + + if screenIndex < NSScreen.screens.count { + let screen = NSScreen.screens[screenIndex] + let screenName = screen.localizedName + return option.getDisplayName(for: screenIndex, screenName: screenName) + } else { + return option.description + " (not available)" + } + } + } + + private func updateFilenameExample() -> String { + let prefix = tempFilenamePrefix + let preset = tempFilenameFormatPreset + var example = prefix + let now = Date() + let dateFormatter = DateFormatter() + + switch preset { + case .macOSStyle: + dateFormatter.dateFormat = "yyyy-MM-dd HH.mm.ss" + let dateString = dateFormatter.string(from: now) + example += (prefix.isEmpty ? "" : " ") + "at " + dateString + case .compactDateTime: + dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + example += (prefix.isEmpty ? "" : "_") + dateFormatter.string(from: now) + case .superCompactDateTime: + dateFormatter.dateFormat = "yyyyMMdd_HHmmss" + example += (prefix.isEmpty ? "" : "_") + dateFormatter.string(from: now) + case .timestamp: + example += (prefix.isEmpty ? "" : "_") + String(Int(now.timeIntervalSince1970)) + case .prefixOnly: + if example.isEmpty { example = "filename" } + case .custom: + var custom = tempFilenameCustomFormat + // Simple replacement for preview, the real logic is in ScreenshotApp + dateFormatter.dateFormat = "yyyy"; custom = custom.replacingOccurrences(of: "{YYYY}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "yy"; custom = custom.replacingOccurrences(of: "{YY}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "MM"; custom = custom.replacingOccurrences(of: "{MM}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "dd"; custom = custom.replacingOccurrences(of: "{DD}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "HH"; custom = custom.replacingOccurrences(of: "{hh}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "mm"; custom = custom.replacingOccurrences(of: "{mm}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "ss"; custom = custom.replacingOccurrences(of: "{ss}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "SSS"; custom = custom.replacingOccurrences(of: "{ms}", with: dateFormatter.string(from: now)) + if custom.isEmpty && prefix.isEmpty { example = "custom_example" } + else { example += (prefix.isEmpty || custom.starts(with: ["_", "-", " "]) ? "" : "_") + custom } + } + return example + ".png" + } + + // MARK: - AI Model Management + private static func getModelPath() -> String { + // Use Application Support directory instead of app bundle (which is read-only) + let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let appDirectory = appSupportURL.appendingPathComponent("ShotScreen") + + // Create directory if it doesn't exist + try? FileManager.default.createDirectory(at: appDirectory, withIntermediateDirectories: true) + + return appDirectory.appendingPathComponent("bria-rmbg-coreml.mlpackage").path + } + + private func getModelPath() -> String { + return Self.getModelPath() + } + + private static func isModelInstalled() -> Bool { + return FileManager.default.fileExists(atPath: getModelPath()) + } + + private func isModelInstalled() -> Bool { + return Self.isModelInstalled() + } + + private func downloadModel() { + guard !isDownloadingModel else { return } + + isDownloadingModel = true + downloadProgress = 0.0 + + let downloadURL = AppConfig.modelDownloadURL + + guard let url = URL(string: downloadURL) else { + print("โŒ Invalid download URL") + isDownloadingModel = false + return + } + + // Create secure URLRequest with security headers + var request = URLRequest(url: url) + + // Add all security headers + for (key, value) in AppConfig.secureHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + + // Set secure connection properties + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.timeoutInterval = 60.0 + + // Try two approaches: downloadTask first, then dataTask as fallback + print("๐Ÿš€ Starting model download with downloadTask approach...") + + let task = URLSession.shared.downloadTask(with: request) { [self] tempURL, response, error in + // Process the file IMMEDIATELY in the completion handler (not async) + var permanentURL: URL? + + if let error = error { + print("โŒ DownloadTask failed, trying dataTask approach: \(error.localizedDescription)") + DispatchQueue.main.async { + self.downloadModelWithDataTask(url: url) + } + return + } + + guard let tempURL = tempURL else { + print("โŒ No temporary file URL, trying dataTask approach") + DispatchQueue.main.async { + self.downloadModelWithDataTask(url: url) + } + return + } + + // SYNCHRONOUSLY copy het tijdelijke bestand BEFORE going to main queue + do { + let permanentLocation = FileManager.default.temporaryDirectory + .appendingPathComponent("rmbg_download_\(UUID().uuidString).tmp") + + try FileManager.default.copyItem(at: tempURL, to: permanentLocation) + permanentURL = permanentLocation + print("๐Ÿ“‹ Copied temp file to permanent location: \(permanentLocation.path)") + + } catch { + print("โŒ Failed to copy temp file IMMEDIATELY: \(error.localizedDescription)") + print("๐Ÿ”„ Trying dataTask approach as fallback...") + DispatchQueue.main.async { + self.downloadModelWithDataTask(url: url) + } + return + } + + // Now go to main queue with the permanent file + DispatchQueue.main.async { + self.isDownloadingModel = false + + if let permanentURL = permanentURL { + self.installModel(from: permanentURL) + } + } + } + + // Progress tracking + let _ = task.progress.observe(\.fractionCompleted) { progress, _ in + DispatchQueue.main.async { + self.downloadProgress = progress.fractionCompleted + } + } + + task.resume() + } + + // MARK: - Security File Validation + private func validateDownloadedFile(at url: URL, fileSize: Int64) -> Bool { + print("๐Ÿ”’ SECURITY: Validating downloaded file...") + + // ๐Ÿค– Special handling for ML models (less strict validation) + if isMLModelDownload(fileName: url.lastPathComponent, fileSize: fileSize) { + return validateMLModel(at: url, fileSize: fileSize) + } + + // 1. File size validation (between 1MB and 200MB) + let minSize: Int64 = 1 * 1024 * 1024 // 1MB minimum + let maxSize: Int64 = AppConfig.maxDownloadSize // 200MB maximum + + if fileSize < minSize { + print("โŒ SECURITY: File too small (\(fileSize) bytes) - possible malicious file") + return false + } + + if fileSize > maxSize { + print("โŒ SECURITY: File too large (\(fileSize) bytes) - exceeds safe limit") + return false + } + + // 2. File extension validation (basic check) + let fileName = url.lastPathComponent.lowercased() + let allowedExtensions = ["zip", "mlpackage", "tmp"] + let hasValidExtension = allowedExtensions.contains { fileName.hasSuffix($0) } + + if !hasValidExtension { + print("โŒ SECURITY: Suspicious file extension: \(fileName)") + return false + } + + // 3. Magic byte validation + do { + let fileHandle = try FileHandle(forReadingFrom: url) + defer { fileHandle.closeFile() } + + let headerData = fileHandle.readData(ofLength: 16) + + // Check for known safe file types + if let isValid = validateFileHeader(headerData, fileName: fileName) { + if !isValid { + print("โŒ SECURITY: Invalid file header for type") + return false + } + } + + // 4. Basic content scan for suspicious patterns + if !performBasicContentScan(fileHandle: fileHandle, fileName: fileName) { + return false + } + + } catch { + print("โŒ SECURITY: Could not read file for validation: \(error)") + return false + } + + print("โœ… SECURITY: File validation passed") + return true + } + + // MARK: - ML Model Specific Validation (Less Strict) + private func isMLModelDownload(fileName: String, fileSize: Int64) -> Bool { + let lowerFileName = fileName.lowercased() + + // Check for known ML model patterns + let mlModelPatterns = [ + "bria-rmbg", "rmbg", "coreml", "mlpackage", + "model", "neural", "ai" + ] + + // Check if filename contains ML model indicators + let isMLModel = mlModelPatterns.contains { pattern in + lowerFileName.contains(pattern) + } + + // Check if file size is in reasonable ML model range (10MB - 150MB) + let mlModelMinSize: Int64 = 10 * 1024 * 1024 // 10MB + let mlModelMaxSize: Int64 = 150 * 1024 * 1024 // 150MB + let isSizeReasonable = fileSize >= mlModelMinSize && fileSize <= mlModelMaxSize + + return isMLModel && isSizeReasonable + } + + private func validateMLModel(at url: URL, fileSize: Int64) -> Bool { + print("๐Ÿค– DEBUG: ML Model validation starting (less strict)...") + print("๐Ÿค– DEBUG: File: \(url.lastPathComponent), Size: \(fileSize) bytes") + + // 1. Size validation for ML models (10MB - 150MB) + let mlModelMinSize: Int64 = 10 * 1024 * 1024 // 10MB + let mlModelMaxSize: Int64 = 150 * 1024 * 1024 // 150MB + + if fileSize < mlModelMinSize { + print("โŒ DEBUG: ML model too small (\(fileSize) bytes, min: \(mlModelMinSize))") + return false + } + + if fileSize > mlModelMaxSize { + print("โŒ DEBUG: ML model too large (\(fileSize) bytes, max: \(mlModelMaxSize))") + return false + } + + print("โœ… DEBUG: ML model size validation passed (\(fileSize) bytes)") + + // 2. Basic file header check (allow ZIP or unknown formats) + do { + let fileHandle = try FileHandle(forReadingFrom: url) + defer { fileHandle.closeFile() } + + let headerData = fileHandle.readData(ofLength: 16) + print("๐Ÿ” DEBUG: ML model header: \(headerData.map { String(format: "0x%02X", $0) }.joined(separator: " "))") + + // For ML models, be more permissive with headers + if headerData.count >= 2 { + // Allow ZIP files (most common for ML models) + if headerData[0] == 0x50 && headerData[1] == 0x4B { + print("โœ… DEBUG: ML model ZIP format detected") + return true + } + + // Allow other binary formats (MLPackage can have various headers) + print("โ„น๏ธ DEBUG: ML model with unknown header - allowing (likely MLPackage)") + return true + } + + } catch { + print("โš ๏ธ DEBUG: Could not read ML model header (allowing anyway): \(error)") + // For ML models, be permissive if we can't read the header + return true + } + + print("โœ… DEBUG: ML model validation passed") + return true + } + + private func validateFileHeader(_ headerData: Data, fileName: String) -> Bool? { + guard headerData.count >= 4 else { return nil } + + // ZIP file validation (PK signature) + if headerData[0] == 0x50 && headerData[1] == 0x4B { + // Valid ZIP signatures: PK\x03\x04, PK\x05\x06, PK\x07\x08 + let signature = (UInt16(headerData[2]) << 8) | UInt16(headerData[3]) + let validZipSignatures: [UInt16] = [0x0304, 0x0506, 0x0708] + + if validZipSignatures.contains(signature) { + print("โœ… SECURITY: Valid ZIP file header") + return true + } else { + print("โŒ SECURITY: Invalid ZIP signature") + return false + } + } + + // For .mlpackage (usually a directory, but could be packed differently) + if fileName.contains("mlpackage") { + // MLPackage can have various formats, allow more flexibility + print("โ„น๏ธ SECURITY: MLPackage file - allowing flexible header") + return true + } + + // Unknown file type - allow but log + print("โš ๏ธ SECURITY: Unknown file type - proceeding with caution") + return nil + } + + private func performBasicContentScan(fileHandle: FileHandle, fileName: String) -> Bool { + print("๐Ÿ” SECURITY: Performing basic content scan...") + + // ๐Ÿค– Skip intensive scanning for ML models (they naturally have high entropy) + let lowerFileName = fileName.lowercased() + let isMLModel = ["bria-rmbg", "rmbg", "coreml", "mlpackage", "model"].contains { pattern in + lowerFileName.contains(pattern) + } + + if isMLModel { + print("๐Ÿค– SECURITY: Skipping intensive content scan for ML model") + return true + } + + // Read a larger sample for content analysis (first 1KB) + fileHandle.seek(toFileOffset: 0) + let sampleData = fileHandle.readData(ofLength: 1024) + + // Convert to string for text analysis (if possible) + if let sampleText = String(data: sampleData, encoding: .utf8) { + // Check for suspicious script patterns + let suspiciousPatterns = [ + "#!/bin/sh", "#!/bin/bash", "cmd.exe", "powershell", + " 7.5 { // High entropy could indicate encryption/obfuscation + print("โš ๏ธ SECURITY: High entropy detected (\(String(format: "%.2f", entropy))) - possible obfuscated content") + // Don't fail on high entropy alone, just log it + } + + print("โœ… SECURITY: Content scan passed") + return true + } + + private func calculateEntropy(data: Data) -> Double { + var frequency = [UInt8: Int]() + + // Count byte frequencies + for byte in data { + frequency[byte, default: 0] += 1 + } + + // Calculate Shannon entropy + let dataLength = Double(data.count) + var entropy: Double = 0.0 + + for count in frequency.values { + let probability = Double(count) / dataLength + if probability > 0 { + entropy -= probability * log2(probability) + } + } + + return entropy + } + + private func downloadModelWithDataTask(url: URL) { + print("๐Ÿ”„ Falling back to dataTask approach...") + + // Reset progress for new attempt + downloadProgress = 0.0 + + var request = URLRequest(url: url) + + // Add all security headers + for (key, value) in AppConfig.secureHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + + // Set secure connection properties + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.timeoutInterval = 60.0 + + let task = URLSession.shared.dataTask(with: request) { [self] data, response, error in + DispatchQueue.main.async { + self.isDownloadingModel = false + + if let error = error { + print("โŒ DataTask download error: \(error.localizedDescription)") + self.downloadProgress = 0.0 + return + } + + guard let data = data else { + print("โŒ No data received") + self.downloadProgress = 0.0 + return + } + + print("๐Ÿ“Š Downloaded data size: \(data.count) bytes") + + // Write data to temporary file + do { + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("rmbg_datadownload_\(UUID().uuidString).tmp") + + try data.write(to: tempURL) + print("๐Ÿ“ Wrote data to temp file: \(tempURL.path)") + + self.installModel(from: tempURL) + } catch { + print("โŒ Failed to write downloaded data: \(error.localizedDescription)") + self.downloadProgress = 0.0 + } + } + } + + task.resume() + } + + private func installModel(from tempURL: URL) { + do { + print("๐Ÿ” DEBUG: Starting model installation from: \(tempURL.path)") + + // Check if the file exists and get its size + let fileAttributes = try FileManager.default.attributesOfItem(atPath: tempURL.path) + let fileSize = fileAttributes[.size] as? Int64 ?? 0 + print("๐Ÿ“Š DEBUG: Downloaded file size: \(fileSize) bytes") + + // ๐Ÿ”’ SECURITY: Basic file validation + print("๐Ÿ”’ DEBUG: Starting file validation...") + if !validateDownloadedFile(at: tempURL, fileSize: fileSize) { + print("โŒ DEBUG: File validation failed - removing malicious file") + try? FileManager.default.removeItem(at: tempURL) + DispatchQueue.main.async { + self.isDownloadingModel = false + self.downloadProgress = 0.0 + } + return + } + print("โœ… DEBUG: File validation passed") + + let destinationPath = getModelPath() + let destinationURL = URL(fileURLWithPath: destinationPath) + + // Remove existing model if present + if FileManager.default.fileExists(atPath: destinationPath) { + try FileManager.default.removeItem(at: destinationURL) + } + + // Check if the downloaded file is a zip archive + let fileHandle = try FileHandle(forReadingFrom: tempURL) + let header = fileHandle.readData(ofLength: 4) + fileHandle.closeFile() + + // ZIP file magic numbers: PK\x03\x04 or PK\x05\x06 or PK\x07\x08 + let isPKZip = header.count >= 2 && header[0] == 0x50 && header[1] == 0x4B + + print("๐Ÿ” DEBUG: isPKZip = \(isPKZip), header = \(header.map { String(format: "0x%02X", $0) }.joined(separator: " "))") + + if isPKZip { + print("๐Ÿ“ฆ DEBUG: File appears to be a ZIP archive, extracting...") + + // Create temporary directory for extraction + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + print("๐Ÿ“ DEBUG: Created temp extraction directory: \(tempDir.path)") + + // Unzip the downloaded file + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-q", tempURL.path, "-d", tempDir.path] + + try process.run() + process.waitUntilExit() + + print("๐Ÿ“ฆ DEBUG: Unzip exit code: \(process.terminationStatus)") + + if process.terminationStatus != 0 { + print("โŒ DEBUG: Failed to unzip model with exit code \(process.terminationStatus)") + return + } + + // Find the .mlpackage file + let contents = try FileManager.default.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil) + print("๐Ÿ“ DEBUG: Extracted contents: \(contents.map { $0.lastPathComponent })") + + guard let mlpackageURL = contents.first(where: { $0.pathExtension == "mlpackage" || $0.lastPathComponent.contains("bria-rmbg-coreml") }) else { + print("โŒ DEBUG: No .mlpackage found in downloaded file") + print("๐Ÿ“ DEBUG: All contents: \(contents.map { "\($0.lastPathComponent) (ext: \($0.pathExtension))" })") + // Cleanup temp directory + try? FileManager.default.removeItem(at: tempDir) + return + } + + print("๐Ÿ“ฆ DEBUG: Found mlpackage: \(mlpackageURL.lastPathComponent)") + + // Copy the extracted model + try FileManager.default.copyItem(at: mlpackageURL, to: destinationURL) + print("๐Ÿ“ฆ DEBUG: Successfully copied model to: \(destinationURL.path)") + + // Cleanup + try FileManager.default.removeItem(at: tempDir) + print("๐Ÿ—‘ DEBUG: Cleaned up temp directory") + + } else { + print("๐Ÿ“ฑ DEBUG: File appears to be a direct .mlpackage, copying directly...") + + // Assume it's already a .mlpackage directory and copy directly + try FileManager.default.copyItem(at: tempURL, to: destinationURL) + print("๐Ÿ“ฑ DEBUG: Successfully copied direct model to: \(destinationURL.path)") + } + + print("โœ… DEBUG: Model installed successfully") + + // Check if model actually exists at destination + let finalModelExists = FileManager.default.fileExists(atPath: destinationPath) + print("๐Ÿ” DEBUG: Model exists at destination: \(finalModelExists) (\(destinationPath))") + + // Cleanup: Remove the downloaded temp file + try? FileManager.default.removeItem(at: tempURL) + print("๐Ÿ—‘ DEBUG: Cleaned up downloaded temp file") + + // Refresh the UI to show the model is now available + DispatchQueue.main.async { + // Update the local state to refresh the Settings UI + print("๐Ÿ”„ DEBUG: Updating UI state - setting isRMBGModelInstalled = true") + self.isRMBGModelInstalled = true + self.isDownloadingModel = false + self.downloadProgress = 0.0 + + // Notify background remove windows that model is now available + NotificationCenter.default.post( + name: NSNotification.Name("RMBGModelDownloadCompleted"), + object: nil + ) + print("๐Ÿ“ก DEBUG: Sent RMBGModelDownloadCompleted notification") + } + + } catch { + print("โŒ DEBUG: Installation error: \(error.localizedDescription)") + print("โŒ DEBUG: Full error: \(error)") + + // Cleanup: Remove the downloaded temp file on error + try? FileManager.default.removeItem(at: tempURL) + print("๐Ÿ—‘ DEBUG: Cleaned up temp file after error") + + // Reset download state on error + DispatchQueue.main.async { + print("๐Ÿ”„ DEBUG: Resetting download state due to error") + self.isDownloadingModel = false + self.downloadProgress = 0.0 + } + } + } + + private func removeModel() { + let modelPath = getModelPath() + + do { + if FileManager.default.fileExists(atPath: modelPath) { + try FileManager.default.removeItem(atPath: modelPath) + print("โœ… Model removed successfully") + + // Update UI state + DispatchQueue.main.async { + self.isRMBGModelInstalled = false + + // Notify other parts of the app that model was removed + NotificationCenter.default.post( + name: NSNotification.Name("RMBGModelRemoved"), + object: nil + ) + } + } else { + print("โš ๏ธ Model file not found at path: \(modelPath)") + } + } catch { + print("โŒ Error removing model: \(error.localizedDescription)") + } + } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + // Add minimal top padding since traffic lights are hidden + Spacer() + .frame(height: 20) + + VStack(spacing: 24) { + // Title Section + VStack(spacing: 8) { + Text("ShotScreen Settings") + .font(.largeTitle) + .fontWeight(.bold) + .foregroundColor(.primary) + .multilineTextAlignment(.center) + + Text("Configure your screenshot experience") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(.top, 10) + .padding(.bottom, 20) + + // Thumbnail Section + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Thumbnail") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + Divider() + } + HStack { + Text("Visibility duration:") + Slider(value: $tempThumbnailTimer, in: 5...31, step: 1) { + Text("") + } + Text(formatTimerDisplay(tempThumbnailTimer)) + .frame(width: 80, alignment: .trailing) + } + + HStack { + Text("Thumbnail size:") + .frame(width: 110, alignment: .leading) + Picker("", selection: $tempThumbnailFixedSize) { + ForEach(ThumbnailFixedSize.allCases, id: \.self) { size in + Text(size.displayName).tag(size) + } + } + .labelsHidden() + } + + HStack { + Text("Show on screen:") + .frame(width: 110, alignment: .leading) + Picker("", selection: $tempThumbnailDisplayScreen) { + ForEach(getAvailableScreenOptions(), id: \.self) { option in + Text(getScreenDisplayName(for: option)).tag(option) + } + } + .labelsHidden() + } + + Toggle("Close thumbnail after dragging", isOn: $tempCloseAfterDrag) + Toggle("Close thumbnail after saving", isOn: $tempCloseAfterSave) + + Divider() + + // Storage Subsection + VStack(alignment: .leading, spacing: 12) { + Text("Storage") + .font(.headline) + .foregroundColor(.primary) + + // Folder settings + HStack { + TextField("Folder path", text: .constant(folderPathDisplay)) + .disabled(true) + Button("Choose...") { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.message = "Select folder to save screenshots" + panel.prompt = "Choose" + if panel.runModal() == .OK, let url = panel.url { + tempScreenshotFolder = url.path + folderPathDisplay = url.path + } + } + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Save Mode:") + .frame(width: 110, alignment: .leading) + Picker("", selection: Binding( + get: { tempAutoSaveScreenshot ? ScreenshotSaveMode.automatic : ScreenshotSaveMode.manual }, + set: { tempAutoSaveScreenshot = ($0 == .automatic) } + )) { + ForEach(ScreenshotSaveMode.allCases) { mode in + Text(mode.description).tag(mode) + } + } + .labelsHidden() + } + + // Warning text voor manual mode + if !tempAutoSaveScreenshot { + Text("Recommending to set timer to No Timeout, otherwise the screenshot will close and is lost") + .font(.caption) + .foregroundColor(.red) + .padding(.top, 4) + } + } + + Toggle("Show folder button in thumbnail", isOn: $tempShowFolderButton) + } + + Divider() + + // Cache Management Subsection + VStack(alignment: .leading, spacing: 12) { + Text("Cache Management") + .font(.headline) + .foregroundColor(.primary) + + // Cache info display + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Cache size:") + .frame(width: 110, alignment: .leading) + Text("\(String(format: "%.1f", cacheSize)) MB (\(cacheFileCount) files)") + .foregroundColor(.secondary) + .font(.caption) + + Spacer() + + Button("Open Folder") { + openCacheFolder() + } + .buttonStyle(.bordered) + .controlSize(.small) + + Button("Refresh") { + updateCacheInfo() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Cache contains temporary screenshots for thumbnail restoration and recovery") + .font(.caption) + .foregroundColor(.secondary) + } + } + + // Cache retention time setting + HStack { + Text("Keep for:") + .frame(width: 110, alignment: .leading) + Picker("", selection: $tempCacheRetentionTime) { + ForEach(CacheRetentionTime.allCases, id: \.self) { time in + Text(time.displayName).tag(time) + } + } + .labelsHidden() + } + + // Clear cache button + HStack { + Button("Clear Cache") { + showClearCacheConfirmation = true + } + .buttonStyle(.bordered) + .foregroundColor(.red) + + Spacer() + + Text("Removes old screenshots but preserves active thumbnails") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .padding(20) + .background(Color.primary.opacity(0.03)) + .cornerRadius(12) + .onAppear { + updateCacheInfo() + } + + // Screenshot Section + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Screenshot") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + Divider() + } + Toggle("Hide desktop icons during screenshots", isOn: $tempHideDesktopIconsDuringScreenshot) + .help("When enabled, desktop icons will be temporarily hidden during screenshot capture for a cleaner look") + + Toggle("Hide desktop widgets during screenshots", isOn: $tempHideDesktopWidgetsDuringScreenshot) + .help("When enabled, desktop widgets (like weather widgets, clock widgets, etc.) will be temporarily hidden during screenshot capture for a cleaner look") + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text("Screenshot Shortcut") + .font(.headline) + .foregroundColor(.primary) + + Toggle("Use Custom Shortcut", isOn: $settings.useCustomShortcut) + + if settings.useCustomShortcut { + HStack { + Text("Custom Shortcut:") + .frame(minWidth: 120, alignment: .leading) + + ShortcutRecorder( + modifiers: $settings.customShortcutModifiers, + keyCode: $settings.customShortcutKey, + placeholder: "Click to record shortcut" + ) + .frame(height: 28) + } + + Text("Current: \(formatCurrentShortcut())") + .font(.caption) + .foregroundColor(.secondary) + } else { + HStack { + Text("Using Default:") + .frame(minWidth: 120, alignment: .leading) + Text("โŒ˜โ‡ง4") + .font(.system(size: 16, design: .monospaced)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.2)) + .cornerRadius(4) + } + } + + HStack { + Button("Open macOS Screenshot Settings") { + openMacOSScreenshotSettings() + } + .buttonStyle(.bordered) + + Spacer() + } + + Text("Note: Disable the macOS Cmd+Shift+4 shortcut in System Preferences to avoid conflicts.") + .font(.caption) + .foregroundColor(.secondary) + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Screenshot Modes") + .font(.headline) + .foregroundColor(.primary) + + let currentShortcut = getCurrentShortcutDisplay() + + Text("โ€ข \(currentShortcut) once: Drag to select area or click for full screen") + .font(.caption) + .foregroundColor(.secondary) + Text("โ€ข \(currentShortcut) twice: Capture whole screen under cursor") + .font(.caption) + .foregroundColor(.secondary) + Text("โ€ข \(currentShortcut) three times: Capture all screens combined") + .font(.caption) + .foregroundColor(.secondary) + Text("โ€ข During selection: Press Space for window capture") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(20) + .background(Color.primary.opacity(0.03)) + .cornerRadius(12) + + // Filename Format Section + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Filename Format") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + Divider() + } + TextField("Prefix:", text: $tempFilenamePrefix) + HStack { + Text("Format Preset:") + .frame(width: 110, alignment: .leading) + Picker("", selection: $tempFilenameFormatPreset) { + ForEach(FilenameFormatPreset.allCases, id: \.self) { preset in + Text(preset.description).tag(preset) + } + } + .labelsHidden() + } + + if tempFilenameFormatPreset == .custom { + TextField("Custom Format:", text: $tempFilenameCustomFormat) + Text("Use: {YYYY}, {MM}, {DD}, {hh}, {mm}, {ss}, {ms}") + .font(.caption) + .foregroundColor(.gray) + } + Text("Example: \(updateFilenameExample())") + .font(.caption) + .foregroundColor(.gray) + } + .padding() + .background(Color.primary.opacity(0.03)) + .cornerRadius(12) + + // Action Bar Section + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Action Bar") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Spacer() + + Button("Reset order") { + tempActionOrder = ActionType.allCases + } + .buttonStyle(.bordered) + } + Divider() + } + + ForEach(tempActionOrder) { action in + HStack { + Toggle("\(action.displayName)", isOn: Binding( + get: { + switch action { + case .rename: return tempIsRenameActionEnabled + case .stash: return tempIsStashActionEnabled + case .ocr: return tempIsOCRActionEnabled + case .clipboard: return tempIsClipboardActionEnabled + case .backgroundRemove: return tempIsBackgroundRemoveActionEnabled + case .cancel: return tempIsCancelActionEnabled + case .remove: return tempIsRemoveActionEnabled + } + }, + set: { newValue in + switch action { + case .rename: tempIsRenameActionEnabled = newValue + case .stash: tempIsStashActionEnabled = newValue + case .ocr: tempIsOCRActionEnabled = newValue + case .clipboard: tempIsClipboardActionEnabled = newValue + case .backgroundRemove: tempIsBackgroundRemoveActionEnabled = newValue + case .cancel: tempIsCancelActionEnabled = newValue + case .remove: tempIsRemoveActionEnabled = newValue + } + } + )) + + Spacer() + + HStack(spacing: 4) { + Button(action: { + moveActionInTemp(action, direction: -1) + }) { + Image(systemName: "arrow.up") + .frame(width: 20, height: 20) + } + .buttonStyle(.borderless) + .disabled(tempActionOrder.first == action) + + Button(action: { + moveActionInTemp(action, direction: 1) + }) { + Image(systemName: "arrow.down") + .frame(width: 20, height: 20) + } + .buttonStyle(.borderless) + .disabled(tempActionOrder.last == action) + } + } + } + } + .padding() + .background(Color.primary.opacity(0.03)) + .cornerRadius(12) + + // Stash Section + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Stash") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Spacer() + + Button("Show Stash") { + openStash() + } + .buttonStyle(.bordered) + } + Divider() + } + + VStack(alignment: .leading, spacing: 12) { + Text("Window Behavior") + .font(.headline) + .foregroundColor(.primary) + + Toggle("Always on top", isOn: $tempStashAlwaysOnTop) + .help("Keep the Stash window visible above all other applications") + + VStack(alignment: .leading, spacing: 4) { + Toggle("Persistent storage", isOn: $tempPersistentStash) + .help("Keep stash images available when you close and reopen the window") + + Text("When enabled, your stashed screenshots remain available after closing the Stash window. When disabled, stash is cleared each time you close it.") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text("Grid Layout") + .font(.headline) + .foregroundColor(.primary) + + HStack { + Text("Layout direction:") + .frame(width: 110, alignment: .leading) + Picker("", selection: $tempStashGridMode) { + ForEach(StashGridMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .labelsHidden() + } + + HStack { + Text(tempStashGridMode == .fixedColumns ? "Max columns:" : "Max rows:") + + Stepper(value: tempStashGridMode == .fixedColumns ? $tempStashMaxColumns : $tempStashMaxRows, + in: 1...5) { + Text("\(tempStashGridMode == .fixedColumns ? tempStashMaxColumns : tempStashMaxRows)") + .foregroundColor(.blue) + .frame(minWidth: 20) + } + } + + Text(tempStashGridMode == .fixedColumns ? + "Grid expands vertically as you add more screenshots" : + "Grid expands horizontally as you add more screenshots") + .font(.caption) + .foregroundColor(.secondary) + } + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text("Preview Settings") + .font(.headline) + .foregroundColor(.primary) + + HStack { + Text("Preview size:") + .frame(width: 110, alignment: .leading) + Picker("", selection: $tempStashPreviewSize) { + ForEach(StashPreviewSize.allCases, id: \.self) { size in + Text(size.displayName).tag(size) + } + } + .labelsHidden() + .help("Choose the size of preview windows when hovering over thumbnails") + } + } + } + .padding(20) + .background(Color.primary.opacity(0.03)) + .cornerRadius(12) + + // Background Removal Section + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Background Removal") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + Divider() + } + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Vision Framework") + .font(.headline) + Text("Apple's built-in background removal - Always available") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + + Text("โœ… Available") + .font(.caption) + .foregroundColor(.green) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.green.opacity(0.1)) + .cornerRadius(8) + .frame(minWidth: 80) + } + + Divider() + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("RMBG-1.4") + .font(.headline) + Text("Better background removal tool - Additional download required") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + + HStack(spacing: 8) { + if isRMBGModelInstalled { + Button(action: { + showRemoveConfirmation = true + }) { + Text("๐Ÿ—‘") + .font(.caption) + .foregroundColor(.red) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.red.opacity(0.1)) + .cornerRadius(8) + } + .buttonStyle(.borderless) + .help("Remove RMBG-1.4 model") + } + + if isRMBGModelInstalled { + Text("โœ… Available") + .font(.caption) + .foregroundColor(.green) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.green.opacity(0.1)) + .cornerRadius(8) + .frame(minWidth: 80) + } else { + Button(action: { + showDownloadConfirmation = true + }) { + if isDownloadingModel { + HStack(spacing: 6) { + ProgressView() + .scaleEffect(0.7) + Text("\(Int(downloadProgress * 100))%") + .font(.caption) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(minWidth: 80) + } else { + Text("๐Ÿ“ฅ Download") + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(minWidth: 80) + } + } + .disabled(isDownloadingModel) + .buttonStyle(.borderedProminent) + } + } + } + + if isDownloadingModel { + ProgressView(value: downloadProgress) + .progressViewStyle(LinearProgressViewStyle()) + } + } + .padding(.vertical, 8) + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text("Preferred Method") + .font(.headline) + .foregroundColor(.primary) + + VStack(alignment: .leading, spacing: 8) { + ForEach(BackgroundRemovalMethod.allCases) { method in + HStack { + Button(action: { + tempPreferredBackgroundRemovalMethod = method + }) { + HStack { + Image(systemName: tempPreferredBackgroundRemovalMethod == method ? "largecircle.fill.circle" : "circle") + .foregroundColor(tempPreferredBackgroundRemovalMethod == method ? .blue : .secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(method.displayName) + .font(.body) + .foregroundColor(.primary) + + Text(method.description) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + .disabled(method == .rmbg && !isRMBGModelInstalled) + .opacity(method == .rmbg && !isRMBGModelInstalled ? 0.5 : 1.0) + } + + if method == .rmbg && !isRMBGModelInstalled { + Text("โš ๏ธ RMBG-1.4 model not installed - this option will not work") + .font(.caption) + .foregroundColor(.orange) + .padding(.leading, 24) + } + } + } + } + } + .padding() + .background(Color.primary.opacity(0.03)) + .cornerRadius(12) + + // Other Section + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Other") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + Divider() + } + + VStack(alignment: .leading, spacing: 12) { + Text("Audio & System") + .font(.headline) + .foregroundColor(.primary) + + Toggle("Play sound on screenshot", isOn: $tempPlaySoundOnCapture) + + if tempPlaySoundOnCapture { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Sound type:") + .frame(width: 110, alignment: .leading) + Picker("", selection: $tempScreenshotSoundType) { + ForEach(ScreenshotSoundType.allCases, id: \.self) { soundType in + Text(soundType.displayName).tag(soundType) + } + } + .labelsHidden() + + Spacer() + + Button("Test") { + testSound() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + HStack { + Text("Volume:") + .frame(width: 110, alignment: .leading) + Slider(value: $tempScreenshotSoundVolume, in: 0.0...1.0, step: 0.05) + Text("\(Int(tempScreenshotSoundVolume * 100))%") + .frame(width: 40, alignment: .trailing) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.leading, 16) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + VStack(alignment: .leading, spacing: 12) { + Text("Startup & Integration") + .font(.headline) + .foregroundColor(.primary) + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Start ShotScreen automatically at login") + .font(.body) + Text("Configure ShotScreen to launch automatically when you log in to your Mac") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Button("Setup Instructions") { + showLoginInstructionsPopup = true + } + .buttonStyle(.bordered) + } + } + } + .padding(20) + .background(Color.primary.opacity(0.03)) + .cornerRadius(12) + + // LICENSE SECTION + LicenseSection() + + // ๐Ÿ”„ UPDATES SECTION + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Updates") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Spacer() + + Button("Check for Updates") { + if let app = NSApp.delegate as? ScreenshotApp { + app.checkForUpdates() + } + } + .buttonStyle(.bordered) + .disabled(!UpdateManager.shared.isUpdaterAvailable) + } + Divider() + } + + VStack(alignment: .leading, spacing: 12) { + Text("Current Version") + .font(.headline) + .foregroundColor(.primary) + + HStack { + Text("ShotScreen") + .font(.subheadline) + .foregroundColor(.secondary) + + Text(getCurrentVersionInfo()) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.1)) + .cornerRadius(6) + + Spacer() + } + } + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text("Contact Developer") + .font(.headline) + .foregroundColor(.primary) + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Need help or have feedback?") + .font(.body) + .foregroundColor(.primary) + + Text("Get in touch with questions, feature requests, or bug reports") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Button("Contact Developer") { + openDeveloperEmail() + } + .buttonStyle(.bordered) + } + + HStack { + Image(systemName: "envelope") + .foregroundColor(.secondary) + .frame(width: 16, height: 16) + + Text("info@shotscreen.app") + .font(.caption) + .foregroundColor(.secondary) + .textSelection(.enabled) + + Spacer() + } + } + + } + .padding(20) + .background(Color.primary.opacity(0.03)) + .cornerRadius(12) + + Spacer() + } + .padding() + } + } + + Divider() + // Save / Cancel buttons + HStack { + // "Apply" knop met visuele feedback - alleen actief als er wijzigingen zijn + Button(action: { + applyAllChanges() + + // Start animatie + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + showApplyConfirmation = true + } + // Na 1,2 s verdwijnt de animatie weer + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + withAnimation(.easeOut) { + showApplyConfirmation = false + } + } + }) { + if showApplyConfirmation { + Label("Applied", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + .transition(.scale.combined(with: .opacity)) + } else { + Text("Apply") + } + } + .keyboardShortcut(.defaultAction) // Return-toets = Apply + .disabled(!hasUnsavedChanges) // Alleen actief als er wijzigingen zijn + + Spacer() + + Button("Cancel") { + resetToCurrentSettings() + } + .disabled(!hasUnsavedChanges) // Alleen actief als er wijzigingen zijn + + Button("Close") { + if hasUnsavedChanges && !forceClose { + showUnsavedChangesAlert = true + } else { + closeWindow() + } + } + } + .padding() + } + .frame(minWidth: 550, idealWidth: 650, minHeight: 800, idealHeight: 800) + .alert("Unsaved Changes", isPresented: $showUnsavedChangesAlert) { + Button("Save & Close") { + applyAllChanges() + forceClose = true + DispatchQueue.main.async { + closeWindow() + } + } + Button("Don't Save", role: .destructive) { + resetToCurrentSettings() + forceClose = true + DispatchQueue.main.async { + closeWindow() + } + } + Button("Cancel", role: .cancel) { + // Do nothing, just dismiss alert + } + } message: { + Text("You have unsaved changes. Do you want to save them before closing?") + } + .alert("Download RMBG-1.4 Model", isPresented: $showDownloadConfirmation) { + Button("Download") { + downloadModel() + } + Button("Cancel", role: .cancel) { + // Do nothing + } + } message: { + Text("The RMBG-1.4 model is approximately 90MB in size. Do you want to download and install it?") + } + .alert("Remove RMBG-1.4 Model", isPresented: $showRemoveConfirmation) { + Button("Remove", role: .destructive) { + removeModel() + } + Button("Cancel", role: .cancel) { + // Do nothing + } + } message: { + Text("Are you sure you want to remove the RMBG-1.4 model? You'll need to download it again to use this feature.") + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RMBGModelDownloadCompleted"))) { _ in + // Update the model status when notified from external sources + isRMBGModelInstalled = Self.isModelInstalled() + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RMBGModelRemoved"))) { _ in + // Update the model status when model is removed + isRMBGModelInstalled = false + } + // Toegevoegd .onReceive voor hideDesktopIconsSettingChanged + .onReceive(NotificationCenter.default.publisher(for: .hideDesktopIconsSettingChanged)) { _ in + print("SettingsTabView: Received hideDesktopIconsSettingChanged notification in backup.") + self.tempHideDesktopIconsDuringScreenshot = SettingsManager.shared.hideDesktopIconsDuringScreenshot + } + .sheet(isPresented: $showLoginInstructionsPopup) { + LoginInstructionsPopupView(openSettingsAction: openLoginItemsSettings) + } + .alert("Clear Cache", isPresented: $showClearCacheConfirmation) { + Button("Clear", role: .destructive) { + clearCache() + } + Button("Cancel", role: .cancel) { + // Do nothing + } + } message: { + Text("Are you sure you want to clear \(String(format: "%.1f", cacheSize)) MB of cached screenshots? Active thumbnails will be preserved.") + } + } + + // Helper to close the containing window + private func closeWindow() { + NSApp.keyWindow?.close() + } + + // NIEUW: Helper methods for shortcut management + private func formatCurrentShortcut() -> String { + let modifiers = settings.customShortcutModifiers + let keyCode = settings.customShortcutKey + + if modifiers == 0 && keyCode == 0 { + return "No shortcut set" + } + + var parts: [String] = [] + + if modifiers & (1 << 3) != 0 { parts.append("โŒƒ") } // Control + if modifiers & (1 << 2) != 0 { parts.append("โŒฅ") } // Option + if modifiers & (1 << 1) != 0 { parts.append("โ‡ง") } // Shift + if modifiers & (1 << 0) != 0 { parts.append("โŒ˜") } // Command + + if let keyName = keyCodeToString(keyCode) { + parts.append(keyName) + } + + return parts.joined() + } + + private func keyCodeToString(_ keyCode: UInt16) -> String? { + // Same mapping as in ShortcutRecorderView + switch keyCode { + case 0: return "A" + case 1: return "S" + case 2: return "D" + case 3: return "F" + case 4: return "H" + case 5: return "G" + case 6: return "Z" + case 7: return "X" + case 8: return "C" + case 9: return "V" + case 11: return "B" + case 12: return "Q" + case 13: return "W" + case 14: return "E" + case 15: return "R" + case 16: return "Y" + case 17: return "T" + case 18: return "1" + case 19: return "2" + case 20: return "3" + case 21: return "4" + case 22: return "6" + case 23: return "5" + case 24: return "=" + case 25: return "9" + case 26: return "7" + case 27: return "-" + case 28: return "8" + case 29: return "0" + case 30: return "]" + case 31: return "O" + case 32: return "U" + case 33: return "[" + case 34: return "I" + case 35: return "P" + case 37: return "L" + case 38: return "J" + case 39: return "'" + case 40: return "K" + case 41: return ";" + case 42: return "\\" + case 43: return "," + case 44: return "/" + case 45: return "N" + case 46: return "M" + case 47: return "." + case 50: return "`" + case 36: return "โ†ฉ" // Return + case 48: return "โ‡ฅ" // Tab + case 49: return "Space" + case 51: return "โŒซ" // Delete + case 53: return "โŽ‹" // Escape + case 122: return "F1" + case 120: return "F2" + case 99: return "F3" + case 118: return "F4" + case 96: return "F5" + case 97: return "F6" + case 98: return "F7" + case 100: return "F8" + case 101: return "F9" + case 109: return "F10" + case 103: return "F11" + case 111: return "F12" + default: return "\(keyCode)" + } + } + + private func openMacOSScreenshotSettings() { + // Open macOS Keyboard > Screenshots settings + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.keyboard?Shortcuts") { + NSWorkspace.shared.open(url) + } + } + + private func openStash() { + // Open Stash window via the main app delegate + if let app = NSApp.delegate as? ScreenshotApp { + app.showStash(nil) + } + } + + private func openLoginItemsSettings() { + print("๐Ÿ”ง Opening Login Items Settings...") + + // Voor macOS 13+ - gebruik eenvoudige URL + if #available(macOS 13.0, *) { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.users") { + NSWorkspace.shared.open(url) + return + } + } + + // Voor macOS 12 en ouder + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.users") { + NSWorkspace.shared.open(url) + } else { + print("โŒ Could not open Login Items Settings") + } + } + + private func getCurrentVersionInfo() -> String { + if let infoDictionary = Bundle.main.infoDictionary { + let version = infoDictionary["CFBundleShortVersionString"] as? String ?? "Unknown" + return "v\(version)" + } + return "Unknown" + } + + private func getCurrentShortcutDisplay() -> String { + let settings = SettingsManager.shared + + if settings.useCustomShortcut && settings.customShortcutModifiers != 0 && settings.customShortcutKey != 0 { + // Use custom shortcut + return formatCurrentShortcut() + } else { + // Use default shortcut + return "โŒ˜โ‡ง4" + } + } + + private func openDeveloperEmail() { + let subject = "ShotScreen \(getCurrentVersionInfo()) - Feedback" + let body = "Hi,\n\nI have a question/feedback about ShotScreen:\n\n" + + // URL encode the subject and body + guard let encodedSubject = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let encodedBody = body.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { + print("โŒ Failed to encode email parameters") + return + } + + let mailtoString = "mailto:info@shotscreen.app?subject=\(encodedSubject)&body=\(encodedBody)" + + if let url = URL(string: mailtoString) { + NSWorkspace.shared.open(url) + print("๐Ÿ“ง Opened default email client to contact developer") + } else { + print("โŒ Failed to create mailto URL") + // Fallback: copy email to clipboard + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString("info@shotscreen.app", forType: .string) + print("๐Ÿ“‹ Copied email address to clipboard as fallback") + } + } + + // ๐Ÿ”Š NEW: Test sound function + private func testSound() { + if let sound = NSSound(named: tempScreenshotSoundType.systemSoundName) { + sound.volume = tempScreenshotSoundVolume + sound.play() + } else { + // Fallback to Pop if selected sound doesn't exist + if let fallbackSound = NSSound(named: "Pop") { + fallbackSound.volume = tempScreenshotSoundVolume + fallbackSound.play() + } + } + } + + // ๐Ÿ—‚๏ธ NEW: Cache management functions + private func updateCacheInfo() { + DispatchQueue.global(qos: .background).async { + let size = CacheManager.shared.getCacheSize() + let count = CacheManager.shared.getCacheFileCount() + + DispatchQueue.main.async { + self.cacheSize = size + self.cacheFileCount = count + } + } + } + + private func clearCache() { + DispatchQueue.global(qos: .background).async { + let result = CacheManager.shared.clearCache(preserveActiveThumbnails: true) + + DispatchQueue.main.async { + print("โœ… Cache cleared: \(result.deletedFiles) files, \(String(format: "%.1f", result.savedSpace)) MB freed") + // Update cache info after clearing + self.updateCacheInfo() + } + } + } + + private func openCacheFolder() { + let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let shotScreenDirectory = appSupportDirectory.appendingPathComponent("ShotScreen") + let thumbnailsDirectory = shotScreenDirectory.appendingPathComponent("Thumbnails") + + // Create directory if it doesn't exist + try? FileManager.default.createDirectory(at: thumbnailsDirectory, withIntermediateDirectories: true, attributes: nil) + + // Open in Finder + NSWorkspace.shared.open(thumbnailsDirectory) + print("๐Ÿ“ Opened cache folder: \(thumbnailsDirectory.path)") + } + + private func openPurchaseURL() { + // ShotScreen Gumroad license product + if let url = URL(string: "https://roodenrijs.gumroad.com/l/uxexr") { + NSWorkspace.shared.open(url) + } + } +} + +class SettingsWindow: NSWindow { + private weak var screenshotDelegate: ScreenshotApp? + private var visualEffectViewContainer: NSView? + + init(currentFolder: URL?, timerValue: Int, delegate: ScreenshotApp) { + self.screenshotDelegate = delegate + + super.init(contentRect: NSMakeRect(0, 0, 600, 800), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false) + + self.title = "ShotScreen Settings" + self.isReleasedWhenClosed = false + self.center() + + // Ensure the window appears in front and can be brought to front + self.level = .normal + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + self.isOpaque = false + self.backgroundColor = .clear + self.titlebarAppearsTransparent = true + self.titleVisibility = .hidden + self.isMovableByWindowBackground = true + + // Hide traffic lights (red, yellow, green buttons) to prevent content overlap + self.standardWindowButton(.closeButton)?.isHidden = true + self.standardWindowButton(.miniaturizeButton)?.isHidden = true + self.standardWindowButton(.zoomButton)?.isHidden = true + + let settingsTabView = SettingsTabView() + let hostingView = NSHostingView(rootView: settingsTabView) + + let visualEffectView = NSVisualEffectView() + visualEffectView.blendingMode = .behindWindow + visualEffectView.material = .hudWindow // krachtigste systeemblur + visualEffectView.state = .active + visualEffectView.alphaValue = 1.0 + visualEffectView.autoresizingMask = [.width, .height] + + // ---------- NIEUW: extra blurlaag ---------- + let extraBlurView = NSVisualEffectView() + extraBlurView.blendingMode = .behindWindow // zelfde type blur + extraBlurView.material = .hudWindow + extraBlurView.state = .active + extraBlurView.alphaValue = 0.6 // half transparant โ†’ optisch meer blur + extraBlurView.autoresizingMask = [.width, .height] + // ------------------------------------------- + + let newRootContentView = NSView(frame: self.contentRect(forFrameRect: self.frame)) + + // volgorde: sterkste blur onderaan, halve blur daarboven, SwiftUI-content erboven + visualEffectView.frame = newRootContentView.bounds + extraBlurView.frame = newRootContentView.bounds + + newRootContentView.addSubview(visualEffectView) // laag 0 + newRootContentView.addSubview(extraBlurView) // laag 1 (versterking) + newRootContentView.addSubview(hostingView) // laag 2 (UI) + + self.contentView = newRootContentView + self.visualEffectViewContainer = newRootContentView + + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + + hostingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: newRootContentView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: newRootContentView.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: newRootContentView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: newRootContentView.trailingAnchor) + ]) + + // Forceer een layout pass na het opzetten van de hierarchy + self.contentView?.layoutSubtreeIfNeeded() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc override func close() { + super.close() + } +} + +// Helper om toegang te krijgen tot de ScreenshotApp instantie voor UI updates +// Dit is een beetje een hack; idealiter zou de SettingsManager zelf de updates afhandelen +// of zou er een meer formele delegate/callback structuur zijn. +extension ScreenshotApp { + static var sharedInstance: ScreenshotApp? { + return NSApp.delegate as? ScreenshotApp + } +} + +struct VisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + } +} + +// MARK: - Shortcut Recorder Component +struct ShortcutRecorder: NSViewRepresentable { + @Binding var modifiers: UInt + @Binding var keyCode: UInt16 + let placeholder: String + + func makeNSView(context: Context) -> ShortcutRecorderView { + let view = ShortcutRecorderView() + view.placeholder = placeholder + view.onShortcutChange = { modifiers, keyCode in + self.modifiers = modifiers + self.keyCode = keyCode + } + return view + } + + func updateNSView(_ nsView: ShortcutRecorderView, context: Context) { + nsView.modifiers = modifiers + nsView.keyCode = keyCode + nsView.placeholder = placeholder + } +} + +class ShortcutRecorderView: NSView { + var placeholder: String = "Click to record shortcut" { + didSet { needsDisplay = true } + } + var modifiers: UInt = 0 { + didSet { needsDisplay = true } + } + var keyCode: UInt16 = 0 { + didSet { needsDisplay = true } + } + var onShortcutChange: ((UInt, UInt16) -> Void)? + + private var isRecording = false + private var localEventMonitor: Any? + private var globalEventMonitor: Any? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + wantsLayer = true + layer?.cornerRadius = 8 + layer?.borderWidth = 2 + layer?.borderColor = NSColor.separatorColor.cgColor + layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor + + // Make this view focusable and always accept first responder + needsDisplay = true + } + + override var acceptsFirstResponder: Bool { return true } + override var canBecomeKeyView: Bool { return true } + + // Force focus when clicked + override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + if result { + layer?.borderColor = NSColor.systemBlue.cgColor + needsDisplay = true + } + return result + } + + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + layer?.borderColor = NSColor.separatorColor.cgColor + needsDisplay = true + return result + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + // Draw background with recording state + if isRecording { + NSColor.controlAccentColor.withAlphaComponent(0.1).setFill() + bounds.fill() + } + + let text: String + if modifiers == 0 && keyCode == 0 { + text = isRecording ? "โŒจ๏ธ Press shortcut keys... (ESC to cancel)" : placeholder + } else { + text = formatShortcut(modifiers: modifiers, keyCode: keyCode) + } + + let fontSize: CGFloat = isRecording ? 12 : 13 + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: fontSize, weight: isRecording ? .medium : .regular), + .foregroundColor: isRecording ? NSColor.systemBlue : NSColor.controlTextColor + ] + + let attributedString = NSAttributedString(string: text, attributes: attrs) + let textRect = attributedString.boundingRect(with: bounds.size, options: .usesLineFragmentOrigin) + let centeredRect = NSRect( + x: (bounds.width - textRect.width) / 2, + y: (bounds.height - textRect.height) / 2, + width: textRect.width, + height: textRect.height + ) + + attributedString.draw(in: centeredRect) + + // Draw recording indicator + if isRecording { + let dotSize: CGFloat = 6 + let dotRect = NSRect( + x: bounds.width - dotSize - 8, + y: bounds.height - dotSize - 8, + width: dotSize, + height: dotSize + ) + NSColor.systemRed.setFill() + NSBezierPath(ovalIn: dotRect).fill() + } + } + + override func mouseDown(with event: NSEvent) { + // Force focus first, then start recording + if window?.makeFirstResponder(self) == true { + // Small delay to ensure focus is properly established + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.startRecording() + } + } else { + startRecording() + } + } + + override func keyDown(with event: NSEvent) { + if isRecording { + let modifierFlags = event.modifierFlags.intersection([.command, .shift, .option, .control]) + let newModifiers = convertModifiers(modifierFlags) + let newKeyCode = event.keyCode + + print("๐ŸŽน ShortcutRecorderView: keyDown - modifiers: \(newModifiers), keyCode: \(newKeyCode)") + + // Require at least one modifier + if newModifiers != 0 { + self.modifiers = newModifiers + self.keyCode = newKeyCode + onShortcutChange?(newModifiers, newKeyCode) + stopRecording() + } else if event.keyCode == 53 { // ESC key + print("๐ŸŽน ShortcutRecorderView: ESC pressed, stopping recording") + stopRecording() + } + } + } + + private func startRecording() { + guard !isRecording else { return } + + print("๐ŸŽน ShortcutRecorderView: Starting recording...") + isRecording = true + needsDisplay = true + + // Force the window to be key and this view to be first responder + window?.makeKey() + window?.makeFirstResponder(self) + + // Add both local and global event monitors for maximum coverage + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in + guard let self = self, self.isRecording else { return event } + + print("๐ŸŽน Local monitor: Event type: \(event.type.rawValue), keyCode: \(event.keyCode)") + + if event.type == .keyDown { + self.keyDown(with: event) + return nil // Consume the event + } + + return event + } + + // Global monitor as backup to catch events even if focus is lost + globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown]) { [weak self] event in + guard let self = self, self.isRecording else { return } + + print("๐ŸŽน Global monitor: keyDown - keyCode: \(event.keyCode)") + + let modifierFlags = event.modifierFlags.intersection([.command, .shift, .option, .control]) + let newModifiers = self.convertModifiers(modifierFlags) + let newKeyCode = event.keyCode + + // Require at least one modifier + if newModifiers != 0 { + DispatchQueue.main.async { + self.modifiers = newModifiers + self.keyCode = newKeyCode + self.onShortcutChange?(newModifiers, newKeyCode) + self.stopRecording() + } + } else if event.keyCode == 53 { // ESC key + DispatchQueue.main.async { + self.stopRecording() + } + } + } + + print("๐ŸŽน ShortcutRecorderView: Recording started, monitors added") + } + + private func stopRecording() { + guard isRecording else { return } + + print("๐ŸŽน ShortcutRecorderView: Stopping recording...") + isRecording = false + needsDisplay = true + + // Remove both event monitors + if let monitor = localEventMonitor { + NSEvent.removeMonitor(monitor) + localEventMonitor = nil + } + + if let monitor = globalEventMonitor { + NSEvent.removeMonitor(monitor) + globalEventMonitor = nil + } + + // Don't immediately resign first responder - let user click elsewhere + print("๐ŸŽน ShortcutRecorderView: Recording stopped, monitors removed") + } + + // Add cleanup when view is removed + deinit { + stopRecording() + print("๐ŸŽน ShortcutRecorderView: deinit") + } + + private func convertModifiers(_ flags: NSEvent.ModifierFlags) -> UInt { + var result: UInt = 0 + if flags.contains(.command) { result |= 1 << 0 } + if flags.contains(.shift) { result |= 1 << 1 } + if flags.contains(.option) { result |= 1 << 2 } + if flags.contains(.control) { result |= 1 << 3 } + return result + } + + private func formatShortcut(modifiers: UInt, keyCode: UInt16) -> String { + var parts: [String] = [] + + if modifiers & (1 << 3) != 0 { parts.append("โŒƒ") } // Control + if modifiers & (1 << 2) != 0 { parts.append("โŒฅ") } // Option + if modifiers & (1 << 1) != 0 { parts.append("โ‡ง") } // Shift + if modifiers & (1 << 0) != 0 { parts.append("โŒ˜") } // Command + + if let keyName = keyCodeToString(keyCode) { + parts.append(keyName) + } + + return parts.joined() + } + + private func keyCodeToString(_ keyCode: UInt16) -> String? { + // Map common key codes to readable strings + switch keyCode { + case 0: return "A" + case 1: return "S" + case 2: return "D" + case 3: return "F" + case 4: return "H" + case 5: return "G" + case 6: return "Z" + case 7: return "X" + case 8: return "C" + case 9: return "V" + case 11: return "B" + case 12: return "Q" + case 13: return "W" + case 14: return "E" + case 15: return "R" + case 16: return "Y" + case 17: return "T" + case 18: return "1" + case 19: return "2" + case 20: return "3" + case 21: return "4" + case 22: return "6" + case 23: return "5" + case 24: return "=" + case 25: return "9" + case 26: return "7" + case 27: return "-" + case 28: return "8" + case 29: return "0" + case 30: return "]" + case 31: return "O" + case 32: return "U" + case 33: return "[" + case 34: return "I" + case 35: return "P" + case 37: return "L" + case 38: return "J" + case 39: return "'" + case 40: return "K" + case 41: return ";" + case 42: return "\\" + case 43: return "," + case 44: return "/" + case 45: return "N" + case 46: return "M" + case 47: return "." + case 50: return "`" + case 36: return "โ†ฉ" // Return + case 48: return "โ‡ฅ" // Tab + case 49: return "Space" + case 51: return "โŒซ" // Delete + case 53: return "โŽ‹" // Escape + case 122: return "F1" + case 120: return "F2" + case 99: return "F3" + case 118: return "F4" + case 96: return "F5" + case 97: return "F6" + case 98: return "F7" + case 100: return "F8" + case 101: return "F9" + case 109: return "F10" + case 103: return "F11" + case 111: return "F12" + default: return "\(keyCode)" + } + } +} + +// MARK: - Login Instructions Popup View +struct LoginInstructionsPopupView: View { + @Environment(\.presentationMode) var presentationMode + let openSettingsAction: () -> Void + + var body: some View { + VStack(spacing: 20) { + // Header + HStack { + Text("How to Add ShotScreen to Login Items") + .font(.title2) + .fontWeight(.bold) + + Spacer() + + Button("Close") { + presentationMode.wrappedValue.dismiss() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.top, 20) + .padding(.horizontal, 20) + + // Instructions text (no image needed for login items) + VStack(alignment: .leading, spacing: 12) { + Text("Follow these steps:") + .font(.headline) + .fontWeight(.medium) + .padding(.bottom, 4) + + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 8) { + Text("1.") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.blue) + .frame(width: 20) + Text("Click 'Open Settings' below to open System Settings") + .font(.body) + } + + HStack(alignment: .top, spacing: 8) { + Text("2.") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.blue) + .frame(width: 20) + Text("Navigate to General โ†’ Login Items") + .font(.body) + } + + HStack(alignment: .top, spacing: 8) { + Text("3.") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.blue) + .frame(width: 20) + Text("Click the + button to add a new login item") + .font(.body) + } + + HStack(alignment: .top, spacing: 8) { + Text("4.") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.blue) + .frame(width: 20) + Text("Find and select ShotScreen from Applications folder") + .font(.body) + } + + HStack(alignment: .top, spacing: 8) { + Text("5.") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.blue) + .frame(width: 20) + Text("Click 'Add' to enable automatic startup") + .font(.body) + } + } + .padding(.leading, 8) + } + .padding(.horizontal, 24) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer() + + // Bottom buttons + HStack(spacing: 12) { + Button("Open Settings") { + print("๐Ÿ”ง Opening Login Items Settings from popup...") + openSettingsAction() + // Don't dismiss popup - let user read instructions while configuring + } + .buttonStyle(.borderedProminent) + + Button("Got it!") { + presentationMode.wrappedValue.dismiss() + } + .buttonStyle(.bordered) + } + .padding(.bottom, 20) + } + .frame(width: 500, height: 400) + .background(Color(NSColor.windowBackgroundColor)) + } +} + +// MARK: - License Section +struct LicenseSection: View { + @ObservedObject private var licenseManager = LicenseManager.shared + @State private var showLicenseWindow = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("License") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Spacer() + + Button("Manage License") { + showLicenseDialog() + } + .buttonStyle(.bordered) + } + Divider() + } + + VStack(alignment: .leading, spacing: 12) { + // License Status Display + switch licenseManager.licenseStatus { + case .checking: + HStack { + ProgressView() + .scaleEffect(0.8) + Text("Checking license status...") + .foregroundColor(.secondary) + } + + case .trial(let daysLeft): + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "clock.fill") + .foregroundColor(.orange) + Text("Free Trial Active") + .font(.headline) + .foregroundColor(.orange) + } + + Text("\(daysLeft) days remaining") + .font(.subheadline) + .foregroundColor(.secondary) + + Text("Enjoy your free trial! Purchase a license before it expires to continue using ShotScreen.") + .font(.caption) + .foregroundColor(.secondary) + + HStack(spacing: 12) { + Button("Purchase License") { + openPurchaseURL() + } + .buttonStyle(.borderedProminent) + + Button("Enter License Key") { + showLicenseDialog() + } + .buttonStyle(.bordered) + } + } + + case .licensed(let userName, let email): + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Licensed") + .font(.headline) + .foregroundColor(.green) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Licensed to: \(userName)") + .font(.subheadline) + .foregroundColor(.primary) + + if !email.isEmpty { + Text("Email: \(email)") + .font(.caption) + .foregroundColor(.secondary) + } + } + + HStack { + Button("Change License") { + showLicenseDialog() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + case .testLicense(let userName, let email): + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "flask.fill") + .foregroundColor(.blue) + Text("Test License") + .font(.headline) + .foregroundColor(.blue) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Test license for: \(userName)") + .font(.subheadline) + .foregroundColor(.primary) + + if !email.isEmpty { + Text("Email: \(email)") + .font(.caption) + .foregroundColor(.secondary) + } + + Text("๐Ÿงช This is a development/test license") + .font(.caption) + .foregroundColor(.blue) + .fontWeight(.medium) + } + + HStack { + Button("Change License") { + showLicenseDialog() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + case .expired: + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text("Trial Expired") + .font(.headline) + .foregroundColor(.red) + } + + Text("Your trial has expired. Please purchase a license to continue using ShotScreen.") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack(spacing: 12) { + Button("Purchase License") { + openPurchaseURL() + } + .buttonStyle(.borderedProminent) + + Button("Enter License Key") { + showLicenseDialog() + } + .buttonStyle(.bordered) + } + } + + case .invalid: + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + Text("Invalid License") + .font(.headline) + .foregroundColor(.red) + } + + Text("Your license is invalid or has been revoked. Please contact support or purchase a new license.") + .font(.subheadline) + .foregroundColor(.secondary) + + HStack(spacing: 12) { + Button("Purchase License") { + openPurchaseURL() + } + .buttonStyle(.borderedProminent) + + Button("Enter License Key") { + showLicenseDialog() + } + .buttonStyle(.bordered) + } + } + } + + } + } + .padding(20) + .background(Color.primary.opacity(0.03)) + .cornerRadius(12) + .sheet(isPresented: $showLicenseWindow) { + LicenseEntryView() + } + .onReceive(licenseManager.$showLicenseEntry) { shouldShow in + showLicenseWindow = shouldShow + } + } + + private func showLicenseDialog() { + LicenseManager.shared.showLicenseEntryDialog() + } + + private func openPurchaseURL() { + // ShotScreen Gumroad license product + if let url = URL(string: "https://roodenrijs.gumroad.com/l/uxexr") { + NSWorkspace.shared.open(url) + } + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/StashDraggableImageView.swift b/ShotScreen/Sources/StashDraggableImageView.swift new file mode 100644 index 0000000..e501ae9 --- /dev/null +++ b/ShotScreen/Sources/StashDraggableImageView.swift @@ -0,0 +1,1140 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers // NIEUW: Voor UTType support + +// NIEUW: Wrapper class voor PNG data dragging naar browsers +class PNGDataWrapper: NSObject, NSPasteboardWriting { + let data: Data + + init(data: Data) { + self.data = data + } + + func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType] { + // Gereduceerd tot pure image data types + generiek public.data + return [ + .png, // Native PNG type + NSPasteboard.PasteboardType("public.png"), // Standaard UTI voor PNG + .tiff, // TIFF fallback + // NSPasteboard.PasteboardType("public.tiff"), // Optioneel: UTI voor TIFF + NSPasteboard.PasteboardType.URL // Voor het plakken als URL in sommige apps (bijv. Notes) + // VERWIJDERD: .fileURL and NSFilenamesPboardType om conflicten te voorkomen + // VERWIJDERD: NSPasteboard.PasteboardType("public.file-url") + ] + } + + func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? { + switch type { + case .png, NSPasteboard.PasteboardType("public.png"): + print("๐ŸŽ PNGDataWrapper: Providing PNG data") + return data + case .tiff: + print("๐ŸŽ PNGDataWrapper: Providing TIFF data (via PNG data)") + return data // PNG data kan vaak als TIFF gelezen worden + case .URL: // Case voor NSPasteboard.PasteboardType.URL + print("๐ŸŽ PNGDataWrapper: Providing data as a base64 data URL string") + let base64String = data.base64EncodedString() + // Maak een data URL. Zorg ervoor dat dit formaat correct is voor de doelapplicaties. + // Dit is een voorbeeld; niet alle apps ondersteunen het plakken van data-URLs. + return "data:image/png;base64,\(base64String)" + default: + print("๐ŸŽ PNGDataWrapper: Type '\(type)' niet ondersteund voor property list") + return nil + } + } +} + +// MARK: - Stash Grid Delegate Protocol +protocol StashGridDelegate: AnyObject { + func stashGridDidDropImage(at cellIndex: Int, stashItem: IdentifiableImage, imageURL: URL) +} + +// MARK: - Stash Grid Manager +class StashGridManager { + var gridWindow: StashGridWindow? + var delegate: StashGridDelegate? + + // GEFIXT: Explicit initialization to prevent state inconsistencies + private var proximityTimer: Timer? + private var isDragSessionActive: Bool = false + private var lastStashFrame: NSRect? + + // NIEUW: Initializer om state correct te resetten + init() { + // CRITICAL: Reset all state to ensure consistent behavior between swift run and .app + self.isDragSessionActive = false + self.lastStashFrame = nil + print("๐Ÿ”ถ STASH DEBUG: StashGridManager initialized with clean state - isDragSessionActive: \(self.isDragSessionActive)") + } + + func showGrid(previewFrame: NSRect?, for stashItem: IdentifiableImage, on originatingScreen: NSScreen) { + print("๐Ÿ”ถ STASH DEBUG: StashGridManager: showGrid START for STASH item \(stashItem.id) on screen \(originatingScreen.localizedName)") + print("๐Ÿ”ถ STASH DEBUG: Delegate is \(self.delegate == nil ? "NIL" : "SET") when showing grid.") + print("๐Ÿ”ถ STASH DEBUG: PreviewFrame: \(String(describing: previewFrame))") + + // NIEUW: Stop proximity timer when grid is shown + proximityTimer?.invalidate() + proximityTimer = nil + + if let appDelegate = NSApp.delegate as? ScreenshotApp, + let imageStore = appDelegate.activeStashImageStore { // Krijg referentie naar de image store + print("๐Ÿ”ถ STASH DEBUG: imageStore.images.count BEFORE appDelegate calls: \(imageStore.images.count)") + print("๐Ÿ”ถ STASH DEBUG: Stopping main grid proximity monitoring to prevent conflicts") + appDelegate.gridViewManager?.stopDragSession() // Kan UI updates triggeren + appDelegate.gridViewManager?.hideGrid() // Kan UI updates triggeren + print("๐Ÿ”ถ STASH DEBUG: imageStore.images.count AFTER appDelegate calls: \(imageStore.images.count)") + } else { + print("๐Ÿ”ถ STASH DEBUG: Kon appDelegate of activeStashImageStore niet verkrijgen voor pre/post logging.") + } + + // Close existing stash grid if any + if let existingWindow = self.gridWindow { + print("๐Ÿ”ถ STASH DEBUG: Closing existing stash grid window") + existingWindow.orderOut(nil) + self.gridWindow = nil + } + + // AANGEPAST: Gebruik stash window frame voor positionering in plaats van preview frame + var effectivePreviewFrame: NSRect + + if let providedFrame = previewFrame { + effectivePreviewFrame = providedFrame + print("๐Ÿ”ถ STASH DEBUG: Using provided preview frame: \(providedFrame)") + } else { + // VERBETERD: Real-time stash window detection met betere logging + var stashWindowFrame: NSRect? + print("๐Ÿ” STASH DEBUG: FALLBACK - Scanning \(NSApp.windows.count) windows for stash window...") + + for (index, window) in NSApp.windows.enumerated() { + let windowTitle = window.title + let hasStashInTitle = windowTitle.contains("Stash") + let hasIntegratedGallery = window.contentView?.className.contains("IntegratedGalleryView") == true + let windowFrame = window.frame + let isVisible = window.isVisible + + print("๐Ÿ” STASH DEBUG: Window[\(index)]: title='\(windowTitle)', hasStash=\(hasStashInTitle), hasGallery=\(hasIntegratedGallery), visible=\(isVisible), frame=\(windowFrame)") + + if (hasStashInTitle || hasIntegratedGallery) && isVisible { + stashWindowFrame = windowFrame + print("๐Ÿ” STASH DEBUG: โœ… FOUND active stash window - frame: \(windowFrame)") + break + } + } + + if let foundFrame = stashWindowFrame { + effectivePreviewFrame = foundFrame + print("๐Ÿ”ถ STASH DEBUG: Using FOUND real-time stash window frame: \(foundFrame)") + } else { + // LAATSTE FALLBACK: Gebruik screen center + let fallbackFrame = NSRect(x: originatingScreen.frame.midX - 100, y: originatingScreen.frame.midY - 50, width: 200, height: 100) + effectivePreviewFrame = fallbackFrame + print("โš ๏ธ STASH DEBUG: NO stash window found - using screen center fallback: \(fallbackFrame)") + } + } + + // NIEUW: Store stash frame for proximity monitoring + self.lastStashFrame = effectivePreviewFrame + + print("๐Ÿ”ถ STASH DEBUG: Using provided screen for stash grid: \(originatingScreen.localizedName)") + print("๐Ÿ”ถ STASH DEBUG: Screen frame: \(originatingScreen.frame)") + print("๐Ÿ”ถ STASH DEBUG: Screen visible frame: \(originatingScreen.visibleFrame)") + + // AANGEPAST: IDENTIEKE POSITIONERING ALS MAIN GRID - ALTIJD RECHTS VAN SCHERM + let gridSize = NSSize(width: 176, height: 344) // 7 cells * 44 + spacing + let spacing: CGFloat = 16 + + // NIEUW: Gebruik ABSOLUTE positionering zoals main grid (GridWindow line 86-89) + var gridOrigin: NSPoint + let screenVisibleFrame = originatingScreen.visibleFrame + + // HOOFDREGEL: ALTIJD RECHTS VAN SCHERM (blijft hetzelfde) + let gridX = screenVisibleFrame.maxX - gridSize.width - 10 // Identiek aan main grid + 10px padding + + // NIEUW: VERTICALE POSITIONERING - BOVEN STASH WINDOW + var gridY: CGFloat + let gridAboveStash = effectivePreviewFrame.maxY + spacing // Boven stash window + + // CHECK 1: Past grid boven stash binnen scherm? + if gridAboveStash + gridSize.height <= screenVisibleFrame.maxY { + gridY = gridAboveStash + print("๐Ÿ”ถ STASH DEBUG: Positioning grid ABOVE stash window") + } + // FALLBACK: Grid onder stash window + else { + gridY = effectivePreviewFrame.minY - gridSize.height - spacing + print("๐Ÿ”ถ STASH DEBUG: Not enough space above - positioning grid BELOW stash window") + + // CHECK 2: Past grid onder stash binnen scherm? + if gridY < screenVisibleFrame.minY { + // Laatste redmiddel: naast stash window (midden) + gridY = effectivePreviewFrame.midY - gridSize.height / 2 + print("๐Ÿ”ถ STASH DEBUG: Not enough space below - positioning grid BESIDE stash window (centered)") + } + } + + gridOrigin = NSPoint(x: gridX, y: gridY) + print("๐Ÿ”ถ STASH DEBUG: Positioning grid at RIGHT edge of screen, smart vertical placement") + print("๐Ÿ”ถ STASH DEBUG: Grid X position: screen.maxX(\(screenVisibleFrame.maxX)) - gridWidth(\(gridSize.width)) - 10px = \(gridX)") + print("๐Ÿ”ถ STASH DEBUG: Grid Y position: \(gridY) (relative to stash at Y=\(effectivePreviewFrame.minY)-\(effectivePreviewFrame.maxY))") + + // BACKUP POSITIONERING: Als er geen effectivePreviewFrame is, centreer alles + if effectivePreviewFrame.width == 0 || effectivePreviewFrame.height == 0 { + gridOrigin.y = screenVisibleFrame.midY - gridSize.height / 2 + print("๐Ÿ”ถ STASH DEBUG: No valid stash window frame, centering grid vertically on screen") + } + + // Bounds checking (behoud de bestaande logica) - EXTRA VEILIGHEID + gridOrigin.x = max(screenVisibleFrame.minX, min(gridOrigin.x, screenVisibleFrame.maxX - gridSize.width)) + gridOrigin.y = max(screenVisibleFrame.minY, min(gridOrigin.y, screenVisibleFrame.maxY - gridSize.height)) + + let gridRect = NSRect(origin: gridOrigin, size: gridSize) + print("๐Ÿ”ถ STASH DEBUG: Final calculated grid rect: \(gridRect)") + + print("๐Ÿ”ถ STASH DEBUG: Creating NEW StashGridWindow (not regular GridWindow)") + let newGridWindow = StashGridWindow(contentRect: gridRect, stashItem: stashItem, manager: self) + + // AANGEPAST: Assign the grid window + self.gridWindow = newGridWindow + + guard let assignedGridWindow = self.gridWindow else { + print("โŒ STASH DEBUG: ERROR - Failed to assign StashGridWindow!") + return + } + + print("๐Ÿ”ถ STASH DEBUG: StashGridWindow created successfully") + print("๐Ÿ”ถ STASH DEBUG: Grid window frame: \(assignedGridWindow.frame)") + print("๐Ÿ”ถ STASH DEBUG: Grid window level: \(assignedGridWindow.level)") + + // Setup window properties + assignedGridWindow.alphaValue = 0 + print("๐Ÿ”ถ STASH DEBUG: Set initial alpha to 0") + + assignedGridWindow.makeKeyAndOrderFront(nil) + print("๐Ÿ”ถ STASH DEBUG: Called makeKeyAndOrderFront") + + assignedGridWindow.orderFrontRegardless() + print("๐Ÿ”ถ STASH DEBUG: Called orderFrontRegardless") + + assignedGridWindow.isInitialFadingIn = true + print("๐Ÿ”ถ STASH DEBUG: Set isInitialFadingIn = true") + + // NIEUW: Make window first responder voor key events (ESC) + _ = assignedGridWindow.becomeFirstResponder() + print("๐Ÿ”ถ STASH DEBUG: Made window first responder for key events") + + print("๐Ÿ”ถ STASH DEBUG: Grid window isVisible: \(assignedGridWindow.isVisible)") + print("๐Ÿ”ถ STASH DEBUG: Grid window alphaValue: \(assignedGridWindow.alphaValue)") + + print("๐Ÿ”ถ STASH DEBUG: Animating stash grid appearance") + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.2 + assignedGridWindow.animator().alphaValue = 1 + print("๐Ÿ”ถ STASH DEBUG: Animation started - target alpha: 1.0") + } completionHandler: { + assignedGridWindow.isInitialFadingIn = false + print("๐Ÿ”ถ STASH DEBUG: Stash grid appearance animation complete") + print("๐Ÿ”ถ STASH DEBUG: Final grid window alphaValue: \(assignedGridWindow.alphaValue)") + print("๐Ÿ”ถ STASH DEBUG: Final grid window isVisible: \(assignedGridWindow.isVisible)") + } + } + + func hideGrid() { + print("๐Ÿ”ถ STASH DEBUG: StashGridManager: hideGrid called for STASH") + guard let window = gridWindow else { + print("๐Ÿ”ถ STASH DEBUG: No stash grid window to hide") + return + } + + // NIEUW: Stop proximity timer when manually hiding + proximityTimer?.invalidate() + proximityTimer = nil + + print("๐Ÿ”ถ STASH DEBUG: Hiding stash grid window") + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.2 + window.animator().alphaValue = 0 + }, completionHandler: { [weak self] in + print("๐Ÿ”ถ STASH DEBUG: Stash grid hide animation complete") + window.orderOut(nil) + if self?.gridWindow === window { + self?.gridWindow = nil + } + }) + } + + // NIEUW: Drag session management (identiek aan main grid) + func startDragSession() { + print("๐Ÿ”ถ STASH DEBUG: Drag session started - enabling proximity monitoring") + isDragSessionActive = true + } + + func stopDragSession() { + print("๐Ÿ”ถ STASH DEBUG: Drag session ended - disabling proximity monitoring") + isDragSessionActive = false + + // GEFIXT: Alleen start proximity monitoring als er nog een actieve drag zou zijn + // Dit voorkomt dat de grid blijft hangen na een drag + // (Main grid heeft dezelfde logica) + print("๐Ÿ”ถ STASH DEBUG: Drag session stopped - grid should hide soon unless mouse stays near") + + // NIEUW: Start een eenvoudige timer om de grid te verbergen na een korte delay + // Dit geeft de gebruiker de kans om nog te interacteren, maar zorgt dat de grid verdwijnt + if gridWindow != nil { + print("๐Ÿ”ถ STASH DEBUG: Starting auto-hide timer for stash grid") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + // Alleen verbergen als er geen actieve drag is + if self?.isDragSessionActive == false { + print("๐Ÿ”ถ STASH DEBUG: Auto-hiding stash grid after drag completion") + self?.hideGrid() + } + } + } + } + + // GEFIXT: Proximity monitoring alleen starten wanneer expliciet gevraagd + private func startProximityMonitoring() { + // KRITIEK: Check of er een actieve drag sessie is + // Anders start de monitoring niet (consistent met main grid) + guard isDragSessionActive else { + print("๐Ÿ”ถ STASH DEBUG: Skipping proximity monitoring - no active drag session") + return + } + + guard let targetFrame = lastStashFrame else { + print("๐Ÿ”ถ STASH DEBUG: No stash frame - skipping proximity monitoring") + return + } + + // Stop existing timer + proximityTimer?.invalidate() + + print("๐Ÿ”ถ STASH DEBUG: Starting proximity monitoring (only during active drag)") + proximityTimer = Timer.scheduledTimer(withTimeInterval: 0.12, repeats: true) { [weak self] _ in + self?.evaluateMouseProximity(to: targetFrame) + } + RunLoop.main.add(proximityTimer!, forMode: .common) + } + + private func evaluateMouseProximity(to frame: NSRect) { + // GEFIXT: Check of er een actieve drag sessie is + guard isDragSessionActive else { + print("๐Ÿ”ถ STASH DEBUG: Stopping proximity evaluation - no active drag session") + proximityTimer?.invalidate() + proximityTimer = nil + // NIEUW: Hide grid when no active drag session + hideGrid() + return + } + + // VERBETERD: Gebruik real-time stash window frame in plaats van cached frame + var currentStashFrame = frame // Start met cached frame + + // NIEUW: Probeer real-time stash window frame te verkrijgen + for window in NSApp.windows { + let windowTitle = window.title + if (windowTitle.contains("Stash") || window.contentView?.className.contains("IntegratedGalleryView") == true) && window.isVisible { + currentStashFrame = window.frame + // print("๐Ÿ”ถ PROXIMITY DEBUG: Using real-time stash frame: \(currentStashFrame)") + break + } + } + + // Bereken een vergrote zone (200 px marge) rondom de REAL-TIME stash window + let expansion: CGFloat = 200 + let enlarged = currentStashFrame.insetBy(dx: -expansion, dy: -expansion) + + let currentLoc = NSEvent.mouseLocation + if !enlarged.contains(currentLoc) { + // Mouse is ver weg - verberg grid + print("๐Ÿ”ถ STASH DEBUG: Mouse far from stash window - hiding grid") + proximityTimer?.invalidate() + proximityTimer = nil + hideGrid() + } + // Als mouse nog in de buurt is, blijft grid zichtbaar + } +} + +// MARK: - Stash Grid Window +class StashGridWindow: NSWindow, NSDraggingDestination { + var cellViews: [GridCellView] = [] + weak var manager: StashGridManager? + private var currentlyHighlightedCell: GridCellView? + private var fadeTimer: Timer? + private let fadeStart: CGFloat = 50 + private let fadeEnd: CGFloat = 300 + private let minAlpha: CGFloat = 0 + var isInitialFadingIn: Bool = false + var isPerformingProgrammaticHide: Bool = false + + // Store stash item reference for actions + let stashItem: IdentifiableImage + + init(contentRect: NSRect, stashItem: IdentifiableImage, manager: StashGridManager) { + self.manager = manager + self.stashItem = stashItem + let settings = SettingsManager.shared + + // IDENTIEK AAN MAIN GRID: Gebruik dezelfde settings en layout + var activeActions: [(index: Int, text: String)] = [] + + for (gridIndex, actionType) in settings.actionOrder.enumerated() { + let isEnabled: Bool + let displayText: String + + switch actionType { + case .rename: + isEnabled = settings.isRenameActionEnabled + displayText = "Rename" + case .stash: + isEnabled = settings.isStashActionEnabled + displayText = "Stash" + case .ocr: + isEnabled = settings.isOCRActionEnabled + displayText = "Text" + case .clipboard: + isEnabled = settings.isClipboardActionEnabled + displayText = "Clipboard" + case .backgroundRemove: + isEnabled = settings.isBackgroundRemoveActionEnabled + displayText = "Remove BG" + case .cancel: + isEnabled = settings.isCancelActionEnabled + displayText = "Cancel" + case .remove: + isEnabled = settings.isRemoveActionEnabled + displayText = "Remove" + } + + if isEnabled { + activeActions.append((gridIndex, displayText)) + } + } + + let numberOfActiveActions = activeActions.count + guard numberOfActiveActions > 0 else { + print("๐Ÿ”ท STASH DEBUG: No active actions - creating empty stash grid window") + super.init(contentRect: .zero, styleMask: .borderless, backing: .buffered, defer: false) + self.isOpaque = false + self.backgroundColor = .clear + self.ignoresMouseEvents = true + DispatchQueue.main.async { manager.hideGrid() } + return + } + + print("๐Ÿ”ท STASH DEBUG: StashGridWindow init: Number of active actions = \(numberOfActiveActions)") + print("๐Ÿ”ท STASH DEBUG: Using same layout as main grid but for STASH actions") + + // IDENTIEKE LAYOUT ALS MAIN GRID + let cellsPerRow = 1 + let numberOfRows = Int(ceil(Double(numberOfActiveActions) / Double(cellsPerRow))) + let spacing: CGFloat = 8 + let fixedCellHeight: CGFloat = 40.0 + let fixedCellWidth: CGFloat = 160.0 + + let calculatedGridWidth = (fixedCellWidth * CGFloat(cellsPerRow)) + (spacing * (CGFloat(cellsPerRow) + 1)) + let calculatedGridHeight = (fixedCellHeight * CGFloat(numberOfRows)) + (spacing * (CGFloat(numberOfRows) + 1)) + + var xPosition: CGFloat + var yPosition: CGFloat + + xPosition = contentRect.origin.x + yPosition = contentRect.origin.y + + let contentRect = NSRect(x: xPosition, y: yPosition, width: calculatedGridWidth, height: calculatedGridHeight) + + print("๐Ÿ”ท STASH DEBUG: StashGridWindow init: Calculated contentRect = \(contentRect)") + + super.init(contentRect: contentRect, styleMask: [.borderless], backing: .buffered, defer: false) + self.level = .floating + 10 + print("๐Ÿ”ท STASH DEBUG: StashGridWindow init: Window level set to \(self.level.rawValue)") + self.isOpaque = false + self.backgroundColor = .clear + self.hasShadow = false + self.ignoresMouseEvents = false + self.acceptsMouseMovedEvents = true + + // IDENTIEKE STYLING ALS MAIN GRID + let containerView = NSView(frame: NSRect(origin: .zero, size: contentRect.size)) + containerView.wantsLayer = true + + let barBlur1 = NSVisualEffectView() + barBlur1.blendingMode = .behindWindow + barBlur1.material = .hudWindow + barBlur1.state = .active + barBlur1.frame = containerView.bounds + barBlur1.autoresizingMask = [.width, .height] + + let barBlur2 = NSVisualEffectView() + barBlur2.blendingMode = .behindWindow + barBlur2.material = .hudWindow + barBlur2.state = .active + barBlur2.alphaValue = 0.6 + barBlur2.frame = containerView.bounds + barBlur2.autoresizingMask = [.width, .height] + + containerView.addSubview(barBlur1, positioned: .below, relativeTo: nil) + containerView.addSubview(barBlur2, positioned: .below, relativeTo: nil) + + containerView.layer?.cornerRadius = 12 + containerView.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner] + containerView.layer?.masksToBounds = true + self.contentView = containerView + + // Maak cellen + for (gridIndex, action) in activeActions.enumerated() { + let col = gridIndex % cellsPerRow + let row = gridIndex / cellsPerRow + + let cellX = spacing + CGFloat(col) * (fixedCellWidth + spacing) + let cellY = calculatedGridHeight - spacing - CGFloat(row + 1) * fixedCellHeight - CGFloat(row) * spacing + + let cellFrame = NSRect(x: cellX, y: cellY, width: fixedCellWidth, height: fixedCellHeight) + let cellView = GridCellView(frame: cellFrame, index: action.index, text: action.text) + containerView.addSubview(cellView) + cellViews.append(cellView) + } + + print("๐Ÿ”ท STASH DEBUG: StashGridWindow init: Number of cellViews created = \(cellViews.count)") + + registerForDraggedTypes([.fileURL]) + + // Start timer voor dynamische transparantie + fadeTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true, block: { [weak self] _ in + self?.updateAlphaBasedOnCursor() + }) + + print("๐Ÿ”ท STASH DEBUG: StashGridWindow init completed successfully") + } + + deinit { + fadeTimer?.invalidate() + } + + private func updateAlphaBasedOnCursor() { + guard !isPerformingProgrammaticHide else { return } + guard !isInitialFadingIn else { return } + guard let screenPoint = NSEvent.mouseLocation as NSPoint? else { return } + let windowFrame = self.frame + + let distance: CGFloat + if windowFrame.contains(screenPoint) { + distance = 0 + } else { + let dx: CGFloat + if screenPoint.x < windowFrame.minX { dx = windowFrame.minX - screenPoint.x } + else if screenPoint.x > windowFrame.maxX { dx = screenPoint.x - windowFrame.maxX } else { dx = 0 } + + let dy: CGFloat + if screenPoint.y < windowFrame.minY { dy = windowFrame.minY - screenPoint.y } + else if screenPoint.y > windowFrame.maxY { dy = screenPoint.y - windowFrame.maxY } else { dy = 0 } + + distance = sqrt(dx*dx + dy*dy) + } + + let newAlpha: CGFloat + if distance <= fadeStart { newAlpha = 1 } + else if distance >= fadeEnd { newAlpha = minAlpha } + else { + let ratio = (distance - fadeStart) / (fadeEnd - fadeStart) + newAlpha = 1 - ratio * (1 - minAlpha) + } + + if abs(self.alphaValue - newAlpha) > 0.01 { + self.alphaValue = newAlpha + } + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + // MARK: - NSDraggingDestination Methods + func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + return .copy + } + + func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + let dropLocationInScreen = sender.draggingLocation + guard let dropLocationInContent = self.contentView?.convert(dropLocationInScreen, from: nil) else { + currentlyHighlightedCell?.setHighlighted(false) + currentlyHighlightedCell = nil + return [] + } + + var foundCell: GridCellView? = nil + for cell in cellViews { + if cell.frame.contains(dropLocationInContent) { + foundCell = cell + break + } + } + + if currentlyHighlightedCell !== foundCell { + currentlyHighlightedCell?.setHighlighted(false) + currentlyHighlightedCell?.setHovered(false) + foundCell?.setHighlighted(true) + foundCell?.setHovered(true) + currentlyHighlightedCell = foundCell + } + + return .copy + } + + func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + guard let pasteboard = sender.draggingPasteboard.propertyList(forType: NSPasteboard.PasteboardType(rawValue: "NSFilenamesPboardType")) as? NSArray, + let path = pasteboard[0] as? String else { + self.manager?.hideGrid() + return false + } + let imageURL = URL(fileURLWithPath: path) + let dropLocationInContent = self.contentView?.convert(sender.draggingLocation, from: nil) ?? .zero + + currentlyHighlightedCell?.setHighlighted(false) + currentlyHighlightedCell = nil + + for cell in cellViews { + if cell.frame.contains(dropLocationInContent) { + if let currentManager = self.manager, let currentActionDelegate = currentManager.delegate { + print("โœ… StashGridWindow: Detected drop on cell \(cell.index). Calling manager's delegate...") + currentActionDelegate.stashGridDidDropImage(at: cell.index, stashItem: stashItem, imageURL: imageURL) + return true + } else { + print("โŒ StashGridWindow: Manager (\(self.manager == nil ? "NIL" : "SET")) or manager.delegate (\(self.manager?.delegate == nil ? "NIL" : "SET")) is nil for drop on cell \(cell.index)!") + if self.manager == nil { + print("โŒ Detail: StashGridWindow.manager is nil.") + } else if self.manager?.delegate == nil { + print("โŒ Detail: StashGridWindow.manager.delegate is nil.") + } + } + self.manager?.hideGrid() + return false + } + } + + self.manager?.hideGrid() + return false + } + + func draggingExited(_ sender: NSDraggingInfo?) { + currentlyHighlightedCell?.setHighlighted(false) + currentlyHighlightedCell?.setHovered(false) + currentlyHighlightedCell = nil + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + + // NIEUW: Hide grid on mouse up outside cells (same behavior as main grid) + let locationInWindow = event.locationInWindow + print("๐Ÿ”ถ STASH DEBUG: StashGridWindow: mouseUp at \(locationInWindow)") + + // Check if click was inside any cell + var clickWasInCell = false + for cellView in cellViews { + let cellFrame = cellView.frame + if cellFrame.contains(locationInWindow) { + clickWasInCell = true + print("๐Ÿ”ถ STASH DEBUG: Click was inside cell at frame \(cellFrame)") + break + } + } + + if !clickWasInCell { + print("๐Ÿ”ถ STASH DEBUG: Click was OUTSIDE all cells - hiding stash grid") + // Close the grid when clicking outside cells + DispatchQueue.main.async { + if let manager = self.manager { + manager.hideGrid() + } else { + // Fallback: hide window directly + self.orderOut(nil) + } + } + } else { + print("๐Ÿ”ถ STASH DEBUG: Click was inside cell - keeping grid open") + } + } + + override func keyDown(with event: NSEvent) { + // NIEUW: Handle ESC key to close stash grid + if event.keyCode == 53 { // ESC key + print("๐Ÿ”ถ STASH DEBUG: ESC key pressed - hiding stash grid") + DispatchQueue.main.async { + if let manager = self.manager { + manager.hideGrid() + } else { + // Fallback: hide window directly + self.orderOut(nil) + } + } + } else { + super.keyDown(with: event) + } + } + + override var canBecomeKey: Bool { + return true // NIEUW: Nodig om key events te ontvangen + } + + override var acceptsFirstResponder: Bool { + return true // NIEUW: Nodig om key events te ontvangen + } +} + +// MARK: - Stash Draggable Image View Protocol +protocol StashDraggableImageViewDelegate: AnyObject { + func stashImageDidStartDrag(imageURL: URL, from view: StashDraggableNSImageView) + func stashImageDragDidEnd(imageURL: URL, operation: NSDragOperation, from view: StashDraggableNSImageView) +} + +// MARK: - Stash Draggable Image View (NSView) +class StashDraggableNSImageView: NSImageView, NSFilePromiseProviderDelegate { + weak var delegate: StashDraggableImageViewDelegate? + var imageURL: URL? + var suggestedName: String? + var stashItem: IdentifiableImage? + private var mouseDownEvent: NSEvent? + private let dragThreshold: CGFloat = 3.0 + private var isPerformingDrag: Bool = false + + // NIEUW: Eigen grid manager voor stash items + var stashGridManager: StashGridManager? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + private func setupView() { + self.imageScaling = .scaleProportionallyUpOrDown + self.imageAlignment = .alignCenter + self.animates = true + self.imageFrameStyle = .none + self.registerForDraggedTypes([.fileURL, .URL, .tiff, .png]) + + // Maak eigen grid manager aan + self.stashGridManager = StashGridManager() + } + + // NIEUW: Setup delegate wanneer deze wordt geset + func setDelegate(_ delegate: StashDraggableImageViewDelegate) { + self.delegate = delegate + + // VERBETERDE: Robuustere setup van grid manager connectie + if let stashDelegate = delegate as? StashDragDelegate { + // Zorg ervoor dat grid manager bestaat + if self.stashGridManager == nil { + self.stashGridManager = StashGridManager() + print("๐Ÿ”ถ STASH DEBUG: Created new StashGridManager in setDelegate") + } + + // Verbind delegate met grid manager + stashDelegate.setStashGridManager(self.stashGridManager) + } + } + + override func mouseDown(with event: NSEvent) { + self.mouseDownEvent = event + self.isPerformingDrag = false + print("๐Ÿ–ฑ๏ธ STASH DEBUG: StashDraggableNSImageView: mouseDown. Event stored.") + print("๐Ÿ–ฑ๏ธ STASH DEBUG: Mouse location in window: \(event.locationInWindow)") + print("๐Ÿ–ฑ๏ธ STASH DEBUG: imageURL: \(imageURL?.lastPathComponent ?? "nil")") + print("๐Ÿ–ฑ๏ธ STASH DEBUG: stashItem: \(stashItem?.id.uuidString.prefix(8) ?? "nil")") + print("๐Ÿ–ฑ๏ธ STASH DEBUG: stashGridManager: \(stashGridManager != nil ? "SET" : "NIL")") + } + + override func mouseDragged(with event: NSEvent) { + guard let mouseDownEvent = self.mouseDownEvent else { + print("๐Ÿ–ฑ๏ธ STASH DEBUG: mouseDragged called but no mouseDownEvent - calling super") + super.mouseDragged(with: event) + return + } + + print("๐Ÿ–ฑ๏ธ STASH DEBUG: mouseDragged called, isPerformingDrag: \(isPerformingDrag)") + + if !isPerformingDrag { + let deltaX = abs(event.locationInWindow.x - mouseDownEvent.locationInWindow.x) + let deltaY = abs(event.locationInWindow.y - mouseDownEvent.locationInWindow.y) + + print("๐Ÿ–ฑ๏ธ STASH DEBUG: Delta X: \(deltaX), Delta Y: \(deltaY), threshold: \(dragThreshold)") + + if deltaX > dragThreshold || deltaY > dragThreshold { + print("๐Ÿ–ฑ๏ธ STASH DEBUG: โœ… THRESHOLD EXCEEDED - Starting drag process") + isPerformingDrag = true + self.mouseDownEvent = nil + + guard let url = imageURL, let item = stashItem else { + print("โŒ STASH DEBUG: StashDraggableNSImageView: mouseDragged - Missing imageURL or stashItem for drag.") + print("โŒ STASH DEBUG: imageURL is nil: \(imageURL == nil), stashItem is nil: \(stashItem == nil)") + isPerformingDrag = false + return + } + + print("๐ŸŽฏ STASH DEBUG: StashDraggableNSImageView: Starting STASH GRID (not main grid) for item \(item.id)") + + // VERBETERDE: Extra safety check voor grid manager + if self.stashGridManager == nil { + print("โš ๏ธ STASH DEBUG: Grid manager was nil, creating new one during drag") + self.stashGridManager = StashGridManager() + + // Reconnect delegate if available + if let stashDelegate = delegate as? StashDragDelegate { + stashDelegate.setStashGridManager(self.stashGridManager) + print("๐Ÿ”ถ STASH DEBUG: Re-connected delegate during drag") + } + } else { + print("โœ… STASH DEBUG: Grid manager already exists") + } + + // Start stash grid op de juiste screen + if let window = self.window, let screen = window.screen { + print("๐Ÿ–ฑ๏ธ STASH DEBUG: Window and screen available - \(screen.localizedName)") + print("๐Ÿ”ฅ STASH DEBUG: REAL-TIME stash window frame: \(window.frame)") + if let manager = stashGridManager { + print("๐Ÿ–ฑ๏ธ STASH DEBUG: About to call manager.showGrid with REAL-TIME frame...") + // CRITICAL FIX: Pass REAL-TIME stash window frame instead of nil + manager.showGrid(previewFrame: window.frame, for: item, on: screen) + print("๐Ÿ–ฑ๏ธ STASH DEBUG: manager.showGrid called successfully with dynamic frame") + + // NIEUW: Start drag session voor proximity monitoring + manager.startDragSession() + print("๐Ÿ–ฑ๏ธ STASH DEBUG: Started stash drag session for proximity monitoring") + } else { + print("โŒ STASH DEBUG: ERROR - stashGridManager is still nil after safety check!") + isPerformingDrag = false + return + } + } else { + print("โŒ STASH DEBUG: ERROR - No window or screen available!") + print("โŒ STASH DEBUG: self.window is nil: \(self.window == nil)") + if let w = self.window { + print("โŒ STASH DEBUG: window.screen is nil: \(w.screen == nil)") + } + isPerformingDrag = false + return + } + + // Notify delegate about drag start + print("๐Ÿ–ฑ๏ธ STASH DEBUG: Notifying delegate about drag start") + delegate?.stashImageDidStartDrag(imageURL: url, from: self) + + // NIEUW: Voeg ook direct NSImage support toe (zoals hoofdthumbnail) + var draggingItems: [NSDraggingItem] = [] + + // CRITICAL FIX: Use only ONE multifunctional dragging item to prevent count badge + if let stashFileURL = self.stashItem?.fileURL, let nsImage = self.image { + print("๐ŸŽฏ STASH DEBUG: Creating SINGLE multifunctional dragging item WITH NSFilePromiseProvider") + + // ๐Ÿ”ฅ ULTRA FIX: Add thumbnail scaling like hoofdgrid + let fullFrame = convert(bounds, to: nil) + let scale: CGFloat = 0.05 // 5% van originele grootte (exact zoals hoofdgrid) + let yOffset: CGFloat = 30 + let scaledFrame = NSRect( + x: fullFrame.midX - fullFrame.width * scale / 2, + y: fullFrame.midY - fullFrame.height * scale / 2 - yOffset, + width: fullFrame.width * scale, + height: fullFrame.height * scale + ) + + // ULTRA FIX: Add NSFilePromiseProvider for proper filename handling in Finder + let filePromiseProvider = NSFilePromiseProvider(fileType: UTType.png.identifier, delegate: self) + filePromiseProvider.userInfo = [ + "isFilePromise": true, + "fileURL": stashFileURL + ] + + // Create dragging item with file promise provider for Finder compatibility + let filePromiseDragItem = NSDraggingItem(pasteboardWriter: filePromiseProvider) + filePromiseDragItem.setDraggingFrame(scaledFrame, contents: nsImage) // ๐Ÿ”ฅ SCALED! + draggingItems.append(filePromiseDragItem) + + // Create one comprehensive pasteboard item with ALL types for other apps + let comprehensiveItem = NSPasteboardItem() + + // 1. File URL types + comprehensiveItem.setString(stashFileURL.absoluteString, forType: .fileURL) + comprehensiveItem.setString(stashFileURL.absoluteString, forType: NSPasteboard.PasteboardType("public.file-url")) + comprehensiveItem.setString(stashFileURL.path, forType: .string) + + // 2. File path list + if let pathData = try? PropertyListSerialization.data(fromPropertyList: [stashFileURL.path], format: .binary, options: 0) { + comprehensiveItem.setData(pathData, forType: NSPasteboard.PasteboardType("NSFilenamesPboardType")) + } + + // 3. Image data types + if let tiffRepresentation = nsImage.tiffRepresentation, + let bitmapImageRep = NSBitmapImageRep(data: tiffRepresentation), + let pngData = bitmapImageRep.representation(using: .png, properties: [:]) { + comprehensiveItem.setData(pngData, forType: .png) + comprehensiveItem.setData(pngData, forType: NSPasteboard.PasteboardType("public.png")) + comprehensiveItem.setData(tiffRepresentation, forType: .tiff) + + // Base64 data URL for browser compatibility + let base64String = pngData.base64EncodedString() + comprehensiveItem.setString("data:image/png;base64,\(base64String)", forType: .URL) + } + + // Create additional dragging item for compatibility + let compatibilityDragItem = NSDraggingItem(pasteboardWriter: comprehensiveItem) + compatibilityDragItem.setDraggingFrame(scaledFrame, contents: nsImage) // ๐Ÿ”ฅ SCALED! + draggingItems.append(compatibilityDragItem) + + print("๐ŸŽฏ STASH DEBUG: Created comprehensive dragging session with NSFilePromiseProvider + compatibility item") + } else { + // Fallback to simple NSImage + if let nsImage = self.image { + // ๐Ÿ”ฅ ULTRA FIX: Add scaling for fallback too + let fullFrame = convert(bounds, to: nil) + let scale: CGFloat = 0.05 + let yOffset: CGFloat = 30 + let scaledFrame = NSRect( + x: fullFrame.midX - fullFrame.width * scale / 2, + y: fullFrame.midY - fullFrame.height * scale / 2 - yOffset, + width: fullFrame.width * scale, + height: fullFrame.height * scale + ) + + let nsImageDragItem = NSDraggingItem(pasteboardWriter: nsImage) + nsImageDragItem.setDraggingFrame(scaledFrame, contents: nsImage) // ๐Ÿ”ฅ SCALED! + draggingItems.append(nsImageDragItem) + print("๐ŸŽฏ STASH DEBUG: Created fallback NSImage dragging item WITH SCALING") + } + } + + print("๐ŸŽฏ STASH DEBUG: Total dragging items: \(draggingItems.count) (should be 1 - no badge)") + + // FIXED: Veilige access naar mouseDownEvent met fallback + guard let mouseEvent = self.mouseDownEvent else { + print("โŒ STASH DEBUG: mouseDownEvent is nil! Creating fallback event") + // Maak een fallback event met huidige mouse locatie + let currentLocation = NSEvent.mouseLocation + let windowLocation = self.window?.convertPoint(fromScreen: currentLocation) ?? NSPoint.zero + let localLocation = self.convert(windowLocation, from: nil) + + // Create a minimal mouse event as fallback + let fallbackEvent = NSEvent.mouseEvent( + with: .leftMouseDown, + location: localLocation, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: self.window?.windowNumber ?? 0, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) + + if let fallbackEvent = fallbackEvent { + let draggingSession = self.beginDraggingSession(with: draggingItems, event: fallbackEvent, source: self) + draggingSession.draggingFormation = .none + draggingSession.animatesToStartingPositionsOnCancelOrFail = true + print("๐ŸŽฏ STASH DEBUG: Used fallback event for dragging session") + } else { + print("โŒ STASH DEBUG: Failed to create fallback event - aborting drag") + return + } + return + } + + let draggingSession = self.beginDraggingSession(with: draggingItems, event: mouseEvent, source: self) + draggingSession.draggingFormation = .none + draggingSession.animatesToStartingPositionsOnCancelOrFail = true + + print("๐ŸŽฏ STASH DEBUG: Stash drag session beginning at (\(NSEvent.mouseLocation.x), \(NSEvent.mouseLocation.y))") + print("๐ŸŽฏ STASH DEBUG: Dragging session configured with \(draggingItems.count) items treated as single unit") + } else { + print("๐Ÿ–ฑ๏ธ STASH DEBUG: โธ๏ธ Drag threshold not exceeded yet") + } + } else { + print("๐Ÿ–ฑ๏ธ STASH DEBUG: Already performing drag, ignoring mouseDragged") + } + } + + override func mouseUp(with event: NSEvent) { + print("๐Ÿ–ฑ๏ธ STASH DEBUG: StashDraggableNSImageView: mouseUp called, isPerformingDrag: \(isPerformingDrag)") + + // VERBETERD: Alleen mouseDownEvent clearen als we niet aan het draggen zijn + if !isPerformingDrag { + self.mouseDownEvent = nil + print("๐Ÿ–ฑ๏ธ STASH DEBUG: Cleared mouseDownEvent (not dragging)") + } else { + print("๐Ÿ–ฑ๏ธ STASH DEBUG: Keeping mouseDownEvent during drag operation") + } + } + + // MARK: - NSFilePromiseProviderDelegate + func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, fileNameForType fileType: String) -> String { + // Check if this is a file URL promise + if let userInfo = filePromiseProvider.userInfo as? [String: Any], + let isFilePromise = userInfo["isFilePromise"] as? Bool, + isFilePromise, + let fileURL = userInfo["fileURL"] as? URL { + print("๐Ÿ”„ filePromiseProvider (file URL) asking for filename: '\(fileURL.lastPathComponent)'") + return fileURL.lastPathComponent + } + + // Fallback to original implementation for regular promises + guard let item = stashItem else { + return "StashImage.png" + } + + // Bepaal de originele naam: customName > filename (zonder extensie) > fallback + let originalName: String + if let customName = item.customName, !customName.isEmpty { + originalName = customName + } else if let fileURL = item.fileURL { + originalName = fileURL.deletingPathExtension().lastPathComponent + } else { + originalName = "StashImage" + } + + let filename = "\(originalName).png" + print("๐Ÿ”„ filePromiseProvider asking for filename: '\(filename)'") + return filename + } + + func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, writePromiseTo url: URL, completionHandler: @escaping (Error?) -> Void) { + print("๐Ÿ”„ filePromiseProvider writePromiseTo: \(url.lastPathComponent)") + + // Check if this is a file URL promise + if let userInfo = filePromiseProvider.userInfo as? [String: Any], + let isFilePromise = userInfo["isFilePromise"] as? Bool, + isFilePromise, + let sourceFileURL = userInfo["fileURL"] as? URL { + print("๐Ÿ”„ filePromiseProvider (file URL) copying from: \(sourceFileURL.lastPathComponent)") + + do { + // Check if destination already exists + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + + try FileManager.default.copyItem(at: sourceFileURL, to: url) + print("โœ… Successfully copied file URL promise to: \(url.lastPathComponent)") + completionHandler(nil) + } catch { + print("โŒ Failed to copy file URL promise: \(error)") + completionHandler(error) + } + return + } + + // Fallback to original implementation + guard let item = stashItem else { + completionHandler(NSError(domain: "StashDragError", code: 1, userInfo: [NSLocalizedDescriptionKey: "No stash item available"])) + return + } + + // Kopieer het bestand van stash naar de gevraagde locatie + if let stashFileURL = item.fileURL { + do { + // Check if destination already exists + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + + try FileManager.default.copyItem(at: stashFileURL, to: url) + print("โœ… Successfully copied stash file to: \(url.lastPathComponent)") + completionHandler(nil) + } catch { + print("โŒ Failed to copy stash file: \(error)") + completionHandler(error) + } + } else { + // Fallback: converteer NSImage naar PNG data + guard let tiffRepresentation = item.nsImage.tiffRepresentation, + let bitmapImageRep = NSBitmapImageRep(data: tiffRepresentation), + let pngData = bitmapImageRep.representation(using: .png, properties: [:]) else { + print("โŒ Could not convert stash image to PNG data") + completionHandler(NSError(domain: "StashDragError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not convert image to PNG"])) + return + } + + do { + try pngData.write(to: url) + print("โœ… Successfully wrote stash image data to: \(url.lastPathComponent)") + completionHandler(nil) + } catch { + print("โŒ Failed to write stash image data: \(error)") + completionHandler(error) + } + } + } +} + +// MARK: - NSDraggingSource Extension +extension StashDraggableNSImageView: NSDraggingSource { + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + return .copy + } + + func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) { + print("๐ŸŽฏ STASH DEBUG: Stash drag session beginning at \(screenPoint)") + print("๐ŸŽฏ STASH DEBUG: This should ONLY show STASH grid, NEVER main grid") + } + + func draggingSession(_ session: NSDraggingSession, movedTo screenPoint: NSPoint) { + // Stash items hebben geen proximity feedback - simpel houden + // print("๐ŸŽฏ STASH DEBUG: StashDraggableNSImageView: Drag moved to \(screenPoint) - no proximity feedback (stash only)") + } + + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + let wasDraggingPreviously = self.isPerformingDrag + isPerformingDrag = false + print("๐Ÿ–ฑ๏ธ STASH DEBUG: StashDraggableNSImageView (ended): Drag session ended. Operation: \(operation.rawValue), wasDraggingPreviously: \(wasDraggingPreviously)") + + // NIEUW: Stop drag session voor proximity monitoring + stashGridManager?.stopDragSession() + print("๐Ÿ–ฑ๏ธ STASH DEBUG: Stopped stash drag session - proximity monitoring activated") + + // AANGEPAST: Laat grid zichtbaar voor interactie, maar start proximity monitoring + if operation != .copy { + print("๐Ÿ”ถ STASH DEBUG: Drag ended with no drop (operation \(operation.rawValue)) - starting proximity monitoring") + print("๐Ÿ”ถ STASH DEBUG: Grid will auto-hide when mouse moves away from stash window") + } else { + print("๐Ÿ”ถ STASH DEBUG: Successful drop (operation \(operation.rawValue)) - grid handled by drop action") + } + + // NIEUW: Delegate notificatie + if let imageURL = self.imageURL { + delegate?.stashImageDragDidEnd(imageURL: imageURL, operation: operation, from: self) + } + + // FIXED: Nu pas mouseDownEvent clearen na drag completion + DispatchQueue.main.async { [weak self] in + self?.mouseDownEvent = nil + print("๐Ÿ–ฑ๏ธ STASH DEBUG: StashDraggableNSImageView (ended): Async cleanup completed for \(self?.imageURL?.lastPathComponent ?? "unknown")") + } + } +} + +// MARK: - SwiftUI Representable Wrapper +struct StashDraggableImageView: NSViewRepresentable { + let nsImage: NSImage + let imageURL: URL + let suggestedName: String? + let stashItem: IdentifiableImage + let delegate: StashDraggableImageViewDelegate + + func makeNSView(context: Context) -> StashDraggableNSImageView { + let nsView = StashDraggableNSImageView() + nsView.image = nsImage + nsView.imageURL = imageURL + nsView.suggestedName = suggestedName + nsView.stashItem = stashItem + nsView.setDelegate(delegate) + return nsView + } + + func updateNSView(_ nsView: StashDraggableNSImageView, context: Context) { + nsView.image = nsImage + nsView.imageURL = imageURL + nsView.suggestedName = suggestedName + nsView.stashItem = stashItem + nsView.setDelegate(delegate) + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/SwiftUIViews.swift b/ShotScreen/Sources/SwiftUIViews.swift new file mode 100644 index 0000000..692b1e6 --- /dev/null +++ b/ShotScreen/Sources/SwiftUIViews.swift @@ -0,0 +1,83 @@ +import SwiftUI + +// MARK: - Multi-Monitor Selection Overlay View +struct MultiMonitorSelectionOverlayView: View { + @State private var animationOffset: CGFloat = 0 + + var body: some View { + ZStack { + // Semi-transparent background with subtle gradient + Rectangle() + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color.blue.opacity(0.15), + Color.blue.opacity(0.25) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + // Animated border + Rectangle() + .stroke( + LinearGradient( + gradient: Gradient(colors: [ + Color.blue.opacity(0.8), + Color.cyan.opacity(0.6), + Color.blue.opacity(0.8) + ]), + startPoint: .leading, + endPoint: .trailing + ), + lineWidth: 2 + ) + .overlay( + // Animated dashed border for better visibility + Rectangle() + .stroke( + Color.white.opacity(0.8), + style: StrokeStyle( + lineWidth: 1, + dash: [8, 4], + dashPhase: animationOffset + ) + ) + ) + + + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + // Start the dash animation + withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) { + animationOffset = 24 + } + } + } +} + +// MARK: - Crosshair Overlay View +struct CrosshairOverlayView: View { + let mouseLocation: CGPoint + private let crosshairSize: CGFloat = 20 + + var body: some View { + ZStack { + // Horizontal line + Rectangle() + .fill(Color.white) + .frame(width: crosshairSize, height: 2) + .shadow(color: .black, radius: 1, x: 0, y: 0) + + // Vertical line + Rectangle() + .fill(Color.white) + .frame(width: 2, height: crosshairSize) + .shadow(color: .black, radius: 1, x: 0, y: 0) + } + .position(mouseLocation) + .allowsHitTesting(false) // Don't interfere with mouse events + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/ThemeManager.swift b/ShotScreen/Sources/ThemeManager.swift new file mode 100644 index 0000000..b332e9d --- /dev/null +++ b/ShotScreen/Sources/ThemeManager.swift @@ -0,0 +1,176 @@ +import AppKit +import SwiftUI + +// MARK: - Centralized Theme Manager +class ThemeManager { + static let shared = ThemeManager() + + private init() {} + + // MARK: - System-Aware Colors (automatic light/dark adaptation) + + /// Background colors for containers (GLASS EFFECT zoals stash) + var containerBackground: NSColor { + // NIEUW: Glass effect achtergrond zoals stash window + // Gebruik transparante achtergrond zodat visual effect view erdoor kan + return NSColor.clear + } + + /// Glass effect background for visual effect views + var glassEffectMaterial: NSVisualEffectView.Material { + return .hudWindow // Exact hetzelfde als stash window + } + + /// Glass effect blending mode + var glassEffectBlending: NSVisualEffectView.BlendingMode { + return .behindWindow // Exact hetzelfde als stash window + } + + /// Glass effect alpha + var glassEffectAlpha: CGFloat { + return 0.95 // Exact hetzelfde als stash window + } + + /// Background colors for secondary containers + var secondaryContainerBackground: NSColor { + if NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua { + return NSColor(white: 0.42, alpha: 0.90) // Dark mode: iets lichter + } else { + return NSColor(white: 0.98, alpha: 0.90) // Light mode: bijna wit + } + } + + /// Button and icon colors (adaptive) + var buttonTintColor: NSColor { + if NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua { + return NSColor(white: 0.95, alpha: 1.0) // Dark mode: wit + } else { + return NSColor(white: 0.25, alpha: 1.0) // Light mode: donkergrijs + } + } + + /// Text colors (adaptive) + var primaryTextColor: NSColor { + if NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua { + return NSColor.white // Dark mode: wit + } else { + return NSColor.black // Light mode: zwart + } + } + + var secondaryTextColor: NSColor { + if NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua { + return NSColor(white: 0.7, alpha: 1.0) // Dark mode: grijs + } else { + return NSColor(white: 0.4, alpha: 1.0) // Light mode: donkergrijs + } + } + + /// Filename label text color (adaptive with opacity) + var filenameLabelTextColor: NSColor { + if NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua { + return NSColor(white: 0.7, alpha: 1.0) // Dark mode: lichtgrijs + } else { + return NSColor(white: 0.1, alpha: 1.0) // Light mode: veel donkerder voor betere zichtbaarheid + } + } + + /// Grid cell icon background (adaptive) + var gridCellIconBackground: NSColor { + if NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua { + return NSColor.white.withAlphaComponent(0.25) // Dark mode + } else { + return NSColor.black.withAlphaComponent(0.25) // Light mode + } + } + + /// Grid cell text color with hover effect + func gridCellTextColor(isHovered: Bool) -> NSColor { + if NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua { + return isHovered ? NSColor.white : NSColor.white.withAlphaComponent(0.1) + } else { + return isHovered ? NSColor.black : NSColor.black.withAlphaComponent(0.1) + } + } + + // MARK: - Shadow Configuration (adaptive) + var shadowColor: NSColor { + if NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua { + return NSColor.black // Dark mode: black shadows + } else { + return NSColor.black.withAlphaComponent(0.3) // Light mode: lighter shadows + } + } + + var shadowOpacity: Float { + if NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua { + return 0.50 // Dark mode: stronger shadows + } else { + return 0.25 // Light mode: subtle shadows + } + } + + // MARK: - Utility Methods + + /// Check if currently in dark mode + var isDarkMode: Bool { + return NSApp.effectiveAppearance.name == NSAppearance.Name.darkAqua + } + + /// Debug function to print current theme info + func printCurrentTheme() { + let mode = isDarkMode ? "DARK" : "LIGHT" + print("๐ŸŽจ THEME DEBUG: Current mode = \(mode)") + print("๐ŸŽจ THEME DEBUG: Container background = GLASS EFFECT (\(glassEffectMaterial.rawValue))") + print("๐ŸŽจ THEME DEBUG: Glass effect alpha = \(glassEffectAlpha)") + print("๐ŸŽจ THEME DEBUG: Button tint = \(buttonTintColor)") + print("๐ŸŽจ THEME DEBUG: Primary text = \(primaryTextColor)") + print("๐ŸŽจ THEME DEBUG: Shadow opacity = \(shadowOpacity)") + } + + /// Get hover color for buttons + var buttonHoverColor: NSColor { + if isDarkMode { + return NSColor.white + } else { + return NSColor.black + } + } + + /// Get original button color (for hover restoration) + var buttonOriginalColor: NSColor { + return buttonTintColor + } + + // MARK: - Notification for Theme Changes + func observeThemeChanges(callback: @escaping () -> Void) { + // Observer voor system appearance changes + DistributedNotificationCenter.default.addObserver( + forName: NSNotification.Name("AppleInterfaceThemeChangedNotification"), + object: nil, + queue: .main + ) { _ in + print("๐ŸŽจ THEME: System theme changed - notifying observers") + callback() + } + } +} + +// MARK: - SwiftUI Color Extensions +extension Color { + static var adaptiveContainerBackground: Color { + Color(ThemeManager.shared.containerBackground) + } + + static var adaptiveSecondaryBackground: Color { + Color(ThemeManager.shared.secondaryContainerBackground) + } + + static var adaptivePrimaryText: Color { + Color(ThemeManager.shared.primaryTextColor) + } + + static var adaptiveSecondaryText: Color { + Color(ThemeManager.shared.secondaryTextColor) + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/UpdateManager.swift b/ShotScreen/Sources/UpdateManager.swift new file mode 100644 index 0000000..9b79e0e --- /dev/null +++ b/ShotScreen/Sources/UpdateManager.swift @@ -0,0 +1,446 @@ +import Foundation +import AppKit +import Sparkle + +// MARK: - Update Manager Delegate Protocol +protocol UpdateManagerDelegate: AnyObject { + func updateCheckDidStart() + func updateCheckDidFinish() + func updateAvailable(_ update: SUAppcastItem) + func noUpdateAvailable() + func updateCheckFailed(error: Error) +} + +// MARK: - Custom Update Check Result +struct CustomUpdateCheckResult { + let hasUpdate: Bool + let latestVersion: String? + let currentVersion: String + let downloadURL: String? + let releaseNotes: String? +} + +// MARK: - Update Manager +class UpdateManager: NSObject { + + // MARK: - Properties + static let shared = UpdateManager() + + private var updaterController: SPUStandardUpdaterController? + weak var delegate: UpdateManagerDelegate? + private var isManualCheck = false // Track if this is a manual check + + private let feedURL = "https://git.plet.i234.me/Nick/shotscreen/raw/branch/main/appcast.xml" + + // MARK: - Initialization + override init() { + super.init() + setupUpdater() + } + + // MARK: - Setup + private func setupUpdater() { + print("๐Ÿ”„ UPDATE: Initializing Sparkle updater...") + + // Check if we're in a development build + let isDevelopmentBuild = isRunningInDevelopment() + print("๐Ÿ”ง UPDATE: Development build detected: \(isDevelopmentBuild)") + + // Additional debug info + print("๐Ÿ” UPDATE: Current app version: \(currentVersion)") + print("๐Ÿ” UPDATE: Current build number: \(currentBuildNumber)") + print("๐Ÿ” UPDATE: Bundle path: \(Bundle.main.bundlePath)") + print("๐Ÿ” UPDATE: Bundle URL: \(Bundle.main.bundleURL)") + print("๐Ÿ” UPDATE: Executable path: \(CommandLine.arguments[0])") + + // Initialize Sparkle with standard configuration (for automatic updates only) + updaterController = SPUStandardUpdaterController( + startingUpdater: !isDevelopmentBuild, // Don't auto-start for dev builds + updaterDelegate: self, + userDriverDelegate: nil // Use standard driver for automatic checks + ) + + // Configure the updater + if let updater = updaterController?.updater { + // Configure automatic updates based on user settings and build type + let automaticUpdatesEnabled = !isDevelopmentBuild && SettingsManager.shared.automaticUpdates + updater.automaticallyChecksForUpdates = automaticUpdatesEnabled + updater.automaticallyDownloadsUpdates = false // Always ask user + + print("โœ… UPDATE: Sparkle updater initialized successfully") + print("๐Ÿ”— UPDATE: Feed URL configured via Info.plist: \(feedURL)") + print("๐Ÿค– UPDATE: Automatic checks: \(updater.automaticallyChecksForUpdates)") + print("โš™๏ธ UPDATE: SettingsManager automatic updates: \(SettingsManager.shared.automaticUpdates)") + print("๐Ÿš€ UPDATE: Updater started: \(!isDevelopmentBuild)") + + // Force an immediate update check if this is a manual debug + print("๐Ÿ” UPDATE: Last update check date: \(updater.lastUpdateCheckDate?.description ?? "Never")") + } else { + print("โŒ UPDATE: Failed to access Sparkle updater") + } + } + + // MARK: - Development Detection + private func isRunningInDevelopment() -> Bool { + // Check if we're running from .build directory (swift run) + let executablePath = CommandLine.arguments[0] + let isDevelopment = executablePath.contains(".build") || + executablePath.contains("DerivedData") || + Bundle.main.bundleURL.pathExtension != "app" + + print("๐Ÿ” UPDATE: Executable path: \(executablePath)") + print("๐Ÿ” UPDATE: Bundle URL: \(Bundle.main.bundleURL)") + return isDevelopment + } + + // MARK: - Public Interface + + /// Manually check for updates (triggered by user) - USE SPARKLE'S NATIVE APPROACH + func checkForUpdates() { + print("๐Ÿ” UPDATE: === checkForUpdates() START ===") + print("๐Ÿ” UPDATE: updaterController exists: \(updaterController != nil)") + + if let controller = updaterController { + print("๐Ÿ” UPDATE: updaterController.updater exists: true") + } + + guard let updater = updaterController?.updater else { + print("โŒ UPDATE: Updater not available - showing fallback") + showManualUpdateFallback() + return + } + + print("๐Ÿ” UPDATE: Manual update check triggered (using Sparkle's native method)") + print("๐Ÿ” UPDATE: Current version from bundle: \(currentVersion)") + print("๐Ÿ” UPDATE: Current build number: \(currentBuildNumber)") + print("๐Ÿ” UPDATE: Feed URL: \(feedURL)") + print("๐Ÿ” UPDATE: Automatic checks enabled: \(updater.automaticallyChecksForUpdates)") + print("๐Ÿ” UPDATE: Last check date: \(updater.lastUpdateCheckDate?.description ?? "Never")") + + isManualCheck = true + delegate?.updateCheckDidStart() + + print("๐Ÿ” UPDATE: About to call updater.checkForUpdates()") + // Use Sparkle's own checkForUpdates method (this should work reliably) + updater.checkForUpdates() + print("๐Ÿ” UPDATE: updater.checkForUpdates() called successfully") + } + + // MARK: - Custom Update Check Implementation (REMOVED - now only used as fallback) + + private func performCustomUpdateCheckFallback() { + guard let url = URL(string: feedURL) else { + print("โŒ UPDATE: Invalid feed URL") + delegate?.updateCheckFailed(error: NSError(domain: "UpdateManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid feed URL"])) + isManualCheck = false + return + } + + print("๐Ÿ” UPDATE: Performing fallback update check from: \(feedURL)") + + // Create URL session + let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in + DispatchQueue.main.async { + self?.handleCustomUpdateCheckResponse(data: data, response: response, error: error) + } + } + task.resume() + } + + private func handleCustomUpdateCheckResponse(data: Data?, response: URLResponse?, error: Error?) { + defer { + isManualCheck = false + delegate?.updateCheckDidFinish() + } + + if let error = error { + print("โŒ UPDATE: Network error: \(error.localizedDescription)") + delegate?.updateCheckFailed(error: error) + return + } + + guard let data = data else { + print("โŒ UPDATE: No data received") + let error = NSError(domain: "UpdateManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "No data received"]) + delegate?.updateCheckFailed(error: error) + return + } + + print("โœ… UPDATE: Received appcast data (\(data.count) bytes)") + + // Parse the appcast XML + parseAppcastAndCheckForUpdates(data: data) + } + + private func parseAppcastAndCheckForUpdates(data: Data) { + do { + let xml = try XMLDocument(data: data, options: []) + + // Find the latest item in the appcast + guard let latestItem = xml.rootElement()?.elements(forName: "channel").first?.elements(forName: "item").first else { + print("โŒ UPDATE: No items found in appcast") + delegate?.noUpdateAvailable() + return + } + + // Extract version information + guard let enclosure = latestItem.elements(forName: "enclosure").first, + let versionAttribute = enclosure.attribute(forName: "sparkle:version"), + let latestVersionString = versionAttribute.stringValue else { + print("โŒ UPDATE: Could not extract version from appcast") + delegate?.noUpdateAvailable() + return + } + + let currentVersionString = currentVersion + + print("๐Ÿ” UPDATE: Current version: \(currentVersionString)") + print("๐Ÿ” UPDATE: Latest version: \(latestVersionString)") + + // Compare versions + let hasUpdate = compareVersions(current: currentVersionString, latest: latestVersionString) + + if hasUpdate { + print("๐ŸŽ‰ UPDATE: New version available!") + + // Create a mock SUAppcastItem for compatibility + if let mockItem = createMockAppcastItem(from: latestItem, version: latestVersionString) { + delegate?.updateAvailable(mockItem) + } else { + // Fallback to custom update available notification + showCustomUpdateAvailable(latestVersion: latestVersionString, currentVersion: currentVersionString) + } + } else { + print("โ„น๏ธ UPDATE: No update available (current: \(currentVersionString), latest: \(latestVersionString))") + delegate?.noUpdateAvailable() + } + + } catch { + print("โŒ UPDATE: XML parsing error: \(error.localizedDescription)") + delegate?.updateCheckFailed(error: error) + } + } + + private func compareVersions(current: String, latest: String) -> Bool { + // Handle "Unknown" version (development builds) + if current == "Unknown" { + print("๐Ÿ” UPDATE: Skipping update check for development build") + return false // Don't offer updates for development builds + } + + print("๐Ÿ” UPDATE: Comparing versions - Current: '\(current)' vs Latest: '\(latest)'") + + // Simple version comparison (assuming semantic versioning like "1.0", "1.1", etc.) + let currentComponents = current.split(separator: ".").compactMap { Int($0) } + let latestComponents = latest.split(separator: ".").compactMap { Int($0) } + + print("๐Ÿ” UPDATE: Current components: \(currentComponents)") + print("๐Ÿ” UPDATE: Latest components: \(latestComponents)") + + // Pad arrays to same length + let maxLength = max(currentComponents.count, latestComponents.count) + var currentPadded = currentComponents + var latestPadded = latestComponents + + while currentPadded.count < maxLength { currentPadded.append(0) } + while latestPadded.count < maxLength { latestPadded.append(0) } + + print("๐Ÿ” UPDATE: Current padded: \(currentPadded)") + print("๐Ÿ” UPDATE: Latest padded: \(latestPadded)") + + // Compare component by component + for i in 0.. currentPadded[i] { + print("๐ŸŽ‰ UPDATE: Found newer version at component \(i): \(latestPadded[i]) > \(currentPadded[i])") + return true + } else if latestPadded[i] < currentPadded[i] { + print("โš ๏ธ UPDATE: Latest version is older at component \(i): \(latestPadded[i]) < \(currentPadded[i])") + return false + } + } + + print("โ„น๏ธ UPDATE: Versions are equal") + return false // Versions are equal + } + + private func createMockAppcastItem(from xmlElement: XMLElement, version: String) -> SUAppcastItem? { + // This is a bit tricky as SUAppcastItem is not easily creatable + // For now, we'll use our custom update available method + return nil + } + + private func showCustomUpdateAvailable(latestVersion: String, currentVersion: String) { + DispatchQueue.main.async { [weak self] in + let alert = NSAlert() + alert.messageText = "Update Available! ๐ŸŽ‰" + alert.informativeText = """ + ShotScreen \(latestVersion) is now available. + You have version \(currentVersion). + + Would you like to visit the releases page to download it? + """ + alert.addButton(withTitle: "Download Update") + alert.addButton(withTitle: "Skip This Version") + alert.addButton(withTitle: "Remind Me Later") + alert.alertStyle = .informational + + // Set app icon + if let appIcon = NSApplication.shared.applicationIconImage { + alert.icon = appIcon + } + + let response = alert.runModal() + switch response { + case .alertFirstButtonReturn: + print("โœ… UPDATE: User chose to download update") + if let url = URL(string: "https://git.plet.i234.me/Nick/shotscreen/releases") { + NSWorkspace.shared.open(url) + } + case .alertSecondButtonReturn: + print("โŒ UPDATE: User chose to skip this version") + case .alertThirdButtonReturn: + print("โฐ UPDATE: User chose to be reminded later") + default: + break + } + } + } + + /// Update automatic update settings + func updateAutomaticUpdateSettings() { + guard let updater = updaterController?.updater else { return } + + let automaticUpdates = SettingsManager.shared.automaticUpdates + updater.automaticallyChecksForUpdates = automaticUpdates + + print("โš™๏ธ UPDATE: Automatic updates \(automaticUpdates ? "enabled" : "disabled")") + } + + /// Check if updater is available + var isUpdaterAvailable: Bool { + return updaterController?.updater != nil + } + + /// Get current app version + var currentVersion: String { + return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown" + } + + /// Get current build number + var currentBuildNumber: String { + return Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "Unknown" + } + + // MARK: - Private Methods + + private func showManualUpdateFallback() { + print("๐Ÿ”„ UPDATE: Showing manual update fallback") + + let alert = NSAlert() + alert.messageText = "Check for Updates" + alert.informativeText = "Automatic update checking is currently unavailable. You can check for updates manually on our releases page." + alert.addButton(withTitle: "Open Releases Page") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .informational + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + if let url = URL(string: "https://git.plet.i234.me/Nick/shotscreen/releases") { + NSWorkspace.shared.open(url) + } + } + } + + // MARK: - Debug Info + + func printDebugInfo() { + print("\n๐Ÿ” UPDATE DEBUG INFO:") + print("๐Ÿ“ฑ Current Version: \(currentVersion)") + print("๐Ÿ”ข Build Number: \(currentBuildNumber)") + print("๐Ÿ”— Feed URL: \(feedURL)") + print("โšก Updater Available: \(isUpdaterAvailable)") + print("๐Ÿค– Automatic Updates: \(SettingsManager.shared.automaticUpdates)") + + if let updater = updaterController?.updater { + print("๐Ÿ“… Last Check: \(updater.lastUpdateCheckDate?.description ?? "Never")") + } + print("๐Ÿ” UPDATE DEBUG INFO END\n") + } + + /// Force a debug update check - bypasses development detection temporarily + func forceDebugUpdateCheck() { + print("๐Ÿ”ง DEBUG: Forcing update check (bypassing development detection)") + + // Temporarily create a new updater controller that ignores development mode + let debugController = SPUStandardUpdaterController( + startingUpdater: true, // Always start for debug + updaterDelegate: self, + userDriverDelegate: nil + ) + + let debugUpdater = debugController.updater + print("๐Ÿ”ง DEBUG: Debug updater created successfully") + debugUpdater.automaticallyChecksForUpdates = false + debugUpdater.automaticallyDownloadsUpdates = false + + isManualCheck = true + delegate?.updateCheckDidStart() + + print("๐Ÿ”ง DEBUG: Starting forced update check...") + debugUpdater.checkForUpdates() + } +} + +// MARK: - SPUUpdaterDelegate (automatic updates only - manual updates use custom implementation) +extension UpdateManager: @preconcurrency SPUUpdaterDelegate { + + func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: Error?) { + // This is only called for automatic background checks now (not manual checks) + print("โœ… UPDATE: Automatic update check completed") + + if let error = error { + print("โŒ UPDATE: Automatic update check failed: \(error.localizedDescription)") + } + + // Let Sparkle handle everything - no delegate calls to prevent UI interference + isManualCheck = false + } + + func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { + print("๐ŸŽ‰ UPDATE: Sparkle found valid update!") + print("๐Ÿ” UPDATE: Update version: \(item.displayVersionString)") + print("๐Ÿ” UPDATE: Current version: \(currentVersion)") + print("๐Ÿ” UPDATE: Update info: \(item.infoURL?.absoluteString ?? "None")") + print("๐Ÿ” UPDATE: Was manual check: \(isManualCheck)") + + // Let Sparkle handle the UI completely - don't trigger additional dialogs + // Just notify delegate for internal state tracking (no additional UI) + isManualCheck = false + // delegate?.updateAvailable(item) // Commented out to prevent double UI + } + + func updaterDidNotFindUpdate(_ updater: SPUUpdater) { + print("โ„น๏ธ UPDATE: No updates found by Sparkle") + print("๐Ÿ” UPDATE: Current version: \(currentVersion)") + print("๐Ÿ” UPDATE: Feed URL used: \(feedURL)") + print("๐Ÿ” UPDATE: Was manual check: \(isManualCheck)") + + // Let Sparkle handle the "no update" UI completely - don't add our own popup + // Sparkle already shows its own "You're up to date!" dialog for manual checks + + isManualCheck = false + // delegate?.noUpdateAvailable() // Commented out to prevent multiple dialogs + } + + func updater(_ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem) { + print("๐Ÿ“ฆ UPDATE: Will install update: \(item.displayVersionString)") + } + + func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { + print("๐Ÿ’ฅ UPDATE: Update aborted with error: \(error.localizedDescription)") + + // For automatic updates, we don't need to notify the delegate of failures + // since they're silent background operations + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/WindowCaptureManager.swift b/ShotScreen/Sources/WindowCaptureManager.swift new file mode 100644 index 0000000..77a6d8d --- /dev/null +++ b/ShotScreen/Sources/WindowCaptureManager.swift @@ -0,0 +1,998 @@ +import AppKit +import ScreenCaptureKit +import SwiftUI +import UniformTypeIdentifiers +import ObjectiveC + +// MARK: - Window Capture Manager +@available(macOS 12.3, *) +class WindowCaptureManager: NSObject, ObservableObject { + + // MARK: - Properties + weak var screenshotApp: ScreenshotApp? + var determinedMainScreen: NSScreen! // Stores the true main screen for the current selection session + + // Available content for capture + @Published var availableWindows: [SCWindow] = [] + @Published var availableDisplays: [SCDisplay] = [] + @Published var isCapturing = false + @Published var captureError: String? + + // Window selection state + @Published var isWindowSelectionActive = false + private var windowSelectionOverlay: WindowSelectionOverlay? + private var globalMouseMonitor: Any? + + // MARK: - Initialization + override init() { + super.init() + } + + convenience init(screenshotApp: ScreenshotApp) { + self.init() + self.screenshotApp = screenshotApp + } + + // MARK: - Content Discovery + func refreshAvailableContent() async { + print("๐Ÿ”„ Refreshing available content for window capture...") + + do { + let shareableContent = try await SCShareableContent.current + await updateAvailableContent(shareableContent) + print("โœ… Content refreshed successfully") + } catch { + await handleContentRefreshError(error) + } + } + + // MARK: - Private Helper Methods for Content Discovery + + private func updateAvailableContent(_ shareableContent: SCShareableContent) async { + let filteredWindows = filterShareableWindows(shareableContent.windows) + let validDisplays = shareableContent.displays.filter { $0.width > 0 && $0.height > 0 } + + await MainActor.run { + self.availableWindows = filteredWindows + self.availableDisplays = validDisplays + self.captureError = nil + } + + logContentSummary(windows: filteredWindows, displays: validDisplays) + } + + private func filterShareableWindows(_ windows: [SCWindow]) -> [SCWindow] { + return windows.filter { window in + // Filter criteria for capturable windows + guard window.isOnScreen, + window.frame.width > 50, + window.frame.height > 50, + let app = window.owningApplication, + app.applicationName != "WindowServer", + app.applicationName != "Dock" else { + return false + } + + // Exclude our own app's windows + if let ourBundleID = Bundle.main.bundleIdentifier, + app.bundleIdentifier == ourBundleID { + return false + } + + return true + } + } + + private func logContentSummary(windows: [SCWindow], displays: [SCDisplay]) { + print("๐Ÿ“Š Content Summary:") + print(" Windows: \(windows.count) capturable windows found") + print(" Displays: \(displays.count) displays found") + + if windows.count > 0 { + print("๐ŸชŸ Sample windows:") + for (index, window) in windows.prefix(5).enumerated() { + let app = window.owningApplication?.applicationName ?? "Unknown" + let title = window.title?.isEmpty == false ? window.title! : "No Title" + print(" \(index + 1). \(title) (\(app)) - Layer: \(window.windowLayer)") + } + if windows.count > 5 { + print(" ... and \(windows.count - 5) more windows") + } + } + } + + private func handleContentRefreshError(_ error: Error) async { + await MainActor.run { + self.captureError = "Failed to get shareable content: \(error.localizedDescription)" + print("โŒ WindowCaptureManager Error: \(error)") + } + } + + // MARK: - Window Selection Mode + func activateWindowSelectionMode() { + print("๐ŸŽฌ ACTIVATE: Starting window selection mode...") + print("๐ŸชŸ Activating window selection mode") + + // Determine the actual main screen (with origin 0,0) for consistent coordinate calculations + self.determinedMainScreen = NSScreen.screens.first { $0.frame.origin == .zero } + if self.determinedMainScreen == nil { + print("โš ๏ธ CRITICAL: Could not find screen with origin (0,0). Falling back to default NSScreen.main.") + self.determinedMainScreen = NSScreen.main! // Fallback, though this might be the unreliable one + } + print("โœจ Using determined main screen for this session: \(determinedMainScreen.customLocalizedName) with frame \(determinedMainScreen.frame)") + + print("๐ŸŽฌ ACTIVATE: Starting async task to refresh content and show overlay...") + + Task { + print("๐ŸŽฌ TASK: Inside async task, refreshing content...") + await refreshAvailableContent() + await MainActor.run { + print("๐ŸŽฌ TASK: Content refreshed, setting flags and showing overlay...") + self.isWindowSelectionActive = true + self.showWindowSelectionOverlay() + print("๐ŸŽฌ TASK: Window selection overlay should now be shown") + } + } + } + + // MARK: - Window Selection Mode Deactivation + func deactivateWindowSelectionMode() { + print("๐ŸชŸ Deactivating window selection mode") + + isWindowSelectionActive = false + hideWindowSelectionOverlay() + + // Remove any mouse monitors + if let monitor = globalMouseMonitor { + NSEvent.removeMonitor(monitor) + globalMouseMonitor = nil + } + } + + private func showWindowSelectionOverlay() { + // Create overlay window that shows available windows + let overlay = WindowSelectionOverlay(windowCaptureManager: self) + overlay.show() + windowSelectionOverlay = overlay + } + + private func hideWindowSelectionOverlay() { + windowSelectionOverlay?.hide() + windowSelectionOverlay = nil + } + + private func setupWindowSelectionMonitoring() { + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in + guard let self = self, self.isWindowSelectionActive else { return } + + let location = NSEvent.mouseLocation + self.handleWindowSelection(at: location) + } + } + + private func removeWindowSelectionMonitoring() { + if let monitor = globalMouseMonitor { + NSEvent.removeMonitor(monitor) + globalMouseMonitor = nil + } + } + + private func handleWindowSelection(at location: NSPoint) { + // HERSTEL: Zoek naar een window + if let window = findWindowAt(location: location) { + let windowTitleForPrint = window.title ?? "Untitled" + print("๐ŸŽฏ Window selected: \(windowTitleForPrint) at \(location)") + deactivateWindowSelectionMode() + + Task { + await captureWindow(window) // Gebruik de captureWindow methode + } + } else { + print(" No window found at \(location), canceling") + deactivateWindowSelectionMode() + } + } + + // MARK: - Window Detection + func findWindowAt(location: NSPoint) -> SCWindow? { + NSLog("๐ŸŽฏ Finding window at global location: %@", NSStringFromPoint(location)) + + // Step 1: Validate main screen reference + guard let mainScreenRef = validateMainScreenReference() else { + return nil + } + + // Step 2: Find the screen containing the mouse location + guard let mouseScreen = findScreenContaining(location: location) else { + return nil + } + + // Step 3: Search for window at location + return searchWindowsAt(location: location, mouseScreen: mouseScreen, mainScreenRef: mainScreenRef) + } + + // MARK: - Private Helper Methods for Window Detection + + private func validateMainScreenReference() -> NSScreen? { + guard let mainScreenRef = self.determinedMainScreen else { + NSLog("โŒ No determined main screen available for findWindowAt") + return nil + } + return mainScreenRef + } + + private func findScreenContaining(location: NSPoint) -> NSScreen? { + for screen in NSScreen.screens { + if screen.frame.contains(location) { + NSLog("๐ŸŽฏ Mouse is on NSScreen: '%@' (Frame: %@)", screen.customLocalizedName, NSStringFromRect(screen.frame)) + return screen + } + } + NSLog("โŒ Mouse location not on any NSScreen: %@", NSStringFromPoint(location)) + return nil + } + + private func searchWindowsAt(location: NSPoint, mouseScreen: NSScreen, mainScreenRef: NSScreen) -> SCWindow? { + // Sort windows by layer, so frontmost windows are checked first + let sortedWindows = availableWindows.sorted { $0.windowLayer > $1.windowLayer } + NSLog("๐ŸŽฏ Checking \(sortedWindows.count) sorted windows.") + + for window in sortedWindows { + if let foundWindow = checkWindowAtLocation(window: window, location: location, + mouseScreen: mouseScreen, mainScreenRef: mainScreenRef) { + return foundWindow + } + } + + NSLog(" โŒ No window found at global location: %@ on screen %@", NSStringFromPoint(location), mouseScreen.customLocalizedName) + return nil + } + + private func checkWindowAtLocation(window: SCWindow, location: NSPoint, + mouseScreen: NSScreen, mainScreenRef: NSScreen) -> SCWindow? { + let scWinFrame = window.frame + let globalWindowFrame = self.convertSCWindowFrameToGlobal(scWinFrame, mainScreen: mainScreenRef) + + // Check 1: Does the window intersect with the screen the mouse is on? + guard mouseScreen.frame.intersects(globalWindowFrame) else { + return nil + } + + // Check 2: Does the global window frame contain the mouse location? + guard globalWindowFrame.contains(location) else { + return nil + } + + NSLog(" ๐ŸŽ‰ FOUND window by global coords: '%@' (App: %@) on screen '%@'", + window.title ?? "Untitled", + window.owningApplication?.applicationName ?? "Unknown", + mouseScreen.customLocalizedName) + return window + } + + // MARK: - Window Capture Methods + + /// Capture a specific window by SCWindow object + func captureWindow(_ window: SCWindow) async { + print("๐Ÿ“ธ WindowCaptureManager: Capturing window: \(window.title ?? "Untitled") using ScreenCaptureKitProvider") + + // Step 1: Initialize capture state + await initializeCaptureState() + + // Step 2: Validate screen capture provider + guard let provider = validateScreenCaptureProvider() else { + await handleCaptureError("ScreenCaptureProvider not available.", code: 3) + return + } + + // Step 3: Perform capture + await performWindowCapture(window: window, provider: provider) + } + + // MARK: - Private Helper Methods for Window Capture + + private func initializeCaptureState() async { + await MainActor.run { + isCapturing = true + captureError = nil + } + } + + private func validateScreenCaptureProvider() -> ScreenCaptureKitProvider? { + return self.screenshotApp?.screenCaptureProvider + } + + private func performWindowCapture(window: SCWindow, provider: ScreenCaptureKitProvider) async { + if let image = await provider.captureWindow(window: window) { + await handleSuccessfulCapture(image: image) + } else { + await handleCaptureError("Failed to capture window with ScreenCaptureKitProvider.", code: 2) + } + } + + private func handleSuccessfulCapture(image: NSImage) async { + await MainActor.run { + self.isCapturing = false + self.screenshotApp?.processCapture(image: image) + self.deactivateWindowSelectionMode() + print("โœ… Window captured successfully and selection mode deactivated.") + } + } + + private func handleCaptureError(_ description: String, code: Int) async { + await MainActor.run { + self.isCapturing = false + self.captureError = NSError(domain: "WindowCaptureError", code: code, + userInfo: [NSLocalizedDescriptionKey: description]).localizedDescription + self.deactivateWindowSelectionMode() + print("Error: \(description)") + } + } + + // MARK: - Core Graphics Capture Method (Old - Replaced by ScreenCaptureKitProvider) + /* + private func captureWindowWithCoreGraphics(_ window: SCWindow) async { + let windowID = window.windowID + let imageRef = CGWindowListCreateImage(.null, .optionIncludingWindow, windowID, .bestResolution) + + guard let cgImage = imageRef else { + await MainActor.run { + self.captureError = "Failed to capture window" + self.isCapturing = false + } + return + } + + let nsImage = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + + await MainActor.run { + self.isCapturing = false + // Process the captured image through the main app + self.screenshotApp?.processWindowCapture(image: nsImage, windowTitle: window.title) + } + } + */ + + /// Capture window by application name + func captureWindowByApp(appName: String) async { + await refreshAvailableContent() + + guard let window = availableWindows.first(where: { + $0.owningApplication?.applicationName == appName + }) else { + await MainActor.run { + self.captureError = "No window found for app: \(appName)" + } + return + } + + await captureWindow(window) + } + + /// Capture frontmost window + func captureFrontmostWindow() async { + await refreshAvailableContent() + + // Get the frontmost window (highest window level) + guard let frontmostWindow = availableWindows.max(by: { $0.windowLayer < $1.windowLayer }) else { + await MainActor.run { + self.captureError = "No frontmost window found" + } + return + } + + await captureWindow(frontmostWindow) + } + + /// Capture window containing a specific point + func captureWindowAt(point: NSPoint) async { + await refreshAvailableContent() + + guard let window = findWindowAt(location: point) else { + await MainActor.run { + self.captureError = "No window found at specified location" + } + return + } + + await captureWindow(window) + } + + // MARK: - Settings Integration + func getWindowCaptureSettings() -> WindowCaptureSettings { + return WindowCaptureSettings( + includeCursor: UserDefaults.standard.bool(forKey: "windowCaptureIncludeCursor"), + showSelectionUI: UserDefaults.standard.bool(forKey: "windowCaptureShowSelectionUI") + ) + } + + func saveWindowCaptureSettings(_ settings: WindowCaptureSettings) { + UserDefaults.standard.set(settings.includeCursor, forKey: "windowCaptureIncludeCursor") + UserDefaults.standard.set(settings.showSelectionUI, forKey: "windowCaptureShowSelectionUI") + } + + // MARK: - Coordinate Conversion Helper + // Converts an SCWindow.frame (origin top-left of main screen, Y-down) to Global coordinates (origin bottom-left of main screen, Y-up) + func convertSCWindowFrameToGlobal(_ scWindowFrame: CGRect, mainScreen: NSScreen) -> CGRect { + let globalX = scWindowFrame.origin.x + // Y of top edge of scWindow in Global Y-up (from main screen bottom) + let globalY_topEdge = mainScreen.frame.height - scWindowFrame.origin.y + // Y of bottom edge of scWindow in Global Y-up (this becomes the origin for the CGRect) + let globalY_bottomEdge_forOrigin = globalY_topEdge - scWindowFrame.height + + return CGRect(x: globalX, y: globalY_bottomEdge_forOrigin, width: scWindowFrame.width, height: scWindowFrame.height) + } + + // MARK: - Window Detection (Wordt Display Detection) + func findDisplayAt(location: NSPoint) -> SCDisplay? { + NSLog("๐ŸŽฏ Finding display at global location: %@", NSStringFromPoint(location)) + + // We gebruiken de beschikbare SCDisplay objecten direct. + // Hun frames zijn in pixels, oorsprong linksboven van *dat specifieke display*. + // Om te checken of de muis (globale punten, oorsprong linksonder hoofdmenu) binnen een display valt, + // moeten we de SCDisplay.frame converteren naar globale punten, of de muislocatie naar de coรถrdinaten van elk display. + // Makkelijker: gebruik NSScreen.screens, vind de NSScreen waar de muis op is, en zoek dan de SCDisplay met dezelfde displayID. + + var currentMouseNSScreen: NSScreen? + for s in NSScreen.screens { + if s.frame.contains(location) { + currentMouseNSScreen = s + break + } + } + guard let mouseNSScreen = currentMouseNSScreen else { + NSLog("โŒ Mouse location %@ not on any NSScreen.", NSStringFromPoint(location)) + return nil + } + NSLog("๐ŸŽฏ Mouse is on NSScreen: '%@' (Frame: %@), DisplayID: \(mouseNSScreen.displayID)", mouseNSScreen.customLocalizedName, NSStringFromRect(mouseNSScreen.frame)) + + // Zoek de SCDisplay die overeenkomt met deze NSScreen + if let matchedDisplay = availableDisplays.first(where: { $0.displayID == mouseNSScreen.displayID }) { + NSLog(" ๐ŸŽ‰ FOUND display by ID match: DisplayID \(matchedDisplay.displayID)") + return matchedDisplay + } + + NSLog(" โŒ No SCDisplay found matching NSScreen with DisplayID \(mouseNSScreen.displayID) at global location: %@", NSStringFromPoint(location)) + return nil + } + + // MARK: - Window Capture Methods (Wordt Display Capture Methods) + + /// Capture a specific display by SCDisplay object + func captureDisplay(_ display: SCDisplay) async { + print("๐Ÿ–ฅ๏ธ WindowCaptureManager: Capturing display ID \(display.displayID) using ScreenCaptureKitProvider") + + // Converteer SCDisplay naar NSScreen + guard let targetNSScreen = NSScreen.screens.first(where: { $0.displayID == display.displayID }) else { + await MainActor.run { + self.isCapturing = false + self.captureError = NSError(domain: "DisplayCaptureError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not find NSScreen matching SCDisplay ID \(display.displayID)."]).localizedDescription + self.deactivateWindowSelectionMode() + print("Error: Could not find NSScreen for SCDisplay ID \(display.displayID).") + } + return + } + print("๐Ÿ–ฅ๏ธ Corresponderende NSScreen gevonden: \(targetNSScreen.customLocalizedName)") + + await MainActor.run { + isCapturing = true + captureError = nil + } + + if let provider = self.screenshotApp?.screenCaptureProvider { + // ScreenCaptureKitProvider.captureScreen verwacht een NSScreen + // De excludingWindows parameter is optioneel en wordt hier nil gelaten, + // omdat we het hele scherm capturen en desktop icons apart worden behandeld in de provider. + if let image = await provider.captureScreen(screen: targetNSScreen, excludingWindows: nil) { + await MainActor.run { + self.isCapturing = false + self.screenshotApp?.processCapture(image: image) + self.deactivateWindowSelectionMode() + print("โœ… Display captured successfully and selection mode deactivated.") + } + } else { + await MainActor.run { + self.isCapturing = false + self.captureError = NSError(domain: "DisplayCaptureError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to capture display with ScreenCaptureKitProvider."]).localizedDescription + self.deactivateWindowSelectionMode() + print("Error: Failed to capture display ID \(display.displayID) with ScreenCaptureKitProvider.") + } + } + } else { + await MainActor.run { + self.isCapturing = false + self.captureError = NSError(domain: "DisplayCaptureError", code: 3, userInfo: [NSLocalizedDescriptionKey: "ScreenCaptureProvider not available."]).localizedDescription + self.deactivateWindowSelectionMode() + print("Error: ScreenCaptureProvider not available for display capture.") + } + } + } +} + +// MARK: - Window Capture Settings +struct WindowCaptureSettings { + var includeCursor: Bool = true + var showSelectionUI: Bool = true +} + +// MARK: - Window Selection Overlay +@available(macOS 12.3, *) +class WindowSelectionOverlay: NSObject { + private var windowCaptureManager: WindowCaptureManager + private var overlayWindows: [NSWindow] = [] // Dit zijn de semi-transparante overlay windows per scherm + private var globalMouseMonitor: Any? + + init(windowCaptureManager: WindowCaptureManager) { + self.windowCaptureManager = windowCaptureManager + super.init() + } + + func show() { + print("๐ŸŽฌ OVERLAY SHOW: Starting to create overlays...") + + hide() // Clean up any existing overlays + + print("๐ŸชŸ WindowCaptureManager: Starting window selection on \(NSScreen.screens.count) screens") + for (index, screen) in NSScreen.screens.enumerated() { + print(" Screen \(index): \(screen.customLocalizedName) - Frame: \(screen.frame)") + } + + // Create a separate overlay window for each screen + for (index, screen) in NSScreen.screens.enumerated() { + print("๐ŸŽฌ OVERLAY SHOW: Creating overlay for screen \(index): \(screen.customLocalizedName)") + let overlayWindow = createOverlayWindow(for: screen) + overlayWindows.append(overlayWindow) + + print("๐ŸŽฌ OVERLAY SHOW: Window created, ordering front...") + + // IMPROVED: Proper window ordering and activation + overlayWindow.orderFrontRegardless() + overlayWindow.makeKeyAndOrderFront(nil) + + print("๐ŸŽฌ OVERLAY SHOW: Window ordered front, checking content view...") + + // Force the content view to display immediately + if let contentView = overlayWindow.contentView { + print("๐ŸŽฌ OVERLAY SHOW: Content view found, forcing display...") + contentView.needsDisplay = true + contentView.displayIfNeeded() + print("๐ŸŽจ FORCED DISPLAY for screen \(screen.customLocalizedName)") + } else { + print("โŒ OVERLAY SHOW: NO CONTENT VIEW found for screen \(screen.customLocalizedName)") + } + } + + print("๐ŸŽฌ OVERLAY SHOW: All overlays created, setting up mouse monitoring...") + + // Setup global mouse monitoring for all screens + setupGlobalMouseMonitoring() + + // Force app activation + NSApp.activate(ignoringOtherApps: true) + + print("๐ŸชŸ Window selection overlay shown on \(overlayWindows.count) screens") + + // ADDITIONAL: Force a refresh after a short delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + print("๐ŸŽฌ DELAYED REFRESH: Starting...") + for (index, window) in self.overlayWindows.enumerated() { + if let contentView = window.contentView { + contentView.needsDisplay = true + print("๐ŸŽจ DELAYED REFRESH for window \(index)") + } else { + print("โŒ DELAYED REFRESH: NO CONTENT VIEW for window \(index)") + } + } + } + + // Debug: List available windows with their positions + print("๐ŸชŸ Available windows for capture:") + for (index, window) in windowCaptureManager.availableWindows.enumerated() { + let frame = window.frame + let app = window.owningApplication?.applicationName ?? "Unknown" + print(" \(index): \(window.title ?? "Untitled") (\(app)) - Frame: \(frame)") + } + } + + func hide() { + // Remove all overlay windows + for window in overlayWindows { + window.orderOut(nil) + } + overlayWindows.removeAll() + + // Remove global mouse monitor + if let monitor = globalMouseMonitor { + NSEvent.removeMonitor(monitor) + globalMouseMonitor = nil + } + + print("๐ŸชŸ Window selection overlay hidden") + } + + private func createOverlayWindow(for screen: NSScreen) -> NSWindow { + let window = NSWindow( + contentRect: screen.frame, + styleMask: [.borderless, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + // FIXED: Window configuration for proper drawing and event handling + window.backgroundColor = NSColor.clear // Clear background so we can draw our own + window.isOpaque = false + window.level = NSWindow.Level.floating // Lower level but still above normal windows + window.ignoresMouseEvents = false + window.acceptsMouseMovedEvents = true + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] + + // CRITICAL: Ensure window can receive events and display properly + window.displaysWhenScreenProfileChanges = true + window.animationBehavior = .none + window.hidesOnDeactivate = false + + // IMPORTANT: Enable automatic invalidation for proper drawing + window.invalidateShadow() + window.viewsNeedDisplay = true + + // Create content view for this screen + let overlayView = WindowSelectionOverlayView( + windowCaptureManager: windowCaptureManager, + screen: screen + ) + + // CRITICAL: Properly configure the view for drawing + overlayView.wantsLayer = false // Use direct drawing for better control + overlayView.needsDisplay = true + + window.contentView = overlayView + + // Position window exactly on this screen + window.setFrame(screen.frame, display: true) // Force display update + + print("๐ŸชŸ Created overlay window for screen: \(screen.customLocalizedName) at level \(window.level.rawValue)") + print("๐ŸชŸ Window frame: \(window.frame)") + print("๐ŸชŸ Content view frame: \(overlayView.frame)") + print("๐ŸชŸ Content view needsDisplay: \(overlayView.needsDisplay)") + + return window + } + + private func setupGlobalMouseMonitoring() { + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .keyDown]) { [weak self] event in + guard let self = self else { return } + + if event.type == .leftMouseDown { + let location = NSEvent.mouseLocation + print("๐ŸŽฏ Global mouse click at: \(location) for window capture") + print("๐Ÿ”ฅ DEBUG: Available windows: \(self.windowCaptureManager.availableWindows.count)") + + // ๐Ÿ”ฅ FIXED: In window capture mode, any click should capture the window + print("๐ŸชŸ Click detected in window capture mode - capturing window") + self.handleMouseClick(at: location) + } else if event.type == .keyDown && event.keyCode == 53 { // ESC + print("โŒจ๏ธ ESC key pressed, canceling window/display capture") + self.windowCaptureManager.deactivateWindowSelectionMode() + } + } + + print("๐ŸŽฏ Global mouse monitoring setup for window capture") + } + + private func handleMouseClick(at location: NSPoint) { + // HERSTEL: Zoek naar een window + if let window = windowCaptureManager.findWindowAt(location: location) { + let selectedWindowTitle = window.title ?? "Untitled" + print("๐ŸŽฏ Window selected: \(selectedWindowTitle) at \(location)") + windowCaptureManager.deactivateWindowSelectionMode() + + Task { + await windowCaptureManager.captureWindow(window) // Gebruik de captureWindow methode + } + } else { + print(" No window found at \(location), canceling") + windowCaptureManager.deactivateWindowSelectionMode() + } + } +} + +// MARK: - Window Selection Overlay View +@available(macOS 12.3, *) +class WindowSelectionOverlayView: NSView { + private var windowCaptureManager: WindowCaptureManager + private var screen: NSScreen // Het NSScreen object waar deze view op getekend wordt + private var hoveredWindow: SCWindow? // HERSTEL: terug naar SCWindow + private var trackingArea: NSTrackingArea? + + init(windowCaptureManager: WindowCaptureManager, screen: NSScreen) { + self.windowCaptureManager = windowCaptureManager + self.screen = screen + super.init(frame: NSRect(origin: .zero, size: screen.frame.size)) + + self.wantsLayer = false + self.layerContentsRedrawPolicy = .onSetNeedsDisplay + self.autoresizingMask = [.width, .height] + self.translatesAutoresizingMaskIntoConstraints = true + + NSLog("๐ŸŽจโœ… VIEW INIT for screen: %@, Frame: %@, Bounds: %@", screen.customLocalizedName, NSStringFromRect(self.frame), NSStringFromRect(self.bounds)) + + setupTrackingArea() + + self.needsDisplay = true + DispatchQueue.main.async { + NSLog("๐ŸŽจโœ… VIEW INIT DELAYED DISPLAY for screen: %@", self.screen.customLocalizedName) + self.needsDisplay = true + self.displayIfNeeded() // Force display after setup + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupTrackingArea() { + // Remove existing tracking area + if let existingArea = trackingArea { + removeTrackingArea(existingArea) + } + + // Create new tracking area that covers this view + trackingArea = NSTrackingArea( + rect: bounds, + options: [.activeAlways, .mouseMoved, .inVisibleRect], + owner: self, + userInfo: nil + ) + + if let trackingArea = trackingArea { + addTrackingArea(trackingArea) + } + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + setupTrackingArea() + } + + override func draw(_ dirtyRect: NSRect) { + // NSLog("๐ŸŽจโœ… DRAWING ENTRY POINT for view on screen: %@ with dirtyRect: %@", self.window?.screen?.customLocalizedName ?? "UnknownScreen", NSStringFromRect(dirtyRect)) + super.draw(dirtyRect) + + NSColor.black.withAlphaComponent(0.05).setFill() + dirtyRect.fill() // Lichte overlay over het hele scherm + + // HERSTEL: Teken highlight voor hoveredWindow + guard let currentHoveredWindow = self.hoveredWindow, + let currentViewNSScreen = self.window?.screen, // Het NSScreen waar deze view op is + let mainScreenForConversion = self.windowCaptureManager.determinedMainScreen else { + // Als er geen window gehovered is, teken dan alleen de instructietekst op het hoofdscherm + if self.window?.screen == self.windowCaptureManager.determinedMainScreen { // Gebruik determinedMainScreen voor consistentie + drawInstructionText(on: self.window!.screen!, viewSize: self.bounds.size) + } + return + } + + let scWindowFrame = currentHoveredWindow.frame + // Converteer SCWindow.frame naar Globale AppKit coรถrdinaten (Y-omhoog, oorsprong linksonder hoofd NSScreen) + let globalHoveredWindowFrame = self.windowCaptureManager.convertSCWindowFrameToGlobal(scWindowFrame, mainScreen: mainScreenForConversion) + + // Converteer de globale frame naar coรถrdinaten lokaal aan *deze* view. + // De (0,0) van deze view is de linksonderhoek van het currentViewNSScreen. + let localHighlightX = globalHoveredWindowFrame.origin.x - currentViewNSScreen.frame.origin.x + let localHighlightY = globalHoveredWindowFrame.origin.y - currentViewNSScreen.frame.origin.y + let rectToHighlightInViewCoordinates = NSRect(x: localHighlightX, + y: localHighlightY, + width: globalHoveredWindowFrame.width, + height: globalHoveredWindowFrame.height) + + // NSLog("๐ŸŽจ DRAW on view for NSScreen \'%@\': Highlighting window \'%@\'", currentViewNSScreen.customLocalizedName, currentHoveredWindow.title ?? "N/A") + // NSLog("๐ŸŽจ SCWindow Frame: %@ (Layer: \(currentHoveredWindow.windowLayer))", NSStringFromRect(scWindowFrame)) + // NSLog("๐ŸŽจ Global Hovered Frame: %@", NSStringFromRect(globalHoveredWindowFrame)) + // NSLog("๐ŸŽจ Calculated Highlight Rect (local to view on screen \'%@\'): %@", currentViewNSScreen.customLocalizedName, NSStringFromRect(rectToHighlightInViewCoordinates)) + + // Teken alleen als de highlight rect daadwerkelijk de bounds van deze view snijdt. + // Dit is belangrijk omdat een venster over meerdere schermen kan spannen. + if self.bounds.intersects(rectToHighlightInViewCoordinates) { + NSColor.blue.withAlphaComponent(0.3).setFill() + // We clippen de highlight path naar de intersectie met de view bounds voor het geval het venster groter is dan dit schermsegment. + let drawingRect = self.bounds.intersection(rectToHighlightInViewCoordinates) + let highlightPath = NSBezierPath(rect: drawingRect) + highlightPath.fill() + + NSColor.blue.withAlphaComponent(0.8).setStroke() + highlightPath.lineWidth = 2 // Standaard lijndikte voor venster + highlightPath.stroke() + // NSLog("๐ŸŽจ Window highlight drawn (intersected rect: %@).", NSStringFromRect(drawingRect)) + } else { + // NSLog("๐ŸŽจ Highlight rect %@ does NOT intersect view bounds %@. Not drawing highlight on this screen segment.", NSStringFromRect(rectToHighlightInViewCoordinates), NSStringFromRect(self.bounds)) + } + + // Teken instructietekst (alleen op de primary overlay) + if currentViewNSScreen == self.windowCaptureManager.determinedMainScreen { // Gebruik determinedMainScreen + drawInstructionText(on: currentViewNSScreen, viewSize: self.bounds.size) + } + } + + private func drawInstructionText(on screen: NSScreen, viewSize: NSSize) { + // HERSTEL: Tekst voor window selectie + let instructionText = "Hover to select a window, Click to capture, ESC to cancel" + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 18, weight: .medium), + .foregroundColor: NSColor.white.withAlphaComponent(0.9), + .strokeColor: NSColor.black.withAlphaComponent(0.5), + .strokeWidth: -2.0, + .paragraphStyle: { + let style = NSMutableParagraphStyle() + style.alignment = .center + return style + }() + ] + let attributedString = NSAttributedString(string: instructionText, attributes: attributes) + let textSize = attributedString.size() + + let textRect = NSRect(x: (viewSize.width - textSize.width) / 2, + y: viewSize.height - textSize.height - 30, + width: textSize.width, + height: textSize.height) + + let backgroundPadding: CGFloat = 10 + let backgroundRect = NSRect( + x: textRect.origin.x - backgroundPadding, + y: textRect.origin.y - backgroundPadding, + width: textRect.width + (2 * backgroundPadding), + height: textRect.height + (2 * backgroundPadding) + ) + let BORDER_RADIUS: CGFloat = 10 + let textBackgroundPath = NSBezierPath(roundedRect: backgroundRect, xRadius: BORDER_RADIUS, yRadius: BORDER_RADIUS) + NSColor.black.withAlphaComponent(0.4).setFill() + textBackgroundPath.fill() + + attributedString.draw(in: textRect) + // NSLog("๐ŸŽจ Instruction text drawn on main screen.") // Keep this one less verbose for now + } + + override func mouseMoved(with event: NSEvent) { + let globalLocation = NSEvent.mouseLocation + + var currentMouseNSScreen: NSScreen? + for s in NSScreen.screens { + if s.frame.contains(globalLocation) { + currentMouseNSScreen = s + break + } + } + + // Als de muis niet op het scherm van DEZE specifieke overlay view is, reset hoveredWindow voor DEZE view. + // Dit zorgt ervoor dat een venster dat over meerdere schermen spant, alleen gehighlight wordt op het scherm waar de muis is. + guard let mouseNSScreen = currentMouseNSScreen, mouseNSScreen.displayID == self.screen.displayID else { + if hoveredWindow != nil { + hoveredWindow = nil + needsDisplay = true + } + return + } + + // Muis is op het scherm van deze view. Kijk welk SCWindow (indien aanwezig) gehovered wordt. + let foundWindow = windowCaptureManager.findWindowAt(location: globalLocation) + + if hoveredWindow != foundWindow { // Vergelijk SCWindow objecten direct + hoveredWindow = foundWindow + needsDisplay = true + + // if foundWindow != nil { + // NSLog("๐Ÿ–ฑ๏ธ โœจ Hovered window SET to: \'%@\' (ID: \(foundWindow!.windowID), Layer: \(foundWindow!.windowLayer)) on screen %@", foundWindow!.title ?? "N/A", self.screen.customLocalizedName) + // } else { + // NSLog("๐Ÿ–ฑ๏ธ ๐Ÿ’จ No window hovered on screen %@ at %@", self.screen.customLocalizedName, NSStringFromPoint(globalLocation)) + // } + } + } + + // ๐Ÿ”ฅ NIEUW: Handle mouse clicks directly in the overlay view + override func mouseDown(with event: NSEvent) { + let globalLocation = NSEvent.mouseLocation + print("๐Ÿ”ฅ OVERLAY: Mouse click detected at \(globalLocation)") + + // Find window at click location + if let window = windowCaptureManager.findWindowAt(location: globalLocation) { + let selectedWindowTitle = window.title ?? "Untitled" + print("๐ŸŽฏ โœ… OVERLAY: Window selected: \(selectedWindowTitle) at \(globalLocation)") + + // Deactivate window selection mode first + windowCaptureManager.deactivateWindowSelectionMode() + + // Capture the window + Task { + print("๐Ÿ”ฅ OVERLAY: Starting window capture task for \(selectedWindowTitle)") + await windowCaptureManager.captureWindow(window) + print("๐Ÿ”ฅ OVERLAY: Window capture task completed for \(selectedWindowTitle)") + } + } else { + print("โŒ OVERLAY: No window found at \(globalLocation)") + print("๐Ÿ”ฅ DEBUG: Available windows count: \(windowCaptureManager.availableWindows.count)") + + // Extra debug: List all windows and their positions + for (index, win) in windowCaptureManager.availableWindows.enumerated() { + let frame = win.frame + let isPointInFrame = NSPointInRect(globalLocation, frame) + print("๐Ÿ”ฅ DEBUG: Window \(index): \(win.title ?? "Untitled") - Frame: \(frame) - Contains point: \(isPointInFrame)") + } + + windowCaptureManager.deactivateWindowSelectionMode() + } + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { // ESC + windowCaptureManager.deactivateWindowSelectionMode() + } + } + + override var acceptsFirstResponder: Bool { + return true + } +} + +// MARK: - ScreenshotApp Extension for Window Capture Integration +extension ScreenshotApp { + + // Add window capture manager property to ScreenshotApp + private static var windowCaptureManagerKey: UInt8 = 0 + + var windowCaptureManager: WindowCaptureManager? { + get { + return objc_getAssociatedObject(self, &ScreenshotApp.windowCaptureManagerKey) as? WindowCaptureManager + } + set { + objc_setAssociatedObject(self, &ScreenshotApp.windowCaptureManagerKey, newValue, .OBJC_ASSOCIATION_RETAIN) + } + } + + func initializeWindowCaptureManager() { + if #available(macOS 12.3, *) { + windowCaptureManager = WindowCaptureManager(screenshotApp: self) + print("โœ… WindowCaptureManager initialized") + } else { + print("โš ๏ธ WindowCaptureManager requires macOS 12.3 or later") + } + } + + func processWindowCapture(image: NSImage, windowTitle: String?) { + // Process window capture similar to regular screenshot capture + print("๐ŸชŸ Processing window capture: \(windowTitle ?? "Untitled Window")") + + let tempURL = createTempUrl() + setTempFileURL(tempURL) + + guard let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let pngData = bitmap.representation(using: .png, properties: [:]) else { + print("โŒ Failed to convert window capture to PNG") + return + } + + do { + try pngData.write(to: tempURL) + print("๐Ÿ’พ Window capture saved temporarily: \(tempURL.path)") + + // Play sound if enabled + if SettingsManager.shared.playSoundOnCapture { + NSSound(named: "Glass")?.play() + } + + // Show preview with window title context + showPreview(image: image, windowTitle: windowTitle) + } catch { + print("โŒ Failed to save window capture: \(error)") + } + } + + private func showPreview(image: NSImage, windowTitle: String?) { + // Use existing preview logic but potentially add window title to UI + showPreview(image: image) + + // You could extend the preview UI to show window title + if let title = windowTitle { + print("๐Ÿ“ธ Window captured: \(title)") + } + } +} \ No newline at end of file diff --git a/ShotScreen/Sources/images/BannerFinder.png b/ShotScreen/Sources/images/BannerFinder.png new file mode 100644 index 0000000..5acc2d9 Binary files /dev/null and b/ShotScreen/Sources/images/BannerFinder.png differ diff --git a/ShotScreen/Sources/images/FinderBackground.png b/ShotScreen/Sources/images/FinderBackground.png new file mode 100644 index 0000000..941ae50 Binary files /dev/null and b/ShotScreen/Sources/images/FinderBackground.png differ diff --git a/ShotScreen/Sources/images/MenuIcon.png b/ShotScreen/Sources/images/MenuIcon.png new file mode 100644 index 0000000..2a9e17f Binary files /dev/null and b/ShotScreen/Sources/images/MenuIcon.png differ diff --git a/ShotScreen/Sources/images/ShotScreenIcon.png b/ShotScreen/Sources/images/ShotScreenIcon.png new file mode 100644 index 0000000..6bb282d Binary files /dev/null and b/ShotScreen/Sources/images/ShotScreenIcon.png differ diff --git a/ShotScreen/Sources/images/ShotScreenIcon_200x200.png b/ShotScreen/Sources/images/ShotScreenIcon_200x200.png new file mode 100644 index 0000000..86ef4e6 Binary files /dev/null and b/ShotScreen/Sources/images/ShotScreenIcon_200x200.png differ diff --git a/ShotScreen/Sources/images/ShotScreenIcon_600x600.png b/ShotScreen/Sources/images/ShotScreenIcon_600x600.png new file mode 100644 index 0000000..961ef35 Binary files /dev/null and b/ShotScreen/Sources/images/ShotScreenIcon_600x600.png differ diff --git a/ShotScreen/Sources/images/ShotScreenIcon_600x600_background_colour.png b/ShotScreen/Sources/images/ShotScreenIcon_600x600_background_colour.png new file mode 100644 index 0000000..39a5481 Binary files /dev/null and b/ShotScreen/Sources/images/ShotScreenIcon_600x600_background_colour.png differ diff --git a/ShotScreen/Sources/images/ShotScreen_Banner.png b/ShotScreen/Sources/images/ShotScreen_Banner.png new file mode 100644 index 0000000..10ce3c6 Binary files /dev/null and b/ShotScreen/Sources/images/ShotScreen_Banner.png differ diff --git a/ShotScreen/Sources/images/Wizard_TurnOffSceenShot.png b/ShotScreen/Sources/images/Wizard_TurnOffSceenShot.png new file mode 100644 index 0000000..4b3eff4 Binary files /dev/null and b/ShotScreen/Sources/images/Wizard_TurnOffSceenShot.png differ diff --git a/ShotScreen/Sources/main.swift b/ShotScreen/Sources/main.swift new file mode 100644 index 0000000..9fc2da4 --- /dev/null +++ b/ShotScreen/Sources/main.swift @@ -0,0 +1,3679 @@ +import AppKit +import HotKey +import Sparkle +import UniformTypeIdentifiers +import SwiftUI +import ServiceManagement +import UserNotifications + +// Grid-gerelateerde klassen en protocol +protocol GridViewManagerDelegate: AnyObject { + func gridViewManager(_ manager: GridViewManager, didDropImage imageURL: URL, ontoCell cellIndex: Int, at dropPoint: NSPoint) + func getActiveWindowForGridPositioning() -> NSWindow? // Nodig voor positionering GridWindow +} + +// Make ScreenshotApp conform to DraggableImageViewClickHandler +class ScreenshotApp: NSObject, NSApplicationDelegate, RenameActionHandlerDelegate, GridViewManagerDelegate, DraggableImageViewClickHandler, MenuManagerDelegate, PreviewManagerDelegate, GridActionManagerDelegate { + var hotKey: HotKey! + var lastImage: NSImage? + + // ๐ŸŽฏ NEW: Thumbnail restoration backup system for thumbnail restoration after settings apply + private var backupImagePath: String? + private var backupImage: NSImage? + + // NIEUW: Runtime Cmd monitoring voor all screens toggle + + var activePreviewWindow: NSWindow? // Het standaard screenshot preview venster + private var currentImageView: DraggableImageView? + var tempURL: URL? + private var isClosing = false + private var menuManager: MenuManager! // NIEUW: MenuManager instance + private var previewManager: PreviewManager! // NIEUW: PreviewManager instance + private var gridActionManager: GridActionManager! // NIEUW: GridActionManager instance + var gridViewManager: GridViewManager? + var didGridHandleDrop = false // Flag om dubbele events te voorkomen + + // ๐Ÿ”„ NEW: Flag voor stash grid action handling + var didStashGridHandleDrop = false + private var isPreviewUpdating = false + private var previewDismissTimer: Timer? + var renameActionHandler: RenameActionHandler! + var activeOverlayWindow: OverlayWindow? + private var previewWasVisibleBeforeSettingsOpen: Bool = false + let settings = SettingsManager.shared // <-- TOEGEVOEGD: SettingsManager instance + + // ๐Ÿงช NEW: Cache cleanup timer for dynamic scheduling + private var cacheCleanupTimer: Timer? + + // NIEUW: Provider voor ScreenCaptureKit (Intel Mac compatible) + let screenCaptureProvider: ScreenCaptureKitProvider? = { + if #available(macOS 12.3, *) { + return ScreenCaptureKitProvider() + } else { + print("โš ๏ธ ScreenCaptureKit requires macOS 12.3 or later (Intel Mac fallback)") + return nil + } + }() + + // Houd de window controller en de image store voor het Stash venster bij + private var stashWindowController: NSWindowController? + var activeStashImageStore: GalleryImageStore? + + // ๐Ÿ”ฅ NIEUW: Persistent stash image store voor persistent images + private var persistentStashImageStore: GalleryImageStore? + + // ๐Ÿ”„ UPDATE: Update Manager for Sparkle functionality + private let updateManager = UpdateManager.shared + private var activeSettingsWindow: SettingsWindow? // NIEUW: Referentie naar open instellingenvenster + + private let launchHelperBundleID = "com.shotscreen.launchhelper" // Definieer de Bundle ID van de helper + private var firstLaunchWizard: FirstLaunchWizard? // NIEUW: First launch wizard window + + // NIEUW: FinderWindowManager voor het bewaren en herstellen van Finder vensters + private var finderWindowManager: FinderWindowManager! + + // NIEUW: Thumbnail directory voor permanente screenshot opslag + private lazy var thumbnailDirectory: URL = { + let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let shotScreenDirectory = appSupportDirectory.appendingPathComponent("ShotScreen") + let thumbnailsDirectory = shotScreenDirectory.appendingPathComponent("Thumbnails") + + // Maak de directory aan als die niet bestaat + try? FileManager.default.createDirectory(at: thumbnailsDirectory, withIntermediateDirectories: true, attributes: nil) + + // ๐ŸŽฏ NEW: Ensure thumbnail restoration backup directory exists + let restorationDirectory = thumbnailsDirectory.appendingPathComponent("thumbnail_restoration") + try? FileManager.default.createDirectory(at: restorationDirectory, withIntermediateDirectories: true, attributes: nil) + + return thumbnailsDirectory + }() + + + // MARK: - Multi-Monitor Support Properties + var overlayWindows: [NSWindow] = [] + var windowScreenMap: [NSWindow: NSScreen] = [:] + var isDragging = false + var startPoint: NSPoint = NSZeroPoint + var currentEndPoint: NSPoint = NSZeroPoint + var globalMouseDownMonitor: Any? + var globalMouseDragMonitor: Any? + var globalMouseUpMonitor: Any? + var localMouseDownMonitor: Any? + var localMouseDragMonitor: Any? + var localMouseUpMonitor: Any? + + // MARK: - Multi-Monitor State + var isMultiMonitorSelectionActive = false + var isAllScreensCaptureToggledOn = false // NIEUW: State voor getogglede "alle schermen" modus + var lastKnownModifierFlags: NSEvent.ModifierFlags = NSEvent.ModifierFlags() // Nieuwe property voor Command-toggle + + // MARK: - Event Capture Overlay + var eventCaptureWindow: NSWindow? + + // MARK: - Performance Optimization + var lastUpdateTime: CFTimeInterval = 0 + let updateThrottle: CFTimeInterval = 1.0 / 120.0 // 120 FPS max + + // MARK: - Mouse Click Detection + var mouseDownLocation: NSPoint = NSZeroPoint + var mouseDownTime: TimeInterval = 0 + var hasMouseMoved = false + var isAllScreenModifierPressed = false + + // MARK: - Additional Multi-Monitor Properties + var globalKeyMonitor: Any? + var globalMouseMonitor: Any? + var crosshairWindows: [NSWindow] = [] + var crosshairTrackingMonitor: Any? + var eventCaptureWindows: [NSWindow] = [] + + // Reset tracking variables for clean state + func resetTrackingVariables() { + mouseDownTime = 0 + mouseDownLocation = NSZeroPoint + hasMouseMoved = false + isAllScreenModifierPressed = false + print("๐Ÿ”„ Tracking variables reset for clean state") + } + + func applicationDidFinishLaunching(_ notification: Notification) { + // ๐Ÿ”ง DEBUG: Check for command line arguments + let arguments = CommandLine.arguments + if arguments.contains("--force-debug-update") { + print("๐Ÿ”ง DEBUG: Force debug update mode detected") + performForceDebugUpdate() + return // Don't continue with normal app initialization + } + + hotKey = HotKey(key: .four, modifiers: [.command, .shift]) + hotKey.keyDownHandler = { [weak self] in + print("๐Ÿ”ฅ Hotkey pressed - launching native macOS screencapture") + self?.activateMultiMonitorSelection() + } + + renameActionHandler = RenameActionHandler(delegate: self) + + // NIEUW: Initialize MenuManager + menuManager = MenuManager(delegate: self) + menuManager.setupMenu() + + // NIEUW: Initialize PreviewManager + previewManager = PreviewManager(delegate: self) + + // NIEUW: Initialize GridActionManager + gridActionManager = GridActionManager(delegate: self) + + // NIEUW: Initialize FinderWindowManager + finderWindowManager = FinderWindowManager() + + // NIEUW: Initialize WindowCaptureManager + initializeWindowCaptureManager() + + gridViewManager = GridViewManager() + gridViewManager?.delegate = self + + // NIEUW: Use cache management system for thumbnail cleanup instead of fixed count limit + // cleanupOldThumbnails() - REMOVED: This limited to 10 files, use cache retention instead + + // MARK: - Multi-Monitor Setup + print("๐Ÿš€ Screenshot System Started with Double Hotkey Detection!") + print("๐Ÿ“ Press Cmd+Shift+4 for normal screenshot selection") + print(" ๐ŸŽฏ Press Cmd+Shift+4 TWICE (within 1 second) = capture whole screen under cursor") + print(" ๐ŸŽฏ Single press = normal selection capture") + print(" ๐ŸชŸ Spatiebalk during drag = window capture") + print(" ๐Ÿšซ ESC = cancel (no thumbnail!)") + print("๐ŸŽฏ Double hotkey = instant whole screen capture!") + + // List available screens + listAvailableScreens() + + // Setup global mouse tracking for multi-monitor support + setupGlobalMouseTracking() + + // Registreer voor setting change notificaties + registerForSettingsChanges() + + + + // Pas opstarten bij login toe op basis van instelling + toggleLaunchAtLogin(shouldLaunch: SettingsManager.shared.startAppOnLogin) + // Observeer wijzigingen in de opstartinstelling + NotificationCenter.default.addObserver(self, selector: #selector(handleStartAppOnLoginChanged), name: .startAppOnLoginSettingChanged, object: nil) + + // NIEUW: Add observers for grid actions + NotificationCenter.default.addObserver(self, selector: #selector(handleGridCancelAction), name: .gridActionCancelRequested, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleGridRemoveAction), name: .gridActionRemoveRequested, object: nil) + + // NIEUW: Check for first launch and show wizard + checkAndShowFirstLaunchWizard() + + // NIEUW: Setup initial hotkey based on settings + setupHotKey() + + // ๐Ÿ”„ NIEUW: Initialize automatic updates for direct sales + setupAutomaticUpdates() + + // ๐Ÿ—‚๏ธ NIEUW: Perform cache cleanup on startup + performStartupCacheCleanup() + + // ๐Ÿ” LICENSE: Initialize license system + initializeLicenseSystem() + } + + // VERWIJDERD: cleanupOldThumbnails() - Deze functie beperkte thumbnails tot 10 bestanden + // Nu gebruikt de app het cache retention systeem dat gebaseerd is op tijd (bijv. 1 uur) + // Alle thumbnails blijven bewaard totdat de ingestelde retention tijd verstreken is + + func registerForSettingsChanges() { + NotificationCenter.default.addObserver(self, selector: #selector(handleCloseAfterDragChanged), name: .closeAfterDragSettingChanged, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleCloseAfterSaveChanged), name: .closeAfterSaveSettingChanged, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handlePlaySoundOnCaptureChanged), name: .playSoundOnCaptureSettingChanged, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleStashAlwaysOnTopChanged), name: .stashAlwaysOnTopSettingChanged, object: nil) + // ๐Ÿ”ฅ NIEUW: Observer for persistent stash setting changes + NotificationCenter.default.addObserver(self, selector: #selector(handlePersistentStashChanged), name: .persistentStashChanged, object: nil) + // NIEUW: Observer for shortcut changes + NotificationCenter.default.addObserver(self, selector: #selector(handleShortcutSettingChanged), name: .shortcutSettingChanged, object: nil) + // NIEUW: Observer for desktop icons setting changes + NotificationCenter.default.addObserver(self, selector: #selector(handleHideDesktopIconsChanged), name: .hideDesktopIconsSettingChanged, object: nil) + // ๐Ÿงช NIEUW: Observer for cache retention setting changes + NotificationCenter.default.addObserver(self, selector: #selector(handleCacheRetentionTimeChanged), name: NSNotification.Name("cacheRetentionTimeChanged"), object: nil) + // Voeg hier observers toe voor andere settings die directe actie in ScreenshotApp vereisen + } + + @objc func handleCloseAfterDragChanged() { + print("Setting changed: closeAfterDrag is now \(SettingsManager.shared.closeAfterDrag)") + // Geen directe actie nodig hier, de waarde wordt gelezen wanneer nodig. + // Als dit de UI van de preview zou beรฏnvloeden, roep hier updatePreviewSize() aan. + } + + @objc func handleCloseAfterSaveChanged() { + print("Setting changed: closeAfterSave is now \(SettingsManager.shared.closeAfterSave)") + // Geen directe actie nodig hier. + } + + @objc func handlePlaySoundOnCaptureChanged() { + print("Setting changed: playSoundOnCapture is now \(SettingsManager.shared.playSoundOnCapture)") + // Geen directe actie nodig hier, capture() leest de waarde. + } + + // NIEUW: Handler voor wijziging opstartinstelling + @objc func handleStartAppOnLoginChanged() { + print("Setting changed: startAppOnLogin is now \(SettingsManager.shared.startAppOnLogin)") + toggleLaunchAtLogin(shouldLaunch: SettingsManager.shared.startAppOnLogin) + } + + // NIEUW: Handler voor wijziging stash always on top instelling + @objc func handleStashAlwaysOnTopChanged() { + print("Setting changed: stashAlwaysOnTop is now \(SettingsManager.shared.stashAlwaysOnTop)") + updateStashWindowLevel() + } + + // ๐Ÿ”ฅ NIEUW: Handler voor wijziging persistent stash instelling + @objc func handlePersistentStashChanged() { + print("๐Ÿ”ฅ PERSISTENT STASH: Setting changed - persistentStash is now \(SettingsManager.shared.persistentStash)") + + if !SettingsManager.shared.persistentStash { + print("๐Ÿ”ฅ PERSISTENT STASH: Feature disabled - clearing persistent image store") + persistentStashImageStore = nil + } + } + + // NIEUW: Handler voor wijziging shortcut instelling + @objc func handleShortcutSettingChanged() { + print("Setting changed: shortcut configuration updated") + setupHotKey() + } + + @objc func handleHideDesktopIconsChanged() { + print("Setting changed: hideDesktopIconsDuringScreenshot is now \(SettingsManager.shared.hideDesktopIconsDuringScreenshot)") + // Update the menu item to reflect the new state + menuManager?.refreshDesktopIconsMenuItem() + } + + // ๐Ÿงช NIEUW: Handler for cache retention time changes + @objc func handleCacheRetentionTimeChanged() { + let retentionTime = SettingsManager.shared.cacheRetentionTime + print("๐Ÿงช CACHE: Setting changed - cacheRetentionTime is now \(retentionTime.displayName)") + + // Trigger immediate cleanup to test new retention time + print("๐Ÿงช CACHE: Triggering immediate cleanup for testing...") + DispatchQueue.global(qos: .utility).async { + CacheManager.shared.cleanupOldCache() + } + + // Reschedule periodic cleanup with new interval + reschedulePeriodicCacheCleanup() + } + + // NIEUW: Setup hotkey based on current settings + private func setupHotKey() { + // Remove existing hotkeys + hotKey = nil + + let settings = SettingsManager.shared + + if settings.useCustomShortcut && settings.customShortcutModifiers != 0 && settings.customShortcutKey != 0 { + // Use custom shortcut + let modifiers = convertToHotKeyModifiers(settings.customShortcutModifiers) + if let key = convertToHotKeyKey(settings.customShortcutKey) { + hotKey = HotKey(key: key, modifiers: modifiers) + hotKey.keyDownHandler = { [weak self] in + print("๐Ÿ”ฅ Custom hotkey pressed - activating multi-monitor selection mode") + self?.activateMultiMonitorSelection() + } + print("๐ŸŽฏ Custom hotkey set: \(formatShortcutForLog(modifiers: settings.customShortcutModifiers, keyCode: settings.customShortcutKey))") + } else { + print("โŒ Invalid custom shortcut key, falling back to default") + setupDefaultHotKey() + } + } else { + // Use default Cmd+Shift+4 + setupDefaultHotKey() + } + } + + private func setupDefaultHotKey() { + hotKey = HotKey(key: .four, modifiers: [.command, .shift]) + hotKey.keyDownHandler = { [weak self] in + print("๐Ÿ”ฅ Default hotkey pressed - activating native screencapture mode") + self?.activateMultiMonitorSelection() + } + print("๐ŸŽฏ Default hotkey set: Cmd+Shift+4 (native screencapture)") + } + + // activateWindowCaptureMode function removed - use native spatiebalk instead + + private func convertToHotKeyModifiers(_ modifiers: UInt) -> NSEvent.ModifierFlags { + var result: NSEvent.ModifierFlags = [] + if modifiers & (1 << 0) != 0 { result.insert(.command) } + if modifiers & (1 << 1) != 0 { result.insert(.shift) } + if modifiers & (1 << 2) != 0 { result.insert(.option) } + if modifiers & (1 << 3) != 0 { result.insert(.control) } + return result + } + + private func convertToHotKeyKey(_ keyCode: UInt16) -> Key? { + // Map key codes to HotKey.Key enum values + 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 .one + case 19: return .two + case 20: return .three + case 21: return .four + case 22: return .six + case 23: return .five + case 25: return .nine + case 26: return .seven + case 28: return .eight + case 29: return .zero + case 31: return .o + case 32: return .u + case 34: return .i + case 35: return .p + case 37: return .l + case 38: return .j + case 40: return .k + case 45: return .n + case 46: return .m + 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 nil + } + } + + private func formatShortcutForLog(modifiers: UInt, keyCode: UInt16) -> String { + var parts: [String] = [] + + if modifiers & (1 << 3) != 0 { parts.append("Ctrl") } + if modifiers & (1 << 2) != 0 { parts.append("Opt") } + if modifiers & (1 << 1) != 0 { parts.append("Shift") } + if modifiers & (1 << 0) != 0 { parts.append("Cmd") } + + if let key = convertToHotKeyKey(keyCode) { + parts.append("\(key)") + } else { + parts.append("Key\(keyCode)") + } + + return parts.joined(separator: "+") + } + + // NIEUW: Grid action handlers + @objc func handleGridCancelAction() { + print("๐Ÿšซ Grid cancel action received via notification") + // Legacy: This is now handled by GridActionManager directly + gridViewManager?.hideGrid(monitorForReappear: false) + } + + @objc func handleGridRemoveAction() { + print("๐Ÿ—‘ Grid remove action received via notification") + // Legacy: This is now handled by GridActionManager directly + gridViewManager?.hideGrid(monitorForReappear: false) + if let temp = self.tempURL { + print("๐Ÿ—‘๏ธ Removing temp file: \(temp.path)") + try? FileManager.default.removeItem(at: temp) + self.setTempFileURL(nil) + } + closePreviewWithAnimation(immediate: true) + } + + // NIEUW: First Launch Wizard + private func checkAndShowFirstLaunchWizard() { + if !SettingsManager.shared.hasCompletedFirstLaunch { + // Show the wizard + firstLaunchWizard = FirstLaunchWizard() + firstLaunchWizard?.makeKeyAndOrderFront(nil as Any?) + // Optionally, bring to front + firstLaunchWizard?.orderFrontRegardless() + print("๐Ÿ‘‹ First launch wizard shown") + } else { + print("๐Ÿ‘‹ Welcome back! First launch already completed") + } + } + + // Function to capture the current screen based on mouse location + func captureCurrentScreen(at clickLocation: NSPoint) { + guard let screen = NSScreen.screens.first(where: { $0.frame.contains(clickLocation) }) else { + NSLog("Error: Could not determine screen for single click at \(clickLocation)") + return + } + NSLog("๐ŸŽฏ Capturing full screen: \(screen.customLocalizedName) due to single click.") + + Task { [weak self] in + guard let self = self else { return } + // Intel Mac compatible ScreenCaptureKit usage + if let provider = self.screenCaptureProvider { + let windowsToExclude = await provider.getAllWindowsToExclude() + if let image = await provider.captureScreen(screen: screen, excludingWindows: windowsToExclude) { + await MainActor.run { + self.processCapture(image: image) + } + } else { + NSLog("Error: Failed to capture full screen \(screen.customLocalizedName) using ScreenCaptureKitProvider") + } + } else { + // Fallback to native screencapture for Intel Macs without ScreenCaptureKit + print("โšก Intel Mac fallback: Using native screencapture for full screen capture") + self.captureCurrentScreenNative(screen: screen) + } + } + } + + // NIEUW: Intel Mac fallback for native screencapture of specific screen + private func captureCurrentScreenNative(screen: NSScreen) { + guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + print("โŒ Could not get screen number for native capture") + return + } + + // Create temporary file for screenshot + let tempDirectory = FileManager.default.temporaryDirectory + let tempFileName = "ShotScreen_intel_\(UUID().uuidString).png" + let tempFileURL = tempDirectory.appendingPathComponent(tempFileName) + + print("๐Ÿ“„ Intel Mac using temporary file: \(tempFileURL.path)") + + // Native screencapture for specific display (NO clipboard) + let task = Process() + task.launchPath = "/usr/sbin/screencapture" + task.arguments = ["-D", "\(screenNumber.intValue)", "-x", tempFileURL.path] // specific display to file, no sounds + + do { + try task.run() + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + task.waitUntilExit() + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + print("โœ… Intel Mac: Native screencapture completed for \(screen.customLocalizedName)") + + // Check if file was created and process it + if FileManager.default.fileExists(atPath: tempFileURL.path) { + self.processFileImage(at: tempFileURL) + } else { + print("โŒ Intel Mac: No screenshot file created") + } + } + } + } catch { + print("โŒ Intel Mac: Failed to start native screencapture: \(error)") + } + } + + // Function to capture all screens + // ๐Ÿ”ฅ NIEUW: Window capture at specific location + func captureWindowAt(location: NSPoint) { + if #available(macOS 12.3, *) { + if let windowManager = windowCaptureManager { + Task { + await windowManager.captureWindowAt(point: location) + } + } else { + print("โŒ WindowCaptureManager not available for window capture") + } + } else { + print("โš ๏ธ Window capture requires macOS 12.3 or later") + } + } + + // ๐Ÿ”„ UPDATE: Setup automatic updates using UpdateManager + @MainActor private func setupAutomaticUpdates() { + print("๐Ÿ”„ UPDATE: Setting up UpdateManager...") + updateManager.delegate = self + updateManager.printDebugInfo() + } + + // ๐Ÿ—‚๏ธ NEW: Perform cache cleanup on startup + private func performStartupCacheCleanup() { + print("๐Ÿ—‚๏ธ CACHE: Performing startup cache cleanup...") + + // Perform cleanup in background to avoid blocking startup + DispatchQueue.global(qos: .utility).async { + CacheManager.shared.cleanupOldCache() + } + + // Schedule periodic cleanup with dynamic interval + schedulePeriodicCacheCleanup() + } + + // ๐Ÿ—‚๏ธ NEW: Schedule periodic cache cleanup with dynamic interval based on retention time + private func schedulePeriodicCacheCleanup() { + let retentionTime = SettingsManager.shared.cacheRetentionTime + let cleanupInterval = getCleanupInterval(for: retentionTime) + + cacheCleanupTimer = Timer.scheduledTimer(withTimeInterval: cleanupInterval, repeats: true) { _ in + print("๐Ÿ—‚๏ธ CACHE: Performing scheduled cache cleanup...") + DispatchQueue.global(qos: .utility).async { + CacheManager.shared.cleanupOldCache() + } + } + print("๐Ÿ—‚๏ธ CACHE: Scheduled periodic cleanup every \(Int(cleanupInterval)) seconds for retention: \(retentionTime.displayName)") + } + + // ๐Ÿงช NEW: Reschedule periodic cache cleanup with new settings + private func reschedulePeriodicCacheCleanup() { + // Cancel existing timer + cacheCleanupTimer?.invalidate() + cacheCleanupTimer = nil + + // Schedule new timer with updated interval + schedulePeriodicCacheCleanup() + } + + // ๐Ÿงช NEW: Get cleanup interval based on retention time + private func getCleanupInterval(for retentionTime: CacheRetentionTime) -> TimeInterval { + switch retentionTime { + case .oneHour: + return 300 // Check every 5 minutes for 1 hour retention + case .sixHours, .twelveHours: + return 1800 // Check every 30 minutes + case .oneDay, .threeDays: + return 3600 // Check every hour + case .oneWeek, .twoWeeks, .oneMonth: + return 7200 // Check every 2 hours + case .forever: + return 86400 // Check once per day (in case setting changes) + } + } + + // ๐Ÿ”„ UPDATE: Manual update check via UpdateManager + @MainActor func checkForUpdates() { + print("๐Ÿ” UPDATE: Triggering manual update check...") + updateManager.checkForUpdates() + } + + // MARK: - Dynamic About Dialog Helper + private func getDynamicAboutDialogTitle() -> String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + return "๐ŸŽฏ About ShotScreen v\(version) (\(build)) ULTIMATE!" + } + + @MainActor func showAbout() { + print("โ„น๏ธ ABOUT: Showing about dialog...") + + let alert = NSAlert() + alert.messageText = getDynamicAboutDialogTitle() + + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown" + let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "Unknown" + + alert.informativeText = """ + Version: \(version) (Build \(buildNumber)) + + ShotScreen is a powerful screenshot utility for macOS. + + Features: + โ€ข Screenshot capture with custom regions + โ€ข Window capture + โ€ข All screens capture + โ€ข Stash for organizing screenshots + โ€ข Automatic updates via Sparkle + + ยฉ 2025 Your Company Name. All rights reserved. + """ + + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Check for Updates") + alert.alertStyle = .informational + + // Set app icon if available + if let appIcon = NSApplication.shared.applicationIconImage { + alert.icon = appIcon + } + + let response = alert.runModal() + if response == .alertSecondButtonReturn { + // User clicked "Check for Updates" + checkForUpdates() + } + } + + func captureAllScreens() { + NSLog("๐ŸŽฏ Capturing all screens.") + + // Reset de toggle na het capturen + if isAllScreensCaptureToggledOn { + isAllScreensCaptureToggledOn = false + updateAllScreensModeNotifier() // Notifier ook updaten + print("๐Ÿ”„ All Screens Capture Toggled Off after capture.") + } + + // ๐ŸŽฏ Check of desktop filtering nodig is + let needsDesktopFiltering = SettingsManager.shared.hideDesktopIconsDuringScreenshot || + SettingsManager.shared.hideDesktopWidgetsDuringScreenshot + + if needsDesktopFiltering { + // Gebruik ScreenCaptureKit voor filtering + print("๐ŸŽฏ Using ScreenCaptureKit for all screens capture (desktop filtering enabled)") + captureAllScreensWithFiltering() + } else { + // Gebruik native screencapture voor alle schermen + print("๐ŸŽฏ Using native screencapture for all screens capture") + captureAllScreensNative() + } + } + + // ๐ŸŽฏ BESTAANDE FUNCTIE: ScreenCaptureKit all screens capture + private func captureAllScreensWithFiltering() { + // Toon loading indicator voor stitching process + previewManager.showLoadingIndicator() + + Task { [weak self] in + guard let self = self else { return } + + // Intel Mac compatible ScreenCaptureKit usage + guard let provider = self.screenCaptureProvider else { + print("โšก Intel Mac fallback: ScreenCaptureKit not available, using native all screens capture") + await MainActor.run { + self.previewManager.hideLoadingIndicator() + self.captureAllScreensNative() + } + return + } + + let windowsToExclude = await provider.getAllWindowsToExclude() + + let allScreens = NSScreen.screens + guard !allScreens.isEmpty else { + NSLog("Error: No screens found to capture.") + await MainActor.run { + self.previewManager.hideLoadingIndicator() + } + return + } + + var totalBounds = CGRect.null + for screen in allScreens { + totalBounds = totalBounds.union(screen.frame) + } + NSLog("๐Ÿ–ผ๏ธ Total combined bounds for all screens: \(totalBounds)") + + var imagePartsToDraw: [(image: NSImage, rect: NSRect, screenName: String)] = [] + var allCapturedSuccessfully = true + + for screen in allScreens { + NSLog("๐Ÿ“ธ Attempting to capture screen for combined image: \(screen.customLocalizedName)") + if let imagePart = await provider.captureScreen(screen: screen, excludingWindows: windowsToExclude) { + let drawRectX = screen.frame.origin.x - totalBounds.origin.x + let drawRectY = screen.frame.origin.y - totalBounds.origin.y + let targetRect = NSRect(x: drawRectX, y: drawRectY, width: screen.frame.width, height: screen.frame.height) + imagePartsToDraw.append((image: imagePart, rect: targetRect, screenName: screen.customLocalizedName)) + } else { + NSLog("Error: Failed to capture screen \(screen.customLocalizedName) for combined image.") + allCapturedSuccessfully = false + // Optioneel: break om te stoppen bij de eerste fout + } + } + + if allCapturedSuccessfully && !imagePartsToDraw.isEmpty { + await MainActor.run { + let combinedImage = NSImage(size: totalBounds.size) + combinedImage.lockFocus() + for item in imagePartsToDraw { + item.image.draw(in: item.rect) + NSLog("๐Ÿ–ผ Drawn screen \(item.screenName) at \(item.rect) on combined image") + } + combinedImage.unlockFocus() + + // Verberg loading indicator voordat we de preview tonen + self.previewManager.hideLoadingIndicator() + self.processCapture(image: combinedImage) + } + } else if !allCapturedSuccessfully { + await MainActor.run { + NSLog("Error: Not all screens were captured successfully for the combined image.") + self.previewManager.hideLoadingIndicator() + } + } else { + await MainActor.run { + NSLog("Error: No images were captured to create a combined image.") + self.previewManager.hideLoadingIndicator() + } + } + } + } + + // ๐Ÿ”ง OUDE WERKENDE FUNCTIE: Screen-by-screen capture en combinatie (restored from working version) + private func captureAllScreensNative() { + print("๐Ÿ“ธ NATIVE all screens capture starting (using ScreenCaptureKit method)...") + print("๐Ÿ”ง BYPASSING desktop filtering for multi-click all screens") + + // ๐Ÿ”ง CRITICAL FIX: Deactivate multi-monitor selection mode IMMEDIATELY + deactivateMultiMonitorSelection() + + // Toon loading indicator voor stitching process + previewManager.showLoadingIndicator() + + Task { [weak self] in + guard let self = self else { return } + let windowsToExclude = await self.screenCaptureProvider?.getAllWindowsToExclude() ?? [] + + let allScreens = NSScreen.screens + guard !allScreens.isEmpty else { + print("โŒ Error: No screens found to capture.") + await MainActor.run { + self.previewManager.hideLoadingIndicator() + } + return + } + + var totalBounds = CGRect.null + for screen in allScreens { + totalBounds = totalBounds.union(screen.frame) + } + print("๐Ÿ–ผ๏ธ Total combined bounds for all screens: \(totalBounds)") + + var imagePartsToDraw: [(image: NSImage, rect: NSRect, screenName: String)] = [] + var allCapturedSuccessfully = true + + for screen in allScreens { + print("๐Ÿ“ธ Attempting to capture screen for combined image: \(screen.customLocalizedName)") + if let imagePart = await self.screenCaptureProvider?.captureScreen(screen: screen, excludingWindows: windowsToExclude) { + let drawRectX = screen.frame.origin.x - totalBounds.origin.x + let drawRectY = screen.frame.origin.y - totalBounds.origin.y + let targetRect = NSRect(x: drawRectX, y: drawRectY, width: screen.frame.width, height: screen.frame.height) + imagePartsToDraw.append((image: imagePart, rect: targetRect, screenName: screen.customLocalizedName)) + } else { + print("โŒ Error: Failed to capture screen \(screen.customLocalizedName) for combined image.") + allCapturedSuccessfully = false + // Optioneel: break om te stoppen bij de eerste fout + } + } + + if allCapturedSuccessfully && !imagePartsToDraw.isEmpty { + await MainActor.run { + let combinedImage = NSImage(size: totalBounds.size) + combinedImage.lockFocus() + for item in imagePartsToDraw { + item.image.draw(in: item.rect) + print("๐Ÿ–ผ Drawn screen \(item.screenName) at \(item.rect) on combined image") + } + combinedImage.unlockFocus() + + // Verberg loading indicator voordat we de preview tonen + self.previewManager.hideLoadingIndicator() + self.processCapture(image: combinedImage) + } + } else if !allCapturedSuccessfully { + await MainActor.run { + print("โŒ Error: Not all screens were captured successfully for the combined image.") + self.previewManager.hideLoadingIndicator() + } + } else { + await MainActor.run { + print("โŒ Error: No images were captured to create a combined image.") + self.previewManager.hideLoadingIndicator() + } + } + } + } + + // NIEUW: Methode om de notifier voor "alle schermen" modus bij te werken (placeholder) + func updateAllScreensModeNotifier() { + let isActive = isAllScreensCaptureToggledOn + print("๐Ÿ”” Notifier update: All Screens Mode is \(isActive ? "Active" : "Inactive")") + + for window in eventCaptureWindows { + // Update CrosshairCursorView (voor de blauwe cirkel) + if let contentView = window.contentView { + for subview in contentView.subviews { + if let crosshairView = subview as? CrosshairCursorView { + crosshairView.currentMode = isActive ? .allScreensActive : .normal + // crosshairView.needsDisplay = true // needsDisplay wordt al in didSet van currentMode afgehandeld + } + // Update EventCaptureView (voor de tekst-overlay) + if let eventCaptureView = subview as? EventCaptureView { + eventCaptureView.shouldDisplayAllScreensActiveText = isActive + eventCaptureView.needsDisplay = true + } + } + } + } + } + + // NIEUW: Reset de toggle expliciet, bijvoorbeeld bij het annuleren van een selectie + func resetAllScreensCaptureToggle() { + if isAllScreensCaptureToggledOn { + isAllScreensCaptureToggledOn = false + updateAllScreensModeNotifier() + print("๐Ÿ”„ All Screens Capture Toggled Off explicitly (e.g., selection cancelled).") + } + } + + // NIEUW: Selector method for wizard close notification + @objc private func firstLaunchWizardDidClose(_ notification: Notification) { + print("๐ŸŽ‰ First launch wizard closed") + if let wizard = notification.object as? FirstLaunchWizard { + // Remove the observer for this specific wizard + NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: wizard) + } + firstLaunchWizard = nil + } + + // NIEUW: Reset first launch wizard (for testing) + @objc func resetFirstLaunchWizardInternal() { + print("๐Ÿ”„ Resetting first launch wizard for testing") + SettingsManager.shared.hasCompletedFirstLaunch = false + + // FIXED: Better memory management - check if wizard still exists + if let existingWizard = firstLaunchWizard { + print("๐Ÿ—‘๏ธ Closing existing wizard before showing new one") + // Remove observer first to prevent issues + NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: existingWizard) + // Close the wizard safely + if existingWizard.isVisible { + existingWizard.close() + } + // Clear the reference immediately + firstLaunchWizard = nil + } else { + print("โ„น๏ธ No existing wizard to close") + } + + // Add small delay to ensure cleanup is complete before creating new wizard + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard let self = self else { return } + print("๐ŸŽ‰ Showing wizard after reset delay") + self.checkAndShowFirstLaunchWizard() + } + } + + // NIEUW: Show Stash via menu + @objc func showStash(_ sender: Any?) { + print("๐Ÿ—‚๏ธ Show Stash menu item selected") + + // Controleer of er al een stash window open is + if let existingController = stashWindowController, + let existingWindow = existingController.window, existingWindow.isVisible { + print("๐Ÿ—‚๏ธ Stash window already open. Bringing to front.") + existingWindow.orderFrontRegardless() + existingWindow.makeKeyAndOrderFront(nil as Any?) + NSApp.activate(ignoringOtherApps: true) + return + } + + // ๐Ÿ”ฅ NIEUW: Load persistent stash images if available + print("๐Ÿ—‚๏ธ Creating new Stash window") + let newImageStore = GalleryImageStore() + + // Check for persistent stash images + if SettingsManager.shared.persistentStash, + let persistentStore = persistentStashImageStore, + persistentStore.images.count > 0 { + print("๐Ÿ”ฅ PERSISTENT STASH: Loading \(persistentStore.images.count) saved images") + newImageStore.images = persistentStore.images + } else { + print("๐Ÿ”ฅ PERSISTENT STASH: No saved images or persistent stash disabled - starting empty") + } + + let initialStashWidth: CGFloat = 200 + let initialStashHeight: CGFloat = 300 + let newWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: initialStashWidth, height: initialStashHeight), + styleMask: [.borderless, .closable, .fullSizeContentView], + backing: .buffered, defer: false) + + let closeWindowAction = { [weak newWindow] in + guard let windowToClose = newWindow else { return } + windowToClose.close() + } + + let galleryView = IntegratedGalleryView(imageStore: newImageStore, initialImage: nil, hostingWindow: newWindow, closeAction: closeWindowAction) + let hostingView = NSHostingView(rootView: galleryView) + + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.hasShadow = true + newWindow.titleVisibility = .hidden + newWindow.titlebarAppearsTransparent = true + newWindow.isMovable = false + + let rootContentView = NSView(frame: newWindow.contentRect(forFrameRect: newWindow.frame)) + rootContentView.wantsLayer = true + rootContentView.layer?.cornerRadius = 12 + rootContentView.layer?.masksToBounds = true + + let visualEffectView = NSVisualEffectView() + visualEffectView.blendingMode = .behindWindow + visualEffectView.material = .hudWindow + visualEffectView.state = .active + visualEffectView.autoresizingMask = [.width, .height] + visualEffectView.frame = rootContentView.bounds + visualEffectView.wantsLayer = true + visualEffectView.layer?.cornerRadius = 12 + visualEffectView.layer?.masksToBounds = true + + let extraBlurView = NSVisualEffectView() + extraBlurView.blendingMode = .behindWindow + extraBlurView.material = .hudWindow + extraBlurView.state = .active + extraBlurView.alphaValue = 0.6 + extraBlurView.autoresizingMask = [.width, .height] + extraBlurView.frame = rootContentView.bounds + + rootContentView.addSubview(visualEffectView) + rootContentView.addSubview(extraBlurView) + hostingView.translatesAutoresizingMaskIntoConstraints = false + rootContentView.addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: rootContentView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: rootContentView.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: rootContentView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: rootContentView.trailingAnchor) + ]) + + newWindow.contentView = rootContentView + + // FIXED: Position stash window on the same screen as the main thumbnail + let targetScreen = self.getTargetScreenForStashPreview() ?? NSScreen.main ?? NSScreen.screens.first + + if let screenToDisplayOn = targetScreen { + let targetY = (screenToDisplayOn.visibleFrame.height - initialStashHeight) / 2 + screenToDisplayOn.visibleFrame.origin.y + let spacing: CGFloat = 20 + let targetX = screenToDisplayOn.visibleFrame.maxX - initialStashWidth - spacing + newWindow.setFrameOrigin(NSPoint(x: targetX, y: targetY)) + print("๐ŸชŸ Stash window positioned on screen: \(screenToDisplayOn.customLocalizedName) at {\(targetX), \(targetY)}") + } else { + newWindow.center() // Fallback + print("๐ŸชŸ Stash window centered (could not determine target screen).") + } + + newWindow.delegate = self + let newWindowController = NSWindowController(window: newWindow) + newWindowController.showWindow(self) + self.stashWindowController = newWindowController + self.activeStashImageStore = newImageStore + + // Set window level based on always on top setting + if SettingsManager.shared.stashAlwaysOnTop { + newWindow.level = .floating + print("๐Ÿ” New empty stash window created with always on top") + } else { + newWindow.level = .normal + print("๐Ÿ“‹ New empty stash window created with normal level") + } + + newWindow.makeKeyAndOrderFront(nil as Any?) + NSApp.activate(ignoringOtherApps: true) + + // ๐Ÿ”ฅ NIEUW: Shake animatie ook voor Menu "Show Stash" windows! + print("๐ŸŽ‰ Adding MENU stash shake animation!") + addSubtleShakeAnimation(to: newWindow) + + print("๐Ÿ—‚๏ธ Empty Stash window created and shown") + } + + // NIEUW: Update stash window level based on setting + func updateStashWindowLevel() { + guard let stashWindow = stashWindowController?.window else { return } + + if SettingsManager.shared.stashAlwaysOnTop { + stashWindow.level = .floating + print("๐Ÿ” Stash window set to always on top") + } else { + stashWindow.level = .normal + print("๐Ÿ“‹ Stash window set to normal level") + } + } + + func applicationWillTerminate(_ notification: Notification) { + print("๐Ÿ” DEBUG: applicationWillTerminate called") + + previewDismissTimer?.invalidate() + previewDismissTimer = nil + + // NIEUW: Cleanup hotkey reset timer + hotkeyResetTimer?.invalidate() + hotkeyResetTimer = nil + + // ๐Ÿ”ง FIX: Cleanup double-click action timer + doubleClickActionTimer?.invalidate() + doubleClickActionTimer = nil + + // ๐Ÿงช NIEUW: Cleanup cache cleanup timer + cacheCleanupTimer?.invalidate() + cacheCleanupTimer = nil + + // NIEUW: Cleanup FirstLaunchWizard properly + if let wizard = firstLaunchWizard { + NotificationCenter.default.removeObserver(self, name: NSWindow.willCloseNotification, object: wizard) + wizard.close() + firstLaunchWizard = nil + } + + NotificationCenter.default.removeObserver(self) // Verwijder alle observers + + // MARK: - Multi-Monitor Cleanup + cleanupMultiMonitorResources() + + // NIEUW: Cleanup MenuManager + menuManager?.cleanup() + + // NIEUW: Cleanup PreviewManager + previewManager?.cleanup() + + // NIEUW: Cleanup FinderWindowManager + finderWindowManager?.forceCleanup() + } + + // MARK: - Menu functions moved to MenuManager.swift + + func activateMultiMonitorSelection() { + // Sluit een eventueel openstaand rename panel voordat een nieuwe selectie start + renameActionHandler.closeRenamePanelAndCleanup() + + // NIEUW: Reset de "alle schermen" toggle bij het starten van een nieuwe selectie + resetAllScreensCaptureToggle() + + // NIEUW: Reset isDragging state voor een schone start + self.isDragging = false + + // NIEUW: Sluit ook een eventueel bestaande activePreviewWindow direct + if let existingPreview = self.activePreviewWindow, existingPreview.isVisible { + print("๐Ÿ”ต Closing existing activePreviewWindow before starting new selection.") + self.closePreviewWithAnimation(immediate: true, preserveTempFile: false) + } + + // NIEUW: Ruim een eventueel bestaande actieve overlay op + if let existingOverlay = activeOverlayWindow, existingOverlay.isVisible { + print("๐Ÿ”ต Closing existing active overlay window before starting new selection.") + existingOverlay.orderOut(nil as Any?) + activeOverlayWindow = nil + } + + // ๐ŸŽฏ NIEUWE OPLOSSING: Gebruik native macOS screencapture in plaats van complexe crosshair + triggerNativeScreencapture() + } + + // ๐ŸŽฏ VASTE OPLOSSING: Multi-hotkey tracking met correcte timing + private var hotkeyPressCount: Int = 0 + private var firstHotkeyPressTime: Date = Date.distantPast + private var hotkeyResetTimer: Timer? + private let multiHotkeyTimeWindow: TimeInterval = 1.5 // 1.5 seconde venster voor betrouwbare multi-click detectie + + // ๐Ÿ”ง FIX: Remember mouse location from first hotkey press for accurate multi-screen detection + private var rememberedMouseLocation: NSPoint = NSPoint.zero + + private func triggerNativeScreencapture() { + let now = Date() + let timeSinceFirstPress = now.timeIntervalSince(firstHotkeyPressTime) + + // Reset counter als te veel tijd verstreken is + if timeSinceFirstPress > multiHotkeyTimeWindow { + hotkeyPressCount = 0 + firstHotkeyPressTime = now + } + + // Verhoog counter + hotkeyPressCount += 1 + + // Als dit de eerste press is, stel eerste tijd in en onthoud mouse location + if hotkeyPressCount == 1 { + firstHotkeyPressTime = now + // ๐Ÿ”ง FIX: Remember mouse location from first press for accurate multi-screen detection + rememberedMouseLocation = NSEvent.mouseLocation + print("๐Ÿ–ฑ๏ธ Remembered mouse location for multi-click: \(rememberedMouseLocation)") + } + + print("๐ŸŽฏ Hotkey press count: \(hotkeyPressCount), time since first: \(timeSinceFirstPress)") + + // ๐Ÿ”ง FIX: Multi-click detection EERST - boven desktop filtering check + // Cancel any existing reset timer + hotkeyResetTimer?.invalidate() + + if hotkeyPressCount == 3 { + print("๐ŸŽฏ๐ŸŽฏ๐ŸŽฏ TRIPLE hotkey press detected - capturing ALL SCREENS!") + print("๐Ÿ”ง Triple hotkey bypasses desktop filtering settings - using NATIVE mode") + // ๐Ÿ”ง CRITICAL FIX: Deactivate multi-monitor selection mode IMMEDIATELY + deactivateMultiMonitorSelection() + // ๐Ÿ”ง FIX: KILL active screencapture immediately to remove crosshair + killActiveScreencapture() + // ๐Ÿ”ง FIX: Cancel any pending double-click action + doubleClickActionTimer?.invalidate() + doubleClickActionTimer = nil + print("๐Ÿ”ง Cancelled pending double-click action due to triple-click") + // ๐Ÿ”ง FIX: Delay reset to allow for any rapid subsequent clicks + scheduleDelayedReset() + captureAllScreensNative() + return + } else if hotkeyPressCount == 2 { + print("๐ŸŽฏ๐ŸŽฏ Double hotkey press detected - capturing whole screen under cursor!") + print("๐Ÿ”ง Double hotkey bypasses desktop filtering settings - using NATIVE mode") + print("๐Ÿ”ง Waiting for potential 3rd click before executing...") + // ๐Ÿ”ง CRITICAL FIX: Deactivate multi-monitor selection mode IMMEDIATELY + deactivateMultiMonitorSelection() + // ๐Ÿ”ง FIX: KILL active screencapture immediately to remove crosshair + killActiveScreencapture() + // ๐Ÿ”ง FIX: Schedule 2x action with delay to allow 3rd click to interrupt + scheduleDelayedDoubleClickAction() + return + } + + // ๐Ÿ”ง First press only: Check desktop filtering for single press behavior + // Start timer to reset if no second press comes + startHotkeyResetTimer() + + // ๐ŸŽฏ HYBRIDE SYSTEEM: Check of desktop filtering nodig is (alleen voor eerste press) + let needsDesktopFiltering = SettingsManager.shared.hideDesktopIconsDuringScreenshot || + SettingsManager.shared.hideDesktopWidgetsDuringScreenshot + + if needsDesktopFiltering { + // Gebruik ScreenCaptureKit interface voor filtering + print("๐ŸŽฏ Desktop filtering enabled - using ScreenCaptureKit selection mode") + print("๐Ÿ‘† Press Cmd+Shift+4 again within 1.2 seconds for whole screen capture") + print("๐Ÿ‘†๐Ÿ‘† Press Cmd+Shift+4 THREE times for all screens capture!") + print("๐ŸŽฏ Use drag selection for area capture (with desktop filtering)") + + continueActivateMultiMonitorSelection() + } else { + // Gebruik native screencapture voor normale macOS ervaring + print("๐ŸŽฏ Normal mode - using native macOS screencapture with crosshair cursor") + print("๐Ÿ‘† Press Cmd+Shift+4 again within 1.2 seconds for whole screen capture") + print("๐Ÿ‘†๐Ÿ‘† Press Cmd+Shift+4 THREE times for all screens capture!") + print("๐ŸŽฏ Use normal drag selection with native crosshair") + + startNativeScreencapture() + } + } + + // ๐ŸŽฏ NIEUWE FUNCTIE: Start native macOS screencapture + private func startNativeScreencapture() { + // Create temporary file for screenshot + let tempDirectory = FileManager.default.temporaryDirectory + let tempFileName = "ShotScreen_temp_\(UUID().uuidString).png" + let tempFileURL = tempDirectory.appendingPathComponent(tempFileName) + + print("๐Ÿ“„ Using temporary file: \(tempFileURL.path)") + + // Start screencapture to file (NO clipboard) + let task = Process() + task.launchPath = "/usr/sbin/screencapture" + task.arguments = ["-i", "-x", tempFileURL.path] // interactive, no sounds, save to file + + do { + try task.run() + + // Wait for screencapture completion in background + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + task.waitUntilExit() + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Check if file was created (indicates successful capture) + if FileManager.default.fileExists(atPath: tempFileURL.path) { + print("โœ… Native screenshot saved to file - processing...") + self.processFileImage(at: tempFileURL) + } else { + print("๐Ÿšซ No screenshot file - user cancelled or no selection made") + } + } + } + } catch { + print("โŒ Failed to start screencapture: \(error)") + } + } + + // ๐ŸŽฏ NIEUWE FUNCTIE: Process file image (no clipboard pollution) + private func processFileImage(at fileURL: URL) { + if let image = NSImage(contentsOf: fileURL) { + print("โœ… Successfully loaded image from file") + processCapture(image: image) + + // Clean up temporary file + try? FileManager.default.removeItem(at: fileURL) + print("๐Ÿ—‘๏ธ Temporary screenshot file cleaned up") + } else { + print("โš ๏ธ Failed to load image from file") + // Clean up failed file attempt + try? FileManager.default.removeItem(at: fileURL) + } + } + + // ๐ŸŽฏ LEGACY FUNCTIE: Process clipboard image (for fallback compatibility) + private func processClipboardImage() { + let pasteboard = NSPasteboard.general + + if let image = NSImage(pasteboard: pasteboard) { + print("โœ… Successfully captured image from clipboard") + processCapture(image: image) + } else { + print("โš ๏ธ No image found in clipboard - user may have cancelled") + } + } + + // NIEUWE FUNCTIE: Capture whole screen onder cursor + private func captureWholeScreenUnderCursor() { + let mouseLocation = NSEvent.mouseLocation + print("๐Ÿ–ฑ๏ธ Mouse location: \(mouseLocation)") + + // ๐Ÿ”ง CRITICAL FIX: Kill active screencapture immediately to remove crosshair + killActiveScreencapture() + + if let targetScreen = findScreenContaining(point: mouseLocation) { + print("๐Ÿ–ฅ๏ธ Found target screen: \(targetScreen.customLocalizedName)") + print("๐Ÿ“ธ Capturing whole screen...") + + // ๐ŸŽฏ Check of desktop filtering nodig is + let needsDesktopFiltering = SettingsManager.shared.hideDesktopIconsDuringScreenshot || + SettingsManager.shared.hideDesktopWidgetsDuringScreenshot + + if needsDesktopFiltering { + // Gebruik ScreenCaptureKit voor filtering + print("๐ŸŽฏ Using ScreenCaptureKit for whole screen capture (desktop filtering enabled)") + Task { [weak self] in + guard let self = self else { return } + + // Intel Mac compatible ScreenCaptureKit usage + if let provider = self.screenCaptureProvider { + let windowsToExclude = await provider.getAllWindowsToExclude() + + if let image = await provider.captureScreen(screen: targetScreen, excludingWindows: windowsToExclude) { + await MainActor.run { + print("โœ… Whole screen captured successfully with filtering!") + self.processCapture(image: image) + } + } else { + print("โŒ Failed to capture whole screen with filtering") + } + } else { + // Fallback to native screencapture for Intel Macs without ScreenCaptureKit + print("โšก Intel Mac fallback: Using native screencapture for whole screen") + await MainActor.run { + self.captureWholeScreenNative(screen: targetScreen) + } + } + } + } else { + // Gebruik native screencapture voor hele scherm + print("๐ŸŽฏ Using native screencapture for whole screen capture") + captureWholeScreenNative(screen: targetScreen) + } + } else { + print("โŒ No target screen found for location: \(mouseLocation)") + } + } + + // ๐Ÿ”ง NIEUWE MULTI-CLICK FUNCTIE: Direct native whole screen capture (bypasses desktop filtering) + private func captureWholeScreenNativeMultiClick() { + print("๐ŸŽฏ DEBUG: captureWholeScreenNativeMultiClick CALLED!") + + // ๐Ÿ”ง CRITICAL FIX: Deactivate multi-monitor selection mode IMMEDIATELY + deactivateMultiMonitorSelection() + + // ๐Ÿ”ง FIX: Use remembered mouse location from first hotkey press for accurate multi-screen detection + let mouseLocation = rememberedMouseLocation + print("๐Ÿ–ฑ๏ธ Multi-click using REMEMBERED mouse location: \(mouseLocation)") + print("๐Ÿ”ง Current mouse location would be: \(NSEvent.mouseLocation)") + + // ๐Ÿ”ง CRITICAL FIX: Kill active screencapture immediately to remove crosshair + killActiveScreencapture() + + if let targetScreen = findScreenContaining(point: mouseLocation) { + print("๐Ÿ–ฅ๏ธ Multi-click found target screen: \(targetScreen.customLocalizedName)") + print("๐Ÿ“ธ Multi-click capturing whole screen DIRECTLY with native screencapture...") + + // ๐Ÿ”ง DIRECT NATIVE - NO DESKTOP FILTERING CHECK + captureWholeScreenNative(screen: targetScreen) + } else { + print("โŒ Multi-click: No target screen found for REMEMBERED location: \(mouseLocation)") + print("โš ๏ธ Falling back to main screen for multi-click capture") + // Fallback to main screen if remembered location doesn't work + if let mainScreen = NSScreen.main { + captureWholeScreenNative(screen: mainScreen) + } + } + } + + // ๐ŸŽฏ NIEUWE FUNCTIE: Native whole screen capture + private func captureWholeScreenNative(screen: NSScreen) { + let displayID = screen.displayID + + // ๐Ÿ”ง FIX: Get screen index for screencapture (1-based) instead of display ID + guard let screenIndex = NSScreen.screens.firstIndex(of: screen) else { + print("โŒ Could not find screen index for \(screen.customLocalizedName)") + return + } + let screencaptureDisplayNumber = screenIndex + 1 // screencapture is 1-based + + print("๐ŸŽฏ DEBUG: captureWholeScreenNative called") + print(" Screen: \(screen.customLocalizedName)") + print(" Frame: \(screen.frame)") + print(" Display ID: \(displayID)") + print(" Screen Index: \(screenIndex)") + print(" Screencapture Display Number: \(screencaptureDisplayNumber)") + + // Create temporary file for screenshot + let tempDirectory = FileManager.default.temporaryDirectory + let tempFileName = "ShotScreen_wholescreen_\(UUID().uuidString).png" + let tempFileURL = tempDirectory.appendingPathComponent(tempFileName) + + print("๐Ÿ“„ Using temporary file: \(tempFileURL.path)") + + // Start screencapture for specific display (NO clipboard) + let task = Process() + task.launchPath = "/usr/sbin/screencapture" + task.arguments = ["-D", "\(screencaptureDisplayNumber)", "-x", tempFileURL.path] // ๐Ÿ”ง FIX: No clipboard, save to file + + print("๐Ÿ”ง DEBUG: Starting screencapture with arguments: \(task.arguments ?? [])") + + do { + try task.run() + + // Wait for completion + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + task.waitUntilExit() + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Check if file was created (indicates successful capture) + if FileManager.default.fileExists(atPath: tempFileURL.path) { + print("โœ… Native whole screen captured successfully!") + self.processFileImage(at: tempFileURL) + } else { + print("โŒ No whole screen screenshot file created") + } + } + } + } catch { + print("โŒ Failed to start whole screen screencapture: \(error)") + } + } + + + // NIEUWE FUNCTIE: Vind scherm waar punt zich bevindt + private func findScreenContaining(point: NSPoint) -> NSScreen? { + print("๐Ÿ” DEBUG: Looking for screen containing point: \(point)") + print("๐Ÿ” DEBUG: Available screens:") + for (index, screen) in NSScreen.screens.enumerated() { + print(" Screen \(index): \(screen.customLocalizedName) - Frame: \(screen.frame)") + if screen.frame.contains(point) { + print(" โœ… MATCH! Screen \(index) contains the point") + return screen + } else { + print(" โŒ Screen \(index) does NOT contain the point") + } + } + print("โš ๏ธ DEBUG: No screen found containing point, falling back to main screen") + return NSScreen.main // fallback naar main screen + } + + // ๐Ÿ”ง CRITICAL FUNCTION: Kill active screencapture to remove crosshair cursor + private func killActiveScreencapture() { + print("โšก Killing active screencapture to remove crosshair cursor...") + let killTask = Process() + killTask.launchPath = "/usr/bin/killall" + killTask.arguments = ["screencapture"] + + do { + try killTask.run() + killTask.waitUntilExit() + print("โšก Active screencapture killed, crosshair cursor should be restored to normal") + } catch { + print("โš ๏ธ Failed to kill screencapture: \(error)") + } + } + + // NIEUWE FUNCTIES: Hotkey counter management + private func resetHotkeyCounter() { + hotkeyPressCount = 0 + firstHotkeyPressTime = Date.distantPast + hotkeyResetTimer?.invalidate() + hotkeyResetTimer = nil + // ๐Ÿ”ง FIX: Also cleanup double-click action timer + doubleClickActionTimer?.invalidate() + doubleClickActionTimer = nil + // ๐Ÿ”ง FIX: Reset remembered mouse location + rememberedMouseLocation = NSPoint.zero + print("๐Ÿ”„ Hotkey counter reset") + } + + private func startHotkeyResetTimer() { + hotkeyResetTimer?.invalidate() + hotkeyResetTimer = Timer.scheduledTimer(withTimeInterval: multiHotkeyTimeWindow, repeats: false) { [weak self] _ in + self?.resetHotkeyCounter() + } + } + + // ๐Ÿ”ง NEW: Schedule delayed reset to allow rapid multi-clicks + private func scheduleDelayedReset() { + hotkeyResetTimer?.invalidate() + hotkeyResetTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) { [weak self] _ in + self?.resetHotkeyCounter() + print("๐Ÿ”„ Delayed hotkey counter reset after multi-click action") + } + } + + // ๐Ÿ”ง NEW: Timer for delayed double-click action + private var doubleClickActionTimer: Timer? + + // ๐Ÿ”ง NEW: Schedule delayed double-click action to allow 3rd click to interrupt + private func scheduleDelayedDoubleClickAction() { + print("โฒ๏ธ DEBUG: scheduleDelayedDoubleClickAction CALLED!") + doubleClickActionTimer?.invalidate() + doubleClickActionTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { [weak self] _ in + guard let self = self else { return } + print("๐ŸŽฏ๐ŸŽฏ DEBUG: Executing delayed double-click action (no 3rd click detected)") + self.captureWholeScreenNativeMultiClick() + self.scheduleDelayedReset() + } + } + + + + + + + + + private func continueActivateMultiMonitorSelection() { + // VERWIJDERD: Herstel Finder vensters - niet meer nodig zonder Finder restart + // if SettingsManager.shared.cleanDesktopScreenshots { + // finderWindowManager.restoreFinderWindows() + // } + + // Hide any existing multi-monitor overlay windows + hideMultiMonitorOverlayWindows() + + // Create event capture window to intercept all mouse events + createEventCaptureWindow() + + // Activate multi-monitor selection mode + isMultiMonitorSelectionActive = true + + // Hide system cursor and setup custom crosshair overlay + hideCursor() + setupCustomCrosshairOverlay() + + // Add global key monitor for ESC key + setupGlobalKeyMonitor() + + print("๐Ÿš€ Multi-monitor selection mode activated!") + print("๐ŸŽฏ Click and drag across multiple monitors for seamless selection") + print("๐Ÿ”ง Press ESC or right-click to cancel selection") + } + + func beginSelection() { + // Legacy method - redirect to new multi-monitor system + activateMultiMonitorSelection() + } + + func capture(rect: NSRect, isMultiMonitor: Bool = false) { + print("๐Ÿ“ธ capture gestart") + print("๐Ÿ“ Capture gebied: x=\(rect.origin.x), y=\(rect.origin.y), width=\(rect.width), height=\(rect.height)") + + // ๐Ÿ”ง CRITICAL FIX: Kill any active crosshair cursor immediately when capture starts + killActiveScreencapture() + + // ๐Ÿ”ง CRITICAL FIX: Deactivate multi-monitor selection mode to remove custom crosshair overlay + deactivateMultiMonitorSelection() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + guard let self = self else { return } + + if isMultiMonitor { + // Use specialized multi-monitor capture + self.captureMultiMonitorRegion(rect: rect) + } else { + // Use traditional single-screen capture + self.captureSingleScreen(rect: rect) + } + } + } + + private func captureSingleScreen(rect: NSRect) { + guard let screen = NSScreen.main else { return } + + NSLog("๐ŸŽฏ Single-screen capture (main screen selection): \(rect)") + + Task { [weak self] in + guard let self = self else { return } + + // Intel Mac compatible ScreenCaptureKit usage + if let provider = self.screenCaptureProvider { + let windowsToExclude = await provider.getAllWindowsToExclude() + let selectionCGRect = CGRect(x: rect.origin.x, y: rect.origin.y, width: rect.width, height: rect.height) + + if let image = await provider.captureSelection(selectionRectInPoints: selectionCGRect, screen: screen, excludingWindows: windowsToExclude) { + await MainActor.run { + self.processCapture(image: image) + } + } else { + NSLog("Error: Failed to capture main screen selection using ScreenCaptureKitProvider") + } + } else { + // Fallback to native screencapture for Intel Macs without ScreenCaptureKit + print("โšก Intel Mac fallback: Single screen selection capture not supported - using full screen") + await MainActor.run { + self.captureCurrentScreenNative(screen: screen) + } + } + } + } + + // Helper function to flip image vertically (from display coordinates to AppKit coordinates) + private func flipImageVertically(_ image: NSImage) -> NSImage { + let flippedImage = NSImage(size: image.size) + flippedImage.lockFocus() + + // Create a transform that flips vertically + let transform = NSAffineTransform() + transform.translateX(by: 0, yBy: image.size.height) + transform.scaleX(by: 1.0, yBy: -1.0) + transform.concat() + + // Draw the original image with the transform applied + image.draw(at: .zero, from: .zero, operation: .sourceOver, fraction: 1.0) + + flippedImage.unlockFocus() + return flippedImage + } + + private func captureMultiMonitorRegion(rect: NSRect) { + print("๐Ÿ”„ Multi-monitor capture: analyzing screen intersections") + + // Find all screens that intersect with the capture region + let intersectingScreens = NSScreen.screens.compactMap { screen -> (screen: NSScreen, intersection: NSRect)? in + let intersection = rect.intersection(screen.frame) + if intersection.width > 1 && intersection.height > 1 { + return (screen: screen, intersection: intersection) + } + return nil + }.sorted { first, second in + // Sort by Y coordinate (higher Y = higher on screen = should be at top of final image) + return first.intersection.midY > second.intersection.midY + } + + print("๐Ÿ“บ Found \(intersectingScreens.count) intersecting screens:") + for (index, screenInfo) in intersectingScreens.enumerated() { + print(" \(index): \(screenInfo.screen.customLocalizedName) - intersection: \(screenInfo.intersection)") // GEWIJZIGD + } + + if intersectingScreens.isEmpty { + print("โŒ No screens intersect with capture region") + return + } + + if intersectingScreens.count == 1 { + // Single screen, capture directly + let screenInfo = intersectingScreens[0] + self.captureSingleScreenRegion(screen: screenInfo.screen, region: screenInfo.intersection) + } else { + // Multiple screens, capture each and combine + self.captureAndCombineScreens(intersectingScreens: intersectingScreens, totalRect: rect) + } + } + + private func captureSingleScreenRegion(screen: NSScreen, region: NSRect) { + NSLog("๐Ÿ“ธ Capturing single screen region on \(screen.customLocalizedName), region: \(region)") + + Task { [weak self] in + guard let self = self else { return } + + // Intel Mac compatible ScreenCaptureKit usage + if let provider = self.screenCaptureProvider { + let windowsToExclude = await provider.getAllWindowsToExclude() + let selectionCGRect = CGRect(x: region.origin.x, y: region.origin.y, width: region.width, height: region.height) + + if let image = await provider.captureSelection(selectionRectInPoints: selectionCGRect, screen: screen, excludingWindows: windowsToExclude) { + await MainActor.run { + self.processCapture(image: image) + } + } else { + NSLog("Error: Failed to capture screen region on \(screen.customLocalizedName) using ScreenCaptureKitProvider") + } + } else { + // Fallback to native screencapture for Intel Macs without ScreenCaptureKit + print("โšก Intel Mac fallback: Single screen region capture not supported - using full screen") + await MainActor.run { + self.captureCurrentScreenNative(screen: screen) + } + } + } + } + + private func captureAndCombineScreens(intersectingScreens: [(screen: NSScreen, intersection: NSRect)], totalRect: NSRect) { + NSLog("๐Ÿ”„ Capturing and combining \(intersectingScreens.count) screens for totalRect: \(totalRect)") + + let canvasSize = NSSize(width: totalRect.width, height: totalRect.height) + + // Show loading indicator for multi-screen stitching + previewManager.showLoadingIndicator() + + Task { [weak self] in + guard let self = self else { return } + + let combinedImage = await self.createCombinedImage(canvasSize: canvasSize) + await self.captureAndStitchScreenParts(intersectingScreens: intersectingScreens, + totalRect: totalRect, + combinedImage: combinedImage) + } + } + + // MARK: - Private Helper Methods for Multi-Screen Capture + + private func createCombinedImage(canvasSize: NSSize) async -> NSImage { + return await MainActor.run { NSImage(size: canvasSize) } + } + + private func captureAndStitchScreenParts(intersectingScreens: [(screen: NSScreen, intersection: NSRect)], + totalRect: NSRect, + combinedImage: NSImage) async { + var allPartsCaptured = true + + for screenInfo in intersectingScreens { + let captureSuccessful = await captureScreenPart(screenInfo: screenInfo, + totalRect: totalRect, + combinedImage: combinedImage) + if !captureSuccessful { + allPartsCaptured = false + break + } + } + + await handleCombinedCaptureResult(allPartsCaptured: allPartsCaptured, + intersectingScreens: intersectingScreens, + combinedImage: combinedImage) + } + + private func captureScreenPart(screenInfo: (screen: NSScreen, intersection: NSRect), + totalRect: NSRect, + combinedImage: NSImage) async -> Bool { + let screen = screenInfo.screen + let intersection = screenInfo.intersection + + NSLog("๐Ÿ“ธ Capturing part from screen: \(screen.customLocalizedName), intersection: \(intersection)") + + let selectionCGRect = CGRect(x: intersection.origin.x, y: intersection.origin.y, + width: intersection.width, height: intersection.height) + + // Intel Mac compatible ScreenCaptureKit usage + guard let provider = self.screenCaptureProvider else { + print("โšก Intel Mac fallback: Multi-screen capture not supported without ScreenCaptureKit") + return false + } + + let windowsToExclude = await provider.getAllWindowsToExclude() + + guard let partImage = await provider.captureSelection( + selectionRectInPoints: selectionCGRect, + screen: screen, + excludingWindows: windowsToExclude + ) else { + NSLog("Error: Failed to capture part of screen \(screen.customLocalizedName) using ScreenCaptureKitProvider for intersection \(intersection)") + return false + } + + await stitchImagePart(partImage: partImage, intersection: intersection, + totalRect: totalRect, combinedImage: combinedImage, + screenName: screen.customLocalizedName) + return true + } + + private func stitchImagePart(partImage: NSImage, intersection: NSRect, + totalRect: NSRect, combinedImage: NSImage, + screenName: String) async { + await MainActor.run { + combinedImage.lockFocus() + let drawRect = NSRect( + x: intersection.origin.x - totalRect.origin.x, + y: intersection.origin.y - totalRect.origin.y, + width: intersection.width, + height: intersection.height + ) + partImage.draw(in: drawRect) + combinedImage.unlockFocus() + NSLog("๐Ÿ–ผ Drawn part from \(screenName) at \(drawRect) on combined image") + } + } + + private func handleCombinedCaptureResult(allPartsCaptured: Bool, + intersectingScreens: [(screen: NSScreen, intersection: NSRect)], + combinedImage: NSImage) async { + await MainActor.run { + self.previewManager.hideLoadingIndicator() + + if allPartsCaptured && !intersectingScreens.isEmpty { + self.processCapture(image: combinedImage) + } else if intersectingScreens.isEmpty { + NSLog("Error: No intersecting screens found for combined capture.") + } else { + NSLog("Error: Not all parts captured successfully for combined image.") + } + } + } + + func processCapture(image: NSImage) { + // VERWIJDERD: Desktop icon restore functionality - feature disabled + + if SettingsManager.shared.playSoundOnCapture { + let soundType = SettingsManager.shared.screenshotSoundType + let volume = SettingsManager.shared.screenshotSoundVolume + + if let sound = NSSound(named: soundType.systemSoundName) { + sound.volume = volume + sound.play() + } else { + print("โš ๏ธ Could not load sound '\(soundType.systemSoundName)' - falling back to Pop") + // Fallback to Pop if selected sound doesn't exist + if let fallbackSound = NSSound(named: "Pop") { + fallbackSound.volume = volume + fallbackSound.play() + } + } + } + + if let pngData = self.createPngData(from: image) { + let tempURL = self.createTempUrl() + do { + try pngData.write(to: tempURL) + print("๐Ÿ“ธ Capture: Setting tempURL to \(tempURL.path)") + self.setTempFileURL(tempURL) + print("๐Ÿ’พ Tijdelijk bestand aangemaakt: \(tempURL.path)") + print("๐Ÿ“Š PNG data grootte: \(pngData.count) bytes") + + // ๐ŸŽฏ NEW: Clear old thumbnail restoration backup before creating new one + self.clearBackupAfterNewScreenshot() + + // ๐ŸŽฏ NEW: Create thumbnail restoration backup + self.createImageBackup(image: image, tempURL: tempURL) + + if SettingsManager.shared.autoSaveScreenshot { + print("โš™๏ธ Automatisch opslaan is AAN.") + self.saveDirectlyToConfiguredFolder() + if !SettingsManager.shared.closeAfterSave { + self.lastImage = image + self.showPreview(image: image) + } + } else { + print("โš™๏ธ Automatisch opslaan is UIT. Preview wordt getoond.") + self.lastImage = image + self.showPreview(image: image) + } + } catch { + print("โŒ Fout bij opslaan tijdelijk bestand: \(error)") + } + } + } + + private func createPngData(from image: NSImage) -> Data? { + guard let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData) else { return nil } + return bitmap.representation(using: .png, properties: [:]) + } + + internal func createTempUrl() -> URL { + let filenameBase = generateFilename() + // GEWIJZIGD: Gebruik permanente thumbnail directory in plaats van temp directory + return thumbnailDirectory + .appendingPathComponent(filenameBase) + .appendingPathExtension("png") + } + + internal func generateFilename() -> String { + let prefix = SettingsManager.shared.filenamePrefix + let preset = SettingsManager.shared.filenameFormatPreset + let customFormat = SettingsManager.shared.filenameCustomFormat + let now = Date() + let dateFormatter = DateFormatter() + var filename = prefix + + switch preset { + case .macOSStyle: + dateFormatter.dateFormat = "yyyy-MM-dd HH.mm.ss" + let dateString = dateFormatter.string(from: now) + if prefix == "Schermafbeelding" { + filename = "Schermafbeelding \(dateString)" + } else { + filename += (prefix.isEmpty ? "" : " ") + dateString + } + case .compactDateTime: + dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + filename += (prefix.isEmpty ? "" : "_") + dateFormatter.string(from: now) + case .superCompactDateTime: + dateFormatter.dateFormat = "yyyyMMdd_HHmmss" + filename += (prefix.isEmpty ? "" : "_") + dateFormatter.string(from: now) + case .timestamp: + filename += (prefix.isEmpty ? "" : "_") + String(Int(now.timeIntervalSince1970)) + case .prefixOnly: + if filename.isEmpty { filename = "screenshot" } + case .custom: + var format = customFormat + dateFormatter.dateFormat = "yyyy" + format = format.replacingOccurrences(of: "{YYYY}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "yy" + format = format.replacingOccurrences(of: "{YY}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "MM" + format = format.replacingOccurrences(of: "{MM}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "dd" + format = format.replacingOccurrences(of: "{DD}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "HH" + format = format.replacingOccurrences(of: "{hh}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "mm" + format = format.replacingOccurrences(of: "{mm}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "ss" + format = format.replacingOccurrences(of: "{ss}", with: dateFormatter.string(from: now)) + dateFormatter.dateFormat = "SSS" + format = format.replacingOccurrences(of: "{ms}", with: dateFormatter.string(from: now)) + + if !prefix.isEmpty && !format.isEmpty && !["_", "-", " "].contains(where: { format.hasPrefix($0) }) { + filename += "_" + } + filename += format + if filename.isEmpty { filename = "custom_screenshot" } + } + + // FIXED: Ensure we always have a valid filename with fallback + if filename.isEmpty { + filename = "Schermafbeelding \(dateFormatter.string(from: now))" + } + + return filename.replacingOccurrences(of: ":", with: ".") + .replacingOccurrences(of: "/", with: "-") + } + + func showPreview(image: NSImage) { + // NIEUW: Delegate to PreviewManager + previewManager.showPreview(image: image) + // Update activePreviewWindow reference for compatibility + activePreviewWindow = previewManager.getActivePreviewWindow() + } + + func updatePreviewSize() { + // NIEUW: Delegate to PreviewManager + previewManager.updatePreviewSize() + // Update activePreviewWindow reference for compatibility + activePreviewWindow = previewManager.getActivePreviewWindow() + } + + func closePreviewWithAnimation(immediate: Bool, preserveTempFile: Bool) { + // NIEUW: Delegate to PreviewManager + previewManager.closePreviewWithAnimation(immediate: immediate, preserveTempFile: preserveTempFile) + // Update activePreviewWindow reference for compatibility + activePreviewWindow = previewManager.getActivePreviewWindow() + } + + @objc func closePreviewWindow() { + // NIEUW: Delegate to PreviewManager + previewManager.closePreviewWindow() + // Update activePreviewWindow reference for compatibility + activePreviewWindow = previewManager.getActivePreviewWindow() + } + + @objc func openSettings(_ sender: Any?) { // Any? om ook vanuit menu te kunnen aanroepen + _openSettings(sender) + } + + // Implementatie voor GridViewManagerDelegate + func getActiveWindowForGridPositioning() -> NSWindow? { + return self.activePreviewWindow // Gebruik het standaard preview venster voor positionering van de grid + } + + // Functie om een nieuw gallery venster te openen, of een bestaande te updaten + // MOVED TO GridActionManagerDelegate extension - showOrUpdateStash + + // MARK: - Private stash implementation helpers + + // AANGEPAST: Accepteert nu onScreen parameter + private func _showOrUpdateStash(with imageURL: URL?, image: NSImage? = nil, imageStore providedStore: GalleryImageStore? = nil, onScreen: NSScreen?) { + let callId = UUID().uuidString.prefix(4) + print("๐Ÿ—‚๏ธ [\(callId)] _showOrUpdateStash CALLED. imageURL: \(imageURL?.lastPathComponent ?? "nil"), image isNil: \(image == nil), providedStore isNil: \(providedStore == nil), onScreen: \(onScreen?.localizedName ?? "nil")") + + let currentImage = image ?? (imageURL != nil ? NSImage(contentsOf: imageURL!) : nil) + print("๐Ÿ—‚๏ธ [\(callId)] currentImage isNil: \(currentImage == nil)") + + if let existingController = stashWindowController, + let existingWindow = existingController.window, existingWindow.isVisible, + let existingStore = activeStashImageStore { + print("๐Ÿ—‚๏ธ [\(callId)] Stash window BESTAAT. Store heeft \(existingStore.images.count) items.") + if let img = currentImage, let url = imageURL { // url is hier de imageURL parameter + print("๐Ÿ—‚๏ธ [\(callId)] Afbeelding & URL aanwezig. Gaat naar moveToStashDirectory met url: \(url.lastPathComponent)") + let stashURL = moveToStashDirectory(from: url) // url wordt gekopieerd naar stash + let suggestedNameForAdd = url.deletingPathExtension().lastPathComponent + print("๐Ÿ—‚๏ธ [\(callId)] Probeert existingStore.addImage. stashURL: \(stashURL?.lastPathComponent ?? "nil"), suggestedNameForAdd: \(suggestedNameForAdd)") + // ๐Ÿ”ฅ CRITICAL FIX: Skip duplicate check to allow BGR thumbnails to be added multiple times + existingStore.addImage(img, fileURL: stashURL, suggestedName: suggestedNameForAdd, skipDuplicateCheck: true) + + // ๐Ÿ”„ REMOVED: Hardcoded preview closing - let DraggableImageView handle this based on closeAfterDrag setting + print("๐Ÿ”„ Stash action: Preview closing handled by DraggableImageView with closeAfterDrag setting") + } else { + print("๐Ÿ—‚๏ธ [\(callId)] Geen image of imageURL meegegeven aan bestaande stash, geen addImage.") + } + existingWindow.makeKeyAndOrderFront(nil as Any?) + NSApp.activate(ignoringOtherApps: true) + + if let targetScreen = onScreen, existingWindow.screen != targetScreen { + // ... (positionering code) + print("๐Ÿ—‚๏ธ [\(callId)] Bestaande Stash window verplaatst naar scherm: \(targetScreen.customLocalizedName)") + } + return + } + + print("๐Ÿ—‚๏ธ [\(callId)] Stash window bestaat NIET. Nieuwe stash wordt aangemaakt.") + // ... (rest van de functie voor nieuwe stash, daar zit de init van IntegratedGalleryView) + + // Clean up orphaned stash files before creating new stash + do { + let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let shotScreenDirectory = appSupportDirectory.appendingPathComponent("ShotScreen") + let stashDirectory = shotScreenDirectory.appendingPathComponent("StashItems") + + if FileManager.default.fileExists(atPath: stashDirectory.path) { + let stashContents = try FileManager.default.contentsOfDirectory(at: stashDirectory, includingPropertiesForKeys: nil) + for fileURL in stashContents { + if fileURL.pathExtension == "png" { + try? FileManager.default.removeItem(at: fileURL) + print("๐Ÿงน Cleaned up orphaned stash file: \(fileURL.lastPathComponent)") + } + } + } + } catch { + print("โš ๏ธ Could not clean up stash directory: \(error)") + } + + let store = providedStore ?? GalleryImageStore() + + if let img = currentImage, let url = imageURL { + // NIEUW: Kopieer bestand van Thumbnail naar StashItems directory + let stashURL = moveToStashDirectory(from: url) + let suggestedName = url.deletingPathExtension().lastPathComponent + + // VERWIJDERD: Geen addImage call hier meer - IntegratedGalleryView doet dit + print("๐Ÿ”„ DEBUG: Copied thumbnail to stash directory: \(stashURL?.lastPathComponent ?? "nil")") + + // ๐Ÿ”„ REMOVED: Hardcoded preview closing - let DraggableImageView handle this based on closeAfterDrag setting + print("๐Ÿ”„ Stash action: Preview closing handled by DraggableImageView with closeAfterDrag setting") + + // FIXED: Pass image data to IntegratedGalleryView for single addition + showOrUpdateStashImplementation(image: img, imageURL: stashURL, suggestedName: suggestedName, imageStore: store, onScreen: onScreen) + } else { + // No image to add + showOrUpdateStashImplementation(image: nil, imageURL: nil, suggestedName: nil, imageStore: store, onScreen: onScreen) + } + } + + // NIEUW: Hulpmethode om bestanden van Thumbnail naar StashItems directory te kopiรซren + private func moveToStashDirectory(from thumbnailURL: URL) -> URL? { + // Verkrijg de stash directory (hetzelfde als in IntegratedGalleryView) + let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let shotScreenDirectory = appSupportDirectory.appendingPathComponent("ShotScreen") + let stashDirectory = shotScreenDirectory.appendingPathComponent("StashItems") + + // Maak de directory aan als die niet bestaat + try? FileManager.default.createDirectory(at: stashDirectory, withIntermediateDirectories: true, attributes: nil) + + // FIXED: Behoud de originele naam en voeg alleen suffix toe bij conflict + let originalName = thumbnailURL.deletingPathExtension().lastPathComponent + let fileExtension = thumbnailURL.pathExtension + let baseStashURL = stashDirectory.appendingPathComponent("\(originalName).\(fileExtension)") + + // Controleer of er al een bestand bestaat met deze naam + var finalStashURL = baseStashURL + var counter = 1 + while FileManager.default.fileExists(atPath: finalStashURL.path) { + finalStashURL = stashDirectory.appendingPathComponent("\(originalName)_\(counter).\(fileExtension)") + counter += 1 + } + + do { + try FileManager.default.copyItem(at: thumbnailURL, to: finalStashURL) + print("โœ… Copied thumbnail from \(thumbnailURL.lastPathComponent) to stash: \(finalStashURL.lastPathComponent)") + print("๐Ÿ”„ DEBUG: Copied thumbnail to stash directory: \(finalStashURL.lastPathComponent)") + return finalStashURL + } catch { + print("โŒ Failed to copy thumbnail to stash: \(error)") + return nil + } + } + + // AANGEPAST: Accepteert nu onScreen parameter + private func showOrUpdateStashImplementation(image: NSImage?, imageURL: URL?, suggestedName: String?, imageStore: GalleryImageStore, onScreen: NSScreen?) { + // ๐Ÿ”ฅ NIEUW: Load persistent stash images if available and store is empty + if SettingsManager.shared.persistentStash, + let persistentStore = persistentStashImageStore, + persistentStore.images.count > 0, + imageStore.images.isEmpty { + print("๐Ÿ”ฅ PERSISTENT STASH: Loading \(persistentStore.images.count) saved images into new stash") + imageStore.images = persistentStore.images + } + let initialStashWidth: CGFloat = 200 + let initialStashHeight: CGFloat = 300 + let newWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: initialStashWidth, height: initialStashHeight), + styleMask: [.borderless, .closable, .fullSizeContentView], + backing: .buffered, defer: false) + + let closeWindowAction = { [weak newWindow] in + guard let windowToClose = newWindow else { return } + windowToClose.close() + } + + // FIXED: Pass all data to IntegratedGalleryView for single addition + let galleryView = IntegratedGalleryView( + imageStore: imageStore, + initialImage: image, + initialImageURL: imageURL, + initialImageName: suggestedName, + hostingWindow: newWindow, + closeAction: closeWindowAction + ) + let hostingView = NSHostingView(rootView: galleryView) + + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.hasShadow = true + newWindow.titleVisibility = .hidden + newWindow.titlebarAppearsTransparent = true + newWindow.isMovable = false + + let rootContentView = NSView(frame: newWindow.contentRect(forFrameRect: newWindow.frame)) + rootContentView.wantsLayer = true + rootContentView.layer?.cornerRadius = 12 + rootContentView.layer?.masksToBounds = true + + let visualEffectView = NSVisualEffectView() + visualEffectView.blendingMode = .behindWindow + visualEffectView.material = .hudWindow + visualEffectView.state = .active + visualEffectView.autoresizingMask = [.width, .height] + visualEffectView.frame = rootContentView.bounds + visualEffectView.wantsLayer = true + visualEffectView.layer?.cornerRadius = 12 + visualEffectView.layer?.masksToBounds = true + + let extraBlurView = NSVisualEffectView() + extraBlurView.blendingMode = .behindWindow + extraBlurView.material = .hudWindow + extraBlurView.state = .active + extraBlurView.alphaValue = 0.6 + extraBlurView.autoresizingMask = [.width, .height] + extraBlurView.frame = rootContentView.bounds + + rootContentView.addSubview(visualEffectView) + rootContentView.addSubview(extraBlurView) + hostingView.translatesAutoresizingMaskIntoConstraints = false + rootContentView.addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: rootContentView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: rootContentView.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: rootContentView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: rootContentView.trailingAnchor) + ]) + + newWindow.contentView = rootContentView + + // FIXED: Position stash window on the determined screen + let screenForPositioning = onScreen ?? NSScreen.screenWithMouse() ?? NSScreen.main ?? NSScreen.screens.first + + if let screenToDisplayOn = screenForPositioning { + let targetY = (screenToDisplayOn.visibleFrame.height - initialStashHeight) / 2 + screenToDisplayOn.visibleFrame.origin.y + let spacing: CGFloat = 20 + let targetX = screenToDisplayOn.visibleFrame.maxX - initialStashWidth - spacing + newWindow.setFrameOrigin(NSPoint(x: targetX, y: targetY)) + print("๐ŸชŸ Stash window positioned on screen: \(screenToDisplayOn.customLocalizedName) at {\(targetX), \(targetY)}") + } else { + newWindow.center() // Fallback + print("๐ŸชŸ Stash window centered (could not determine target screen).") + } + + newWindow.delegate = self + let newWindowController = NSWindowController(window: newWindow) + newWindowController.showWindow(self) + self.stashWindowController = newWindowController + self.activeStashImageStore = imageStore + + if SettingsManager.shared.stashAlwaysOnTop { + newWindow.level = .floating + print("๐Ÿ” New empty stash window created with always on top") + } else { + newWindow.level = .normal + print("๐Ÿ“‹ New empty stash window created with normal level") + } + + newWindow.makeKeyAndOrderFront(nil as Any?) + NSApp.activate(ignoringOtherApps: true) + + // ๐ŸŽฏ NIEUW: Subtiele shake animatie voor nieuwe stash windows + print("๐ŸŽ‰ Adding shake animation to new stash window") + addSubtleShakeAnimation(to: newWindow) + } + + func setTempFileURL(_ url: URL?) { + // DEBUG LOG + if let newURL = url { + print("๐Ÿ”„ setTempFileURL: New URL = \(newURL.path)") + } else { + print("๐Ÿ”„ setTempFileURL: Clearing URL (nil)") + } + + // AANGEPAST: Behoud alle screenshots, laat cache retention systeem de cleanup doen + // Als url is nil, clear alleen de tempURL referentie, verwijder het bestand NIET + if url == nil { + if let oldURL = tempURL { + print("โ„น๏ธ Clearing tempURL reference to: \(oldURL.lastPathComponent) (file kept for cache retention)") + } + tempURL = nil + print("โ„น๏ธ Cleared tempURL reference (screenshot preserved).") + } else if let newURL = url { + // Geen cleanup van oude files - alle screenshots blijven bestaan voor cache retention + if let oldURL = tempURL, oldURL != newURL { + print("๐Ÿ“ธ Previous screenshot preserved: \(oldURL.lastPathComponent)") + } + tempURL = newURL + print("โ„น๏ธ Updated tempURL to: \(newURL.lastPathComponent)") + } + } + + // MOVED: findFilenameLabel function moved to PreviewManagerDelegate extension + + func saveImageDataToURL(_ url: URL, from sourceURL: URL) -> Bool { + print("๐Ÿ’พ Attempting to save data from \(sourceURL.lastPathComponent) to \(url.lastPathComponent)") + do { + let imageData = try Data(contentsOf: sourceURL) + try imageData.write(to: url) + print("๐Ÿ“Š Saved PNG data size: \(imageData.count) bytes") + return true + } catch { + print("โŒ Error reading from \(sourceURL.path) or writing to \(url.path): \(error)") + return false + } + } + + // Implementatie van RenameActionHandlerDelegate methoden + func getActivePreviewWindow() -> NSWindow? { + // NIEUW: Delegate to PreviewManager + return previewManager.getActivePreviewWindow() + } + + // MARK: - Legacy code cleanup - CancelRemoveButtonsView methods removed + // These methods were replaced by the grid-based Cancel/Remove system in GridComponents + + + + func renameActionHandler(_ handler: RenameActionHandler, didRenameFileFrom oldURL: URL, to newURL: URL) { + print("Delegate: File was renamed from \(oldURL) to \(newURL)") + if self.tempURL == oldURL { + self.setTempFileURL(newURL) + if let previewWindow = getActivePreviewWindow(), let label = self.findFilenameLabel(in: previewWindow) { + label.stringValue = newURL.lastPathComponent + label.toolTip = newURL.lastPathComponent + } + } else { + // Zoek in stash + if let store = self.activeStashImageStore { + if let idx = store.images.firstIndex(where: { $0.fileURL == oldURL }) { + store.images[idx].fileURL = newURL + store.images[idx].customName = newURL.lastPathComponent + print("โœ… Stash image updated to new name \(newURL.lastPathComponent)") + } + } + print("Info: Renamed file was not the active tempURL.") + } + } + + // TOEGEVOEGD: Vereiste methode voor RenameActionHandlerDelegate + func getScreenshotFolder() -> String? { + return SettingsManager.shared.screenshotFolder + } + + // REQUIRED: findFilenameLabel method for RenameActionHandlerDelegate protocol + func findFilenameLabel(in window: NSWindow?) -> NSTextField? { + guard let targetWindow = window else { return nil } + return targetWindow.contentView?.viewWithTag(11001) as? NSTextField + } + + // NIEUW: Hide grid delegate method + func hideGrid() { + print("๐Ÿ”ถ RenameActionHandlerDelegate: Hiding grid after rename action") + // GEWIJZIGD: monitorForReappear naar false zodat de grid NIET LATER KAN TERUGKEREN + gridViewManager?.hideGrid(monitorForReappear: false) + } + + // NIEUW: Disable grid monitoring delegate method + func disableGridMonitoring() { + print("๐Ÿ”ถ RenameActionHandlerDelegate: Disabling grid monitoring during rename") + gridViewManager?.disableMonitoring() + } + + // NIEUW: Enable grid monitoring delegate method + func enableGridMonitoring() { + print("๐Ÿ”ถ RenameActionHandlerDelegate: Enabling grid monitoring after rename") + gridViewManager?.enableMonitoring() + } + + // HERSTEL / VOEG DEZE IMPLEMENTATIE TOE BINNEN DE KLASSE + func gridViewManager(_ manager: GridViewManager, didDropImage imageURL: URL, ontoCell cellIndex: Int, at dropPoint: NSPoint) { + print("โœ… ScreenshotApp: Received delegate call for drop on cell \(cellIndex)") + print("๐Ÿ–ผ๏ธ Dropped image \(imageURL.path) onto cell \(cellIndex)") + self.didGridHandleDrop = true + + // VERWIJDERD: Oude logica voor het direct verbergen van de grid. + // De GridActionManager is nu verantwoordelijk voor het correct verbergen van de grid + // na het afhandelen van de actie en de FeedbackBubblePanel (indien van toepassing). + // De RenameActionHandler beheert het ook zelf via zijn delegate. + + // NIEUW: Delegate to GridActionManager + gridActionManager.handleGridAction( + imageURL: imageURL, + cellIndex: cellIndex, + dropPoint: dropPoint, + gridWindow: manager.gridWindow + ) { [weak self] actionCompletedSuccessfully, wasSaveToFolder, isStashAction in + guard let self = self else { return } + self.gridActionManager.handleActionCompletion( + actionCompletedSuccessfully: actionCompletedSuccessfully, + wasSaveToFolder: wasSaveToFolder, + isStashAction: isStashAction + ) + } + } + + // De @objc func saveImage() methode moet hier ook staan... + @objc func saveImage() { + // ... volledige implementatie van saveImage ... + } + + // Nieuwe methode om selectie veilig te annuleren + func cancelSelection(overlay: OverlayWindow?) { + print("๐Ÿ”ต Canceling selection...") + CGDisplayShowCursor(CGMainDisplayID()) + + DispatchQueue.main.async { + print("๐Ÿ”ต Ordering out overlay asynchronously from cancelSelection...") + overlay?.orderOut(nil as Any?) + // Zorg ervoor dat de referentie wordt opgeruimd als deze overlay de actieve was + if self.activeOverlayWindow === overlay { + self.activeOverlayWindow = nil + } + } + } + + // Nieuwe functie voor visuele feedback + func flashPreviewBorder() { + // NIEUW: Delegate to PreviewManager + previewManager.flashPreviewBorder() + } + + @objc func saveFromPreview(_ sender: Any) { + _saveFromPreview(sender) + } + + // HERSTELDE/TOEGEVOEGDE FUNCTIE + @objc func openScreenshotFolder() { + _openScreenshotFolder() + } + + // MARK: - Background Remove Helper + + // REMOVED: Legacy BGR window system - now uses thumbnail-based BGR workflow + + // ๐ŸŽจ NEW: Background removal thumbnail workflow + func showBackgroundRemovalThumbnail(with image: NSImage, originalURL: URL) { + print("๐ŸŽจ ScreenshotApp: Starting BGR thumbnail workflow") + + // Always hide the grid when starting BGR workflow + gridViewManager?.hideGrid(monitorForReappear: false) + + // ๐Ÿ”ง CRITICAL FIX: Do NOT close existing preview here! + // Let the normal drag completion logic in DraggableImageView handle preview closing + // based on the user's "close thumbnail after dragging" setting. + print("๐Ÿ”„ BGR: Starting BGR workflow without closing existing preview (handled by drag completion)") + + // Use PreviewManager's new BGR mode with original URL + previewManager?.showBackgroundRemovalPreview(originalImage: image, originalURL: originalURL) + } + + // MARK: - OCR & Toast Helpers + + private func performOcrAndCopy(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?) { + // MOVED TO GridActionManager.swift - performOcrAndCopy functionality + } + + private func showToast(message: String, near point: NSPoint, gridWindow: NSWindow?) { + // MOVED TO GridActionManager.swift - showToast functionality + } + + // Helper to copy image to clipboard and show toast + private func copyImageToClipboard(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?) { + // MOVED TO GridActionManager.swift - copyImageToClipboard functionality + } + + // NIEUW: Functie om automatisch opstarten te regelen + func toggleLaunchAtLogin(shouldLaunch: Bool) { + if #available(macOS 13.0, *) { + // Voor macOS 13+ gebruiken we SMAppService.mainApp + // Dit werkt direct voor de main app zonder extra configuratie + let service = SMAppService.mainApp + do { + if shouldLaunch { + // Controleer eerst de huidige status + let currentStatus = service.status + print("๐Ÿ” LOGIN: Current SMAppService.mainApp status: \(currentStatus)") + + switch currentStatus { + case .notRegistered: + try service.register() + print("โœ… LOGIN: App successfully registered to launch at login via SMAppService.mainApp") + // Check status na registratie + let newStatus = service.status + print("๐Ÿ” LOGIN: Status after registration: \(newStatus)") + // Geen popup meer - gebruiker krijgt macOS native bevestiging + case .enabled: + print("โ„น๏ธ LOGIN: App is already enabled for launch at login") + case .requiresApproval: + print("โš ๏ธ LOGIN: Launch at login requires user approval in System Settings") + // Geen popup meer - macOS toont native bevestiging automatisch + case .notFound: + print("โŒ LOGIN: App service not found - SMAppService.mainApp failed") + // Fallback naar legacy methode + print("๐Ÿ”„ LOGIN: Falling back to legacy method for macOS 13+") + toggleLaunchAtLoginLegacy(shouldLaunch: shouldLaunch) + return + @unknown default: + print("โš ๏ธ LOGIN: Unknown SMAppService status: \(currentStatus)") + } + } else { + // Uitschakelen + let currentStatus = service.status + if currentStatus == .enabled || currentStatus == .requiresApproval { + try service.unregister() + print("โœ… LOGIN: App unregistered from launch at login (SMAppService.mainApp)") + } else { + print("โ„น๏ธ LOGIN: App was not registered for launch at login (status: \(currentStatus))") + } + } + } catch { + print("โŒ LOGIN: Error with SMAppService.mainApp: \(error.localizedDescription)") + print("๐Ÿ”„ LOGIN: Falling back to legacy method") + toggleLaunchAtLoginLegacy(shouldLaunch: shouldLaunch) + return + } + } else { + // Voor macOS < 13 + print("โš ๏ธ LOGIN: macOS version < 13.0 detected, using legacy method") + toggleLaunchAtLoginLegacy(shouldLaunch: shouldLaunch) + } + } + + // Legacy methode voor macOS < 13 en fallback + private func toggleLaunchAtLoginLegacy(shouldLaunch: Bool) { + // Voor macOS < 13 of als SMAppService.mainApp faalt + print("๐Ÿ”„ LOGIN: Using legacy approach - LSSharedFileList") + + // Probeer LSSharedFileList methode (werkt tot macOS 13) + if #available(macOS 10.11, *) { + if toggleLaunchAtLoginUsingLSSharedFileList(shouldLaunch: shouldLaunch) { + print("โœ… LOGIN: Successfully used LSSharedFileList method") + return + } + } + + // Als alles faalt, toon instructies aan gebruiker + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Manual Setup Required" + if shouldLaunch { + alert.informativeText = "To enable ShotScreen to start at login:\n\n1. Open System Settings/Preferences\n2. Go to General โ†’ Login Items (or Users & Groups โ†’ Login Items)\n3. Click + and add ShotScreen from Applications" + } else { + alert.informativeText = "To disable ShotScreen from starting at login:\n\n1. Open System Settings/Preferences\n2. Go to General โ†’ Login Items (or Users & Groups โ†’ Login Items)\n3. Select ShotScreen and click - or toggle it off" + } + alert.addButton(withTitle: "Open System Settings") + alert.addButton(withTitle: "OK") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + // Open System Settings/Preferences + if #available(macOS 13.0, *) { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.users?LoginItems") { + NSWorkspace.shared.open(url) + } + } else { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.users") { + NSWorkspace.shared.open(url) + } + } + } + } + + // Reset de setting omdat gebruiker handmatig moet handelen + DispatchQueue.main.async { + SettingsManager.shared.startAppOnLogin = false + } + } + + // Hulpfunctie voor LSSharedFileList (legacy) - alleen voor testen + @available(macOS 10.11, *) + private func toggleLaunchAtLoginUsingLSSharedFileList(shouldLaunch: Bool) -> Bool { + // Deze API's zijn deprecated vanaf 10.11, dus gebruiken we alleen SMAppService.mainApp + print("โš ๏ธ LOGIN: LSSharedFileList APIs are deprecated, falling back to manual setup") + return false + } + + // Toon instructies voor gebruiker om launch at login goed te keuren + private func showLoginItemApprovalAlert() { + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Launch at Login Requires Approval" + alert.informativeText = "To enable ShotScreen to start automatically at login:\n\n1. Open System Settings\n2. Go to General โ†’ Login Items\n3. Find ShotScreen and enable it" + alert.addButton(withTitle: "Open System Settings") + alert.addButton(withTitle: "Cancel") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + // Open System Settings to Login Items + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.users?LoginItems") { + NSWorkspace.shared.open(url) + } + } + } + } + + // NIEUW: Functie om direct op te slaan zonder interactie + func saveDirectlyToConfiguredFolder(isCalledFromPreviewButton: Bool = false) { + print("๐Ÿ’พ Opslaan naar geconfigureerde map gestart. Oorsprong: \(isCalledFromPreviewButton ? "Preview Knop" : "Automatisch")") + guard let sourceURL = self.tempURL else { + print("โŒ Opslaan Mislukt: Geen tijdelijk screenshot bestand URL beschikbaar.") + return + } + + guard let destinationFolderStr = SettingsManager.shared.screenshotFolder, + !destinationFolderStr.isEmpty else { + print("โŒ Opslaan Mislukt: Standaard opslagmap niet ingesteld.") + if let window = previewManager.getActivePreviewWindow() { // Toon alleen alert als er een UI context is + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Save Folder Not Set" + alert.informativeText = "Please set a default save folder in Settings before using the save button." + alert.addButton(withTitle: "OK") + alert.beginSheetModal(for: window, completionHandler: nil) + } + } + return + } + + let fileManager = FileManager.default + let destinationFolderURL = URL(fileURLWithPath: destinationFolderStr) + + // 1. Bepaal basisnaam en extensie van de sourceURL + var baseName = sourceURL.deletingPathExtension().lastPathComponent + let ext = sourceURL.pathExtension.isEmpty ? "png" : sourceURL.pathExtension // Behoud en gebruik 'ext' + + // 2. Verwijder eventueel bestaande "_getal" suffix van de baseName + // Dit voorkomt namen zoals "bestand_1_1.png" als "bestand_1.png" al bestond. + if let regex = try? NSRegularExpression(pattern: "_(\\d+)$"), + let match = regex.firstMatch(in: baseName, range: NSRange(baseName.startIndex..., in: baseName)), + match.numberOfRanges > 1 { + let numberRange = Range(match.range(at: 1), in: baseName)! + if let _ = Int(baseName[numberRange]) { // Controleer of het echt een getal is + baseName = String(baseName[.. 1 { + let numberRange = Range(match.range(at: 1), in: baseName)! + if let _ = Int(baseName[numberRange]) { + baseName = String(baseName[.. NSWindow? { + // MOVED TO PreviewManager.swift - showFloatingThumbnail functionality + return nil + } + + // MARK: - Screen Selection for Thumbnail + func getTargetScreenForThumbnail() -> NSScreen? { + // MOVED TO PreviewManager.swift - getTargetScreenForThumbnail functionality + return previewManager.getTargetScreenForThumbnail() + } + + // HERNOEM EN PAS AAN: showFloatingThumbnail naar createNewThumbnailWindow + // MOVED TO PreviewManager.swift - createNewThumbnailWindow functionality + + // MARK: - Multi-Monitor Functions + // Multi-monitor functionality has been moved to MultiMonitorSystem.swift + + // MARK: - Clean Desktop Functions + // VERWIJDERD: Desktop cleaning functions - feature has been disabled + + // Cleanup function from MultiMonitorSystem.swift is used instead + + // MARK: - GridActionManagerDelegate Implementation + func disableMonitoring() { // Renamed from gridViewManagerDisableMonitoring + gridViewManager?.disableMonitoring() + } + + func enableMonitoring() { // Renamed from gridViewManagerEnableMonitoring + gridViewManager?.enableMonitoring() + } + + func gridViewManagerHideGrid(monitorForReappear: Bool) { + gridViewManager?.hideGrid(monitorForReappear: monitorForReappear) + } + + func getGridCurrentFrame() -> NSRect? { + return gridViewManager?.gridWindow?.frame + } + + // NIEUW: Methode om de "alle schermen" toggle modus te beheren + func toggleAllScreensCaptureMode() { + isAllScreensCaptureToggledOn.toggle() + print("๐Ÿ”„ All Screens Capture Toggled: \(isAllScreensCaptureToggledOn ? "ON" : "OFF")") + updateAllScreensModeNotifier() + } + + // MARK: - Public Methods for Stash Preview Positioning + + /// Returns the target screen for stash preview positioning (same as main thumbnail) + func getTargetScreenForStashPreview() -> NSScreen? { + return previewManager.getTargetScreenForThumbnail() + } + + // MARK: - Private stash implementation helpers + + // NIEUW: Subtiele shake animatie voor stash window + private func addSubtleShakeAnimation(to window: NSWindow) { + print("๐Ÿ”ฅ STARTING PROVEN CAKeyframeAnimation shake for stash window") + + // PROVEN WORKING PARAMETERS from Eric Dolecki blog + let numberOfShakes = 4 + let durationOfShake = 0.3 + let vigourOfShake: CGFloat = 0.03 // 3% of window width - more visible + let frame = window.frame + + print("๐Ÿ”ฅ Shake parameters: shakes=\(numberOfShakes), duration=\(durationOfShake)s, vigour=\(vigourOfShake)") + print("๐Ÿ”ฅ Window frame: \(frame)") + + // Create CAKeyframeAnimation using PROVEN technique + let shakeAnimation = CAKeyframeAnimation() + + // Create shake path with left-right movement + let shakePath = CGMutablePath() + shakePath.move(to: CGPoint(x: frame.minX, y: frame.minY)) + + for _ in 0.. 0 { + if persistentStashImageStore == nil { + persistentStashImageStore = GalleryImageStore() + } + + // Copy all images to persistent store + persistentStashImageStore!.images = currentStore.images + print("๐Ÿ”ฅ PERSISTENT STASH: Saved \(currentStore.images.count) images for next session") + } else { + print("๐Ÿ”ฅ PERSISTENT STASH: No images to save") + } + } else { + print("๐Ÿ”ฅ PERSISTENT STASH: Persistent stash disabled - clearing all images") + persistentStashImageStore = nil + } + + stashWindowController = nil + activeStashImageStore = nil + } + + // Check voor settings window + if let settingsWin = activeSettingsWindow, settingsWin == closingWindow { + print("Settings window closed. Clearing reference.") + activeSettingsWindow = nil + } + } +} + +// MARK: - MenuManagerDelegate Implementation +extension ScreenshotApp { + func triggerScreenshot() { + activateMultiMonitorSelection() + } + + func triggerWholeScreenCapture() { + print("๐Ÿ–ฅ๏ธ Triggering whole screen capture under cursor from menu!") + + // Use existing function that handles everything correctly + captureWholeScreenUnderCursor() + } + + func triggerNativeWindowCapture() { + print("๐ŸชŸ Triggering native window capture - direct window selection!") + + // Create temporary file for screenshot + let tempDirectory = FileManager.default.temporaryDirectory + let tempFileName = "ShotScreen_window_\(UUID().uuidString).png" + let tempFileURL = tempDirectory.appendingPathComponent(tempFileName) + + print("๐Ÿ“„ Using temporary file: \(tempFileURL.path)") + + // Start screencapture to file (NO clipboard) + let task = Process() + task.launchPath = "/usr/sbin/screencapture" + task.arguments = [ + "-iw", // Interactive WINDOW selection (direct) + "-x", // Do not play sounds + tempFileURL.path // Save to file + ] + + do { + try task.run() + + // Wait for screencapture completion in background + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + task.waitUntilExit() + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Check if file was created (indicates successful capture) + if FileManager.default.fileExists(atPath: tempFileURL.path) { + // Extra check: Is it a valid image file? + if let image = NSImage(contentsOf: tempFileURL), + image.size.width > 0 && image.size.height > 0 { + print("โœ… Valid window screenshot saved to file - processing...") + self.processCapture(image: image) + + // Clean up temporary file + try? FileManager.default.removeItem(at: tempFileURL) + print("๐Ÿ—‘๏ธ Temporary window screenshot file cleaned up") + } else { + print("๐Ÿšซ Invalid/empty image file - right-click cancelled") + try? FileManager.default.removeItem(at: tempFileURL) + } + } else { + print("๐Ÿšซ No screenshot file - ESC or other cancellation") + } + } + } + } catch { + print("โŒ Failed to start window capture: \(error)") + } + } + + func triggerAllScreensCapture() { + // ๐Ÿ”ง FIX: Menu item bypasses desktop filtering - direct native capture + print("๐Ÿ“‹ Menu: Capture All Screens - using direct native capture (bypasses desktop filtering)") + captureAllScreensNative() + } + + func openSettings() { + openSettings(nil) + } + + func showStash() { + showStash(nil) + } + + func resetFirstLaunchWizard() { + self.resetFirstLaunchWizardInternal() + } + + func toggleDesktopIcons() { + // Toggle the setting + SettingsManager.shared.hideDesktopIconsDuringScreenshot.toggle() + + // Update the menu item to reflect the new state + menuManager?.refreshDesktopIconsMenuItem() + + // Log the change + let newState = SettingsManager.shared.hideDesktopIconsDuringScreenshot + print("๐Ÿ–ฅ๏ธ Desktop icons during screenshots: \(newState ? "HIDDEN" : "VISIBLE")") + + // DISABLED: Visual feedback causes crashes due to complex animation closures + // showDesktopIconsToggleFeedback(hidden: newState) + } + + func toggleDesktopWidgets() { + // Toggle the setting + SettingsManager.shared.hideDesktopWidgetsDuringScreenshot.toggle() + + // Update the menu item to reflect the new state + menuManager?.refreshDesktopWidgetsMenuItem() + + // Log the change + let newState = SettingsManager.shared.hideDesktopWidgetsDuringScreenshot + print("๐Ÿ“ฑ Desktop widgets during screenshots: \(newState ? "HIDDEN" : "VISIBLE")") + } + + // MARK: - License Management + private func initializeLicenseSystem() { + print("๐Ÿ” LICENSE: Initializing license system...") + + // Initialize license manager which will check status on startup + _ = LicenseManager.shared + + // Add observer for license status changes + NotificationCenter.default.addObserver( + self, + selector: #selector(handleLicenseStatusChanged), + name: .licenseStatusChanged, + object: nil + ) + + // Start observing license status + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.checkLicenseStatusAndEnforcePolicy() + } + } + + @objc private func handleLicenseStatusChanged() { + DispatchQueue.main.async { + self.checkLicenseStatusAndEnforcePolicy() + } + } + + private func checkLicenseStatusAndEnforcePolicy() { + let licenseManager = LicenseManager.shared + + print("๐Ÿ” LICENSE: Checking current license status...") + + switch licenseManager.licenseStatus { + case .checking: + print("๐Ÿ” LICENSE: Status checking in progress...") + // Wait a bit and check again + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.checkLicenseStatusAndEnforcePolicy() + } + + case .licensed(let userName, let email): + print("โœ… LICENSE: Valid production license found for \(userName) (\(email))") + // App can function normally + enableAppFunctionality() + + case .testLicense(let userName, let email): + print("๐Ÿงช LICENSE: Valid test license found for \(userName) (\(email))") + print("๐Ÿงช LICENSE: Test license detected - app functionality enabled for development") + // App can function normally (same as production license) + enableAppFunctionality() + + case .trial(let daysLeft): + print("โฐ LICENSE: Trial active with \(daysLeft) days remaining") + if daysLeft <= 0 { + print("โŒ LICENSE: Trial has expired!") + showTrialExpiredBlocker() + } else { + // Show trial reminder for last 2 days + if daysLeft <= 2 { + showTrialReminder(daysLeft: daysLeft) + } + enableAppFunctionality() + } + + case .expired: + print("โŒ LICENSE: Trial has expired!") + showTrialExpiredBlocker() + + case .invalid: + print("โŒ LICENSE: Invalid license detected!") + showInvalidLicenseBlocker() + } + } + + private func showTrialReminder(daysLeft: Int) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "Trial Reminder" + alert.informativeText = """ + Your ShotScreen trial expires in \(daysLeft) day\(daysLeft == 1 ? "" : "s"). + + Purchase a license to continue using ShotScreen after the trial period. + """ + alert.addButton(withTitle: "Purchase License") + alert.addButton(withTitle: "Enter License Key") + alert.addButton(withTitle: "Continue Trial") + + let response = alert.runModal() + + switch response { + case .alertFirstButtonReturn: + openPurchaseURL() + case .alertSecondButtonReturn: + showLicenseEntryDialog() + default: + break + } + } + + private func showTrialExpiredBlocker() { + // Create and show a blocking window that prevents app usage + let expiredWindow = createTrialExpiredWindow() + expiredWindow.makeKeyAndOrderFront(nil) + + // Disable all app functionality except license entry + disableAppFunctionality() + } + + private func showInvalidLicenseBlocker() { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = "Invalid License" + alert.informativeText = """ + Your license is invalid or has been revoked. + + Please contact support or purchase a new license to continue using ShotScreen. + """ + alert.addButton(withTitle: "Purchase License") + alert.addButton(withTitle: "Enter License Key") + alert.addButton(withTitle: "Quit") + + let response = alert.runModal() + + switch response { + case .alertFirstButtonReturn: + openPurchaseURL() + case .alertSecondButtonReturn: + showLicenseEntryDialog() + default: + NSApplication.shared.terminate(nil) + } + } + + private func createTrialExpiredWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 350), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + + window.title = "ShotScreen - Trial Expired" + window.center() + window.level = .modalPanel + window.isReleasedWhenClosed = false + + // Make window non-closable and non-resizable + window.standardWindowButton(.closeButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + + let trialExpiredView = TrialExpiredView() + let hostingView = NSHostingView(rootView: trialExpiredView) + window.contentView = hostingView + + return window + } + + private func disableAppFunctionality() { + // Disable hotkey + hotKey = nil + + // Close any open windows + if let preview = activePreviewWindow { + preview.close() + } + + if let stash = stashWindowController?.window { + stash.close() + } + + // Hide menu or disable menu items + menuManager?.setAppEnabled(false) + + print("๐Ÿ”’ LICENSE: App functionality disabled due to expired/invalid license") + } + + private func enableAppFunctionality() { + // Re-enable hotkey + setupHotKey() + + // Re-enable menu + menuManager?.setAppEnabled(true) + + print("๐Ÿ”“ LICENSE: App functionality enabled with valid license") + } + + private func showLicenseEntryDialog() { + 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) + } + } + +} + +// MARK: - PreviewManagerDelegate Implementation +extension ScreenshotApp { + func getLastImage() -> NSImage? { + return lastImage + } + + func setLastImage(_ image: NSImage) { + lastImage = image + } + + // ๐ŸŽฏ NEW: Create backup for thumbnail restoration + private func createImageBackup(image: NSImage, tempURL: URL) { + // Store the backup image and path + self.backupImage = image + self.backupImagePath = tempURL.path + + // ๐ŸŽฏ NEW: Create a persistent backup file that won't be deleted + let backupDirectory = thumbnailDirectory.appendingPathComponent("thumbnail_restoration") + do { + // Always ensure the directory exists (recreate if user deleted it) + try FileManager.default.createDirectory(at: backupDirectory, withIntermediateDirectories: true, attributes: nil) + let backupFileURL = backupDirectory.appendingPathComponent("latest_backup.png") + + if let pngData = createPngData(from: image) { + try pngData.write(to: backupFileURL) + self.backupImagePath = backupFileURL.path + print("๐Ÿ’พ Thumbnail restoration backup created: \(tempURL.lastPathComponent) โ†’ saved to \(backupFileURL.lastPathComponent)") + } + } catch { + print("โŒ Failed to create thumbnail restoration backup file: \(error)") + // Fall back to original path (might be deleted) + self.backupImagePath = tempURL.path + print("๐Ÿ’พ Thumbnail restoration backup created: \(tempURL.lastPathComponent) (fallback)") + } + } + + // ๐ŸŽฏ NEW: Restore thumbnail after settings changes that may have closed it + func restoreCurrentThumbnailIfNeeded() { + // Check if there's a backup available and no active thumbnail + guard let backupImg = backupImage, + let backupPath = backupImagePath, + previewManager.getActivePreviewWindow() == nil else { + print("๐Ÿ”„ No need to restore thumbnail - either no backup available or thumbnail already open") + return + } + + print("๐Ÿ”„ Restoring thumbnail from restoration backup: \(URL(fileURLWithPath: backupPath).lastPathComponent)") + + // Check if backup file still exists before restoring + if !FileManager.default.fileExists(atPath: backupPath) { + print("โŒ Thumbnail restoration backup file no longer exists at: \(backupPath)") + clearImageBackup() + return + } + + // Small delay to ensure any settings UI updates are complete + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard let self = self else { return } + + // Create a new temp file for the restored image + let newTempURL = self.createTempUrl() + + do { + if let pngData = self.createPngData(from: backupImg) { + try pngData.write(to: newTempURL) + + // Restore the state + self.lastImage = backupImg + self.tempURL = newTempURL + + // Show the thumbnail again with the backup image + self.showPreview(image: backupImg) + + print("โœ… Thumbnail restored from restoration backup successfully: \(newTempURL.lastPathComponent)") + } else { + print("โŒ Failed to create PNG data for thumbnail restoration backup restore") + } + } catch { + print("โŒ Failed to write restored thumbnail restoration backup file: \(error)") + } + } + } + + func clearTempFile() { + setTempFileURL(nil) + // ๐ŸŽฏ NEW: Don't clear thumbnail restoration backup here - only clear when explicitly needed + // clearImageBackup() - Removed: Let restore function handle backup cleanup + } + + // ๐ŸŽฏ NEW: Clear thumbnail restoration backup system + private func clearImageBackup() { + // Clean up backup file if it exists + if let backupPath = backupImagePath { + let backupURL = URL(fileURLWithPath: backupPath) + if backupURL.lastPathComponent == "latest_backup.png" { + try? FileManager.default.removeItem(at: backupURL) + print("๐Ÿ—‘๏ธ Thumbnail restoration backup file deleted: \(backupURL.lastPathComponent)") + } + } + + backupImage = nil + backupImagePath = nil + print("๐Ÿ—‘๏ธ Thumbnail restoration backup cleared") + } + + // ๐ŸŽฏ NEW: Clear thumbnail restoration backup only in specific cases + func clearBackupAfterNewScreenshot() { + clearImageBackup() + print("๐Ÿ—‘๏ธ Thumbnail restoration backup cleared after new screenshot") + } + + func getTempURL() -> URL? { + return tempURL + } + + // MARK: - Private implementations to avoid recursion + private func _openScreenshotFolder() { + if let path = SettingsManager.shared.screenshotFolder, !path.isEmpty { + print("Opening folder: \(path)") + let url = URL(fileURLWithPath: path) + NSWorkspace.shared.open(url) + } else { + print("Screenshot folder not set.") + let alert = NSAlert() + alert.messageText = "Screenshot Folder Not Set" + alert.informativeText = "Please set a default screenshot folder in Settings." + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Open Settings") + let response = alert.runModal() + if response == .alertSecondButtonReturn { + _openSettings(nil) + } + } + } + + private func _openSettings(_ sender: Any?) { + renameActionHandler.closeRenamePanelAndCleanup() + + if let existingWindow = activeSettingsWindow, existingWindow.isVisible { + print("Settings window already open. Bringing to front.") + existingWindow.level = NSWindow.Level.floating + existingWindow.orderFrontRegardless() + existingWindow.makeKeyAndOrderFront(nil as Any?) + NSApp.activate(ignoringOtherApps: true) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + existingWindow.level = NSWindow.Level.normal + } + return + } + + print("Opening new settings window.") + let currentFolder = SettingsManager.shared.screenshotFolder + let url = currentFolder != nil ? URL(fileURLWithPath: currentFolder!) : nil + let timerValue = SettingsManager.shared.thumbnailTimer + + let newSettingsWindow = SettingsWindow(currentFolder: url, timerValue: timerValue, delegate: self) + self.activeSettingsWindow = newSettingsWindow + + newSettingsWindow.orderFront(nil as Any?) + newSettingsWindow.makeKeyAndOrderFront(nil as Any?) + NSApp.activate(ignoringOtherApps: true) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + newSettingsWindow.orderFront(nil as Any?) + } + } + + private func _saveFromPreview(_ sender: Any) { + print("๐Ÿ’พ Save From Preview button clicked.") + + // ๐ŸŽจ NEW: Check if we're in BGR mode and use the appropriate URL + if let previewMgr = previewManager, previewMgr.isBackgroundRemovalMode { + let bgrSourceURL: URL? + + if previewMgr.isShowingProcessedImage { + bgrSourceURL = previewMgr.processedImageURL + print("๐Ÿ’พ BGR Mode: Saving processed image from \(bgrSourceURL?.path ?? "nil")") + } else { + bgrSourceURL = previewMgr.originalImageURL + print("๐Ÿ’พ BGR Mode: Saving original image from \(bgrSourceURL?.path ?? "nil")") + } + + if let sourceURL = bgrSourceURL { + saveBGRImageToConfiguredFolder(sourceURL: sourceURL, isCalledFromPreviewButton: true) + } else { + print("โŒ BGR Mode: No valid source URL available for save") + } + } else { + // Normal mode - use existing implementation + saveDirectlyToConfiguredFolder(isCalledFromPreviewButton: true) + } + } + + // MARK: - Desktop Icons Toggle Feedback + // DISABLED: These functions cause crashes due to complex animation and window management + /* + private func showDesktopIconsToggleFeedback(hidden: Bool) { + // Create a simple notification-style feedback window + let message = hidden ? "Desktop icons will be hidden during screenshots" : "Desktop icons will be visible during screenshots" + let icon = hidden ? "folder.badge.minus" : "folder.badge.plus" + + // Find the screen where the mouse is for positioning + let mouseLocation = NSEvent.mouseLocation + guard let screen = NSScreen.screens.first(where: { $0.frame.contains(mouseLocation) }) else { return } + + // Create feedback window + let feedbackWindow = createFeedbackWindow(message: message, icon: icon, on: screen) + + // Show and auto-hide the window + feedbackWindow.makeKeyAndOrderFront(nil as Any?) + + // Enhanced Animation with weak self reference and completion handler for safety + var animationWorkItem: DispatchWorkItem? + let workItem = DispatchWorkItem { [weak feedbackWindow, weak self] in + guard let self = self, let strongFeedbackWindow = feedbackWindow else { return } + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.5 // Duration of the fade-out animation + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + strongFeedbackWindow.animator().alphaValue = 0 + }, completionHandler: { + strongFeedbackWindow.close() + // Ensure the work item is nilled out after execution to break potential retain cycles + if animationWorkItem === DispatchWorkItem.cancelled || animationWorkItem?.isCancelled == true { + // Already cancelled, do nothing further + } else { + animationWorkItem = nil // Break cycle after completion + } + }) + } + animationWorkItem = workItem // Assign to the tracking variable + + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: workItem) + } + + private func createFeedbackWindow(message: String, icon: String, on screen: NSScreen) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 60), // Adjusted size + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + + window.isReleasedWhenClosed = true // Changed to true + window.level = .floating // Keep on top + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = false // No shadow for this simple notification + window.isMovableByWindowBackground = false + window.ignoresMouseEvents = true // Click-through + + // Position the window at the center of the specified screen + let screenRect = screen.visibleFrame + let windowRect = window.frame + let xPos = screenRect.origin.x + (screenRect.width - windowRect.width) / 2 + let yPos = screenRect.origin.y + (screenRect.height - windowRect.height) / 2 + window.setFrameOrigin(NSPoint(x: xPos, y: yPos)) + + // Content View with rounded corners and blur + let contentView = NSView() + contentView.wantsLayer = true + contentView.layer?.cornerRadius = 12 // Rounded corners + + let blurView = NSVisualEffectView() + blurView.material = .hudWindow // Or another appropriate material + blurView.blendingMode = .behindWindow + blurView.state = .active + blurView.frame = contentView.bounds + blurView.autoresizingMask = [.width, .height] + contentView.addSubview(blurView) + + // StackView for Icon and Text + let stackView = NSStackView() + stackView.orientation = .horizontal + stackView.alignment = .centerY + stackView.spacing = 10 + stackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stackView) + + // Icon + if let nsIcon = NSImage(systemSymbolName: icon, accessibilityDescription: nil) { + let imageView = NSImageView(image: nsIcon) + imageView.symbolConfiguration = .init(pointSize: 20, weight: .medium) // Larger icon + imageView.contentTintColor = .white // Ensure icon is visible on dark blur + stackView.addArrangedSubview(imageView) + } + + // Text + let textField = NSTextField(labelWithString: message) + textField.textColor = .white // Ensure text is visible + textField.font = .systemFont(ofSize: 14, weight: .medium) + textField.maximumNumberOfLines = 2 + textField.lineBreakMode = .byWordWrapping + stackView.addArrangedSubview(textField) + + // Constraints for StackView + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15), // Padding + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15), // Padding + stackView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + ]) + + window.contentView = contentView + return window + } + */ +} + +// MARK: - GridActionManagerDelegate Implementation +extension ScreenshotApp { + func getRenameActionHandler() -> RenameActionHandler { + return renameActionHandler + } + + func showOrUpdateStash(with imageURL: URL) { + // Call the existing implementation to avoid recursion + _showOrUpdateStash(with: imageURL, onScreen: nil) + } + + func closePreviewWithAnimation(immediate: Bool) { + previewManager.closePreviewWithAnimation(immediate: immediate, preserveTempFile: false) + } + + // NIEUW: Grid frame delegate method + func getGridWindowFrame() -> NSRect? { + return self.gridViewManager?.gridWindow?.frame + } +} + +// MARK: - UpdateManagerDelegate Implementation +extension ScreenshotApp: UpdateManagerDelegate { + func updateCheckDidStart() { + print("๐Ÿ”„ UPDATE: Update check started...") + } + + func updateCheckDidFinish() { + print("โœ… UPDATE: Update check finished") + } + + func updateAvailable(_ update: SUAppcastItem) { + print("๐ŸŽ‰ UPDATE: Update available - \(update.displayVersionString)") + print("๐Ÿ“‹ UPDATE: Current version: \(updateManager.currentVersion)") + print("๐Ÿ“‹ UPDATE: New version: \(update.displayVersionString)") + + // Show update notification + DispatchQueue.main.async { [weak self] in + let alert = NSAlert() + alert.messageText = "Update Available! ๐ŸŽ‰" + alert.informativeText = """ + ShotScreen \(update.displayVersionString) is now available. + You have version \(self?.updateManager.currentVersion ?? "unknown"). + + Would you like to download and install the update? + """ + alert.addButton(withTitle: "Download & Install") + alert.addButton(withTitle: "Skip This Version") + alert.addButton(withTitle: "Remind Me Later") + alert.alertStyle = .informational + + // Set app icon + if let appIcon = NSApplication.shared.applicationIconImage { + alert.icon = appIcon + } + + let response = alert.runModal() + switch response { + case .alertFirstButtonReturn: + print("โœ… UPDATE: User chose to download and install update") + // Sparkle will handle the download and installation automatically + case .alertSecondButtonReturn: + print("โŒ UPDATE: User chose to skip this version") + case .alertThirdButtonReturn: + print("โฐ UPDATE: User chose to be reminded later") + default: + break + } + } + } + + func noUpdateAvailable() { + print("โ„น๏ธ UPDATE: No updates available") + + // Show our simple, clean "no updates" popup + DispatchQueue.main.async { [weak self] in + print("โœ… UPDATE: Showing our custom 'no update' popup") + let alert = NSAlert() + alert.messageText = "You're up to date!" + alert.informativeText = "ShotScreen \(self?.updateManager.currentVersion ?? "unknown") is currently the newest version available." + alert.addButton(withTitle: "OK") + alert.alertStyle = .informational + alert.runModal() + } + } + + func updateCheckFailed(error: Error) { + print("โŒ UPDATE: Update check failed - \(error.localizedDescription)") + + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Update Check Failed" + alert.informativeText = "Could not check for updates: \(error.localizedDescription)" + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.runModal() + } + } +} + +// MARK: - App Entry Point +let app = NSApplication.shared +let delegate = ScreenshotApp() +app.delegate = delegate +app.run() + +extension NSScreen { + static func screenWithMouse(at point: NSPoint? = NSEvent.mouseLocation) -> NSScreen? { + guard let point = point else { return nil } + return NSScreen.screens.first { $0.frame.contains(point) } + } + + var displayID: CGDirectDisplayID { + return deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID ?? 0 + } + + // Add a helper to get localized name for logging, etc. + var customLocalizedName: String { // GEWIJZIGD + if #available(macOS 10.15, *) { + return self.localizedName // Dit roept de native property aan + } else { + // Fallback for older macOS versions if needed, or just use description + return "Screen \(displayID)" + } + } +} + + // ๐Ÿ”ง DEBUG: Force debug update check function for terminal testing + private func performForceDebugUpdate() { + print("๐Ÿ”ง DEBUG: Starting force debug update check...") + print("๐Ÿ”ง DEBUG: This will bypass development detection and show all Sparkle debug info") + print("๐Ÿ”ง DEBUG: ========================================") + + // Initialize UpdateManager and print debug info + UpdateManager.shared.printDebugInfo() + + // Call the force debug update check + UpdateManager.shared.forceDebugUpdateCheck() + + // Keep the run loop alive for a bit to see the results + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + print("๐Ÿ”ง DEBUG: Force debug update test completed") + print("๐Ÿ”ง DEBUG: Exiting in 2 seconds...") + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + NSApplication.shared.terminate(nil) + } + } + } diff --git a/ShotScreen/entitlements.plist b/ShotScreen/entitlements.plist new file mode 100644 index 0000000..8d7cadc --- /dev/null +++ b/ShotScreen/entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.screencapture + + + \ No newline at end of file diff --git a/appcast.xml b/appcast.xml new file mode 100644 index 0000000..50b284c --- /dev/null +++ b/appcast.xml @@ -0,0 +1,31 @@ + + + + ShotScreen Updates + https://git.plet.i234.me/Nick/shotscreen/raw/branch/main/appcast.xml + ShotScreen Updates + en + + + ShotScreen 1.0 + ๐ŸŽ‰ ShotScreen 1.0 - Initial Release +

The first official release of ShotScreen with full screenshot capabilities!

+
    +
  • ๐Ÿ“ธ Advanced screenshot capture modes
  • +
  • ๐Ÿ–ฑ๏ธ Intuitive user interface
  • +
  • ๐Ÿ”„ Automatic update system
  • +
  • ๐Ÿ’พ Smart file management
  • +
  • ๐ŸŽฏ Multi-monitor support
  • +
+ ]]>
+ Fri, 28 Jun 2024 14:00:00 +0000 + + 13.0 +
+
+
diff --git a/bria-rmbg-coreml.mlpackage/Data/com.apple.CoreML/model.mlmodel b/bria-rmbg-coreml.mlpackage/Data/com.apple.CoreML/model.mlmodel new file mode 100644 index 0000000..1a70414 Binary files /dev/null and b/bria-rmbg-coreml.mlpackage/Data/com.apple.CoreML/model.mlmodel differ diff --git a/bria-rmbg-coreml.mlpackage/Data/com.apple.CoreML/weights/weight.bin b/bria-rmbg-coreml.mlpackage/Data/com.apple.CoreML/weights/weight.bin new file mode 100644 index 0000000..3aa5d59 Binary files /dev/null and b/bria-rmbg-coreml.mlpackage/Data/com.apple.CoreML/weights/weight.bin differ diff --git a/bria-rmbg-coreml.mlpackage/Manifest.json b/bria-rmbg-coreml.mlpackage/Manifest.json new file mode 100644 index 0000000..7e156cd --- /dev/null +++ b/bria-rmbg-coreml.mlpackage/Manifest.json @@ -0,0 +1,18 @@ +{ + "fileFormatVersion": "1.0.0", + "itemInfoEntries": { + "216F8D6B-C0C1-4AE6-BEF9-618A1A41B9DD": { + "author": "com.apple.CoreML", + "description": "CoreML Model Weights", + "name": "weights", + "path": "com.apple.CoreML/weights" + }, + "B321596D-B0BF-46B8-8D28-69DBA4172E49": { + "author": "com.apple.CoreML", + "description": "CoreML Model Specification", + "name": "model.mlmodel", + "path": "com.apple.CoreML/model.mlmodel" + } + }, + "rootModelIdentifier": "B321596D-B0BF-46B8-8D28-69DBA4172E49" +} diff --git a/build_release_signed.sh b/build_release_signed.sh new file mode 100755 index 0000000..2a7a57f --- /dev/null +++ b/build_release_signed.sh @@ -0,0 +1,373 @@ +#!/bin/bash + +# ShotScreen - Professional Build & Sign Script +# This script creates a properly signed and notarized build to prevent antivirus false positives + +set -e # Exit on any error + +echo "๐Ÿ”จ Starting professional build process for ShotScreen..." + +# Configuration +APP_NAME="ShotScreen" +BUNDLE_ID="com.shotscreen.app" +DEVELOPER_ID="Developer ID Application: Nick Roodenrijs (HX9B36NN8V)" +KEYCHAIN_PROFILE="ShotScreen-Profile" # Create this with 'xcrun notarytool store-credentials' + +# Build paths - FIXED: Use SwiftPM standard paths like build_app.sh +DIST_DIR="./dist" +APP_DIR="$DIST_DIR/$APP_NAME.app" +CONTENTS_DIR="$APP_DIR/Contents" +MACOS_DIR="$CONTENTS_DIR/MacOS" +RESOURCES_DIR="$CONTENTS_DIR/Resources" +FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks" + +# Create dist directory +rm -rf "$DIST_DIR" +mkdir -p "$DIST_DIR" + +# Read version from Info.plist for DMG naming +if [ -f "Info.plist" ]; then + APP_VERSION=$(plutil -extract CFBundleShortVersionString raw Info.plist 2>/dev/null || echo "1.0") +else + APP_VERSION="1.4" +fi + +DMG_PATH="./dist/$APP_NAME-$APP_VERSION.dmg" + +echo "๐Ÿ“ฆ Building release version..." +# FIXED: Use standard SwiftPM release build like build_app.sh does for debug +swift build --configuration release + +# FIXED: Get the path to the SwiftPM built binary (like build_app.sh) +BINARY_PATH=".build/release/ShotScreen" + +# Verify binary exists +if [ ! -f "$BINARY_PATH" ]; then + echo "โŒ Error: Built binary not found at $BINARY_PATH" + echo "Available files in .build/release/:" + ls -la .build/release/ || echo "No .build/release/ directory found" + exit 1 +fi + +echo "โœ… Found built binary at: $BINARY_PATH" + +echo "๐Ÿ—๏ธ Creating app bundle..." +# Create app bundle structure (like build_app.sh) +mkdir -p "$MACOS_DIR" +mkdir -p "$RESOURCES_DIR" +mkdir -p "$FRAMEWORKS_DIR" + +# Copy binary to app bundle and rename it to ShotScreen +echo "Copying binary to app bundle and renaming to ShotScreen..." +cp "$BINARY_PATH" "$MACOS_DIR/ShotScreen" + +# Verify the copy was successful +if [ -f "$MACOS_DIR/ShotScreen" ]; then + echo "โœ… Binary successfully copied to app bundle" + ls -la "$MACOS_DIR/ShotScreen" +else + echo "โŒ Error: Failed to copy binary to app bundle" + exit 1 +fi + +# Copy Info.plist +echo "Copying Info.plist..." +cp Info.plist "$CONTENTS_DIR/" + +# Copy app icon +echo "Copying app icon..." +if [ -f "./AppIcon.icns" ]; then + cp "./AppIcon.icns" "$RESOURCES_DIR/" + echo "โœ… Copied AppIcon.icns to app bundle Resources" +else + echo "โš ๏ธ Warning: AppIcon.icns not found" +fi + +# Copy resource images +if [ -d "ShotScreen/Sources/images" ]; then + cp -r "ShotScreen/Sources/images" "$RESOURCES_DIR/" 2>/dev/null || true + echo "โœ… Copied resource images" +fi + +# Copy frameworks (using same logic as build_app.sh) +echo "๐Ÿ“ฆ Copying frameworks..." + +# Find and copy Sparkle framework +SPARKLE_FRAMEWORK_PATH="" +for framework_path in .build/release/*.framework .build/*/release/*.framework .build/release/PackageFrameworks/*.framework; do + if [[ -d "$framework_path" && "$(basename "$framework_path")" == "Sparkle.framework" ]]; then + SPARKLE_FRAMEWORK_PATH="$framework_path" + break + fi +done + +if [[ -n "$SPARKLE_FRAMEWORK_PATH" && -d "$SPARKLE_FRAMEWORK_PATH" ]]; then + echo "โœ… Found Sparkle framework at: $SPARKLE_FRAMEWORK_PATH" + cp -R "$SPARKLE_FRAMEWORK_PATH" "$FRAMEWORKS_DIR/" + echo "โœ… Copied Sparkle framework to app bundle" +else + echo "โŒ Warning: Sparkle framework not found in SwiftPM build output" + echo "๐Ÿ” Searching in .build directory:" + find .build -name "*.framework" -type d 2>/dev/null || echo "No frameworks found" + + # Try alternative locations for Sparkle + if [ -d ".build/arm64-apple-macosx/release/Sparkle.framework" ]; then + cp -R ".build/arm64-apple-macosx/release/Sparkle.framework" "$FRAMEWORKS_DIR/" + echo "โœ… Copied Sparkle framework from arm64 location" + else + echo "โŒ Sparkle framework not found!" + exit 1 + fi +fi + +# Configure @rpath for framework loading (like build_app.sh) +echo "๐Ÿ”ง Configuring @rpath for framework loading..." +install_name_tool -add_rpath "@loader_path/../Frameworks" "$MACOS_DIR/ShotScreen" +if [ $? -eq 0 ]; then + echo "โœ… Added @rpath pointing to ../Frameworks directory" +else + echo "โŒ Failed to add @rpath configuration" +fi + +# Create PkgInfo file +echo "APPLSCR" > "$CONTENTS_DIR/PkgInfo" + +# Make the app bundle executable +chmod +x "$MACOS_DIR/ShotScreen" + +echo "โœ๏ธ Code signing with hardened runtime..." + +# Sign frameworks first +echo "๐Ÿ” Signing frameworks..." +for framework in "$FRAMEWORKS_DIR"/*.framework; do + if [ -d "$framework" ]; then + echo " Signing framework: $(basename "$framework")" + codesign --sign "$DEVELOPER_ID" \ + --force \ + --timestamp \ + --options runtime \ + "$framework" + echo " โœ… Signed framework: $(basename "$framework")" + fi +done + +# Sign with hardened runtime to prevent malware-like behavior detection +echo "๐Ÿ” Signing main app bundle..." +codesign --sign "$DEVELOPER_ID" \ + --deep \ + --force \ + --options runtime \ + --entitlements ShotScreen/entitlements.plist \ + --timestamp \ + "$APP_DIR" + +echo "๐Ÿ” Verifying signature..." +codesign --verify --deep --strict "$APP_DIR" +echo "โœ… Code signature is valid" + +# Note: spctl assessment may fail for unnotarized apps, but that's okay for local testing +if spctl --assess --type exec "$APP_DIR" 2>/dev/null; then + echo "โœ… Gatekeeper assessment passed" +else + echo "โš ๏ธ Gatekeeper assessment failed (app needs notarization for distribution)" +fi + +echo "๐Ÿ“ฆ Creating professional DMG with custom background and layout..." +# FIXED: Use unique volume name to avoid conflicts with existing ShotScreen volume +DMG_VOLUME_NAME="$APP_NAME-$APP_VERSION" +# First unmount any existing volumes to prevent conflicts +hdiutil detach "/Volumes/$DMG_VOLUME_NAME" 2>/dev/null || true + +# Create a temporary folder for DMG contents +TEMP_DMG_DIR="./temp_dmg" +rm -rf "$TEMP_DMG_DIR" +mkdir -p "$TEMP_DMG_DIR" + +# Copy app to temp folder +cp -R "$APP_DIR" "$TEMP_DMG_DIR/" + +# Create symlink to Applications folder +echo "๐Ÿ”— Creating Applications symlink..." +ln -s /Applications "$TEMP_DMG_DIR/Applications" + +# Copy background image to temp folder +echo "๐ŸŽจ Adding custom background image..." +if [ -f "./ShotScreen/Sources/images/BannerFinder.png" ]; then + mkdir -p "$TEMP_DMG_DIR/.background" + cp "./ShotScreen/Sources/images/BannerFinder.png" "$TEMP_DMG_DIR/.background/background.png" + echo "โœ… Background image added (BannerFinder.png)" +else + echo "โš ๏ธ Warning: BannerFinder.png not found in ShotScreen/Sources/images/" +fi + +# Create initial DMG (read-write for customization) +TEMP_DMG_PATH="./temp_dmg_rw.dmg" +rm -f "$TEMP_DMG_PATH" + +hdiutil create -volname "$DMG_VOLUME_NAME" \ + -srcfolder "$TEMP_DMG_DIR" \ + -ov \ + -format UDRW \ + -size 100m \ + "$TEMP_DMG_PATH" + +if [ $? -ne 0 ]; then + echo "โŒ DMG creation failed!" + echo "๐Ÿ” Checking if volume is still mounted..." + mount | grep -i shotscreen || echo "No ShotScreen volumes mounted" + # Clean up temp folder + rm -rf "$TEMP_DMG_DIR" + exit 1 +fi + +# Mount the DMG for customization +echo "๐Ÿ”ง Mounting DMG for customization..." +hdiutil attach "$TEMP_DMG_PATH" -mountpoint "/Volumes/$DMG_VOLUME_NAME" -nobrowse + +# Wait for mount to complete +sleep 2 + +# Customize DMG appearance with AppleScript +echo "โœจ Customizing DMG appearance with background and layout..." +cat > dmg_style.applescript << 'EOF' +tell application "Finder" + tell disk "DMG_VOLUME_NAME_PLACEHOLDER" + open + + -- Wait for window to open + delay 3 + + set current view of container window to icon view + set toolbar visible of container window to false + set statusbar visible of container window to false + + -- Smaller window size (500x350 instead of 500x300) + set the bounds of container window to {300, 100, 800, 450} + + set theViewOptions to the icon view options of container window + set arrangement of theViewOptions to not arranged + set icon size of theViewOptions to 96 -- Large icons + set text size of theViewOptions to 12 + + -- Set background picture + set background picture of theViewOptions to file ".background:background.png" + + -- Wait for settings to apply + delay 2 + + -- Position icons with more space + set position of item "ShotScreen.app" of container window to {150, 200} + set position of item "Applications" of container window to {350, 200} + + -- Force update and wait + update without registering applications + delay 3 + + -- Close and reopen to ensure settings stick + close + delay 1 + open + + delay 2 + end tell +end tell +EOF + +# Replace placeholder with actual volume name +sed -i '' "s/DMG_VOLUME_NAME_PLACEHOLDER/$DMG_VOLUME_NAME/g" dmg_style.applescript + +# Apply the styling +echo "๐ŸŽจ Applying AppleScript styling..." +if osascript dmg_style.applescript; then + echo "โœ… AppleScript styling applied successfully" +else + echo "โš ๏ธ AppleScript styling failed, trying alternative method..." + + # Alternative: Set view manually via Finder automation + cat > dmg_style_alt.applescript << 'EOF' +tell application "Finder" + tell disk "DMG_VOLUME_NAME_PLACEHOLDER" + open + delay 5 + + -- Simple settings that usually work + set current view of container window to icon view + delay 1 + + set theViewOptions to the icon view options of container window + set icon size of theViewOptions to 96 + delay 1 + + close + delay 1 + open + delay 2 + end tell +end tell +EOF + + # Replace placeholder and run alternative + sed -i '' "s/DMG_VOLUME_NAME_PLACEHOLDER/$DMG_VOLUME_NAME/g" dmg_style_alt.applescript + osascript dmg_style_alt.applescript 2>/dev/null || echo "โš ๏ธ Alternative styling also failed" + rm -f dmg_style_alt.applescript +fi + +# Clean up AppleScript +rm -f dmg_style.applescript + +# Force create .DS_Store with proper settings +echo "๐Ÿ“ Setting Finder view options..." +if [ -d "/Volumes/$DMG_VOLUME_NAME" ]; then + # Ensure background image is accessible + if [ -f "/Volumes/$DMG_VOLUME_NAME/.background/background.png" ]; then + echo "โœ… Background image found in DMG" + else + echo "โš ๏ธ Background image not found in DMG" + fi + + # Force Finder to acknowledge the DMG + osascript -e "tell application \"Finder\" to open disk \"$DMG_VOLUME_NAME\"" 2>/dev/null || true + sleep 3 + + # Let Finder settle and write .DS_Store + sleep 5 +fi + +# Unmount the DMG +echo "๐Ÿ“ค Unmounting DMG..." +hdiutil detach "/Volumes/$DMG_VOLUME_NAME" + +# Convert to compressed read-only DMG +echo "๐Ÿ—œ๏ธ Converting to final compressed DMG..." +hdiutil convert "$TEMP_DMG_PATH" \ + -format UDZO \ + -o "$DMG_PATH" + +# Clean up temporary files +rm -f "$TEMP_DMG_PATH" +rm -rf "$TEMP_DMG_DIR" + +echo "โœ… Professional DMG created successfully with custom background and layout!" + +echo "โœ๏ธ Signing DMG..." +codesign --sign "$DEVELOPER_ID" \ + --timestamp \ + "$DMG_PATH" + +echo "๐Ÿ“ค Submitting for notarization..." +if xcrun notarytool submit "$DMG_PATH" --keychain-profile "$KEYCHAIN_PROFILE" --wait; then + echo "โœ… Notarization successful!" + echo "๐Ÿ” Stapling notarization ticket to DMG..." + xcrun stapler staple "$DMG_PATH" + echo "โœ… DMG is now notarized and ready for distribution!" +else + echo "โš ๏ธ Notarization failed or timed out" + echo "๐Ÿ’ก You can check status with: xcrun notarytool history --keychain-profile $KEYCHAIN_PROFILE" + echo "๐Ÿ’ก Manual stapling: xcrun stapler staple $DMG_PATH" +fi + +echo "" +echo "โœ… Build complete!" +echo "๐Ÿ“ฆ DMG created at: $DMG_PATH" +echo "โœ… Code signed with Developer ID: Nick Roodenrijs (HX9B36NN8V)" +echo "๐ŸŽฏ DMG ready for hufterproof release script!" \ No newline at end of file diff --git a/dist/ShotScreen-1.0.dmg b/dist/ShotScreen-1.0.dmg new file mode 100644 index 0000000..d69c324 Binary files /dev/null and b/dist/ShotScreen-1.0.dmg differ diff --git a/release_hufterproof_v2.sh b/release_hufterproof_v2.sh new file mode 100755 index 0000000..7c359e3 --- /dev/null +++ b/release_hufterproof_v2.sh @@ -0,0 +1,320 @@ +#!/bin/bash + +# ๐Ÿš€ SUPER HUFTERPROOF ShotScreen Release Script V2 +# Zero HTML, Zero Problems, 100% Bulletproof +# ๐ŸŽฏ NEW: Auto-increment version detection! +# +# Usage: +# ./release_hufterproof_v2.sh # Auto-increment from current version +# ./release_hufterproof_v2.sh 1.15 # Use specific version + +set -e + +# Colors +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +PURPLE='\033[0;35m' +NC='\033[0m' + +# Config +GITEA_URL="https://git.plet.i234.me" +RELEASES_REPO="Nick/shotscreen" + +echo -e "${PURPLE}๐Ÿš€ SUPER HUFTERPROOF Release Script V2${NC}" +echo -e "${PURPLE}=====================================${NC}" + +# Get current version from Info.plist and auto-increment +get_next_version() { + if [ -f "Info.plist" ]; then + CURRENT_VERSION=$(plutil -extract CFBundleShortVersionString raw Info.plist 2>/dev/null || echo "1.0") + echo -e "${BLUE}๐Ÿ” Current version: $CURRENT_VERSION${NC}" + + # Split version into parts (e.g., "1.12" -> major=1, minor=12) + IFS='.' read -r MAJOR MINOR <<< "$CURRENT_VERSION" + + # Increment minor version + NEXT_MINOR=$((MINOR + 1)) + SUGGESTED_VERSION="$MAJOR.$NEXT_MINOR" + + echo -e "${GREEN}๐Ÿš€ Suggested next version: $SUGGESTED_VERSION${NC}" + return 0 + else + SUGGESTED_VERSION="1.0" + echo -e "${YELLOW}โš ๏ธ No Info.plist found, suggesting: $SUGGESTED_VERSION${NC}" + return 1 + fi +} + +# Determine version +NEW_VERSION="$1" +if [ -z "$NEW_VERSION" ]; then + get_next_version + echo -e "${YELLOW}Press ENTER to use suggested version ($SUGGESTED_VERSION) or type custom version:${NC}" + read -r USER_INPUT + + if [ -z "$USER_INPUT" ]; then + NEW_VERSION="$SUGGESTED_VERSION" + echo -e "${GREEN}โœ… Using suggested version: $NEW_VERSION${NC}" + else + NEW_VERSION="$USER_INPUT" + echo -e "${BLUE}โœ… Using custom version: $NEW_VERSION${NC}" + fi +fi + +echo -e "${BLUE}๐Ÿ“‹ Building version: $NEW_VERSION${NC}" + +# Update Info.plist +echo -e "${BLUE}๐Ÿ“ Updating Info.plist...${NC}" +plutil -replace CFBundleVersion -string "$NEW_VERSION" Info.plist +plutil -replace CFBundleShortVersionString -string "$NEW_VERSION" Info.plist + +# Update build_release_signed.sh with new version +echo -e "${BLUE}๐Ÿ“ Updating build script version...${NC}" +sed -i '' "s/APP_VERSION=\".*\"/APP_VERSION=\"$NEW_VERSION\"/" build_release_signed.sh + +# Build app with Developer ID signing and notarization +echo -e "${BLUE}๐Ÿ”จ Building app with Developer ID signing...${NC}" +if ! ./build_release_signed.sh; then + echo -e "${RED}โŒ Build failed${NC}" + exit 1 +fi + +# Check DMG exists +DMG_PATH="./dist/ShotScreen-$NEW_VERSION.dmg" +if [ ! -f "$DMG_PATH" ]; then + echo -e "${RED}โŒ DMG not found: $DMG_PATH${NC}" + exit 1 +fi + +# Get signature and size +echo -e "${BLUE}๐Ÿ” Getting signature...${NC}" +SIG_OUTPUT=$(./.build/artifacts/sparkle/Sparkle/bin/sign_update "$DMG_PATH") +SIGNATURE=$(echo "$SIG_OUTPUT" | grep -o 'sparkle:edSignature="[^"]*"' | cut -d'"' -f2) +DMG_SIZE=$(stat -f%z "$DMG_PATH") + +if [ -z "$SIGNATURE" ]; then + echo -e "${RED}โŒ Signature generation failed${NC}" + exit 1 +fi + +echo -e "${GREEN}โœ… Signature: ${SIGNATURE:0:20}...${NC}" +echo -e "${GREEN}โœ… Size: $DMG_SIZE bytes${NC}" + +# Get release notes from TXT file (preserve original formatting) +RELEASE_NOTES=$(awk ' + /^ShotScreen [0-9]/ { + if (count++ > 0) exit + next + } + /^=+$/ { next } + /^[A-Za-z]/ && !/^ShotScreen/ && NF > 0 { + print $0 + } + /^ / && NF > 0 { + print $0 + } +' release_notes.txt) + +if [ -z "$RELEASE_NOTES" ]; then + RELEASE_NOTES="Release version $NEW_VERSION" +fi + +# Create Gitea release +echo -e "${BLUE}๐Ÿ“ฆ Creating Gitea release...${NC}" + +# Format release notes beautifully for Gitea (markdown format) +FORMATTED_NOTES="## ๐Ÿš€ ShotScreen $NEW_VERSION Features + +$RELEASE_NOTES" + +# Clean JSON (proper escaping for multiline text) +CLEAN_NOTES=$(printf "%s" "$FORMATTED_NOTES" | sed 's/"/\\"/g' | awk '{printf "%s\\n", $0}' | sed 's/\\n$//') + +JSON_PAYLOAD="{ + \"tag_name\": \"v$NEW_VERSION\", + \"name\": \"ShotScreen v$NEW_VERSION\", + \"body\": \"$CLEAN_NOTES\", + \"draft\": false, + \"prerelease\": false +}" + +RESPONSE=$(curl -s -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$JSON_PAYLOAD" \ + "$GITEA_URL/api/v1/repos/$RELEASES_REPO/releases") + +RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2) + +if [ -z "$RELEASE_ID" ]; then + echo -e "${YELLOW}โš ๏ธ Checking for existing release...${NC}" + EXISTING=$(curl -s "$GITEA_URL/api/v1/repos/$RELEASES_REPO/releases/tags/v$NEW_VERSION") + RELEASE_ID=$(echo "$EXISTING" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2) +fi + +if [ -z "$RELEASE_ID" ]; then + echo -e "${RED}โŒ Could not create/find release${NC}" + exit 1 +fi + +echo -e "${GREEN}โœ… Release ID: $RELEASE_ID${NC}" + +# Upload DMG +echo -e "${BLUE}๐Ÿ“ค Uploading DMG...${NC}" +UPLOAD_RESPONSE=$(curl -s -X POST \ + -H "Authorization: token $GITEA_TOKEN" \ + -F "attachment=@$DMG_PATH" \ + "$GITEA_URL/api/v1/repos/$RELEASES_REPO/releases/$RELEASE_ID/assets") + +ASSET_ID=$(echo "$UPLOAD_RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2) + +if [ -z "$ASSET_ID" ]; then + echo -e "${RED}โŒ DMG upload failed${NC}" + exit 1 +fi + +echo -e "${GREEN}โœ… DMG uploaded${NC}" + +# Create super simple appcast (no HTML, just plain text) +echo -e "${BLUE}๐Ÿ“„ Creating simple appcast...${NC}" + +# Convert plain text to simple HTML for appcast +HTML_NOTES=$(echo "$RELEASE_NOTES" | sed 's/^- /
  • /' | sed 's/$/<\/li>/') +if [[ "$HTML_NOTES" == *"
  • "* ]]; then + HTML_NOTES="
      $HTML_NOTES
    " +fi + +# Clean up any old appcast first +rm -f appcast.xml + +echo -e "${BLUE}๐Ÿ“„ Generating appcast for version $NEW_VERSION...${NC}" +cat > appcast.xml << EOF + + + + ShotScreen Updates + $GITEA_URL/$RELEASES_REPO/raw/branch/main/appcast.xml + ShotScreen Updates + en + + + ShotScreen $NEW_VERSION + ShotScreen $NEW_VERSION + $HTML_NOTES + ]]> + $(date -R) + + 13.0 + + + +EOF + +# Validate appcast +if ! xmllint --noout appcast.xml 2>/dev/null; then + echo -e "${RED}โŒ Appcast validation failed${NC}" + exit 1 +fi + +echo -e "${GREEN}โœ… Valid appcast created${NC}" + +# Debug: Show what we're about to deploy +echo -e "${BLUE}๐Ÿ” DEBUG: Appcast content preview:${NC}" +echo "Version: $(grep 'sparkle:version=' appcast.xml | head -1)" +echo "URL: $(grep 'enclosure url=' appcast.xml | head -1 | cut -d'"' -f2)" + +# Deploy appcast +echo -e "${BLUE}๐Ÿš€ Deploying appcast...${NC}" + +RELEASES_DIR="../shotscreen" + +# Clean up any existing problematic repository +if [ -d "$RELEASES_DIR" ]; then + echo -e "${YELLOW}๐Ÿงน Cleaning up existing repository...${NC}" + rm -rf "$RELEASES_DIR" +fi + +# Create fresh directory and clone +mkdir -p "$RELEASES_DIR" +cd "$RELEASES_DIR" + +echo -e "${BLUE}๐Ÿ“ฅ Fresh clone from remote...${NC}" +git clone "$GITEA_URL/$RELEASES_REPO.git" . || { + echo -e "${YELLOW}โš ๏ธ Remote repository doesn't exist or clone failed, creating new repo...${NC}" + git init + git remote add origin "$GITEA_URL/$RELEASES_REPO.git" +} + +# Configure git for this repository +git config pull.rebase false # Use merge instead of rebase +git config user.name "ShotScreen Release Bot" || true +git config user.email "releases@shotscreen.app" || true + +# Backup old appcast for comparison +if [ -f "appcast.xml" ]; then + echo -e "${BLUE}๐Ÿ“‹ Backing up old appcast...${NC}" + cp "appcast.xml" "appcast.xml.backup" + echo "Old version: $(grep 'sparkle:version=' "appcast.xml" | head -1)" +fi + +# Copy new appcast +cd - > /dev/null +cp appcast.xml "$RELEASES_DIR/" +cd "$RELEASES_DIR" + +# Add and commit +git add appcast.xml +git commit -m "Deploy appcast for v$NEW_VERSION" || true + +# Push with force if needed (since we're authoritative for appcast) +echo -e "${BLUE}๐Ÿ“ค Pushing appcast to remote...${NC}" +if ! git push origin main; then + echo -e "${YELLOW}โš ๏ธ Normal push failed, force pushing appcast update...${NC}" + git push origin main --force +fi + +cd - > /dev/null + +# Test everything +echo -e "${BLUE}๐Ÿงช Testing...${NC}" +sleep 2 + +# Test appcast download +if ! curl -s "$GITEA_URL/$RELEASES_REPO/raw/branch/main/appcast.xml" | grep -q "$NEW_VERSION"; then + echo -e "${RED}โŒ Appcast test failed${NC}" + exit 1 +fi + +# Test DMG download +if ! curl -I -s "$GITEA_URL/$RELEASES_REPO/releases/download/v$NEW_VERSION/ShotScreen-$NEW_VERSION.dmg" | grep -q "200"; then + echo -e "${RED}โŒ DMG test failed${NC}" + exit 1 +fi + +# Git operations +echo -e "${BLUE}๐Ÿ“ Git operations...${NC}" +git add . +git commit -m "Release v$NEW_VERSION" || true +git tag -a "v$NEW_VERSION" -m "Release v$NEW_VERSION" || true +git push -u origin main || git push origin main --force +git push origin "v$NEW_VERSION" || true + +echo +echo -e "${GREEN}๐ŸŽ‰ SUPER HUFTERPROOF RELEASE COMPLETE! ๐ŸŽ‰${NC}" +echo -e "${GREEN}======================================${NC}" +echo -e "${GREEN}โœ… Version: $NEW_VERSION${NC}" +echo -e "${GREEN}โœ… DMG Size: $DMG_SIZE bytes${NC}" +echo -e "${GREEN}โœ… All tests: PASSED${NC}" +echo +echo -e "${BLUE}๐ŸŒ Release: $GITEA_URL/$RELEASES_REPO/releases/tag/v$NEW_VERSION${NC}" +echo -e "${BLUE}๐Ÿ“ก Appcast: $GITEA_URL/$RELEASES_REPO/raw/branch/main/appcast.xml${NC}" +echo +echo -e "${YELLOW}๐ŸŽฏ Ready to test updates on other computer!${NC}" \ No newline at end of file