feat: initail commit

This commit is contained in:
uttarayan21
2025-08-15 16:28:28 +05:30
commit f0dce3f233
16 changed files with 2453 additions and 0 deletions

186
src/config.rs Normal file
View File

@@ -0,0 +1,186 @@
use serde::{Deserialize, Serialize};
use std::env;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
pub server: ServerConfig,
pub hyprland: HyprlandConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub cors_enabled: bool,
pub log_level: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HyprlandConfig {
pub timeout_ms: u64,
pub retry_attempts: u32,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: "0.0.0.0".to_string(),
port: 3000,
cors_enabled: true,
log_level: "info".to_string(),
}
}
}
impl Default for HyprlandConfig {
fn default() -> Self {
Self {
timeout_ms: 5000,
retry_attempts: 3,
}
}
}
impl Config {
/// Load configuration from environment variables with fallback to defaults
pub fn from_env() -> Self {
let mut config = Config::default();
// Server configuration
if let Ok(host) = env::var("HYPRMONITORS_HOST") {
config.server.host = host;
}
if let Ok(port_str) = env::var("HYPRMONITORS_PORT") {
if let Ok(port) = port_str.parse::<u16>() {
config.server.port = port;
}
}
if let Ok(cors_str) = env::var("HYPRMONITORS_CORS_ENABLED") {
config.server.cors_enabled = cors_str.to_lowercase() == "true";
}
if let Ok(log_level) = env::var("HYPRMONITORS_LOG_LEVEL") {
config.server.log_level = log_level;
}
// Hyprland configuration
if let Ok(timeout_str) = env::var("HYPRMONITORS_TIMEOUT_MS") {
if let Ok(timeout) = timeout_str.parse::<u64>() {
config.hyprland.timeout_ms = timeout;
}
}
if let Ok(retry_str) = env::var("HYPRMONITORS_RETRY_ATTEMPTS") {
if let Ok(retry) = retry_str.parse::<u32>() {
config.hyprland.retry_attempts = retry;
}
}
config
}
/// Get the server bind address
pub fn bind_address(&self) -> String {
format!("{}:{}", self.server.host, self.server.port)
}
/// Get the full server URL for logging
pub fn server_url(&self) -> String {
format!("http://{}:{}", self.server.host, self.server.port)
}
/// Print configuration summary
pub fn print_summary(&self) {
tracing::info!("Configuration:");
tracing::info!(" Server: {}", self.server_url());
tracing::info!(" CORS: {}", self.server.cors_enabled);
tracing::info!(" Log Level: {}", self.server.log_level);
tracing::info!(" Hyprland Timeout: {}ms", self.hyprland.timeout_ms);
tracing::info!(" Retry Attempts: {}", self.hyprland.retry_attempts);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.port, 3000);
assert_eq!(config.server.cors_enabled, true);
assert_eq!(config.server.log_level, "info");
assert_eq!(config.hyprland.timeout_ms, 5000);
assert_eq!(config.hyprland.retry_attempts, 3);
}
#[test]
fn test_bind_address() {
let config = Config::default();
assert_eq!(config.bind_address(), "0.0.0.0:3000");
}
#[test]
fn test_server_url() {
let config = Config::default();
assert_eq!(config.server_url(), "http://0.0.0.0:3000");
}
#[test]
fn test_env_config() {
// Clean up any existing env vars first
env::remove_var("HYPRMONITORS_HOST");
env::remove_var("HYPRMONITORS_PORT");
env::remove_var("HYPRMONITORS_CORS_ENABLED");
env::remove_var("HYPRMONITORS_LOG_LEVEL");
env::remove_var("HYPRMONITORS_TIMEOUT_MS");
env::remove_var("HYPRMONITORS_RETRY_ATTEMPTS");
env::set_var("HYPRMONITORS_HOST", "127.0.0.1");
env::set_var("HYPRMONITORS_PORT", "8080");
env::set_var("HYPRMONITORS_CORS_ENABLED", "false");
env::set_var("HYPRMONITORS_LOG_LEVEL", "debug");
env::set_var("HYPRMONITORS_TIMEOUT_MS", "10000");
env::set_var("HYPRMONITORS_RETRY_ATTEMPTS", "5");
let config = Config::from_env();
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.server.port, 8080);
assert_eq!(config.server.cors_enabled, false);
assert_eq!(config.server.log_level, "debug");
assert_eq!(config.hyprland.timeout_ms, 10000);
assert_eq!(config.hyprland.retry_attempts, 5);
// Clean up
env::remove_var("HYPRMONITORS_HOST");
env::remove_var("HYPRMONITORS_PORT");
env::remove_var("HYPRMONITORS_CORS_ENABLED");
env::remove_var("HYPRMONITORS_LOG_LEVEL");
env::remove_var("HYPRMONITORS_TIMEOUT_MS");
env::remove_var("HYPRMONITORS_RETRY_ATTEMPTS");
}
#[test]
fn test_invalid_env_values() {
// Clean up any existing env vars first
env::remove_var("HYPRMONITORS_PORT");
env::remove_var("HYPRMONITORS_TIMEOUT_MS");
env::set_var("HYPRMONITORS_PORT", "invalid");
env::set_var("HYPRMONITORS_TIMEOUT_MS", "not_a_number");
let config = Config::from_env();
// Should fall back to defaults for invalid values
assert_eq!(config.server.port, 3000);
assert_eq!(config.hyprland.timeout_ms, 5000);
env::remove_var("HYPRMONITORS_PORT");
env::remove_var("HYPRMONITORS_TIMEOUT_MS");
}
}

