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

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Hyprmonitors Configuration
# Copy this file to .env and modify as needed
# Server Configuration
HYPRMONITORS_HOST=0.0.0.0
HYPRMONITORS_PORT=3000
HYPRMONITORS_CORS_ENABLED=true
HYPRMONITORS_LOG_LEVEL=info
# Hyprland Configuration
HYPRMONITORS_TIMEOUT_MS=5000
HYPRMONITORS_RETRY_ATTEMPTS=3
# Logging levels: trace, debug, info, warn, error
# Host can be 0.0.0.0 (all interfaces) or 127.0.0.1 (localhost only)
# Port should be between 1024-65535
# CORS should be true for web browser access, false for API-only usage

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

62
.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,62 @@
name: build
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
checks-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- id: set-matrix
name: Generate Nix Matrix
run: |
set -Eeu
matrix="$(nix eval --json '.#githubActions.matrix')"
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
checks-build:
needs: checks-matrix
runs-on: ${{ matrix.os }}
strategy:
matrix: ${{fromJSON(needs.checks-matrix.outputs.matrix)}}
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix build -L '.#${{ matrix.attr }}'
codecov:
runs-on: ubuntu-latest
permissions:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Run codecov
run: nix build .#checks.x86_64-linux.hello-llvm-cov
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.0.1
with:
flags: unittests
name: codecov-hello
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
files: ./result
verbose: true

38
.github/workflows/docs.yaml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: docs
on:
push:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
docs:
runs-on: ubuntu-latest
permissions:
id-token: "write"
contents: "read"
pages: "write"
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: DeterminateSystems/flake-checker-action@main
- name: Generate docs
run: nix build .#checks.x86_64-linux.hello-docs
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: result/share/doc
- name: Deploy to gh-pages
id: deployment
uses: actions/deploy-pages@v4

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/result
/target
.direnv

1148
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[package]
name = "hyprmonitors"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
hyprland = "0.4.0-beta.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }

85
Makefile Normal file
View File

@@ -0,0 +1,85 @@
.PHONY: build run dev clean install test check fmt clippy examples help
# Default target
all: build
# Build the project
build:
cargo build
# Build optimized release version
build-release:
cargo build --release
# Run the server
run:
cargo run
# Run in development mode with logging
dev:
RUST_LOG=info cargo run
# Clean build artifacts
clean:
cargo clean
# Install dependencies and build
install:
cargo fetch
cargo build
# Run tests
test:
cargo test
# Check code without building
check:
cargo check
# Format code
fmt:
cargo fmt
# Run clippy linter
clippy:
cargo clippy -- -D warnings
# Run API examples (requires server to be running)
examples:
./examples.sh
# Start server in background and run examples
demo: build
@echo "Starting server in background..."
@cargo run & echo $$! > .server.pid
@sleep 3
@echo "Running examples..."
@./examples.sh || true
@echo "Stopping server..."
@if [ -f .server.pid ]; then kill `cat .server.pid` && rm .server.pid; fi
# Development setup
setup:
@echo "Setting up development environment..."
@rustup component add rustfmt clippy
@echo "Done! Run 'make run' to start the server."
# Show help
help:
@echo "Hyprmonitors Makefile"
@echo ""
@echo "Available targets:"
@echo " build - Build the project"
@echo " build-release - Build optimized release version"
@echo " run - Run the server"
@echo " dev - Run with debug logging"
@echo " clean - Clean build artifacts"
@echo " install - Install dependencies and build"
@echo " test - Run tests"
@echo " check - Check code without building"
@echo " fmt - Format code"
@echo " clippy - Run linter"
@echo " examples - Run API examples (server must be running)"
@echo " demo - Start server and run examples"
@echo " setup - Setup development environment"
@echo " help - Show this help"

189
README.md Normal file
View File

