🎉 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.
BIN
AppIcon.icns
Normal file
67
Info.plist
Normal 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
@@ -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
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
BIN
Pixelmator images/BalloonS.pxd
Normal file
BIN
Pixelmator images/Banner.pxd
Normal file
BIN
Pixelmator images/Logo.pxd
Normal file
BIN
Pixelmator images/Logo_200x200px.pxd
Normal file
BIN
Pixelmator images/MenuBarIcon.pxd
Normal file
BIN
Pixelmator images/Thumbnail_Met_Achtergrond.pxd
Normal file
BIN
ShotScreen/.DS_Store
vendored
Normal file
BIN
ShotScreen/.build/build.db
Executable file
5
ShotScreen/.build/debug
Executable file
@@ -0,0 +1,5 @@
|
||||
XSym
|
||||
0024
|
||||
65d970057a31dc065e5b25921548a548
|
||||
arm64-apple-macosx/debug
|
||||
|
||||
100
ShotScreen/.build/debug.yaml
Executable 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
|
||||
|
||||
100
ShotScreen/.build/plugin-tools.yaml
Executable 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
|
||||
|
||||
1615
ShotScreen/Sources/BackgroundRemover.swift
Normal file
236
ShotScreen/Sources/ButtonHoverExtension.swift
Normal 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
|
||||
}
|
||||
}
|
||||
88
ShotScreen/Sources/Config.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
379
ShotScreen/Sources/CrosshairViews.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
186
ShotScreen/Sources/DesktopIconManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
213
ShotScreen/Sources/DraggableImageView.swift
Normal 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: [:])
|
||||
}
|
||||
}
|
||||
280
ShotScreen/Sources/EventCapture.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
163
ShotScreen/Sources/FeedbackBubblePanel.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
64
ShotScreen/Sources/FinderWindowManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
606
ShotScreen/Sources/FirstLaunchWizard.swift
Normal 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
|
||||
443
ShotScreen/Sources/GridActionManager.swift
Normal 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")
|
||||
}
|
||||
194
ShotScreen/Sources/GridCellView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
515
ShotScreen/Sources/GridComponents.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
2666
ShotScreen/Sources/IntegratedGalleryView.swift
Normal file
473
ShotScreen/Sources/LicenseEntryView.swift
Normal 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()
|
||||
}
|
||||
605
ShotScreen/Sources/LicenseManager.swift
Normal 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")
|
||||
}
|
||||
783
ShotScreen/Sources/MenuManager.swift
Normal 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 }
|
||||
}
|
||||
1263
ShotScreen/Sources/MultiMonitorSystem.swift
Normal file
253
ShotScreen/Sources/OverlayComponents.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
1804
ShotScreen/Sources/PreviewManager.swift
Normal file
711
ShotScreen/Sources/RenameActionHandler.swift
Executable 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
|
||||
}
|
||||
}
|
||||
318
ShotScreen/Sources/ScreenCaptureKitProvider.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
962
ShotScreen/Sources/SettingsManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
413
ShotScreen/Sources/SettingsModels.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
2868
ShotScreen/Sources/SettingsUI.swift
Normal file
1140
ShotScreen/Sources/StashDraggableImageView.swift
Normal file
83
ShotScreen/Sources/SwiftUIViews.swift
Normal 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
|
||||
}
|
||||
}
|
||||
176
ShotScreen/Sources/ThemeManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
446
ShotScreen/Sources/UpdateManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
998
ShotScreen/Sources/WindowCaptureManager.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
ShotScreen/Sources/images/BannerFinder.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
BIN
ShotScreen/Sources/images/FinderBackground.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
ShotScreen/Sources/images/MenuIcon.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
ShotScreen/Sources/images/ShotScreenIcon.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
ShotScreen/Sources/images/ShotScreenIcon_200x200.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
ShotScreen/Sources/images/ShotScreenIcon_600x600.png
Normal file
|
After Width: | Height: | Size: 379 KiB |
|
After Width: | Height: | Size: 342 KiB |
BIN
ShotScreen/Sources/images/ShotScreen_Banner.png
Normal file
|
After Width: | Height: | Size: 518 KiB |
BIN
ShotScreen/Sources/images/Wizard_TurnOffSceenShot.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
3679
ShotScreen/Sources/main.swift
Normal file
8
ShotScreen/entitlements.plist
Normal 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
@@ -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>
|
||||
BIN
bria-rmbg-coreml.mlpackage/Data/com.apple.CoreML/model.mlmodel
Normal file
18
bria-rmbg-coreml.mlpackage/Manifest.json
Normal 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
@@ -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
320
release_hufterproof_v2.sh
Executable 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}"
|
||||