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"]
|
#[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;
|
||||||
|
|||||||
@@ -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(|| {
|
||||||
|
|||||||
Reference in New Issue
Block a user