kanske 0.1.0

Wayland display configuration daemon — automatically applies output profiles on hotplug
pub mod exec;
pub mod state;

use calloop::{
    EventLoop,
    signals::{Signal, Signals},
};
use calloop_wayland_source::WaylandSource;
use std::path::PathBuf;
use std::{env, fs, process};

use kanske_lib::{
    AppResult,
    applier::find_and_apply_profile,
    error::KanskeError,
    parser::config_parser::parse_file,
    paths::pid_file_path,
    wayland_interface::{WaylandState, connect},
};
use tracing::{debug, info, warn};
use wayland_client::EventQueue;

use crate::exec::run_exec_commands;
use crate::state::KanskeState;

const DEFAULT_CONFIG: &str = include_str!("../default_config");

struct PidFailGuard(PathBuf);
impl Drop for PidFailGuard {
    fn drop(&mut self) {
        let _ = fs::remove_file(&self.0);
    }
}

fn main() {
    let _ = tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
                tracing_subscriber::EnvFilter::new("kanske=info,kanske_lib=info")
            }),
        )
        .try_init();

    if let Err(e) = run() {
        eprintln!("Error: {}", e);
        process::exit(1);
    }
}

fn run() -> AppResult<()> {
    let config_file = config_path()?;
    ensure_config(&config_file)?;
    info!(config = %config_file.display(), "Loading config");
    let config = parse_file(config_file)?;
    let (connection, mut event_queue, queue_handle) = connect::<KanskeState>()?;

    let mut state = KanskeState {
        wayland: WaylandState {
            manager: None,
            heads: Vec::new(),
            serial: None,
        },
        config,
        queue_handle,
        connection: connection.clone(),
        last_serial: None,
        reload_pending: false,
    };
    event_queue.roundtrip(&mut state)?;
    event_queue.roundtrip(&mut state)?;

    let mut event_loop: EventLoop<KanskeState> =
        EventLoop::try_new().map_err(|e| KanskeError::CalloopError(e.to_string()))?;
    let signal = event_loop.get_signal();
    let signal_handle = signal.clone();
    let loop_handle = event_loop.handle();

    loop_handle
        .insert_source(
            WaylandSource::new(connection, event_queue),
            move |_event, queue, state| {
                let count = queue.dispatch_pending(state)?;
                if let Err(e) = apply_if_changed(state, queue) {
                    warn!("Fatal apply error, stopping: {}", e);
                    signal.stop();
                }
                Ok(count)
            },
        )
        .map_err(|e| KanskeError::CalloopError(e.to_string()))?;

    let signals = [Signal::SIGHUP, Signal::SIGINT, Signal::SIGTERM];
    loop_handle
        .insert_source(
            Signals::new(&signals).map_err(|e| KanskeError::CalloopError(e.to_string()))?,
            move |signal_event, _meta, state| match signal_event.signal() {
                Signal::SIGINT | Signal::SIGTERM => {
                    info!(signal = ?signal_event.signal(), "Shutdown signal received");
                    signal_handle.stop();
                }
                Signal::SIGHUP => {
                    info!("SIGHUP received, reloading config");
                    if let Err(e) = reload_config(state) {
                        warn!("Config reload failed, keeping previous config: {}", e);
                        return;
                    }
                    let _ = state.connection.display().sync(&state.queue_handle, ());
                    if let Err(e) = state.connection.flush() {
                        warn!("Failed to flush sync request: {}", e);
                    }
                }
                _ => {}
            },
        )
        .map_err(|e| KanskeError::CalloopError(e.to_string()))?;

    let pid_path = pid_file_path()?;
    fs::write(&pid_path, std::process::id().to_string())?;
    let _pid_guard = PidFailGuard(pid_path.clone());

    info!("Monitoring for display changes...");

    event_loop
        .run(None, &mut state, |_| {})
        .map_err(|e| KanskeError::CalloopError(e.to_string()))?;

    info!("Event loop exited");
    Ok(())
}

fn ensure_config(path: &PathBuf) -> AppResult<()> {
    if path.exists() {
        return Ok(());
    }
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, DEFAULT_CONFIG)?;
    info!(path = %path.display(), "Created default config file");

    Ok(())
}

fn config_path() -> AppResult<PathBuf> {
    let config_dir = match env::var_os("XDG_CONFIG_HOME") {
        Some(dir) => PathBuf::from(dir),
        None => {
            let home = env::var_os("HOME").ok_or(KanskeError::NoConfigDir)?;
            PathBuf::from(home).join(".config")
        }
    };

    Ok(config_dir.join("kanske").join("config"))
}

fn apply_if_changed(state: &mut KanskeState, queue: &mut EventQueue<KanskeState>) -> AppResult<()> {
    if state.wayland.serial == state.last_serial || state.wayland.serial.is_none() {
        return Ok(());
    }
    let reason = if std::mem::take(&mut state.reload_pending) {
        "Config reloaded"
    } else {
        "Display hotplug detected"
    };
    info!("{}", reason);
    debug!(previous_serial = ?state.last_serial, new_serial = ?state.wayland.serial, heads = state.wayland.heads.len());

    let config_obj =
        match find_and_apply_profile(&mut state.wayland, &state.queue_handle, &state.config) {
            Ok((execs, config_obj)) => {
                run_exec_commands(&execs);
                config_obj
            }
            Err(e @ (KanskeError::ManagerNotAvailable | KanskeError::NoSerial)) => {
                return Err(e);
            }
            Err(e) => {
                warn!("Profile apply failed: {}", e);
                None
            }
        };
    queue.roundtrip(state)?;
    if let Some(c) = config_obj {
        c.destroy();
    }
    state.last_serial = state.wayland.serial;

    Ok(())
}

fn reload_config(state: &mut KanskeState) -> AppResult<()> {
    let config_file = config_path()?;
    info!(config = %config_file.display(), "Reloading config");
    state.config = parse_file(config_file)?;
    state.reload_pending = true;
    state.last_serial = None;
    Ok(())
}