Every frame above is the daemon's own output — daemon --headless, screenshot out.png, touch x,y, stitched with ffmpeg. In order: [layouts.default], the same bar with a terminal focused (copy / paste / clear), then the media, display and system groups expanding into the left zone. The frames are not one chronological run — the default-layout frame was captured separately, with no Hyprland, which is why its clock reads a minute later than the rest.
A Python daemon that owns the MacBook Touch Bar on Linux, drawing every pixel itself instead of leaning on tiny-dfr's built-in function-key strip. It reads Hyprland's focused window to switch layouts, and modules (built in or dropped in as Omarchy shell plugins) supply the widgets, groups and scenes that fill them.
tiny-dfr is the reason this hardware works at
all on Linux, and everything here stands on the ground it broke. macarchy-touchbar does not
replace it upstream — it takes the panel over on one machine, and install.sh masks
tiny-dfr so the two never fight over the display.
If you have been carrying a patched tiny-dfr fork around because you wanted one more button on the bar, this section is for you.
- The bar is composed, not configured. The daemon opens the Touch Bar's DRM card,
drives its own 64-px-wide dumb buffer and draws every pixel with cairo: pills,
Material Symbols glyphs, app icons, sliders, meters, animated sprites. A widget is a
Python object with
measure(),draw()and gesture handlers — not an entry in a fixed vocabulary. - The row follows the focused window. A
[layouts.<name>]table takes amatchregex tried against the focused window's class and title, read live off Hyprland's event socket. A terminal getscopy / paste / clear, a browser getsback / forward / reload / new tab / close tab, anything else falls through to[layouts.default]. - Groups, scenes and a module API. A
[groups.*]table folds a pile of buttons behind one pill that expands in place (and can be slid into with a single drag). A scene takes the whole bar for a few seconds — an incoming notification, a battery readout. A module is a directory with amanifest.jsonand atouchbar.pythat registers widgets, scenes, timers, subprocesses and its own CLI verbs. - It can be driven and screenshotted with no hardware at all.
daemon --headlessruns the whole engine against an in-memory surface,screenshotdumps that to a PNG, andtouch x,yinjects a synthetic gesture. The animation at the top of this page was made that way, on a laptop whose real Touch Bar was busy being used.
Worth being clear-eyed about before you mask tiny-dfr:
- It is Python, not Rust. The runtime is
python-cairo+python-gobject, and every redraw and every touch goes through the interpreter. It is a daemon drawing a 2008×60 strip at up to 30 fps, not a hot loop — but it is not a small static binary either. - Per-app layouts need Hyprland. The focused-window lookup speaks Hyprland's IPC
socket and nothing else. Without it the daemon still runs: it logs
no Hyprland (…); static default layoutand comes up on[layouts.default], with modules, groups, scenes, sliders, touch, the CLI, and the Fn layer (read straight from evdev, not from the compositor) all working. What you lose is exactly thematchregexes, thecore.appwidget, and the night-light button's state — thedisplaymodule pollshyprctl hyprsunset temperatureto know whether to light up, though its tap action is a plain shell command. The coupling is confined: Hyprland lives in one module (macarchy_touchbar/hypr.py, used only bydaemon.py) plus a singlehyprctlcall inmodules/display/touchbar.py.install.shhas a Hyprland migration step too, and it skips itself with a printed note when you have no~/.config/hypr. - It masks tiny-dfr. Only one process can drive that CRTC, so
install.shrunssystemctl disable --now tiny-dfrthensystemctl mask tiny-dfr. The daemon's own message for a faileddrmModeSetCrtcsays as much:SetCrtc failed (is tiny-dfr still running?). - The shipped layout is wired for Omarchy. Most buttons in
config/layouts.tomlshell out toomarchy …,macarchy-alsormacarchy-battery-limit. They are onlyrun = "…"strings and the engine does not care what you point them at — but out of the box on a non-Omarchy system, several of them will do nothing. - It is very new, and it has exactly one test machine. The first commit in this
repository is dated 2026-09-02. Everything has been calibrated against a single
MacBook Pro (13-inch, M2, 2022) running Asahi Linux — the buffer stride, the pill
geometry, the
MTP keyboardname the Fn watcher looks for. tiny-dfr has years of other people's hardware behind it; this has days and one laptop. - It runs in your session, not as root.
install.shadds you tovideofor the DRM card and installs a udev rule for/dev/uinput, then runs the daemon as a systemd user unit. That is a different trust model from a root system service — arguably better, certainly different.
This is the part worth knowing before anything else:
./install.sh --uninstall
stops and disables the user service, removes it and the macarchy-touchbar symlink, then
systemctl unmask tiny-dfr and systemctl enable --now tiny-dfr. One command and you
are back on tiny-dfr. Your ~/.config/macarchy-touchbar/layouts.toml is left alone, so
re-running ./install.sh picks up exactly where you left off.
What --uninstall does not undo: the udev rule in /etc/udev/rules.d/,
/etc/modules-load.d/macarchy-touchbar.conf, your membership in the video group, and the
Hyprland autostart line — all harmless to leave behind, all one rm away.
A button that runs a command is one table in ~/.config/macarchy-touchbar/layouts.toml
plus its name in a layout. Save the file; the daemon notices within a second.
[items.notes]
widget = "core.button"
icon = "edit_note" # any Material Symbols name
run = "kitty -e nvim ~/notes.md"
[layouts.default]
left = ["menu", "notes", "core.spacer"]
right = ["group:system", "core.clock", "system.battery"]core.button also takes text, keys = ["LeftCtrl", "T"] (evdev names without the
KEY_ prefix, sent through uinput), icon_size, width, stretch, tint, badge
and active.
A button that runs your own code needs a module: a directory with two files. Put it
in modules/, or ship it as an Omarchy shell plugin under ~/.config/omarchy/plugins/
— where it additionally has to declare "kinds": ["touchbar-module"] and be enabled in
shell.json.
modules/hello/manifest.json
{ "schemaVersion": 1, "id": "hello", "name": "Hello", "kinds": ["touchbar-module"],
"entryPoints": { "touchbarModule": "touchbar.py" }, "touchbarModule": { "apiVersion": 1, "order": 50 } }modules/hello/touchbar.py
from macarchy_touchbar.widgets import Button
class Module:
def setup(self, api):
api.widget("hi", lambda api, **p: Button(
api, icon="mood", text="hi", on_tap=lambda: api.log("tapped"), **p))Then macarchy-touchbar reload, and "hello.hi" becomes a reference any layout or group can
name — directly in a left/right list, or through an [items.*] table that passes it
parameters:
[layouts.default]
left = ["hello.hi", "core.spacer"]That is the entire contract for a button. Everything else grows off the same api
object — timers, file watches, subprocesses, full-bar scenes, IPC verbs, the
focused-window context — and Writing a module below lists all of
it.
./install.sh
This installs the system packages the daemon needs (python-cairo, python-gobject, papirus-icon-theme, brightnessctl), downloads the Material Symbols Rounded font it draws icons with, grants hardware access without root (adds you to the video group for the Touch Bar's DRM card, installs a udev rule for /dev/uinput), disables and masks tiny-dfr so it stops fighting over the display, migrates the Hyprland config off any earlier release of this daemon (removes the Touch Bar binding block it used to write into ~/.config/hypr/bindings.lua and points ~/.config/hypr/autostart.lua at the macarchy-touchbar.service user unit instead of launching the daemon directly, printing what it changed and reloading Hyprland — a no-op once done, and skipped entirely if you have no ~/.config/hypr), symlinks the macarchy-touchbar CLI into ~/.local/bin, copies config/layouts.toml to ~/.config/macarchy-touchbar/layouts.toml (only if that file doesn't already exist — your edits are never overwritten), and installs + enables the macarchy-touchbar.service systemd user unit. Re-run it any time to update.
./install.sh --uninstall
Stops and removes the service and CLI symlink, then unmasks and re-enables tiny-dfr. Your layouts.toml is left in place.
~/.config/macarchy-touchbar/layouts.toml is watched by the daemon and reloaded within a second of being saved. It has three kinds of table:
[items.<name>]— one bar entry: awidget(either a built-in likecore.buttonor a"<module>.<widget>"reference) plus that widget's parameters (icon,run, etc).fallback = "<item>"names another item to draw instead when this one's widget module isn't loaded (an optional plugin).[groups.<name>]— a named group of items that opens into its own row when tapped, with its owniconand ordereditemslist.[layouts.<name>]— a full bar:leftandrightlists of references (an item name, a"<module>.<widget>", or"group:<name>"), and an optionalmatchregex against the focused window's class that selects this layout automatically;layouts.defaultis used when nothing else matches.
[items.screenshot]
widget = "system.screenshot"
[groups.system]
icon = "settings"
items = ["screenshot", "clipboard", "system.lock"]
[layouts.default]
left = ["menu", "core.spacer"]
right = ["group:system", "core.clock"]A module lives in its own directory with a manifest.json:
{ "schemaVersion": 1, "id": "example", "name": "Example",
"kinds": ["touchbar-module"],
"entryPoints": { "touchbarModule": "touchbar.py" },
"touchbarModule": { "apiVersion": 1, "order": 50 } }touchbar.py exports a Module class with a setup method that receives an api object:
from macarchy_touchbar.widgets import Button
class Module:
def setup(self, api):
def factory(api=None, **p):
return Button(api, text="Hi", icon="mood", run="notify-send hi", **p)
api.widget("hello", factory)
api.ipc("ping", lambda *a: "pong")
api.log("hello module ready")Built-in modules live under modules/; drop-in ones are discovered the same way Omarchy shell plugins are, from ~/.config/omarchy/plugins, and must be enabled in shell.json to load. For a reference external module, see ~/Work/jarvis/plugin/touchbar.py (github.com/macarchy/jarvis): a Sprite in a pill, a scene, and six IPC verbs.
api calls:
| call | what it does |
|---|---|
widget(name, factory) |
register a widget factory as "<module>.<name>" |
scene(name, factory) |
register a full-screen scene |
show_scene(name, priority=50, timeout=None, dismissable=True) |
display a registered scene |
hide_scene(name) |
dismiss it |
every(seconds, fn) |
repeating timer |
after(seconds, fn) |
one-shot timer |
watch_file(path, fn) |
call fn when a file's mtime changes (polled once a second) |
watch_fd(fd, fn) |
call fn when a file descriptor is readable |
run(argv, on_done=None, on_line=None) |
run a subprocess under the event loop |
run_detached(cmd) |
fire-and-forget a subprocess outside the loop |
ipc(verb, fn) |
register a CLI/IPC verb as "<module> <verb>" |
context |
the current focused-window Context |
on_context(fn) |
subscribe to context changes |
keys(names) |
request uinput keys be sent |
invalidate(widget=None) |
request a redraw |
open_group(name) / close_group() / is_group_open(name) |
drive group state |
slide_into(name, x, y) |
animate a group open from a touch point |
wake() |
light the bar as a touch would (a scene taking a dark bar) |
measure_text(s, size=...) |
text width in pixels, for layout math |
app_icon_path(cls, size=32) |
resolve an app icon path from its window class |
theme |
the shared Theme (colors, sizes) |
log(*a) |
log tagged with the module id |
state_dir |
a per-module writable directory under XDG_STATE_HOME |
now() |
monotonic clock, for animation timing |
Sprite (a strip of frames, one row, played at its own fps) takes a few widget parameters worth calling out: pill=True draws a button pill behind the strip that lights while pressed; on_tap / on_long_press are callbacks, like Button's; fps (a float) is the playback rate; frames=0 (the default) reads the frame count off the sheet's width instead of a fixed number, so a regenerated sheet with more frames never shows holes.
macarchy-touchbar daemon [--headless] # run the daemon (normally via the systemd unit)
macarchy-touchbar status # current layout, open group, active scenes, loaded modules
macarchy-touchbar reload # re-read layouts.toml and every module (also picks up plugins installed or removed since start)
macarchy-touchbar group <name>|close # open or close a group
macarchy-touchbar screenshot <png> # dump the current bar to a PNG
macarchy-touchbar touch x,y [x2,y2] [--long] # inject a synthetic touch (tap, drag, or long-press)
macarchy-touchbar brightness <n>|auto # set or hand back panel brightness
macarchy-touchbar <module> <verb> [args] # dispatch a module-registered IPC verb
The daemon can be driven and inspected entirely from the terminal, which matters because the Touch Bar itself can't be screenshotted by normal tools:
macarchy-touchbar daemon --headlessruns the full engine against an in-memory surface instead of the real DRM output — useful under a test harness or when the physical bar is in use by something else.macarchy-touchbar screenshot out.pngrenders whatever the bar (headless or real) currently shows to a PNG you can open normally.macarchy-touchbar touch x,y [x2,y2] [--long]injects a synthetic touch gesture — a tap, a drag between two points, or a long-press — so a module's behavior can be exercised without physically touching the hardware.- Modules answer for themselves:
macarchy-touchbar media statusprints the player, the track and the volume the bar is showing as one line.
Most of the test suite runs against fakes and needs nothing special. Two tests touch the real Touch Bar hardware (the Touch Bar’s DRM card, /dev/uinput) and are skipped by default:
MACARCHY_TOUCHBAR_HW_TESTS=1 python3 -m pytest tests/ -q
Stop the daemon first (systemctl --user stop macarchy-touchbar.service) — the DRM test needs exclusive access to the display.
api.icon/api.text/api.imageare not implemented: modules composeButton,Label,Imageand the widgets draw through thePainter, so nothing needs a drawing API onApi.- A module marked broken at runtime is reported by
macarchy-touchbar statusand in the journal, but its already-built widgets keep drawing normally instead of showing ⚠. The ⚠ fallback exists (BrokenWidget) and covers widgets that fail to resolve or build; swapping live widgets out on a runtime failure lands in a later lot. - The grid values shipped differ from the spec's, after calibration on the hardware on 2026-09-02: pills are 60 px (the full height of the bar) rather than 44, glyphs 36 px rather than 24, and buttons 130 px wide. Radius 6, outer margin 8 and 6 px spacing are unchanged.
- The Jarvis button shows the state and the punctual emotions the FSM sends; the QML mascot's own moods (dnd, low battery, night) are not mirrored on the bar.
- Sprites draw at integer scale 1 — 56 px on the button and in the scene — rather than the spec's 40/54 px, superseded by lot 1's 60 px pills.
- A hot takeover restores the fish's sheet from the state file at load, but not the scene: the scene itself only reappears on the next state transition.
Lot 3 is the media module: media.now and media.art sit on the default bar (Now Playing, with the cover or the player's app icon), and the media group holds media.playpause and a media.volume slider you can slide straight into from the group button. Now Playing is read from omarchy-shell media status — the shell's own MPRIS client, which already picks the active player — pushed by a busctl --user monitor doorbell; the volume is polled with pactl, but only while the group is open. playerctl and cava are deliberately not dependencies.
- The auto-popping volume/brightness HUD scene was not built. The only keyboard
/proc/bus/input/deviceslists on this machine isApple MTP keyboard: Mac14,7 has no physical volume or brightness keys (the F-row is the Touch Bar), so the spec's trigger "while those keys are pressed" has no source.Bar.current_layout()returnsscenes.top().layout, so a priority-20 scene replaces the whole bar and covers the very slider it was fired from; andomarchy-audio-output-volumealready ends inomarchy-osd -i volume-high -p N, so it would be a second copy of a display that exists. The trigger to add it is an external keyboard with a real media row, and it is then apactl subscribedoorbell plusapi.show_scene, with this module's volumeSlideras the scene body.[settings] hudstays inert, exactly as it is today. - The spectrum analyser was not built.
cavais not installed here, it needs a continuous FFT process and a 30 fps full-width repaint of a 2008×60 buffer against a daemon whoseactivity.pyandbacklight.pyexist to stop drawing.Meter.set_bandsalready takes the data, so add it gated on the media group being open and the backlight awake. - No draggable timeline. The
Scrubberwidget is lot 4, and MPRIS never signalsPosition— reading it means a subprocess a second for as long as anything plays. - The title does not scroll and
prev/nextnever grey out.Painter.textellipsizes by default; a marquee would be a permanent 30 fps invalidation of the widest widget on the bar.canGoNext/canGoPreviousare in the status JSON, butButtonhas no disabled state — that lands with the first engine-side one, not before. - Upgrading:
install.shnever overwrites an existing~/.config/macarchy-touchbar/layouts.toml, so the media group,slide_into = "media.volume"and themedia.art/media.nowentries in theleftrows have to be copied across by hand fromconfig/layouts.toml.
The full specification and the plan this was built from live under docs/superpowers/specs and docs/superpowers/plans.
Versions are derived from Conventional Commits
by release-please: pushing to main keeps a release PR up to date, and merging
it tags vX.Y.Z. PRs land squash-merged, so it is the PR title that decides
the bump — fix: patch, feat: minor, feat!: major.
MIT — see LICENSE.
