From 3a64cbdf70b18535c5aebf200a39d5daf6205475 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:02:43 +0200 Subject: [PATCH] fix(wayland): recover the startup activation token GTK unsets GTK 4.16 and later save XDG_ACTIVATION_TOKEN and DESKTOP_STARTUP_ID for their own windows and unset both as soon as libgtk-4 loads, before main. With the default toolbar-gtk feature, the overlay's std::env read of the token always came back empty, so the xdg fallback window (GNOME and other compositors without layer-shell) never applied the token a launcher or the portal global shortcut passed, and could open without focus. - Read startup-notification variables from the launch environment, /proc/self/environ, falling back to the live environment without /proc. - Use XDG_ACTIVATION_TOKEN, else DESKTOP_STARTUP_ID, keeping the trimming and empty-value handling. - Forward both variables when `--active` relaunches itself detached: the broker relaunches with the live environment, which no longer has them. - Reuse the reader in the fake overlay fixture. --- src/app/mod.rs | 112 ++++++++++++++++- src/backend/wayland/backend/state_init/mod.rs | 11 +- src/daemon/overlay/tests/fake_overlay.rs | 22 +--- src/launch_environment.rs | 65 ++++++++++ src/launch_environment/tests.rs | 116 ++++++++++++++++++ src/lib.rs | 1 + 6 files changed, 300 insertions(+), 27 deletions(-) create mode 100644 src/launch_environment.rs create mode 100644 src/launch_environment/tests.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 8d2cf641..8c851e47 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,6 +12,7 @@ use crate::session_override::set_runtime_session_override; use anyhow::Context; use env::env_flag_enabled; use session::run_session_cli_commands; +use std::ffi::OsString; use std::fs::{File, OpenOptions}; use std::io::ErrorKind; use std::path::{Path, PathBuf}; @@ -48,17 +49,35 @@ fn maybe_detach_active(cli: &Cli) -> anyhow::Result { return Ok(false); } let exe = std::env::current_exe()?; - let args: Vec = std::env::args_os().skip(1).collect(); + let args: Vec = std::env::args_os().skip(1).collect(); crate::process_broker::current()?.spawn( crate::process_broker::HelperKind::InitialDetach, crate::process_broker::HelperLifetime::DetachedAfterExec, exe.as_os_str(), args, - vec![(DETACHED_ENV.into(), Some("1".into()))], + detach_environment(crate::launch_environment::var_os), )?; Ok(true) } +/// The detached relaunch's environment: the detached marker and the +/// startup-notification variables this process was launched with. The broker +/// relaunches with the live environment, from which a linked GTK may already +/// have unset the startup token, so the launch values are forwarded. +fn detach_environment( + launched_with: impl Fn(&str) -> Option, +) -> Vec<(OsString, Option)> { + let mut environment = vec![(DETACHED_ENV.into(), Some("1".into()))]; + + for name in crate::launch_environment::STARTUP_NOTIFICATION_VARIABLES { + if let Some(value) = launched_with(name) { + environment.push((name.into(), Some(value))); + } + } + + environment +} + fn normalized_named_session_file(cli: &Cli) -> anyhow::Result> { let Some(raw_path) = cli.session_file.as_ref() else { return Ok(None); @@ -380,4 +399,93 @@ mod tests { assert!(unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) } >= 0); assert_eq!(std::fs::read(&path).unwrap(), b"before "); } + + /// Names the report file, and so marks the detached child run, of the + /// relaunch test below. + const DETACH_CHILD_REPORT_ENV: &str = "WAYSCRIBER_TEST_DETACH_CHILD_REPORT"; + const DETACH_LAUNCH_TOKEN: &str = "detach-launch-token"; + + fn launched_with_token(name: &str) -> Option { + (name == crate::env_vars::XDG_ACTIVATION_TOKEN_ENV).then(|| DETACH_LAUNCH_TOKEN.into()) + } + + #[test] + fn the_detached_relaunch_forwards_the_launch_startup_notification() { + let detached = (OsString::from(DETACHED_ENV), Some(OsString::from("1"))); + let token = ( + OsString::from(crate::env_vars::XDG_ACTIVATION_TOKEN_ENV), + Some(OsString::from(DETACH_LAUNCH_TOKEN)), + ); + + assert_eq!( + detach_environment(launched_with_token), + vec![detached.clone(), token] + ); + assert_eq!(detach_environment(|_| None), vec![detached]); + } + + /// The broker relaunches with the live environment, which here lacks the + /// startup variables, as it does once GTK has unset them. Only a forwarded + /// token reaches the detached process. + #[test] + fn a_detached_relaunch_keeps_the_launch_token_only_when_forwarded() { + if let Some(report) = std::env::var_os(DETACH_CHILD_REPORT_ENV) { + let token = crate::launch_environment::startup_activation_token().unwrap_or_default(); + crate::durable_io::write_atomic( + Path::new(&report), + token.as_bytes(), + crate::durable_io::AtomicWriteOptions::private_runtime_file(), + ) + .unwrap(); + return; + } + + let temp = crate::test_temp::tempdir().unwrap(); + let report = temp.path().join("detached-token"); + let test_name = concat!( + module_path!(), + "::a_detached_relaunch_keeps_the_launch_token_only_when_forwarded" + ) + .strip_prefix(concat!(env!("CARGO_CRATE_NAME"), "::")) + .expect("test path contains the crate prefix"); + let removed = crate::launch_environment::STARTUP_NOTIFICATION_VARIABLES + .map(|name| (name, None::<&std::ffi::OsStr>)); + let mut variables = vec![(DETACH_CHILD_REPORT_ENV, Some(report.as_os_str()))]; + variables.extend(removed); + + crate::test_env::with_env_vars(&variables, || { + let guard = crate::process_broker::start_for_runtime().unwrap(); + let relaunch = |environment| { + let _ = std::fs::remove_file(&report); + guard + .broker() + .spawn( + crate::process_broker::HelperKind::InitialDetach, + crate::process_broker::HelperLifetime::DetachedAfterExec, + std::env::current_exe().unwrap().as_os_str(), + [test_name, "--exact", "--test-threads=1"], + environment, + ) + .unwrap(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + if let Ok(token) = std::fs::read_to_string(&report) { + break token; + } + assert!( + std::time::Instant::now() < deadline, + "the detached child did not report" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }; + + let unforwarded = relaunch(vec![(DETACHED_ENV.into(), Some("1".into()))]); + let forwarded = relaunch(detach_environment(launched_with_token)); + + assert_eq!(unforwarded, ""); + assert_eq!(forwarded, DETACH_LAUNCH_TOKEN); + }); + } } diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index f9a5e7ad..802f3008 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -9,9 +9,7 @@ use super::WaylandBackend; use super::runtime_wake::RuntimeWakeSource; use super::setup::WaylandSetup; use crate::backend::wayland::portal_capture::portal_freeze_fallback; -use crate::env_vars::{ - DESKTOP_SESSION_ENV, XDG_ACTIVATION_TOKEN_ENV, XDG_CURRENT_DESKTOP_ENV, XDG_SESSION_DESKTOP_ENV, -}; +use crate::env_vars::{DESKTOP_SESSION_ENV, XDG_CURRENT_DESKTOP_ENV, XDG_SESSION_DESKTOP_ENV}; use crate::{ capture::CaptureManager, config::Config, @@ -108,10 +106,9 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul portal_freeze_fallback(&backend.tokio_runtime, direct_capture_supported); let frozen_supported = direct_capture_supported || portal_freeze_supported; let tokio_handle = backend.tokio_runtime.handle().clone(); - let startup_activation_token = env::var(XDG_ACTIVATION_TOKEN_ENV) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); + // Read from the launch environment: a linked GTK 4.16 or later unsets the + // token from the live environment before `main`. + let startup_activation_token = crate::launch_environment::startup_activation_token(); if startup_activation_token.is_some() { info!("Received startup activation token from launcher environment"); } diff --git a/src/daemon/overlay/tests/fake_overlay.rs b/src/daemon/overlay/tests/fake_overlay.rs index 2a860845..946c856d 100644 --- a/src/daemon/overlay/tests/fake_overlay.rs +++ b/src/daemon/overlay/tests/fake_overlay.rs @@ -8,7 +8,6 @@ //! arguments. Without both the fixture marker and an overlay generation, the //! constructor returns and the binary runs its tests as usual. -use std::collections::HashMap; use std::convert::Infallible; use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; @@ -98,8 +97,10 @@ fn serve(ignore_term: bool) -> Result { } fn write_receipt() -> Result<()> { - let environment = launch_environment()?; - let launched_with = |name: &str| environment.get(name).cloned(); + // As launched: GTK may already have unset the startup-notification variables. + let launched_with = |name: &str| { + crate::launch_environment::var_os(name).map(|value| value.to_string_lossy().into_owned()) + }; let receipt = serde_json::json!({ "args": launch_arguments()?, "token": launched_with(crate::env_vars::XDG_ACTIVATION_TOKEN_ENV), @@ -149,21 +150,6 @@ fn launch_arguments() -> Result> { .collect() } -/// Reads the environment as the daemon launched this process. Library -/// constructors that run first consume startup-notification variables such -/// as `XDG_ACTIVATION_TOKEN`, so the live environment no longer shows them. -fn launch_environment() -> Result> { - Ok(nul_separated("/proc/self/environ")? - .into_iter() - .filter_map(|entry| String::from_utf8(entry).ok()) - .filter_map(|entry| { - entry - .split_once('=') - .map(|(name, value)| (name.to_owned(), value.to_owned())) - }) - .collect()) -} - fn nul_separated(path: &str) -> Result>> { let raw = std::fs::read(path).with_context(|| format!("failed to read {path}"))?; let raw = raw.strip_suffix(b"\0").unwrap_or(&raw); diff --git a/src/launch_environment.rs b/src/launch_environment.rs new file mode 100644 index 00000000..36f1a6b6 --- /dev/null +++ b/src/launch_environment.rs @@ -0,0 +1,65 @@ +//! The environment the process was launched with. +//! +//! A library can change the live environment before `main` runs. GTK 4.16 and +//! later save `XDG_ACTIVATION_TOKEN` and `DESKTOP_STARTUP_ID` for their own +//! windows and unset both as soon as libgtk-4 loads (GTK 4.14 unsets only +//! `DESKTOP_STARTUP_ID`), so with the `toolbar-gtk` feature `std::env::var` may +//! no longer see a startup token the launcher or the daemon passed. The +//! kernel's copy of the launch environment, `/proc/self/environ`, keeps the +//! original values. A child process inherits only the live environment, so a +//! relaunch forwards these variables explicitly. + +use std::ffi::OsString; +use std::os::unix::ffi::OsStringExt; +use std::path::Path; + +use crate::env_vars::{DESKTOP_STARTUP_ID_ENV, XDG_ACTIVATION_TOKEN_ENV}; + +const LAUNCH_ENVIRONMENT: &str = "/proc/self/environ"; + +/// The startup-notification variables a launcher passes for the first window, +/// in the order they are tried. The daemon sets both to the same token. +pub(crate) const STARTUP_NOTIFICATION_VARIABLES: [&str; 2] = + [XDG_ACTIVATION_TOKEN_ENV, DESKTOP_STARTUP_ID_ENV]; + +/// `name`'s value as the process was launched, the first one if it was passed +/// twice. Without `/proc` this falls back to the live environment, where a +/// linked GTK may already have unset a startup token. +pub(crate) fn var_os(name: &str) -> Option { + var_os_in(Path::new(LAUNCH_ENVIRONMENT), name) +} + +fn var_os_in(launch_environment: &Path, name: &str) -> Option { + match std::fs::read(launch_environment) { + Ok(environment) => { + value_in(&environment, name).map(|value| OsString::from_vec(value.to_vec())) + } + Err(_) => std::env::var_os(name), + } +} + +/// The activation token a launcher passed for the first window: the first of +/// [`STARTUP_NOTIFICATION_VARIABLES`] with a value. A value is trimmed, and an +/// empty one counts as absent. +pub(crate) fn startup_activation_token() -> Option { + startup_activation_token_from(var_os) +} + +fn startup_activation_token_from(lookup: impl Fn(&str) -> Option) -> Option { + STARTUP_NOTIFICATION_VARIABLES.into_iter().find_map(|name| { + let value = lookup(name)?.into_string().ok()?; + let value = value.trim(); + + (!value.is_empty()).then(|| value.to_owned()) + }) +} + +/// The value of the first `name=value` entry in a NUL-separated environment. +fn value_in<'a>(environment: &'a [u8], name: &str) -> Option<&'a [u8]> { + environment + .split(|byte| *byte == 0) + .find_map(|entry| entry.strip_prefix(name.as_bytes())?.strip_prefix(b"=")) +} + +#[cfg(test)] +mod tests; diff --git a/src/launch_environment/tests.rs b/src/launch_environment/tests.rs new file mode 100644 index 00000000..eb518214 --- /dev/null +++ b/src/launch_environment/tests.rs @@ -0,0 +1,116 @@ +use super::*; +use std::ffi::OsStr; + +/// Marks the child run of the launch test below. +const CHILD_ENV: &str = "WAYSCRIBER_TEST_LAUNCH_ENVIRONMENT_CHILD"; +const LAUNCH_TOKEN: &str = "launch-environment-probe"; +const LAUNCH_STARTUP_ID: &str = "launch-environment-startup-id"; + +#[test] +fn the_first_entry_for_a_name_is_its_value() { + let environment = b"HOME=/home/user\0XDG_ACTIVATION_TOKEN=first\0XDG_ACTIVATION_TOKEN=second\0"; + + assert_eq!( + value_in(environment, XDG_ACTIVATION_TOKEN_ENV), + Some(&b"first"[..]) + ); +} + +#[test] +fn only_a_whole_name_matches() { + let environment = b"XDG_ACTIVATION_TOKEN_EXTRA=x\0XDG_ACTIVATION=y\0EMPTY=\0"; + + assert_eq!(value_in(environment, XDG_ACTIVATION_TOKEN_ENV), None); + assert_eq!(value_in(environment, "EMPTY"), Some(&b""[..])); +} + +#[test] +fn the_startup_token_is_trimmed_and_falls_back_to_the_startup_id() { + let token = |entries: &[(&str, &str)]| { + startup_activation_token_from(|name| { + entries + .iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| OsString::from(value)) + }) + }; + + assert_eq!( + token(&[(XDG_ACTIVATION_TOKEN_ENV, " token \n")]).as_deref(), + Some("token") + ); + assert_eq!( + token(&[ + (XDG_ACTIVATION_TOKEN_ENV, "first"), + (DESKTOP_STARTUP_ID_ENV, "second") + ]) + .as_deref(), + Some("first") + ); + assert_eq!( + token(&[ + (XDG_ACTIVATION_TOKEN_ENV, " "), + (DESKTOP_STARTUP_ID_ENV, "startup") + ]) + .as_deref(), + Some("startup") + ); + assert_eq!(token(&[(DESKTOP_STARTUP_ID_ENV, "")]), None); + assert_eq!(token(&[]), None); +} + +/// A token passed at launch reaches the reader after the live environment +/// loses it. The launched child removes both variables itself, as GTK 4.16 and +/// later do before `main`, so the outcome does not depend on the build's +/// features or the system's GTK. +#[test] +fn a_launch_token_survives_its_removal_from_the_live_environment() { + if std::env::var_os(CHILD_ENV).is_some() { + let removed = STARTUP_NOTIFICATION_VARIABLES.map(|name| (name, None::<&OsStr>)); + crate::test_env::with_env_vars(&removed, || { + for name in STARTUP_NOTIFICATION_VARIABLES { + assert_eq!(std::env::var_os(name), None, "{name} is still live"); + } + assert_eq!(startup_activation_token().as_deref(), Some(LAUNCH_TOKEN)); + assert_eq!( + var_os(DESKTOP_STARTUP_ID_ENV).as_deref(), + Some(OsStr::new(LAUNCH_STARTUP_ID)) + ); + }); + return; + } + + let test_name = concat!( + module_path!(), + "::a_launch_token_survives_its_removal_from_the_live_environment" + ) + .strip_prefix(concat!(env!("CARGO_CRATE_NAME"), "::")) + .expect("test path contains the crate prefix"); + let output = std::process::Command::new(std::env::current_exe().expect("test binary")) + .args([test_name, "--exact", "--test-threads=1"]) + .env(CHILD_ENV, "1") + .env(XDG_ACTIVATION_TOKEN_ENV, LAUNCH_TOKEN) + .env(DESKTOP_STARTUP_ID_ENV, LAUNCH_STARTUP_ID) + .output() + .expect("run the test binary as a launched child"); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!(output.status.success(), "launched child failed:\n{stdout}"); + // A filter that matched nothing would pass without running the child. + assert!( + stdout.contains("1 passed;"), + "launched child ran no test:\n{stdout}" + ); +} + +#[test] +fn without_a_launch_environment_the_live_one_is_read() { + const NAME: &str = "WAYSCRIBER_TEST_LAUNCH_ENVIRONMENT_FALLBACK"; + + crate::test_env::with_env_var(NAME, Some(OsStr::new("live")), || { + assert_eq!( + var_os_in(Path::new("/nonexistent/environ"), NAME).as_deref(), + Some(OsStr::new("live")) + ); + }); +} diff --git a/src/lib.rs b/src/lib.rs index 0d86668d..667a77b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ pub mod help_overlay_interaction; pub(crate) mod image_decode; pub mod input; mod label_format; +pub(crate) mod launch_environment; mod logger; mod notification; mod ocr;