diff --git a/docs/wiki/Virtual-Outputs.md b/docs/wiki/Virtual-Outputs.md new file mode 100644 index 0000000000..629592f76d --- /dev/null +++ b/docs/wiki/Virtual-Outputs.md @@ -0,0 +1,193 @@ +# Virtual Outputs + +Virtual outputs are extra `wl_output`s (usually named `HEADLESS-*`) that niri can create even when +there’s no physical monitor attached (or in addition to real monitors). + +They’re useful for: + +- Sunshine / Moonlight (wlr-screencopy capture) +- VNC (e.g. wayvnc) +- “headless” remote sessions and general screen sharing + +## Creating virtual outputs + +### TTY backend (regular session with physical displays) + +When running niri on a TTY with your physical monitor, you can create additional virtual outputs: + +```bash +# Create a 1920x1080@144 output +niri msg create-virtual-output --width 1920 --height 1080 --refresh-rate 144 +# Output: Created virtual output: HEADLESS-1 +``` + +If you want to see what exists: + +```bash +niri msg outputs +``` + +### Headless backend (no physical displays) + +For servers or remote-only access (e.g. SSH) where a real TTY is not available: + +```bash +# Start niri in headless mode +NIRI_BACKEND=headless niri --session & +# A 1920x1080@60 HEADLESS-1 virtual output is created by default +``` + +Note: if you’re running commands from another shell, you may need to set `WAYLAND_DISPLAY` to the +socket of that headless session. + +## Removing virtual outputs + +```bash +niri msg remove-virtual-output HEADLESS-1 +# Output: Removed virtual output: HEADLESS-1 +``` + +## Configuring virtual outputs + +Virtual outputs can be configured like regular outputs: + +```bash +# Enable/disable +niri msg output HEADLESS-1 + +# Set scale +niri msg output HEADLESS-1 scale 1.25 + +# Set transform +niri msg output HEADLESS-1 transform 90 +``` + +You can also configure them in your `config.kdl` file: + +```kdl +output "HEADLESS-1" { + scale 1.25 + transform "90" + position x=1920 y=0 +} +``` + +## Using with Sunshine + +If you want, you can tell [Sunshine](https://github.com/LizardByte/Sunshine) to create a virtual output that matches the client ([Moonlight](https://github.com/moonlight-stream/moonlight-qt)) resolution + refresh rate when a session starts, and remove it when the session ends. +resolution + refresh rate when a session starts, and remove it when the session ends. + +The snippets below are intentionally minimal; adjust them to your Sunshine setup. + +### TTY backend (create + remove per session) + +```json +{ + "apps": [ + { + "name": "Remote Desktop", + "output": "HEADLESS-1", + "prep-cmd": [ + { + "do": "sh -c \"niri msg create-virtual-output --width ${SUNSHINE_CLIENT_WIDTH} --height ${SUNSHINE_CLIENT_HEIGHT} --refresh-rate ${SUNSHINE_CLIENT_FPS}\"", + "undo": "niri msg remove-virtual-output HEADLESS-1" + } + ] + } + ] +} +``` + +### Headless backend (reuse the default output) + +In headless mode, niri creates `HEADLESS-1` by default. You can just change its mode for the +session and restore it afterwards. + +```json +{ + "apps": [ + { + "name": "Remote Desktop", + "output": "HEADLESS-1", + "prep-cmd": [ + { + "do": "sh -c \"niri msg output HEADLESS-1 custom-mode \\\"${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT}@${SUNSHINE_CLIENT_FPS}\\\"\"", + "undo": "niri msg output HEADLESS-1 mode '1920x1080@60.000'" + } + ] + } + ] +} +``` + +## Input and seats (headless mode) + +In headless mode, niri can still use libinput to read local input devices (if it has permission to +open `/dev/input/event*`). This is independent of virtual outputs. + +libinput enumerates devices by udev seat. By default niri uses `seat0`, but you can override it: + +```bash +XDG_SEAT=seat0 NIRI_BACKEND=headless niri --session +``` + +The Wayland `wl_seat` name exposed by niri matches this seat string (`XDG_SEAT` / `seat0`). +If niri can’t access any input devices (permissions, container, etc.), it will still start; you’ll +just have no _local_ input. + +This section is mostly about _local_ kernel input devices. If you’re using a remote client that +injects input over Wayland (like wayvnc), niri doesn’t need access to `/dev/input` for that. + +## Using with wayvnc + +[wayvnc](https://github.com/any1/wayvnc) is a VNC server for wlroots-based Wayland compositors. It forwards keyboard and pointer input from VNC clients to the compositor using Wayland “virtual input” protocols (virtual keyboard + virtual pointer). This means you can have working remote input even if the headless niri process can’t open `/dev/input/event*`. + +### Physical displays + extra virtual output + +```bash +# 1. Start niri normally on your TTY +niri --session & + +# 2. Create a virtual output for VNC +niri msg create-virtual-output --width 1920 --height 1080 + +# 3. Start wayvnc on the virtual output +wayvnc --output HEADLESS-1 + +# 4. Connect from a VNC client to your machine's IP +``` + +### Pure headless (remote only) + +```bash +# 1. Start niri in headless mode (e.g., over SSH) +NIRI_BACKEND=headless niri & + +# 2. Start wayvnc +WAYLAND_DISPLAY=wayland-1 wayvnc --output HEADLESS-1 + +# 3. Connect from a VNC client +``` + +### Headless with systemd + +For a persistent headless niri session: + +```ini +# ~/.config/systemd/user/niri-headless.service +[Unit] +Description=Niri Headless Session + +[Service] +Type=simple +Environment=NIRI_BACKEND=headless +ExecStart=/usr/bin/niri +Restart=on-failure + +[Install] +WantedBy=default.target +``` + +```bash +systemctl --user enable --now niri-headless +``` diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 4b5c830d54..b499563fee 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -24,6 +24,7 @@ * [Introduction](./Configuration:-Introduction.md) * [Input](./Configuration:-Input.md) * [Outputs](./Configuration:-Outputs.md) +* [Virtual Outputs](./Virtual-Outputs.md) * [Key Bindings](./Configuration:-Key-Bindings.md) * [Switch Events](./Configuration:-Switch-Events.md) * [Layout](./Configuration:-Layout.md) diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index 508456333d..d0a6b6aa52 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -98,6 +98,21 @@ pub enum Request { /// Configuration to apply. action: OutputAction, }, + /// Create a new virtual headless output. + /// Defaults to 1920x1080 @ 60 Hz when not specified. + CreateVirtualOutput { + /// Width in pixels. + width: Option, + /// Height in pixels. + height: Option, + /// Refresh rate in Hz. + refresh_rate: Option, + }, + /// Remove a virtual headless output by name. + RemoveVirtualOutput { + /// Identifier of the output to remove. + name: String, + }, /// Start continuously receiving events from the compositor. /// /// The compositor should reply with `Reply::Ok(Response::Handled)`, then continuously send @@ -143,6 +158,8 @@ pub enum Response { /// /// Map from output name to output info. Outputs(HashMap), + /// Virtual output successfully created. + VirtualOutputCreated(String), /// Information about workspaces. Workspaces(Vec), /// Information about open windows. diff --git a/src/backend/headless.rs b/src/backend/headless.rs index 8b5ee827cb..c9b155448f 100644 --- a/src/backend/headless.rs +++ b/src/backend/headless.rs @@ -1,42 +1,178 @@ -//! Headless backend for tests. -//! -//! This can eventually grow into a more complete backend if needed, but for now it's missing some -//! crucial parts like dmabufs. +//! Note: This backend has limited DMA-BUF support intended for screencopy. +use std::collections::HashMap; use std::mem; +use std::os::fd::{FromRawFd, OwnedFd}; +use std::path::Path; use std::sync::{Arc, Mutex}; +use std::time::Duration; use anyhow::Context as _; use niri_config::OutputName; use smithay::backend::allocator::dmabuf::Dmabuf; +use smithay::backend::allocator::format::FormatSet; +#[cfg(feature = "xdp-gnome-screencast")] +use smithay::backend::allocator::gbm::GbmDevice; +use smithay::backend::allocator::Buffer; +#[cfg(feature = "xdp-gnome-screencast")] +use smithay::backend::drm::DrmDeviceFd; +use smithay::backend::drm::DrmNode; use smithay::backend::egl::native::EGLSurfacelessDisplay; -use smithay::backend::egl::{EGLContext, EGLDisplay}; +use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay}; +use smithay::backend::libinput::LibinputInputBackend; use smithay::backend::renderer::element::RenderElementStates; use smithay::backend::renderer::gles::GlesRenderer; +use smithay::backend::renderer::{ImportDma, ImportEgl}; use smithay::output::{Mode, Output, PhysicalProperties, Subpixel}; +use smithay::reexports::calloop::timer::{TimeoutAction, Timer}; +use smithay::reexports::calloop::LoopHandle; +use smithay::reexports::input; +use smithay::reexports::input::Libinput; use smithay::reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback; +#[cfg(feature = "xdp-gnome-screencast")] +use smithay::utils::DeviceFd; use smithay::utils::Size; +use smithay::wayland::dmabuf::{DmabufFeedbackBuilder, DmabufGlobal}; use smithay::wayland::presentation::Refresh; -use super::{IpcOutputMap, OutputId, RenderResult}; -use crate::niri::{Niri, RedrawState}; +use super::{virtual_output, IpcOutputMap, OutputId, RenderResult, VirtualOutputMarker}; +use crate::niri::{Niri, RedrawState, State}; use crate::render_helpers::{resources, shaders}; use crate::utils::{get_monotonic_time, logical_output}; pub struct Headless { renderer: Option, + dmabuf_global: Option, + /// DRM render node used by the EGL device backing the renderer (if detectable). + /// + /// This is used to provide linux-dmabuf feedback so clients can allocate buffers on the + /// correct device/modifier set (important for dmabuf-based screencopy clients like Sunshine). + render_node: Option, + /// GBM device backed by the detected DRM render node. + /// + /// This is required for PipeWire/portal screencasting (e.g. Discord/OBS PipeWire sources). + #[cfg(feature = "xdp-gnome-screencast")] + gbm: Option>, ipc_outputs: Arc>, + _libinput: Option, + /// Seat name used for both libinput udev enumeration (`udev_assign_seat`) and the compositor + /// `wl_seat` name. + /// + /// This defaults to `seat0` and can be overridden with `XDG_SEAT`. + udev_seat: String, + /// Counter for auto-naming headless outputs (HEADLESS-1, HEADLESS-2, etc.) + output_counter: u32, + /// Track outputs by name for removal, storing (Output, OutputId) + outputs: HashMap, } impl Headless { - pub fn new() -> Self { + pub fn new(event_loop: LoopHandle<'static, State>) -> Self { + let udev_seat = std::env::var("XDG_SEAT").unwrap_or_else(|_| "seat0".to_owned()); + let libinput = init_headless_libinput(event_loop, &udev_seat); + Self { renderer: None, + dmabuf_global: None, + render_node: None, + #[cfg(feature = "xdp-gnome-screencast")] + gbm: None, ipc_outputs: Default::default(), + _libinput: libinput, + udev_seat, + output_counter: 0, + outputs: HashMap::new(), } } - pub fn init(&mut self, _niri: &mut Niri) {} + #[cfg(feature = "xdp-gnome-screencast")] + pub fn gbm_device(&self) -> Option> { + self.gbm.clone() + } + + pub fn init(&mut self, niri: &mut Niri) { + if let Err(err) = self.add_renderer() { + warn!("failed to create headless renderer: {err:?}"); + } else if let Some(renderer) = self.renderer.as_mut() { + if let Err(err) = renderer.bind_wl_display(&niri.display_handle) { + warn!("error binding renderer wl_display: {err:?}"); + } + + let config = niri.config.borrow(); + if let Some(src) = config.animations.window_resize.custom_shader.as_deref() { + shaders::set_custom_resize_program(renderer, Some(src)); + } + if let Some(src) = config.animations.window_close.custom_shader.as_deref() { + shaders::set_custom_close_program(renderer, Some(src)); + } + if let Some(src) = config.animations.window_open.custom_shader.as_deref() { + shaders::set_custom_open_program(renderer, Some(src)); + } + drop(config); + + niri.update_shaders(); + + // Advertise linux-dmabuf feedback when we can identify the EGL device's DRM render + // node. This helps dmabuf-based screencopy clients (e.g. Sunshine) pick compatible + // formats/modifiers. If we can't determine a node, fall back to a format-list-only + // dmabuf global. + if self.dmabuf_global.is_none() { + // For screencopy, the client-provided dmabuf must be usable as a render target, + // so only advertise formats that are both renderable by the EGL context and + // importable by the renderer. + let render_formats = renderer.egl_context().dmabuf_render_formats(); + let import_formats: FormatSet = renderer.dmabuf_formats().into_iter().collect(); + let formats: FormatSet = render_formats + .intersection(&import_formats) + .copied() + .collect(); + + if formats.iter().next().is_none() { + warn!( + "headless: renderer reports no compatible dmabuf render formats; dmabuf screencopy will be unavailable" + ); + } else if let Some(render_node) = self.render_node { + match DmabufFeedbackBuilder::new(render_node.dev_id(), formats.clone()) + .build() + .context("error building default dmabuf feedback") + { + Ok(default_feedback) => { + let global = niri + .dmabuf_state + .create_global_with_default_feedback::( + &niri.display_handle, + &default_feedback, + ); + self.dmabuf_global = Some(global); + } + Err(err) => { + warn!( + "headless: failed to build dmabuf feedback ({err:?}); falling back to format-list-only dmabuf global" + ); + let formats_vec = formats.into_iter().collect::>(); + let global = niri + .dmabuf_state + .create_global::(&niri.display_handle, formats_vec); + self.dmabuf_global = Some(global); + } + } + } else { + let formats_vec = formats.into_iter().collect::>(); + let global = niri + .dmabuf_state + .create_global::(&niri.display_handle, formats_vec); + self.dmabuf_global = Some(global); + } + } + } + + // In real headless sessions we want a default output so clients have something to render + // on. In tests, the harness explicitly creates predictable `headless-N` outputs; creating + // an extra default `HEADLESS-1` here causes name collisions and snapshot churn. + if self.outputs.is_empty() && !cfg!(test) { + self.create_virtual_output(niri, 1920, 1080, 60); + } + } pub fn add_renderer(&mut self) -> anyhow::Result<()> { if self.renderer.is_some() { @@ -47,6 +183,40 @@ impl Headless { let mut renderer = unsafe { let display = EGLDisplay::new(EGLSurfacelessDisplay).context("error creating EGL display")?; + + self.render_node = EGLDevice::device_for_display(&display) + .ok() + .and_then(|egl_device| { + if egl_device.is_software() { + debug!( + "headless: EGL device is software; skipping dmabuf feedback device metadata" + ); + return None; + } + + egl_device.try_get_render_node().ok().flatten() + }); + + #[cfg(feature = "xdp-gnome-screencast")] + { + if self.gbm.is_none() { + if let Some(render_node) = self.render_node { + match try_init_headless_gbm_device(render_node) { + Ok(gbm) => self.gbm = Some(gbm), + Err(err) => { + warn!( + "headless: failed to initialize GBM device from render node {render_node}: {err:?}" + ); + } + } + } else { + debug!( + "headless: no DRM render node detected; portal/PipeWire screencasting will be unavailable" + ); + } + } + } + let context = EGLContext::new(&display).context("error creating EGL context")?; GlesRenderer::new(context).context("error creating renderer")? }; @@ -58,6 +228,8 @@ impl Headless { Ok(()) } + /// Add an output for testing (uses lowercase naming like `headless-1`). + /// This is kept for backwards compatibility with tests. pub fn add_output(&mut self, niri: &mut Niri, n: u8, size: (u16, u16)) { let connector = format!("headless-{n}"); let make = "niri".to_string(); @@ -89,6 +261,10 @@ impl Headless { serial: Some(serial), }); + output + .user_data() + .insert_if_missing(VirtualOutputMarker::default); + let physical_properties = output.physical_properties(); self.ipc_outputs.lock().unwrap().insert( OutputId::next(), @@ -115,8 +291,49 @@ impl Headless { niri.add_output(output, None, false); } + /// Create a virtual output with the given mode and add it to the compositor. + /// Returns the name of the created output. + pub fn create_virtual_output( + &mut self, + niri: &mut Niri, + width: u16, + height: u16, + refresh_rate: u32, + ) -> String { + let built = virtual_output::build_headless_virtual_output( + &mut self.output_counter, + width, + height, + refresh_rate, + ); + + self.ipc_outputs + .lock() + .unwrap() + .insert(built.output_id, built.ipc_output); + + self.outputs + .insert(built.name.clone(), (built.output.clone(), built.output_id)); + + niri.add_output(built.output, Some(built.refresh_interval), false); + + built.name + } + + /// Remove the virtual output with the given name. + /// Returns an error if no such virtual output exists. + pub fn remove_virtual_output(&mut self, niri: &mut Niri, name: &str) -> Result<(), String> { + virtual_output::remove_virtual_output_from_map( + niri, + &self.ipc_outputs, + &mut self.outputs, + name, + "output", + ) + } + pub fn seat_name(&self) -> String { - "headless".to_owned() + self.udev_seat.clone() } pub fn with_primary_renderer( @@ -127,10 +344,12 @@ impl Headless { } pub fn render(&mut self, niri: &mut Niri, output: &Output) -> RenderResult { + let now = get_monotonic_time(); + let states = RenderElementStates::default(); let mut presentation_feedbacks = niri.take_presentation_feedbacks(output, &states); presentation_feedbacks.presented::<_, smithay::utils::Monotonic>( - get_monotonic_time(), + now, Refresh::Unknown, 0, wp_presentation_feedback::Kind::empty(), @@ -141,19 +360,92 @@ impl Headless { RedrawState::Idle => unreachable!(), RedrawState::Queued => (), RedrawState::WaitingForVBlank { .. } => unreachable!(), - RedrawState::WaitingForEstimatedVBlank(_) => unreachable!(), - RedrawState::WaitingForEstimatedVBlankAndQueued(_) => unreachable!(), + RedrawState::WaitingForEstimatedVBlank(token) + | RedrawState::WaitingForEstimatedVBlankAndQueued(token) => { + niri.event_loop.remove(token); + } } + output_state.frame_clock.presented(now); output_state.frame_callback_sequence = output_state.frame_callback_sequence.wrapping_add(1); - // FIXME: request redraw on unfinished animations remain + let refresh_interval = output_state + .frame_clock + .refresh_interval() + .unwrap_or(Duration::from_micros(16_667)); + + let output_clone = output.clone(); + let timer = Timer::from_duration(refresh_interval); + let token = niri + .event_loop + .insert_source(timer, move |_, _, data| { + let output_state = data.niri.output_state.get_mut(&output_clone).unwrap(); + output_state.frame_callback_sequence = + output_state.frame_callback_sequence.wrapping_add(1); + + match mem::replace(&mut output_state.redraw_state, RedrawState::Idle) { + RedrawState::WaitingForEstimatedVBlank(_) => (), + RedrawState::WaitingForEstimatedVBlankAndQueued(_) => { + output_state.redraw_state = RedrawState::Queued; + return TimeoutAction::Drop; + } + _ => unreachable!(), + } + + if output_state.unfinished_animations_remain { + data.niri.queue_redraw(&output_clone); + } else { + data.niri + .send_frame_callbacks_for_virtual_output(&output_clone); + } + TimeoutAction::Drop + }) + .unwrap(); + output_state.redraw_state = RedrawState::WaitingForEstimatedVBlank(token); RenderResult::Submitted } - pub fn import_dmabuf(&mut self, _dmabuf: &Dmabuf) -> bool { - unimplemented!() + pub fn import_dmabuf(&mut self, dmabuf: &Dmabuf) -> bool { + let renderer = match self.renderer.as_mut() { + Some(r) => r, + None => { + debug!("import_dmabuf: no renderer available"); + return false; + } + }; + + let render_formats = renderer.egl_context().dmabuf_render_formats(); + let import_formats: FormatSet = renderer.dmabuf_formats().into_iter().collect(); + let format = dmabuf.format(); + if !render_formats.contains(&format) || !import_formats.contains(&format) { + debug!( + "import_dmabuf: unsupported format code={:?} modifier={:?}", + format.code, format.modifier + ); + return false; + } + + match renderer.import_dmabuf(dmabuf, None) { + Ok(_texture) => { + dmabuf.set_node(self.render_node); + true + } + Err(err) => { + debug!("error importing dmabuf: {err:?}"); + false + } + } + } + + pub fn on_output_config_changed(&mut self, niri: &mut Niri) { + let config = niri.config.clone(); + virtual_output::apply_config_to_managed_virtual_outputs( + niri, + &mut self.outputs, + Some(&self.ipc_outputs), + &config, + ); } pub fn ipc_outputs(&self) -> Arc> { @@ -163,6 +455,99 @@ impl Headless { impl Default for Headless { fn default() -> Self { - Self::new() + // This is only used by tests / callers that don't care about input. + // Headless libinput requires an event loop handle, so default disables it. + Self { + renderer: None, + dmabuf_global: None, + render_node: None, + #[cfg(feature = "xdp-gnome-screencast")] + gbm: None, + ipc_outputs: Default::default(), + _libinput: None, + udev_seat: "seat0".to_string(), + output_counter: 0, + outputs: HashMap::new(), + } + } +} + +#[cfg(feature = "xdp-gnome-screencast")] +fn try_init_headless_gbm_device(render_node: DrmNode) -> anyhow::Result> { + use std::fs::OpenOptions; + use std::os::fd::OwnedFd; + use std::os::unix::fs::OpenOptionsExt; + + let path = render_node + .dev_path() + .context("render node has no dev_path")?; + + let file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_CLOEXEC | libc::O_NOCTTY) + .open(&path) + .with_context(|| format!("error opening render node at {path:?}"))?; + + let owned_fd = OwnedFd::from(file); + let device_fd = DrmDeviceFd::new(DeviceFd::from(owned_fd)); + let gbm = GbmDevice::new(device_fd).context("error creating GBM device")?; + Ok(gbm) +} + +#[derive(Clone, Default)] +struct HeadlessLibinputInterface; + +impl input::LibinputInterface for HeadlessLibinputInterface { + fn open_restricted(&mut self, path: &Path, flags: i32) -> Result { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|_| libc::EINVAL)?; + // Keep libinput's requested access mode (read-only vs read-write), but add a few + // safety/behavior flags (mirrors libseat's noop backend). + let flags = flags | libc::O_CLOEXEC | libc::O_NOCTTY | libc::O_NOFOLLOW | libc::O_NONBLOCK; + let fd = unsafe { libc::open(c_path.as_ptr(), flags) }; + if fd < 0 { + let errno = unsafe { *libc::__errno_location() }; + + if errno == libc::ENOENT || errno == libc::ENODEV { + trace!("headless: libinput open_restricted failed for {path:?}: errno={errno}"); + } else { + debug!("headless: libinput open_restricted failed for {path:?}: errno={errno}"); + } + return Err(errno); + } + + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) + } + + fn close_restricted(&mut self, fd: OwnedFd) { + drop(fd); } } + +fn init_headless_libinput(event_loop: LoopHandle<'static, State>, seat: &str) -> Option { + let mut libinput = Libinput::new_with_udev(HeadlessLibinputInterface::default()); + + unsafe { super::libinput_plugins::init_libinput_plugin_system(&libinput) }; + + if libinput.udev_assign_seat(seat).is_err() { + debug!("headless: failed to assign libinput seat {seat:?}; input will be unavailable"); + return None; + } + + let input_backend = LibinputInputBackend::new(libinput.clone()); + if event_loop + .insert_source(input_backend, |mut event, _, state| { + state.process_libinput_event(&mut event); + state.process_input_event(event); + }) + .is_err() + { + debug!("headless: failed to insert libinput backend; input will be unavailable"); + return None; + } + + Some(libinput) +} diff --git a/src/backend/libinput_plugins.rs b/src/backend/libinput_plugins.rs new file mode 100644 index 0000000000..e06206f52f --- /dev/null +++ b/src/backend/libinput_plugins.rs @@ -0,0 +1,49 @@ +use smithay::reexports::input::Libinput; + +/// Initializes the libinput plugin system. +/// +/// # Safety +/// +/// This function must be called before libinput iterates through the devices, i.e. before +/// `libinput_udev_assign_seat()` or the first call to `libinput_path_add_device()`. +#[allow(unused_variables)] +pub(super) unsafe fn init_libinput_plugin_system(libinput: &Libinput) { + #[cfg(have_libinput_plugin_system)] + unsafe { + use std::ffi::{c_char, c_int, CString}; + use std::os::unix::ffi::OsStringExt; + + use directories::BaseDirs; + use input::ffi::libinput; + use input::AsRaw as _; + + extern "C" { + fn libinput_plugin_system_append_path(libinput: *const libinput, path: *const c_char); + fn libinput_plugin_system_append_default_paths(libinput: *const libinput); + fn libinput_plugin_system_load_plugins( + libinput: *const libinput, + flags: c_int, + ) -> c_int; + } + const LIBINPUT_PLUGIN_SYSTEM_FLAG_NONE: c_int = 0; + let libinput = libinput.as_raw(); + + // Also load plugins from $XDG_CONFIG_HOME/libinput/plugins. + if let Some(dirs) = BaseDirs::new() { + let mut plugins_dir = dirs.config_dir().to_path_buf(); + plugins_dir.push("libinput"); + plugins_dir.push("plugins"); + if let Ok(plugins_dir) = CString::new(plugins_dir.into_os_string().into_vec()) { + libinput_plugin_system_append_path(libinput, plugins_dir.as_ptr()); + } + } + + libinput_plugin_system_append_default_paths(libinput); + libinput_plugin_system_load_plugins(libinput, LIBINPUT_PLUGIN_SYSTEM_FLAG_NONE); + } + + // When libinput's plugin system isn't available, this function is intentionally a no-op. + // We keep an explicit reference so the intent is clear under that cfg. + #[cfg(not(have_libinput_plugin_system))] + let _ = libinput; +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 49fbc6b00d..960a3817ce 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -14,6 +14,9 @@ use crate::utils::id::IdCounter; pub mod tty; pub use tty::Tty; +mod libinput_plugins; +mod virtual_output; + pub mod winit; pub use winit::Winit; @@ -39,6 +42,20 @@ pub enum RenderResult { pub type IpcOutputMap = HashMap; +/// Marker inserted into `Output::user_data()` for outputs that are not driven by a real scanout +/// pipeline (e.g. HEADLESS-* virtual outputs). +/// +/// Such outputs require special-casing in a few places (notably frame callback delivery), because +/// they don't participate in the normal GPU pipeline that tracks `surface_primary_scanout_output`. +#[derive(Debug, Default)] +pub struct VirtualOutputMarker; + +impl VirtualOutputMarker { + pub(crate) fn is_virtual(output: &Output) -> bool { + output.user_data().get::().is_some() + } +} + static OUTPUT_ID_COUNTER: IdCounter = IdCounter::new(); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -165,7 +182,7 @@ impl Backend { match self { Backend::Tty(tty) => tty.primary_gbm_device(), Backend::Winit(_) => None, - Backend::Headless(_) => None, + Backend::Headless(headless) => headless.gbm_device(), } } @@ -197,7 +214,7 @@ impl Backend { match self { Backend::Tty(tty) => tty.on_output_config_changed(niri), Backend::Winit(_) => (), - Backend::Headless(_) => (), + Backend::Headless(headless) => headless.on_output_config_changed(niri), } } @@ -232,4 +249,35 @@ impl Backend { panic!("backend is not Headless") } } + + /// Create a new virtual output and return its name. + /// Only supported on TTY and Headless backends. + pub fn create_virtual_output( + &mut self, + niri: &mut Niri, + width: u16, + height: u16, + refresh_rate: u32, + ) -> Result { + match self { + Backend::Headless(headless) => { + Ok(headless.create_virtual_output(niri, width, height, refresh_rate)) + } + Backend::Tty(tty) => Ok(tty.create_virtual_output(niri, width, height, refresh_rate)), + Backend::Winit(_) => { + Err("virtual outputs are not supported on the Winit backend".to_string()) + } + } + } + + /// Remove a virtual output by name. + pub fn remove_virtual_output(&mut self, niri: &mut Niri, name: &str) -> Result<(), String> { + match self { + Backend::Headless(headless) => headless.remove_virtual_output(niri, name), + Backend::Tty(tty) => tty.remove_virtual_output(niri, name), + Backend::Winit(_) => { + Err("virtual outputs are not supported on the Winit backend".to_string()) + } + } + } } diff --git a/src/backend/tty.rs b/src/backend/tty.rs index 7b52b272dd..b0accc35ca 100644 --- a/src/backend/tty.rs +++ b/src/backend/tty.rs @@ -61,8 +61,7 @@ use smithay_drm_extras::drm_scanner::{DrmScanEvent, DrmScanner}; use wayland_protocols::wp::linux_dmabuf::zv1::server::zwp_linux_dmabuf_feedback_v1::TrancheFlags; use wayland_protocols::wp::presentation_time::server::wp_presentation_feedback; -use super::{IpcOutputMap, RenderResult}; -use crate::backend::OutputId; +use super::{virtual_output, IpcOutputMap, OutputId, RenderResult, VirtualOutputMarker}; use crate::frame_clock::FrameClock; use crate::niri::{Niri, RedrawState, State}; use crate::render_helpers::debug::draw_damage; @@ -103,6 +102,16 @@ pub struct Tty { // Whether the debug tinting is enabled. debug_tint: bool, ipc_outputs: Arc>, + // Virtual outputs that we manage and mirror to IPC, indexed by output name. + virtual_outputs: VirtualOutputs, +} + +/// State related to managing virtual outputs (e.g. HEADLESS-* outputs). +struct VirtualOutputs { + /// Counter for generating unique virtual output names and IDs. + counter: u32, + /// Virtual outputs indexed by output name. + outputs: HashMap, } pub type TtyRenderer<'render> = MultiRenderer< @@ -434,7 +443,7 @@ impl Tty { .unwrap(); let mut libinput = Libinput::new_with_udev(LibinputSessionInterface::from(session.clone())); - unsafe { init_libinput_plugin_system(&libinput) }; + unsafe { super::libinput_plugins::init_libinput_plugin_system(&libinput) }; { let _span = tracy_client::span!("Libinput::udev_assign_seat"); libinput.udev_assign_seat(&seat_name) @@ -507,6 +516,10 @@ impl Tty { update_ignored_nodes_on_resume: false, debug_tint: false, ipc_outputs: Arc::new(Mutex::new(HashMap::new())), + virtual_outputs: VirtualOutputs { + counter: 0, + outputs: HashMap::new(), + }, }) } @@ -1804,7 +1817,12 @@ impl Tty { if output_state.unfinished_animations_remain { niri.queue_redraw(&output); } else { - niri.send_frame_callbacks(&output); + let is_virtual = VirtualOutputMarker::is_virtual(&output); + if is_virtual { + niri.send_frame_callbacks_for_virtual_output(&output); + } else { + niri.send_frame_callbacks(&output); + } } } @@ -1833,6 +1851,11 @@ impl Tty { let mut rv = RenderResult::Skipped; + let is_virtual = VirtualOutputMarker::is_virtual(output); + if is_virtual { + return self.render_virtual_output(niri, output, target_presentation_time); + } + let tty_state: &TtyOutputState = output.user_data().get().unwrap(); let Some(device) = self.devices.get_mut(&tty_state.node) else { error!("missing output device"); @@ -2162,7 +2185,14 @@ impl Tty { .global_space .outputs() .find(|output| { - let tty_state: &TtyOutputState = output.user_data().get().unwrap(); + if VirtualOutputMarker::is_virtual(output) { + return false; + } + + let Some(tty_state) = output.user_data().get::() else { + return false; + }; + tty_state.node == *node && tty_state.crtc == crtc }) .map(logical_output); @@ -2191,6 +2221,45 @@ impl Tty { } } + // Since these are not DRM connectors, the loop above does not include them. + // If we drop them here, `niri msg outputs` will act as if they are disconnected and + // subsequent `niri msg output HEADLESS-* ...` commands will report "not connected". + for (name, (output, output_id)) in &self.virtual_outputs.outputs { + let current_mode = output.current_mode(); + let Some(mode) = current_mode else { + continue; + }; + + let logical = niri + .global_space + .outputs() + .find(|o| o.name() == *name) + .map(logical_output); + + let physical_properties = output.physical_properties(); + + let ipc_output = niri_ipc::Output { + name: name.clone(), + make: physical_properties.make, + model: physical_properties.model, + serial: None, + physical_size: None, + modes: vec![niri_ipc::Mode { + width: mode.size.w as u16, + height: mode.size.h as u16, + refresh_rate: mode.refresh as u32, + is_preferred: true, + }], + current_mode: Some(0), + is_custom_mode: true, + vrr_supported: false, + vrr_enabled: false, + logical, + }; + + ipc_outputs.insert(*output_id, ipc_output); + } + let mut guard = self.ipc_outputs.lock().unwrap(); *guard = ipc_outputs; niri.ipc_outputs_changed = true; @@ -2200,6 +2269,75 @@ impl Tty { self.ipc_outputs.clone() } + /// Render a virtual output (no actual rendering, just presentation feedback). + fn render_virtual_output( + &mut self, + niri: &mut Niri, + output: &Output, + target_presentation_time: Duration, + ) -> RenderResult { + use smithay::backend::renderer::element::RenderElementStates; + use smithay::wayland::presentation::Refresh; + + let now = get_monotonic_time(); + + let states = RenderElementStates::default(); + let mut presentation_feedbacks = niri.take_presentation_feedbacks(output, &states); + presentation_feedbacks.presented::<_, smithay::utils::Monotonic>( + now, + Refresh::Unknown, + 0, + wp_presentation_feedback::Kind::empty(), + ); + + // Update the frame clock so animation timing works correctly. + let output_state = niri.output_state.get_mut(output).unwrap(); + output_state.frame_clock.presented(now); + + // Use the estimated vblank timer to pace redraws, just like physical outputs. + queue_estimated_vblank_timer(niri, output.clone(), target_presentation_time); + + RenderResult::Submitted + } + + pub fn create_virtual_output( + &mut self, + niri: &mut Niri, + width: u16, + height: u16, + refresh_rate: u32, + ) -> String { + let built = virtual_output::build_headless_virtual_output( + &mut self.virtual_outputs.counter, + width, + height, + refresh_rate, + ); + + self.ipc_outputs + .lock() + .unwrap() + .insert(built.output_id, built.ipc_output); + + self.virtual_outputs + .outputs + .insert(built.name.clone(), (built.output.clone(), built.output_id)); + + niri.add_output(built.output, Some(built.refresh_interval), false); + + built.name + } + + pub fn remove_virtual_output(&mut self, niri: &mut Niri, name: &str) -> Result<(), String> { + virtual_output::remove_virtual_output_from_map( + niri, + &self.ipc_outputs, + &mut self.virtual_outputs.outputs, + name, + "virtual output", + ) + } + #[cfg(feature = "xdp-gnome-screencast")] pub fn primary_gbm_device(&self) -> Option> { // Try to find a device corresponding to the primary render node. @@ -2235,6 +2373,14 @@ impl Tty { pub fn set_output_on_demand_vrr(&mut self, niri: &mut Niri, output: &Output, enable_vrr: bool) { let _span = tracy_client::span!("Tty::set_output_on_demand_vrr"); + if VirtualOutputMarker::is_virtual(output) { + return; + } + + let Some(tty_state) = output.user_data().get::() else { + return; + }; + let output_state = niri.output_state.get_mut(output).unwrap(); output_state.on_demand_vrr_enabled = enable_vrr; if output_state.frame_clock.vrr() == enable_vrr { @@ -2242,7 +2388,6 @@ impl Tty { } for (&node, device) in self.devices.iter_mut() { for (&crtc, surface) in device.surfaces.iter_mut() { - let tty_state: &TtyOutputState = output.user_data().get().unwrap(); if tty_state.node == node && tty_state.crtc == crtc { let word = if enable_vrr { "enabling" } else { "disabling" }; if let Err(err) = surface.compositor.use_vrr(enable_vrr) { @@ -2524,6 +2669,17 @@ impl Tty { } } + // Apply config changes to virtual outputs (HEADLESS-*). + { + let config = self.config.clone(); + virtual_output::apply_config_to_managed_virtual_outputs( + niri, + &mut self.virtual_outputs.outputs, + None, + &config, + ); + } + self.refresh_ipc_outputs(niri); } @@ -3361,50 +3517,6 @@ fn make_output_name( } } -/// Initializes the libinput plugin system. -/// -/// # Safety -/// -/// This function must be called before libinput iterates through the devices, i.e. before -/// libinput_udev_assign_seat() or the first call to libinput_path_add_device(). -unsafe fn init_libinput_plugin_system(libinput: &Libinput) { - #[cfg(have_libinput_plugin_system)] - unsafe { - use std::ffi::{c_char, c_int, CString}; - use std::os::unix::ffi::OsStringExt; - - use directories::BaseDirs; - use input::ffi::libinput; - use input::AsRaw as _; - - extern "C" { - fn libinput_plugin_system_append_path(libinput: *const libinput, path: *const c_char); - fn libinput_plugin_system_append_default_paths(libinput: *const libinput); - fn libinput_plugin_system_load_plugins( - libinput: *const libinput, - flags: c_int, - ) -> c_int; - } - const LIBINPUT_PLUGIN_SYSTEM_FLAG_NONE: c_int = 0; - let libinput = libinput.as_raw(); - - // Also load plugins from $XDG_CONFIG_HOME/libinput/plugins. - if let Some(dirs) = BaseDirs::new() { - let mut plugins_dir = dirs.config_dir().to_path_buf(); - plugins_dir.push("libinput"); - plugins_dir.push("plugins"); - if let Ok(plugins_dir) = CString::new(plugins_dir.into_os_string().into_vec()) { - libinput_plugin_system_append_path(libinput, plugins_dir.as_ptr()); - } - } - - libinput_plugin_system_append_default_paths(libinput); - libinput_plugin_system_load_plugins(libinput, LIBINPUT_PLUGIN_SYSTEM_FLAG_NONE); - } - #[cfg(not(have_libinput_plugin_system))] - let _ = libinput; -} - #[cfg(test)] mod tests { use insta::assert_debug_snapshot; diff --git a/src/backend/virtual_output.rs b/src/backend/virtual_output.rs new file mode 100644 index 0000000000..81e1a7f205 --- /dev/null +++ b/src/backend/virtual_output.rs @@ -0,0 +1,245 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use niri_config::{Config, OutputName}; +use smithay::output::{Mode, Output, PhysicalProperties, Subpixel}; +use smithay::utils::Size; + +use super::{IpcOutputMap, OutputId, VirtualOutputMarker}; +use crate::frame_clock::FrameClock; +use crate::niri::Niri; +use crate::utils::logical_output; + +pub(super) struct BuiltVirtualOutput { + pub name: String, + pub output: Output, + pub output_id: OutputId, + pub refresh_interval: Duration, + pub ipc_output: niri_ipc::Output, +} + +pub(super) fn refresh_interval_from_millihz(refresh_mhz: u64) -> Duration { + let refresh_mhz = refresh_mhz.max(1); + let interval_nanos = 1_000_000_000_000 / refresh_mhz; + + // Clamp extremely low refresh rates to a reasonable pacing interval. + // This matches existing virtual-output code paths (off/on + mode changes). + if interval_nanos >= 1_000_000_000 { + Duration::from_micros(16_667) + } else { + Duration::from_nanos(interval_nanos) + } +} + +pub(super) fn build_headless_virtual_output( + counter: &mut u32, + width: u16, + height: u16, + refresh_rate: u32, +) -> BuiltVirtualOutput { + let refresh_rate = if refresh_rate < 2 { 60 } else { refresh_rate }; + + *counter += 1; + let n = *counter; + + let connector = format!("HEADLESS-{n}"); + let make = "niri".to_string(); + let model = "virtual".to_string(); + let serial = n.to_string(); + + let refresh_mhz = i32::try_from(refresh_rate.saturating_mul(1000)).unwrap_or(60_000); + + let output = Output::new( + connector.clone(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: make.clone(), + model: model.clone(), + serial_number: serial.clone(), + }, + ); + + let mode = Mode { + size: Size::from((i32::from(width), i32::from(height))), + refresh: refresh_mhz.max(1), + }; + output.change_current_state(Some(mode), None, None, None); + output.set_preferred(mode); + + output.user_data().insert_if_missing(|| OutputName { + connector: connector.clone(), + make: Some(make), + model: Some(model), + serial: Some(serial), + }); + + output + .user_data() + .insert_if_missing(VirtualOutputMarker::default); + + let output_id = OutputId::next(); + + let physical_properties = output.physical_properties(); + let ipc_output = niri_ipc::Output { + name: output.name(), + make: physical_properties.make, + model: physical_properties.model, + serial: None, + physical_size: None, + modes: vec![niri_ipc::Mode { + width, + height, + refresh_rate: refresh_rate * 1000, + is_preferred: true, + }], + current_mode: Some(0), + is_custom_mode: true, + vrr_supported: false, + vrr_enabled: false, + logical: Some(logical_output(&output)), + }; + + let refresh_interval = refresh_interval_from_millihz(u64::from(refresh_rate) * 1000); + + BuiltVirtualOutput { + name: connector, + output, + output_id, + refresh_interval, + ipc_output, + } +} + +pub(super) fn remove_virtual_output_from_map( + niri: &mut Niri, + ipc_outputs: &Arc>, + outputs: &mut HashMap, + name: &str, + kind: &str, +) -> Result<(), String> { + let (output, output_id) = outputs + .remove(name) + .ok_or_else(|| format!("{kind} '{name}' not found"))?; + + ipc_outputs.lock().unwrap().remove(&output_id); + niri.remove_output(&output); + + Ok(()) +} + +pub(super) fn apply_config_to_managed_virtual_outputs( + niri: &mut Niri, + outputs: &mut HashMap, + ipc_outputs: Option<&Arc>>, + config: &Rc>, +) { + let config = config.borrow(); + let mut resized_outputs = Vec::new(); + + // Apply config to all managed virtual outputs, even if currently disconnected (off). + // This allows `off`/`on` to work without losing internal state or IPC entries. + for (output_name, (output, output_id)) in outputs.iter_mut() { + let name = output.user_data().get::().unwrap(); + let output_config = config.outputs.find(name); + + let is_off = output_config.is_some_and(|c| c.off); + let new_mode = output_config.and_then(|config| { + config.mode.as_ref().map(|mode_config| { + let refresh_hz = mode_config.mode.refresh.unwrap_or(60.0); + let refresh_mhz = (refresh_hz * 1000.0).round().clamp(1.0, i32::MAX as f64) as i32; + Mode { + size: Size::from(( + i32::from(mode_config.mode.width), + i32::from(mode_config.mode.height), + )), + refresh: refresh_mhz, + } + }) + }); + + // Apply mode changes even when off so the mode is ready when re-enabled. + let mut mode_changed = false; + if let Some(new_mode) = new_mode { + if output.current_mode() != Some(new_mode) { + output.change_current_state(Some(new_mode), None, None, None); + output.set_preferred(new_mode); + mode_changed = true; + + if let Some(ipc_outputs) = ipc_outputs { + if let Some(ipc_output) = ipc_outputs.lock().unwrap().get_mut(output_id) { + ipc_output.modes = vec![niri_ipc::Mode { + width: new_mode.size.w as u16, + height: new_mode.size.h as u16, + refresh_rate: new_mode.refresh as u32, + is_preferred: true, + }]; + ipc_output.current_mode = Some(0); + ipc_output.is_custom_mode = true; + } + } + } + } + + let was_connected = niri + .global_space + .outputs() + .any(|o| o.name() == *output_name); + + // Handle off/on by removing/adding the output from/to the space. + match (is_off, was_connected) { + (true, true) => { + niri.remove_output(output); + niri.ipc_outputs_changed = true; + } + (false, false) => { + let refresh_mhz = output + .current_mode() + .map(|m| m.refresh.max(1) as u64) + .unwrap_or(60_000); + let refresh_interval = refresh_interval_from_millihz(refresh_mhz); + niri.add_output(output.clone(), Some(refresh_interval), false); + niri.ipc_outputs_changed = true; + resized_outputs.push(output.clone()); + } + _ => {} + } + + let is_connected = niri + .global_space + .outputs() + .any(|o| o.name() == *output_name); + + if mode_changed && !is_off && is_connected { + if let Some(new_mode) = output.current_mode() { + // Keep refresh pacing in sync with the new mode when connected. + if let Some(output_state) = niri.output_state.get_mut(output) { + let refresh_mhz = new_mode.refresh.max(1) as u64; + let refresh_interval = refresh_interval_from_millihz(refresh_mhz); + output_state.frame_clock = FrameClock::new(Some(refresh_interval), false); + } + } + + resized_outputs.push(output.clone()); + } + + if let Some(ipc_outputs) = ipc_outputs { + if let Some(ipc_output) = ipc_outputs.lock().unwrap().get_mut(output_id) { + ipc_output.logical = is_connected.then(|| logical_output(output)); + } + } + } + + if !resized_outputs.is_empty() { + niri.ipc_outputs_changed = true; + for output in resized_outputs { + if niri.output_state.contains_key(&output) { + niri.output_resized(&output); + niri.queue_redraw(&output); + } + } + } +} diff --git a/src/cli.rs b/src/cli.rs index 308b3127b7..f610addd28 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -99,6 +99,24 @@ pub enum Msg { #[command(subcommand)] action: OutputAction, }, + /// Create a virtual output. + CreateVirtualOutput { + /// Width in pixels. + #[arg(long, default_value = "1920")] + width: u16, + /// Height in pixels. + #[arg(long, default_value = "1080")] + height: u16, + /// Refresh rate in Hz. + #[arg(long, default_value = "60")] + refresh_rate: u32, + }, + /// Remove a virtual output. + RemoveVirtualOutput { + /// Identifier of the output to remove. + #[arg()] + name: String, + }, /// Start continuously receiving events from the compositor. EventStream, /// Print the version of the running niri instance. diff --git a/src/ipc/client.rs b/src/ipc/client.rs index 3c826bfff1..84fe32a269 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -41,6 +41,16 @@ pub fn handle_msg(mut msg: Msg, json: bool) -> anyhow::Result<()> { output: output.clone(), action: action.clone(), }, + Msg::CreateVirtualOutput { + width, + height, + refresh_rate, + } => Request::CreateVirtualOutput { + width: Some(*width), + height: Some(*height), + refresh_rate: Some(*refresh_rate), + }, + Msg::RemoveVirtualOutput { name } => Request::RemoveVirtualOutput { name: name.clone() }, Msg::Workspaces => Request::Workspaces, Msg::Windows => Request::Windows, Msg::Layers => Request::Layers, @@ -339,6 +349,31 @@ pub fn handle_msg(mut msg: Msg, json: bool) -> anyhow::Result<()> { println!("The change will apply when it is connected."); } } + Msg::CreateVirtualOutput { .. } => { + let Response::VirtualOutputCreated(name) = response else { + bail!("unexpected response: expected VirtualOutputCreated, got {response:?}"); + }; + + if json { + let response = json!({ "name": name }); + println!("{response}"); + return Ok(()); + } + + println!("Created virtual output: {name}"); + } + Msg::RemoveVirtualOutput { name } => { + let Response::Handled = response else { + bail!("unexpected response: expected Handled, got {response:?}"); + }; + + if json { + println!("{{}}"); + return Ok(()); + } + + println!("Removed virtual output: {name}"); + } Msg::Workspaces => { let Response::Workspaces(mut response) = response else { bail!("unexpected response: expected Workspaces, got {response:?}"); diff --git a/src/ipc/server.rs b/src/ipc/server.rs index db71da6dbd..8ca62afee0 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -418,6 +418,51 @@ async fn process(ctx: &ClientCtx, request: Request) -> Reply { Response::OutputConfigChanged(response) } + Request::CreateVirtualOutput { + width, + height, + refresh_rate, + } => { + let width = width.unwrap_or(1920); + let height = height.unwrap_or(1080); + let refresh_rate = refresh_rate.unwrap_or(60); + + let (tx, rx) = async_channel::bounded(1); + + ctx.event_loop.insert_idle(move |state| { + let result = state.backend.create_virtual_output( + &mut state.niri, + width, + height, + refresh_rate, + ); + let _ = tx.send_blocking(result); + }); + + let result = rx + .recv() + .await + .map_err(|_| String::from("error creating virtual output"))?; + match result { + Ok(name) => Response::VirtualOutputCreated(name), + Err(e) => return Err(e), + } + } + Request::RemoveVirtualOutput { name } => { + let (tx, rx) = async_channel::bounded(1); + + ctx.event_loop.insert_idle(move |state| { + let result = state.backend.remove_virtual_output(&mut state.niri, &name); + let _ = tx.send_blocking(result); + }); + + let result = rx + .recv() + .await + .map_err(|_| String::from("error removing virtual output"))?; + result?; + Response::Handled + } Request::FocusedOutput => { let (tx, rx) = async_channel::bounded(1); ctx.event_loop.insert_idle(move |state| { diff --git a/src/main.rs b/src/main.rs index bbca88f8eb..e2d9b9aa6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -167,6 +167,13 @@ fn main() -> Result<(), Box> { // Handle Ctrl+C and other signals. niri::utils::signals::listen(&event_loop.handle()); + // Determine if we're starting in headless mode. + let headless = env::var("NIRI_BACKEND") + .map(|v| v.eq_ignore_ascii_case("headless")) + .unwrap_or(false); + + headless.then(|| info!("starting in headless mode")); + // Create the compositor. let display = Display::new().unwrap(); @@ -178,7 +185,7 @@ fn main() -> Result<(), Box> { event_loop.handle(), event_loop.get_signal(), display, - false, + headless, true, cli.session, ) diff --git a/src/niri.rs b/src/niri.rs index d84c390abf..2902939c93 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -710,7 +710,7 @@ impl State { || env::var_os("DISPLAY").is_some(); let mut backend = if headless { - let headless = Headless::new(); + let headless = Headless::new(event_loop.clone()); Backend::Headless(headless) } else if has_display { let winit = Winit::new(config.clone(), event_loop.clone())?; @@ -4420,7 +4420,13 @@ impl Niri { // // However, this should probably be restricted to sending frame callbacks to more surfaces, // to err on the safe side. - self.send_frame_callbacks(output); + let is_virtual = crate::backend::VirtualOutputMarker::is_virtual(output); + if is_virtual { + self.send_frame_callbacks_for_virtual_output(output); + } else { + self.send_frame_callbacks(output); + } + backend.with_primary_renderer(|renderer| { #[cfg(feature = "xdp-gnome-screencast")] { @@ -4782,6 +4788,52 @@ impl Niri { } } + /// Send frame callbacks for a virtual output. + /// + /// Virtual outputs (e.g. HEADLESS-*) don't go through the normal scanout/render pipeline that + /// updates `surface_primary_scanout_output`, so the regular `send_frame_callbacks` visibility + /// filtering would often suppress callbacks entirely. + /// + /// This function therefore sends callbacks to surfaces associated with `output` + /// unconditionally (still subject to the per-surface throttling done inside `send_frame`). + /// + /// Note: this deliberately does not send callbacks for global/pointer-related surfaces (cursor + /// image, drag-and-drop icon) because those rely on the normal primary-output based + /// deduplication. + pub fn send_frame_callbacks_for_virtual_output(&mut self, output: &Output) { + let _span = tracy_client::span!("Niri::send_frame_callbacks_for_virtual_output"); + + let frame_callback_time = get_monotonic_time(); + + for mapped in self.layout.windows_for_output_mut(output) { + mapped.send_frame( + output, + frame_callback_time, + FRAME_CALLBACK_THROTTLE, + |_, _| Some(output.clone()), + ); + } + + for surface in layer_map_for_output(output).layers() { + surface.send_frame( + output, + frame_callback_time, + FRAME_CALLBACK_THROTTLE, + |_, _| Some(output.clone()), + ); + } + + if let Some(surface) = &self.output_state[output].lock_surface { + send_frames_surface_tree( + surface.wl_surface(), + output, + frame_callback_time, + FRAME_CALLBACK_THROTTLE, + |_, _| Some(output.clone()), + ); + } + } + pub fn send_frame_callbacks_on_fallback_timer(&mut self) { let _span = tracy_client::span!("Niri::send_frame_callbacks_on_fallback_timer"); diff --git a/src/utils/spawning.rs b/src/utils/spawning.rs index f5b2cb47a0..a6a361e391 100644 --- a/src/utils/spawning.rs +++ b/src/utils/spawning.rs @@ -100,6 +100,11 @@ fn spawn_sync( ) { let _span = tracy_client::span!(); + let args: Vec = args + .into_iter() + .map(|a| a.as_ref().to_os_string()) + .collect(); + let mut command = command.as_ref(); // Expand `~` at the start. @@ -114,11 +119,13 @@ fn spawn_sync( let mut process = Command::new(command); process - .args(args) + .args(&args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); + debug!("spawning child: {command:?} {args:?}"); + // Remove RUST_BACKTRACE and RUST_LIB_BACKTRACE from the environment if needed. if REMOVE_ENV_RUST_BACKTRACE.load(Ordering::Relaxed) { process.env_remove("RUST_BACKTRACE"); @@ -342,7 +349,7 @@ mod systemd { match read_all(pipe, &mut buf) { Ok(()) => { let pid = i32::from_ne_bytes(buf); - trace!("spawned PID: {pid}"); + debug!("spawned PID: {pid}"); // Start a systemd scope for the grandchild. if let Err(err) = start_systemd_scope(command, child.id(), pid as u32) {