From 580f70a5a14a3cdd2510c2d8d260387c9110856c Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Wed, 20 Jul 2022 15:13:24 -0700 Subject: [PATCH] feat(komokana): initial commit This is the initial commit of a minimal working version of komokana, a daemon that I'm starting to use both as a kanata current layer notification widget, and as a way to automatically change kanata layers when different conditions are met in the komorebi subscriber event stream. I'm not sure if this will finally be merged into the master branch or not, but for now I'll keep the code here. --- Cargo.lock | 60 ++++++ Cargo.toml | 3 +- justfile | 4 + komokana/Cargo.toml | 25 +++ komokana/src/configuration.rs | 37 ++++ komokana/src/main.rs | 376 ++++++++++++++++++++++++++++++++++ 6 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 komokana/Cargo.toml create mode 100644 komokana/src/configuration.rs create mode 100644 komokana/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 7b33828f..7d8e7fdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -251,6 +260,19 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be" +[[package]] +name = "env_logger" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + [[package]] name = "eyre" version = "0.6.8" @@ -392,6 +414,12 @@ dependencies = [ "notify", ] +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "indenter" version = "0.3.3" @@ -452,6 +480,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" +[[package]] +name = "json_dotpath" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbdcfef3cf5591f0cef62da413ae795e3d1f5a00936ccec0b2071499a32efd1a" +dependencies = [ + "serde", + "serde_derive", + "serde_json", + "thiserror", +] + [[package]] name = "kernel32-sys" version = "0.2.2" @@ -462,6 +502,24 @@ dependencies = [ "winapi-build", ] +[[package]] +name = "komokana" +version = "0.1.0" +dependencies = [ + "clap", + "color-eyre", + "dirs", + "env_logger", + "json_dotpath", + "log", + "miow 0.4.0", + "parking_lot", + "serde", + "serde_json", + "serde_yaml", + "windows", +] + [[package]] name = "komorebi" version = "0.1.10" @@ -945,6 +1003,8 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" dependencies = [ + "aho-corasick", + "memchr", "regex-syntax", ] diff --git a/Cargo.toml b/Cargo.toml index 1adc4354..c304bc17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,5 +4,6 @@ members = [ "derive-ahk", "komorebi", "komorebi-core", - "komorebic" + "komorebic", + "komokana" ] diff --git a/justfile b/justfile index 045b5512..6e8b3d21 100644 --- a/justfile +++ b/justfile @@ -15,9 +15,13 @@ install-komorebic: install-komorebi: cargo +stable install --path komorebi --locked +install-komokana: + cargo +stable install --path komokana --locked + install: just install-komorebic just install-komorebi + just install-komokana komorebic ahk-library cat '%USERPROFILE%\.config\komorebi\komorebic.lib.ahk' > komorebic.lib.sample.ahk diff --git a/komokana/Cargo.toml b/komokana/Cargo.toml new file mode 100644 index 00000000..7adcdc3d --- /dev/null +++ b/komokana/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "komokana" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clap = { version = "3", features = ["derive", "wrap_help"] } +color-eyre = "0.6" +dirs = "4" +env_logger = "0.9" +json_dotpath = "1" +log = "0.4" +miow = "0.4" +parking_lot = "0.12" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.8" + +[dependencies.windows] +version = "0.39" +features = [ + "Win32_UI_Input_KeyboardAndMouse", +] diff --git a/komokana/src/configuration.rs b/komokana/src/configuration.rs new file mode 100644 index 00000000..12ff3fa7 --- /dev/null +++ b/komokana/src/configuration.rs @@ -0,0 +1,37 @@ +#![allow(clippy::use_self)] + +use serde::Deserialize; +use serde::Serialize; + +pub type Configuration = Vec; + +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Entry { + pub exe: String, + pub target_layer: String, + pub title_overrides: Option>, + pub virtual_key_overrides: Option>, + pub virtual_key_ignores: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TitleOverride { + pub title: String, + pub strategy: Strategy, + pub target_layer: String, +} + +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VirtualKeyOverride { + pub virtual_key_code: i32, + pub targer_layer: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Strategy { + StartsWith, + EndsWith, + Contains, + Equals, +} diff --git a/komokana/src/main.rs b/komokana/src/main.rs new file mode 100644 index 00000000..1069eec9 --- /dev/null +++ b/komokana/src/main.rs @@ -0,0 +1,376 @@ +#![warn(clippy::all, clippy::nursery, clippy::pedantic)] +#![allow(clippy::missing_errors_doc)] + +use clap::Parser; +use std::io::Read; +use std::io::Write; +use std::net::TcpStream; +use std::path::PathBuf; +use std::process::Command; +use std::sync::Arc; +use std::thread; +use std::thread::sleep; +use std::time::Duration; + +use color_eyre::eyre::anyhow; +use color_eyre::Report; +use color_eyre::Result; +use dirs::home_dir; +use json_dotpath::DotPaths; +use miow::pipe::NamedPipe; +use parking_lot::Mutex; +use serde_json::json; +use windows::Win32::UI::Input::KeyboardAndMouse::GetKeyState; + +use crate::configuration::Configuration; +use crate::configuration::Strategy; + +mod configuration; + +#[derive(Debug, Parser)] +struct Cli { + #[clap(short = 'p', long)] + kanata_port: i32, + #[clap(short, long, default_value = "~/komokana.yaml")] + configuration: String, + #[clap(short, long)] + default_layer: String, + #[clap(short, long, action)] + tmpfile: bool, +} + +fn main() -> Result<()> { + let cli: Cli = Cli::parse(); + let configuration = resolve_windows_path(&cli.configuration)?; + + if std::env::var("RUST_LOG").is_err() { + std::env::set_var("RUST_LOG", "info"); + } + + color_eyre::install()?; + env_logger::builder().format_timestamp(None).init(); + + let mut komokana = Komokana::init( + configuration, + cli.kanata_port, + cli.default_layer, + cli.tmpfile, + )?; + + komokana.listen(); + + loop { + sleep(Duration::from_secs(60)); + } +} + +struct Komokana { + komorebi: Arc>, + kanata: Arc>, + configuration: Configuration, + default_layer: String, + tmpfile: bool, +} + +const PIPE: &str = r#"\\.\pipe\"#; + +impl Komokana { + pub fn init( + configuration: PathBuf, + kanata_port: i32, + default_layer: String, + tmpfile: bool, + ) -> Result { + let name = "komokana"; + let pipe = format!("{}\\{}", PIPE, name); + let mut cfg = home_dir().expect("could not look up home dir"); + cfg.push("komokana.yaml"); + + let configuration: Configuration = + serde_yaml::from_str(&std::fs::read_to_string(configuration)?)?; + + let named_pipe = NamedPipe::new(pipe)?; + + let mut output = Command::new("cmd.exe") + .args(["/C", "komorebic.exe", "subscribe", name]) + .output()?; + + while !output.status.success() { + log::warn!( + "komorebic.exe failed with error code {:?}, retrying in 5 seconds...", + output.status.code() + ); + + sleep(Duration::from_secs(5)); + + output = Command::new("cmd.exe") + .args(["/C", "komorebic.exe", "subscribe", name]) + .output()?; + } + + named_pipe.connect()?; + log::debug!("connected to komorebi"); + + let stream = TcpStream::connect(format!("localhost:{kanata_port}"))?; + log::debug!("connected to kanata"); + + Ok(Self { + komorebi: Arc::new(Mutex::new(named_pipe)), + kanata: Arc::new(Mutex::new(stream)), + configuration, + default_layer, + tmpfile, + }) + } + + pub fn listen(&mut self) { + let pipe = self.komorebi.clone(); + let stream = self.kanata.clone(); + let stream_read = self.kanata.clone(); + let tmpfile = self.tmpfile; + log::info!("listening"); + + thread::spawn(move || -> Result<()> { + let mut read_stream = stream_read.lock().try_clone()?; + drop(stream_read); + + loop { + let mut buf = vec![0; 1024]; + if let Ok(bytes_read) = read_stream.read(&mut buf) { + let data = String::from_utf8(buf[0..bytes_read].to_vec())?; + if data == "\n" { + continue; + } + + let notification: serde_json::Value = serde_json::from_str(&data)?; + + if notification.dot_has("LayerChange.new") { + if let Some(new) = notification.dot_get::("LayerChange.new")? { + log::info!("current layer: {new}"); + if tmpfile { + let mut tmp = std::env::temp_dir(); + tmp.push("kanata_layer"); + std::fs::write(tmp, new)?; + } + } + } + } + } + }); + + let config = self.configuration.clone(); + let default_layer = self.default_layer.clone(); + thread::spawn(move || -> Result<()> { + let mut buf = vec![0; 4096]; + loop { + let mut named_pipe = pipe.lock(); + match (*named_pipe).read(&mut buf) { + Ok(bytes_read) => { + let data = String::from_utf8(buf[0..bytes_read].to_vec())?; + if data == "\n" { + continue; + } + + let notification: serde_json::Value = serde_json::from_str(&data)?; + if notification.dot_has("event.content.1.exe") { + if let (Some(exe), Some(title), Some(kind)) = ( + notification.dot_get::("event.content.1.exe")?, + notification.dot_get::("event.content.1.title")?, + notification.dot_get::("event.type")?, + ) { + match kind.as_str() { + "Show" => handle_event( + &config, + &stream, + &default_layer, + Event::Show, + &exe, + &title, + )?, + "FocusChange" => handle_event( + &config, + &stream, + &default_layer, + Event::FocusChange, + &exe, + &title, + )?, + _ => {} + }; + } + } + } + Err(error) => { + // Broken pipe + if error.raw_os_error().expect("could not get raw os error") == 109 { + named_pipe.disconnect()?; + + let mut output = Command::new("cmd.exe") + .args(["/C", "komorebic.exe", "subscribe", "bar"]) + .output()?; + + while !output.status.success() { + log::warn!( + "komorebic.exe failed with error code {:?}, retrying in 5 seconds...", + output.status.code() + ); + + sleep(Duration::from_secs(5)); + + output = Command::new("cmd.exe") + .args(["/C", "komorebic.exe", "subscribe", "bar"]) + .output()?; + } + + named_pipe.connect()?; + } else { + return Err(Report::from(error)); + } + } + } + } + }); + } +} + +fn handle_event( + configuration: &Configuration, + stream: &Arc>, + default_layer: &str, + event: Event, + exe: &str, + title: &str, +) -> Result<()> { + let target = calculate_target( + configuration, + event, + exe, + title, + if matches!(event, Event::FocusChange) { + Option::from(default_layer) + } else { + None + }, + ); + + if let Some(target) = target { + let mut stream = stream.lock(); + let request = json!({ + "ChangeLayer": { + "new": target, + } + }); + + stream.write_all(request.to_string().as_bytes())?; + log::debug!("request sent: {request}"); + }; + + Ok(()) +} + +#[derive(Debug, Copy, Clone)] +pub enum Event { + Show, + FocusChange, +} + +fn calculate_target( + configuration: &Configuration, + event: Event, + exe: &str, + title: &str, + default: Option<&str>, +) -> Option { + let mut new_layer = default; + for entry in configuration { + if entry.exe == exe { + if matches!(event, Event::FocusChange) { + new_layer = Option::from(entry.target_layer.as_str()); + } + + if let Some(title_overrides) = &entry.title_overrides { + for title_override in title_overrides { + match title_override.strategy { + Strategy::StartsWith => { + if title.starts_with(&title_override.title) { + new_layer = Option::from(title_override.target_layer.as_str()); + } + } + Strategy::EndsWith => { + if title.ends_with(&title_override.title) { + new_layer = Option::from(title_override.target_layer.as_str()); + } + } + Strategy::Contains => { + if title.contains(&title_override.title) { + new_layer = Option::from(title_override.target_layer.as_str()); + } + } + Strategy::Equals => { + if title.eq(&title_override.title) { + new_layer = Option::from(title_override.target_layer.as_str()); + } + } + } + } + + // This acts like a default target layer within the application + // which defaults back to the entry's main target layer + if new_layer.is_none() { + new_layer = Option::from(entry.target_layer.as_str()); + } + } + + if matches!(event, Event::FocusChange) { + if let Some(virtual_key_overrides) = &entry.virtual_key_overrides { + for virtual_key_override in virtual_key_overrides { + if unsafe { GetKeyState(virtual_key_override.virtual_key_code) } < 0 { + new_layer = Option::from(virtual_key_override.targer_layer.as_str()); + } + } + } + + if let Some(virtual_key_ignores) = &entry.virtual_key_ignores { + for virtual_key in virtual_key_ignores { + if unsafe { GetKeyState(*virtual_key) } < 0 { + new_layer = None; + } + } + } + } + } + } + + new_layer.and_then(|new_layer| Option::from(new_layer.to_string())) +} + +fn resolve_windows_path(raw_path: &str) -> Result { + let path = if raw_path.starts_with('~') { + raw_path.replacen( + '~', + &dirs::home_dir() + .ok_or_else(|| anyhow!("there is no home directory"))? + .display() + .to_string(), + 1, + ) + } else { + raw_path.to_string() + }; + + let full_path = PathBuf::from(path); + + let parent = full_path + .parent() + .ok_or_else(|| anyhow!("cannot parse directory"))?; + + let file = full_path + .components() + .last() + .ok_or_else(|| anyhow!("cannot parse filename"))?; + + let mut canonicalized = std::fs::canonicalize(parent)?; + canonicalized.push(file); + + Ok(canonicalized) +}