hyprland workspace, service

This commit is contained in:
2026-09-13 18:15:22 +01:00
parent 57201bc0ff
commit a14b6f4967
12 changed files with 551 additions and 7 deletions
+205
View File
@@ -0,0 +1,205 @@
//! Hyprland compositor service: listens on the compositor's event socket and
//! republishes focus changes (`HyprlandEvent`) to modules subscribed to
//! `hyprland.events`.
//!
//! Seeds the workspace list and current focus on startup so subscribers have
//! state before the first event — the event socket only reports changes.
//!
//! The startup snapshot is queried over Hyprland's command socket directly:
//! the `hyprland` crate's `data` types cannot deserialize 0.56 responses
//! (`Workspace.id` was dropped), though its event stream still works.
use std::path::PathBuf;
use common::{Endpoint, HyprlandEvent, Inbox, Wire};
use hyprland::event_listener::{Event, EventStream};
use iced::futures::{channel::mpsc, SinkExt, StreamExt};
use iced::Subscription;
use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
/// Route-key namespace owned by this service; the key is `"hyprland.events"`.
pub const NAMESPACE: &str = "hyprland.";
/// A workspace as far as the bar cares — only the name, which for numbered
/// workspaces is also the id.
#[derive(Deserialize)]
struct WorkspaceState {
name: String,
}
#[derive(Deserialize)]
struct ClientState {
class: String,
title: String,
}
#[derive(Deserialize)]
struct MonitorState {
name: String,
focused: bool,
}
pub struct HyprlandService;
impl HyprlandService {
pub fn run(inbox: Inbox) -> Subscription<Wire> {
Subscription::run_with(inbox, |inbox| {
let key = inbox.key();
let mut _rx = inbox.take().expect("inbox receiver is taken once");
iced::stream::channel(0, move |mut sender: mpsc::Sender<Wire>| async move {
tracing::info!(key, "hyprland service started");
if !seed(&mut sender, key).await {
return;
}
let mut stream = EventStream::new();
while let Some(event) = stream.next().await {
let event = match event {
Ok(event) => event,
Err(e) => {
tracing::warn!(error = %e, "hyprland event stream error");
continue;
}
};
tracing::debug!(?event, "hyprland raw event");
for event in translate(event) {
if !publish(&mut sender, key, event).await {
return;
}
}
}
tracing::warn!(key, "hyprland event stream ended");
})
})
}
}
/// Publishes the current compositor state before live events start; `false`
/// if the subscriber channel is gone.
async fn seed(sender: &mut mpsc::Sender<Wire>, key: &'static str) -> bool {
if let Some(list) = query::<Vec<WorkspaceState>>("workspaces").await {
for ws in list {
let event = HyprlandEvent::WorkspaceAdded {
id: workspace_id(&ws.name, -1),
name: ws.name,
};
if !publish(sender, key, event).await {
return false;
}
}
}
if let Some(ws) = query::<WorkspaceState>("activeworkspace").await {
let event = HyprlandEvent::WorkspaceChanged {
id: workspace_id(&ws.name, -1),
name: ws.name,
};
if !publish(sender, key, event).await {
return false;
}
}
if let Some(m) = query::<Vec<MonitorState>>("monitors")
.await
.and_then(|list| list.into_iter().find(|m| m.focused))
{
if !publish(sender, key, HyprlandEvent::Monitor { name: m.name }).await {
return false;
}
}
if let Some(c) = query::<ClientState>("activewindow").await {
let event = HyprlandEvent::ActiveWindow {
class: c.class,
title: c.title,
};
if !publish(sender, key, event).await {
return false;
}
}
true
}
/// Runs `j/<cmd>` against Hyprland's command socket and parses the reply.
/// `None` if Hyprland isn't running, the query fails, or the shape is unknown.
async fn query<T: for<'de> Deserialize<'de>>(cmd: &str) -> Option<T> {
let runtime = std::env::var_os("XDG_RUNTIME_DIR")?;
let signature = std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE")?;
let path = PathBuf::from(runtime)
.join("hypr")
.join(signature)
.join(".socket.sock");
let mut stream = UnixStream::connect(path).await.ok()?;
stream.write_all(format!("j/{cmd}").as_bytes()).await.ok()?;
let mut reply = String::new();
stream.read_to_string(&mut reply).await.ok()?;
serde_json::from_str(&reply).ok()
}
/// Workspace id for the bar: the numeric name when it is one (numbered
/// workspaces), else `fallback` (the event's own id, `-1` when unknown).
fn workspace_id(name: &str, fallback: i32) -> i32 {
name.parse().unwrap_or(fallback)
}
/// Publishes one event to subscribers; `false` if the channel is gone.
async fn publish(sender: &mut mpsc::Sender<Wire>, key: &'static str, event: HyprlandEvent) -> bool {
tracing::debug!(?event, "hyprland event");
let wire = Wire::topic(Endpoint::service(key), key, event);
if sender.send(wire).await.is_err() {
tracing::warn!(key, "hyprland send failed; stopping service");
return false;
}
true
}
/// Maps one crate event onto the protocol payload(s); empty for events the
/// bar does not publish. `focusedmon` yields two: the monitor change *and* the
/// workspace that monitor just focused.
fn translate(event: Event) -> Vec<HyprlandEvent> {
match event {
Event::WorkspaceChanged(w) => {
let name = w.name.to_string();
vec![HyprlandEvent::WorkspaceChanged {
id: workspace_id(&name, w.id),
name,
}]
}
Event::WorkspaceAdded(w) => {
let name = w.name.to_string();
vec![HyprlandEvent::WorkspaceAdded {
id: workspace_id(&name, w.id),
name,
}]
}
Event::WorkspaceDeleted(w) => {
let name = w.name.to_string();
vec![HyprlandEvent::WorkspaceRemoved {
id: workspace_id(&name, w.id),
name,
}]
}
Event::ActiveWindowChanged(w) => vec![HyprlandEvent::ActiveWindow {
class: w.as_ref().map(|w| w.class.clone()).unwrap_or_default(),
title: w.map(|w| w.title).unwrap_or_default(),
}],
Event::ActiveMonitorChanged(m) => {
let mut events = vec![HyprlandEvent::Monitor {
name: m.monitor_name,
}];
// Focusing a monitor also focuses its workspace; switching to a
// workspace on another monitor fires only this event, so without
// it the bar's active workspace never updates.
if let Some(ws) = m.workspace_name {
let name = ws.to_string();
events.push(HyprlandEvent::WorkspaceChanged {
id: workspace_id(&name, -1),
name,
});
}
events
}
_ => Vec::new(),
}
}
+1
View File
@@ -0,0 +1 @@
pub mod hyprland;
+4
View File
@@ -6,6 +6,7 @@ use iced::Subscription;
use common::{Inbox, Service, Wire};
pub mod compositors;
pub mod datetime;
pub mod weather;
@@ -23,6 +24,9 @@ impl IntoSubscription for Service {
let key = self.0;
match key {
k if k.starts_with(datetime::NAMESPACE) => datetime::ClockTicker::run(inbox),
k if k.starts_with(compositors::hyprland::NAMESPACE) => {
compositors::hyprland::HyprlandService::run(inbox)
}
k if k.starts_with(weather::NAMESPACE) => weather::WeatherService::run(inbox),
_ => {
tracing::warn!(service = key, "no service impl for route key");