From 8650d4af697a4d5543aa41a2ee8f6dcff412bb17 Mon Sep 17 00:00:00 2001 From: mpuchstein Date: Wed, 5 Mar 2025 20:29:02 +0100 Subject: [PATCH] made the manager daemonizable and added client mode --- hyprman/Cargo.toml | 8 +- hyprman/src/main.rs | 800 +++++++++++++++++++++++++------------------- 2 files changed, 456 insertions(+), 352 deletions(-) diff --git a/hyprman/Cargo.toml b/hyprman/Cargo.toml index a757f92..284c8ce 100644 --- a/hyprman/Cargo.toml +++ b/hyprman/Cargo.toml @@ -5,4 +5,10 @@ edition = "2024" [dependencies] serde = { version = "1", features = ["derive"] } -serde_json = "1" \ No newline at end of file +serde_json = "1" +log = "0.4.26" +signal-hook = "0.3.17" +toml = "0.8.20" +env_logger = "0.11.6" +daemonize = "0.5.0" +libc = "0.2.170" \ No newline at end of file diff --git a/hyprman/src/main.rs b/hyprman/src/main.rs index c25bb7f..1e241cb 100644 --- a/hyprman/src/main.rs +++ b/hyprman/src/main.rs @@ -1,344 +1,133 @@ -use std::io::{BufRead, BufReader}; -use std::os::unix::net::UnixStream; -use std::{env, thread}; -use std::error::Error; -use serde::{Serialize, Deserialize}; +use std::{ + collections::HashSet, + env, + error::Error, + fs, + io::{BufRead, BufReader, BufWriter, Write}, + os::unix::net::{UnixListener, UnixStream}, + process::Command, + sync::{mpsc, Arc, Mutex}, + thread, + time::Duration, +}; -/// Represents a Hyprland event emitted from a UnixStream. -/// Each variant corresponds to a specific event type with its associated data. -/// The enum is annotated for JSON (de)serialization with serde. -#[derive(Debug, Serialize, Deserialize)] +use daemonize::Daemonize; +use log::{error, info}; +use serde::{Deserialize, Serialize}; +use signal_hook::{consts::TERM_SIGNALS, iterator::Signals}; + +/// === Hyprland Event Types and Parsing === + +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "event", content = "data")] enum HyprlandEvent { - /// Emitted on workspace change. - /// Triggered ONLY when a user explicitly requests a workspace change (not due to mouse movements). - /// Data: workspace_name. - Workspace { - workspace_name: String, - }, - /// Emitted on workspace change (v2). - /// Triggered ONLY when a user explicitly requests a workspace change. - /// Data: workspace_id, workspace_name. - WorkspaceV2 { - workspace_id: u8, - workspace_name: String, - }, - /// Emitted when the active monitor changes. - /// Data: monitor_name, workspace_name. - FocusedMon { - monitor_name: String, - workspace_name: String, - }, - /// Emitted when the active monitor changes (v2). - /// Data: monitor_name, workspace_id. - FocusedMonV2 { - monitor_name: String, - workspace_id: u8, - }, - /// Emitted on active window change. - /// Data: window_class, window_title. - ActiveWindow { - window_class: String, - window_title: String, - }, - /// Emitted on active window change (v2). - /// Data: window_address. - ActiveWindowV2 { - window_address: String, - }, - /// Emitted when a window enters or exits fullscreen mode. - /// Data: status (0 for exit fullscreen, 1 for enter fullscreen). - Fullscreen { - status: u8, - }, - /// Emitted when a monitor is removed (disconnected). - /// Data: monitor_name. - MonitorRemoved { - monitor_name: String, - }, - /// Emitted when a monitor is added (connected). - /// Data: monitor_name. - MonitorAdded { - monitor_name: String, - }, - /// Emitted when a monitor is added (connected) (v2). - /// Data: monitor_id, monitor_name, monitor_description. - MonitorAddedV2 { - monitor_id: u8, - monitor_name: String, - monitor_description: String, - }, - /// Emitted when a workspace is created. - /// Data: workspace_name. - CreateWorkspace { - workspace_name: String, - }, - /// Emitted when a workspace is created (v2). - /// Data: workspace_id, workspace_name. - CreateWorkspaceV2 { - workspace_id: u8, - workspace_name: String, - }, - /// Emitted when a workspace is destroyed. - /// Data: workspace_name. - DestroyWorkspace { - workspace_name: String, - }, - /// Emitted when a workspace is destroyed (v2). - /// Data: workspace_id, workspace_name. - DestroyWorkspaceV2 { - workspace_id: u8, - workspace_name: String, - }, - /// Emitted when a workspace is moved to a different monitor. - /// Data: workspace_name, monitor_name. - MoveWorkspace { - workspace_name: String, - monitor_name: String, - }, - /// Emitted when a workspace is moved to a different monitor (v2). - /// Data: workspace_id, workspace_name, monitor_name. - MoveWorkspaceV2 { - workspace_id: u8, - workspace_name: String, - monitor_name: String, - }, - /// Emitted when a workspace is renamed. - /// Data: workspace_id, new_name. - RenameWorkspace { - workspace_id: u8, - new_name: String, - }, - /// Emitted when the special workspace opened in a monitor changes. - /// Data: workspace_name, monitor_name. - ActiveSpecial { - workspace_name: String, - monitor_name: String, - }, - /// Emitted when the layout of the active keyboard changes. - /// Data: keyboard_name, layout_name. - ActiveLayout { - keyboard_name: String, - layout_name: String, - }, - /// Emitted when a window is opened. - /// Data: window_address, workspace_name, window_class, window_title. - OpenWindow { - window_address: String, - workspace_name: String, - window_class: String, - window_title: String, - }, - /// Emitted when a window is closed. - /// Data: window_address. - CloseWindow { - window_address: String, - }, - /// Emitted when a window is moved to a workspace. - /// Data: window_address, workspace_name. - MoveWindow { - window_address: String, - workspace_name: String, - }, - /// Emitted when a window is moved to a workspace (v2). - /// Data: window_address, workspace_id, workspace_name. - MoveWindowV2 { - window_address: String, - workspace_id: u8, - workspace_name: String, - }, - /// Emitted when a layerSurface is mapped. - /// Data: namespace. - OpenLayer { - namespace: String, - }, - /// Emitted when a layerSurface is unmapped. - /// Data: namespace. - CloseLayer { - namespace: String, - }, - /// Emitted when a keybind submap changes. - /// Data: submap_name. - Submap { - submap_name: String, - }, - /// Emitted when a window changes its floating mode. - /// Data: window_address, floating (0 for non‑floating, 1 for floating). - ChangeFloatingMode { - window_address: String, - floating: u8, - }, - /// Emitted when a window requests an urgent state. - /// Data: window_address. - Urgent { - window_address: String, - }, - /// Emitted when a screencast state changes. - /// Data: state (0 or 1), owner (0 for monitor share, 1 for window share). - Screencast { - state: u8, - owner: u8, - }, - /// Emitted when a window title changes. - /// Data: window_address. - WindowTitle { - window_address: String, - }, - /// Emitted when a window title changes (v2). - /// Data: window_address, window_title. - WindowTitleV2 { - window_address: String, - window_title: String, - }, - /// Emitted when the togglegroup command is used. - /// Data: toggle_status (0 means group destroyed, 1 means group exists), - /// and window_addresses (one or more window addresses). - ToggleGroup { - toggle_status: u8, - window_addresses: Vec, - }, - /// Emitted when a window is merged into a group. - /// Data: window_address. - MoveIntoGroup { - window_address: String, - }, - /// Emitted when a window is removed from a group. - /// Data: window_address. - MoveOutOfGroup { - window_address: String, - }, - /// Emitted when ignoregrouplock is toggled. - /// Data: value (0 or 1). - IgnoreGroupLock { - value: u8, - }, - /// Emitted when lockgroups is toggled. - /// Data: value (0 or 1). - LockGroups { - value: u8, - }, - /// Emitted when the configuration is done reloading. - /// No data. + Workspace { workspace_name: String }, + WorkspaceV2 { workspace_id: u8, workspace_name: String }, + FocusedMon { monitor_name: String, workspace_name: String }, + FocusedMonV2 { monitor_name: String, workspace_id: u8 }, + ActiveWindow { window_class: String, window_title: String }, + ActiveWindowV2 { window_address: String }, + Fullscreen { status: u8 }, + MonitorRemoved { monitor_name: String }, + MonitorAdded { monitor_name: String }, + MonitorAddedV2 { monitor_id: u8, monitor_name: String, monitor_description: String }, + CreateWorkspace { workspace_name: String }, + CreateWorkspaceV2 { workspace_id: u8, workspace_name: String }, + DestroyWorkspace { workspace_name: String }, + DestroyWorkspaceV2 { workspace_id: u8, workspace_name: String }, + MoveWorkspace { workspace_name: String, monitor_name: String }, + MoveWorkspaceV2 { workspace_id: u8, workspace_name: String, monitor_name: String }, + RenameWorkspace { workspace_id: u8, new_name: String }, + ActiveSpecial { workspace_name: String, monitor_name: String }, + ActiveLayout { keyboard_name: String, layout_name: String }, + OpenWindow { window_address: String, workspace_name: String, window_class: String, window_title: String }, + CloseWindow { window_address: String }, + MoveWindow { window_address: String, workspace_name: String }, + MoveWindowV2 { window_address: String, workspace_id: u8, workspace_name: String }, + OpenLayer { namespace: String }, + CloseLayer { namespace: String }, + Submap { submap_name: String }, + ChangeFloatingMode { window_address: String, floating: u8 }, + Urgent { window_address: String }, + Screencast { state: u8, owner: u8 }, + WindowTitle { window_address: String }, + WindowTitleV2 { window_address: String, window_title: String }, + ToggleGroup { toggle_status: u8, window_addresses: Vec }, + MoveIntoGroup { window_address: String }, + MoveOutOfGroup { window_address: String }, + IgnoreGroupLock { value: u8 }, + LockGroups { value: u8 }, ConfigReloaded, - /// Emitted when a window is pinned or unpinned. - /// Data: window_address, pin_state (0 or 1). - Pin { - window_address: String, - pin_state: u8, - }, + Pin { window_address: String, pin_state: u8 }, } -/// Parses a single event line from the Hyprland stream into a `HyprlandEvent`. -/// -/// The expected format is: -/// -/// EVENT>>DATA\n -/// -/// where DATA may be a single value or a comma‑separated list of fields. -/// For example: -/// - `"workspace>>Development"` -/// - `"workspacev2>>2,Development"` -/// -/// Returns an error if the event type is unknown or if required fields are missing/cannot be parsed. fn parse_event_line(line: &str) -> Result> { - // Trim whitespace and any newline characters. let line = line.trim(); - // Split into event name and data using ">>" as delimiter. let mut parts = line.split(">>"); let event_name = parts.next().ok_or("Missing event name")?; let data = parts.next().unwrap_or("").trim(); match event_name { - "workspace" => { - // Data: WORKSPACENAME - Ok(HyprlandEvent::Workspace { - workspace_name: data.to_string(), - }) - } + "workspace" => Ok(HyprlandEvent::Workspace { workspace_name: data.to_string() }), "workspacev2" => { - // Data: WORKSPACEID,WORKSPACENAME let mut fields = data.split(','); let workspace_id = fields.next().ok_or("Missing workspace_id")?.parse::()?; let workspace_name = fields.next().ok_or("Missing workspace_name")?.to_string(); Ok(HyprlandEvent::WorkspaceV2 { workspace_id, workspace_name }) } "focusedmon" => { - // Data: MONITORNAME,WORKSPACENAME let mut fields = data.split(','); let monitor_name = fields.next().ok_or("Missing monitor_name")?.to_string(); let workspace_name = fields.next().ok_or("Missing workspace_name")?.to_string(); Ok(HyprlandEvent::FocusedMon { monitor_name, workspace_name }) } "focusedmonv2" => { - // Data: MONITORNAME,WORKSPACEID let mut fields = data.split(','); let monitor_name = fields.next().ok_or("Missing monitor_name")?.to_string(); let workspace_id = fields.next().ok_or("Missing workspace_id")?.parse::()?; Ok(HyprlandEvent::FocusedMonV2 { monitor_name, workspace_id }) } "activewindow" => { - // Data: WINDOWCLASS,WINDOWTITLE let mut fields = data.split(','); let window_class = fields.next().ok_or("Missing window_class")?.to_string(); let window_title = fields.next().ok_or("Missing window_title")?.to_string(); Ok(HyprlandEvent::ActiveWindow { window_class, window_title }) } - "activewindowv2" => { - // Data: WINDOWADDRESS - Ok(HyprlandEvent::ActiveWindowV2 { window_address: data.to_string() }) - } + "activewindowv2" => Ok(HyprlandEvent::ActiveWindowV2 { window_address: data.to_string() }), "fullscreen" => { - // Data: 0/1 let status = data.parse::()?; Ok(HyprlandEvent::Fullscreen { status }) } - "monitorremoved" => { - // Data: MONITORNAME - Ok(HyprlandEvent::MonitorRemoved { monitor_name: data.to_string() }) - } - "monitoradded" => { - // Data: MONITORNAME - Ok(HyprlandEvent::MonitorAdded { monitor_name: data.to_string() }) - } + "monitorremoved" => Ok(HyprlandEvent::MonitorRemoved { monitor_name: data.to_string() }), + "monitoradded" => Ok(HyprlandEvent::MonitorAdded { monitor_name: data.to_string() }), "monitoraddedv2" => { - // Data: MONITORID,MONITORNAME,MONITORDESCRIPTION let mut fields = data.split(','); let monitor_id = fields.next().ok_or("Missing monitor_id")?.parse::()?; let monitor_name = fields.next().ok_or("Missing monitor_name")?.to_string(); let monitor_description = fields.next().ok_or("Missing monitor_description")?.to_string(); Ok(HyprlandEvent::MonitorAddedV2 { monitor_id, monitor_name, monitor_description }) } - "createworkspace" => { - // Data: WORKSPACENAME - Ok(HyprlandEvent::CreateWorkspace { workspace_name: data.to_string() }) - } + "createworkspace" => Ok(HyprlandEvent::CreateWorkspace { workspace_name: data.to_string() }), "createworkspacev2" => { - // Data: WORKSPACEID,WORKSPACENAME let mut fields = data.split(','); let workspace_id = fields.next().ok_or("Missing workspace_id")?.parse::()?; let workspace_name = fields.next().ok_or("Missing workspace_name")?.to_string(); Ok(HyprlandEvent::CreateWorkspaceV2 { workspace_id, workspace_name }) } - "destroyworkspace" => { - // Data: WORKSPACENAME - Ok(HyprlandEvent::DestroyWorkspace { workspace_name: data.to_string() }) - } + "destroyworkspace" => Ok(HyprlandEvent::DestroyWorkspace { workspace_name: data.to_string() }), "destroyworkspacev2" => { - // Data: WORKSPACEID,WORKSPACENAME let mut fields = data.split(','); let workspace_id = fields.next().ok_or("Missing workspace_id")?.parse::()?; let workspace_name = fields.next().ok_or("Missing workspace_name")?.to_string(); Ok(HyprlandEvent::DestroyWorkspaceV2 { workspace_id, workspace_name }) } "moveworkspace" => { - // Data: WORKSPACENAME,MONITORNAME let mut fields = data.split(','); let workspace_name = fields.next().ok_or("Missing workspace_name")?.to_string(); let monitor_name = fields.next().ok_or("Missing monitor_name")?.to_string(); Ok(HyprlandEvent::MoveWorkspace { workspace_name, monitor_name }) } "moveworkspacev2" => { - // Data: WORKSPACEID,WORKSPACENAME,MONITORNAME let mut fields = data.split(','); let workspace_id = fields.next().ok_or("Missing workspace_id")?.parse::()?; let workspace_name = fields.next().ok_or("Missing workspace_name")?.to_string(); @@ -346,28 +135,24 @@ fn parse_event_line(line: &str) -> Result> { Ok(HyprlandEvent::MoveWorkspaceV2 { workspace_id, workspace_name, monitor_name }) } "renameworkspace" => { - // Data: WORKSPACEID,NEWNAME let mut fields = data.split(','); let workspace_id = fields.next().ok_or("Missing workspace_id")?.parse::()?; let new_name = fields.next().ok_or("Missing new_name")?.to_string(); Ok(HyprlandEvent::RenameWorkspace { workspace_id, new_name }) } "activespecial" => { - // Data: WORKSPACENAME,MONITORNAME let mut fields = data.split(','); let workspace_name = fields.next().ok_or("Missing workspace_name")?.to_string(); let monitor_name = fields.next().ok_or("Missing monitor_name")?.to_string(); Ok(HyprlandEvent::ActiveSpecial { workspace_name, monitor_name }) } "activelayout" => { - // Data: KEYBOARDNAME,LAYOUTNAME let mut fields = data.split(','); let keyboard_name = fields.next().ok_or("Missing keyboard_name")?.to_string(); let layout_name = fields.next().ok_or("Missing layout_name")?.to_string(); Ok(HyprlandEvent::ActiveLayout { keyboard_name, layout_name }) } "openwindow" => { - // Data: WINDOWADDRESS,WORKSPACENAME,WINDOWCLASS,WINDOWTITLE let mut fields = data.split(','); let window_address = fields.next().ok_or("Missing window_address")?.to_string(); let workspace_name = fields.next().ok_or("Missing workspace_name")?.to_string(); @@ -375,97 +160,61 @@ fn parse_event_line(line: &str) -> Result> { let window_title = fields.next().ok_or("Missing window_title")?.to_string(); Ok(HyprlandEvent::OpenWindow { window_address, workspace_name, window_class, window_title }) } - "closewindow" => { - // Data: WINDOWADDRESS - Ok(HyprlandEvent::CloseWindow { window_address: data.to_string() }) - } + "closewindow" => Ok(HyprlandEvent::CloseWindow { window_address: data.to_string() }), "movewindow" => { - // Data: WINDOWADDRESS,WORKSPACENAME let mut fields = data.split(','); let window_address = fields.next().ok_or("Missing window_address")?.to_string(); let workspace_name = fields.next().ok_or("Missing workspace_name")?.to_string(); Ok(HyprlandEvent::MoveWindow { window_address, workspace_name }) } "movewindowv2" => { - // Data: WINDOWADDRESS,WORKSPACEID,WORKSPACENAME let mut fields = data.split(','); let window_address = fields.next().ok_or("Missing window_address")?.to_string(); let workspace_id = fields.next().ok_or("Missing workspace_id")?.parse::()?; let workspace_name = fields.next().ok_or("Missing workspace_name")?.to_string(); Ok(HyprlandEvent::MoveWindowV2 { window_address, workspace_id, workspace_name }) } - "openlayer" => { - // Data: NAMESPACE - Ok(HyprlandEvent::OpenLayer { namespace: data.to_string() }) - } - "closelayer" => { - // Data: NAMESPACE - Ok(HyprlandEvent::CloseLayer { namespace: data.to_string() }) - } - "submap" => { - // Data: SUBMAPNAME - Ok(HyprlandEvent::Submap { submap_name: data.to_string() }) - } + "openlayer" => Ok(HyprlandEvent::OpenLayer { namespace: data.to_string() }), + "closelayer" => Ok(HyprlandEvent::CloseLayer { namespace: data.to_string() }), + "submap" => Ok(HyprlandEvent::Submap { submap_name: data.to_string() }), "changefloatingmode" => { - // Data: WINDOWADDRESS,FLOATING let mut fields = data.split(','); let window_address = fields.next().ok_or("Missing window_address")?.to_string(); let floating = fields.next().ok_or("Missing floating")?.parse::()?; Ok(HyprlandEvent::ChangeFloatingMode { window_address, floating }) } - "urgent" => { - // Data: WINDOWADDRESS - Ok(HyprlandEvent::Urgent { window_address: data.to_string() }) - } + "urgent" => Ok(HyprlandEvent::Urgent { window_address: data.to_string() }), "screencast" => { - // Data: STATE,OWNER let mut fields = data.split(','); let state = fields.next().ok_or("Missing state")?.parse::()?; let owner = fields.next().ok_or("Missing owner")?.parse::()?; Ok(HyprlandEvent::Screencast { state, owner }) } - "windowtitle" => { - // Data: WINDOWADDRESS - Ok(HyprlandEvent::WindowTitle { window_address: data.to_string() }) - } + "windowtitle" => Ok(HyprlandEvent::WindowTitle { window_address: data.to_string() }), "windowtitlev2" => { - // Data: WINDOWADDRESS,WINDOWTITLE let mut fields = data.split(','); let window_address = fields.next().ok_or("Missing window_address")?.to_string(); let window_title = fields.next().ok_or("Missing window_title")?.to_string(); Ok(HyprlandEvent::WindowTitleV2 { window_address, window_title }) } "togglegroup" => { - // Data: TOGGLE_STATUS,WINDOWADDRESS(ES) let mut fields = data.split(','); let toggle_status = fields.next().ok_or("Missing toggle_status")?.parse::()?; let window_addresses: Vec = fields.map(|s| s.to_string()).collect(); Ok(HyprlandEvent::ToggleGroup { toggle_status, window_addresses }) } - "moveintogroup" => { - // Data: WINDOWADDRESS - Ok(HyprlandEvent::MoveIntoGroup { window_address: data.to_string() }) - } - "moveoutofgroup" => { - // Data: WINDOWADDRESS - Ok(HyprlandEvent::MoveOutOfGroup { window_address: data.to_string() }) - } + "moveintogroup" => Ok(HyprlandEvent::MoveIntoGroup { window_address: data.to_string() }), + "moveoutofgroup" => Ok(HyprlandEvent::MoveOutOfGroup { window_address: data.to_string() }), "ignoregrouplock" => { - // Data: VALUE let value = data.parse::()?; Ok(HyprlandEvent::IgnoreGroupLock { value }) } "lockgroups" => { - // Data: VALUE let value = data.parse::()?; Ok(HyprlandEvent::LockGroups { value }) } - "configreloaded" => { - // No data. - Ok(HyprlandEvent::ConfigReloaded) - } + "configreloaded" => Ok(HyprlandEvent::ConfigReloaded), "pin" => { - // Data: WINDOWADDRESS,PINSTATE let mut fields = data.split(','); let window_address = fields.next().ok_or("Missing window_address")?.to_string(); let pin_state = fields.next().ok_or("Missing pin_state")?.parse::()?; @@ -474,51 +223,400 @@ fn parse_event_line(line: &str) -> Result> { _ => Err(format!("Unknown event type: {}", event_name).into()), } } -/// Helper to fetch environment variables or panic with a clear message. -fn get_env_var(var: &str) -> String { - env::var(var).unwrap_or_else(|_| panic!("Environment variable {} is not set", var)) + +/// === Utility: Extract event type string for filtering === + +fn event_type(event: &HyprlandEvent) -> &'static str { + match event { + HyprlandEvent::Workspace { .. } => "workspace", + HyprlandEvent::WorkspaceV2 { .. } => "workspacev2", + HyprlandEvent::FocusedMon { .. } => "focusedmon", + HyprlandEvent::FocusedMonV2 { .. } => "focusedmonv2", + HyprlandEvent::ActiveWindow { .. } => "activewindow", + HyprlandEvent::ActiveWindowV2 { .. } => "activewindowv2", + HyprlandEvent::Fullscreen { .. } => "fullscreen", + HyprlandEvent::MonitorRemoved { .. } => "monitorremoved", + HyprlandEvent::MonitorAdded { .. } => "monitoradded", + HyprlandEvent::MonitorAddedV2 { .. } => "monitoraddedv2", + HyprlandEvent::CreateWorkspace { .. } => "createworkspace", + HyprlandEvent::CreateWorkspaceV2 { .. } => "createworkspacev2", + HyprlandEvent::DestroyWorkspace { .. } => "destroyworkspace", + HyprlandEvent::DestroyWorkspaceV2 { .. } => "destroyworkspacev2", + HyprlandEvent::MoveWorkspace { .. } => "moveworkspace", + HyprlandEvent::MoveWorkspaceV2 { .. } => "moveworkspacev2", + HyprlandEvent::RenameWorkspace { .. } => "renameworkspace", + HyprlandEvent::ActiveSpecial { .. } => "activespecial", + HyprlandEvent::ActiveLayout { .. } => "activelayout", + HyprlandEvent::OpenWindow { .. } => "openwindow", + HyprlandEvent::CloseWindow { .. } => "closewindow", + HyprlandEvent::MoveWindow { .. } => "movewindow", + HyprlandEvent::MoveWindowV2 { .. } => "movewindowv2", + HyprlandEvent::OpenLayer { .. } => "openlayer", + HyprlandEvent::CloseLayer { .. } => "closelayer", + HyprlandEvent::Submap { .. } => "submap", + HyprlandEvent::ChangeFloatingMode { .. } => "changefloatingmode", + HyprlandEvent::Urgent { .. } => "urgent", + HyprlandEvent::Screencast { .. } => "screencast", + HyprlandEvent::WindowTitle { .. } => "windowtitle", + HyprlandEvent::WindowTitleV2 { .. } => "windowtitlev2", + HyprlandEvent::ToggleGroup { .. } => "togglegroup", + HyprlandEvent::MoveIntoGroup { .. } => "moveintogroup", + HyprlandEvent::MoveOutOfGroup { .. } => "moveoutofgroup", + HyprlandEvent::IgnoreGroupLock { .. } => "ignoregrouplock", + HyprlandEvent::LockGroups { .. } => "lockgroups", + HyprlandEvent::ConfigReloaded => "configreloaded", + HyprlandEvent::Pin { .. } => "pin", + } +} + +/// === Client Subscription Infrastructure === + +#[derive(Debug, Clone)] +enum Subscription { + All, + Filtered(HashSet), +} + +struct ClientHandle { + sender: mpsc::Sender, + subscription: Subscription, +} + +/// === Configuration Loading === + +#[derive(Debug, Deserialize)] +struct Config { + // Socket path where clients connect to receive events. + // If relative, it will be interpreted relative to $XDG_RUNTIME_DIR/hyprman/ + client_socket_path: String, +} + +fn load_config(path: &str) -> Config { + let content = fs::read_to_string(path).expect("Failed to read config file"); + toml::from_str(&content).expect("Failed to parse config file") +} + +/// === Daemon Mode Functions === + +fn client_handler(stream: UnixStream, subscriptions: Arc>>) { + let mut reader = BufReader::new(stream.try_clone().expect("Failed to clone stream")); + let mut writer = BufWriter::new(stream); + // Read a line from the client to get subscription preferences. + let mut subscription_line = String::new(); + if let Err(e) = reader.read_line(&mut subscription_line) { + error!("Failed to read subscription from client: {}", e); + return; + } + let subscription_line = subscription_line.trim(); + let subscription = if subscription_line.is_empty() || subscription_line.to_lowercase() == "all" { + Subscription::All + } else { + let filters: HashSet = subscription_line + .split(',') + .map(|s| s.trim().to_lowercase()) + .collect(); + Subscription::Filtered(filters) + }; + info!("Client subscribed to: {:?}", subscription); + + // Create a channel for sending events to this client. + let (tx, rx) = mpsc::channel::(); + + { + let mut subs = subscriptions.lock().unwrap(); + subs.push(ClientHandle { sender: tx, subscription }); + } + + // Loop and write events to the client. + loop { + match rx.recv() { + Ok(event) => { + let json = serde_json::to_string(&event).unwrap(); + if let Err(e) = writeln!(writer, "{}", json) { + error!("Failed to write to client: {}", e); + break; + } + if let Err(e) = writer.flush() { + error!("Failed to flush writer: {}", e); + break; + } + } + Err(e) => { + error!("Channel error: {}", e); + break; + } + } + } +} + +fn hyprland_event_thread(subscriptions: Arc>>) { + let xdg_runtime_dir = env::var("XDG_RUNTIME_DIR") + .unwrap_or_else(|_| panic!("Environment variable XDG_RUNTIME_DIR is not set")); + let hypr_instance_signature = env::var("HYPRLAND_INSTANCE_SIGNATURE") + .unwrap_or_else(|_| panic!("Environment variable HYPRLAND_INSTANCE_SIGNATURE is not set")); + let hypr_rundir_path = format!("{}/hypr/{}", xdg_runtime_dir, hypr_instance_signature); + info!("Using hypr runtime directory: {}", hypr_rundir_path); + + let socket2_path = format!("{}/.socket2.sock", hypr_rundir_path); + info!("Using hypr socket2 path: {}", socket2_path); + let socket2 = create_socket(&socket2_path); + let reader = BufReader::new(socket2); + + for line in reader.lines() { + match line { + Ok(line_content) => { + match parse_event_line(&line_content) { + Ok(event) => { + let event_name = event_type(&event); + let json = serde_json::to_string(&event).unwrap(); + info!("Received event: {}", json); + let mut subs = subscriptions.lock().unwrap(); + // Dispatch events to matching clients. + subs.retain(|client| { + let send_result = match &client.subscription { + Subscription::All => client.sender.send(event.clone()), + Subscription::Filtered(filters) => { + if filters.contains(&event_name.to_string()) { + client.sender.send(event.clone()) + } else { + Ok(()) + } + } + }; + send_result.is_ok() + }); + } + Err(e) => error!("Error parsing event '{}': {}", line_content, e), + } + } + Err(e) => error!("Error reading line: {}", e), + } + } +} + +fn client_server_thread(client_socket_path: String, subscriptions: Arc>>) { + // Remove existing socket file if present. + let _ = fs::remove_file(&client_socket_path); + let listener = UnixListener::bind(&client_socket_path).unwrap_or_else(|e| { + panic!("Failed to bind client socket {}: {}", client_socket_path, e) + }); + info!("Client server listening on {}", client_socket_path); + + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let subs = subscriptions.clone(); + thread::spawn(move || client_handler(stream, subs)); + } + Err(e) => error!("Failed to accept client connection: {}", e), + } + } } -/// Creates a UnixStream from a given socket path. fn create_socket(socket_path: &str) -> UnixStream { UnixStream::connect(socket_path).unwrap_or_else(|err| { panic!("Could not connect to socket {}: {}", socket_path, err) }) } -/// Handles a single event string: parses it and prints its JSON representation. -fn handle_event(event_str: String) { - match parse_event_line(&event_str) { - Ok(event) => { - let json = serde_json::to_string(&event).unwrap(); - println!("{}", json); +/// The main daemon functionality: spawn threads, handle signals, etc. +fn run_daemon(config: Config) { + // Global subscription registry. + let subscriptions = Arc::new(Mutex::new(Vec::::new())); + + // Setup signal handling for graceful shutdown. + let mut signals = Signals::new(TERM_SIGNALS).expect("Unable to setup signal handling"); + let signals_handle = signals.handle(); + let shutdown_flag = Arc::new(Mutex::new(false)); + { + let shutdown_flag = shutdown_flag.clone(); + thread::spawn(move || { + for signal in signals.forever() { + info!("Received termination signal: {}", signal); + *shutdown_flag.lock().unwrap() = true; + break; + } + }); + } + + // Spawn thread to read and dispatch Hyprland events. + let subs_clone = subscriptions.clone(); + thread::spawn(move || { + hyprland_event_thread(subs_clone); + }); + + // Spawn thread to accept client connections. + let client_socket_path = config.client_socket_path; + let subs_clone = subscriptions.clone(); + thread::spawn(move || { + client_server_thread(client_socket_path, subs_clone); + }); + + // Main thread waits for shutdown. + loop { + if *shutdown_flag.lock().unwrap() { + info!("Shutting down daemon"); + signals_handle.close(); + break; } - Err(e) => eprintln!("Error parsing event '{}': {}", event_str, e), + thread::sleep(Duration::from_secs(1)); } } +/// === Client Mode Function === +/// Accepts a subscription filter (e.g. "all" or "activewindow") +fn run_client(config: &Config, subscription: &str) { + match UnixStream::connect(&config.client_socket_path) { + Ok(mut stream) => { + // Send subscription preferences. + let subscription_line = format!("{}\n", subscription); + if let Err(e) = stream.write_all(subscription_line.as_bytes()) { + eprintln!("Failed to send subscription: {}", e); + std::process::exit(1); + } + println!("Subscribed to '{}' events. Waiting for events...", subscription); + + let reader = BufReader::new(stream); + for line in reader.lines() { + match line { + Ok(msg) => println!("{}", msg), + Err(e) => { + eprintln!("Error reading from daemon: {}", e); + break; + } + } + } + } + Err(e) => { + eprintln!("Failed to connect to daemon. Is it running? Error: {}", e); + std::process::exit(1); + } + } +} + +/// === Daemon Control Functions === + +fn stop_daemon() -> Result<(), Box> { + // Compute pid file path from $XDG_RUNTIME_DIR/hyprman/ + let xdg_runtime_dir = env::var("XDG_RUNTIME_DIR") + .expect("XDG_RUNTIME_DIR not set"); + let hyprman_dir = format!("{}/hyprman", xdg_runtime_dir); + let pid_file_path = format!("{}/hyprman.pid", hyprman_dir); + let pid_str = fs::read_to_string(&pid_file_path)?; + let pid: i32 = pid_str.trim().parse()?; + unsafe { + if libc::kill(pid, libc::SIGTERM) != 0 { + return Err(format!("Failed to kill process {}", pid).into()); + } + } + fs::remove_file(&pid_file_path)?; + println!("Daemon stopped."); + Ok(()) +} + +fn restart_daemon() -> Result<(), Box> { + stop_daemon()?; + thread::sleep(Duration::from_secs(1)); + let current_exe = env::current_exe()?; + Command::new(current_exe).arg("-d").spawn()?; + println!("Daemon restarted."); + Ok(()) +} + +/// Print usage help text. +fn print_help() { + println!("Usage:"); + println!(" -d, --daemon Run as daemon"); + println!(" -r, --restart Restart the daemon"); + println!(" -k, --kill Stop the daemon"); + println!(" -f, --filter [event] Run in client mode, subscribing to [event] (default: all)"); + println!(" -h, --help Show this help message"); +} + +/// === Main Entry Point: Mode Selection Based on Command‑Line Arguments === + fn main() { - let xdg_runtime_dir = get_env_var("XDG_RUNTIME_DIR"); - let hypr_instance_signature = get_env_var("HYPRLAND_INSTANCE_SIGNATURE"); + // Load configuration from $XDG_CONFIG_HOME/hyprman/config.toml + let config_dir = env::var("XDG_CONFIG_HOME") + .unwrap_or_else(|_| panic!("Environment variable XDG_CONFIG_HOME is not set")); + let config_path = format!("{}/hyprman/config.toml", config_dir); + let mut config = load_config(&config_path); + env_logger::init(); - let hypr_rundir_path = format!("{}/hypr/{}", xdg_runtime_dir, hypr_instance_signature); - println!("Using hypr runtime directory: {}", hypr_rundir_path); + // Ensure $XDG_RUNTIME_DIR/hyprman/ exists. + let xdg_runtime_dir = env::var("XDG_RUNTIME_DIR") + .expect("XDG_RUNTIME_DIR not set"); + let hyprman_dir = format!("{}/hyprman", xdg_runtime_dir); + if fs::metadata(&hyprman_dir).is_err() { + fs::create_dir_all(&hyprman_dir) + .expect("Failed to create hyprman runtime directory"); + } + // If the socket path from the config is relative, interpret it relative to hyprman_dir. + if !config.client_socket_path.starts_with("/") { + config.client_socket_path = format!("{}/{}", hyprman_dir, config.client_socket_path); + } - let socket1_path = format!("{}/.socket.sock", hypr_rundir_path); - let socket2_path = format!("{}/.socket2.sock", hypr_rundir_path); - println!("Using socket1 path: {}", socket1_path); - println!("Using socket2 path: {}", socket2_path); + // Also, compute the PID file path to be used. + let pid_file_path = format!("{}/hyprman.pid", hyprman_dir); - // let socket1 = create_socket(socket1_path); - let socket2 = create_socket(&socket2_path); - let stream = BufReader::new(socket2); - for line in stream.lines() { - match line { - Ok(line_content) => { - // Spawn a thread to handle the event. The closure takes ownership of `line_content`. - thread::spawn(move || handle_event(line_content)); + let args: Vec = env::args().collect(); + if args.len() > 1 { + match args[1].as_str() { + "-d" | "--daemon" => { + // Check if daemon is already running. + if let Ok(pid_str) = fs::read_to_string(&pid_file_path) { + if let Ok(pid) = pid_str.trim().parse::() { + if unsafe { libc::kill(pid, 0) } == 0 { + eprintln!("Daemon already running with PID {}.", pid); + std::process::exit(1); + } + } + } + let daemonize = Daemonize::new() + .pid_file(&pid_file_path) + .working_directory("/") + .umask(0o022) + .privileged_action(|| { + info!("Daemon started successfully"); + }); + if let Err(e) = daemonize.start() { + eprintln!("Error daemonizing: {}", e); + std::process::exit(1); + } + run_daemon(config); + } + "-r" | "--restart" => { + if let Err(e) = restart_daemon() { + eprintln!("Error restarting daemon: {}", e); + std::process::exit(1); + } + } + "-k" | "--kill" => { + if let Err(e) = stop_daemon() { + eprintln!("Error stopping daemon: {}", e); + std::process::exit(1); + } + } + "-f" | "--filter" => { + // Client mode with a subscription filter. + let query = if args.len() > 2 { + args[2].clone() + } else { + "all".to_string() + }; + run_client(&config, &query); + } + "-h" | "--help" => { + print_help(); + } + _ => { + eprintln!("Unknown option."); + print_help(); + std::process::exit(1); } - Err(e) => eprintln!("Error reading line: {}", e), } + } else { + // No arguments provided: run as client with "all" subscription. + run_client(&config, "all"); } }