Omarchy becomes a first-class host, and the host grows a control surface #428

Merged
enricobuehler merged 8 commits from worktree-omarchy-impl into main 2026-08-28 19:07:36 +00:00
Owner

Implements punktfunk-planning's design/omarchy-integration.md + its implementation plan, then takes the result to a real Omarchy 4.0.1 box and fixes what that found. 39 files, +3970/−62.

punktfunk-host ctl (design WP8)

A subcommand, not a second binary — main.rs already dispatches a dozen verbs, and in-crate means no second declaration of the mgmt types to drift. 16 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, console-url, and watch, which bridges the SSE stream to line-JSON with Last-Event-ID resume and a synthetic ctl.resync.

The load-bearing property is pin before token: the agent pins the host's own leaf 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 changemgmt/auth.rs has no diff. There is deliberately no --token flag and no token env read; both are readable cross-uid through /proc.

Two corrections to the plan, both from reading the code:

  • the mgmt listener serves the native identity, so the pin is native-cert.pemcert.pem. Pinning cert.pem as the plan said exits 4 on every healthy host — confirmed on glass: the port serves 306d95…, cert.pem is 34a4cb….
  • the pin reuses punktfunk-core's existing PinVerify rather than a new bytes-compare.

What the box changed

Five defects that only a real Omarchy box could surface:

  1. pacman -Syu <pkgs> installs nothing. A libalpm PreTransaction hook aborts any transaction carrying both -S and -u. The Arch line this branch originally shipped for Omarchy died with "Woah partner…". -Sy then -S works. This was a first-run blocker on the documented path.
  2. The idle guard never restored anything. omarchy-toggle-idle status prints JSON, and its tooltip names the action the button would take — so "Stay Awake" appears exactly when stay-awake is off. Every arm fell through to "do nothing".
  3. status lied twice — claimed no ufw rules seconds after adding six (a failed sudo -n read treated as absence), and claimed xdph was missing on a box capturing through it (pgrep -x can never match a 27-char name; comm truncates at 15).
  4. D3 is half true. Omarchy ships two session entries; graphical-session.target starts only under the uwsm one. On the plain one Omarchy's own units that want it are enabled-and-dead too. Setup and status now detect and say which you have.
  5. ctl watch leaked an SSE stream per dead consumer — six orphans after three shell restarts on an idle host, because a read-only stream never notices its stdout pipe closed. The keep-alive now surfaces as ctl.heartbeat, giving a write to fail on.

The xdph landmine (design D6)

Omarchy ships its own ~/.config/hypr/xdph.conf naming hyprland-preview-share-picker — the picker every browser share goes through — and our Hyprland backend takes that key over. The merge was already right; the way back did not exist.

The fix that does not depend on teardown: the shim delegates, exec'ing whatever was configured before us when no selection is pending. Plus a marker recorded in the same atomic write, and the hand-back moved to host shutdown. Restoring per-cast turned out to restart xdph mid-session, which is its own black screen — the portal runtime caches its D-Bus connection process-globally.

Verified end to end on the box: real capture (xdph → PipeWire 1920×1080 → cuda-import → nvenc), shim picked up their picker automatically, restore fired, xdph.conf byte-identical.

#283 on the Lua era

hyprctl dispatch focusmonitor <name> parses as hl.dispatch(focusmonitor <name>) and dies. The code documented this as unfixable because "hl.dsp.focusmonitor does not exist" — true, and the wrong name. It is hl.dsp.focus({ monitor = "…" }), which the compositor volunteers if you ask with any other key. Not cosmetic: an unfocused headless output stays empty, an empty output produces no damage, and no damage means no frames.

Console: log in once, follow the desktop's theme

ctl console-url mints a ticket signed with the management token; the console verifies with the copy it already holds. Not "skip the login" — the console binds 0.0.0.0:47992, so trusting the network would hand pairing/unpair/session-control to the LAN. Trusting the 0600 token is the same bar reached differently: whoever can read it already holds the credential the console's own proxy presents. 60 s TTL, single use, one 401 for every rejection. A visitor without a ticket still meets the login page.

The theme template rendered fine all along and nothing consumed it. Now ui-config carries it and the root applies mode and accent — both brand variables, since .dark derives --primary from --pf-brand-light and the console ships pinned dark.

Also: Super+Space gains a Punktfunk submenu (merged into the user's JSONC on a copy, validated as JSONC and checked that our rows landed, with a byte-exact remove), and the launcher entry finally has an icon — the host package never shipped one.

Rest of the design

WP1 detection + notify-only updates (enforced at the apply route, not merely reported — a direct POST would otherwise run pacman -Syu past their snapper snapshot), WP2 packaging (+ the hyprland/xdg-desktop-portal-hyprland optdepends owed since before Omarchy), WP3 punktfunk-omarchy setup|remove|status, WP4 docs, WP5/6/7 hooks/idle/theme, and two diagnostics rows for failures that report nothing at all.

Verification

Gates: fmt, clippy -D warnings (punktfunk-host + pf-vdisplay), 57 host tests, 268 pf-vdisplay tests, 14 web tests, 13 shell self-checks, docs-drift, shellcheck, biome. Rebased onto d12d494f with zero file overlap.

On the box: all four ctl exit codes (4 by pointing ctl at the host's own legacy cert), the D6 round trip, the ticket verified against an independent HMAC, the theme template rendered and parsed, setup/remove end to end. The tree builds on Omarchy in 8m16s.

Not verified on glass: the two console-side changes need a punktfunk-web build to reach a box — it ships as a prebuilt Nitro .output and the testbox has no bun — so the handoff route and the theme are unit-tested and file-verified but have not served a request. Also unfixed and out of scope here: topology: exclusive disables the operator's heads and does not restore them on the pipeline-build failure path, leaving the desk dark (hyprctl reload recovers). That belongs to #284's in-flight work; recorded with its evidence rather than half-fixed in a file another effort is editing.

Implements `punktfunk-planning`'s `design/omarchy-integration.md` + its implementation plan, then takes the result to a real Omarchy 4.0.1 box and fixes what that found. 39 files, +3970/−62. ## `punktfunk-host ctl` (design WP8) A subcommand, not a second binary — `main.rs` already dispatches a dozen verbs, and in-crate means no second declaration of the mgmt types to drift. 16 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, `console-url`, and `watch`, which bridges the SSE stream to line-JSON with `Last-Event-ID` resume and a synthetic `ctl.resync`. The load-bearing property is **pin before token**: the agent pins the host's own leaf 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` has no diff. There is deliberately no `--token` flag and no token env read; both are readable cross-uid through `/proc`. Two corrections to the plan, both from reading the code: - the mgmt listener serves the **native** identity, so the pin is `native-cert.pem` → `cert.pem`. Pinning `cert.pem` as the plan said exits 4 on every healthy host — **confirmed on glass**: the port serves `306d95…`, `cert.pem` is `34a4cb…`. - the pin reuses `punktfunk-core`'s existing `PinVerify` rather than a new bytes-compare. ## What the box changed Five defects that only a real Omarchy box could surface: 1. **`pacman -Syu <pkgs>` installs nothing.** A libalpm PreTransaction hook aborts any transaction carrying both `-S` and `-u`. The Arch line this branch originally shipped for Omarchy died with *"Woah partner…"*. `-Sy` then `-S` works. This was a first-run blocker on the documented path. 2. **The idle guard never restored anything.** `omarchy-toggle-idle status` prints JSON, and its tooltip names the action the button *would* take — so `"Stay Awake"` appears exactly when stay-awake is **off**. Every arm fell through to "do nothing". 3. **`status` lied twice** — claimed no ufw rules seconds after adding six (a failed `sudo -n` read treated as absence), and claimed xdph was missing on a box capturing through it (`pgrep -x` can never match a 27-char name; comm truncates at 15). 4. **D3 is half true.** Omarchy ships *two* session entries; `graphical-session.target` starts only under the uwsm one. On the plain one Omarchy's own units that want it are enabled-and-dead too. Setup and status now detect and say which you have. 5. **`ctl watch` leaked an SSE stream per dead consumer** — six orphans after three shell restarts on an idle host, because a read-only stream never notices its stdout pipe closed. The keep-alive now surfaces as `ctl.heartbeat`, giving a write to fail on. ## The xdph landmine (design D6) Omarchy ships its own `~/.config/hypr/xdph.conf` naming `hyprland-preview-share-picker` — the picker every browser share goes through — and our Hyprland backend takes that key over. The merge was already right; the way back did not exist. The fix that does not depend on teardown: **the shim delegates**, exec'ing whatever was configured before us when no selection is pending. Plus a marker recorded in the same atomic write, and the hand-back moved to host shutdown. Restoring per-cast turned out to restart xdph mid-session, which is its own black screen — the portal runtime caches its D-Bus connection process-globally. Verified end to end on the box: real capture (xdph → PipeWire 1920×1080 → `cuda-import → nvenc`), shim picked up their picker automatically, restore fired, `xdph.conf` byte-identical. ## #283 on the Lua era `hyprctl dispatch focusmonitor <name>` parses as `hl.dispatch(focusmonitor <name>)` and dies. The code documented this as unfixable because "`hl.dsp.focusmonitor` does not exist" — true, and the wrong name. It is **`hl.dsp.focus({ monitor = "…" })`**, which the compositor volunteers if you ask with any other key. Not cosmetic: an unfocused headless output stays empty, an empty output produces no damage, and no damage means no frames. ## Console: log in once, follow the desktop's theme `ctl console-url` mints a ticket signed with the **management token**; the console verifies with the copy it already holds. Not "skip the login" — the console binds `0.0.0.0:47992`, so trusting the network would hand pairing/unpair/session-control to the LAN. Trusting the 0600 token is the same bar reached differently: whoever can read it already holds the credential the console's own proxy presents. 60 s TTL, single use, one 401 for every rejection. A visitor without a ticket still meets the login page. The theme template rendered fine all along and nothing consumed it. Now `ui-config` carries it and the root applies `mode` and `accent` — both brand variables, since `.dark` derives `--primary` from `--pf-brand-light` and the console ships pinned dark. Also: Super+Space gains a Punktfunk submenu (merged into the user's JSONC on a copy, validated as JSONC *and* checked that our rows landed, with a byte-exact `remove`), and the launcher entry finally has an icon — the host package never shipped one. ## Rest of the design WP1 detection + notify-only updates (enforced at the apply route, not merely reported — a direct POST would otherwise run `pacman -Syu` past their snapper snapshot), WP2 packaging (+ the `hyprland`/`xdg-desktop-portal-hyprland` optdepends owed since before Omarchy), WP3 `punktfunk-omarchy setup|remove|status`, WP4 docs, WP5/6/7 hooks/idle/theme, and two diagnostics rows for failures that report nothing at all. ## Verification Gates: fmt, clippy `-D warnings` (punktfunk-host + pf-vdisplay), 57 host tests, 268 pf-vdisplay tests, 14 web tests, 13 shell self-checks, docs-drift, shellcheck, biome. Rebased onto `d12d494f` with zero file overlap. On the box: all four ctl exit codes (4 by pointing ctl at the host's own legacy cert), the D6 round trip, the ticket verified against an independent HMAC, the theme template rendered and parsed, setup/remove end to end. The tree builds on Omarchy in 8m16s. **Not verified on glass:** the two console-side changes need a `punktfunk-web` build to reach a box — it ships as a prebuilt Nitro `.output` and the testbox has no bun — so the handoff route and the theme are unit-tested and file-verified but have not served a request. Also unfixed and **out of scope here**: `topology: exclusive` disables the operator's heads and does not restore them on the pipeline-build failure path, leaving the desk dark (`hyprctl reload` recovers). That belongs to #284's in-flight work; recorded with its evidence rather than half-fixed in a file another effort is editing.
enricobuehler added 7 commits 2026-08-28 18:26:17 +00:00
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.
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".
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.
#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.
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.
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.
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
38d2b0e454
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.
enricobuehler added 1 commit 2026-08-28 18:46:42 +00:00
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
9c64cc6099
`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.
enricobuehler merged commit 46e617f7e6 into main 2026-08-28 19:07:36 +00:00
enricobuehler deleted branch worktree-omarchy-impl 2026-08-28 19:07:40 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#428