Three-finger trackpad gestures for music control on macOS.
Windows Precision Touchpads let you bind three-finger gestures straight to media controls. macOS doesn't. Baton fills exactly that gap, and nothing else.
| Gesture | Default action |
|---|---|
| Three-finger tap | Play / Pause |
| Three-finger swipe left | Previous track |
| Three-finger swipe right | Next track |
| Three-finger swipe up | Volume up |
| Three-finger swipe down | Volume down |
All five are remappable to any of Play/Pause, Next Track, Previous Track, Volume Up, Volume Down, Mute, or None, and there is an Invert vertical swipes toggle if you want up to mean quieter.
Horizontal swipes are discrete — exactly one track skip per physical swipe, never a second on the return motion. Vertical swipes are continuous — one volume step per increment of travel, so a short swipe nudges and a long one sweeps. A diagonal swipe locks to one axis and can never fire both.
It works with whatever currently holds media focus — Music, Spotify, a YouTube tab, a podcast app — because it posts the same system events a real media key sends rather than scripting any particular application. Volume moves by the system's own increment and raises the system's own volume HUD.
Baton lives in the menu bar, has no Dock icon, and asks for one permission.
- Download the DMG from the releases page.
- Drag Baton onto Applications.
- Open it.
You will see this on first launch, and it does not mean anything is wrong. Baton has no paid Apple Developer certificate behind it, so it is signed with a self-signed certificate and is not notarized. macOS shows this warning for every app in that position regardless of what the app does. Nothing is damaged.
- Try to open Baton once and dismiss the warning.
- Open System Settings → Privacy & Security and scroll down. There is a line about Baton being blocked, with an Open Anyway button. Click it.
- On older versions of macOS, or if that button never appears, run
xattr -cr /Applications/Baton.appin Terminal and open Baton again.
You only have to do this once per version.
Baton asks for Accessibility on first launch. It needs it for two things: noticing three-finger gestures, and posting media keys.
That is the only permission it asks for. Not Input Monitoring, not Screen Recording, not Full Disk Access, not Apple Events. Fewer permissions is a feature.
macOS often keeps reporting the grant as denied inside a process that was already running when the grant was made, so Baton offers a Relaunch button after you grant it.
macOS claims three-finger swipes in all four directions by default:
| Direction | What macOS does with it |
|---|---|
| Up | Mission Control |
| Down | App Exposé |
| Left / Right | Swipe between full-screen applications |
Baton stops those swipes reaching the app underneath, but macOS itself still acts on them, so both fire until you move them out of the way. Under System Settings → Trackpad → More Gestures, set all three of these to Four Fingers:
- Mission Control
- App Exposé
- Swipe between full-screen applications
Baton's Setup Guide (in the menu bar) shows this as a checklist and detects which of them are still on three fingers, so you can see what is left to do.
Baton acts on exactly three fingers, never three or more, and its event tap only suppresses while exactly three are down. Four- and five-finger gestures are left entirely alone — which they have to be, since four fingers is where this page just asked you to put them. If a fourth finger joins a swipe in progress, Baton abandons that gesture rather than firing something you didn't mean.
Relatedly, one physical placement of three fingers produces at most one discrete gesture. Fingers are not perfectly synchronous, and during a fast swipe the third one often reads as briefly lifted; without that rule the tail of a swipe gets treated as a fresh gesture and a track skip also fires play/pause.
Mission Control and App Exposé are gated by the Dock as well as by the trackpad, and the Dock only re-reads the setting when it restarts — log out and back in if a vertical swipe still opens them.
Baton never changes any of these for you. Disabling a user's trackpad gestures from inside an app is invasive, and if the app is later deleted while they are still disabled it leaves a trackpad that behaves oddly with no way to find out why. Baton points; you decide.
Your Accessibility grant should survive updates. Getting that right without a paid Apple Developer account took a little care, and it is worth explaining, because most unsigned Mac apps get it wrong.
macOS ties a privacy permission to an app's designated requirement. For an ad-hoc signed app there is no certificate to point at, so the requirement is the binary's own hash:
designated => cdhash H"c319f1..." or cdhash H"079b68..."
Change one byte and that is a different app as far as privacy is concerned: Accessibility silently stops working after every update, while System Settings still shows the checkbox ticked. It is the most confusing failure an app like this can produce.
Signing with a certificate — any certificate, including a self-signed one that costs nothing — moves the requirement onto the certificate instead:
designated => identifier "com.baton.Baton" and certificate root = H"bb7736..."
No binary hash. That is stable across rebuilds, so the grant survives. Releases are signed this way; see Signing below.
This does not get past Gatekeeper — a self-signed certificate is not trusted by Apple and these builds are not notarized, so a new install still needs "Open Anyway". It fixes the re-granting, which is the part people actually hit.
It should be rare now, but it can still happen — if you previously ran an ad-hoc build, if the signing certificate is ever rotated, or if you build from source yourself (your build has your identity, not the release's). Open Baton's menu and choose Repair Permissions. It resets Baton's Accessibility entry, asks for it again, and relaunches — a fresh grant does not take effect inside a process that was already running when it was made.
If tccutil is blocked on your machine, the same panel gives the manual route:
in System Settings → Privacy & Security → Accessibility, select Baton, click
the minus (−) button to remove it, then plus (+) to add it back from
Applications. Removing and re-adding matters — toggling the switch off and on
reuses the same stale entry.
Baton keeps almost nothing outside its own bundle, but two things do live elsewhere and are worth removing deliberately.
-
Turn off "Launch Baton at login" in Preferences → Advanced, before deleting the app. macOS records login items separately from the app itself, and the only supported way to withdraw one is from inside the app that registered it. Delete the app first and you are left with an entry in System Settings → General → Login Items pointing at something that no longer exists.
-
Quit Baton from the menu bar.
-
Drag Baton.app to the Trash.
-
Remove its preferences (bindings and tuning; a few hundred bytes of JSON):
rm -rf ~/Library/Application\ Support/Baton rm -rf ~/Library/Caches/com.baton.Baton ~/Library/HTTPStorages/com.baton.Baton
-
Forget the Accessibility grant, so macOS is not still holding a permission for an app you removed:
tccutil reset Accessibility com.baton.Baton
The row may linger in System Settings until you remove it with the − button; the grant itself is already gone.
Nothing else is left. Baton never wrote to your trackpad or Dock settings, so there is nothing to put back — whatever you changed under System Settings → Trackpad is yours to change back if you want the native three-finger gestures returned.
Releases are signed with a stable identity, so this should not happen — but it will if you are coming from an early ad-hoc build, if the signing certificate was rotated, or if you build from source yourself. Open Baton's menu and choose Repair Permissions. The System Settings checkbox looking correct is not evidence that it is; when the identity has moved, it isn't.
Three Finger Drag is on. Find it under System Settings → Accessibility → Pointer Control → Trackpad Options → Enable dragging, and set it to something other than Three Finger Drag.
When it is on, macOS consumes three-finger movement before Baton ever sees it, so every swipe stops working. There is no workaround and no clever fix: Baton and three-finger drag cannot coexist, and you have to pick one. Baton's System Check detects this and says so directly.
Look up & data detectors is set to Tap with Three Fingers, under System Settings → Trackpad → Point & Click. It collides head-on with the play/pause tap — both fire. Set it to Force Click with One Finger, or off.
Those three native gestures are still on three fingers. See Clearing the native gestures above. If vertical swipes in particular keep opening Mission Control after you have changed the setting, log out and back in — the Dock caches it.
It isn't damaged. That is Gatekeeper's wording for an app that is neither
notarized nor signed with a paid Developer ID certificate, and it says the same
thing about every such app. Use the Open Anyway button in
System Settings → Privacy & Security, or run
xattr -cr /Applications/Baton.app.
Preferences → Advanced has a diagnostics panel showing the event tap tier, the exact event mask in use, the touch stream state and a live finger count, plus a Copy Diagnostics button. Paste that into an issue.
Baton asks for Accessibility, which is a broad and permanent grant. You are right to be suspicious of anything that does. The honest answer is that this is verifiable, so here is exactly where to look.
- The event tap never observes keyboard input. Its mask contains scroll,
gesture and pointer event types only. The three masks are defined in
Baton/Sources/Actions/EventSuppressor.swiftinmask(for:)(around line 139), and the guard that refuses to create a tap from any mask carryingkeyDown,keyUp,flagsChangedorCGEventMask.maxiscontainsKeyboardTypesjust below it (around line 165). Reading those two functions is the whole audit.CGEventMask.maxis never used, at any fallback tier. The release workflow fails the build if a keyboard event type appears outside that guard. - No network access except one optional GET to the GitHub releases API to compare version numbers. It is switchable off in Preferences. Baton is deliberately not a self-updater: it opens the releases page in your browser and leaves the decision to you, because an app that downloads and swaps its own bundle turns a compromise of this repository into code execution on every user's machine.
- No analytics, no telemetry, no crash reporting, of any kind.
- All preferences are stored locally, as plain JSON at
~/Library/Application Support/Baton/preferences.json. Nothing in that file is executable content — it is bindings and numbers. - Baton never writes your system settings. It reads a handful of trackpad preference keys to tell you which ones are in its way, and that is all.
- Quitting Baton leaves the system as it found it. The event tap is removed and the multitouch devices are stopped.
- No third-party dependencies. The whole app is in this repository.
Baton reads raw touch data through MultitouchSupport.framework, a private
system framework — there is no public API for finger positions. It is opened
with dlopen by absolute path at runtime and never linked at build time, so a
macOS release that moves or removes it produces a message in the menu bar rather
than an app that will not launch.
Requires the Xcode Command Line Tools. There is no Xcode project and no package
manifest; build.sh drives swiftc and clang directly.
./build.sh # build build/Baton.app (universal: arm64 + x86_64)
./build.sh --dmg # also produce build/Baton-<version>.dmg
./build.sh --run # build, then launchbuild.sh picks the best of three, and a missing certificate is never an error:
- Developer ID, if one happens to be in your keychain. Nobody here has one.
- Baton's own self-signed certificate, if it has been created. This is what releases use, and it is what makes a permission grant survive an update.
- Ad-hoc (
--sign -) otherwise, which builds and runs fine but gives every build a different identity.
To create the certificate once, on your own machine:
./scripts/make-signing-cert.shIt generates a self-signed code signing certificate with openssl, imports it
into your login keychain, and prints the two values GitHub Actions needs to sign
releases with the same identity. build.sh finds it automatically from then on;
every build prints the designated requirement it produced, so you can see at a
glance which tier you got.
Keep the .p12 and its password out of the repository. Anyone holding both can
produce a build that inherits your users' Accessibility grants — though they
still cannot get past Gatekeeper with it, so a user would have to deliberately
"Open Anyway" a build they did not download from you.
Two workflows, and neither one uses a secret or a signing certificate.
ci.yml runs on every push and pull request. It
checks the event tap mask first — a mask regression should fail in seconds, not
after a full build — then builds the universal app with warnings promoted to
errors (BATON_STRICT=1), and asserts the result is actually shippable: both
architectures present, signature verifies, LSUIElement still true, icon
present, and no entitlement beyond leaving the sandbox. The DMG is attached to
the run as an artifact.
release.yml runs on a v* tag. It stamps the
version into Info.plist from the tag, builds, re-runs the same verification, and
publishes a GitHub release with the DMG and its SHA-256. Publishing goes through
the preinstalled gh CLI rather than a third-party action, so nothing sits
between the tag and the download that isn't GitHub or this repository.
Building the released binary in CI from tagged source is the point: it lets somebody who is reasonably suspicious of an app that asks for Accessibility check that the download corresponds to code they can read.
On secrets. ci.yml uses none, deliberately — a pull request build has no
business touching a signing key. release.yml uses two, and only if you choose
to set them:
| Secret | Purpose |
|---|---|
BATON_CERT_P12 |
base64 of the signing certificate |
BATON_CERT_PASSWORD |
its export password |
With them set, the release is signed with the same identity every time, so users
keep their Accessibility grant across updates. Without them the release is
ad-hoc signed and still works — users simply re-grant after each update.
./scripts/make-signing-cert.sh creates the certificate and
./scripts/set-ci-secrets.sh uploads it to the repository without printing it. The certificate is imported
into a temporary keychain that is deleted at the end of the run, whether or not
the build succeeded, and the resulting designated requirement is printed into
the build log so a release carrying an unexpected identity is visible.
There is no notarization step at any tier, because there is no paid Apple Developer account behind Baton.
Baton/Resources/AppIcon.icns is committed so a clean checkout builds without
an extra step, but it is drawn in code rather than by hand — a conductor's baton
with three motion streaks, one per finger. Regenerate it with:
swift assets/make-icon.swiftBaton/Sources/
App/ entry point, menu bar, preferences store, permissions, system checks
Multitouch/ dlopen bridge, C struct shim, raw touch stream and its layout guard
Gestures/ recognizer: taps and directional swipes from touch frames
Actions/ media key emitter, event suppression tap
UI/ preferences, onboarding, permission repair, feedback HUD
The separation between touch input, gesture recognition and action execution is
what makes the security property above auditable — the tap is confined to
Actions/, and nothing in Gestures/ can widen it.
The contact struct that the private framework fills in is undocumented, and its
layout has differed across macOS releases. A mismatch does not throw — it yields
plausible-looking garbage, or crashes on a machine the author does not own. So
Baton validates before it acts: normalized coordinates must fall within
-0.05...1.05, the contact count within 0...16, and ten consecutive clean
frames carrying real contacts are required before any gesture is acted on. If
a frame fails while unvalidated, Baton stops the device and puts a message in
the menu bar instead of guessing.
To exercise that path on a healthy Mac, run with a deliberately wrong stride:
BATON_FORCE_BAD_STRIDE=1 BATON_DEBUG=1 ./build/Baton.app/Contents/MacOS/BatonTouch the trackpad and the menu bar should read "Baton can't read this trackpad on this macOS version" with a link to the issues page — no crash, and nothing acted on.
BATON_DEBUG=1 on its own turns on verbose logging to stdout, including which
event tap tier was created.
Requesting the undocumented gesture event types reportedly makes
CGEvent.tapCreate return nil, silently, on some macOS versions — which would
make suppression fail entirely with no error. So Baton tries three masks in
order and stops at the first that works, and reports which one it got in
Preferences → Advanced. To exercise the fallbacks on a machine where the first
tier works:
BATON_FORCE_TAP_TIER=2 BATON_DEBUG=1 ./build/Baton.app/Contents/MacOS/Baton
BATON_FORCE_TAP_TIER=3 BATON_DEBUG=1 ./build/Baton.app/Contents/MacOS/BatonSuppression should behave the same at each tier. None of the three masks
contains a keyboard event type, and scripts/check-event-mask.sh enforces that.
Baton does one thing. It is not going to grow window management, app switching, four- and five-finger gestures, pinch or rotate, custom gesture recording, shell or AppleScript actions, scheduled automations, brightness control, or cloud sync. If you want a general-purpose gesture engine, this is the wrong app, and that is on purpose — the small permission surface above is only defensible because the app is small.
MIT. See LICENSE.