fix(pf-vdisplay): one non-UTF-8 byte in a portal config destroyed the whole file — in the module written to prevent exactly that
`portal_config::ensure_key` folded EVERY read failure into an empty string
(`read_to_string(path).unwrap_or_default()`). `upsert("", …)` then produced a file containing only
our block, the one-time backup was skipped because `!existing.is_empty()` was false, and the write
replaced the user's config — returning `Ok(true)`.
So a single Latin-1 character in a comment in `~/.config/hypr/xdph.conf` or
`~/.config/xdg-desktop-portal-wlr/config` destroyed the operator's entire portal configuration, with
no backup and no warning. The module doc says flat-writing these files "destroyed [everything else]
on first connect, silently and permanently" and that this module exists so it cannot happen; that
one line re-opened the door. The same shape hit a transient EIO on an NFS or overlay config dir.
Now: bytes are read with an explicit match, only `NotFound` may mean "empty", a non-UTF-8 config is
refused by name rather than replaced, the backup is taken by BYTES, and the write is atomic
(temp + `sync_all` + rename in the same directory, permissions carried over). Five new tests, all
running on macOS — `a_non_utf8_config_is_refused_not_replaced` fails against the old code.
Also in the wlr/Mutter family:
* **Mutter's `Primary` rebuilt kept physicals from scratch** — scale forced to 1.0, transform to 0,
disabled heads re-enabled — so a rotated, 2x-scaled or deliberately-disabled monitor came back
wrong, while the code went to real trouble to preserve refresh. Each head now carries its
pre-connect scale and transform, and x advances by the LOGICAL width.
* Three availability probes read session env (`SWAYSOCK`, `XDG_CURRENT_DESKTOP`,
`HYPRLAND_INSTANCE_SIGNATURE`) with no `ENV_LOCK` while `apply_session_env` `set_var`s the same
keys from another thread — the glibc setenv/getenv race this crate's own lib.rs documents as UB.
* `wlroots::create_output` ran a statement before its `OutputGuard` existed, so a raced
`wait_new_output` orphaned the output permanently — hyprland takes the guard first. The
before/after name diff also ran outside any lock, so two concurrent creates could adopt each
other's output. Both now run under a create lock, with a stray sweep on the failure path.
* `select_and_cast`'s timeout arm dropped the portal thread's `stop` flag un-set — the same leak
Mutter was already fixed for. The guard is now built before the wait, in both copies.
* The xdpw chooser file was written per session and never removed, permanently shadowing the
config's fallback with the name of an already-unplugged output. Its lifetime is now the handshake,
not the session — scoped deliberately, because tying removal to the keepalive would let one
session delete another's selection hours later.
* Hyprland's headless outputs are now named `PF-<pid>-<n>` and reconciled at startup, so a crashed
host's leftovers are reclaimed while a live sibling host's outputs cannot be pulled out from under
it. `set_monitor_rule` no longer discards hyprctl's rejection text and then hard-codes a
GBM/dmabuf diagnosis it never verified.
* Both wlr backends silently dropped the `topology` policy axis: `Primary`/`Exclusive` was accepted,
echoed by the mgmt API, applied on three backends and a no-op on two. They now say so.
Item 8.1: `swaymsg`, `hyprctl` and the portal `systemctl --user try-restart` calls are bounded
through `proc` with named budgets.
This commit is contained in:
@@ -5,9 +5,10 @@
|
||||
//! protocols, so it shares the wlr virtual-input path with sway — but it needs its own IPC and
|
||||
//! portal, so it is a **distinct backend** from [`super::wlroots`], not a branch inside it (D1):
|
||||
//!
|
||||
//! 1. `hyprctl output create headless PF-<n>` adds a named headless output — Hyprland supports
|
||||
//! 1. `hyprctl output create headless PF-<pid>-<n>` adds a named headless output — Hyprland supports
|
||||
//! **explicit names**, so no before/after diffing like sway's `HEADLESS-N` (D6). We poll
|
||||
//! `hyprctl -j monitors` until the name shows up.
|
||||
//! `hyprctl -j monitors` until the name shows up. The creator's pid rides in the name so a
|
||||
//! crashed host's leftovers are attributable, and only those (see [`reclaim_leftovers_once`]).
|
||||
//! 2. A monitor rule sets the client's exact mode. [`set_monitor_rule`] uses `hyprctl keyword
|
||||
//! monitor NAME,WxH@Hz,auto,1` (the hyprlang path — the default config manager on every current
|
||||
//! release, ≥0.55 included) and falls back to the Lua `hyprctl eval 'hl.monitor{…}'` only for a
|
||||
@@ -69,12 +70,46 @@ fn picker_selection_line(name: &str) -> String {
|
||||
format!("[SELECTION]screen:{name}\n")
|
||||
}
|
||||
|
||||
/// Monotonic per-process counter for headless output names (`PF-1`, `PF-2`, …). Named outputs kill
|
||||
/// the before/after diff race sway needs (D6).
|
||||
/// Monotonic per-process counter for headless output names (`PF-<pid>-1`, `PF-<pid>-2`, …). Named
|
||||
/// outputs kill the before/after diff race sway needs (D6).
|
||||
static OUTPUT_SEQ: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// The name for our next headless output: `PF-<pid>-<n>`.
|
||||
///
|
||||
/// The pid is not decoration. `OutputGuard::drop` is the only thing that removes an output, so a
|
||||
/// host that was SIGKILLed leaves its outputs in the compositor — and a bare `PF-<n>` counter starts
|
||||
/// again at `PF-1` in the next process, colliding with the corpses it just inherited. Stamping the
|
||||
/// creator's pid into the name makes a leftover both recognisable and *attributable*, which is what
|
||||
/// lets [`reclaim_leftovers_once`] remove only the ones whose owner is gone.
|
||||
fn next_output_name() -> String {
|
||||
format!("PF-{}", OUTPUT_SEQ.fetch_add(1, Ordering::Relaxed) + 1)
|
||||
format!(
|
||||
"PF-{}-{}",
|
||||
std::process::id(),
|
||||
OUTPUT_SEQ.fetch_add(1, Ordering::Relaxed) + 1
|
||||
)
|
||||
}
|
||||
|
||||
/// Is `name` an output some punktfunk host created (`PF-<pid>-<n>`, or a legacy `PF-<n>`)? Pure —
|
||||
/// this is what [`list_monitors`] reports as `managed`, so a user's own monitor called `PF-office`
|
||||
/// must not qualify.
|
||||
fn is_managed_output(name: &str) -> bool {
|
||||
let Some(rest) = name.strip_prefix("PF-") else {
|
||||
return false;
|
||||
};
|
||||
!rest.is_empty()
|
||||
&& rest
|
||||
.split('-')
|
||||
.all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// The pid of the host that created `name`, for `PF-<pid>-<n>` only. `None` for anything else —
|
||||
/// including a legacy `PF-<n>` from a host older than this naming scheme, which carries no owner and
|
||||
/// therefore may not be reclaimed on a guess.
|
||||
fn output_owner_pid(name: &str) -> Option<u32> {
|
||||
let rest = name.strip_prefix("PF-")?;
|
||||
let (pid, seq) = rest.split_once('-')?;
|
||||
seq.parse::<u32>().ok()?;
|
||||
pid.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
/// The Hyprland virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) adds one
|
||||
@@ -100,11 +135,24 @@ impl HyprlandDisplay {
|
||||
/// under `$XDG_RUNTIME_DIR/hypr/*/.socket.sock` (so the systemd `--user` host works without env
|
||||
/// import, unlike sway's `SWAYSOCK`; the signature is then exported by `apply_session_env`). Cheap,
|
||||
/// side-effect-free — safe on the enumeration path.
|
||||
///
|
||||
/// Both env reads take [`crate::with_env_lock`] — in ONE scope, so the pair is sampled from a single
|
||||
/// consistent view. This runs on a management worker (`/host/compositors` → [`crate::available`])
|
||||
/// concurrently with another connect's `apply_session_env`, which `set_var`s the signature for a
|
||||
/// live Hyprland session and `remove_var`s it for anything else; a glibc `getenv` racing that
|
||||
/// `setenv`/`unsetenv` is the `environ` realloc data race ENV_LOCK exists for. No caller holds the
|
||||
/// lock (it is not reentrant), and the `read_dir` below deliberately runs outside it.
|
||||
pub fn is_available() -> bool {
|
||||
if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() {
|
||||
let (sig, runtime) = crate::with_env_lock(|| {
|
||||
(
|
||||
std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE"),
|
||||
std::env::var_os("XDG_RUNTIME_DIR"),
|
||||
)
|
||||
});
|
||||
if sig.is_some() {
|
||||
return true;
|
||||
}
|
||||
let dir = match std::env::var_os("XDG_RUNTIME_DIR") {
|
||||
let dir = match runtime {
|
||||
Some(d) => std::path::PathBuf::from(d).join("hypr"),
|
||||
None => return false,
|
||||
};
|
||||
@@ -147,6 +195,9 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Log the permission-system caveat once per process (silent black frames otherwise).
|
||||
preflight_once();
|
||||
// Remove any output a PREVIOUS host left in this compositor, before we mint our first.
|
||||
reclaim_leftovers_once();
|
||||
warn_topology_is_extend_only();
|
||||
|
||||
let name = next_output_name();
|
||||
hyprctl_dispatch(&["output", "create", "headless", &name]).with_context(|| {
|
||||
@@ -181,7 +232,7 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
remote_fd: Some(fd),
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(Keepalive {
|
||||
_stop: StopGuard(stop),
|
||||
_stop: stop,
|
||||
_output: output,
|
||||
}),
|
||||
// Owned (the compositor output is ours to tear down), but not registry-poolable: the
|
||||
@@ -212,6 +263,62 @@ impl Drop for StopGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the `PF-<pid>-<n>` outputs left behind by host processes that are **gone**, once per
|
||||
/// process before we create our first.
|
||||
///
|
||||
/// [`OutputGuard::drop`] is the only unplug path there is, so a host that was SIGKILLed, OOM-killed
|
||||
/// or crashed leaves its headless outputs in the compositor for as long as the Hyprland session
|
||||
/// lives — a dead `PF-…` head in the operator's layout, forever, with the next host start happily
|
||||
/// adding more beside it. Reclaim is keyed on the OWNER pid in the name and only removes an output
|
||||
/// whose creator no longer exists, so a second live host on the same session (or this very process)
|
||||
/// can never have its output pulled out from under it. `Once` puts the sweep strictly before this
|
||||
/// process owns anything, and blocks a concurrent first `create` until it is done.
|
||||
fn reclaim_leftovers_once() {
|
||||
static RECLAIMED: Once = Once::new();
|
||||
RECLAIMED.call_once(|| {
|
||||
let Ok(names) = monitor_names() else { return };
|
||||
for name in names {
|
||||
let Some(pid) = output_owner_pid(&name) else {
|
||||
// Either not ours, or a legacy `PF-<n>` with no owner recorded — which we must not
|
||||
// remove on a guess, because a still-running older host may be streaming it.
|
||||
if is_managed_output(&name) {
|
||||
tracing::debug!(output = %name, "a managed headless output with no owner pid in \
|
||||
its name (an older host build) — left alone");
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if pid == std::process::id() || std::path::Path::new(&format!("/proc/{pid}")).exists() {
|
||||
continue;
|
||||
}
|
||||
match hyprctl_dispatch(&["output", "remove", &name]) {
|
||||
Ok(()) => tracing::info!(output = %name, owner_pid = pid, "removed a headless \
|
||||
output left behind by a host that is no longer running"),
|
||||
Err(e) => tracing::warn!(output = %name, owner_pid = pid, error = %format!("{e:#}"),
|
||||
"could not remove a leftover headless output"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The configured [`crate::policy::Topology`] is not implemented on this backend — say so once per
|
||||
/// create instead of leaving the management API's echo as the only signal that the pin was dropped
|
||||
/// (sweep 13.18). The Hyprland headless output is always an EXTENSION: nothing here promotes it to
|
||||
/// primary or disables the operator's heads.
|
||||
fn warn_topology_is_extend_only() {
|
||||
let topology = crate::effective_topology();
|
||||
if !matches!(
|
||||
topology,
|
||||
crate::policy::Topology::Extend | crate::policy::Topology::Auto
|
||||
) {
|
||||
tracing::warn!(
|
||||
?topology,
|
||||
"hyprland: this backend implements EXTEND only — the headless output is added beside \
|
||||
the operator's heads and nothing is promoted or disabled. Configure `topology: extend` \
|
||||
to stop the console promising otherwise."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the created headless output; dropping it removes it from Hyprland.
|
||||
struct OutputGuard(String);
|
||||
|
||||
@@ -226,14 +333,25 @@ impl Drop for OutputGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Budget for one `hyprctl` call ([`crate::proc`]).
|
||||
///
|
||||
/// `hyprctl` is a client of the compositor it drives — it connects to the instance socket and waits
|
||||
/// for a reply, so against a wedged Hyprland it never returns. These calls run on the session's
|
||||
/// stream thread, whose only way to end a session is to return, so one hung query used to wedge the
|
||||
/// session for good. Generous next to a healthy call (single-digit milliseconds), and every call
|
||||
/// site already has a failed-query path.
|
||||
const HYPRCTL_BUDGET: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Budget for the one-shot xdph restart. `systemctl --user try-restart` waits for the user manager's
|
||||
/// job to settle, so it is the slowest helper on this path — and its result is already ignored.
|
||||
const PORTAL_RESTART_BUDGET: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Run `hyprctl <args>`, returning stdout. `hyprctl` reads `HYPRLAND_INSTANCE_SIGNATURE` from the
|
||||
/// env (exported by `apply_session_env`) to reach the right instance socket. It exits non-zero on a
|
||||
/// hard failure, but for dispatch commands it can print an error with status 0 — see
|
||||
/// [`hyprctl_dispatch`].
|
||||
fn hyprctl(args: &[&str]) -> Result<String> {
|
||||
let out = Command::new("hyprctl")
|
||||
.args(args)
|
||||
.output()
|
||||
let out = crate::proc::output_within(Command::new("hyprctl").args(args), HYPRCTL_BUDGET)
|
||||
.context("run hyprctl (is Hyprland installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
@@ -251,12 +369,36 @@ fn hyprctl(args: &[&str]) -> Result<String> {
|
||||
/// write between ours and xdph's read would silently steer capture at the other session's output.
|
||||
static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// The per-session selection file, removed when the handshake it steers is over.
|
||||
///
|
||||
/// Its lifetime is the HANDSHAKE, not the session: the shim cats it once, inside
|
||||
/// [`select_and_cast`]'s critical section, and everything after that is the cast's own business.
|
||||
/// Left behind (as it was) the stale `[SELECTION]screen:PF-…` outlives the output `Drop` has since
|
||||
/// removed, and it permanently shadows xdph's documented empty-read fallback — every later capture
|
||||
/// that reaches the picker without a session of ours is steered at an output that is gone. Tying
|
||||
/// removal to the CAST instead would be worse: the file is one per user, so a session ending hours
|
||||
/// later would delete a *sibling's* selection out from under its picker.
|
||||
struct SelectionFile(String);
|
||||
|
||||
impl Drop for SelectionFile {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = std::fs::remove_file(&self.0) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
tracing::debug!(path = %self.0, error = %e, "could not remove the xdph selection file");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point xdph's custom picker at `output` and run the ScreenCast handshake, returning the portal fd
|
||||
/// + node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<AtomicBool>)> {
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
|
||||
ensure_xdph_config()?;
|
||||
let sel = selection_file();
|
||||
std::fs::write(&sel, picker_selection_line(output)).with_context(|| format!("write {sel}"))?;
|
||||
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
|
||||
// only thing that reads it.
|
||||
let _sel_file = SelectionFile(sel);
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
@@ -264,8 +406,16 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<A
|
||||
.name("punktfunk-hypr-cast".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.context("spawn hyprland portal thread")?;
|
||||
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
|
||||
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
|
||||
// arms dropping an un-set flag: the thread's `send` can still LAND in the queue in the window
|
||||
// between `recv_timeout` giving up and `setup_rx` being dropped, so it reports success and then
|
||||
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
|
||||
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
|
||||
// lifetime, against an output that no longer exists.
|
||||
let guard = StopGuard(stop);
|
||||
match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)),
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
|
||||
}
|
||||
@@ -285,7 +435,7 @@ pub(crate) fn stream_existing_output(
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
keepalive: Box::new(StopGuard(stop)),
|
||||
keepalive: Box::new(stop),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -330,11 +480,12 @@ pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
|
||||
.unwrap_or(1.0),
|
||||
primary: m.get("focused").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
enabled: !m.get("disabled").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
// Our headless outputs are named `PF-<n>` (see `next_output_name`).
|
||||
// Our headless outputs are named `PF-<pid>-<n>` (see `next_output_name`); the shape
|
||||
// is checked, not just the prefix, so a user's own `PF-office` stays theirs.
|
||||
managed: m
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|n| n.starts_with("PF-")),
|
||||
.is_some_and(is_managed_output),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -382,6 +533,23 @@ fn wait_monitor_ready(name: &str, timeout: Duration) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every monitor name Hyprland reports, **disabled ones included** (`-j monitors all`) — a leftover
|
||||
/// output from a dead host may well have ended up disabled, and [`reclaim_leftovers_once`] must see
|
||||
/// it anyway.
|
||||
fn monitor_names() -> Result<Vec<String>> {
|
||||
let out = hyprctl(&["-j", "monitors", "all"])?;
|
||||
let monitors: serde_json::Value =
|
||||
serde_json::from_str(&out).context("parse hyprctl -j monitors all")?;
|
||||
Ok(monitors
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Is a monitor named `name` present in `hyprctl -j monitors` (JSON)?
|
||||
fn monitor_exists(name: &str) -> Result<bool> {
|
||||
let out = hyprctl(&["-j", "monitors"])?;
|
||||
@@ -417,17 +585,33 @@ fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> {
|
||||
);
|
||||
let keyword: Vec<&str> = vec!["keyword", "monitor", &spec];
|
||||
let eval: Vec<&str> = vec!["eval", &lua];
|
||||
// What each form actually said. hyprctl reports a rejection in its OUTPUT TEXT ("eval is only
|
||||
// supported with the lua config manager", "invalid monitor rule", a permission denial), and
|
||||
// dropping it on the floor with `.is_err()` is what left the failure below guessing at GBM when
|
||||
// the compositor had already named the real cause.
|
||||
let mut attempts: Vec<String> = Vec::new();
|
||||
for a in [&keyword, &eval] {
|
||||
// A wrong-era command errors (`keyword` gone under Lua, or `eval` under hyprlang) — skip to
|
||||
// the other form. A command that's accepted then has up to the timeout to take effect.
|
||||
if hyprctl_dispatch(a).is_err() {
|
||||
if let Err(e) = hyprctl_dispatch(a) {
|
||||
let said = format!("{e:#}");
|
||||
tracing::debug!(output = %name, cmd = ?a, error = %said, "hyprctl rejected this monitor-rule form — trying the other config era");
|
||||
attempts.push(said);
|
||||
continue;
|
||||
}
|
||||
if wait_exact_mode(name, mode, Duration::from_millis(1500)) {
|
||||
tracing::debug!(output = %name, cmd = ?a, w = mode.width, h = mode.height, "monitor adopted the requested mode");
|
||||
return Ok(());
|
||||
}
|
||||
attempts.push(format!(
|
||||
"hyprctl {a:?} was accepted but the mode never took effect"
|
||||
));
|
||||
}
|
||||
let said = if attempts.is_empty() {
|
||||
"nothing (no form was attempted)".to_string()
|
||||
} else {
|
||||
attempts.join("; ")
|
||||
};
|
||||
// Neither form produced the exact mode. Distinguish "usable but different size" (proceed with a
|
||||
// warning — a working stream beats none) from "0×0 / gone" (the output has no framebuffer at all).
|
||||
match monitor_size(name)? {
|
||||
@@ -436,14 +620,20 @@ fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> {
|
||||
output = %name,
|
||||
requested = %format!("{}x{}", mode.width, mode.height),
|
||||
got = %format!("{w}x{h}"),
|
||||
hyprctl = %said,
|
||||
"Hyprland did not adopt the exact requested mode — streaming at the output's current size"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
// The output has no framebuffer at all. Lead with what hyprctl SAID: if every form was
|
||||
// rejected the cause is named right there (wrong config era, a permission denial, a bad
|
||||
// rule) and no allocation was ever attempted; only a form that was accepted and still left
|
||||
// the output at 0×0 points at the compositor failing to back the mode.
|
||||
_ => bail!(
|
||||
"headless output {name} never got a framebuffer (stayed 0x0) after the monitor rule for \
|
||||
{}x{}@{hz} — the compositor could not back the mode, likely a headless GBM/dmabuf \
|
||||
allocation failure (GPU driver; cf. Sunshine#4197). Check the Hyprland log.",
|
||||
{}x{}@{hz}. hyprctl said: {said}. If a form was accepted, the compositor could not back \
|
||||
the mode — likely a headless GBM/dmabuf allocation failure (GPU driver; cf. \
|
||||
Sunshine#4197). Check the Hyprland log.",
|
||||
mode.width,
|
||||
mode.height
|
||||
),
|
||||
@@ -574,13 +764,17 @@ fn ensure_xdph_config() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!(path = %path.display(), "pointed xdg-desktop-portal-hyprland at the managed picker shim");
|
||||
let _ = Command::new("systemctl")
|
||||
.args([
|
||||
// Bounded: `systemctl --user` blocks on the user manager's job queue, and this runs on the
|
||||
// session's stream thread. Its result was already ignored — a timeout just means xdph picks the
|
||||
// new config up whenever it next starts.
|
||||
let _ = crate::proc::status_within(
|
||||
Command::new("systemctl").args([
|
||||
"--user",
|
||||
"try-restart",
|
||||
"xdg-desktop-portal-hyprland.service",
|
||||
])
|
||||
.status();
|
||||
]),
|
||||
PORTAL_RESTART_BUDGET,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -702,6 +896,28 @@ mod tests {
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
/// The name carries the creating host's pid, which is what makes a leftover attributable — a
|
||||
/// reclaim that could not tell whose output it was would have to remove a LIVE sibling's or
|
||||
/// nothing at all.
|
||||
#[test]
|
||||
fn a_name_carries_its_owner_pid_and_only_ours_does() {
|
||||
let mine = next_output_name();
|
||||
assert_eq!(output_owner_pid(&mine), Some(std::process::id()));
|
||||
assert!(is_managed_output(&mine));
|
||||
|
||||
// A legacy `PF-<n>` from an older host: recognisably managed, but with no owner recorded —
|
||||
// so it may be reported, never reclaimed on a guess.
|
||||
assert!(is_managed_output("PF-1"));
|
||||
assert_eq!(output_owner_pid("PF-1"), None);
|
||||
|
||||
// Not ours: a user's own monitor name that happens to start with the prefix, and the
|
||||
// connectors every wlr-family compositor mints.
|
||||
for theirs in ["PF-office", "PF-", "PF-12-abc", "HEADLESS-1", "DP-1", ""] {
|
||||
assert!(!is_managed_output(theirs), "{theirs:?} is not ours");
|
||||
assert_eq!(output_owner_pid(theirs), None, "{theirs:?} has no owner");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_line_carries_the_selection_marker() {
|
||||
// xdph requires the `[SELECTION]` prefix; a bare `screen:NAME` is rejected as strange output.
|
||||
|
||||
@@ -122,8 +122,14 @@ impl MutterDisplay {
|
||||
/// `XDG_SESSION_DESKTOP` alongside would resurrect the bug that scrub exists to prevent — a stale
|
||||
/// `gnome` there after a gnome-shell crash reports Mutter usable and routes the next client into a
|
||||
/// dead session (45 s create timeouts instead of a crisp handshake error).
|
||||
///
|
||||
/// The read takes [`crate::with_env_lock`]: this runs on a management worker (`/host/compositors` →
|
||||
/// [`crate::available`]) concurrently with another connect's `apply_session_env`, which `set_var`s
|
||||
/// this key for a live session and `remove_var`s it when nothing is — and a glibc `getenv` racing
|
||||
/// that is the `environ` realloc data race ENV_LOCK exists for, torn answer at best and a host
|
||||
/// segfault mid-connect at worst. Read-then-drop; no caller holds the lock (it is not reentrant).
|
||||
pub fn is_available() -> bool {
|
||||
std::env::var("XDG_CURRENT_DESKTOP")
|
||||
crate::with_env_lock(|| std::env::var("XDG_CURRENT_DESKTOP"))
|
||||
.map(|d| d.to_ascii_uppercase().contains("GNOME"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -718,13 +724,24 @@ async fn connect(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Optional: make the per-session virtual output the PRIMARY monitor (PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY).
|
||||
// Optional: make the per-session virtual output the PRIMARY monitor.
|
||||
//
|
||||
// `RecordVirtual` adds the virtual monitor as an *extended* desktop. On a headless host that's the
|
||||
// only display, so the shell + windows live there. But when a physical monitor is attached, GNOME
|
||||
// keeps it primary and the virtual output is an empty extension — the stream shows only the
|
||||
// wallpaper. We fix that by promoting the virtual output to primary (physical kept on, secondary)
|
||||
// via `org.gnome.Mutter.DisplayConfig.ApplyMonitorsConfig`, and restore on teardown.
|
||||
// wallpaper. We fix that by promoting the virtual output via
|
||||
// `org.gnome.Mutter.DisplayConfig.ApplyMonitorsConfig`.
|
||||
//
|
||||
// Which shape is `crate::effective_topology()`'s call, not this module's: the console policy first,
|
||||
// then the legacy `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, then the Auto default. `Primary`
|
||||
// keeps the physicals on as secondaries; `Exclusive` omits them, so Mutter disables them for the
|
||||
// session; `Extend` skips this block entirely.
|
||||
//
|
||||
// Applied at APPLY_TEMPORARY, and **MUTTER ITSELF REVERTS IT** when the virtual monitor disappears
|
||||
// and our DisplayConfig connection closes. We must never re-assert the layout on teardown: the
|
||||
// banner used to promise a "restore on teardown" that the teardown deliberately does not do, and
|
||||
// issuing that ApplyMonitorsConfig is what SIGSEGVed gnome-shell on Mutter 50 + NVIDIA and wedged a
|
||||
// box at the GDM greeter (see the teardown comment in `session_thread`).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// `org.gnome.Mutter.DisplayConfig.GetCurrentState` reply shapes (see the interface XML):
|
||||
@@ -811,7 +828,9 @@ fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i
|
||||
/// Pure mode-pick for a KEPT physical (unit-tested). Given the physical's PRE-connect mode
|
||||
/// (`pre_mode = (id, w, h, refresh)`; `None` when the connector is new since the snapshot) and the
|
||||
/// mode list Mutter reports for it in the POST-virtual state
|
||||
/// (`(id, w, h, refresh, is_current, is_preferred)`), return the `(mode_id, width)` to re-apply.
|
||||
/// (`(id, w, h, refresh, is_current, is_preferred)`), return the `(mode_id, width, height)` to
|
||||
/// re-apply. The height is not decoration: a head rotated 90°/270° is as wide on the desktop as its
|
||||
/// mode is tall, and the caller lays the kept heads out side by side.
|
||||
///
|
||||
/// Mutter re-derives its layout when the `RecordVirtual` output appears and can silently drop a
|
||||
/// 120 Hz panel to its EDID-preferred 60 Hz — so the post-virtual `is-current` is *already* 60 Hz.
|
||||
@@ -821,40 +840,40 @@ fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i
|
||||
fn pick_keep_mode(
|
||||
pre_mode: Option<(String, i32, i32, f64)>,
|
||||
state_modes: &[(String, i32, i32, f64, bool, bool)],
|
||||
) -> Option<(String, i32)> {
|
||||
) -> Option<(String, i32, i32)> {
|
||||
let state_current = || {
|
||||
state_modes
|
||||
.iter()
|
||||
.find(|m| m.4)
|
||||
.or_else(|| state_modes.iter().find(|m| m.5))
|
||||
.or_else(|| state_modes.first())
|
||||
.map(|m| (m.0.clone(), m.1))
|
||||
.map(|m| (m.0.clone(), m.1, m.2))
|
||||
};
|
||||
let Some((pre_id, w, h, hz)) = pre_mode else {
|
||||
return state_current();
|
||||
};
|
||||
// The exact pre mode id, if the connector still offers it (same session ⇒ usually true).
|
||||
if state_modes.iter().any(|m| m.0 == pre_id) {
|
||||
return Some((pre_id, w));
|
||||
return Some((pre_id, w, h));
|
||||
}
|
||||
// Else a re-keyed id with the same geometry + refresh (still the real 120 Hz).
|
||||
if let Some(m) = state_modes
|
||||
.iter()
|
||||
.find(|m| m.1 == w && m.2 == h && (m.3 - hz).abs() < 0.5)
|
||||
{
|
||||
return Some((m.0.clone(), m.1));
|
||||
return Some((m.0.clone(), m.1, m.2));
|
||||
}
|
||||
// The physical genuinely no longer offers that mode — use whatever is valid now.
|
||||
state_current()
|
||||
}
|
||||
|
||||
/// The `(mode_id, width)` a kept physical should be RE-APPLIED at — its PRE-connect mode preserved
|
||||
/// across Mutter's virtual-output layout re-derive. See [`pick_keep_mode`].
|
||||
/// The `(mode_id, width, height)` a kept physical should be RE-APPLIED at — its PRE-connect mode
|
||||
/// preserved across Mutter's virtual-output layout re-derive. See [`pick_keep_mode`].
|
||||
fn physical_keep_mode(
|
||||
pre: &CurrentState,
|
||||
state: &CurrentState,
|
||||
conn: &str,
|
||||
) -> Option<(String, i32)> {
|
||||
) -> Option<(String, i32, i32)> {
|
||||
let pre_mode = current_mode_full(pre, conn);
|
||||
let state_modes: Vec<(String, i32, i32, f64, bool, bool)> = state
|
||||
.1
|
||||
@@ -1044,13 +1063,57 @@ fn snap_integral_scale(want: f64, width: u32, height: u32) -> f64 {
|
||||
.unwrap_or(want)
|
||||
}
|
||||
|
||||
/// The scale of the logical monitor carrying `connector`, if present.
|
||||
fn logical_scale(state: &CurrentState, connector: &str) -> Option<f64> {
|
||||
/// The `(scale, transform)` of the logical monitor carrying `connector`. `None` means **no logical
|
||||
/// monitor carries it** — which is how Mutter reports a head the operator has DISABLED, and is the
|
||||
/// distinction [`keep_head_layout`] turns into "leave it off".
|
||||
fn logical_placement(state: &CurrentState, connector: &str) -> Option<(f64, u32)> {
|
||||
state
|
||||
.2
|
||||
.iter()
|
||||
.find(|l| l.5.iter().any(|spec| spec.0 == connector))
|
||||
.map(|l| l.2)
|
||||
.map(|l| (l.2, l.3))
|
||||
}
|
||||
|
||||
/// The scale of the logical monitor carrying `connector`, if present.
|
||||
fn logical_scale(state: &CurrentState, connector: &str) -> Option<f64> {
|
||||
logical_placement(state, connector).map(|(scale, _)| scale)
|
||||
}
|
||||
|
||||
/// Whether a kept physical should be re-applied at all, and with what `(scale, transform)`. Pure —
|
||||
/// unit-tested, because getting it wrong is invisible on a headless lab box and very visible on the
|
||||
/// operator's desk.
|
||||
///
|
||||
/// The rebuild used to hardcode `scale = 1.0`, `transform = 0` and to list every connector Mutter
|
||||
/// reported, so one connect un-rotated a portrait panel, dropped a 2×-scaled 4K head to native
|
||||
/// pixels, and switched a deliberately-dark monitor back on. All three facts are in the PRE-connect
|
||||
/// snapshot: `pre_logical` is the head's logical-monitor entry there, and Mutter reports a disabled
|
||||
/// head by omitting it from `logical_monitors` entirely. So: carry the pre values when the head was
|
||||
/// on; leave it out when the connector existed pre-connect and carried no logical monitor (disabled
|
||||
/// on purpose); and for a connector that was not in the snapshot at all — a hotplug inside our
|
||||
/// window — keep it on at whatever Mutter has just derived for it, which is the friendlier reading
|
||||
/// of "the operator plugged this in while we were connecting".
|
||||
fn keep_head_layout(
|
||||
existed_pre: bool,
|
||||
pre_logical: Option<(f64, u32)>,
|
||||
state_logical: Option<(f64, u32)>,
|
||||
) -> Option<(f64, u32)> {
|
||||
// A non-finite or non-positive scale would fail the whole ApplyMonitorsConfig, taking the
|
||||
// primary switch down with it.
|
||||
let sane = |(scale, transform): (f64, u32)| {
|
||||
(
|
||||
if scale.is_finite() && scale > 0.0 {
|
||||
scale
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
transform,
|
||||
)
|
||||
};
|
||||
match (pre_logical, existed_pre) {
|
||||
(Some(l), _) => Some(sane(l)),
|
||||
(None, true) => None,
|
||||
(None, false) => Some(sane(state_logical.unwrap_or((1.0, 0)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every head Mutter reports, for [`crate::monitors::list`].
|
||||
@@ -1142,16 +1205,20 @@ fn build_exclusive_config(vconn: &str, vmode: &str, scale: f64) -> Vec<ApplyLogi
|
||||
)]
|
||||
}
|
||||
|
||||
/// **Primary** — the virtual output primary at `(0, 0)`, with every currently-active physical
|
||||
/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its **pre-connect**
|
||||
/// mode). So the shell + new windows land on the streamed surface, but the operator's physical
|
||||
/// screen stays on **at its real refresh**. On a headless host (no physicals) this is identical to
|
||||
/// [`build_exclusive_config`].
|
||||
/// **Primary** — the virtual output primary at `(0, 0)`, with every physical monitor the operator
|
||||
/// had ENABLED kept as a secondary (laid left-to-right past the virtual, each at its **pre-connect**
|
||||
/// mode, scale and transform). So the shell + new windows land on the streamed surface, but the
|
||||
/// operator's physical screen stays exactly as they left it. On a headless host (no physicals) this
|
||||
/// is identical to [`build_exclusive_config`].
|
||||
///
|
||||
/// `pre` is the snapshot taken *before* the virtual output existed (physical still at its true
|
||||
/// refresh); `state` is the post-virtual state. We read each physical's mode from `pre` because
|
||||
/// Mutter can knock a 120 Hz panel down to 60 Hz when it re-derives the layout for the virtual
|
||||
/// monitor — reading `state` would cement that 60 Hz (`physical_keep_mode`).
|
||||
/// refresh); `state` is the post-virtual state. Everything about a kept head is read from `pre`,
|
||||
/// because the post-virtual state is already contaminated: Mutter re-derives the layout when the
|
||||
/// `RecordVirtual` output appears and can knock a 120 Hz panel down to 60 Hz, so reading `state`
|
||||
/// would cement that 60 Hz (`physical_keep_mode`). Scale, transform and enabled-ness come from the
|
||||
/// same snapshot for the same reason — and because rebuilding them from scratch is what used to
|
||||
/// un-rotate portrait panels, flatten a 2× scale and re-light a head the operator had switched off
|
||||
/// ([`keep_head_layout`]).
|
||||
///
|
||||
/// *Physical-keep is unvalidated on-glass* — the lab boxes are headless (no attached display to keep
|
||||
/// on); the layout math is conservative (append to the right) but wants a display-attached box.
|
||||
@@ -1190,16 +1257,42 @@ fn build_primary_keeping_physicals(
|
||||
if conn == vconn {
|
||||
continue;
|
||||
}
|
||||
if let Some((mode_id, w)) = physical_keep_mode(pre, state, conn) {
|
||||
let existed_pre = pre.1.iter().any(|m| m.0 .0 == *conn);
|
||||
let Some((head_scale, transform)) = keep_head_layout(
|
||||
existed_pre,
|
||||
logical_placement(pre, conn),
|
||||
logical_placement(state, conn),
|
||||
) else {
|
||||
// Omitted from the config ⇒ Mutter leaves it disabled, which is what the operator asked
|
||||
// for. Listing it would switch their dark head on for the length of the session.
|
||||
tracing::debug!(
|
||||
connector = %conn,
|
||||
"mutter: this head was disabled before the session — leaving it disabled"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if let Some((mode_id, w, h)) = physical_keep_mode(pre, state, conn) {
|
||||
logicals.push((
|
||||
x,
|
||||
0,
|
||||
1.0,
|
||||
0,
|
||||
head_scale,
|
||||
transform,
|
||||
false,
|
||||
vec![(conn.clone(), mode_id, HashMap::new())],
|
||||
));
|
||||
x += w.max(0);
|
||||
// Advance by the head's own LOGICAL footprint, in the layout's coordinate space — the
|
||||
// same space the virtual's advance above uses. A 3840-wide panel at scale 2 occupies
|
||||
// 1920, and a head rotated 90°/270° (transform 1/3, or their flipped twins 5/7) is as
|
||||
// wide as its mode is TALL. Advancing by raw mode width was only ever *consistent* with
|
||||
// the forced scale of 1.0 this rebuild used to apply; preserving the real scale without
|
||||
// this would just trade one wrong layout for another (overlapping or gapped heads).
|
||||
let rotated = matches!(transform, 1 | 3 | 5 | 7);
|
||||
let footprint = if rotated { h } else { w };
|
||||
x += if physical_layout {
|
||||
footprint.max(0)
|
||||
} else {
|
||||
((footprint as f64 / head_scale).round() as i32).max(0)
|
||||
};
|
||||
}
|
||||
}
|
||||
logicals
|
||||
@@ -1207,7 +1300,10 @@ fn build_primary_keeping_physicals(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pick_keep_mode, pick_virtual, snap_integral_scale, HashMap, Mode, MonitorInfo};
|
||||
use super::{
|
||||
keep_head_layout, pick_keep_mode, pick_virtual, snap_integral_scale, HashMap, Mode,
|
||||
MonitorInfo,
|
||||
};
|
||||
|
||||
// (id, w, h, refresh, is_current, is_preferred)
|
||||
fn m(
|
||||
@@ -1232,7 +1328,7 @@ mod tests {
|
||||
];
|
||||
assert_eq!(
|
||||
pick_keep_mode(pre, &state),
|
||||
Some(("M120".to_string(), 2560))
|
||||
Some(("M120".to_string(), 2560, 1440))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1247,7 +1343,7 @@ mod tests {
|
||||
];
|
||||
assert_eq!(
|
||||
pick_keep_mode(pre, &state),
|
||||
Some(("new-120".to_string(), 2560))
|
||||
Some(("new-120".to_string(), 2560, 1440))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1262,7 +1358,7 @@ mod tests {
|
||||
];
|
||||
assert_eq!(
|
||||
pick_keep_mode(pre, &state),
|
||||
Some(("s-100".to_string(), 3440))
|
||||
Some(("s-100".to_string(), 3440, 1440))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1289,7 +1385,10 @@ mod tests {
|
||||
m("A", 1920, 1080, 60.0, true, false),
|
||||
m("B", 1920, 1080, 144.0, false, true),
|
||||
];
|
||||
assert_eq!(pick_keep_mode(None, &state), Some(("A".to_string(), 1920)));
|
||||
assert_eq!(
|
||||
pick_keep_mode(None, &state),
|
||||
Some(("A".to_string(), 1920, 1080))
|
||||
);
|
||||
|
||||
let no_current = vec![
|
||||
m("A", 1920, 1080, 60.0, false, false),
|
||||
@@ -1297,7 +1396,35 @@ mod tests {
|
||||
];
|
||||
assert_eq!(
|
||||
pick_keep_mode(None, &no_current),
|
||||
Some(("B".to_string(), 1920))
|
||||
Some(("B".to_string(), 1920, 1080))
|
||||
);
|
||||
}
|
||||
|
||||
/// A kept physical must come back exactly as the operator had it. Rebuilding the layout from
|
||||
/// scratch (`scale = 1.0`, `transform = 0`, every connector listed) un-rotated portrait panels,
|
||||
/// flattened a 2× scale, and switched a deliberately-dark head back on the moment a client
|
||||
/// connected — while the code went to real trouble to preserve the refresh.
|
||||
#[test]
|
||||
fn a_kept_head_carries_its_pre_connect_scale_and_transform() {
|
||||
// Rotated + 2×-scaled, exactly as it was before the virtual output appeared.
|
||||
assert_eq!(
|
||||
keep_head_layout(true, Some((2.0, 1)), Some((1.0, 0))),
|
||||
Some((2.0, 1))
|
||||
);
|
||||
// Disabled on purpose (present pre-connect, carried by no logical monitor) — stays off.
|
||||
assert_eq!(keep_head_layout(true, None, Some((1.0, 0))), None);
|
||||
// Hotplugged inside our window: not in the snapshot at all, so keep it on at whatever
|
||||
// Mutter derived rather than disabling a monitor the operator just plugged in.
|
||||
assert_eq!(
|
||||
keep_head_layout(false, None, Some((1.5, 2))),
|
||||
Some((1.5, 2))
|
||||
);
|
||||
assert_eq!(keep_head_layout(false, None, None), Some((1.0, 0)));
|
||||
// A junk scale would fail the WHOLE ApplyMonitorsConfig, taking the primary switch with it.
|
||||
assert_eq!(keep_head_layout(true, Some((0.0, 3)), None), Some((1.0, 3)));
|
||||
assert_eq!(
|
||||
keep_head_layout(true, Some((f64::NAN, 0)), None),
|
||||
Some((1.0, 0))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -99,8 +99,42 @@ pub(crate) fn upsert(existing: &str, block: Block<'_>, key: &str, value: &str) -
|
||||
/// 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).
|
||||
///
|
||||
/// The read is matched EXPLICITLY, and only [`ErrorKind::NotFound`](std::io::ErrorKind::NotFound)
|
||||
/// may mean "empty". This used to be `read_to_string(path).unwrap_or_default()`, which folded every
|
||||
/// read failure into an empty string — and an empty string is the one input for which this function
|
||||
/// destroys data: `upsert("")` yields a file holding ONLY our block, the backup below is skipped
|
||||
/// because there is nothing to back up, and the write replaces the user's config. One non-UTF-8 byte
|
||||
/// in a comment (a Latin-1 character, an 8-bit paste) or a transient EIO on an NFS/overlay config
|
||||
/// dir was enough, and the result was exactly the silent, permanent loss this module exists to
|
||||
/// prevent. A config we cannot read is a config we refuse to rewrite.
|
||||
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();
|
||||
// Read BYTES: whether a backup is owed is a question about what is on disk, not about what
|
||||
// decoded — and the decode failure below is itself one of the cases that must not be silent.
|
||||
let raw = match std::fs::read(path) {
|
||||
Ok(b) => Some(b),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => {
|
||||
return Err(e).with_context(|| {
|
||||
format!(
|
||||
"read {} (refusing to rewrite a portal config we could not read)",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
};
|
||||
let existing = match &raw {
|
||||
Some(bytes) => std::str::from_utf8(bytes)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"{} is not UTF-8 — refusing to rewrite it (the one key we own is not worth \
|
||||
losing the rest of the file for; fix or move the file and reconnect)",
|
||||
path.display()
|
||||
)
|
||||
})?
|
||||
.to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
let updated = upsert(&existing, block, key, value);
|
||||
if updated == existing {
|
||||
return Ok(false);
|
||||
@@ -108,9 +142,9 @@ pub(crate) fn ensure_key(path: &Path, block: Block<'_>, key: &str, value: &str)
|
||||
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() {
|
||||
// One-time backup, of the bytes we actually read. `create_new` makes this genuinely once: a
|
||||
// later edit must not overwrite the user's ORIGINAL with our own previous output.
|
||||
if let Some(bytes) = raw.as_deref().filter(|b| !b.is_empty()) {
|
||||
let backup = path.with_extension("punktfunk-backup");
|
||||
match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
@@ -119,7 +153,7 @@ pub(crate) fn ensure_key(path: &Path, block: Block<'_>, key: &str, value: &str)
|
||||
{
|
||||
Ok(mut f) => {
|
||||
use std::io::Write;
|
||||
let _ = f.write_all(existing.as_bytes());
|
||||
let _ = f.write_all(bytes);
|
||||
tracing::info!(
|
||||
backup = %backup.display(),
|
||||
"backed up the existing portal config before editing it"
|
||||
@@ -133,10 +167,49 @@ pub(crate) fn ensure_key(path: &Path, block: Block<'_>, key: &str, value: &str)
|
||||
),
|
||||
}
|
||||
}
|
||||
std::fs::write(path, &updated).with_context(|| format!("write {}", path.display()))?;
|
||||
write_atomic(path, updated.as_bytes())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Replace `path`'s contents with `bytes` **atomically**: fill a temp file beside it, then rename
|
||||
/// over it. `fs::write` truncates first and fills afterwards, so a crash, a full disk or a killed
|
||||
/// host between the two leaves the user's config truncated — the same loss this module exists to
|
||||
/// prevent, arrived at from the other side. The temp file goes in the SAME directory because a
|
||||
/// rename is only atomic within one filesystem, and it inherits the original's permission bits so
|
||||
/// an operator's 0600 config does not come back at the umask default.
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
let dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let stem = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "config".to_string());
|
||||
// Per-process name: two hosts editing the same config must not fill one another's temp file.
|
||||
let tmp = dir.join(format!(".{stem}.punktfunk-{}.tmp", std::process::id()));
|
||||
let write = || -> Result<()> {
|
||||
{
|
||||
let mut f =
|
||||
std::fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
|
||||
f.write_all(bytes)
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
// The rename must not publish a name whose contents are still in the page cache only.
|
||||
f.sync_all()
|
||||
.with_context(|| format!("sync {}", tmp.display()))?;
|
||||
} // closed before the rename — Windows is far happier renaming a file nobody holds open.
|
||||
if let Ok(md) = std::fs::metadata(path) {
|
||||
let _ = std::fs::set_permissions(&tmp, md.permissions());
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))
|
||||
};
|
||||
let r = write();
|
||||
if r.is_err() {
|
||||
// Never leave a half-written dotfile beside the user's config.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -237,3 +310,166 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ensure_key`] itself — the half that touches the user's disk.
|
||||
///
|
||||
/// The merge above was pinned by seven cases while the I/O wrapper around it, which is where the
|
||||
/// destructive behaviour lives (the read, the once-only backup, the replacing write), had none. That
|
||||
/// is backwards: `upsert` can at worst return a wrong string, `ensure_key` can delete a config.
|
||||
/// Filesystem-only — no compositor, no portal — so these run on every platform, like the merge tests.
|
||||
#[cfg(test)]
|
||||
mod io_tests {
|
||||
use super::*;
|
||||
|
||||
/// A scratch directory removed on drop. `tempfile` is deliberately not a dependency of this
|
||||
/// crate; the temp-dir + pid + counter convention is the one `proc.rs`'s fixtures already use.
|
||||
struct Scratch(std::path::PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Self {
|
||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("pf-vd-portalcfg-{tag}-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("scratch dir");
|
||||
Self(dir)
|
||||
}
|
||||
fn path(&self, name: &str) -> std::path::PathBuf {
|
||||
self.0.join(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn backup_of(p: &Path) -> std::path::PathBuf {
|
||||
p.with_extension("punktfunk-backup")
|
||||
}
|
||||
|
||||
/// The data-loss case. A config that cannot be decoded must be left EXACTLY as it is: the old
|
||||
/// `unwrap_or_default()` turned it into an empty string, wrote a file holding only our block,
|
||||
/// skipped the backup (nothing to back up, as far as it could tell) and returned `Ok(true)`.
|
||||
#[test]
|
||||
fn a_non_utf8_config_is_refused_not_replaced() {
|
||||
let s = Scratch::new("nonutf8");
|
||||
let p = s.path("config");
|
||||
// A Latin-1 'ÿ' in a comment — the whole file is otherwise perfectly ordinary.
|
||||
let raw: &[u8] = b"[screencast]\n# r\xffgler\nchooser_type=simple\noutput_name=DP-1\n";
|
||||
std::fs::write(&p, raw).expect("seed");
|
||||
let err = ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat x")
|
||||
.expect_err("an unreadable config must not be rewritten");
|
||||
assert!(
|
||||
format!("{err:#}").contains("not UTF-8"),
|
||||
"the error must name the real cause: {err:#}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&p).expect("still there"),
|
||||
raw,
|
||||
"byte-identical"
|
||||
);
|
||||
assert!(
|
||||
!backup_of(&p).exists(),
|
||||
"nothing was edited, so nothing is owed a backup"
|
||||
);
|
||||
}
|
||||
|
||||
/// The ordinary first-connect path: no file yet, so one is created — and there is no original
|
||||
/// to preserve, so no backup is left lying beside it.
|
||||
#[test]
|
||||
fn a_missing_file_is_created_without_a_backup() {
|
||||
let s = Scratch::new("missing");
|
||||
let p = s.path("nested").join("config");
|
||||
assert!(ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat x").expect("write"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&p).expect("created"),
|
||||
"[screencast]\nchooser_cmd=cat x\n"
|
||||
);
|
||||
assert!(!backup_of(&p).exists());
|
||||
}
|
||||
|
||||
/// `create_new` is what makes the backup once-only, and this is the invariant it buys: after a
|
||||
/// second edit (a new `$XDG_RUNTIME_DIR`, so a new value) the backup must still hold the user's
|
||||
/// PRISTINE file — not our own previous output.
|
||||
#[test]
|
||||
fn the_backup_holds_the_original_across_two_edits() {
|
||||
let s = Scratch::new("backup");
|
||||
let p = s.path("config");
|
||||
let pristine = "[screencast]\nchooser_type=simple\noutput_name=DP-1\n";
|
||||
std::fs::write(&p, pristine).expect("seed");
|
||||
assert!(
|
||||
ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat /run/a").expect("1st")
|
||||
);
|
||||
assert!(
|
||||
ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat /run/b").expect("2nd")
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(backup_of(&p)).expect("backup"),
|
||||
pristine
|
||||
);
|
||||
let now = std::fs::read_to_string(&p).expect("edited");
|
||||
assert!(
|
||||
now.contains("chooser_cmd=cat /run/b"),
|
||||
"the second value won"
|
||||
);
|
||||
assert!(
|
||||
now.contains("output_name=DP-1"),
|
||||
"the user's other keys survived"
|
||||
);
|
||||
}
|
||||
|
||||
/// Idempotence at the I/O level: an already-correct file is not rewritten and reports `false`,
|
||||
/// because the caller RESTARTS the portal on `true` — a spurious `true` restarts xdpw/xdph on
|
||||
/// every connect.
|
||||
#[test]
|
||||
fn an_unchanged_file_returns_false_and_does_not_rewrite() {
|
||||
let s = Scratch::new("unchanged");
|
||||
let p = s.path("config");
|
||||
assert!(ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat x").expect("1st"));
|
||||
let after_first = std::fs::read_to_string(&p).expect("written");
|
||||
let mtime = std::fs::metadata(&p)
|
||||
.and_then(|m| m.modified())
|
||||
.expect("mtime");
|
||||
assert!(
|
||||
!ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat x").expect("2nd"),
|
||||
"an unchanged config must report no change"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&p).expect("still there"),
|
||||
after_first
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&p)
|
||||
.and_then(|m| m.modified())
|
||||
.expect("mtime"),
|
||||
mtime,
|
||||
"the file must not have been touched at all"
|
||||
);
|
||||
}
|
||||
|
||||
/// The write publishes the WHOLE new file or nothing (temp + rename), and it leaves no debris
|
||||
/// beside the config — a stray dotfile in `~/.config/hypr` is the kind of thing that outlives
|
||||
/// several releases.
|
||||
#[test]
|
||||
fn the_write_is_atomic_and_leaves_no_temp_behind() {
|
||||
let s = Scratch::new("atomic");
|
||||
let p = s.path("config");
|
||||
std::fs::write(&p, "[other]\nkeep=me\n").expect("seed");
|
||||
assert!(ensure_key(&p, Block::Ini("screencast"), "chooser_cmd", "cat x").expect("write"));
|
||||
let names: Vec<String> = std::fs::read_dir(&s.0)
|
||||
.expect("dir")
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(
|
||||
!names.iter().any(|n| n.ends_with(".tmp")),
|
||||
"temp file left behind: {names:?}"
|
||||
);
|
||||
assert!(std::fs::read_to_string(&p)
|
||||
.expect("edited")
|
||||
.contains("keep=me"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,11 @@ fn chooser_file() -> 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.
|
||||
/// plain portal capture (`--source portal`) working when no session of ours is mid-handshake — it
|
||||
/// is a GUESS at sway's own first headless output, right on a box whose sway loads the headless
|
||||
/// backend with one output of its own and wrong (a cast of nothing) otherwise. It is reachable
|
||||
/// again: the per-session file is removed with the handshake it steers ([`ChooserFile`]), so it no
|
||||
/// longer sits there naming an output we have since unplugged.
|
||||
fn chooser_cmd() -> String {
|
||||
format!(
|
||||
"cat {} 2>/dev/null || echo 'Monitor: HEADLESS-1'",
|
||||
@@ -68,8 +72,14 @@ impl WlrootsDisplay {
|
||||
|
||||
/// wlroots/Sway is usable when the host runs inside a Sway session — signalled by `SWAYSOCK`
|
||||
/// (the IPC socket `swaymsg create_output` needs). Cheap env check for the enumeration path.
|
||||
///
|
||||
/// Under [`crate::with_env_lock`]: this runs on a management worker (`/host/compositors` →
|
||||
/// [`crate::available`]) concurrently with another connect's `apply_session_env`, which `set_var`s
|
||||
/// — and, when no sway session is live, `remove_var`s — this very key. A glibc `getenv` racing a
|
||||
/// `setenv` is the `environ` realloc data race ENV_LOCK exists for, and it is UB whichever key each
|
||||
/// side names. No caller holds the lock (the mutex is not reentrant).
|
||||
pub fn is_available() -> bool {
|
||||
std::env::var_os("SWAYSOCK").is_some()
|
||||
crate::with_env_lock(|| std::env::var_os("SWAYSOCK")).is_some()
|
||||
}
|
||||
|
||||
impl VirtualDisplay for WlrootsDisplay {
|
||||
@@ -86,13 +96,33 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
let before = output_names()
|
||||
.context("swaymsg get_outputs (is the host inside the sway session env — SWAYSOCK?)")?;
|
||||
swaymsg(&["create_output"])
|
||||
.context("swaymsg create_output (sway needs the headless backend loaded)")?;
|
||||
// The output appears synchronously in practice; poll briefly to be safe, and own it
|
||||
// from here on so error unwinding unplugs it.
|
||||
let output = OutputGuard(wait_new_output(&before, Duration::from_secs(5))?);
|
||||
warn_topology_is_extend_only();
|
||||
// Snapshot → create → identify, all under CREATE_LOCK. sway names the headless output
|
||||
// itself (`HEADLESS-N`), so the only way to know which one is ours is "the name that was not
|
||||
// there before" — and two concurrent creates each picking the other's output is a silent
|
||||
// mis-capture, not a failure (mutter's TOPOLOGY_LOCK exists for exactly this class). The
|
||||
// lock also gives the failure path somewhere safe to unplug from: the output already exists
|
||||
// by the time `wait_new_output` can fail, and nothing else may have created one meanwhile.
|
||||
let output = {
|
||||
let _create = CREATE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let before = output_names().context(
|
||||
"swaymsg get_outputs (is the host inside the sway session env — SWAYSOCK?)",
|
||||
)?;
|
||||
swaymsg(&["create_output"])
|
||||
.context("swaymsg create_output (sway needs the headless backend loaded)")?;
|
||||
// The output appears synchronously in practice; poll briefly to be safe, and own it
|
||||
// from here on so error unwinding unplugs it.
|
||||
match wait_new_output(&before, Duration::from_secs(5)) {
|
||||
Ok(name) => OutputGuard(name),
|
||||
Err(e) => {
|
||||
// `create_output` reported success, so an output very probably exists — it just
|
||||
// never showed up in time (or showed up a moment after we gave up). Unowned, it
|
||||
// would sit in the operator's sway layout forever.
|
||||
unplug_strays(&before);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
let name = output.0.clone();
|
||||
|
||||
// The client's exact mode (also the refresh clock that makes the output produce frames).
|
||||
@@ -128,7 +158,7 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
remote_fd: Some(fd),
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(Keepalive {
|
||||
_stop: StopGuard(stop),
|
||||
_stop: stop,
|
||||
_output: output,
|
||||
}),
|
||||
// Owned (the compositor output is ours to tear down), but not registry-poolable: the
|
||||
@@ -159,6 +189,52 @@ impl Drop for StopGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes **snapshot → `create_output` → identify-the-new-name**, process-wide. sway names its
|
||||
/// headless outputs itself, so ownership is established by a before/after diff and two concurrent
|
||||
/// creates would each adopt the other's output — which does not fail, it silently streams the wrong
|
||||
/// one. Mutter's `TOPOLOGY_LOCK` is the same guard for the same reason; Hyprland needs none because
|
||||
/// it lets us NAME the output (D6).
|
||||
static CREATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Unplug any headless output that appeared since `before` and that nothing owns — the cleanup for a
|
||||
/// `create_output` whose output we could not identify in time. Only `HEADLESS-*` is touched: a
|
||||
/// physical hotplug in the same window is the operator's, not ours, and `unplug` on a real connector
|
||||
/// would take their screen away. Best-effort by construction, and it runs with [`CREATE_LOCK`] held
|
||||
/// so nothing else in this process can have created the strays it sees.
|
||||
fn unplug_strays(before: &[String]) {
|
||||
let Ok(now) = output_names() else { return };
|
||||
for name in now
|
||||
.into_iter()
|
||||
.filter(|n| n.starts_with("HEADLESS-") && !before.iter().any(|b| b == n))
|
||||
{
|
||||
match swaymsg(&["output", &name, "unplug"]) {
|
||||
Ok(_) => tracing::warn!(output = %name, "unplugged a headless output we created but \
|
||||
could not identify in time"),
|
||||
Err(e) => tracing::warn!(output = %name, error = %format!("{e:#}"), "could not unplug \
|
||||
the headless output left behind by a failed create"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The configured [`crate::policy::Topology`] is not implemented on this backend — say so once per
|
||||
/// create instead of leaving the management API's echo as the only signal that the pin was dropped
|
||||
/// (sweep 13.18). sway's virtual output is always an EXTENSION: nothing here promotes it to primary
|
||||
/// or disables the operator's heads.
|
||||
fn warn_topology_is_extend_only() {
|
||||
let topology = crate::effective_topology();
|
||||
if !matches!(
|
||||
topology,
|
||||
crate::policy::Topology::Extend | crate::policy::Topology::Auto
|
||||
) {
|
||||
tracing::warn!(
|
||||
?topology,
|
||||
"wlroots: this backend implements EXTEND only — the headless output is added beside the \
|
||||
operator's heads and nothing is promoted or disabled. Configure `topology: extend` to \
|
||||
stop the console promising otherwise."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the created headless output; dropping it unplugs it from sway.
|
||||
struct OutputGuard(String);
|
||||
|
||||
@@ -171,15 +247,26 @@ impl Drop for OutputGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Budget for one `swaymsg` call ([`crate::proc`]).
|
||||
///
|
||||
/// swaymsg is a CLIENT of the compositor it drives: against a wedged sway it blocks in its own
|
||||
/// connect to the IPC socket and never returns — and these calls run on the session's stream thread,
|
||||
/// whose only way to end a session is to return, so one hung query used to wedge the session
|
||||
/// permanently. Generous next to a healthy call (single-digit milliseconds), and every call site
|
||||
/// here already has a failed-query path, so a timeout lands on behaviour that already exists.
|
||||
const SWAYMSG_BUDGET: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Budget for the one-shot xdpw restart. `systemctl --user try-restart` waits for the unit's job to
|
||||
/// settle, so it is the slowest helper on this path — and its result is already ignored.
|
||||
const PORTAL_RESTART_BUDGET: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Run `swaymsg -- <args>`, returning stdout (`--` so command tokens like `--custom` reach
|
||||
/// sway instead of swaymsg's own getopt). swaymsg exits non-zero (with the error on stderr/
|
||||
/// stdout) when the command fails, so checking the status covers `{"success": false}` too.
|
||||
fn swaymsg(args: &[&str]) -> Result<String> {
|
||||
let out = Command::new("swaymsg")
|
||||
.arg("--")
|
||||
.args(args)
|
||||
.output()
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
let out =
|
||||
crate::proc::output_within(Command::new("swaymsg").arg("--").args(args), SWAYMSG_BUDGET)
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"swaymsg {:?} failed: {}{}",
|
||||
@@ -197,10 +284,11 @@ fn swaymsg(args: &[&str]) -> Result<String> {
|
||||
/// *command*, which is right for `create_output` and wrong for a query — `-t` after `--` comes back
|
||||
/// as `Unknown/invalid command '-t'` (caught on-glass writing the monitor enumeration).
|
||||
fn swaymsg_query(kind: &str) -> Result<serde_json::Value> {
|
||||
let out = Command::new("swaymsg")
|
||||
.args(["-t", kind, "--raw"])
|
||||
.output()
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
let out = crate::proc::output_within(
|
||||
Command::new("swaymsg").args(["-t", kind, "--raw"]),
|
||||
SWAYMSG_BUDGET,
|
||||
)
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"swaymsg -t {kind} failed: {}",
|
||||
@@ -230,13 +318,37 @@ fn output_names() -> Result<Vec<String>> {
|
||||
/// handshake, not just the write, because the read happens inside it.
|
||||
static SELECTION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// The per-session chooser file, removed when the handshake it steers is over.
|
||||
///
|
||||
/// Its lifetime is the HANDSHAKE, not the session: xdpw reads it once, inside
|
||||
/// [`select_and_cast`]'s critical section, and everything after that is the cast's own business.
|
||||
/// Left behind (as it was) the stale `Monitor: HEADLESS-3` outlives the output `Drop` has since
|
||||
/// unplugged, and it permanently shadows [`chooser_cmd`]'s `|| echo` fallback — so a later
|
||||
/// `--source portal` capture with no session of ours running steers at a connector that is gone.
|
||||
/// Tying removal to the CAST instead would be worse still: the file is one per user, so a session
|
||||
/// ending hours later would delete a *sibling's* selection out from under its picker.
|
||||
struct ChooserFile(String);
|
||||
|
||||
impl Drop for ChooserFile {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = std::fs::remove_file(&self.0) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
tracing::debug!(path = %self.0, error = %e, "could not remove the xdpw chooser file");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point xdpw's chooser at `output` and run the ScreenCast handshake, returning the portal fd +
|
||||
/// node id and the guard that stops the cast. The caller must hold [`SELECTION_LOCK`].
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<AtomicBool>)> {
|
||||
fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, StopGuard)> {
|
||||
ensure_xdpw_config()?;
|
||||
let chooser = chooser_file();
|
||||
std::fs::write(&chooser, format!("Monitor: {output}\n"))
|
||||
.with_context(|| format!("write {chooser}"))?;
|
||||
// Owned from the write on: every arm below (and every `?`) leaves the handshake, which is the
|
||||
// only thing that reads it.
|
||||
let _chooser = ChooserFile(chooser);
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
@@ -244,8 +356,16 @@ fn select_and_cast(output: &str, hw_cursor: bool) -> Result<(OwnedFd, u32, Arc<A
|
||||
.name("punktfunk-wlr-cast".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread, hw_cursor))
|
||||
.context("spawn wlroots portal thread")?;
|
||||
// Built BEFORE the wait so EVERY error arm below sets the flag on its way out — as Mutter's
|
||||
// `create` does. Returning the bare `Arc` and letting the CALLER wrap it left the two failure
|
||||
// arms dropping an un-set flag: the thread's `send` can still LAND in the queue in the window
|
||||
// between `recv_timeout` giving up and `setup_rx` being dropped, so it reports success and then
|
||||
// parks forever on `while !stop`, holding a live ScreenCast session, its zbus connection, an
|
||||
// `OwnedFd` and a 2-worker tokio runtime — one more set per slow-portal connect, for the host's
|
||||
// lifetime, against an output that no longer exists.
|
||||
let guard = StopGuard(stop);
|
||||
match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, stop)),
|
||||
Ok(Ok((fd, node_id))) => Ok((fd, node_id, guard)),
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {output} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {output}"),
|
||||
}
|
||||
@@ -266,7 +386,7 @@ pub(crate) fn stream_existing_output(
|
||||
Ok(crate::mirror::MirrorStream {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
keepalive: Box::new(StopGuard(stop)),
|
||||
keepalive: Box::new(stop),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -374,9 +494,13 @@ fn ensure_xdpw_config() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
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();
|
||||
// Bounded: `systemctl --user` blocks on the user manager's job queue, and this runs on the
|
||||
// session's stream thread. Its result was already ignored — a timeout just means the portal
|
||||
// picks the new config up whenever it next starts.
|
||||
let _ = crate::proc::status_within(
|
||||
Command::new("systemctl").args(["--user", "try-restart", "xdg-desktop-portal-wlr.service"]),
|
||||
PORTAL_RESTART_BUDGET,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user