Isolate nested IPC sockets and define session ownership policy

This commit is contained in:
Samuel Onoja committed 2026-10-07 08:03:30 +01:00
1 parent 97026a0db7
commit ce024d4794
42 files changed
+635 -147

No files matched your search

+19
View File
@@ -7,6 +7,7 @@ use smithay::reexports::calloop::EventLoop;
use crate::Ferese; use crate::Ferese;
use crate::private_client::ClientCapabilities; use crate::private_client::ClientCapabilities;
use crate::session_environment::SessionPolicy;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BackendKind { pub enum BackendKind {
@@ -17,6 +18,7 @@ pub enum BackendKind {
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
pub struct LaunchConfig { pub struct LaunchConfig {
pub backend: BackendKind, pub backend: BackendKind,
pub(crate) session_policy: SessionPolicy,
pub client: Vec<OsString>, pub client: Vec<OsString>,
pub client_capabilities: ClientCapabilities, pub client_capabilities: ClientCapabilities,
} }
@@ -75,6 +77,10 @@ impl LaunchConfig {
Ok(Self { Ok(Self {
backend, backend,
session_policy: match backend {
BackendKind::Drm => SessionPolicy::Desktop,
BackendKind::Nested => SessionPolicy::Embedded,
},
client, client,
client_capabilities, client_capabilities,
}) })
@@ -110,6 +116,7 @@ mod tests {
let config = LaunchConfig::parse([OsString::from("foot")], true).unwrap(); let config = LaunchConfig::parse([OsString::from("foot")], true).unwrap();
assert_eq!(config.backend, BackendKind::Nested); assert_eq!(config.backend, BackendKind::Nested);
assert_eq!(config.session_policy, SessionPolicy::Embedded);
assert_eq!(config.client, [OsString::from("foot")]); assert_eq!(config.client, [OsString::from("foot")]);
assert!(config.client_capabilities.is_empty()); assert!(config.client_capabilities.is_empty());
} }
@@ -119,6 +126,7 @@ mod tests {
let config = LaunchConfig::parse(Vec::<OsString>::new(), false).unwrap(); let config = LaunchConfig::parse(Vec::<OsString>::new(), false).unwrap();
assert_eq!(config.backend, BackendKind::Drm); assert_eq!(config.backend, BackendKind::Drm);
assert_eq!(config.session_policy, SessionPolicy::Desktop);
assert!(config.client.is_empty()); assert!(config.client.is_empty());
assert!(config.client_capabilities.is_empty()); assert!(config.client_capabilities.is_empty());
} }
@@ -178,4 +186,15 @@ mod tests {
assert!(error.contains("unknown backend")); 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);
}
}
} }
+69 -16
View File
@@ -9,10 +9,10 @@ use smithay::reexports::calloop::generic::Generic;
use smithay::reexports::calloop::timer::{TimeoutAction, Timer}; use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::reexports::calloop::{Interest, Mode, PostAction, RegistrationToken}; use smithay::reexports::calloop::{Interest, Mode, PostAction, RegistrationToken};
use crate::Ferese;
use crate::config::DaemonConfig; use crate::config::DaemonConfig;
use crate::private_client::ClientCapabilities; use crate::private_client::ClientCapabilities;
use crate::process::{pidfd, spawn_client, terminate_child}; use crate::process::{pidfd, spawn_client, terminate_child};
use crate::{Ferese, SessionPolicy};
struct Service { struct Service {
config: DaemonConfig, config: DaemonConfig,
@@ -31,7 +31,7 @@ impl Runner {
pub fn start(state: &mut Ferese) -> Rc<RefCell<Self>> { pub fn start(state: &mut Ferese) -> Rc<RefCell<Self>> {
let runner = Rc::new(RefCell::new(Self::new( let runner = Rc::new(RefCell::new(Self::new(
&state.autostart, &state.autostart,
state.direct_backend.is_none(), state.session_environment.policy,
))); )));
Self::refresh(&runner, state); Self::refresh(&runner, state);
runner 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); 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) { if self.0.iter().any(|service| service.config.command == config.command) {
continue; continue;
} }
@@ -148,11 +151,11 @@ impl Runner {
} }
} }
pub fn new(configs: &[DaemonConfig], nested: bool) -> Self { pub fn new(configs: &[DaemonConfig], policy: SessionPolicy) -> Self {
Self( Self(
configs configs
.iter() .iter()
.filter(|config| config.enabled && (!nested || config.nested)) .filter(|config| config.enabled && policy.allows_autostart(config.nested))
.map(|config| Service { .map(|config| Service {
config: config.clone(), config: config.clone(),
child: None, child: None,
@@ -166,7 +169,7 @@ impl Runner {
} }
pub fn tick(&mut self, state: &mut Ferese) { 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(); let now = Instant::now();
for service in &mut self.0 { for service in &mut self.0 {
if let Some(child) = &mut service.child { if let Some(child) = &mut service.child {
@@ -244,17 +247,17 @@ mod tests {
restart: false, restart: false,
nested: 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()); 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(); let pid = runner.0[0].child.as_ref().unwrap().id();
config.restart = true; 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_eq!(runner.0[0].child.as_ref().unwrap().id(), pid);
assert!(runner.0[0].config.restart); 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); assert_eq!(runner.0.len(), 1);
config.enabled = false; config.enabled = false;
runner.reconcile(&[config], false); runner.reconcile(&[config], SessionPolicy::Desktop);
assert!(runner.0.is_empty()); assert!(runner.0.is_empty());
let deadline = Instant::now() + Duration::from_secs(2); let deadline = Instant::now() + Duration::from_secs(2);
while unsafe { libc::kill(pid as i32, 0) } == 0 { while unsafe { libc::kill(pid as i32, 0) } == 0 {
@@ -271,22 +274,72 @@ mod tests {
restart: true, restart: true,
nested: false, nested: false,
}]; }];
assert!(Runner::new(&configs, true).0.is_empty()); assert!(Runner::new(&configs, SessionPolicy::Embedded).0.is_empty());
let mut direct = Runner::new(&configs, false); let mut direct = Runner::new(&configs, SessionPolicy::Desktop);
assert_eq!(direct.0.len(), 1); assert_eq!(direct.0.len(), 1);
direct.stop(); direct.stop();
assert!(direct.0[0].finished); assert!(direct.0[0].finished);
} }
#[test] #[test]
fn disabled_login_items_never_start_on_either_backend() { fn disabled_login_items_never_start_under_either_session_policy() {
let configs = vec![DaemonConfig { let configs = vec![DaemonConfig {
command: vec!["not-executed".into()], command: vec!["not-executed".into()],
enabled: false, enabled: false,
restart: true, restart: true,
nested: true, nested: true,
}]; }];
assert!(Runner::new(&configs, true).0.is_empty()); assert!(Runner::new(&configs, SessionPolicy::Embedded).0.is_empty());
assert!(Runner::new(&configs, false).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());
} }
} }
+98 -12
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{SyncSender, sync_channel}; use std::sync::mpsc::{SyncSender, sync_channel};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use std::{env, fs, io, thread}; use std::{fs, io, thread};
use ferese_core::LayoutMode; use ferese_core::LayoutMode;
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
@@ -78,6 +78,41 @@ pub(crate) struct IpcSocketGuard {
path: PathBuf, path: PathBuf,
device: u64, device: u64,
inode: u64, inode: u64,
_instance: Option<tempfile::TempDir>,
}
pub(crate) struct Endpoint {
pub(crate) path: PathBuf,
instance: Option<tempfile::TempDir>,
}
impl Endpoint {
pub(crate) fn desktop() -> io::Result<Self> {
Self::desktop_at(ferese_ipc::socket::runtime_default()?)
}
fn desktop_at(path: PathBuf) -> io::Result<Self> {
prepare_parent(&path)?;
Ok(Self { path, instance: None })
}
pub(crate) fn instance() -> io::Result<Self> {
Self::instance_at(ferese_ipc::socket::runtime_default()?)
}
fn instance_at(default: PathBuf) -> io::Result<Self> {
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 { impl Drop for IpcSocketGuard {
@@ -90,8 +125,11 @@ impl Drop for IpcSocketGuard {
} }
} }
pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<ScreenshotInit, Box<dyn std::error::Error>> { pub(crate) fn init(
let path = socket_path()?; event_loop: &mut EventLoop<'static, Ferese>,
endpoint: Endpoint,
) -> Result<ScreenshotInit, Box<dyn std::error::Error>> {
let Endpoint { path, instance } = endpoint;
prepare_parent(&path)?; prepare_parent(&path)?;
let listener = bind_listener(&path)?; let listener = bind_listener(&path)?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?; fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
@@ -100,6 +138,7 @@ pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Screen
path: path.clone(), path: path.clone(),
device: metadata.dev(), device: metadata.dev(),
inode: metadata.ino(), inode: metadata.ino(),
_instance: instance,
}; };
let (sender, receiver): (channel::SyncSender<IpcEvent>, channel::Channel<IpcEvent>) = let (sender, receiver): (channel::SyncSender<IpcEvent>, channel::Channel<IpcEvent>) =
@@ -1531,16 +1570,17 @@ fn workspace_arg(args: &Value) -> Result<u32, CommandError> {
.ok_or_else(|| CommandError::new("invalid_argument", "index must be a positive 32-bit integer")) .ok_or_else(|| CommandError::new("invalid_argument", "index must be a positive 32-bit integer"))
} }
fn socket_path() -> Result<PathBuf, io::Error> {
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> { fn prepare_parent(socket: &Path) -> Result<(), io::Error> {
let parent = socket.parent().expect("the Ferese control socket always has a parent"); 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) { match fs::symlink_metadata(parent) {
Ok(metadata) => { Ok(metadata) => {
if !metadata.is_dir() || metadata.file_type().is_symlink() { 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), Err(error) => return Err(error),
} }
@@ -1613,6 +1652,7 @@ fn effective_uid() -> u32 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::env;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::Shutdown; use std::net::Shutdown;
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@@ -1818,6 +1858,52 @@ mod tests {
fs::remove_dir_all(root).unwrap(); 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] #[test]
fn listener_reclaims_only_a_stale_owned_socket() { fn listener_reclaims_only_a_stale_owned_socket() {
let root = unique_test_directory("stale-socket"); let root = unique_test_directory("stale-socket");
+6 -2
View File
@@ -46,7 +46,7 @@ mod window_rules;
mod winit; mod winit;
mod xwayland; mod xwayland;
pub(crate) use session_environment::{SessionEnvironment, X11Environment}; pub(crate) use session_environment::{SessionEnvironment, SessionPolicy, X11Environment};
use std::error::Error; use std::error::Error;
use std::io; use std::io;
@@ -95,7 +95,11 @@ fn main() -> Result<(), Box<dyn Error>> {
state.loop_signal.stop(); state.loop_signal.stop();
})?; })?;
let display = Display::new()?; 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_source = initial_source.filter(|source| source.len() <= 60 * 1024);
state.config_sections = state state.config_sections = state
.config_source .config_source
+66
View File
@@ -8,10 +8,31 @@ pub(crate) struct X11Environment {
pub authority: PathBuf, 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. // Child endpoints are separate from the host endpoints used by the nested backend.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct SessionEnvironment { pub(crate) struct SessionEnvironment {
pub wayland_display: OsString, pub wayland_display: OsString,
pub control_socket: PathBuf,
pub policy: SessionPolicy,
pub x11: Option<X11Environment>, pub x11: Option<X11Environment>,
} }
@@ -21,6 +42,8 @@ impl SessionEnvironment {
command command
.env("WAYLAND_DISPLAY", &self.wayland_display) .env("WAYLAND_DISPLAY", &self.wayland_display)
.env("FERESE_PUBLIC_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("WAYLAND_SOCKET")
.env_remove("FERESE_SHELL_CONTROL_SOCKET") .env_remove("FERESE_SHELL_CONTROL_SOCKET")
.env_remove("DISPLAY") .env_remove("DISPLAY")
@@ -29,6 +52,9 @@ impl SessionEnvironment {
if let Some(x11) = &self.x11 { if let Some(x11) = &self.x11 {
command.env("DISPLAY", &x11.display).env("XAUTHORITY", &x11.authority); 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<X11Environment>) -> SessionEnvironment { fn environment(x11: Option<X11Environment>) -> SessionEnvironment {
SessionEnvironment { SessionEnvironment {
wayland_display: OsString::from("wayland-7"), wayland_display: OsString::from("wayland-7"),
control_socket: PathBuf::from("/run/user/1000/ferese/instances/preview/control.sock"),
policy: SessionPolicy::Desktop,
x11, x11,
} }
} }
@@ -135,4 +163,42 @@ mod tests {
assert_eq!(value_of(&command, "DISPLAY"), Some(Some(":7".to_owned()))); 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())));
}
}
} }
+122
View File
@@ -1,4 +1,5 @@
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::unix::fs::FileTypeExt;
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use std::os::unix::process::ExitStatusExt; use std::os::unix::process::ExitStatusExt;
use std::process::{Command, Stdio}; 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() 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] #[test]
fn launched_clients_unblock_termination_signals_and_keep_private_sockets() { fn launched_clients_unblock_termination_signals_and_keep_private_sockets() {
if !private_runtime("startup_tests::launched_clients_unblock_termination_signals_and_keep_private_sockets") { if !private_runtime("startup_tests::launched_clients_unblock_termination_signals_and_keep_private_sockets") {
+20 -1
View File
@@ -565,10 +565,27 @@ pub struct RuntimeConfig {
} }
impl Ferese { impl Ferese {
#[cfg(test)]
pub fn new( pub fn new(
event_loop: &mut EventLoop<'static, Self>, event_loop: &mut EventLoop<'static, Self>,
display: Display<Self>, display: Display<Self>,
config: RuntimeConfig, config: RuntimeConfig,
) -> Result<Self, Box<dyn Error>> {
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<Self>,
config: RuntimeConfig,
ipc_endpoint: crate::ipc::Endpoint,
session_policy: crate::SessionPolicy,
) -> Result<Self, Box<dyn Error>> { ) -> Result<Self, Box<dyn Error>> {
let display_handle = display.handle(); let display_handle = display.handle();
@@ -657,6 +674,8 @@ impl Ferese {
session_environment: crate::SessionEnvironment { session_environment: crate::SessionEnvironment {
wayland_display: socket_name.clone(), wayland_display: socket_name.clone(),
control_socket: ipc_endpoint.path.clone(),
policy: session_policy,
x11: None, x11: None,
}, },
socket_name, socket_name,
@@ -791,7 +810,7 @@ impl Ferese {
xdg_toplevel_icon_manager, xdg_toplevel_icon_manager,
}; };
state.config_worker = Some(crate::reload::Worker::new(event_loop)?); 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 state
.screenshot .screenshot
.set_deadline_observer(move |deadline| screenshot.deadline_timer.update(deadline)); .set_deadline_observer(move |deadline| screenshot.deadline_timer.update(deadline));
+2
View File
@@ -349,6 +349,8 @@ mod tests {
fn environment() -> SessionEnvironment { fn environment() -> SessionEnvironment {
SessionEnvironment { SessionEnvironment {
control_socket: std::path::PathBuf::from("/run/user/1000/ferese/control.sock"),
policy: crate::SessionPolicy::Desktop,
wayland_display: OsString::from("wayland-7"), wayland_display: OsString::from("wayland-7"),
x11: None, x11: None,
} }
@@ -90,6 +90,8 @@ impl Fixture {
fn environment(&self) -> SessionEnvironment { fn environment(&self) -> SessionEnvironment {
SessionEnvironment { SessionEnvironment {
control_socket: PathBuf::from("/run/user/1000/ferese/control.sock"),
policy: crate::SessionPolicy::Desktop,
wayland_display: std::ffi::OsString::from("wayland-it"), wayland_display: std::ffi::OsString::from("wayland-it"),
x11: Some(X11Environment { x11: Some(X11Environment {
display: std::ffi::OsString::from(self.reservation.display_name()), display: std::ffi::OsString::from(self.reservation.display_name()),
+5
View File
@@ -11,6 +11,11 @@ pub fn resolve(explicit: Option<&Path>) -> io::Result<PathBuf> {
) )
} }
/// The DRM session's listening address, independent of a client's IPC target.
pub fn runtime_default() -> io::Result<PathBuf> {
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<PathBuf> { fn resolve_with(explicit: Option<&Path>, socket: Option<&OsStr>, runtime: Option<&OsStr>) -> io::Result<PathBuf> {
if let Some(path) = explicit { if let Some(path) = explicit {
if path.as_os_str().is_empty() { if path.as_os_str().is_empty() {
+9 -1
View File
@@ -242,12 +242,20 @@ authentication, idle locking and limits.
## Preview and logs ## 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 To preview a source build before installing, run these commands from the checkout
inside an existing Wayland desktop: inside an existing Wayland desktop:
```sh ```sh
cargo build --release --locked -p ferese -p ferese-shell -p ferese-settings -p feresectl -p ferese-lock -p xdg-desktop-portal-ferese 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 -- \ --grant-effects --grant-shell-control -- \
target/release/ferese-shell target/release/ferese-shell
``` ```
+11 -4
View File
@@ -45,19 +45,26 @@ its target in this order:
3. `$XDG_RUNTIME_DIR/ferese/control.sock` 3. `$XDG_RUNTIME_DIR/ferese/control.sock`
```sh ```sh
feresectl --socket /tmp/ferese-preview/ferese/control.sock outputs feresectl --socket "$XDG_RUNTIME_DIR/ferese/instances/12345-abcdef/control.sock" outputs
FERESE_SOCKET=/tmp/ferese-preview/ferese/control.sock feresectl theme get -j 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 `--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/<id>/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. `autostart` launches desktop entries locally and does not use IPC.
## Follow changes ## Follow changes
```sh ```sh
feresectl event-stream 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 `event-stream` writes one JSON value per line and flushes each line. It always
+7 -2
View File
@@ -4,6 +4,10 @@ session_target_started=0
shell_pid= shell_pid=
agent_pid= agent_pid=
autostart_pid= autostart_pid=
manage_session=0
if [[ ${FERESE_SESSION_MODE:-desktop} == desktop && ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then
manage_session=1
fi
cleanup() { cleanup() {
trap - EXIT INT TERM trap - EXIT INT TERM
if [[ -n $autostart_pid ]]; then if [[ -n $autostart_pid ]]; then
@@ -25,7 +29,7 @@ trap 'exit 143' TERM
# The compositor supplies its new Wayland socket before this helper starts. # The compositor supplies its new Wayland socket before this helper starts.
# Nested previews must never replace the host session's activation environment. # 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 # Shell control uses a private socket FD. Activation must use the public
# socket so portal and other desktop services connect as ordinary clients. # socket so portal and other desktop services connect as ordinary clients.
public_display=${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}} public_display=${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}
@@ -34,6 +38,7 @@ if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then
"WAYLAND_DISPLAY=$public_display" "WAYLAND_DISPLAY=$public_display"
"DISPLAY=${DISPLAY:-}" "DISPLAY=${DISPLAY:-}"
"XAUTHORITY=${XAUTHORITY:-}" "XAUTHORITY=${XAUTHORITY:-}"
"FERESE_SOCKET=${FERESE_SOCKET:-}"
XDG_CURRENT_DESKTOP XDG_CURRENT_DESKTOP
XDG_SESSION_DESKTOP XDG_SESSION_DESKTOP
XDG_SESSION_TYPE XDG_SESSION_TYPE
@@ -70,7 +75,7 @@ env -u WAYLAND_SOCKET WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAN
agent_pid=$! agent_pid=$!
"$release_dir/ferese-shell" & "$release_dir/ferese-shell" &
shell_pid=$! 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:-}}" \ env WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \
"$release_dir/feresectl" autostart & "$release_dir/feresectl" autostart &
autostart_pid=$! autostart_pid=$!
+1 -4
View File
@@ -24,10 +24,7 @@ struct BridgeInner {
impl Bridge { impl Bridge {
pub(crate) fn connect() -> Result<Self, String> { pub(crate) fn connect() -> Result<Self, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR") let path = ferese_ipc::socket::resolve(None).map_err(|error| error.to_string())?;
.map(std::path::PathBuf::from)
.ok_or("Missing runtime directory")?
.join("ferese/control.sock");
let stream = UnixStream::connect(path).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)) Self::from_stream(stream, Duration::from_secs(2))
} }
+1 -4
View File
@@ -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<serde_json::Value, String> { pub(crate) fn ipc_sync(command: &'static str, args: serde_json::Value) -> Result<serde_json::Value, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR") let path = ferese_ipc::socket::resolve(None).map_err(|error| error.to_string())?;
.map(PathBuf::from)
.ok_or("Missing runtime directory")?
.join("ferese/control.sock");
let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?; let mut stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
stream stream
.set_read_timeout(Some(Duration::from_secs(15))) .set_read_timeout(Some(Duration::from_secs(15)))
+17
View File
@@ -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"])))
+3 -2
View File
@@ -6,6 +6,7 @@ Requires a Wayland host, wayland-scanner and a C compiler.
""" """
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import subprocess import subprocess
import tempfile import tempfile
import time import time
@@ -56,7 +57,7 @@ class BlurredPopupTest(unittest.TestCase):
with log_path.open("w") as log: with log_path.open("w") as log:
compositor = subprocess.Popen( compositor = subprocess.Popen(
[str(binary), "--backend", "nested", "--grant-effects", "--", str(client)], [str(binary), "--backend", "nested", "--grant-effects", "--", str(client)],
env=env, stdout=log, stderr=log, env=ipc_environment(env), stdout=log, stderr=log,
) )
try: try:
deadline = time.monotonic() + 30 deadline = time.monotonic() + 30
@@ -66,7 +67,7 @@ class BlurredPopupTest(unittest.TestCase):
self.fail("Blurred popup stopped presenting frames:\n" + log_path.read_text()) self.fail("Blurred popup stopped presenting frames:\n" + log_path.read_text())
time.sleep(.025) time.sleep(.025)
control = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") 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) stdout=subprocess.DEVNULL, timeout=5)
phases = log_path.read_text().splitlines() phases = log_path.read_text().splitlines()
for name, count in [("parent", 1), ("open", 3), ("update", 9), ("close", 3)]: for name, count in [("parent", 1), ("open", 3), ("update", 9), ("close", 3)]:
+4 -3
View File
@@ -7,6 +7,7 @@ the compositor and FERESE_TEST_CTL at feresectl when they are not siblings.
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import subprocess import subprocess
import tempfile import tempfile
import time import time
@@ -50,7 +51,7 @@ class FloatingSizeTest(unittest.TestCase):
self.fail(f"missing {ctl}; build it with cargo build -p feresectl") self.fail(f"missing {ctl}; build it with cargo build -p feresectl")
with (root / "compositor.log").open("w") as log: with (root / "compositor.log").open("w") as log:
process = subprocess.Popen([str(binary), "--backend", "nested", "--", str(root / "client")], 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: try:
for _ in range(100): for _ in range(100):
if process.poll() is not None: if process.poll() is not None:
@@ -62,7 +63,7 @@ class FloatingSizeTest(unittest.TestCase):
self.assertTrue(sockets, "nested Wayland socket did not appear") self.assertTrue(sockets, "nested Wayland socket did not appear")
childenv = dict(env, WAYLAND_DISPLAY=str(sockets[0])) childenv = dict(env, WAYLAND_DISPLAY=str(sockets[0]))
time.sleep(2) 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) check=True, capture_output=True, timeout=10, text=True)
enabled = [o for o in json.loads(listed.stdout) if o.get("enabled")] enabled = [o for o in json.loads(listed.stdout) if o.get("enabled")]
if not enabled: if not enabled:
@@ -74,7 +75,7 @@ class FloatingSizeTest(unittest.TestCase):
screenshot = root / "frame.png" screenshot = root / "frame.png"
with screenshot.open("wb") as png: with screenshot.open("wb") as png:
captured = subprocess.run([str(ctl), "screenshot", "-g", geometry], 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: if captured.returncode != 0:
self.fail(f"capture of {geometry} failed: {captured.stderr.decode()}") self.fail(f"capture of {geometry} failed: {captured.stderr.decode()}")
if screenshot.stat().st_size == 0: if screenshot.stat().st_size == 0:
@@ -6,6 +6,7 @@ Requires built debug ferese/feresectl, cc, wayland-scanner and a Wayland host.
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import signal import signal
import subprocess import subprocess
import tempfile import tempfile
@@ -63,12 +64,12 @@ class FloatingPlacementTest(unittest.TestCase):
children = [] children = []
def launch(command, **kwargs): 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) children.append(child)
return child return child
def command(*args): 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: with (root / "compositor.log").open("w") as log:
try: try:
@@ -76,7 +77,7 @@ class FloatingPlacementTest(unittest.TestCase):
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log) compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log)
deadline = time.monotonic() + 10 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: if compositor.poll() is not None or time.monotonic() > deadline:
self.fail((root / "compositor.log").read_text()) self.fail((root / "compositor.log").read_text())
time.sleep(.025) time.sleep(.025)
+4 -3
View File
@@ -6,6 +6,7 @@ Requires built debug ferese/feresectl, foot, dbus-daemon, and a Wayland host.
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import signal import signal
import subprocess import subprocess
import tempfile import tempfile
@@ -34,13 +35,13 @@ class FocusHistoryTest(unittest.TestCase):
children = [] children = []
def launch(command, **kwargs): 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) children.append(child)
return child return child
def command(*args): def command(*args):
return json.loads(subprocess.check_output( 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): def wait_for(predicate):
@@ -62,7 +63,7 @@ class FocusHistoryTest(unittest.TestCase):
stdout=subprocess.PIPE, text=True) stdout=subprocess.PIPE, text=True)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log) 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") command("focus-last-window")
self.assertIsNone(focused()) self.assertIsNone(focused())
env["WAYLAND_DISPLAY"] = str(next( env["WAYLAND_DISPLAY"] = str(next(
@@ -7,6 +7,7 @@ FERESE_TEST_BINARY and FERESE_TEST_CTL can select release binaries.
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import signal import signal
import subprocess import subprocess
import sys import sys
@@ -99,7 +100,7 @@ def private_checks():
with (root / "compositor.log").open("w") as log: with (root / "compositor.log").open("w") as log:
try: 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) processes.append(compositor)
def wait(check, message): def wait(check, message):
@@ -112,11 +113,11 @@ def private_checks():
time.sleep(0.02) time.sleep(0.02)
raise AssertionError(message + "\n" + (root / "compositor.log").read_text()) 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"))) env["WAYLAND_DISPLAY"] = str(next(path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")))
def call(*args): 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 inhibited(expected):
def matches(): def matches():
@@ -126,7 +127,7 @@ def private_checks():
wait(matches, f"idle inhibition != {expected}") wait(matches, f"idle inhibition != {expected}")
wait(lambda: len(call("get-idle-inhibition")["players"]) == 1, "MPRIS discovery") 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) processes.append(client)
wait(lambda: len(call("get-windows")) == 1, "window map") wait(lambda: len(call("get-windows")) == 1, "window map")
call("toggle-fullscreen") call("toggle-fullscreen")
@@ -153,7 +154,7 @@ def private_checks():
inhibited(False) inhibited(False)
player.set("Playing") player.set("Playing")
inhibited(True) 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) stdout=log, stderr=log, start_new_session=True)
processes.append(ambiguous) processes.append(ambiguous)
wait(lambda: len(call("get-windows")) == 2, "second player window") wait(lambda: len(call("get-windows")) == 2, "second player window")
+6 -5
View File
@@ -6,6 +6,7 @@ Uses a nested compositor and mock logind; never suspends or ends the host sessio
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import signal import signal
import socket import socket
import struct import struct
@@ -141,13 +142,13 @@ def private_test():
env.pop("WAYLAND_SOCKET", None) env.pop("WAYLAND_SOCKET", None)
log = (root / "test.log").open("w") log = (root / "test.log").open("w")
try: 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) processes.append(compositor)
deadline = time.monotonic() + 10 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() assert compositor.poll() is None and time.monotonic() < deadline, (root / "test.log").read_text()
time.sleep(0.02) 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) processes.append(backend)
deadline = time.monotonic() + 10 deadline = time.monotonic() + 10
while True: while True:
@@ -164,7 +165,7 @@ def private_test():
except GLib.Error as error: except GLib.Error as error:
assert "AccessDenied" in str(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) 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): def inhibit(path, flags):
call(INTERFACE, "Inhibit", GLib.Variant("(ossua{sv})", (path, "test.media", "", flags, {"reason": GLib.Variant("s", "Playing media")}))) call(INTERFACE, "Inhibit", GLib.Variant("(ossua{sv})", (path, "test.media", "", flags, {"reason": GLib.Variant("s", "Playing media")})))
inhibit(request, 8) 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,) 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") 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,) 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") state = query.call("begin-session-end")
assert state["query-ready"] is False assert state["query-ready"] is False
wait_for(lambda: any(value["session-state"] == 2 for value in changes), "Missing QueryEnd") wait_for(lambda: any(value["session-state"] == 2 for value in changes), "Missing QueryEnd")
+5 -4
View File
@@ -5,6 +5,7 @@ Requires built binaries and bwrap. FERESE_TEST_BIN_DIR selects debug/release. Ho
import os import os
import unittest import unittest
from pathlib import Path 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") @unittest.skipUnless(os.environ.get("FERESE_TEST_LOCK") == "1", "visible nested lock test is opt-in")
class NativeLockTest(unittest.TestCase): 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') 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')) binary_dir = Path(os.environ.get('FERESE_TEST_BIN_DIR', 'target/release'))
log=(root/'compositor.log').open('w') 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' ctl=binary_dir / 'feresectl'
assert ctl.is_file(), f'missing {ctl}; build it with cargo build --release -p feresectl' assert ctl.is_file(), f'missing {ctl}; build it with cargo build --release -p feresectl'
locker=None locker=None
@@ -37,13 +38,13 @@ class NativeLockTest(unittest.TestCase):
childenv=dict(env,WAYLAND_DISPLAY=str(sockets[0]),FERESE_LOCK_READY='1') childenv=dict(env,WAYLAND_DISPLAY=str(sockets[0]),FERESE_LOCK_READY='1')
def capture(destination, timeout=10): def capture(destination, timeout=10):
with open(destination,'wb') as png: 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) time.sleep(.6)
result=capture(root/'unlocked.png') result=capture(root/'unlocked.png')
assert result.returncode == 0, f'Capture failed while unlocked: {result.stderr.decode()}' 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' assert (root/'unlocked.png').stat().st_size > 0, 'Capture produced an empty file'
print('Native capture succeeded while unlocked',flush=True) 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() assert locker.wait(timeout=20) == 0, (root/'locker.log').read_text()
print('Native locker received compositor confirmation',flush=True) print('Native locker received compositor confirmation',flush=True)
result=capture(root/'locked.png') result=capture(root/'locked.png')
@@ -54,7 +55,7 @@ class NativeLockTest(unittest.TestCase):
result=capture(root/'after-crash.png') result=capture(root/'after-crash.png')
assert result.returncode != 0, 'Capture unexpectedly succeeded after locker crash' assert result.returncode != 0, 'Capture unexpectedly succeeded after locker crash'
print('Capture remains denied after locker crash',flush=True) 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() assert locker.wait(timeout=20) == 0, (root/'locker.log').read_text()
result=capture(root/'replacement.png') result=capture(root/'replacement.png')
assert result.returncode != 0, 'Capture unexpectedly succeeded after locker takeover' assert result.returncode != 0, 'Capture unexpectedly succeeded after locker takeover'
+5 -4
View File
@@ -5,6 +5,7 @@ Requires built debug compositor binaries, a Wayland host and dbus-daemon.
""" """
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import signal import signal
import subprocess import subprocess
import tempfile import tempfile
@@ -34,17 +35,17 @@ class MaterialLifetime(unittest.TestCase):
with (root / "compositor.log").open("w") as log: with (root / "compositor.log").open("w") as log:
try: try:
bus = subprocess.Popen(["dbus-daemon", "--session", "--nofork", "--print-address=1"], 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) children.append(bus)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
compositor = subprocess.Popen([str(ROOT / "target/debug/ferese"), "--backend", "nested"], 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) children.append(compositor)
deadline = time.monotonic() + 10 deadline = time.monotonic() + 10
while time.monotonic() < deadline: while time.monotonic() < deadline:
self.assertIsNone(compositor.poll(), (root / "compositor.log").read_text()) self.assertIsNone(compositor.poll(), (root / "compositor.log").read_text())
sockets = [p for p in runtime.glob("wayland-*") if p.is_socket()] 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 break
time.sleep(.05) time.sleep(.05)
else: else:
@@ -54,7 +55,7 @@ class MaterialLifetime(unittest.TestCase):
"cargo", "test", "--workspace", "--locked", "cargo", "test", "--workspace", "--locked",
"material::tests::attachment_acknowledgement_and_last_clone_release", "material::tests::attachment_acknowledgement_and_last_clone_release",
"--", "--ignored", "--exact", "--", "--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.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("1 passed", result.stdout) self.assertIn("1 passed", result.stdout)
self.assertIsNone(compositor.poll()) self.assertIsNone(compositor.poll())
+6 -5
View File
@@ -7,6 +7,7 @@ Requires a Wayland host, PyGObject and dbus-run-session.
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import signal import signal
import socket import socket
import struct import struct
@@ -135,7 +136,7 @@ def private_checks():
binary = REPO / os.environ.get('FERESE_TEST_BINARY', 'target/debug/ferese') binary = REPO / os.environ.get('FERESE_TEST_BINARY', 'target/debug/ferese')
ctl = REPO / os.environ.get('FERESE_TEST_CTL', 'target/debug/feresectl') ctl = REPO / os.environ.get('FERESE_TEST_CTL', 'target/debug/feresectl')
with (root / 'compositor.log').open('w') as log: 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) stdout=log, stderr=log, start_new_session=True)
def wait(check): def wait(check):
@@ -148,17 +149,17 @@ def private_checks():
time.sleep(.02) time.sleep(.02)
raise AssertionError('Timed out\n' + (root / 'compositor.log').read_text()) 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): 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): def request(command, args):
payload = json.dumps({'version': 1, 'id': 17, 'type': 'command', payload = json.dumps({'version': 1, 'id': 17, 'type': 'command',
'command': command, 'args': args}).encode() 'command': command, 'args': args}).encode()
connection = socket.socket(socket.AF_UNIX) connection = socket.socket(socket.AF_UNIX)
connection.settimeout(3) connection.settimeout(3)
connection.connect(str(runtime / 'ferese/control.sock')) connection.connect(str(ipc_socket(runtime)))
connection.sendall(struct.pack('!I', len(payload)) + payload) connection.sendall(struct.pack('!I', len(payload)) + payload)
return connection 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'))) 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') shell_binary = os.environ.get('FERESE_TEST_SHELL')
if shell_binary: 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] first = players[0]
selected(first.name) selected(first.name)
initial = call('media') initial = call('media')
@@ -10,6 +10,7 @@ import errno
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import re import re
import signal import signal
import subprocess import subprocess
@@ -46,12 +47,12 @@ class OverviewDamageTest(unittest.TestCase):
log_path = root / "compositor.log" log_path = root / "compositor.log"
def launch(command, **kwargs): 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) children.append(child)
return child return child
def command(*args): 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): def wait_for(predicate, seconds=12):
deadline = time.monotonic() + seconds deadline = time.monotonic() + seconds
@@ -98,7 +99,7 @@ class OverviewDamageTest(unittest.TestCase):
stdout=subprocess.PIPE, text=True) stdout=subprocess.PIPE, text=True)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
launch([str(binary), "--backend", "nested"], stdout=log, stderr=log) 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( env["WAYLAND_DISPLAY"] = str(next(
path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock") path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")
)) ))
@@ -8,6 +8,7 @@ import io
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import signal import signal
import subprocess import subprocess
import tempfile import tempfile
@@ -55,7 +56,7 @@ class OverviewPreviews(unittest.TestCase):
log_path = root / "compositor.log" log_path = root / "compositor.log"
def call(*args): 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): def wait_for(predicate):
deadline = time.monotonic() + 10 deadline = time.monotonic() + 10
@@ -69,12 +70,12 @@ class OverviewPreviews(unittest.TestCase):
with log_path.open("w") as log: with log_path.open("w") as log:
def launch(command): 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) children.append(child)
try: try:
launch([str(binary), "--backend", "nested"]) 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())) env["WAYLAND_DISPLAY"] = str(next(path for path in runtime.glob("wayland-*") if path.is_socket()))
launch([str(root / "client")]) launch([str(root / "client")])
wait_for(lambda: len(json.loads(call("get-windows"))) == 1) wait_for(lambda: len(json.loads(call("get-windows"))) == 1)
+6 -5
View File
@@ -6,6 +6,7 @@ Requires built debug compositor/control binaries, a Wayland host, cc and wayland
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import signal import signal
import subprocess import subprocess
import tempfile import tempfile
@@ -57,10 +58,10 @@ class Presentation(unittest.TestCase):
env.pop("FERESE_SHELL_CONTROL_SOCKET", None) env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
log = (root / "compositor.log").open("w") log = (root / "compositor.log").open("w")
try: 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) processes.append(compositor)
deadline = time.monotonic() + 10 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: if compositor.poll() is not None or time.monotonic() > deadline:
self.fail((root / "compositor.log").read_text()) self.fail((root / "compositor.log").read_text())
time.sleep(0.02) 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"))) env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock")))
def call(*args): 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): def windows(count):
deadline = time.monotonic() + 10 deadline = time.monotonic() + 10
@@ -80,7 +81,7 @@ class Presentation(unittest.TestCase):
self.fail((root / "compositor.log").read_text()) self.fail((root / "compositor.log").read_text())
events = (root / "events").open("w") 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) stdout=events, stderr=log, start_new_session=True)
processes.append(target) processes.append(target)
windows(1) windows(1)
@@ -95,7 +96,7 @@ class Presentation(unittest.TestCase):
self.assertGreater(count("frame"), before, "visible empty commits must get callbacks") self.assertGreater(count("frame"), before, "visible empty commits must get callbacks")
self.assertEqual(count("idle"), idle, "visible inhibitor must keep the notifier active") 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) stderr=log, start_new_session=True)
processes.append(cover) processes.append(cover)
current = windows(2) current = windows(2)
@@ -8,6 +8,7 @@ before the 300 ms deadline; it does not measure frame pacing or GPU performance.
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import select import select
import subprocess import subprocess
import tempfile import tempfile
@@ -50,10 +51,10 @@ class ResizeDependencies(unittest.TestCase):
ctl = REPO / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") ctl = REPO / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl")
with (root / "compositor.log").open("w") as log: with (root / "compositor.log").open("w") as log:
try: 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) processes.append(compositor)
deadline = time.monotonic() + 10 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: if compositor.poll() is not None or time.monotonic() > deadline:
self.fail((root / "compositor.log").read_text()) self.fail((root / "compositor.log").read_text())
time.sleep(.02) time.sleep(.02)
@@ -61,10 +62,10 @@ class ResizeDependencies(unittest.TestCase):
client_env = dict(env, WAYLAND_DISPLAY=str(socket)) client_env = dict(env, WAYLAND_DISPLAY=str(socket))
def call(*args): 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): 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): def client(name):
process = subprocess.Popen([str(root / "client"), name], env=client_env, process = subprocess.Popen([str(root / "client"), name], env=client_env,
+6 -5
View File
@@ -7,6 +7,7 @@ No host permission store, compositor configuration or portal service is changed.
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import signal import signal
import socket import socket
import struct import struct
@@ -94,10 +95,10 @@ def private_test():
env.pop("WAYLAND_SOCKET", None) env.pop("WAYLAND_SOCKET", None)
log = (root / "test.log").open("w") log = (root / "test.log").open("w")
try: 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) processes.append(compositor)
destination = runtime / "ferese/control.sock" wait_for(lambda: ipc_socket(runtime).is_socket(), "Nested compositor did not start")
wait_for(lambda: destination.exists(), "Nested compositor did not start") destination = ipc_socket(runtime)
admin = IPC(destination) admin = IPC(destination)
outputs = admin.call("get-outputs") outputs = admin.call("get-outputs")
name = next(output["name"] for output in outputs if output["enabled"]) 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() threading.Thread(target=accept, daemon=True).start()
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) 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) 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) processes.append(backend)
def call(method, params=None, path=PATH, interface=INTERFACE): 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() 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) fallback("replacement", saved=stale)
print("PASS: other-app and changed-device restore require fresh consent", flush=True) 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) processes.append(mismatch)
mismatch.wait(timeout=8) mismatch.wait(timeout=8)
error = mismatch.stderr.read() error = mismatch.stderr.read()
+50
View File
@@ -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()
+11
View File
@@ -32,6 +32,7 @@ class SessionStartupTest(unittest.TestCase):
path.write_text('#!/bin/bash\n' + body) path.write_text('#!/bin/bash\n' + body)
path.chmod(0o755) path.chmod(0o755)
env = dict(os.environ, PATH=f'{root}:{os.environ["PATH"]}', TEST_ROOT=directory, 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_SESSION_IMPORT_ENV=str(int(direct)), WAYLAND_DISPLAY='private-display',
FERESE_PUBLIC_WAYLAND_DISPLAY='public-display', WAYLAND_SOCKET='77', 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))) 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 try-restart xdg-desktop-portal-ferese.service', 'shell:77',
'--user stop ferese-session.target']) '--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): 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') status, events = self.run_session(display=':7', xauthority='/run/user/1000/ferese-xauth-abcd')
self.assertEqual(status, 0) self.assertEqual(status, 0)
@@ -105,6 +111,11 @@ class SessionStartupTest(unittest.TestCase):
self.assertEqual(status, 0) self.assertEqual(status, 0)
self.assertEqual(events, ['shell:77']) 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): def test_failed_target_does_not_prevent_shell_or_stop_unowned_target(self):
status, events = self.run_session(fail_target=True) status, events = self.run_session(fail_target=True)
self.assertEqual(status, 0) self.assertEqual(status, 0)
+8 -7
View File
@@ -7,6 +7,7 @@ Protocol traces contain only this test's shell traffic, never the host desktop.
""" """
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import re import re
import signal import signal
import subprocess import subprocess
@@ -53,7 +54,7 @@ class ShellMotionTest(unittest.TestCase):
'<allow receive_sender="*"/><allow own="*"/></policy></busconfig>') '<allow receive_sender="*"/><allow own="*"/></policy></busconfig>')
def launch(command, **kwargs): 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) children.append(process)
return process return process
@@ -73,7 +74,7 @@ class ShellMotionTest(unittest.TestCase):
return subprocess.run(["gdbus", "call", "--session", "--dest", "org.freedesktop.Notifications", return subprocess.run(["gdbus", "call", "--session", "--dest", "org.freedesktop.Notifications",
"--object-path", "/org/freedesktop/Notifications", "--method", "--object-path", "/org/freedesktop/Notifications", "--method",
"org.freedesktop.Notifications." + method, *args], "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): def layer_surface(namespace):
matches = list(re.finditer(r'get_layer_surface\([^\n]*?wl_surface[@#](\d+)[^\n]*"' + namespace + '"', log_path.read_text())) 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", compositor = launch([str(binary), "--backend", "nested", "--grant-effects", "--grant-shell-control",
"--", "env", "WAYLAND_DEBUG=client", "ICED_BACKEND=tiny-skia", str(shell)], "--", "env", "WAYLAND_DEBUG=client", "ICED_BACKEND=tiny-skia", str(shell)],
stdout=log, stderr=log) 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) wait_for(lambda: dbus("GetServerInformation", check=False).returncode == 0)
bar = wait_for(lambda: layer_surface("ferese-shell-top-bar")) bar = wait_for(lambda: layer_surface("ferese-shell-top-bar"))
if bar_layout == "islands": if bar_layout == "islands":
@@ -165,11 +166,11 @@ class ShellMotionTest(unittest.TestCase):
"notification materials did not follow their fade") "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) 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")) modal = wait_for(lambda: layer_surface("ferese-system-modal"))
wait_for(lambda: frames(modal) >= 4) wait_for(lambda: frames(modal) >= 4)
time.sleep(.3) 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) wait_for(lambda: destroyed(modal), timeout=5)
self.assertGreater(frames(modal), 10, "modal close did not follow frames") self.assertGreater(frames(modal), 10, "modal close did not follow frames")
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
@@ -181,7 +182,7 @@ class ShellMotionTest(unittest.TestCase):
source = config_file.read_text() source = config_file.read_text()
config_file.write_text(re.sub( config_file.write_text(re.sub(
r'bar-layout "[^"]+"', f'bar-layout "{layout_name}"', source)) 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)) wait_for(lambda: bar_region_count(bar) == (expected_count or 2))
self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:])
@@ -192,7 +193,7 @@ class ShellMotionTest(unittest.TestCase):
source = config_file.read_text() source = config_file.read_text()
config_file.write_text(re.sub( config_file.write_text(re.sub(
r'bar-island-padding \d+', f'bar-island-padding {padding}', source)) 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) expected_width = original_input[0][2] - 2 * (12 - padding)
wait_for(lambda: len(bar_input_rectangles(bar)) == 2 wait_for(lambda: len(bar_input_rectangles(bar)) == 2
and bar_input_rectangles(bar)[0][2] == expected_width) and bar_input_rectangles(bar)[0][2] == expected_width)
+4 -3
View File
@@ -2,6 +2,7 @@
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import signal import signal
import socket import socket
import struct 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 = 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("WAYLAND_SOCKET", None)
env.pop("FERESE_SHELL_CONTROL_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) stdout=log, stderr=log, start_new_session=True)
connections = [] connections = []
try: try:
deadline = time.monotonic() + 15 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: if process.poll() is not None or time.monotonic() >= deadline:
self.fail((root / "compositor.log").read_text()) self.fail((root / "compositor.log").read_text())
time.sleep(0.02) time.sleep(0.02)
@@ -44,7 +45,7 @@ class ShortcutConnections(unittest.TestCase):
def connect(): def connect():
connection = socket.socket(socket.AF_UNIX) connection = socket.socket(socket.AF_UNIX)
connection.settimeout(2) connection.settimeout(2)
connection.connect(str(runtime / "ferese/control.sock")) connection.connect(str(ipc_socket(runtime)))
connections.append(connection) connections.append(connection)
return connection return connection
+6 -5
View File
@@ -6,6 +6,7 @@ Requires target/debug/ferese, feresectl, and a running Wayland desktop.
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import select import select
import signal import signal
import shutil import shutil
@@ -38,12 +39,12 @@ class ThemeTest(unittest.TestCase):
XDG_CONFIG_HOME=str(root / "config")) XDG_CONFIG_HOME=str(root / "config"))
def launch(command, **kwargs): 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) children.append(child)
return child return child
def command(*args): 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): def until(predicate, seconds=5):
deadline = time.monotonic() + seconds 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) bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE, text=True)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log) 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") self.assertEqual(command("theme", "get")["theme"]["appearance"], "dark")
subscriber = launch([str(repo / "target/debug/feresectl"), "theme", "subscribe"], stdout=subprocess.PIPE, text=True) subscriber = launch([str(repo / "target/debug/feresectl"), "theme", "subscribe"], stdout=subprocess.PIPE, text=True)
self.assertTrue(select.select([subscriber.stdout], [], [], 3)[0]) self.assertTrue(select.select([subscriber.stdout], [], [], 3)[0])
@@ -116,10 +117,10 @@ class ThemeTest(unittest.TestCase):
command("theme", "mode", "light") command("theme", "mode", "light")
until(lambda: command("theme", "get")["theme"]["tokens"]["colors"]["surface_base"] == "#FBF1C7") until(lambda: command("theme", "get")["theme"]["tokens"]["colors"]["surface_base"] == "#FBF1C7")
if shutil.which("gsettings"): 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') 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) 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"]) until(lambda: (snapshot := command("theme", "get"))["theme"]["appearance"] == "light" and snapshot["presented"] == snapshot["theme"])
self.assertIsNone(compositor.poll()) self.assertIsNone(compositor.poll())
finally: finally:
@@ -7,6 +7,7 @@ Requires cc, pkg-config, wayland-scanner, and wayland-protocols.
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import select import select
import subprocess import subprocess
import tempfile import tempfile
@@ -62,7 +63,7 @@ class UnmappedToplevelTest(unittest.TestCase):
with (root / "compositor.log").open("w") as log: with (root / "compositor.log").open("w") as log:
compositor = subprocess.Popen( compositor = subprocess.Popen(
[str(compositor_binary), "--backend", "nested"], [str(compositor_binary), "--backend", "nested"],
env=env, env=ipc_environment(env),
stdout=log, stdout=log,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
) )
@@ -101,7 +102,7 @@ class UnmappedToplevelTest(unittest.TestCase):
while True: while True:
result = subprocess.run( result = subprocess.run(
[str(ctl_binary), "-j", "focused-window"], [str(ctl_binary), "-j", "focused-window"],
env=env, env=ipc_environment(env),
capture_output=True, capture_output=True,
text=True, text=True,
check=True, check=True,
@@ -116,7 +117,7 @@ class UnmappedToplevelTest(unittest.TestCase):
visible_ids.append(focused["id"]) visible_ids.append(focused["id"])
if phase == "visible": if phase == "visible":
# Leave a configure/resize outstanding when the buffer is detached. # 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) capture_output=True, check=True)
time.sleep(.08) time.sleep(.08)
if phase == "detached": if phase == "detached":
@@ -131,7 +132,7 @@ class UnmappedToplevelTest(unittest.TestCase):
deadline = time.monotonic() + 3 deadline = time.monotonic() + 3
while True: while True:
remaining = json.loads(subprocess.check_output( 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: if not remaining:
break break
@@ -139,10 +140,10 @@ class UnmappedToplevelTest(unittest.TestCase):
self.fail(f"destroyed window still managed: {remaining}") self.fail(f"destroyed window still managed: {remaining}")
time.sleep(0.05) 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) capture_output=True, check=True)
focused = json.loads(subprocess.check_output( 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") self.assertIsNone(focused, "focus history must not restore a destroyed window")
deadline = time.monotonic() + 3 deadline = time.monotonic() + 3
@@ -9,6 +9,7 @@ import io
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import signal import signal
import selectors import selectors
import subprocess import subprocess
@@ -58,10 +59,10 @@ class WindowCapture(unittest.TestCase):
env.pop("FERESE_SHELL_CONTROL_SOCKET", None) env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
log = (root / "compositor.log").open("w") log = (root / "compositor.log").open("w")
try: 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) processes.append(compositor)
deadline = time.monotonic() + 10 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: if compositor.poll() is not None or time.monotonic() > deadline:
self.fail((root / "compositor.log").read_text()) self.fail((root / "compositor.log").read_text())
time.sleep(0.02) 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"))) env["WAYLAND_DISPLAY"] = str(next(path for path in sockets if not path.name.endswith(".lock")))
def call(*args): 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): def windows(count):
deadline = time.monotonic() + 10 deadline = time.monotonic() + 10
@@ -80,9 +81,9 @@ class WindowCapture(unittest.TestCase):
time.sleep(0.02) time.sleep(0.02)
self.fail((root / "compositor.log").read_text()) 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] 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) current = windows(2)
cover = next(window for window in current if window["title"] == "Occluding window") cover = next(window for window in current if window["title"] == "Occluding window")
self.assertEqual((target["x"], target["y"], target["width"], target["height"]), self.assertEqual((target["x"], target["y"], target["width"], target["height"]),
@@ -131,7 +132,7 @@ class WindowCapture(unittest.TestCase):
worker.stdin.close() worker.stdin.close()
worker.stdout.close() worker.stdout.close()
rejected = subprocess.run([str(CTL), "screenshot-window", str(target["id"])], 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.assertNotEqual(rejected.returncode, 0)
self.assertIn(b"protected", rejected.stderr) self.assertIn(b"protected", rejected.stderr)
self.assertTrue(next(window for window in windows(2) if window["id"] == target["id"])["mapped"]) self.assertTrue(next(window for window in windows(2) if window["id"] == target["id"])["mapped"])
+9 -8
View File
@@ -7,6 +7,7 @@ Only this test's compositor, windows and streams are stopped.
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import select import select
import signal import signal
import subprocess import subprocess
@@ -65,21 +66,21 @@ class WindowStream(unittest.TestCase):
env.pop("FERESE_SHELL_CONTROL_SOCKET", None) env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
log = (root / "test.log").open("w") log = (root / "test.log").open("w")
try: 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) processes.append(compositor)
socket = runtime / "ferese/control.sock" wait_for(lambda: ipc_socket(runtime).is_socket(), "Nested compositor did not start")
wait_for(socket.exists, "Nested compositor did not start") socket = ipc_socket(runtime)
admin = IPC(socket) admin = IPC(socket)
sockets = [path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")] sockets = [path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")]
env["WAYLAND_DISPLAY"] = str(sockets[0]) 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) 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") 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"] 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) processes.append(cover)
wait_for(lambda: len(admin.call("get-windows")) == 2, "Occluding window did not map") 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) processes.append(helper)
self.assertTrue(select.select([helper.stdout], [], [], 10)[0], "Stream did not announce a PipeWire node") self.assertTrue(select.select([helper.stdout], [], [], 10)[0], "Stream did not announce a PipeWire node")
ready = json.loads(helper.stdout.readline()) ready = json.loads(helper.stdout.readline())
@@ -126,11 +127,11 @@ class WindowStream(unittest.TestCase):
pipeline.set_state(Gst.State.NULL) pipeline.set_state(Gst.State.NULL)
pipeline = None pipeline = None
cover_id = next(window["id"] for window in admin.call("get-windows") if window["title"] == "Occluding window") 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) processes.append(paused)
self.assertTrue(select.select([paused.stdout], [], [], 10)[0]) self.assertTrue(select.select([paused.stdout], [], [], 10)[0])
self.assertGreater(json.loads(paused.stdout.readline())["width"], 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) processes.append(locker)
self.assertTrue(select.select([locker.stdout], [], [], 5)[0], "Nested lock request did not complete") self.assertTrue(select.select([locker.stdout], [], [], 5)[0], "Nested lock request did not complete")
self.assertIn(locker.stdout.readline().strip(), ("locked", "requested")) self.assertIn(locker.stdout.readline().strip(), ("locked", "requested"))
@@ -6,6 +6,7 @@ Requires target/debug/ferese, feresectl, foot, dbus-daemon, and a Wayland host.
import json import json
import os import os
from pathlib import Path from pathlib import Path
from session_socket import ipc_environment, ipc_socket
import signal import signal
import subprocess import subprocess
import tempfile import tempfile
@@ -35,13 +36,13 @@ class WorkspaceHistoryTest(unittest.TestCase):
children = [] children = []
def launch(command, **kwargs): 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) children.append(child)
return child return child
def command(*args): def command(*args):
return json.loads(subprocess.check_output( 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(): def active():
@@ -55,7 +56,7 @@ class WorkspaceHistoryTest(unittest.TestCase):
compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"], compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"],
stdout=log, stderr=log) stdout=log, stderr=log)
deadline = time.monotonic() + 10 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: if compositor.poll() is not None or time.monotonic() >= deadline:
self.fail((root / "compositor.log").read_text()) self.fail((root / "compositor.log").read_text())
time.sleep(.025) time.sleep(.025)
+3 -1
View File
@@ -221,7 +221,9 @@ async fn session_subject(
connection: &zbus::Connection, connection: &zbus::Connection,
) -> Result<(String, HashMap<String, Value<'static>>), Box<dyn std::error::Error>> { ) -> Result<(String, HashMap<String, Value<'static>>), Box<dyn std::error::Error>> {
// Nested previews must not take over authentication for the host desktop. // 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); return process_subject().map_err(Into::into);
} }
let manager = zbus::Proxy::new( let manager = zbus::Proxy::new(
+2 -3
View File
@@ -228,11 +228,10 @@ fn save_with(
pub fn reload_running() -> bool { pub fn reload_running() -> bool {
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; 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; return false;
}; };
let Ok(mut socket) = std::os::unix::net::UnixStream::connect(PathBuf::from(runtime).join("ferese/control.sock")) let Ok(mut socket) = std::os::unix::net::UnixStream::connect(path) else {
else {
return false; return false;
}; };
let _ = socket.set_read_timeout(Some(Duration::from_secs(2))); let _ = socket.set_read_timeout(Some(Duration::from_secs(2)));
+1 -5
View File
@@ -1,5 +1,4 @@
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -15,10 +14,7 @@ struct SessionConnection {
impl SessionConnection { impl SessionConnection {
fn connect() -> Result<Self, String> { fn connect() -> Result<Self, String> {
let path = std::env::var_os("XDG_RUNTIME_DIR") let path = ferese_ipc::socket::resolve(None).map_err(|error| error.to_string())?;
.map(PathBuf::from)
.ok_or("Missing runtime directory")?
.join("ferese/control.sock");
let stream = UnixStream::connect(path).map_err(|error| error.to_string())?; let stream = UnixStream::connect(path).map_err(|error| error.to_string())?;
stream stream
.set_read_timeout(Some(Duration::from_secs(2))) .set_read_timeout(Some(Duration::from_secs(2)))