301
src/lib.rs Normal file
View File

@@ -0,0 +1,301 @@
//! Hyprmonitors Library
//!
//! A Rust library for controlling Hyprland desktop monitors via HTTP API.
pub mod config;
use axum::{
extract::Path,
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use hyprland::dispatch::{Dispatch, DispatchType};
use hyprland::shared::HyprData;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tower_http::cors::CorsLayer;
use tracing::{error, info, warn};
pub use config::Config;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MonitorResponse {
pub success: bool,
pub message: String,
pub monitor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MonitorRequest {
pub monitor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct StatusResponse {
pub success: bool,
pub monitors: HashMap<String, String>,
}
/// Create the main application router with all endpoints
pub fn create_app(config: &Config) -> Router {
let mut app = Router::new()
.route("/", get(health_check))
.route("/health", get(health_check))
.route("/monitors/on", post(turn_all_monitors_on))
.route("/monitors/off", post(turn_all_monitors_off))
.route("/monitors/:monitor/on", post(turn_monitor_on))
.route("/monitors/:monitor/off", post(turn_monitor_off))
.route("/monitors/status", get(get_monitor_status));
// Add CORS if enabled
if config.server.cors_enabled {
app = app.layer(CorsLayer::permissive());
}
app
}
/// Verify connection to Hyprland
pub async fn verify_hyprland_connection() -> Result<(), Box<dyn std::error::Error>> {
match hyprland::data::Monitors::get() {
Ok(monitors) => {
let monitor_vec: Vec<_> = monitors.into_iter().collect();
info!(
"Successfully connected to Hyprland, found {} monitors",
monitor_vec.len()
);
for monitor in monitor_vec {
info!(
" Monitor: {} ({}x{}) - DPMS: {}",
monitor.name,
monitor.width,
monitor.height,
if monitor.dpms_status { "on" } else { "off" }
);
}
Ok(())
}
Err(e) => {
error!("Failed to connect to Hyprland: {}", e);
Err(e.into())
}
}
}
/// Health check endpoint
pub async fn health_check() -> Json<MonitorResponse> {
Json(MonitorResponse {
success: true,
message: "Hyprland Monitor Control Server is running".to_string(),
monitor: None,
})
}
/// Turn all monitors on
pub async fn turn_all_monitors_on() -> Result<Json<MonitorResponse>, StatusCode> {
match execute_dpms_command("on", None).await {
Ok(_) => {
info!("All monitors turned on");
Ok(Json(MonitorResponse {
success: true,
message: "All monitors turned on".to_string(),
monitor: None,
}))
}
Err(e) => {
error!("Failed to turn on all monitors: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
/// Turn all monitors off
pub async fn turn_all_monitors_off() -> Result<Json<MonitorResponse>, StatusCode> {
match execute_dpms_command("off", None).await {
Ok(_) => {
info!("All monitors turned off");
Ok(Json(MonitorResponse {
success: true,
message: "All monitors turned off".to_string(),
monitor: None,
}))
}
Err(e) => {
error!("Failed to turn off all monitors: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
/// Turn specific monitor on
pub async fn turn_monitor_on(
Path(monitor): Path<String>,
) -> Result<Json<MonitorResponse>, StatusCode> {
match execute_dpms_command("on", Some(&monitor)).await {
Ok(_) => {
info!("Monitor {} turned on", monitor);
Ok(Json(MonitorResponse {
success: true,
message: format!("Monitor {} turned on", monitor),
monitor: Some(monitor),
}))
}
Err(e) => {
error!("Failed to turn on monitor {}: {}", monitor, e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
/// Turn specific monitor off
pub async fn turn_monitor_off(
Path(monitor): Path<String>,
) -> Result<Json<MonitorResponse>, StatusCode> {
match execute_dpms_command("off", Some(&monitor)).await {
Ok(_) => {
info!("Monitor {} turned off", monitor);
Ok(Json(MonitorResponse {
success: true,
message: format!("Monitor {} turned off", monitor),
monitor: Some(monitor),
}))
}
Err(e) => {
error!("Failed to turn off monitor {}: {}", monitor, e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
/// Get monitor status
pub async fn get_monitor_status() -> Result<Json<StatusResponse>, StatusCode> {
match hyprland::data::Monitors::get() {
Ok(monitors) => {
let mut monitor_map = HashMap::new();
for monitor in monitors.into_iter() {
let status = if monitor.dpms_status { "on" } else { "off" };
monitor_map.insert(monitor.name, status.to_string());
}
info!("Retrieved status for {} monitors", monitor_map.len());
Ok(Json(StatusResponse {
success: true,
monitors: monitor_map,
}))
}
Err(e) => {
error!("Failed to get monitor status: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
/// Execute DPMS command via Hyprland dispatch
pub async fn execute_dpms_command(
action: &str,
monitor: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let command = match monitor {
Some(monitor_name) => format!("dpms {} {}", action, monitor_name),
None => format!("dpms {}", action),
};
info!("Executing hyprctl dispatch: {}", command);
// Validate monitor exists if specified
if let Some(monitor_name) = monitor {
if !monitor_exists(monitor_name).await? {
warn!("Monitor '{}' not found, proceeding anyway", monitor_name);
}
}
// Parse the command for hyprland dispatch
let dispatch_type = match action {
"on" => {
if let Some(monitor_name) = monitor {
DispatchType::Custom("dpms", &format!("on {}", monitor_name))
} else {
DispatchType::Custom("dpms", "on")
}
}
"off" => {
if let Some(monitor_name) = monitor {
DispatchType::Custom("dpms", &format!("off {}", monitor_name))
} else {
DispatchType::Custom("dpms", "off")
}
}
_ => return Err("Invalid action".into()),
};
Dispatch::call(dispatch_type)?;
Ok(())
}
/// Check if a monitor exists
pub async fn monitor_exists(monitor_name: &str) -> Result<bool, Box<dyn std::error::Error>> {
let monitors = hyprland::data::Monitors::get()?;
Ok(monitors.into_iter().any(|m| m.name == monitor_name))
}
/// Get list of available monitors
pub async fn get_available_monitors() -> Result<Vec<String>, Box<dyn std::error::Error>> {
let monitors = hyprland::data::Monitors::get()?;
Ok(monitors.into_iter().map(|m| m.name).collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_monitor_response_creation() {
let response = MonitorResponse {
success: true,
message: "Test message".to_string(),
monitor: Some("DP-1".to_string()),
};
assert!(response.success);
assert_eq!(response.message, "Test message");
assert_eq!(response.monitor, Some("DP-1".to_string()));
}
#[test]
fn test_status_response_creation() {
let mut monitors = HashMap::new();
monitors.insert("DP-1".to_string(), "on".to_string());
monitors.insert("HDMI-A-1".to_string(), "off".to_string());
let response = StatusResponse {
success: true,
monitors,
};
assert!(response.success);
assert_eq!(response.monitors.len(), 2);
assert_eq!(response.monitors.get("DP-1"), Some(&"on".to_string()));
assert_eq!(response.monitors.get("HDMI-A-1"), Some(&"off".to_string()));
}
#[tokio::test]
async fn test_health_check() {
let response = health_check().await;
assert!(response.0.success);
assert_eq!(
response.0.message,
"Hyprland Monitor Control Server is running"
);
assert_eq!(response.0.monitor, None);
}
#[test]
fn test_create_app() {
let config = Config::default();
let _app = create_app(&config);
// This test just ensures the app can be created without panicking
assert!(true);
}
}

52
src/main.rs Normal file
View File

@@ -0,0 +1,52 @@
use hyprmonitors::{create_app, verify_hyprland_connection, Config};
use tracing::{error, info};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load configuration
let config = Config::from_env();
// Initialize tracing with configured log level
let log_level = config
.server
.log_level
.parse()
.unwrap_or(tracing::Level::INFO);
tracing_subscriber::fmt()
.with_target(false)
.with_max_level(log_level)
.compact()
.init();
// Print configuration summary
config.print_summary();
// Verify Hyprland connection
if let Err(e) = verify_hyprland_connection().await {
error!("Failed to connect to Hyprland: {}", e);
error!("Make sure you're running this inside a Hyprland session");
return Err(e);
}
// Create the application
let app = create_app(&config);
// Run the server
let bind_addr = config.bind_address();
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
info!(
"Hyprland Monitor Control Server running on {}",
config.server_url()
);
info!("Available endpoints:");
info!(" GET /health - Health check");
info!(" POST /monitors/on - Turn all monitors on");
info!(" POST /monitors/off - Turn all monitors off");
info!(" POST /monitors/:monitor/on - Turn specific monitor on");
info!(" POST /monitors/:monitor/off - Turn specific monitor off");
info!(" GET /monitors/status - Get monitor status");
axum::serve(listener, app).await?;
Ok(())
}