fix(vdisplay/linux): stop destroying the user's portal config, and honour the /tmp promise the docs made

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/<uid>`, 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-28 19:46:15 +02:00
co-authored by Claude Opus 5
parent 2c015bba30
commit f94191d927
7 changed files with 325 additions and 52 deletions
+9
View File
@@ -597,6 +597,15 @@ pub(crate) mod identity;
#[path = "vdisplay/admission.rs"] #[path = "vdisplay/admission.rs"]
pub mod admission; 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")] #[cfg(target_os = "linux")]
#[path = "vdisplay/linux/hyprland.rs"] #[path = "vdisplay/linux/hyprland.rs"]
mod hyprland; mod hyprland;
+11 -3
View File
@@ -137,13 +137,18 @@ impl DisplayIdentityMap {
} }
/// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may /// 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) { fn persist(&self) {
let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else { let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else {
return; return;
}; };
if let Some(dir) = self.path.parent() { 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"); let tmp = self.path.with_extension("json.tmp");
if std::fs::write(&tmp, &bytes).is_ok() { if std::fs::write(&tmp, &bytes).is_ok() {
@@ -265,7 +270,10 @@ impl ScaleMap {
return; return;
}; };
if let Some(dir) = self.path.parent() { 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"); let tmp = self.path.with_extension("json.tmp");
if std::fs::write(&tmp, &bytes).is_ok() { if std::fs::write(&tmp, &bytes).is_ok() {
@@ -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 /// 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. /// clobber each other's `stream available on node ID:` line.
fn spawn_log_path(inst: u64) -> std::path::PathBuf { 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")) 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 /// 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). /// on reboot, which is correct: a reboot restarts the autologin itself).
fn takeover_state_path() -> std::path::PathBuf { 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") 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`). /// Directory holding the per-user `gamescope` PATH-shim (tmpfs under `XDG_RUNTIME_DIR`).
fn headless_shim_dir() -> std::path::PathBuf { 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") 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). /// Path of the host-written `GAMESCOPE_BIN` wrapper (per-user, in tmpfs).
fn gamescope_bin_wrapper_path() -> std::path::PathBuf { 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") std::path::Path::new(&base).join("punktfunk-gamescope-bin")
} }
@@ -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 /// pre-create or rewrite between our write and xdph's read (steer capture elsewhere). Mirrors the
/// wlroots chooser file. /// wlroots chooser file.
fn selection_file() -> String { 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") 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 /// `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 interactive picker — the graceful fallback.
fn picker_shim_path() -> String { 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") format!("{dir}/punktfunk-xdph-picker.sh")
} }
@@ -69,19 +69,6 @@ fn picker_selection_line(name: &str) -> String {
format!("[SELECTION]screen:{name}\n") 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 /// Monotonic per-process counter for headless output names (`PF-1`, `PF-2`, …). Named outputs kill
/// the before/after diff race sway needs (D6). /// the before/after diff race sway needs (D6).
static OUTPUT_SEQ: AtomicU32 = AtomicU32::new(0); 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) { if std::fs::read_to_string(&shim).is_ok_and(|c| c == shim_body) {
// already installed // already installed
} else { } else {
std::fs::write(&shim, &shim_body).with_context(|| format!("write {shim}"))?; // Mode set AT CREATION, not chmod-ed after: xdph EXECUTES this file, and a
#[cfg(target_os = "linux")] // 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::os::unix::fs::PermissionsExt; use std::io::Write;
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o700)) use std::os::unix::fs::OpenOptionsExt;
.with_context(|| format!("chmod {shim}"))?; 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. // 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) .map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) .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_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?;
let dir = base.join("hypr"); let path = base.join("hypr").join("xdph.conf");
let path = dir.join("xdph.conf"); // ONE key, in place. This used to `fs::write` a complete file over whatever the user had,
let cfg = xdph_config(); // destroying every other xdph setting they owned on first connect.
if std::fs::read_to_string(&path).is_ok_and(|c| c == cfg) { let changed = crate::portal_config::ensure_key(
&path,
crate::portal_config::Block::Hyprlang("screencopy"),
"custom_picker_binary",
&shim,
)?;
if !changed {
return Ok(()); return Ok(());
} }
std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?; tracing::info!(path = %path.display(), "pointed xdg-desktop-portal-hyprland at the managed picker shim");
std::fs::write(&path, &cfg).with_context(|| format!("write {}", path.display()))?;
tracing::info!(path = %path.display(), "wrote managed xdg-desktop-portal-hyprland config");
let _ = Command::new("systemctl") let _ = Command::new("systemctl")
.args([ .args([
"--user", "--user",
@@ -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<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();
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<bool> {
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"
);
}
}
@@ -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 /// 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). /// xdpw's read (steer capture at a different output).
fn chooser_file() -> String { 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") format!("{dir}/punktfunk-xdpw-output")
} }
/// The managed xdpw config: per-session output selection with no GUI. The `|| echo` fallback /// The chooser command xdpw runs via `/bin/sh -c`, reading stdout. The `|| echo` fallback keeps
/// keeps plain portal capture (`--source portal` flow) working when no session has written /// plain portal capture (`--source portal`) working when no session has written the chooser file.
/// the chooser file. xdpw runs `chooser_cmd` via `/bin/sh -c`, reads stdout. fn chooser_cmd() -> String {
fn xdpw_config() -> String {
format!( format!(
"# managed by punktfunk (vdisplay/wlroots.rs) — per-session output selection.\n\ "cat {} 2>/dev/null || echo 'Monitor: HEADLESS-1'",
[screencast]\n\
chooser_type=simple\n\
chooser_cmd=cat {} 2>/dev/null || echo 'Monitor: HEADLESS-1'\n",
chooser_file() chooser_file()
) )
} }
@@ -359,15 +355,25 @@ fn ensure_xdpw_config() -> Result<()> {
.map(std::path::PathBuf::from) .map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) .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_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?;
let dir = base.join("xdg-desktop-portal-wlr"); let path = base.join("xdg-desktop-portal-wlr").join("config");
let path = dir.join("config"); // The two keys we own, set IN PLACE. This used to `fs::write` a complete file over whatever the
let cfg = xdpw_config(); // user had, destroying every other xdpw setting they owned on first connect.
if std::fs::read_to_string(&path).is_ok_and(|c| c == cfg) { 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(()); return Ok(());
} }
std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?; tracing::info!(path = %path.display(), "pointed xdg-desktop-portal-wlr at the managed output chooser");
std::fs::write(&path, &cfg).with_context(|| format!("write {}", path.display()))?;
tracing::info!(path = %path.display(), "wrote managed xdg-desktop-portal-wlr config");
let _ = Command::new("systemctl") let _ = Command::new("systemctl")
.args(["--user", "try-restart", "xdg-desktop-portal-wlr.service"]) .args(["--user", "try-restart", "xdg-desktop-portal-wlr.service"])
.status(); .status();
@@ -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")] #[cfg(target_os = "linux")]
fn default_runtime_dir(env: &EnvProbe) -> String { fn default_runtime_dir(env: &EnvProbe) -> String {
env.xdg_runtime_dir.clone().unwrap_or_else(|| { env.xdg_runtime_dir.clone().unwrap_or_else(|| {