fix(punktfunk/1): adversarial-review fixes — SPAKE2 pairing, renegotiation hardening, +more
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
Triaged the multi-agent review of the renegotiation + pairing + Sway + AV1/surround batch
(1 critical, 11 major/minor confirmed). Fixes:
CRITICAL — PIN pairing was offline-brute-forceable. The HMAC-of-PIN proof let an active
MITM who terminates the TOFU ceremony recover the 4-digit PIN by offline dictionary search
(all other inputs observable) and forge a correctly-bound proof. Replaced with **SPAKE2**
(balanced PAKE, `spake2` crate) + key-confirmation MACs, binding both cert fingerprints as
the SPAKE2 identities: an attacker gets exactly ONE online guess, no offline search, and
mismatched cert views (a real MITM) never reach a shared key. Also reworked the UX to an
"arming PIN" — one PIN per arming window shown at host startup (the SPAKE2 client needs the
PIN to build its first message, so it can't be minted per-connection). Validated live:
wrong PIN rejected in 0.1s, right PIN pairs + persists + the paired identity streams.
Pairing hardening: `--allow-pairing`/`--require-pairing` must arm pairing (default rejects
unsolicited ceremonies); per-host cooldown bounds online guessing; the client flushes its
CONNECTION_CLOSE so a refused ceremony can't wedge the sequential host for the full timeout;
atomic (temp+rename) paired-store writes.
Protocol: control/pairing messages use a distinct CTL_MAGIC (PKFc) — fully disjoint from
the positional Hello namespace (a future abi_version can't be misparsed as a control
message); all typed decodes are length-exact. ABI_VERSION → 2 (punktfunk_connect signature
gained the identity params; header regenerated).
Renegotiation: drain the reconfig channel to the NEWEST mode (one rebuild, not one per
stale step); validate refresh_hz; build the new pipeline BEFORE dropping the old so a
rebuild failure keeps the session on its current mode instead of killing it.
GameStream: packetDuration snaps to {5,10} (an in-between value isn't a legal Opus frame
size and would kill audio). Sway: chooser file moved to $XDG_RUNTIME_DIR (was a fixed
world-writable /tmp path — DoS / capture-misdirection by another local user).
Swift: fixed two compile breakers in the new pairing/identity APIs (Int32 status .rawValue,
UInt cap cast). New SPAKE2 + namespace-disjointness + pairing-roundtrip unit tests; the
in-process pairing test now also exercises the arming PIN + cooldown. 114 tests green,
clippy -D warnings clean (both feature sets), fmt, C-ABI harness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -466,7 +466,13 @@ fn audio_body(
|
||||
let mut enc = SessionEncoder::new(layout)?;
|
||||
// Opus frame duration; Moonlight negotiates 5 ms (default) or 10 ms via
|
||||
// `x-nv-aqos.packetDuration` and sizes its decoder at `48 * duration` samples.
|
||||
let frame_ms = params.packet_duration_ms.clamp(5, 10) as usize;
|
||||
// Already snapped to {5, 10} at parse time; guard here too so only legal Opus frame
|
||||
// sizes (48 kHz × {5,10} ms = 240/480 samples) ever reach the encoder.
|
||||
let frame_ms = if params.packet_duration_ms >= 10 {
|
||||
10
|
||||
} else {
|
||||
5
|
||||
} as usize;
|
||||
let samples_per_channel = SAMPLE_RATE as usize * frame_ms / 1000;
|
||||
let frame_len = samples_per_channel * layout.channels as usize; // interleaved samples
|
||||
let mut acc: Vec<f32> = Vec::with_capacity(frame_len * 4);
|
||||
|
||||
@@ -321,10 +321,13 @@ fn audio_params(map: &HashMap<String, String>) -> audio::AudioParams {
|
||||
}
|
||||
};
|
||||
let high_quality = parse_u("x-nv-audio.surround.AudioQuality") == Some(1);
|
||||
// Moonlight uses 5 ms (default) or 10 ms (slow decoder / low-bitrate links).
|
||||
let packet_duration_ms = parse_u("x-nv-aqos.packetDuration")
|
||||
.map(|d| d.clamp(5, 10) as u8)
|
||||
.unwrap_or(5);
|
||||
// Moonlight uses 5 ms (default) or 10 ms (slow decoder / low-bitrate links). Snap to
|
||||
// those two — an in-between value like 7 isn't a legal Opus frame size and would make
|
||||
// every encode fail; clamping (not snapping) would let it through.
|
||||
let packet_duration_ms = match parse_u("x-nv-aqos.packetDuration") {
|
||||
Some(d) if d >= 10 => 10,
|
||||
_ => 5,
|
||||
};
|
||||
audio::AudioParams {
|
||||
channels,
|
||||
high_quality,
|
||||
|
||||
+120
-45
@@ -53,9 +53,14 @@ pub struct M3Options {
|
||||
pub frames: u32,
|
||||
/// Exit after this many sessions (0 = serve forever).
|
||||
pub max_sessions: u32,
|
||||
/// Only serve clients whose certificate fingerprint is in the paired set (pairing
|
||||
/// ceremonies themselves are always allowed — that's how a client gets in).
|
||||
/// Only serve clients whose certificate fingerprint is in the paired set. Implies
|
||||
/// `allow_pairing` (a host that requires pairing must accept ceremonies to admit
|
||||
/// anyone).
|
||||
pub require_pairing: bool,
|
||||
/// Accept pairing ceremonies (the operator "arming" pairing mode). Default off: a host
|
||||
/// with neither flag set rejects unsolicited PairRequests outright, closing that
|
||||
/// attack surface. `require_pairing` forces this on.
|
||||
pub allow_pairing: bool,
|
||||
/// Fixed pairing PIN (tests); `None` = a fresh random 4-digit PIN per ceremony.
|
||||
pub pairing_pin: Option<String>,
|
||||
/// Paired-clients store path override (tests); `None` = the default config path.
|
||||
@@ -100,10 +105,19 @@ fn save_paired(state: &PairedState) -> Result<()> {
|
||||
if let Some(dir) = state.path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(&state.path, serde_json::to_vec_pretty(&state.clients)?)?;
|
||||
// Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which
|
||||
// would silently lock out every paired client on a --require-pairing host). Write a
|
||||
// temp beside the target, then rename.
|
||||
let tmp = state.path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, serde_json::to_vec_pretty(&state.clients)?)?;
|
||||
std::fs::rename(&tmp, &state.path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimum spacing between accepted pairing ceremonies (bounds online PIN guessing — with
|
||||
/// SPAKE2 an attacker already gets only one guess per ceremony; this caps the rate).
|
||||
const PAIRING_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
impl PairedClients {
|
||||
fn contains(&self, fp: &[u8; 32]) -> bool {
|
||||
let hex = fingerprint_hex(fp);
|
||||
@@ -174,10 +188,25 @@ async fn serve(opts: M3Options) -> Result<()> {
|
||||
clients: load_paired(&paired_at),
|
||||
path: paired_at,
|
||||
}));
|
||||
if opts.require_pairing {
|
||||
// The arming PIN: one PIN for the whole pairing window (NOT per-ceremony), because the
|
||||
// SPAKE2 client must know the PIN to build its first message — so the user has to read
|
||||
// the PIN before connecting. Generated once when pairing is armed, displayed here.
|
||||
let arming_pin = if opts.allow_pairing || opts.require_pairing {
|
||||
let pin = opts.pairing_pin.clone().unwrap_or_else(|| {
|
||||
use rand::Rng;
|
||||
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
|
||||
});
|
||||
let n = paired.lock().unwrap().clients.clients.len();
|
||||
tracing::info!(paired = n, "pairing required for sessions");
|
||||
}
|
||||
tracing::info!(
|
||||
paired = n,
|
||||
require = opts.require_pairing,
|
||||
"PAIRING ARMED — enter this PIN on the client to pair: {pin}"
|
||||
);
|
||||
Some(pin)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let last_pairing = std::sync::Mutex::new(None::<std::time::Instant>);
|
||||
|
||||
let mut served = 0u32;
|
||||
loop {
|
||||
@@ -194,7 +223,17 @@ async fn serve(opts: M3Options) -> Result<()> {
|
||||
};
|
||||
let peer = conn.remote_address();
|
||||
tracing::info!(%peer, "punktfunk/1 client connected");
|
||||
if let Err(e) = serve_session(conn, &opts, &audio_cap, &fingerprint, &paired).await {
|
||||
if let Err(e) = serve_session(
|
||||
conn,
|
||||
&opts,
|
||||
&audio_cap,
|
||||
&fingerprint,
|
||||
&paired,
|
||||
&last_pairing,
|
||||
arming_pin.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error");
|
||||
} else {
|
||||
tracing::info!(%peer, "session complete");
|
||||
@@ -222,9 +261,10 @@ type AudioCapSlot = Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCaptu
|
||||
/// client), so its budget is far larger than the machine-speed session handshake.
|
||||
const PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// The host side of the PIN ceremony (see `punktfunk_core::quic::pair_proof`): generate a
|
||||
/// PIN, display it (log), challenge with a fresh salt, verify the client's single proof
|
||||
/// attempt, and persist the client's certificate fingerprint on success.
|
||||
/// The host side of the SPAKE2 pairing ceremony (see `punktfunk_core::quic::pake`):
|
||||
/// generate + display a PIN, run SPAKE2 as B binding both cert fingerprints, verify the
|
||||
/// client's key-confirmation MAC (its single online guess), and persist the client's
|
||||
/// fingerprint on success.
|
||||
async fn pair_ceremony(
|
||||
conn: &quinn::Connection,
|
||||
mut send: quinn::SendStream,
|
||||
@@ -232,37 +272,40 @@ async fn pair_ceremony(
|
||||
req: PairRequest,
|
||||
host_fp: &[u8; 32],
|
||||
paired: &PairedStore,
|
||||
opts: &M3Options,
|
||||
pin: &str,
|
||||
) -> Result<()> {
|
||||
use punktfunk_core::quic::pake;
|
||||
let client_fp = endpoint::peer_fingerprint(conn)
|
||||
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?;
|
||||
|
||||
let pin = opts.pairing_pin.clone().unwrap_or_else(|| {
|
||||
use rand::Rng;
|
||||
format!("{:04}", rand::thread_rng().gen_range(0..10_000u32))
|
||||
});
|
||||
let mut salt = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut salt);
|
||||
tracing::info!(
|
||||
name = %req.name,
|
||||
client = %fingerprint_hex(&client_fp),
|
||||
"PAIRING REQUEST — enter this PIN on the client: {pin}"
|
||||
"PAIRING REQUEST — verifying against the armed PIN"
|
||||
);
|
||||
|
||||
io::write_msg(&mut send, &PairChallenge { salt }.encode()).await?;
|
||||
// SPAKE2 as B; bind our own host_fp + the client cert we actually received.
|
||||
let (pake, spake_b) = pake::start(false, pin, &client_fp, host_fp);
|
||||
let confirms = pake.finish(&req.spake_a)?; // Err only on a malformed peer message
|
||||
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&PairChallenge {
|
||||
spake_b,
|
||||
confirm: confirms.host,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let proof = tokio::time::timeout(PAIRING_TIMEOUT, io::read_msg(&mut recv))
|
||||
.await
|
||||
.map_err(|_| anyhow!("pairing timed out waiting for the PIN proof"))??;
|
||||
.map_err(|_| anyhow!("pairing timed out waiting for the client's confirmation"))??;
|
||||
let proof = PairProof::decode(&proof).map_err(|e| anyhow!("PairProof decode: {e:?}"))?;
|
||||
|
||||
let expected = punktfunk_core::quic::pair_proof(&pin, &salt, &client_fp, host_fp);
|
||||
// Constant-time compare — don't leak a prefix-match timing oracle on the proof.
|
||||
let ok = proof
|
||||
.hmac
|
||||
.iter()
|
||||
.zip(expected.iter())
|
||||
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
|
||||
== 0;
|
||||
// A wrong PIN (or a MITM with mismatched cert views) yields a different SPAKE2 key, so
|
||||
// the client's confirmation MAC won't match ours — one online attempt, no offline search.
|
||||
let ok = pake::verify(&confirms.client, &proof.confirm);
|
||||
|
||||
if ok {
|
||||
let mut store = paired.lock().unwrap();
|
||||
@@ -281,8 +324,9 @@ async fn pair_ceremony(
|
||||
}
|
||||
io::write_msg(&mut send, &PairResult { ok }.encode()).await?;
|
||||
let _ = send.finish();
|
||||
// Let the result reach the client before the connection drops.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
// Wait for the client to acknowledge by closing, so the PairResult isn't dropped by our
|
||||
// close on a slow link (bounded so a vanished client can't wedge the sequential host).
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), conn.closed()).await;
|
||||
conn.close(0u32.into(), b"pairing done");
|
||||
anyhow::ensure!(ok, "pairing rejected (wrong PIN)");
|
||||
Ok(())
|
||||
@@ -297,6 +341,8 @@ async fn serve_session(
|
||||
audio_cap: &AudioCapSlot,
|
||||
host_fp: &[u8; 32],
|
||||
paired: &PairedStore,
|
||||
last_pairing: &std::sync::Mutex<Option<std::time::Instant>>,
|
||||
arming_pin: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let peer = conn.remote_address();
|
||||
|
||||
@@ -309,7 +355,18 @@ async fn serve_session(
|
||||
.await
|
||||
.map_err(|_| anyhow!("first message timeout"))??;
|
||||
if let Ok(req) = PairRequest::decode(&first) {
|
||||
return pair_ceremony(&conn, send, recv, req, host_fp, paired, opts).await;
|
||||
let pin = arming_pin.context("pairing not armed (start with --allow-pairing)")?;
|
||||
{
|
||||
let mut last = last_pairing.lock().unwrap();
|
||||
if let Some(t) = *last {
|
||||
anyhow::ensure!(
|
||||
t.elapsed() >= PAIRING_COOLDOWN,
|
||||
"pairing rate-limited — retry shortly"
|
||||
);
|
||||
}
|
||||
*last = Some(std::time::Instant::now());
|
||||
}
|
||||
return pair_ceremony(&conn, send, recv, req, host_fp, paired, pin).await;
|
||||
}
|
||||
|
||||
let source = opts.source;
|
||||
@@ -388,12 +445,13 @@ async fn serve_session(
|
||||
tracing::warn!("unknown control message — ignoring");
|
||||
continue;
|
||||
};
|
||||
let ok = crate::encode::validate_dimensions(
|
||||
crate::encode::Codec::H265,
|
||||
req.mode.width,
|
||||
req.mode.height,
|
||||
)
|
||||
.is_ok();
|
||||
let ok = req.mode.refresh_hz > 0
|
||||
&& crate::encode::validate_dimensions(
|
||||
crate::encode::Codec::H265,
|
||||
req.mode.width,
|
||||
req.mode.height,
|
||||
)
|
||||
.is_ok();
|
||||
if ok {
|
||||
active = req.mode;
|
||||
tracing::info!(mode = ?req.mode, "mode switch accepted");
|
||||
@@ -770,14 +828,27 @@ fn virtual_stream(
|
||||
let mut next = std::time::Instant::now();
|
||||
let mut sent: u64 = 0;
|
||||
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
||||
if let Ok(new_mode) = reconfig.try_recv() {
|
||||
// Drain to the NEWEST requested mode (a resize drag queues many) so we rebuild once,
|
||||
// not once per stale intermediate mode.
|
||||
let mut want = None;
|
||||
while let Ok(m) = reconfig.try_recv() {
|
||||
want = Some(m);
|
||||
}
|
||||
if let Some(new_mode) = want {
|
||||
tracing::info!(?new_mode, "rebuilding pipeline for mode switch");
|
||||
// Tear down in order — capture stream (and with it the virtual output) before
|
||||
// the new output appears, encoder with it. The data plane keeps running.
|
||||
drop(enc);
|
||||
drop(capturer);
|
||||
(capturer, enc, frame, interval) = build_pipeline(&mut vd, new_mode)?;
|
||||
next = std::time::Instant::now();
|
||||
// Build the new pipeline BEFORE dropping the old one: the host already acked
|
||||
// the switch as accepted, so a rebuild failure must not kill an otherwise
|
||||
// healthy session — keep streaming the current mode and log instead.
|
||||
match build_pipeline(&mut vd, new_mode) {
|
||||
Ok(next_pipe) => {
|
||||
(capturer, enc, frame, interval) = next_pipe;
|
||||
next = std::time::Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %format!("{e:#}"), ?new_mode,
|
||||
"mode-switch rebuild failed — staying on the current mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = capturer.try_latest().context("capture")? {
|
||||
frame = f;
|
||||
@@ -925,6 +996,7 @@ mod tests {
|
||||
frames: 25,
|
||||
max_sessions: 3,
|
||||
require_pairing: false,
|
||||
allow_pairing: false,
|
||||
pairing_pin: None,
|
||||
paired_store: None,
|
||||
})
|
||||
@@ -1079,6 +1151,7 @@ mod tests {
|
||||
frames: 25,
|
||||
max_sessions: 4,
|
||||
require_pairing: true,
|
||||
allow_pairing: false,
|
||||
pairing_pin: Some("4321".into()),
|
||||
paired_store: Some(test_paired_path()),
|
||||
})
|
||||
@@ -1107,7 +1180,9 @@ mod tests {
|
||||
"anonymous session must be rejected"
|
||||
);
|
||||
|
||||
// 3: correct PIN → paired, host fingerprint returned.
|
||||
// 3: correct PIN → paired, host fingerprint returned. Space past the pairing
|
||||
// cooldown that the wrong-PIN attempt above just triggered (a real retry is slower).
|
||||
std::thread::sleep(PAIRING_COOLDOWN + std::time::Duration::from_millis(200));
|
||||
let host_fp =
|
||||
NativeClient::pair("127.0.0.1", 19778, identity, "4321", "test-client", timeout)
|
||||
.expect("pairing with the right PIN");
|
||||
|
||||
@@ -91,6 +91,7 @@ fn real_main() -> Result<()> {
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
require_pairing: args.iter().any(|a| a == "--require-pairing"),
|
||||
allow_pairing: args.iter().any(|a| a == "--allow-pairing"),
|
||||
pairing_pin: None,
|
||||
paired_store: None,
|
||||
})
|
||||
@@ -320,8 +321,9 @@ M3-HOST OPTIONS:
|
||||
--seconds <N> per-session stream duration, virtual source (default: 30)
|
||||
--frames <N> per-session frame count, synthetic source (default: 300)
|
||||
--max-sessions <N> exit after N sessions; 0 = serve forever (default: 0)
|
||||
--require-pairing only serve PIN-paired clients (the host logs a 4-digit
|
||||
PIN when a client starts the ceremony)
|
||||
--allow-pairing accept PIN pairing ceremonies (arm pairing mode)
|
||||
--require-pairing only serve PIN-paired clients (implies --allow-pairing;
|
||||
the host logs a 4-digit PIN when a client starts pairing)
|
||||
|
||||
M0 OPTIONS:
|
||||
--source <synthetic|portal|kwin-virtual>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! 3. The ScreenCast portal yields the output's PipeWire node. There is no GUI to pick an
|
||||
//! output headlessly, so xdpw is steered through its chooser hook: a managed config
|
||||
//! (`~/.config/xdg-desktop-portal-wlr/config`, written once + portal restarted on change)
|
||||
//! sets `chooser_type=simple` with a `chooser_cmd` that cats [`CHOOSER_FILE`], which we
|
||||
//! sets `chooser_type=simple` with a `chooser_cmd` that cats the chooser file, which we
|
||||
//! write per session (`Monitor: <NAME>` — xdpw 0.8 parses that prefix strictly).
|
||||
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and
|
||||
//! runs `swaymsg output <NAME> unplug` (headless outputs support unplug since sway 1.8).
|
||||
@@ -29,18 +29,28 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// File the xdpw output chooser reads the selected output from (see [`XDPW_CONFIG`]); we write
|
||||
/// `Monitor: <NAME>\n` here right before the portal handshake selects sources.
|
||||
const CHOOSER_FILE: &str = "/tmp/punktfunk-xdpw-output";
|
||||
/// File the xdpw output chooser reads the selected output from (see [`xdpw_config`]); we
|
||||
/// write `Monitor: <NAME>\n` here right before the portal handshake selects sources. Lives
|
||||
/// under `$XDG_RUNTIME_DIR` (per-user, mode 0700) — NOT a fixed world-writable /tmp path,
|
||||
/// where another local user could pre-create it (DoS) or rewrite it between our write and
|
||||
/// xdpw's read (steer capture at a different output).
|
||||
fn chooser_file() -> String {
|
||||
let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
|
||||
format!("{dir}/punktfunk-xdpw-output")
|
||||
}
|
||||
|
||||
/// The managed xdpw config: per-session output selection with no GUI. The `|| echo` fallback
|
||||
/// keeps plain portal capture (`--source portal`, M0 flow) working when no session has written
|
||||
/// the chooser file. xdpw runs `chooser_cmd` via `/bin/sh -c`, reads stdout.
|
||||
const XDPW_CONFIG: &str =
|
||||
"# managed by punktfunk (vdisplay/wlroots.rs) — per-session output selection.\n\
|
||||
fn xdpw_config() -> String {
|
||||
format!(
|
||||
"# managed by punktfunk (vdisplay/wlroots.rs) — per-session output selection.\n\
|
||||
[screencast]\n\
|
||||
chooser_type=simple\n\
|
||||
chooser_cmd=cat /tmp/punktfunk-xdpw-output 2>/dev/null || echo 'Monitor: HEADLESS-1'\n";
|
||||
chooser_cmd=cat {} 2>/dev/null || echo 'Monitor: HEADLESS-1'\n",
|
||||
chooser_file()
|
||||
)
|
||||
}
|
||||
|
||||
/// The wlroots/Sway virtual-display driver. Stateless — each [`create`](VirtualDisplay::create)
|
||||
/// adds one headless output and spins up a portal thread owning the cast on it.
|
||||
@@ -82,8 +92,9 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
// Steer xdpw's headless output chooser at our new output, then run the portal
|
||||
// handshake on its own thread (it parks to keep the cast alive, like the other backends).
|
||||
ensure_xdpw_config()?;
|
||||
std::fs::write(CHOOSER_FILE, format!("Monitor: {name}\n"))
|
||||
.with_context(|| format!("write {CHOOSER_FILE}"))?;
|
||||
let chooser = chooser_file();
|
||||
std::fs::write(&chooser, format!("Monitor: {name}\n"))
|
||||
.with_context(|| format!("write {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();
|
||||
@@ -207,7 +218,7 @@ fn wait_new_output(before: &[String], timeout: Duration) -> Result<String> {
|
||||
|
||||
/// Make sure xdpw uses our output chooser. xdpw reads its config only at startup, so on a
|
||||
/// change restart it if running (`try-restart`; if it isn't, D-Bus activation will start it
|
||||
/// with the new config). The config itself is static — the *selection* is [`CHOOSER_FILE`].
|
||||
/// with the new config). The config itself is static — the *selection* is the chooser file.
|
||||
fn ensure_xdpw_config() -> Result<()> {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
@@ -215,11 +226,12 @@ fn ensure_xdpw_config() -> Result<()> {
|
||||
.ok_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?;
|
||||
let dir = base.join("xdg-desktop-portal-wlr");
|
||||
let path = dir.join("config");
|
||||
if std::fs::read_to_string(&path).is_ok_and(|c| c == XDPW_CONFIG) {
|
||||
let cfg = xdpw_config();
|
||||
if std::fs::read_to_string(&path).is_ok_and(|c| c == cfg) {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?;
|
||||
std::fs::write(&path, XDPW_CONFIG).with_context(|| format!("write {}", path.display()))?;
|
||||
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")
|
||||
.args(["--user", "try-restart", "xdg-desktop-portal-wlr.service"])
|
||||
|
||||
Reference in New Issue
Block a user