mirror of
https://github.com/outbackdingo/hyprcosmic-comp.git
synced 2026-08-25 07:10:25 +00:00
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:
@@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user