Add Konverter: a local-only menu bar file converter
Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
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<NSPasteboard.PasteboardType> = [
|
||||
.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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user