Native Hammerspoon helper that streams per-touch events from connected
Magic Mouse and Trackpad devices to a Lua callback. Wraps Apple's
private MultitouchSupport.framework; no public macOS API exposes
multi-finger touch position or finger count on a per-device basis.
Designed as the input side of
MouseTrackpadTweaks.spoon:
the Spoon owns the hs.eventtap and per-touch decisions (scroll
inversion, middle-click synthesis); this module owns device enumeration
and the per-frame MTSF callback.
On start(callback):
- Enumerates every multitouch device via
MTDeviceCreateList. - Registers a contact-frame callback on each (
MTRegisterContactFrameCallback). - Calls
MTDeviceStarton each so MTSF begins delivering touch frames.
For every touch frame MTSF delivers (on its private thread), the module:
- Filters the frame to states
3(began),4(moved),5(ended) and snapshots into a thread-safe POD buffer. - Flips the Y coordinate so the Lua side gets
(0, 0)at top-left. - Marshals the snapshot to the main thread via
dispatch_async. - Invokes the registered Lua callback once per touch transition.
- Synthesizes
"ended"for any touch id that disappeared from a frame without going through state=5 first — defensive against MTSF dropping the terminal frame under load or on device disconnect.
local mt = require("hs._ckol.multitouch")
mt.start(function(deviceId, deviceKind, touchId, phase, nx, ny, timestamp)
-- deviceId integer -- per-session id (MTDeviceGetDeviceID)
-- deviceKind string -- "magicMouse" | "trackpad"
-- touchId integer -- stable from began → moved → ended
-- phase string -- "began" | "moved" | "ended"
-- nx, ny number -- normalized 0..1, (0, 0) at top-left
-- timestamp number -- MTSF frame timestamp (seconds)
end)
mt.stop() -- unregisters every device; safe to call repeatedly
hs.inspect(mt.devices())
--> { { id=1234567890, kind="trackpad", familyId=99 },
-- { id=2345678901, kind="magicMouse", familyId=113 } }start registers / replaces the callback; calling it twice swaps the
callback without re-enumerating devices. stop undoes everything,
including releasing the Lua callback ref.
Family IDs 112, 113, and 218 are mapped to "magicMouse". Every
other ID maps to "trackpad" — that covers the built-in MacBook
trackpads, the Magic Trackpad 1/2/3, and any unrecognized future
device. If you have a new Magic Mouse generation that reports a
different family ID, extend kindForFamilyID in internal.m.
Each release ships a multitouch-<version>-macos-universal.zip
containing a fat internal.so (arm64 + x86_64) plus init.lua,
built against -mmacosx-version-min=13.0. Pull the latest release
artifact and unzip straight into ~/.hammerspoon:
# 1. Download (replace <version> with whatever's current on the Releases page).
curl -L -o multitouch.zip \
https://github.com/catokolas/HS_ModulesContrib-multitouch/releases/latest/download/multitouch-<version>-macos-universal.zip
# 2. macOS may quarantine a downloaded .so; clear the flag so dlopen accepts it.
xattr -dr com.apple.quarantine multitouch.zip 2>/dev/null || true
# 3. Unzip into ~/.hammerspoon. The archive's top-level is hs/, so this
# lands at ~/.hammerspoon/hs/_ckol/multitouch/.
unzip -o multitouch.zip -d ~/.hammerspoon
# 4. Quit and relaunch Hammerspoon (Reload Config will NOT pick up a fresh .so).
# Then verify in the Console:
# hs.inspect(require("hs._ckol.multitouch").devices())If dlopen still complains about the quarantine after step 2, repeat
the xattr after step 3 on the unpacked .so:
xattr -dr com.apple.quarantine ~/.hammerspoon/hs/_ckol/multitouch.
cd multitouch
make install # copies into ~/.hammerspoon/hs/_ckol/multitouch/
# or for development:
make link # symlinks instead, so future `make` picks up automaticallyThen quit and relaunch Hammerspoon (Reload Config does not refresh
native modules — already-loaded .so files stay pinned in
package.loaded).
To produce a release artifact (universal binary zip) yourself:
cd multitouch
VERSION=0.1
make dist $VERSION # → dist/multitouch-0.1-macos-universal.zipIf you have access — publish the artifact as a GitHub Release with the
gh CLI:
# From the repo root (the parent of the `multitouch/` subdir):
gh release create v$VERSION \
dist/multitouch-$VERSION-macos-universal.zip \
--title "v$VERSION" \
--notes "Initial release. Universal arm64 + x86_64 binary built against macOS 13.0+."To remove an installed copy:
make uninstallAfter installing and relaunching Hammerspoon, run this from the Console:
local mt = require("hs._ckol.multitouch")
print(hs.inspect(mt.devices()))
mt.start(function(devId, kind, tid, phase, nx, ny, ts)
print(string.format("[%s %d] touch %d %s (%.2f,%.2f) @ %.3f",
kind, devId, tid, phase, nx, ny, ts))
end)Touch the trackpad or Magic Mouse and watch the console. Expect a
began, several moved, then a single ended per finger; nx, ny
should stay in [0, 1] with (0, 0) at the top-left of the device
surface.
mt.stop() ends the stream.
- Private framework.
MultitouchSupport.frameworkis not part of Apple's public SDK. The symbols used here have been stable since OS X 10.5, but a future macOS release could break this module without notice. Re-validate on each major macOS bump. - No hot-plug. Devices connected after
start()are not seen. Callstop()thenstart()again to re-enumerate. - Magic Mouse 1 multi-finger. The first-generation Magic Mouse tracks ≤ 2 simultaneous fingers reliably; 3+ finger gestures may not register at all. Magic Mouse 2 / 3 don't have this limitation.
- Click events not provided. This module only exposes touch state.
Physical clicks ride macOS's normal mouse-button event stream and
should be intercepted via
hs.eventtap. SeeMouseTrackpadTweaks.spoonfor the correlation pattern. - No per-touch pressure / size / angle. The Lua callback only
receives position and phase. The MTSF struct exposes more (
size,angle,majorAxis,minorAxis,zDensity), but nothing in the current consumer needs them — adding new fields is straightforward if a future use case warrants it.
multitouch/
├── multitouch/
│ ├── init.lua # Lua loader
│ ├── internal.m # MultitouchSupport wrapper + Lua bridge
│ └── Makefile # build + make link / make install / make dist
├── LICENSE # MIT
└── README.md # this file
MIT — see LICENSE.