🎉 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.
This commit is contained in:
2025-06-28 16:15:15 +02:00
commit 0dabed11d2
63 changed files with 25727 additions and 0 deletions

BIN
AppIcon.icns Normal file

Binary file not shown.

67
Info.plist Normal file
View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>ShotScreen</string>
<key>CFBundleExecutable</key>
<string>ShotScreen</string>
<key>CFBundleGetInfoString</key>
<string>ShotScreen - Professional Screenshot Tool</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>com.shotscreen.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ShotScreen</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.3</string>
<key>NSRequiresCertificateTransparency</key>
<true/>
</dict>
<key>NSApplicationDescription</key>
<string>Professional screenshot and screen capture utility for macOS</string>
<key>NSCameraUsageDescription</key>
<string>ShotScreen needs camera access to capture screenshots.</string>
<key>NSDesktopFolderUsageDescription</key>
<string>ShotScreen needs access to save screenshots to your Desktop.</string>
<key>NSDocumentsFolderUsageDescription</key>
<string>ShotScreen needs access to save screenshots to your Documents folder.</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>© 2025 ShotScreen. All rights reserved.</string>
<key>NSScreenCaptureUsageDescription</key>
<string>ShotScreen is a professional screenshot tool that captures images of your screen and windows to help you save and organize screenshots efficiently.</string>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<key>SUAutomaticallyUpdate</key>
<true/>
<key>SUCheckAtStartup</key>
<true/>
<key>SUFeedURL</key>
<string>https://git.plet.i234.me/Nick/shotscreen/raw/branch/main/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>q0Ia/obtuDugqhwa1aSZsQAqZxQsdgX3y4K9wuqkemM=</string>
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
</dict>
</plist>

23
Package.resolved Normal file
View File

@@ -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
}

59
Package.swift Executable file
View File

@@ -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")
]
)
]
)

Binary file not shown.

Binary file not shown.

BIN
Pixelmator images/Logo.pxd Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
ShotScreen/.DS_Store vendored Normal file

Binary file not shown.

BIN
ShotScreen/.build/build.db Executable file

Binary file not shown.

5
ShotScreen/.build/debug Executable file
View File

@@ -0,0 +1,5 @@
XSym
0024
65d970057a31dc065e5b25921548a548
arm64-apple-macosx/debug

100
ShotScreen/.build/debug.yaml Executable file
View File

@@ -0,0 +1,100 @@
client:
name: basic
file-system: device-agnostic
tools: {}
targets:
"HotKey-arm64-apple-macosx15.0-debug.module": ["<HotKey-arm64-apple-macosx15.0-debug.module>"]
"PackageStructure": ["<PackageStructure>"]
"ScreenShot-arm64-apple-macosx15.0-debug.exe": ["<ScreenShot-arm64-apple-macosx15.0-debug.exe>"]
"ScreenShot-arm64-apple-macosx15.0-debug.module": ["<ScreenShot-arm64-apple-macosx15.0-debug.module>"]
"main": ["<ScreenShot-arm64-apple-macosx15.0-debug.exe>","<ScreenShot-arm64-apple-macosx15.0-debug.module>"]
"test": ["<ScreenShot-arm64-apple-macosx15.0-debug.exe>","<ScreenShot-arm64-apple-macosx15.0-debug.module>"]
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: ["<sources-file-list>","/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: ["<entitlement-plist>","<com.apple.security.get-task-allow>"]
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: ["<sources-file-list>","/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: ["<link-file-list>","/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: ["<swift-get-version>","/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"
"<HotKey-arm64-apple-macosx15.0-debug.module>":
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: ["<HotKey-arm64-apple-macosx15.0-debug.module>"]
"<ScreenShot-arm64-apple-macosx15.0-debug.exe>":
tool: phony
inputs: ["<ScreenShot-arm64-apple-macosx15.0-debug.exe-CodeSigning>"]
outputs: ["<ScreenShot-arm64-apple-macosx15.0-debug.exe>"]
"<ScreenShot-arm64-apple-macosx15.0-debug.module>":
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: ["<ScreenShot-arm64-apple-macosx15.0-debug.module>"]
"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: ["<ScreenShot-arm64-apple-macosx15.0-debug.exe-CodeSigning>"]
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: ["<PackageStructure>"]
description: "Planning build"
allow-missing-inputs: true

View File

@@ -0,0 +1,100 @@
client:
name: basic
file-system: device-agnostic
tools: {}
targets:
"HotKey-arm64-apple-macosx15.0-debug.module": ["<HotKey-arm64-apple-macosx15.0-debug.module>"]
"PackageStructure": ["<PackageStructure>"]
"ScreenShot-arm64-apple-macosx15.0-debug.exe": ["<ScreenShot-arm64-apple-macosx15.0-debug.exe>"]
"ScreenShot-arm64-apple-macosx15.0-debug.module": ["<ScreenShot-arm64-apple-macosx15.0-debug.module>"]
"main": ["<ScreenShot-arm64-apple-macosx15.0-debug.exe>","<ScreenShot-arm64-apple-macosx15.0-debug.module>"]
"test": ["<ScreenShot-arm64-apple-macosx15.0-debug.exe>","<ScreenShot-arm64-apple-macosx15.0-debug.module>"]
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: ["<sources-file-list>","/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: ["<entitlement-plist>","<com.apple.security.get-task-allow>"]
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: ["<sources-file-list>","/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: ["<link-file-list>","/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: ["<swift-get-version>","/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"
"<HotKey-arm64-apple-macosx15.0-debug.module>":
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: ["<HotKey-arm64-apple-macosx15.0-debug.module>"]
"<ScreenShot-arm64-apple-macosx15.0-debug.exe>":
tool: phony
inputs: ["<ScreenShot-arm64-apple-macosx15.0-debug.exe-CodeSigning>"]
outputs: ["<ScreenShot-arm64-apple-macosx15.0-debug.exe>"]
"<ScreenShot-arm64-apple-macosx15.0-debug.module>":
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: ["<ScreenShot-arm64-apple-macosx15.0-debug.module>"]
"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: ["<ScreenShot-arm64-apple-macosx15.0-debug.exe-CodeSigning>"]
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: ["<PackageStructure>"]
description: "Planning build"
allow-missing-inputs: true

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}

View File

@@ -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)"
}
}

View File

@@ -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()
}
}
}