@@ -0,0 +1,189 @@
# Hyprmonitors
A Rust web server for controlling Hyprland desktop monitors via HTTP API using `hyprctl dispatch dpms` commands.
## Features
- Turn all monitors on/off
- Control individual monitors by name
- Get current monitor status
- RESTful API with JSON responses
- CORS enabled for web applications
## Prerequisites
- Hyprland window manager
- Rust toolchain (1.70+)
- Running Hyprland session
## Installation
1. Clone or download this project
2. Build the project:
```bash
cargo build --release
```
## Usage
### Starting the Server
```bash
cargo run
```
The server will start on `http://0.0.0.0:3000` by default.
### API Endpoints
#### Health Check
```bash
curl http://localhost:3000/health
```
#### Turn All Monitors On
```bash
curl -X POST http://localhost:3000/monitors/on
```
#### Turn All Monitors Off
```bash
curl -X POST http://localhost:3000/monitors/off
```
#### Turn Specific Monitor On
```bash
curl -X POST http://localhost:3000/monitors/DP-1/on
```
#### Turn Specific Monitor Off
```bash
curl -X POST http://localhost:3000/monitors/DP-1/off
```
#### Get Monitor Status
```bash
curl http://localhost:3000/monitors/status
```
### Example Responses
#### Success Response
```json
{
"success": true,
"message": "Monitor DP-1 turned on",
"monitor": "DP-1"
}
```
#### Status Response
```json
{
"success": true,
"monitors": {
"DP-1": "on",
"HDMI-A-1": "off"
}
}
```
## Monitor Names
To find your monitor names, you can use:
```bash
hyprctl monitors
```
Common monitor names include:
- `DP-1`, `DP-2` (DisplayPort)
- `HDMI-A-1`, `HDMI-A-2` (HDMI)
- `eDP-1` (Laptop screen)
## Development
### Dependencies
- `axum` - Web framework
- `hyprland` - Hyprland IPC client
- `tokio` - Async runtime
- `serde` - Serialization
- `tower-http` - HTTP middleware
### Building
```bash
# Debug build
cargo build
# Release build
cargo build --release
# Run with logging
RUST_LOG=info cargo run
```
### Testing
Test the endpoints manually:
```bash
# Test health
curl http://localhost:3000/health
# Test turning monitors off and on
curl -X POST http://localhost:3000/monitors/off
sleep 2
curl -X POST http://localhost:3000/monitors/on
# Check status
curl http://localhost:3000/monitors/status
```
## Configuration
The server binds to `0.0.0.0:3000` by default. To change the port, modify the `main.rs` file:
```rust
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
```
## Systemd Service (Optional)
Create a systemd service to run the server automatically:
```ini
[Unit]
Description=Hyprland Monitor Control Server
After=graphical-session.target
[Service]
Type=simple
ExecStart=/path/to/hyprmonitors/target/release/hyprmonitors
Restart=always
User=%i
Environment=DISPLAY=:0
[Install]
WantedBy=default.target
```
## License
This project is open source. Feel free to modify and distribute.
## Troubleshooting
### Server won't start
- Ensure you're running inside a Hyprland session
- Check that port 3000 is available
- Verify Rust toolchain is installed
### Monitor commands fail
- Confirm monitor names with `hyprctl monitors`
- Ensure Hyprland IPC is accessible
- Check server logs for error details
### Permission issues
- The server needs access to Hyprland's IPC socket
- Run the server as the same user running Hyprland

138
examples.sh Executable file
View File

