From f94191d92728dcc0840d61ce4dd7a5522cedfdf4 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 28 Jul 2026 16:46:35 +0200 Subject: [PATCH] fix(vdisplay/linux): stop destroying the user's portal config, and honour the /tmp promise the docs made MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both wlr-family backends need ONE key set in a config file the user also owns — `chooser_cmd` in xdpw's `[screencast]`, `custom_picker_binary` in xdph's `screencopy { … }`. Both did it by `fs::write`-ing a COMPLETE file over whatever was there, so every other setting the user had in `~/.config/xdg-desktop-portal-wlr/config` or `~/.config/hypr/xdph.conf` was destroyed on first connect — silently, permanently, no backup. They now set that one key in place and keep every other line byte-for-byte, with a one-time `create_new` backup the first time we touch a file we did not write (once, so a later edit cannot overwrite the user's ORIGINAL with our own earlier output). The merge lives in its own module because a merge is only worth doing if it cannot corrupt what it merges into: seven tests cover the three cases per grammar — block absent, key present, key absent — plus idempotence (which is what keeps the caller from restarting the portal on every connect) and the empty-file shape. Four of them FAIL against the old whole-file write, so they are not vacuous. The module is declared unconditionally though only Linux calls it: the logic is pure string handling, and tests that guard a user's real config should run on every platform's CI, not only where the callers compile. Separately, seven call sites resolved the runtime dir as `env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into())` while their own doc comments promised the opposite — "per-user, 0700 — NOT a world-writable /tmp path another local user could pre-create or rewrite between our write and xdph's read". One of those paths is an executable xdph then RUNS. `Ok("")` was the same defect's other half: it yielded a path relative to the process CWD rather than any runtime dir. All seven now go through one resolver that applies the rule `session.rs` already had (non-empty, else `/run/user/`, never /tmp). The picker shim also gets its mode at CREATION rather than a chmod afterwards, so it is never briefly world-readable while being a file something else executes. And identity.rs was the only writer in the workspace creating `pf_paths::config_dir()` — which holds the host private key, the pairing allow-list and the mgmt token — with a plain `create_dir_all` instead of the 0700 `create_private_dir` every other writer uses. Co-Authored-By: Claude Opus 5 (1M context) --- crates/pf-vdisplay/src/lib.rs | 9 + crates/pf-vdisplay/src/vdisplay/identity.rs | 14 +- .../src/vdisplay/linux/gamescope.rs | 8 +- .../src/vdisplay/linux/hyprland.rs | 56 ++-- .../src/vdisplay/linux/portal_config.rs | 239 ++++++++++++++++++ .../pf-vdisplay/src/vdisplay/linux/wlroots.rs | 38 +-- crates/pf-vdisplay/src/vdisplay/session.rs | 13 + 7 files changed, 325 insertions(+), 52 deletions(-) create mode 100644 crates/pf-vdisplay/src/vdisplay/linux/portal_config.rs diff --git a/crates/pf-vdisplay/src/lib.rs b/crates/pf-vdisplay/src/lib.rs index f21a6994..efba7777 100644 --- a/crates/pf-vdisplay/src/lib.rs +++ b/crates/pf-vdisplay/src/lib.rs @@ -597,6 +597,15 @@ pub(crate) mod identity; #[path = "vdisplay/admission.rs"] pub mod admission; +/// Editing the user's xdg-desktop-portal configs in place — the wlr-family backends both need one +/// key set in a file the USER owns, and used to overwrite the whole thing. +/// +/// Declared unconditionally although only the Linux backends call it: the merge is pure string +/// handling, so its tests — which are what make a merge safe to run against a user's real config — +/// should run on every platform's CI rather than only where the callers compile. +#[path = "vdisplay/linux/portal_config.rs"] +mod portal_config; + #[cfg(target_os = "linux")] #[path = "vdisplay/linux/hyprland.rs"] mod hyprland; diff --git a/crates/pf-vdisplay/src/vdisplay/identity.rs b/crates/pf-vdisplay/src/vdisplay/identity.rs index 4d55c191..6115e8ae 100644 --- a/crates/pf-vdisplay/src/vdisplay/identity.rs +++ b/crates/pf-vdisplay/src/vdisplay/identity.rs @@ -137,13 +137,18 @@ impl DisplayIdentityMap { } /// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may - /// re-derive an id (one scaling re-set). Not a credential, so a plain (non-ACL'd) write is fine. + /// re-derive an id (one scaling re-set). The CONTENTS are not a credential, so the file itself + /// is a plain write — but the directory it lands in is `pf_paths::config_dir()`, which is, so it + /// is created with the 0700 helper rather than `create_dir_all`. fn persist(&self) { let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else { return; }; if let Some(dir) = self.path.parent() { - let _ = std::fs::create_dir_all(dir); + // `create_private_dir`, not `create_dir_all`: this is `pf_paths::config_dir()`, which + // also holds the host private key, the pairing allow-list and the mgmt token. Every + // other writer in the workspace uses the 0700 helper; these two did not. + let _ = pf_paths::create_private_dir(dir); } let tmp = self.path.with_extension("json.tmp"); if std::fs::write(&tmp, &bytes).is_ok() { @@ -265,7 +270,10 @@ impl ScaleMap { return; }; if let Some(dir) = self.path.parent() { - let _ = std::fs::create_dir_all(dir); + // `create_private_dir`, not `create_dir_all`: this is `pf_paths::config_dir()`, which + // also holds the host private key, the pairing allow-list and the mgmt token. Every + // other writer in the workspace uses the 0700 helper; these two did not. + let _ = pf_paths::create_private_dir(dir); } let tmp = self.path.with_extension("json.tmp"); if std::fs::write(&tmp, &bytes).is_ok() { diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index f4ca8d17..ed983734 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -135,7 +135,7 @@ static SPAWN_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::n /// only if unset). Replaces the shared `/tmp/punktfunk-gamescope.log` so concurrent spawns don't /// clobber each other's `stream available on node ID:` line. fn spawn_log_path(inst: u64) -> std::path::PathBuf { - let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + let base = crate::session::runtime_dir(); std::path::Path::new(&base).join(format!("punktfunk-gamescope-{inst}.log")) } @@ -178,7 +178,7 @@ struct TakeoverState { /// Path of the persisted [`TakeoverState`], under `$XDG_RUNTIME_DIR` (per-user, 0700, tmpfs — cleared /// on reboot, which is correct: a reboot restarts the autologin itself). fn takeover_state_path() -> std::path::PathBuf { - let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + let base = crate::session::runtime_dir(); std::path::Path::new(&base).join("punktfunk-session-takeover.json") } @@ -820,7 +820,7 @@ fn systemctl_user(args: &[&str]) { /// Directory holding the per-user `gamescope` PATH-shim (tmpfs under `XDG_RUNTIME_DIR`). fn headless_shim_dir() -> std::path::PathBuf { - let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + let base = crate::session::runtime_dir(); std::path::Path::new(&base).join("punktfunk-gsbin") } @@ -2148,7 +2148,7 @@ fn point_injector_at_eis() { /// Path of the host-written `GAMESCOPE_BIN` wrapper (per-user, in tmpfs). fn gamescope_bin_wrapper_path() -> std::path::PathBuf { - let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); + let base = crate::session::runtime_dir(); std::path::Path::new(&base).join("punktfunk-gamescope-bin") } diff --git a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs index c0daf943..ab4a57e9 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs @@ -48,7 +48,7 @@ use std::time::{Duration, Instant}; /// pre-create or rewrite between our write and xdph's read (steer capture elsewhere). Mirrors the /// wlroots chooser file. fn selection_file() -> String { - let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + let dir = crate::session::runtime_dir(); format!("{dir}/punktfunk-xdph-output") } @@ -56,7 +56,7 @@ fn selection_file() -> String { /// `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. fn picker_shim_path() -> String { - let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + let dir = crate::session::runtime_dir(); format!("{dir}/punktfunk-xdph-picker.sh") } @@ -69,19 +69,6 @@ fn picker_selection_line(name: &str) -> String { format!("[SELECTION]screen:{name}\n") } -/// The managed xdph config: point the screencopy custom picker at our shim so headless output -/// selection needs no GUI. xdph reads its config at startup, so a change restarts it (see -/// [`ensure_xdph_config`]). The *selection* is the per-session file, not this static config. -fn xdph_config() -> String { - format!( - "# managed by punktfunk (vdisplay/hyprland.rs) — headless per-session output selection.\n\ -screencopy {{\n\ - custom_picker_binary = {}\n\ -}}\n", - picker_shim_path() - ) -} - /// Monotonic per-process counter for headless output names (`PF-1`, `PF-2`, …). Named outputs kill /// the before/after diff race sway needs (D6). static OUTPUT_SEQ: AtomicU32 = AtomicU32::new(0); @@ -553,13 +540,20 @@ fn ensure_xdph_config() -> Result<()> { if std::fs::read_to_string(&shim).is_ok_and(|c| c == shim_body) { // already installed } else { - std::fs::write(&shim, &shim_body).with_context(|| format!("write {shim}"))?; - #[cfg(target_os = "linux")] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o700)) - .with_context(|| format!("chmod {shim}"))?; - } + // Mode set AT CREATION, not chmod-ed after: xdph EXECUTES this file, and a + // write-then-chmod leaves it briefly at the umask default. (It also lives in a 0700 + // runtime dir now — see `session::runtime_dir` — so this is defence in depth.) + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o700) + .open(&shim) + .with_context(|| format!("write {shim}"))?; + f.write_all(shim_body.as_bytes()) + .with_context(|| format!("write {shim}"))?; } // 2. Write the managed xdph config and restart xdph on change. @@ -567,15 +561,19 @@ fn ensure_xdph_config() -> Result<()> { .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 dir = base.join("hypr"); - let path = dir.join("xdph.conf"); - let cfg = xdph_config(); - if std::fs::read_to_string(&path).is_ok_and(|c| c == cfg) { + 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, + )?; + if !changed { return Ok(()); } - std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?; - std::fs::write(&path, &cfg).with_context(|| format!("write {}", path.display()))?; - tracing::info!(path = %path.display(), "wrote managed xdg-desktop-portal-hyprland config"); + tracing::info!(path = %path.display(), "pointed xdg-desktop-portal-hyprland at the managed picker shim"); let _ = Command::new("systemctl") .args([ "--user", diff --git a/crates/pf-vdisplay/src/vdisplay/linux/portal_config.rs b/crates/pf-vdisplay/src/vdisplay/linux/portal_config.rs new file mode 100644 index 00000000..17930cd4 --- /dev/null +++ b/crates/pf-vdisplay/src/vdisplay/linux/portal_config.rs @@ -0,0 +1,239 @@ +//! Editing the user's xdg-desktop-portal config files WITHOUT eating the rest of them. +//! +//! Both wlr-family backends need one key set in one block of a config the USER also owns: +//! `chooser_cmd` in xdpw's `[screencast]`, `custom_picker_binary` in xdph's `screencopy { … }`. +//! Both used to do it with a flat `std::fs::write` of a complete file, so anything else the user +//! had in `~/.config/xdg-desktop-portal-wlr/config` or `~/.config/hypr/xdph.conf` was destroyed on +//! first connect, silently and permanently. +//! +//! So: set the one key in place, keep every other line byte-for-byte, and take a one-time backup +//! the first time we touch a file we did not write. A merge is only worth doing if it cannot +//! corrupt what it merges into — hence the tests at the bottom, which are the point of this module. + +use anyhow::{Context, Result}; +use std::path::Path; + +/// The two config grammars this crate writes into. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Block<'a> { + /// INI: a `[name]` header, running to the next `[`-header or EOF. `key=value`. + Ini(&'a str), + /// hyprlang: `name {` … `}`. `key = value`, conventionally indented. + Hyprlang(&'a str), +} + +/// Is `line` the header that opens `block`? +fn opens(line: &str, block: Block<'_>) -> bool { + let t = line.trim(); + match block { + Block::Ini(name) => t == format!("[{name}]"), + // `name {` — tolerate `name{` and extra spacing. + Block::Hyprlang(name) => { + t.strip_suffix('{').map(str::trim_end) == Some(name) || t == format!("{name} {{") + } + } +} + +/// Does `line` assign `key`? Matches on the key alone so a changed VALUE is still recognised as +/// ours to replace (that is the whole point — the shim path moves with `$XDG_RUNTIME_DIR`). +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. + let end_at = lines + .iter() + .enumerate() + .skip(open_at + 1) + .find(|(_, l)| match block { + Block::Ini(_) => l.trim_start().starts_with('['), + Block::Hyprlang(_) => l.trim() == "}", + }) + .map(|(i, _)| i) + .unwrap_or(lines.len()); + + let mut out: Vec = 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(); + out[i] = assignment(&indent); + } else { + let indent = match block { + Block::Ini(_) => "", + Block::Hyprlang(_) => " ", + }; + out.insert(end_at, assignment(indent)); + } + let mut joined = out.join("\n"); + if existing.ends_with('\n') || !joined.ends_with('\n') { + joined.push('\n'); + } + joined +} + +/// Read `path`, set `key` in `block`, write it back — and back the original up ONCE, the first time +/// we touch a file we did not write. Returns `true` when the file changed (the caller restarts the +/// portal only then). +pub(crate) fn ensure_key(path: &Path, block: Block<'_>, key: &str, value: &str) -> Result { + let existing = std::fs::read_to_string(path).unwrap_or_default(); + let updated = upsert(&existing, block, key, value); + if updated == existing { + return Ok(false); + } + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).with_context(|| format!("mkdir {}", dir.display()))?; + } + // One-time backup. `create_new` makes this genuinely once: a later edit must not overwrite the + // user's ORIGINAL with our own previous output. + if !existing.is_empty() { + let backup = path.with_extension("punktfunk-backup"); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&backup) + { + Ok(mut f) => { + use std::io::Write; + let _ = f.write_all(existing.as_bytes()); + tracing::info!( + backup = %backup.display(), + "backed up the existing portal config before editing it" + ); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => tracing::warn!( + backup = %backup.display(), + error = %e, + "could not back up the existing portal config; editing it anyway" + ), + } + } + std::fs::write(path, &updated).with_context(|| format!("write {}", path.display()))?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_absent_block_is_appended_and_the_rest_survives() { + let user = "[somethingelse]\nkeep=me\n"; + let out = upsert(user, Block::Ini("screencast"), "chooser_cmd", "cat x"); + assert!( + out.contains("[somethingelse]\nkeep=me"), + "user content kept" + ); + assert!(out.contains("[screencast]\nchooser_cmd=cat x")); + } + + #[test] + fn an_existing_key_is_replaced_in_place() { + 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")); + assert!(out.contains("chooser_type=simple"), "sibling key kept"); + assert!(out.contains("output_name=DP-1"), "sibling key kept"); + } + + #[test] + fn a_key_missing_from_an_existing_block_is_inserted_into_it() { + let user = "[screencast]\nchooser_type=simple\n\n[other]\nx=1\n"; + let out = upsert(user, Block::Ini("screencast"), "chooser_cmd", "cat x"); + let cmd = out.find("chooser_cmd").expect("inserted"); + let other = out.find("[other]").expect("kept"); + assert!( + cmd < other, + "must land inside [screencast], not after [other]" + ); + assert!(out.contains("x=1"), "the later section survives"); + } + + #[test] + fn hyprlang_blocks_are_edited_in_place_too() { + let user = "misc {\n keep = 1\n}\n\nscreencopy {\n custom_picker_binary = OLD\n allow_token_by_default = true\n}\n"; + let out = upsert( + user, + Block::Hyprlang("screencopy"), + "custom_picker_binary", + "/run/user/1000/shim.sh", + ); + assert!(out.contains("custom_picker_binary = /run/user/1000/shim.sh")); + assert!(!out.contains("OLD")); + assert!( + out.contains("allow_token_by_default = true"), + "sibling kept" + ); + assert!(out.contains("misc {\n keep = 1\n}"), "other block kept"); + } + + #[test] + fn a_hyprlang_key_missing_from_its_block_lands_before_the_brace() { + let user = "screencopy {\n allow_token_by_default = true\n}\n"; + let out = upsert( + user, + Block::Hyprlang("screencopy"), + "custom_picker_binary", + "/x", + ); + let key = out.find("custom_picker_binary").expect("inserted"); + let brace = out.find('}').expect("kept"); + assert!(key < brace, "must land inside the block"); + } + + /// Idempotence is what keeps the caller from restarting the portal on every connect. + #[test] + fn a_second_pass_changes_nothing() { + let once = upsert("", Block::Ini("screencast"), "chooser_cmd", "cat x"); + let twice = upsert(&once, Block::Ini("screencast"), "chooser_cmd", "cat x"); + assert_eq!(once, twice); + let h1 = upsert( + "", + Block::Hyprlang("screencopy"), + "custom_picker_binary", + "/x", + ); + let h2 = upsert( + &h1, + Block::Hyprlang("screencopy"), + "custom_picker_binary", + "/x", + ); + assert_eq!(h1, h2); + } + + /// An empty file must not gain a leading blank line. + #[test] + fn an_empty_file_yields_just_the_block() { + assert_eq!( + upsert("", Block::Ini("screencast"), "k", "v"), + "[screencast]\nk=v\n" + ); + } +} diff --git a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs index 74aad2a7..9d1a868d 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs @@ -35,19 +35,15 @@ use std::time::{Duration, Instant}; /// where another local user could pre-create it (DoS) or rewrite it between our write and /// xdpw's read (steer capture at a different output). fn chooser_file() -> String { - let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + let dir = crate::session::runtime_dir(); format!("{dir}/punktfunk-xdpw-output") } -/// The managed xdpw config: per-session output selection with no GUI. The `|| echo` fallback -/// keeps plain portal capture (`--source portal` flow) working when no session has written -/// the chooser file. xdpw runs `chooser_cmd` via `/bin/sh -c`, reads stdout. -fn xdpw_config() -> String { +/// The chooser command xdpw runs via `/bin/sh -c`, reading stdout. The `|| echo` fallback keeps +/// plain portal capture (`--source portal`) working when no session has written the chooser file. +fn chooser_cmd() -> String { format!( - "# managed by punktfunk (vdisplay/wlroots.rs) — per-session output selection.\n\ -[screencast]\n\ -chooser_type=simple\n\ -chooser_cmd=cat {} 2>/dev/null || echo 'Monitor: HEADLESS-1'\n", + "cat {} 2>/dev/null || echo 'Monitor: HEADLESS-1'", chooser_file() ) } @@ -359,15 +355,25 @@ fn ensure_xdpw_config() -> Result<()> { .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 dir = base.join("xdg-desktop-portal-wlr"); - let path = dir.join("config"); - let cfg = xdpw_config(); - if std::fs::read_to_string(&path).is_ok_and(|c| c == cfg) { + let path = base.join("xdg-desktop-portal-wlr").join("config"); + // The two keys we own, set IN PLACE. This used to `fs::write` a complete file over whatever the + // user had, destroying every other xdpw setting they owned on first connect. + let mut changed = crate::portal_config::ensure_key( + &path, + crate::portal_config::Block::Ini("screencast"), + "chooser_type", + "simple", + )?; + changed |= crate::portal_config::ensure_key( + &path, + crate::portal_config::Block::Ini("screencast"), + "chooser_cmd", + &chooser_cmd(), + )?; + if !changed { return Ok(()); } - std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?; - std::fs::write(&path, &cfg).with_context(|| format!("write {}", path.display()))?; - tracing::info!(path = %path.display(), "wrote managed xdg-desktop-portal-wlr config"); + tracing::info!(path = %path.display(), "pointed xdg-desktop-portal-wlr at the managed output chooser"); let _ = Command::new("systemctl") .args(["--user", "try-restart", "xdg-desktop-portal-wlr.service"]) .status(); diff --git a/crates/pf-vdisplay/src/vdisplay/session.rs b/crates/pf-vdisplay/src/vdisplay/session.rs index 37fa2bc7..f655a3f0 100644 --- a/crates/pf-vdisplay/src/vdisplay/session.rs +++ b/crates/pf-vdisplay/src/vdisplay/session.rs @@ -255,6 +255,19 @@ impl EnvProbe { } } +/// The per-user runtime directory, resolved ONCE for callers outside detection. +/// +/// The rule is the one [`default_runtime_dir`] applies, and the point is what it is NOT: several +/// callers used to spell it `std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into())`, +/// while their own doc comments promised "per-user, 0700 — NOT a world-writable /tmp path another +/// local user could pre-create or rewrite". One of those paths is an executable that +/// xdg-desktop-portal-hyprland then RUNS. `Ok("")` was the second half of the same defect: it +/// yielded a path relative to the process's CWD rather than any runtime dir at all. +#[cfg(target_os = "linux")] +pub(crate) fn runtime_dir() -> String { + default_runtime_dir(&EnvProbe::sample()) +} + #[cfg(target_os = "linux")] fn default_runtime_dir(env: &EnvProbe) -> String { env.xdg_runtime_dir.clone().unwrap_or_else(|| {