From ce024d4794e1f7a78e828a72ef5e8f26a3643d92 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 7 Oct 2026 08:03:30 +0100 Subject: [PATCH] Isolate nested IPC sockets and define session ownership policy --- compositor/src/backends/mod.rs | 19 +++ compositor/src/daemon.rs | 85 +++++++++--- compositor/src/ipc.rs | 110 ++++++++++++++-- compositor/src/main.rs | 8 +- compositor/src/session_environment.rs | 66 ++++++++++ compositor/src/startup_tests.rs | 122 ++++++++++++++++++ compositor/src/state.rs | 21 ++- compositor/src/xwayland/child.rs | 2 + compositor/src/xwayland/integration_tests.rs | 2 + crates/ferese-ipc/src/socket.rs | 5 + docs/installation.md | 10 +- docs/ipc.md | 15 ++- packaging/ferese-session-shell | 9 +- portal/src/bridge.rs | 5 +- portal/src/desktop.rs | 5 +- scripts/tests/session_socket.py | 17 +++ scripts/tests/test_blurred_popup_isolated.py | 5 +- scripts/tests/test_floating_isolated.py | 7 +- .../tests/test_floating_placement_isolated.py | 7 +- scripts/tests/test_focus_history_isolated.py | 7 +- .../tests/test_idle_inhibition_isolated.py | 11 +- scripts/tests/test_inhibit_isolated.py | 11 +- scripts/tests/test_lock_isolated.py | 9 +- scripts/tests/test_material_isolated.py | 9 +- scripts/tests/test_media_isolated.py | 11 +- .../tests/test_overview_damage_isolated.py | 7 +- .../tests/test_overview_previews_isolated.py | 7 +- scripts/tests/test_presentation_isolated.py | 11 +- .../test_resize_dependencies_isolated.py | 9 +- scripts/tests/test_restore_isolated.py | 11 +- scripts/tests/test_session_socket.py | 50 +++++++ scripts/tests/test_session_startup.py | 11 ++ scripts/tests/test_shell_motion_isolated.py | 15 ++- scripts/tests/test_shortcuts_isolated.py | 7 +- scripts/tests/test_theme_isolated.py | 11 +- .../tests/test_unmapped_toplevel_isolated.py | 13 +- scripts/tests/test_window_capture_isolated.py | 13 +- scripts/tests/test_window_stream_isolated.py | 17 +-- .../tests/test_workspace_history_isolated.py | 7 +- shell/ferese-polkit-agent/src/main.rs | 4 +- shell/ferese-settings/src/store.rs | 5 +- shell/ferese-shell/src/compositor_ipc.rs | 6 +- 42 files changed, 635 insertions(+), 147 deletions(-) create mode 100644 scripts/tests/session_socket.py create mode 100644 scripts/tests/test_session_socket.py diff --git a/compositor/src/backends/mod.rs b/compositor/src/backends/mod.rs index 85c98dc..70f4106 100644 --- a/compositor/src/backends/mod.rs +++ b/compositor/src/backends/mod.rs @@ -7,6 +7,7 @@ use smithay::reexports::calloop::EventLoop; use crate::Ferese; use crate::private_client::ClientCapabilities; +use crate::session_environment::SessionPolicy; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum BackendKind { @@ -17,6 +18,7 @@ pub enum BackendKind { #[derive(Debug, Eq, PartialEq)] pub struct LaunchConfig { pub backend: BackendKind, + pub(crate) session_policy: SessionPolicy, pub client: Vec, pub client_capabilities: ClientCapabilities, } @@ -75,6 +77,10 @@ impl LaunchConfig { Ok(Self { backend, + session_policy: match backend { + BackendKind::Drm => SessionPolicy::Desktop, + BackendKind::Nested => SessionPolicy::Embedded, + }, client, client_capabilities, }) @@ -110,6 +116,7 @@ mod tests { let config = LaunchConfig::parse([OsString::from("foot")], true).unwrap(); assert_eq!(config.backend, BackendKind::Nested); + assert_eq!(config.session_policy, SessionPolicy::Embedded); assert_eq!(config.client, [OsString::from("foot")]); assert!(config.client_capabilities.is_empty()); } @@ -119,6 +126,7 @@ mod tests { let config = LaunchConfig::parse(Vec::::new(), false).unwrap(); assert_eq!(config.backend, BackendKind::Drm); + assert_eq!(config.session_policy, SessionPolicy::Desktop); assert!(config.client.is_empty()); assert!(config.client_capabilities.is_empty()); } @@ -178,4 +186,15 @@ mod tests { assert!(error.contains("unknown backend")); } + + #[test] + fn explicit_backend_selects_the_default_session_policy() { + for (backend, graphical, policy) in [ + ("--backend=drm", true, SessionPolicy::Desktop), + ("--backend=nested", false, SessionPolicy::Embedded), + ] { + let config = LaunchConfig::parse([OsString::from(backend)], graphical).unwrap(); + assert_eq!(config.session_policy, policy); + } + } } diff --git a/compositor/src/daemon.rs b/compositor/src/daemon.rs index fb5f046..3635d3f 100644 --- a/compositor/src/daemon.rs +++ b/compositor/src/daemon.rs @@ -9,10 +9,10 @@ use smithay::reexports::calloop::generic::Generic; use smithay::reexports::calloop::timer::{TimeoutAction, Timer}; use smithay::reexports::calloop::{Interest, Mode, PostAction, RegistrationToken}; -use crate::Ferese; use crate::config::DaemonConfig; use crate::private_client::ClientCapabilities; use crate::process::{pidfd, spawn_client, terminate_child}; +use crate::{Ferese, SessionPolicy}; struct Service { config: DaemonConfig, @@ -31,7 +31,7 @@ impl Runner { pub fn start(state: &mut Ferese) -> Rc> { let runner = Rc::new(RefCell::new(Self::new( &state.autostart, - state.direct_backend.is_none(), + state.session_environment.policy, ))); Self::refresh(&runner, state); runner @@ -114,9 +114,12 @@ impl Runner { } } - pub fn reconcile(&mut self, configs: &[DaemonConfig], nested: bool) { + pub fn reconcile(&mut self, configs: &[DaemonConfig], policy: SessionPolicy) { let mut previous = std::mem::take(&mut self.0); - for config in configs.iter().filter(|c| c.enabled && (!nested || c.nested)) { + for config in configs + .iter() + .filter(|c| c.enabled && policy.allows_autostart(c.nested)) + { if self.0.iter().any(|service| service.config.command == config.command) { continue; } @@ -148,11 +151,11 @@ impl Runner { } } - pub fn new(configs: &[DaemonConfig], nested: bool) -> Self { + pub fn new(configs: &[DaemonConfig], policy: SessionPolicy) -> Self { Self( configs .iter() - .filter(|config| config.enabled && (!nested || config.nested)) + .filter(|config| config.enabled && policy.allows_autostart(config.nested)) .map(|config| Service { config: config.clone(), child: None, @@ -166,7 +169,7 @@ impl Runner { } pub fn tick(&mut self, state: &mut Ferese) { - self.reconcile(&state.autostart, state.direct_backend.is_none()); + self.reconcile(&state.autostart, state.session_environment.policy); let now = Instant::now(); for service in &mut self.0 { if let Some(child) = &mut service.child { @@ -244,17 +247,17 @@ mod tests { restart: false, nested: false, }; - let mut runner = Runner::new(&[config.clone()], false); + let mut runner = Runner::new(&[config.clone()], SessionPolicy::Desktop); runner.0[0].child = Some(std::process::Command::new("/bin/sleep").arg("30").spawn().unwrap()); let pid = runner.0[0].child.as_ref().unwrap().id(); config.restart = true; - runner.reconcile(&[config.clone()], false); + runner.reconcile(&[config.clone()], SessionPolicy::Desktop); assert_eq!(runner.0[0].child.as_ref().unwrap().id(), pid); assert!(runner.0[0].config.restart); - runner.reconcile(&[config.clone(), config.clone()], false); + runner.reconcile(&[config.clone(), config.clone()], SessionPolicy::Desktop); assert_eq!(runner.0.len(), 1); config.enabled = false; - runner.reconcile(&[config], false); + runner.reconcile(&[config], SessionPolicy::Desktop); assert!(runner.0.is_empty()); let deadline = Instant::now() + Duration::from_secs(2); while unsafe { libc::kill(pid as i32, 0) } == 0 { @@ -271,22 +274,72 @@ mod tests { restart: true, nested: false, }]; - assert!(Runner::new(&configs, true).0.is_empty()); - let mut direct = Runner::new(&configs, false); + assert!(Runner::new(&configs, SessionPolicy::Embedded).0.is_empty()); + let mut direct = Runner::new(&configs, SessionPolicy::Desktop); assert_eq!(direct.0.len(), 1); direct.stop(); assert!(direct.0[0].finished); } #[test] - fn disabled_login_items_never_start_on_either_backend() { + fn disabled_login_items_never_start_under_either_session_policy() { let configs = vec![DaemonConfig { command: vec!["not-executed".into()], enabled: false, restart: true, nested: true, }]; - assert!(Runner::new(&configs, true).0.is_empty()); - assert!(Runner::new(&configs, false).0.is_empty()); + assert!(Runner::new(&configs, SessionPolicy::Embedded).0.is_empty()); + assert!(Runner::new(&configs, SessionPolicy::Desktop).0.is_empty()); + } + + #[test] + fn embedded_autostart_requires_opt_in_on_start_and_reload() { + let regular = DaemonConfig { + command: vec!["regular".into()], + enabled: true, + restart: false, + nested: false, + }; + let embedded = DaemonConfig { + command: vec!["embedded".into()], + nested: true, + ..regular.clone() + }; + let configs = [regular, embedded.clone()]; + let mut runner = Runner::new(&configs, SessionPolicy::Embedded); + assert_eq!(runner.0.len(), 1); + assert_eq!(runner.0[0].config, embedded); + runner.reconcile(&configs, SessionPolicy::Desktop); + assert_eq!(runner.0.len(), 2); + runner.reconcile(&configs, SessionPolicy::Embedded); + assert_eq!(runner.0.len(), 1); + assert_eq!(runner.0[0].config, embedded); + } + + #[test] + fn desktop_autostart_does_not_depend_on_a_live_drm_backend() { + if !crate::startup_tests::private_runtime( + "daemon::tests::desktop_autostart_does_not_depend_on_a_live_drm_backend", + ) { + return; + } + let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap(); + let mut state = crate::startup_tests::state(&mut events); + assert!(state.direct_backend.is_none()); + assert_eq!(state.session_environment.policy, SessionPolicy::Desktop); + state.autostart = vec![DaemonConfig { + command: vec!["/bin/sleep".into(), "30".into()], + enabled: true, + restart: false, + nested: false, + }]; + let runner = Runner::start(&mut state); + assert_eq!(runner.borrow().0.len(), 1); + assert!(runner.borrow().0[0].child.is_some()); + runner.borrow_mut().stop(); + state.session_environment.policy = SessionPolicy::Embedded; + Runner::refresh(&runner, &mut state); + assert!(runner.borrow().0.is_empty()); } } diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 2386ed6..84ff48d 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::mpsc::{SyncSender, sync_channel}; use std::time::{Duration, Instant}; -use std::{env, fs, io, thread}; +use std::{fs, io, thread}; use ferese_core::LayoutMode; use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; @@ -78,6 +78,41 @@ pub(crate) struct IpcSocketGuard { path: PathBuf, device: u64, inode: u64, + _instance: Option, +} + +pub(crate) struct Endpoint { + pub(crate) path: PathBuf, + instance: Option, +} + +impl Endpoint { + pub(crate) fn desktop() -> io::Result { + Self::desktop_at(ferese_ipc::socket::runtime_default()?) + } + + fn desktop_at(path: PathBuf) -> io::Result { + prepare_parent(&path)?; + Ok(Self { path, instance: None }) + } + + pub(crate) fn instance() -> io::Result { + Self::instance_at(ferese_ipc::socket::runtime_default()?) + } + + fn instance_at(default: PathBuf) -> io::Result { + let root = default.parent().expect("runtime control socket has a parent"); + prepare_directory(root)?; + let instances = root.join("instances"); + prepare_directory(&instances)?; + let instance = tempfile::Builder::new() + .prefix(&format!("{}-", std::process::id())) + .tempdir_in(instances)?; + Ok(Self { + path: instance.path().join("control.sock"), + instance: Some(instance), + }) + } } impl Drop for IpcSocketGuard { @@ -90,8 +125,11 @@ impl Drop for IpcSocketGuard { } } -pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result> { - let path = socket_path()?; +pub(crate) fn init( + event_loop: &mut EventLoop<'static, Ferese>, + endpoint: Endpoint, +) -> Result> { + let Endpoint { path, instance } = endpoint; prepare_parent(&path)?; let listener = bind_listener(&path)?; fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; @@ -100,6 +138,7 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result, channel::Channel) = @@ -1531,16 +1570,17 @@ fn workspace_arg(args: &Value) -> Result { .ok_or_else(|| CommandError::new("invalid_argument", "index must be a positive 32-bit integer")) } -fn socket_path() -> Result { - env::var_os("XDG_RUNTIME_DIR") - .filter(|value| !value.is_empty()) - .map(PathBuf::from) - .map(|directory| directory.join("ferese/control.sock")) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set")) -} - fn prepare_parent(socket: &Path) -> Result<(), io::Error> { let parent = socket.parent().expect("the Ferese control socket always has a parent"); + prepare_directory(parent) +} + +fn prepare_directory(parent: &Path) -> Result<(), io::Error> { + match fs::create_dir(parent) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } match fs::symlink_metadata(parent) { Ok(metadata) => { if !metadata.is_dir() || metadata.file_type().is_symlink() { @@ -1556,7 +1596,6 @@ fn prepare_parent(socket: &Path) -> Result<(), io::Error> { )); } } - Err(error) if error.kind() == io::ErrorKind::NotFound => fs::create_dir(parent)?, Err(error) => return Err(error), } @@ -1613,6 +1652,7 @@ fn effective_uid() -> u32 { #[cfg(test)] mod tests { + use std::env; use std::io::{Read, Write}; use std::net::Shutdown; use std::os::unix::fs::PermissionsExt; @@ -1818,6 +1858,52 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn nested_endpoints_reserve_private_unique_directories_and_preserve_the_default() { + let runtime = tempfile::tempdir().unwrap(); + let default = runtime.path().join("ferese/control.sock"); + let drm = Endpoint::desktop_at(default.clone()).unwrap(); + let host = bind_listener(&drm.path).unwrap(); + let first = Endpoint::instance_at(default.clone()).unwrap(); + let second = Endpoint::instance_at(default.clone()).unwrap(); + assert_eq!(drm.path, default); + assert_ne!(first.path, second.path); + assert_eq!( + first.path.parent().unwrap().parent().unwrap(), + default.parent().unwrap().join("instances") + ); + for endpoint in [&first, &second] { + assert_eq!( + fs::metadata(endpoint.path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + } + let first_directory = first.path.parent().unwrap().to_owned(); + drop(first); + assert!(!first_directory.exists()); + assert!(second.path.parent().unwrap().is_dir()); + assert!(default.symlink_metadata().unwrap().file_type().is_socket()); + assert_eq!(bind_listener(&default).unwrap_err().kind(), io::ErrorKind::AddrInUse); + drop(host); + } + + #[test] + fn nested_endpoint_rejects_an_instances_symlink() { + let runtime = tempfile::tempdir().unwrap(); + let default = runtime.path().join("ferese/control.sock"); + let _drm = Endpoint::desktop_at(default.clone()).unwrap(); + let outside = runtime.path().join("outside"); + fs::create_dir(&outside).unwrap(); + fs::set_permissions(&outside, fs::Permissions::from_mode(0o755)).unwrap(); + std::os::unix::fs::symlink(&outside, default.parent().unwrap().join("instances")).unwrap(); + assert!(Endpoint::instance_at(default).is_err()); + assert_eq!(fs::metadata(outside).unwrap().permissions().mode() & 0o777, 0o755); + } + #[test] fn listener_reclaims_only_a_stale_owned_socket() { let root = unique_test_directory("stale-socket"); diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 2cba7c1..26a177b 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -46,7 +46,7 @@ mod window_rules; mod winit; mod xwayland; -pub(crate) use session_environment::{SessionEnvironment, X11Environment}; +pub(crate) use session_environment::{SessionEnvironment, SessionPolicy, X11Environment}; use std::error::Error; use std::io; @@ -95,7 +95,11 @@ fn main() -> Result<(), Box> { state.loop_signal.stop(); })?; let display = Display::new()?; - let mut state = Ferese::new(&mut event_loop, display, runtime)?; + let endpoint = match launch.backend { + backends::BackendKind::Drm => ipc::Endpoint::desktop()?, + backends::BackendKind::Nested => ipc::Endpoint::instance()?, + }; + let mut state = Ferese::new_with_session(&mut event_loop, display, runtime, endpoint, launch.session_policy)?; state.config_source = initial_source.filter(|source| source.len() <= 60 * 1024); state.config_sections = state .config_source diff --git a/compositor/src/session_environment.rs b/compositor/src/session_environment.rs index ca2a06b..b92126a 100644 --- a/compositor/src/session_environment.rs +++ b/compositor/src/session_environment.rs @@ -8,10 +8,31 @@ pub(crate) struct X11Environment { pub authority: PathBuf, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SessionPolicy { + Desktop, + Embedded, +} + +impl SessionPolicy { + fn environment_value(self) -> &'static str { + match self { + Self::Desktop => "desktop", + Self::Embedded => "embedded", + } + } + + pub(crate) fn allows_autostart(self, embedded_opt_in: bool) -> bool { + self == Self::Desktop || embedded_opt_in + } +} + // Child endpoints are separate from the host endpoints used by the nested backend. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct SessionEnvironment { pub wayland_display: OsString, + pub control_socket: PathBuf, + pub policy: SessionPolicy, pub x11: Option, } @@ -21,6 +42,8 @@ impl SessionEnvironment { command .env("WAYLAND_DISPLAY", &self.wayland_display) .env("FERESE_PUBLIC_WAYLAND_DISPLAY", &self.wayland_display) + .env("FERESE_SOCKET", &self.control_socket) + .env("FERESE_SESSION_MODE", self.policy.environment_value()) .env_remove("WAYLAND_SOCKET") .env_remove("FERESE_SHELL_CONTROL_SOCKET") .env_remove("DISPLAY") @@ -29,6 +52,9 @@ impl SessionEnvironment { if let Some(x11) = &self.x11 { command.env("DISPLAY", &x11.display).env("XAUTHORITY", &x11.authority); } + if self.policy == SessionPolicy::Embedded { + command.env("FERESE_SESSION_IMPORT_ENV", "0"); + } } } @@ -39,6 +65,8 @@ mod tests { fn environment(x11: Option) -> SessionEnvironment { SessionEnvironment { wayland_display: OsString::from("wayland-7"), + control_socket: PathBuf::from("/run/user/1000/ferese/instances/preview/control.sock"), + policy: SessionPolicy::Desktop, x11, } } @@ -135,4 +163,42 @@ mod tests { assert_eq!(value_of(&command, "DISPLAY"), Some(Some(":7".to_owned()))); } + + #[test] + fn a_host_control_socket_is_replaced_by_the_instance_socket() { + let mut command = Command::new("true"); + command.env("FERESE_SOCKET", "/run/user/1000/ferese/control.sock"); + environment(None).apply_public(&mut command); + assert_eq!( + value_of(&command, "FERESE_SOCKET"), + Some(Some("/run/user/1000/ferese/instances/preview/control.sock".into())) + ); + } + + #[test] + fn embedded_clients_cannot_import_their_environment_into_the_host_session() { + let mut command = Command::new("true"); + command.env("FERESE_SESSION_IMPORT_ENV", "1"); + command.env("FERESE_SESSION_MODE", "desktop"); + let mut embedded = environment(None); + embedded.policy = SessionPolicy::Embedded; + embedded.apply_public(&mut command); + assert_eq!(value_of(&command, "FERESE_SESSION_IMPORT_ENV"), Some(Some("0".into()))); + assert_eq!(value_of(&command, "FERESE_SESSION_MODE"), Some(Some("embedded".into()))); + } + + #[test] + fn desktop_clients_preserve_the_launchers_activation_import_choice() { + for choice in ["0", "1"] { + let mut command = Command::new("true"); + command.env("FERESE_SESSION_IMPORT_ENV", choice); + command.env("FERESE_SESSION_MODE", "embedded"); + environment(None).apply_public(&mut command); + assert_eq!( + value_of(&command, "FERESE_SESSION_IMPORT_ENV"), + Some(Some(choice.into())) + ); + assert_eq!(value_of(&command, "FERESE_SESSION_MODE"), Some(Some("desktop".into()))); + } + } } diff --git a/compositor/src/startup_tests.rs b/compositor/src/startup_tests.rs index c6783c5..3f93efe 100644 --- a/compositor/src/startup_tests.rs +++ b/compositor/src/startup_tests.rs @@ -1,4 +1,5 @@ use std::io::{Read, Write}; +use std::os::unix::fs::FileTypeExt; use std::os::unix::net::UnixStream; use std::os::unix::process::ExitStatusExt; use std::process::{Command, Stdio}; @@ -64,6 +65,127 @@ pub(crate) fn state(events: &mut EventLoop<'static, Ferese>) -> Ferese { Ferese::new(events, Display::new().unwrap(), config).unwrap() } +#[test] +fn nested_instances_share_a_runtime_and_route_launched_clients_to_their_own_ipc() { + use smithay::output::{Mode, Output, PhysicalProperties, Subpixel}; + if !private_runtime("startup_tests::nested_instances_share_a_runtime_and_route_launched_clients_to_their_own_ipc") { + return; + } + let mut host_events = EventLoop::try_new().unwrap(); + let host = state(&mut host_events); + let host_socket = host.session_environment.control_socket.clone(); + let mut first_events = EventLoop::try_new().unwrap(); + let mut second_events = EventLoop::try_new().unwrap(); + let config = || { + let mut config = crate::config::Config::default().runtime_config().unwrap(); + config.wallpaper.path = None; + config + }; + let mut first = Ferese::new_with_session( + &mut first_events, + Display::new().unwrap(), + config(), + crate::ipc::Endpoint::instance().unwrap(), + crate::SessionPolicy::Embedded, + ) + .unwrap(); + let mut second = Ferese::new_with_session( + &mut second_events, + Display::new().unwrap(), + config(), + crate::ipc::Endpoint::instance().unwrap(), + crate::SessionPolicy::Embedded, + ) + .unwrap(); + let first_socket = first.session_environment.control_socket.clone(); + let second_socket = second.session_environment.control_socket.clone(); + assert_ne!(first_socket, host_socket); + assert_ne!(second_socket, host_socket); + assert_ne!(first_socket, second_socket); + assert_ne!(first.socket_name, second.socket_name); + let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap()); + assert_eq!(host_socket, runtime.join("ferese/control.sock")); + for (events, state, name) in [ + (&mut first_events, &mut first, "first"), + (&mut second_events, &mut second, "second"), + ] { + let output = Output::new( + name.into(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + output.change_current_state( + Some(Mode { + size: (800, 600).into(), + refresh: 60_000, + }), + None, + None, + None, + ); + state.space.map_output(&output, (0, 0)); + state.register_output(&output, name.into()); + let report = runtime.join(format!("{name}.json")); + let script = r#"import json, os, socket, struct, sys +s = socket.socket(socket.AF_UNIX) +s.connect(os.environ['FERESE_SOCKET']) +s.settimeout(2) +payload = json.dumps({'version':1, 'id':1, 'type':'command', 'command':'outputs', 'args':{}}).encode() +s.sendall(struct.pack('>I', len(payload)) + payload) +def read_exact(n): + data = b'' + while len(data) < n: + part = s.recv(n - len(data)) + if not part: raise RuntimeError('IPC disconnected') + data += part + return data +response = json.loads(read_exact(struct.unpack('>I', read_exact(4))[0])) +with open(sys.argv[1], 'w') as report: + json.dump({'socket':os.environ['FERESE_SOCKET'], 'response':response}, report) +"#; + let mut child = crate::process::spawn_client( + state, + [ + std::ffi::OsStr::new("python3"), + std::ffi::OsStr::new("-c"), + std::ffi::OsStr::new(script), + report.as_os_str(), + ], + crate::private_client::ClientCapabilities::default(), + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success()); + break; + } + assert!(Instant::now() < deadline, "nested IPC client timed out"); + events.dispatch(Duration::from_millis(10), state).unwrap(); + } + let result: serde_json::Value = serde_json::from_slice(&std::fs::read(report).unwrap()).unwrap(); + assert_eq!( + result["socket"], + state.session_environment.control_socket.to_str().unwrap() + ); + assert_eq!(result["response"]["result"][0]["name"], name); + assert!(result["response"]["error"].is_null()); + } + let first_directory = first_socket.parent().unwrap().to_owned(); + drop(first); + assert!(!first_directory.exists()); + assert!(host_socket.symlink_metadata().unwrap().file_type().is_socket()); + assert!(second_socket.symlink_metadata().unwrap().file_type().is_socket()); + let second_directory = second_socket.parent().unwrap().to_owned(); + drop(second); + assert!(!second_directory.exists()); + assert!(host_socket.symlink_metadata().unwrap().file_type().is_socket()); +} + #[test] fn launched_clients_unblock_termination_signals_and_keep_private_sockets() { if !private_runtime("startup_tests::launched_clients_unblock_termination_signals_and_keep_private_sockets") { diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 1f97f5d..20bc43c 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -565,10 +565,27 @@ pub struct RuntimeConfig { } impl Ferese { + #[cfg(test)] pub fn new( event_loop: &mut EventLoop<'static, Self>, display: Display, config: RuntimeConfig, + ) -> Result> { + Self::new_with_session( + event_loop, + display, + config, + crate::ipc::Endpoint::desktop()?, + crate::SessionPolicy::Desktop, + ) + } + + pub(crate) fn new_with_session( + event_loop: &mut EventLoop<'static, Self>, + display: Display, + config: RuntimeConfig, + ipc_endpoint: crate::ipc::Endpoint, + session_policy: crate::SessionPolicy, ) -> Result> { let display_handle = display.handle(); @@ -657,6 +674,8 @@ impl Ferese { session_environment: crate::SessionEnvironment { wayland_display: socket_name.clone(), + control_socket: ipc_endpoint.path.clone(), + policy: session_policy, x11: None, }, socket_name, @@ -791,7 +810,7 @@ impl Ferese { xdg_toplevel_icon_manager, }; state.config_worker = Some(crate::reload::Worker::new(event_loop)?); - let screenshot = crate::ipc::init(event_loop)?; + let screenshot = crate::ipc::init(event_loop, ipc_endpoint)?; state .screenshot .set_deadline_observer(move |deadline| screenshot.deadline_timer.update(deadline)); diff --git a/compositor/src/xwayland/child.rs b/compositor/src/xwayland/child.rs index 476b3dc..36c2143 100644 --- a/compositor/src/xwayland/child.rs +++ b/compositor/src/xwayland/child.rs @@ -349,6 +349,8 @@ mod tests { fn environment() -> SessionEnvironment { SessionEnvironment { + control_socket: std::path::PathBuf::from("/run/user/1000/ferese/control.sock"), + policy: crate::SessionPolicy::Desktop, wayland_display: OsString::from("wayland-7"), x11: None, } diff --git a/compositor/src/xwayland/integration_tests.rs b/compositor/src/xwayland/integration_tests.rs index 43f7dda..151155b 100644 --- a/compositor/src/xwayland/integration_tests.rs +++ b/compositor/src/xwayland/integration_tests.rs @@ -90,6 +90,8 @@ impl Fixture { fn environment(&self) -> SessionEnvironment { SessionEnvironment { + control_socket: PathBuf::from("/run/user/1000/ferese/control.sock"), + policy: crate::SessionPolicy::Desktop, wayland_display: std::ffi::OsString::from("wayland-it"), x11: Some(X11Environment { display: std::ffi::OsString::from(self.reservation.display_name()), diff --git a/crates/ferese-ipc/src/socket.rs b/crates/ferese-ipc/src/socket.rs index 696cd34..8908092 100644 --- a/crates/ferese-ipc/src/socket.rs +++ b/crates/ferese-ipc/src/socket.rs @@ -11,6 +11,11 @@ pub fn resolve(explicit: Option<&Path>) -> io::Result { ) } +/// The DRM session's listening address, independent of a client's IPC target. +pub fn runtime_default() -> io::Result { + resolve_with(None, None, std::env::var_os("XDG_RUNTIME_DIR").as_deref()) +} + fn resolve_with(explicit: Option<&Path>, socket: Option<&OsStr>, runtime: Option<&OsStr>) -> io::Result { if let Some(path) = explicit { if path.as_os_str().is_empty() { diff --git a/docs/installation.md b/docs/installation.md index 9e42407..a4c32db 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -242,12 +242,20 @@ authentication, idle locking and limits. ## Preview and logs +`ferese` selects a nested window when `WAYLAND_DISPLAY` or `DISPLAY` is set, +and DRM otherwise. Nested instances have separate control sockets. Clients +launched by Ferese inherit that instance’s `FERESE_SOCKET`. Use +`--backend=nested` or `--backend=drm` to override detection. + +Nested previews leave the host activation environment and session target alone. +They skip XDG autostart and run configured autostart entries only with `nested true`. + To preview a source build before installing, run these commands from the checkout inside an existing Wayland desktop: ```sh cargo build --release --locked -p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p xdg-desktop-portal-ferese -FERESE_ENABLE_SCREENCOPY=1 target/release/ferese --backend nested \ +FERESE_ENABLE_SCREENCOPY=1 target/release/ferese \ --grant-effects --grant-shell-control -- \ target/release/ferese-shell ``` diff --git a/docs/ipc.md b/docs/ipc.md index 045c55a..8908495 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -45,19 +45,26 @@ its target in this order: 3. `$XDG_RUNTIME_DIR/ferese/control.sock` ```sh -feresectl --socket /tmp/ferese-preview/ferese/control.sock outputs -FERESE_SOCKET=/tmp/ferese-preview/ferese/control.sock feresectl theme get -j +feresectl --socket "$XDG_RUNTIME_DIR/ferese/instances/12345-abcdef/control.sock" outputs +FERESE_SOCKET="$XDG_RUNTIME_DIR/ferese/instances/12345-abcdef/control.sock" feresectl theme get -j ``` `--socket` selects an existing server; it does not create one or change where the -compositor listens. A nested compositor uses its own runtime directory. +compositor listens. DRM uses `$XDG_RUNTIME_DIR/ferese/control.sock`. Nested +instances use `$XDG_RUNTIME_DIR/ferese/instances//control.sock`; copy the +actual path from the compositor's startup log. Each instance removes its own +socket and directory on normal shutdown. A forced kill can leave the directory +behind; later instances allocate a different one. + +Clients launched by a nested compositor inherit its `FERESE_SOCKET`, so +`feresectl outputs` inside that session connects to that instance. `autostart` launches desktop entries locally and does not use IPC. ## Follow changes ```sh feresectl event-stream -feresectl --socket /tmp/ferese-preview/ferese/control.sock event-stream +feresectl --socket "$XDG_RUNTIME_DIR/ferese/instances/12345-abcdef/control.sock" event-stream ``` `event-stream` writes one JSON value per line and flushes each line. It always diff --git a/packaging/ferese-session-shell b/packaging/ferese-session-shell index 334c179..acd3af1 100755 --- a/packaging/ferese-session-shell +++ b/packaging/ferese-session-shell @@ -4,6 +4,10 @@ session_target_started=0 shell_pid= agent_pid= autostart_pid= +manage_session=0 +if [[ ${FERESE_SESSION_MODE:-desktop} == desktop && ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then + manage_session=1 +fi cleanup() { trap - EXIT INT TERM if [[ -n $autostart_pid ]]; then @@ -25,7 +29,7 @@ trap 'exit 143' TERM # The compositor supplies its new Wayland socket before this helper starts. # Nested previews must never replace the host session's activation environment. -if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then +if ((manage_session)); then # Shell control uses a private socket FD. Activation must use the public # socket so portal and other desktop services connect as ordinary clients. public_display=${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}} @@ -34,6 +38,7 @@ if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then "WAYLAND_DISPLAY=$public_display" "DISPLAY=${DISPLAY:-}" "XAUTHORITY=${XAUTHORITY:-}" + "FERESE_SOCKET=${FERESE_SOCKET:-}" XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE @@ -70,7 +75,7 @@ env -u WAYLAND_SOCKET WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAN agent_pid=$! "$release_dir/ferese-shell" & shell_pid=$! -if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 && -x $release_dir/feresectl ]]; then +if ((manage_session)) && [[ -x $release_dir/feresectl ]]; then env WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \ "$release_dir/feresectl" autostart & autostart_pid=$! diff --git a/portal/src/bridge.rs b/portal/src/bridge.rs index 0d00a80..b35b39a 100644 --- a/portal/src/bridge.rs +++ b/portal/src/bridge.rs @@ -24,10 +24,7 @@ struct BridgeInner { impl Bridge { pub(crate) fn connect() -> Result { - let path = std::env::var_os("XDG_RUNTIME_DIR") - .map(std::path::PathBuf::from) - .ok_or("Missing runtime directory")? - .join("ferese/control.sock"); + let path = ferese_ipc::socket::resolve(None).map_err(|error| error.to_string())?; let stream = UnixStream::connect(path).map_err(|error| error.to_string())?; Self::from_stream(stream, Duration::from_secs(2)) } diff --git a/portal/src/desktop.rs b/portal/src/desktop.rs index 8487048..d710c6a 100644 --- a/portal/src/desktop.rs +++ b/portal/src/desktop.rs @@ -236,10 +236,7 @@ pub(crate) async fn ipc(command: &'static str, args: serde_json::Value) -> Resul } pub(crate) fn ipc_sync(command: &'static str, args: serde_json::Value) -> Result { - let path = std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .ok_or("Missing runtime directory")? - .join("ferese/control.sock"); + let path = ferese_ipc::socket::resolve(None).map_err(|error| error.to_string())?; let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?; stream .set_read_timeout(Some(Duration::from_secs(15))) diff --git a/scripts/tests/session_socket.py b/scripts/tests/session_socket.py new file mode 100644 index 0000000..e6b41a0 --- /dev/null +++ b/scripts/tests/session_socket.py @@ -0,0 +1,17 @@ +"""Find the compositor endpoint in an isolated test runtime.""" +from pathlib import Path + + +def ipc_socket(runtime): + runtime = Path(runtime) + default = runtime / "ferese/control.sock" + instances = sorted(path for path in runtime.glob("ferese/instances/*/control.sock") if path.is_socket()) + if default.is_socket(): + instances.append(default) + if len(instances) > 1: + raise RuntimeError("Isolated test runtime contains multiple compositor instances") + return instances[0] if instances else default + + +def ipc_environment(environment): + return dict(environment, FERESE_SOCKET=str(ipc_socket(environment["XDG_RUNTIME_DIR"]))) diff --git a/scripts/tests/test_blurred_popup_isolated.py b/scripts/tests/test_blurred_popup_isolated.py index 275da6d..480c3d3 100644 --- a/scripts/tests/test_blurred_popup_isolated.py +++ b/scripts/tests/test_blurred_popup_isolated.py @@ -6,6 +6,7 @@ Requires a Wayland host, wayland-scanner and a C compiler. """ import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import subprocess import tempfile import time @@ -56,7 +57,7 @@ class BlurredPopupTest(unittest.TestCase): with log_path.open("w") as log: compositor = subprocess.Popen( [str(binary), "--backend", "nested", "--grant-effects", "--", str(client)], - env=env, stdout=log, stderr=log, + env=ipc_environment(env), stdout=log, stderr=log, ) try: deadline = time.monotonic() + 30 @@ -66,7 +67,7 @@ class BlurredPopupTest(unittest.TestCase): self.fail("Blurred popup stopped presenting frames:\n" + log_path.read_text()) time.sleep(.025) control = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") - subprocess.run([str(control), "-j", "outputs"], env=env, check=True, + subprocess.run([str(control), "-j", "outputs"], env=ipc_environment(env), check=True, stdout=subprocess.DEVNULL, timeout=5) phases = log_path.read_text().splitlines() for name, count in [("parent", 1), ("open", 3), ("update", 9), ("close", 3)]: diff --git a/scripts/tests/test_floating_isolated.py b/scripts/tests/test_floating_isolated.py index f6ff8aa..df78133 100644 --- a/scripts/tests/test_floating_isolated.py +++ b/scripts/tests/test_floating_isolated.py @@ -7,6 +7,7 @@ the compositor and FERESE_TEST_CTL at feresectl when they are not siblings. import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import subprocess import tempfile import time @@ -50,7 +51,7 @@ class FloatingSizeTest(unittest.TestCase): self.fail(f"missing {ctl}; build it with cargo build -p feresectl") with (root / "compositor.log").open("w") as log: process = subprocess.Popen([str(binary), "--backend", "nested", "--", str(root / "client")], - env=env, stdout=log, stderr=subprocess.STDOUT) + env=ipc_environment(env), stdout=log, stderr=subprocess.STDOUT) try: for _ in range(100): if process.poll() is not None: @@ -62,7 +63,7 @@ class FloatingSizeTest(unittest.TestCase): self.assertTrue(sockets, "nested Wayland socket did not appear") childenv = dict(env, WAYLAND_DISPLAY=str(sockets[0])) time.sleep(2) - listed = subprocess.run([str(ctl), "-j", "outputs"], env=childenv, + listed = subprocess.run([str(ctl), "-j", "outputs"], env=ipc_environment(childenv), check=True, capture_output=True, timeout=10, text=True) enabled = [o for o in json.loads(listed.stdout) if o.get("enabled")] if not enabled: @@ -74,7 +75,7 @@ class FloatingSizeTest(unittest.TestCase): screenshot = root / "frame.png" with screenshot.open("wb") as png: captured = subprocess.run([str(ctl), "screenshot", "-g", geometry], - env=childenv, stdout=png, stderr=subprocess.PIPE, timeout=15) + env=ipc_environment(childenv), stdout=png, stderr=subprocess.PIPE, timeout=15) if captured.returncode != 0: self.fail(f"capture of {geometry} failed: {captured.stderr.decode()}") if screenshot.stat().st_size == 0: diff --git a/scripts/tests/test_floating_placement_isolated.py b/scripts/tests/test_floating_placement_isolated.py index 5a650e1..2fb7485 100644 --- a/scripts/tests/test_floating_placement_isolated.py +++ b/scripts/tests/test_floating_placement_isolated.py @@ -6,6 +6,7 @@ Requires built debug ferese/feresectl, cc, wayland-scanner and a Wayland host. import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import signal import subprocess import tempfile @@ -63,12 +64,12 @@ class FloatingPlacementTest(unittest.TestCase): children = [] def launch(command, **kwargs): - child = subprocess.Popen(command, env=env, start_new_session=True, **kwargs) + child = subprocess.Popen(command, env=ipc_environment(env), start_new_session=True, **kwargs) children.append(child) return child def command(*args): - return json.loads(subprocess.check_output([str(repo / "target/debug/feresectl"), "-j", *args], env=env, timeout=5)) + return json.loads(subprocess.check_output([str(repo / "target/debug/feresectl"), "-j", *args], env=ipc_environment(env), timeout=5)) with (root / "compositor.log").open("w") as log: try: @@ -76,7 +77,7 @@ class FloatingPlacementTest(unittest.TestCase): env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log) deadline = time.monotonic() + 10 - while not (runtime / "ferese/control.sock").is_socket(): + while not (ipc_socket(runtime)).is_socket(): if compositor.poll() is not None or time.monotonic() > deadline: self.fail((root / "compositor.log").read_text()) time.sleep(.025) diff --git a/scripts/tests/test_focus_history_isolated.py b/scripts/tests/test_focus_history_isolated.py index f65756c..b4b071e 100644 --- a/scripts/tests/test_focus_history_isolated.py +++ b/scripts/tests/test_focus_history_isolated.py @@ -6,6 +6,7 @@ Requires built debug ferese/feresectl, foot, dbus-daemon, and a Wayland host. import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import signal import subprocess import tempfile @@ -34,13 +35,13 @@ class FocusHistoryTest(unittest.TestCase): children = [] def launch(command, **kwargs): - child = subprocess.Popen(command, env=env, start_new_session=True, **kwargs) + child = subprocess.Popen(command, env=ipc_environment(env), start_new_session=True, **kwargs) children.append(child) return child def command(*args): return json.loads(subprocess.check_output( - [str(repo / "target/debug/feresectl"), "-j", *args], env=env, timeout=5 + [str(repo / "target/debug/feresectl"), "-j", *args], env=ipc_environment(env), timeout=5 )) def wait_for(predicate): @@ -62,7 +63,7 @@ class FocusHistoryTest(unittest.TestCase): stdout=subprocess.PIPE, text=True) env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log) - wait_for(lambda: (runtime / "ferese/control.sock").is_socket()) + wait_for(lambda: (ipc_socket(runtime)).is_socket()) command("focus-last-window") self.assertIsNone(focused()) env["WAYLAND_DISPLAY"] = str(next( diff --git a/scripts/tests/test_idle_inhibition_isolated.py b/scripts/tests/test_idle_inhibition_isolated.py index 1cb7a86..bd7dbd4 100644 --- a/scripts/tests/test_idle_inhibition_isolated.py +++ b/scripts/tests/test_idle_inhibition_isolated.py @@ -7,6 +7,7 @@ FERESE_TEST_BINARY and FERESE_TEST_CTL can select release binaries. import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import signal import subprocess import sys @@ -99,7 +100,7 @@ def private_checks(): with (root / "compositor.log").open("w") as log: try: - compositor = subprocess.Popen([str(BINARY), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True) + compositor = subprocess.Popen([str(BINARY), "--backend=nested"], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) processes.append(compositor) def wait(check, message): @@ -112,11 +113,11 @@ def private_checks(): time.sleep(0.02) raise AssertionError(message + "\n" + (root / "compositor.log").read_text()) - wait(lambda: (runtime / "ferese/control.sock").exists(), "compositor startup") + wait(lambda: (ipc_socket(runtime)).exists(), "compositor startup") env["WAYLAND_DISPLAY"] = str(next(path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock"))) def call(*args): - return json.loads(subprocess.check_output([str(CTL), "-j", *args], env=env, timeout=5)) + return json.loads(subprocess.check_output([str(CTL), "-j", *args], env=ipc_environment(env), timeout=5)) def inhibited(expected): def matches(): @@ -126,7 +127,7 @@ def private_checks(): wait(matches, f"idle inhibition != {expected}") wait(lambda: len(call("get-idle-inhibition")["players"]) == 1, "MPRIS discovery") - client = subprocess.Popen([str(root / "client")], env=env, stdout=log, stderr=log, start_new_session=True) + client = subprocess.Popen([str(root / "client")], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) processes.append(client) wait(lambda: len(call("get-windows")) == 1, "window map") call("toggle-fullscreen") @@ -153,7 +154,7 @@ def private_checks(): inhibited(False) player.set("Playing") inhibited(True) - ambiguous = subprocess.Popen([str(root / "client"), "cover"], env=env, + ambiguous = subprocess.Popen([str(root / "client"), "cover"], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) processes.append(ambiguous) wait(lambda: len(call("get-windows")) == 2, "second player window") diff --git a/scripts/tests/test_inhibit_isolated.py b/scripts/tests/test_inhibit_isolated.py index 5c3a21f..fb4c4e7 100644 --- a/scripts/tests/test_inhibit_isolated.py +++ b/scripts/tests/test_inhibit_isolated.py @@ -6,6 +6,7 @@ Uses a nested compositor and mock logind; never suspends or ends the host sessio import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import signal import socket import struct @@ -141,13 +142,13 @@ def private_test(): env.pop("WAYLAND_SOCKET", None) log = (root / "test.log").open("w") try: - compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True) + compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) processes.append(compositor) deadline = time.monotonic() + 10 - while not (runtime / "ferese/control.sock").exists(): + while not (ipc_socket(runtime)).exists(): assert compositor.poll() is None and time.monotonic() < deadline, (root / "test.log").read_text() time.sleep(0.02) - backend = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese")], env=env, stdout=log, stderr=log, start_new_session=True) + backend = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese")], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) processes.append(backend) deadline = time.monotonic() + 10 while True: @@ -164,7 +165,7 @@ def private_test(): except GLib.Error as error: assert "AccessDenied" in str(error) bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), None, Gio.DBusCallFlags.NONE, 2000, None) - admin = IPC(runtime / "ferese/control.sock") + admin = IPC(ipc_socket(runtime)) def inhibit(path, flags): call(INTERFACE, "Inhibit", GLib.Variant("(ossua{sv})", (path, "test.media", "", flags, {"reason": GLib.Variant("s", "Playing media")}))) inhibit(request, 8) @@ -180,7 +181,7 @@ def private_test(): assert call(INTERFACE, "CreateMonitor", GLib.Variant("(ooss)", (PATH + "/request/test/monitor", session, "test.media", "")), "(u)") == (0,) wait_for(lambda: any(value["session-state"] == 1 for value in changes), "Missing initial Running") assert call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.Session", "version")), "(v)", session) == (1,) - query = IPC(runtime / "ferese/control.sock") + query = IPC(ipc_socket(runtime)) state = query.call("begin-session-end") assert state["query-ready"] is False wait_for(lambda: any(value["session-state"] == 2 for value in changes), "Missing QueryEnd") diff --git a/scripts/tests/test_lock_isolated.py b/scripts/tests/test_lock_isolated.py index 30f287e..1e4b172 100644 --- a/scripts/tests/test_lock_isolated.py +++ b/scripts/tests/test_lock_isolated.py @@ -5,6 +5,7 @@ Requires built binaries and bwrap. FERESE_TEST_BIN_DIR selects debug/release. Ho import os import unittest from pathlib import Path +from session_socket import ipc_environment, ipc_socket @unittest.skipUnless(os.environ.get("FERESE_TEST_LOCK") == "1", "visible nested lock test is opt-in") class NativeLockTest(unittest.TestCase): @@ -23,7 +24,7 @@ class NativeLockTest(unittest.TestCase): env=dict(os.environ,XDG_RUNTIME_DIR=str(runtime),XDG_CONFIG_HOME=str(root/'config'),WAYLAND_DISPLAY=host_display,FERESE_ENABLE_SCREENCOPY='1') binary_dir = Path(os.environ.get('FERESE_TEST_BIN_DIR', 'target/release')) log=(root/'compositor.log').open('w') - compositor=subprocess.Popen([str(binary_dir / 'ferese'),'--backend','nested'],env=env,stdout=log,stderr=subprocess.STDOUT) + compositor=subprocess.Popen([str(binary_dir / 'ferese'),'--backend','nested'],env=ipc_environment(env),stdout=log,stderr=subprocess.STDOUT) ctl=binary_dir / 'feresectl' assert ctl.is_file(), f'missing {ctl}; build it with cargo build --release -p feresectl' locker=None @@ -37,13 +38,13 @@ class NativeLockTest(unittest.TestCase): childenv=dict(env,WAYLAND_DISPLAY=str(sockets[0]),FERESE_LOCK_READY='1') def capture(destination, timeout=10): with open(destination,'wb') as png: - return subprocess.run([str(ctl),'screenshot'],env=childenv,stdout=png,stderr=subprocess.PIPE,timeout=timeout) + return subprocess.run([str(ctl),'screenshot'],env=ipc_environment(childenv),stdout=png,stderr=subprocess.PIPE,timeout=timeout) time.sleep(.6) result=capture(root/'unlocked.png') assert result.returncode == 0, f'Capture failed while unlocked: {result.stderr.decode()}' assert (root/'unlocked.png').stat().st_size > 0, 'Capture produced an empty file' print('Native capture succeeded while unlocked',flush=True) - locker=subprocess.Popen(['bwrap','--bind','/','/','--dev-bind','/dev','/dev','--ro-bind',str(pam),'/etc/pam.d','--unshare-user','--',str(binary_dir / 'ferese-lock')],env=childenv,stdout=subprocess.DEVNULL,stderr=locker_log,start_new_session=True) + locker=subprocess.Popen(['bwrap','--bind','/','/','--dev-bind','/dev','/dev','--ro-bind',str(pam),'/etc/pam.d','--unshare-user','--',str(binary_dir / 'ferese-lock')],env=ipc_environment(childenv),stdout=subprocess.DEVNULL,stderr=locker_log,start_new_session=True) assert locker.wait(timeout=20) == 0, (root/'locker.log').read_text() print('Native locker received compositor confirmation',flush=True) result=capture(root/'locked.png') @@ -54,7 +55,7 @@ class NativeLockTest(unittest.TestCase): result=capture(root/'after-crash.png') assert result.returncode != 0, 'Capture unexpectedly succeeded after locker crash' print('Capture remains denied after locker crash',flush=True) - locker=subprocess.Popen(['bwrap','--bind','/','/','--dev-bind','/dev','/dev','--ro-bind',str(pam),'/etc/pam.d','--unshare-user','--',str(binary_dir / 'ferese-lock')],env=childenv,stdout=subprocess.DEVNULL,stderr=locker_log,start_new_session=True) + locker=subprocess.Popen(['bwrap','--bind','/','/','--dev-bind','/dev','/dev','--ro-bind',str(pam),'/etc/pam.d','--unshare-user','--',str(binary_dir / 'ferese-lock')],env=ipc_environment(childenv),stdout=subprocess.DEVNULL,stderr=locker_log,start_new_session=True) assert locker.wait(timeout=20) == 0, (root/'locker.log').read_text() result=capture(root/'replacement.png') assert result.returncode != 0, 'Capture unexpectedly succeeded after locker takeover' diff --git a/scripts/tests/test_material_isolated.py b/scripts/tests/test_material_isolated.py index 371bef5..5d98f96 100644 --- a/scripts/tests/test_material_isolated.py +++ b/scripts/tests/test_material_isolated.py @@ -5,6 +5,7 @@ Requires built debug compositor binaries, a Wayland host and dbus-daemon. """ import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import signal import subprocess import tempfile @@ -34,17 +35,17 @@ class MaterialLifetime(unittest.TestCase): with (root / "compositor.log").open("w") as log: try: bus = subprocess.Popen(["dbus-daemon", "--session", "--nofork", "--print-address=1"], - env=env, stdout=subprocess.PIPE, text=True, start_new_session=True) + env=ipc_environment(env), stdout=subprocess.PIPE, text=True, start_new_session=True) children.append(bus) env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() compositor = subprocess.Popen([str(ROOT / "target/debug/ferese"), "--backend", "nested"], - env=env, stdout=log, stderr=log, start_new_session=True) + env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) children.append(compositor) deadline = time.monotonic() + 10 while time.monotonic() < deadline: self.assertIsNone(compositor.poll(), (root / "compositor.log").read_text()) sockets = [p for p in runtime.glob("wayland-*") if p.is_socket()] - if sockets and (runtime / "ferese/control.sock").is_socket(): + if sockets and (ipc_socket(runtime)).is_socket(): break time.sleep(.05) else: @@ -54,7 +55,7 @@ class MaterialLifetime(unittest.TestCase): "cargo", "test", "--workspace", "--locked", "material::tests::attachment_acknowledgement_and_last_clone_release", "--", "--ignored", "--exact", - ], cwd=ROOT, env=env, capture_output=True, text=True, timeout=120) + ], cwd=ROOT, env=ipc_environment(env), capture_output=True, text=True, timeout=120) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertIn("1 passed", result.stdout) self.assertIsNone(compositor.poll()) diff --git a/scripts/tests/test_media_isolated.py b/scripts/tests/test_media_isolated.py index 5296c32..39e2a8b 100644 --- a/scripts/tests/test_media_isolated.py +++ b/scripts/tests/test_media_isolated.py @@ -7,6 +7,7 @@ Requires a Wayland host, PyGObject and dbus-run-session. import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import signal import socket import struct @@ -135,7 +136,7 @@ def private_checks(): binary = REPO / os.environ.get('FERESE_TEST_BINARY', 'target/debug/ferese') ctl = REPO / os.environ.get('FERESE_TEST_CTL', 'target/debug/feresectl') with (root / 'compositor.log').open('w') as log: - child = subprocess.Popen([str(binary), '--backend=nested'], env=env, + child = subprocess.Popen([str(binary), '--backend=nested'], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) def wait(check): @@ -148,17 +149,17 @@ def private_checks(): time.sleep(.02) raise AssertionError('Timed out\n' + (root / 'compositor.log').read_text()) - wait(lambda: (runtime / 'ferese/control.sock').exists()) + wait(lambda: (ipc_socket(runtime)).exists()) def call(*args): - return json.loads(subprocess.check_output([str(ctl), "-j", *args], env=env, timeout=5)) + return json.loads(subprocess.check_output([str(ctl), "-j", *args], env=ipc_environment(env), timeout=5)) def request(command, args): payload = json.dumps({'version': 1, 'id': 17, 'type': 'command', 'command': command, 'args': args}).encode() connection = socket.socket(socket.AF_UNIX) connection.settimeout(3) - connection.connect(str(runtime / 'ferese/control.sock')) + connection.connect(str(ipc_socket(runtime))) connection.sendall(struct.pack('!I', len(payload)) + payload) return connection @@ -181,7 +182,7 @@ def private_checks(): env['WAYLAND_DISPLAY'] = str(next(path for path in runtime.glob('wayland-*') if not path.name.endswith('.lock'))) shell_binary = os.environ.get('FERESE_TEST_SHELL') if shell_binary: - shell = subprocess.Popen([str(REPO / shell_binary)], env=env, stdout=log, stderr=log, start_new_session=True) + shell = subprocess.Popen([str(REPO / shell_binary)], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) first = players[0] selected(first.name) initial = call('media') diff --git a/scripts/tests/test_overview_damage_isolated.py b/scripts/tests/test_overview_damage_isolated.py index ec22091..eb523b8 100644 --- a/scripts/tests/test_overview_damage_isolated.py +++ b/scripts/tests/test_overview_damage_isolated.py @@ -10,6 +10,7 @@ import errno import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import re import signal import subprocess @@ -46,12 +47,12 @@ class OverviewDamageTest(unittest.TestCase): log_path = root / "compositor.log" def launch(command, **kwargs): - child = subprocess.Popen(command, env=env, start_new_session=True, **kwargs) + child = subprocess.Popen(command, env=ipc_environment(env), start_new_session=True, **kwargs) children.append(child) return child def command(*args): - return json.loads(subprocess.check_output([str(control), "-j", *args], env=env, timeout=5)) + return json.loads(subprocess.check_output([str(control), "-j", *args], env=ipc_environment(env), timeout=5)) def wait_for(predicate, seconds=12): deadline = time.monotonic() + seconds @@ -98,7 +99,7 @@ class OverviewDamageTest(unittest.TestCase): stdout=subprocess.PIPE, text=True) env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() launch([str(binary), "--backend", "nested"], stdout=log, stderr=log) - wait_for(lambda: (runtime / "ferese/control.sock").is_socket()) + wait_for(lambda: (ipc_socket(runtime)).is_socket()) env["WAYLAND_DISPLAY"] = str(next( path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock") )) diff --git a/scripts/tests/test_overview_previews_isolated.py b/scripts/tests/test_overview_previews_isolated.py index 5cdea5b..61ae076 100644 --- a/scripts/tests/test_overview_previews_isolated.py +++ b/scripts/tests/test_overview_previews_isolated.py @@ -8,6 +8,7 @@ import io import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import signal import subprocess import tempfile @@ -55,7 +56,7 @@ class OverviewPreviews(unittest.TestCase): log_path = root / "compositor.log" def call(*args): - return subprocess.check_output([str(ctl), "-j", *args], env=env, timeout=10) + return subprocess.check_output([str(ctl), "-j", *args], env=ipc_environment(env), timeout=10) def wait_for(predicate): deadline = time.monotonic() + 10 @@ -69,12 +70,12 @@ class OverviewPreviews(unittest.TestCase): with log_path.open("w") as log: def launch(command): - child = subprocess.Popen(command, env=env, stdout=log, stderr=log, start_new_session=True) + child = subprocess.Popen(command, env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) children.append(child) try: launch([str(binary), "--backend", "nested"]) - wait_for(lambda: (runtime / "ferese/control.sock").is_socket()) + wait_for(lambda: (ipc_socket(runtime)).is_socket()) env["WAYLAND_DISPLAY"] = str(next(path for path in runtime.glob("wayland-*") if path.is_socket())) launch([str(root / "client")]) wait_for(lambda: len(json.loads(call("get-windows"))) == 1) diff --git a/scripts/tests/test_presentation_isolated.py b/scripts/tests/test_presentation_isolated.py index 4798706..bf88bde 100644 --- a/scripts/tests/test_presentation_isolated.py +++ b/scripts/tests/test_presentation_isolated.py @@ -6,6 +6,7 @@ Requires built debug compositor/control binaries, a Wayland host, cc and wayland import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import signal import subprocess import tempfile @@ -57,10 +58,10 @@ class Presentation(unittest.TestCase): env.pop("FERESE_SHELL_CONTROL_SOCKET", None) log = (root / "compositor.log").open("w") try: - compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True) + compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) processes.append(compositor) deadline = time.monotonic() + 10 - while not (runtime / "ferese/control.sock").exists(): + while not (ipc_socket(runtime)).exists(): if compositor.poll() is not None or time.monotonic() > deadline: self.fail((root / "compositor.log").read_text()) time.sleep(0.02) @@ -68,7 +69,7 @@ class Presentation(unittest.TestCase): env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock"))) def call(*args): - return subprocess.check_output([str(REPO / "target/debug/feresectl"), "-j", *args], env=env, timeout=10) + return subprocess.check_output([str(REPO / "target/debug/feresectl"), "-j", *args], env=ipc_environment(env), timeout=10) def windows(count): deadline = time.monotonic() + 10 @@ -80,7 +81,7 @@ class Presentation(unittest.TestCase): self.fail((root / "compositor.log").read_text()) events = (root / "events").open("w") - target = subprocess.Popen([str(root / "client")], env=env, stdin=subprocess.PIPE, + target = subprocess.Popen([str(root / "client")], env=ipc_environment(env), stdin=subprocess.PIPE, stdout=events, stderr=log, start_new_session=True) processes.append(target) windows(1) @@ -95,7 +96,7 @@ class Presentation(unittest.TestCase): self.assertGreater(count("frame"), before, "visible empty commits must get callbacks") self.assertEqual(count("idle"), idle, "visible inhibitor must keep the notifier active") - cover = subprocess.Popen([str(root / "client"), "cover"], env=env, stdout=log, + cover = subprocess.Popen([str(root / "client"), "cover"], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) processes.append(cover) current = windows(2) diff --git a/scripts/tests/test_resize_dependencies_isolated.py b/scripts/tests/test_resize_dependencies_isolated.py index fbb480d..b3ed570 100644 --- a/scripts/tests/test_resize_dependencies_isolated.py +++ b/scripts/tests/test_resize_dependencies_isolated.py @@ -8,6 +8,7 @@ before the 300 ms deadline; it does not measure frame pacing or GPU performance. import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import select import subprocess import tempfile @@ -50,10 +51,10 @@ class ResizeDependencies(unittest.TestCase): ctl = REPO / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") with (root / "compositor.log").open("w") as log: try: - compositor = subprocess.Popen([str(binary), "--backend=nested"], env=env, stdout=log, stderr=log) + compositor = subprocess.Popen([str(binary), "--backend=nested"], env=ipc_environment(env), stdout=log, stderr=log) processes.append(compositor) deadline = time.monotonic() + 10 - while not (runtime / "ferese/control.sock").exists(): + while not (ipc_socket(runtime)).exists(): if compositor.poll() is not None or time.monotonic() > deadline: self.fail((root / "compositor.log").read_text()) time.sleep(.02) @@ -61,10 +62,10 @@ class ResizeDependencies(unittest.TestCase): client_env = dict(env, WAYLAND_DISPLAY=str(socket)) def call(*args): - return json.loads(subprocess.check_output([str(ctl), "-j", *args], env=env, timeout=5)) + return json.loads(subprocess.check_output([str(ctl), "-j", *args], env=ipc_environment(env), timeout=5)) def action(*args): - subprocess.run([str(ctl), *args], env=env, check=True, capture_output=True, timeout=5) + subprocess.run([str(ctl), *args], env=ipc_environment(env), check=True, capture_output=True, timeout=5) def client(name): process = subprocess.Popen([str(root / "client"), name], env=client_env, diff --git a/scripts/tests/test_restore_isolated.py b/scripts/tests/test_restore_isolated.py index 90f03a3..0a86bb3 100644 --- a/scripts/tests/test_restore_isolated.py +++ b/scripts/tests/test_restore_isolated.py @@ -7,6 +7,7 @@ No host permission store, compositor configuration or portal service is changed. import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import signal import socket import struct @@ -94,10 +95,10 @@ def private_test(): env.pop("WAYLAND_SOCKET", None) log = (root / "test.log").open("w") try: - compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True) + compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) processes.append(compositor) - destination = runtime / "ferese/control.sock" - wait_for(lambda: destination.exists(), "Nested compositor did not start") + wait_for(lambda: ipc_socket(runtime).is_socket(), "Nested compositor did not start") + destination = ipc_socket(runtime) admin = IPC(destination) outputs = admin.call("get-outputs") name = next(output["name"] for output in outputs if output["enabled"]) @@ -122,7 +123,7 @@ def private_test(): threading.Thread(target=accept, daemon=True).start() bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), None, Gio.DBusCallFlags.NONE, 2000, None) - backend = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese")], env=env, stdout=log, stderr=log, start_new_session=True) + backend = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese")], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) processes.append(backend) def call(method, params=None, path=PATH, interface=INTERFACE): return bus.call_sync(NAME, path, interface, method, params, None, Gio.DBusCallFlags.NO_AUTO_START, 12000, None).unpack() @@ -180,7 +181,7 @@ def private_test(): fallback("replacement", saved=stale) print("PASS: other-app and changed-device restore require fresh consent", flush=True) - mismatch = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"), "--stream", name, "hidden", str(2**32 - 1)], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True) + mismatch = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"), "--stream", name, "hidden", str(2**32 - 1)], env=ipc_environment(env), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True) processes.append(mismatch) mismatch.wait(timeout=8) error = mismatch.stderr.read() diff --git a/scripts/tests/test_session_socket.py b/scripts/tests/test_session_socket.py new file mode 100644 index 0000000..3dc5065 --- /dev/null +++ b/scripts/tests/test_session_socket.py @@ -0,0 +1,50 @@ +"""Check IPC discovery for isolated compositor drivers.""" +from pathlib import Path +from unittest.mock import patch +import tempfile +import unittest + +from session_socket import ipc_environment, ipc_socket + + +class SessionSocketTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.runtime = Path(self.directory.name) + # Only path discovery is under test; endpoint permissions and actual + # socket binding are covered by the compositor's IPC tests. + self.socket_type = patch.object(Path, 'is_socket', Path.is_file) + self.socket_type.start() + self.addCleanup(self.socket_type.stop) + + def endpoint(self, suffix): + path = self.runtime / suffix + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + return path + + def test_discovery_changes_when_the_nested_listener_appears(self): + self.assertEqual(ipc_socket(self.runtime), self.runtime / 'ferese/control.sock') + nested = self.endpoint('ferese/instances/123-abc/control.sock') + self.assertEqual(ipc_socket(self.runtime), nested) + + def test_client_environment_replaces_an_inherited_host_target(self): + nested = self.endpoint('ferese/instances/123-abc/control.sock') + environment = {'XDG_RUNTIME_DIR': str(self.runtime), 'FERESE_SOCKET': '/run/user/1000/ferese/control.sock'} + self.assertEqual(ipc_environment(environment)['FERESE_SOCKET'], str(nested)) + self.assertEqual(environment['FERESE_SOCKET'], '/run/user/1000/ferese/control.sock') + + def test_proxy_or_drm_endpoint_remains_supported(self): + default = self.endpoint('ferese/control.sock') + self.assertEqual(ipc_socket(self.runtime), default) + + def test_multiple_instances_require_an_explicit_test_target(self): + self.endpoint('ferese/instances/123-abc/control.sock') + self.endpoint('ferese/instances/456-def/control.sock') + with self.assertRaises(RuntimeError): + ipc_socket(self.runtime) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_session_startup.py b/scripts/tests/test_session_startup.py index be4feda..c41e533 100644 --- a/scripts/tests/test_session_startup.py +++ b/scripts/tests/test_session_startup.py @@ -32,6 +32,7 @@ class SessionStartupTest(unittest.TestCase): path.write_text('#!/bin/bash\n' + body) path.chmod(0o755) env = dict(os.environ, PATH=f'{root}:{os.environ["PATH"]}', TEST_ROOT=directory, + FERESE_SESSION_MODE='desktop', FERESE_SESSION_IMPORT_ENV=str(int(direct)), WAYLAND_DISPLAY='private-display', FERESE_PUBLIC_WAYLAND_DISPLAY='public-display', WAYLAND_SOCKET='77', TEST_FAIL=str(int(fail_target)), TEST_STATUS=str(shell_status), TEST_WAIT=str(int(terminate))) @@ -71,6 +72,11 @@ class SessionStartupTest(unittest.TestCase): '--user try-restart xdg-desktop-portal-ferese.service', 'shell:77', '--user stop ferese-session.target']) + def test_direct_session_imports_its_control_socket(self): + status, events = self.run_session(extra_env={'FERESE_SOCKET': '/run/user/1000/ferese/control.sock'}) + self.assertEqual(status, 0) + self.assertIn('FERESE_SOCKET=/run/user/1000/ferese/control.sock', self.imported_vars(events)) + def test_a_managed_x11_endpoint_is_imported_for_dbus_activators(self): status, events = self.run_session(display=':7', xauthority='/run/user/1000/ferese-xauth-abcd') self.assertEqual(status, 0) @@ -105,6 +111,11 @@ class SessionStartupTest(unittest.TestCase): self.assertEqual(status, 0) self.assertEqual(events, ['shell:77']) + def test_embedded_policy_blocks_session_services_despite_an_inherited_import_flag(self): + status, events = self.run_session(direct=True, extra_env={'FERESE_SESSION_MODE': 'embedded'}) + self.assertEqual(status, 0) + self.assertEqual(events, ['shell:77']) + def test_failed_target_does_not_prevent_shell_or_stop_unowned_target(self): status, events = self.run_session(fail_target=True) self.assertEqual(status, 0) diff --git a/scripts/tests/test_shell_motion_isolated.py b/scripts/tests/test_shell_motion_isolated.py index be5d051..f67a773 100644 --- a/scripts/tests/test_shell_motion_isolated.py +++ b/scripts/tests/test_shell_motion_isolated.py @@ -7,6 +7,7 @@ Protocol traces contain only this test's shell traffic, never the host desktop. """ import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import re import signal import subprocess @@ -53,7 +54,7 @@ class ShellMotionTest(unittest.TestCase): '') def launch(command, **kwargs): - process = subprocess.Popen(command, env=env, start_new_session=True, **kwargs) + process = subprocess.Popen(command, env=ipc_environment(env), start_new_session=True, **kwargs) children.append(process) return process @@ -73,7 +74,7 @@ class ShellMotionTest(unittest.TestCase): return subprocess.run(["gdbus", "call", "--session", "--dest", "org.freedesktop.Notifications", "--object-path", "/org/freedesktop/Notifications", "--method", "org.freedesktop.Notifications." + method, *args], - env=env, text=True, capture_output=True, timeout=5, check=check) + env=ipc_environment(env), text=True, capture_output=True, timeout=5, check=check) def layer_surface(namespace): matches = list(re.finditer(r'get_layer_surface\([^\n]*?wl_surface[@#](\d+)[^\n]*"' + namespace + '"', log_path.read_text())) @@ -132,7 +133,7 @@ class ShellMotionTest(unittest.TestCase): compositor = launch([str(binary), "--backend", "nested", "--grant-effects", "--grant-shell-control", "--", "env", "WAYLAND_DEBUG=client", "ICED_BACKEND=tiny-skia", str(shell)], stdout=log, stderr=log) - wait_for(lambda: (runtime / "ferese/control.sock").is_socket()) + wait_for(lambda: (ipc_socket(runtime)).is_socket()) wait_for(lambda: dbus("GetServerInformation", check=False).returncode == 0) bar = wait_for(lambda: layer_surface("ferese-shell-top-bar")) if bar_layout == "islands": @@ -165,11 +166,11 @@ class ShellMotionTest(unittest.TestCase): "notification materials did not follow their fade") print(f"Toast: {opening_frames} opening callback requests, {idle_frames / 2:g} settled commits/s; destroyed", flush=True) - subprocess.run([str(ctl), "toggle-keybinding-guide"], env=env, check=True, capture_output=True) + subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), check=True, capture_output=True) modal = wait_for(lambda: layer_surface("ferese-system-modal")) wait_for(lambda: frames(modal) >= 4) time.sleep(.3) - subprocess.run([str(ctl), "toggle-keybinding-guide"], env=env, check=True, capture_output=True) + subprocess.run([str(ctl), "toggle-keybinding-guide"], env=ipc_environment(env), check=True, capture_output=True) wait_for(lambda: destroyed(modal), timeout=5) self.assertGreater(frames(modal), 10, "modal close did not follow frames") self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) @@ -181,7 +182,7 @@ class ShellMotionTest(unittest.TestCase): source = config_file.read_text() config_file.write_text(re.sub( r'bar-layout "[^"]+"', f'bar-layout "{layout_name}"', source)) - subprocess.run([str(ctl), "reload-config"], env=env, check=True, capture_output=True) + subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True) wait_for(lambda: bar_region_count(bar) == (expected_count or 2)) self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) @@ -192,7 +193,7 @@ class ShellMotionTest(unittest.TestCase): source = config_file.read_text() config_file.write_text(re.sub( r'bar-island-padding \d+', f'bar-island-padding {padding}', source)) - subprocess.run([str(ctl), "reload-config"], env=env, check=True, capture_output=True) + subprocess.run([str(ctl), "reload-config"], env=ipc_environment(env), check=True, capture_output=True) expected_width = original_input[0][2] - 2 * (12 - padding) wait_for(lambda: len(bar_input_rectangles(bar)) == 2 and bar_input_rectangles(bar)[0][2] == expected_width) diff --git a/scripts/tests/test_shortcuts_isolated.py b/scripts/tests/test_shortcuts_isolated.py index 3b409fe..7d5667d 100644 --- a/scripts/tests/test_shortcuts_isolated.py +++ b/scripts/tests/test_shortcuts_isolated.py @@ -2,6 +2,7 @@ import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import signal import socket import struct @@ -31,12 +32,12 @@ class ShortcutConnections(unittest.TestCase): env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=str(host_display)) env.pop("WAYLAND_SOCKET", None) env.pop("FERESE_SHELL_CONTROL_SOCKET", None) - process = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, + process = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) connections = [] try: deadline = time.monotonic() + 15 - while not (runtime / "ferese/control.sock").exists(): + while not (ipc_socket(runtime)).exists(): if process.poll() is not None or time.monotonic() >= deadline: self.fail((root / "compositor.log").read_text()) time.sleep(0.02) @@ -44,7 +45,7 @@ class ShortcutConnections(unittest.TestCase): def connect(): connection = socket.socket(socket.AF_UNIX) connection.settimeout(2) - connection.connect(str(runtime / "ferese/control.sock")) + connection.connect(str(ipc_socket(runtime))) connections.append(connection) return connection diff --git a/scripts/tests/test_theme_isolated.py b/scripts/tests/test_theme_isolated.py index bde26e2..51e9a6e 100644 --- a/scripts/tests/test_theme_isolated.py +++ b/scripts/tests/test_theme_isolated.py @@ -6,6 +6,7 @@ Requires target/debug/ferese, feresectl, and a running Wayland desktop. import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import select import signal import shutil @@ -38,12 +39,12 @@ class ThemeTest(unittest.TestCase): XDG_CONFIG_HOME=str(root / "config")) def launch(command, **kwargs): - child = subprocess.Popen(command, env=env, start_new_session=True, **kwargs) + child = subprocess.Popen(command, env=ipc_environment(env), start_new_session=True, **kwargs) children.append(child) return child def command(*args): - return json.loads(subprocess.check_output([str(repo / "target/debug/feresectl"), "-j", *args], env=env, timeout=5)) + return json.loads(subprocess.check_output([str(repo / "target/debug/feresectl"), "-j", *args], env=ipc_environment(env), timeout=5)) def until(predicate, seconds=5): deadline = time.monotonic() + seconds @@ -67,7 +68,7 @@ class ThemeTest(unittest.TestCase): bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE, text=True) env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log) - until(lambda: (runtime / "ferese/control.sock").is_socket()) + until(lambda: (ipc_socket(runtime)).is_socket()) self.assertEqual(command("theme", "get")["theme"]["appearance"], "dark") subscriber = launch([str(repo / "target/debug/feresectl"), "theme", "subscribe"], stdout=subprocess.PIPE, text=True) self.assertTrue(select.select([subscriber.stdout], [], [], 3)[0]) @@ -116,10 +117,10 @@ class ThemeTest(unittest.TestCase): command("theme", "mode", "light") until(lambda: command("theme", "get")["theme"]["tokens"]["colors"]["surface_base"] == "#FBF1C7") if shutil.which("gsettings"): - subprocess.run(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-dark"], env=env, check=True, timeout=5) + subprocess.run(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-dark"], env=ipc_environment(env), check=True, timeout=5) replace(source, 'theme { mode "auto"; family "everforest"; schedule { source "system"; }; }\n') until(lambda: (snapshot := command("theme", "get"))["mode"] == "auto" and snapshot["theme"]["appearance"] == "dark" and snapshot["fallback_note"] is None) - subprocess.run(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-light"], env=env, check=True, timeout=5) + subprocess.run(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "prefer-light"], env=ipc_environment(env), check=True, timeout=5) until(lambda: (snapshot := command("theme", "get"))["theme"]["appearance"] == "light" and snapshot["presented"] == snapshot["theme"]) self.assertIsNone(compositor.poll()) finally: diff --git a/scripts/tests/test_unmapped_toplevel_isolated.py b/scripts/tests/test_unmapped_toplevel_isolated.py index f38af0f..238cf52 100644 --- a/scripts/tests/test_unmapped_toplevel_isolated.py +++ b/scripts/tests/test_unmapped_toplevel_isolated.py @@ -7,6 +7,7 @@ Requires cc, pkg-config, wayland-scanner, and wayland-protocols. import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import select import subprocess import tempfile @@ -62,7 +63,7 @@ class UnmappedToplevelTest(unittest.TestCase): with (root / "compositor.log").open("w") as log: compositor = subprocess.Popen( [str(compositor_binary), "--backend", "nested"], - env=env, + env=ipc_environment(env), stdout=log, stderr=subprocess.STDOUT, ) @@ -101,7 +102,7 @@ class UnmappedToplevelTest(unittest.TestCase): while True: result = subprocess.run( [str(ctl_binary), "-j", "focused-window"], - env=env, + env=ipc_environment(env), capture_output=True, text=True, check=True, @@ -116,7 +117,7 @@ class UnmappedToplevelTest(unittest.TestCase): visible_ids.append(focused["id"]) if phase == "visible": # Leave a configure/resize outstanding when the buffer is detached. - subprocess.run([str(ctl_binary), "toggle-maximized"], env=env, + subprocess.run([str(ctl_binary), "toggle-maximized"], env=ipc_environment(env), capture_output=True, check=True) time.sleep(.08) if phase == "detached": @@ -131,7 +132,7 @@ class UnmappedToplevelTest(unittest.TestCase): deadline = time.monotonic() + 3 while True: remaining = json.loads(subprocess.check_output( - [str(ctl_binary), "-j", "windows"], env=env, text=True + [str(ctl_binary), "-j", "windows"], env=ipc_environment(env), text=True )) if not remaining: break @@ -139,10 +140,10 @@ class UnmappedToplevelTest(unittest.TestCase): self.fail(f"destroyed window still managed: {remaining}") time.sleep(0.05) - subprocess.run([str(ctl_binary), "focus-last-window"], env=env, + subprocess.run([str(ctl_binary), "focus-last-window"], env=ipc_environment(env), capture_output=True, check=True) focused = json.loads(subprocess.check_output( - [str(ctl_binary), "-j", "focused-window"], env=env, text=True + [str(ctl_binary), "-j", "focused-window"], env=ipc_environment(env), text=True )) self.assertIsNone(focused, "focus history must not restore a destroyed window") deadline = time.monotonic() + 3 diff --git a/scripts/tests/test_window_capture_isolated.py b/scripts/tests/test_window_capture_isolated.py index 5c8240a..568a3a8 100644 --- a/scripts/tests/test_window_capture_isolated.py +++ b/scripts/tests/test_window_capture_isolated.py @@ -9,6 +9,7 @@ import io import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import signal import selectors import subprocess @@ -58,10 +59,10 @@ class WindowCapture(unittest.TestCase): env.pop("FERESE_SHELL_CONTROL_SOCKET", None) log = (root / "compositor.log").open("w") try: - compositor = subprocess.Popen([str(COMPOSITOR), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True) + compositor = subprocess.Popen([str(COMPOSITOR), "--backend=nested"], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) processes.append(compositor) deadline = time.monotonic() + 10 - while not (runtime / "ferese/control.sock").exists(): + while not (ipc_socket(runtime)).exists(): if compositor.poll() is not None or time.monotonic() > deadline: self.fail((root / "compositor.log").read_text()) time.sleep(0.02) @@ -69,7 +70,7 @@ class WindowCapture(unittest.TestCase): env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock"))) def call(*args): - return subprocess.check_output([str(CTL), "-j", *args], env=env, timeout=10) + return subprocess.check_output([str(CTL), "-j", *args], env=ipc_environment(env), timeout=10) def windows(count): deadline = time.monotonic() + 10 @@ -80,9 +81,9 @@ class WindowCapture(unittest.TestCase): time.sleep(0.02) self.fail((root / "compositor.log").read_text()) - processes.append(subprocess.Popen([str(root / "client")], env=env, stdout=log, stderr=log, start_new_session=True)) + processes.append(subprocess.Popen([str(root / "client")], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True)) target = windows(1)[0] - processes.append(subprocess.Popen([str(root / "client"), "cover"], env=env, stdout=log, stderr=log, start_new_session=True)) + processes.append(subprocess.Popen([str(root / "client"), "cover"], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True)) current = windows(2) cover = next(window for window in current if window["title"] == "Occluding window") self.assertEqual((target["x"], target["y"], target["width"], target["height"]), @@ -131,7 +132,7 @@ class WindowCapture(unittest.TestCase): worker.stdin.close() worker.stdout.close() rejected = subprocess.run([str(CTL), "screenshot-window", str(target["id"])], - env=env, capture_output=True, timeout=10) + env=ipc_environment(env), capture_output=True, timeout=10) self.assertNotEqual(rejected.returncode, 0) self.assertIn(b"protected", rejected.stderr) self.assertTrue(next(window for window in windows(2) if window["id"] == target["id"])["mapped"]) diff --git a/scripts/tests/test_window_stream_isolated.py b/scripts/tests/test_window_stream_isolated.py index 8a8c805..c501750 100644 --- a/scripts/tests/test_window_stream_isolated.py +++ b/scripts/tests/test_window_stream_isolated.py @@ -7,6 +7,7 @@ Only this test's compositor, windows and streams are stopped. import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import select import signal import subprocess @@ -65,21 +66,21 @@ class WindowStream(unittest.TestCase): env.pop("FERESE_SHELL_CONTROL_SOCKET", None) log = (root / "test.log").open("w") try: - compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True) + compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) processes.append(compositor) - socket = runtime / "ferese/control.sock" - wait_for(socket.exists, "Nested compositor did not start") + wait_for(lambda: ipc_socket(runtime).is_socket(), "Nested compositor did not start") + socket = ipc_socket(runtime) admin = IPC(socket) sockets = [path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")] env["WAYLAND_DISPLAY"] = str(sockets[0]) - target = subprocess.Popen([str(root / "client"), "resize"], env=env, stdin=subprocess.PIPE, stdout=log, stderr=log, start_new_session=True) + target = subprocess.Popen([str(root / "client"), "resize"], env=ipc_environment(env), stdin=subprocess.PIPE, stdout=log, stderr=log, start_new_session=True) processes.append(target) windows = wait_for(lambda: [window for window in admin.call("get-windows") if window["capture_width"] == 640], "Window fixture did not map; private manager must be hidden from ordinary clients") target_id = windows[0]["id"] - cover = subprocess.Popen([str(root / "client"), "cover"], env=env, stdout=log, stderr=log, start_new_session=True) + cover = subprocess.Popen([str(root / "client"), "cover"], env=ipc_environment(env), stdout=log, stderr=log, start_new_session=True) processes.append(cover) wait_for(lambda: len(admin.call("get-windows")) == 2, "Occluding window did not map") - helper = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"), "--stream-window", str(target_id), "hidden"], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True) + helper = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"), "--stream-window", str(target_id), "hidden"], env=ipc_environment(env), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True) processes.append(helper) self.assertTrue(select.select([helper.stdout], [], [], 10)[0], "Stream did not announce a PipeWire node") ready = json.loads(helper.stdout.readline()) @@ -126,11 +127,11 @@ class WindowStream(unittest.TestCase): pipeline.set_state(Gst.State.NULL) pipeline = None cover_id = next(window["id"] for window in admin.call("get-windows") if window["title"] == "Occluding window") - paused = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"), "--stream-window", str(cover_id), "hidden"], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True) + paused = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese"), "--stream-window", str(cover_id), "hidden"], env=ipc_environment(env), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True) processes.append(paused) self.assertTrue(select.select([paused.stdout], [], [], 10)[0]) self.assertGreater(json.loads(paused.stdout.readline())["width"], 0) - locker = subprocess.Popen([str(root / "locker")], env=env, stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True) + locker = subprocess.Popen([str(root / "locker")], env=ipc_environment(env), stdout=subprocess.PIPE, stderr=log, text=True, start_new_session=True) processes.append(locker) self.assertTrue(select.select([locker.stdout], [], [], 5)[0], "Nested lock request did not complete") self.assertIn(locker.stdout.readline().strip(), ("locked", "requested")) diff --git a/scripts/tests/test_workspace_history_isolated.py b/scripts/tests/test_workspace_history_isolated.py index dbc9033..35d4994 100644 --- a/scripts/tests/test_workspace_history_isolated.py +++ b/scripts/tests/test_workspace_history_isolated.py @@ -6,6 +6,7 @@ Requires target/debug/ferese, feresectl, foot, dbus-daemon, and a Wayland host. import json import os from pathlib import Path +from session_socket import ipc_environment, ipc_socket import signal import subprocess import tempfile @@ -35,13 +36,13 @@ class WorkspaceHistoryTest(unittest.TestCase): children = [] def launch(command, **kwargs): - child = subprocess.Popen(command, env=env, start_new_session=True, **kwargs) + child = subprocess.Popen(command, env=ipc_environment(env), start_new_session=True, **kwargs) children.append(child) return child def command(*args): return json.loads(subprocess.check_output( - [str(repo / "target/debug/feresectl"), "-j", *args], env=env, timeout=5 + [str(repo / "target/debug/feresectl"), "-j", *args], env=ipc_environment(env), timeout=5 )) def active(): @@ -55,7 +56,7 @@ class WorkspaceHistoryTest(unittest.TestCase): compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log) deadline = time.monotonic() + 10 - while not (runtime / "ferese/control.sock").is_socket(): + while not (ipc_socket(runtime)).is_socket(): if compositor.poll() is not None or time.monotonic() >= deadline: self.fail((root / "compositor.log").read_text()) time.sleep(.025) diff --git a/shell/ferese-polkit-agent/src/main.rs b/shell/ferese-polkit-agent/src/main.rs index 9f2d6fc..06a3d96 100644 --- a/shell/ferese-polkit-agent/src/main.rs +++ b/shell/ferese-polkit-agent/src/main.rs @@ -221,7 +221,9 @@ async fn session_subject( connection: &zbus::Connection, ) -> Result<(String, HashMap>), Box> { // Nested previews must not take over authentication for the host desktop. - if std::env::var("FERESE_SESSION_IMPORT_ENV").as_deref() == Ok("0") { + if std::env::var("FERESE_SESSION_MODE").as_deref() == Ok("embedded") + || std::env::var("FERESE_SESSION_IMPORT_ENV").as_deref() == Ok("0") + { return process_subject().map_err(Into::into); } let manager = zbus::Proxy::new( diff --git a/shell/ferese-settings/src/store.rs b/shell/ferese-settings/src/store.rs index 20b03ff..14a8e7d 100644 --- a/shell/ferese-settings/src/store.rs +++ b/shell/ferese-settings/src/store.rs @@ -228,11 +228,10 @@ fn save_with( pub fn reload_running() -> bool { use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; - let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") else { + let Ok(path) = ferese_ipc::socket::resolve(None) else { return false; }; - let Ok(mut socket) = std::os::unix::net::UnixStream::connect(PathBuf::from(runtime).join("ferese/control.sock")) - else { + let Ok(mut socket) = std::os::unix::net::UnixStream::connect(path) else { return false; }; let _ = socket.set_read_timeout(Some(Duration::from_secs(2))); diff --git a/shell/ferese-shell/src/compositor_ipc.rs b/shell/ferese-shell/src/compositor_ipc.rs index f835d74..f6e2c2a 100644 --- a/shell/ferese-shell/src/compositor_ipc.rs +++ b/shell/ferese-shell/src/compositor_ipc.rs @@ -1,5 +1,4 @@ use std::os::unix::net::UnixStream; -use std::path::PathBuf; use std::time::{Duration, Instant}; use serde_json::{Value, json}; @@ -15,10 +14,7 @@ struct SessionConnection { impl SessionConnection { fn connect() -> Result { - let path = std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .ok_or("Missing runtime directory")? - .join("ferese/control.sock"); + let path = ferese_ipc::socket::resolve(None).map_err(|error| error.to_string())?; let stream = UnixStream::connect(path).map_err(|error| error.to_string())?; stream .set_read_timeout(Some(Duration::from_secs(2)))