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