🚀 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.
379 lines
14 KiB
Swift
379 lines
14 KiB
Swift
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()
|
|
}
|
|
}
|
|
} |