Add XWayland support (#16)

* save dev state

* fix xwayland-satellite supervisor lifecycle, readiness and cleanup

* remove wayland scanner protocol generated files

* default xwayland enabled with path

* remove jargons

* address x11 review: park unconfirmed cleanup, never signal a reaped group, fix storm test and notify dir mode

* fix resize tests: never build Size values from negative dimensions

* add min-width/min-height window rules that replace client minimum sizes

* rename effective_size_constraints to effective_minimum_size and minor cleanups
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-06 19:40:22 +01:00
1 parent ed23949a95
commit a6f83689f8
42 files changed
+7153 -1324

No files matched your search

+1 -1
View File
@@ -30,9 +30,9 @@ serde_json.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
xcursor.workspace = true xcursor.workspace = true
tempfile.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true
[features] [features]
# Opt-in counters/histograms with a per-client exit summary. # Opt-in counters/histograms with a per-client exit summary.
+52 -5
View File
@@ -3,12 +3,14 @@ mod bindings;
mod input_motion; mod input_motion;
mod layout; mod layout;
mod outputs; mod outputs;
mod xwayland;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::error::Error; use std::error::Error;
use std::fmt; use std::fmt;
use std::path::PathBuf; use std::path::PathBuf;
pub(crate) use self::xwayland::{XwaylandConfig, XwaylandStartup};
use appearance::*; use appearance::*;
pub use appearance::{BorderGradient, InactiveDimSettings, MaterialStyle, ThemeSettings}; pub use appearance::{BorderGradient, InactiveDimSettings, MaterialStyle, ThemeSettings};
use bindings::*; use bindings::*;
@@ -61,6 +63,8 @@ pub struct Config {
scrolling: ScrollingConfig, scrolling: ScrollingConfig,
#[serde(default)] #[serde(default)]
output_profiles: Vec<OutputProfileConfig>, output_profiles: Vec<OutputProfileConfig>,
#[serde(default)]
pub(crate) xwayland: XwaylandConfig,
#[serde(default, rename = "status")] #[serde(default, rename = "status")]
_status: ShellStatusConfig, _status: ShellStatusConfig,
} }
@@ -114,6 +118,7 @@ impl Config {
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> { pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
self.notifications.validate().map_err(ConfigError::Binding)?; self.notifications.validate().map_err(ConfigError::Binding)?;
self.desktop_widgets.validate().map_err(ConfigError::Binding)?; self.desktop_widgets.validate().map_err(ConfigError::Binding)?;
self.xwayland.validate().map_err(ConfigError::Binding)?;
for daemon in &self.autostart { for daemon in &self.autostart {
if daemon.command.first().is_none_or(|program| program.trim().is_empty()) { if daemon.command.first().is_none_or(|program| program.trim().is_empty()) {
return Err(ConfigError::Binding("autostart command must contain a program".into())); return Err(ConfigError::Binding("autostart command must contain a program".into()));
@@ -143,6 +148,7 @@ impl Config {
output_profiles: self.output_profiles()?, output_profiles: self.output_profiles()?,
wallpaper: self.wallpaper_settings(), wallpaper: self.wallpaper_settings(),
overview_font_family: self.overview_font_family(), overview_font_family: self.overview_font_family(),
xwayland: self.xwayland.clone(),
}) })
} }
@@ -517,10 +523,13 @@ mod tests {
#[test] #[test]
fn packaged_and_custom_kdl_pass_runtime_validation() { fn packaged_and_custom_kdl_pass_runtime_validation() {
Config::parse_source(include_str!("../../packaging/config.kdl")) let packaged = Config::parse_source(include_str!("../../packaging/config.kdl")).unwrap();
.unwrap() packaged.runtime_config().unwrap();
.runtime_config()
.unwrap(); assert!(
packaged.xwayland.enabled,
"packaging/config.kdl must ship X11 support enabled, matching the default"
);
let source = "input {\n touchpad {\n swipe-threshold 96\n }\n}\nbinding keys=\"Swipe3Up\" action=\"toggle-overview\"\noutput-profile name=\"desk\" {\n output match=\"DP-1\" scale=1.5 {\n position 0 0\n }\n}\ndesktop-widgets {\n clock {\n enabled #true\n outputs \"DP-1\"\n }\n}\nautostart {\n command \"program\" \"argument with space\"\n}\n"; let source = "input {\n touchpad {\n swipe-threshold 96\n }\n}\nbinding keys=\"Swipe3Up\" action=\"toggle-overview\"\noutput-profile name=\"desk\" {\n output match=\"DP-1\" scale=1.5 {\n position 0 0\n }\n}\ndesktop-widgets {\n clock {\n enabled #true\n outputs \"DP-1\"\n }\n}\nautostart {\n command \"program\" \"argument with space\"\n}\n";
let config = Config::parse_source(source).unwrap(); let config = Config::parse_source(source).unwrap();
config.runtime_config().unwrap(); config.runtime_config().unwrap();
@@ -557,6 +566,40 @@ mod tests {
ferese_config::from_str(source).unwrap() ferese_config::from_str(source).unwrap()
} }
#[test]
fn an_empty_configuration_enables_the_managed_x11_service() {
let config = parse("");
assert_eq!(config.xwayland, XwaylandConfig::default());
assert!(config.xwayland.enabled, "an empty config keeps the default");
assert_eq!(config.xwayland.startup, XwaylandStartup::OnDemand);
let runtime = config.runtime_config().expect("an empty config is valid");
assert!(runtime.xwayland.enabled);
}
#[test]
fn a_configuration_without_an_xwayland_section_keeps_the_defaults() {
let source = r#"
layout-mode "scrolling";
desktop-widgets {
clock {
time-format "%H:%M";
};
}
scroll-factor 1.0;
"#;
let config = parse(source);
assert!(
source.find("xwayland").is_none(),
"the sample must have no xwayland node"
);
assert_eq!(config.xwayland, XwaylandConfig::default());
assert!(config.xwayland.enabled);
let runtime = config.runtime_config().expect("the existing config stays valid");
assert!(runtime.xwayland.enabled);
}
#[test] #[test]
fn defaults_to_half_width_scrolling_columns() { fn defaults_to_half_width_scrolling_columns() {
let config = parse(""); let config = parse("");
@@ -1084,7 +1127,7 @@ mod tests {
#[test] #[test]
fn parses_and_validates_window_rules() { fn parses_and_validates_window_rules() {
let config = parse( let config = parse(
"window-rule app-id=\"org.example.Editor\" workspace=3 floating=#true width=900.0 height=600.0 fullscreen=#false block-out-from-screencasts=#true\n", "window-rule app-id=\"org.example.Editor\" workspace=3 floating=#true width=900.0 height=600.0 min-width=500 min-height=400 fullscreen=#false block-out-from-screencasts=#true\n",
); );
let rules = config.window_rules().unwrap(); let rules = config.window_rules().unwrap();
let result = window_rules::resolve(&rules, Some("org.example.editor.desktop"), Some("Document"), false); let result = window_rules::resolve(&rules, Some("org.example.editor.desktop"), Some("Document"), false);
@@ -1093,6 +1136,8 @@ mod tests {
assert_eq!(result.floating, Some(true)); assert_eq!(result.floating, Some(true));
assert_eq!(result.width, Some(900.0)); assert_eq!(result.width, Some(900.0));
assert_eq!(result.height, Some(600.0)); assert_eq!(result.height, Some(600.0));
assert_eq!(result.min_width, Some(500.0));
assert_eq!(result.min_height, Some(400.0));
assert_eq!(result.fullscreen, Some(false)); assert_eq!(result.fullscreen, Some(false));
assert_eq!(result.block_out_from_screencasts, Some(true)); assert_eq!(result.block_out_from_screencasts, Some(true));
} }
@@ -1101,9 +1146,11 @@ mod tests {
fn rejects_invalid_window_rule_configuration() { fn rejects_invalid_window_rule_configuration() {
let catch_all = parse("window-rule floating=#true\n"); let catch_all = parse("window-rule floating=#true\n");
let zero_workspace = parse("window-rule app-id=\"editor\" workspace=0\n"); let zero_workspace = parse("window-rule app-id=\"editor\" workspace=0\n");
let zero_minimum = parse("window-rule app-id=\"editor\" min-width=0\n");
assert!(catch_all.window_rules().is_err()); assert!(catch_all.window_rules().is_err());
assert!(zero_workspace.window_rules().is_err()); assert!(zero_workspace.window_rules().is_err());
assert!(zero_minimum.window_rules().is_err());
} }
#[test] #[test]
+101
View File
@@ -0,0 +1,101 @@
use std::path::PathBuf;
use serde::Deserialize;
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum XwaylandStartup {
#[default]
OnDemand,
Eager,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct XwaylandConfig {
pub enabled: bool,
pub startup: XwaylandStartup,
pub path: PathBuf,
}
impl Default for XwaylandConfig {
fn default() -> Self {
Self {
enabled: true,
startup: XwaylandStartup::OnDemand,
path: PathBuf::from("xwayland-satellite"),
}
}
}
impl XwaylandConfig {
pub fn validate(&self) -> Result<(), String> {
if self.enabled && self.path.as_os_str().is_empty() {
return Err("xwayland.path must name an executable".into());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_enable_on_demand_with_satellite_on_path() {
let config = XwaylandConfig::default();
assert!(config.enabled);
assert_eq!(config.startup, XwaylandStartup::OnDemand);
assert_eq!(config.path, PathBuf::from("xwayland-satellite"));
assert_eq!(config.validate(), Ok(()));
}
#[test]
fn disabled_config_does_not_require_a_path() {
let config = XwaylandConfig {
enabled: false,
path: PathBuf::new(),
..XwaylandConfig::default()
};
assert_eq!(config.validate(), Ok(()));
}
#[test]
fn enabled_config_rejects_an_empty_path() {
let config = XwaylandConfig {
enabled: true,
path: PathBuf::new(),
..XwaylandConfig::default()
};
assert_eq!(
config.validate(),
Err("xwayland.path must name an executable".to_owned())
);
}
#[test]
fn startup_mode_accepts_kebab_case() {
#[derive(Deserialize)]
struct Wrapper {
startup: XwaylandStartup,
}
assert_eq!(
serde_json::from_str::<Wrapper>(r#"{"startup":"eager"}"#)
.unwrap()
.startup,
XwaylandStartup::Eager
);
assert_eq!(
serde_json::from_str::<Wrapper>(r#"{"startup":"on-demand"}"#)
.unwrap()
.startup,
XwaylandStartup::OnDemand
);
assert!(serde_json::from_str::<Wrapper>(r#"{"startup":"eagerly"}"#).is_err());
}
}
+28 -17
View File
@@ -150,7 +150,18 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
let state = cached.current(); let state = cached.current();
(state.min_size, state.max_size) (state.min_size, state.max_size)
}); });
self.last_size = constrained_size(self.initial_rect.size, delta, self.edges, minimum, maximum); let (minimum, maximum) = data.effective_size_constraints(
&self.window,
(minimum.w, minimum.h),
(maximum.w, maximum.h),
);
self.last_size = constrained_size(
self.initial_rect.size,
delta,
self.edges,
minimum,
(maximum.0, maximum.1),
);
let rect = resized_rect(self.initial_rect, self.last_size, self.edges); let rect = resized_rect(self.initial_rect, self.last_size, self.edges);
data.set_floating_window_geometry(&self.window, rect.loc, rect.size); data.set_floating_window_geometry(&self.window, rect.loc, rect.size);
if let Some(id) = data.windows.ids().get(&self.window).copied() { if let Some(id) = data.windows.ids().get(&self.window).copied() {
@@ -303,8 +314,8 @@ fn constrained_size(
initial: Size<i32, Logical>, initial: Size<i32, Logical>,
delta: Point<f64, Logical>, delta: Point<f64, Logical>,
edges: ResizeEdge, edges: ResizeEdge,
minimum: Size<i32, Logical>, minimum: (i32, i32),
maximum: Size<i32, Logical>, maximum: (i32, i32),
) -> Size<i32, Logical> { ) -> Size<i32, Logical> {
let mut width = initial.w; let mut width = initial.w;
let mut height = initial.h; let mut height = initial.h;
@@ -322,17 +333,17 @@ fn constrained_size(
// Protocol validation rejects these ranges, but a grab may still be alive // Protocol validation rejects these ranges, but a grab may still be alive
// when its client is disconnected. Keep this path safe for any cached state. // when its client is disconnected. Keep this path safe for any cached state.
let minimum_width = minimum.w.max(1); let minimum_width = minimum.0.max(1);
let minimum_height = minimum.h.max(1); let minimum_height = minimum.1.max(1);
let maximum_width = if maximum.w <= 0 { let maximum_width = if maximum.0 <= 0 {
i32::MAX i32::MAX
} else { } else {
maximum.w.max(minimum_width) maximum.0.max(minimum_width)
}; };
let maximum_height = if maximum.h <= 0 { let maximum_height = if maximum.1 <= 0 {
i32::MAX i32::MAX
} else { } else {
maximum.h.max(minimum_height) maximum.1.max(minimum_height)
}; };
( (
width.clamp(minimum_width, maximum_width), width.clamp(minimum_width, maximum_width),
@@ -394,8 +405,8 @@ mod tests {
(800, 600).into(), (800, 600).into(),
(100.0, 50.0).into(), (100.0, 50.0).into(),
ResizeEdge(xdg_toplevel::ResizeEdge::TopLeft), ResizeEdge(xdg_toplevel::ResizeEdge::TopLeft),
(1, 1).into(), (1, 1),
(0, 0).into(), (0, 0),
); );
assert_eq!(size, (700, 550).into()); assert_eq!(size, (700, 550).into());
} }
@@ -406,8 +417,8 @@ mod tests {
(800, 600).into(), (800, 600).into(),
(100.0, 50.0).into(), (100.0, 50.0).into(),
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight), ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
(1, 1).into(), (1, 1),
(0, 0).into(), (0, 0),
); );
assert_eq!(size, (900, 650).into()); assert_eq!(size, (900, 650).into());
} }
@@ -418,8 +429,8 @@ mod tests {
(800, 600).into(), (800, 600).into(),
(1_000.0, 1_000.0).into(), (1_000.0, 1_000.0).into(),
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight), ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
(640, 480).into(), (640, 480),
(1_024, 768).into(), (1_024, 768),
); );
assert_eq!(size, (1_024, 768).into()); assert_eq!(size, (1_024, 768).into());
} }
@@ -437,8 +448,8 @@ mod tests {
(800, 600).into(), (800, 600).into(),
delta.into(), delta.into(),
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight), ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
minimum.into(), minimum,
maximum.into(), maximum,
); );
assert!(size.w >= 1 && size.h >= 1); assert!(size.w >= 1 && size.h >= 1);
let _ = resized_rect( let _ = resized_rect(
+19 -1
View File
@@ -11,6 +11,7 @@ mod xdg_shell;
use smithay::desktop::{PopupManager, layer_map_for_output}; use smithay::desktop::{PopupManager, layer_map_for_output};
use smithay::input::pointer::{CursorImageStatus, PointerHandle}; use smithay::input::pointer::{CursorImageStatus, PointerHandle};
use smithay::input::{Seat, SeatHandler, SeatState}; use smithay::input::{Seat, SeatHandler, SeatState};
use smithay::reexports::wayland_protocols::xdg::shell::server::{xdg_popup, xdg_positioner, xdg_surface, xdg_wm_base};
use smithay::reexports::wayland_server::Resource; use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::wayland::compositor::{TraversalAction, with_states, with_surface_tree_downward}; use smithay::wayland::compositor::{TraversalAction, with_states, with_surface_tree_downward};
@@ -30,6 +31,9 @@ use smithay::wayland::selection::data_device::{
use smithay::wayland::selection::primary_selection::{ use smithay::wayland::selection::primary_selection::{
PrimarySelectionHandler, PrimarySelectionState, set_primary_focus, PrimarySelectionHandler, PrimarySelectionState, set_primary_focus,
}; };
use smithay::wayland::shell::xdg::{
XdgPositionerUserData, XdgShellState, XdgShellSurfaceUserData, XdgSurfaceUserData, XdgWmBaseUserData,
};
use smithay::wayland::tablet_manager::TabletSeatHandler; use smithay::wayland::tablet_manager::TabletSeatHandler;
use smithay::wayland::xdg_foreign::{XdgForeignHandler, XdgForeignState}; use smithay::wayland::xdg_foreign::{XdgForeignHandler, XdgForeignState};
use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconHandler; use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconHandler;
@@ -338,7 +342,21 @@ smithay::delegate_viewporter!(Ferese);
smithay::delegate_xdg_activation!(Ferese); smithay::delegate_xdg_activation!(Ferese);
smithay::delegate_xdg_decoration!(Ferese); smithay::delegate_xdg_decoration!(Ferese);
smithay::delegate_xdg_foreign!(Ferese); smithay::delegate_xdg_foreign!(Ferese);
smithay::delegate_xdg_shell!(Ferese); smithay::reexports::wayland_server::delegate_global_dispatch!(
Ferese: [xdg_wm_base::XdgWmBase: ()] => XdgShellState
);
smithay::reexports::wayland_server::delegate_dispatch!(
Ferese: [xdg_wm_base::XdgWmBase: XdgWmBaseUserData] => XdgShellState
);
smithay::reexports::wayland_server::delegate_dispatch!(
Ferese: [xdg_positioner::XdgPositioner: XdgPositionerUserData] => XdgShellState
);
smithay::reexports::wayland_server::delegate_dispatch!(
Ferese: [xdg_popup::XdgPopup: XdgShellSurfaceUserData] => XdgShellState
);
smithay::reexports::wayland_server::delegate_dispatch!(
Ferese: [xdg_surface::XdgSurface: XdgSurfaceUserData] => XdgShellState
);
smithay::delegate_xdg_toplevel_icon!(Ferese); smithay::delegate_xdg_toplevel_icon!(Ferese);
smithay::delegate_data_control!(Ferese); smithay::delegate_data_control!(Ferese);
+5 -3
View File
@@ -54,9 +54,8 @@ impl Worker {
fn run(receiver: Receiver<Job>, results: channel::SyncSender<Encoded>) { fn run(receiver: Receiver<Job>, results: channel::SyncSender<Encoded>) {
while let Ok(job) = receiver.recv() { while let Ok(job) = receiver.recv() {
let result = encode(&job);
let request = job.request; let request = job.request;
drop(job); let result = encode(job);
// This send cannot block. Each request produces exactly one Encode, so at // This send cannot block. Each request produces exactly one Encode, so at
// most QUEUE_CAPACITY jobs can be queued while one more is being encoded, // most QUEUE_CAPACITY jobs can be queued while one more is being encoded,
// and RESULT_QUEUE_CAPACITY is asserted to exceed that. Blocking here // and RESULT_QUEUE_CAPACITY is asserted to exceed that. Blocking here
@@ -99,8 +98,10 @@ pub(crate) fn sweep_stale_files() {
} }
} }
fn encode(job: &Job) -> Result<PathBuf, String> { fn encode(job: Job) -> Result<PathBuf, String> {
let canvas = compose(&job.frames)?; let canvas = compose(&job.frames)?;
drop(job);
let png = encode_png(&canvas)?; let png = encode_png(&canvas)?;
write_private_file(&png) write_private_file(&png)
} }
@@ -114,6 +115,7 @@ fn encode_png(canvas: &Canvas) -> Result<Vec<u8>, String> {
} }
let image = image::ImageBuffer::<image::Rgba<u8>, _>::from_raw(width, height, canvas.pixels.as_slice()) let image = image::ImageBuffer::<image::Rgba<u8>, _>::from_raw(width, height, canvas.pixels.as_slice())
.ok_or("Screenshot buffer does not match its dimensions")?; .ok_or("Screenshot buffer does not match its dimensions")?;
let mut out = Vec::new(); let mut out = Vec::new();
let encoder = image::codecs::png::PngEncoder::new_with_quality( let encoder = image::codecs::png::PngEncoder::new_with_quality(
std::io::Cursor::new(&mut out), std::io::Cursor::new(&mut out),
+70 -2
View File
@@ -5,15 +5,17 @@ use smithay::desktop::{
use smithay::input::Seat; use smithay::input::Seat;
use smithay::input::pointer::{Focus, GrabStartData}; use smithay::input::pointer::{Focus, GrabStartData};
use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode; use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode;
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as ToplevelState; use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::State as ToplevelState;
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_server::protocol::wl_seat; use smithay::reexports::wayland_server::protocol::wl_seat;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, Resource, backend::ClientId};
use smithay::utils::{Logical, Point, Rectangle, Serial}; use smithay::utils::{Logical, Point, Rectangle, Serial};
use smithay::wayland::compositor::with_states; use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler; use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler;
use smithay::wayland::shell::xdg::{ use smithay::wayland::shell::xdg::{
PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState, XdgToplevelSurfaceData, PopupSurface, PositionerState, SurfaceCachedState, ToplevelSurface, XdgShellHandler, XdgShellState,
XdgShellSurfaceUserData, XdgToplevelSurfaceData,
}; };
use crate::Ferese; use crate::Ferese;
@@ -467,6 +469,72 @@ fn popup_constraint_target(
output output
} }
impl Dispatch<xdg_toplevel::XdgToplevel, XdgShellSurfaceUserData> for Ferese {
fn request(
state: &mut Self,
client: &Client,
resource: &xdg_toplevel::XdgToplevel,
request: xdg_toplevel::Request,
data: &XdgShellSurfaceUserData,
dhandle: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
let unusable = match &request {
xdg_toplevel::Request::SetMinSize { width, height } => {
!size_limits_usable(state, resource, (*width, *height), true)
}
xdg_toplevel::Request::SetMaxSize { width, height } => {
!size_limits_usable(state, resource, (*width, *height), false)
}
_ => false,
};
if unusable {
resource.post_error(xdg_toplevel::Error::InvalidSize, "size limits are unusable");
return;
}
<XdgShellState as Dispatch<xdg_toplevel::XdgToplevel, XdgShellSurfaceUserData, Ferese>>::request(
state, client, resource, request, data, dhandle, data_init,
);
}
fn destroyed(
state: &mut Self,
client: ClientId,
resource: &xdg_toplevel::XdgToplevel,
data: &XdgShellSurfaceUserData,
) {
<XdgShellState as Dispatch<xdg_toplevel::XdgToplevel, XdgShellSurfaceUserData, Ferese>>::destroyed(
state, client, resource, data,
);
}
}
fn size_limits_usable(
state: &Ferese,
toplevel: &xdg_toplevel::XdgToplevel,
proposed: (i32, i32),
minimum: bool,
) -> bool {
if proposed.0 < 0 || proposed.1 < 0 {
return false;
}
let Some(surface) = state.xdg_shell_state.get_toplevel(toplevel) else {
return true;
};
let (min, max) = with_states(surface.wl_surface(), |states| {
let mut cached = states.cached_state.get::<SurfaceCachedState>();
let pending = cached.pending();
(
(pending.min_size.w, pending.min_size.h),
(pending.max_size.w, pending.max_size.h),
)
});
let (min, max) = if minimum { (proposed, max) } else { (min, proposed) };
(max.0 == 0 || min.0 <= max.0) && (max.1 == 0 || min.1 <= max.1)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_positioner; use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_positioner;
+9
View File
@@ -535,6 +535,7 @@ impl Ferese {
| "input-capture-disable" | "input-capture-disable"
| "input-capture-release" | "input-capture-release"
| "get-session-state" | "get-session-state"
| ferese_ipc::xwayland::STATUS_COMMAND
| "portal-inhibit" | "portal-inhibit"
| "portal-monitor-register" | "portal-monitor-register"
| "portal-monitor-ack" | "portal-monitor-ack"
@@ -576,6 +577,14 @@ impl Ferese {
"get-session-state" => { "get-session-state" => {
return Ok(self.portal_session.snapshot()); return Ok(self.portal_session.snapshot());
} }
ferese_ipc::xwayland::STATUS_COMMAND => {
return Ok(crate::xwayland::status_snapshot(self).to_value());
}
ferese_ipc::xwayland::RETRY_COMMAND => {
crate::xwayland::retry(self).map_err(|error| CommandError::new("x11_unavailable", error))?;
return Ok(crate::xwayland::status_snapshot(self).to_value());
}
"portal-monitor-register" => { "portal-monitor-register" => {
self.portal_session self.portal_session
.register_monitor(owner) .register_monitor(owner)
+8
View File
@@ -32,6 +32,7 @@ mod render;
mod resize_metrics; mod resize_metrics;
mod resize_transaction; mod resize_transaction;
mod resume; mod resume;
mod session_environment;
mod session_lock; mod session_lock;
mod shell_control; mod shell_control;
mod stacking; mod stacking;
@@ -42,6 +43,9 @@ mod theme;
mod wallpaper; mod wallpaper;
mod window_rules; mod window_rules;
mod winit; mod winit;
mod xwayland;
pub(crate) use session_environment::{SessionEnvironment, X11Environment};
use std::error::Error; use std::error::Error;
use std::io; use std::io;
@@ -109,6 +113,8 @@ fn main() -> Result<(), Box<dyn Error>> {
overview::init_font_loader(&mut event_loop, &mut state)?; overview::init_font_loader(&mut event_loop, &mut state)?;
backends::init(launch.backend, &mut event_loop, &mut state)?; backends::init(launch.backend, &mut event_loop, &mut state)?;
xwayland::initialize(&mut state);
info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients");
let child = spawn_client(&mut state, launch.client, launch.client_capabilities) let child = spawn_client(&mut state, launch.client, launch.client_capabilities)
@@ -131,6 +137,8 @@ fn main() -> Result<(), Box<dyn Error>> {
terminate_child(&mut child); terminate_child(&mut child);
} }
xwayland::shutdown_after_loop(&mut state);
result.map_err(Into::into) result.map_err(Into::into)
} }
+7 -3
View File
@@ -52,9 +52,8 @@ pub(crate) fn spawn_client<S: AsRef<OsStr>>(
let mut command = command(program); let mut command = command(program);
command.args(args); command.args(args);
// Workers may already exist. Change only this child's environment. // Workers may already exist. Change only this child's environment.
command.env("WAYLAND_DISPLAY", &state.socket_name); // Apply before granting the child a private Wayland connection.
command.env_remove("WAYLAND_SOCKET"); state.session_environment.apply_public(&mut command);
command.env_remove("FERESE_SHELL_CONTROL_SOCKET");
command.env("FERESE_COMPOSITOR_WALLPAPER", "1"); command.env("FERESE_COMPOSITOR_WALLPAPER", "1");
let private_connection = if capabilities.is_empty() { let private_connection = if capabilities.is_empty() {
None None
@@ -83,6 +82,11 @@ pub(crate) fn spawn_client<S: AsRef<OsStr>>(
} }
pub(crate) fn pidfd(pid: u32) -> io::Result<OwnedFd> { pub(crate) fn pidfd(pid: u32) -> io::Result<OwnedFd> {
#[cfg(test)]
if crate::xwayland::test_hooks::PIDFD_UNAVAILABLE.load(std::sync::atomic::Ordering::SeqCst) {
return Err(io::Error::from_raw_os_error(libc::ENOSYS));
}
// SAFETY: pidfd_open has no pointer arguments and returns a new owned fd. // SAFETY: pidfd_open has no pointer arguments and returns a new owned fd.
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
if fd < 0 { if fd < 0 {
+52
View File
@@ -513,6 +513,58 @@ mod tests {
assert!(!state.inactive_dim.enabled); assert!(!state.inactive_dim.enabled);
} }
/// The status endpoint must be able to tell "the live service is running
/// with the settings it started with" from "the file was edited", so the
/// desired X11 configuration has to follow live reloads.
#[test]
fn xwayland_configuration_follows_live_reloads() {
const CHILD: &str = "FERESE_XWAYLAND_RELOAD_TEST_CHILD";
if std::env::var_os(CHILD).is_none() {
use std::os::unix::fs::PermissionsExt;
let directory = Directory::new();
std::fs::set_permissions(&directory.0, std::fs::Permissions::from_mode(0o700)).unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"reload::tests::xwayland_configuration_follows_live_reloads",
"--nocapture",
])
.env(CHILD, "1")
.env("XDG_RUNTIME_DIR", &directory.0)
.env("XDG_CONFIG_HOME", &directory.0)
.env_remove("FERESE_SOCKET")
.output()
.unwrap();
assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr));
return;
}
let directory = Directory::new();
let (_, runtime, _) = crate::theme::prepare("xwayland { enabled #false; }", &directory.0).unwrap();
let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let display = smithay::reexports::wayland_server::Display::new().unwrap();
let mut state = crate::Ferese::new(&mut event_loop, display, runtime).unwrap();
assert!(
!state.xwayland_config.enabled,
"the session starts with the configured value"
);
state
.reload_config_source("xwayland { enabled #true; startup \"eager\"; }".into())
.unwrap();
assert!(
state.xwayland_config.enabled,
"a reload must update the desired configuration, or restart_required can never become true"
);
assert_eq!(
state.xwayland_config.startup,
crate::config::XwaylandStartup::Eager,
"every field follows the reload, not just the enabled flag"
);
}
#[test] #[test]
fn invalid_monitor_reload_keeps_last_good_runtime_configuration() { fn invalid_monitor_reload_keeps_last_good_runtime_configuration() {
const CHILD: &str = "FERESE_OUTPUT_RELOAD_TEST_CHILD"; const CHILD: &str = "FERESE_OUTPUT_RELOAD_TEST_CHILD";
+138
View File
@@ -0,0 +1,138 @@
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Command;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct X11Environment {
pub display: OsString,
pub authority: PathBuf,
}
// Child endpoints are separate from the host endpoints used by the nested backend.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct SessionEnvironment {
pub wayland_display: OsString,
pub x11: Option<X11Environment>,
}
impl SessionEnvironment {
/// Apply before private_client::prepare_command(), which overrides WAYLAND_SOCKET.
pub fn apply_public(&self, command: &mut Command) {
command
.env("WAYLAND_DISPLAY", &self.wayland_display)
.env("FERESE_PUBLIC_WAYLAND_DISPLAY", &self.wayland_display)
.env_remove("WAYLAND_SOCKET")
.env_remove("FERESE_SHELL_CONTROL_SOCKET")
.env_remove("DISPLAY")
.env_remove("XAUTHORITY");
if let Some(x11) = &self.x11 {
command.env("DISPLAY", &x11.display).env("XAUTHORITY", &x11.authority);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn environment(x11: Option<X11Environment>) -> SessionEnvironment {
SessionEnvironment {
wayland_display: OsString::from("wayland-7"),
x11,
}
}
fn rendered(command: &Command) -> Vec<(String, Option<String>)> {
command
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().into_owned(),
value.map(|value| value.to_string_lossy().into_owned()),
)
})
.collect()
}
fn value_of(command: &Command, key: &str) -> Option<Option<String>> {
rendered(command)
.into_iter()
.find(|(name, _)| name == key)
.map(|(_, value)| value)
}
#[test]
fn x11_available_publishes_display_and_authority() {
let mut command = Command::new("true");
environment(Some(X11Environment {
display: OsString::from(":7"),
authority: PathBuf::from("/run/user/1000/ferese-xauth-abc"),
}))
.apply_public(&mut command);
assert_eq!(value_of(&command, "DISPLAY"), Some(Some(":7".to_owned())));
assert_eq!(
value_of(&command, "XAUTHORITY"),
Some(Some("/run/user/1000/ferese-xauth-abc".to_owned()))
);
assert_eq!(
value_of(&command, "WAYLAND_DISPLAY"),
Some(Some("wayland-7".to_owned()))
);
}
#[test]
fn x11_unavailable_removes_display_and_authority() {
let mut command = Command::new("true");
environment(None).apply_public(&mut command);
assert_eq!(value_of(&command, "DISPLAY"), Some(None));
assert_eq!(value_of(&command, "XAUTHORITY"), Some(None));
}
#[test]
fn the_host_private_socket_is_always_cleared() {
let mut command = Command::new("true");
environment(None).apply_public(&mut command);
assert_eq!(value_of(&command, "WAYLAND_SOCKET"), Some(None));
assert_eq!(value_of(&command, "FERESE_SHELL_CONTROL_SOCKET"), Some(None));
}
#[test]
fn the_public_socket_is_exported_for_shell_launched_apps() {
let mut command = Command::new("true");
environment(None).apply_public(&mut command);
assert_eq!(
value_of(&command, "FERESE_PUBLIC_WAYLAND_DISPLAY"),
Some(Some("wayland-7".to_owned()))
);
}
#[test]
fn unrelated_variables_are_left_alone() {
let mut command = Command::new("true");
command.env("FERESE_COMPOSITOR_WALLPAPER", "1");
environment(None).apply_public(&mut command);
assert_eq!(
value_of(&command, "FERESE_COMPOSITOR_WALLPAPER"),
Some(Some("1".to_owned()))
);
}
#[test]
fn a_host_display_is_replaced_by_the_session_display() {
let mut command = Command::new("true");
command.env("DISPLAY", ":0");
environment(Some(X11Environment {
display: OsString::from(":7"),
authority: PathBuf::from("/run/user/1000/auth"),
}))
.apply_public(&mut command);
assert_eq!(value_of(&command, "DISPLAY"), Some(Some(":7".to_owned())));
}
}
+22
View File
@@ -402,6 +402,14 @@ pub struct Ferese {
pub(crate) theme_engine: crate::theme::Engine, pub(crate) theme_engine: crate::theme::Engine,
pub start_time: Instant, pub start_time: Instant,
pub socket_name: OsString, pub socket_name: OsString,
pub(crate) session_environment: crate::SessionEnvironment,
pub(crate) xwayland: Option<crate::xwayland::XwaylandManager>,
pub(crate) xwayland_config: crate::config::XwaylandConfig,
pub(crate) x11_diagnostic: Option<String>,
pub display_handle: DisplayHandle, pub display_handle: DisplayHandle,
pub loop_signal: LoopSignal, pub loop_signal: LoopSignal,
pub(crate) loop_handle: LoopHandle<'static, Self>, pub(crate) loop_handle: LoopHandle<'static, Self>,
@@ -552,6 +560,7 @@ pub struct RuntimeConfig {
pub spring_config: SpringConfig, pub spring_config: SpringConfig,
pub viewport_spring_config: SpringConfig, pub viewport_spring_config: SpringConfig,
pub output_profiles: Vec<OutputProfile>, pub output_profiles: Vec<OutputProfile>,
pub(crate) xwayland: crate::config::XwaylandConfig,
} }
impl Ferese { impl Ferese {
@@ -643,7 +652,15 @@ impl Ferese {
config_worker: None, config_worker: None,
theme_engine: Default::default(), theme_engine: Default::default(),
start_time, start_time,
session_environment: crate::SessionEnvironment {
wayland_display: socket_name.clone(),
x11: None,
},
socket_name, socket_name,
xwayland: None,
xwayland_config: config.xwayland.clone(),
x11_diagnostic: None,
display_handle, display_handle,
loop_signal: event_loop.get_signal(), loop_signal: event_loop.get_signal(),
loop_handle: event_loop.handle(), loop_handle: event_loop.handle(),
@@ -793,6 +810,11 @@ impl Ferese {
.as_ref() .as_ref()
.is_none_or(|previous| keys.iter().any(|key| previous.get(*key) != sections.get(*key))) .is_none_or(|previous| keys.iter().any(|key| previous.get(*key) != sections.get(*key)))
}; };
// Desired configuration: reloads update it while endpoints stay
// session-scoped, so a difference from the running service is reported
// as `restart_required` rather than applied behind the user's back.
self.xwayland_config = config.xwayland.clone();
let layout_changed = changed(&["layout", "scrolling", "workspaces"]); let layout_changed = changed(&["layout", "scrolling", "workspaces"]);
let bindings_changed = changed(&["bindings", "commands"]); let bindings_changed = changed(&["bindings", "commands"]);
let input_changed = changed(&["input"]); let input_changed = changed(&["input"]);
+11 -6
View File
@@ -300,17 +300,22 @@ impl Ferese {
let constraints = window let constraints = window
.toplevel() .toplevel()
.map(|top| { .map(|top| {
with_states(top.wl_surface(), |states| { let (minimum, maximum) = with_states(top.wl_surface(), |states| {
let mut cache = states.cached_state.get::<SurfaceCachedState>(); let mut cache = states.cached_state.get::<SurfaceCachedState>();
let state = cache.current(); let state = cache.current();
(
(state.min_size.w, state.min_size.h),
(state.max_size.w, state.max_size.h),
)
});
let (minimum, maximum) = self.effective_size_constraints(window, minimum, maximum);
SizeConstraints { SizeConstraints {
min_width: state.min_size.w.max(1) as f64, min_width: minimum.0.max(1) as f64,
min_height: state.min_size.h.max(1) as f64, min_height: minimum.1.max(1) as f64,
max_width: (state.max_size.w > 0).then_some(state.max_size.w as f64), max_width: (maximum.0 > 0).then_some(maximum.0 as f64),
max_height: (state.max_size.h > 0).then_some(state.max_size.h as f64), max_height: (maximum.1 > 0).then_some(maximum.1 as f64),
} }
}) })
})
.unwrap_or_default(); .unwrap_or_default();
let stored_size = if parent_rect.is_none() { let stored_size = if parent_rect.is_none() {
remembered.map(|r| (r.width, r.height)).unwrap_or(size) remembered.map(|r| (r.width, r.height)).unwrap_or(size)
+9 -4
View File
@@ -574,14 +574,19 @@ impl Ferese {
let state = cached.current(); let state = cached.current();
(state.min_size, state.max_size) (state.min_size, state.max_size)
}); });
let (minimum, maximum) = self.effective_size_constraints(
window,
(minimum.w, minimum.h),
(maximum.w, maximum.h),
);
Some(( Some((
*id, *id,
SizeConstraints { SizeConstraints {
min_width: minimum.w.max(1) as f64, min_width: minimum.0.max(1) as f64,
min_height: minimum.h.max(1) as f64, min_height: minimum.1.max(1) as f64,
max_width: (maximum.w > 0).then_some(maximum.w as f64), max_width: (maximum.0 > 0).then_some(maximum.0 as f64),
max_height: (maximum.h > 0).then_some(maximum.h as f64), max_height: (maximum.1 > 0).then_some(maximum.1 as f64),
}, },
)) ))
}) })
@@ -463,3 +463,41 @@ fn malformed_resize_limits_disconnect_only_the_client_and_motion_survives() {
let (healthy, _wire) = window(&mut state, &mut events, 0xff112233); let (healthy, _wire) = window(&mut state, &mut events, 0xff112233);
assert!(state.windows.ids().contains_key(&healthy)); assert!(state.windows.ids().contains_key(&healthy));
} }
#[test]
fn window_rule_minimum_size_replaces_the_client_advertised_minimum() {
if !crate::startup_tests::private_runtime(
"state::window_lifecycle_tests::window_rule_minimum_size_replaces_the_client_advertised_minimum",
) {
return;
}
let (mut events, mut state, _) = fixture();
let (window, mut wire) = window(&mut state, &mut events, 0xff112233);
let id = state.windows.ids()[&window];
request(&mut wire, 4, 8, &[800, 600], None);
request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert_eq!(state.effective_size_constraints(&window, (800, 600), (0,0)).0, (800, 600));
assert_eq!(state.window_constraints()[&id].min_width, 800.0);
assert_eq!(state.window_constraints()[&id].min_height, 600.0);
state.window_rules =
crate::config::Config::parse_source("window-rule transient=#false min-width=500 min-height=400\n")
.unwrap()
.window_rules()
.unwrap();
assert_eq!(state.effective_size_constraints(&window, (800, 600), (0,0)).0, (500, 400));
let constraints = state.window_constraints();
assert_eq!(constraints[&id].min_width, 500.0);
assert_eq!(constraints[&id].min_height, 400.0);
state.window_rules = crate::config::Config::parse_source("window-rule transient=#false min-height=400\n")
.unwrap()
.window_rules()
.unwrap();
assert_eq!(state.effective_size_constraints(&window, (800, 600), (0,0)).0, (800, 400));
state.window_rules.clear();
assert_eq!(state.effective_size_constraints(&window, (800, 600), (0,0)).0, (800, 600));
}
+38
View File
@@ -261,8 +261,42 @@ impl Ferese {
self.apply_window_rule_result(window, rule); self.apply_window_rule_result(window, rule);
} }
pub(crate) fn effective_size_constraints(
&self,
window: &Window,
minimum: (i32, i32),
maximum: (i32, i32),
) -> ((i32, i32), (i32, i32)) {
let Some(toplevel) = window.toplevel() else {
return (minimum, maximum);
};
let rules = &self.window_rules;
with_states(toplevel.wl_surface(), |states| {
let attributes = states
.data_map
.get::<XdgToplevelSurfaceData>()
.expect("xdg toplevel state exists")
.lock()
.expect("xdg toplevel state is not poisoned");
let rule = resolve_window_rules(
rules,
attributes.app_id.as_deref(),
attributes.title.as_deref(),
attributes.parent.is_some(),
);
(
(
rule.min_width.map_or(minimum.0, |width| width.ceil() as i32),
rule.min_height.map_or(minimum.1, |height| height.ceil() as i32),
),
maximum,
)
})
}
pub(super) fn reapply_window_rules(&mut self, old_rules: &[WindowRule]) { pub(super) fn reapply_window_rules(&mut self, old_rules: &[WindowRule]) {
let windows = self.windows.ids().keys().cloned().collect::<Vec<_>>(); let windows = self.windows.ids().keys().cloned().collect::<Vec<_>>();
let mut minimums_changed = false;
for window in windows { for window in windows {
let Some(toplevel) = window.toplevel() else { let Some(toplevel) = window.toplevel() else {
continue; continue;
@@ -278,10 +312,14 @@ impl Ferese {
}); });
let old = resolve_window_rules(old_rules, app_id.as_deref(), title.as_deref(), transient); let old = resolve_window_rules(old_rules, app_id.as_deref(), title.as_deref(), transient);
let new = resolve_window_rules(&self.window_rules, app_id.as_deref(), title.as_deref(), transient); let new = resolve_window_rules(&self.window_rules, app_id.as_deref(), title.as_deref(), transient);
minimums_changed |= old.min_width != new.min_width || old.min_height != new.min_height;
if let Some(new) = crate::window_rules::live_result(old, new, transient) { if let Some(new) = crate::window_rules::live_result(old, new, transient) {
self.apply_window_rule_result(&window, new); self.apply_window_rule_result(&window, new);
} }
} }
if minimums_changed {
self.relayout();
}
} }
pub(super) fn apply_window_rule_result(&mut self, window: &Window, rule: crate::window_rules::WindowRuleResult) { pub(super) fn apply_window_rule_result(&mut self, window: &Window, rule: crate::window_rules::WindowRuleResult) {
+103 -21
View File
@@ -9,6 +9,8 @@ pub(crate) struct WindowRuleConfig {
pub floating: Option<bool>, pub floating: Option<bool>,
pub width: Option<f64>, pub width: Option<f64>,
pub height: Option<f64>, pub height: Option<f64>,
pub min_width: Option<f64>,
pub min_height: Option<f64>,
pub fullscreen: Option<bool>, pub fullscreen: Option<bool>,
pub block_out_from_screencasts: Option<bool>, pub block_out_from_screencasts: Option<bool>,
pub idle_inhibit: Option<crate::idle_inhibition::Mode>, pub idle_inhibit: Option<crate::idle_inhibition::Mode>,
@@ -23,6 +25,8 @@ pub struct WindowRule {
floating: Option<bool>, floating: Option<bool>,
width: Option<f64>, width: Option<f64>,
height: Option<f64>, height: Option<f64>,
min_width: Option<f64>,
min_height: Option<f64>,
fullscreen: Option<bool>, fullscreen: Option<bool>,
block_out_from_screencasts: Option<bool>, block_out_from_screencasts: Option<bool>,
idle_inhibit: Option<crate::idle_inhibition::Mode>, idle_inhibit: Option<crate::idle_inhibition::Mode>,
@@ -40,6 +44,8 @@ pub struct WindowRuleResult {
pub floating: Option<bool>, pub floating: Option<bool>,
pub width: Option<f64>, pub width: Option<f64>,
pub height: Option<f64>, pub height: Option<f64>,
pub min_width: Option<f64>,
pub min_height: Option<f64>,
pub fullscreen: Option<bool>, pub fullscreen: Option<bool>,
pub block_out_from_screencasts: Option<bool>, pub block_out_from_screencasts: Option<bool>,
pub idle_inhibit: Option<crate::idle_inhibition::Mode>, pub idle_inhibit: Option<crate::idle_inhibition::Mode>,
@@ -56,6 +62,10 @@ pub(crate) fn live_result(
new.block_out_from_screencasts = None; new.block_out_from_screencasts = None;
old.idle_inhibit = None; old.idle_inhibit = None;
new.idle_inhibit = None; new.idle_inhibit = None;
old.min_width = None;
old.min_height = None;
new.min_width = None;
new.min_height = None;
if old == new { if old == new {
return None; return None;
} }
@@ -106,6 +116,8 @@ pub fn resolve(rules: &[WindowRule], app_id: Option<&str>, title: Option<&str>,
result.floating = rule.floating.or(result.floating); result.floating = rule.floating.or(result.floating);
result.width = rule.width.or(result.width); result.width = rule.width.or(result.width);
result.height = rule.height.or(result.height); result.height = rule.height.or(result.height);
result.min_width = rule.min_width.or(result.min_width);
result.min_height = rule.min_height.or(result.min_height);
result.fullscreen = rule.fullscreen.or(result.fullscreen); result.fullscreen = rule.fullscreen.or(result.fullscreen);
result.block_out_from_screencasts = rule.block_out_from_screencasts.or(result.block_out_from_screencasts); result.block_out_from_screencasts = rule.block_out_from_screencasts.or(result.block_out_from_screencasts);
result.idle_inhibit = rule.idle_inhibit.or(result.idle_inhibit); result.idle_inhibit = rule.idle_inhibit.or(result.idle_inhibit);
@@ -138,6 +150,8 @@ fn validate_rule(index: usize, rule: &WindowRuleConfig) -> Result<WindowRule, St
.map(str::to_owned); .map(str::to_owned);
let width = positive_dimension(rule.width, index, "width")?; let width = positive_dimension(rule.width, index, "width")?;
let height = positive_dimension(rule.height, index, "height")?; let height = positive_dimension(rule.height, index, "height")?;
let min_width = positive_dimension(rule.min_width, index, "min-width")?;
let min_height = positive_dimension(rule.min_height, index, "min-height")?;
Ok(WindowRule { Ok(WindowRule {
app_id, app_id,
@@ -147,6 +161,8 @@ fn validate_rule(index: usize, rule: &WindowRuleConfig) -> Result<WindowRule, St
floating: rule.floating, floating: rule.floating,
width, width,
height, height,
min_width,
min_height,
fullscreen: rule.fullscreen, fullscreen: rule.fullscreen,
block_out_from_screencasts: rule.block_out_from_screencasts, block_out_from_screencasts: rule.block_out_from_screencasts,
idle_inhibit: rule.idle_inhibit, idle_inhibit: rule.idle_inhibit,
@@ -194,27 +210,6 @@ pub(crate) fn normalize_app_id(value: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn live_rule_removal_reverts_placement_but_unchanged_rules_preserve_manual_state() {
let old = super::WindowRuleResult {
floating: Some(true),
fullscreen: Some(true),
..Default::default()
};
assert_eq!(super::live_result(old, old, false), None);
let removed = super::live_result(old, Default::default(), false).unwrap();
assert_eq!(removed.floating, Some(false));
assert_eq!(removed.fullscreen, Some(false));
assert_eq!(
super::live_result(old, Default::default(), true).unwrap().floating,
Some(true)
);
let new = super::WindowRuleResult {
width: Some(800.),
..Default::default()
};
assert_eq!(super::live_result(old, new, false).unwrap().floating, None);
}
use super::*; use super::*;
fn config(app_id: &str) -> WindowRuleConfig { fn config(app_id: &str) -> WindowRuleConfig {
@@ -226,6 +221,8 @@ mod tests {
floating: None, floating: None,
width: None, width: None,
height: None, height: None,
min_width: None,
min_height: None,
fullscreen: None, fullscreen: None,
block_out_from_screencasts: None, block_out_from_screencasts: None,
idle_inhibit: None, idle_inhibit: None,
@@ -358,6 +355,8 @@ mod tests {
floating: Some(true), floating: Some(true),
width: None, width: None,
height: None, height: None,
min_width: None,
min_height: None,
fullscreen: None, fullscreen: None,
block_out_from_screencasts: None, block_out_from_screencasts: None,
idle_inhibit: None, idle_inhibit: None,
@@ -366,8 +365,91 @@ mod tests {
width: Some(f64::NAN), width: Some(f64::NAN),
..config("editor") ..config("editor")
}; };
let invalid_minimum = WindowRuleConfig {
min_width: Some(0.0),
..config("editor")
};
assert!(validate(&[catch_all]).is_err()); assert!(validate(&[catch_all]).is_err());
assert!(validate(&[invalid_size]).is_err()); assert!(validate(&[invalid_size]).is_err());
assert!(validate(&[invalid_minimum]).is_err());
}
#[test]
fn minimum_size_overrides_are_per_axis_and_later_rules_win() {
let rules = validate(&[
WindowRuleConfig {
min_width: Some(800.0),
min_height: Some(600.0),
..config("spotify")
},
WindowRuleConfig {
min_width: Some(500.0),
..config("spotify")
},
])
.unwrap();
let result = resolve(&rules, Some("spotify"), None, false);
assert_eq!(result.min_width, Some(500.0));
assert_eq!(result.min_height, Some(600.0));
let single = validate(&[WindowRuleConfig {
min_height: Some(400.0),
..config("spotify")
}])
.unwrap();
let result = resolve(&single, Some("spotify"), None, false);
assert_eq!(result.min_width, None);
assert_eq!(result.min_height, Some(400.0));
assert_eq!(resolve(&[], Some("spotify"), None, false).min_width, None);
}
#[test]
fn live_rule_removal_reverts_placement_but_unchanged_rules_preserve_manual_state() {
let old = super::WindowRuleResult {
floating: Some(true),
fullscreen: Some(true),
..Default::default()
};
assert_eq!(super::live_result(old, old, false), None);
let removed = super::live_result(old, Default::default(), false).unwrap();
assert_eq!(removed.floating, Some(false));
assert_eq!(removed.fullscreen, Some(false));
assert_eq!(
super::live_result(old, Default::default(), true).unwrap().floating,
Some(true)
);
let new = super::WindowRuleResult {
width: Some(800.),
..Default::default()
};
assert_eq!(super::live_result(old, new, false).unwrap().floating, None);
}
#[test]
fn minimum_size_changes_are_constraint_policy_not_placement_work() {
let old = super::WindowRuleResult {
min_width: Some(500.),
min_height: Some(400.),
..Default::default()
};
assert_eq!(super::live_result(old, Default::default(), false), None);
let raised = super::WindowRuleResult {
min_width: Some(600.),
min_height: Some(400.),
..Default::default()
};
assert_eq!(super::live_result(old, raised, false), None);
assert_eq!(super::live_result(old, old, false), None);
let with_placement = super::WindowRuleResult {
floating: Some(true),
min_width: Some(700.),
..Default::default()
};
let applied = super::live_result(old, with_placement, false).unwrap();
assert_eq!(applied.floating, Some(true));
assert_eq!(applied.min_width, None);
} }
} }
+12 -13
View File
@@ -107,7 +107,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
.current_monitor() .current_monitor()
.and_then(|monitor| monitor.refresh_rate_millihertz()) .and_then(|monitor| monitor.refresh_rate_millihertz())
.unwrap_or(60_000) as i32; .unwrap_or(60_000) as i32;
if !resize_nested_output(state, &output, &drawable, size, scale_factor, rate) { if !resize_nested_output(state, &output, &drawable, (size.w, size.h), scale_factor, rate) {
return; return;
} }
@@ -249,19 +249,23 @@ fn configure_nested_protocols(state: &mut Ferese) {
.disable_global::<Ferese>(state.presentation_state.global()); .disable_global::<Ferese>(state.presentation_state.global());
} }
fn usable_dimensions(width: i32, height: i32) -> bool {
width > 0 && height > 0
}
fn usable_size(size: Size<i32, Physical>) -> bool { fn usable_size(size: Size<i32, Physical>) -> bool {
size.w > 0 && size.h > 0 usable_dimensions(size.w, size.h)
} }
fn resize_nested_output( fn resize_nested_output(
state: &mut Ferese, state: &mut Ferese,
output: &Output, output: &Output,
drawable: &Cell<bool>, drawable: &Cell<bool>,
size: Size<i32, Physical>, size: (i32, i32),
scale_factor: f64, scale_factor: f64,
rate: i32, rate: i32,
) -> bool { ) -> bool {
if !usable_size(size) { if !usable_dimensions(size.0, size.1) {
drawable.set(false); drawable.set(false);
return false; return false;
} }
@@ -272,7 +276,7 @@ fn resize_nested_output(
return false; return false;
}; };
resized.mode = Mode { resized.mode = Mode {
size, size: (size.0, size.1).into(),
refresh: rate.max(1), refresh: rate.max(1),
}; };
resized.scale = Scale::Fractional(normalized_scale(scale_factor)); resized.scale = Scale::Fractional(normalized_scale(scale_factor));
@@ -372,19 +376,14 @@ mod tests {
&mut state, &mut state,
&output, &output,
&drawable, &drawable,
(900, 600).into(), (900, 600),
1.25, 1.25,
60_000 60_000
)); ));
let before = output.current_mode(); let before = output.current_mode();
for size in [(0, 600), (900, 0), (0, 0), (-1, 600)] { for size in [(0, 600), (900, 0), (0, 0), (-1, 600)] {
assert!(!resize_nested_output( assert!(!resize_nested_output(
&mut state, &mut state, &output, &drawable, size, 2.0, 144_000
&output,
&drawable,
size.into(),
2.0,
144_000
)); ));
assert_eq!(output.current_mode(), before); assert_eq!(output.current_mode(), before);
assert_eq!(output.current_scale().fractional_scale(), 1.25); assert_eq!(output.current_scale().fractional_scale(), 1.25);
@@ -396,7 +395,7 @@ mod tests {
&mut state, &mut state,
&output, &output,
&drawable, &drawable,
(1200, 800).into(), (1200, 800),
1.5, 1.5,
120_000 120_000
)); ));
+223
View File
@@ -0,0 +1,223 @@
use std::fs;
use std::io::{self, Write};
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::Path;
use tempfile::NamedTempFile;
// The cookie authenticates the server; it does not isolate X11 clients from each other.
/// Deletion has one owner: the `NamedTempFile` removes the cookie file when
/// this value drops, so nothing here unlinks the path a second time.
#[derive(Debug)]
pub(crate) struct AuthorityFile {
file: NamedTempFile,
}
fn random_cookie() -> io::Result<[u8; 16]> {
let mut cookie = [0_u8; 16];
let mut filled = 0;
while filled < cookie.len() {
let count = unsafe { libc::getrandom(cookie[filled..].as_mut_ptr().cast(), cookie.len() - filled, 0) };
if count < 0 {
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(error);
}
if count == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "getrandom returned zero"));
}
filled += count as usize;
}
Ok(cookie)
}
fn hostname() -> io::Result<Vec<u8>> {
let mut buffer = [0_u8; 256];
if unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) } != 0 {
return Err(io::Error::last_os_error());
}
let end = buffer
.iter()
.position(|byte| *byte == 0)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "hostname is not terminated"))?;
Ok(buffer[..end].to_vec())
}
fn field(output: &mut impl Write, bytes: &[u8]) -> io::Result<()> {
let length = u16::try_from(bytes.len())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Xauthority field is too long"))?;
output.write_all(&length.to_be_bytes())?;
output.write_all(bytes)
}
impl AuthorityFile {
pub fn create(runtime_directory: &Path, display: u32) -> io::Result<Self> {
let metadata = fs::symlink_metadata(runtime_directory)?;
let uid = unsafe { libc::geteuid() };
if !metadata.is_dir()
|| metadata.file_type().is_symlink()
|| metadata.uid() != uid
|| metadata.mode() & 0o077 != 0
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"XDG_RUNTIME_DIR must be a private directory owned by this user",
));
}
let mut file = tempfile::Builder::new()
.prefix("ferese-xauth-")
.tempfile_in(runtime_directory)?;
file.as_file().set_permissions(fs::Permissions::from_mode(0o600))?;
let cookie = random_cookie()?;
let host = hostname()?;
let number = display.to_string();
let output = file.as_file_mut();
output.write_all(&256_u16.to_be_bytes())?; // FamilyLocal
field(output, &host)?;
field(output, number.as_bytes())?;
field(output, b"MIT-MAGIC-COOKIE-1")?;
field(output, &cookie)?;
output.flush()?;
Ok(Self { file })
}
pub fn path(&self) -> &Path {
self.file.path()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn private_runtime_dir(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("ferese-xauth-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap();
root
}
fn read_record(path: &Path) -> (u16, String, String, String, Vec<u8>) {
let bytes = fs::read(path).unwrap();
let mut offset = 0;
let family = u16::from_be_bytes([bytes[offset], bytes[offset + 1]]);
offset += 2;
let take = &|offset: &mut usize| -> Vec<u8> {
let length = u16::from_be_bytes([bytes[*offset], bytes[*offset + 1]]) as usize;
*offset += 2;
let value = bytes[*offset..*offset + length].to_vec();
*offset += length;
value
};
let host = String::from_utf8(take(&mut offset)).unwrap();
let display = String::from_utf8(take(&mut offset)).unwrap();
let name = String::from_utf8(take(&mut offset)).unwrap();
let cookie = take(&mut offset);
assert_eq!(offset, bytes.len(), "trailing bytes in authority file");
(family, host, display, name, cookie)
}
#[test]
fn writes_a_family_local_mit_cookie_record() {
let runtime = private_runtime_dir("record");
let file = AuthorityFile::create(&runtime, 7).unwrap();
let (family, host, display, name, cookie) = read_record(file.path());
assert_eq!(family, 256, "FamilyLocal");
assert!(!host.is_empty(), "hostname must be recorded");
assert_eq!(display, "7");
assert_eq!(name, "MIT-MAGIC-COOKIE-1");
assert_eq!(cookie.len(), 16);
}
#[test]
fn the_cookie_is_fresh_for_each_display() {
let runtime = private_runtime_dir("fresh");
let first = AuthorityFile::create(&runtime, 1).unwrap();
let second = AuthorityFile::create(&runtime, 2).unwrap();
assert_ne!(first.path(), second.path());
assert_ne!(read_record(first.path()).4, read_record(second.path()).4);
}
#[test]
fn the_file_is_private_and_removed_on_drop() {
let runtime = private_runtime_dir("private");
let path = {
let file = AuthorityFile::create(&runtime, 3).unwrap();
let path = file.path().to_owned();
let mode = fs::metadata(&path).unwrap().mode() & 0o777;
assert_eq!(mode, 0o600, "authority file must not be group/world readable");
path
};
assert!(!path.exists(), "authority file must be removed on drop");
}
#[test]
fn a_group_readable_runtime_directory_is_rejected() {
let runtime = std::env::temp_dir().join(format!("ferese-xauth-loose-{}", std::process::id()));
let _ = fs::remove_dir_all(&runtime);
fs::create_dir_all(&runtime).unwrap();
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o755)).unwrap();
let error = AuthorityFile::create(&runtime, 1).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
}
#[test]
fn a_missing_runtime_directory_is_rejected() {
let error = AuthorityFile::create(Path::new("/nonexistent/ferese-runtime"), 1).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
#[test]
fn the_record_is_decodable_by_libxau_via_xauth() {
let runtime = private_runtime_dir("libxau");
let file = AuthorityFile::create(&runtime, 91).unwrap();
let output = std::process::Command::new("xauth")
.args(["-f", file.path().to_str().unwrap(), "list"])
.output()
.expect("xauth must be available to verify the authority file");
assert!(
output.status.success(),
"xauth rejected the authority file: {}",
String::from_utf8_lossy(&output.stderr)
);
let listing = String::from_utf8_lossy(&output.stdout);
let entry = listing
.lines()
.find(|line| line.contains("MIT-MAGIC-COOKIE-1"))
.unwrap_or_else(|| panic!("xauth did not list the cookie record: {listing:?}"));
assert!(entry.contains(":91"), "entry must reference the display: {entry:?}");
let cookie_hex = entry.split_whitespace().last().expect("cookie field");
assert_eq!(cookie_hex.len(), 32, "16-byte cookie as hex: {cookie_hex}");
assert!(cookie_hex.chars().any(|c| c != '0'), "cookie must not be zero");
}
#[test]
fn another_user_cannot_read_the_authority_file() {
let runtime = private_runtime_dir("cross-user");
let file = AuthorityFile::create(&runtime, 92).unwrap();
let mode = fs::metadata(file.path()).unwrap().mode() & 0o777;
assert_eq!(mode, 0o600);
assert_eq!(fs::metadata(&runtime).unwrap().mode() & 0o777, 0o700);
}
}
+571
View File
@@ -0,0 +1,571 @@
use std::io;
use std::os::fd::AsRawFd;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Child, Stdio};
use crate::session_environment::SessionEnvironment;
use super::sockets::Reservation;
#[cfg(test)]
pub(crate) mod test_hook {
use std::cell::{Cell, RefCell};
type InChild = Box<dyn FnMut() -> std::io::Result<()> + Send + Sync>;
thread_local! {
static IN_CHILD: RefCell<Option<InChild>> = const { RefCell::new(None) };
/// Group signals this thread's owners actually sent.
///
/// Kept per thread so parallel tests cannot inflate it: an owner whose
/// leader was reaped must send none, and a test that measures before
/// and after a shutdown is measuring that owner alone.
static GROUP_SIGNALS: Cell<usize> = const { Cell::new(0) };
}
pub(crate) fn record_group_signal() {
GROUP_SIGNALS.with(|count| count.set(count.get() + 1));
}
pub(crate) fn group_signals() -> usize {
GROUP_SIGNALS.with(Cell::get)
}
/// Arrange an action that must happen in *this* child only, after fork.
///
/// Close-on-exec must be cleared here: the parent is shared with parallel
/// spawns, so a parent-side change would race them.
pub(crate) fn set_in_child(hook: impl FnMut() -> std::io::Result<()> + Send + Sync + 'static) {
IN_CHILD.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
}
pub(crate) fn take_in_child() -> Option<InChild> {
IN_CHILD.with(|slot| slot.borrow_mut().take())
}
}
// Use the public Wayland socket: Satellite passes its environment to Xwayland.
pub(crate) fn spawn_satellite(
executable: &Path,
environment: &SessionEnvironment,
reservation: &Reservation,
authority: &Path,
notify_socket: Option<&Path>,
) -> io::Result<Child> {
if reservation.listeners().is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"cannot launch Satellite without reserved listening sockets",
));
}
satellite_command(executable, environment, reservation, authority, notify_socket).spawn()
}
fn satellite_command(
executable: &Path,
environment: &SessionEnvironment,
reservation: &Reservation,
authority: &Path,
notify_socket: Option<&Path>,
) -> std::process::Command {
let inherited: Vec<_> = reservation.listeners().iter().map(AsRawFd::as_raw_fd).collect();
if inherited.is_empty() {
panic!("cannot build a Satellite command without reserved listening sockets");
}
let mut command = crate::process::command(executable);
environment.apply_public(&mut command);
command
.arg(reservation.display_name())
.arg("-auth")
.arg(authority)
.args(["-nolisten", "tcp"])
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.process_group(0);
for name in [
"NOTIFY_SOCKET",
"WATCHDOG_PID",
"WATCHDOG_USEC",
"LISTEN_PID",
"LISTEN_FDS",
"LISTEN_FDNAMES",
] {
command.env_remove(name);
}
if let Some(path) = notify_socket {
command.env("NOTIFY_SOCKET", path);
}
for fd in &inherited {
command.arg("-listenfd").arg(fd.to_string());
}
// PR_SET_PDEATHSIG follows the spawning thread; spawn from the compositor thread.
let expected_parent = unsafe { libc::getpid() };
// SAFETY: this child-only hook performs only raw, non-allocating
// Linux/POSIX operations on prevalidated descriptors. All formatting and
// allocation occurred above, before fork.
unsafe {
command.pre_exec(move || {
for fd in &inherited {
let flags = libc::fcntl(*fd, libc::F_GETFD);
if flags == -1 || libc::fcntl(*fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) == -1 {
return Err(io::Error::last_os_error());
}
}
if libc::prctl(
libc::PR_SET_PDEATHSIG,
libc::SIGTERM as libc::c_ulong,
0 as libc::c_ulong,
0 as libc::c_ulong,
0 as libc::c_ulong,
) == -1
{
return Err(io::Error::last_os_error());
}
if libc::getppid() != expected_parent {
return Err(io::Error::from_raw_os_error(libc::ESRCH));
}
Ok(())
});
}
// One-shot, thread-local, and consumed by exactly this spawn: the hook
// changes descriptors in the forked child, never in the parent.
#[cfg(test)]
if let Some(hook) = test_hook::take_in_child() {
// SAFETY: same contract as the hook above: a closure that runs in the
// forked child before exec, using only prevalidated descriptors and
// no allocation.
unsafe {
command.pre_exec(hook);
}
}
command
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Observation {
Running,
Exited,
Failed(String),
}
pub(crate) struct OwnedProcessGroup {
pid: u32,
child: Option<Child>,
reaped: bool,
cleanup_error: Option<String>,
}
impl OwnedProcessGroup {
pub fn adopt(child: Child) -> Self {
let pid = child.id();
Self {
pid,
child: Some(child),
reaped: false,
cleanup_error: None,
}
}
#[cfg(test)]
fn from_command(mut command: std::process::Command) -> std::io::Result<Self> {
command.process_group(0);
Ok(Self::adopt(command.spawn()?))
}
pub fn pid(&self) -> u32 {
self.pid
}
pub fn observe(&mut self) -> Observation {
if self.reaped {
return Observation::Exited;
}
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
// SAFETY: `info` is a live, correctly aligned local; P_PID with our own
// pid, and WNOWAIT|WNOHANG leave the status claimable by `wait`.
let result = unsafe {
libc::waitid(
libc::P_PID,
self.pid,
std::ptr::from_mut(&mut info),
libc::WEXITED | libc::WNOWAIT | libc::WNOHANG,
)
};
if result == -1 {
// ECHILD means someone else already reaped the leader, so the
// process-group identity can no longer be trusted.
return Observation::Failed(format!(
"cannot observe X11 service {}: {}",
self.pid,
io::Error::last_os_error()
));
}
// si_pid == 0 with WNOHANG means no state change is pending.
// SAFETY: `info` was filled in by the waitid call above.
if unsafe { info.si_pid() } == 0 {
Observation::Running
} else {
Observation::Exited
}
}
/// Ask the whole group to stop, but only while this owner still holds the
/// unreaped leader that names it.
///
/// Once the leader is reaped the group number identifies nothing this
/// owner controls: it may already belong to an unrelated process group.
/// Cleanup in that state resumes by *waiting* for the group, never by
/// signalling it, so a stale number can never kill a stranger.
pub fn signal_group(&self, signal: i32) {
if self.reaped {
// The group number is stale: it may already name an unrelated
// process group, so resume by waiting and never by signalling.
return;
}
#[cfg(test)]
test_hook::record_group_signal();
signal_group(self.pid, signal);
}
pub fn group_is_empty(&self) -> bool {
#[cfg(test)]
if super::test_hooks::force_group_not_empty() {
return false;
}
// SAFETY: `kill` with signal 0 performs error checking only. A negative
// pid addresses the process group led by `self.pid`.
let outcome = unsafe { libc::kill(-(self.pid as i32), 0) };
if outcome == 0 {
return false;
}
// ESRCH is the only "nothing left" answer; anything else (notably
// EPERM) means the group still exists and must not be assumed gone.
std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
}
pub fn is_reaped(&self) -> bool {
self.reaped
}
pub fn terminate(&mut self, grace: std::time::Duration) -> Result<Option<std::process::ExitStatus>, String> {
self.signal_group(libc::SIGTERM);
let deadline = std::time::Instant::now() + grace;
loop {
match self.observe() {
Observation::Exited => break,
Observation::Running => {
if std::time::Instant::now() >= deadline {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
Observation::Failed(error) => return Err(error),
}
}
self.signal_group(libc::SIGKILL);
// A zombie still counts as a group member, so reap before asking
// whether a descendant is left.
let status = self.reap()?;
let deadline = std::time::Instant::now() + grace;
while !self.group_is_empty() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(20));
}
if !self.group_is_empty() {
// A survivor may still hold the X11 listeners, so report it.
return Err("the X11 service group survived SIGKILL".to_owned());
}
Ok(status)
}
pub fn reap(&mut self) -> Result<Option<std::process::ExitStatus>, String> {
if self.reaped {
return Ok(None);
}
let Some(child) = self.child.as_mut() else {
self.reaped = true;
return Ok(None);
};
match child.wait() {
Ok(status) => {
self.reaped = true;
Ok(Some(status))
}
Err(error) => Err(format!("cannot reap X11 service {}: {error}", self.pid)),
}
}
pub fn cleanup_error(&self) -> Option<&str> {
self.cleanup_error.as_deref()
}
pub fn note_cleanup_error(&mut self, error: String) {
self.cleanup_error = Some(error);
}
}
impl Drop for OwnedProcessGroup {
fn drop(&mut self) {
if self.reaped {
return;
}
// Last resort: the manager must already have reached a completed
// cleanup transition, so a process is never silently forgotten.
self.signal_group(libc::SIGKILL);
let _ = self.reap();
}
}
/// Requires an unreaped group leader owned by the caller.
pub(crate) fn signal_group(pid: u32, signal: i32) {
// SAFETY: negative pid targets the process group led by pid.
unsafe {
libc::kill(-(pid as i32), signal);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SessionEnvironment;
use std::ffi::OsString;
use std::time::Duration;
fn environment() -> SessionEnvironment {
SessionEnvironment {
wayland_display: OsString::from("wayland-7"),
x11: None,
}
}
#[test]
fn refusing_to_launch_without_owned_listeners() {
let mut reservation = Reservation::allocate().unwrap();
reservation.close_listeners();
let error = spawn_satellite(
Path::new("/nonexistent/xwayland-satellite"),
&environment(),
&reservation,
Path::new("/nonexistent/auth"),
None,
)
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
assert!(error.to_string().contains("without reserved listening sockets"));
}
#[test]
fn a_missing_executable_is_reported_not_panicked() {
let reservation = Reservation::allocate().unwrap();
let error = spawn_satellite(
Path::new("/nonexistent/xwayland-satellite"),
&environment(),
&reservation,
Path::new("/nonexistent/auth"),
None,
)
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
#[test]
fn a_stopped_group_reports_itself_empty() {
let mut command = std::process::Command::new("/bin/sh");
command.arg("-c").arg("exit 0");
let mut process = OwnedProcessGroup::from_command(command).expect("spawn /bin/sh");
assert!(!process.group_is_empty());
process.signal_group(libc::SIGTERM);
let status = process.terminate(Duration::from_secs(5)).expect("terminate");
assert!(status.is_some());
assert!(process.group_is_empty(), "a reaped group has no members left");
assert_eq!(process.cleanup_error(), None);
}
#[test]
fn a_descendant_that_ignores_sigterm_is_still_killed() {
// A leader that exits immediately while a descendant keeps running is
// the crash shape that leaks X11 listeners into the next generation.
let mut command = std::process::Command::new("/bin/sh");
command.arg("-c").arg("sleep 300 & exit 0");
let mut process = OwnedProcessGroup::from_command(command).expect("spawn /bin/sh");
// The leader is gone, yet the group still has a member.
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while process.observe() == Observation::Running && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(process.observe(), Observation::Exited);
assert!(
!process.group_is_empty(),
"the surviving descendant keeps the group non-empty"
);
process.terminate(Duration::from_secs(5)).expect("terminate");
assert!(
process.group_is_empty(),
"SIGKILL must reclaim a descendant that ignored SIGTERM"
);
}
#[test]
fn reaping_reports_a_failure_instead_of_panicking() {
let mut process = OwnedProcessGroup {
pid: 0,
child: None,
reaped: false,
cleanup_error: None,
};
// An owner with no child has nothing left to reap.
assert_eq!(process.reap().expect("reap"), None);
assert!(process.reaped);
// Reaping twice is harmless, so cleanup paths stay simple.
assert_eq!(process.reap().expect("reap again"), None);
}
#[test]
fn a_reaped_owner_resumes_shutdown_without_signalling_a_stale_group() {
let mut command = std::process::Command::new("/bin/sh");
command.arg("-c").arg("exit 0");
let mut process = OwnedProcessGroup::from_command(command).expect("spawn /bin/sh");
process.reap().expect("reap the leader");
assert!(process.is_reaped(), "the leader is reaped");
// A shutdown that resumes here still owns the cleanup record, but the
// group number may already name another process group: it must be
// waited for, never signalled.
let before = test_hook::group_signals();
let status = process
.terminate(Duration::from_secs(5))
.expect("terminating a reaped owner must not fail");
assert_eq!(status, None, "there is no leader left to report a status for");
assert_eq!(
test_hook::group_signals(),
before,
"a reaped owner must resume by waiting, not by signalling its stale group number"
);
assert!(process.group_is_empty(), "the group really was reclaimed");
}
#[test]
fn the_command_line_matches_the_verified_cli_contract() {
let reservation = Reservation::allocate().unwrap();
let notify = std::env::temp_dir().join("ferese-notify-test");
let command = satellite_command(
Path::new("xwayland-satellite"),
&environment(),
&reservation,
Path::new("/run/user/1000/ferese-xauth-abc"),
Some(notify.as_path()),
);
let args: Vec<_> = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert_eq!(
args.first().map(String::as_str),
Some(reservation.display_name().as_str())
);
let mut listenfds = Vec::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"-listenfd" => {
listenfds.push(args[index + 1].clone());
index += 2;
}
"-auth" => {
assert_eq!(args[index + 1], "/run/user/1000/ferese-xauth-abc");
index += 2;
}
"-nolisten" => {
assert_eq!(args[index + 1], "tcp");
index += 2;
}
_ => index += 1,
}
}
assert!(args.windows(2).any(|pair| pair == ["-nolisten", "tcp"]));
assert!(!args.iter().any(|arg| arg == "-ac"));
assert!(!args.iter().any(|arg| arg == "-displayfd"));
assert_eq!(listenfds.len(), reservation.listeners().len());
}
#[test]
fn inherited_socket_activation_state_is_cleared() {
let reservation = Reservation::allocate().unwrap();
let command = satellite_command(
Path::new("xwayland-satellite"),
&environment(),
&reservation,
Path::new("/auth"),
None,
);
for (key, value) in command.get_envs() {
let key = key.to_string_lossy();
if matches!(
key.as_ref(),
"LISTEN_PID" | "LISTEN_FDS" | "LISTEN_FDNAMES" | "WATCHDOG_PID" | "WATCHDOG_USEC" | "NOTIFY_SOCKET"
) {
assert_eq!(value, None, "{key} must not be inherited from the launching service");
}
if key.as_ref() == "WAYLAND_SOCKET" {
assert_eq!(
value, None,
"Satellite must connect by display name, not an inherited socket"
);
}
}
}
#[test]
fn a_private_notify_socket_replaces_the_host_one() {
let reservation = Reservation::allocate().unwrap();
let private = std::env::temp_dir().join("ferese-notify-private");
let command = satellite_command(
Path::new("xwayland-satellite"),
&environment(),
&reservation,
Path::new("/auth"),
Some(private.as_path()),
);
let notify = command
.get_envs()
.find(|(key, _)| key.to_string_lossy() == "NOTIFY_SOCKET")
.and_then(|(_, value)| value)
.expect("private NOTIFY_SOCKET must be set");
assert_eq!(notify, private);
}
}
File diff suppressed because it is too large. Load diff
+620
View File
@@ -0,0 +1,620 @@
use std::collections::VecDeque;
use std::time::{Duration, Instant};
const FAILURE_WINDOW: Duration = Duration::from_secs(30);
const MAX_FAILURES: usize = 3;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AfterStop {
/// Schedule a bounded retry.
Backoff,
/// Open the circuit: fail fast until an explicit retry.
Failed,
/// The session is shutting down.
Stopped,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Phase {
Idle,
Starting(u64),
Spawned(u64),
Running(u64),
Stopping {
generation: u64,
after_stop: AfterStop,
},
Backoff {
generation: u64,
until: Instant,
},
/// A stop whose cleanup could not be confirmed.
CleanupFailed {
generation: u64,
},
Failed,
Stopped,
}
impl Phase {
pub fn generation(self) -> Option<u64> {
match self {
Phase::Starting(generation)
| Phase::Spawned(generation)
| Phase::Running(generation)
| Phase::Stopping { generation, .. }
| Phase::Backoff { generation, .. }
| Phase::CleanupFailed { generation } => Some(generation),
Phase::Idle | Phase::Failed | Phase::Stopped => None,
}
}
pub fn is_running(self) -> bool {
matches!(self, Phase::Running(_))
}
pub fn can_start(self) -> bool {
matches!(self, Phase::Idle)
}
/// True while a generation's process and cleanup sources are still owned.
#[cfg(test)]
pub fn is_stopping(self) -> bool {
matches!(self, Phase::Stopping { .. })
}
}
// Process resources belong to the manager, not this state model.
pub(crate) struct Lifecycle {
pub phase: Phase,
next_generation: u64,
failures: VecDeque<Instant>,
}
impl Default for Lifecycle {
fn default() -> Self {
Self {
phase: Phase::Idle,
next_generation: 0,
failures: VecDeque::new(),
}
}
}
impl Lifecycle {
pub fn request_start(&mut self) -> Option<u64> {
if !self.phase.can_start() {
return None;
}
let Some(generation) = self.next_generation.checked_add(1) else {
self.phase = Phase::Failed;
return None;
};
self.next_generation = generation;
self.phase = Phase::Starting(generation);
Some(generation)
}
pub fn spawned(&mut self, generation: u64) -> bool {
if self.phase != Phase::Starting(generation) {
return false;
}
self.phase = Phase::Spawned(generation);
true
}
pub fn ready(&mut self, generation: u64) -> bool {
if self.phase != Phase::Spawned(generation) {
return false;
}
self.phase = Phase::Running(generation);
true
}
pub fn begin_stop_after_failure(&mut self, generation: u64, now: Instant, retryable: bool) -> Option<AfterStop> {
let current = matches!(
self.phase,
Phase::Starting(current)
| Phase::Spawned(current)
| Phase::Running(current)
if current == generation
);
if !current {
return None;
}
while self
.failures
.front()
.is_some_and(|at| now.saturating_duration_since(*at) > FAILURE_WINDOW)
{
self.failures.pop_front();
}
self.failures.push_back(now);
let after_stop = if !retryable || self.failures.len() >= MAX_FAILURES {
AfterStop::Failed
} else {
AfterStop::Backoff
};
self.phase = Phase::Stopping { generation, after_stop };
Some(after_stop)
}
pub fn backoff_delay(&self) -> Duration {
if self.failures.len() <= 1 {
Duration::from_millis(250)
} else {
Duration::from_secs(1)
}
}
pub fn finish_stop(&mut self, generation: u64, now: Instant) -> Option<AfterStop> {
let Phase::Stopping {
generation: current,
after_stop,
} = self.phase
else {
return None;
};
if current != generation {
return None;
}
self.phase = match after_stop {
AfterStop::Backoff => Phase::Backoff {
generation,
until: now + self.backoff_delay(),
},
AfterStop::Failed => Phase::Failed,
AfterStop::Stopped => Phase::Stopped,
};
Some(after_stop)
}
pub fn park_cleanup_failure(&mut self, generation: u64) -> bool {
let Phase::Stopping {
generation: current, ..
} = self.phase
else {
return false;
};
if current != generation {
return false;
}
self.phase = Phase::CleanupFailed { generation };
true
}
pub fn confirm_cleanup(&mut self, generation: u64) -> bool {
if self.phase != (Phase::CleanupFailed { generation }) {
return false;
}
self.phase = Phase::Failed;
true
}
pub fn finish_backoff(&mut self, generation: u64, now: Instant) -> bool {
match self.phase {
Phase::Backoff {
generation: current,
until,
} if current == generation && now >= until => {
self.phase = Phase::Idle;
true
}
_ => false,
}
}
pub fn can_explicit_retry(&self) -> bool {
self.phase == Phase::Failed
}
pub fn commit_explicit_retry(&mut self) -> bool {
if !self.can_explicit_retry() {
return false;
}
self.failures.clear();
self.phase = Phase::Idle;
true
}
pub fn begin_shutdown_stop(&mut self) -> Option<u64> {
if self.phase == Phase::Stopped {
return None;
}
let generation = self.phase.generation().unwrap_or(0);
self.phase = Phase::Stopping {
generation,
after_stop: AfterStop::Stopped,
};
Some(generation)
}
pub fn recent_failures(&self) -> usize {
self.failures.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn start(lifecycle: &mut Lifecycle) -> u64 {
lifecycle.request_start().expect("Idle must yield a generation ticket")
}
/// Fail a generation the way the manager does, then complete the stop.
///
/// Cleanup completing is what publishes the next phase, so a test that
/// needs the next generation must say so explicitly.
fn fail_and_stop(lifecycle: &mut Lifecycle, generation: u64, now: Instant, retryable: bool) -> AfterStop {
let after_stop = lifecycle
.begin_stop_after_failure(generation, now, retryable)
.expect("the current generation must accept a failure");
assert_eq!(
lifecycle.finish_stop(generation, now),
Some(after_stop),
"cleanup completion publishes the chosen outcome"
);
after_stop
}
fn start_after_backoff(lifecycle: &mut Lifecycle) -> u64 {
let now = Instant::now();
let Phase::Backoff { generation, until } = lifecycle.phase else {
panic!("expected a backoff, found {:?}", lifecycle.phase);
};
assert!(lifecycle.finish_backoff(generation, until.max(now)));
start(lifecycle)
}
#[test]
fn starts_from_idle_and_claims_one_generation_at_a_time() {
let mut lifecycle = Lifecycle::default();
assert_eq!(lifecycle.phase, Phase::Idle);
let generation = start(&mut lifecycle);
assert_eq!(generation, 1);
assert_eq!(lifecycle.phase, Phase::Starting(1));
assert_eq!(lifecycle.request_start(), None);
assert_eq!(lifecycle.phase, Phase::Starting(1));
}
#[test]
fn generations_are_monotonic() {
let mut lifecycle = Lifecycle::default();
let first = start(&mut lifecycle);
lifecycle.ready(first);
fail_and_stop(&mut lifecycle, first, Instant::now(), true);
let second = start_after_backoff(&mut lifecycle);
assert!(second > first);
}
#[test]
fn spawn_then_readiness_reaches_running() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
assert!(lifecycle.spawned(generation));
assert_eq!(lifecycle.phase, Phase::Spawned(generation));
assert!(lifecycle.ready(generation));
assert_eq!(lifecycle.phase, Phase::Running(generation));
assert!(lifecycle.phase.is_running());
}
#[test]
fn stale_generations_cannot_advance_the_phase() {
let mut lifecycle = Lifecycle::default();
let stale = start(&mut lifecycle);
fail_and_stop(&mut lifecycle, stale, Instant::now(), true);
let current = start_after_backoff(&mut lifecycle);
assert!(!lifecycle.spawned(stale));
assert!(!lifecycle.ready(stale));
assert_eq!(lifecycle.begin_stop_after_failure(stale, Instant::now(), true), None);
assert_eq!(lifecycle.phase, Phase::Starting(current));
}
#[test]
fn readiness_cannot_be_claimed_before_exec() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
assert!(!lifecycle.ready(generation));
assert_eq!(lifecycle.phase, Phase::Starting(generation));
}
#[test]
fn a_non_retryable_failure_opens_the_circuit_immediately() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
assert_eq!(
fail_and_stop(&mut lifecycle, generation, Instant::now(), false),
AfterStop::Failed
);
assert_eq!(lifecycle.phase, Phase::Failed);
assert!(!lifecycle.phase.can_start());
}
#[test]
fn backoff_is_bounded_and_then_opens_the_circuit() {
let mut lifecycle = Lifecycle::default();
let now = Instant::now();
let first = start(&mut lifecycle);
assert_eq!(fail_and_stop(&mut lifecycle, first, now, true), AfterStop::Backoff);
let Phase::Backoff { until, .. } = lifecycle.phase else {
panic!("first failure must back off");
};
assert_eq!(until, now + Duration::from_millis(250));
let second = start_after_backoff(&mut lifecycle);
let now = Instant::now();
assert_eq!(fail_and_stop(&mut lifecycle, second, now, true), AfterStop::Backoff);
let Phase::Backoff { until, .. } = lifecycle.phase else {
panic!("second failure must back off");
};
assert_eq!(until, now + Duration::from_secs(1));
let third = start_after_backoff(&mut lifecycle);
assert_eq!(
fail_and_stop(&mut lifecycle, third, Instant::now(), true),
AfterStop::Failed,
"reaching the failure limit must open the circuit instead of backing off"
);
assert_eq!(lifecycle.phase, Phase::Failed);
assert_eq!(lifecycle.recent_failures(), MAX_FAILURES);
}
#[test]
fn backoff_releases_to_idle_only_after_the_window() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
let now = Instant::now();
fail_and_stop(&mut lifecycle, generation, now, true);
let Phase::Backoff { until, .. } = lifecycle.phase else {
panic!("must be backing off");
};
assert!(!lifecycle.finish_backoff(generation, until - Duration::from_millis(1)));
assert!(lifecycle.finish_backoff(generation, until));
assert_eq!(lifecycle.phase, Phase::Idle);
}
#[test]
fn a_successful_spawn_does_not_reset_the_failure_window() {
let mut lifecycle = Lifecycle::default();
let first = start(&mut lifecycle);
fail_and_stop(&mut lifecycle, first, Instant::now(), true);
let second = start_after_backoff(&mut lifecycle);
lifecycle.spawned(second);
lifecycle.ready(second);
assert_eq!(lifecycle.recent_failures(), 1);
}
#[test]
fn failures_outside_the_window_are_discarded() {
let mut lifecycle = Lifecycle::default();
let start_time = Instant::now();
let generation = start(&mut lifecycle);
fail_and_stop(&mut lifecycle, generation, Instant::now(), true);
assert_eq!(lifecycle.recent_failures(), 1);
let second = start_after_backoff(&mut lifecycle);
let later = start_time + FAILURE_WINDOW + Duration::from_secs(1);
fail_and_stop(&mut lifecycle, second, later, true);
assert_eq!(lifecycle.recent_failures(), 1);
}
#[test]
fn explicit_retry_clears_the_circuit_but_a_connection_cannot() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
fail_and_stop(&mut lifecycle, generation, Instant::now(), false);
assert_eq!(lifecycle.phase, Phase::Failed);
assert_eq!(lifecycle.request_start(), None);
assert!(lifecycle.can_explicit_retry());
assert!(lifecycle.commit_explicit_retry());
assert_eq!(lifecycle.phase, Phase::Idle);
assert_eq!(lifecycle.recent_failures(), 0);
assert!(lifecycle.request_start().is_some());
}
#[test]
fn an_unconfirmed_cleanup_is_not_retryable_until_it_is_confirmed() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
let now = Instant::now();
lifecycle
.begin_stop_after_failure(generation, now, true)
.expect("the current generation accepts a failure");
assert!(lifecycle.park_cleanup_failure(generation), "an in-flight stop parks");
assert_eq!(lifecycle.phase, Phase::CleanupFailed { generation });
// The deadline proved nothing, so the service must refuse to act on it.
assert!(!lifecycle.phase.can_start());
assert_eq!(lifecycle.request_start(), None);
assert!(
!lifecycle.can_explicit_retry(),
"an unconfirmed cleanup is not retryable"
);
assert!(!lifecycle.commit_explicit_retry());
assert_eq!(
lifecycle.finish_stop(generation, Instant::now()),
None,
"a parked cleanup publishes nothing, not even the stop it replaced"
);
assert_eq!(lifecycle.phase, Phase::CleanupFailed { generation });
// Only evidence of a reclaimed group reopens the circuit.
assert!(lifecycle.confirm_cleanup(generation));
assert_eq!(lifecycle.phase, Phase::Failed);
assert!(lifecycle.can_explicit_retry());
assert!(lifecycle.commit_explicit_retry());
assert_eq!(lifecycle.phase, Phase::Idle);
}
#[test]
fn a_parked_cleanup_of_another_generation_is_never_confirmed() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
lifecycle
.begin_stop_after_failure(generation, Instant::now(), false)
.expect("the current generation accepts a failure");
assert!(lifecycle.park_cleanup_failure(generation));
assert!(!lifecycle.confirm_cleanup(generation.wrapping_add(1)));
assert_eq!(lifecycle.phase, Phase::CleanupFailed { generation });
assert!(!lifecycle.can_explicit_retry());
}
#[test]
fn only_an_in_flight_stop_can_park_a_cleanup() {
let mut lifecycle = Lifecycle::default();
assert!(
!lifecycle.park_cleanup_failure(1),
"an idle service has no cleanup to park"
);
let generation = start(&mut lifecycle);
assert!(
!lifecycle.park_cleanup_failure(generation),
"a start in flight is not a stop, so it cannot park"
);
assert_eq!(lifecycle.phase, Phase::Starting(generation));
assert!(
!lifecycle.confirm_cleanup(generation),
"a confirmation outside a park must change nothing"
);
assert_eq!(lifecycle.phase, Phase::Starting(generation));
}
#[test]
fn explicit_retry_is_refused_outside_the_failed_phase() {
let mut lifecycle = Lifecycle::default();
assert!(!lifecycle.can_explicit_retry());
assert!(!lifecycle.commit_explicit_retry());
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
assert!(!lifecycle.can_explicit_retry());
assert!(!lifecycle.commit_explicit_retry());
}
#[test]
fn a_failed_generation_is_not_startable_until_cleanup_completes() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
let now = Instant::now();
let after_stop = lifecycle
.begin_stop_after_failure(generation, now, true)
.expect("the current generation accepts a failure");
// The process is still alive and still holds the listeners, so the
// lifecycle must not be startable and must not look like a failure
// that a queued connection could act on.
assert!(lifecycle.phase.is_stopping());
assert!(!lifecycle.phase.can_start());
assert_eq!(lifecycle.request_start(), None);
assert!(!lifecycle.can_explicit_retry());
// Only cleanup finishing publishes the retryable outcome.
assert_eq!(
lifecycle.finish_stop(generation, now),
Some(after_stop),
"cleanup completion publishes the outcome"
);
assert!(matches!(lifecycle.phase, Phase::Backoff { .. }));
assert!(!lifecycle.phase.can_start(), "backoff still blocks a start");
}
#[test]
fn an_abandoned_cleanup_cannot_publish_a_newer_generation() {
let mut lifecycle = Lifecycle::default();
let generation = start(&mut lifecycle);
lifecycle.spawned(generation);
lifecycle
.begin_stop_after_failure(generation, Instant::now(), false)
.expect("the current generation accepts a failure");
assert_eq!(lifecycle.finish_stop(generation.wrapping_add(1), Instant::now()), None);
// A stale completion leaves the real transition in flight.
assert!(lifecycle.phase.is_stopping());
assert!(lifecycle.finish_stop(generation, Instant::now()).is_some());
assert_eq!(lifecycle.phase, Phase::Failed);
}
#[test]
fn stopping_invalidates_scheduled_starts() {
let mut lifecycle = Lifecycle::default();
let _generation = start(&mut lifecycle);
let generation = lifecycle.begin_shutdown_stop().expect("a stop is required");
assert!(lifecycle.phase.is_stopping());
assert_eq!(
lifecycle.finish_stop(generation, Instant::now()),
Some(AfterStop::Stopped)
);
assert_eq!(lifecycle.phase, Phase::Stopped);
assert_eq!(lifecycle.request_start(), None);
assert!(!lifecycle.spawned(generation));
assert!(!lifecycle.ready(generation));
// Shutdown is idempotent once stopped.
assert_eq!(lifecycle.begin_shutdown_stop(), None);
assert_eq!(lifecycle.phase, Phase::Stopped);
}
#[test]
fn phase_reports_its_generation() {
assert_eq!(Phase::Starting(4).generation(), Some(4));
assert_eq!(
Phase::Backoff {
generation: 4,
until: Instant::now()
}
.generation(),
Some(4)
);
assert_eq!(Phase::Idle.generation(), None);
assert_eq!(Phase::Failed.generation(), None);
assert_eq!(
Phase::CleanupFailed { generation: 4 }.generation(),
Some(4),
"a parked cleanup names the generation it still owns"
);
}
}
File diff suppressed because it is too large. Load diff
+875
View File
@@ -0,0 +1,875 @@
use std::io;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixDatagram;
use std::path::{Path, PathBuf};
/// Bytes reserved for ancillary control data, well above `CMSG_LEN(ucred)`.
const CONTROL_CAPACITY: usize = 128;
fn cmsg_len_ucred() -> usize {
cmsg_data_offset() + std::mem::size_of::<libc::ucred>()
}
/// Byte offset at which `CMSG_DATA` starts: `cmsghdr` rounded up to
/// `size_t` alignment.
fn cmsg_data_offset() -> usize {
let header = std::mem::size_of::<libc::cmsghdr>();
let align = std::mem::size_of::<libc::size_t>();
header.div_ceil(align) * align
}
/// Room for the control data of one notification.
///
/// `cmsghdr` is the first field so the buffer carries that type's alignment.
/// A `#[repr(C)]` union with a `ucred` is not enough: the kernel requires
/// `msg_control` to be aligned for `cmsghdr`, which is wider.
#[repr(C)]
struct ControlBuffer {
header: libc::cmsghdr,
payload: [u8; CONTROL_CAPACITY],
}
// Requires Satellite's systemd notification feature; exec success is not readiness.
pub(crate) struct ReadinessSocket {
socket: UnixDatagram,
path: PathBuf,
}
impl ReadinessSocket {
pub fn create(runtime_directory: &Path, generation: u64) -> io::Result<Self> {
let path = runtime_directory.join(format!("ferese-x11-notify-{generation}.sock"));
let socket = UnixDatagram::bind(&path)?;
socket.set_nonblocking(true)?;
let enable: libc::c_int = 1;
let length = libc::socklen_t::try_from(std::mem::size_of::<libc::c_int>())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "optlen overflow"))?;
// SAFETY: setsockopt on a live owned socket, with `enable` a live local
// int of exactly `length` bytes.
if unsafe {
libc::setsockopt(
socket.as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PASSCRED,
std::ptr::from_ref(&enable).cast(),
length,
)
} == -1
{
return Err(io::Error::last_os_error());
}
Ok(Self { socket, path })
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn recv_ready(&self, satellite_pid: u32) -> io::Result<bool> {
let mut buffer = [0_u8; 512];
let mut control = ControlBuffer {
header: unsafe { std::mem::zeroed() },
payload: [0_u8; CONTROL_CAPACITY],
};
let mut iov = libc::iovec {
iov_base: buffer.as_mut_ptr().cast(),
iov_len: buffer.len(),
};
let mut message: libc::msghdr = unsafe { std::mem::zeroed() };
message.msg_iov = std::ptr::from_mut(&mut iov);
message.msg_iovlen = 1;
message.msg_control = std::ptr::from_mut(&mut control).cast();
message.msg_controllen = std::mem::size_of::<ControlBuffer>();
// SAFETY: every pointer refers to a live local buffer for the duration
// of the call, and `message` is fully initialized before use.
let received = unsafe {
libc::recvmsg(
self.socket.as_raw_fd(),
std::ptr::from_mut(&mut message),
libc::MSG_DONTWAIT | libc::MSG_CMSG_CLOEXEC,
)
};
if received < 0 {
return Err(io::Error::last_os_error());
}
// Walk *every* ancillary message, not just the first: a sender is free
// to put credentials second, and descriptors attached to any message
// must not be leaked into this long-lived process. This happens before
// the truncation check below, because a truncated datagram can still
// have installed descriptors: the flag does not release them.
let mut from_satellite = false;
// SAFETY: the control buffer was sized, aligned, and described by this
// message, and CMSG_NXTHDR walks only what the kernel filled in.
unsafe {
let mut header = libc::CMSG_FIRSTHDR(&message);
while !header.is_null() {
let (cmsg, data) = (*header, libc::CMSG_DATA(header));
if cmsg.cmsg_level == libc::SOL_SOCKET && cmsg.cmsg_type == libc::SCM_CREDENTIALS {
// Only an exactly sized ucred is a credential. A shorter or
// longer one is malformed, so it does not count.
if cmsg.cmsg_len == cmsg_len_ucred() {
// SAFETY: cmsg_len was checked to describe exactly one
// ucred, and CMSG_DATA points into our own buffer.
let credential = &*data.cast::<libc::ucred>();
// Ignore the Xwayland child and any other process.
if credential.pid as u32 == satellite_pid {
from_satellite = true;
}
}
} else if cmsg.cmsg_level == libc::SOL_SOCKET && cmsg.cmsg_type == libc::SCM_RIGHTS {
// Accepting a datagram must never adopt descriptors from an
// untrusted sender. Close them instead of dropping them on
// the floor, where they would stay open in this process.
let payload = cmsg.cmsg_len.saturating_sub(cmsg_data_offset());
let count = payload / std::mem::size_of::<libc::c_int>();
for index in 0..count {
// SAFETY: element `index` lies inside the ancillary data
// the kernel reported, and is read exactly once.
let raw = *data.cast::<libc::c_int>().add(index);
// SAFETY: `raw` is an open descriptor delivered by the
// kernel, so closing it releases the sender's copy.
libc::close(raw);
}
}
header = libc::CMSG_NXTHDR(&message, header);
}
}
// Decided only after the control walk: a completely filled payload
// buffer cannot be distinguished from truncation, and the kernel's
// truncation flags never satisfy the readiness contract.
if received as usize == buffer.len() || message.msg_flags & (libc::MSG_CTRUNC | libc::MSG_TRUNC) != 0 {
return Ok(false);
}
if !from_satellite {
return Ok(false);
}
Ok(matches!(
exact_line(&buffer[..received as usize], b"READY=1"),
Some(true)
))
}
pub fn try_clone_owned(&self) -> io::Result<UnixDatagram> {
self.socket.try_clone()
}
}
impl AsRawFd for ReadinessSocket {
fn as_raw_fd(&self) -> std::os::fd::RawFd {
self.socket.as_raw_fd()
}
}
fn exact_line(payload: &[u8], wanted: &[u8]) -> Option<bool> {
if payload.is_empty() || payload.contains(&0) {
return None;
}
let text = std::str::from_utf8(payload).ok()?;
let mut found = false;
for line in text.split('\n') {
if line.as_bytes() == wanted {
found = true;
} else if !line.is_empty() && !is_key_value(line) {
return None;
}
}
Some(found)
}
fn is_key_value(line: &str) -> bool {
line.split_once('=')
.is_some_and(|(key, _)| !key.is_empty() && key.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'))
}
impl Drop for ReadinessSocket {
fn drop(&mut self) {
if let Err(error) = std::fs::remove_file(&self.path)
&& error.kind() != io::ErrorKind::NotFound
{
tracing::warn!(path = %self.path.display(), %error, "could not remove readiness socket");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Bounded wait, mirroring the production drain budget.
const READINESS_DRAIN_BUDGET_TESTS: usize = 200;
/// Open a datagram socket already connected to `path`.
///
/// Connecting first means the senders can use plain `sendmsg`, whose
/// glibc export carries no destination address.
///
/// # Safety
/// Async-signal-safe, for use between `fork` and `_exit`.
unsafe fn connect_datagram(path: &Path) -> libc::c_int {
unsafe {
let fd = libc::socket(libc::AF_UNIX, libc::SOCK_DGRAM, 0);
if fd < 0 {
return -1;
}
let mut address: libc::sockaddr_un = std::mem::zeroed();
address.sun_family = libc::AF_UNIX as libc::sa_family_t;
let bytes = path.as_os_str().as_encoded_bytes();
if bytes.len() >= address.sun_path.len() {
libc::close(fd);
return -1;
}
for (slot, byte) in address.sun_path.iter_mut().zip(bytes) {
*slot = *byte as libc::c_char;
}
if libc::connect(
fd,
std::ptr::from_ref(&address).cast(),
// The fixed `sockaddr_un` length is what the kernel expects for
// AF_UNIX; `sun_path` is already zero-filled past the name.
libc::socklen_t::try_from(std::mem::size_of::<libc::sockaddr_un>()).unwrap(),
) == -1
{
libc::close(fd);
return -1;
}
fd
}
}
fn private_runtime_dir(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("ferese-readiness-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::set_permissions(&root, std::os::unix::fs::PermissionsExt::from_mode(0o700)).unwrap();
root
}
fn send_from_child(path: &Path, payload: &[u8]) -> i32 {
// SAFETY: the child path performs only socket/bind-free sendto and
// _exit, which are async-signal-safe.
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork must succeed");
if pid == 0 {
let Ok(client) = UnixDatagram::unbound() else {
libc::_exit(70);
};
let sent = client.send_to(payload, path);
libc::_exit(if sent.is_ok() { 0 } else { 71 });
}
let mut status = 0;
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
status
}
}
/// Send `payload` with `MSG_TRUNC`, which makes the kernel deliver a short
/// datagram and set `MSG_TRUNC` on the receiver.
fn send_truncated_from_child(path: &Path, payload: &[u8]) {
// SAFETY: the child performs only sendmsg and _exit, both
// async-signal-safe, on freshly created descriptors.
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork must succeed");
if pid == 0 {
let fd = connect_datagram(path);
if fd < 0 {
libc::_exit(70);
}
let mut iov = libc::iovec {
iov_base: payload.as_ptr().cast_mut().cast(),
iov_len: payload.len(),
};
let mut message: libc::msghdr = std::mem::zeroed();
message.msg_iov = std::ptr::from_mut(&mut iov);
message.msg_iovlen = 1;
// The kernel truncates the payload and reports MSG_TRUNC.
message.msg_flags = libc::MSG_TRUNC;
let sent = libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL);
libc::_exit(if sent >= 0 { 0 } else { 71 });
}
let mut status = 0;
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
assert!(
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
"child must send a truncated datagram"
);
}
}
/// Send `READY=1` from a child that also passes a descriptor.
///
/// The child stays alive so the receiver can name its PID, and the extra
/// descriptor must be released by the receiver rather than leaked.
fn send_with_descriptor_from_child(path: &Path, sentinel: &Path) -> i32 {
// SAFETY: the child performs only socket/open/sendmsg/_exit, all
// async-signal-safe.
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork must succeed");
if pid == 0 {
let fd = connect_datagram(path);
if fd < 0 {
libc::_exit(70);
}
// A descriptor for a known file, so the receiver can prove it
// closed exactly this one rather than guessing at a count.
let Ok(name) = std::ffi::CString::new(sentinel.as_os_str().as_encoded_bytes()) else {
libc::_exit(73);
};
let spare = libc::open(name.as_ptr(), libc::O_RDONLY);
if spare < 0 {
libc::_exit(72);
}
let mut iov = libc::iovec {
iov_base: b"READY=1".as_ptr().cast_mut().cast(),
iov_len: 7,
};
// One SCM_RIGHTS message, in an aligned buffer.
let mut control = ControlBuffer {
header: std::mem::zeroed(),
payload: [0_u8; CONTROL_CAPACITY],
};
// `payload` starts where `CMSG_DATA` starts, so the descriptor
// belongs at the beginning of it. Writing it further along would
// silently send whatever zeros sit in between.
let descriptor = spare;
std::ptr::copy_nonoverlapping(
std::ptr::from_ref(&descriptor).cast::<u8>(),
control.payload.as_mut_ptr(),
std::mem::size_of::<libc::c_int>(),
);
let total = cmsg_data_offset() + std::mem::size_of::<libc::c_int>();
control.header.cmsg_len = total as _;
control.header.cmsg_level = libc::SOL_SOCKET;
control.header.cmsg_type = libc::SCM_RIGHTS;
let mut message: libc::msghdr = std::mem::zeroed();
message.msg_iov = std::ptr::from_mut(&mut iov);
message.msg_iovlen = 1;
message.msg_control = std::ptr::from_mut(&mut control).cast();
message.msg_controllen = total;
let sent = libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL);
if sent < 0 {
libc::_exit(71);
}
// Stay alive so the receiver can accept the credential while
// this process still exists.
libc::sleep(1);
libc::_exit(0);
}
pid
}
}
/// A truncation flag must be exercised for real, not simulated by a shorter payload.
#[test]
fn a_truncated_notification_is_rejected_even_with_valid_credentials() {
let runtime = private_runtime_dir("truncated");
let socket = ReadinessSocket::create(&runtime, 21).unwrap();
let path = socket.path().to_owned();
send_truncated_from_child(&path, b"READY=1");
// Any PID is fine here: the point is the flag, not the credential.
assert!(
!recv_within(&socket, u32::MAX),
"MSG_TRUNC must never satisfy readiness"
);
}
/// Descriptors attached to a notification must be released, not adopted.
#[test]
fn attached_descriptors_are_closed_rather_than_leaked() {
let runtime = private_runtime_dir("fds");
let socket = ReadinessSocket::create(&runtime, 22).unwrap();
let path = socket.path().to_owned();
// Counted by identity, not by process-wide total, so a parallel test
// cannot make this flaky.
let sentinel = sentinel_file("fds");
let pid = send_with_descriptor_from_child(&path, &sentinel);
assert!(
recv_within(&socket, pid as u32),
"a READY=1 from the named PID is accepted even with an attached fd"
);
assert!(
!holds_open(&sentinel),
"the descriptor delivered with the notification must be closed, \
not left open in the compositor"
);
let _ = std::fs::remove_file(&sentinel);
// SAFETY: reaping a direct child that has finished sleeping.
let mut status = 0;
unsafe { libc::waitpid(pid, std::ptr::from_mut(&mut status), 0) };
}
#[test]
fn credentials_after_another_control_message_are_still_read() {
let runtime = private_runtime_dir("cmsg-order");
let socket = ReadinessSocket::create(&runtime, 23).unwrap();
let path = socket.path().to_owned();
let sentinel = sentinel_file("cmsg-order");
let pid = send_with_descriptor_from_child(&path, &sentinel);
assert!(
recv_within(&socket, pid as u32),
"a credential that is not the first control message must still count"
);
// SAFETY: reaping a direct child that has finished sleeping.
let mut status = 0;
unsafe { libc::waitpid(pid, std::ptr::from_mut(&mut status), 0) };
}
/// Send `READY=1` with more descriptors than the receiver's control buffer can
/// hold, so the kernel truncates the ancillary data while installing what fits.
fn send_descriptors_beyond_capacity_from_child(path: &Path, sentinel: &Path, count: usize) -> i32 {
// SAFETY: the child performs only socket/open/sendmsg/sleep/_exit, all
// async-signal-safe except sleep, which is a bounded wait before exit.
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork must succeed");
if pid == 0 {
let fd = connect_datagram(path);
if fd < 0 {
libc::_exit(70);
}
let Ok(name) = std::ffi::CString::new(sentinel.as_os_str().as_encoded_bytes()) else {
libc::_exit(73);
};
let sentinel_fd = libc::open(name.as_ptr(), libc::O_RDONLY);
if sentinel_fd < 0 {
libc::_exit(72);
}
let filler = libc::open(c"/dev/null".as_ptr(), libc::O_RDONLY);
if filler < 0 {
libc::_exit(72);
}
let mut iov = libc::iovec {
iov_base: b"READY=1".as_ptr().cast_mut().cast(),
iov_len: 7,
};
// A control buffer large enough to *send* the flood; the
// receiver's much smaller buffer is what truncates it.
let mut control = [0_u8; 1024];
let mut message: libc::msghdr = std::mem::zeroed();
message.msg_iov = std::ptr::from_mut(&mut iov);
message.msg_iovlen = 1;
message.msg_control = control.as_mut_ptr().cast();
message.msg_controllen = control.len() as _;
let header = libc::CMSG_FIRSTHDR(&message);
if header.is_null() {
libc::_exit(74);
}
(*header).cmsg_level = libc::SOL_SOCKET;
(*header).cmsg_type = libc::SCM_RIGHTS;
let bytes = (count * std::mem::size_of::<libc::c_int>()) as u32;
(*header).cmsg_len = libc::CMSG_LEN(bytes) as _;
// Exactly the aligned size of one control message: trailing
// zero bytes would be read as a second, malformed message and
// the kernel would reject the send with EINVAL.
message.msg_controllen = libc::CMSG_SPACE(bytes) as _;
let data = libc::CMSG_DATA(header);
for index in 0..count {
// The sentinel rides along first, so it is one of the
// descriptors the receiver's short buffer actually installs.
let descriptor = if index == 0 { sentinel_fd } else { filler };
*data.cast::<libc::c_int>().add(index) = descriptor;
}
let sent = libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL);
if sent < 0 {
libc::_exit(71);
}
libc::sleep(1);
libc::_exit(0);
}
pid
}
}
/// Send `count` malformed datagrams, each carrying a descriptor.
fn send_malformed_storm_from_child(path: &Path, sentinel: &Path, count: usize) -> i32 {
// SAFETY: the child performs only socket/open/sendmsg/_exit, all
// async-signal-safe.
unsafe {
let pid = libc::fork();
assert!(pid >= 0, "fork must succeed");
if pid == 0 {
let fd = connect_datagram(path);
if fd < 0 {
libc::_exit(70);
}
let Ok(name) = std::ffi::CString::new(sentinel.as_os_str().as_encoded_bytes()) else {
libc::_exit(73);
};
let sentinel_fd = libc::open(name.as_ptr(), libc::O_RDONLY);
if sentinel_fd < 0 {
libc::_exit(72);
}
for _ in 0..count {
let mut iov = libc::iovec {
iov_base: b"READY=2".as_ptr().cast_mut().cast(),
iov_len: 7,
};
let mut control = ControlBuffer {
header: std::mem::zeroed(),
payload: [0_u8; CONTROL_CAPACITY],
};
let descriptor = sentinel_fd;
std::ptr::copy_nonoverlapping(
std::ptr::from_ref(&descriptor).cast::<u8>(),
control.payload.as_mut_ptr(),
std::mem::size_of::<libc::c_int>(),
);
control.header.cmsg_len = (cmsg_data_offset() + std::mem::size_of::<libc::c_int>()) as _;
control.header.cmsg_level = libc::SOL_SOCKET;
control.header.cmsg_type = libc::SCM_RIGHTS;
let mut message: libc::msghdr = std::mem::zeroed();
message.msg_iov = std::ptr::from_mut(&mut iov);
message.msg_iovlen = 1;
message.msg_control = std::ptr::from_mut(&mut control).cast();
message.msg_controllen = cmsg_data_offset() + std::mem::size_of::<libc::c_int>();
if libc::sendmsg(fd, std::ptr::from_ref(&message), libc::MSG_NOSIGNAL) < 0 {
libc::_exit(71);
}
}
libc::_exit(0);
}
pid
}
}
/// Open descriptors in this process; only *deltas* between two calls are
/// meaningful, because parallel tests add and remove descriptors too.
fn open_fd_count() -> usize {
std::fs::read_dir("/proc/self/fd")
.map(|entries| entries.count())
.unwrap_or(0)
}
/// A truncated ancillary buffer must be rejected and its delivered descriptors closed.
#[test]
fn truncated_control_messages_still_close_the_descriptors_they_delivered() {
let runtime = private_runtime_dir("ctrunc");
let socket = ReadinessSocket::create(&runtime, 24).unwrap();
let path = socket.path().to_owned();
let sentinel = sentinel_file("ctrunc");
let before = open_fd_count();
// Far more descriptors than the receiver's 128-byte payload can hold.
let pid = send_descriptors_beyond_capacity_from_child(&path, &sentinel, 64);
// SAFETY: reaping a direct child that has finished sleeping.
let mut status = 0;
unsafe { libc::waitpid(pid, std::ptr::from_mut(&mut status), 0) };
assert!(
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
"child must deliver the truncated notification: {status:#x}"
);
let verdict = recv_within(&socket, pid as u32);
assert!(!verdict, "MSG_CTRUNC must never satisfy readiness");
assert!(
!holds_open(&sentinel),
"descriptors installed before MSG_CTRUNC was noticed must be closed"
);
let after = open_fd_count();
assert!(
after <= before + 4,
"truncation leaked descriptors: {before} open before, {after} after"
);
let _ = std::fs::remove_file(&sentinel);
}
/// A flood of malformed datagrams must leak nothing and leave the socket usable.
#[test]
fn a_storm_of_malformed_datagrams_leaks_nothing_and_leaves_the_socket_usable() {
let runtime = private_runtime_dir("storm");
let socket = ReadinessSocket::create(&runtime, 25).unwrap();
let path = socket.path().to_owned();
let sentinel = sentinel_file("storm");
let before = open_fd_count();
let pid = send_malformed_storm_from_child(&path, &sentinel, 128);
// Drain while the sender may still be blocked in `sendmsg`: the socket
// queue holds far fewer datagrams than the storm, so waiting for the
// child to exit before reading would deadlock the pair. One deadline
// covers both the send and the receive.
let started = std::time::Instant::now();
let deadline = started + std::time::Duration::from_secs(10);
let mut judged = 0;
let mut status = 0;
while judged < 128 {
loop {
match socket.recv_ready(u32::MAX) {
Ok(_) => judged += 1,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
Err(error) => panic!("unexpected readiness read error: {error}"),
}
}
if judged >= 128 || std::time::Instant::now() >= deadline {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
if judged < 128 {
// The sender never finished, so it is still blocked. Kill it and
// reap it here rather than leave it behind for another test.
// SAFETY: signalling and reaping a direct child of this process.
unsafe {
libc::kill(pid, libc::SIGKILL);
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
}
panic!("the storm was not delivered within the shared deadline: {judged} of 128 judged");
}
// Every datagram was accepted, so the sender has reached its exit.
// SAFETY: reaping a direct child that has finished sending.
unsafe { libc::waitpid(pid, std::ptr::from_mut(&mut status), 0) };
assert!(
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
"child must deliver the storm: {status:#x}"
);
assert!(judged >= 128, "the storm must be delivered and judged: {judged}");
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"judging the storm must not stall the consumer: {:?}",
started.elapsed()
);
assert!(
!holds_open(&sentinel),
"every descriptor carried by the storm must be closed"
);
let after = open_fd_count();
assert!(
after <= before + 4,
"the storm leaked descriptors: {before} open before, {after} after"
);
let _ = std::fs::remove_file(&sentinel);
let ready_sentinel = sentinel_file("storm-ready");
let ready_pid = send_with_descriptor_from_child(&path, &ready_sentinel);
assert!(
recv_within(&socket, ready_pid as u32),
"a READY=1 after the storm is still judged by credential, and the \
storm sender's exit must not have wedged the socket"
);
assert!(
!holds_open(&ready_sentinel),
"cleanup must keep working after the storm"
);
let _ = std::fs::remove_file(&ready_sentinel);
// SAFETY: reaping a direct child that has finished sleeping.
let mut ready_status = 0;
unsafe { libc::waitpid(ready_pid, std::ptr::from_mut(&mut ready_status), 0) };
}
/// Drain the socket until one datagram is judged, bounded like production.
fn recv_within(socket: &ReadinessSocket, pid: u32) -> bool {
for _ in 0..READINESS_DRAIN_BUDGET_TESTS {
match socket.recv_ready(pid) {
Ok(verdict) => return verdict,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) => panic!("unexpected readiness read error: {error}"),
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("no readiness datagram arrived");
}
/// A unique empty file used to identify a delivered descriptor.
fn sentinel_file(name: &str) -> PathBuf {
let path = std::env::temp_dir().join(format!("ferese-readiness-sentinel-{name}-{}", std::process::id()));
let _ = std::fs::remove_file(&path);
std::fs::write(&path, b"").unwrap();
path
}
/// Whether this process currently holds any descriptor on `path`.
fn holds_open(path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir("/proc/self/fd") else {
return false;
};
entries
.filter_map(Result::ok)
.any(|entry| std::fs::read_link(entry.path()).is_ok_and(|target| target == path))
}
#[test]
fn creates_a_private_socket_that_is_removed_on_drop() {
let runtime = private_runtime_dir("create");
let path = {
let socket = ReadinessSocket::create(&runtime, 7).unwrap();
let path = socket.path().to_owned();
assert!(path.exists());
assert!(path.starts_with(&runtime));
assert!(path.to_string_lossy().contains('7'), "path must encode the generation");
path
};
assert!(!path.exists(), "readiness socket must be removed on drop");
}
#[test]
fn each_generation_gets_a_distinct_socket() {
let runtime = private_runtime_dir("generations");
let first = ReadinessSocket::create(&runtime, 1).unwrap();
let second = ReadinessSocket::create(&runtime, 2).unwrap();
assert_ne!(first.path(), second.path());
}
#[test]
fn line_matching_requires_an_exact_ready_line() {
assert_eq!(exact_line(b"READY=1", b"READY=1"), Some(true));
assert_eq!(exact_line(b"READY=1\n", b"READY=1"), Some(true));
assert_eq!(exact_line(b"MAINPID=42\nREADY=1", b"READY=1"), Some(true));
assert_eq!(exact_line(b"READY=0", b"READY=1"), Some(false));
assert_eq!(exact_line(b"READY=1 ", b"READY=1"), Some(false));
assert_eq!(exact_line(b"XREADY=1", b"READY=1"), Some(false));
assert_eq!(exact_line(b"READY=11", b"READY=1"), Some(false));
assert_eq!(exact_line(b"READY=1=2", b"READY=1"), Some(false));
assert_eq!(exact_line(b"", b"READY=1"), None);
assert_eq!(exact_line(b"READY=1\x00", b"READY=1"), None);
assert_eq!(exact_line(b"\xff\xfe\x00\x01", b"READY=1"), None);
}
#[test]
fn a_ready_datagram_from_another_process_is_rejected() {
let runtime = private_runtime_dir("foreign");
let socket = ReadinessSocket::create(&runtime, 3).unwrap();
let path = socket.path().to_owned();
let status = send_from_child(&path, b"READY=1");
assert!(
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
"child must send"
);
let mut rejected = false;
for _ in 0..1000 {
match socket.recv_ready(u32::MAX) {
Ok(false) => {
rejected = true;
break;
}
Ok(true) => panic!("a foreign READY=1 must never satisfy readiness"),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) => panic!("unexpected readiness read error: {error}"),
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert!(rejected, "a READY=1 from another process must be rejected");
}
#[test]
fn a_ready_datagram_from_the_expected_pid_is_accepted() {
let runtime = private_runtime_dir("expected");
let socket = ReadinessSocket::create(&runtime, 4).unwrap();
let path = socket.path().to_owned();
let pid = {
// SAFETY: mirror of send_from_child, returning the child PID.
unsafe {
let pid = libc::fork();
assert!(pid >= 0);
if pid == 0 {
for _ in 0..500 {
if let Ok(client) = UnixDatagram::unbound()
&& client.send_to(b"READY=1", &path).is_ok()
{
libc::_exit(0);
}
libc::usleep(2_000);
}
libc::_exit(72);
}
pid
}
};
let mut accepted = false;
for _ in 0..1000 {
match socket.recv_ready(pid as u32) {
Ok(true) => {
accepted = true;
break;
}
Ok(false) => {}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) => panic!("unexpected readiness read error: {error}"),
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
// SAFETY: reap the child we just spawned.
unsafe {
let mut status = 0;
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
}
assert!(accepted, "an exact READY=1 from the expected PID must be accepted");
}
#[test]
fn a_non_ready_datagram_from_the_expected_pid_is_rejected() {
let runtime = private_runtime_dir("not-ready");
let socket = ReadinessSocket::create(&runtime, 5).unwrap();
let path = socket.path().to_owned();
let pid = unsafe {
let pid = libc::fork();
assert!(pid >= 0);
if pid == 0 {
for _ in 0..500 {
if let Ok(client) = UnixDatagram::unbound()
&& client.send_to(b"RELOADING=1\nSTATUS=starting", &path).is_ok()
{
libc::_exit(0);
}
libc::usleep(2_000);
}
libc::_exit(73);
}
pid
};
let mut rejected = false;
for _ in 0..1000 {
match socket.recv_ready(pid as u32) {
Ok(false) => {
rejected = true;
break;
}
Ok(true) => panic!("RELOADING=1 must not satisfy readiness"),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
Err(error) => panic!("unexpected readiness read error: {error}"),
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
unsafe {
let mut status = 0;
libc::waitpid(pid, std::ptr::from_mut(&mut status), 0);
}
assert!(rejected, "RELOADING=1 must not satisfy readiness");
}
}
+371
View File
@@ -0,0 +1,371 @@
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::ops::Range;
use std::os::linux::net::SocketAddrExt;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
use std::os::unix::net::{SocketAddr, UnixListener};
use std::path::{Path, PathBuf};
use tracing::warn;
// Identity checks prevent accidental unlinking, not races with hostile same-UID code.
#[derive(Debug)]
struct CreatedPath {
path: PathBuf,
device: u64,
inode: u64,
}
impl CreatedPath {
fn record(path: PathBuf, metadata: &fs::Metadata) -> Self {
Self {
path,
device: metadata.dev(),
inode: metadata.ino(),
}
}
fn remove(&mut self) {
match fs::symlink_metadata(&self.path) {
Ok(metadata) if metadata.dev() == self.device && metadata.ino() == self.inode => {
if let Err(error) = fs::remove_file(&self.path) {
warn!(
path = %self.path.display(),
%error,
"could not remove X11 path this process created"
);
}
}
Ok(_) => warn!(
path = %self.path.display(),
"refusing to remove an X11 path that no longer matches the one this process created"
),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => warn!(
path = %self.path.display(),
%error,
"could not inspect X11 path during cleanup"
),
}
}
}
impl Drop for CreatedPath {
fn drop(&mut self) {
self.remove();
}
}
fn validate_shared_directory(path: &Path) -> io::Result<()> {
let metadata = fs::symlink_metadata(path)?;
let uid = unsafe { libc::geteuid() };
if !metadata.is_dir() || metadata.file_type().is_symlink() || (metadata.uid() != 0 && metadata.uid() != uid) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("untrusted X11 directory: {}", path.display()),
));
}
if metadata.mode() & 0o022 != 0 && metadata.mode() & 0o1000 == 0 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("shared X11 directory is not sticky: {}", path.display()),
));
}
Ok(())
}
#[derive(Debug)]
pub(crate) struct Reservation {
display: u32,
listeners: Vec<UnixListener>,
socket_path: Option<CreatedPath>,
lock_path: Option<CreatedPath>,
socket_directory: PathBuf,
}
impl Reservation {
// Leave pre-existing locks and socket paths untouched.
pub fn allocate() -> io::Result<Self> {
#[cfg(test)]
{
// Owned inputs, so an ordinary test never competes for a real slot in
// `/tmp/.X11-unix` nor depends on that directory existing.
let (sockets, locks) = crate::xwayland::test_hooks::allocator_root_for_reservation().unwrap_or_else(|| {
let root = crate::xwayland::test_hooks::default_allocator_root();
(root.clone(), root)
});
Self::allocate_in(&sockets, &locks, 0..64)
}
#[cfg(not(test))]
Self::allocate_in(Path::new("/tmp/.X11-unix"), Path::new("/tmp"), 0..64)
}
fn allocate_in(socket_directory: &Path, lock_directory: &Path, candidates: Range<u32>) -> io::Result<Self> {
validate_shared_directory(socket_directory)?;
validate_shared_directory(lock_directory)?;
for display in candidates {
let path = lock_directory.join(format!(".X{display}-lock"));
let mut file = match OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o644)
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
.open(&path)
{
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
};
let lock_path = CreatedPath::record(path, &file.metadata()?);
if let Err(error) = writeln!(file, "{:>10}", std::process::id()).and_then(|()| file.flush()) {
drop(file);
drop(lock_path);
return Err(error);
}
drop(file);
let mut reservation = Self {
display,
listeners: Vec::new(),
socket_path: None,
lock_path: Some(lock_path),
socket_directory: socket_directory.to_owned(),
};
match reservation.bind_same_display() {
Ok(()) => return Ok(reservation),
Err(error) if error.kind() == io::ErrorKind::AddrInUse => {
drop(reservation);
}
Err(error) => return Err(error),
}
}
Err(io::Error::new(
io::ErrorKind::AddrInUse,
"no unused X11 display in the configured internal candidate range",
))
}
pub fn display_number(&self) -> u32 {
self.display
}
pub fn display_name(&self) -> String {
format!(":{}", self.display)
}
pub fn listeners(&self) -> &[UnixListener] {
&self.listeners
}
// Remove source clones and stop the server before closing the reservation.
pub fn close_listeners(&mut self) {
self.listeners.clear();
self.socket_path = None;
}
pub fn bind_same_display(&mut self) -> io::Result<()> {
#[cfg(test)]
if crate::xwayland::test_hooks::fire(&crate::xwayland::test_hooks::FAIL_REBIND_AFTER) {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
"injected X11 display rebind failure",
));
}
if !self.listeners.is_empty() || self.socket_path.is_some() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"X11 listeners are already bound",
));
}
validate_shared_directory(&self.socket_directory)?;
let path = self.socket_directory.join(format!("X{}", self.display));
let filesystem = UnixListener::bind(&path)?;
let owned_path = CreatedPath::record(path.clone(), &fs::symlink_metadata(&path)?);
let abstract_address = SocketAddr::from_abstract_name(path.as_os_str().as_bytes())?;
let abstract_socket = match UnixListener::bind_addr(&abstract_address) {
Ok(listener) => listener,
Err(error) => {
// Drop the recorded path first so cleanup happens in order, once.
drop(owned_path);
drop(filesystem);
return Err(error);
}
};
self.listeners = vec![filesystem, abstract_socket];
self.socket_path = Some(owned_path);
Ok(())
}
fn release_display(&mut self) {
self.close_listeners();
self.lock_path = None;
}
}
impl Drop for Reservation {
fn drop(&mut self) {
self.release_display();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn shared_root(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("ferese-xwayland-alloc-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
for directory in ["sockets", "locks"] {
fs::create_dir(root.join(directory)).unwrap();
fs::set_permissions(root.join(directory), fs::Permissions::from_mode(0o1777)).unwrap();
}
root
}
#[test]
fn allocates_a_display_with_two_listener_transports() {
let root = shared_root("two-transports");
let reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap();
assert!(reservation.listeners().len() >= 2);
let socket = root.join("sockets").join(format!("X{}", reservation.display_number()));
assert!(socket.exists(), "conventional socket path must exist");
assert!(
root.join("locks")
.join(format!(".X{}-lock", reservation.display_number()))
.exists()
);
}
#[test]
fn skips_an_occupied_display_without_reclaiming_it() {
let root = shared_root("occupied");
let sockets = root.join("sockets");
let locks = root.join("locks");
let first = Reservation::allocate_in(&sockets, &locks, 0..64).unwrap();
let taken = first.display_number();
let second = Reservation::allocate_in(&sockets, &locks, 0..64).unwrap();
assert_ne!(second.display_number(), taken);
assert!(second.display_number() > taken);
assert!(locks.join(format!(".X{taken}-lock")).exists());
assert!(sockets.join(format!("X{taken}")).exists());
}
#[test]
fn an_existing_lock_file_is_left_untouched() {
let root = shared_root("stale-lock");
let locks = root.join("locks");
let stale = locks.join(".X3-lock");
fs::write(&stale, b"9999\n").unwrap();
let reservation = Reservation::allocate_in(&root.join("sockets"), &locks, 0..64).unwrap();
assert_ne!(reservation.display_number(), 3, "occupied number must be skipped");
assert_eq!(fs::read(&stale).unwrap(), b"9999\n", "stale lock must not be reclaimed");
}
#[test]
fn cleanup_removes_paths_this_process_created() {
let root = shared_root("cleanup");
let reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap();
let display = reservation.display_number();
let socket = root.join("sockets").join(format!("X{display}"));
let lock = root.join("locks").join(format!(".X{display}-lock"));
drop(reservation);
assert!(!socket.exists(), "owned socket path must be removed");
assert!(!lock.exists(), "owned lock path must be removed");
}
#[test]
fn close_listeners_keeps_the_display_lock_owned() {
let root = shared_root("close-listeners");
let mut reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap();
let display = reservation.display_number();
reservation.close_listeners();
assert!(reservation.listeners().is_empty());
assert!(root.join("locks").join(format!(".X{display}-lock")).exists());
assert!(!root.join("sockets").join(format!("X{display}")).exists());
}
#[test]
fn retry_rebinds_the_same_display_number() {
let root = shared_root("retry");
let mut reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap();
let display = reservation.display_number();
reservation.close_listeners();
reservation.bind_same_display().unwrap();
assert_eq!(reservation.display_number(), display);
assert!(!reservation.listeners().is_empty());
assert!(root.join("sockets").join(format!("X{display}")).exists());
}
#[test]
fn binding_twice_is_rejected() {
let root = shared_root("double-bind");
let mut reservation = Reservation::allocate_in(&root.join("sockets"), &root.join("locks"), 0..64).unwrap();
reservation.close_listeners();
reservation.bind_same_display().unwrap();
let error = reservation.bind_same_display().unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn an_untrusted_socket_directory_is_rejected() {
let root = std::env::temp_dir().join(format!("ferese-xwayland-untrusted-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
fs::set_permissions(&root, fs::Permissions::from_mode(0o777)).unwrap();
let locks = root.join("locks");
fs::create_dir(&locks).unwrap();
fs::set_permissions(&locks, fs::Permissions::from_mode(0o1777)).unwrap();
let error = Reservation::allocate_in(&root, &locks, 0..64).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert!(error.to_string().contains("not sticky"));
}
#[test]
fn a_missing_socket_directory_is_an_actionable_error() {
let root = shared_root("missing-dir");
let error = Reservation::allocate_in(&root.join("absent"), &root.join("locks"), 0..64).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
#[test]
fn exhausting_the_candidate_range_reports_no_free_display() {
let root = shared_root("exhausted");
let locks = root.join("locks");
for display in 10..13 {
fs::write(locks.join(format!(".X{display}-lock")), b"1\n").unwrap();
}
let error = Reservation::allocate_in(&root.join("sockets"), &locks, 10..13).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AddrInUse);
assert!(error.to_string().contains("no unused X11 display"));
}
}
+1
View File
@@ -7,6 +7,7 @@ use serde_json::Value;
pub mod media; pub mod media;
pub mod theme; pub mod theme;
pub mod xwayland;
pub const VERSION: u32 = 1; pub const VERSION: u32 = 1;
pub const MAX_PAYLOAD_SIZE: usize = 1024 * 1024; pub const MAX_PAYLOAD_SIZE: usize = 1024 * 1024;
+139
View File
@@ -0,0 +1,139 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const STATUS_COMMAND: &str = "xwayland-status";
pub const RETRY_COMMAND: &str = "xwayland-retry";
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum State {
Disabled,
Idle,
Starting,
Running,
Backoff,
Failed,
Stopped,
}
impl State {
pub fn is_inactive(self) -> bool {
matches!(self, Self::Disabled | Self::Idle | Self::Stopped)
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Readiness {
Pending,
Verified,
Rejected,
NotAttempted,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Status {
pub enabled: bool,
pub effective_startup: String,
pub state: State,
pub display: Option<String>,
/// PID of Satellite, not its Xwayland child.
pub satellite_pid: Option<u32>,
pub generation: Option<u64>,
pub readiness: Readiness,
pub recent_failures: u32,
pub restart_required: bool,
pub last_error: Option<String>,
}
impl Status {
pub fn from_value(value: Value) -> serde_json::Result<Self> {
serde_json::from_value(value)
}
pub fn to_value(&self) -> Value {
match serde_json::to_value(self) {
Ok(value) => value,
Err(error) => serde_json::json!({ "error": error.to_string() }),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn states_use_stable_snake_case_names() {
for (state, name) in [
(State::Disabled, "disabled"),
(State::Idle, "idle"),
(State::Starting, "starting"),
(State::Running, "running"),
(State::Backoff, "backoff"),
(State::Failed, "failed"),
(State::Stopped, "stopped"),
] {
assert_eq!(serde_json::to_value(state).unwrap(), json!(name));
}
}
#[test]
fn readiness_values_round_trip() {
for (readiness, name) in [
(Readiness::Pending, "pending"),
(Readiness::Verified, "verified"),
(Readiness::Rejected, "rejected"),
(Readiness::NotAttempted, "not_attempted"),
] {
let encoded = serde_json::to_value(readiness).unwrap();
assert_eq!(encoded, json!(name));
assert_eq!(serde_json::from_value::<Readiness>(encoded).unwrap(), readiness);
}
}
#[test]
fn a_status_snapshot_never_carries_credentials() {
let status = Status {
enabled: true,
effective_startup: "on-demand".to_owned(),
state: State::Running,
display: Some(":7".to_owned()),
satellite_pid: Some(24017),
generation: Some(2),
readiness: Readiness::Verified,
recent_failures: 1,
restart_required: false,
last_error: None,
};
let value = status.to_value();
let text = value.to_string();
assert!(!text.contains("MIT-MAGIC-COOKIE-1"));
assert_eq!(value["satellite_pid"], json!(24017));
assert_eq!(value["display"], json!(":7"));
assert_eq!(value["state"], json!("running"));
assert_eq!(Status::from_value(value).unwrap(), status);
}
#[test]
fn an_unknown_field_from_a_newer_compositor_is_ignored() {
let decoded = Status::from_value(json!({
"enabled": true,
"effective_startup": "eager",
"state": "running",
"display": ":7",
"satellite_pid": 1,
"generation": 1,
"readiness": "verified",
"recent_failures": 0,
"restart_required": false,
"last_error": null,
"future_field": {"anything": true},
}))
.expect("a newer field must not break decoding");
assert_eq!(decoded.state, State::Running);
assert_eq!(decoded.effective_startup, "eager");
}
}
+98 -2
View File
@@ -109,7 +109,7 @@ columns align with the viewport. Wider columns reveal their left edge when
approached from the left and their right edge when approached from the right; approached from the left and their right edge when approached from the right;
an existing view inside a wider column stays in place. `center_on_focus` centers an existing view inside a wider column stays in place. `center_on_focus` centers
the focused column. `paged` packs columns into viewport-sized pages: halves form pairs, thirds the focused column. `paged` packs columns into viewport-sized pages: halves form pairs, thirds
form triples; mixed widths and client minimum sizes determine actual boundaries. form triples; mixed widths and effective minimum sizes determine actual boundaries.
Changing default width preserves manually resized columns. Changing default width preserves manually resized columns.
## Animations ## Animations
@@ -574,10 +574,16 @@ window rules can override that behavior.
| `workspace` | integer > 0 | Leave placement unchanged | | `workspace` | integer > 0 | Leave placement unchanged |
| `floating` | boolean | Leave placement unchanged | | `floating` | boolean | Leave placement unchanged |
| `width`, `height` | numbers > 0 | Application-chosen floating size | | `width`, `height` | numbers > 0 | Application-chosen floating size |
| `min-width`, `min-height` | numbers > 0 | Replaces the client's advertised minimum size for that axis |
| `fullscreen` | boolean | Leave fullscreen state unchanged | | `fullscreen` | boolean | Leave fullscreen state unchanged |
| `block-out-from-screencasts` | boolean | Exclude from captures; enabled by default for Ferese authentication dialogs | | `block-out-from-screencasts` | boolean | Exclude from captures; enabled by default for Ferese authentication dialogs |
| `idle-inhibit` | `none`, `visible`, `fullscreen`, `playing`, `fullscreen-playing` | Automatic fullscreen playback; see [Idle inhibition](#idle-inhibition) | | `idle-inhibit` | `none`, `visible`, `fullscreen`, `playing`, `fullscreen-playing` | Automatic fullscreen playback; see [Idle inhibition](#idle-inhibition) |
`window-rule app-id="spotify" min-width=500 min-height=400` replaces what the
app itself reports as its smallest size, so a configured column width wins over
an app that refuses to shrink (`min-width=1` removes the floor). Each axis is
independent; an omitted axis keeps the client's minimum.
`window-rule app-id="org.example.Private" block-out-from-screencasts=#true` `window-rule app-id="org.example.Private" block-out-from-screencasts=#true`
keeps matching windows visible on the display but omits them (including their keeps matching windows visible on the display but omits them (including their
popups, subsurfaces, shadows and overview previews) from monitor and region popups, subsurfaces, shadows and overview previews) from monitor and region
@@ -603,7 +609,7 @@ part of their parent, then tries saved geometry, then the position with the leas
summed overlap. summed overlap.
Equal-overlap candidates favor the focused window's center. If the size cannot Equal-overlap candidates favor the focused window's center. If the size cannot
fit, a per-output cascade advances by 32 logical pixels and wraps to the work-area fit, a per-output cascade advances by 32 logical pixels and wraps to the work-area
origin. Placement respects layer-shell exclusive zones and client minimum sizes; origin. Placement respects layer-shell exclusive zones and effective minimum sizes;
an oversized window keeps its size with its top-left corner reachable. an oversized window keeps its size with its top-left corner reachable.
Successful move/resize completion saves ordinary floating geometry by app ID in Successful move/resize completion saves ordinary floating geometry by app ID in
@@ -949,6 +955,96 @@ Settings → Windows. `theme.geometry.shell-radius` takes precedence over the ol
none are set, the shell uses 14 px. Legacy clock/note radius fields no longer none are set, the shell uses 14 px. Legacy clock/note radius fields no longer
override shell rounding. override shell rounding.
## X11 support (xwayland-satellite)
Ferese can run legacy X11-only applications through
[xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite).
```kdl
xwayland {
enabled #true
startup on-demand
path "xwayland-satellite"
}
```
| Key | Values | Default | Meaning |
| --- | --- | --- | --- |
| `enabled` | `true` / `false` | `true` | Run the bridge at all. `false` is a complete opt-out. |
| `startup` | `on-demand` / `eager` | `on-demand` | `on-demand` starts Satellite the first time an application connects to the X11 socket. `eager` starts it during session startup. |
| `path` | path to an executable | `xwayland-satellite` | Which binary to run. |
These keys require a new session. Reloading configuration does not restart or
reallocate the X11 service; `feresectl xwayland status` reports
`restart_required` when the running configuration no longer matches the file.
### Requirements and failure behavior
`xwayland-satellite` and an `Xwayland` binary it can find must be installed.
Satellite must be built with its `systemd` feature enabled
(`cargo build --release --features systemd`); that feature sends the `READY=1`
notification Ferese waits for. Upstream's default feature set is empty, so a
default build never sends it and every start ends in a startup failure after
Ferese's fixed 10-second budget, even though the process is running. Being
spawned is not readiness. If any of these are missing, the session still starts
as a normal Wayland desktop and `feresectl xwayland status` explains why X11 is
unavailable. Ferese never publishes a `DISPLAY` value that does not work, so
applications fail cleanly instead of hanging against an endpoint that accepts
nothing.
In a nested session, Ferese's own environment is preserved for backend
initialization, but applications it launches receive the new Ferese endpoints.
The host's `DISPLAY` is never exported to the activation environment of a nested
session, so it cannot leak into services started by your login session.
### Diagnostics and recovery
```text
feresectl xwayland status
feresectl xwayland retry
```
`status` reports whether the service is idle, starting, running, backing off, or
failed, plus the display, the Satellite PID, and whether readiness was verified.
A failed service is not restarted automatically: Ferese retries a small bounded
number of times, then stops and waits, so a broken binary cannot produce a
restart loop. Run `feresectl xwayland retry` once the cause is fixed. The
snapshot never contains the X11 cookie, so it is safe to paste into a bug
report.
`retry` can also refuse to run. If the previous service group cannot be proven
gone — for example it survived the stop sequence — Ferese keeps its cleanup
record instead of starting a second generation that would compete with
survivors for the same display, and the retry fails with a message naming what
still holds the X11 display. The endpoint is not republished in the meantime.
Run the retry again once that group has finished exiting.
Each start uses a fresh, private notification socket and only a `READY=1`
message from the Satellite process that was actually spawned is accepted, so a
stale or unrelated notification cannot be mistaken for readiness.
### Limitations
- X11 applications are ordinary Wayland windows as far as layout, focus, and
window rules are concerned. Rules match the application identity Satellite
reports, so they are configured the same way as for native clients.
- Native Wayland preferences are kept as they are. Ferese does not force GTK,
Qt, Firefox, or Electron onto X11 to demonstrate support.
- Portal dialogs requested with an `x11:` parent are shown **unparented**. The
chooser or consent prompt still opens and authorization is unchanged; Ferese
does not yet translate an X11 window ID into a Wayland export, and inventing a
parent would be a security problem.
- Per-monitor fractional scaling of X11 applications depends on the selected
Satellite build and its Xsettings policy. Text sharpness, pointer coordinates,
and popup placement should be checked on each output profile rather than
assumed.
- Drag and drop between native and X11 applications depends on the Satellite
build and is not part of the initial support claim.
- Sandboxed applications may need explicit permission to reach the display. A
flatpak with only `fallback-x11` gets no X11 socket inside a Wayland session and
cannot read the private `XAUTHORITY` file under `$XDG_RUNTIME_DIR`, so it exits
without a window while the service itself reports healthy. See
[Sandboxed applications](installation.md#sandboxed-applications).
## Power and logout ## Power and logout
Power actions use a centered confirmation. **Super+Shift+E** and Power actions use a centered confirmation. **Super+Shift+E** and
+56
View File
@@ -54,6 +54,62 @@ other distributions; check the [Smithay
dependencies](https://github.com/Smithay/smithay#system-dependencies) for equivalent dependencies](https://github.com/Smithay/smithay#system-dependencies) for equivalent
libraries. libraries.
### X11 support (optional)
To run X11-only applications, Ferese launches
[xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite),
which in turn needs an `Xwayland` binary. Both are runtime dependencies, not
build dependencies, and neither is needed to build Ferese.
- `Xwayland` ships with the X.Org server packages: `xorg-x11-server-Xwayland`
on Fedora and Debian/Ubuntu, `xorg-xwayland` on Arch.
- `xwayland-satellite` is not packaged by most distributions. Build or install
it from its upstream releases **with its `systemd` feature enabled**
(`cargo build --release --features systemd`). That feature is what sends the
`READY=1` readiness notification Ferese waits for. Upstream's default feature
set is empty, so a default build never sends it, Ferese's fixed 10-second
startup budget expires, and the service reports a startup failure instead of
a display. A spawned process that is still alive is not readiness.
Both the compositor's default and the packaged configuration ship with X11
enabled, so a session with the two pieces above needs no configuration change.
If you maintain your own `config.kdl`, the block to keep is
```kdl
xwayland {
enabled #true
startup on-demand
path "xwayland-satellite"
}
```
in `~/.config/ferese/config.kdl` and starting a new session, then check
`feresectl xwayland status`. See
[X11 support](configuration.md#x11-support-xwayland-satellite) for the full key
list, diagnostics, and known limitations.
#### Sandboxed applications
Ferese keeps the X11 cookie in a private file under `$XDG_RUNTIME_DIR`, and
applications inherit it through `XAUTHORITY`. Sandboxed packaging formats do not
all make that path visible, so an X11-only application may fail to start even
though `feresectl xwayland status` reports a running service.
Flatpak masks `/run/user/1000` with the application's own runtime directory, and
a `fallback-x11` permission is not granted inside a Wayland session. Both effects
deny the sandbox access to the display, so the application exits without a
window. Grant the socket explicitly:
```sh
flatpak override --user --socket=x11 com.spotify.Client
```
Snaps under strict confinement usually expose the real `/run/user/1000`, so a
snap that speaks X11 typically connects without extra configuration. That is a
confinement detail rather than a guarantee: if a snap application still cannot
reach the display, check whether its sandbox can read the `XAUTHORITY` path
reported by `feresectl xwayland status`.
### Rust toolchain ### Rust toolchain
Install Rust through [rustup.rs](https://rustup.rs/). The checkout's Install Rust through [rustup.rs](https://rustup.rs/). The checkout's
+100 -1
View File
@@ -19,6 +19,9 @@ fn main() -> Result<(), Box<dyn Error>> {
if env::args().nth(1).as_deref() == Some("theme") { if env::args().nth(1).as_deref() == Some("theme") {
return theme_command(env::args().skip(2).collect()); return theme_command(env::args().skip(2).collect());
} }
if env::args().nth(1).as_deref() == Some("xwayland") {
return xwayland_command(env::args().skip(2).collect());
}
let (command, args) = parse_args(env::args().skip(1))?; let (command, args) = parse_args(env::args().skip(1))?;
let request = Request { let request = Request {
version: VERSION, version: VERSION,
@@ -87,6 +90,40 @@ fn theme_command(args: Vec<String>) -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
fn xwayland_command(args: Vec<String>) -> Result<(), Box<dyn Error>> {
use ferese_ipc::xwayland::{RETRY_COMMAND, STATUS_COMMAND, Status};
let command = match args.as_slice() {
[command] if command == "status" => STATUS_COMMAND,
[command] if command == "retry" => RETRY_COMMAND,
_ => return Err("usage: feresectl xwayland <status|retry>".into()),
};
let request = Request {
version: VERSION,
id: 1,
kind: "command".to_owned(),
command: command.to_owned(),
args: json!({}),
};
let mut stream = UnixStream::connect(socket_path()?)?;
write_frame(&mut stream, &request)?;
let response: Response = read_frame(&mut stream)?;
if let Some(error) = response.error {
return Err(format!("{}: {}", error.code, error.message).into());
}
let status = Status::from_value(response.result.unwrap_or(Value::Null))?;
if command == RETRY_COMMAND {
println!(
"X11 retry requested; current state: {}",
serde_json::to_string(&status.state)?
);
}
println!("{}", serde_json::to_string_pretty(&status)?);
Ok(())
}
// The compositor stages the PNG in a private directory and replies with its // The compositor stages the PNG in a private directory and replies with its
// path. Open it before unlinking so a failure never destroys the only copy, and // path. Open it before unlinking so a failure never destroys the only copy, and
// stream the bytes with explicit writes: println! would corrupt binary output. // stream the bytes with explicit writes: println! would corrupt binary output.
@@ -233,13 +270,75 @@ fn socket_path() -> Result<PathBuf, io::Error> {
} }
fn usage() -> String { fn usage() -> String {
"usage: feresectl media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]\n feresectl outputs\n feresectl output-profiles\n feresectl <output-confirm|output-revert>\n feresectl output-layout <internal-only|external-only|extend|mirror>\n feresectl toggle-display-mode\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|get-idle-inhibition|reload-config>".to_owned() "usage: feresectl media [get|play-pause|next|previous|raise|auto|pin PLAYER|ignore PLAYER|unignore PLAYER]\n feresectl outputs\n feresectl output-profiles\n feresectl <output-confirm|output-revert>\n feresectl output-layout <internal-only|external-only|extend|mirror>\n feresectl toggle-display-mode\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl xwayland <status|retry>\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|get-idle-inhibition|reload-config>".to_owned()
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn xwayland_commands_map_to_the_shared_ipc_command_names() {
assert_eq!(ferese_ipc::xwayland::STATUS_COMMAND, "xwayland-status");
assert_eq!(ferese_ipc::xwayland::RETRY_COMMAND, "xwayland-retry");
assert!(usage().contains("feresectl xwayland <status|retry>"));
}
#[test]
fn the_xwayland_status_snapshot_round_trips_over_the_ipc_framing() {
let status = ferese_ipc::xwayland::Status {
enabled: true,
effective_startup: "on-demand".to_owned(),
state: ferese_ipc::xwayland::State::Running,
display: Some(":7".to_owned()),
satellite_pid: Some(24017),
generation: Some(2),
readiness: ferese_ipc::xwayland::Readiness::Verified,
recent_failures: 1,
restart_required: false,
last_error: None,
};
let request = Request {
version: VERSION,
id: 1,
kind: "command".to_owned(),
command: ferese_ipc::xwayland::STATUS_COMMAND.to_owned(),
args: json!({}),
};
let mut bytes = Vec::new();
write_frame(&mut bytes, &request).expect("encode the request");
let decoded: Request = read_frame(&mut bytes.as_slice()).expect("decode the request");
assert_eq!(decoded.command, "xwayland-status");
let mut reply = Vec::new();
write_frame(
&mut reply,
&Response::success(1, ferese_ipc::xwayland::Status::to_value(&status)),
)
.expect("encode the response");
let response: Response = read_frame(&mut reply.as_slice()).expect("decode the response");
assert!(response.error.is_none());
let decoded = ferese_ipc::xwayland::Status::from_value(response.result.expect("a result"))
.expect("decode the status snapshot");
assert_eq!(decoded, status);
assert_eq!(decoded.state, ferese_ipc::xwayland::State::Running);
}
#[test]
fn a_failed_x11_retry_surfaces_the_compositor_error_code() {
let response = Response::error(1, "x11_unavailable", "X11 is disabled in the configuration");
let mut bytes = Vec::new();
write_frame(&mut bytes, &response).expect("encode");
let decoded: Response = read_frame(&mut bytes.as_slice()).expect("decode");
let error = decoded.error.expect("an error response");
assert_eq!(error.code, "x11_unavailable");
assert!(error.message.contains("disabled"));
}
#[test] #[test]
fn media_commands_route_transport_and_player_preferences() { fn media_commands_route_transport_and_player_preferences() {
assert_eq!(parse_args(["media".into()]).unwrap().0, "media-get"); assert_eq!(parse_args(["media".into()]).unwrap().0, "media-get");
+6
View File
@@ -15,6 +15,12 @@ commands {
mic-mute "wpctl" "set-mute" "@DEFAULT_AUDIO_SOURCE@" "toggle" mic-mute "wpctl" "set-mute" "@DEFAULT_AUDIO_SOURCE@" "toggle"
} }
xwayland {
enabled #true
startup on-demand
path "xwayland-satellite"
}
// These supplement the built-in shortcuts listed in docs/shortcuts.md. // These supplement the built-in shortcuts listed in docs/shortcuts.md.
binding "Super+B" "spawn" "browser" binding "Super+B" "spawn" "browser"
binding "Super+Z" "spawn" "editor" binding "Super+Z" "spawn" "editor"
+16 -4
View File
@@ -29,10 +29,22 @@ if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; 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:-}}
env WAYLAND_DISPLAY="$public_display" dbus-update-activation-environment --systemd \ # Empty assignments replace stale values; D-Bus cannot remove activation variables.
WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || \ activation_vars=(
env WAYLAND_DISPLAY="$public_display" dbus-update-activation-environment \ "WAYLAND_DISPLAY=$public_display"
WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || true "DISPLAY=${DISPLAY:-}"
"XAUTHORITY=${XAUTHORITY:-}"
XDG_CURRENT_DESKTOP
XDG_SESSION_DESKTOP
XDG_SESSION_TYPE
)
if command -v dbus-update-activation-environment >/dev/null; then
if ! env WAYLAND_DISPLAY="$public_display" \
dbus-update-activation-environment --systemd "${activation_vars[@]}"; then
env WAYLAND_DISPLAY="$public_display" \
dbus-update-activation-environment "${activation_vars[@]}" || true
fi
fi
# User buses can outlive logout and may predate a newly installed service directory. # User buses can outlive logout and may predate a newly installed service directory.
if command -v dbus-send >/dev/null; then if command -v dbus-send >/dev/null; then
dbus-send --session --type=method_call --print-reply \ dbus-send --session --type=method_call --print-reply \
+5 -2
View File
@@ -136,8 +136,11 @@ impl cosmic::Application for Consent {
Message::Opened(id) => { Message::Opened(id) => {
let parent = self.prompt.parent.clone(); let parent = self.prompt.parent.clone();
return cosmic::iced::window::run(id, move |window| { return cosmic::iced::window::run(id, move |window| {
let parent = crate::parent::Parent::attach(window, &parent)?.map(Arc::new); let attached = crate::parent::Parent::attach(window, &parent)?;
Ok((parent, ModalMaterial::attach(window))) if let Some(diagnostic) = attached.diagnostic {
eprintln!("ferese portal: {diagnostic}");
}
Ok((attached.parent.map(Arc::new), ModalMaterial::attach(window)))
}) })
.map(|result| cosmic::Action::App(Message::Attached(result))); .map(|result| cosmic::Action::App(Message::Attached(result)));
} }
+146 -9
View File
@@ -13,6 +13,62 @@ use wayland_protocols::xdg::foreign::zv2::client::zxdg_imported_v2;
use wayland_protocols::xdg::foreign::zv2::client::zxdg_imported_v2::ZxdgImportedV2; use wayland_protocols::xdg::foreign::zv2::client::zxdg_imported_v2::ZxdgImportedV2;
use wayland_protocols::xdg::foreign::zv2::client::zxdg_importer_v2::ZxdgImporterV2; use wayland_protocols::xdg::foreign::zv2::client::zxdg_importer_v2::ZxdgImporterV2;
const MAX_PARENT_IDENTIFIER: usize = 4096;
#[derive(Debug, Default)]
pub(crate) struct ParentAttachment {
pub parent: Option<Parent>,
pub diagnostic: Option<String>,
}
fn classify_parent<'a>(parent: &'a str) -> ParentKind<'a> {
if parent.is_empty() {
return ParentKind::None;
}
if parent.len() > MAX_PARENT_IDENTIFIER {
return ParentKind::Unsupported;
}
if let Some(handle) = parent.strip_prefix("wayland:") {
return if handle.is_empty() {
ParentKind::Unsupported
} else {
ParentKind::Wayland(handle)
};
}
if let Some(handle) = parent.strip_prefix("x11:") {
return if is_x11_window_identifier(handle) {
ParentKind::X11
} else {
// A malformed XID is a protocol error, not an X11 parent to
// degrade from: accepting arbitrary text here would let any string
// be reported as an X11 window.
ParentKind::Unsupported
};
}
ParentKind::Unsupported
}
/// Whether `identifier` is a syntactically valid X11 window identifier.
///
/// XIDs are unsigned 32-bit hex, optionally with the `0x` prefix, as X11
/// clients write them (for example `0x2400003` or `1a2b3c`).
fn is_x11_window_identifier(identifier: &str) -> bool {
let digits = identifier
.strip_prefix("0x")
.or_else(|| identifier.strip_prefix("0X"))
.unwrap_or(identifier);
!digits.is_empty() && digits.len() <= 8 && digits.bytes().all(|byte| byte.is_ascii_hexdigit())
}
#[derive(Debug, PartialEq, Eq)]
enum ParentKind<'a> {
None,
Wayland(&'a str),
X11,
Unsupported,
}
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct Parent { pub(crate) struct Parent {
connection: Connection, connection: Connection,
@@ -21,15 +77,25 @@ pub(crate) struct Parent {
} }
impl Parent { impl Parent {
pub(crate) fn attach(window: &dyn cosmic::iced::window::Window, parent: &str) -> Result<Option<Self>, String> { pub(crate) fn attach(window: &dyn cosmic::iced::window::Window, parent: &str) -> Result<ParentAttachment, String> {
use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle}; use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
if parent.is_empty() { let handle = match classify_parent(parent) {
return Ok(None); ParentKind::None => {
return Ok(ParentAttachment::default());
} }
let handle = parent ParentKind::X11 => {
.strip_prefix("wayland:") return Ok(ParentAttachment {
.filter(|handle| !handle.is_empty() && handle.len() <= 4096) parent: None,
.ok_or("Unsupported portal parent window identifier")?; diagnostic: Some(
"showing this dialog unparented: X11 parent windows are not supported yet".to_owned(),
),
});
}
ParentKind::Unsupported => {
return Err("Unsupported portal parent window identifier".into());
}
ParentKind::Wayland(handle) => handle,
};
let display = window.display_handle().map_err(|error| error.to_string())?; let display = window.display_handle().map_err(|error| error.to_string())?;
let surface = window.window_handle().map_err(|error| error.to_string())?; let surface = window.window_handle().map_err(|error| error.to_string())?;
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(surface)) = let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(surface)) =
@@ -65,11 +131,14 @@ impl Parent {
} }
imported.set_parent_of(&surface); imported.set_parent_of(&surface);
connection.flush().map_err(|error| error.to_string())?; connection.flush().map_err(|error| error.to_string())?;
Ok(Some(Self { Ok(ParentAttachment {
parent: Some(Self {
connection, connection,
imported, imported,
importer, importer,
})) }),
diagnostic: None,
})
} }
} }
@@ -114,3 +183,71 @@ impl Dispatch<ZxdgImportedV2, ()> for State {
} }
delegate_noop!(State: ignore ZxdgImporterV2); delegate_noop!(State: ignore ZxdgImporterV2);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_x11_parent_degrades_instead_of_failing_the_request() {
assert!(matches!(classify_parent("x11:0x2400003"), ParentKind::X11));
assert!(matches!(classify_parent("x11:1a2b3c"), ParentKind::X11));
}
#[test]
fn an_x11_parent_is_never_reinterpreted_as_a_wayland_handle() {
match classify_parent("x11:0x2400003") {
ParentKind::Wayland(handle) => panic!("an X11 parent must not become a Wayland handle: {handle}"),
ParentKind::X11 => {}
other => panic!("unexpected classification: {other:?}"),
}
}
#[test]
fn wayland_parents_are_still_imported() {
assert!(matches!(
classify_parent("wayland:handle-1"),
ParentKind::Wayland("handle-1")
));
assert_eq!(classify_parent(""), ParentKind::None);
}
#[test]
fn malformed_x11_window_identifiers_are_errors_not_parents() {
// Anything that is not an X11 window identifier must never be reported
// as one: doing so would turn arbitrary caller text into a window.
for parent in [
"x11:",
"x11:not-a-window",
"x11:0xzzzz",
"x11:0x2400003extra",
"x11:9999999999",
"x11:-1",
] {
assert!(
matches!(classify_parent(parent), ParentKind::Unsupported),
"{parent} must stay a hard error"
);
}
// Every form an X11 client actually produces is still accepted.
for parent in ["x11:0x2400003", "x11:1a2b3c", "x11:0X1", "x11:ffffffff"] {
assert!(
matches!(classify_parent(parent), ParentKind::X11),
"{parent} is a valid window identifier"
);
}
}
#[test]
fn malformed_identifiers_remain_hard_errors() {
for parent in ["x11:", "wayland:", "dbus:", "not-a-parent", "X11:0x1"] {
assert!(
matches!(classify_parent(parent), ParentKind::Unsupported),
"{parent} must stay a hard error"
);
}
let huge = format!("wayland:{}", "a".repeat(MAX_PARENT_IDENTIFIER + 1));
assert!(matches!(classify_parent(&huge), ParentKind::Unsupported));
}
}
+8 -2
View File
@@ -177,8 +177,14 @@ impl cosmic::Application for Picker {
Message::WindowOpened(id) => { Message::WindowOpened(id) => {
let parent = self.prompt.parent.clone(); let parent = self.prompt.parent.clone();
return cosmic::iced::window::run(id, move |window| { return cosmic::iced::window::run(id, move |window| {
let parent = crate::parent::Parent::attach(window, &parent)?.map(std::sync::Arc::new); let attached = crate::parent::Parent::attach(window, &parent)?;
Ok((parent, ferese_theme_client::material::ModalMaterial::attach(window))) if let Some(diagnostic) = attached.diagnostic {
eprintln!("ferese portal: {diagnostic}");
}
Ok((
attached.parent.map(std::sync::Arc::new),
ferese_theme_client::material::ModalMaterial::attach(window),
))
}) })
.map(|result| cosmic::Action::App(Message::Attached(result))); .map(|result| cosmic::Action::App(Message::Attached(result)));
} }
-103
View File
@@ -1,103 +0,0 @@
/* Generated by wayland-scanner 1.26.0 */
/*
* Copyright 2026 Ferese contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*/
#include <stdbool.h>
#include <stdlib.h>
#include <stdint.h>
#include "wayland-util.h"
#ifndef __has_attribute
# define __has_attribute(x) 0 /* Compatibility with non-clang compilers. */
#endif
#if (__has_attribute(visibility) || defined(__GNUC__) && __GNUC__ >= 4)
#define WL_PRIVATE __attribute__ ((visibility("hidden")))
#else
#define WL_PRIVATE
#endif
extern const struct wl_interface ferese_shell_v1_interface;
static const struct wl_interface *ferese_shell_v1_types[] = {
NULL,
NULL,
NULL,
NULL,
NULL,
NULL,
NULL,
NULL,
NULL,
&ferese_shell_v1_interface,
};
static const struct wl_message ferese_shell_manager_v1_requests[] = {
{ "get_shell", "n", ferese_shell_v1_types + 9 },
{ "destroy", "", ferese_shell_v1_types + 0 },
};
WL_PRIVATE const struct wl_interface ferese_shell_manager_v1_interface = {
"ferese_shell_manager_v1", 6,
2, ferese_shell_manager_v1_requests,
0, NULL,
};
static const struct wl_message ferese_shell_v1_requests[] = {
{ "activate_window", "uu", ferese_shell_v1_types + 0 },
{ "close_window", "uu", ferese_shell_v1_types + 0 },
{ "activate_workspace", "uu", ferese_shell_v1_types + 0 },
{ "enter_overview", "", ferese_shell_v1_types + 0 },
{ "exit_overview", "", ferese_shell_v1_types + 0 },
{ "select_overview_window", "uu", ferese_shell_v1_types + 0 },
{ "destroy", "", ferese_shell_v1_types + 0 },
{ "confirm_logout", "3u", ferese_shell_v1_types + 0 },
{ "cancel_logout", "3u", ferese_shell_v1_types + 0 },
{ "confirm_logout_with_inhibitors", "4uuuu", ferese_shell_v1_types + 0 },
};
static const struct wl_message ferese_shell_v1_events[] = {
{ "snapshot_begin", "u", ferese_shell_v1_types + 0 },
{ "window", "uuuussu", ferese_shell_v1_types + 0 },
{ "output", "uusuuu", ferese_shell_v1_types + 0 },
{ "workspace", "uuuusuuuu", ferese_shell_v1_types + 0 },
{ "snapshot_end", "u", ferese_shell_v1_types + 0 },
{ "overview_state", "u", ferese_shell_v1_types + 0 },
{ "request_failed", "uuus", ferese_shell_v1_types + 0 },
{ "capabilities", "u", ferese_shell_v1_types + 0 },
{ "config_begin", "2", ferese_shell_v1_types + 0 },
{ "config_chunk", "2s", ferese_shell_v1_types + 0 },
{ "config_end", "2", ferese_shell_v1_types + 0 },
{ "logout_cancelled", "3u", ferese_shell_v1_types + 0 },
{ "logout_requested", "3us", ferese_shell_v1_types + 0 },
{ "toggle_keybinding_guide", "5s", ferese_shell_v1_types + 0 },
{ "toggle_display_mode", "6s", ferese_shell_v1_types + 0 },
{ "monitor_state_changed", "6", ferese_shell_v1_types + 0 },
};
WL_PRIVATE const struct wl_interface ferese_shell_v1_interface = {
"ferese_shell_v1", 6,
10, ferese_shell_v1_requests,
16, ferese_shell_v1_events,
};
-560
View File
@@ -1,560 +0,0 @@
/* Generated by wayland-scanner 1.26.0 */
#ifndef FERESE_SHELL_V1_CLIENT_PROTOCOL_H
#define FERESE_SHELL_V1_CLIENT_PROTOCOL_H
#include <stdint.h>
#include <stddef.h>
#include "wayland-client.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* @page page_ferese_shell_v1 The ferese_shell_v1 protocol
* @section page_ifaces_ferese_shell_v1 Interfaces
* - @subpage page_iface_ferese_shell_manager_v1 - create a capability-scoped Ferese shell controller
* - @subpage page_iface_ferese_shell_v1 - managed-window metadata and shell control
* @section page_copyright_ferese_shell_v1 Copyright
* <pre>
*
* Copyright 2026 Ferese contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
* </pre>
*/
struct ferese_shell_manager_v1;
struct ferese_shell_v1;
#ifndef FERESE_SHELL_MANAGER_V1_INTERFACE
#define FERESE_SHELL_MANAGER_V1_INTERFACE
/**
* @page page_iface_ferese_shell_manager_v1 ferese_shell_manager_v1
* @section page_iface_ferese_shell_manager_v1_desc Description
*
* This private global is visible only to clients granted the shell-control
* capability. It does not assign a Wayland surface role.
* @section page_iface_ferese_shell_manager_v1_api API
* See @ref iface_ferese_shell_manager_v1.
*/
/**
* @defgroup iface_ferese_shell_manager_v1 The ferese_shell_manager_v1 interface
*
* This private global is visible only to clients granted the shell-control
* capability. It does not assign a Wayland surface role.
*/
extern const struct wl_interface ferese_shell_manager_v1_interface;
#endif
#ifndef FERESE_SHELL_V1_INTERFACE
#define FERESE_SHELL_V1_INTERFACE
/**
* @page page_iface_ferese_shell_v1 ferese_shell_v1
* @section page_iface_ferese_shell_v1_desc Description
*
* Window, workspace, and output identifiers are unsigned 64-bit values
* encoded as high and low 32-bit words. Each snapshot atomically replaces
* the prior shell view after snapshot_end.
* @section page_iface_ferese_shell_v1_api API
* See @ref iface_ferese_shell_v1.
*/
/**
* @defgroup iface_ferese_shell_v1 The ferese_shell_v1 interface
*
* Window, workspace, and output identifiers are unsigned 64-bit values
* encoded as high and low 32-bit words. Each snapshot atomically replaces
* the prior shell view after snapshot_end.
*/
extern const struct wl_interface ferese_shell_v1_interface;
#endif
#define FERESE_SHELL_MANAGER_V1_GET_SHELL 0
#define FERESE_SHELL_MANAGER_V1_DESTROY 1
/**
* @ingroup iface_ferese_shell_manager_v1
*/
#define FERESE_SHELL_MANAGER_V1_GET_SHELL_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_manager_v1
*/
#define FERESE_SHELL_MANAGER_V1_DESTROY_SINCE_VERSION 1
/** @ingroup iface_ferese_shell_manager_v1 */
static inline void
ferese_shell_manager_v1_set_user_data(struct ferese_shell_manager_v1 *ferese_shell_manager_v1, void *user_data)
{
wl_proxy_set_user_data((struct wl_proxy *) ferese_shell_manager_v1, user_data);
}
/** @ingroup iface_ferese_shell_manager_v1 */
static inline void *
ferese_shell_manager_v1_get_user_data(struct ferese_shell_manager_v1 *ferese_shell_manager_v1)
{
return wl_proxy_get_user_data((struct wl_proxy *) ferese_shell_manager_v1);
}
static inline uint32_t
ferese_shell_manager_v1_get_version(struct ferese_shell_manager_v1 *ferese_shell_manager_v1)
{
return wl_proxy_get_version((struct wl_proxy *) ferese_shell_manager_v1);
}
/**
* @ingroup iface_ferese_shell_manager_v1
*/
static inline struct ferese_shell_v1 *
ferese_shell_manager_v1_get_shell(struct ferese_shell_manager_v1 *ferese_shell_manager_v1)
{
struct wl_proxy *id;
id = wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_manager_v1,
FERESE_SHELL_MANAGER_V1_GET_SHELL, &ferese_shell_v1_interface, wl_proxy_get_version((struct wl_proxy *) ferese_shell_manager_v1), 0, NULL);
return (struct ferese_shell_v1 *) id;
}
/**
* @ingroup iface_ferese_shell_manager_v1
*/
static inline void
ferese_shell_manager_v1_destroy(struct ferese_shell_manager_v1 *ferese_shell_manager_v1)
{
wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_manager_v1,
FERESE_SHELL_MANAGER_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_manager_v1), WL_MARSHAL_FLAG_DESTROY);
}
#ifndef FERESE_SHELL_V1_WINDOW_STATE_ENUM
#define FERESE_SHELL_V1_WINDOW_STATE_ENUM
enum ferese_shell_v1_window_state {
FERESE_SHELL_V1_WINDOW_STATE_FOCUSED = 1,
FERESE_SHELL_V1_WINDOW_STATE_URGENT = 2,
FERESE_SHELL_V1_WINDOW_STATE_FULLSCREEN = 4,
FERESE_SHELL_V1_WINDOW_STATE_FLOATING = 8,
};
#endif /* FERESE_SHELL_V1_WINDOW_STATE_ENUM */
#ifndef FERESE_SHELL_V1_FAILED_REQUEST_ENUM
#define FERESE_SHELL_V1_FAILED_REQUEST_ENUM
enum ferese_shell_v1_failed_request {
FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WINDOW = 0,
FERESE_SHELL_V1_FAILED_REQUEST_CLOSE_WINDOW = 1,
FERESE_SHELL_V1_FAILED_REQUEST_SELECT_OVERVIEW_WINDOW = 2,
FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WORKSPACE = 3,
};
#endif /* FERESE_SHELL_V1_FAILED_REQUEST_ENUM */
/**
* @ingroup iface_ferese_shell_v1
* @struct ferese_shell_v1_listener
*/
struct ferese_shell_v1_listener {
/**
*/
void (*snapshot_begin)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
uint32_t serial);
/**
*/
void (*window)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
uint32_t window_hi,
uint32_t window_lo,
uint32_t workspace_hi,
uint32_t workspace_lo,
const char *app_id,
const char *title,
uint32_t state);
/**
* @param focused one when this is the focused output
*/
void (*output)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
uint32_t output_hi,
uint32_t output_lo,
const char *name,
uint32_t active_workspace_hi,
uint32_t active_workspace_lo,
uint32_t focused);
/**
* @param output_hi zero when the workspace has no output
* @param output_lo zero when the workspace has no output
* @param index one-based position on this output
* @param window_count managed windows, including floating windows
* @param visible one when displayed on its output
* @param focused one when visible on the focused output
*/
void (*workspace)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
uint32_t workspace_hi,
uint32_t workspace_lo,
uint32_t output_hi,
uint32_t output_lo,
const char *name,
uint32_t index,
uint32_t window_count,
uint32_t visible,
uint32_t focused);
/**
*/
void (*snapshot_end)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
uint32_t serial);
/**
* @param active one when overview is active
*/
void (*overview_state)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
uint32_t active);
/**
*/
void (*request_failed)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
uint32_t request,
uint32_t object_hi,
uint32_t object_lo,
const char *reason);
/**
* marks completion of initial protocol setup
*
*
*/
void (*capabilities)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
uint32_t version);
/**
* complete validated configuration for live reload
*
* Sent only after the compositor has accepted the complete
* configuration. Followed by bounded UTF-8 chunks and config_end.
* Clients apply only the completed source, preserving their
* current appearance if parsing fails.
* @since 2
*/
void (*config_begin)(void *data,
struct ferese_shell_v1 *ferese_shell_v1);
/**
* @since 2
*/
void (*config_chunk)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
const char *source);
/**
* @since 2
*/
void (*config_end)(void *data,
struct ferese_shell_v1 *ferese_shell_v1);
/**
* @since 3
*/
void (*logout_cancelled)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
uint32_t serial);
/**
* @since 3
*/
void (*logout_requested)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
uint32_t serial,
const char *output_name);
/**
* toggle the shortcut hint on the focused output
*
* Explicit requests are independent of the guide-at-login
* preference.
* @since 5
*/
void (*toggle_keybinding_guide)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
const char *output_name);
/**
* toggle the display mode chooser on the focused output
*
*
* @since 6
*/
void (*toggle_display_mode)(void *data,
struct ferese_shell_v1 *ferese_shell_v1,
const char *output_name);
/**
* physical monitor configuration changed
*
* Query the output IPC inventory after reconciliation. This
* includes disabled monitors and mirror targets without a logical
* wl_output.
* @since 6
*/
void (*monitor_state_changed)(void *data,
struct ferese_shell_v1 *ferese_shell_v1);
};
/**
* @ingroup iface_ferese_shell_v1
*/
static inline int
ferese_shell_v1_add_listener(struct ferese_shell_v1 *ferese_shell_v1,
const struct ferese_shell_v1_listener *listener, void *data)
{
return wl_proxy_add_listener((struct wl_proxy *) ferese_shell_v1,
(void (**)(void)) listener, data);
}
#define FERESE_SHELL_V1_ACTIVATE_WINDOW 0
#define FERESE_SHELL_V1_CLOSE_WINDOW 1
#define FERESE_SHELL_V1_ACTIVATE_WORKSPACE 2
#define FERESE_SHELL_V1_ENTER_OVERVIEW 3
#define FERESE_SHELL_V1_EXIT_OVERVIEW 4
#define FERESE_SHELL_V1_SELECT_OVERVIEW_WINDOW 5
#define FERESE_SHELL_V1_DESTROY 6
#define FERESE_SHELL_V1_CONFIRM_LOGOUT 7
#define FERESE_SHELL_V1_CANCEL_LOGOUT 8
#define FERESE_SHELL_V1_CONFIRM_LOGOUT_WITH_INHIBITORS 9
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_SNAPSHOT_BEGIN_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_WINDOW_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_OUTPUT_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_WORKSPACE_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_SNAPSHOT_END_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_OVERVIEW_STATE_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_REQUEST_FAILED_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CAPABILITIES_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CONFIG_BEGIN_SINCE_VERSION 2
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CONFIG_CHUNK_SINCE_VERSION 2
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CONFIG_END_SINCE_VERSION 2
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_LOGOUT_CANCELLED_SINCE_VERSION 3
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_LOGOUT_REQUESTED_SINCE_VERSION 3
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_TOGGLE_KEYBINDING_GUIDE_SINCE_VERSION 5
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_TOGGLE_DISPLAY_MODE_SINCE_VERSION 6
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_MONITOR_STATE_CHANGED_SINCE_VERSION 6
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_ACTIVATE_WINDOW_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CLOSE_WINDOW_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_ACTIVATE_WORKSPACE_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_ENTER_OVERVIEW_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_EXIT_OVERVIEW_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_SELECT_OVERVIEW_WINDOW_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_DESTROY_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CONFIRM_LOGOUT_SINCE_VERSION 3
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CANCEL_LOGOUT_SINCE_VERSION 3
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CONFIRM_LOGOUT_WITH_INHIBITORS_SINCE_VERSION 4
/** @ingroup iface_ferese_shell_v1 */
static inline void
ferese_shell_v1_set_user_data(struct ferese_shell_v1 *ferese_shell_v1, void *user_data)
{
wl_proxy_set_user_data((struct wl_proxy *) ferese_shell_v1, user_data);
}
/** @ingroup iface_ferese_shell_v1 */
static inline void *
ferese_shell_v1_get_user_data(struct ferese_shell_v1 *ferese_shell_v1)
{
return wl_proxy_get_user_data((struct wl_proxy *) ferese_shell_v1);
}
static inline uint32_t
ferese_shell_v1_get_version(struct ferese_shell_v1 *ferese_shell_v1)
{
return wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1);
}
/**
* @ingroup iface_ferese_shell_v1
*/
static inline void
ferese_shell_v1_activate_window(struct ferese_shell_v1 *ferese_shell_v1, uint32_t window_hi, uint32_t window_lo)
{
wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1,
FERESE_SHELL_V1_ACTIVATE_WINDOW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, window_hi, window_lo);
}
/**
* @ingroup iface_ferese_shell_v1
*/
static inline void
ferese_shell_v1_close_window(struct ferese_shell_v1 *ferese_shell_v1, uint32_t window_hi, uint32_t window_lo)
{
wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1,
FERESE_SHELL_V1_CLOSE_WINDOW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, window_hi, window_lo);
}
/**
* @ingroup iface_ferese_shell_v1
*/
static inline void
ferese_shell_v1_activate_workspace(struct ferese_shell_v1 *ferese_shell_v1, uint32_t workspace_hi, uint32_t workspace_lo)
{
wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1,
FERESE_SHELL_V1_ACTIVATE_WORKSPACE, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, workspace_hi, workspace_lo);
}
/**
* @ingroup iface_ferese_shell_v1
*/
static inline void
ferese_shell_v1_enter_overview(struct ferese_shell_v1 *ferese_shell_v1)
{
wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1,
FERESE_SHELL_V1_ENTER_OVERVIEW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0);
}
/**
* @ingroup iface_ferese_shell_v1
*/
static inline void
ferese_shell_v1_exit_overview(struct ferese_shell_v1 *ferese_shell_v1)
{
wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1,
FERESE_SHELL_V1_EXIT_OVERVIEW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0);
}
/**
* @ingroup iface_ferese_shell_v1
*/
static inline void
ferese_shell_v1_select_overview_window(struct ferese_shell_v1 *ferese_shell_v1, uint32_t window_hi, uint32_t window_lo)
{
wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1,
FERESE_SHELL_V1_SELECT_OVERVIEW_WINDOW, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, window_hi, window_lo);
}
/**
* @ingroup iface_ferese_shell_v1
*/
static inline void
ferese_shell_v1_destroy(struct ferese_shell_v1 *ferese_shell_v1)
{
wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1,
FERESE_SHELL_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), WL_MARSHAL_FLAG_DESTROY);
}
/**
* @ingroup iface_ferese_shell_v1
*/
static inline void
ferese_shell_v1_confirm_logout(struct ferese_shell_v1 *ferese_shell_v1, uint32_t serial)
{
wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1,
FERESE_SHELL_V1_CONFIRM_LOGOUT, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, serial);
}
/**
* @ingroup iface_ferese_shell_v1
*/
static inline void
ferese_shell_v1_cancel_logout(struct ferese_shell_v1 *ferese_shell_v1, uint32_t serial)
{
wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1,
FERESE_SHELL_V1_CANCEL_LOGOUT, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, serial);
}
/**
* @ingroup iface_ferese_shell_v1
*/
static inline void
ferese_shell_v1_confirm_logout_with_inhibitors(struct ferese_shell_v1 *ferese_shell_v1, uint32_t serial, uint32_t inhibitor_revision, uint32_t query_token, uint32_t force)
{
wl_proxy_marshal_flags((struct wl_proxy *) ferese_shell_v1,
FERESE_SHELL_V1_CONFIRM_LOGOUT_WITH_INHIBITORS, NULL, wl_proxy_get_version((struct wl_proxy *) ferese_shell_v1), 0, serial, inhibitor_revision, query_token, force);
}
#ifdef __cplusplus
}
#endif
#endif
-561
View File
@@ -1,561 +0,0 @@
/* Generated by wayland-scanner 1.26.0 */
#ifndef FERESE_SHELL_V1_SERVER_PROTOCOL_H
#define FERESE_SHELL_V1_SERVER_PROTOCOL_H
#include <stdint.h>
#include <stddef.h>
#include "wayland-server.h"
#ifdef __cplusplus
extern "C" {
#endif
struct wl_client;
struct wl_resource;
/**
* @page page_ferese_shell_v1 The ferese_shell_v1 protocol
* @section page_ifaces_ferese_shell_v1 Interfaces
* - @subpage page_iface_ferese_shell_manager_v1 - create a capability-scoped Ferese shell controller
* - @subpage page_iface_ferese_shell_v1 - managed-window metadata and shell control
* @section page_copyright_ferese_shell_v1 Copyright
* <pre>
*
* Copyright 2026 Ferese contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
* </pre>
*/
struct ferese_shell_manager_v1;
struct ferese_shell_v1;
#ifndef FERESE_SHELL_MANAGER_V1_INTERFACE
#define FERESE_SHELL_MANAGER_V1_INTERFACE
/**
* @page page_iface_ferese_shell_manager_v1 ferese_shell_manager_v1
* @section page_iface_ferese_shell_manager_v1_desc Description
*
* This private global is visible only to clients granted the shell-control
* capability. It does not assign a Wayland surface role.
* @section page_iface_ferese_shell_manager_v1_api API
* See @ref iface_ferese_shell_manager_v1.
*/
/**
* @defgroup iface_ferese_shell_manager_v1 The ferese_shell_manager_v1 interface
*
* This private global is visible only to clients granted the shell-control
* capability. It does not assign a Wayland surface role.
*/
extern const struct wl_interface ferese_shell_manager_v1_interface;
#endif
#ifndef FERESE_SHELL_V1_INTERFACE
#define FERESE_SHELL_V1_INTERFACE
/**
* @page page_iface_ferese_shell_v1 ferese_shell_v1
* @section page_iface_ferese_shell_v1_desc Description
*
* Window, workspace, and output identifiers are unsigned 64-bit values
* encoded as high and low 32-bit words. Each snapshot atomically replaces
* the prior shell view after snapshot_end.
* @section page_iface_ferese_shell_v1_api API
* See @ref iface_ferese_shell_v1.
*/
/**
* @defgroup iface_ferese_shell_v1 The ferese_shell_v1 interface
*
* Window, workspace, and output identifiers are unsigned 64-bit values
* encoded as high and low 32-bit words. Each snapshot atomically replaces
* the prior shell view after snapshot_end.
*/
extern const struct wl_interface ferese_shell_v1_interface;
#endif
/**
* @ingroup iface_ferese_shell_manager_v1
* @struct ferese_shell_manager_v1_interface
*/
struct ferese_shell_manager_v1_interface {
/**
*/
void (*get_shell)(struct wl_client *client,
struct wl_resource *resource,
uint32_t id);
/**
*/
void (*destroy)(struct wl_client *client,
struct wl_resource *resource);
};
/**
* @ingroup iface_ferese_shell_manager_v1
*/
#define FERESE_SHELL_MANAGER_V1_GET_SHELL_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_manager_v1
*/
#define FERESE_SHELL_MANAGER_V1_DESTROY_SINCE_VERSION 1
#ifndef FERESE_SHELL_V1_WINDOW_STATE_ENUM
#define FERESE_SHELL_V1_WINDOW_STATE_ENUM
enum ferese_shell_v1_window_state {
FERESE_SHELL_V1_WINDOW_STATE_FOCUSED = 1,
FERESE_SHELL_V1_WINDOW_STATE_URGENT = 2,
FERESE_SHELL_V1_WINDOW_STATE_FULLSCREEN = 4,
FERESE_SHELL_V1_WINDOW_STATE_FLOATING = 8,
};
#endif /* FERESE_SHELL_V1_WINDOW_STATE_ENUM */
#ifndef FERESE_SHELL_V1_WINDOW_STATE_ENUM_IS_VALID
#define FERESE_SHELL_V1_WINDOW_STATE_ENUM_IS_VALID
/**
* @ingroup iface_ferese_shell_v1
* Validate a ferese_shell_v1 window_state value.
*
* @return true on success, false on error.
* @ref ferese_shell_v1_window_state
*/
static inline bool
ferese_shell_v1_window_state_is_valid(uint32_t value, uint32_t version) {
uint32_t valid = 0;
if (version >= 1)
valid |= FERESE_SHELL_V1_WINDOW_STATE_FOCUSED;
if (version >= 1)
valid |= FERESE_SHELL_V1_WINDOW_STATE_URGENT;
if (version >= 1)
valid |= FERESE_SHELL_V1_WINDOW_STATE_FULLSCREEN;
if (version >= 1)
valid |= FERESE_SHELL_V1_WINDOW_STATE_FLOATING;
return (value & ~valid) == 0;
}
#endif /* FERESE_SHELL_V1_WINDOW_STATE_ENUM_IS_VALID */
#ifndef FERESE_SHELL_V1_FAILED_REQUEST_ENUM
#define FERESE_SHELL_V1_FAILED_REQUEST_ENUM
enum ferese_shell_v1_failed_request {
FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WINDOW = 0,
FERESE_SHELL_V1_FAILED_REQUEST_CLOSE_WINDOW = 1,
FERESE_SHELL_V1_FAILED_REQUEST_SELECT_OVERVIEW_WINDOW = 2,
FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WORKSPACE = 3,
};
#endif /* FERESE_SHELL_V1_FAILED_REQUEST_ENUM */
#ifndef FERESE_SHELL_V1_FAILED_REQUEST_ENUM_IS_VALID
#define FERESE_SHELL_V1_FAILED_REQUEST_ENUM_IS_VALID
/**
* @ingroup iface_ferese_shell_v1
* Validate a ferese_shell_v1 failed_request value.
*
* @return true on success, false on error.
* @ref ferese_shell_v1_failed_request
*/
static inline bool
ferese_shell_v1_failed_request_is_valid(uint32_t value, uint32_t version) {
switch (value) {
case FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WINDOW:
return version >= 1;
case FERESE_SHELL_V1_FAILED_REQUEST_CLOSE_WINDOW:
return version >= 1;
case FERESE_SHELL_V1_FAILED_REQUEST_SELECT_OVERVIEW_WINDOW:
return version >= 1;
case FERESE_SHELL_V1_FAILED_REQUEST_ACTIVATE_WORKSPACE:
return version >= 1;
default:
return false;
}
}
#endif /* FERESE_SHELL_V1_FAILED_REQUEST_ENUM_IS_VALID */
/**
* @ingroup iface_ferese_shell_v1
* @struct ferese_shell_v1_interface
*/
struct ferese_shell_v1_interface {
/**
*/
void (*activate_window)(struct wl_client *client,
struct wl_resource *resource,
uint32_t window_hi,
uint32_t window_lo);
/**
*/
void (*close_window)(struct wl_client *client,
struct wl_resource *resource,
uint32_t window_hi,
uint32_t window_lo);
/**
*/
void (*activate_workspace)(struct wl_client *client,
struct wl_resource *resource,
uint32_t workspace_hi,
uint32_t workspace_lo);
/**
*/
void (*enter_overview)(struct wl_client *client,
struct wl_resource *resource);
/**
*/
void (*exit_overview)(struct wl_client *client,
struct wl_resource *resource);
/**
*/
void (*select_overview_window)(struct wl_client *client,
struct wl_resource *resource,
uint32_t window_hi,
uint32_t window_lo);
/**
*/
void (*destroy)(struct wl_client *client,
struct wl_resource *resource);
/**
* @since 3
*/
void (*confirm_logout)(struct wl_client *client,
struct wl_resource *resource,
uint32_t serial);
/**
* @since 3
*/
void (*cancel_logout)(struct wl_client *client,
struct wl_resource *resource,
uint32_t serial);
/**
* @since 4
*/
void (*confirm_logout_with_inhibitors)(struct wl_client *client,
struct wl_resource *resource,
uint32_t serial,
uint32_t inhibitor_revision,
uint32_t query_token,
uint32_t force);
};
#define FERESE_SHELL_V1_SNAPSHOT_BEGIN 0
#define FERESE_SHELL_V1_WINDOW 1
#define FERESE_SHELL_V1_OUTPUT 2
#define FERESE_SHELL_V1_WORKSPACE 3
#define FERESE_SHELL_V1_SNAPSHOT_END 4
#define FERESE_SHELL_V1_OVERVIEW_STATE 5
#define FERESE_SHELL_V1_REQUEST_FAILED 6
#define FERESE_SHELL_V1_CAPABILITIES 7
#define FERESE_SHELL_V1_CONFIG_BEGIN 8
#define FERESE_SHELL_V1_CONFIG_CHUNK 9
#define FERESE_SHELL_V1_CONFIG_END 10
#define FERESE_SHELL_V1_LOGOUT_CANCELLED 11
#define FERESE_SHELL_V1_LOGOUT_REQUESTED 12
#define FERESE_SHELL_V1_TOGGLE_KEYBINDING_GUIDE 13
#define FERESE_SHELL_V1_TOGGLE_DISPLAY_MODE 14
#define FERESE_SHELL_V1_MONITOR_STATE_CHANGED 15
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_SNAPSHOT_BEGIN_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_WINDOW_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_OUTPUT_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_WORKSPACE_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_SNAPSHOT_END_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_OVERVIEW_STATE_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_REQUEST_FAILED_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CAPABILITIES_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CONFIG_BEGIN_SINCE_VERSION 2
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CONFIG_CHUNK_SINCE_VERSION 2
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CONFIG_END_SINCE_VERSION 2
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_LOGOUT_CANCELLED_SINCE_VERSION 3
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_LOGOUT_REQUESTED_SINCE_VERSION 3
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_TOGGLE_KEYBINDING_GUIDE_SINCE_VERSION 5
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_TOGGLE_DISPLAY_MODE_SINCE_VERSION 6
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_MONITOR_STATE_CHANGED_SINCE_VERSION 6
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_ACTIVATE_WINDOW_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CLOSE_WINDOW_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_ACTIVATE_WORKSPACE_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_ENTER_OVERVIEW_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_EXIT_OVERVIEW_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_SELECT_OVERVIEW_WINDOW_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_DESTROY_SINCE_VERSION 1
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CONFIRM_LOGOUT_SINCE_VERSION 3
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CANCEL_LOGOUT_SINCE_VERSION 3
/**
* @ingroup iface_ferese_shell_v1
*/
#define FERESE_SHELL_V1_CONFIRM_LOGOUT_WITH_INHIBITORS_SINCE_VERSION 4
/**
* @ingroup iface_ferese_shell_v1
* Sends an snapshot_begin event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_snapshot_begin(struct wl_resource *resource_, uint32_t serial)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_SNAPSHOT_BEGIN, serial);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an window event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_window(struct wl_resource *resource_, uint32_t window_hi, uint32_t window_lo, uint32_t workspace_hi, uint32_t workspace_lo, const char *app_id, const char *title, uint32_t state)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_WINDOW, window_hi, window_lo, workspace_hi, workspace_lo, app_id, title, state);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an output event to the client owning the resource.
* @param resource_ The client's resource
* @param focused one when this is the focused output
*/
static inline void
ferese_shell_v1_send_output(struct wl_resource *resource_, uint32_t output_hi, uint32_t output_lo, const char *name, uint32_t active_workspace_hi, uint32_t active_workspace_lo, uint32_t focused)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_OUTPUT, output_hi, output_lo, name, active_workspace_hi, active_workspace_lo, focused);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an workspace event to the client owning the resource.
* @param resource_ The client's resource
* @param output_hi zero when the workspace has no output
* @param output_lo zero when the workspace has no output
* @param index one-based position on this output
* @param window_count managed windows, including floating windows
* @param visible one when displayed on its output
* @param focused one when visible on the focused output
*/
static inline void
ferese_shell_v1_send_workspace(struct wl_resource *resource_, uint32_t workspace_hi, uint32_t workspace_lo, uint32_t output_hi, uint32_t output_lo, const char *name, uint32_t index, uint32_t window_count, uint32_t visible, uint32_t focused)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_WORKSPACE, workspace_hi, workspace_lo, output_hi, output_lo, name, index, window_count, visible, focused);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an snapshot_end event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_snapshot_end(struct wl_resource *resource_, uint32_t serial)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_SNAPSHOT_END, serial);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an overview_state event to the client owning the resource.
* @param resource_ The client's resource
* @param active one when overview is active
*/
static inline void
ferese_shell_v1_send_overview_state(struct wl_resource *resource_, uint32_t active)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_OVERVIEW_STATE, active);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an request_failed event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_request_failed(struct wl_resource *resource_, uint32_t request, uint32_t object_hi, uint32_t object_lo, const char *reason)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_REQUEST_FAILED, request, object_hi, object_lo, reason);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an capabilities event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_capabilities(struct wl_resource *resource_, uint32_t version)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_CAPABILITIES, version);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an config_begin event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_config_begin(struct wl_resource *resource_)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_CONFIG_BEGIN);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an config_chunk event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_config_chunk(struct wl_resource *resource_, const char *source)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_CONFIG_CHUNK, source);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an config_end event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_config_end(struct wl_resource *resource_)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_CONFIG_END);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an logout_cancelled event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_logout_cancelled(struct wl_resource *resource_, uint32_t serial)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_LOGOUT_CANCELLED, serial);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an logout_requested event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_logout_requested(struct wl_resource *resource_, uint32_t serial, const char *output_name)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_LOGOUT_REQUESTED, serial, output_name);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an toggle_keybinding_guide event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_toggle_keybinding_guide(struct wl_resource *resource_, const char *output_name)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_TOGGLE_KEYBINDING_GUIDE, output_name);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an toggle_display_mode event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_toggle_display_mode(struct wl_resource *resource_, const char *output_name)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_TOGGLE_DISPLAY_MODE, output_name);
}
/**
* @ingroup iface_ferese_shell_v1
* Sends an monitor_state_changed event to the client owning the resource.
* @param resource_ The client's resource
*/
static inline void
ferese_shell_v1_send_monitor_state_changed(struct wl_resource *resource_)
{
wl_resource_post_event(resource_, FERESE_SHELL_V1_MONITOR_STATE_CHANGED);
}
#ifdef __cplusplus
}
#endif
#endif
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Fake Satellite for child-launch and notification tests; it does not serve X11."""
import ctypes
import errno
import json
import os
import signal
import socket
import sys
import time
SO_ACCEPTCONN = 30
_LIBC = ctypes.CDLL(None, use_errno=True)
_LIBC.getsockopt.argtypes = [
ctypes.c_int, ctypes.c_int, ctypes.c_int,
ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint),
]
RECORD = os.environ.get('FAKE_SATELLITE_RECORD', '')
READY = os.environ.get('FAKE_SATELLITE_READY', '1')
READY_DELAY = float(os.environ.get('FAKE_SATELLITE_READY_DELAY', '0'))
EXIT_BEFORE_READY = os.environ.get('FAKE_SATELLITE_EXIT_BEFORE_READY', '0') == '1'
EXIT_AFTER_READY = os.environ.get('FAKE_SATELLITE_EXIT_AFTER_READY', '0') == '1'
FORK_NOTIFY = os.environ.get('FAKE_SATELLITE_FORK_NOTIFY', '0') == '1'
IGNORE_SIGTERM = os.environ.get('FAKE_SATELLITE_IGNORE_SIGTERM', '0') == '1'
HOLD_FDS = os.environ.get('FAKE_SATELLITE_HOLD_FDS', '0') == '1'
LINGER = float(os.environ.get('FAKE_SATELLITE_LINGER', '30'))
def parse_args(argv):
display = None
authority = None
listenfds = []
index = 0
while index < len(argv):
argument = argv[index]
if display is None and not argument.startswith('-'):
display = argument
index += 1
continue
if argument == '-auth':
index += 1
authority = argv[index]
elif argument == '-nolisten':
index += 1
if argv[index] != 'tcp':
sys.exit('-nolisten must be followed by tcp')
elif argument == '-listenfd':
index += 1
listenfds.append(int(argv[index]))
else:
sys.exit(f'unexpected argument: {argument}')
index += 1
if display is None or authority is None:
sys.exit('missing display or -auth')
if not listenfds:
sys.exit('expected at least one -listenfd')
if len(set(listenfds)) != len(listenfds):
sys.exit('duplicate -listenfd')
return display, authority, listenfds
# The scan's own descriptor and entries that vanish mid-scan are not held descriptors.
def inherited_fds():
inherited = []
with os.scandir('/proc/self/fd') as entries:
for entry in entries:
try:
fd = int(entry.name)
except ValueError:
continue
try:
target = os.readlink(entry.path)
except OSError:
continue
if target == '/proc/self/fd':
continue
inherited.append(fd)
return sorted(inherited)
# Compare the link target: an fd number alone can be reused by an unrelated descriptor.
def sentinel_is_leaked(inherited):
sentinel = os.environ.get('FAKE_SATELLITE_SENTINEL_FD')
if not sentinel:
return False
try:
fd = int(sentinel)
except ValueError:
return False
target = os.environ.get('FAKE_SATELLITE_SENTINEL_TARGET')
if target:
try:
return os.readlink(f'/proc/self/fd/{fd}') == target
except OSError:
return False
return fd in inherited
def describe_fds(listenfds):
inherited = inherited_fds()
sentinel = os.environ.get('FAKE_SATELLITE_SENTINEL_FD')
return {
'inherited_fds': inherited,
'listenfds': listenfds,
'sentinel_fd': int(sentinel) if sentinel else None,
'sentinel_leaked': sentinel_is_leaked(inherited),
'listening': {
str(fd): is_listening(fd) for fd in listenfds
},
'all_listening': all(is_listening(fd) for fd in listenfds),
}
def is_listening(fd):
value = ctypes.c_int(0)
length = ctypes.c_uint(ctypes.sizeof(value))
if _LIBC.getsockopt(
fd,
socket.SOL_SOCKET,
SO_ACCEPTCONN,
ctypes.byref(value),
ctypes.byref(length),
) != 0:
return False
return value.value == 1
def notify(payload):
notify_socket = os.environ.get('NOTIFY_SOCKET')
if not notify_socket:
return False
address = notify_socket[1:] if notify_socket.startswith('@') else notify_socket
sender = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
try:
sender.connect(address)
sender.sendall(payload)
return True
except OSError as error:
print(f'fake-satellite: notify failed: {error}', file=sys.stderr)
return False
finally:
sender.close()
# The descendant never exits on its own: a live holder after the leader is gone means group cleanup failed.
def hold_descriptors(listenfds):
child = os.fork()
if child == 0:
for fd in listenfds:
try:
os.dup2(fd, fd)
except OSError:
pass
while True:
time.sleep(3600)
return child
def main():
display, authority, listenfds = parse_args(sys.argv[1:])
if IGNORE_SIGTERM:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
holder_pid = None
if HOLD_FDS:
holder_pid = hold_descriptors(listenfds)
if RECORD:
record = {
'pid': os.getpid(),
'display': display,
'authority': authority,
'argv': sys.argv[1:],
'env': {
key: os.environ.get(key)
for key in ('DISPLAY', 'XAUTHORITY', 'WAYLAND_DISPLAY',
'WAYLAND_SOCKET', 'FERESE_PUBLIC_WAYLAND_DISPLAY',
'FERESE_SHELL_CONTROL_SOCKET')
},
}
record.update(describe_fds(listenfds))
record['holder_pid'] = holder_pid
with open(RECORD, 'a', encoding='utf-8') as handle:
handle.write(json.dumps(record) + '\n')
if EXIT_BEFORE_READY:
sys.exit(int(os.environ.get('FAKE_SATELLITE_EXIT_CODE', '17')))
if READY_DELAY:
time.sleep(READY_DELAY)
if READY == '1':
payload = b'READY=1'
elif READY == 'child':
payload = b'READY=1'
elif READY == 'garbage':
payload = b'READY'
elif READY == 'truncated':
# Oversize the buffer so the kernel really reports MSG_TRUNC; a short
# payload would only be a malformed line.
payload = b'READY=1' + b'X' * 4096
else:
payload = b''
if payload:
if FORK_NOTIFY or READY == 'child':
pid = os.fork()
if pid == 0:
notify(payload)
os._exit(0)
os.waitpid(pid, 0)
else:
notify(payload)
if EXIT_AFTER_READY:
sys.exit(int(os.environ.get('FAKE_SATELLITE_EXIT_CODE', '19')))
deadline = time.monotonic() + LINGER
while time.monotonic() < deadline:
time.sleep(0.05)
sys.exit(0)
if __name__ == '__main__':
try:
main()
except OSError as error:
if error.errno != errno.EINTR:
raise
+51 -3
View File
@@ -12,13 +12,16 @@ SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-session-shell'
class SessionStartupTest(unittest.TestCase): class SessionStartupTest(unittest.TestCase):
def run_session(self, direct=True, fail_target=False, shell_status=0, terminate=False): def run_session(self, direct=True, fail_target=False, shell_status=0, terminate=False,
display=None, xauthority=None, extra_env=None):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
helper = root / 'ferese-session-shell' helper = root / 'ferese-session-shell'
shutil.copyfile(SCRIPT, helper) shutil.copyfile(SCRIPT, helper)
programs = { programs = {
'dbus-update-activation-environment': 'printf "import:%s\\n" "$WAYLAND_DISPLAY" >> "$TEST_ROOT/events"\n', 'dbus-update-activation-environment': (
'printf "import:%s\\n" "$WAYLAND_DISPLAY" >> "$TEST_ROOT/events"\n'
'printf "vars:%s\\n" "$*" >> "$TEST_ROOT/events"\n'),
'dbus-send': 'echo reload-bus >> "$TEST_ROOT/events"\n', 'dbus-send': 'echo reload-bus >> "$TEST_ROOT/events"\n',
'systemctl': 'echo "$*" >> "$TEST_ROOT/events"\nif [[ $* == "--user start ferese-session.target" && $TEST_FAIL == 1 ]]; then exit 1; fi\n', 'systemctl': 'echo "$*" >> "$TEST_ROOT/events"\nif [[ $* == "--user start ferese-session.target" && $TEST_FAIL == 1 ]]; then exit 1; fi\n',
'ferese-polkit-agent': 'exec sleep 30\n', 'ferese-polkit-agent': 'exec sleep 30\n',
@@ -32,6 +35,13 @@ class SessionStartupTest(unittest.TestCase):
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)))
env.pop('DISPLAY', None)
env.pop('XAUTHORITY', None)
if display is not None:
env['DISPLAY'] = display
if xauthority is not None:
env['XAUTHORITY'] = xauthority
env.update(extra_env or {})
child = subprocess.Popen(['bash', str(helper)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) child = subprocess.Popen(['bash', str(helper)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if terminate: if terminate:
deadline = time.monotonic() + 3 deadline = time.monotonic() + 3
@@ -44,14 +54,52 @@ class SessionStartupTest(unittest.TestCase):
child.communicate(timeout=4) child.communicate(timeout=4)
return child.returncode, (root / 'events').read_text().splitlines() return child.returncode, (root / 'events').read_text().splitlines()
@staticmethod
def imported_vars(events):
for line in events:
if line.startswith('vars:'):
return line[len('vars:'):].split(' ')
raise AssertionError('no activation-environment import recorded')
def test_direct_session_imports_public_display_before_activation_and_cleans_up(self): def test_direct_session_imports_public_display_before_activation_and_cleans_up(self):
status, events = self.run_session(shell_status=7) status, events = self.run_session(shell_status=7)
self.assertEqual(status, 7) self.assertEqual(status, 7)
self.assertEqual(events, ['import:public-display', 'reload-bus', '--user daemon-reload', self.assertEqual([e for e in events if e.startswith('import:')], ['import:public-display'])
self.assertEqual([e for e in events if not e.startswith(('import:', 'vars:'))],
['reload-bus', '--user daemon-reload',
'--user start ferese-session.target', '--user start ferese-session.target',
'--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_a_managed_x11_endpoint_is_imported_for_dbus_activators(self):
status, events = self.run_session(display=':7', xauthority='/run/user/1000/ferese-xauth-abcd')
self.assertEqual(status, 0)
imported = self.imported_vars(events)
self.assertIn('DISPLAY=:7', imported)
self.assertIn('XAUTHORITY=/run/user/1000/ferese-xauth-abcd', imported)
self.assertIn('WAYLAND_DISPLAY=public-display', imported)
def test_a_session_without_x11_imports_empty_values_instead_of_omitting_them(self):
status, events = self.run_session()
self.assertEqual(status, 0)
imported = self.imported_vars(events)
self.assertIn('DISPLAY=', imported)
self.assertIn('XAUTHORITY=', imported)
def test_activation_import_never_leaks_the_private_socket_or_cookies(self):
status, events = self.run_session(
display=':7', xauthority='/run/user/1000/ferese-xauth-abcd',
extra_env={'WAYLAND_SOCKET': '77',
'FERESE_SHELL_CONTROL_SOCKET': '/run/user/1000/ferese/shell.sock',
'FERESE_PRIVATE_X11_COOKIE': 'deadbeefdeadbeefdeadbeefdeadbeef'})
self.assertEqual(status, 0)
imported = self.imported_vars(events)
for leaked in ('WAYLAND_SOCKET', 'FERESE_SHELL_CONTROL_SOCKET',
'FERESE_PRIVATE_X11_COOKIE', 'deadbeef'):
self.assertFalse([entry for entry in imported if leaked in entry],
f'{leaked} must not reach the activation environment')
self.assertIn('WAYLAND_DISPLAY=public-display', imported)
def test_nested_session_never_changes_host_activation(self): def test_nested_session_never_changes_host_activation(self):
status, events = self.run_session(direct=False) status, events = self.run_session(direct=False)
self.assertEqual(status, 0) self.assertEqual(status, 0)
+2 -1
View File
@@ -243,7 +243,8 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
.center_y(64) .center_y(64)
.into(), .into(),
}; };
let mut info = column![text(player.label()).size(15).width(Length::Fill).ellipsize( let text_size = if player.label().len() >= 24 { 13 } else { 15 };
let mut info = column![text(player.label()).size(text_size).width(Length::Fill).ellipsize(
cosmic::iced::widget::text::Ellipsize::End(cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(2)) cosmic::iced::widget::text::Ellipsize::End(cosmic::iced::advanced::text::EllipsizeHeightLimit::Lines(2))
)] )]
.spacing(4) .spacing(4)