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:
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
/// 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();
|
||||
|
||||
@@ -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(|| {
|
||||
|
||||
Reference in New Issue
Block a user