Omarchy becomes a first-class host, and the host grows a control surface #428
Merged
enricobuehler
merged 8 commits from 2026-08-28 19:07:36 +00:00
worktree-omarchy-impl into main
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9c64cc6099 |
The ticket parser proves its own segments exist
ci / bun-nix (pull_request) Successful in 34s
ci / docs-drift (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m1s
ci / docs-site (pull_request) Successful in 1m0s
ci / rust-arm64 (pull_request) Successful in 1m49s
installer-smoke / smoke (arch) (pull_request) Successful in 1m9s
installer-smoke / smoke (fedora-44) (pull_request) Successful in 1m30s
installer-smoke / smoke (debian-13) (pull_request) Successful in 1m55s
android / android (pull_request) Successful in 6m6s
ci / rust (pull_request) Successful in 7m12s
`tsc --noEmit` under `noUncheckedIndexedAccess`: destructuring `ticket.split(".")` yields
`string | undefined` per element, and `parts.length !== 3` does not narrow a plain array — so five
type errors on the regex tests and the compare. An explicit `!ts || !nonce || !mac` guard proves it
to the compiler AND rejects the empty segments a `"1..2"` ticket would otherwise reach the regexes
with, which is what the removed `parts.some(p => p.length === 0)` was doing less directly.
Caught by CI's `web` job, not by me: I ran `bun test` and biome and stopped there. The typecheck
needs `node_modules`, which a fresh worktree has none of, and I took the two h3 import failures as
"pre-existing" without noticing that the same missing install was hiding the type check entirely.
With the deps in place the full web suite is 36/36 and `tsc --noEmit` exits 0.
|
||
|
|
38d2b0e454 |
The console follows the desktop's theme instead of ignoring it
ci / docs-drift (pull_request) Successful in 26s
ci / bun-nix (pull_request) Successful in 28s
ci / docs-site (pull_request) Successful in 53s
ci / web (pull_request) Failing after 57s
installer-smoke / smoke (arch) (pull_request) Successful in 38s
installer-smoke / smoke (fedora-44) (pull_request) Failing after 39s
ci / rust-arm64 (pull_request) Successful in 2m49s
installer-smoke / smoke (debian-13) (pull_request) Successful in 5m32s
android / android (pull_request) Successful in 8m11s
ci / rust (pull_request) Successful in 10m2s
WP7 shipped a template and no consumer. `punktfunk.json.tpl` rendered fine — Omarchy globs
`~/.config/omarchy/themed/*.tpl` and writes the result to
`~/.local/state/omarchy/current/theme/` on every `omarchy-theme-set`, and the keys the template
asks for (`mode`, `accent`, `background`, `foreground`) all exist in a theme's `colors.toml`,
verified against tokyo-night on the box. Nothing read it, so nothing changed, which is exactly what
"the theme is not applied" looks like from the outside.
Now: the server reads the rendered file and `ui-config` carries it; the root applies `mode` as the
`.dark` class the whole stylesheet already keys off, and `accent` as the brand variable
`--primary`, `--accent` and `--ring` all derive from. One value re-tints the buttons, the active
nav and the focus rings together.
BOTH brand variables, not just `--pf-brand`. The light palette derives `--primary` from it, but
`.dark` derives `--primary` from `--pf-brand-light`, and the console ships pinned dark — so setting
only the first would have re-tinted light mode and done nothing whatsoever in the mode people
actually see.
The accent reaches a style attribute, so the reader validates it as a colour rather than trusting
the file: hex and the functional notations Omarchy themes use, nothing else. That is not
theoretical tidiness — an UNRENDERED template contains the literal `{{ accent }}`, and a theme
switch can be caught mid-write. Both are "no theme" here, and no theme means the console's own
violet, which is also what SSR paints and what shows for the moment before the fetch resolves.
Every failure is the fallback; none is an error page.
Read per request rather than cached, because `omarchy-theme-set` rewrites the file whenever the
user switches and a console that only looked at startup would be wrong until it restarted.
Verified on the box: the template renders (`"mode":"dark","accent":"#7aa2f7"`), and the reader
parses that exact file. 14 tests, biome clean.
⚠ Like the handoff, the console half needs a `punktfunk-web` build to reach a box — it ships as a
prebuilt Nitro `.output` and the Omarchy testbox has no bun — so this is unit-tested and
file-verified but has not painted a pixel on glass.
|
||
|
|
30c31ed31b |
Opening the console from your own launcher no longer asks who you are
Three things an Omarchy user meets in the first minute, all of which were wrong.
The login (the handoff)
-----------------------
Asking for a password to open your own console from your own app menu buys nothing — but the
obvious fix, dropping the login, is not available: the console binds **0.0.0.0:47992** so it can be
reached from a phone, and its admin surface is pairing, unpair and session control. Trusting the
network would hand that to the LAN.
So trust what actually distinguishes the operator: the **management token**, a 0600 file inside the
0700 config dir. `punktfunk-host ctl console-url` mints a ticket signed with it; the console
verifies with the copy it already holds and seals the ordinary session cookie. Whoever can read
that file can already drive the whole admin API — it is the credential the console's own proxy
presents — so skipping a password they could simply read widens nothing, and a visitor without a
ticket still meets the login page.
`<unix-seconds>.<nonce>.<HMAC-SHA256>` over `pf-console-handoff:v1:ts:nonce`. 60 s TTL, single use,
symmetric window (a ticket from the future is as wrong as an old one), one 401 for every rejection
so probing learns nothing. No new host route and no shared state: both sides already hold the key.
The decision lives in `util/handoff` rather than the route so it is testable without an h3 event —
and the test suite includes a vector minted by the real Rust host and cross-checked against
python's `hmac`, so a drift in the message format fails here instead of six weeks later in the
field.
The launcher entry
------------------
`Icon=` was empty, so the entry drew nothing. Two causes: the host package never shipped an app
icon (only the CLIENT package installed `io.unom.Punktfunk.svg`), and `omarchy-webapp-install`
derives an icon name with `${ref%.*}` + slugify, which turns a dotted theme name into `io-unom` —
resolving to nothing. The host package now ships the scalable mark, and setup writes `Icon=` and
`Exec=` itself after letting their tool create the entry.
The Omarchy menu
----------------
Super+Space had only the webapp. There is now a Punktfunk submenu — open console, pair a device,
devices waiting, paired devices, stop the session, status, restart — with `when` guards so a row
the box cannot honour does not show.
D7 avoided writing the user's `omarchy-menu.jsonc` because it is a single document where one parse
error silently drops EVERY row they own. That risk is real, so the merge earns its place: work on a
copy, insert between markers so a re-run replaces rather than stacks, validate the result as JSONC
AND confirm our rows actually landed, and only then move it into place. A file that does not parse
to begin with is left completely alone — it is not ours to repair. `remove` takes the block back
out and restores their file byte for byte.
Two bugs the self-check caught before the box did: `awk -v` refuses a newline inside a `-v`
assignment, so the rows silently never landed while the function reported success (hence the merge
now verifies its own output, not just that the file still parses); and `MENU_FILE` was a `readonly`
computed at LOAD time, so it ignored an `XDG_CONFIG_HOME` set afterwards and edited the real config
of the machine running the test. Paths resolve at call time now, like `write_hooks` already did.
Gates: fmt, clippy -D warnings, 13 ctl tests, 7 handoff tests, 13 shell self-checks, docs-drift,
shellcheck. On the box: the ticket verifies against an independent HMAC, two tickets in the same
second differ, and the launcher entry carries the right Exec and Icon.
⚠ The console half needs a `punktfunk-web` build to reach a box — it ships as a prebuilt Nitro
`.output`, and there is no bun on the Omarchy testbox — so the route itself is unit-tested but has
not served a request on glass.
|
||
|
|
739d63fc04 |
The picker is handed back when the host exits, not between casts
Restoring `custom_picker_binary` at the end of every cast meant the NEXT session found the config changed, rewrote it, and restarted xdg-desktop-portal-hyprland — and a ScreenCast bound across an xdph restart never delivers a buffer. The portal runtime caches its D-Bus connection process-globally (`portal_thread`'s own note), so the restart orphans the cached connection and the handshake then succeeds against a session nothing is alive to serve. That is the failure our own error text already names: "a stream bound during a compositor (re)start that will never deliver". So the restore moves to the host's shutdown path, through the `restore_takeover_now` hook the host already calls on SIGTERM — no cast is live there, so the xdph restart it triggers costs nothing. Leaving the shim installed between sessions is safe precisely because it DELEGATES: with no selection pending it execs the picker that was configured before us, so an ordinary browser share behaves exactly as it did. That is what D6 actually asks for — the user's screen sharing keeps working — and it is what lets the takeover be idempotent instead of churning the file. A host that is SIGKILLed still leaves the shim, and `punktfunk-omarchy remove` puts the original back from the marker. Found while chasing a black screen on Omarchy that turned out to have a different root cause (the VM had two GPUs and the portal allocated on the wrong one), so this fixes a real defect that was not the one being hunted — every session after the first would have restarted xdph underneath itself regardless of the GPU topology. |
||
|
|
c7d270b466 |
The streamed head can be focused on a Lua box, which is what makes it produce frames
#283's focus fix has never reached a Lua-configured Hyprland. The old note here said why and then stopped: `hyprctl dispatch focusmonitor <name>` is parsed as `hl.dispatch(focusmonitor <name>)` and dies with `')' expected near '<name>'`, and `hl.dsp.focusmonitor` does not exist. Both true. The name was simply wrong. Measured on Omarchy 4.0.1 / Hyprland 0.56.2, the spelling is hl.dsp.focus({ monitor = "<name>" }) and the compositor volunteers it — ask with any other key and it answers "hl.focus: unrecognized arguments. Expected one of: direction, monitor, window, urgent_or_last, last". Same two-era shape as `dpms_one`: try classic, then Lua, report both if neither lands. Each spelling is rejected by the other's parser, which is what makes running them blind safe. This is not the cosmetic window-placement issue it was filed as. A headless output nothing has focused stays empty; an empty output produces no damage; no damage means no PipeWire frames — and capture then misses its first-frame deadline: pipeline build failed — retrying attempt=4 max=8 error=first frame: no PipeWire frame within 10s (node 85): format negotiated but no buffers arrived — the compositor produced no frames i.e. a black screen on the client, on every Omarchy box. It is only HALF the black screen seen on glass, and the other half is NOT fixed here. The same session had already run `topology: exclusive`, which logged `topology: exclusive` — the streamed output is now the desk disabled=["Virtual-1","HDMI-A-1"] twice, and then never restored them: `restore_heads` logs on success AND on failure, and neither line appears anywhere in the journal, so it was never called on the pipeline-build failure path. The ten retries that followed all reported "had nothing to disable" — correctly, the heads were already off — so once the first attempt fails the desk stays dark and every later attempt fails for the same reason. `hyprctl reload` is the recovery. That belongs to #284's in-flight work rather than here, so it is recorded with its evidence instead of half-fixed in a file another effort is editing. |
||
|
|
b8720e5e4a |
The Omarchy box says otherwise: five things the plan got wrong, measured
WP0 + S2 run against a real Omarchy 4.0.1 box (Hyprland 0.56.2, Quickshell 0.3.1, RTX 5070 Ti). Everything below is a measurement, not a re-reading of the docs. Fixed, each one a first-run defect ---------------------------------- 1. **The install line could not work.** Omarchy ships a libalpm PreTransaction hook (00-omarchy-update-guard) that aborts any transaction whose pacman invocation carries BOTH -S and -u, to funnel upgrades through `omarchy update`. So the Arch line this branch shipped for Omarchy — `pacman -Syu <pkgs>` — dies with "Woah partner..." and installs NOTHING. Seen in /var/log/pacman.log, then confirmed the other way: `pacman -Sy` + `pacman -S <pkgs>` installs all three cleanly. platforms.json, the docs page and install.sh now split the two, and plain Arch keeps its full -Syu (a partial upgrade against a ROLLING repo is what breaks those boxes; Omarchy's frozen snapshot mirror is exactly why it does not break here). 2. **The idle guard never restored anything.** `omarchy-toggle-idle status` prints JSON, not a keyword — and the payload is a trap: its tooltip names the action the button WOULD take, so the string "Stay Awake" appears precisely when stay-awake is OFF. The old case-glob matched neither that nor the words it was looking for, so every arm fell through to "do nothing" and the box stayed awake after every session. Now reads `"enabled":true`. Verified both ways on the real tool: a user's own stay-awake survives a stream, one we set is undone. 3. **`status` lied twice.** It reported "no punktfunk rules" seconds after adding six of them (a failed `sudo -n` read was being treated as proof of absence), and reported xdph missing on a box that was capturing through it — `pgrep -x` can never match `xdg-desktop-portal-hyprland` because Linux truncates comm to 15 characters, and pgrep says so on a stderr the check threw away. Now: never claim absence from a failed read, and ask systemd about the portal. 4. **D3's premise is only half true.** Omarchy ships TWO session entries. On `hyprland-uwsm` graphical-session.target starts; on plain `hyprland` it never does — measured, and Omarchy's OWN units that want it (omarchy-crash-watch, omarchy-sleep-lock) sit enabled-and-dead there too. The drop-in is additive so autostart still works either way, but "restarts with your session" was a promise we could not keep. setup and status now detect and say which it is, and the docs explain when switching sessions is worth it (rarely: the host re-derives the live compositor per connect). 5. **`ctl watch` leaked a stream per dead consumer.** A watcher only ever READS, so when the widget died its end of our stdout pipe closed unnoticed: six orphans accumulated across three shell restarts on a host with no events at all. The SSE keep-alive now surfaces as a `ctl.heartbeat` line, which gives us a write to fail on, and a failed write ends the process. Verified with its control: dies ~13s after its reader goes, survives 50s while the reader lives. Confirmed as designed --------------------- * **D6 end to end.** The box's xdph.conf is exactly the shape the fix targets. A real capture (xdph → PipeWire 1920x1080 → cuda-import → nvenc) installed the shim, the shim picked up `hyprland-preview-share-picker` as its fallback automatically, the restore fired on teardown, and the file came back byte-identical with no marker left. The landmine is defused. * **The pin correction.** The mgmt port serves 306d95… = native-cert.pem; cert.pem is 34a4cb…, a different certificate entirely. Pinning cert.pem as the plan said would have exited 4 on every healthy host. All four exit codes exercised on glass — 4 by pointing ctl at the host's own legacy cert, which is a valid certificate that simply is not the one mgmt serves. * **R1 and R3 closed.** Our soname depends (libavcodec.so=63-64 …) match the frozen snapshot mirror's ffmpeg 9.0.1 exactly, and /etc/pacman.conf is owned by pacman, not omarchy-settings, so our repo stanza is not clobbered. R4 closed by (1); R7 closed — enforce_permissions is off as shipped. * The tree builds on Omarchy in 8m16s against the snapshot's toolchain (rustc 1.98). Gates: fmt, clippy -D warnings, 13 ctl tests, docs-drift, shellcheck, 8 shell self-checks. |
||
|
|
32a89fd8ec |
Two checks for the two failures that report nothing at all
Diagnostics catalog rows for the console's troubleshooting page (design §3.M, WP1). `hyprland_permissions` — Hyprland 0.49+ can enforce per-application permissions, and when it does, screencopy and virtual input are denied SILENTLY: black frames and dead input, with no error from the host and none from the compositor. Every other failure on that path announces itself; this one is the reason the row exists. Warning rather than Critical on purpose — enforcement being ON does not mean we are denied, a granted host streams fine, and no probe outside the compositor can tell the two apart. Not Omarchy-specific: it applies to every Hyprland box, and Omarchy is merely the distro most likely to turn it on. `omarchy_updates` — on Omarchy the console's apply button is deliberately absent (D5). Without a row saying where updates come from instead, "my update button is missing" is an unanswerable support question, asked in the one place we could have answered it. Both ids are registered in the documented-ids test, so a rename is caught in review rather than in a bug report about a check that suddenly renders in English. Host suite: 678 pass. The one red — gamestream::stream::tests::sender_delivers_batches — is the documented EINTR flake in this qemu container: it fails identically with these changes stashed, it lives in a file this branch never touches, and the panic is literally "Interrupted system call". |
||
|
|
d9cb3ce701 |
The host grows a control surface, and Omarchy stops being a distro we merely tolerate
Implements the Omarchy integration plan (punktfunk-planning design/omarchy-integration.md + omarchy-integration-implementation-plan.md): the ctl surface M-C1..C4, design WP1-WP7, and the D6 xdph landmine. WP0 (the measurement battery) and WP11 (upstreaming) are not here — the first needs an Omarchy box, the second needs an explicit go. `punktfunk-host ctl` (WP8 / M-C1..C4) ------------------------------------ A subcommand, not a second binary. The entry-point analysis is in the plan's §3; the short version is that a new binary touches every Linux artifact we ship to buy nothing, while `main.rs` already dispatches a dozen verbs and in-crate means no second declaration of the mgmt types to drift. 15 verbs over the existing admin lane — pairing arm/pending/approve/deny, the Moonlight PIN, both planes' device lists with rename/unpair/access presets, session stop/end-game, and `watch`, which bridges the SSE stream to line-JSON on stdout with `Last-Event-ID` resume and a synthetic `ctl.resync` line after a `dropped` frame. `--json` on every verb returns a versioned envelope; the human tables are not parsed by anything we ship. The security model is the plan's §§1-2, and the load-bearing part is **pin before token**: the agent pins the host's own leaf (via punktfunk-core's canonical `PinVerify`) and rustls rejects a mismatch *during the handshake*, so ureq never gets as far as serialising an `Authorization` header. That closes the local port-squat vector with zero server-side change — `mgmt/auth.rs` is untouched by this whole surface. There is deliberately no `--token` flag and no token env read: a credential in either is readable cross-uid through /proc, which is what the 0700 config dir exists to prevent. ctl consumes the token the host persists and never mints one (the `web-password` silent-adoption lesson, inverted). Two corrections to the plan, both from reading the code rather than the doc: * the mgmt listener serves the NATIVE identity, so the pin is `native-cert.pem` then `cert.pem` — pinning `cert.pem` alone would exit 4 on a perfectly healthy host; * the pin is a SHA-256 fingerprint compare through the existing audited verifier, not a new bytes-compare. Same property, one less thing to review. The test that matters is the negative: a squatter presenting a valid, well-formed, wrong self-signed certificate gets exit 4 and receives ZERO application bytes. The xdph collision (D6) ----------------------- Omarchy ships its own `~/.config/hypr/xdph.conf` naming `hyprland-preview-share-picker` — the picker every browser share on the box goes through — and our Hyprland backend takes `custom_picker_binary` over on first connect. The merge half was already right (one key, in place, with a backup); the way back did not exist, so a box that had ever streamed kept our shim forever, including after a crash and after a reboot. Two changes, and the first is the one that does not depend on getting teardown right: the shim now DELEGATES — with no selection pending it execs whatever was configured before us, so ordinary browser shares behave exactly as they did, during a session, after it, after a kill and after a reboot that emptied $XDG_RUNTIME_DIR. Second, the edit records what it replaced (a marker comment written in the same atomic write, so it survives everything a sidecar state file would not), and the picker is handed back when the last cast ends. `punktfunk-omarchy remove` restores it too, from the same marker. The rest -------- * WP1 — `osinfo::is_omarchy()`; the console update tier is notify-only on Omarchy and names `omarchy update`. Enforced at the apply route, not merely reported: a direct POST on a box with the helper, the group and the sysupgrade opt-in would otherwise run `pacman -Syu` into their guard, or past it, skipping the snapper snapshot their rollback depends on. * WP2/WP3 — `punktfunk-omarchy setup|remove|status`, installed but never run by the package (packages do not open firewalls or edit user config behind an operator's back). LAN-scoped comment-tagged ufw rules over our existing app profiles, the session drop-in that Omarchy's uwsm actually starts, the console as a webapp rather than an edit to the user's single menu JSONC, and a Sunshine-coexistence check that says plainly which plane collides. PKGBUILD gains the hyprland/xdph optdepends that were owed before Omarchy. * WP4 — docs/omarchy.mdx, the ctl reference in host-cli.md, platforms.json (+ snapshot), the Hyprland/Omarchy rows in requirements.md, an honest "not verified on glass" row in the support matrix, and an install.sh branch that hands over to `punktfunk-omarchy setup`. * WP5/WP6/WP7 — sample hooks wired through hooks.json (never host.env: hooks are a list, and an operator's existing file is printed to rather than overwritten), an idle guard that snapshots and RESTORES the user's own stay-awake setting rather than assuming, and the theme template. Gates: fmt, clippy -D warnings on punktfunk-host and pf-vdisplay's Linux half, 45 host tests, 267 pf-vdisplay tests, check-docs-drift, shellcheck, and 8 shell self-checks covering the awk picker restore and the hooks.json generator against the same fixtures the Rust round-trip tests use. Not verified on an Omarchy box — nothing activates without `punktfunk-omarchy setup`, so a plain Arch box is unaffected either way. |