Compare commits

...
Author SHA1 Message Date
enricobuehler c12476736d docs(changelog): v0.28.0 was tagged, so these fixes belong in a v0.28.1 section
ci / bun-nix (pull_request) Successful in 28s
ci / rust (pull_request) Successful in 4m44s
ci / rust-arm64 (pull_request) Successful in 4m32s
ci / docs-site (pull_request) Successful in 1m30s
ci / web (pull_request) Successful in 5m54s
nix / flake (pull_request) Successful in 15m13s
android / android (pull_request) Failing after 1m10s
The branch is based on the commit v0.28.0 points at, so writing the NixOS
runner fix into that section would have credited a released version with
a change it does not contain. Moved to a fresh `v0.28.1 — in development`
section, matching how the v0.28.0 cycle was kept (a `— in development`
heading the release commit renames).
2026-08-13 20:40:02 +02:00
enricobuehler 5bcee83c34 test: /bin/true and /bin/false do not exist on NixOS
Two tests hardcoded absolute paths into /bin, which on NixOS holds only
`sh` — so `cargo test` failed there for reasons that had nothing to do
with the code under test.

`gamelease` only needs a process that exits quickly and successfully, so
the bare name resolved through PATH is exactly right.

`pyrowave_remote` cannot use a bare name: `spawn_link` pins the binary
with `PinnedExe::open`, so a name that PATH would have resolved fails the
OPEN instead — which takes the spawn-failure rung rather than the
handshake rung the test exists to exercise. It resolves a real path off
PATH first, keeping the test on the rung it names.
2026-08-13 20:30:33 +02:00
enricobuehler 4ebe7d1185 fix(host): the plugin runner was located by FHS path only, so NixOS never found it
On NixOS every plugin PACKAGE op failed with "the plugin runner isn't
installed" on a box where the runner was installed, enabled and running.
`runner_command()` checked FHS locations exclusively — /usr/bin, the
/usr/lib + /usr/share pair behind it, and the ~/.local mirror the SteamOS
installer lays down. Nix ships punktfunk-scripting as a derivation of its
OWN, so its wrapper is neither beside the host binary nor anywhere under
/usr, and no rung could ever match. Service ops go through systemd and
were unaffected, which is what made it read as arbitrary: `plugins
status` said running/enabled while `plugins add` said not installed.

Resolution now matches punktfunk-encode-worker's: PUNKTFUNK_SCRIPTING ->
beside the host binary -> PATH -> /usr -> ~/.local. PATH is the rung Nix
lands on. The /usr rungs stay AFTER it rather than being dropped, because
a systemd unit's PATH need not include /usr/bin. As with the encode
worker the env override is deliberately not existence-checked — a named
path that is wrong should fail naming itself, not fall through to some
other runner. Lifted into a pure injected function so the whole table is
testable, which is also how the regression is pinned: removing the PATH
rung fails the NixOS row specifically.

Second half, and the reason the Rust change alone would not have fixed
the console: the NixOS module now puts the runner on the HOST UNIT's
`path`. The console installs plugins from inside the host service, whose
PATH is exactly that unit list — `environment.systemPackages` only ever
covered an operator's interactive shell. Without it the CLI would have
been fixed and the console would not. module-check.nix gains both the
positive and the negative assertion, so CI's `nix flake check --no-build`
holds the property.

