Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ on:
push:
branches: [main]
# Manual re-run, e.g. after a token or permission failure. Idempotent: it re-opens or refreshes
# the same release PR rather than creating a second one.
# the same release PR rather than creating a second one. `package_tag` re-runs
# ONLY the packaging job for an existing tag, for a build that failed.
workflow_dispatch:
inputs:
package_tag:
description: "Re-build and re-attach the package for this tag (e.g. v0.4.1). Leave empty for a normal run."
required: false
default: ""

permissions:
contents: write
Expand All @@ -31,9 +37,65 @@ jobs:
release-please:
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: googleapis/release-please-action@v5
id: release
with:
target-branch: main
config-file: release-please-config.json
manifest-file: .release-please-manifest.json

# Attaches an installable package to the release the job above just cut, so a
# release is something you can `pacman -U` instead of a source zip.
#
# IT LIVES HERE, not in a workflow keyed on `release: [published]`, and that is
# the whole point: release-please creates the Release with the default
# GITHUB_TOKEN, and GitHub does not start workflow runs from events raised by
# GITHUB_TOKEN. A `release: published` trigger would never fire on the real
# path -- and would leave no failed run in the Actions tab to notice. This repo
# has already paid for that lesson once.
package:
needs: release-please
if: needs.release-please.outputs.release_created == 'true' || inputs.package_tag != ''
runs-on: ubuntu-latest
# arch=('any'), so the x86_64 image is honest here: nothing is compiled, and
# the python package is co-located with its data rather than dropped into the
# BUILDING interpreter's site-packages. A repo that really compiles needs the
# other treatment -- macarchy-install#18.
container: archlinux:base-devel
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
# The tag, never the default branch: re-running for an older tag must
# not build main's PKGBUILD and clobber that package onto the old release.
ref: ${{ needs.release-please.outputs.tag_name || inputs.package_tag }}

