🎉 ShotScreen v1.0 - Initial Release

🚀 First official release of ShotScreen with complete feature set:

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

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

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

This is the foundation release that establishes ShotScreen as a premium screenshot tool for macOS.
This commit is contained in:
2025-06-28 16:15:15 +02:00
commit 0dabed11d2
63 changed files with 25727 additions and 0 deletions

View File

@@ -0,0 +1,443 @@
import AppKit
import Vision
import SwiftUI
// MARK: - Grid Action Manager
protocol GridActionManagerDelegate: AnyObject {
func showOrUpdateStash(with imageURL: URL)
func closePreviewWithAnimation(immediate: Bool)
func ensurePreviewVisible()
func getLastImage() -> NSImage?
func getTempURL() -> URL?
func getActivePreviewWindow() -> NSWindow?
func getRenameActionHandler() -> RenameActionHandler
func flashPreviewBorder()
func disableMonitoring()
func enableMonitoring()
func gridViewManagerHideGrid(monitorForReappear: Bool)
func getGridCurrentFrame() -> NSRect?
// 🎨 NEW: Background removal thumbnail workflow with original URL
func showBackgroundRemovalThumbnail(with image: NSImage, originalURL: URL)
}
class GridActionManager {
weak var delegate: GridActionManagerDelegate?
init(delegate: GridActionManagerDelegate) {
self.delegate = delegate
}
// MARK: - Grid Action Handler
func handleGridAction(imageURL: URL, cellIndex: Int, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) {
print("✅ GridActionManager: Handling action for cell \(cellIndex)")
// Get the action type based on the dynamic action order
let actionOrder = SettingsManager.shared.actionOrder
guard cellIndex < actionOrder.count else {
print(" Cell index \(cellIndex) out of range for action order.")
completion(false, false, false)
return
}
let actionType = actionOrder[cellIndex]
switch actionType {
case .rename:
print("📝 Rename action triggered for \(imageURL.path)")
handleRenameAction(imageURL: imageURL, completion: completion)
case .stash:
print("✨ Stash action triggered for \(imageURL.path)")
handleStashAction(imageURL: imageURL, completion: completion)
case .ocr:
print("📑 OCR action triggered for \(imageURL.path)")
handleOCRAction(imageURL: imageURL, dropPoint: dropPoint, gridWindow: gridWindow, completion: completion)
case .clipboard:
print("📋 Clipboard action triggered for \(imageURL.path)")
handleClipboardAction(imageURL: imageURL, dropPoint: dropPoint, gridWindow: gridWindow, completion: completion)
case .backgroundRemove:
print("🎨 Background Remove action triggered for \(imageURL.path)")
handleBackgroundRemoveAction(imageURL: imageURL, dropPoint: dropPoint, gridWindow: gridWindow, completion: completion)
case .cancel:
print("🚫 Cancel action triggered")
handleCancelAction(completion: completion)
case .remove:
print("🗑 Remove action triggered")
handleRemoveAction(completion: completion)
}
}
// MARK: - Individual Action Handlers
private func handleRenameAction(imageURL: URL, completion: @escaping (Bool, Bool, Bool) -> Void) {
print("🔍 DEBUG: handleRenameAction called with URL: \(imageURL)")
print("🔍 DEBUG: File exists at URL: \(FileManager.default.fileExists(atPath: imageURL.path))")
delegate?.getRenameActionHandler().promptAndRename(originalURL: imageURL) { responseForRename in
let successfulAction = (responseForRename != .alertThirdButtonReturn)
let saveToFolderTriggered = (responseForRename == .alertSecondButtonReturn)
print("🔍 DEBUG: Rename response: \(responseForRename), successful: \(successfulAction)")
completion(successfulAction, saveToFolderTriggered, false)
}
}
private func handleStashAction(imageURL: URL, completion: @escaping (Bool, Bool, Bool) -> Void) {
print("✨ Stash action triggered for \(imageURL.path)")
// CRITICALLY IMPORTANT: Close main grid immediately after stash action
print("🔶 STASH ACTION: Closing main grid and disabling proximity monitoring")
delegate?.gridViewManagerHideGrid(monitorForReappear: false)
delegate?.showOrUpdateStash(with: imageURL)
completion(true, false, true) // Pass true for isStashAction
}
private func handleOCRAction(imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) {
delegate?.disableMonitoring()
performOcrAndCopy(from: imageURL, dropPoint: dropPoint, gridWindow: gridWindow) { success in
// Completion for performOcrAndCopy is now async after panel closes
// The actual success of OCR (text found vs. no text) is handled by the message in the panel.
// For the grid action itself, we consider it 'successful' if the process ran.
self.delegate?.enableMonitoring()
self.delegate?.gridViewManagerHideGrid(monitorForReappear: false)
completion(true, false, false) // OCR action always 'succeeds' in terms of grid interaction
}
}
private func handleClipboardAction(imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) {
delegate?.disableMonitoring()
copyImageToClipboard(from: imageURL, dropPoint: dropPoint, gridWindow: gridWindow) {
self.delegate?.enableMonitoring()
self.delegate?.gridViewManagerHideGrid(monitorForReappear: false)
completion(true, false, false)
}
}
private func handleBackgroundRemoveAction(imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) {
performBackgroundRemove(from: imageURL, dropPoint: dropPoint, gridWindow: gridWindow)
completion(true, false, false)
}
private func handleCancelAction(completion: @escaping (Bool, Bool, Bool) -> Void) {
// Handle cancel action through delegate callback mechanism
NotificationCenter.default.post(name: .gridActionCancelRequested, object: nil)
completion(false, false, false)
}
private func handleRemoveAction(completion: @escaping (Bool, Bool, Bool) -> Void) {
// Handle remove action through delegate callback mechanism
NotificationCenter.default.post(name: .gridActionRemoveRequested, object: nil)
completion(false, false, false)
}
// MARK: - OCR Implementation
private func performOcrAndCopy(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool) -> Void) {
// Intel Mac compatible Vision framework OCR
guard #available(macOS 10.15, *) else {
print("❌ OCR requires macOS 10.15 or later")
if let gridFrame = delegate?.getGridCurrentFrame() {
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
panel.show(aroundGridFrame: gridFrame, text: "OCR requires macOS 10.15+", autoCloseAfter: 1.8, onAutoCloseCompletion: {
completion(false)
})
} else {
completion(false)
}
return
}
guard let nsImage = NSImage(contentsOf: imageURL) else {
print("❌ OCR: Could not load image from URL: \(imageURL.path)")
if let gridFrame = delegate?.getGridCurrentFrame() {
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
panel.show(aroundGridFrame: gridFrame, text: "Image load failed for OCR", autoCloseAfter: 1.8, onAutoCloseCompletion: {
completion(false)
})
} else {
completion(false)
}
return
}
guard let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
print("❌ OCR: Could not obtain CGImage")
if let gridFrame = delegate?.getGridCurrentFrame() {
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
panel.show(aroundGridFrame: gridFrame, text: "Could not get image data for OCR", autoCloseAfter: 1.8, onAutoCloseCompletion: {
completion(false)
})
} else {
completion(false)
}
return
}
let request = VNRecognizeTextRequest { [weak self] request, error in
guard let self = self else { completion(false); return }
var message: String
var ocrDidFindText = false
if let err = error {
print("❌ OCR error: \(err.localizedDescription)")
message = "OCR error: \(err.localizedDescription)"
} else {
let observations = request.results as? [VNRecognizedTextObservation] ?? []
let recognizedText = observations.compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\n")
if recognizedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
message = "No text found"
} else {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(recognizedText, forType: .string)
message = "Text copied to clipboard!"
ocrDidFindText = true
}
}
DispatchQueue.main.async {
if let gridFrame = self.delegate?.getGridCurrentFrame() {
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Processing...")
panel.show(aroundGridFrame: gridFrame, text: message, autoCloseAfter: 1.8, onAutoCloseCompletion: {
completion(ocrDidFindText)
})
} else {
completion(ocrDidFindText) // Call completion if gridFrame is nil
}
}
}
request.recognitionLevel = VNRequestTextRecognitionLevel.accurate
request.usesLanguageCorrection = true
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
DispatchQueue.global(qos: .userInitiated).async {
do {
try handler.perform([request])
} catch {
print("❌ OCR: Failed to perform request \(error.localizedDescription)")
DispatchQueue.main.async {
self.showToast(message: "OCR failed", near: dropPoint, gridWindow: gridWindow)
}
}
}
}
// MARK: - Clipboard Implementation
private func copyImageToClipboard(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping () -> Void) {
guard let nsImage = NSImage(contentsOf: imageURL) else {
print("❌ Clipboard: Could not load image from URL: \(imageURL.path)")
// Consider showing error with FeedbackBubblePanel if gridFrame is available
if let gridFrame = delegate?.getGridCurrentFrame() {
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
panel.show(aroundGridFrame: gridFrame, text: "Image load failed for Clipboard", autoCloseAfter: 2.0)
panel.closeWithAnimation { completion() }
} else {
completion()
}
return
}
NSPasteboard.general.clearContents()
NSPasteboard.general.writeObjects([nsImage])
if let gridFrame = delegate?.getGridCurrentFrame() {
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Copied!")
panel.show(aroundGridFrame: gridFrame, text: "Copied to clipboard!", autoCloseAfter: nil)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
panel.closeWithAnimation {
completion()
}
}
} else {
print("⚠️ Could not get grid frame to show feedback bubble for clipboard.")
completion()
}
}
// MARK: - Background Remove Implementation
private func performBackgroundRemove(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?) {
print("🎨 Background Remove action triggered for \(imageURL.path)")
// 🔄 ENHANCED: Better error handling and recovery for image loading
var finalImageURL = imageURL
var originalImage: NSImage? = nil
// First attempt: Direct load
originalImage = NSImage(contentsOf: finalImageURL)
// If failed, try recovery strategies
if originalImage == nil {
print("⚠️ Initial image load failed, attempting recovery...")
// Strategy 1: Check if file exists but has load issues
if FileManager.default.fileExists(atPath: imageURL.path) {
print("🔍 File exists but NSImage can't load it, trying alternative loading...")
// Try loading with different methods
if let imageData = try? Data(contentsOf: imageURL) {
originalImage = NSImage(data: imageData)
if originalImage != nil {
print("✅ RECOVERED: Successfully loaded image via Data method")
}
}
}
// Strategy 2: Check temp directory for file
if originalImage == nil {
let tempDir = FileManager.default.temporaryDirectory
let fileName = imageURL.lastPathComponent
let tempURL = tempDir.appendingPathComponent(fileName)
if FileManager.default.fileExists(atPath: tempURL.path) {
originalImage = NSImage(contentsOf: tempURL)
if originalImage != nil {
finalImageURL = tempURL
print("✅ RECOVERED: Found image in temp directory: \(tempURL.path)")
}
}
}
// Strategy 3: Check if it's a cache file issue and try alternative cache locations
if originalImage == nil {
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let shotScreenDir = supportDir.appendingPathComponent("ShotScreen/Thumbnails")
let cacheName = imageURL.lastPathComponent
let cacheURL = shotScreenDir.appendingPathComponent(cacheName)
if FileManager.default.fileExists(atPath: cacheURL.path) {
originalImage = NSImage(contentsOf: cacheURL)
if originalImage != nil {
finalImageURL = cacheURL
print("✅ RECOVERED: Found image in cache: \(cacheURL.path)")
}
}
}
}
// Final check
guard let finalImage = originalImage else {
print("❌ Could not load image for background removal after all recovery attempts")
print("📍 Attempted URLs:")
print(" - Original: \(imageURL.path)")
print(" - File exists: \(FileManager.default.fileExists(atPath: imageURL.path))")
showToast(message: "Image load failed", near: dropPoint, gridWindow: gridWindow)
return
}
print("✅ Successfully loaded image for BGR: \(finalImageURL.lastPathComponent)")
print("🎨 Background Remove thumbnail UI starting...")
// 🎯 NEW: Use thumbnail-based BGR workflow via delegate with recovered URL
delegate?.showBackgroundRemovalThumbnail(with: finalImage, originalURL: finalImageURL)
}
// MARK: - Toast Notification System
private func showToast(message: String, near point: NSPoint, gridWindow: NSWindow?) {
let toastWidth: CGFloat = 180
let toastHeight: CGFloat = 40
// Kies vaste positie links (anders rechts) naast de grid
let margin: CGFloat = 5
var screenPoint: NSPoint
if let gw = gridWindow {
let gridFrame = gw.frame
let screenVisible = gw.screen?.visibleFrame ?? NSScreen.main!.visibleFrame
var x = gridFrame.minX - toastWidth - margin // links van grid
if x < screenVisible.minX + margin { // niet genoeg ruimte links rechts
x = gridFrame.maxX + margin
}
let y = gridFrame.midY - toastHeight / 2
screenPoint = NSPoint(x: x, y: y)
} else {
// Fallback: gebruik muislocatie zoals voorheen
screenPoint = NSEvent.mouseLocation
screenPoint.x -= toastWidth / 2
screenPoint.y += 20
}
let toastWindow = NSWindow(contentRect: NSRect(x: screenPoint.x, y: screenPoint.y, width: toastWidth, height: toastHeight),
styleMask: [.borderless], backing: .buffered, defer: false)
toastWindow.isOpaque = false
toastWindow.backgroundColor = .clear
toastWindow.level = .floating + 5
toastWindow.hasShadow = true
// Opbouw content
let container = NSView(frame: NSRect(x: 0, y: 0, width: toastWidth, height: toastHeight))
let blur = NSVisualEffectView(frame: container.bounds)
blur.blendingMode = .behindWindow
blur.material = .hudWindow
blur.state = .active
blur.alphaValue = 0.9
blur.wantsLayer = true
blur.layer?.cornerRadius = 12
blur.layer?.masksToBounds = true
let label = NSTextField(labelWithString: message)
label.textColor = .white
label.alignment = .center
label.font = .systemFont(ofSize: 13, weight: .medium)
label.frame = NSRect(x: 0, y: (toastHeight - 20) / 2, width: toastWidth, height: 20)
container.addSubview(blur)
container.addSubview(label)
toastWindow.contentView = container
toastWindow.alphaValue = 0
toastWindow.makeKeyAndOrderFront(nil as Any?)
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.35
toastWindow.animator().alphaValue = 1
}, completionHandler: {
let flyUp: CGFloat = 30
let targetOrigin = NSPoint(x: toastWindow.frame.origin.x, y: toastWindow.frame.origin.y + flyUp)
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 2
toastWindow.animator().alphaValue = 0
toastWindow.animator().setFrameOrigin(targetOrigin)
}, completionHandler: {
toastWindow.orderOut(nil as Any?)
})
})
}
// MARK: - Action Completion Handler
func handleActionCompletion(actionCompletedSuccessfully: Bool, wasSaveToFolder: Bool, isStashAction: Bool) {
print(" Action panel completion: Success - \(actionCompletedSuccessfully), WasSaveToFolder - \(wasSaveToFolder), IsStashAction - \(isStashAction)")
if actionCompletedSuccessfully {
if wasSaveToFolder {
// Save to folder actions handle their own preview closing via closeAfterSave setting.
print(" Save to folder action: Preview management handled by save logic.")
} else if isStashAction {
// CRITICAL: Ensure main grid stays closed after stash action
print("🔶 STASH ACTION COMPLETION: Ensuring main grid stays closed - no proximity monitoring")
delegate?.gridViewManagerHideGrid(monitorForReappear: false)
// FIXED: For stash action, preview is already properly closed in main.swift _showOrUpdateStash
// No need to close again here or check tempURL - it's already moved to stash directory
print(" Stash action: Preview management handled by stash logic in main.swift")
} else {
if delegate?.getTempURL() != nil {
print(" Other successful grid action (not save/stash): Ensuring preview is visible.")
delegate?.ensurePreviewVisible()
}
}
} else {
if delegate?.getTempURL() != nil {
print(" Action not successful or cancelled: Ensuring preview is visible.")
delegate?.ensurePreviewVisible()
}
}
}
}
// MARK: - Notification Names
extension Notification.Name {
static let gridActionCancelRequested = Notification.Name("gridActionCancelRequested")
static let gridActionRemoveRequested = Notification.Name("gridActionRemoveRequested")
}