The error text named only apt and SteamOS; it now names NixOS and the
override. The ~/.local/bin symlink workaround is no longer needed.
2026-08-13 20:27:26 +02:00
enricobuehler 7df321f459 Merge pull request '0.28.0' (#203) from worktree-release-0280 into main
audit / cargo-audit (push) Successful in 1m4s
audit / bun-audit (plugin-kit) (push) Successful in 29s
audit / bun-audit (sdk) (push) Successful in 23s
audit / bun-audit (web) (push) Successful in 22s
audit / docs-site-audit (push) Successful in 24s
audit / pnpm-audit (push) Successful in 15s
audit / license-gate (push) Successful in 4m55s
audit / miri (push) Successful in 5m1s
audit / c-abi-asan (push) Successful in 10m17s
android-screenshots / screenshots (push) Successful in 3m7s
apple / distribute (push) Successful in 12m23s
apple / swift (push) Successful in 1m57s
nix / flake (push) Successful in 17m4s
sbom / sbom (push) Successful in 2m31s
linux-client-screenshots / screenshots (push) Successful in 7m15s
apple / screenshots (push) Successful in 6m55s
ci / rust (push) Successful in 21m17s
ci / web (push) Successful in 2m17s
ci / docs-site (push) Successful in 1m25s
ci / bun-nix (push) Successful in 53s
ci / rust-arm64 (push) Successful in 2m32s
decky / build-publish (push) Successful in 41s
arch / build-publish (push) Successful in 7m35s
windows-host / package (push) Successful in 14m11s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 30s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m9s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m6s
flatpak / build-publish (push) Successful in 27m0s
deb / build-publish (push) Successful in 11m57s
deb / build-publish-host (push) Successful in 10m52s
deb / build-publish-gamescope (push) Successful in 28s
deb / smoke-install (push) Successful in 3m37s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m49s
deb / build-publish-client-arm64 (push) Successful in 4m21s
web-screenshots / screenshots (push) Successful in 7m2s
android / android (push) Successful in 6m58s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / builders-arm64cross (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 58s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m25s
docker / deploy-docs (push) Successful in 14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m24s
2026-08-13 16:20:00 +00:00
8 changed files with 363 additions and 42 deletions
+42
View File
@@ -12,6 +12,48 @@ with the version table of the release you are moving to, then read **Breaking ch
---
## v0.28.1 — in development
### NixOS — the plugin runner was installed, running, and reported missing
🛑 **On NixOS every plugin *package* op failed with "the plugin runner isn't installed", on a box
where the runner was installed, enabled and running.** `punktfunk-host plugins status` said so, and
the console's Plugins screen still refused to install anything.
The host resolved `punktfunk-scripting` by checking FHS locations exclusively —
`/usr/bin/punktfunk-scripting`, the `/usr/lib` + `/usr/share` pair behind it, and the `~/.local`
mirror the SteamOS installer lays down. Nix installs a wrapper at `$out/bin/punktfunk-scripting` in
a **derivation of its own**, so it is neither beside the host binary nor anywhere under `/usr`, and
nothing the resolver looked at could ever match. Service ops (`enable`/`disable`/`status`) go
through systemd and were unaffected, which is what made the failure read as arbitrary: the runner
demonstrably worked, and only the half that had to *locate the executable* was blind.
Resolution now matches `punktfunk-encode-worker`'s: **`PUNKTFUNK_SCRIPTING` → beside the host
binary → `PATH` → the `/usr` layout → the `~/.local` layout.** `PATH` is the rung Nix lands on. The
`/usr` rungs are kept after it rather than dropped, because a systemd unit's `PATH` need not include
`/usr/bin`. As with the encode worker, an explicit `PUNKTFUNK_SCRIPTING` is deliberately *not*
existence-checked — a named path that is wrong should fail naming itself, not fall through to some
other runner. The "not installed" text now also names NixOS and the override, instead of pointing
every operator at `apt`.
**Packager-visible, and the other half of the fix:** the NixOS module now puts
`services.punktfunk.scripting.package` on the **host unit's** `path`. `environment.systemPackages`
only ever covered an operator's interactive shell, and the console installs plugins from *inside*
the host service — whose `PATH` is exactly that unit list. Without it the CLI would have been fixed
and the console would not. Anyone packaging the host separately wants the same property: the runner
must be on the service's `PATH`, or `PUNKTFUNK_SCRIPTING` set for it.
The `ln -s "$(command -v punktfunk-scripting)" ~/.local/bin/punktfunk-scripting` workaround is no
longer needed and can be removed.
### `/bin/true` and `/bin/false` are not portable — two tests failed on NixOS
NixOS ships only `sh` in `/bin`, so `gamelease`'s hand-off test and `pyrowave_remote`'s
handshake-rung test failed there for reasons unrelated to the code under test. Both now resolve a
real binary rather than assuming an FHS path.
---
## v0.28.0
180 commits since v0.27.0.
@@ -1016,11 +1016,27 @@ mod tests {
);
}
/// Ladder rung: the binary exists and runs but is not a worker. `/bin/false` exits at once, so
/// the handshake reads EOF — the same rung a worker that dies during Vulkan bring-up takes.
/// A binary that exists, execs, and exits at once. Resolved off `PATH` rather than hardcoded
/// to `/bin/false`: NixOS ships only `/bin/sh` in `/bin`, and `PinnedExe::open` needs a real
/// path (so a bare name cannot stand in for one — it would fail the OPEN and take the
/// spawn-failure rung instead of the handshake rung this exercises).
fn a_binary_that_exits_immediately() -> PathBuf {
std::env::var_os("PATH")
.as_deref()
.map(std::env::split_paths)
.into_iter()
.flatten()
.map(|d| d.join("false"))
.find(|p| p.is_file())
.expect("a `false` binary on PATH")
}
/// Ladder rung: the binary exists and runs but is not a worker. It exits at once, so the
/// handshake reads EOF — the same rung a worker that dies during Vulkan bring-up takes.
#[test]
fn a_worker_that_exits_immediately_is_a_handshake_failure() {
let err = spawn_link(Path::new("/bin/false"), &params(), 40_000_000).unwrap_err();
let err =
spawn_link(&a_binary_that_exits_immediately(), &params(), 40_000_000).unwrap_err();
let text = format!("{err:#}");
assert!(
text.contains("handshake"),
+3 -1
View File
@@ -1410,7 +1410,9 @@ mod tests {
static EXITS: AtomicUsize = AtomicUsize::new(0);
EXITS.store(0, Ordering::SeqCst);
let child = std::process::Command::new("/bin/true")
// Resolved through PATH, not `/bin/true`: NixOS ships only `/bin/sh` in `/bin`, so the
// absolute path made this test — and nothing else about the code under test — fail there.
let child = std::process::Command::new("true")
.spawn()
.expect("spawn the fake launcher");
let lease = open(
+248 -34
View File
@@ -35,6 +35,12 @@ const UNIT: &str = "punktfunk-scripting";
#[cfg(target_os = "windows")]
const TASK: &str = "PunktfunkScripting";
/// The runner executable's name. Every non-Windows package installs a wrapper under exactly this
/// name — the deb/rpm at `/usr/bin`, the SteamOS installer at `~/.local/bin`, Nix at
/// `$out/bin` — so one name covers every layout the resolver walks.
#[cfg(not(target_os = "windows"))]
const RUNNER_BIN: &str = "punktfunk-scripting";
pub fn main(args: &[String]) -> Result<()> {
match args.first().map(String::as_str) {
Some("add") | Some("remove") | Some("rm") | Some("uninstall") | Some("list")
@@ -161,41 +167,103 @@ pub(crate) fn runner_command() -> Result<(std::path::PathBuf, Vec<String>)> {
}
#[cfg(not(target_os = "windows"))]
{
// The scripting package ships /usr/bin/punktfunk-scripting, a wrapper that runs the bundled
// bun on the runner bundle and forwards "$@" (packaging/debian/build-scripting-deb.sh).
let wrapper = std::path::PathBuf::from("/usr/bin/punktfunk-scripting");
if wrapper.exists() {
return Ok((wrapper, Vec::new()));
}
// Fall back to the package's private layout in case the wrapper is absent.
let bun = std::path::PathBuf::from("/usr/lib/punktfunk-scripting/bun");
let runner = std::path::PathBuf::from("/usr/share/punktfunk-scripting/runner-cli.js");
if bun.exists() && runner.exists() {
return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
}
// Immutable-/usr distros (SteamOS): scripts/steamdeck/install.sh lays the SAME payload
// out user-scoped under ~/.local — wrapper, private bun, and bundle mirroring the deb's
// /usr layout — because a system package can't exist there.
if let Ok(home) = std::env::var("HOME") {
let home = std::path::Path::new(&home);
let wrapper = home.join(".local/bin/punktfunk-scripting");
if wrapper.exists() {
return Ok((wrapper, Vec::new()));
}
let bun = home.join(".local/lib/punktfunk-scripting/bun");
let runner = home.join(".local/share/punktfunk-scripting/runner-cli.js");
if bun.exists() && runner.exists() {
return Ok((bun, vec![runner.to_string_lossy().into_owned()]));
}
}
bail!(
"the plugin runner isn't installed — install it first (Debian/Ubuntu: \
`sudo apt install punktfunk-scripting`; SteamOS: re-run \
scripts/steamdeck/install.sh)"
let exe = std::env::current_exe().ok();
let path_var = std::env::var("PATH").ok();
let home = std::env::var("HOME").ok();
resolve_runner_in(
std::env::var("PUNKTFUNK_SCRIPTING").ok().as_deref(),
exe.as_deref().and_then(std::path::Path::parent),
path_var.as_deref(),
home.as_deref().map(std::path::Path::new),
&|p| p.is_file(),
)
.ok_or_else(|| anyhow::anyhow!("{RUNNER_MISSING}"))
}
}
/// What to say when no rung matched. Shared with [`runtime_status`], so the CLI and the console
/// tell an operator the same thing.
#[cfg(not(target_os = "windows"))]
pub(crate) const RUNNER_MISSING: &str =
"the plugin runner isn't installed — install it first (Debian/Ubuntu: `sudo apt install \
punktfunk-scripting`; SteamOS: re-run scripts/steamdeck/install.sh; NixOS: enable \
`services.punktfunk.scripting`). If it is installed somewhere else, point PUNKTFUNK_SCRIPTING \
at the punktfunk-scripting executable.";
/// The rungs, in order: `PUNKTFUNK_SCRIPTING` → beside the host binary → `PATH` → the packaged
/// `/usr` layout → the user-scoped SteamOS layout. Pure and fully injected so the table can be
/// tested without mutating process env, which races `getenv` in parallel tests.
///
/// `PATH` is load-bearing rather than a nicety: it is the ONLY rung a Nix install can land on.
/// `punktfunk-scripting` is a derivation of its own there (packaging/nix/packages.nix), so its
/// wrapper is neither beside the host binary nor anywhere under `/usr` — the layouts this
/// resolver used to check exclusively, which is why a fully working NixOS box reported the runner
/// as not installed.
#[cfg(not(target_os = "windows"))]
fn resolve_runner_in(
env: Option<&str>,
exe_dir: Option<&std::path::Path>,
path_var: Option<&str>,
home: Option<&std::path::Path>,
exists: &dyn Fn(&std::path::Path) -> bool,
) -> Option<(std::path::PathBuf, Vec<String>)> {
use std::path::{Path, PathBuf};
// The two-file layout: a private bun plus the runner bundle, which the deb/rpm and the SteamOS
// installer both lay down beside their wrapper. Only a rung when BOTH halves are present.
let pair = |bun: PathBuf, runner: PathBuf| -> Option<(PathBuf, Vec<String>)> {
(exists(&bun) && exists(&runner))
.then(|| (bun, vec![runner.to_string_lossy().into_owned()]))
};
// The operator's own override, and deliberately NOT existence-checked: whoever names a path is
// entitled to a failure that names it back, where falling through to a runner that happens to
// be installed would hide the typo behind a working install.
if let Some(v) = env.map(str::trim).filter(|v| !v.is_empty()) {
return Some((PathBuf::from(v), Vec::new()));
}
// Beside the host binary — a source tree or any relocatable layout shipping both in one prefix.
if let Some(p) = exe_dir.map(|d| d.join(RUNNER_BIN)).filter(|p| exists(p)) {
return Some((p, Vec::new()));
}
if let Some(p) = path_var
.into_iter()
.flat_map(|v| v.split(':'))
.filter(|d| !d.is_empty())
.map(|d| Path::new(d).join(RUNNER_BIN))
.find(|p| exists(p))
{
return Some((p, Vec::new()));
}
// The packaged /usr layout (packaging/debian/build-scripting-deb.sh). Still checked explicitly
// after `PATH` because a systemd unit can carry a PATH that does not include /usr/bin.
let wrapper = Path::new("/usr/bin").join(RUNNER_BIN);
if exists(&wrapper) {
return Some((wrapper, Vec::new()));
}
if let Some(cmd) = pair(
Path::new("/usr/lib").join(RUNNER_BIN).join("bun"),
Path::new("/usr/share")
.join(RUNNER_BIN)
.join("runner-cli.js"),
) {
return Some(cmd);
}
// Immutable-/usr distros (SteamOS): scripts/steamdeck/install.sh lays the SAME payload out
// user-scoped under ~/.local, because a system package can't exist there.
let home = home?;
let wrapper = home.join(".local/bin").join(RUNNER_BIN);
if exists(&wrapper) {
return Some((wrapper, Vec::new()));
}
pair(
home.join(".local/lib").join(RUNNER_BIN).join("bun"),
home.join(".local/share")
.join(RUNNER_BIN)
.join("runner-cli.js"),
)
}
// ---- service ops ------------------------------------------------------------------------------
#[cfg(target_os = "linux")]
@@ -277,9 +345,7 @@ pub(crate) fn runtime_status() -> RuntimeStatus {
detail: if installed {
String::new()
} else {
"the plugin runner package isn't installed (Debian/Ubuntu: `sudo apt install \
punktfunk-scripting`)"
.into()
RUNNER_MISSING.into()
},
}
}
@@ -807,3 +873,151 @@ fn enable() -> Result<()> {
fn disable() -> Result<()> {
bail!("the plugin runner is only available on Linux and Windows hosts")
}
#[cfg(all(test, not(target_os = "windows")))]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
/// Build an `exists` probe over a fixed set of paths.
fn present(ps: Vec<PathBuf>) -> impl Fn(&Path) -> bool {
move |p: &Path| ps.iter().any(|q| q == p)
}
/// Every layout the resolver has to serve, in one table — the regression guard for the NixOS
/// report (a runner on `PATH` and nowhere else read as "not installed").
#[test]
fn runner_resolution_table() {
let beside = Path::new("/opt/punktfunk/bin");
let nix = Path::new("/run/current-system/sw/bin");
let home = Path::new("/home/deck");
// The rung a Nix install lands on: NOT beside the host binary, NOT under /usr — `PATH`
// only. This is the whole bug.
let exists = present(vec![nix.join(RUNNER_BIN)]);
assert_eq!(
resolve_runner_in(
None,
Some(beside),
Some(nix.to_str().unwrap()),
None,
&exists
),
Some((nix.join(RUNNER_BIN), Vec::new()))
);
// An explicit override wins over every discovery…
let exists = present(vec![beside.join(RUNNER_BIN)]);
assert_eq!(
resolve_runner_in(
Some("/nix/store/abc/bin/punktfunk-scripting"),
Some(beside),
Some("/usr/bin"),
Some(home),
&exists
),
Some(("/nix/store/abc/bin/punktfunk-scripting".into(), Vec::new()))
);
// …and is not existence-checked, so a typo surfaces as a spawn failure naming the path
// rather than silently running some other runner.
assert_eq!(
resolve_runner_in(Some("/nope/pf"), Some(beside), None, None, &exists),
Some(("/nope/pf".into(), Vec::new()))
);
// Empty/whitespace reads as unset, not as a path.
assert_eq!(
resolve_runner_in(Some(" "), Some(beside), None, None, &exists),
Some((beside.join(RUNNER_BIN), Vec::new()))
);
// Beside the host binary beats PATH.
let exists = present(vec![beside.join(RUNNER_BIN), nix.join(RUNNER_BIN)]);
assert_eq!(
resolve_runner_in(
None,
Some(beside),
Some(nix.to_str().unwrap()),
None,
&exists
),
Some((beside.join(RUNNER_BIN), Vec::new()))
);
// PATH is walked entry by entry, skipping empties.
let exists = present(vec![nix.join(RUNNER_BIN)]);
assert_eq!(
resolve_runner_in(
None,
Some(Path::new("/nowhere")),
Some(":/nope:/run/current-system/sw/bin"),
None,
&exists
),
Some((nix.join(RUNNER_BIN), Vec::new()))
);
// The deb/rpm wrapper, found even when the unit's PATH omits /usr/bin.
let exists = present(vec![PathBuf::from("/usr/bin").join(RUNNER_BIN)]);
assert_eq!(
resolve_runner_in(
None,
Some(Path::new("/nowhere")),
Some("/nope"),
None,
&exists
),
Some((PathBuf::from("/usr/bin").join(RUNNER_BIN), Vec::new()))
);
// …and its private two-file layout when the wrapper is absent.
let bun = PathBuf::from("/usr/lib").join(RUNNER_BIN).join("bun");
let cli = PathBuf::from("/usr/share")
.join(RUNNER_BIN)
.join("runner-cli.js");
let exists = present(vec![bun.clone(), cli.clone()]);
assert_eq!(
resolve_runner_in(None, None, None, None, &exists),
Some((bun, vec![cli.to_string_lossy().into_owned()]))
);
// Half of that layout is not a rung — a partial install must fall through, not spawn a
// bun with no script.
let exists = present(vec![PathBuf::from("/usr/lib").join(RUNNER_BIN).join("bun")]);
assert_eq!(resolve_runner_in(None, None, None, None, &exists), None);
// SteamOS: the same payload, user-scoped. Reached only via HOME.
let exists = present(vec![home.join(".local/bin").join(RUNNER_BIN)]);
assert_eq!(
resolve_runner_in(None, None, None, Some(home), &exists),
Some((home.join(".local/bin").join(RUNNER_BIN), Vec::new()))
);
assert_eq!(resolve_runner_in(None, None, None, None, &exists), None);
let bun = home.join(".local/lib").join(RUNNER_BIN).join("bun");
let cli = home
.join(".local/share")
.join(RUNNER_BIN)
.join("runner-cli.js");
let exists = present(vec![bun.clone(), cli.clone()]);
assert_eq!(
resolve_runner_in(None, None, None, Some(home), &exists),
Some((bun, vec![cli.to_string_lossy().into_owned()]))
);
// Nothing anywhere: the "not installed" rung the error text speaks for.
let exists = present(vec![]);
assert_eq!(
resolve_runner_in(None, Some(beside), Some("/nope"), Some(home), &exists),
None
);
}
/// The operator-facing miss must name NixOS — the report's second half was that the error
/// pointed a NixOS operator at `apt`.
#[test]
fn the_missing_runner_error_names_every_platform_it_can_be_installed_on() {
for hint in [
"apt install",
"steamdeck/install.sh",
"NixOS",
"PUNKTFUNK_SCRIPTING",
] {
assert!(RUNNER_MISSING.contains(hint), "missing hint: {hint}");
}
}
}
+1
View File
@@ -244,6 +244,7 @@ notes for context.
| `PUNKTFUNK_IDD_DEPTH` | `N` (default `2`) | **(Windows)** IDD-push pipeline depth. `1` cuts latency once GPU priority is raised; higher smooths a contended GPU. |
| `PYROWAVE_QUEUE_PRIORITY` | `realtime` *(default)* · `high` · `off` | [PyroWave](/docs/pyrowave) sessions only — the *intent*, forwarded to whichever process does the encode. PyroWave encodes on the same GPU shader cores a game uses, so a demanding game can starve it and the frame rate drops. This asks the driver to schedule the encode ahead of the game. `realtime` tries the strongest class and falls back to `high`; `high` asks only for the middle one; `off` disables the request. A driver that refuses simply encodes at normal priority — it can never stop a session starting. Granting the request needs the `CAP_SYS_NICE` capability, which the Linux packages give to `punktfunk-encode-worker` and **never** to `punktfunk-host` — a host holding any capability cannot be identified by KWin and loses desktop streaming entirely. Do not `setcap` the host to "make this work"; see [Running as a service](/docs/running-as-a-service#gpu-scheduling-priority). Set `off` if you see the desktop stutter while streaming. |
| `PUNKTFUNK_ENCODE_WORKER` | path · `off` | Where the host looks for `punktfunk-encode-worker`, the small capability-carrying helper that owns the priority-elevated [PyroWave](/docs/pyrowave) encode (previous row). Unset, the host looks beside its own binary and then on `PATH`, which is right for every package — set it only when the worker lives somewhere unusual. **NixOS needs it and the module sets it for you:** a file capability cannot live on a read-only nix store path, so the worker is exposed through `security.wrappers` and this points the host at that wrapper. `off` forces the encode back into the host process at default priority — a debug escape hatch, not a tuning knob. Every failure short of that is already handled: a missing binary, a worker that will not start, or one that dies mid-session falls back to encoding in-process with one line in the log, and never drops the session. |
| `PUNKTFUNK_SCRIPTING` | path | Where the host looks for `punktfunk-scripting`, the runner that performs every [plugin](/docs/plugins) package op (`plugins add`/`remove`/`list`, and the console's store installs). Unset, the host looks beside its own binary, then on `PATH`, then in the packaged `/usr` and `~/.local` layouts — right for every package, so set it only when the runner lives somewhere unusual. Like the row above it is **not** existence-checked: a path you name is a path you get, so a typo fails naming itself instead of quietly running a different runner. Worth knowing: the console runs installs inside the host *service*, whose `PATH` is normally much shorter than your login shell's — if `punktfunk-host plugins add` works and the console says the runner isn't installed, that gap is why, and this is the fix. |
## Diagnostics
+10 -1
View File
@@ -305,7 +305,16 @@ installer's `PATH` change, or call the exe by full path. On Linux the host packa
[same RPM repo you installed the host from](/docs/fedora). On Arch:
`sudo pacman -Syu punktfunk-scripting` (a full `-Syu`, like every other install from that repo).
On SteamOS, re-run `scripts/steamdeck/install.sh` (or
`scripts/steamdeck/update.sh`). On Windows, re-run the installer and keep the scripting component.
`scripts/steamdeck/update.sh`). On NixOS it comes with `services.punktfunk.scripting.enable` (on by
default whenever the host is). On Windows, re-run the installer and keep the scripting component.
If the runner *is* installed and the host still says it isn't, the host could not find the
`punktfunk-scripting` executable. It looks beside its own binary, then on `PATH`, then in the
packaged `/usr` and `~/.local` layouts — so a runner installed somewhere else needs
`PUNKTFUNK_SCRIPTING` pointed at it (see [Configuration](/docs/configuration)). Note that the
console installs plugins from inside the host *service*, whose `PATH` is usually much shorter than
your shell's: a runner that `punktfunk-host plugins add` finds and the console does not is that
difference, and the env var is the fix.
**Where a plugin's log output goes** — the console's **Logs** page, under the **Plugins** filter.
The runner ships everything your plugins print to the host, so a plugin's own lines sit next to the
+31 -2
View File
@@ -76,8 +76,10 @@ let
];
}).config;
# Every scenario keeps `gamescopeHdr = false`: it is the one option whose default would pull a
# real (stub, here) gamescope onto the unit PATH, and nothing below is about that.
# Every scenario keeps `gamescopeHdr = false`: its default would pull a real (stub, here)
# gamescope onto the host unit's PATH, and nothing below is about that. `scripting` is left at
# its default (on with the host) precisely BECAUSE the runner belongs on that PATH — see the
# "not just in systemPackages" check.
desktop = evalWith {
services.punktfunk.host = {
enable = true;
@@ -108,6 +110,15 @@ let
};
};
# A host that has opted out of the runner — the negative half of the PATH check.
noScripting = evalWith {
services.punktfunk.host = {
enable = true;
gamescopeHdr = false;
};
services.punktfunk.scripting.enable = false;
};
clientOnly = evalWith { services.punktfunk.client.enable = true; };
unit = cfg: name: cfg.systemd.user.units."${name}.service".text;
@@ -243,6 +254,24 @@ let
name = "the plugin runner is started by default, like every other channel";
ok = has appliance "punktfunk-scripting" "WantedBy=default.target";
}
{
# The runner has to be on the HOST unit's PATH, not merely in systemPackages. Package ops
# (`plugins add`, and the console's store jobs, which run inside the host process) locate
# `punktfunk-scripting` as an executable, and on NixOS PATH is the only rung that can ever
# match — the runner is its own derivation, so it is never beside the host binary and never
# under /usr. systemPackages covers an operator's shell and NOT this unit, which is exactly
# how a running, enabled runner reported itself "not installed" through the console.
name = "the plugin runner is on the host unit's PATH, not just in systemPackages";
ok =
has appliance "punktfunk-host" "/pf-stub/punktfunk-scripting/bin"
&& has desktop "punktfunk-host" "/pf-stub/punktfunk-scripting/bin";
}
{
# …and only when it is actually installed, so `scripting.enable = false` does not put a
# package the machine never built onto a unit's PATH.
name = "a host without the runner does not carry it on PATH";
ok = !(has noScripting "punktfunk-host" "/pf-stub/punktfunk-scripting/bin");
}
# --- the client half must not drag the host's system wiring in -----------------------------
{
+9 -1
View File
@@ -487,7 +487,15 @@ in
]
# The HDR-capable gamescope, if enabled. On PATH rather than pinned through
# PUNKTFUNK_GAMESCOPE_BIN so an operator's own override of that env still wins.
++ optional cfg.host.gamescopeHdr cfg.host.gamescopePackage;
++ optional cfg.host.gamescopeHdr cfg.host.gamescopePackage
# The plugin runner, if enabled. Package ops (`plugins add`, and the console's store jobs,
# which run INSIDE this service) exec `punktfunk-scripting`; its resolution order is
# PUNKTFUNK_SCRIPTING -> beside the host binary -> PATH -> the /usr and ~/.local layouts.
# On NixOS only the PATH rung can ever match: the runner is a derivation of its OWN, so it
# is never beside the host binary and nothing lands in /usr. `environment.systemPackages`
# covers an operator's interactive shell but NOT this unit, whose PATH is exactly this
# list — without it the console reports a running, enabled runner as "not installed".
++ optional cfg.scripting.enable cfg.scripting.package;
# Point the host at the WRAPPED encode worker (see `security.wrappers` above). The host's
# own resolution order is PUNKTFUNK_ENCODE_WORKER -> alongside /proc/self/exe -> PATH, and
# on NixOS the sibling of the store binary is the UNCAPPED store copy — it would run, and