Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d5a9f66c9 | ||
|
|
5c7a9407ff | ||
|
|
7781d09e26 |
@@ -2652,6 +2652,18 @@ mod pipewire {
|
||||
build_default_format_obj(preferred)
|
||||
};
|
||||
|
||||
// gamescope trap — the Steam overlay's presence in the stream is decided HERE by omission:
|
||||
// gamescope's `paint_pipewire()` composites the overlay (Shift+Tab / Quick Access Menu) into
|
||||
// the node it hands us ONLY when the consumer-negotiated `gamescope_focus_appid` is 0 — the
|
||||
// default, and the "mirror the focused window + overlay" branch (gamescope ≥ 3.16.23; see
|
||||
// `MIN_GAMESCOPE_OVERLAY`). None of the EnumFormat pods below advertise the
|
||||
// `SPA_FORMAT_VIDEO_gamescope_focus_appid` property, so gamescope reads 0 and paints the
|
||||
// overlay for us for free. DO NOT add a non-zero focus-appid (e.g. to "isolate the game" in a
|
||||
// dedicated session) — that flips gamescope into the Remote-Play branch that deliberately
|
||||
// drops the overlay (and all host chrome) back out of the capture. The cursor, external
|
||||
// overlay (MangoHUD), and notifications are excluded from the node on EVERY gamescope
|
||||
// version and are composited host-side instead (see `xfixes_cursor.rs`).
|
||||
//
|
||||
// When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers
|
||||
// (offering shm too makes the compositor pick shm). The modifier list is advertised with
|
||||
// DONT_FIXATE so the compositor's allocator chooses one; we re-emit the fixated format in
|
||||
|
||||
@@ -1139,18 +1139,67 @@ fn dm_survives_masked_unit(dm: &str) -> bool {
|
||||
dm == "sddm.service"
|
||||
}
|
||||
|
||||
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||
/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see
|
||||
/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the
|
||||
/// caller degrades to attach.
|
||||
fn try_stop_display_manager(dm: &str) -> bool {
|
||||
Command::new("systemctl")
|
||||
.args(["stop", dm])
|
||||
/// The packaged privileged fallback for the display-manager takeover verbs: a root helper behind
|
||||
/// its own polkit action (`io.unom.punktfunk.dm-helper`, `allow_any` — the mechanism these
|
||||
/// distros use for their own session switcher, e.g. Nobara's `os-session-select`), so the managed
|
||||
/// takeover works out of the box on mask-fragile DM flavors with no hand-installed polkit rule.
|
||||
/// The helper derives the DM unit from the `display-manager.service` symlink itself, so this
|
||||
/// process never gets to name an arbitrary unit across the privilege boundary. Two layouts: the
|
||||
/// rpm/deb `libexec` path (what the shipped policy annotates) and Arch's `/usr/lib/<pkg>` (its
|
||||
/// PKGBUILD rewrites the annotation to match).
|
||||
const DM_HELPER_PATHS: &[&str] = &[
|
||||
"/usr/libexec/punktfunk/pf-dm-helper",
|
||||
"/usr/lib/punktfunk/pf-dm-helper",
|
||||
];
|
||||
|
||||
/// Run the packaged DM helper (`stop` | `restore`) via pkexec. `false` when the helper isn't
|
||||
/// installed (tarball/old package), pkexec is missing, or polkit denies the action.
|
||||
fn dm_helper(verb: &str) -> bool {
|
||||
let Some(helper) = DM_HELPER_PATHS
|
||||
.iter()
|
||||
.find(|p| std::path::Path::new(p).exists())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
Command::new("pkexec")
|
||||
.arg(helper)
|
||||
.arg(verb)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
|
||||
/// the SYSTEM bus first — succeeds as root or under an operator polkit rule scoped to the DM unit
|
||||
/// (see docs); fails cleanly otherwise ("interactive authentication required") — then the
|
||||
/// packaged pkexec helper. `false` means no privilege path exists and the caller degrades to
|
||||
/// attach.
|
||||
fn try_stop_display_manager(dm: &str) -> bool {
|
||||
let direct = Command::new("systemctl")
|
||||
.args(["stop", dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
direct || dm_helper("stop")
|
||||
}
|
||||
|
||||
/// Restore the display manager: `reset-failed` (a relogin loop may have tripped the unit's start
|
||||
/// limit, and a plain restart is refused until the accounting clears) + `restart` — its autologin
|
||||
/// session Exec brings the box's own session back up. Plain system-bus verbs first (root / an
|
||||
/// operator polkit rule), then the packaged pkexec helper, whose `restore` verb performs the same
|
||||
/// two steps as root.
|
||||
fn restore_display_manager(dm: &str) -> bool {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["reset-failed", dm])
|
||||
.status();
|
||||
let direct = Command::new("systemctl")
|
||||
.args(["restart", dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
direct || dm_helper("restore")
|
||||
}
|
||||
|
||||
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
|
||||
/// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so
|
||||
/// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but
|
||||
@@ -1219,10 +1268,13 @@ fn honor_session_select_switch(dm: String) {
|
||||
clear_takeover();
|
||||
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["reset-failed", &dm])
|
||||
.status();
|
||||
let _ = Command::new("systemctl").args(["start", &dm]).status();
|
||||
if !restore_display_manager(&dm) {
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
"gamescope: display-manager start was denied — the desktop switch may need a manual \
|
||||
`systemctl restart` of the DM"
|
||||
);
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
let active = Command::new("systemctl")
|
||||
@@ -1343,8 +1395,10 @@ fn stop_autologin_sessions() -> Result<()> {
|
||||
if !try_stop_display_manager(&dm) {
|
||||
bail!(
|
||||
"the box's gaming session is driven by {dm}, which does not survive a masked \
|
||||
session unit, and stopping it needs privilege — install the punktfunk \
|
||||
display-manager polkit rule (see docs) to enable the managed takeover"
|
||||
session unit, and stopping it needs privilege — the packaged pf-dm-helper \
|
||||
polkit action is missing or was denied (reinstall the punktfunk package, or \
|
||||
install the display-manager polkit rule from the docs) so the managed takeover \
|
||||
is unavailable"
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
@@ -1668,14 +1722,7 @@ fn do_restore_tv_session() {
|
||||
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
|
||||
// session Exec starts the gamescope unit itself.
|
||||
if let Some(dm) = dm {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["reset-failed", &dm])
|
||||
.status();
|
||||
let restart = Command::new("systemctl")
|
||||
.args(["restart", &dm])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
let restart = restore_display_manager(&dm);
|
||||
if restart {
|
||||
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
|
||||
} else if crate::try_recover_session() {
|
||||
|
||||
@@ -340,9 +340,19 @@ pub(crate) fn is_available() -> bool {
|
||||
/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon.
|
||||
const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22);
|
||||
|
||||
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing
|
||||
/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a
|
||||
/// gate. Returns the parsed version when it could read one.
|
||||
/// First gamescope that paints the Steam overlay (Shift+Tab / Quick Access Menu) into its built-in
|
||||
/// PipeWire node. `paint_pipewire()` is a *separate, reduced* composite from the display scanout;
|
||||
/// the overlay-window paint (gated on the consumer negotiating `gamescope_focus_appid == 0`, which
|
||||
/// we do by never advertising that property — see the capturer's EnumFormat builders) first ships
|
||||
/// in 3.16.23 (gamescope commits `ccd62074` + `f8b33d38`). Below this the overlay is *never* in the
|
||||
/// node, so it cannot appear in the stream no matter what the host does. The cursor and
|
||||
/// external-overlay / notification layers are excluded on *every* version (handled host-side).
|
||||
const MIN_GAMESCOPE_OVERLAY: (u32, u32, u32) = (3, 16, 23);
|
||||
|
||||
/// Best-effort: warn if the installed gamescope is older than [`MIN_GAMESCOPE`] (capture is
|
||||
/// unreliable) or than [`MIN_GAMESCOPE_OVERLAY`] (capture works but the Steam overlay can't reach
|
||||
/// the stream). Parsing failures are silent (don't block a possibly-fine custom build) — this is a
|
||||
/// diagnostic, not a gate. Returns the parsed version when it could read one.
|
||||
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
||||
let out = Command::new("gamescope").arg("--version").output().ok()?;
|
||||
// gamescope prints the version banner to stderr on some builds, stdout on others.
|
||||
@@ -360,6 +370,18 @@ pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
||||
capture deadlock against PipeWire ≥ 1.6 (a wedged link head-blocks the daemon); \
|
||||
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
|
||||
);
|
||||
} else if ver < MIN_GAMESCOPE_OVERLAY {
|
||||
// Capture is fine; the Steam overlay just won't be in the frame gamescope hands us.
|
||||
tracing::warn!(
|
||||
found = %format!("{}.{}.{}", ver.0, ver.1, ver.2),
|
||||
min = %format!(
|
||||
"{}.{}.{}",
|
||||
MIN_GAMESCOPE_OVERLAY.0, MIN_GAMESCOPE_OVERLAY.1, MIN_GAMESCOPE_OVERLAY.2
|
||||
),
|
||||
"gamescope is older than the first version that paints the Steam overlay (Shift+Tab / \
|
||||
Quick Access Menu) into its PipeWire node — the overlay will be absent from the \
|
||||
stream until you upgrade gamescope (the cursor is composited host-side regardless)"
|
||||
);
|
||||
}
|
||||
Some(ver)
|
||||
}
|
||||
@@ -379,7 +401,7 @@ fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE};
|
||||
use super::{parse_version, steam_appid_from_launch, MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY};
|
||||
|
||||
#[test]
|
||||
fn parses_steam_appid_from_launch() {
|
||||
@@ -425,4 +447,15 @@ mod tests {
|
||||
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
||||
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_threshold_brackets_the_fix() {
|
||||
// 3.16.22 captures fine but predates the overlay-in-pipewire paint — it sits in the
|
||||
// "capture works, overlay absent" window `[MIN_GAMESCOPE, MIN_GAMESCOPE_OVERLAY)`, which is
|
||||
// exactly the `else if` warn arm; 3.16.23 is the first to include the overlay.
|
||||
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
||||
assert!(parse_version("gamescope version 3.16.22").unwrap() < MIN_GAMESCOPE_OVERLAY);
|
||||
assert!(parse_version("gamescope version 3.16.23").unwrap() >= MIN_GAMESCOPE_OVERLAY);
|
||||
assert!(parse_version("gamescope version 3.16.25").unwrap() >= MIN_GAMESCOPE_OVERLAY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,16 +431,69 @@ fn web_setup(args: &[String]) -> Result<()> {
|
||||
) {
|
||||
eprintln!("warning: could not add the firewall rule for TCP 47992");
|
||||
}
|
||||
// 5. wait briefly for the host's mgmt token, then start (restart-on-failure picks it up otherwise)
|
||||
for _ in 0..30 {
|
||||
if token_path.exists() {
|
||||
break;
|
||||
// 5. Wait for EVERY file the launcher needs, then start the task and VERIFY it came up.
|
||||
//
|
||||
// `web-run.cmd` refuses to serve without the mgmt token AND the host identity cert, and the
|
||||
// host writes them in that order: the token during argument parsing (`main::parse_serve`,
|
||||
// milliseconds after `serve` starts), the cert only once `serve` reaches
|
||||
// `gamestream::cert::ServerIdentity::load_or_create` - behind a pure-Rust RSA-2048 keygen
|
||||
// whose prime search has a multi-second tail. Gating on the token alone therefore fired
|
||||
// `schtasks /run` while that keygen was still running: the launcher exited 1, and since the
|
||||
// task carries no trigger other than boot (and Task Scheduler's restart-on-failure does not
|
||||
// reliably fire for a plain non-zero exit code), a freshly installed console stayed down
|
||||
// until the next reboot. Gate on `cert.pem` - written LAST, after `key.pem` - so all three
|
||||
// files are on disk before the first start.
|
||||
let cert_path = data_dir.join("cert.pem");
|
||||
if !wait_for_files(&[token_path.as_path(), cert_path.as_path()], 90) {
|
||||
eprintln!(
|
||||
"warning: the host service has not written {} + {} yet - not starting the console now; \
|
||||
it will start at the next boot (or run: schtasks /run /tn {WEB_TASK})",
|
||||
token_path.display(),
|
||||
cert_path.display()
|
||||
);
|
||||
println!("web console set up (https://<host-ip>:47992)");
|
||||
return Ok(());
|
||||
}
|
||||
if start_web_task() {
|
||||
println!("web console set up + started (https://<host-ip>:47992)");
|
||||
} else {
|
||||
// Never claim "started" when it isn't: the launcher's own diagnostics go to a task with no
|
||||
// console, so this warning is the only trace an operator gets at install time.
|
||||
eprintln!(
|
||||
"warning: the {WEB_TASK} task did not bring up a listener on :47992 - check its Last Run \
|
||||
Result in Task Scheduler; the console will be retried at the next boot"
|
||||
);
|
||||
println!("web console set up (https://<host-ip>:47992)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll until every path exists, or `secs` elapse. Returns whether they all showed up.
|
||||
fn wait_for_files(paths: &[&Path], secs: u64) -> bool {
|
||||
for _ in 0..secs {
|
||||
if paths.iter().all(|p| p.exists()) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
paths.iter().all(|p| p.exists())
|
||||
}
|
||||
|
||||
/// `schtasks /run` + verify: the command reports only that a start was *attempted*, so poll for the
|
||||
/// console's own listener and retry a couple of times before giving up. Returns whether :47992 ended
|
||||
/// up listening.
|
||||
fn start_web_task() -> bool {
|
||||
for _ in 0..3 {
|
||||
run_quiet("schtasks", &["/run", "/tn", WEB_TASK]);
|
||||
println!("web console set up + started (https://<host-ip>:47992)");
|
||||
Ok(())
|
||||
// Bun binds the port a second or two after launch on a warm box; allow for a cold one.
|
||||
for _ in 0..10 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
if !web_listener_pids().is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Source: a non-empty `--password-file` (fresh install) > keep existing (upgrade) > random fallback.
|
||||
@@ -505,30 +558,57 @@ fn random_password() -> String {
|
||||
/// ("LISTENING"/"ABHOEREN"/...) is never parsed.
|
||||
fn stop_web_console() {
|
||||
run_quiet("schtasks", &["/end", "/tn", WEB_TASK]);
|
||||
for line in run_capture("netstat", &["-ano", "-p", "tcp"]).lines() {
|
||||
let toks: Vec<&str> = line.split_whitespace().collect();
|
||||
if toks.len() >= 5
|
||||
&& toks[0].eq_ignore_ascii_case("tcp")
|
||||
&& toks[1].ends_with(":47992")
|
||||
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0")
|
||||
{
|
||||
let pid = toks[toks.len() - 1];
|
||||
if !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()) {
|
||||
run_quiet("taskkill", &["/PID", pid, "/F"]);
|
||||
}
|
||||
}
|
||||
for pid in web_listener_pids() {
|
||||
run_quiet("taskkill", &["/PID", &pid, "/F"]);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
/// Register the boot/SYSTEM/restart-on-failure task via a generated Task Scheduler XML (`schtasks /xml`,
|
||||
/// no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
|
||||
/// PIDs owning a LISTEN socket on :47992. Also the console's liveness probe (`start_web_task`).
|
||||
fn web_listener_pids() -> Vec<String> {
|
||||
run_capture("netstat", &["-ano", "-p", "tcp"])
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let toks: Vec<&str> = line.split_whitespace().collect();
|
||||
let listening = toks.len() >= 5
|
||||
&& toks[0].eq_ignore_ascii_case("tcp")
|
||||
&& toks[1].ends_with(":47992")
|
||||
&& (toks[2] == "0.0.0.0:0" || toks[2] == "[::]:0");
|
||||
let pid = *toks.last()?;
|
||||
(listening && !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()))
|
||||
.then(|| pid.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Register the boot+logon/SYSTEM/restart-on-failure task via a generated Task Scheduler XML
|
||||
/// (`schtasks /xml`, no COM). The XML declares UTF-16, so it's written UTF-16LE+BOM.
|
||||
///
|
||||
/// Two triggers, because boot alone left too many holes: a console that lost the install-time start
|
||||
/// (or a box where the host service was started only later) had to wait for a full reboot. Logon is
|
||||
/// free insurance - `MultipleInstancesPolicy=IgnoreNew` makes a redundant start a no-op. If a
|
||||
/// Task Scheduler build rejects the two-trigger XML we fall back to the boot-only form rather than
|
||||
/// fail registration outright (no task at all is strictly worse than a boot-only task).
|
||||
fn register_web_task(cmd: &Path) -> Result<()> {
|
||||
let cmd_xml = xml_escape(&cmd.to_string_lossy());
|
||||
let boot = "<BootTrigger><Enabled>true</Enabled></BootTrigger>";
|
||||
let boot_and_logon = "<BootTrigger><Enabled>true</Enabled></BootTrigger>\
|
||||
<LogonTrigger><Enabled>true</Enabled></LogonTrigger>";
|
||||
if try_register_web_task(&cmd_xml, boot_and_logon) || try_register_web_task(&cmd_xml, boot) {
|
||||
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("schtasks /create {WEB_TASK} failed")
|
||||
}
|
||||
}
|
||||
|
||||
/// One `schtasks /create /xml` attempt with the given `<Triggers>` body. Returns whether it took.
|
||||
fn try_register_web_task(cmd_xml: &str, triggers: &str) -> bool {
|
||||
let xml = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n\
|
||||
<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n\
|
||||
<RegistrationInfo><Description>punktfunk web management console (Nitro SSR on bun, :47992)</Description></RegistrationInfo>\n\
|
||||
<Triggers><BootTrigger><Enabled>true</Enabled></BootTrigger></Triggers>\n\
|
||||
<Triggers>{triggers}</Triggers>\n\
|
||||
<Principals><Principal id=\"Author\"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>\n\
|
||||
<Settings>\n\
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n\
|
||||
@@ -538,12 +618,13 @@ fn register_web_task(cmd: &Path) -> Result<()> {
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n\
|
||||
<RestartOnFailure><Interval>PT1M</Interval><Count>10</Count></RestartOnFailure>\n\
|
||||
</Settings>\n\
|
||||
<Actions Context=\"Author\"><Exec><Command>{}</Command></Exec></Actions>\n\
|
||||
</Task>",
|
||||
xml_escape(&cmd.to_string_lossy())
|
||||
<Actions Context=\"Author\"><Exec><Command>{cmd_xml}</Command></Exec></Actions>\n\
|
||||
</Task>"
|
||||
);
|
||||
let xml_path = std::env::temp_dir().join("punktfunk-web-task.xml");
|
||||
write_utf16le_bom(&xml_path, &xml)?;
|
||||
if write_utf16le_bom(&xml_path, &xml).is_err() {
|
||||
return false;
|
||||
}
|
||||
let ok = run_quiet(
|
||||
"schtasks",
|
||||
&[
|
||||
@@ -556,12 +637,7 @@ fn register_web_task(cmd: &Path) -> Result<()> {
|
||||
],
|
||||
);
|
||||
let _ = std::fs::remove_file(&xml_path);
|
||||
if ok {
|
||||
println!("registered scheduled task {WEB_TASK} -> {}", cmd.display());
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("schtasks /create {WEB_TASK} failed")
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
fn write_utf16le_bom(path: &Path, s: &str) -> Result<()> {
|
||||
|
||||
@@ -40,8 +40,16 @@ depends on the display manager driving the autologin:
|
||||
|
||||
- **SDDM** (Bazzite, SteamOS): handled automatically — no setup.
|
||||
- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager
|
||||
itself for the length of the stream and restart it afterwards, which needs privilege. Allow it
|
||||
with a polkit rule (adjust the unit and user names to your box):
|
||||
itself for the length of the stream and restart it afterwards, which needs privilege. The
|
||||
packages ship that privilege: a root helper (`/usr/libexec/punktfunk/pf-dm-helper`) behind its
|
||||
own polkit action (`io.unom.punktfunk.dm-helper`), invoked automatically when the plain
|
||||
`systemctl` verbs are denied — no setup. The helper only stops/restores the unit the
|
||||
`display-manager.service` symlink points at, the same class of local-seat operation these
|
||||
distros already authorize for their own session switcher (Nobara's `os-session-select`).
|
||||
|
||||
Installed from a tarball, or prefer not to ship the `allow_any` action? Remove the `.policy`
|
||||
file and use a polkit rule scoped to your user instead (adjust the unit and user names to your
|
||||
box) — the host tries the plain verbs first, so the rule takes precedence:
|
||||
|
||||
```js
|
||||
// /etc/polkit-1/rules.d/49-punktfunk-dm.rules
|
||||
@@ -54,15 +62,16 @@ depends on the display manager driving the autologin:
|
||||
});
|
||||
```
|
||||
|
||||
Without the rule the host degrades safely: it **attaches** to the live Gaming Mode session
|
||||
instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the
|
||||
display manager. If the display-manager restart ever loses its privilege mid-restore,
|
||||
`PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the
|
||||
fallback.
|
||||
With no privilege path at all the host degrades safely: it **attaches** to the live Gaming Mode
|
||||
session instead (Game Mode stays on the box's display at the box's own resolution, mirrored to
|
||||
the client — if your monitor stays on and the stream runs at the desktop's resolution, this is
|
||||
what happened; check the host log for "managed takeover unavailable"). If the display-manager
|
||||
restart ever loses its privilege mid-restore, `PUNKTFUNK_RECOVER_SESSION_CMD` (see
|
||||
[Configuration](/docs/configuration)) is fired as the fallback.
|
||||
|
||||
With the rule in place the **in-stream session switch round-trips** in managed mode: Steam's
|
||||
"Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session and the
|
||||
stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
|
||||
With the takeover authorized the **in-stream session switch round-trips** in managed mode:
|
||||
Steam's "Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session
|
||||
and the stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
|
||||
|
||||
## Session following
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ the Windows specifics follow.
|
||||
|
||||
The installer also sets up the **web management console** (status, paired devices, the PIN pairing
|
||||
flow): it bundles the console plus its own runtime and runs it as the **`PunktfunkWeb`** task on
|
||||
**`https://<this-PC>:47992`**, starting at boot.
|
||||
**`https://<this-PC>:47992`**, starting at boot and at sign-in.
|
||||
|
||||
#### Console login password
|
||||
|
||||
|
||||
@@ -129,6 +129,14 @@ package_punktfunk-host() {
|
||||
install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host"
|
||||
# /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID)
|
||||
install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules"
|
||||
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
|
||||
# stop/restore the display manager for the stream. Arch has no /usr/libexec — install under
|
||||
# /usr/lib/punktfunk and rewrite the policy's exec.path annotation to match (the host probes both).
|
||||
install -Dm0755 "$R/scripts/pf-dm-helper" "$pkgdir/usr/lib/punktfunk/pf-dm-helper"
|
||||
install -Dm0644 "$R/scripts/io.unom.punktfunk.dm-helper.policy" \
|
||||
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||
sed -i 's#/usr/libexec/punktfunk/pf-dm-helper#/usr/lib/punktfunk/pf-dm-helper#' \
|
||||
"$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads)
|
||||
install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf"
|
||||
# 32 MB UDP socket buffers (send-side headroom at high bitrate)
|
||||
|
||||
@@ -52,6 +52,11 @@ SHAREDIR="$STAGE/usr/share/$PKG"
|
||||
# --- file layout (matches the RPM %install) ----------------------------------
|
||||
install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG"
|
||||
install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules"
|
||||
# Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can
|
||||
# stop/restore the display manager for the stream (the helper derives the DM unit itself).
|
||||
install -Dm0755 scripts/pf-dm-helper "$STAGE/usr/libexec/punktfunk/pf-dm-helper"
|
||||
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy \
|
||||
"$STAGE/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy"
|
||||
# vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads).
|
||||
install -Dm0644 scripts/punktfunk-modules.conf "$STAGE/usr/lib/modules-load.d/punktfunk.conf"
|
||||
# UDP socket-buffer tuning (32 MB) — without it the kernel clamps the host's SO_SNDBUF to ~416 KB
|
||||
|
||||
@@ -249,6 +249,12 @@ install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-h
|
||||
# udev rule — /dev/uinput access for virtual gamepads (input group).
|
||||
install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules
|
||||
|
||||
# Managed gamescope takeover on DM-autologin boxes (Nobara's plasmalogin): a root helper + polkit
|
||||
# action let the host stop/restore the display manager for the stream without a hand-installed
|
||||
# polkit rule. The helper derives the DM unit itself — callers can't name arbitrary units.
|
||||
install -Dm0755 scripts/pf-dm-helper %{buildroot}%{_libexecdir}/punktfunk/pf-dm-helper
|
||||
install -Dm0644 scripts/io.unom.punktfunk.dm-helper.policy %{buildroot}%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
|
||||
|
||||
# vhci-hcd autoload — the usbip transport that makes the virtual Steam Deck controller a
|
||||
# real USB device (Steam Input only adopts those; the UHID fallback is invisible to Steam).
|
||||
install -Dm0644 scripts/punktfunk-modules.conf %{buildroot}%{_prefix}/lib/modules-load.d/punktfunk.conf
|
||||
@@ -383,6 +389,9 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/
|
||||
%{_bindir}/punktfunk-host
|
||||
%{_bindir}/punktfunk-tray
|
||||
%{_udevrulesdir}/60-punktfunk.rules
|
||||
%dir %{_libexecdir}/punktfunk
|
||||
%{_libexecdir}/punktfunk/pf-dm-helper
|
||||
%{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy
|
||||
%{_prefix}/lib/modules-load.d/punktfunk.conf
|
||||
%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf
|
||||
%{_prefix}/lib/firewalld/services/punktfunk-gamestream.xml
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
|
||||
<policyconfig>
|
||||
<vendor>Punktfunk</vendor>
|
||||
<vendor_url>https://punktfunk.unom.io</vendor_url>
|
||||
|
||||
<!-- Stop/restore the box's display manager for a managed gamescope takeover. The helper derives
|
||||
the unit from the display-manager.service symlink itself (no caller-chosen units), so this
|
||||
grant is scoped to the box's own local-seat session lifecycle — the same class of operation
|
||||
these distros already authorize for their session switcher (e.g. Nobara's
|
||||
os-session-select, allow_any). allow_any because the host commonly runs sessionless (a
|
||||
lingering user unit, no polkit agent), where interactive auth can never be answered. -->
|
||||
<action id="io.unom.punktfunk.dm-helper">
|
||||
<description>Stop or restore the display manager for a Punktfunk stream</description>
|
||||
<message>Authentication is required to switch the display manager for a Punktfunk stream</message>
|
||||
<defaults>
|
||||
<allow_any>yes</allow_any>
|
||||
<allow_inactive>yes</allow_inactive>
|
||||
<allow_active>yes</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/libexec/punktfunk/pf-dm-helper</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/bin/sh
|
||||
# Privileged display-manager verbs for the punktfunk managed gamescope takeover.
|
||||
#
|
||||
# On DM-autologin boxes whose display manager does not survive a masked session unit (Nobara's
|
||||
# plasmalogin, unknown DMs), taking the Gaming Mode session over means stopping the display
|
||||
# manager for the length of the stream and restarting it afterwards — root-only operations. The
|
||||
# host invokes this helper via pkexec under its own polkit action
|
||||
# (io.unom.punktfunk.dm-helper.policy, installed by the packages), the same mechanism these
|
||||
# distros use for their own session switching (Nobara's os-session-select).
|
||||
#
|
||||
# The unit is NEVER caller-controlled: it is derived here from the display-manager.service alias
|
||||
# symlink, so the polkit grant's blast radius is exactly "the box's own display manager" — a
|
||||
# local-seat operation, not arbitrary unit management.
|
||||
set -eu
|
||||
|
||||
dm_unit() {
|
||||
target=$(readlink /etc/systemd/system/display-manager.service) || {
|
||||
echo "pf-dm-helper: no display-manager.service alias — no display manager to manage" >&2
|
||||
exit 1
|
||||
}
|
||||
basename "$target"
|
||||
}
|
||||
|
||||
case "${1-}" in
|
||||
stop)
|
||||
exec systemctl stop "$(dm_unit)"
|
||||
;;
|
||||
restore)
|
||||
dm=$(dm_unit)
|
||||
# reset-failed first: a relogin loop may have tripped the unit's start limit, and a plain
|
||||
# restart would be refused until the accounting is cleared.
|
||||
systemctl reset-failed "$dm" 2>/dev/null || :
|
||||
exec systemctl restart "$dm"
|
||||
;;
|
||||
*)
|
||||
echo "usage: pf-dm-helper stop|restore" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -12,6 +12,11 @@ Description=punktfunk management web console
|
||||
# web-init generates the login password; the host writes the mgmt token. Order after both.
|
||||
After=punktfunk-web-init.service punktfunk-host.service
|
||||
Wants=punktfunk-web-init.service
|
||||
# Retry indefinitely while the host is still writing the mgmt token + identity cert. Without this,
|
||||
# systemd's default rate limit (5 starts / 10 s) plus RestartSec=2 gives up permanently after ~10 s
|
||||
# - so a console enabled before the host's first run stayed dead until someone restarted it by hand
|
||||
# (the same defect the Windows PunktfunkWeb task had).
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -16,17 +16,26 @@ set "CERTFILE=%PFDATA%\cert.pem"
|
||||
set "KEYFILE=%PFDATA%\key.pem"
|
||||
|
||||
rem The host's `serve` writes the mgmt token + its identity cert/key on first run. Until they exist
|
||||
rem we have no credential and no TLS material, so fail and let the task's restart-on-failure retry
|
||||
rem (mirrors the Linux unit's Restart=on-failure waiting for the host to create them) rather than
|
||||
rem silently downgrading to plain HTTP.
|
||||
if not exist "%TOKENFILE%" (
|
||||
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%CERTFILE%" (
|
||||
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
|
||||
rem we have no credential and no TLS material, so WAIT rather than silently downgrading to plain HTTP.
|
||||
rem
|
||||
rem Wait in-process instead of exiting 1 and hoping the task's restart-on-failure retries: Task
|
||||
rem Scheduler does not reliably restart on a plain non-zero exit code, so a console that started
|
||||
rem before the host finished writing those files (the token lands at argument parse, the cert only
|
||||
rem after the RSA-2048 keygen) used to stay down until the next reboot. ~5 min at 2 s, then give up
|
||||
rem so a genuinely broken install still surfaces as a failed task rather than one that runs forever.
|
||||
rem `timeout` needs a console this task does not have, so `ping -n 3` is the 2-second sleep.
|
||||
set /a PFWAITS=0
|
||||
:pfwait
|
||||
if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready
|
||||
if %PFWAITS% GEQ 150 (
|
||||
echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host service running?
|
||||
exit /b 1
|
||||
)
|
||||
if %PFWAITS%==0 echo [punktfunk-web] waiting for the host service to write the mgmt token + identity cert...
|
||||
set /a PFWAITS+=1
|
||||
ping -n 3 127.0.0.1 >nul 2>&1
|
||||
goto pfwait
|
||||
:pfready
|
||||
|
||||
rem Both files are single KEY=VALUE lines (LF), written 0600/ACL'd: PUNKTFUNK_MGMT_TOKEN=... and
|
||||
rem PUNKTFUNK_UI_PASSWORD=... . Split on the first '=' and import each into the environment.
|
||||
|
||||
+13
-8
@@ -18,16 +18,21 @@ set "CERTFILE=%PFDATA%\cert.pem"
|
||||
set "KEYFILE=%PFDATA%\key.pem"
|
||||
|
||||
rem The host's `serve` writes the mgmt token + identity cert on first run. Until they exist the proxy
|
||||
rem has no credential and no TLS material, so fail and let restart-on-failure retry (mirrors the
|
||||
rem installed launcher / Linux unit) rather than silently serving plain HTTP.
|
||||
if not exist "%TOKENFILE%" (
|
||||
echo [punktfunk-web] mgmt token not present yet at "%TOKENFILE%" - waiting for the host service.
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%CERTFILE%" (
|
||||
echo [punktfunk-web] host identity cert not present yet at "%CERTFILE%" - waiting for the host service.
|
||||
rem has no credential and no TLS material, so WAIT for them (mirrors the installed launcher) rather
|
||||
rem than silently serving plain HTTP - see scripts\windows\web-run.cmd for why waiting here beats
|
||||
rem exiting 1 and relying on the task's restart-on-failure. ~5 min at 2 s, then give up.
|
||||
set /a PFWAITS=0
|
||||
:pfwait
|
||||
if exist "%TOKENFILE%" if exist "%CERTFILE%" goto pfready
|
||||
if %PFWAITS% GEQ 150 (
|
||||
echo [punktfunk-web] gave up waiting for "%TOKENFILE%" + "%CERTFILE%" - is the punktfunk host running?
|
||||
exit /b 1
|
||||
)
|
||||
if %PFWAITS%==0 echo [punktfunk-web] waiting for the host to write the mgmt token + identity cert...
|
||||
set /a PFWAITS+=1
|
||||
ping -n 3 127.0.0.1 >nul 2>&1
|
||||
goto pfwait
|
||||
:pfready
|
||||
|
||||
rem Both files are single KEY=VALUE lines: PUNKTFUNK_MGMT_TOKEN=... and PUNKTFUNK_UI_PASSWORD=... .
|
||||
rem Split on the first '=' and import each into the environment.
|
||||
|
||||
Reference in New Issue
Block a user