Patch B: Hyprland-compatible IPC socket

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<HyprIpcHandle>, 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.
This commit is contained in:
2026-08-10 07:44:29 +07:00
parent 84bdd3b9df
commit 1de9027b07
8 changed files with 977 additions and 0 deletions
+114
View File
@@ -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'));
}
}
}
+28
View File
@@ -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};
+192
View File
@@ -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<String>,
pub tags: Vec<String>,
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));
}
}
+352
View File
@@ -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::<WindowAddress>()
.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<MonitorInfo> {
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<WorkspaceInfo> {
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<WorkspaceInfo> {
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<ClientInfo>,
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<ClientInfo> {
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<ClientInfo> {
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<Request> {
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);
}
}
+271
View File
@@ -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<RefCell<Vec<UnixStream>>>,
}
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<PathBuf> {
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<HyprIpcHandle> {
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)
}
+11
View File
@@ -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)
+5
View File
@@ -69,6 +69,11 @@ pub fn get_env(common: &Common) -> Result<HashMap<String, String>> {
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)
}
+4
View File
@@ -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<crate::hypr_ipc::HyprIpcHandle>,
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,