View File

@@ -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
}
}

View File

@@ -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: [:])
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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<AnyView>?
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

View File

@@ -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")
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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()
}

View File

@@ -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")
}

View File

@@ -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 }
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}

View File

@@ -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<NSImage?, Never>?
// 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
}
}
}

View File

@@ -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
}
}

View File

@@ -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)"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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..<maxLength {
print("🔍 UPDATE: Comparing index \(i): \(latestPadded[i]) vs \(currentPadded[i])")
if latestPadded[i] > 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
}
}

View File

@@ -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)")
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.screencapture</key>
<true/>
</dict>
</plist>

31
appcast.xml Normal file
View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>ShotScreen Updates</title>
<link>https://git.plet.i234.me/Nick/shotscreen/raw/branch/main/appcast.xml</link>
<description>ShotScreen Updates</description>
<language>en</language>
<item>
<title>ShotScreen 1.0</title>
<description><![CDATA[
<h2>🎉 ShotScreen 1.0 - Initial Release</h2>
<p>The first official release of ShotScreen with full screenshot capabilities!</p>
<ul>
<li>📸 Advanced screenshot capture modes</li>
<li>🖱️ Intuitive user interface</li>
<li>🔄 Automatic update system</li>
<li>💾 Smart file management</li>
<li>🎯 Multi-monitor support</li>
</ul>
]]></description>
<pubDate>Fri, 28 Jun 2024 14:00:00 +0000</pubDate>
<enclosure url="https://git.plet.i234.me/Nick/shotscreen/releases/download/v1.0/ShotScreen-1.0.dmg"
sparkle:version="1.0"
sparkle:shortVersionString="1.0"
length="11218106"
type="application/octet-stream" />
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
</item>
</channel>
</rss>

View File

@@ -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"
}

373
build_release_signed.sh Executable file
View File

@@ -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!"

BIN
dist/ShotScreen-1.0.dmg vendored Normal file

Binary file not shown.

320
release_hufterproof_v2.sh Executable file
View File

@@ -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/^- /<li>/' | sed 's/$/<\/li>/')
if [[ "$HTML_NOTES" == *"<li>"* ]]; then
HTML_NOTES="<ul>$HTML_NOTES</ul>"
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
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>ShotScreen Updates</title>
<link>$GITEA_URL/$RELEASES_REPO/raw/branch/main/appcast.xml</link>
<description>ShotScreen Updates</description>
<language>en</language>
<item>
<title>ShotScreen $NEW_VERSION</title>
<description><![CDATA[
<h2>ShotScreen $NEW_VERSION</h2>
$HTML_NOTES
]]></description>
<pubDate>$(date -R)</pubDate>
<enclosure url="$GITEA_URL/$RELEASES_REPO/releases/download/v$NEW_VERSION/ShotScreen-$NEW_VERSION.dmg"
sparkle:version="$NEW_VERSION"
sparkle:shortVersionString="$NEW_VERSION"
length="$DMG_SIZE"
type="application/octet-stream"
sparkle:edSignature="$SIGNATURE" />
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
</item>
</channel>
</rss>
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}"