- name: Build the package
# -Syu, not -Sy: a partial upgrade can link the fresh python against a
# glibc this image does not have, and it reads as a build bug.
# github-cli because `gh` lives on the RUNNER, not inside the container.
# makepkg refuses to run as root, so it gets a throwaway user.
# --nodeps because `depends` is a runtime contract for the target machine.
run: |
pacman -Syu --noconfirm --needed git python github-cli
useradd -m build && chown -R build:build .
su build -c 'makepkg -f --nodeps --noconfirm'
ls -l ./*.pkg.tar.*

- name: Attach it to the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.release-please.outputs.tag_name || inputs.package_tag }}
# A GLOB, not a name: PKGEXT is .zst here and .xz on the maintainer's
# machine, and hardcoding either uploads nothing on the other. The version
# check is what stops a mis-targeted manual re-run from clobbering an old
# release with a package built from a different tag.
run: |
test -n "$(echo ./*.pkg.tar.* )"
ls ./*.pkg.tar.* | grep -q "${TAG#v}" \
|| { echo "built package does not carry $TAG — refusing to upload"; exit 1; }
gh release upload "$TAG" ./*.pkg.tar.* --clobber --repo "$GITHUB_REPOSITORY"
66 changes: 66 additions & 0 deletions PKGBUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Maintainer: Philippe Matray <phmatray@gmail.com>
#
# Installs system-wide what ./install.sh installs into $HOME. The two channels
# are kept honest by tests/test_pkgbuild.py, which fails if either grows a file
# the other does not carry.
pkgname=macarchy-touchbar
pkgver=0.4.0 # x-release-please-version
pkgrel=1
pkgdesc="A Touch Bar daemon for MacBooks on Linux — draws every pixel over DRM, follows the focused app, takes modules"
arch=('any')
url="https://github.com/macarchy/macarchy-touchbar"
license=('MIT')
install=macarchy-touchbar.install
depends=('python' 'python-cairo' 'python-gobject' 'brightnessctl')
optdepends=('papirus-icon-theme: application icons on the bar'
'tiny-dfr: what install.sh --uninstall hands the bar back to')
# install.sh:27 curls this from master, unpinned -- the file changed between
# 2 Sep and 5 Sep 2026. A package has to be reproducible, so the commit is
# pinned and the checksum is real.
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz"
"MaterialSymbolsRounded.ttf::https://raw.githubusercontent.com/google/material-design-icons/0cbb08816df07faaae3dca060d4ebb10b66c214f/variablefont/MaterialSymbolsRounded%5BFILL%2CGRAD%2Copsz%2Cwght%5D.ttf"
"MaterialSymbolsRounded.codepoints::https://raw.githubusercontent.com/google/material-design-icons/0cbb08816df07faaae3dca060d4ebb10b66c214f/variablefont/MaterialSymbolsRounded%5BFILL%2CGRAD%2Copsz%2Cwght%5D.codepoints")
sha256sums=('SKIP'
'24f9f678388abc5a0e2c5bf722eeab7aea08a0a058459920d5eb117bf0f8557b'
'cbea7bfbd34d1d4f8dd2628c34587e447f935cf4f2219b264988da48736eca75')

package() {
cd "$srcdir/$pkgname-$pkgver"

install -Dm755 bin/macarchy-touchbar "$pkgdir/usr/bin/macarchy-touchbar"

# Code and data together under /usr/share, NOT the python package in
# site-packages. site-packages would bake the BUILDING interpreter's version
# into an arch=('any') artifact: this is built in a container whose python is
# routinely ahead of Asahi's, and the target would then get ImportError while
# depends=('python') claims to be satisfied. Co-located, "one directory above
# the package" resolves in both layouts and there is one rule, not two.
install -d "$pkgdir/usr/share/$pkgname"
cp -r macarchy_touchbar modules config "$pkgdir/usr/share/$pkgname/"

install -Dm644 "$srcdir/MaterialSymbolsRounded.ttf" \
"$pkgdir/usr/share/fonts/TTF/MaterialSymbolsRounded.ttf"
# draw.py:57 opens this. It is gitignored, so it is NOT in the release tarball
# and has to come from the same pinned commit as the font. No `|| true`: a
# missing codepoints file means a bar with no icons, and the build should say
# so rather than ship one.
install -Dm644 "$srcdir/MaterialSymbolsRounded.codepoints" \
"$pkgdir/usr/share/$pkgname/fonts/MaterialSymbolsRounded.codepoints"

install -Dm644 udev/70-macarchy-touchbar.rules \
"$pkgdir/usr/lib/udev/rules.d/70-macarchy-touchbar.rules"
install -Dm644 modules-load.d/macarchy-touchbar.conf \
"$pkgdir/usr/lib/modules-load.d/macarchy-touchbar.conf"
# The shipped unit says ExecStart=%h/.local/bin/… because install.sh symlinks
# the CLI there. A package install never writes into $HOME, so shipping it
# verbatim would give 203/EXEC, ten restarts to StartLimitBurst, and an
# OnFailure toast -- from a package that installed perfectly.
sed 's|%h/\.local/bin/|/usr/bin/|' systemd/macarchy-touchbar.service \
> "$srcdir/macarchy-touchbar.service.pkg"
grep -q '^ExecStart=/usr/bin/' "$srcdir/macarchy-touchbar.service.pkg" # or fail the build
install -Dm644 "$srcdir/macarchy-touchbar.service.pkg" \
"$pkgdir/usr/lib/systemd/user/macarchy-touchbar.service"

install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
}
24 changes: 22 additions & 2 deletions bin/macarchy-touchbar
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,28 @@
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
sys.path.insert(0, ROOT)
# Find the tree that holds macarchy_touchbar/ next to modules/ and config/, and
# put it on sys.path. Code and data are co-located ON PURPOSE: everything below
# then resolves relative to the package, exactly as it did when the only layout
# was a git checkout.
#
# The alternative -- python package in site-packages, data in /usr/share -- was
# tried and rejected: it bakes the CI container's interpreter version into an
# arch=('any') package, so a target whose python differs by one minor version
# gets ImportError while depends=('python') claims to be satisfied.
_HERE = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
_CANDIDATES = (os.environ.get("MACARCHY_TOUCHBAR_DATA"), _HERE, "/usr/share/macarchy-touchbar")
for _root in _CANDIDATES:
if _root and os.path.isdir(os.path.join(_root, "macarchy_touchbar")):
sys.path.insert(0, _root)
break
else:
# Say which places were tried. Falling through to the import would raise a
# bare ImportError naming a module, which tells the reader nothing about the
# real problem: the tree is somewhere this binary did not look.
sys.exit("macarchy-touchbar: cannot find macarchy_touchbar/ — looked in "
+ ", ".join(repr(c) for c in _CANDIDATES if c)
+ "\nSet MACARCHY_TOUCHBAR_DATA to the tree that holds it.")

if len(sys.argv) > 1 and sys.argv[1] == "daemon":
from macarchy_touchbar.daemon import main # noqa: E402
Expand Down
32 changes: 32 additions & 0 deletions macarchy-touchbar.install
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
post_install() {
cat <<'NOTE'

macarchy-touchbar is installed, but pacman cannot do the half of install.sh
that is not file copying. Until these are done the bar will not come up:

sudo usermod -aG video "$USER" # open the Touch Bar's DRM card (needs a relogin)
sudo modprobe uinput # the rule is shipped; the module must be loaded once
sudo systemctl disable --now tiny-dfr && sudo systemctl mask tiny-dfr
# tiny-dfr drives the same panel; two owners means neither works

systemctl --user enable --now macarchy-touchbar.service

A dead bar looks exactly like a bar with nothing on it, so if it stays blank:
journalctl --user -u macarchy-touchbar -b

NOTE
}

post_upgrade() {
systemctl --user try-restart macarchy-touchbar.service 2>/dev/null || true
}

pre_remove() {
systemctl --user disable --now macarchy-touchbar.service 2>/dev/null || true
cat <<'NOTE'

The Touch Bar is unowned now. To hand it back to tiny-dfr:
sudo systemctl unmask tiny-dfr && sudo systemctl enable --now tiny-dfr

NOTE
}
8 changes: 4 additions & 4 deletions macarchy_touchbar/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from .uinput import VirtualKeyboard
from .widgets import Sprite

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
from macarchy_touchbar.paths import data_root # noqa: E402
HOME = os.path.expanduser("~")
CFG = os.path.join(os.environ.get("XDG_CONFIG_HOME") or f"{HOME}/.config", "macarchy-touchbar", "layouts.toml")
PLUGINS = os.path.join(os.environ.get("XDG_CONFIG_HOME") or f"{HOME}/.config", "omarchy", "plugins")
Expand All @@ -40,7 +40,7 @@ def build(loop, output, config, plugins_dir=None, shell_json=None):
host = ModuleHost(loop, None, registry)
bar = Bar(output, loop, Painter(output.surface), config, registry, host)
host.hooks = bar
specs = discover(os.path.join(ROOT, "modules"), plugins_dir or PLUGINS,
specs = discover(os.path.join(data_root(), "modules"), plugins_dir or PLUGINS,
_shell_json() if shell_json is None else shell_json)
for spec in specs:
host.load(spec)
Expand Down Expand Up @@ -68,7 +68,7 @@ def _load_config(path):
return Config.load(path)
except (OSError, ValueError) as e:
log(f"{path}: {e}; using the shipped layouts")
return Config.load(os.path.join(ROOT, "config", "layouts.toml"))
return Config.load(os.path.join(data_root(), "config", "layouts.toml"))


def run_daemon(headless=False, config_path=CFG):
Expand Down Expand Up @@ -155,7 +155,7 @@ def deliver(gs):
def reload():
nonlocal config
config = _load_config(config_path)
rediscover(host, os.path.join(ROOT, "modules"), PLUGINS, _shell_json())
rediscover(host, os.path.join(data_root(), "modules"), PLUGINS, _shell_json())
bar.reload_config(config)
return "reloaded"

Expand Down
12 changes: 9 additions & 3 deletions macarchy_touchbar/draw.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,14 @@
except (ValueError, ImportError):
Gdk = None

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CODEPOINTS = os.path.join(ROOT, "fonts", "MaterialSymbolsRounded.codepoints")
from macarchy_touchbar.paths import data_root

def codepoints_path():
# A call, not a module constant: data_root() reads the environment, and a
# constant frozen at import would move modules/ and config/ without moving
# the icon font -- the OSError below is swallowed and every icon silently
# resolves to None. (_codepoints, below, is the parsed cache; different thing.)
return os.path.join(data_root(), "fonts", "MaterialSymbolsRounded.codepoints")


class Theme:
Expand Down Expand Up @@ -53,7 +59,7 @@ def icon_codepoint(name):
if _codepoints is None:
_codepoints = {}
try:
with open(CODEPOINTS) as f:
with open(codepoints_path()) as f:
for line in f:
n, _, hexcode = line.strip().partition(" ")
if hexcode:
Expand Down
23 changes: 23 additions & 0 deletions macarchy_touchbar/paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Where the daemon's data lives.

modules/, config/layouts.toml and fonts/*.codepoints sit one directory above the
python package. That was already true in a git checkout, and the packaging work
kept it true rather than inventing a second layout: the package installs
macarchy_touchbar/ NEXT TO modules/ and config/ under /usr/share/macarchy-touchbar,
so "one level up from the code" resolves correctly in both.

Co-locating them is what makes arch=('any') honest. Putting the python package in
site-packages instead would bake the building interpreter's version into the
artifact, and a target whose python differs by a minor version gets ImportError
while depends=('python') claims to be satisfied. macarchy-install#16.

$MACARCHY_TOUCHBAR_DATA overrides, and is honoured even when it points nowhere:
someone who sets it meant it, and a silent fallback would hide the typo until the
bar came up with no modules on it.
"""
import os


def data_root():
return os.environ.get("MACARCHY_TOUCHBAR_DATA") or \
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
5 changes: 4 additions & 1 deletion release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
"release-type": "simple",
"package-name": "macarchy-touchbar",
"include-component-in-tag": false,
"changelog-path": "CHANGELOG.md"
"changelog-path": "CHANGELOG.md",
"extra-files": [
"PKGBUILD"
]
}
}
}
68 changes: 68 additions & 0 deletions tests/test_data_root.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Where the daemon looks for modules/, config/ and fonts/.

The data sits one directory above the python package. That was already true in a
git checkout; the packaging work kept it true rather than adding a second layout,
by installing macarchy_touchbar/ NEXT TO modules/ and config/ under
/usr/share/macarchy-touchbar. macarchy-install#16.

The checkout case is the one that must never break: ./install.sh symlinks
bin/macarchy-touchbar out of the repo and everything still has to resolve.
"""
import os
import subprocess
import sys

from macarchy_touchbar import paths

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def test_the_env_var_wins(tmp_path, monkeypatch):
monkeypatch.setenv("MACARCHY_TOUCHBAR_DATA", str(tmp_path))
assert paths.data_root() == str(tmp_path)


def test_an_env_var_pointing_nowhere_is_still_honoured(tmp_path, monkeypatch):
# Explicit beats clever: a silent fallback would hide the typo until the bar
# came up with no modules on it.
missing = tmp_path / "gone"
monkeypatch.setenv("MACARCHY_TOUCHBAR_DATA", str(missing))
assert paths.data_root() == str(missing)


def test_the_data_sits_beside_the_code(monkeypatch):
monkeypatch.delenv("MACARCHY_TOUCHBAR_DATA", raising=False)
root = paths.data_root()
assert os.path.isdir(os.path.join(root, "modules"))
assert os.path.isfile(os.path.join(root, "config", "layouts.toml"))
assert os.path.isdir(os.path.join(root, "macarchy_touchbar"))


def test_a_package_layout_resolves_the_same_way(tmp_path, monkeypatch):
# Simulate /usr/share/macarchy-touchbar: the python package beside the data.
# No special case in data_root() is what makes the two layouts one rule.
monkeypatch.delenv("MACARCHY_TOUCHBAR_DATA", raising=False)
share = tmp_path / "share" / "macarchy-touchbar"
(share / "macarchy_touchbar").mkdir(parents=True)
(share / "modules").mkdir()
(share / "macarchy_touchbar" / "paths.py").write_text(
(ROOT / "macarchy_touchbar" / "paths.py").read_text()
if hasattr(ROOT, "__truediv__") else
open(os.path.join(ROOT, "macarchy_touchbar", "paths.py")).read())
(share / "macarchy_touchbar" / "__init__.py").write_text("")
out = subprocess.run(
[sys.executable, "-c",
"import sys; sys.path.insert(0, %r);"
"from macarchy_touchbar.paths import data_root; print(data_root())" % str(share)],
capture_output=True, text=True, env={k: v for k, v in os.environ.items()
if k != "MACARCHY_TOUCHBAR_DATA"})
assert out.stdout.strip() == str(share), out.stderr


def test_the_launcher_finds_the_tree_in_a_packaged_layout(tmp_path):
# bin/macarchy-touchbar sits in /usr/bin once packaged, so "one level up" is
# /usr and useless — it has to search. This is the bootstrap that makes the
# single rule above work from an installed binary.
launcher = open(os.path.join(ROOT, "bin", "macarchy-touchbar")).read()
assert "/usr/share/macarchy-touchbar" in launcher
assert "MACARCHY_TOUCHBAR_DATA" in launcher
4 changes: 2 additions & 2 deletions tests/test_draw.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def pixel(s, x, y):
return (r, g, b)


@pytest.mark.skipif(not os.path.exists(draw.CODEPOINTS),
@pytest.mark.skipif(not os.path.exists(draw.codepoints_path()),
reason="fonts/MaterialSymbolsRounded.codepoints not downloaded (install.sh)")
def test_codepoint_lookup():
assert icon_codepoint("brightness_high") == ""
Expand Down Expand Up @@ -61,7 +61,7 @@ def test_missing_icon_falls_back_to_warning_and_never_raises():

def test_icon_never_raises_when_codepoints_file_is_missing(monkeypatch):
# Fresh checkout: fonts/*.codepoints is git-ignored and may not exist.
monkeypatch.setattr(draw, "CODEPOINTS", "/nonexistent/MaterialSymbolsRounded.codepoints")
monkeypatch.setattr(draw, "codepoints_path", lambda: "/nonexistent/MaterialSymbolsRounded.codepoints")
monkeypatch.setattr(draw, "_codepoints", None)
s = surface()
Painter(s).icon(cairo.Context(s), "brightness_high", 50, 30)
Expand Down
Loading
Loading