feat: initail commit
This commit is contained in:
186
src/config.rs
Normal file
186
src/config.rs
Normal 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
301
src/lib.rs
Normal 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
52
src/main.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user