Skip to content
RusiruSadathanaPublic

About

Three-finger trackpad gestures for music control on macOS.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

24 Commits

Folders and files

Repository files navigation

Baton

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.


Installing

  1. Download the DMG from the releases page.
  2. Drag Baton onto Applications.
  3. Open it.

"macOS cannot verify this app is free from malware"

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.app in Terminal and open Baton again.

You only have to do this once per version.

Accessibility

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.


Clearing the native gestures

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.


Updating Baton

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.

If gestures do stop after an update

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.


Uninstalling

Baton keeps almost nothing outside its own bundle, but two things do live elsewhere and are worth removing deliberately.

  1. 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.

  2. Quit Baton from the menu bar.

  3. Drag Baton.app to the Trash.

  4. 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
  5. 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.


Troubleshooting

1. Gestures stopped working after I updated Baton

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.

2. Swipes do nothing (taps might still work)

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.

3. A dictionary panel opens when I tap

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.

4. Swipes also trigger Mission Control, App Exposé or space switching

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.

5. macOS says Baton is damaged and can't be opened

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.

Anything else

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.


Privacy and security

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.swift in mask(for:) (around line 139), and the guard that refuses to create a tap from any mask carrying keyDown, keyUp, flagsChanged or CGEventMask.max is containsKeyboardTypes just below it (around line 165). Reading those two functions is the whole audit. CGEventMask.max is 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.


Building from source

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 launch

Signing

build.sh picks the best of three, and a missing certificate is never an error:

  1. Developer ID, if one happens to be in your keychain. Nobody here has one.
  2. 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.
  3. 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.sh

It 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.

Continuous integration

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.

The icon

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.swift

Layout

Baton/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.

Verifying the layout guard

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/Baton

Touch 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.

Verifying the mask ladder

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/Baton

Suppression 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.


Scope

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.

License

MIT. See LICENSE.

About

Three-finger trackpad gestures for music control on macOS.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages