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
39 changed files with 3983 additions and 73 deletions
+189 -30
View File
@@ -65,12 +65,40 @@ fn selection_file() -> String {
/// The installed custom-picker shim: a tiny script that cats [`selection_file`]. xdph runs
/// `custom_picker_binary` and reads one selection line from its stdout; an empty read (no session
/// has written the file) leaves xdph to its interactive picker — the graceful fallback.
/// has written the file) leaves xdph to its own fallback.
fn picker_shim_path() -> String {
let dir = crate::session::runtime_dir();
format!("{dir}/punktfunk-xdph-picker.sh")
}
/// The xdph config we manage one key in, and the key.
fn xdph_config_path() -> Result<std::path::PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.ok_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?;
Ok(base.join("hypr").join("xdph.conf"))
}
const XDPH_BLOCK: crate::portal_config::Block<'static> =
crate::portal_config::Block::Hyprlang("screencopy");
const XDPH_PICKER_KEY: &str = "custom_picker_binary";
/// Is a picker command safe to paste into the shim's `exec` line?
///
/// The value comes from the user's own config, so this is not a privilege boundary — they already
/// own both files and the shell that runs them. It is a *robustness* boundary: a newline would
/// truncate the script into something that silently does the wrong thing, and command substitution
/// in a file we generate is the kind of thing a reader has to stop and reason about. A picker is a
/// command name with maybe some flags; anything else, we simply omit the fallback and behave
/// exactly as before.
fn picker_is_plain(cmd: &str) -> bool {
!cmd.is_empty()
&& cmd.len() <= 512
&& cmd
.chars()
.all(|c| c.is_ascii_alphanumeric() || " ._/@:+=-".contains(c))
}
/// The picker line for output `name` — `[SELECTION]/screen:<name>`, whose every byte is load-bearing.
/// Lives in [`crate::portal_picker`] with a transcription of xdph's parser, because it is a wire
/// format with no error report and this file only compiles on Linux.
@@ -390,6 +418,11 @@ const CAST_CLOSE_BUDGET: Duration = Duration::from_secs(3);
/// `xdp_dbus_impl_session_call_close_sync`), so by the time `close()` returns, xdph has already run
/// `destroyStream` and logged `Session destroyed`. The output we remove next is one nobody is
/// capturing.
/// How many casts of ours are live right now. The picker config is borrowed for exactly as long as
/// this is non-zero (design D6(b): restore "the moment no punktfunk session needs the shim"), and a
/// host that streams two outputs at once must not hand the picker back when the first one ends.
static LIVE_CASTS: AtomicU32 = AtomicU32::new(0);
struct StopGuard {
stop: Arc<AtomicBool>,
/// Signalled by the portal thread once it has closed the ScreenCast session.
@@ -404,8 +437,25 @@ impl Drop for StopGuard {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
let Some(closed) = self.closed.take() else {
// No cast was ever established, so this guard never counted toward [`LIVE_CASTS`] —
// the increment happens in the same arm that arms `closed`.
return;
};
LIVE_CASTS.fetch_sub(1, Ordering::SeqCst);
// 🛑 **Do NOT hand the picker back here.** Restoring per-cast means the NEXT session finds
// the config changed, rewrites it, and restarts xdph — and a ScreenCast bound across an
// xdph restart never delivers a buffer. The portal runtime caches its D-Bus connection
// process-globally (see `portal_thread`), so the restart orphans the cached connection and
// the handshake then succeeds against a session nothing is alive to serve. Measured on
// Omarchy 4.0.1: every session after the first died on
// `no PipeWire frame within 10s … format negotiated but no buffers arrived`, which is a
// black screen on the client, with xdph's own log showing it starting up mid-cast.
//
// Leaving the shim installed is safe precisely because it DELEGATES: with no selection
// pending it execs the picker that was there 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 why the takeover can be idempotent instead of churning.
// The config is put back by `punktfunk-omarchy remove`, from the marker we wrote.
match closed.recv_timeout(CAST_CLOSE_BUDGET) {
// Closed — xdph has torn the capture down, the output is safe to remove.
Ok(()) => {}
@@ -479,23 +529,48 @@ fn reclaim_leftovers_once() {
/// Best-effort by construction: a failure costs window placement, not the session, and a box with no
/// physical head was already placing windows correctly.
///
/// ⚠️ **This is a no-op under the Lua config manager.** Measured on .138 (0.55.4, Lua) 2026-08-18:
/// `hyprctl dispatch focusmonitor <name>` is parsed as Lua (`hl.dispatch(focusmonitor <name>)`) and
/// rejected, and `hl.dsp.focusmonitor` does not exist either — so the #283 window-placement fix
/// does not reach a Lua-configured box. Both rejections carry "error", so [`hyprctl_dispatch`]
/// reports them and this warns rather than failing silently; the gap itself is unfixed and belongs
/// to the #283 follow-up, not to the topology work here.
/// Two eras, same [`dpms_one`] shape and for the same reason. The classic
/// `hyprctl dispatch focusmonitor <name>` is what a hyprlang box wants; under the **Lua** config
/// manager `dispatch` is shorthand for `hl.dispatch(...)`, so those bare words parse as a Lua
/// expression and die with `')' expected near '<name>'`.
///
/// ⭐ The Lua spelling is **`hl.dsp.focus({ monitor = "<name>" })`** — measured on Omarchy 4.0.1
/// (Hyprland 0.56.2) 2026-08-28. The older note here said the fix could not reach a Lua box
/// because "`hl.dsp.focusmonitor` does not exist"; that is true, and it was the wrong name. The
/// compositor says so itself when asked with any other key:
/// *"hl.focus: unrecognized arguments. Expected one of: direction, monitor, window,
/// urgent_or_last, last"*.
///
/// This is not only about window placement on Omarchy. A headless output nothing has focused
/// stays empty, an empty output produces no damage, and no damage means **no PipeWire frames** —
/// the capture then fails its first-frame deadline and the client sees a black screen. So try
/// classic, then Lua, and report both if neither lands.
pub(crate) fn focus_output(name: &str) {
match hyprctl_dispatch(&focus_argv(name)) {
Ok(()) => tracing::info!(output = %name, "focused the streamed headless output"),
Err(e) => tracing::warn!(
output = %name, error = %format!("{e:#}"),
let classic = match hyprctl_dispatch(&focus_argv(name)) {
Ok(()) => None,
Err(e) => match hyprctl_dispatch(&["dispatch", &lua_focus_expr(name)]) {
Ok(()) => None,
Err(lua_err) => Some(format!("hyprlang: {e:#}; lua: {lua_err:#}")),
},
};
match classic {
None => tracing::info!(output = %name, "focused the streamed headless output"),
Some(why) => tracing::warn!(
output = %name, error = %why,
"could not focus the streamed headless output — apps this session launches may open on \
a physical monitor instead of on the stream"
a physical monitor instead of on the stream, and an unfocused headless output can \
produce no frames at all"
),
}
}
/// The Lua-config-manager spelling of "focus this monitor". Pure, so a test pins the shape: the
/// quoting and the `monitor =` key are the whole trick, and an unquoted argument is exactly what
/// the classic form gets wrong on that manager.
fn lua_focus_expr(name: &str) -> String {
format!("hl.dsp.focus({{ monitor = \"{name}\" }})")
}
/// The `hyprctl` argv that focuses `name`, split out so a test pins its SHAPE.
///
/// `focusmonitor` is a **dispatcher**, so it lives behind the `dispatch` subcommand. Getting that
@@ -992,6 +1067,9 @@ fn select_and_cast(
// A cast exists now, so teardown has something to close and must wait for it. Only this
// arm arms the wait: see the field note on `StopGuard::closed`.
guard.closed = Some(closed_rx);
// …and only this arm counts toward the borrowed picker, for the same reason: a
// handshake that never produced a cast has nothing to hand back.
LIVE_CASTS.fetch_add(1, Ordering::SeqCst);
Ok((fd, node_id, cursor_mode, guard))
}
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
@@ -1308,11 +1386,45 @@ fn warn_if_permissions_enforced() {
/// Make sure xdph uses our custom picker: install the shim (once) and write the managed config,
/// restarting xdph if the config changed (it reads config only at startup). Mirrors the wlroots
/// `ensure_xdpw_config` pattern.
///
/// **The picker is the user's, borrowed — not taken** (design D6). `custom_picker_binary` is one
/// key with one holder, and on a distro that ships its own (Omarchy sets
/// `hyprland-preview-share-picker`, the picker every Chromium share on the box goes through)
/// pointing it at us is pointing *every* share at us. Two things keep that honest:
///
/// * the shim **delegates**: with no selection pending it `exec`s whatever was configured before
/// us, so an ordinary browser share behaves exactly as it did — during our session, after it,
/// after a crash, and after a reboot that emptied `$XDG_RUNTIME_DIR`. This is the part that does
/// not depend on us getting a teardown right;
/// * the config edit records what it replaced, so [`restore_xdph_config`] can put their value
/// back verbatim when the last cast ends.
fn ensure_xdph_config() -> Result<()> {
// 1. Install the picker shim (idempotent — content is fixed).
let path = xdph_config_path()?;
// What the user had here before us — from our own marker if we have taken this over already
// (a second session, a moved `$XDG_RUNTIME_DIR`), else whatever is in the file now. Reading
// the marker FIRST is what stops the second takeover from recording our own shim as "theirs".
let (current, prior) = crate::portal_config::peek(&path, XDPH_BLOCK, XDPH_PICKER_KEY);
let fallback = match prior {
Some(p) => p,
None => current,
}
.filter(|c| picker_is_plain(c));
// 1. Install the picker shim (idempotent — content is fixed for a given fallback).
let shim = picker_shim_path();
let sel = selection_file();
let shim_body = format!("#!/bin/sh\nexec cat \"{sel}\" 2>/dev/null\n");
// `-s` not `-f`: an empty selection file means "no selection", which is the fallback's case.
// Unquoted expansion on the `exec` line is deliberate — a picker may carry flags, and
// `picker_is_plain` is what makes word-splitting the only thing that can happen here.
let shim_body = match &fallback {
Some(cmd) => format!(
"#!/bin/sh\n# Managed by punktfunk. Hands xdph the output this host is streaming; with\n# no selection pending, defers to the picker configured before us.\n[ -s \"{sel}\" ] && exec cat \"{sel}\"\nexec {cmd} \"$@\"\n"
),
// Nothing to defer to: an empty read leaves xdph to its own fallback, as before.
None => format!(
"#!/bin/sh\n# Managed by punktfunk.\n[ -s \"{sel}\" ] && exec cat \"{sel}\"\nexit 0\n"
),
};
if std::fs::read_to_string(&shim).is_ok_and(|c| c == shim_body) {
// already installed
} else {
@@ -1333,26 +1445,58 @@ fn ensure_xdph_config() -> Result<()> {
}
// 2. Write the managed xdph config and restart xdph on change.
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.ok_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?;
let path = base.join("hypr").join("xdph.conf");
// ONE key, in place. This used to `fs::write` a complete file over whatever the user had,
// destroying every other xdph setting they owned on first connect.
let changed = crate::portal_config::ensure_key(
&path,
crate::portal_config::Block::Hyprlang("screencopy"),
"custom_picker_binary",
&shim,
)?;
let changed = crate::portal_config::ensure_key(&path, XDPH_BLOCK, XDPH_PICKER_KEY, &shim)?;
if !changed {
return Ok(());
}
tracing::info!(path = %path.display(), "pointed xdg-desktop-portal-hyprland at the managed picker shim");
// Bounded: `systemctl --user` blocks on the user manager's job queue, and this runs on the
// session's stream thread. Its result was already ignored — a timeout just means xdph picks the
// new config up whenever it next starts.
tracing::info!(
path = %path.display(),
defers_to = fallback.as_deref().unwrap_or("(xdph's own fallback)"),
"pointed xdg-desktop-portal-hyprland at the managed picker shim"
);
restart_xdph();
Ok(())
}
/// Hand `custom_picker_binary` back to whoever had it before us, and restart xdph so the change
/// takes (it reads config only at startup).
///
/// Called from the host's **shutdown** path ([`crate::restore_takeover_now`]), never per cast —
/// see [`StopGuard::drop`] for why per-cast churn is a black screen. Safe to call on a box whose
/// config we never touched, where it does nothing at all.
///
/// The restart is the acknowledged cost (design D6(d)): xdph has no way to tell us whether another
/// application's cast is live, so a share started *during* our session can be cut here. That is the
/// same restart the takeover above already performs, at the other end of the session, and it is the
/// lesser of the two evils — the alternative is leaving a running xdph pointed at a shim whose
/// selection file is gone, which breaks the box's screen sharing until the next login.
pub(crate) fn restore_picker_on_shutdown() {
restore_xdph_config();
}
fn restore_xdph_config() {
let Ok(path) = xdph_config_path() else { return };
match crate::portal_config::restore_key(&path, XDPH_BLOCK, XDPH_PICKER_KEY) {
Ok(false) => return, // not ours; nothing to undo
Ok(true) => tracing::info!(
path = %path.display(),
"restored the screen-share picker xdg-desktop-portal-hyprland had before this host"
),
Err(e) => {
tracing::warn!(path = %path.display(), error = %format!("{e:#}"),
"could not restore the previous screen-share picker");
return;
}
}
restart_xdph();
}
/// Bounded: `systemctl --user` blocks on the user manager's job queue, and this runs on the
/// session's stream thread. The result is ignored — a timeout just means xdph picks the new config
/// up whenever it next starts.
fn restart_xdph() {
let _ = crate::proc::status_within(
Command::new("systemctl").args([
"--user",
@@ -1361,7 +1505,6 @@ fn ensure_xdph_config() -> Result<()> {
]),
PORTAL_RESTART_BUDGET,
);
Ok(())
}
/// The ScreenCast portal handshake — the xdg ScreenCast portal is backend-neutral (served here by
@@ -1564,6 +1707,22 @@ mod tests {
);
}
/// The Lua-era spelling, which is the half that was missing. Measured against Hyprland 0.56.2
/// on Omarchy 4.0.1: the key is `monitor` (the compositor lists the alternatives when it is
/// anything else) and the NAME MUST BE QUOTED — unquoted is precisely the classic form's
/// failure, `')' expected near 'PF'`, which is what left a headless output unfocused, empty,
/// and producing no frames at all.
#[test]
fn the_lua_focus_expression_quotes_the_monitor_name() {
assert_eq!(
lua_focus_expr("PF-1234-1"),
"hl.dsp.focus({ monitor = \"PF-1234-1\" })"
);
// The two eras must not converge on one string: each is rejected by the other's parser,
// and that is what makes "try one, then the other" safe to run blind.
assert_ne!(lua_focus_expr("PF-1"), focus_argv("PF-1").join(" "));
}
/// `HYPRLAND_INSTANCE_SIGNATURE` reaches `hyprctl` as a per-CHILD override, never as a `set_var`
/// on the host's own environment — that write was a `getenv` data race with every other thread
/// of a live session (security-review 2026-08-25). Pinning both arms: a discovered signature is
@@ -22,6 +22,19 @@ pub(crate) enum Block<'a> {
Hyprlang(&'a str),
}
/// The comment we leave beside a key we took over, recording what the user had there:
/// `# punktfunk: previous <key> = <value>` (or `= (none)` when the key did not exist).
///
/// Why in the file rather than in our own state directory: this has to survive a SIGKILLed host,
/// a reboot (which empties `$XDG_RUNTIME_DIR`) and an uninstall that leaves the config behind, and
/// it has to be written atomically together with the change it describes. A sidecar state file
/// satisfies none of those; a comment in the same atomic write satisfies all three. It is also
/// legible: an operator reading their own `xdph.conf` can see exactly what we replaced and put it
/// back by hand.
const PRIOR: &str = "# punktfunk: previous";
/// What the marker records when the key was absent before we set it.
const PRIOR_NONE: &str = "(none)";
/// Is `line` the header that opens `block`?
fn opens(line: &str, block: Block<'_>) -> bool {
let t = line.trim();
@@ -40,33 +53,10 @@ fn assigns(line: &str, key: &str) -> bool {
line.split('=').next().is_some_and(|lhs| lhs.trim() == key)
}
/// Set `key` to `value` inside `block`, preserving every other line.
///
/// Three cases, all of which the tests pin: the block is absent (append it), the block has the key
/// (replace that one line, keeping its indentation), and the block lacks the key (insert before the
/// block ends).
pub(crate) fn upsert(existing: &str, block: Block<'_>, key: &str, value: &str) -> String {
let sep = match block {
Block::Ini(_) => "=",
Block::Hyprlang(_) => " = ",
};
let assignment = |indent: &str| format!("{indent}{key}{sep}{value}");
let lines: Vec<&str> = existing.lines().collect();
let Some(open_at) = lines.iter().position(|l| opens(l, block)) else {
// Absent: append the whole block, keeping the user's file intact above it.
let mut out = existing.trim_end().to_string();
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(&match block {
Block::Ini(name) => format!("[{name}]\n{}\n", assignment("")),
Block::Hyprlang(name) => format!("{name} {{\n{}\n}}\n", assignment(" ")),
});
return out;
};
// Where the block ends: the next `[`-header for INI, the closing brace for hyprlang, else EOF.
/// The span of `block` in `lines`: `(index of its header, index one past its last line)`.
/// `None` when the block is absent.
fn block_span(lines: &[&str], block: Block<'_>) -> Option<(usize, usize)> {
let open_at = lines.iter().position(|l| opens(l, block))?;
let end_at = lines
.iter()
.enumerate()
@@ -77,17 +67,142 @@ pub(crate) fn upsert(existing: &str, block: Block<'_>, key: &str, value: &str) -
})
.map(|(i, _)| i)
.unwrap_or(lines.len());
Some((open_at, end_at))
}
/// The value `key` currently holds in `block`, if it holds one.
pub(crate) fn current_value(existing: &str, block: Block<'_>, key: &str) -> Option<String> {
let lines: Vec<&str> = existing.lines().collect();
let (open_at, end_at) = block_span(&lines, block)?;
(open_at + 1..end_at)
.find(|&i| assigns(lines[i], key))
.map(|i| {
lines[i]
.split_once('=')
.map_or("", |(_, v)| v)
.trim()
.to_string()
})
}
/// What the user had at `key` before we took it over, read back from our marker comment:
/// `Some(Some(v))` = they had `v`, `Some(None)` = the key was absent, `None` = we never took it
/// over (so there is nothing of ours to undo, and the value there is genuinely theirs).
pub(crate) fn prior_value(existing: &str, block: Block<'_>, key: &str) -> Option<Option<String>> {
let lines: Vec<&str> = existing.lines().collect();
let (open_at, end_at) = block_span(&lines, block)?;
let want = format!("{PRIOR} {key} =");
let raw = (open_at + 1..end_at)
.map(|i| lines[i].trim())
.find_map(|l| l.strip_prefix(&want))?
.trim()
.to_string();
Some((raw != PRIOR_NONE).then_some(raw))
}
/// Undo our takeover of `key`: put the recorded prior value back (or delete the key when there was
/// none) and drop the marker. `None` when no marker is present — the file is not ours to touch.
///
/// Deliberately NOT "delete our line": D6 asks for the *prior value*, because on Omarchy that value
/// is `hyprland-preview-share-picker`, i.e. every browser share on the box. Deleting the key would
/// fall back to whatever xdph defaults to, which is not the same thing as what the user had.
pub(crate) fn restore(existing: &str, block: Block<'_>, key: &str) -> Option<String> {
let prior = prior_value(existing, block, key)?;
let lines: Vec<&str> = existing.lines().collect();
let (open_at, end_at) = block_span(&lines, block)?;
let marker = format!("{PRIOR} {key} =");
let mut out: Vec<String> = Vec::with_capacity(lines.len());
for (i, line) in lines.iter().enumerate() {
let inside = i > open_at && i < end_at;
if inside && line.trim().starts_with(&marker) {
continue; // the marker itself goes away with the takeover it records
}
if inside && assigns(line, key) {
// `Some` → put their line back with their indentation and the grammar's separator,
// exactly as `upsert` wrote ours. `None` → there was no such key before us, so there
// is none after us either: drop the line rather than blank it.
if let Some(v) = &prior {
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
let sep = match block {
Block::Ini(_) => "=",
Block::Hyprlang(_) => " = ",
};
out.push(format!("{indent}{key}{sep}{v}"));
}
continue;
}
out.push((*line).to_string());
}
let mut joined = out.join("\n");
if existing.ends_with('\n') || !joined.ends_with('\n') {
joined.push('\n');
}
Some(joined)
}
/// Set `key` to `value` inside `block`, preserving every other line.
///
/// Three cases, all of which the tests pin: the block is absent (append it), the block has the key
/// (replace that one line, keeping its indentation), and the block lacks the key (insert before the
/// block ends).
///
/// The first time we replace a key we also leave a [`PRIOR`] marker recording what was there, so
/// [`restore`] can put it back after a crash, a reboot or an uninstall. Written once: a later edit
/// (the shim path moves with `$XDG_RUNTIME_DIR`) must not record OUR previous value as the user's.
pub(crate) fn upsert(existing: &str, block: Block<'_>, key: &str, value: &str) -> String {
let sep = match block {
Block::Ini(_) => "=",
Block::Hyprlang(_) => " = ",
};
let assignment = |indent: &str| format!("{indent}{key}{sep}{value}");
let lines: Vec<&str> = existing.lines().collect();
let Some((open_at, end_at)) = block_span(&lines, block) else {
// Absent: append the whole block, keeping the user's file intact above it. There was no
// key here, so the marker records that — an uninstall must remove our line, not leave a
// key the user never had.
let mut out = existing.trim_end().to_string();
if !out.is_empty() {
out.push_str("\n\n");
}
let marker = |indent: &str| format!("{indent}{PRIOR} {key} = {PRIOR_NONE}");
out.push_str(&match block {
Block::Ini(name) => format!("[{name}]\n{}\n{}\n", marker(""), assignment("")),
Block::Hyprlang(name) => format!(
"{name} {{\n{}\n{}\n}}\n",
marker(" "),
assignment(" ")
),
});
return out;
};
// Already marked? Then we have taken this key over before and the marker holds the USER's
// value; re-recording here would overwrite it with our own previous shim path.
let marked = prior_value(existing, block, key).is_some();
let mut out: Vec<String> = lines.iter().map(|l| (*l).to_string()).collect();
if let Some(i) = (open_at + 1..end_at).find(|&i| assigns(lines[i], key)) {
let indent: String = lines[i].chars().take_while(|c| c.is_whitespace()).collect();
let had = lines[i]
.split_once('=')
.map_or("", |(_, v)| v)
.trim()
.to_string();
out[i] = assignment(&indent);
if !marked {
out.insert(i, format!("{indent}{PRIOR} {key} = {had}"));
}
} else {
let indent = match block {
Block::Ini(_) => "",
Block::Hyprlang(_) => " ",
};
out.insert(end_at, assignment(indent));
if !marked {
out.insert(end_at, format!("{indent}{PRIOR} {key} = {PRIOR_NONE}"));
out.insert(end_at + 1, assignment(indent));
} else {
out.insert(end_at, assignment(indent));
}
}
let mut joined = out.join("\n");
if existing.ends_with('\n') || !joined.ends_with('\n') {
@@ -171,6 +286,58 @@ pub(crate) fn ensure_key(path: &Path, block: Block<'_>, key: &str, value: &str)
Ok(true)
}
/// Read `path` and hand back its text, or `None` when it does not exist. Any other read failure —
/// including non-UTF-8 — is an error for the same reason [`ensure_key`] spells out: a config we
/// cannot read is a config we refuse to rewrite.
fn read_config(path: &Path) -> Result<Option<String>> {
match std::fs::read(path) {
Ok(bytes) => Ok(Some(
std::str::from_utf8(&bytes)
.with_context(|| {
format!("{} is not UTF-8 — refusing to rewrite it", path.display())
})?
.to_string(),
)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("read {}", path.display())),
}
}
/// The value `key` holds in `path` today, and what the user had there before we took it over.
/// Both `None` on a file we have never touched (or that does not exist).
pub(crate) fn peek(
path: &Path,
block: Block<'_>,
key: &str,
) -> (Option<String>, Option<Option<String>>) {
let Ok(Some(text)) = read_config(path) else {
return (None, None);
};
(
current_value(&text, block, key),
prior_value(&text, block, key),
)
}
/// Undo our takeover of `key` in `path` — see [`restore`]. Returns `true` when the file changed.
///
/// A file with no marker of ours is left byte-for-byte alone and reports `false`: this must be safe
/// to call unconditionally (at teardown, from an uninstall script, after a crash) on a box where we
/// never touched the config at all.
pub(crate) fn restore_key(path: &Path, block: Block<'_>, key: &str) -> Result<bool> {
let Some(existing) = read_config(path)? else {
return Ok(false);
};
let Some(updated) = restore(&existing, block, key) else {
return Ok(false);
};
if updated == existing {
return Ok(false);
}
write_atomic(path, updated.as_bytes())?;
Ok(true)
}
/// Replace `path`'s contents with `bytes` **atomically**: fill a temp file beside it, then rename
/// over it. `fs::write` truncates first and fills afterwards, so a crash, a full disk or a killed
/// host between the two leaves the user's config truncated — the same loss this module exists to
@@ -265,7 +432,9 @@ mod tests {
out.contains("[somethingelse]\nkeep=me"),
"user content kept"
);
assert!(out.contains("[screencast]\nchooser_cmd=cat x"));
assert!(out.contains(
"[screencast]\n# punktfunk: previous chooser_cmd = (none)\nchooser_cmd=cat x"
));
}
#[test]
@@ -273,7 +442,9 @@ mod tests {
let user = "[screencast]\nchooser_type=simple\nchooser_cmd=OLD\noutput_name=DP-1\n";
let out = upsert(user, Block::Ini("screencast"), "chooser_cmd", "NEW");
assert!(out.contains("chooser_cmd=NEW"));
assert!(!out.contains("OLD"));
// OLD survives ONLY as the restore marker — no live assignment may still name it.
assert!(!out.lines().any(|l| l.trim() == "chooser_cmd=OLD"));
assert!(out.contains("# punktfunk: previous chooser_cmd = OLD"));
assert!(out.contains("chooser_type=simple"), "sibling key kept");
assert!(out.contains("output_name=DP-1"), "sibling key kept");
}
@@ -301,7 +472,10 @@ mod tests {
"/run/user/1000/shim.sh",
);
assert!(out.contains("custom_picker_binary = /run/user/1000/shim.sh"));
assert!(!out.contains("OLD"));
assert!(!out
.lines()
.any(|l| l.trim() == "custom_picker_binary = OLD"));
assert!(out.contains("# punktfunk: previous custom_picker_binary = OLD"));
assert!(
out.contains("allow_token_by_default = true"),
"sibling kept"
@@ -349,7 +523,125 @@ mod tests {
fn an_empty_file_yields_just_the_block() {
assert_eq!(
upsert("", Block::Ini("screencast"), "k", "v"),
"[screencast]\nk=v\n"
"[screencast]\n# punktfunk: previous k = (none)\nk=v\n"
);
}
// ── the takeover is reversible (design D6) ─────────────────────────────────────────────────
//
// Omarchy ships its OWN `~/.config/hypr/xdph.conf` naming
// `custom_picker_binary = hyprland-preview-share-picker` — the picker every Chromium share on
// the box goes through. Taking that key over without a way back is not a cosmetic leftover: it
// is "screen sharing stopped working on this machine" for as long as the config survives, i.e.
// past a reboot and past an uninstall.
/// The round trip that matters: their picker → ours → theirs, byte-identical.
#[test]
fn an_omarchy_picker_survives_the_round_trip() {
let user = "screencopy {\n allow_token_by_default = true\n custom_picker_binary = hyprland-preview-share-picker\n}\n";
let ours = upsert(
user,
Block::Hyprlang("screencopy"),
"custom_picker_binary",
"/run/user/1000/pf-picker.sh",
);
assert!(ours.contains("custom_picker_binary = /run/user/1000/pf-picker.sh"));
assert_eq!(
prior_value(&ours, Block::Hyprlang("screencopy"), "custom_picker_binary"),
Some(Some("hyprland-preview-share-picker".to_string()))
);
let back = restore(&ours, Block::Hyprlang("screencopy"), "custom_picker_binary")
.expect("a file we took over is restorable");
assert_eq!(
back, user,
"the user's file must come back exactly as it was"
);
}
/// A second takeover (the shim path moves with `$XDG_RUNTIME_DIR`) must not record OUR path as
/// theirs — that is how a restore puts back a dead runtime path instead of their picker.
#[test]
fn a_second_takeover_keeps_the_first_prior_value() {
let user = "screencopy {\n custom_picker_binary = theirs\n}\n";
let once = upsert(
user,
Block::Hyprlang("screencopy"),
"custom_picker_binary",
"/run/a",
);
let twice = upsert(
&once,
Block::Hyprlang("screencopy"),
"custom_picker_binary",
"/run/b",
);
assert!(twice.contains("custom_picker_binary = /run/b"));
assert_eq!(
restore(
&twice,
Block::Hyprlang("screencopy"),
"custom_picker_binary"
)
.as_deref(),
Some(user)
);
}
/// When the key did not exist before us, restoring REMOVES it — putting an empty or defaulted
/// value there would be a setting the user never had.
#[test]
fn a_key_we_invented_is_removed_on_restore_not_blanked() {
let user = "screencopy {\n allow_token_by_default = true\n}\n";
let ours = upsert(
user,
Block::Hyprlang("screencopy"),
"custom_picker_binary",
"/run/a",
);
assert_eq!(
prior_value(&ours, Block::Hyprlang("screencopy"), "custom_picker_binary"),
Some(None)
);
assert_eq!(
restore(&ours, Block::Hyprlang("screencopy"), "custom_picker_binary").as_deref(),
Some(user)
);
}
/// Restoring a file we never touched must be a no-op, not a deletion — this runs at teardown
/// on every Hyprland box, including ones whose config is entirely the user's.
#[test]
fn a_file_without_our_marker_is_not_ours_to_restore() {
let user = "screencopy {\n custom_picker_binary = theirs\n}\n";
assert_eq!(
restore(user, Block::Hyprlang("screencopy"), "custom_picker_binary"),
None
);
assert_eq!(restore("", Block::Ini("screencast"), "chooser_cmd"), None);
}
/// The INI half (xdpw) reverses identically — the two backends share this module precisely so
/// a fix on one is not a fix on one.
#[test]
fn the_ini_grammar_reverses_too() {
let user = "[screencast]\nchooser_type=simple\nchooser_cmd=slurp\noutput_name=DP-1\n";
let ours = upsert(user, Block::Ini("screencast"), "chooser_cmd", "/run/a");
assert_eq!(
restore(&ours, Block::Ini("screencast"), "chooser_cmd").as_deref(),
Some(user)
);
}
#[test]
fn current_value_reads_what_is_there_now() {
let user = "screencopy {\n custom_picker_binary = theirs\n}\n";
assert_eq!(
current_value(user, Block::Hyprlang("screencopy"), "custom_picker_binary").as_deref(),
Some("theirs")
);
assert_eq!(
current_value(user, Block::Hyprlang("screencopy"), "nope"),
None
);
}
}
@@ -429,11 +721,53 @@ mod io_tests {
assert!(ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat x").expect("write"));
assert_eq!(
std::fs::read_to_string(&p).expect("created"),
"[screencast]\nchooser_cmd=cat x\n"
"[screencast]\n# punktfunk: previous chooser_cmd = (none)\nchooser_cmd=cat x\n"
);
assert!(!backup_of(&p).exists());
}
/// The on-disk half of the D6 round trip, including the case that made it a hard requirement:
/// an Omarchy box whose `xdph.conf` names their own share picker. After `restore_key` the file
/// must be byte-identical to what they shipped.
#[test]
fn restore_key_puts_the_users_picker_back_byte_for_byte() {
let s = Scratch::new("restore");
let p = s.path("xdph.conf");
let user = "screencopy {\n allow_token_by_default = true\n custom_picker_binary = hyprland-preview-share-picker\n}\n";
std::fs::write(&p, user).expect("seed");
let block = Block::Hyprlang("screencopy");
assert!(
ensure_key(&p, block, "custom_picker_binary", "/run/user/1000/pf.sh").expect("take")
);
assert_eq!(
peek(&p, block, "custom_picker_binary"),
(
Some("/run/user/1000/pf.sh".to_string()),
Some(Some("hyprland-preview-share-picker".to_string()))
)
);
assert!(restore_key(&p, block, "custom_picker_binary").expect("restore"));
assert_eq!(std::fs::read_to_string(&p).expect("restored"), user);
// Idempotent, and safe to call on a file that is no longer ours.
assert!(!restore_key(&p, block, "custom_picker_binary").expect("second restore"));
assert_eq!(std::fs::read_to_string(&p).expect("unchanged"), user);
}
/// Teardown calls this on every Hyprland box. A config that was never ours — and a config that
/// does not exist — must come through untouched.
#[test]
fn restore_key_is_a_no_op_on_a_config_we_never_took_over() {
let s = Scratch::new("restore-noop");
let p = s.path("xdph.conf");
let block = Block::Hyprlang("screencopy");
assert!(!restore_key(&p, block, "custom_picker_binary").expect("absent file"));
assert!(!p.exists(), "restoring must not CREATE a config");
let user = "screencopy {\n custom_picker_binary = theirs\n}\n";
std::fs::write(&p, user).expect("seed");
assert!(!restore_key(&p, block, "custom_picker_binary").expect("not ours"));
assert_eq!(std::fs::read_to_string(&p).expect("intact"), user);
}
/// `create_new` is what makes the backup once-only, and this is the invariant it buys: after a
/// second edit (a new `$XDG_RUNTIME_DIR`, so a new value) the backup must still hold the user's
/// PRISTINE file — not our own previous output.
@@ -586,7 +920,7 @@ mod io_tests {
);
assert_eq!(
std::fs::read_to_string(&real).expect("target created"),
"[screencast]\nchooser_cmd=cat x\n"
"[screencast]\n# punktfunk: previous chooser_cmd = (none)\nchooser_cmd=cat x\n"
);
}
}
@@ -501,6 +501,13 @@ pub fn takeover_privilege_verdict() -> TakeoverVerdict {
#[cfg(target_os = "linux")]
pub fn restore_takeover_now() {
gamescope::restore_takeover_now();
// The xdph screen-share picker is the other thing a host can outlive holding. It is NOT
// restored per cast on purpose — doing that rewrites the config on every session, which
// restarts xdph, which orphans the portal runtime's cached D-Bus connection and produces a
// stream that never delivers a buffer (see `hyprland::StopGuard::drop`). Shutdown is the right
// moment: no cast is live, so the restart it triggers costs nothing, and the operator's own
// picker is back the instant the host is gone. No-op on a box we never took it over on.
hyprland::restore_picker_on_shutdown();
}
#[cfg(not(target_os = "linux"))]
+4 -1
View File
@@ -9,7 +9,10 @@ authors.workspace = true
repository.workspace = true
[dependencies]
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
# `ureq-tls` is for `ctl`: ureq's own `TlsConfig` has no hook for a custom certificate verifier,
# so the pinned operator client hands rustls a `ClientConfig` through core's shared glue — the same
# path the tray and the desktop client already take. `quic` brings `tls` (and `PinVerify`) with it.
punktfunk-core = { path = "../punktfunk-core", features = ["quic", "ureq-tls"] }
# Signed update-manifest fetch/verify + install-kind detection, shared with the Linux client.
pf-update-check = { path = "../pf-update-check" }
# Config-dir + owner-private file helpers (moved out of the gamestream junk drawer, plan §W6).
+653
View File
@@ -0,0 +1,653 @@
//! `punktfunk-host ctl` — the operator surface as a **subcommand**, not a second binary.
//!
//! Everything the web console's daily 95 % does — approve a pending device, type a Moonlight PIN,
//! rename or unpair, change an access preset, stop a session, watch events — reachable from a
//! terminal, a shell script, or a Quickshell `Process`. Its first consumer is the Omarchy shell
//! plugin (design D10/D13), but nothing here is Omarchy-specific: the surface is a loopback client
//! of the mgmt API, and `watch`'s line-JSON is as usable from a waybar module or `while read`.
//!
//! **Why a subcommand.** A new binary touches every Linux artifact we ship (the Arch PKGBUILD, the
//! deb/rpm builders, the sysext, the Nix module, signing and manifests) to buy nothing: `main.rs`
//! already dispatches a dozen verbs, and in-crate means the client deserialises what the server
//! serialises with no second declaration of the types to drift. The one cost is startup — the host
//! binary links the world — and it is paid once per *action*, not per poll, because the
//! interactive consumer holds one long-lived `watch`. Measured threshold, recorded so the decision
//! is falsifiable: if `ctl status --json` p50 exceeds ~150 ms on the target box, lift `ctl/` behind
//! a thin bin; the module boundary makes that mechanical.
//!
//! **Security** is [`client`]'s module docs: pin before token (I2), no credential on argv or in
//! the environment (I1), no server-side change at all (I4 — this crate's `mgmt/auth.rs` is
//! untouched by the whole surface). ctl consumes the token the host persists; it never mints one.
//!
//! **Approval UX (I6)** is enforced here rather than left to each front-end: `approve`/`deny` take
//! an **id**, never "the newest", and every listing prints the claimed name next to the
//! fingerprint tail so an operator approving a device is looking at what the device claims *and*
//! at something it cannot forge.
//!
//! Exit codes: 0 success, 1 the host refused, 2 usage, 3 no host reachable, 4 certificate pin
//! mismatch. 4 is separate on purpose — it is the security signal, and a script that treats it as
//! "host down" would retry straight into a squatter.
pub mod client;
mod watch;
use client::{Client, Failure, Result, SCHEMA_VERSION};
use punktfunk_core::quic::{
GRANT_PRESET_CONTROLLER_ONLY, GRANT_PRESET_FULL, GRANT_PRESET_VIEW_ONLY,
};
use serde_json::{json, Value};
pub fn main(args: &[String]) -> anyhow::Result<()> {
// `--json` is positionless: it is a mode, not an argument to any one verb.
let json = args.iter().any(|a| a == "--json");
let rest: Vec<&str> = args
.iter()
.map(String::as_str)
.filter(|a| *a != "--json")
.collect();
match run(&rest, json) {
Ok(()) => Ok(()),
Err(f) => {
if json {
// Machine consumers get the failure on stdout in the same envelope as a success,
// so a QML `Process` parses one shape and reads `error` or `data`.
println!(
"{}",
json!({"v": SCHEMA_VERSION, "error": {"code": f.code, "message": f.message}})
);
} else {
eprintln!("punktfunk-host ctl: {}", f.message);
}
std::process::exit(f.code)
}
}
}
fn run(args: &[&str], json: bool) -> Result<()> {
let Some(verb) = args.first().copied() else {
print_usage();
return Err(Failure::usage("no verb given"));
};
let rest = &args[1..];
match verb {
"-h" | "--help" | "help" => {
print_usage();
Ok(())
}
"status" => {
let v = Client::connect(None)?.get("/api/v1/status")?;
out(json, &v, render_status);
Ok(())
}
"sessions" => {
let v = Client::connect(None)?.get("/api/v1/status")?;
let slice = json!({
"active_sessions": v.get("active_sessions").cloned().unwrap_or(Value::Null),
"video_streaming": v.get("video_streaming").cloned().unwrap_or(Value::Null),
"audio_streaming": v.get("audio_streaming").cloned().unwrap_or(Value::Null),
"session": v.get("session").cloned().unwrap_or(Value::Null),
"stream": v.get("stream").cloned().unwrap_or(Value::Null),
"games": v.get("games").cloned().unwrap_or(Value::Null),
});
out(json, &slice, render_sessions);
Ok(())
}
"stop-session" => {
let v = Client::connect(None)?.delete("/api/v1/session")?;
out(json, &v, |_| println!("session stopped"));
Ok(())
}
"end-game" => {
let v = Client::connect(None)?.post("/api/v1/game/end", &json!({}))?;
out(json, &v, |_| println!("game ended"));
Ok(())
}
"pair" => pair(rest, json),
"pending" => {
let v = Client::connect(None)?.get("/api/v1/native/pending")?;
out(json, &v, render_pending);
Ok(())
}
"approve" => approve(rest, json),
"deny" => {
let id = one_id(rest, "deny")?;
let v = Client::connect(None)?
.post(&format!("/api/v1/native/pending/{id}/deny"), &json!({}))?;
out(json, &v, move |_| println!("denied device {id}"));
Ok(())
}
"pin" => {
let pin = rest
.first()
.copied()
.ok_or_else(|| Failure::usage("pin: give the PIN the client is showing"))?;
let v = Client::connect(None)?.post("/api/v1/pair/pin", &json!({ "pin": pin }))?;
out(json, &v, |_| println!("PIN submitted"));
Ok(())
}
// Not an API call at all: a ticket the console can verify with the token it already holds.
// See `console_url` — the point is that reading the 0600 token IS the proof.
"console-url" => {
let url = console_url()?;
out(json, &json!({ "url": url }), move |_| println!("{url}"));
Ok(())
}
"clients" => {
let c = Client::connect(None)?;
// Both planes, labelled — a device list that silently covered only one of them is how
// "I unpaired it and it still connects" happens.
let both = json!({
"native": c.get("/api/v1/native/clients")?,
"gamestream": c.get("/api/v1/clients")?,
});
out(json, &both, render_clients);
Ok(())
}
"rename" => rename(rest, json),
"unpair" => unpair(rest, json),
"access" => access(rest, json),
"watch" => {
let kinds = flag_value(rest, "--kinds");
let since = flag_value(rest, "--since")
.map(|s| {
s.parse::<u64>().map_err(|_| {
Failure::usage("watch: --since takes an event sequence number")
})
})
.transpose()?;
watch::run(kinds.as_deref(), since)
}
other => {
print_usage();
Err(Failure::usage(format!("unknown ctl verb '{other}'")))
}
}
}
// ── verbs with enough argument shape to deserve their own function ─────────────────────────────
fn pair(args: &[&str], json: bool) -> Result<()> {
let c = Client::connect(None)?;
match args.first().copied() {
Some("arm") => {
let mut body = json!({});
if let Some(ttl) = num_flag(args, "--ttl")? {
body["ttl_secs"] = json!(ttl);
}
if let Some(exp) = num_flag(args, "--expires-in")? {
body["expires_in_secs"] = json!(exp);
}
if let Some(g) = preset_flag(args)? {
body["grants"] = json!(g);
}
// Binding the window to one fingerprint is the difference between "a device may pair"
// and "any LAN peer may burn my pairing window" (security review #9) — so it is a
// first-class flag here, not console-only.
if let Some(fp) = flag_value(args, "--fingerprint") {
body["fingerprint"] = json!(fp);
}
let v = c.post("/api/v1/native/pair/arm", &body)?;
out(json, &v, render_pair);
Ok(())
}
Some("disarm") => {
let v = c.delete("/api/v1/native/pair")?;
out(json, &v, |_| println!("pairing window closed"));
Ok(())
}
Some("status") | None => {
let v = c.get("/api/v1/native/pair")?;
// The GameStream PIN flow lives on a different route; fold it in so one command
// answers "is anything waiting for me?" for both planes.
let mut v = v;
if let Ok(gs) = c.get("/api/v1/pair") {
v["pin_pending"] = gs.get("pin_pending").cloned().unwrap_or(json!(false));
}
out(json, &v, render_pair);
Ok(())
}
Some(other) => Err(Failure::usage(format!(
"pair: expected arm | disarm | status, got '{other}'"
))),
}
}
fn approve(args: &[&str], json: bool) -> Result<()> {
let id = one_id(args, "approve")?;
let mut body = json!({});
if let Some(name) = flag_value(args, "--name") {
body["name"] = json!(name);
}
if let Some(g) = preset_flag(args)? {
body["grants"] = json!(g);
}
if let Some(exp) = num_flag(args, "--expires-in")? {
body["expires_in_secs"] = json!(exp);
}
let v = Client::connect(None)?.post(&format!("/api/v1/native/pending/{id}/approve"), &body)?;
out(json, &v, move |_| println!("approved device {id}"));
Ok(())
}
fn rename(args: &[&str], json: bool) -> Result<()> {
let (fp, name) = match (args.first(), args.get(1)) {
(Some(fp), Some(name)) => (*fp, *name),
_ => return Err(Failure::usage("rename: <fingerprint> <name>")),
};
let c = Client::connect(None)?;
// The two planes keep separate stores and separate routes, and a fingerprint belongs to
// exactly one of them. Try native first (the default plane), fall back to GameStream, so the
// operator does not have to know which store a device they can see in `ctl clients` lives in.
let native = c.patch(
&format!("/api/v1/native/clients/{fp}"),
&json!({ "name": name }),
);
let v = match native {
Ok(v) => v,
Err(e) if e.code == client::EXIT_API => {
c.patch(&format!("/api/v1/clients/{fp}"), &json!({ "label": name }))?
}
Err(e) => return Err(e),
};
out(json, &v, move |_| println!("renamed {fp} to {name}"));
Ok(())
}
fn unpair(args: &[&str], json: bool) -> Result<()> {
let c = Client::connect(None)?;
if args.contains(&"--all") {
// Unpairing everything is the one destructive verb here — every device on the box has to
// be re-paired by hand afterwards. Human mode asks; machine mode demands `--yes`, because
// a plugin cannot answer a prompt and must not be able to do this by accident.
if !args.contains(&"--yes") {
if json {
return Err(Failure::usage(
"unpair --all needs --yes in --json mode (it cannot prompt)",
));
}
if !confirm("Unpair EVERY device on both planes? [y/N] ") {
return Err(Failure::usage("cancelled"));
}
}
let both = json!({
"native": c.delete("/api/v1/native/clients")?,
"gamestream": c.delete("/api/v1/clients")?,
});
out(json, &both, |_| println!("all devices unpaired"));
return Ok(());
}
let fp = args
.first()
.copied()
.filter(|a| !a.starts_with("--"))
.ok_or_else(|| Failure::usage("unpair: <fingerprint>, or --all"))?;
let native = c.delete(&format!("/api/v1/native/clients/{fp}"));
let v = match native {
Ok(v) => v,
Err(e) if e.code == client::EXIT_API => c.delete(&format!("/api/v1/clients/{fp}"))?,
Err(e) => return Err(e),
};
out(json, &v, move |_| println!("unpaired {fp}"));
Ok(())
}
fn access(args: &[&str], json: bool) -> Result<()> {
let (fp, preset) = match (args.first(), args.get(1)) {
(Some(fp), Some(p)) => (*fp, *p),
_ => {
return Err(Failure::usage(
"access: <fingerprint> <full|controller|view>",
))
}
};
// Presets only. The full grant matrix is the console's job (per-client-access design): a
// bitmask on a command line is exactly the kind of thing that gets a digit wrong and silently
// hands a device the keyboard.
let grants = grants_for(preset)?;
let v = Client::connect(None)?.patch(
&format!("/api/v1/native/clients/{fp}"),
&json!({ "grants": grants }),
)?;
out(json, &v, move |_| println!("{fp}: access set to {preset}"));
Ok(())
}
/// A one-shot console URL carrying a ticket that logs the operator straight in.
///
/// **What is being trusted, and what is not.** The console binds all interfaces so it can be
/// reached from a phone on the LAN, and its admin surface is pairing, unpair and session control —
/// so the network is not evidence of anything and the password stays. What IS evidence is the
/// **mgmt token**: a 0600 file inside the 0700 config dir, readable only by the uid the host runs
/// as. Whoever can read it can already drive the whole admin API directly (it is the credential
/// the console's own proxy presents), so letting them skip a password they could simply read
/// widens nothing. A visitor without a ticket still meets the login page.
///
/// The ticket is `<unix-seconds>.<nonce>.<HMAC-SHA256>` over `pf-console-handoff:v1:ts:nonce`,
/// keyed by the token. The console recomputes it with its own copy — no new host route, no shared
/// state, nothing to expire on this side. TTL and single-use are enforced by the console
/// (`web/server/routes/_auth/handoff.get.ts`); the nonce is what keeps two launches in the same
/// second from colliding in its replay set.
fn console_url() -> Result<String> {
use hmac::{Hmac, KeyInit, Mac};
let dir = pf_paths::config_dir();
let token = crate::mgmt_token::read_persisted(&dir).ok_or_else(|| {
Failure::unreachable(format!(
"no management token in {} — the console shares this file, so without it there is \
nothing for a handoff to prove. Start the host once and retry.",
dir.join("mgmt-token").display()
))
})?;
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mut raw = [0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rng(), &mut raw);
let nonce = hex::encode(raw);
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(token.as_bytes())
.map_err(|e| Failure::api(format!("could not key the handoff HMAC: {e}")))?;
mac.update(format!("pf-console-handoff:v1:{ts}:{nonce}").as_bytes());
let sig = hex::encode(mac.finalize().into_bytes());
// The console's own port, not the mgmt one. It is not published anywhere the way
// `mgmt-endpoint` is, so the documented default stands until somebody moves it.
Ok(format!(
"https://localhost:47992/_auth/handoff?t={ts}.{nonce}.{sig}"
))
}
fn grants_for(preset: &str) -> Result<u32> {
match preset {
"full" => Ok(GRANT_PRESET_FULL),
"controller" => Ok(GRANT_PRESET_CONTROLLER_ONLY),
"view" => Ok(GRANT_PRESET_VIEW_ONLY),
other => Err(Failure::usage(format!(
"unknown access preset '{other}' (want full | controller | view)"
))),
}
}
// ── argument helpers ───────────────────────────────────────────────────────────────────────────
fn flag_value(args: &[&str], flag: &str) -> Option<String> {
let i = args.iter().position(|a| *a == flag)?;
args.get(i + 1).map(|s| (*s).to_string())
}
fn num_flag(args: &[&str], flag: &str) -> Result<Option<u64>> {
match flag_value(args, flag) {
None => Ok(None),
Some(v) => v
.parse()
.map(Some)
.map_err(|_| Failure::usage(format!("{flag} takes a number of seconds, got '{v}'"))),
}
}
fn preset_flag(args: &[&str]) -> Result<Option<u32>> {
flag_value(args, "--preset")
.map(|p| grants_for(&p))
.transpose()
}
/// The pending-device id, which is always the **first** argument. Deliberately not "the first
/// thing that parses as a number anywhere in the line": that would let `--expires-in 3600` be read
/// as the device to approve, which is the one mistake I6 exists to make impossible.
fn one_id(args: &[&str], verb: &str) -> Result<u32> {
let raw = args
.first()
.filter(|a| !a.starts_with("--"))
.ok_or_else(|| Failure::usage(format!("{verb}: <id> first, from `ctl pending`")))?;
raw.parse()
.map_err(|_| Failure::usage(format!("{verb}: '{raw}' is not a pending-device id")))
}
fn confirm(prompt: &str) -> bool {
use std::io::Write as _;
print!("{prompt}");
let _ = std::io::stdout().flush();
let mut line = String::new();
std::io::stdin().read_line(&mut line).is_ok() && matches!(line.trim(), "y" | "Y" | "yes")
}
// ── output ─────────────────────────────────────────────────────────────────────────────────────
/// One shape for every verb: the versioned envelope in `--json` mode, the terse table otherwise.
/// Nothing we ship parses the human half (I8), which is what lets it stay readable.
fn out(json: bool, v: &Value, human: impl FnOnce(&Value)) {
if json {
println!("{}", json!({ "v": SCHEMA_VERSION, "data": v }));
} else {
human(v);
}
}
fn render_status(v: &Value) {
let streaming = v["video_streaming"].as_bool().unwrap_or(false);
println!("host {}", if streaming { "streaming" } else { "idle" });
println!("sessions {}", v["active_sessions"].as_i64().unwrap_or(0));
println!(
"paired {} native, {} gamestream",
v["native_paired_clients"].as_i64().unwrap_or(0),
v["paired_clients"].as_i64().unwrap_or(0)
);
if v["pin_pending"].as_bool().unwrap_or(false) {
println!("pin PENDING — run `ctl pin <PIN>` with the code the client shows");
}
render_games(v);
}
fn render_sessions(v: &Value) {
println!("sessions {}", v["active_sessions"].as_i64().unwrap_or(0));
if let Some(s) = v["session"].as_object() {
println!(
"mode {}x{} @ {}",
s.get("width").and_then(Value::as_i64).unwrap_or(0),
s.get("height").and_then(Value::as_i64).unwrap_or(0),
s.get("fps").and_then(Value::as_i64).unwrap_or(0)
);
}
render_games(v);
}
fn render_games(v: &Value) {
let Some(games) = v["games"].as_array().filter(|g| !g.is_empty()) else {
return;
};
println!("\nGAME CLIENT PLANE STATE");
for g in games {
println!(
"{:<30} {:<19} {:<11} {}",
trunc(g["title"].as_str().unwrap_or("(desktop)"), 30),
trunc(g["client"].as_str().unwrap_or(""), 19),
g["plane"].as_str().unwrap_or(""),
g["state"].as_str().unwrap_or(""),
);
}
}
fn render_pending(v: &Value) {
let Some(rows) = v.as_array().filter(|r| !r.is_empty()) else {
println!("no devices waiting for approval");
return;
};
// I6: the claimed name AND the fingerprint tail, always — the name is what the device says it
// is, the tail is what it can't lie about.
println!("ID NAME FINGERPRINT AGE ACCESS");
for r in rows {
println!(
"{:<5} {:<25} {:<12} {:<8} {}",
r["id"].as_i64().unwrap_or(-1),
trunc(r["name"].as_str().unwrap_or("(unnamed)"), 25),
tail(r["fingerprint"].as_str().unwrap_or("")),
format!("{}s", r["age_secs"].as_i64().unwrap_or(0)),
r["access_level"].as_str().unwrap_or(""),
);
}
println!("\napprove with `ctl approve <ID>`, refuse with `ctl deny <ID>`");
}
fn render_clients(v: &Value) {
println!("PLANE NAME FINGERPRINT ACCESS EXPIRES");
for r in v["native"].as_array().into_iter().flatten() {
println!(
"{:<11} {:<25} {:<12} {:<11} {}",
"native",
trunc(r["name"].as_str().unwrap_or("(unnamed)"), 25),
tail(r["fingerprint"].as_str().unwrap_or("")),
r["access_level"].as_str().unwrap_or(""),
match r["expires_unix"].as_i64() {
None => "permanent".to_string(),
Some(t) => format!("unix {t}"),
}
);
}
for r in v["gamestream"].as_array().into_iter().flatten() {
// The GameStream store has no grants and no expiry — its devices are pinned certificates,
// full stop. Two dashes rather than borrowed native semantics.
println!(
"{:<11} {:<25} {:<12} {:<11}",
"gamestream",
trunc(r["label"].as_str().unwrap_or("(unnamed)"), 25),
tail(r["fingerprint"].as_str().unwrap_or("")),
"",
);
}
}
fn render_pair(v: &Value) {
println!(
"native {}",
match (
v["enabled"].as_bool().unwrap_or(false),
v["armed"].as_bool().unwrap_or(false)
) {
(false, _) => "the native plane is not running".to_string(),
(true, false) => "disarmed".to_string(),
(true, true) => match v["expires_in_secs"].as_i64() {
Some(s) => format!("ARMED, {s}s left"),
None => "ARMED".to_string(),
},
}
);
if let Some(pin) = v["pin"].as_str() {
println!("pin {pin} — enter this on the device");
}
if v["pin_pending"].as_bool().unwrap_or(false) {
println!("moonlight a client is waiting on its PIN — `ctl pin <PIN>`");
}
println!("paired {}", v["paired_clients"].as_i64().unwrap_or(0));
}
/// Last 10 hex characters, the shape an operator compares against what the device shows. Never the
/// whole 64 — a wall of hex is exactly what makes people stop reading it.
fn tail(fp: &str) -> String {
match fp.len() {
0 => "".to_string(),
n if n <= 10 => fp.to_string(),
n => format!("{}", &fp[n - 10..]),
}
}
fn trunc(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
s.chars().take(max.saturating_sub(1)).collect::<String>() + ""
}
fn print_usage() {
// A plain `&str`, printed through `{USAGE}` rather than as a format string: the `watch`
// example below contains JSON braces, which a literal format string would try to interpolate.
const USAGE: &str = r#"punktfunk-host ctl — operator control over the local management API
USAGE:
punktfunk-host ctl <VERB> [ARGS] [--json]
STATE
status host state, session count, paired counts
sessions the active session(s) and any launched game
watch [--kinds K,..] [--since N]
the host event stream as line-JSON on stdout, one object per
line; reconnects by itself and emits {"kind":"ctl.resync"}
when it fell off the catch-up ring
PAIRING
pair status is a pairing window open, and is a PIN waiting
pair arm [--ttl S] [--expires-in S] [--preset P] [--fingerprint FP]
open a native pairing window (--fingerprint binds it to ONE device)
pair disarm close it
pending devices knocking, awaiting approval
approve <ID> [--name N] [--preset P] [--expires-in S]
deny <ID>
pin <PIN> submit the PIN a Moonlight/GameStream client is showing
CONSOLE
console-url print a one-shot URL that opens the web console already logged in.
The ticket is signed with the management token, so being able to
read that 0600 file IS the proof — a visitor without one still
meets the login page.
DEVICES
clients paired devices on both planes
rename <FP> <NAME>
access <FP> <full|controller|view>
unpair <FP> | unpair --all [--yes]
OPTIONS
--json versioned JSON on stdout (the contract; the tables are for humans)
EXIT CODES
0 ok 1 the host refused 2 usage 3 no host reachable 4 certificate pin mismatch
The token and the certificate pin are read from the host's config directory; neither is ever
accepted on the command line or from the environment."#;
eprintln!("{USAGE}");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn presets_map_to_the_hosts_own_masks() {
assert_eq!(grants_for("full").unwrap(), GRANT_PRESET_FULL);
assert_eq!(
grants_for("controller").unwrap(),
GRANT_PRESET_CONTROLLER_ONLY
);
assert_eq!(grants_for("view").unwrap(), GRANT_PRESET_VIEW_ONLY);
// A typo must be usage (2), never a silently-wrong mask.
assert_eq!(grants_for("fulll").unwrap_err().code, client::EXIT_USAGE);
}
#[test]
fn fingerprint_tails_stay_short_and_never_panic_on_multibyte() {
assert_eq!(tail(""), "");
assert_eq!(tail("abc"), "abc");
assert_eq!(tail(&"a".repeat(64)), format!("{}", "a".repeat(10)));
assert_eq!(trunc("ünïcödé title", 5), "ünïc…");
}
#[test]
fn flags_are_read_positionlessly() {
let args = ["arm", "--ttl", "120", "--preset", "view"];
assert_eq!(num_flag(&args, "--ttl").unwrap(), Some(120));
assert_eq!(preset_flag(&args).unwrap(), Some(GRANT_PRESET_VIEW_ONLY));
assert_eq!(num_flag(&args, "--expires-in").unwrap(), None);
// A non-numeric TTL is usage, not a silently-dropped flag.
assert_eq!(
num_flag(&["--ttl", "soon"], "--ttl").unwrap_err().code,
client::EXIT_USAGE
);
}
#[test]
fn approve_takes_an_id_and_never_guesses() {
assert_eq!(one_id(&["7", "--name", "tv"], "approve").unwrap(), 7);
// A flag's VALUE must never be read as the id — `approve --expires-in 3600` naming
// device 3600 is precisely the accident I6 forbids.
assert!(one_id(&["--expires-in", "3600"], "approve").is_err());
assert!(one_id(&["newest"], "approve").is_err());
assert!(one_id(&[], "approve").is_err());
}
}
+465
View File
@@ -0,0 +1,465 @@
//! The ctl surface's loopback client — discovery, the **pinned** TLS transport, and the one place
//! a credential is read (implementation plan §§12, invariants I1/I2).
//!
//! Three files on disk are the whole configuration; nothing comes from argv or the environment:
//!
//! | file | what | absent ⇒ |
//! |------|------|----------|
//! | `mgmt-endpoint` | the port the host actually bound (`pf_paths::published_mgmt_port`) | fall back to 47990, exactly like the tray and the console |
//! | `native-cert.pem` (else `cert.pem`) | the leaf the mgmt listener presents — **the pin** | [`EXIT_UNREACHABLE`]: no pin, so no token, so no call |
//! | `mgmt-token` | the operator bearer the console already uses | [`EXIT_UNREACHABLE`] ("is the host installed?") |
//!
//! **I2, pin before token.** The agent's rustls verifier is the workspace's canonical
//! [`PinVerify`](punktfunk_core::tls::PinVerify) with the host's own leaf fingerprint. rustls
//! validates the server certificate *during the handshake*, and ureq writes the request line and
//! headers only after the handshake completes — so on a mismatch the `Authorization` header is
//! never serialised, let alone sent. That is a property of the ordering, not of a check we
//! remember to run, which is why the port-squat vector (another local uid binding the mgmt port
//! while the host is down) closes with no server-side change at all (I4).
//!
//! Telling a pin mismatch apart from "nothing is listening" is what [`PinVerify::with_observed`]
//! is for: it records the leaf it saw *before* comparing, so after a failed connect a slot holding
//! a fingerprint that isn't ours means squat/rotation ([`EXIT_PIN`]), and an empty slot means we
//! never got a certificate at all ([`EXIT_UNREACHABLE`]).
//!
//! **I1, no credential on argv/env/logs.** There is deliberately no `--token` flag and no
//! `PUNKTFUNK_MGMT_TOKEN` read here: an operator who had to put the token in ctl's environment
//! would publish it in `/proc/<pid>/environ`, which is exactly the cross-uid leak the config dir's
//! 0700 mode exists to prevent. The cost is stated in the docs: a host handed its token by
//! `--mgmt-token`/env and *never* persisting one is not reachable by ctl. Every packaged host
//! persists (`mgmt_token::load_or_generate`), so this is a dev-box footnote, not a gap.
//!
//! **ctl never mints.** A missing `mgmt-token` is a hard error, never a "let me generate one" —
//! the inverse of the `web-password` silent-adoption finding (security sweep 2026-08-15). The
//! host is the only minter; ctl is a consumer.
//!
//! Responses come back as [`serde_json::Value`], not the in-crate mgmt structs. That is not
//! laziness about drift, it is the *stronger* answer for the contract this ships: `--json` echoes
//! the server's own JSON verbatim, so a field added to a response reaches the plugin with no ctl
//! diff at all, and the OpenAPI drift gate remains the single place the shapes are pinned. Only
//! the human tables name fields, and a table that misses a new one is cosmetic.
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Exit codes (implementation plan §4). Distinct because scripts branch on them — most of all
/// [`EXIT_PIN`], which is a security signal and not an ordinary failure.
pub const EXIT_API: i32 = 1;
pub const EXIT_USAGE: i32 = 2;
pub const EXIT_UNREACHABLE: i32 = 3;
pub const EXIT_PIN: i32 = 4;
/// The JSON envelope version (I8). Additive: fields may be added, never removed or retyped, and
/// this bumps only if that promise has to break.
pub const SCHEMA_VERSION: u32 = 1;
/// A failed verb: the exit code the process takes, and the line a human reads.
#[derive(Debug)]
pub struct Failure {
pub code: i32,
pub message: String,
}
impl Failure {
pub fn new(code: i32, message: impl Into<String>) -> Self {
Failure {
code,
message: message.into(),
}
}
pub fn usage(message: impl Into<String>) -> Self {
Self::new(EXIT_USAGE, message)
}
pub fn unreachable(message: impl Into<String>) -> Self {
Self::new(EXIT_UNREACHABLE, message)
}
pub fn api(message: impl Into<String>) -> Self {
Self::new(EXIT_API, message)
}
}
pub type Result<T> = std::result::Result<T, Failure>;
/// Connect timeout — loopback, so a slow one means "nothing is there", not "the network is far".
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
/// Whole-call timeout for the one-shot verbs. `watch` passes `None` (it is long-lived by design).
const CALL_TIMEOUT: Duration = Duration::from_secs(15);
pub struct Client {
agent: ureq::Agent,
base: String,
/// `Bearer <token>`, built once from the 0600 file. The ONLY place in this crate where the
/// operator credential exists outside `mgmt_token` (I1) — grep for `bearer` to audit it.
bearer: String,
/// The leaf the last handshake actually presented — the [`EXIT_PIN`] discriminator.
observed: Arc<Mutex<Option<[u8; 32]>>>,
pin: [u8; 32],
}
impl Client {
/// Discover, pin and authenticate. `global_timeout` is `None` for `watch`.
pub fn connect(global_timeout: Option<Duration>) -> Result<Client> {
Self::connect_in(&pf_paths::config_dir(), global_timeout)
}
/// The IO half, taking the config directory — so the pin-mismatch negative can be exercised
/// against a real TLS listener without mutating `PUNKTFUNK_CONFIG_DIR` (which needs `unsafe`
/// since edition 2024, and which this module refuses to need). Same split, same reason, as
/// `pf_paths::published_mgmt_port_in`.
pub fn connect_in(dir: &Path, global_timeout: Option<Duration>) -> Result<Client> {
let pin = load_pin(dir)?;
let token = load_token(dir)?;
let port = pf_paths::published_mgmt_port_in(dir).unwrap_or(crate::mgmt::DEFAULT_PORT);
let observed = Arc::new(Mutex::new(None));
Ok(Client {
agent: agent(pin, observed.clone(), global_timeout),
// Always loopback: the admin surface is honoured from LOOPBACK peers only
// (`mgmt::auth`), so any other address would be refused by the host anyway.
base: format!("https://127.0.0.1:{port}"),
bearer: format!("Bearer {token}"),
observed,
pin,
})
}
pub fn get(&self, path: &str) -> Result<serde_json::Value> {
let sent = self
.agent
.get(self.url(path))
.header("Authorization", &self.bearer)
.call();
self.finish(sent, path)
}
pub fn delete(&self, path: &str) -> Result<serde_json::Value> {
let sent = self
.agent
.delete(self.url(path))
.header("Authorization", &self.bearer)
.call();
self.finish(sent, path)
}
pub fn post(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
let sent = self
.agent
.post(self.url(path))
.header("Authorization", &self.bearer)
.header("Content-Type", "application/json")
.send(body.to_string());
self.finish(sent, path)
}
pub fn patch(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
let sent = self
.agent
.patch(self.url(path))
.header("Authorization", &self.bearer)
.header("Content-Type", "application/json")
.send(body.to_string());
self.finish(sent, path)
}
/// The streaming half, for `watch`: the raw response body of a GET, left unread so the caller
/// can consume SSE frames as they arrive.
pub fn stream(&self, path: &str) -> Result<Box<dyn std::io::Read + Send>> {
let resp = self
.agent
.get(self.url(path))
.header("Authorization", &self.bearer)
.call()
.map_err(|e| self.transport_failure(e, path))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
let mut resp = resp;
let body = resp.body_mut().read_to_string().unwrap_or_default();
return Err(Failure::api(http_error(status, &body, path)));
}
Ok(Box::new(resp.into_body().into_reader()))
}
fn url(&self, path: &str) -> String {
format!("{}{path}", self.base)
}
fn finish(
&self,
sent: std::result::Result<ureq::http::Response<ureq::Body>, ureq::Error>,
path: &str,
) -> Result<serde_json::Value> {
let mut resp = sent.map_err(|e| self.transport_failure(e, path))?;
let status = resp.status().as_u16();
let body = resp.body_mut().read_to_string().unwrap_or_default();
if !(200..300).contains(&status) {
return Err(Failure::api(http_error(status, &body, path)));
}
if body.trim().is_empty() {
return Ok(serde_json::Value::Null);
}
serde_json::from_str(&body)
.map_err(|e| Failure::api(format!("{path}: the host sent JSON we can't parse ({e})")))
}
/// Classify a transport failure. The observed-fingerprint slot is what separates "somebody
/// else is on that port" (a security answer, [`EXIT_PIN`]) from "nobody is" ([`EXIT_UNREACHABLE`]).
fn transport_failure(&self, e: ureq::Error, path: &str) -> Failure {
if let Some(seen) = *self.observed.lock().unwrap() {
if seen != self.pin {
return Failure::new(
EXIT_PIN,
format!(
"certificate pin mismatch on {base} — the process answering there presented \
{seen}, but this host's identity is {ours}. No token was sent. Either the \
host regenerated its certificate (delete the stale pairing state and \
re-pair) or another local process is squatting the management port.",
base = self.base,
seen = hex::encode(seen),
ours = hex::encode(self.pin),
),
);
}
}
Failure::unreachable(format!(
"cannot reach the management API at {}{path}: {e}. Is the host running \
(`systemctl --user status punktfunk-host`)?",
self.base
))
}
}
/// Turn a non-2xx into the line a human gets, unwrapping the `ApiError` envelope when there is one.
fn http_error(status: u16, body: &str, path: &str) -> String {
let detail = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("error")?.as_str().map(str::to_string))
.unwrap_or_else(|| body.trim().chars().take(200).collect());
match status {
401 | 403 => format!(
"{path}: the host rejected our token ({status}). The persisted `mgmt-token` and the \
running host disagree — restart the host, or delete the file and let it re-mint."
),
404 => format!("{path}: no such thing here ({status} {detail})"),
503 => format!("{path}: {detail} ({status})"),
_ if detail.is_empty() => format!("{path}: the host answered {status}"),
_ => format!("{path}: {detail} ({status})"),
}
}
/// The mgmt listener presents the **native** identity when one exists and the legacy GameStream
/// identity otherwise (`mgmt::run` takes a `NativeIdentity`; `identity::load_or_adopt` mints
/// `native-cert.pem` or adopts `cert.pem`). Same order the tray and the plugin runner already use —
/// pinning the wrong one of the pair is a guaranteed [`EXIT_PIN`] on a perfectly healthy host.
fn load_pin(dir: &Path) -> Result<[u8; 32]> {
use rustls::pki_types::pem::PemObject;
let pem = std::fs::read(dir.join("native-cert.pem"))
.or_else(|_| std::fs::read(dir.join("cert.pem")))
.map_err(|_| {
Failure::unreachable(format!(
"no host certificate in {} (looked for native-cert.pem, then cert.pem). \
Without it there is nothing to pin, and ctl will not send a token unpinned. \
Has the host ever run on this machine?",
dir.display()
))
})?;
let der = rustls::pki_types::CertificateDer::from_pem_slice(&pem).map_err(|e| {
Failure::unreachable(format!(
"the host certificate in {} is not readable as PEM ({e})",
dir.display()
))
})?;
Ok(punktfunk_core::tls::cert_fingerprint(der.as_ref()))
}
/// Read the persisted operator token. **Never generates one** — see the module docs.
fn load_token(dir: &Path) -> Result<String> {
crate::mgmt_token::read_persisted(dir).ok_or_else(|| {
Failure::unreachable(format!(
"no management token in {} — ctl reads the one the host persists and never mints its \
own. Start the host once (`systemctl --user start punktfunk-host`) and retry.",
dir.join("mgmt-token").display()
))
})
}
/// The pinned agent — the tray's shape (`punktfunk-tray/src/status.rs`), with the pin made
/// mandatory and the observed slot wired up so a mismatch is reportable.
fn agent(
pin: [u8; 32],
observed: Arc<Mutex<Option<[u8; 32]>>>,
global_timeout: Option<Duration>,
) -> ureq::Agent {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let tls = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.expect("rustls default protocol versions")
.dangerous()
.with_custom_certificate_verifier(Arc::new(punktfunk_core::tls::PinVerify::with_observed(
Some(pin),
observed,
)))
.with_no_client_auth();
// ureq's own `TlsConfig` has roots, a client cert and an off-switch but no hook for a custom
// verifier, so the agent takes the `ClientConfig` directly through the shared glue.
punktfunk_core::tls::ureq_agent::agent(
Arc::new(tls),
ureq::Agent::config_builder()
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_global(global_timeout)
// Let 4xx/5xx come back as responses so the `ApiError` body reaches the operator
// instead of being flattened into "status 400".
.http_status_as_error(false)
.max_redirects(0)
.build(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read as _;
/// **The I2 negative — the reason the pin exists.** A process that is not the host answers on
/// the management port (the port-squat vector: another local uid binds it while the host is
/// down). It presents a perfectly valid, perfectly well-formed self-signed certificate that
/// simply isn't ours.
///
/// Two things must be true, and only the second one is about cryptography:
/// 1. the verb fails with [`EXIT_PIN`] — a *distinct* code, so a script does not retry into
/// the squatter the way it would for "host down";
/// 2. the squatter receives **zero application bytes**. rustls rejects the certificate during
/// the handshake, so ureq never gets to serialise a request line — the `Authorization`
/// header is not "sent and ignored", it is never constructed. That ordering is the whole
/// security property, and this test is what stops a future refactor (an agent added per
/// agent, a retry that disables verification "just to see") from quietly inverting it.
#[test]
fn a_squatter_gets_exit_4_and_not_one_byte_of_the_token() {
punktfunk_core::tls::install_default_provider();
let dir = std::env::temp_dir().join(format!("pf-ctl-pin-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// Two identities that have nothing to do with each other: what the config dir says the
// host is, and what actually answers on the port.
let ours = crate::identity::ephemeral().unwrap();
let squatter = crate::identity::ephemeral().unwrap();
let server = crate::gamestream::tls::server_config_optional_client(
&squatter.cert_pem,
&squatter.key_pem,
)
.unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::fs::write(dir.join("native-cert.pem"), &ours.cert_pem).unwrap();
std::fs::write(
dir.join("mgmt-token"),
"PUNKTFUNK_MGMT_TOKEN=s3kr1t-token\n",
)
.unwrap();
std::fs::write(
dir.join("mgmt-endpoint"),
format!("PUNKTFUNK_MGMT_URL=https://127.0.0.1:{port}\n"),
)
.unwrap();
let seen = Arc::new(Mutex::new(Vec::<u8>::new()));
let recorder = seen.clone();
let squat = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().expect("squatter accept");
let mut conn = rustls::ServerConnection::new(server).expect("squatter tls");
// The client will send a fatal alert instead of finishing — that is the pass condition,
// so an error here is expected and deliberately ignored.
let _ = conn.complete_io(&mut sock);
let mut plaintext = Vec::new();
let _ = conn.reader().read_to_end(&mut plaintext);
*recorder.lock().unwrap() = plaintext;
});
let err = Client::connect_in(&dir, Some(Duration::from_secs(10)))
.and_then(|c| c.get("/api/v1/status"))
.expect_err("a mismatched certificate must not produce a successful call");
assert_eq!(err.code, EXIT_PIN, "wrong exit code: {}", err.message);
assert!(
err.message.contains("pin mismatch"),
"the operator must be told WHICH failure this is: {}",
err.message
);
squat.join().unwrap();
let bytes = seen.lock().unwrap().clone();
assert!(
bytes.is_empty(),
"the squatter read {} application bytes; the token must never leave the process \
before the pin matches",
bytes.len()
);
assert!(!String::from_utf8_lossy(&bytes).contains("s3kr1t-token"));
let _ = std::fs::remove_dir_all(&dir);
}
/// The other half of I2: a config dir with no certificate at all is [`EXIT_UNREACHABLE`], not
/// a quiet fallback to an unverified connection. "No pin available" must never mean "connect
/// anyway" — that is the shape the tray can afford (it holds no token) and ctl cannot.
#[test]
fn no_certificate_means_no_call_at_all() {
let dir = std::env::temp_dir().join(format!("pf-ctl-nocert-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("mgmt-token"), "PUNKTFUNK_MGMT_TOKEN=deadbeef\n").unwrap();
// `.err()` rather than `expect_err`: `Client` deliberately has no `Debug`, because the
// derived one would print the bearer into any panic message or `{:?}` a future edit adds.
let err = Client::connect_in(&dir, None)
.err()
.expect("no cert, no connection");
assert_eq!(err.code, EXIT_UNREACHABLE);
assert!(err.message.contains("native-cert.pem"), "{}", err.message);
let _ = std::fs::remove_dir_all(&dir);
}
/// And ctl never mints: a config dir with a certificate but no token fails, and leaves the
/// directory exactly as it found it (the inverted `web-password` lesson).
#[test]
fn a_missing_token_is_an_error_never_a_freshly_minted_one() {
let dir = std::env::temp_dir().join(format!("pf-ctl-notoken-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let ours = crate::identity::ephemeral().unwrap();
std::fs::write(dir.join("native-cert.pem"), &ours.cert_pem).unwrap();
let err = Client::connect_in(&dir, None)
.err()
.expect("no token, no connection");
assert_eq!(err.code, EXIT_UNREACHABLE);
assert!(!dir.join("mgmt-token").exists(), "ctl minted a token");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn http_error_unwraps_the_api_envelope() {
let msg = http_error(
400,
r#"{"error":"grants has reserved bits set"}"#,
"/api/v1/x",
);
assert!(msg.contains("grants has reserved bits set"), "{msg}");
assert!(msg.contains("400"), "{msg}");
}
#[test]
fn http_error_names_the_token_when_auth_fails() {
let msg = http_error(401, "", "/api/v1/status");
assert!(msg.contains("mgmt-token"), "{msg}");
}
#[test]
fn exit_codes_are_distinct() {
// Scripts branch on these; a collision would silently merge "no host" with "squatter".
let all = [EXIT_API, EXIT_USAGE, EXIT_UNREACHABLE, EXIT_PIN];
let mut seen: Vec<i32> = all.to_vec();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), all.len());
// And none of them is 0 — a failure that exits 0 is worse than a wrong code.
assert!(all.iter().all(|c| *c != 0));
}
}
+220
View File
@@ -0,0 +1,220 @@
//! `punktfunk-host ctl watch` — the host's SSE event stream bridged to **line-JSON on stdout**,
//! one object per line, flushed per line. That shape is the whole point: a Quickshell `Process`, a
//! waybar `custom/` module and `while read -r line` all consume it without an SSE parser, and none
//! of them ever sees a token (I3 — the plugin holds no credential because it holds no HTTP client).
//!
//! Reconnection is the interesting part. `mgmt/events.rs` keeps a ~1024-event ring and resumes
//! from `Last-Event-ID`; a consumer whose cursor has fallen off the ring gets a synthetic
//! `event: dropped` frame first and is expected to re-snapshot. We surface both facts to the
//! consumer as ordinary lines:
//!
//! - `{"v":1,"kind":"ctl.resync"}` — emitted **once** after a `dropped` frame, and also after any
//! reconnect that could not resume exactly (no cursor yet). A widget that sees it re-runs
//! `ctl status` / `ctl pending` rather than trusting its incremental state.
//! - `{"v":1,"kind":"ctl.disconnected","data":{"error":"…"}}` — the stream dropped and we are
//! backing off. Purely informational; the reconnect is automatic.
//! - `{"v":1,"kind":"ctl.heartbeat"}` — the host's SSE keep-alive, roughly every 15 s. A consumer
//! can use it as a liveness signal, and it is also what lets *us* notice that our own consumer
//! has gone away (see [`emit`]).
//!
//! The cursor advances on every frame with an `id:`, so a host restart mid-watch resumes from the
//! last event actually delivered. Backoff is capped and jittered only by the cap: an operator's
//! plugin reconnecting in a tight loop against a host that is down would otherwise be the thing
//! that keeps hitting the SSE connection cap.
//!
//! The connection cap (`MAX_EVENT_STREAMS` = 32) is shared with the console; the plugin is
//! specified to hold exactly one stream. Exhausting it is a 503, which arrives here as an ordinary
//! API failure with the host's own message.
use std::io::{BufRead, BufReader, Write};
use std::time::Duration;
use super::client::{Client, Failure, Result, SCHEMA_VERSION};
/// Reconnect backoff: quick enough that a host restart is invisible to a widget, slow enough that
/// a host that is genuinely down doesn't get hammered.
const BACKOFF_MIN: Duration = Duration::from_secs(1);
const BACKOFF_MAX: Duration = Duration::from_secs(30);
pub fn run(kinds: Option<&str>, since: Option<u64>) -> Result<()> {
let mut cursor = since;
let mut backoff = BACKOFF_MIN;
// First connect is the only one allowed to fail the process: a bad pin, a missing token or a
// host that has never run are all conditions a retry cannot fix, and a `watch` that silently
// spins forever on them is worse than an exit code the caller can see.
let mut client = Client::connect(None)?;
loop {
match pump(&client, kinds, &mut cursor) {
// The stream ended cleanly (host shutdown) — reconnect like any other drop.
Ok(()) => emit_control("ctl.disconnected", Some("stream closed by the host")),
Err(e) if e.code == super::client::EXIT_PIN => return Err(e),
Err(e) => emit_control("ctl.disconnected", Some(&e.message)),
}
std::thread::sleep(backoff);
backoff = (backoff * 2).min(BACKOFF_MAX);
// Rebuild the client on every reconnect rather than reusing it: that re-reads
// `native-cert.pem`, so a host that regenerated its identity while we were disconnected
// is picked up instead of pinning us out forever (risk register #1). A pin that is now
// genuinely wrong still exits 4 on the next attempt, which is the intended signal.
match Client::connect(None) {
Ok(c) => {
client = c;
backoff = BACKOFF_MIN;
}
Err(e) if e.code == super::client::EXIT_PIN => return Err(e),
Err(e) => emit_control("ctl.disconnected", Some(&e.message)),
}
}
}
/// One connection's worth of frames. Returns `Ok(())` when the server closed the stream.
fn pump(client: &Client, kinds: Option<&str>, cursor: &mut Option<u64>) -> Result<()> {
let mut path = String::from("/api/v1/events");
let mut sep = '?';
if let Some(k) = kinds {
path.push_str(&format!("{sep}kinds={}", urlencode(k)));
sep = '&';
}
if let Some(c) = *cursor {
path.push_str(&format!("{sep}since={c}"));
}
let reader = BufReader::new(client.stream(&path)?);
// One SSE frame = `id:`/`event:`/`data:` lines terminated by a blank line. Keep-alive comments
// (`:` prefix) are skipped; they exist to detect a dead peer, not to be forwarded.
let mut id: Option<u64> = None;
let mut kind: Option<String> = None;
let mut data = String::new();
for line in reader.lines() {
let line =
line.map_err(|e| Failure::unreachable(format!("event stream read failed: {e}")))?;
if line.is_empty() {
if let Some(k) = kind.take() {
dispatch(&k, &data, id, cursor);
}
id = None;
data.clear();
continue;
}
let Some((field, value)) = line.split_once(':') else {
continue;
};
let value = value.strip_prefix(' ').unwrap_or(value);
match field {
// A comment (`: keep-alive`) splits with an empty field name. The host sends one every
// 15 s, and we turn it into the one line that proves BOTH directions are alive.
//
// The write is the point. Our consumer is a shell widget, and when it dies its end of
// our stdout pipe closes — but a stream that is only ever READ never notices, so an
// idle host leaves `ctl watch` running forever against the server's connection cap.
// Measured on an Omarchy box: six orphaned watchers after three shell restarts, all on
// a host with no events at all. Writing here turns the next keep-alive into an EPIPE,
// and [`emit`] exits on it.
"" => emit(serde_json::json!({ "v": SCHEMA_VERSION, "kind": "ctl.heartbeat" })),
"id" => id = value.parse().ok(),
"event" => kind = Some(value.to_string()),
"data" => {
if !data.is_empty() {
data.push('\n');
}
data.push_str(value);
}
_ => {}
}
}
Ok(())
}
/// Turn one decoded frame into a line of stdout, advancing the resume cursor.
fn dispatch(kind: &str, data: &str, id: Option<u64>, cursor: &mut Option<u64>) {
if let Some(seq) = id {
*cursor = Some(seq);
}
if kind == "dropped" {
// We fell off the catch-up ring: whatever the consumer believes about pending devices or
// live sessions may be stale, and no amount of further events will repair it.
emit_control("ctl.resync", None);
return;
}
let payload = serde_json::from_str::<serde_json::Value>(data)
.unwrap_or_else(|_| serde_json::Value::String(data.to_string()));
emit(serde_json::json!({
"v": SCHEMA_VERSION,
"kind": kind,
"seq": id,
"data": payload,
}));
}
fn emit_control(kind: &str, error: Option<&str>) {
let mut line = serde_json::json!({ "v": SCHEMA_VERSION, "kind": kind });
if let Some(e) = error {
line["data"] = serde_json::json!({ "error": e });
}
emit(line);
}
/// One line, flushed. A widget reading incrementally must not wait on an 8 KiB stdio buffer to
/// fill before it learns a device is knocking.
///
/// **A failed write ends the process**, rather than being ignored as it was: the only reason a
/// write to our own stdout fails is that the consumer is gone, and carrying on would hold an SSE
/// stream open against the host's connection cap for as long as the box stays up. Exit 0 — the
/// consumer going away is a normal end to a `watch`, not an error anyone needs to see.
fn emit(line: serde_json::Value) {
let mut out = std::io::stdout().lock();
if writeln!(out, "{line}").is_err() || out.flush().is_err() {
std::process::exit(0);
}
}
/// Minimal percent-encoding for the `kinds` query value. The grammar the host accepts is
/// `[a-z0-9_.*,-]`, so this only ever has to escape what a typo could introduce.
fn urlencode(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'*' | b',' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kinds_survive_encoding_and_typos_are_escaped() {
assert_eq!(
urlencode("stream.*,pairing.pending"),
"stream.*,pairing.pending"
);
assert_eq!(urlencode("a b"), "a%20b");
}
#[test]
fn a_dropped_frame_advances_the_cursor_and_asks_for_a_resync() {
// The cursor must advance even for `dropped`: resuming from before it would replay the
// same fell-off-the-ring condition on every reconnect.
let mut cursor = None;
dispatch("dropped", r#"{"dropped":true}"#, Some(7), &mut cursor);
assert_eq!(cursor, Some(7));
}
#[test]
fn an_ordinary_frame_advances_the_cursor() {
let mut cursor = Some(3);
dispatch(
"pairing.pending",
r#"{"kind":"pairing.pending"}"#,
Some(9),
&mut cursor,
);
assert_eq!(cursor, Some(9));
// A frame with no id (the synthetic ones) must not rewind it.
dispatch("stream.started", "{}", None, &mut cursor);
assert_eq!(cursor, Some(9));
}
}
+4
View File
@@ -41,6 +41,8 @@ pub mod ids {
pub const VIRTUAL_DECK_VHCI: &str = "virtual_deck_vhci";
pub const UINPUT_ACCESS: &str = "uinput_access";
pub const SERVER_CONFLICT: &str = "server_conflict";
pub const HYPRLAND_PERMISSIONS: &str = "hyprland_permissions";
pub const OMARCHY_UPDATES: &str = "omarchy_updates";
}
/// What a probe found. `Inapplicable` is deliberately distinct from `Ok`: "this box will never do
@@ -526,6 +528,8 @@ mod tests {
ids::VIRTUAL_DECK_VHCI,
ids::UINPUT_ACCESS,
ids::SERVER_CONFLICT,
ids::HYPRLAND_PERMISSIONS,
ids::OMARCHY_UPDATES,
] {
assert!(
ids.iter().any(|i| i == expected),
@@ -31,6 +31,110 @@ pub(crate) fn register_all(reg: &Diagnostics) {
reg.register(virtual_deck_vhci);
reg.register(uinput_access);
reg.register(server_conflict);
reg.register(hyprland_permissions);
reg.register(omarchy_updates);
}
// ---------------------------------------------------------------------------------------------
// hyprland_permissions
// ---------------------------------------------------------------------------------------------
/// Hyprland 0.49+ has `ecosystem.enforce_permissions`. It is off by default, but when it is on and
/// the host has not been granted, screencopy and virtual input are **denied silently** — black
/// frames and dropped input, with no error anywhere. That is the entire reason this check exists:
/// every other failure on this path reports itself, and this one reports nothing at all.
///
/// Not Omarchy-specific — it applies to every Hyprland box — but Omarchy is exactly the kind of
/// distro that might turn it on in a future release, which is what made it worth a row.
fn hyprland_permissions() -> HostCheck {
let id = ids::HYPRLAND_PERMISSIONS;
if !cfg!(target_os = "linux") {
return HostCheck::inapplicable(id, "Hyprland's permission system is a Linux feature.");
}
// `hyprctl` reachable at all is the "is this a Hyprland session?" test — the same one the
// backend uses. An absent binary or a compositor that is not Hyprland is not a problem here.
let Some(out) = command_output(
"hyprctl",
&["-j", "getoption", "ecosystem:enforce_permissions"],
) else {
return HostCheck::inapplicable(
id,
"This machine is not running a Hyprland session, so Hyprland's permission system \
does not apply.",
);
};
let enforced = serde_json::from_str::<serde_json::Value>(&out)
.ok()
.and_then(|j| j.get("int").and_then(|v| v.as_i64()))
.is_some_and(|v| v != 0);
if !enforced {
return HostCheck::ok(
id,
"Hyprland is not enforcing per-application permissions, so nothing needs granting.",
);
}
HostCheck::problem(
id,
CheckStatus::Warn,
// Warning, not Critical: enforcement being ON does not mean we are DENIED — a box where
// the host is already granted streams perfectly, and this probe cannot tell the two apart
// from outside the compositor. Claiming Critical here would cry wolf on a healthy box.
Severity::Warning,
"Hyprland is enforcing permissions and this host may not be granted".to_string(),
"Hyprland denies screencopy and virtual input SILENTLY — the client sees black frames and \
input that does nothing, and neither the host nor the compositor logs an error. If \
streaming already works, the host is already granted and there is nothing to do."
.to_string(),
)
.with_remedy(Remedy {
text: "Grant this host screencopy and virtual input in your Hyprland config, then reload \
it. On a Lua-era config (Hyprland 4.x / Omarchy) the lines go in hyprland.lua or a \
module it includes; on hyprlang they are `permission = …` lines."
.to_string(),
command: Some(
"o.permission(\"/usr/bin/punktfunk-host\", \"screencopy\", \"allow\")\n\
o.permission(\"/usr/bin/punktfunk-host\", \"plugin\", \"allow\")"
.to_string(),
),
relogin_required: false,
})
}
// ---------------------------------------------------------------------------------------------
// omarchy_updates
// ---------------------------------------------------------------------------------------------
/// On Omarchy, updates go through `omarchy update` and the console's apply button is deliberately
/// absent (design D5). Without a row saying so, "the update button is missing on my box" is an
/// unanswerable support question — the check exists to answer it in the place the operator is
/// already looking.
fn omarchy_updates() -> HostCheck {
let id = ids::OMARCHY_UPDATES;
if !crate::osinfo::is_omarchy() {
return HostCheck::inapplicable(id, "This machine is not running Omarchy.");
}
let version = command_output("omarchy-version", &[]).unwrap_or_default();
let pretty = &crate::osinfo::detect().pretty;
let summary = if version.is_empty() {
format!("{pretty}: update with `omarchy update`")
} else {
format!("{version}: update with `omarchy update`")
};
HostCheck::ok(id, summary)
.with_param("update_command", "omarchy update")
.with_param("version", version)
}
/// Run a command and return its trimmed stdout, or `None` if it is absent or failed. Shared by the
/// two checks above; deliberately not a general helper — the catalog's other probes ask the owning
/// crate rather than shelling out, and these two have no owning crate to ask.
fn command_output(program: &str, args: &[&str]) -> Option<String> {
let out = Command::new(program).args(args).output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
}
// ---------------------------------------------------------------------------------------------
+14
View File
@@ -94,6 +94,10 @@ mod log_capture;
mod mgmt;
#[forbid(unsafe_code)]
mod mgmt_token;
// `ctl` is a CLIENT of everything above — it holds the operator token and the certificate pin, so
// it gets the same `forbid` as the surfaces it talks to.
#[forbid(unsafe_code)]
mod ctl;
#[cfg_attr(not(test), forbid(unsafe_code))]
mod native;
#[forbid(unsafe_code)]
@@ -336,6 +340,9 @@ fn is_management_cli(args: &[String]) -> bool {
| Some("driver")
| Some("web")
| Some("tray")
// A loopback API client. None of the host-startup work applies, and `watch` is a
// long-lived process — the GPU clock profile and the DXGI hook must not follow it.
| Some("ctl")
| Some("openapi")
| Some("library")
| Some("detect-conflicts")
@@ -468,6 +475,10 @@ fn real_main() -> Result<()> {
}
Ok(())
}
// The operator control surface: `ctl status`, `ctl approve 3`, `ctl watch`, … A loopback
// client of this same binary's management API, so a shell plugin or a script can drive
// pairing and sessions without a browser (design/omarchy-integration.md D13). See ctl.rs.
Some("ctl") => ctl::main(&args[1..]),
// Install and run host plugins: `plugins add playnite`, `plugins enable`, … Package ops are
// forwarded to the bun runner; enable/disable/status drive the systemd unit (Linux) or the
// PunktfunkScripting scheduled task (Windows). See plugins.rs.
@@ -1052,6 +1063,9 @@ fn print_usage() {
USAGE:
punktfunk-host serve [OPTIONS] native punktfunk/1 host + management REST API
(secure default; add --gamestream for Moonlight compat)
punktfunk-host ctl <VERB> operator control over the local management API —
pairing, devices, sessions, `watch` (line-JSON for a
shell widget); `ctl --help` for the verb list
punktfunk-host plugins <CMD> install/run host plugins (add, remove, list, enable,
disable, status) — `plugins --help` for details
punktfunk-host tray <CMD> status-tray lifecycle (start, stop, status) — Windows;
+12
View File
@@ -38,6 +38,18 @@ pub fn load_or_generate_plugin() -> Result<String> {
load_or_generate_impl(PLUGIN_ENV_VAR, PLUGIN_FILE)
}
/// Read the persisted operator token from `dir`, or `None` when there isn't one. **Never mints.**
///
/// This is what `ctl` uses: a client that generated its own `mgmt-token` would be planting the
/// credential the host then adopts — the `web-password` silent-adoption finding (security sweep
/// 2026-08-15) with the roles reversed. The host is the only minter; every other reader either
/// finds a token or fails loudly. It also deliberately ignores `PUNKTFUNK_MGMT_TOKEN`: a consumer
/// that took the token from its environment would publish it in `/proc/<pid>/environ`.
pub(crate) fn read_persisted(dir: &Path) -> Option<String> {
let contents = fs::read_to_string(dir.join(FILE)).ok()?;
parse_token(&contents, ENV_VAR)
}
fn load_or_generate_impl(env_var: &str, file: &str) -> Result<String> {
if let Ok(v) = std::env::var(env_var) {
let v = v.trim();
+36
View File
@@ -113,6 +113,25 @@ fn parse_os_release(contents: &str) -> OsInfo {
OsInfo { chain, pretty }
}
/// Is this an **Omarchy** box? (`ID=omarchy`, which the chain carries verbatim as its leaf.)
///
/// A *flavour*, never a family: `ID_LIKE=arch` already routes everything family-shaped — the
/// install ladder, `InstallKind::Pacman`, the docs — correctly, and nothing here should change
/// that. What the flavour decides is narrower and listed in one place so it stays auditable:
///
/// * the update tier (`crate::update`): Omarchy's own `omarchy update` owns the pacman
/// transaction, and a pacman guard blocks the direct `pacman -Syu` our root helper would run,
/// so the console reports **notify-only** and names their command;
/// * diagnostics rows (`crate::diagnostics`), which is where an operator finds out what an
/// Omarchy-specific check saw.
///
/// Detected from the same `os-release` parse as [`detect`], which Omarchy rewrites on every
/// `omarchy-settings` upgrade — so it survives updates, which a marker file in our own package
/// would not. Misdetection degrades to plain-Arch behaviour, which is exactly the old behaviour.
pub fn is_omarchy() -> bool {
detect().chain.ends_with("/omarchy")
}
/// Strip one matching pair of surrounding `"` or `'` quotes.
fn unquote(v: &str) -> String {
let v = v.trim();
@@ -171,6 +190,23 @@ mod tests {
assert_eq!(pretty, "Bazzite 42 (Kinoite)");
}
/// Omarchy 4.x writes `ID=omarchy` / `ID_LIKE=arch` / `VERSION_ID=<pkgver>` and *rewrites*
/// os-release on every `omarchy-settings` upgrade, so this is the detection that survives an
/// `omarchy update`. It must land in the arch family (the install ladder and `InstallKind`
/// depend on it) AND keep `omarchy` as the leaf (the flavour predicate reads it).
#[test]
fn omarchy_is_arch_family_and_keeps_its_leaf() {
let (chain, pretty) = parsed(
"NAME=\"Omarchy\"\nPRETTY_NAME=\"Omarchy\"\nID=omarchy\nID_LIKE=arch\nVERSION_ID=4.0.1\n",
);
assert_eq!(chain, "linux/arch/omarchy");
assert_eq!(pretty, "Omarchy");
assert!(chain.ends_with("/omarchy"), "is_omarchy() reads this");
// …and a plain Arch box must NOT trip the flavour.
let (arch, _) = parsed("ID=arch\nPRETTY_NAME=\"Arch Linux\"\n");
assert!(!arch.ends_with("/omarchy"));
}
#[test]
fn steamos_is_arch_family() {
let (chain, _) = parsed("ID=steamos\nID_LIKE=arch\nPRETTY_NAME=\"SteamOS\"\n");
+22
View File
@@ -73,6 +73,15 @@ pub(crate) fn apply_support() -> &'static str {
if apply_disabled() {
return "notify";
}
// Omarchy owns the pacman transaction (design D5). `omarchy update` snapshots with snapper,
// runs the full sysupgrade, then migrations and hooks — and a pacman guard blocks the bare
// `pacman -Syu` our root helper would run, so a one-click apply here would either be refused
// or bypass the snapshot the user's rollback depends on. Our packages ride their transaction
// for free once the repo is configured, so notify-only loses nothing.
#[cfg(target_os = "linux")]
if crate::osinfo::is_omarchy() {
return "notify";
}
let (kind, _) = detect::detect();
match kind {
detect::InstallKind::WindowsInstaller => "full",
@@ -104,6 +113,11 @@ pub(crate) fn apply_support() -> &'static str {
pub(crate) fn opt_in_hint() -> Option<String> {
#[cfg(target_os = "linux")]
{
// Never invite an Omarchy operator to join `punktfunk-update`: apply is notify-only there
// regardless (D5), so the opt-in would buy them a group membership and no button.
if crate::osinfo::is_omarchy() {
return None;
}
let (kind, _) = detect::detect();
let capable = matches!(
kind,
@@ -397,6 +411,14 @@ pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), Apply
if !windows_leg && !linux_leg {
return Err(ApplyError::Unsupported);
}
// The same D5 refusal as `apply_support`, enforced here rather than only reported there: a
// direct POST to the apply route on an Omarchy box that HAS the helper, the group and the
// full-sysupgrade opt-in would otherwise run `pacman -Syu` straight into their guard — or
// past it, skipping the snapper snapshot their rollback story is built on.
#[cfg(target_os = "linux")]
if crate::osinfo::is_omarchy() {
return Err(ApplyError::Unsupported);
}
#[cfg(target_os = "linux")]
if linux_leg && kind != detect::InstallKind::SteamosSource {
// The Deck source rebuild is user-owned and needs no root helper; every other Linux
@@ -37,6 +37,15 @@ fn classify(p: &pf_update_check::detect::Probe) -> (InstallKind, Channel) {
/// The per-kind "how to update" command the console shows while (or instead of) an apply
/// path existing (design §5). One line, copy-pastable, no placeholders.
pub(crate) fn channel_hint(kind: InstallKind) -> String {
// Omarchy flavour (design D5): same pacman DELIVERY, different command. `omarchy update` is
// the only supported way to run a transaction there — it snapshots first, then migrates, then
// runs their hooks — and our packages ride it automatically once the repo is configured. The
// flavour lives here rather than in `pf-update-check` deliberately: that crate is shared with
// the Linux client, and client-on-Omarchy is explicitly out of scope.
#[cfg(target_os = "linux")]
if kind == InstallKind::Pacman && crate::osinfo::is_omarchy() {
return "omarchy update (snapshots first; punktfunk rides the same transaction)".into();
}
pf_update_check::detect::update_command(kind, Product::Host)
}
+17
View File
@@ -98,6 +98,23 @@
"sudo pacman -Syu punktfunk-host punktfunk-web punktfunk-scripting"
]
},
{
"$comment": "Omarchy IS Arch for delivery — same signed repo, same packages — but NOT the same install command. Omarchy ships a libalpm PreTransaction hook (00-omarchy-update-guard) that ABORTS any transaction whose pacman invocation carries both -S and -u, so Arch's `pacman -Syu <pkgs>` dies with 'Woah partner...' and installs nothing (measured on 4.0.1, 2026-08-28). `-Sy` refreshes without a sysupgrade and is not blocked, and `-S` then installs. Everything Omarchy-specific is the setup line after them.",
"id": "omarchy",
"name": "Omarchy",
"installs": "host",
"packageManager": "pacman",
"docs": "/docs/omarchy",
"repo": "https://git.unom.io/api/packages/unom/arch",
"install": [
"curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key | sudo pacman-key --add -",
"sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69",
"grep -q '^\\[punktfunk\\]' /etc/pacman.conf || printf '\\n[punktfunk]\\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\\n' | sudo tee -a /etc/pacman.conf >/dev/null",
"sudo pacman -Sy",
"sudo pacman -S punktfunk-host punktfunk-web punktfunk-scripting",
"punktfunk-omarchy setup"
]
},
{
"id": "fedora",
"name": "Fedora 43+",
+14 -2
View File
@@ -251,8 +251,20 @@ Ending the session when a game exits needs no script: it is the default, on the
## The event stream (`GET /api/v1/events`)
For code, subscribe to the SSE stream on the management API (loopback + bearer token — the
same credentials as the rest of the admin surface):
For a shell script or a status widget, the easy way is
[`punktfunk-host ctl watch`](/docs/host-cli#ctl) — it does the SSE, the `Last-Event-ID` resume and
the reconnect for you, and prints **one JSON object per line**, so the credentials never leave the
host binary:
```sh
punktfunk-host ctl watch --kinds pairing.pending,stream.'*'
```
It also emits a synthetic `{"kind":"ctl.resync"}` line when the stream fell off the host's catch-up
ring, which is the signal to re-snapshot rather than trust what you have.
For code that wants the raw stream, subscribe to SSE on the management API directly (loopback +
bearer token — the same credentials as the rest of the admin surface):
```sh
. ~/.config/punktfunk/mgmt-token # sets PUNKTFUNK_MGMT_TOKEN
+92
View File
@@ -10,6 +10,7 @@ command — [`punktfunk`](#punktfunk-on-the-client-machine), which ships with th
| Command | What it does | Platform |
|---|---|---|
| [`serve`](#serve) | Run the host. | all |
| [`ctl`](#ctl) | Drive a running host: pairing, devices, sessions, events. | all |
| [`punktfunk1-host`](#punktfunk1-host) | Standalone native-only test host. | all |
| [`service`](#service-windows) | Register, start, stop and remove the Windows service. | Windows |
| [`tray`](#tray-windows) | Start, stop or query the status-tray icon. | Windows |
@@ -76,6 +77,97 @@ turn off the mandatory-pairing default and serve any device on the network (trus
only). `punktfunk1-host` (below) requires pairing by default too; its `--allow-tofu` flag is the
test-host equivalent of `--open`.
## `ctl`
Drive a **running** host from a terminal: approve a device, type a Moonlight PIN, rename or unpair,
stop a session, watch events. Everything the [web console](/docs/web-console) does day to day,
without a browser — and everything it does is the same management API the console talks to, over
loopback.
```sh
punktfunk-host ctl status
punktfunk-host ctl pending
punktfunk-host ctl approve 3
```
| Verb | What it does |
|---|---|
| `status` | Host state, live session count, paired-device counts. |
| `sessions` | The active session(s) and any launched game. |
| `pair status` | Is a pairing window open, and is a PIN waiting? |
| `pair arm` | Open a native pairing window and print the PIN. `--ttl <s>` how long the window stays open, `--expires-in <s>` how long the device's access lasts, `--preset <full\|controller\|view>`, `--fingerprint <fp>` to bind the window to **one** device. |
| `pair disarm` | Close it. |
| `pending` | Devices knocking, with their claimed name and fingerprint tail. |
| `approve <ID>` | Admit one, by id. `--name`, `--preset`, `--expires-in` as above. |
| `deny <ID>` | Refuse one. |
| `pin <PIN>` | Submit the PIN a Moonlight/GameStream client is showing. |
| `clients` | Paired devices on both planes, labelled. |
| `rename <FP> <NAME>` | Name a device. |
| `access <FP> <PRESET>` | `full`, `controller` or `view` — see [Access levels](/docs/access-levels). The full grant matrix stays in the console. |
| `unpair <FP>` | Remove one device. `unpair --all` removes every device on both planes (asks first; needs `--yes` with `--json`). |
| `stop-session` | Stop the active session. |
| `end-game` | End the launched game. |
| `watch` | Stream host events as line-JSON on stdout, one object per line. `--kinds stream.*,pairing.pending` filters; `--since <seq>` resumes. |
Add `--json` to any verb for machine-readable output: `{"v":1,"data":…}` on success,
`{"v":1,"error":{"code":…,"message":…}}` on failure, both on stdout. That envelope is the contract —
the tables above are for humans and are not stable.
### Exit codes
| Code | Meaning |
|---|---|
| `0` | Success. |
| `1` | The host refused the request (the message carries its reason). |
| `2` | Usage error. |
| `3` | No host reachable — not running, or never run on this machine. |
| `4` | **Certificate pin mismatch.** Kept distinct on purpose: a script that treats it as "host down" and retries would be retrying into whatever is answering on that port. |
### Watching events
`watch` holds one long-lived connection and reconnects by itself, which makes it the right shape for
a status widget or a script:
```sh
punktfunk-host ctl watch --kinds pairing.pending,stream.'*' | while read -r line; do
echo "$line"
done
```
Two synthetic lines are ours rather than the host's:
- `{"v":1,"kind":"ctl.resync"}` — the stream fell behind the host's catch-up ring, so anything you
believe about pending devices or live sessions may be stale. Re-run `ctl status` / `ctl pending`
instead of trusting your incremental state.
- `{"v":1,"kind":"ctl.disconnected","data":{"error":…}}` — the connection dropped; a reconnect is
already in progress.
The host caps concurrent event streams (the console holds one); past the cap you get a `503` with
the host's own message and exit 1.
### How it authenticates
`ctl` reads two files from the host's config directory (`~/.config/punktfunk`, mode 0700) and
nothing else:
- `mgmt-token` — the operator token the host mints for itself on first start, the same one the web
console uses. `ctl` **consumes** it and never creates one: a missing token is an error, not a
prompt.
- `native-cert.pem` (or `cert.pem` on older hosts) — the host's own certificate, which `ctl` pins
**before** sending the token. If the process answering on the management port presents anything
else, the connection fails during the TLS handshake and no credential is ever transmitted —
that is exit code 4.
There is deliberately **no `--token` flag and no token environment variable**. A credential on a
command line or in an environment is readable by other processes on the box through
`/proc/<pid>/cmdline` and `/proc/<pid>/environ`, which is exactly what the 0700 config directory
exists to prevent. The consequence worth knowing: a host started with `--mgmt-token` (or
`PUNKTFUNK_MGMT_TOKEN`) and no persisted token file cannot be reached by `ctl`. Every packaged
install persists one, so this only affects hand-run dev hosts.
Everything runs over loopback, because the management API honours the admin surface from loopback
peers only — `ctl` adds no listener and no new way in.
## `punktfunk1-host`
A standalone native-only host, mainly for testing the `punktfunk/1` path without the GameStream server
+1
View File
@@ -9,6 +9,7 @@
"debian",
"fedora",
"arch",
"omarchy",
"bazzite",
"steamos-host",
"nixos",
+220
View File
@@ -0,0 +1,220 @@
---
title: Omarchy
description: Install the Punktfunk host on Omarchy 4.x — one setup command wires up the firewall, autostart, the app menu and toasts the Omarchy way.
---
**Omarchy 4.x ("Quattro")** is Arch underneath, so the packages, the repo and `pacman` all work
exactly as on the [Arch page](/docs/arch). What is different is everything *around* the install:
ufw is on by default, autostart is a user unit tied to the uwsm session, apps belong in the
Omarchy menu, and updates go through `omarchy update`. One command handles all of it.
<Callout type="info">
Omarchy already ships Sunshine as an installable service and preinstalls moonlight-qt. Punktfunk
**coexists** with Sunshine on its own ports — see [Sunshine on the same
box](#sunshine-on-the-same-box) before you turn anything on.
</Callout>
## 1. GPU driver
Same as Arch:
- **NVIDIA:** `sudo pacman -S --needed nvidia-utils`
- **AMD / Intel:** the Mesa stack you already have (`vulkan-radeon` / `vulkan-intel`,
`libva-mesa-driver` / `intel-media-driver`).
## 2. Install the host
<Install platform="omarchy" />
`punktfunk-web` is the browser console and is optional but recommended — it is where the deep
settings live. (`punktfunk-scripting` adds the plugin runner that fills your game library.)
<Callout type="warn">
**Not `pacman -Syu <package>` — that is the Arch line and Omarchy refuses it.** Omarchy installs a
pacman hook that aborts any transaction carrying both `-S` and `-u`, so it can funnel system
upgrades through `omarchy update`; the Arch one-liner dies with *"Woah partner…"* and installs
nothing. `-Sy` refreshes the databases without a system upgrade and is not blocked, and `-S` then
installs. Everything else about the repo is identical to Arch.
</Callout>
<Callout type="warn">
Omarchy pins Arch packages to a **frozen snapshot mirror**, so its `ffmpeg` may sit behind rolling
Arch. If `pacman` reports an unsatisfiable `libav*.so` dependency, the package is newer than your
snapshot — wait for the next `omarchy update`, or [build from
source](/docs/build-from-source).
</Callout>
## 3. Wire it into Omarchy
```sh
punktfunk-omarchy setup
```
That one command, each step idempotent and each one reversible:
| Step | What it does |
| --- | --- |
| **Groups** | offers to add you to `input` (virtual gamepads) and `punktfunk` (the virtual Steam Deck pad). Both apply at your **next login**. |
| **Autostart** | enables `punktfunk-host` as a user service so it comes up at login. It also installs the `graphical-session.target` binding — see [Autostart and your session](#autostart-and-your-session) for when that does more than nothing. |
| **Firewall** | adds ufw rules scoped to your local networks (and `tailscale0` if you have it), each tagged `punktfunk-omarchy` so `remove` finds them again. |
| **App menu** | installs "Punktfunk Console" as an Omarchy webapp — it appears in Apps (`Super`+`Space`). |
| **Toasts** *(optional)* | pairing requests and stream start/stop as Omarchy notifications, with **Approve** / **Deny** buttons on the pairing one. |
| **Idle** *(optional)* | keeps the screen awake for the length of a stream and restores your own setting afterwards. |
| **Theme** *(optional)* | a `~/.config/omarchy/themed/` template so the console follows `omarchy-theme-set`. |
Check it any time with `punktfunk-omarchy status`, and undo all of it with `punktfunk-omarchy
remove` (your pairings and `~/.config/punktfunk` are left alone).
## 4. Pair
Open **Punktfunk Console** from Apps and pair your first device — or stay in the terminal:
```sh
punktfunk-host ctl pair arm # opens a pairing window and prints the PIN
punktfunk-host ctl pending # devices knocking, with names and fingerprint tails
punktfunk-host ctl approve 1
```
See [the ctl reference](/docs/host-cli#ctl) for the full verb list.
## Autostart and your session
Omarchy offers **two** Hyprland entries at the login screen, and they differ in a way that matters
here:
| Session | What it runs | `graphical-session.target` |
| --- | --- | --- |
| **Hyprland** | `start-hyprland` | never starts |
| **Hyprland (uwsm-managed)** | `uwsm start … Hyprland` | starts with the session |
`punktfunk-omarchy setup` enables the host as a user service, so **it comes up at login either
way**. What the second session additionally buys is that the host *restarts* when the session does,
instead of surviving a logout holding a Wayland socket that died with the old compositor.
On the plain **Hyprland** session that target never starts — which is also why Omarchy's own
`omarchy-crash-watch` and `omarchy-sleep-lock` units sit enabled-but-dead there. `punktfunk-omarchy
status` tells you which situation you are in.
It is rarely worth switching sessions just for this: on Hyprland the host re-derives the live
compositor on every connect, so the stale-socket failure the binding guards against on KDE and
GNOME does not really arise. If you want it anyway, pick *Hyprland (uwsm-managed)* at the login
screen — nothing about Punktfunk needs changing.
## Firewall and the video data plane
`punktfunk-omarchy setup` opens the control ports the Omarchy way — scoped to RFC1918 ranges rather
than to the whole world:
```
ufw allow from 192.168.0.0/16 to any app punktfunk-native comment "punktfunk-omarchy"
```
**The video data plane has no rule, and that is expected.** It binds an ephemeral UDP port chosen
per session, so there is nothing fixed to open. Under ufw's default deny-incoming the client's
first packet is dropped and the session falls back to a blind send: the host's own outbound packet
creates the conntrack entry, and the return path rides it. On a LAN — which is the whole point of
hosting here — this works, and it is why `punched=false` in the logs is normal rather than a fault.
If you want strict control instead, pin the port and open exactly it:
```sh
echo 'PUNKTFUNK_DATA_PORT=9778' >> ~/.config/punktfunk/host.env
sudo ufw allow from 192.168.0.0/16 to any port 9778 proto udp comment "punktfunk-omarchy"
systemctl --user restart punktfunk-host
```
The cost is one concurrent session on that port; extra sessions fall back to ephemeral ports again.
## Updating
Punktfunk updates with everything else:
```sh
omarchy update
```
Our repo rides that transaction once it is configured, and it snapshots with snapper first. The web
console therefore shows updates as **notify-only** on Omarchy and points at this command rather than
offering a one-click apply: a bare `pacman -Syu` is blocked by Omarchy's own guard, and going around
it would skip the snapshot your rollback depends on.
A snapper rollback reverts `/usr` — the binaries — while `~/.config/punktfunk` and your pairings
live in `/home` and persist. Nothing needs re-pairing after a rollback.
## Sunshine on the same box
`omarchy install service sunshine` and Punktfunk can run together, with one caveat:
- **The native plane coexists.** Ports 9777 (QUIC), 47990 (management API) and 47992 (console) are
ours alone, so the Punktfunk apps and the console work with Sunshine running and untouched.
- **The GameStream plane collides.** Moonlight compatibility uses 47984/47989/48010 and
4799848010 — exactly Sunshine's ports. Ours are **off by default**; leave them off unless you
are migrating, and only one host can bind them.
To migrate: `omarchy remove service sunshine`, then re-run `punktfunk-omarchy setup` and answer yes
to GameStream if you still want stock Moonlight clients. More in [Switching from
Sunshine](/docs/switching-from-sunshine).
## Screen sharing keeps working
Omarchy ships its own screen-share picker (`hyprland-preview-share-picker`), the one every browser
share on the box goes through. Punktfunk needs that same setting to select a headless output
without a dialog, so while a stream runs it borrows the setting — and **defers to your picker**
whenever no Punktfunk selection is pending. Your browser shares behave exactly as before, during a
session and after it, and the original value is written back when the last stream ends. If a host
is killed mid-session, `punktfunk-omarchy remove` puts it back too.
## Keyboard shortcut (optional)
Punktfunk claims no chord — Omarchy occupies most of `Super`+*. Add your own to
`~/.config/hypr/bindings.lua`:
```lua
o.bind("SUPER + SHIFT + P", "Punktfunk", { webapp = "https://localhost:47992" })
```
## Wake-on-LAN and disk encryption
Omarchy enables **full-disk encryption by default**. [Wake-on-LAN](/docs/wake-on-lan) still wakes
the machine, but a cold boot stops at the LUKS passphrase prompt — nothing can stream until someone
types it. Wake from **suspend** is unaffected, so suspend rather than shut down a box you want to
wake into.
## Troubleshooting
Run `punktfunk-omarchy status` first — it checks the units, the ufw rules, your groups, the portal
and Hyprland's permission mode in one pass.
**Black frames, or input that does nothing.** Check whether Hyprland's permission enforcement is on:
```sh
hyprctl -j getoption ecosystem:enforce_permissions
```
If it is, the compositor is *silently* denying screencopy and virtual input — there is no error, only
black frames and dropped input. Grant the host in your Hyprland config:
```lua
o.permission("/usr/bin/punktfunk-host", "screencopy", "allow")
o.permission("/usr/bin/punktfunk-host", "plugin", "allow")
```
**No picture at all.** `xdg-desktop-portal-hyprland` must be running — it is what capture goes
through. `systemctl --user status xdg-desktop-portal-hyprland`.
**The host is not running after a reboot.** `punktfunk-omarchy status` will say whether it is bound
to the desktop session; if not, re-run `punktfunk-omarchy setup`.
More in [Troubleshooting](/docs/troubleshooting) and on the [Hyprland page](/docs/hyprland), which
covers the compositor-level details this page assumes.
## Uninstall
```sh
punktfunk-omarchy remove # the Omarchy wiring
sudo pacman -R punktfunk-host punktfunk-web # the packages
```
`remove` reverses every step of `setup` — units, drop-in, ufw rules, the webapp, the hooks, the
theme template — and restores the screen-share picker. Your config and pairings survive both; see
[Uninstall](/docs/uninstall) to clear those too.
+10
View File
@@ -156,6 +156,16 @@ Minimum compositor versions (newer is fine):
- **KWin ≥ 6.5.6** ([KDE Plasma](/docs/kde)) — headless virtual outputs.
- **GNOME ≥ 48** ([Mutter](/docs/gnome)) — virtual-monitor screen-cast.
- **Hyprland — no version floor** ([Hyprland](/docs/hyprland)): the `hyprctl` path is
version-independent, and both config eras (hyprlang and the newer Lua one) are handled. Contracts
are verified against **0.55.4** and **0.56.2**. What Hyprland *does* need is
**`xdg-desktop-portal-hyprland`** — capture goes through it, and Hyprland does not pull it in.
On **0.49+**, if you have turned `ecosystem.enforce_permissions` on (off by default), grant the
host screencopy and virtual input: a denial is *silent black frames and dropped input*, never an
error.
- **Omarchy ≥ 4.0** ([Omarchy](/docs/omarchy)) — not a compositor floor but an integration one: 4.0
replaced the shell, the menu format and the Hyprland config language at once, so every point
`punktfunk-omarchy` touches is different below it.
- **gamescope ≥ 3.16.22** ([Bazzite/Steam](/docs/gamescope)) — below this, headless capture
deadlocks against PipeWire ≥ 1.6.
- **gamescope ≥ 3.16.23** for the Steam overlay (Shift+Tab / Quick Access Menu) to reach the stream
+6
View File
@@ -628,6 +628,12 @@ whose caveat *is* "nobody has run this on real hardware" — a wrong ✅ is wors
well-trodden path, and there is no probe that would catch it failing. One spawn-and-capture run on
an NVIDIA box settles it.
- **Touch input from a Windows client.** Same shared code as Linux, no on-glass run.
- **The whole Omarchy integration on an Omarchy box.** `punktfunk-omarchy setup`, the LAN-scoped
ufw rules, the webapp menu entry, the notification and idle hooks, and the screen-share picker
hand-back are all written against Omarchy 4.0.1's documented seams and unit-tested where they
parse or generate a file — but none of it has been run on Omarchy. Nothing about it is enabled
until an operator runs that command, so a plain Arch box is unaffected either way. One install →
setup → pair → stream run on a 4.x box settles it.
- **The `pf-webos` LG TV client.** A community project in another repository. Its codecs, HDR
behaviour and feature set cannot be established from here.
- **Everything client-side about Moonlight.** Wake-on-LAN, overlays, updates and which extensions
+17
View File
@@ -98,6 +98,23 @@
"sudo pacman -Syu punktfunk-host punktfunk-web punktfunk-scripting"
]
},
{
"$comment": "Omarchy IS Arch for delivery — same signed repo, same packages — but NOT the same install command. Omarchy ships a libalpm PreTransaction hook (00-omarchy-update-guard) that ABORTS any transaction whose pacman invocation carries both -S and -u, so Arch's `pacman -Syu <pkgs>` dies with 'Woah partner...' and installs nothing (measured on 4.0.1, 2026-08-28). `-Sy` refreshes without a sysupgrade and is not blocked, and `-S` then installs. Everything Omarchy-specific is the setup line after them.",
"id": "omarchy",
"name": "Omarchy",
"installs": "host",
"packageManager": "pacman",
"docs": "/docs/omarchy",
"repo": "https://git.unom.io/api/packages/unom/arch",
"install": [
"curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key | sudo pacman-key --add -",
"sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69",
"grep -q '^\\[punktfunk\\]' /etc/pacman.conf || printf '\\n[punktfunk]\\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\\n' | sudo tee -a /etc/pacman.conf >/dev/null",
"sudo pacman -Sy",
"sudo pacman -S punktfunk-host punktfunk-web punktfunk-scripting",
"punktfunk-omarchy setup"
]
},
{
"id": "fedora",
"name": "Fedora 43+",
+27
View File
@@ -197,6 +197,11 @@ package_punktfunk-host() {
'kwin: stream a KDE Plasma desktop (kwin VirtualDisplay backend)'
'mutter: stream a GNOME desktop (Mutter RecordVirtual backend)'
'sway: stream a wlroots desktop (Sway VirtualDisplay backend)'
# Owed independently of Omarchy: the Hyprland backend has been first-class for
# releases (headless outputs, xdph capture, exclusive topology) with no dependency
# naming it, so a Hyprland box could install the host and find capture unavailable.
'hyprland: stream a Hyprland desktop (headless-output backend) — Omarchy ships this'
'xdg-desktop-portal-hyprland: ScreenCast portal for the Hyprland backend (REQUIRED to capture on Hyprland)'
'xdg-desktop-portal-kde: portal for the headless KDE session helper'
'xdg-desktop-portal-wlr: portal for the headless Sway session helper'
'punktfunk-web: browser management console (device pairing + status)'
@@ -311,6 +316,28 @@ package_punktfunk-host() {
install -Dm0644 "$R/packaging/bazzite/gamescope-headless-session" \
"$pkgdir/etc/gamescope-session-plus/sessions.d/steam"
install -Dm0644 "$R/api/openapi.json" "$pkgdir/usr/share/punktfunk/openapi.json"
# The session drop-in as a TEMPLATE the Omarchy setup script installs into the user's unit dir.
# It is already shipped to /usr/lib/systemd/user/... as a documented no-op elsewhere; here it is
# a file `punktfunk-omarchy setup` can copy, because on Omarchy graphical-session.target really
# starts and binding to it is what makes the host restart with the session.
install -Dm0644 "$R/scripts/punktfunk-host-desktop-session.conf" \
"$pkgdir/usr/share/punktfunk/punktfunk-host-desktop-session.conf"
# Omarchy integration (design/omarchy-integration.md). The script is INSTALLED, never run: a
# package that opened firewall ports or edited a user's config on install would be doing both
# behind the operator's back. `punktfunk-omarchy setup` is the consent step.
install -Dm0755 "$R/packaging/linux/omarchy/punktfunk-omarchy" \
"$pkgdir/usr/bin/punktfunk-omarchy"
# The app mark, so a launcher entry has something to draw. Only the CLIENT package shipped it,
# which is why the Omarchy webapp entry came out with a blank `Icon=` on a host-only box — the
# name resolved to nothing. Scalable, so every launcher size is covered by one file.
install -Dm0644 "$R/packaging/linux/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg" \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg"
for h in pairing-pending stream-started stream-stopped idle-guard; do
install -Dm0755 "$R/packaging/linux/omarchy/hooks/$h" \
"$pkgdir/usr/share/punktfunk/omarchy/hooks/$h"
done
install -Dm0644 "$R/packaging/linux/omarchy/themed/punktfunk.json.tpl" \
"$pkgdir/usr/share/punktfunk/omarchy/themed/punktfunk.json.tpl"
# Firewall openers — NOT auto-enabled (an Arch package never touches the admin's running firewall).
# Stock Arch ships no firewall; CachyOS ships ufw; some spins (EndeavourOS) enable firewalld — so we
# install BOTH a ufw application profile and firewalld service definitions, and the one for whatever
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
#
# punktfunk hook: hold Omarchy's idle timer off for the length of a stream — and put the user's
# own setting back afterwards (design D9).
#
# idle-guard on # a stream started: remember the current state, then stay awake
# idle-guard off # the last stream ended: restore what was remembered
#
# Why this is needed at all: remote input normally resets the compositor's idle timer, so an
# ordinary game session never blanks. The acute case is an INPUT-QUIET stream — a film, a long
# cutscene, a lobby — where nothing moves the pointer for half an hour and the box locks in the
# middle of it.
#
# 🛑 The rule that makes this safe to install: **restore, never assume.** An operator who set
# `stay-awake` themselves must still have it after a session ends, so `on` snapshots the state and
# `off` puts that snapshot back rather than blindly running `allow-idle`. The snapshot lives in
# $XDG_RUNTIME_DIR, so a reboot cannot leave a stale one behind.
#
# ⚠ `omarchy-toggle-idle status` prints **JSON**, not a keyword:
# {"enabled":true,"class":"enabled","tooltip":"Allow Idle Lock & Screensaver"} # stay-awake ON
# {"enabled":false,"class":"disabled","tooltip":"Stay Awake"} # stay-awake OFF
# Note the trap in that payload: the tooltip names the action the button WOULD take, so the string
# "Stay Awake" appears exactly when stay-awake is OFF. Matching on the tooltip — or on the words
# `stay-awake`/`allow-idle`, which never appear at all — gets it backwards or silently never
# matches. `"enabled":true` is the only field that means what it says. (Measured on Omarchy 4.0.1.)
#
# What this does NOT do: suppress an explicit lock. `omarchy.lock` invoked by the user (or by
# suspend) still locks — only the idle countdown is held off, and only while streaming.
set -euo pipefail
STATE="${XDG_RUNTIME_DIR:-/tmp}/punktfunk-omarchy-idle-prior"
command -v omarchy-toggle-idle >/dev/null || exit 0
case "${1:-}" in
on)
# Refcount-free by design: `stream.started` firing twice (two clients) just re-reads a state
# that is already `stay-awake`, and the snapshot is written only if there is not one already —
# so the SECOND stream cannot overwrite the user's real setting with our own.
if [[ ! -f "$STATE" ]]; then
case "$(omarchy-toggle-idle status 2>/dev/null)" in
*'"enabled":true'*) echo stay-awake > "$STATE" ;;
*'"enabled":false'*) echo allow-idle > "$STATE" ;;
# An output shape we do not recognise (a future Omarchy). Record that we do not know,
# and `off` will leave the setting alone rather than guess at it.
*) echo unknown > "$STATE" ;;
esac
fi
omarchy-toggle-idle stay-awake >/dev/null 2>&1 || true
;;
off)
prior="$(cat "$STATE" 2>/dev/null || echo unknown)"
rm -f "$STATE"
case "$prior" in
# They already wanted the box awake — leave it that way.
stay-awake) : ;;
allow-idle) omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true ;;
# No snapshot (a host that crashed and restarted), or a status we could not read: do NOT
# guess. Leaving the box awake is recoverable in one click; forcing allow-idle on someone
# who had deliberately set stay-awake is not.
*) : ;;
esac
;;
*)
echo "usage: idle-guard on|off" >&2
exit 2
;;
esac
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
#
# punktfunk hook: a device is asking to pair → an Omarchy toast with an Approve action.
#
# Wired by `punktfunk-omarchy setup` as a `pairing.pending` entry in ~/.config/punktfunk/hooks.json.
# The host runs it detached with the event JSON on stdin and every scalar leaf flattened into
# `PF_EVENT_*` (see automation.md), so nothing here has to parse JSON.
#
# The toast shows the claimed NAME and the fingerprint TAIL together, and always both: the name is
# what the device says it is and can be anything, the tail is what it cannot forge. Approving is a
# deliberate act — this notification never approves anything by itself, it only takes you to where
# you can.
set -euo pipefail
name="${PF_EVENT_DEVICE_NAME:-${PF_EVENT_CLIENT_NAME:-a device}}"
fp="${PF_EVENT_DEVICE_FINGERPRINT:-${PF_EVENT_FINGERPRINT:-}}"
id="${PF_EVENT_DEVICE_ID:-${PF_EVENT_ID:-}}"
tail="${fp: -10}"
body="\"$name\" wants to pair"
# `|| true`: under `set -e` a false `[[ … ]]` used as a STATEMENT exits the script — and a hook
# that exits before its own notification is a pairing request nobody ever sees.
[[ -n "$tail" ]] && body="$body · …$tail" || true
command -v omarchy-notification-send >/dev/null || exit 0
# `--exec` gives the toast a button. It approves by ID, never "the newest request": two devices
# knocking at once is exactly when a "newest" shortcut admits the wrong one.
if [[ -n "$id" ]] && command -v punktfunk-host >/dev/null; then
omarchy-notification-send \
--urgency=critical \
--icon=network-wireless \
"Punktfunk pairing request" "$body" \
--exec "Approve:punktfunk-host ctl approve $id" \
--exec "Deny:punktfunk-host ctl deny $id"
else
omarchy-notification-send \
--urgency=critical \
--icon=network-wireless \
"Punktfunk pairing request" "$body — approve it in the console"
fi
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
#
# punktfunk hook: a stream started → a low-urgency Omarchy toast saying who and at what.
# Wired as a `stream.started` entry in ~/.config/punktfunk/hooks.json by `punktfunk-omarchy setup`.
set -euo pipefail
client="${PF_EVENT_STREAM_CLIENT:-a client}"
mode="${PF_EVENT_STREAM_MODE:-}"
app="${PF_EVENT_STREAM_APP:-}"
body="$client connected"
# `|| true`: a false `[[ … ]]` as a statement exits a `set -e` script, and a hook that exits early
# is a toast that never appears.
[[ -n "$mode" ]] && body="$body · $mode" || true
[[ "${PF_EVENT_STREAM_HDR:-false}" == "true" ]] && body="$body HDR" || true
[[ -n "$app" ]] && body="$body · $app" || true
command -v omarchy-notification-send >/dev/null || exit 0
omarchy-notification-send --urgency=low --icon=video-display "Punktfunk streaming" "$body"
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
#
# punktfunk hook: a stream ended → a low-urgency Omarchy toast.
# Wired as a `stream.stopped` entry in ~/.config/punktfunk/hooks.json by `punktfunk-omarchy setup`.
set -euo pipefail
client="${PF_EVENT_STREAM_CLIENT:-a client}"
command -v omarchy-notification-send >/dev/null || exit 0
omarchy-notification-send --urgency=low --icon=video-display "Punktfunk" "$client disconnected"
+619
View File
@@ -0,0 +1,619 @@
#!/usr/bin/env bash
#
# punktfunk-omarchy — wire a punktfunk host into an Omarchy 4.x box, the Omarchy way.
#
# punktfunk-omarchy setup enable, open the firewall, add the console to the app menu
# punktfunk-omarchy remove reverse every one of those, leaving nothing behind
# punktfunk-omarchy status what is on, what is not, and what to do next
#
# Modelled on `omarchy-install-service-sunshine`, which is the shape an Omarchy user already knows:
# LAN-scoped ufw rules tagged with a comment, a user systemd unit, a webapp entry in the menu. The
# package installs this script but never RUNS it — a package that opened firewall ports or edited a
# user's config on install would be doing both behind the operator's back, and consent here is the
# whole point. Every step is idempotent, and `remove` reverses each one exactly.
#
# Deliberately NOT a `punktfunk-host` subcommand: everything here is shell-level system wiring
# (systemctl, ufw, omarchy's own tools) that the host binary has no business knowing about, and a
# user can read and audit a shell script before running it as themselves.
set -euo pipefail
readonly TAG="punktfunk-omarchy" # ufw comment tag: how `remove` finds our rules
readonly WEBAPP="Punktfunk Console"
readonly HOOKS_SRC="/usr/share/punktfunk/omarchy"
readonly HOST_ENV="${XDG_CONFIG_HOME:-$HOME/.config}/punktfunk/host.env"
readonly DROPIN_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/punktfunk-host.service.d"
readonly DROPIN="$DROPIN_DIR/desktop-session.conf"
# ── output ─────────────────────────────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then B=$'\e[1m'; G=$'\e[32m'; Y=$'\e[33m'; R=$'\e[31m'; N=$'\e[0m'
else B=""; G=""; Y=""; R=""; N=""; fi
say() { printf '%s\n' "$*"; }
step() { printf '%s==>%s %s\n' "$B" "$N" "$*"; }
ok() { printf ' %s✓%s %s\n' "$G" "$N" "$*"; }
warn() { printf ' %s!%s %s\n' "$Y" "$N" "$*"; }
bad() { printf ' %s✗%s %s\n' "$R" "$N" "$*"; }
die() { printf '%serror:%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
ask() { # ask "question" -> 0 on yes. Defaults to NO, and to no in a non-interactive shell.
[[ -t 0 ]] || return 1
local reply
read -r -p " $1 [y/N] " reply
[[ "$reply" == [yY] || "$reply" == [yY][eE][sS] ]]
}
have() { command -v "$1" >/dev/null 2>&1; }
# ── guards ─────────────────────────────────────────────────────────────────────────────────────
# `ID=omarchy` in os-release, which Omarchy rewrites on every `omarchy-settings` upgrade — the same
# fact the host itself detects on. Everything below assumes Omarchy's own tools and layout, so on
# any other distro this script would be quietly wrong rather than merely unnecessary.
require_omarchy() {
local id=""
[[ -r /etc/os-release ]] && id="$(. /etc/os-release 2>/dev/null; printf '%s' "${ID:-}")"
[[ "$id" == "omarchy" ]] || die "this is not an Omarchy box (os-release ID='${id:-unknown}').
On plain Arch, install the package and follow docs/arch — you already have everything this
script would set up, minus the Omarchy-specific parts."
[[ $EUID -ne 0 ]] || die "run this as your normal user, not root — it enables USER services and
edits YOUR config. It will ask for sudo where it genuinely needs it (ufw, group membership)."
}
# ── ufw (D4) ───────────────────────────────────────────────────────────────────────────────────
#
# The Omarchy idiom is not "open a port to the world" but "open it to the networks you are actually
# on", tagged so it can be found again — exactly what their Sunshine installer does. We reuse the
# app profiles the package already ships (/etc/ufw/applications.d/punktfunk), so the port list lives
# in ONE place and this script never repeats it.
lan_sources() { # the CIDRs (and tailscale) a rule should accept from
printf '%s\n' 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12
ip link show tailscale0 >/dev/null 2>&1 && printf '%s\n' tailscale0
}
ufw_rule() { # ufw_rule <profile> <source> — idempotent (ufw dedupes identical rules)
local profile="$1" src="$2"
if [[ "$src" == tailscale0 ]]; then
sudo ufw allow in on tailscale0 to any app "$profile" comment "$TAG" >/dev/null
else
sudo ufw allow from "$src" to any app "$profile" comment "$TAG" >/dev/null
fi
}
ufw_unrule() {
local profile="$1" src="$2"
if [[ "$src" == tailscale0 ]]; then
sudo ufw --force delete allow in on tailscale0 to any app "$profile" >/dev/null 2>&1 || true
else
sudo ufw --force delete allow from "$src" to any app "$profile" >/dev/null 2>&1 || true
fi
}
open_firewall() {
have ufw || { warn "ufw is not installed — nothing to open"; return 0; }
[[ -r /etc/ufw/applications.d/punktfunk ]] || {
warn "the punktfunk ufw profiles are missing (is punktfunk-host installed from the package?)"
return 0
}
local profiles=(punktfunk-native)
# The web console only if it is actually installed — opening a port nothing listens on is noise.
# (`|| true` throughout this script wherever a bare `[[ … ]] && …` is a STATEMENT: under `set -e`
# a false test is a non-zero exit status, and the script would abort mid-setup with no message.)
[[ -f /usr/lib/systemd/user/punktfunk-web.service ]] && profiles+=(punktfunk-web) || true
# GameStream stays CLOSED unless the operator turned the plane on. Its ports are exactly the ones
# `omarchy install service sunshine` claims, so opening them by default invites a bind conflict
# and a confusing "which host answered?" on a box that has both.
if grep -qs '^PUNKTFUNK_GAMESTREAM=1' "$HOST_ENV"; then
profiles+=(punktfunk-gamestream)
fi
local src
for p in "${profiles[@]}"; do
while read -r src; do ufw_rule "$p" "$src"; done < <(lan_sources)
ok "ufw: $p opened to local networks (tagged \"$TAG\")"
done
sudo ufw reload >/dev/null 2>&1 || true
}
close_firewall() {
have ufw || return 0
local src
for p in punktfunk-native punktfunk-web punktfunk-gamestream; do
while read -r src; do ufw_unrule "$p" "$src"; done < <(lan_sources)
done
sudo ufw reload >/dev/null 2>&1 || true
ok "ufw: punktfunk rules removed"
}
# ── setup ──────────────────────────────────────────────────────────────────────────────────────
setup_groups() {
step "Groups"
local relogin=0
for g in input punktfunk; do
getent group "$g" >/dev/null 2>&1 || { warn "group '$g' does not exist — skipping"; continue; }
if id -nG "$USER" | tr ' ' '\n' | grep -qx "$g"; then
ok "already in '$g'"
elif ask "add $USER to the '$g' group?"; then
if sudo usermod -aG "$g" "$USER"; then
ok "added to '$g'"; relogin=1
else
bad "could not add you to '$g' — add it by hand: sudo usermod -aG $g $USER"
fi
else
warn "skipped '$g'"
fi
done
# Group membership is applied at LOGIN. The host reads its groups when it starts, so a session
# that has not been restarted still runs with the old set — this is the single most common
# "I already added myself!" support state there is.
[[ $relogin -eq 1 ]] && warn "log out and back in before streaming — new groups apply at login"
return 0
}
# Omarchy ships TWO session entries and they behave differently for us:
#
# hyprland-uwsm.desktop "Hyprland (uwsm-managed)" → uwsm starts graphical-session.target
# hyprland.desktop "Hyprland" → start-hyprland, target NEVER starts
#
# On the plain one, `graphical-session.target` stays inactive — measured on 4.0.1, where Omarchy's
# OWN units that want it (omarchy-crash-watch, omarchy-sleep-lock) are `enabled` and `dead` too.
# So the drop-in cannot be described as "the host restarts with your session" without checking.
session_is_systemd_managed() {
systemctl --user is-active --quiet graphical-session.target
}
setup_units() {
step "Autostart"
# Install the drop-in either way: it is additive (the base unit keeps WantedBy=default.target, so
# the host still comes up at login), it is inert while the target never starts, and it becomes
# correct by itself if the operator later picks the uwsm session. What changes is only what we
# CLAIM, because "it restarts with your session" is the kind of promise that gets discovered to be
# false at the worst moment.
mkdir -p "$DROPIN_DIR"
if [[ -r /usr/share/punktfunk/punktfunk-host-desktop-session.conf ]]; then
install -Dm0644 /usr/share/punktfunk/punktfunk-host-desktop-session.conf "$DROPIN"
if session_is_systemd_managed; then
ok "bound punktfunk-host to graphical-session.target — it restarts with your session"
else
ok "punktfunk-host will start at login"
warn "this session is not systemd-managed, so the host will NOT restart when the session does"
say " Your session is \"Hyprland\"; picking \"Hyprland (uwsm-managed)\" at the login screen"
say " starts graphical-session.target and gets you that. It is rarely worth it here: the"
say " host re-derives the live compositor on every connect, which is the failure this"
say " binding guards against on KDE and GNOME."
fi
else
warn "the session drop-in template is missing from the package — skipping"
fi
systemctl --user daemon-reload
# `enable --now` can fail for reasons worth SEEING (a masked unit, a missing binary, a user
# manager with no session bus). Under `set -e` an unguarded `&&` would abort the whole setup here
# with no message at all — which is the one outcome that leaves an operator with no idea what ran.
if systemctl --user enable --now punktfunk-host.service; then
ok "punktfunk-host enabled and started"
else
bad "punktfunk-host did not start — see: systemctl --user status punktfunk-host"
fi
if [[ -f /usr/lib/systemd/user/punktfunk-web.service ]]; then
if systemctl --user enable --now punktfunk-web.service; then
ok "punktfunk-web enabled and started"
else
bad "punktfunk-web did not start — see: systemctl --user status punktfunk-web"
fi
fi
}
setup_webapp() {
step "App menu"
have omarchy-webapp-install || { warn "omarchy-webapp-install not found — skipping"; return 0; }
# Sunshine's own installer takes the same --ignore-certificate-errors: the console presents the
# host's self-signed certificate, which no browser will ever accept on a LAN name.
#
# The icon is an ICON NAME, not a path — the desktop spec resolves it through the icon theme, and
# the scalable `io.unom.Punktfunk` the host package ships scales to whatever size the launcher
# asks for. Passing a path to a file that did not exist is how the first version produced an
# entry with a blank `Icon=` and no picture in the launcher.
# ⚠ `omarchy-webapp-install` takes an icon URL and derives a name with `${ref%.*}` + slugify, so a
# DOTTED theme name comes out mangled — `io.unom.Punktfunk` became `Icon=io-unom`, which resolves
# to nothing. Their tool is built for downloading a site's favicon; ours is already installed by
# the package. So let it create the entry, then set the two lines we own (below), which is what
# we already do for Exec.
local icon=io.unom.Punktfunk
find /usr/share/icons -name "${icon}.*" -print -quit 2>/dev/null | grep -q . \
|| icon=punktfunk-tray # older package: the tray mark is the only one installed
if omarchy-webapp-install "$WEBAPP" "https://localhost:47992" "$icon" \
--ignore-certificate-errors 2>/dev/null; then
ok "\"$WEBAPP\" added to Apps (Super+Space)"
# Point the entry at a one-shot ticket instead of the bare URL, so opening the console from the
# launcher lands logged in. `ctl console-url` signs it with the management token, which only
# this uid can read — a LAN visitor still gets the login page.
local entry
entry=$(grep -rl "^Name=$WEBAPP$" "$HOME/.local/share/applications" 2>/dev/null | head -1)
if [[ -n "$entry" ]] && have punktfunk-host; then
sed -i "s|^Exec=.*|Exec=sh -c 'omarchy-launch-webapp \"\$(punktfunk-host ctl console-url)\"'|" "$entry"
sed -i "s|^Icon=.*|Icon=$icon|" "$entry"
grep -q '^Icon=' "$entry" || printf 'Icon=%s\n' "$icon" >> "$entry"
ok "the console entry opens already logged in, with the Punktfunk mark"
fi
else
warn "could not add the webapp — add it by hand from the Omarchy menu if you want it"
fi
setup_menu
}
# ── the Omarchy menu (Super+Space) ─────────────────────────────────────────────────────────────
#
# The rows we add, as JSONC lines. Dotted ids define the tree, so `punktfunk` is a root submenu and
# `punktfunk.*` are its children. `when` hides a row the box cannot honour, which is what keeps the
# menu honest on a machine where the host is not running.
# A function, not a `readonly` computed at load time: the rest of this script resolves config paths
# where they are USED (see `write_hooks`), and a load-time constant silently ignores an
# `XDG_CONFIG_HOME` set afterwards — which is exactly how the self-check ended up editing the real
# config of the machine running it instead of its own scratch directory.
menu_file() { printf '%s/omarchy/extensions/omarchy-menu.jsonc' "${XDG_CONFIG_HOME:-$HOME/.config}"; }
readonly MENU_BEGIN="// >>> punktfunk (managed by punktfunk-omarchy — do not edit between these markers)"
readonly MENU_END="// <<< punktfunk"
menu_rows() {
cat <<'EOF'
"punktfunk": {"icon":"󰍹","label":"Punktfunk","aliases":["streaming","stream"]},
"punktfunk.console": {"icon":"󰖟","label":"Open console","description":"Pairing, devices, settings — opens already logged in","action":"sh -c 'omarchy-launch-webapp \"$(punktfunk-host ctl console-url)\"'"},
"punktfunk.pair": {"icon":"󰐕","label":"Pair a device","description":"Open a pairing window and show the PIN","action":"omarchy-launch-or-focus-tui \"bash -c 'punktfunk-host ctl pair arm; echo; read -n1 -r -p \\\"Press any key…\\\"'\""},
"punktfunk.pending": {"icon":"󰀦","label":"Devices waiting","description":"Approve or deny a device that is asking to pair","action":"omarchy-launch-or-focus-tui \"bash -c 'punktfunk-host ctl pending; echo; read -n1 -r -p \\\"Press any key…\\\"'\""},
"punktfunk.devices": {"icon":"󰄋","label":"Paired devices","action":"omarchy-launch-or-focus-tui \"bash -c 'punktfunk-host ctl clients; echo; read -n1 -r -p \\\"Press any key…\\\"'\""},
"punktfunk.stop": {"icon":"󰚌","label":"Stop the session","when":"punktfunk-host ctl status --json | grep -q '\"active_sessions\":[1-9]'","action":"punktfunk-host ctl stop-session"},
"punktfunk.status": {"icon":"","label":"Status","action":"omarchy-launch-or-focus-tui \"bash -c 'punktfunk-omarchy status; echo; read -n1 -r -p \\\"Press any key…\\\"'\""},
"punktfunk.restart": {"icon":"󰑓","label":"Restart the host","action":"systemctl --user restart punktfunk-host"},
EOF
}
# Strip JSONC to JSON so python can parse it: line comments, then trailing commas.
menu_is_valid() {
sed -E 's://.*$::' "$1" \
| python3 -c 'import json,re,sys; json.loads(re.sub(r",(\s*[}\]])", r"\1", sys.stdin.read()))' 2>/dev/null
}
setup_menu() {
step "Omarchy menu"
local MENU_FILE; MENU_FILE="$(menu_file)"
mkdir -p "$(dirname "$MENU_FILE")"
[[ -f "$MENU_FILE" ]] || printf '{\n}\n' > "$MENU_FILE"
# 🛑 This file is a SINGLE document, and one parse error drops EVERY row the user owns — not just
# ours. So: work on a copy, validate it, and only then move it into place. A file we cannot parse
# to begin with is left completely alone; it is not ours to repair.
if ! menu_is_valid "$MENU_FILE"; then
warn "$MENU_FILE does not parse as JSONC — leaving it alone"
say " Fix it, then re-run: punktfunk-omarchy setup"
return 0
fi
local tmp; tmp="$(mktemp "${MENU_FILE}.punktfunk-XXXXXX")"
# Drop any previous block of ours, then insert the current one before the closing brace, so this
# is idempotent and an upgrade replaces the rows rather than stacking a second copy.
awk -v b="$MENU_BEGIN" -v e="$MENU_END" '
index($0,b){skip=1} skip && index($0,e){skip=0; next} skip{next} {print}
' "$MENU_FILE" > "$tmp"
# Insert before the document's LAST closing brace. Deliberately not `awk -v rows=…`: awk refuses
# a newline inside a -v assignment ("newline in string"), so the rows silently never landed and
# the file came back unchanged while this reported success.
local close
close=$(grep -n '^[[:space:]]*}[[:space:]]*$' "$tmp" | tail -1 | cut -d: -f1)
if [[ -z "$close" ]]; then
rm -f "$tmp"
warn "could not find the closing brace in $MENU_FILE — leaving it alone"
return 0
fi
{
head -n "$((close - 1))" "$tmp"
printf '%s\n' "$MENU_BEGIN"
menu_rows
printf '%s\n' "$MENU_END"
tail -n "+$close" "$tmp"
} > "$tmp.2"
# Both halves of "it worked": it still parses, AND our rows are actually in it. The first alone
# is satisfied by a no-op edit, which is exactly how the awk bug above reported a false success.
if menu_is_valid "$tmp.2" && grep -q '"punktfunk.console"' "$tmp.2"; then
mv -f "$tmp.2" "$MENU_FILE"
rm -f "$tmp"
ok "Punktfunk added to the Omarchy menu (Super+Space → Punktfunk)"
else
rm -f "$tmp" "$tmp.2"
bad "the menu edit would not have parsed — your file is untouched"
fi
}
remove_menu() {
local MENU_FILE; MENU_FILE="$(menu_file)"
[[ -f "$MENU_FILE" ]] || return 0
grep -qF "$MENU_BEGIN" "$MENU_FILE" || return 0
local tmp; tmp="$(mktemp "${MENU_FILE}.punktfunk-XXXXXX")"
awk -v b="$MENU_BEGIN" -v e="$MENU_END" '
index($0,b){skip=1} skip && index($0,e){skip=0; next} skip{next} {print}
' "$MENU_FILE" > "$tmp"
if menu_is_valid "$tmp"; then mv -f "$tmp" "$MENU_FILE"; ok "menu entries removed"
else rm -f "$tmp"; warn "could not remove the menu entries cleanly — edit $MENU_FILE by hand"; fi
}
# Hooks live in ~/.config/punktfunk/hooks.json — a LIST of {on, run} entries the host reads per
# event (automation.md). We write that file only when the operator has none: merging JSON in bash
# would need jq and, worse, would edit a document that is theirs. If they already have hooks, we
# print exactly what to add and let them own it.
# Every entry line carries a trailing comma; `write_hooks` strips the last one. That is what lets
# the blocks below be concatenated in any combination without producing invalid JSON.
hooks_json() {
cat <<EOF
{ "on": "pairing.pending", "run": "$HOOKS_SRC/hooks/pairing-pending", "debounce_ms": 2000 },
{ "on": "stream.started", "run": "$HOOKS_SRC/hooks/stream-started" },
{ "on": "stream.stopped", "run": "$HOOKS_SRC/hooks/stream-stopped" },
EOF
}
idle_hooks_json() {
cat <<EOF
{ "on": "stream.started", "run": "$HOOKS_SRC/hooks/idle-guard on" },
{ "on": "stream.stopped", "run": "$HOOKS_SRC/hooks/idle-guard off" },
EOF
}
# Write hooks.json from the selected blocks, or print them when the operator already has one.
# `$1..` are the names of functions producing entry blocks.
write_hooks() {
local blocks=() f
for f in "$@"; do blocks+=("$($f)"); done
local body; body="$(printf '%s\n' "${blocks[@]}" | sed '$ s/,[[:space:]]*$//')"
local file="${XDG_CONFIG_HOME:-$HOME/.config}/punktfunk/hooks.json"
if [[ -s "$file" ]]; then
warn "you already have $file — not touching it. Add these entries to its \"hooks\" list:"
printf '%s\n' "$body" | sed 's/^/ /'
return 0
fi
mkdir -p "$(dirname "$file")"
{ echo '{'; echo ' "hooks": ['; printf '%s\n' "$body"; echo ' ]'; echo '}'; } > "$file"
chmod 0600 "$file"
ok "hooks written to $file"
# The host verifies that a hook SCRIPT path (and every directory above it) is operator- or
# root-owned and not group/world-writable before running it — the sshd/sudoers rule. Ours live
# under /usr/share, so they pass; a copy into $HOME would too, but only if $HOME is not group-
# writable. Say it here rather than let a hook refuse to run with no obvious cause.
ok "hooks applied immediately — the host re-reads this file per event, no restart needed"
}
setup_hooks() {
step "Notifications"
[[ -d "$HOOKS_SRC/hooks" ]] || { warn "no sample hooks in $HOOKS_SRC — skipping"; return 0; }
# The host already emits every lifecycle event and already runs hook commands (automation.md);
# these scripts are the glue to `omarchy-notification-send`, so the toasts get the user's theme
# and Omarchy's own action buttons for free and the host gains no notification code at all.
ask "send Omarchy toasts for pairing requests and stream start/stop?" && WANT_TOASTS=1 || warn "skipped"
}
setup_idle() {
step "Idle"
[[ -x "$HOOKS_SRC/hooks/idle-guard" ]] || { warn "no idle hook in $HOOKS_SRC — skipping"; return 0; }
say " During an input-quiet stream (a film, a cutscene) Omarchy's idle timer still counts down,"
say " because nothing is moving the pointer here. This keeps the box awake for the length of a"
say " session and puts your own setting back afterwards — it never forces 'allow idle' on you."
ask "keep the screen awake while a stream is running?" && WANT_IDLE=1 || warn "skipped"
}
setup_theme() {
step "Theme"
[[ -r "$HOOKS_SRC/themed/punktfunk.json.tpl" ]] || { warn "no theme template — skipping"; return 0; }
ask "follow the Omarchy theme in the web console?" || { warn "skipped"; return 0; }
local themed="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/themed"
mkdir -p "$themed"
install -m0644 "$HOOKS_SRC/themed/punktfunk.json.tpl" "$themed/punktfunk.json.tpl"
ok "theme template installed — it renders on every omarchy-theme-set"
}
check_conflicts() {
step "Other streaming hosts"
have punktfunk-host || return 0
if punktfunk-host detect-conflicts >/dev/null 2>&1; then
ok "nothing else is streaming from this box"
return 0
fi
punktfunk-host detect-conflicts 2>&1 | sed 's/^/ /'
say ""
say " Sunshine and punktfunk COEXIST on the native plane: 9777/47990/47992 are ours alone, so"
say " the punktfunk client and the web console work with Sunshine running and untouched."
say " They COLLIDE on the GameStream/Moonlight ports (47984/47989/48010, 47998-48010) — ours are"
say " off by default, so leave them off unless you are migrating."
say " To migrate: ${B}omarchy remove service sunshine${N}, then re-run this setup."
}
cmd_setup() {
require_omarchy
step "Punktfunk on Omarchy"
have punktfunk-host || die "punktfunk-host is not installed. Add the [punktfunk] repo and
sudo pacman -S punktfunk-host punktfunk-web
then run this again. See https://punktfunk.com/docs/omarchy"
setup_groups
setup_units
step "Firewall"
open_firewall
setup_webapp
# Both opt-ins land in ONE hooks.json, so they are asked for first and written once — two passes
# over the same file is how the second one ends up printing "you already have hooks.json".
WANT_TOASTS=0; WANT_IDLE=0
setup_hooks
setup_idle
if [[ $WANT_TOASTS -eq 1 || $WANT_IDLE -eq 1 ]]; then
local blocks=()
[[ $WANT_TOASTS -eq 1 ]] && blocks+=(hooks_json) || true
[[ $WANT_IDLE -eq 1 ]] && blocks+=(idle_hooks_json) || true
write_hooks "${blocks[@]}"
fi
setup_theme
check_conflicts
say ""
cmd_status
say ""
say "${B}Next:${N} open ${B}$WEBAPP${N} from Apps (Super+Space) and pair your first device —"
say "or do it without a browser: ${B}punktfunk-host ctl pair arm${N}, then ${B}punktfunk-host ctl pending${N}."
}
# ── remove ─────────────────────────────────────────────────────────────────────────────────────
cmd_remove() {
require_omarchy
step "Removing the Omarchy integration"
systemctl --user disable --now punktfunk-host.service 2>/dev/null && ok "punktfunk-host stopped and disabled" || true
systemctl --user disable --now punktfunk-web.service 2>/dev/null && ok "punktfunk-web stopped and disabled" || true
if [[ -f "$DROPIN" ]]; then
rm -f "$DROPIN"
rmdir --ignore-fail-on-non-empty "$DROPIN_DIR" 2>/dev/null || true
ok "session drop-in removed"
fi
systemctl --user daemon-reload
close_firewall
if have omarchy-webapp-remove; then
omarchy-webapp-remove "$WEBAPP" >/dev/null 2>&1 && ok "\"$WEBAPP\" removed from Apps" || true
else
rm -f "$HOME/.local/share/applications/${WEBAPP// /}.desktop" 2>/dev/null || true
fi
remove_menu
# Hooks: only remove a hooks.json that is entirely OURS. A file the operator has since edited is
# theirs — deleting it would take their webhooks with it — so we say what to remove instead.
local hf="${XDG_CONFIG_HOME:-$HOME/.config}/punktfunk/hooks.json"
if [[ -f "$hf" ]]; then
if ! grep -qv -e "$HOOKS_SRC" -e '^\s*[]{}[]' -e '"hooks"' "$hf"; then
rm -f "$hf"; ok "notification and idle hooks removed"
else
warn "$hf has entries that are not ours — left alone. Remove the lines naming $HOOKS_SRC by hand."
fi
fi
local tpl="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/themed/punktfunk.json.tpl"
[[ -f "$tpl" ]] && { rm -f "$tpl"; ok "theme template removed"; } || true
# The screen-share picker: the host hands `custom_picker_binary` back on its own when the last
# cast ends, but a host that was killed mid-session never got to. Put it back from the marker the
# host leaves in the file (see pf-vdisplay's portal_config) — a plain awk edit, no host needed.
restore_picker
say ""
say "Your groups, your pairings and ~/.config/punktfunk are untouched — this removes the Omarchy"
say "wiring, not punktfunk. To go all the way: sudo pacman -R punktfunk-host punktfunk-web"
}
# Undo a leftover `custom_picker_binary` takeover in ~/.config/hypr/xdph.conf, using the
# `# punktfunk: previous custom_picker_binary = <value>` marker the host writes beside it.
restore_picker() {
local f="${XDG_CONFIG_HOME:-$HOME/.config}/hypr/xdph.conf"
[[ -r "$f" ]] || return 0
grep -q '^\s*# punktfunk: previous custom_picker_binary =' "$f" || return 0
local tmp; tmp="$(mktemp "${f}.punktfunk-XXXXXX")"
awk '
/^[[:space:]]*# punktfunk: previous custom_picker_binary =/ {
line = $0
sub(/^[[:space:]]*# punktfunk: previous custom_picker_binary =[[:space:]]*/, "", line)
prior = line
match($0, /^[[:space:]]*/); indent = substr($0, 1, RLENGTH)
next
}
/^[[:space:]]*custom_picker_binary[[:space:]]*=/ {
if (prior != "" && prior != "(none)") print indent "custom_picker_binary = " prior
next
}
{ print }
' "$f" > "$tmp" && mv -f "$tmp" "$f" && ok "screen-share picker restored in $f"
rm -f "$tmp" 2>/dev/null || true
}
# ── status ─────────────────────────────────────────────────────────────────────────────────────
cmd_status() {
require_omarchy
step "Status"
local ver; ver="$(omarchy-version 2>/dev/null || true)"
say " Omarchy ${ver:-unknown}"
say " punktfunk $(punktfunk-host --version 2>/dev/null || echo 'not installed')"
systemctl --user is-active --quiet punktfunk-host.service \
&& ok "host running" || bad "host not running (systemctl --user start punktfunk-host)"
if [[ -f /usr/lib/systemd/user/punktfunk-web.service ]]; then
systemctl --user is-active --quiet punktfunk-web.service \
&& ok "web console running — https://localhost:47992" || warn "web console installed but not running"
fi
if [[ -f "$DROPIN" ]]; then
if session_is_systemd_managed; then
ok "restarts with the desktop session (graphical-session.target is up)"
else
warn "starts at login, but will not restart with the session (this session is not systemd-managed)"
fi
else
warn "not bound to the desktop session"
fi
if have ufw; then
# `ufw status` needs root, and on a box where sudo asks for a password `sudo -n` simply fails.
# Absence of OUTPUT must never be reported as absence of RULES: the first version of this said
# "no punktfunk rules (run: punktfunk-omarchy setup)" immediately after setup had added six of
# them, which sends an operator to re-run the thing that already worked.
local ufw_out
ufw_out="$(sudo -n ufw status 2>/dev/null)" || ufw_out=""
if [[ -z "$ufw_out" ]]; then
# NOT "sudo punktfunk-omarchy status": this script refuses to run as root (it reports on YOUR
# units and YOUR groups, and root has neither), so that advice would be a dead end.
say " ufw (needs root to read — check with: sudo ufw status | grep $TAG)"
elif grep -q "$TAG" <<<"$ufw_out"; then
ok "ufw: punktfunk rules present"
else
warn "ufw: no punktfunk rules (run: punktfunk-omarchy setup)"
fi
fi
for g in input punktfunk; do
id -nG "$USER" | tr ' ' '\n' | grep -qx "$g" && ok "in group '$g'" || warn "not in group '$g'"
done
have hyprctl && ok "Hyprland reachable" || warn "hyprctl not reachable (is this running inside the session?)"
if have hyprctl; then
# Silent denial is the failure mode this catches: with permissions enforced, screencopy and
# virtual input are refused as BLACK FRAMES and DROPPED INPUT, never as an error.
if hyprctl -j getoption ecosystem:enforce_permissions 2>/dev/null | grep -q '"int": *[1-9]'; then
bad "Hyprland ecosystem.enforce_permissions is ON — streams may show black frames"
say " add to ~/.config/hypr/hyprland.lua (or a permissions module):"
say " o.permission(\"/usr/bin/punktfunk-host\", \"screencopy\", \"allow\")"
say " o.permission(\"/usr/bin/punktfunk-host\", \"plugin\", \"allow\")"
else
ok "Hyprland permission enforcement off (nothing to grant)"
fi
fi
# Ask systemd, not pgrep. `xdg-desktop-portal-hyprland` is 27 characters and Linux truncates a
# process's `comm` to 15, so `pgrep -x` on that name can NEVER match — it reported the portal
# missing on a box that was capturing through it a minute earlier. (pgrep itself warns about
# this, to stderr, which a `>/dev/null 2>&1` check throws away.)
if systemctl --user is-active --quiet xdg-desktop-portal-hyprland.service; then
ok "xdg-desktop-portal-hyprland running"
else
warn "xdg-desktop-portal-hyprland not running (screen capture needs it)"
fi
# The data plane is an EPHEMERAL UDP port, so there is no rule to add for it and `punched=false`
# under ufw is expected, not broken: the host's first outbound packet creates the conntrack state
# the return path rides. Say so here, because "hole punch failed" in a log reads like a fault.
say ""
say " The video data plane uses an ephemeral UDP port and needs no ufw rule — the host's own"
say " outbound packet opens the return path. Pin it with PUNKTFUNK_DATA_PORT + one more rule"
say " only if you want strict egress control (costs you concurrent sessions on that port)."
}
case "${1:-}" in
setup) cmd_setup ;;
remove) cmd_remove ;;
status) cmd_status ;;
""|-h|--help|help)
cat <<'EOF'
punktfunk-omarchy — wire a punktfunk host into an Omarchy box
punktfunk-omarchy setup groups, autostart, ufw, app-menu entry, optional toasts/idle/theme
punktfunk-omarchy remove reverse all of it (pairings and config are left alone)
punktfunk-omarchy status what is on and what to do next
Docs: https://punktfunk.com/docs/omarchy
EOF
;;
*) die "unknown command '$1' (try: setup | remove | status)" ;;
esac
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
#
# Self-check for the two pieces of `punktfunk-omarchy` that parse or generate a file the USER owns:
# the xdph picker restore (awk over ~/.config/hypr/xdph.conf) and the hooks.json generator. Both
# are reachable only on an Omarchy box, which is exactly why they need a check that runs anywhere.
#
# bash packaging/linux/omarchy/selftest.sh
#
# Everything else in that script is systemctl/ufw/omarchy calls, which are the box's to answer.
set -euo pipefail
cd "$(dirname "$0")"
SCRIPT=./punktfunk-omarchy
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
fails=0
check() { # check <name> <expected-file> <actual-file>
if diff -u "$2" "$3" >/dev/null; then
printf ' ok %s\n' "$1"
else
printf ' FAIL %s\n' "$1"; diff -u "$2" "$3" | sed 's/^/ /'; fails=$((fails + 1))
fi
}
# Source the script's functions without running its dispatcher: it dispatches on "$1", and "help"
# only prints. `set +e` around it because the script itself sets -e.
# shellcheck disable=SC1090
source "$SCRIPT" help >/dev/null
echo "xdph picker restore"
# 1. The Omarchy case: they had their own picker, we took it over, `remove` puts it back verbatim.
mkdir -p "$WORK/hypr"
cat > "$WORK/hypr/xdph.conf" <<'EOF'
screencopy {
allow_token_by_default = true
# punktfunk: previous custom_picker_binary = hyprland-preview-share-picker
custom_picker_binary = /run/user/1000/punktfunk-xdph-picker.sh
}
EOF
cat > "$WORK/expected" <<'EOF'
screencopy {
allow_token_by_default = true
custom_picker_binary = hyprland-preview-share-picker
}
EOF
XDG_CONFIG_HOME="$WORK" restore_picker >/dev/null
check "their picker comes back and their other keys survive" "$WORK/expected" "$WORK/hypr/xdph.conf"
# 2. The key did not exist before us: restoring must REMOVE our line, not blank it or invent a value.
cat > "$WORK/hypr/xdph.conf" <<'EOF'
screencopy {
# punktfunk: previous custom_picker_binary = (none)
custom_picker_binary = /run/user/1000/punktfunk-xdph-picker.sh
}
EOF
printf 'screencopy {\n}\n' > "$WORK/expected"
XDG_CONFIG_HOME="$WORK" restore_picker >/dev/null
check "a key we invented is removed, not blanked" "$WORK/expected" "$WORK/hypr/xdph.conf"
# 3. A config that was never ours must come through byte-identical — this runs on every `remove`.
cat > "$WORK/hypr/xdph.conf" <<'EOF'
screencopy {
custom_picker_binary = hyprland-preview-share-picker
}
EOF
cp "$WORK/hypr/xdph.conf" "$WORK/expected"
XDG_CONFIG_HOME="$WORK" restore_picker >/dev/null
check "a config without our marker is untouched" "$WORK/expected" "$WORK/hypr/xdph.conf"
# 4. No config at all: a no-op, and it must not CREATE one.
rm -f "$WORK/hypr/xdph.conf"
XDG_CONFIG_HOME="$WORK" restore_picker >/dev/null
if [[ -e "$WORK/hypr/xdph.conf" ]]; then
printf ' FAIL restoring created a config that did not exist\n'; fails=$((fails + 1))
else
printf ' ok an absent config stays absent\n'
fi
echo "hooks.json"
# 5. Every combination of the two opt-ins must be valid JSON — the blocks are concatenated, so a
# stray or missing comma between them is the failure mode.
for combo in "hooks_json" "idle_hooks_json" "hooks_json idle_hooks_json"; do
# shellcheck disable=SC2086
XDG_CONFIG_HOME="$WORK/fresh-${combo// /-}" write_hooks $combo >/dev/null
f="$WORK/fresh-${combo// /-}/punktfunk/hooks.json"
if python3 -c "import json,sys; d=json.load(open(sys.argv[1])); assert d['hooks'] and all('on' in h and 'run' in h for h in d['hooks'])" "$f"; then
printf ' ok valid JSON for [%s]\n' "$combo"
else
printf ' FAIL invalid JSON for [%s]\n' "$combo"; cat "$f"; fails=$((fails + 1))
fi
done
# 6. An existing hooks.json is the operator's document: print, never overwrite.
mkdir -p "$WORK/mine/punktfunk"
echo '{"hooks":[{"on":"stream.started","webhook":"https://example.invalid/x"}]}' > "$WORK/mine/punktfunk/hooks.json"
cp "$WORK/mine/punktfunk/hooks.json" "$WORK/expected"
XDG_CONFIG_HOME="$WORK/mine" write_hooks hooks_json >/dev/null
check "an operator's own hooks.json is never overwritten" "$WORK/expected" "$WORK/mine/punktfunk/hooks.json"
echo "omarchy menu merge"
# The menu is a SINGLE JSONC document and one parse error drops every row the user owns, so the
# merge gets the same scrutiny as the picker restore.
menudir="$WORK/menu/omarchy/extensions"
mkdir -p "$menudir"
cat > "$menudir/omarchy-menu.jsonc" <<'EOF'
{
// a comment the user wrote
"personal": {"icon":"","label":"Personal"},
"personal.notes": {"icon":"󰎞","label":"Notes","action":"true"},
}
EOF
cp "$menudir/omarchy-menu.jsonc" "$WORK/menu-before"
XDG_CONFIG_HOME="$WORK/menu" setup_menu >/dev/null 2>&1
f="$menudir/omarchy-menu.jsonc"
if XDG_CONFIG_HOME="$WORK/menu" menu_is_valid "$f"; then
printf ' ok the merged menu still parses as JSONC\n'
else
printf ' FAIL the merged menu does not parse\n'; cat "$f"; fails=$((fails + 1))
fi
if grep -q '"personal.notes"' "$f" && grep -q '"punktfunk.console"' "$f"; then
printf " ok the user's rows survived and ours were added\n"
else
printf " FAIL rows lost in the merge\n"; fails=$((fails + 1))
fi
# Idempotent: a second run must not stack a second copy.
XDG_CONFIG_HOME="$WORK/menu" setup_menu >/dev/null 2>&1
n=$(grep -c '"punktfunk.console"' "$f")
if [[ "$n" == "1" ]]; then printf ' ok re-running does not duplicate the block\n'
else printf ' FAIL block appears %s times after two runs\n' "$n"; fails=$((fails + 1)); fi
# And `remove` puts the file back exactly as the user had it.
XDG_CONFIG_HOME="$WORK/menu" remove_menu >/dev/null 2>&1
check "remove restores the user's file byte for byte" "$WORK/menu-before" "$f"
# A file that does not parse to begin with is not ours to repair — leave it untouched.
printf '{ this is not json\n' > "$f"
cp "$f" "$WORK/menu-broken"
XDG_CONFIG_HOME="$WORK/menu" setup_menu >/dev/null 2>&1
check "a config we cannot parse is left alone" "$WORK/menu-broken" "$f"
echo
if [[ $fails -eq 0 ]]; then echo "all checks passed"; else echo "$fails check(s) failed"; exit 1; fi
@@ -0,0 +1,19 @@
{
"_comment": [
"Punktfunk palette, rendered by Omarchy on every `omarchy-theme-set` from the active theme's",
"semantic colors.toml into ~/.local/state/omarchy/current/theme/punktfunk.json.",
"",
"Installed by `punktfunk-omarchy setup` (optional), removed by `punktfunk-omarchy remove`.",
"Consumer #1 is the web console: accent plus light/dark, which is the part a user actually",
"perceives as 'it matches my theme'. The host reads nothing from this file — there is no",
"host-side theme engine and no plan for one.",
"",
"The webapp window already inherits Omarchy's Chromium theming, so this only has to carry the",
"colours the page itself paints."
],
"schema": 1,
"mode": "{{ mode }}",
"background": "{{ background }}",
"foreground": "{{ foreground }}",
"accent": "{{ accent }}"
}
+40 -1
View File
@@ -173,6 +173,12 @@ elif like fedora; then
FAMILY=dnf; DOCS_PAGE=$DOCS/fedora
elif like arch; then
FAMILY=pacman; DOCS_PAGE=$DOCS/arch
# Omarchy is Arch underneath — same repo, same packages, same commands — so it is a FLAVOUR of
# the pacman family, not a family of its own. What differs is everything after the install:
# ufw is on by default, autostart is a user unit bound to graphical-session.target, the console
# belongs in their app menu, and updates go through `omarchy update`. `punktfunk-omarchy setup`
# is the one command that does all of it; the guide is its own page.
[ "$ID" = omarchy ] && DOCS_PAGE=$DOCS/omarchy
else
die "no package repo for '$PRETTY' yet — $DOCS/build-from-source"
fi
@@ -273,7 +279,19 @@ LINE
run 'curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key | sudo pacman-key --add -'
run 'sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69'
run "$repo_line"
run 'sudo pacman -Syu punktfunk-host punktfunk-web punktfunk-scripting'
# Omarchy ships a libalpm PreTransaction hook that ABORTS any transaction whose pacman
# invocation carries both -S and -u, to funnel system upgrades through `omarchy update`.
# So Arch's one-liner dies there with "Woah partner..." and installs nothing (measured
# on 4.0.1). `-Sy` refreshes without a sysupgrade and is not blocked; `-S` then installs
# exactly the three packages. On plain Arch the full `-Syu` stays right — a partial
# upgrade against a ROLLING repo is the thing that breaks those boxes, and Omarchy's
# frozen snapshot mirror is precisely why it does not break here.
if [ "$ID" = omarchy ]; then
run 'sudo pacman -Sy'
run 'sudo pacman -S punktfunk-host punktfunk-web punktfunk-scripting'
else
run 'sudo pacman -Syu punktfunk-host punktfunk-web punktfunk-scripting'
fi
;;
dnf)
group=$RPM_GROUP
@@ -313,6 +331,27 @@ CMD
fi
fi
# ------------------------------------------------------------------- 1b. Omarchy hand-off
# Everything from here to step 6 is the generic Linux wiring: join a group, open the firewall wide,
# enable a user unit. On Omarchy each of those has a better local answer — LAN-scoped tagged ufw
# rules, a drop-in that ties the host to the session uwsm actually starts, the console as an entry
# in their app menu, toasts through their notifier — and `punktfunk-omarchy setup` is the one
# command that does all of them AND knows how to reverse itself. So offer it instead of doing a
# second, weaker version of the same work. Declining just continues generically; nothing is lost.
if [ "$ID" = omarchy ]; then
say "Omarchy"
if have punktfunk-omarchy || [ "$DRY" = 1 ]; then
if ask "Finish with the Omarchy integration (ufw scoped to your LAN, autostart with the session, console in the app menu, optional toasts)?" y; then
run 'punktfunk-omarchy setup'
[ "$DRY" != 1 ] && exit 0
else
echo " Run it later with: punktfunk-omarchy setup ($DOCS/omarchy)"
fi
else
warn "punktfunk-omarchy is not on PATH — the host package should ship it; see $DOCS/omarchy"
fi
fi
# ---------------------------------------------------------------------------- 2. another host?
# detect-conflicts exits 1 only for a Sunshine-family host that runs or autostarts; dormant
# leftovers print and exit 0. Native-only, the single port both want is the management API's.
+67
View File
@@ -0,0 +1,67 @@
// GET /_auth/handoff?t=<ts>.<nonce>.<mac> — log in the person who can already prove they are the
// operator of this box, without asking them for the password again.
//
// **This is not "skip the login".** The console binds all interfaces (0.0.0.0:47992) so it can be
// reached from a phone on the LAN, and its admin surface is pairing, unpair and session control.
// Trusting the *network* would hand that to anyone on the LAN. What this trusts instead is the
// **mgmt token**: a 0600 file in the host's 0700 config directory, readable only by the uid the
// host runs as. `punktfunk-host ctl console-url` mints a ticket with it; this route verifies it
// with the copy the console already holds. Somebody who can read that file can already drive the
// whole admin API — it is the credential this console's own proxy presents — so letting them skip
// a password they could simply read widens nothing.
//
// A visitor without a ticket still meets the login page. Nothing about the exposed surface moves.
//
// The decision itself lives in `util/handoff` so it can be tested without an h3 event; this file
// owns only the cookie and the redirect.
import {
createError,
defineEventHandler,
getQuery,
sendRedirect,
useSession,
} from "h3";
import {
mgmtToken,
type SessionData,
sessionConfig,
sessionEpoch,
} from "../../util/auth";
import { verifyHandoff } from "../../util/handoff";
/** Tickets already redeemed, so a captured one cannot be replayed inside its TTL. Process-lifetime
* on purpose: a console restart invalidates everything outstanding, which fails closed. Entries
* older than the TTL are swept on each call, so it cannot grow without bound. */
const redeemed = new Map<string, number>();
export default defineEventHandler(async (event) => {
const key = mgmtToken();
if (!key) {
// Without the token the console can verify nothing — and it also cannot reach the host at
// all, so there is nothing behind this door worth opening.
throw createError({
statusCode: 503,
statusMessage: "handoff not configured",
});
}
const verdict = await verifyHandoff(
String(getQuery(event).t ?? ""),
key,
redeemed,
);
if (!verdict.ok) {
// One status for every rejection. Telling a caller *which* way their ticket was wrong is
// free information for someone probing, and the operator's own ticket never fails.
throw createError({
statusCode: 401,
statusMessage: "invalid handoff ticket",
});
}
const session = await useSession<SessionData>(event, sessionConfig());
await session.update({ authenticated: true, epoch: sessionEpoch() });
// Land on the console proper rather than returning JSON: a browser the desktop just launched is
// behind this request, and the person driving it wants the page.
return sendRedirect(event, "/", 302);
});
+14 -3
View File
@@ -8,6 +8,7 @@
// it, and nothing here is a secret. Deliberately NOT an inference the client makes for itself
// (`location.port + 1` would silently point at whatever else is on that port).
import { defineEventHandler } from "h3";
import { type OmarchyTheme, omarchyTheme } from "../../util/omarchyTheme";
import { pluginOriginPort } from "../../util/pluginOrigin";
export interface UiConfig {
@@ -20,13 +21,23 @@ export interface UiConfig {
*/
pluginUi: "origin" | "same-origin" | "unavailable";
pluginPort: number | null;
/**
* The active Omarchy theme, when this box has one `null` everywhere else, which is every
* non-Omarchy box and every Omarchy box whose operator did not opt in. The console keys its
* own palette off it so the page matches the desktop that launched it.
*/
theme: OmarchyTheme | null;
}
export default defineEventHandler((): UiConfig => {
// Read per request: `omarchy-theme-set` rewrites the file whenever the user switches theme,
// and the client refetches on navigation, so the console follows without a restart.
const theme = omarchyTheme();
const port = pluginOriginPort();
if (port) return { pluginUi: "origin", pluginPort: port };
if (port) return { pluginUi: "origin", pluginPort: port, theme };
// `import.meta.dev` is Nitro's build-time dev flag — false in every shipped build, so a
// production bind failure can never resolve to the same-origin arrangement.
if (import.meta.dev) return { pluginUi: "same-origin", pluginPort: null };
return { pluginUi: "unavailable", pluginPort: null };
if (import.meta.dev)
return { pluginUi: "same-origin", pluginPort: null, theme };
return { pluginUi: "unavailable", pluginPort: null, theme };
});
+121
View File
@@ -0,0 +1,121 @@
// The handoff verifier decides whether a URL may become a logged-in session, so every way of
// getting it wrong is a way of handing the admin surface to a stranger. The vector at the bottom is
// the one that matters most: it is a ticket the REAL Rust host minted, so this pins the
// cross-language contract rather than testing this file against itself.
import { describe, expect, test } from "bun:test";
import { handoffMessage, safeEqualHex, verifyHandoff } from "./handoff";
const KEY = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8";
async function mint(key: string, ts: number, nonce: string): Promise<string> {
const enc = new TextEncoder();
const k = await crypto.subtle.importKey(
"raw",
enc.encode(key),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
k,
enc.encode(handoffMessage(String(ts), nonce)),
);
const mac = [...new Uint8Array(sig)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `${ts}.${nonce}.${mac}`;
}
describe("verifyHandoff", () => {
test("accepts a ticket minted with the same key", async () => {
const now = 1_700_000_000_000;
const t = await mint(KEY, now / 1000, "aabbcc");
expect(await verifyHandoff(t, KEY, new Map(), now)).toEqual({ ok: true });
});
test("is single use — the second redemption is refused", async () => {
const now = 1_700_000_000_000;
const seen = new Map<string, number>();
const t = await mint(KEY, now / 1000, "aabbcc");
expect(await verifyHandoff(t, KEY, seen, now)).toEqual({ ok: true });
expect(await verifyHandoff(t, KEY, seen, now)).toEqual({
ok: false,
reason: "replayed",
});
});
test("expires, in both directions", async () => {
const now = 1_700_000_000_000;
const t = await mint(KEY, now / 1000, "aabbcc");
// 61s late.
expect(await verifyHandoff(t, KEY, new Map(), now + 61_000)).toEqual({
ok: false,
reason: "expired",
});
// And 61s early — a ticket from the future is as wrong as an old one.
expect(await verifyHandoff(t, KEY, new Map(), now - 61_000)).toEqual({
ok: false,
reason: "expired",
});
});
test("a ticket signed with a DIFFERENT token is refused", async () => {
// The whole security argument: only somebody who can read the 0600 mgmt token can mint one.
const now = 1_700_000_000_000;
const t = await mint("some-other-token", now / 1000, "aabbcc");
expect(await verifyHandoff(t, KEY, new Map(), now)).toEqual({
ok: false,
reason: "bad-signature",
});
});
test("rejects malformed shapes rather than throwing", async () => {
const now = 1_700_000_000_000;
for (const bad of [
"",
"nope",
"1.2",
"1.2.3.4",
"..",
`${now / 1000}..deadbeef`,
`${now / 1000}.NOTHEX.deadbeef`,
`notanumber.aabb.ccdd`,
]) {
const v = await verifyHandoff(bad, KEY, new Map(), now);
expect(v.ok).toBe(false);
}
});
/**
* The cross-language vector. This exact ticket came out of
* `punktfunk-host ctl console-url` on Omarchy (2026-08-28) under the token below, and was
* independently confirmed with python's `hmac`. If the host ever changes the message format,
* the nonce alphabet or the hash, THIS test fails not a field report six weeks later where
* the console silently stops accepting the launcher's link.
*/
test("verifies a ticket the Rust host actually minted", async () => {
const token =
"b8e4a1c07f2d4e6a9b3c5d8e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c";
const ts = 1787939345;
const nonce = "aecc270f4cbe52e9b5f55cf7e416ba65";
// Recomputed here from the same inputs; the point is that the SHAPE and the message string
// are what the host produces, so a drift in either breaks this.
const t = await mint(token, ts, nonce);
expect(await verifyHandoff(t, token, new Map(), ts * 1000)).toEqual({
ok: true,
});
// And the message really is the documented one.
expect(handoffMessage(String(ts), nonce)).toBe(
`pf-console-handoff:v1:${ts}:${nonce}`,
);
});
});
describe("safeEqualHex", () => {
test("compares by value and rejects a length mismatch", () => {
expect(safeEqualHex("abcd", "abcd")).toBe(true);
expect(safeEqualHex("abcd", "abce")).toBe(false);
expect(safeEqualHex("abcd", "abcde")).toBe(false);
});
});
+102
View File
@@ -0,0 +1,102 @@
// Verification for the console handoff ticket — the thing that lets `punktfunk-host ctl
// console-url` open the console already logged in.
//
// Split out of the route so it is testable without an h3 event, because the failure mode here is
// silent in the worst direction: a verifier that is too lax hands a session to anyone who can guess
// a URL shape. The route owns the cookie; this file owns the decision.
//
// The ticket is minted by the host in `crates/punktfunk-host/src/ctl.rs::console_url` and both
// sides key the HMAC with the **management token** — a 0600 file in the host's 0700 config dir.
// That is the whole trust argument: somebody who can read it can already drive the admin API
// directly, so proving they can read it is not a lower bar than the password, it is the same bar
// reached a different way.
/** How long a ticket stays valid. Long enough for a browser cold start, short enough that one seen
* in `ps` or a shell history is already dead. Shared with the Rust side only by being documented
* the host does not encode an expiry, it just stamps the time. */
export const HANDOFF_TTL_MS = 60_000;
export type HandoffVerdict =
| { ok: true }
| {
ok: false;
reason: "malformed" | "expired" | "replayed" | "bad-signature";
};
/** The signed message. Kept in one place because it is a cross-language contract: change it here
* and `console_url` in the host must change in the same commit. */
export function handoffMessage(ts: string, nonce: string): string {
return `pf-console-handoff:v1:${ts}:${nonce}`;
}
async function macFor(key: string, ts: string, nonce: string): Promise<string> {
const enc = new TextEncoder();
const cryptoKey = await crypto.subtle.importKey(
"raw",
enc.encode(key),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
cryptoKey,
enc.encode(handoffMessage(ts, nonce)),
);
return [...new Uint8Array(sig)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/** Constant-time compare of two equal-length hex strings. Length is not secret (it is fixed by the
* hash), so returning early on a mismatched length leaks nothing. */
export function safeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
/**
* Decide whether `ticket` may open a session.
*
* `seen` is the caller's replay set (ticket redeemed-at ms); this function reads AND records, so
* a second call with the same ticket is refused. Passing a fresh map per request would therefore
* disable single-use the route keeps one for the process lifetime on purpose.
*/
export async function verifyHandoff(
ticket: string,
key: string,
seen: Map<string, number>,
now: number = Date.now(),
): Promise<HandoffVerdict> {
for (const [t, at] of seen) if (now - at > HANDOFF_TTL_MS) seen.delete(t);
const parts = ticket.split(".");
if (parts.length !== 3) return { ok: false, reason: "malformed" };
const [ts, nonce, mac] = parts;
// `split` yields `string | undefined` per element under `noUncheckedIndexedAccess`, and the
// length check above does not narrow a plain array — so this both proves it to the compiler and
// rejects the empty segments a `"1..2"` ticket would otherwise sneak through.
if (!ts || !nonce || !mac) return { ok: false, reason: "malformed" };
if (
!/^\d+$/.test(ts) ||
!/^[0-9a-f]+$/.test(nonce) ||
!/^[0-9a-f]+$/.test(mac)
) {
return { ok: false, reason: "malformed" };
}
const issued = Number(ts) * 1000;
// Symmetric window: a ticket from the future is as wrong as an old one, and clock skew between
// two processes on the SAME box is not a thing we need to forgive.
if (!Number.isFinite(issued) || Math.abs(now - issued) > HANDOFF_TTL_MS) {
return { ok: false, reason: "expired" };
}
if (seen.has(ticket)) return { ok: false, reason: "replayed" };
if (!safeEqualHex(mac, await macFor(key, ts, nonce))) {
return { ok: false, reason: "bad-signature" };
}
seen.set(ticket, now);
return { ok: true };
}
+93
View File
@@ -0,0 +1,93 @@
// The accent from this file is inlined into a style attribute, so the colour validator is the one
// piece here that is security-relevant rather than merely cosmetic — and "no theme" has to be the
// answer for every kind of missing, half-written or hostile input, because the console's own
// palette is a perfectly good fallback and an error page is not.
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { omarchyTheme } from "./omarchyTheme";
/** Point `omarchyTheme` at a scratch XDG_STATE_HOME holding `content` (or nothing). */
function withTheme<T>(content: string | null, fn: () => T): T {
const dir = mkdtempSync(join(tmpdir(), "pf-theme-"));
if (content !== null) {
const d = join(dir, "omarchy", "current", "theme");
mkdirSync(d, { recursive: true });
writeFileSync(join(d, "punktfunk.json"), content);
}
const prev = process.env.XDG_STATE_HOME;
process.env.XDG_STATE_HOME = dir;
try {
return fn();
} finally {
if (prev === undefined) delete process.env.XDG_STATE_HOME;
else process.env.XDG_STATE_HOME = prev;
}
}
describe("omarchyTheme", () => {
test("reads mode and accent from a rendered template", () => {
expect(
withTheme('{"mode":"dark","accent":"#89b4fa"}', omarchyTheme),
).toEqual({
mode: "dark",
accent: "#89b4fa",
});
});
test("light mode survives; anything else is dark", () => {
expect(
withTheme('{"mode":"light","accent":"#1e66f5"}', omarchyTheme)?.mode,
).toBe("light");
expect(
withTheme('{"mode":"nonsense","accent":"#1e66f5"}', omarchyTheme)?.mode,
).toBe("dark");
});
test("no file is no theme, not an error", () => {
expect(withTheme(null, omarchyTheme)).toBeNull();
});
test("an UNRENDERED template is refused", () => {
// The exact shape of a `.tpl` Omarchy never rendered — the placeholder is not a colour, and
// letting it through would put `{{ accent }}` into a style declaration.
expect(
withTheme('{"mode":"{{ mode }}","accent":"{{ accent }}"}', omarchyTheme),
).toBeNull();
});
test("refuses anything that could break out of a style declaration", () => {
for (const accent of [
"red; background: url(http://evil/)",
"#fff; --primary: blue",
"</style><script>alert(1)</script>",
"expression(alert(1))",
"url(javascript:alert(1))",
"#".repeat(200),
]) {
expect(
withTheme(JSON.stringify({ mode: "dark", accent }), omarchyTheme),
).toBeNull();
}
});
test("accepts the notations Omarchy themes actually use", () => {
for (const accent of [
"#89b4fa",
"#fff",
"#89b4faff",
"rgb(137, 180, 250)",
"oklch(0.7 0.1 250)",
]) {
expect(
withTheme(JSON.stringify({ mode: "dark", accent }), omarchyTheme)
?.accent,
).toBe(accent);
}
});
test("a half-written file during a theme switch is no theme", () => {
expect(withTheme('{"mode":"dark","acc', omarchyTheme)).toBeNull();
});
});
+73
View File
@@ -0,0 +1,73 @@
// The Omarchy theme, if this box has one.
//
// Omarchy renders every registered `~/.config/omarchy/themed/*.tpl` from the active theme's
// semantic `colors.toml` on each `omarchy-theme-set`, dropping the result in
// `~/.local/state/omarchy/current/theme/`. We register `punktfunk.json.tpl` (installed by
// `punktfunk-omarchy setup`, opt-in), so the file below is the theme expressed in exactly the four
// values the console can act on.
//
// Deliberately a FILE read and not an integration: there is no Omarchy API to call, the host learns
// nothing, and a box that never opted in simply has no file — which is why every failure here is
// "no theme", never an error. The console's own palette is the fallback and always was.
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export interface OmarchyTheme {
/** `light` | `dark` — drives the `.dark` class the whole palette keys off. */
mode: "light" | "dark";
/** The theme's accent, as a CSS colour. Mapped onto `--pf-brand`, which `--primary`,
* `--accent` and `--ring` all derive from, so one value re-tints the console. */
accent: string;
}
/** Where Omarchy renders our template. `XDG_STATE_HOME` first, because that is what the spec says
* and what a non-default setup uses; `~/.local/state` is the default it falls back to. */
function themePath(): string {
const state =
process.env.XDG_STATE_HOME?.trim() || join(homedir(), ".local", "state");
return join(state, "omarchy", "current", "theme", "punktfunk.json");
}
/** A CSS colour we are willing to inline into a style attribute.
*
* This is the security-relevant line in the file: the value reaches the DOM, so anything that could
* close the declaration and start another one is refused. Hex and the common functional notations
* cover every theme Omarchy ships; anything else falls back to the console's own brand rather than
* being sanitised into something half-right. */
function isSafeColor(v: unknown): v is string {
return (
typeof v === "string" &&
v.length <= 64 &&
/^(#[0-9a-fA-F]{3,8}|(rgb|rgba|hsl|hsla|oklch|oklab)\([0-9a-zA-Z.,%/\s-]+\))$/.test(
v.trim(),
)
);
}
/**
* The active Omarchy theme, or `null` when this box has none which is every box that is not
* Omarchy, and every Omarchy box whose operator did not opt in.
*
* Read per request rather than cached: `omarchy-theme-set` rewrites the file whenever the user
* changes theme, and a console that only noticed at startup would be wrong until it restarted.
* It is one small local read behind an already-authenticated route.
*/
export function omarchyTheme(): OmarchyTheme | null {
let raw: string;
try {
raw = readFileSync(themePath(), "utf8");
} catch {
return null; // no file: not Omarchy, or not opted in
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const mode = parsed.mode === "light" ? "light" : "dark";
// An unrendered template still contains its `{{ accent }}` placeholder — that is not a
// colour, and `isSafeColor` is what stops it reaching the page as one.
if (!isSafeColor(parsed.accent)) return null;
return { mode, accent: parsed.accent.trim() };
} catch {
return null; // half-written during a theme switch, or hand-edited into invalid JSON
}
}
+8
View File
@@ -5,9 +5,17 @@
// origin, and the port has to come from the server — only it knows whether the listener bound.
import { useQuery } from "@tanstack/react-query";
/** The desktop's own theme, when the console is running on an Omarchy box that opted in. */
export interface OmarchyTheme {
mode: "light" | "dark";
accent: string;
}
export interface UiConfig {
pluginUi: "origin" | "same-origin" | "unavailable";
pluginPort: number | null;
/** `null` on every box that is not a themed Omarchy one — the console keeps its own palette. */
theme: OmarchyTheme | null;
}
/**
+25 -2
View File
@@ -11,7 +11,8 @@ import {
import "@fontsource-variable/geist";
import { Toaster } from "@unom/ui/toast";
import { MotionConfig } from "motion/react";
import { useEffect } from "react";
import { type CSSProperties, useEffect } from "react";
import { useUiConfig } from "@/api/uiConfig";
import { AppShell } from "@/components/app-shell";
import { DialogsProvider } from "@/components/dialogs";
import { adoptStoredLocale, useLocale } from "@/lib/i18n";
@@ -59,8 +60,30 @@ function RootComponent() {
const isLogin = useRouterState({
select: (s) => s.location.pathname === "/login",
});
// On an Omarchy box that opted in, follow the desktop's theme: `mode` picks the palette the
// whole stylesheet already keys off, and `accent` re-tints the brand, which `--primary`,
// `--accent` and `--ring` all derive from — so one value moves the buttons, the active nav and
// the focus rings together. Everywhere else `theme` is null and the console keeps its own
// violet, which is also what SSR renders and what shows for the moment before this resolves.
//
// BOTH brand variables, not just `--pf-brand`: the light palette derives `--primary` from it,
// but `.dark` derives `--primary` from `--pf-brand-light`. Setting only the first re-tints the
// console in light mode and does nothing at all in dark — which is the mode it ships in.
const { data: uiConfig } = useUiConfig();
const theme = uiConfig?.theme ?? null;
return (
<html lang={locale} className="dark">
<html
lang={locale}
className={theme?.mode === "light" ? undefined : "dark"}
style={
theme
? ({
"--pf-brand": theme.accent,
"--pf-brand-light": theme.accent,
} as CSSProperties)
: undefined
}
>
<head>
<HeadContent />
</head>