From b38bbbac1ad8bf33cb804091783859ae22bc7271 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 15:23:07 +0800 Subject: [PATCH 1/2] iso: add external-monitors bread module for zero-config docking --- .../bread/modules/external-monitors.lua | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 iso/airootfs/etc/skel/.config/bread/modules/external-monitors.lua diff --git a/iso/airootfs/etc/skel/.config/bread/modules/external-monitors.lua b/iso/airootfs/etc/skel/.config/bread/modules/external-monitors.lua new file mode 100644 index 0000000..27a276c --- /dev/null +++ b/iso/airootfs/etc/skel/.config/bread/modules/external-monitors.lua @@ -0,0 +1,228 @@ +-- external-monitors — behave like a normal laptop desktop +-- +-- Plug in any display (HDMI, DisplayPort, USB-C dock, a random TV) and +-- the session just works. No output names to edit. +-- +-- • the laptop panel stays at its preferred (native) mode +-- • each external uses its preferred mode and refresh +-- • new screens clone the laptop (set ARRANGE = "extend" to sit to the right) +-- • closing the lid does not sleep while an external is on +-- • unplug everything and the laptop is the only display again +-- +-- Drop-in: copy to ~/.config/bread/modules/ and `bread reload`. + +local M = bread.module({ + name = "external-monitors", + version = "1.0.0", + after = { "bread.monitors" }, +}) + +-- "mirror" = every external clones the laptop (presentations, TVs) +-- "extend" = extra desktop to the right +local ARRANGE = "mirror" +local SCALE = "auto" + +local INTERNAL_RE = "^eDP" +local INHIBITOR = "/tmp/bread-lid-inhibitor.pid" + +local function inhibit_lid() + if bread.fs.exists(INHIBITOR) then return end + bread.exec( + "bash -c 'systemd-inhibit --what=handle-lid-switch --who=bread " + .. "--why=external-display sleep infinity & echo $! > " + .. INHIBITOR + .. "'" + ) +end + +local function release_lid() + bread.exec( + "bash -c 'kill $(cat " .. INHIBITOR .. " 2>/dev/null) 2>/dev/null; rm -f " .. INHIBITOR .. "'" + ) +end + +local function is_internal(name) + return type(name) == "string" and name:match(INTERNAL_RE) ~= nil +end + +local function drm_status(name) + for card = 0, 5 do + local raw = bread.fs.read(string.format("/sys/class/drm/card%d-%s/status", card, name)) + if raw then + return raw:match("^%s*(%S+)") + end + end + return nil +end + +local function drm_first_mode(name) + for card = 0, 5 do + local raw = bread.fs.read(string.format("/sys/class/drm/card%d-%s/modes", card, name)) + if raw then + local w, h = raw:match("(%d+)x(%d+)") + if w then + return tonumber(w), tonumber(h) + end + end + end + return 1920, 1080 +end + +local function list_connectors() + local names = {} + local ok, out = bread.exec_capture("ls /sys/class/drm", { timeout_ms = 500 }) + if not ok or not out then + return names + end + for ent in out:gmatch("[^%s]+") do + local name = ent:match("^card%d+%-(.+)$") + if name and not name:match("^Writeback") then + names[#names + 1] = name + end + end + table.sort(names) + return names +end + +local function connected() + local internal, externals = nil, {} + for _, name in ipairs(list_connectors()) do + if drm_status(name) == "connected" then + if is_internal(name) then + internal = internal or name + else + externals[#externals + 1] = name + end + end + end + return internal or "eDP-1", externals +end + +-- BOS Hyprland talks Lua (`hl.monitor`). Stock Hyprland uses the +-- `monitor=` keyword. Try eval first, then keyword. +local function apply_monitor(opts) + local extra = "" + if opts.mirror and opts.mirror ~= "" then + extra = string.format(", mirror = %q", opts.mirror) + end + local expr = string.format( + "hl.monitor({ output = %q, mode = %q, position = %q, scale = %q%s })", + opts.output, + opts.mode or "preferred", + opts.position or "0x0", + opts.scale or SCALE, + extra + ) + local resp = bread.hyprland.eval(expr) + if type(resp) == "string" and resp:match("error") then + local spec = string.format( + "%s, %s, %s, %s", + opts.output, + opts.mode or "preferred", + opts.position or "0x0", + opts.scale or SCALE + ) + if opts.mirror and opts.mirror ~= "" then + spec = spec .. ", mirror, " .. opts.mirror + end + bread.hyprland.keyword("monitor", spec) + end +end + +local function apply(internal, externals) + apply_monitor({ + output = internal, + mode = "preferred", + position = "0x0", + scale = SCALE, + }) + + if ARRANGE == "mirror" then + for _, name in ipairs(externals) do + apply_monitor({ + output = name, + mode = "preferred", + position = "0x0", + scale = SCALE, + mirror = internal, + }) + end + return + end + + local x = select(1, drm_first_mode(internal)) or 1920 + for _, name in ipairs(externals) do + apply_monitor({ + output = name, + mode = "preferred", + position = x .. "x0", + scale = SCALE, + }) + local w = select(1, drm_first_mode(name)) or 1920 + x = x + w + end +end + +function M.on_load() + local last = nil + local applied = false + + local function evaluate() + local internal, externals = connected() + local sig = internal .. "|" .. table.concat(externals, ",") + if sig == last then + return + end + last = sig + + if #externals == 0 then + if applied then + apply_monitor({ + output = internal, + mode = "preferred", + position = "0x0", + scale = SCALE, + }) + release_lid() + applied = false + end + return + end + + apply(internal, externals) + inhibit_lid() + applied = true + bread.log("[external-monitors] " .. internal .. " + " .. table.concat(externals, ", ")) + end + + local settle = bread.debounce(1500, evaluate) + + bread.on("bread.hyprland.monitor.connected", function(event) + local name = event.data and event.data.name + if name and not is_internal(name) then + bread.notify("Display connected: " .. name, { urgency = "low" }) + end + settle() + end) + + bread.on("bread.hyprland.monitor.disconnected", function() + settle() + end) + + bread.on("bread.device.**", function(event) + local sub = event.data and event.data.subsystem + if sub == "drm" then + settle() + end + end) + + bread.hyprland.on_raw("configreloaded", function() + last = nil + evaluate() + end) + + bread.every(3000, evaluate) + settle() +end + +return M From 716c77f93b685a5b273484628279cce6ead85d64 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 31 Aug 2026 19:15:38 +0800 Subject: [PATCH 2/2] packaging: republish python-pywal to [breadway] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python-pywal was dropped from Arch's [extra] repo (AUR-only now), so `pacstrap` can no longer resolve it and every ISO build fails with "target not found: python-pywal". The `wal` binary is load-bearing — bread-theme shells out to it to extract the colour palette from the user's wallpaper. Republish it the same way as calamares / bibata / powerlevel10k / yay-bin: - `packaging/python-pywal/PKGBUILD` — in-house copy of the AUR PKGBUILD (Morten Linderud's), modernised to `python -m build` / `installer` instead of the removed `setup.py install`, sha256-only sources like the sibling PKGBUILDs. Test-built locally: 28 unit tests pass, package ships `/usr/bin/wal`. - `.forgejo/workflows/python-pywal.yml` — builds + PUTs to the Forgejo Arch registry on a push to `packaging/python-pywal/**`. - `signed-repo.yml` gains it as a `workflow_run` trigger; `ci-publish-signed-repo.sh` gains it in `PACKAGES` so the signed dl.breadway.dev/arch db picks it up. - packages.x86_64 keeps the `python-pywal` line (now sourced from [breadway]) with a note. Ordering: `python-pywal.yml` must publish to the registry once before `signed-repo.yml` runs, or the collect step errors "registry db missing packages: python-pywal". --- .forgejo/workflows/python-pywal.yml | 39 +++++++++++++++++++++++++ .forgejo/workflows/signed-repo.yml | 1 + docs/signed-repo.md | 4 +-- iso/packages.x86_64 | 2 ++ packaging/arch/README.md | 2 +- packaging/python-pywal/PKGBUILD | 44 +++++++++++++++++++++++++++++ scripts/ci-publish-signed-repo.sh | 1 + 7 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 .forgejo/workflows/python-pywal.yml create mode 100644 packaging/python-pywal/PKGBUILD diff --git a/.forgejo/workflows/python-pywal.yml b/.forgejo/workflows/python-pywal.yml new file mode 100644 index 0000000..f5f7d61 --- /dev/null +++ b/.forgejo/workflows/python-pywal.yml @@ -0,0 +1,39 @@ +name: Build and publish python-pywal + +# python-pywal was dropped from Arch's [extra] repo (AUR-only now), but the ISO +# needs the `wal` binary (bread-theme extracts the wallpaper palette with it). +# BOS keeps an in-house PKGBUILD and publishes to the [breadway] repo — same +# pattern as calamares / bibata / powerlevel10k / yay-bin. +on: + push: + paths: + - 'packaging/python-pywal/**' + workflow_dispatch: + +jobs: + python-pywal: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + steps: + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + pacman -Syu --noconfirm base-devel git \ + python python-build python-installer python-wheel python-setuptools imagemagick + useradd -m builder + git config --global --add safe.directory '*' + # Clone the ref that triggered this run (not the default branch) — + # same as the other packaging workflows. + git clone --depth 1 --branch "${GITHUB_REF_NAME}" \ + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src + chown -R builder:builder /home/builder/src + su builder -c "cd /home/builder/src/packaging/python-pywal && makepkg -f --noconfirm" + PKG=$(find /home/builder/src/packaging/python-pywal -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.forgejo/workflows/signed-repo.yml b/.forgejo/workflows/signed-repo.yml index 3ef8a65..caa5d15 100644 --- a/.forgejo/workflows/signed-repo.yml +++ b/.forgejo/workflows/signed-repo.yml @@ -21,6 +21,7 @@ on: - Build and publish bibata-cursor-theme - Build and publish powerlevel10k - Build and publish yay-bin + - Build and publish python-pywal types: [completed] concurrency: diff --git a/docs/signed-repo.md b/docs/signed-repo.md index 224476e..2655774 100644 --- a/docs/signed-repo.md +++ b/docs/signed-repo.md @@ -47,7 +47,7 @@ On disk: `/srv/breadway-dl/arch/x86_64/` (nginx already serves The job collects the current ISO `[breadway]` set from the Forgejo Arch registry (breadlock + calamares, zen-browser-bin, bibata-cursor-theme-bin, -zsh-theme-powerlevel10k, yay-bin). Leftover bakery-channel pacman packages +zsh-theme-powerlevel10k, yay-bin, python-pywal). Leftover bakery-channel pacman packages still sitting in that registry are **not** copied. Optional `BREADWAY_PKG_DIR` on the runner overrides individual files. @@ -87,7 +87,7 @@ curl -fsS -X POST \ ``` It also runs after the in-repo AUR republish workflows complete -(`calamares` / `bibata` / `powerlevel10k` / `yay-bin`). breadlock lives in +(`calamares` / `bibata` / `powerlevel10k` / `yay-bin` / `python-pywal`). breadlock lives in another repo; that job can fire this one with `repository_dispatch` event `publish-signed-repo` (or dispatch from the UI after a breadlock tag). diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index ff90edf..d30488a 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -225,6 +225,8 @@ slurp wl-clipboard playerctl # Wallpaper daemon + pywal (drives the bread* colour palette from the wallpaper). +# python-pywal was dropped from Arch [extra] (AUR-only now) — republished to +# [breadway], see packaging/python-pywal. awww python-pywal # Boot splash (BOS logo + spinner instead of kernel text). diff --git a/packaging/arch/README.md b/packaging/arch/README.md index ce80f7e..1116280 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -3,7 +3,7 @@ Arch packaging This directory only holds `PKGBUILD`s for third-party AUR packages BOS republishes to the `[breadway]` pacman repo (`calamares`, `bibata`, -`powerlevel10k`, `yay-bin`) — not the user's own code. See each +`powerlevel10k`, `yay-bin`, `python-pywal`) — not the user's own code. See each subdirectory's `.forgejo/workflows/.yml` (in this repo) for how each one publishes on a push to `packaging//**`. diff --git a/packaging/python-pywal/PKGBUILD b/packaging/python-pywal/PKGBUILD new file mode 100644 index 0000000..b6796f5 --- /dev/null +++ b/packaging/python-pywal/PKGBUILD @@ -0,0 +1,44 @@ +# BOS in-house rebuild of python-pywal. +# +# python-pywal was dropped from Arch's [extra] repo (it is now AUR-only), but +# BOS needs the `wal` binary: bread-theme shells out to it to extract a colour +# palette from the user's wallpaper. Republished to [breadway] so the ISO can +# pull it via pacman, same pattern as calamares / bibata / powerlevel10k / +# yay-bin. Source of truth: https://aur.archlinux.org/packages/python-pywal +# +# Maintainer: Breadway +# Upstream maintainer: Morten Linderud +# Contributor: Sean Haugh + +pkgname=python-pywal +pkgver=3.3.0 +pkgrel=11 +pkgdesc="Generate and change colorschemes on the fly" +arch=('any') +url="https://github.com/dylanaraps/pywal/" +license=('MIT') +depends=('python' 'imagemagick') +makedepends=('python-build' 'python-installer' 'python-wheel' 'python-setuptools') +optdepends=('feh: set wallpaper' + 'nitrogen: set wallpaper') +# BOS PKGBUILDs verify sources by sha256 only (no source PGP), matching +# calamares / powerlevel10k here. +source=("$pkgname-$pkgver.tar.gz::https://github.com/dylanaraps/pywal/archive/${pkgver}.tar.gz") +sha256sums=('fe8fc1c29d1cad1a1a8580293dcfe32e1fac259f9dbfd5c8877439fa5948d189') + +build() { + cd "pywal-${pkgver}" + # setup.py-only project: python-build injects the setuptools backend. + python -m build --wheel --no-isolation +} + +check() { + cd "pywal-${pkgver}" + python -m unittest discover -vs tests +} + +package() { + cd "pywal-${pkgver}" + python -m installer --destdir="$pkgdir" dist/*.whl + install -Dm644 LICENSE.md "$pkgdir/usr/share/licenses/$pkgname/LICENSE" +} diff --git a/scripts/ci-publish-signed-repo.sh b/scripts/ci-publish-signed-repo.sh index f063ce2..6ded0e0 100755 --- a/scripts/ci-publish-signed-repo.sh +++ b/scripts/ci-publish-signed-repo.sh @@ -22,6 +22,7 @@ PACKAGES=( bibata-cursor-theme-bin zsh-theme-powerlevel10k yay-bin + python-pywal ) ARCH="${BREADWAY_ARCH:-x86_64}"