Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 110 additions & 2 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -48,17 +49,35 @@ fn maybe_detach_active(cli: &Cli) -> anyhow::Result<bool> {
return Ok(false);
}
let exe = std::env::current_exe()?;
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let args: Vec<OsString> = 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<OsString>,
) -> Vec<(OsString, Option<OsString>)> {
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<Option<PathBuf>> {
let Some(raw_path) = cli.session_file.as_ref() else {
return Ok(None);
Expand Down Expand Up @@ -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<OsString> {
(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);
});
}
}
11 changes: 4 additions & 7 deletions src/backend/wayland/backend/state_init/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
}
Expand Down
22 changes: 4 additions & 18 deletions src/daemon/overlay/tests/fake_overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -98,8 +97,10 @@ fn serve(ignore_term: bool) -> Result<Infallible> {
}

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),
Expand Down Expand Up @@ -149,21 +150,6 @@ fn launch_arguments() -> Result<Vec<String>> {
.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<HashMap<String, String>> {
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<Vec<Vec<u8>>> {
let raw = std::fs::read(path).with_context(|| format!("failed to read {path}"))?;
let raw = raw.strip_suffix(b"\0").unwrap_or(&raw);
Expand Down
65 changes: 65 additions & 0 deletions src/launch_environment.rs
Original file line number Diff line number Diff line change
@@ -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<OsString> {
var_os_in(Path::new(LAUNCH_ENVIRONMENT), name)
}

fn var_os_in(launch_environment: &Path, name: &str) -> Option<OsString> {
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<String> {
startup_activation_token_from(var_os)
}

fn startup_activation_token_from(lookup: impl Fn(&str) -> Option<OsString>) -> Option<String> {
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;
116 changes: 116 additions & 0 deletions src/launch_environment/tests.rs
Original file line number Diff line number Diff line change
@@ -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"))
);
});
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading