From 1de9027b07777d47ccd67243f7d2c3aa2903ad5a Mon Sep 17 00:00:00 2001 From: dingo Date: Sun, 9 Aug 2026 23:14:35 +0700 Subject: [PATCH] Patch B: Hyprland-compatible IPC socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binds .socket (request/response) and .socket2 (event stream) under $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE, so waybar's hyprland/workspaces and hyprland/window modules work unmodified. Both listeners register on the existing calloop loop following session.rs's run_socket pattern; nothing spawns a thread touching compositor state. Wiring the agent documented but did not apply: - lib.rs calls hypr_ipc::init before announcing readiness, so the signature is exported in time for get_env to pass it to children. Failure is non-fatal; only waybar's hyprland/* modules go dark. - Common gains an Option, None until the sockets bind. - session.rs get_env forwards HYPRLAND_INSTANCE_SIGNATURE alongside WAYLAND_DISPLAY, which is how waybar finds the socket. cargo check passes clean; 13 hypr_ipc unit tests pass, covering the JSON field names, event-line formats and command parsing. Full lib suite: 14 passed, 0 failed. NOT runtime-verified — no Wayland session available here, so nothing confirms waybar actually consumes these sockets. Event notifications are inert until notify_* is called from focus-change sites; request/response works without that. --- src/hypr_ipc/events.rs | 114 +++++++++++++ src/hypr_ipc/mod.rs | 28 ++++ src/hypr_ipc/protocol.rs | 192 +++++++++++++++++++++ src/hypr_ipc/query.rs | 352 +++++++++++++++++++++++++++++++++++++++ src/hypr_ipc/socket.rs | 271 ++++++++++++++++++++++++++++++ src/lib.rs | 11 ++ src/session.rs | 5 + src/state.rs | 4 + 8 files changed, 977 insertions(+) create mode 100644 src/hypr_ipc/events.rs create mode 100644 src/hypr_ipc/mod.rs create mode 100644 src/hypr_ipc/protocol.rs create mode 100644 src/hypr_ipc/query.rs create mode 100644 src/hypr_ipc/socket.rs diff --git a/src/hypr_ipc/events.rs b/src/hypr_ipc/events.rs new file mode 100644 index 0000000..1c2519d --- /dev/null +++ b/src/hypr_ipc/events.rs @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Formatters for Hyprland's `.socket2` event-stream lines. +//! +//! Every event Hyprland emits is a single line of the form +//! `EVENTNAME>>data1,data2,...\n`. Shapes below are taken from +//! `wiki.hypr.land/IPC/#events-list` (fetched via cached/indexed search +//! results; a direct fetch returned HTTP 403) and cross-checked against the +//! Go IPC bindings at `github.com/thiagokokada/hyprland-go` +//! (`event.go`/`ipc.go`), which document the exact comma-separated payloads +//! for `workspace`, `activewindow`, `openwindow` and `closewindow`. +//! +//! These are pure string-formatting functions with no I/O so they can be +//! unit tested even though the socket transport itself cannot be exercised +//! without a live Wayland session. + +/// Format a window's stable pseudo-address the way Hyprland formats real +/// pointers in its event stream: lowercase hex, no `0x` prefix +/// (e.g. `5634a1b2c3d4`). See the `openwindow`/`closewindow` examples in +/// `wiki.hypr.land/IPC/`. +pub fn event_address(id: u64) -> String { + format!("{:x}", id) +} + +/// Same address, but with the `0x` prefix used inside JSON payloads +/// (`hyprctl -j clients` / `activewindow` `"address"` field). +pub fn json_address(id: u64) -> String { + format!("0x{:x}", id) +} + +/// `workspace>>WORKSPACENAME` +/// Emitted when the active workspace on the focused monitor changes. +pub fn workspace_event(name: &str) -> String { + format!("workspace>>{}\n", name) +} + +/// `activewindow>>WINDOWCLASS,WINDOWTITLE` +/// Emitted when the active window changes. +pub fn activewindow_event(class: &str, title: &str) -> String { + format!("activewindow>>{},{}\n", class, title) +} + +/// `openwindow>>ADDRESS,WORKSPACENAME,WINDOWCLASS,WINDOWTITLE` +/// Emitted when a new window is opened. +pub fn openwindow_event(address_id: u64, workspace: &str, class: &str, title: &str) -> String { + format!( + "openwindow>>{},{},{},{}\n", + event_address(address_id), + workspace, + class, + title + ) +} + +/// `closewindow>>ADDRESS` +/// Emitted when a window is closed. +pub fn closewindow_event(address_id: u64) -> String { + format!("closewindow>>{}\n", event_address(address_id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn address_formats_are_hyprland_shaped() { + assert_eq!(event_address(0xdead_beef), "deadbeef"); + assert_eq!(json_address(0xdead_beef), "0xdeadbeef"); + } + + #[test] + fn workspace_event_format() { + assert_eq!(workspace_event("2"), "workspace>>2\n"); + } + + #[test] + fn activewindow_event_format() { + assert_eq!( + activewindow_event("kitty", "~/src"), + "activewindow>>kitty,~/src\n" + ); + } + + #[test] + fn activewindow_event_handles_empty_title() { + assert_eq!(activewindow_event("", ""), "activewindow>>,\n"); + } + + #[test] + fn openwindow_event_format() { + assert_eq!( + openwindow_event(0x1234, "1", "kitty", "term"), + "openwindow>>1234,1,kitty,term\n" + ); + } + + #[test] + fn closewindow_event_format() { + assert_eq!(closewindow_event(0x1234), "closewindow>>1234\n"); + } + + #[test] + fn lines_are_single_line_terminated() { + for line in [ + workspace_event("1"), + activewindow_event("a", "b"), + openwindow_event(1, "1", "a", "b"), + closewindow_event(1), + ] { + assert_eq!(line.matches('\n').count(), 1); + assert!(line.ends_with('\n')); + } + } +} diff --git a/src/hypr_ipc/mod.rs b/src/hypr_ipc/mod.rs new file mode 100644 index 0000000..e59a6d6 --- /dev/null +++ b/src/hypr_ipc/mod.rs @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Hyprland-compatible IPC socket. +//! +//! Implements enough of Hyprland's own two-socket IPC protocol +//! (`.socket` for request/response, `.socket2` for a broadcast event +//! stream, both under `$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/`) +//! for unmodified Hyprland IPC clients — most notably waybar's +//! `hyprland/workspaces` and `hyprland/window` modules — to work against +//! cosmic-comp. +//! +//! This module is entirely additive: it makes no changes to any existing +//! cosmic-comp file. See [`socket::init`]'s doc comment for the exact +//! (unapplied) snippets needed to wire it in. +//! +//! - [`protocol`]: JSON response DTOs matching `hyprctl -j` shapes. +//! - [`events`]: `.socket2` event-line formatters. +//! - [`query`]: read-only data gathering against `Common`/`Shell`, plus +//! request parsing/dispatch. +//! - [`socket`]: calloop wiring (listeners, per-connection sources, +//! [`HyprIpcHandle`]). + +pub mod events; +pub mod protocol; +pub mod query; +pub mod socket; + +pub use socket::{HyprIpcHandle, init}; diff --git a/src/hypr_ipc/protocol.rs b/src/hypr_ipc/protocol.rs new file mode 100644 index 0000000..2fbded0 --- /dev/null +++ b/src/hypr_ipc/protocol.rs @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! JSON DTOs mirroring Hyprland's `hyprctl -j` response shapes. +//! +//! Field names and shapes are taken from Hyprland's own IPC documentation +//! (`wiki.hypr.land/IPC/`, fetched via cached/indexed search results since a +//! direct fetch returned HTTP 403) and cross-checked against third-party +//! IPC clients that document the exact wire shapes, e.g. the Go bindings at +//! `github.com/thiagokokada/hyprland-go` (`ipc.go` response structs) and +//! `github.com/hyprland-community/pyprland`. waybar's `hyprland/workspaces` +//! and `hyprland/window` modules only read a subset of these fields, but we +//! keep the full documented shape so unmodified clients never hit a missing +//! key. + +use serde::Serialize; + +/// A workspace reference as embedded in `activewindow` / `clients` / `monitors` +/// (`{"id": ..., "name": ...}`). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct WorkspaceRef { + pub id: i32, + pub name: String, +} + +/// Shape of one entry in `hyprctl -j workspaces` and of `hyprctl -j activeworkspace`. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct WorkspaceInfo { + pub id: i32, + pub name: String, + pub monitor: String, + #[serde(rename = "monitorID")] + pub monitor_id: i32, + pub windows: usize, + pub hasfullscreen: bool, + pub lastwindow: String, + pub lastwindowtitle: String, + pub ispersistent: bool, +} + +/// Shape of one entry in `hyprctl -j clients` and of `hyprctl -j activewindow`. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ClientInfo { + pub address: String, + pub mapped: bool, + pub hidden: bool, + pub at: [i32; 2], + pub size: [i32; 2], + pub workspace: WorkspaceRef, + pub floating: bool, + pub monitor: i32, + pub class: String, + pub title: String, + #[serde(rename = "initialClass")] + pub initial_class: String, + #[serde(rename = "initialTitle")] + pub initial_title: String, + pub pid: i32, + pub xwayland: bool, + pub pinned: bool, + pub fullscreen: i32, + #[serde(rename = "fullscreenClient")] + pub fullscreen_client: i32, + pub grouped: Vec, + pub tags: Vec, + pub swallowing: String, + #[serde(rename = "focusHistoryID")] + pub focus_history_id: i32, +} + +/// Shape of one entry in `hyprctl -j monitors`. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct MonitorInfo { + pub id: i32, + pub name: String, + pub description: String, + pub width: i32, + pub height: i32, + #[serde(rename = "refreshRate")] + pub refresh_rate: f64, + pub x: i32, + pub y: i32, + #[serde(rename = "activeWorkspace")] + pub active_workspace: WorkspaceRef, + pub scale: f64, + pub focused: bool, + pub disabled: bool, +} + +/// Hyprland mints workspace ids as small sequential integers assigned in +/// creation order (see `wiki.hypr.land/Configuring/Workspace-Rules/`). +/// cosmic-comp workspaces are identified by an opaque `WorkspaceHandle` plus +/// an optional display `name`, not a small stable integer, so we synthesize +/// one from the workspace's position within its output's workspace set. +/// Reserving a block of ids per output keeps ids stable and collision-free +/// across outputs without needing to touch any existing id-allocation code. +pub fn synth_workspace_id(output_index: usize, workspace_index: usize) -> i32 { + (output_index * 1000 + workspace_index + 1) as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_info_serializes_with_hyprland_field_names() { + let info = WorkspaceInfo { + id: 1, + name: "1".into(), + monitor: "eDP-1".into(), + monitor_id: 0, + windows: 2, + hasfullscreen: false, + lastwindow: "0x1".into(), + lastwindowtitle: "term".into(), + ispersistent: false, + }; + let json = serde_json::to_value(&info).unwrap(); + assert_eq!(json["monitorID"], 0); + assert_eq!(json["id"], 1); + assert_eq!(json["name"], "1"); + assert_eq!(json["hasfullscreen"], false); + } + + #[test] + fn client_info_serializes_with_hyprland_field_names() { + let info = ClientInfo { + address: "0xdeadbeef".into(), + mapped: true, + hidden: false, + at: [0, 0], + size: [800, 600], + workspace: WorkspaceRef { + id: 1, + name: "1".into(), + }, + floating: false, + monitor: 0, + class: "kitty".into(), + title: "term".into(), + initial_class: "kitty".into(), + initial_title: "kitty".into(), + pid: 42, + xwayland: false, + pinned: false, + fullscreen: 0, + fullscreen_client: 0, + grouped: vec![], + tags: vec![], + swallowing: "0".into(), + focus_history_id: 0, + }; + let json = serde_json::to_value(&info).unwrap(); + assert_eq!(json["initialClass"], "kitty"); + assert_eq!(json["initialTitle"], "kitty"); + assert_eq!(json["focusHistoryID"], 0); + assert_eq!(json["fullscreenClient"], 0); + assert_eq!(json["workspace"]["id"], 1); + assert_eq!(json["workspace"]["name"], "1"); + } + + #[test] + fn monitor_info_serializes_with_hyprland_field_names() { + let info = MonitorInfo { + id: 0, + name: "eDP-1".into(), + description: "".into(), + width: 1920, + height: 1080, + refresh_rate: 60.0, + x: 0, + y: 0, + active_workspace: WorkspaceRef { + id: 1, + name: "1".into(), + }, + scale: 1.0, + focused: true, + disabled: false, + }; + let json = serde_json::to_value(&info).unwrap(); + assert_eq!(json["refreshRate"], 60.0); + assert_eq!(json["activeWorkspace"]["name"], "1"); + } + + #[test] + fn synth_workspace_id_is_stable_and_unique_per_output() { + assert_eq!(synth_workspace_id(0, 0), 1); + assert_eq!(synth_workspace_id(0, 1), 2); + assert_eq!(synth_workspace_id(1, 0), 1001); + assert_ne!(synth_workspace_id(0, 5), synth_workspace_id(1, 5)); + } +} diff --git a/src/hypr_ipc/query.rs b/src/hypr_ipc/query.rs new file mode 100644 index 0000000..9cc44f7 --- /dev/null +++ b/src/hypr_ipc/query.rs @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! Read-only queries against cosmic-comp's compositor state (`Common`/`Shell`) +//! that gather the data needed to answer Hyprland IPC requests. +//! +//! All functions here are read-only with one exception: `window_address` +//! lazily mints a stable per-window pseudo-address the first time a window +//! is seen and caches it in the window's own `UserDataMap`. That is purely +//! additive bookkeeping (via public smithay API) and does not touch any +//! existing cosmic-comp state. +//! +//! We deliberately query the `Shell` layer (`Common.shell`) rather than the +//! lower-level `wayland::protocols::workspace`/`toplevel_info` protocol +//! state: those protocol-state types expose no public enumeration API +//! (`ToplevelState` is `pub(super)`, workspaces/groups have no public +//! iterator), whereas `Shell`/`Workspace` are the actual public source of +//! truth those protocols are driven from. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use smithay::{reexports::wayland_server::Resource, wayland::seat::WaylandFocus}; +use tracing::error; + +use crate::{ + hypr_ipc::{ + events, + protocol::{ClientInfo, MonitorInfo, WorkspaceInfo, WorkspaceRef, synth_workspace_id}, + }, + shell::{CosmicMapped, CosmicSurface, SeatExt, Shell, Workspace, focus::target::KeyboardFocusTarget}, + state::Common, + utils::prelude::OutputExt, +}; + +/// A stable, process-lifetime-unique pseudo-address for a window, standing +/// in for the real memory pointer Hyprland exposes over IPC. cosmic-comp +/// has no equivalent public concept, so we mint one lazily the first time a +/// `CosmicSurface` is queried and cache it in its `UserDataMap`. +struct WindowAddress(u64); + +static NEXT_WINDOW_ADDRESS: AtomicU64 = AtomicU64::new(1); + +/// Get (or mint) the stable pseudo-address for a window. +pub fn window_address(surface: &CosmicSurface) -> u64 { + surface.user_data().insert_if_missing_threadsafe(|| { + WindowAddress(NEXT_WINDOW_ADDRESS.fetch_add(1, Ordering::Relaxed)) + }); + surface + .user_data() + .get::() + .expect("just inserted above") + .0 +} + +/// Resolve the PID of the client that owns a window's `wl_surface`, if any. +/// Returns `0` (matching Hyprland's behavior for windows without a resolvable +/// pid) when the surface has no client, e.g. it has already been destroyed. +pub fn client_pid(surface: &CosmicSurface, common: &Common) -> i32 { + surface + .wl_surface() + .and_then(|wl_surface| wl_surface.client()) + .and_then(|client| client.get_credentials(&common.display_handle).ok()) + .map(|creds| creds.pid as i32) + .unwrap_or(0) +} + +/// Build a Hyprland-shaped `ClientInfo` for one window. +/// +/// `grouped`/`tags` are always empty and `swallowing` is always `"0"`: +/// cosmic-comp has no equivalent of Hyprland's window grouping/tags/window +/// swallowing features, so there is nothing to report. `focus_history_id` +/// is likewise always the caller-provided value since cosmic-comp does not +/// track a global MRU focus history the way Hyprland does. +#[allow(clippy::too_many_arguments)] +pub fn client_info_for( + surface: &CosmicSurface, + common: &Common, + monitor_id: i32, + workspace: WorkspaceRef, + at: [i32; 2], + size: [i32; 2], + floating: bool, + focus_history_id: i32, +) -> ClientInfo { + let class = surface.app_id(); + let title = surface.title(); + let is_fullscreen = surface.is_fullscreen(false); + ClientInfo { + address: events::json_address(window_address(surface)), + mapped: true, + hidden: surface.is_minimized(), + at, + size, + workspace, + floating, + monitor: monitor_id, + class: class.clone(), + title: title.clone(), + initial_class: class, + initial_title: title, + pid: client_pid(surface, common), + xwayland: surface.x11_surface().is_some(), + pinned: surface.is_sticky(), + fullscreen: is_fullscreen as i32, + fullscreen_client: is_fullscreen as i32, + grouped: Vec::new(), + tags: Vec::new(), + swallowing: "0".to_string(), + focus_history_id, + } +} + +/// Enumerate all outputs/monitors with a Hyprland-shaped `MonitorInfo` each. +pub fn collect_monitors(shell: &Shell) -> Vec { + let focused_output = shell.seats.last_active().active_output(); + shell + .workspaces + .sets + .iter() + .enumerate() + .map(|(idx, (output, set))| { + let geometry = output.geometry(); + let (width, height, refresh) = output + .current_mode() + .map(|m| (m.size.w, m.size.h, m.refresh as f64 / 1000.0)) + .unwrap_or((0, 0, 0.0)); + let active_ws = set.workspaces.get(set.active); + MonitorInfo { + id: idx as i32, + name: output.name(), + description: output.description(), + width, + height, + refresh_rate: refresh, + x: geometry.loc.x, + y: geometry.loc.y, + active_workspace: WorkspaceRef { + id: synth_workspace_id(idx, set.active), + name: active_ws + .and_then(|w| w.name.clone()) + .unwrap_or_else(|| (set.active + 1).to_string()), + }, + scale: output.current_scale().fractional_scale(), + focused: *output == focused_output, + disabled: !output.is_enabled(), + } + }) + .collect() +} + +fn build_workspace_info( + output_idx: usize, + output: &smithay::output::Output, + ws_idx: usize, + workspace: &Workspace, +) -> WorkspaceInfo { + let name = workspace + .name + .clone() + .unwrap_or_else(|| (ws_idx + 1).to_string()); + let last_window = workspace.mapped().last().map(|mapped| mapped.active_window()); + WorkspaceInfo { + id: synth_workspace_id(output_idx, ws_idx), + name, + monitor: output.name(), + monitor_id: output_idx as i32, + windows: workspace.len(), + hasfullscreen: workspace + .fullscreen_surfaces + .iter() + .any(|f| f.ended_at.is_none()), + lastwindow: last_window + .as_ref() + .map(|s| events::json_address(window_address(s))) + .unwrap_or_default(), + lastwindowtitle: last_window.as_ref().map(|s| s.title()).unwrap_or_default(), + ispersistent: workspace.pinned, + } +} + +/// Enumerate every workspace on every output. +pub fn collect_workspaces(shell: &Shell) -> Vec { + shell + .workspaces + .sets + .iter() + .enumerate() + .flat_map(|(output_idx, (output, set))| { + set.workspaces + .iter() + .enumerate() + .map(move |(ws_idx, workspace)| { + build_workspace_info(output_idx, output, ws_idx, workspace) + }) + }) + .collect() +} + +/// The workspace active on the currently focused output. +pub fn active_workspace(shell: &Shell) -> Option { + let focused_output = shell.seats.last_active().active_output(); + let output_idx = shell.workspaces.sets.get_index_of(&focused_output)?; + let (output, set) = shell.workspaces.sets.get_index(output_idx)?; + let workspace = set.workspaces.get(set.active)?; + Some(build_workspace_info(output_idx, output, set.active, workspace)) +} + +fn push_windows_of( + out: &mut Vec, + mapped: &CosmicMapped, + common: &Common, + monitor_id: i32, + workspace: &WorkspaceRef, + floating: bool, +) { + for (surface, _offset) in mapped.windows() { + let (at, size) = match surface.global_geometry() { + Some(geo) => ([geo.loc.x, geo.loc.y], [geo.size.w, geo.size.h]), + None => ([0, 0], [0, 0]), + }; + out.push(client_info_for( + &surface, + common, + monitor_id, + workspace.clone(), + at, + size, + floating, + // cosmic-comp does not track Hyprland's global MRU focus-history + // list; report 0 for every window (see `client_info_for` doc). + 0, + )); + } +} + +/// Enumerate every mapped window across every output/workspace. +pub fn collect_clients(shell: &Shell, common: &Common) -> Vec { + let mut clients = Vec::new(); + for (output_idx, (_output, set)) in shell.workspaces.sets.iter().enumerate() { + for (ws_idx, workspace) in set.workspaces.iter().enumerate() { + let workspace_ref = WorkspaceRef { + id: synth_workspace_id(output_idx, ws_idx), + name: workspace + .name + .clone() + .unwrap_or_else(|| (ws_idx + 1).to_string()), + }; + for mapped in workspace.floating_layer.mapped() { + push_windows_of(&mut clients, mapped, common, output_idx as i32, &workspace_ref, true); + } + for (mapped, _) in workspace.tiling_layer.mapped() { + push_windows_of(&mut clients, mapped, common, output_idx as i32, &workspace_ref, false); + } + } + } + clients +} + +/// The currently keyboard-focused window, if any. +pub fn active_window(shell: &Shell, common: &Common) -> Option { + let seat = shell.seats.last_active(); + let focus = seat.get_keyboard()?.current_focus()?; + let target = match focus { + KeyboardFocusTarget::Element(mapped) => mapped.active_window(), + KeyboardFocusTarget::Fullscreen(surface) => surface, + _ => return None, + }; + let target_address = events::json_address(window_address(&target)); + collect_clients(shell, common) + .into_iter() + .find(|client| client.address == target_address) +} + +/// A parsed hyprctl-style IPC request. We only support the JSON (`-j`) +/// response shapes; the plain-text response shapes hyprctl also supports +/// are out of scope since waybar's Hyprland modules always request JSON. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Request { + Workspaces, + ActiveWorkspace, + ActiveWindow, + Clients, + Monitors, +} + +/// Parse a raw request line/command as sent on `.socket`, e.g. `workspaces` +/// or `j/workspaces` (the `j/` prefix `hyprctl -j` uses to ask for JSON). +pub fn parse_request(raw: &str) -> Option { + let trimmed = raw.trim(); + let trimmed = trimmed.strip_prefix("j/").unwrap_or(trimmed); + match trimmed { + "workspaces" => Some(Request::Workspaces), + "activeworkspace" => Some(Request::ActiveWorkspace), + "activewindow" => Some(Request::ActiveWindow), + "clients" => Some(Request::Clients), + "monitors" => Some(Request::Monitors), + _ => None, + } +} + +/// Build the JSON response body for a parsed request. +/// +/// `activeworkspace`/`activewindow` fall back to `{}` (rather than `null`) +/// when there is nothing to report, matching Hyprland's own behavior of +/// always returning an object for those two endpoints. +pub fn handle_request(request: Request, common: &Common) -> String { + let shell = common.shell.read(); + let result = match request { + Request::Workspaces => serde_json::to_string(&collect_workspaces(&shell)), + Request::ActiveWorkspace => match active_workspace(&shell) { + Some(info) => serde_json::to_string(&info), + None => Ok("{}".to_string()), + }, + Request::ActiveWindow => match active_window(&shell, common) { + Some(info) => serde_json::to_string(&info), + None => Ok("{}".to_string()), + }, + Request::Clients => serde_json::to_string(&collect_clients(&shell, common)), + Request::Monitors => serde_json::to_string(&collect_monitors(&shell)), + }; + result.unwrap_or_else(|err| { + error!(?err, "Failed to serialize hypr_ipc response"); + "null".to_string() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_plain_and_json_prefixed_commands() { + assert_eq!(parse_request("workspaces"), Some(Request::Workspaces)); + assert_eq!(parse_request("j/workspaces"), Some(Request::Workspaces)); + assert_eq!( + parse_request("activeworkspace\n"), + Some(Request::ActiveWorkspace) + ); + assert_eq!( + parse_request("j/activewindow"), + Some(Request::ActiveWindow) + ); + assert_eq!(parse_request(" clients "), Some(Request::Clients)); + assert_eq!(parse_request("j/monitors"), Some(Request::Monitors)); + } + + #[test] + fn rejects_unknown_commands() { + assert_eq!(parse_request("dispatch workspace 2"), None); + assert_eq!(parse_request(""), None); + assert_eq!(parse_request("j/"), None); + } +} diff --git a/src/hypr_ipc/socket.rs b/src/hypr_ipc/socket.rs new file mode 100644 index 0000000..ed2d1e8 --- /dev/null +++ b/src/hypr_ipc/socket.rs @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: GPL-3.0-only + +//! calloop wiring for the Hyprland-compatible IPC sockets. +//! +//! Mirrors the pattern used by `crate::session::run_socket`: every +//! connection is a `calloop::generic::Generic` source wrapping a +//! `UnixStream`/`UnixListener` (both `AsFd`), registered on the +//! compositor's own event loop so nothing here spawns a raw thread or +//! touches compositor state outside of calloop callbacks. +//! +//! # Required integration (not applied here — see the executor's final +//! report for the exact snippets and which files they belong in) +//! +//! 1. `src/lib.rs`: add `pub mod hypr_ipc;` and call [`init`] once the +//! compositor is ready (same place `session::run_socket` is called). +//! 2. `src/state.rs`: add a `pub hypr_ipc: crate::hypr_ipc::HyprIpcHandle` +//! field to `Common`, populated from the `HyprIpcHandle` [`init`] returns. +//! 3. `src/session.rs`'s `get_env`: forward `HYPRLAND_INSTANCE_SIGNATURE` to +//! spawned children, mirroring how `WAYLAND_DISPLAY`/`DISPLAY` are +//! forwarded there. +//! 4. Optional: call `HyprIpcHandle::notify_*` from the existing +//! window/workspace focus-change call sites (e.g. +//! `wayland/protocols/toplevel_info.rs`, `shell/mod.rs`) to drive the +//! `.socket2` event stream. Without this, request/response (`workspaces`, +//! `activewindow`, `clients`, `monitors`) still works; only live event +//! notifications are inert until wired up. + +use std::{ + cell::RefCell, + io::{Read, Write}, + os::unix::{ + io::{AsFd, BorrowedFd}, + net::{UnixListener, UnixStream}, + }, + path::PathBuf, + rc::Rc, +}; + +use anyhow::{Context, Result}; +use smithay::reexports::calloop::{Interest, LoopHandle, Mode, PostAction, generic::Generic}; +use tracing::warn; + +use crate::{ + hypr_ipc::{events, query}, + state::State, +}; + +/// A single accepted connection on the request (`.socket`) listener. +struct RequestStream(UnixStream); +impl AsFd for RequestStream { + fn as_fd(&self) -> BorrowedFd<'_> { + self.0.as_fd() + } +} + +/// Handle for the running Hyprland IPC socket, kept in `Common` so the rest +/// of the compositor can push event-stream notifications. +#[derive(Clone, Debug)] +pub struct HyprIpcHandle { + event_streams: Rc>>, +} + +impl HyprIpcHandle { + fn broadcast_line(&self, line: &str) { + let mut streams = self.event_streams.borrow_mut(); + // Best-effort broadcast: a write failure (client gone, buffer full + // and non-blocking, ...) just drops that subscriber, matching how + // Hyprland itself treats a slow/dead `.socket2` client. + streams.retain_mut(|stream| stream.write_all(line.as_bytes()).is_ok()); + } + + /// Emit `workspace>>NAME` (active workspace on the focused monitor changed). + pub fn notify_workspace(&self, name: &str) { + self.broadcast_line(&events::workspace_event(name)); + } + + /// Emit `activewindow>>CLASS,TITLE` (active window changed). + pub fn notify_active_window(&self, class: &str, title: &str) { + self.broadcast_line(&events::activewindow_event(class, title)); + } + + /// Emit `openwindow>>ADDRESS,WORKSPACE,CLASS,TITLE` (a window was mapped). + pub fn notify_open_window(&self, address_id: u64, workspace: &str, class: &str, title: &str) { + self.broadcast_line(&events::openwindow_event(address_id, workspace, class, title)); + } + + /// Emit `closewindow>>ADDRESS` (a window was unmapped/destroyed). + pub fn notify_close_window(&self, address_id: u64) { + self.broadcast_line(&events::closewindow_event(address_id)); + } +} + +/// Generate a unique `HYPRLAND_INSTANCE_SIGNATURE`-shaped value. Real +/// Hyprland uses this purely as an opaque directory-name token; clients +/// never parse it, they just read the env var and use it verbatim as a +/// path component, so any unique, path-safe string works. +fn instance_signature() -> String { + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + format!("cosmic_{pid}_{nanos}") +} + +/// `$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE`, created if missing. +fn socket_dir(signature: &str) -> Result { + let runtime_dir = + std::env::var("XDG_RUNTIME_DIR").with_context(|| "XDG_RUNTIME_DIR is not set")?; + let dir = PathBuf::from(runtime_dir).join("hypr").join(signature); + std::fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?; + Ok(dir) +} + +/// Register a single accepted request connection: read whatever is +/// available, try to parse it as a request, write back the JSON response, +/// then close. Mirrors `session.rs::run_socket`'s use of +/// `Generic`+`NoIoDrop::get_mut` for accessing the wrapped stream. +fn register_request_connection(handle: &LoopHandle<'static, State>, stream: UnixStream) -> Result<()> { + stream + .set_nonblocking(true) + .with_context(|| "Failed to set hypr_ipc request connection non-blocking")?; + handle + .insert_source( + Generic::new(RequestStream(stream), Interest::READ, Mode::Level), + move |_, conn, state: &mut State| { + // SAFETY: We don't drop the stream from calloop's side; it's + // only ever removed as a whole event source (which owns and + // then drops the stream) via `PostAction::Remove`. + let conn = unsafe { conn.get_mut() }; + let mut buf = [0u8; 512]; + match conn.0.read(&mut buf) { + Ok(0) => Ok(PostAction::Remove), + Ok(n) => { + let request = String::from_utf8_lossy(&buf[..n]); + match query::parse_request(&request) { + Some(req) => { + let response = query::handle_request(req, &state.common); + let _ = conn.0.write_all(response.as_bytes()); + } + None => { + warn!(request = %request, "Unknown hypr_ipc request"); + } + } + Ok(PostAction::Remove) + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + Ok(PostAction::Continue) + } + Err(err) => { + warn!(?err, "Error reading hypr_ipc request socket"); + Ok(PostAction::Remove) + } + } + }, + ) + .map(|_| ()) + .with_context(|| "Failed to register hypr_ipc request connection") +} + +/// Accept loop for the request (`.socket`) listener. +fn register_request_listener(handle: &LoopHandle<'static, State>, listener: UnixListener) -> Result<()> { + listener + .set_nonblocking(true) + .with_context(|| "Failed to set hypr_ipc request listener non-blocking")?; + let handle_clone = handle.clone(); + handle + .insert_source( + Generic::new(listener, Interest::READ, Mode::Level), + move |_, listener, _state: &mut State| { + // SAFETY: We don't drop the listener from calloop's side. + let listener = unsafe { listener.get_mut() }; + loop { + match listener.accept() { + Ok((stream, _addr)) => { + if let Err(err) = register_request_connection(&handle_clone, stream) { + warn!(?err, "Failed to register hypr_ipc request connection"); + } + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break, + Err(err) => { + warn!(?err, "Error accepting hypr_ipc request connection"); + break; + } + } + } + Ok(PostAction::Continue) + }, + ) + .map(|_| ()) + .with_context(|| "Failed to register hypr_ipc request listener") +} + +/// Accept loop for the event (`.socket2`) listener: accepted connections are +/// simply stashed for [`HyprIpcHandle::broadcast_line`] to write to; they +/// don't need their own calloop source since cosmic-comp never reads from +/// them (a dead/closed connection is detected lazily on the next broadcast +/// write and dropped then). +fn register_event_listener( + handle: &LoopHandle<'static, State>, + listener: UnixListener, + ipc: HyprIpcHandle, +) -> Result<()> { + listener + .set_nonblocking(true) + .with_context(|| "Failed to set hypr_ipc event listener non-blocking")?; + handle + .insert_source( + Generic::new(listener, Interest::READ, Mode::Level), + move |_, listener, _state: &mut State| { + // SAFETY: We don't drop the listener from calloop's side. + let listener = unsafe { listener.get_mut() }; + loop { + match listener.accept() { + Ok((stream, _addr)) => match stream.set_nonblocking(true) { + Ok(()) => ipc.event_streams.borrow_mut().push(stream), + Err(err) => { + warn!(?err, "Failed to set hypr_ipc event connection non-blocking") + } + }, + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break, + Err(err) => { + warn!(?err, "Error accepting hypr_ipc event connection"); + break; + } + } + } + Ok(PostAction::Continue) + }, + ) + .map(|_| ()) + .with_context(|| "Failed to register hypr_ipc event listener") +} + +/// Bind both Hyprland-compatible IPC sockets, export +/// `HYPRLAND_INSTANCE_SIGNATURE`, and register their accept loops on +/// `handle`. See the module doc comment for the (unapplied) integration +/// snippets this needs in existing files. +pub fn init(handle: &LoopHandle<'static, State>) -> Result { + let signature = instance_signature(); + // SAFETY: called once, early during compositor startup (alongside + // `session::run_socket`), before any child processes that would read + // this var are spawned — mirrors the existing single-threaded-at-startup + // assumption cosmic-comp's own env/session setup code already makes. + unsafe { + std::env::set_var("HYPRLAND_INSTANCE_SIGNATURE", &signature); + } + + let dir = socket_dir(&signature).with_context(|| "Failed to create hypr_ipc socket dir")?; + + let request_path = dir.join(".socket"); + let event_path = dir.join(".socket2"); + // Stale sockets from a previous run with the same (astronomically + // unlikely to collide) signature would otherwise make `bind` fail. + let _ = std::fs::remove_file(&request_path); + let _ = std::fs::remove_file(&event_path); + + let request_listener = UnixListener::bind(&request_path) + .with_context(|| format!("Failed to bind {}", request_path.display()))?; + let event_listener = UnixListener::bind(&event_path) + .with_context(|| format!("Failed to bind {}", event_path.display()))?; + + let ipc = HyprIpcHandle { + event_streams: Rc::new(RefCell::new(Vec::new())), + }; + + register_request_listener(handle, request_listener)?; + register_event_listener(handle, event_listener, ipc.clone())?; + + Ok(ipc) +} diff --git a/src/lib.rs b/src/lib.rs index f728294..7f773c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,7 @@ pub mod dbus; #[cfg(feature = "debug")] pub mod debug; pub mod hooks; +pub mod hypr_ipc; pub mod input; mod logger; pub mod session; @@ -74,6 +75,16 @@ impl State { } } + // Bind the Hyprland-compatible IPC before announcing readiness, so + // HYPRLAND_INSTANCE_SIGNATURE is exported in time for `get_env` + // below to hand it to any child we spawn. + match hypr_ipc::init(&self.common.event_loop_handle) { + Ok(handle) => self.common.hypr_ipc = Some(handle), + // Non-fatal: the compositor is perfectly usable without it, + // only waybar's hyprland/* modules go dark. + Err(err) => warn!(?err, "Failed to setup Hyprland-compatible IPC"), + } + // potentially tell the session we are setup now if let Err(err) = session::run_socket(self.common.event_loop_handle.clone(), &self.common) diff --git a/src/session.rs b/src/session.rs index 0b4025a..a4f4127 100644 --- a/src/session.rs +++ b/src/session.rs @@ -69,6 +69,11 @@ pub fn get_env(common: &Common) -> Result> { if let Some(display) = common.xwayland_state.as_ref().map(|s| s.display) { env.insert(String::from("DISPLAY"), format!(":{}", display)); } + // waybar's `hyprland/*` modules locate the IPC sockets through this, so it + // has to reach children the same way WAYLAND_DISPLAY does. + if let Ok(signature) = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") { + env.insert(String::from("HYPRLAND_INSTANCE_SIGNATURE"), signature); + } Ok(env) } diff --git a/src/state.rs b/src/state.rs index 24ccd9e..64d0cb4 100644 --- a/src/state.rs +++ b/src/state.rs @@ -234,6 +234,9 @@ pub struct Common { pub config: Config, pub socket: OsString, + /// Hyprland-compatible IPC, present once the compositor is ready. `None` + /// until then, because the sockets bind after `Common` is constructed. + pub hypr_ipc: Option, pub display_handle: DisplayHandle, pub event_loop_handle: LoopHandle<'static, State>, pub event_loop_signal: LoopSignal, @@ -745,6 +748,7 @@ impl State { common: Common { config, socket, + hypr_ipc: None, display_handle: dh.clone(), event_loop_handle: handle, event_loop_signal: signal,