Omarchy becomes a first-class host, and the host grows a control surface #428
@@ -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"))]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 §§1–2, 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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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+",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"debian",
|
||||
"fedora",
|
||||
"arch",
|
||||
"omarchy",
|
||||
"bazzite",
|
||||
"steamos-host",
|
||||
"nixos",
|
||||
|
||||
@@ -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
|
||||
47998–48010 — 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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+",
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+69
@@ -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
@@ -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
|
||||
Executable
+20
@@ -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"
|
||||
Executable
+10
@@ -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"
|
||||
Executable
+619
@@ -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
|
||||
Executable
+150
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user