import AppKit import Foundation /// Watches for drags anywhere on the system and reports them. Mouse events do /// not require any privacy permission; key events (Escape, arrows) do, so the /// wheel stays fully usable without them. final class DragMonitor { static let shared = DragMonitor() /// Called on every poll while a drag is in flight. var onTick: (([URL], CGPoint, NSEvent.ModifierFlags) -> Void)? /// Called once when a drag starts, with the files being dragged. var onBegan: (([URL]) -> Void)? /// Called when the drag finishes (drop or cancel). var onEnded: ((CGPoint) -> Void)? /// Called for keys pressed during a drag. var onKey: ((UInt16, NSEvent.ModifierFlags) -> Bool)? private var globalMouse: Any? private var localMouse: Any? private var keyMonitor: Any? private var timer: Timer? private var isDragging = false private var urls: [URL] = [] private init() {} func start() { guard globalMouse == nil else { return } let mask: NSEvent.EventTypeMask = [.leftMouseDragged, .rightMouseDragged, .otherMouseDragged] globalMouse = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] event in self?.handleDrag(event) } // Drags that end up over our own window are delivered to us, so the // global monitor never sees them. localMouse = NSEvent.addLocalMonitorForEvents(matching: mask) { [weak self] event in self?.handleDrag(event) return event } let upMask: NSEvent.EventTypeMask = [.leftMouseUp, .rightMouseUp, .otherMouseUp] NSEvent.addGlobalMonitorForEvents(matching: upMask) { [weak self] _ in self?.handleEnd() } NSEvent.addLocalMonitorForEvents(matching: upMask) { [weak self] event in self?.handleEnd() return event } keyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self, self.isDragging else { return } _ = self.onKey?(event.keyCode, event.modifierFlags) } } func stop() { if let globalMouse { NSEvent.removeMonitor(globalMouse) } if let localMouse { NSEvent.removeMonitor(localMouse) } if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } globalMouse = nil localMouse = nil keyMonitor = nil stopTimer() } private func handleDrag(_ event: NSEvent) { if !isDragging { let dragged = DragMonitor.draggedFileURLs() guard !dragged.isEmpty else { return } isDragging = true urls = dragged onBegan?(dragged) startTimer() } } private func handleEnd() { guard isDragging else { return } onEnded?(NSEvent.mouseLocation) isDragging = false urls = [] stopTimer() } private func startTimer() { stopTimer() let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in guard let self, self.isDragging else { return } self.onTick?(self.urls, NSEvent.mouseLocation, NSEvent.modifierFlags) } // `.common` keeps it firing even if a tracking loop is running. RunLoop.main.add(timer, forMode: .common) self.timer = timer } private func stopTimer() { timer?.invalidate() timer = nil } /// The files currently on the global drag pasteboard. Finder, Mail and most /// apps publish real file URLs; apps that only promise a file are read too. static func draggedFileURLs() -> [URL] { let pasteboard = NSPasteboard(name: .drag) guard let types = pasteboard.types else { return [] } let fileTypes: Set = [ .fileURL, .filePromise, NSPasteboard.PasteboardType("NSFilenamesPboardType") ] guard types.contains(where: { fileTypes.contains($0) }) else { return [] } var urls: [URL] = [] if let objects = pasteboard.readObjects( forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true] ) as? [URL] { urls = objects } if urls.isEmpty, let paths = pasteboard.propertyList(forType: NSPasteboard.PasteboardType("NSFilenamesPboardType")) as? [String] { urls = paths.map { URL(fileURLWithPath: $0) } } return urls.filter { FileManager.default.fileExists(atPath: $0.path) } } }