From 9e2bfaa14e90f99f1b6bc75edf0e089962d6daa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20S=C3=A1lyi?= Date: Fri, 11 Apr 2025 22:04:10 +0200 Subject: [PATCH] Add support to the Hyprland Wayland compositor --- Cargo.lock | 2 + Cargo.toml | 2 + PKGBUILD | 3 +- src/cli.rs | 5 +- src/compositors.rs | 18 +++- src/compositors/hyprland.rs | 159 ++++++++++++++++++++++++++++++++++++ 6 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 src/compositors/hyprland.rs diff --git a/Cargo.lock b/Cargo.lock index 52fe53d..5ff25d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -699,6 +699,8 @@ dependencies = [ "log", "mio", "niri-ipc", + "serde", + "serde_json", "smithay-client-toolkit", "swayipc", ] diff --git a/Cargo.toml b/Cargo.toml index 1a68e9f..1eddea6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ fast_image_resize = "5.0.0" image = "0.25.0" log = "0.4.21" mio = { version = "1.0.2", features = ["os-ext", "os-poll"] } +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.140" swayipc = "3.0.2" niri-ipc = "=25.2.0" diff --git a/PKGBUILD b/PKGBUILD index 2d28504..4ca6eda 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -9,8 +9,9 @@ license=('MIT' 'Apache') depends=('gcc-libs' 'glibc') makedepends=('cargo') optdepends=( - 'sway: supported window manager to set the wallpapers with' + 'hyprland: supported window manager to set the wallpapers with' 'niri: supported window manager to set the wallpapers with' + 'sway: supported window manager to set the wallpapers with' ) source=("$pkgname-$pkgver.tar.gz::https://static.crates.io/crates/$pkgname/$pkgname-$pkgver.crate") sha256sums=('2b087124ea07635e53d411e707f7d22f73c69b40f3986a42c841f9cc19fc2d51') diff --git a/src/cli.rs b/src/cli.rs index a8cdc4f..778582b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -72,10 +72,11 @@ In case of errors multibg-sway logs to stderr and tries to continue. One may wish to redirect stderr if multibg-sway is being run as a daemon. -multibg-sway supports multiple compositors, currently only sway and niri. +multibg-sway supports multiple compositors, +currently only sway, hyprland and niri. It tries to autodetect the compositor based on environment variables, defaulting to sway if that fails. -Pass --compositor niri to ensure it can to talk to niri.")] +Pass --compositor {hyprland|niri} to ensure it can to talk to them")] pub struct Cli { /// adjust contrast, eg. -c=-25 (default: 0) #[arg(short, long)] diff --git a/src/compositors.rs b/src/compositors.rs index f734ce6..3eeafa5 100644 --- a/src/compositors.rs +++ b/src/compositors.rs @@ -1,3 +1,4 @@ +mod hyprland; mod niri; mod sway; @@ -12,8 +13,9 @@ use std::{ #[derive(Clone, Copy, Debug, clap::ValueEnum)] pub enum Compositor { - Sway, + Hyprland, Niri, + Sway, } impl Compositor { @@ -28,6 +30,9 @@ impl Compositor { if xdg_desktop.as_bytes().starts_with(b"sway") { debug!("Selecting compositor Sway based on {xdg_desktop_var}"); Some(Compositor::Sway) + } else if xdg_desktop.as_bytes().starts_with(b"Hyprland") { + debug!("Selecting compositor Hyprland based on {xdg_desktop_var}"); + Some(Compositor::Hyprland) } else if xdg_desktop.as_bytes().starts_with(b"niri") { debug!("Selecting compositor Niri based on {xdg_desktop_var}"); Some(Compositor::Niri) @@ -47,6 +52,10 @@ impl Compositor { if env::var_os("SWAYSOCK").is_some() { debug!("Selecting compositor Sway based on SWAYSOCK"); Some(Compositor::Sway) + } else if env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() { + debug!("Selecting compositor Hyprland based on \ + HYPRLAND_INSTANCE_SIGNATURE"); + Some(Compositor::Hyprland) } else if env::var_os("NIRI_SOCKET").is_some() { debug!("Selecting compositor Niri based on NIRI_SOCKET"); Some(Compositor::Niri) @@ -99,6 +108,9 @@ impl ConnectionTask { pub fn new(composer: Compositor, tx: Sender, waker: Arc) -> Self { let interface: Box = match composer { Compositor::Sway => Box::new(sway::SwayConnectionTask::new()), + Compositor::Hyprland => Box::new( + hyprland::HyprlandConnectionTask::new() + ), Compositor::Niri => Box::new(niri::NiriConnectionTask::new()), }; @@ -120,6 +132,10 @@ impl ConnectionTask { let composer_interface = sway::SwayConnectionTask::new(); composer_interface.subscribe_event_loop(event_sender); } + Compositor::Hyprland => { + let composer_interface = hyprland::HyprlandConnectionTask::new(); + composer_interface.subscribe_event_loop(event_sender); + } Compositor::Niri => { let composer_interface = niri::NiriConnectionTask::new(); composer_interface.subscribe_event_loop(event_sender); diff --git a/src/compositors/hyprland.rs b/src/compositors/hyprland.rs new file mode 100644 index 0000000..a5aa4cb --- /dev/null +++ b/src/compositors/hyprland.rs @@ -0,0 +1,159 @@ +// https://wiki.hyprland.org/IPC/ + +use std::{ + env, + io::{Read, Write}, + os::unix::net::UnixStream, + path::PathBuf, +}; + +use log::debug; +use serde::Deserialize; + +use super::{CompositorInterface, WorkspaceVisible, EventSender}; + +pub struct HyprlandConnectionTask {} + +impl HyprlandConnectionTask { + pub fn new() -> Self { + HyprlandConnectionTask {} + } +} + +impl CompositorInterface for HyprlandConnectionTask { + fn request_visible_workspaces(&mut self) -> Vec { + current_state().visible_workspaces + } + + fn subscribe_event_loop(self, event_sender: EventSender) { + let mut socket = socket_dir_path(); + socket.push(".socket2.sock"); + let mut connection = UnixStream::connect(socket) + .expect("Failed to connect to Hyprland events socket"); + let initial_state = current_state(); + for workspace in initial_state.visible_workspaces { + event_sender.send(workspace); + } + let mut active_monitor = initial_state.active_monitor; + let mut buf = vec![0u8; 2000]; + let mut filled = 0usize; + let mut parsed = 0usize; + loop { + let read = connection.read(&mut buf[filled..]).unwrap(); + if read == 0 { + panic!("Hyperland events socket disconnected"); + } + filled += read; + if filled == buf.len() { + let new_len = buf.len() * 2; + debug!("Growing Hyprland socket read buffer to {new_len}"); + buf.resize(new_len, 0u8); + } + loop { + let mut unparsed = &buf[parsed..filled]; + let Some(gt_pos) = unparsed.iter().position(|&b| b == b'>') + else { break }; + let event_name = &unparsed[..gt_pos]; + unparsed = &unparsed[gt_pos+2..]; + let Some(lf_pos) = unparsed.iter().position(|&b| b == b'\n') + else { break }; + let event_data = &unparsed[..lf_pos]; + unparsed = &unparsed[lf_pos+1..]; + parsed = filled - unparsed.len(); + debug!( + "Hyprland event: {} {}", + String::from_utf8_lossy(event_name), + String::from_utf8_lossy(event_data), + ); + if event_name == b"workspace" { + event_sender.send(WorkspaceVisible { + output: active_monitor.clone(), + workspace_name: String::from_utf8(event_data.to_vec()) + .unwrap(), + }); + } else if event_name == b"focusedmon" { + let comma_pos = event_data.iter() + .position(|&b| b == b',').unwrap(); + let monname = &event_data[..comma_pos]; + active_monitor = String::from_utf8(monname.to_vec()) + .unwrap(); + } else if event_name == b"moveworkspace" + || event_name == b"renameworkspace" + { + let current_state = current_state(); + for workspace in current_state.visible_workspaces { + event_sender.send(workspace); + } + active_monitor = current_state.active_monitor; + } + } + if parsed == filled { + filled = 0; + parsed = 0; + } else { + buf.copy_within(parsed..filled, 0); + filled -= parsed; + parsed = 0; + } + } + } +} + +// "$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE" +fn socket_dir_path() -> PathBuf { + let xdg_runtime_dir = env::var_os("XDG_RUNTIME_DIR") + .expect("Environment variable XDG_RUNTIME_DIR not set"); + let his = env::var_os("HYPRLAND_INSTANCE_SIGNATURE") + .expect("Environment variable HYPRLAND_INSTANCE_SIGNATURE not set"); + let mut ret = PathBuf::with_capacity(256); + ret.push(xdg_runtime_dir); + ret.push("hypr"); + ret.push(his); + ret +} + +fn current_state() -> CurrentState { + let mut socket = socket_dir_path(); + socket.push(".socket.sock"); + let mut connection = UnixStream::connect(socket) + .expect("Failed to connect to Hyprland requests socket"); + connection.write_all(b"j/monitors") + .expect("Failed to send Hyprland monitors requests"); + let mut buf = Vec::with_capacity(2000); + // This socket .socket.sock for hyprctl-like requests + // only allows one round trip with a single or batched commands + let read = connection.read_to_end(&mut buf) + .expect("Failed to receive Hyprland monitors response"); + let monitors: Vec = serde_json::from_slice(&buf[..read]) + .expect("Failed to parse Hyprland monitors response"); + let mut active_monitor = String::new(); + let mut visible_workspaces = Vec::new(); + for monitor in monitors { + if monitor.focused { + active_monitor = monitor.name.clone(); + } + visible_workspaces.push(WorkspaceVisible { + output: monitor.name, + workspace_name: monitor.active_workspace.name, + }); + } + CurrentState { active_monitor, visible_workspaces } +} + +struct CurrentState { + active_monitor: String, + visible_workspaces: Vec, +} + +#[derive(Deserialize)] +struct Monitor { + name: String, + #[serde(rename = "activeWorkspace")] + active_workspace: ActiveWorkspace, + focused: bool, +} + +#[derive(Deserialize)] +struct ActiveWorkspace { + name: String, +}