
The ring under the pointer
How Yuzu notices a key tap in the middle of someone else's file drag without asking for Accessibility permission, and what it took to ship one build for Intel and Apple Silicon.
Yuzu is a small macOS menu bar app. While you drag any file, you tap a modifier key and a ring opens under the pointer with what you can do with that file: shift for conversions, command for tools, control for actions like AirDrop. You release on an option and the result is saved next to the original. Version 1.0.0 shipped on October 19, 2026 and 1.2.0 on October 24.
The whole app is about 9,400 lines of Swift. The part that decided whether it could exist at all was 85 lines.
The permission I did not want to ask for
The drag happens in Finder or any other app, not in Yuzu. To react to it, Yuzu has to notice two things in another app's event stream: that a file is being dragged, and that a modifier key was tapped during the drag.
The obvious tool for watching keys system-wide is an event tap, and that needs Accessibility permission. Accessibility is the permission that lets an app read and control everything on screen. Asking for that so a file converter can see the shift key felt wrong. I'd close an app that asked me.
So I split the problem into things macOS already tells any app without special permission.
Three signals that do not need it
A global monitor for left mouse presses and drags. NSEvent.addGlobalMonitorForEvents does not let you see key events in other apps without permission, but mouse-down and mouse-dragged events are fine:
if let m = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDragged], handler: { [weak self] _ in
MainActor.assumeIsolated { self?.beginSessionIfNeeded() }
}) { monitors.append(m) }The drag pasteboard. Every drag in macOS writes to a shared pasteboard named .drag, and its changeCount goes up when a new drag starts. Yuzu records the count at mouse-down. If it changes during the drag and the pasteboard holds file URLs, it is a file drag, and the URLs are right there:
let pb = NSPasteboard(name: .drag)
if !isFileDrag, pb.changeCount != baseChangeCount {
if pb.types?.contains(.fileURL) == true {
isFileDrag = true
items = (pb.readObjects(forClasses: [NSURL.self],
options: [.urlReadingFileURLsOnly: true]) as? [URL]) ?? []
} else {
// Not a file drag (text, images from browsers, etc.). Stop watching this one.
baseChangeCount = pb.changeCount
}
}The current modifier state. NSEvent.modifierFlags is a class property that reports which modifiers are held right now, system-wide. You cannot get events for it, but you can read it. So while a drag is in progress, a timer reads it 120 times a second, and the timer only exists between the first drag event and mouse-up:
let t = Timer(timeInterval: 1.0 / 120.0, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.tick() }
}
RunLoop.main.add(t, forMode: .common)Adding it to the .common run loop mode matters. During a drag the main run loop is often in an event tracking mode, and a timer scheduled the default way would simply stop firing at the moment it is needed.
Firing once, on the right keys
Polling has a trap. If you check "is shift held?" on every tick, the ring opens 120 times a second. The fix is to compare against the previous tick and act only on the edge where the flags change to an exact match for a configured combination:
let flags = NSEvent.modifierFlags.intersection(relevant)
defer { lastFlags = flags }
guard isFileDrag, flags != lastFlags, !flags.isEmpty else { return }
// Fire on the press edge of an exact combination match.
for kind in WheelKind.allCases where Prefs.combo(for: kind).flags == flags {
onTap?(kind, items, NSEvent.mouseLocation)
return
}Exact match is deliberate. Holding shift and then adding command should switch to the tools ring, not open both. The README now says it plainly: Yuzu "doesn't read your keystrokes and needs no special permission for that."
In a local benchmark on an M1 MacBook Air, the ring was on screen within the first display refresh after the tap, with a median of 8.8 ms. I have not committed the raw output yet, and the README says so.
One download for two kinds of Mac
Version 1.0.0 was built for Apple Silicon only, because the build script pinned the destination to arch=arm64. 1.2.0 builds both architectures:
xcodebuild -project Yuzu.xcodeproj -scheme Yuzu -configuration Release \
-derivedDataPath build/DerivedData -destination 'generic/platform=macOS' \
ONLY_ACTIVE_ARCH=NO ARCHS='arm64 x86_64' EXCLUDED_ARCHS= \
...The flags are the easy part. What I did not want was a release that silently shipped one architecture because a setting got overridden somewhere, so the script now checks the binary with lipo and refuses to continue:
ARCHS=$(lipo -archs "$BIN")
echo "$ARCHS" | grep -qw x86_64 && echo "$ARCHS" | grep -qw arm64 || { echo "not universal: $ARCHS"; exit 1; }Supporting older Intel Macs also meant supporting older macOS, down to 13. That surfaced APIs I had been using without thinking. Liquid Glass only exists on macOS 26, so every glass surface goes through one modifier that falls back to the system material. Cutting a photo's subject out uses Vision's VNGenerateForegroundInstanceMaskRequest, which needs macOS 14, so on 13 that one switch stays off and says why, while every other background mode still works:
static func liftSubject(_ image: CGImage) throws -> CGImage? {
guard #available(macOS 14, *) else { return nil }
let request = VNGenerateForegroundInstanceMaskRequest()
...
}