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:
1 parent
ed23949a95
commit
a6f83689f8
42 files changed
+7162
-1333
No files matched your search
@@ -30,9 +30,9 @@ serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
xcursor.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[features]
|
||||
# Opt-in counters/histograms with a per-client exit summary.
|
||||
|
||||
@@ -3,12 +3,14 @@ mod bindings;
|
||||
mod input_motion;
|
||||
mod layout;
|
||||
mod outputs;
|
||||
mod xwayland;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(crate) use self::xwayland::{XwaylandConfig, XwaylandStartup};
|
||||
use appearance::*;
|
||||
pub use appearance::{BorderGradient, InactiveDimSettings, MaterialStyle, ThemeSettings};
|
||||
use bindings::*;
|
||||
@@ -61,6 +63,8 @@ pub struct Config {
|
||||
scrolling: ScrollingConfig,
|
||||
#[serde(default)]
|
||||
output_profiles: Vec<OutputProfileConfig>,
|
||||
#[serde(default)]
|
||||
pub(crate) xwayland: XwaylandConfig,
|
||||
#[serde(default, rename = "status")]
|
||||
_status: ShellStatusConfig,
|
||||
}
|
||||
@@ -114,6 +118,7 @@ impl Config {
|
||||
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
|
||||
self.notifications.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 {
|
||||
if daemon.command.first().is_none_or(|program| program.trim().is_empty()) {
|
||||
return Err(ConfigError::Binding("autostart command must contain a program".into()));
|
||||
@@ -143,6 +148,7 @@ impl Config {
|
||||
output_profiles: self.output_profiles()?,
|
||||
wallpaper: self.wallpaper_settings(),
|
||||
overview_font_family: self.overview_font_family(),
|
||||
xwayland: self.xwayland.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -517,10 +523,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn packaged_and_custom_kdl_pass_runtime_validation() {
|
||||
Config::parse_source(include_str!("../../packaging/config.kdl"))
|
||||
.unwrap()
|
||||
.runtime_config()
|
||||
.unwrap();
|
||||
let packaged = Config::parse_source(include_str!("../../packaging/config.kdl")).unwrap();
|
||||
packaged.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 config = Config::parse_source(source).unwrap();
|
||||
config.runtime_config().unwrap();
|
||||
@@ -557,6 +566,40 @@ mod tests {
|
||||
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]
|
||||
fn defaults_to_half_width_scrolling_columns() {
|
||||
let config = parse("");
|
||||
@@ -1084,7 +1127,7 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_and_validates_window_rules() {
|
||||
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 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.width, Some(900.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.block_out_from_screencasts, Some(true));
|
||||
}
|
||||
@@ -1101,9 +1146,11 @@ mod tests {
|
||||
fn rejects_invalid_window_rule_configuration() {
|
||||
let catch_all = parse("window-rule floating=#true\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!(zero_workspace.window_rules().is_err());
|
||||
assert!(zero_minimum.window_rules().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,18 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
|
||||
let state = cached.current();
|
||||
(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);
|
||||
data.set_floating_window_geometry(&self.window, rect.loc, rect.size);
|
||||
if let Some(id) = data.windows.ids().get(&self.window).copied() {
|
||||
@@ -303,8 +314,8 @@ fn constrained_size(
|
||||
initial: Size<i32, Logical>,
|
||||
delta: Point<f64, Logical>,
|
||||
edges: ResizeEdge,
|
||||
minimum: Size<i32, Logical>,
|
||||
maximum: Size<i32, Logical>,
|
||||
minimum: (i32, i32),
|
||||
maximum: (i32, i32),
|
||||
) -> Size<i32, Logical> {
|
||||
let mut width = initial.w;
|
||||
let mut height = initial.h;
|
||||
@@ -322,17 +333,17 @@ fn constrained_size(
|
||||
|
||||
// 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.
|
||||
let minimum_width = minimum.w.max(1);
|
||||
let minimum_height = minimum.h.max(1);
|
||||
let maximum_width = if maximum.w <= 0 {
|
||||
let minimum_width = minimum.0.max(1);
|
||||
let minimum_height = minimum.1.max(1);
|
||||
let maximum_width = if maximum.0 <= 0 {
|
||||
i32::MAX
|
||||
} 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
|
||||
} else {
|
||||
maximum.h.max(minimum_height)
|
||||
maximum.1.max(minimum_height)
|
||||
};
|
||||
(
|
||||
width.clamp(minimum_width, maximum_width),
|
||||
@@ -394,8 +405,8 @@ mod tests {
|
||||
(800, 600).into(),
|
||||
(100.0, 50.0).into(),
|
||||
ResizeEdge(xdg_toplevel::ResizeEdge::TopLeft),
|
||||
(1, 1).into(),
|
||||
(0, 0).into(),
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
);
|
||||
assert_eq!(size, (700, 550).into());
|
||||
}
|
||||
@@ -406,8 +417,8 @@ mod tests {
|
||||
(800, 600).into(),
|
||||
(100.0, 50.0).into(),
|
||||
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
|
||||
(1, 1).into(),
|
||||
(0, 0).into(),
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
);
|
||||
assert_eq!(size, (900, 650).into());
|
||||
}
|
||||
@@ -418,8 +429,8 @@ mod tests {
|
||||
(800, 600).into(),
|
||||
(1_000.0, 1_000.0).into(),
|
||||
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
|
||||
(640, 480).into(),
|
||||
(1_024, 768).into(),
|
||||
(640, 480),
|
||||
(1_024, 768),
|
||||
);
|
||||
assert_eq!(size, (1_024, 768).into());
|
||||
}
|
||||
@@ -437,8 +448,8 @@ mod tests {
|
||||
(800, 600).into(),
|
||||
delta.into(),
|
||||
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
|
||||
minimum.into(),
|
||||
maximum.into(),
|
||||
minimum,
|
||||
maximum,
|
||||
);
|
||||
assert!(size.w >= 1 && size.h >= 1);
|
||||
let _ = resized_rect(
|
||||
|
||||
@@ -11,6 +11,7 @@ mod xdg_shell;
|
||||
use smithay::desktop::{PopupManager, layer_map_for_output};
|
||||
use smithay::input::pointer::{CursorImageStatus, PointerHandle};
|
||||
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::protocol::wl_surface::WlSurface;
|
||||
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::{
|
||||
PrimarySelectionHandler, PrimarySelectionState, set_primary_focus,
|
||||
};
|
||||
use smithay::wayland::shell::xdg::{
|
||||
XdgPositionerUserData, XdgShellState, XdgShellSurfaceUserData, XdgSurfaceUserData, XdgWmBaseUserData,
|
||||
};
|
||||
use smithay::wayland::tablet_manager::TabletSeatHandler;
|
||||
use smithay::wayland::xdg_foreign::{XdgForeignHandler, XdgForeignState};
|
||||
use smithay::wayland::xdg_toplevel_icon::XdgToplevelIconHandler;
|
||||
@@ -338,7 +342,21 @@ smithay::delegate_viewporter!(Ferese);
|
||||
smithay::delegate_xdg_activation!(Ferese);
|
||||
smithay::delegate_xdg_decoration!(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_data_control!(Ferese);
|
||||
|
||||
@@ -54,9 +54,8 @@ impl Worker {
|
||||
|
||||
fn run(receiver: Receiver<Job>, results: channel::SyncSender<Encoded>) {
|
||||
while let Ok(job) = receiver.recv() {
|
||||
let result = encode(&job);
|
||||
let request = job.request;
|
||||
drop(job);
|
||||
let result = encode(job);
|
||||
// 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,
|
||||
// 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)?;
|
||||
drop(job);
|
||||
|
||||
let png = encode_png(&canvas)?;
|
||||
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())
|
||||
.ok_or("Screenshot buffer does not match its dimensions")?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
let encoder = image::codecs::png::PngEncoder::new_with_quality(
|
||||
std::io::Cursor::new(&mut out),
|
||||
|
||||
@@ -5,15 +5,17 @@ use smithay::desktop::{
|
||||
use smithay::input::Seat;
|
||||
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::shell::server::xdg_toplevel;
|
||||
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_surface::WlSurface;
|
||||
use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, Resource, backend::ClientId};
|
||||
use smithay::utils::{Logical, Point, Rectangle, Serial};
|
||||
use smithay::wayland::compositor::with_states;
|
||||
use smithay::wayland::shell::xdg::decoration::XdgDecorationHandler;
|
||||
use smithay::wayland::shell::xdg::{
|
||||
PopupSurface, PositionerState, ToplevelSurface, XdgShellHandler, XdgShellState, XdgToplevelSurfaceData,
|
||||
PopupSurface, PositionerState, SurfaceCachedState, ToplevelSurface, XdgShellHandler, XdgShellState,
|
||||
XdgShellSurfaceUserData, XdgToplevelSurfaceData,
|
||||
};
|
||||
|
||||
use crate::Ferese;
|
||||
@@ -467,6 +469,72 @@ fn popup_constraint_target(
|
||||
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)]
|
||||
mod tests {
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_positioner;
|
||||
|
||||
@@ -535,6 +535,7 @@ impl Ferese {
|
||||
| "input-capture-disable"
|
||||
| "input-capture-release"
|
||||
| "get-session-state"
|
||||
| ferese_ipc::xwayland::STATUS_COMMAND
|
||||
| "portal-inhibit"
|
||||
| "portal-monitor-register"
|
||||
| "portal-monitor-ack"
|
||||
@@ -576,6 +577,14 @@ impl Ferese {
|
||||
"get-session-state" => {
|
||||
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" => {
|
||||
self.portal_session
|
||||
.register_monitor(owner)
|
||||
|
||||
@@ -32,6 +32,7 @@ mod render;
|
||||
mod resize_metrics;
|
||||
mod resize_transaction;
|
||||
mod resume;
|
||||
mod session_environment;
|
||||
mod session_lock;
|
||||
mod shell_control;
|
||||
mod stacking;
|
||||
@@ -42,6 +43,9 @@ mod theme;
|
||||
mod wallpaper;
|
||||
mod window_rules;
|
||||
mod winit;
|
||||
mod xwayland;
|
||||
|
||||
pub(crate) use session_environment::{SessionEnvironment, X11Environment};
|
||||
|
||||
use std::error::Error;
|
||||
use std::io;
|
||||
@@ -109,6 +113,8 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
overview::init_font_loader(&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");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
xwayland::shutdown_after_loop(&mut state);
|
||||
|
||||
result.map_err(Into::into)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,9 +52,8 @@ pub(crate) fn spawn_client<S: AsRef<OsStr>>(
|
||||
let mut command = command(program);
|
||||
command.args(args);
|
||||
// Workers may already exist. Change only this child's environment.
|
||||
command.env("WAYLAND_DISPLAY", &state.socket_name);
|
||||
command.env_remove("WAYLAND_SOCKET");
|
||||
command.env_remove("FERESE_SHELL_CONTROL_SOCKET");
|
||||
// Apply before granting the child a private Wayland connection.
|
||||
state.session_environment.apply_public(&mut command);
|
||||
command.env("FERESE_COMPOSITOR_WALLPAPER", "1");
|
||||
let private_connection = if capabilities.is_empty() {
|
||||
None
|
||||
@@ -83,6 +82,11 @@ pub(crate) fn spawn_client<S: AsRef<OsStr>>(
|
||||
}
|
||||
|
||||
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.
|
||||
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
|
||||
if fd < 0 {
|
||||
|
||||
@@ -513,6 +513,58 @@ mod tests {
|
||||
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]
|
||||
fn invalid_monitor_reload_keeps_last_good_runtime_configuration() {
|
||||
const CHILD: &str = "FERESE_OUTPUT_RELOAD_TEST_CHILD";
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
@@ -402,6 +402,14 @@ pub struct Ferese {
|
||||
pub(crate) theme_engine: crate::theme::Engine,
|
||||
pub start_time: Instant,
|
||||
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 loop_signal: LoopSignal,
|
||||
pub(crate) loop_handle: LoopHandle<'static, Self>,
|
||||
@@ -552,6 +560,7 @@ pub struct RuntimeConfig {
|
||||
pub spring_config: SpringConfig,
|
||||
pub viewport_spring_config: SpringConfig,
|
||||
pub output_profiles: Vec<OutputProfile>,
|
||||
pub(crate) xwayland: crate::config::XwaylandConfig,
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
@@ -643,7 +652,15 @@ impl Ferese {
|
||||
config_worker: None,
|
||||
theme_engine: Default::default(),
|
||||
start_time,
|
||||
|
||||
session_environment: crate::SessionEnvironment {
|
||||
wayland_display: socket_name.clone(),
|
||||
x11: None,
|
||||
},
|
||||
socket_name,
|
||||
xwayland: None,
|
||||
xwayland_config: config.xwayland.clone(),
|
||||
x11_diagnostic: None,
|
||||
display_handle,
|
||||
loop_signal: event_loop.get_signal(),
|
||||
loop_handle: event_loop.handle(),
|
||||
@@ -793,6 +810,11 @@ impl Ferese {
|
||||
.as_ref()
|
||||
.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 bindings_changed = changed(&["bindings", "commands"]);
|
||||
let input_changed = changed(&["input"]);
|
||||
|
||||
@@ -300,16 +300,21 @@ impl Ferese {
|
||||
let constraints = window
|
||||
.toplevel()
|
||||
.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 state = cache.current();
|
||||
SizeConstraints {
|
||||
min_width: state.min_size.w.max(1) as f64,
|
||||
min_height: state.min_size.h.max(1) as f64,
|
||||
max_width: (state.max_size.w > 0).then_some(state.max_size.w as f64),
|
||||
max_height: (state.max_size.h > 0).then_some(state.max_size.h as f64),
|
||||
}
|
||||
})
|
||||
(
|
||||
(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 {
|
||||
min_width: minimum.0.max(1) as f64,
|
||||
min_height: minimum.1.max(1) as f64,
|
||||
max_width: (maximum.0 > 0).then_some(maximum.0 as f64),
|
||||
max_height: (maximum.1 > 0).then_some(maximum.1 as f64),
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let stored_size = if parent_rect.is_none() {
|
||||
|
||||
@@ -574,14 +574,19 @@ impl Ferese {
|
||||
let state = cached.current();
|
||||
(state.min_size, state.max_size)
|
||||
});
|
||||
let (minimum, maximum) = self.effective_size_constraints(
|
||||
window,
|
||||
(minimum.w, minimum.h),
|
||||
(maximum.w, maximum.h),
|
||||
);
|
||||
|
||||
Some((
|
||||
*id,
|
||||
SizeConstraints {
|
||||
min_width: minimum.w.max(1) as f64,
|
||||
min_height: minimum.h.max(1) as f64,
|
||||
max_width: (maximum.w > 0).then_some(maximum.w as f64),
|
||||
max_height: (maximum.h > 0).then_some(maximum.h as f64),
|
||||
min_width: minimum.0.max(1) as f64,
|
||||
min_height: minimum.1.max(1) as f64,
|
||||
max_width: (maximum.0 > 0).then_some(maximum.0 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);
|
||||
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));
|
||||
}
|
||||
@@ -261,8 +261,42 @@ impl Ferese {
|
||||
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]) {
|
||||
let windows = self.windows.ids().keys().cloned().collect::<Vec<_>>();
|
||||
let mut minimums_changed = false;
|
||||
for window in windows {
|
||||
let Some(toplevel) = window.toplevel() else {
|
||||
continue;
|
||||
@@ -278,10 +312,14 @@ impl Ferese {
|
||||
});
|
||||
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);
|
||||
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) {
|
||||
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) {
|
||||
|
||||
+103
-21
@@ -9,6 +9,8 @@ pub(crate) struct WindowRuleConfig {
|
||||
pub floating: Option<bool>,
|
||||
pub width: Option<f64>,
|
||||
pub height: Option<f64>,
|
||||
pub min_width: Option<f64>,
|
||||
pub min_height: Option<f64>,
|
||||
pub fullscreen: Option<bool>,
|
||||
pub block_out_from_screencasts: Option<bool>,
|
||||
pub idle_inhibit: Option<crate::idle_inhibition::Mode>,
|
||||
@@ -23,6 +25,8 @@ pub struct WindowRule {
|
||||
floating: Option<bool>,
|
||||
width: Option<f64>,
|
||||
height: Option<f64>,
|
||||
min_width: Option<f64>,
|
||||
min_height: Option<f64>,
|
||||
fullscreen: Option<bool>,
|
||||
block_out_from_screencasts: Option<bool>,
|
||||
idle_inhibit: Option<crate::idle_inhibition::Mode>,
|
||||
@@ -40,6 +44,8 @@ pub struct WindowRuleResult {
|
||||
pub floating: Option<bool>,
|
||||
pub width: Option<f64>,
|
||||
pub height: Option<f64>,
|
||||
pub min_width: Option<f64>,
|
||||
pub min_height: Option<f64>,
|
||||
pub fullscreen: Option<bool>,
|
||||
pub block_out_from_screencasts: Option<bool>,
|
||||
pub idle_inhibit: Option<crate::idle_inhibition::Mode>,
|
||||
@@ -56,6 +62,10 @@ pub(crate) fn live_result(
|
||||
new.block_out_from_screencasts = None;
|
||||
old.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 {
|
||||
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.width = rule.width.or(result.width);
|
||||
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.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);
|
||||
@@ -138,6 +150,8 @@ fn validate_rule(index: usize, rule: &WindowRuleConfig) -> Result<WindowRule, St
|
||||
.map(str::to_owned);
|
||||
let width = positive_dimension(rule.width, index, "width")?;
|
||||
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 {
|
||||
app_id,
|
||||
@@ -147,6 +161,8 @@ fn validate_rule(index: usize, rule: &WindowRuleConfig) -> Result<WindowRule, St
|
||||
floating: rule.floating,
|
||||
width,
|
||||
height,
|
||||
min_width,
|
||||
min_height,
|
||||
fullscreen: rule.fullscreen,
|
||||
block_out_from_screencasts: rule.block_out_from_screencasts,
|
||||
idle_inhibit: rule.idle_inhibit,
|
||||
@@ -194,27 +210,6 @@ pub(crate) fn normalize_app_id(value: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
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::*;
|
||||
|
||||
fn config(app_id: &str) -> WindowRuleConfig {
|
||||
@@ -226,6 +221,8 @@ mod tests {
|
||||
floating: None,
|
||||
width: None,
|
||||
height: None,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
fullscreen: None,
|
||||
block_out_from_screencasts: None,
|
||||
idle_inhibit: None,
|
||||
@@ -358,6 +355,8 @@ mod tests {
|
||||
floating: Some(true),
|
||||
width: None,
|
||||
height: None,
|
||||
min_width: None,
|
||||
min_height: None,
|
||||
fullscreen: None,
|
||||
block_out_from_screencasts: None,
|
||||
idle_inhibit: None,
|
||||
@@ -366,8 +365,91 @@ mod tests {
|
||||
width: Some(f64::NAN),
|
||||
..config("editor")
|
||||
};
|
||||
let invalid_minimum = WindowRuleConfig {
|
||||
min_width: Some(0.0),
|
||||
..config("editor")
|
||||
};
|
||||
|
||||
assert!(validate(&[catch_all]).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
@@ -107,7 +107,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
||||
.current_monitor()
|
||||
.and_then(|monitor| monitor.refresh_rate_millihertz())
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -249,19 +249,23 @@ fn configure_nested_protocols(state: &mut Ferese) {
|
||||
.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 {
|
||||
size.w > 0 && size.h > 0
|
||||
usable_dimensions(size.w, size.h)
|
||||
}
|
||||
|
||||
fn resize_nested_output(
|
||||
state: &mut Ferese,
|
||||
output: &Output,
|
||||
drawable: &Cell<bool>,
|
||||
size: Size<i32, Physical>,
|
||||
size: (i32, i32),
|
||||
scale_factor: f64,
|
||||
rate: i32,
|
||||
) -> bool {
|
||||
if !usable_size(size) {
|
||||
if !usable_dimensions(size.0, size.1) {
|
||||
drawable.set(false);
|
||||
return false;
|
||||
}
|
||||
@@ -272,7 +276,7 @@ fn resize_nested_output(
|
||||
return false;
|
||||
};
|
||||
resized.mode = Mode {
|
||||
size,
|
||||
size: (size.0, size.1).into(),
|
||||
refresh: rate.max(1),
|
||||
};
|
||||
resized.scale = Scale::Fractional(normalized_scale(scale_factor));
|
||||
@@ -372,19 +376,14 @@ mod tests {
|
||||
&mut state,
|
||||
&output,
|
||||
&drawable,
|
||||
(900, 600).into(),
|
||||
(900, 600),
|
||||
1.25,
|
||||
60_000
|
||||
));
|
||||
let before = output.current_mode();
|
||||
for size in [(0, 600), (900, 0), (0, 0), (-1, 600)] {
|
||||
assert!(!resize_nested_output(
|
||||
&mut state,
|
||||
&output,
|
||||
&drawable,
|
||||
size.into(),
|
||||
2.0,
|
||||
144_000
|
||||
&mut state, &output, &drawable, size, 2.0, 144_000
|
||||
));
|
||||
assert_eq!(output.current_mode(), before);
|
||||
assert_eq!(output.current_scale().fractional_scale(), 1.25);
|
||||
@@ -396,7 +395,7 @@ mod tests {
|
||||
&mut state,
|
||||
&output,
|
||||
&drawable,
|
||||
(1200, 800).into(),
|
||||
(1200, 800),
|
||||
1.5,
|
||||
120_000
|
||||
));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use serde_json::Value;
|
||||
|
||||
pub mod media;
|
||||
pub mod theme;
|
||||
pub mod xwayland;
|
||||
|
||||
pub const VERSION: u32 = 1;
|
||||
pub const MAX_PAYLOAD_SIZE: usize = 1024 * 1024;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
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
|
||||
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.
|
||||
|
||||
## Animations
|
||||
@@ -574,10 +574,16 @@ window rules can override that behavior.
|
||||
| `workspace` | integer > 0 | Leave placement unchanged |
|
||||
| `floating` | boolean | Leave placement unchanged |
|
||||
| `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 |
|
||||
| `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) |
|
||||
|
||||
`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`
|
||||
keeps matching windows visible on the display but omits them (including their
|
||||
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.
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
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 actions use a centered confirmation. **Super+Shift+E** and
|
||||
|
||||
@@ -54,6 +54,62 @@ other distributions; check the [Smithay
|
||||
dependencies](https://github.com/Smithay/smithay#system-dependencies) for equivalent
|
||||
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
|
||||
|
||||
Install Rust through [rustup.rs](https://rustup.rs/). The checkout's
|
||||
|
||||
+100
-1
@@ -19,6 +19,9 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
if env::args().nth(1).as_deref() == Some("theme") {
|
||||
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 request = Request {
|
||||
version: VERSION,
|
||||
@@ -87,6 +90,40 @@ fn theme_command(args: Vec<String>) -> Result<(), Box<dyn Error>> {
|
||||
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
|
||||
// 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.
|
||||
@@ -233,13 +270,75 @@ fn socket_path() -> Result<PathBuf, io::Error> {
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn media_commands_route_transport_and_player_preferences() {
|
||||
assert_eq!(parse_args(["media".into()]).unwrap().0, "media-get");
|
||||
|
||||
@@ -15,6 +15,12 @@ commands {
|
||||
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.
|
||||
binding "Super+B" "spawn" "browser"
|
||||
binding "Super+Z" "spawn" "editor"
|
||||
|
||||
@@ -29,10 +29,22 @@ if [[ ${FERESE_SESSION_IMPORT_ENV:-0} == 1 ]]; then
|
||||
# Shell control uses a private socket FD. Activation must use the public
|
||||
# socket so portal and other desktop services connect as ordinary clients.
|
||||
public_display=${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}
|
||||
env WAYLAND_DISPLAY="$public_display" dbus-update-activation-environment --systemd \
|
||||
WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || \
|
||||
env WAYLAND_DISPLAY="$public_display" dbus-update-activation-environment \
|
||||
WAYLAND_DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP XDG_SESSION_TYPE || true
|
||||
# Empty assignments replace stale values; D-Bus cannot remove activation variables.
|
||||
activation_vars=(
|
||||
"WAYLAND_DISPLAY=$public_display"
|
||||
"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.
|
||||
if command -v dbus-send >/dev/null; then
|
||||
dbus-send --session --type=method_call --print-reply \
|
||||
|
||||
@@ -136,8 +136,11 @@ impl cosmic::Application for Consent {
|
||||
Message::Opened(id) => {
|
||||
let parent = self.prompt.parent.clone();
|
||||
return cosmic::iced::window::run(id, move |window| {
|
||||
let parent = crate::parent::Parent::attach(window, &parent)?.map(Arc::new);
|
||||
Ok((parent, ModalMaterial::attach(window)))
|
||||
let attached = crate::parent::Parent::attach(window, &parent)?;
|
||||
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)));
|
||||
}
|
||||
|
||||
+150
-13
@@ -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_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)]
|
||||
pub(crate) struct Parent {
|
||||
connection: Connection,
|
||||
@@ -21,15 +77,25 @@ pub(crate) struct 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};
|
||||
if parent.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let handle = parent
|
||||
.strip_prefix("wayland:")
|
||||
.filter(|handle| !handle.is_empty() && handle.len() <= 4096)
|
||||
.ok_or("Unsupported portal parent window identifier")?;
|
||||
let handle = match classify_parent(parent) {
|
||||
ParentKind::None => {
|
||||
return Ok(ParentAttachment::default());
|
||||
}
|
||||
ParentKind::X11 => {
|
||||
return Ok(ParentAttachment {
|
||||
parent: None,
|
||||
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 surface = window.window_handle().map_err(|error| error.to_string())?;
|
||||
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(surface)) =
|
||||
@@ -65,11 +131,14 @@ impl Parent {
|
||||
}
|
||||
imported.set_parent_of(&surface);
|
||||
connection.flush().map_err(|error| error.to_string())?;
|
||||
Ok(Some(Self {
|
||||
connection,
|
||||
imported,
|
||||
importer,
|
||||
}))
|
||||
Ok(ParentAttachment {
|
||||
parent: Some(Self {
|
||||
connection,
|
||||
imported,
|
||||
importer,
|
||||
}),
|
||||
diagnostic: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,3 +183,71 @@ impl Dispatch<ZxdgImportedV2, ()> for State {
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -177,8 +177,14 @@ impl cosmic::Application for Picker {
|
||||
Message::WindowOpened(id) => {
|
||||
let parent = self.prompt.parent.clone();
|
||||
return cosmic::iced::window::run(id, move |window| {
|
||||
let parent = crate::parent::Parent::attach(window, &parent)?.map(std::sync::Arc::new);
|
||||
Ok((parent, ferese_theme_client::material::ModalMaterial::attach(window)))
|
||||
let attached = crate::parent::Parent::attach(window, &parent)?;
|
||||
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)));
|
||||
}
|
||||
|
||||
-103
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+232
@@ -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
|
||||
@@ -12,13 +12,16 @@ SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-session-shell'
|
||||
|
||||
|
||||
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:
|
||||
root = Path(directory)
|
||||
helper = root / 'ferese-session-shell'
|
||||
shutil.copyfile(SCRIPT, helper)
|
||||
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',
|
||||
'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',
|
||||
@@ -32,6 +35,13 @@ class SessionStartupTest(unittest.TestCase):
|
||||
FERESE_SESSION_IMPORT_ENV=str(int(direct)), WAYLAND_DISPLAY='private-display',
|
||||
FERESE_PUBLIC_WAYLAND_DISPLAY='public-display', WAYLAND_SOCKET='77',
|
||||
TEST_FAIL=str(int(fail_target)), TEST_STATUS=str(shell_status), TEST_WAIT=str(int(terminate)))
|
||||
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)
|
||||
if terminate:
|
||||
deadline = time.monotonic() + 3
|
||||
@@ -44,13 +54,51 @@ class SessionStartupTest(unittest.TestCase):
|
||||
child.communicate(timeout=4)
|
||||
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):
|
||||
status, events = self.run_session(shell_status=7)
|
||||
self.assertEqual(status, 7)
|
||||
self.assertEqual(events, ['import:public-display', 'reload-bus', '--user daemon-reload',
|
||||
'--user start ferese-session.target',
|
||||
'--user try-restart xdg-desktop-portal-ferese.service', 'shell:77',
|
||||
'--user stop ferese-session.target'])
|
||||
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 try-restart xdg-desktop-portal-ferese.service', 'shell:77',
|
||||
'--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):
|
||||
status, events = self.run_session(direct=False)
|
||||
|
||||
@@ -243,7 +243,8 @@ pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: Me
|
||||
.center_y(64)
|
||||
.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))
|
||||
)]
|
||||
.spacing(4)
|
||||
|
||||
Reference in new issue
Block a user