@@ -0,0 +1,138 @@
#!/bin/bash
# Hyprmonitors API Examples
# Make sure the server is running with: cargo run
BASE_URL="http://localhost:3000"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_step() {
echo -e "${BLUE}$1${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}$1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
# Function to check if server is running
check_server() {
if ! curl -s "$BASE_URL/health" > /dev/null 2>&1; then
print_error "Server is not running at $BASE_URL"
print_warning "Please start the server with: cargo run"
exit 1
fi
print_success "Server is running at $BASE_URL"
}
# Function to make API call with error handling
api_call() {
local method="$1"
local endpoint="$2"
local description="$3"
print_step "$description"
local response
if [[ "$method" == "POST" ]]; then
response=$(curl -s -X POST "$BASE_URL$endpoint" 2>&1)
else
response=$(curl -s "$BASE_URL$endpoint" 2>&1)
fi
local exit_code=$?
if [[ $exit_code -eq 0 ]]; then
echo "$response" | jq '.' 2>/dev/null || echo "$response"
print_success "Request completed"
else
print_error "Request failed: $response"
return 1
fi
echo
}
echo "=== Hyprmonitors API Examples ==="
echo
# Check if required tools are available
if ! command -v curl &> /dev/null; then
print_error "curl is required but not installed"
exit 1
fi
if ! command -v jq &> /dev/null; then
print_warning "jq is not installed - output will not be formatted"
fi
# Check server status
check_server
# Health check
api_call "GET" "/health" "1. Health Check"
# Get monitor status
api_call "GET" "/monitors/status" "2. Current Monitor Status"
# Turn all monitors off
api_call "POST" "/monitors/off" "3. Turning all monitors OFF"
sleep 2
# Check status after turning off
api_call "GET" "/monitors/status" "4. Status after turning off"
# Turn all monitors on
api_call "POST" "/monitors/on" "5. Turning all monitors ON"
sleep 2
# Check status after turning on
api_call "GET" "/monitors/status" "6. Status after turning on"
# Get available monitors and test with first one
print_step "7. Testing specific monitor control"
monitor_status=$(curl -s "$BASE_URL/monitors/status" 2>/dev/null)
if [[ $? -eq 0 ]]; then
MONITOR_NAME=$(echo "$monitor_status" | jq -r '.monitors | keys[0]' 2>/dev/null)
if [[ "$MONITOR_NAME" != "null" && "$MONITOR_NAME" != "" ]]; then
print_success "Found monitor: $MONITOR_NAME"
api_call "POST" "/monitors/$MONITOR_NAME/off" " Turning $MONITOR_NAME off"
sleep 2
api_call "POST" "/monitors/$MONITOR_NAME/on" " Turning $MONITOR_NAME on"
else
print_warning "No monitors found for specific testing"
fi
else
print_warning "Could not retrieve monitor list for specific testing"
fi
# Final status check
api_call "GET" "/monitors/status" "8. Final status check"
print_success "=== Examples completed ==="
echo
print_step "Available endpoints:"
echo " GET $BASE_URL/health"
echo " POST $BASE_URL/monitors/on"
echo " POST $BASE_URL/monitors/off"
echo " POST $BASE_URL/monitors/:monitor/on"
echo " POST $BASE_URL/monitors/:monitor/off"
echo " GET $BASE_URL/monitors/status"
echo
print_step "Tips:"
echo "• To find your monitor names, run: hyprctl monitors"
echo "• To start the server: cargo run"
echo "• To run with debug output: RUST_LOG=debug cargo run"
echo "• To change port: HYPRMONITORS_PORT=8080 cargo run"

96
flake.lock generated Normal file
View File

@@ -0,0 +1,96 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1755027561,
"narHash": "sha256-IVft239Bc8p8Dtvf7UAACMG5P3ZV+3/aO28gXpGtMXI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "005433b926e16227259a1843015b5b2b7f7d1fc3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1755225702,
"narHash": "sha256-i7Rgs943NqX0RgQW0/l1coi8eWBj3XhxVggMpjjzTsk=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "4abaeba6b176979be0da0195b9e4ce86bc501ae4",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

73
flake.nix Normal file
View File

@@ -0,0 +1,73 @@
{
description = "Hyprmonitors - Hyprland monitor control server";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
rust-overlay,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (system: let
overlays = [(import rust-overlay)];
pkgs = import nixpkgs {
inherit system overlays;
};
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = ["rust-src" "rust-analyzer"];
};
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
rustToolchain
pkg-config
openssl
curl
jq
];
shellHook = ''
echo "Hyprmonitors development environment"
echo "Rust version: $(rustc --version)"
echo ""
echo "Available commands:"
echo " cargo build - Build the project"
echo " cargo run - Run the server"
echo " cargo test - Run tests"
echo " ./examples.sh - Run API examples"
echo ""
'';
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
};
packages.default = pkgs.rustPlatform.buildRustPackage {
pname = "hyprmonitors";
version = "0.1.0";
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
};
buildInputs = with pkgs; [
openssl
pkg-config
];
meta = with pkgs.lib; {
description = "Hyprland monitor control server";
homepage = "https://github.com/your-username/hyprmonitors";
license = licenses.mit;
maintainers = [];
};
};
});
}

49
hyprmonitors.service Normal file
View File

@@ -0,0 +1,49 @@
[Unit]
Description=Hyprland Monitor Control Server
Documentation=https://github.com/your-username/hyprmonitors
After=graphical-session.target
Wants=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/local/bin/hyprmonitors
Restart=always
RestartSec=5
User=%i
Group=users
# Environment variables
Environment=RUST_LOG=info
Environment=HYPRMONITORS_HOST=127.0.0.1
Environment=HYPRMONITORS_PORT=3000
Environment=HYPRMONITORS_LOG_LEVEL=info
# Ensure Hyprland environment is available
Environment=XDG_RUNTIME_DIR=/run/user/1000
Environment=HYPRLAND_INSTANCE_SIGNATURE=%i
# Security settings
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=read-only
ProtectSystem=strict
ReadWritePaths=/tmp
# Resource limits
LimitNOFILE=1024
MemoryMax=128M
[Install]
WantedBy=default.target
# Installation Instructions:
# 1. Build the project: cargo build --release
# 2. Copy binary: sudo cp target/release/hyprmonitors /usr/local/bin/
# 3. Copy service file: sudo cp hyprmonitors.service /etc/systemd/system/hyprmonitors@.service
# 4. Enable for your user: systemctl --user enable hyprmonitors@$USER.service
# 5. Start the service: systemctl --user start hyprmonitors@$USER.service
# 6. Check status: systemctl --user status hyprmonitors@$USER.service
#
# For system-wide installation, modify paths and use:
# sudo systemctl enable hyprmonitors@username.service
# sudo systemctl start hyprmonitors@username.service

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(())
}