diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 0641461..3818701 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -18,6 +18,7 @@ jobs: run: | sudo apt-get update sudo apt-get install libx11-dev libxtst-dev + sudo apt-get install libadwaita-1-dev libgtk-4-dev - name: Release Build run: cargo build --release - name: Upload build artifact diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 3164a45..16a0fbb 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -20,6 +20,7 @@ jobs: run: | sudo apt-get update sudo apt-get install libx11-dev libxtst-dev + sudo apt-get install libadwaita-1-dev libgtk-4-dev - name: Build run: cargo build --verbose - name: Run tests diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml index 5f50f5d..6d33472 100644 --- a/.github/workflows/tagged-release.yml +++ b/.github/workflows/tagged-release.yml @@ -14,6 +14,7 @@ jobs: run: | sudo apt-get update sudo apt-get install libx11-dev libxtst-dev + sudo apt-get install libadwaita-1-dev libgtk-4-dev - name: Release Build run: cargo build --release - name: Upload build artifact diff --git a/Cargo.lock b/Cargo.lock index cc993e5..7e7be32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,14 +155,14 @@ checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" [[package]] name = "enum-as-inner" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" +checksum = "5ffccbb6966c05b32ef8fbac435df276c4ae4d3dc55a8cd0eb9745e6c12f546a" dependencies = [ "heck", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.37", ] [[package]] @@ -423,6 +423,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "glib-build-tools" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3431c56f463443cba9bc3600248bc6d680cb614c2ee1cdd39dab5415bd12ac5c" + [[package]] name = "glib-macros" version = "0.18.2" @@ -601,17 +607,6 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" -[[package]] -name = "idna" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" -dependencies = [ - "matches", - "unicode-bidi", - "unicode-normalization", -] - [[package]] name = "idna" version = "0.4.0" @@ -684,6 +679,7 @@ version = "0.2.1" dependencies = [ "anyhow", "env_logger", + "glib-build-tools", "gtk4", "libadwaita", "libc", @@ -705,12 +701,6 @@ dependencies = [ "x11", ] -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - [[package]] name = "libadwaita" version = "0.5.2" @@ -802,12 +792,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "memchr" version = "2.6.3" @@ -1433,9 +1417,9 @@ dependencies = [ [[package]] name = "trust-dns-proto" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f7f83d1e4a0e4358ac54c5c3681e5d7da5efc5a7a632c90bb6d6669ddd9bc26" +checksum = "0dc775440033cb114085f6f2437682b194fa7546466024b1037e82a48a052a69" dependencies = [ "async-trait", "cfg-if", @@ -1444,9 +1428,9 @@ dependencies = [ "futures-channel", "futures-io", "futures-util", - "idna 0.2.3", + "idna", "ipnet", - "lazy_static", + "once_cell", "rand", "smallvec", "thiserror", @@ -1458,16 +1442,17 @@ dependencies = [ [[package]] name = "trust-dns-resolver" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aff21aa4dcefb0a1afbfac26deb0adc93888c7d295fb63ab273ef276ba2b7cfe" +checksum = "2dff7aed33ef3e8bf2c9966fccdfed93f93d46f432282ea875cd66faabc6ef2f" dependencies = [ "cfg-if", "futures-util", "ipconfig", - "lazy_static", "lru-cache", + "once_cell", "parking_lot", + "rand", "resolv-conf", "smallvec", "thiserror", @@ -1504,7 +1489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" dependencies = [ "form_urlencoded", - "idna 0.4.0", + "idna", "percent-encoding", ] diff --git a/Cargo.toml b/Cargo.toml index 0a5c4da..b9f9aae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,8 @@ strip = true lto = "fat" [dependencies] -tempfile = "3.6" -trust-dns-resolver = "0.22" +tempfile = "3.8" +trust-dns-resolver = "0.23" memmap = "0.7" toml = "0.7" serde = { version = "1.0", features = ["derive"] } @@ -33,16 +33,30 @@ wayland-protocols-misc = { version="0.1.0", features=["client"], optional = true wayland-protocols-plasma = { version="0.1.0", features=["client"], optional = true } mio-signals = "0.2.0" x11 = { version = "2.21.0", features = ["xlib", "xtest"], optional = true } -gtk = { package = "gtk4", version = "0.7.2", features = ["v4_8"], optional = true } -adw = { package = "libadwaita", version = "0.5.2", features = ["v1_3"], optional = true } +gtk = { package = "gtk4", version = "0.7.2", features = ["v4_6"], optional = true } +adw = { package = "libadwaita", version = "0.5.2", features = ["v1_1"], optional = true } [target.'cfg(windows)'.dependencies] winapi = { version = "0.3.9", features = ["winuser"] } +[target.'cfg(unix)'.build-dependencies] +glib-build-tools = "0.18.0" + [features] -default = ["wayland", "x11", "xdg_desktop_portal", "libei"] -wayland = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr", "dep:wayland-protocols-misc", "dep:wayland-protocols-plasma"] -x11 = ["dep:x11"] +default = [ + "wayland", + "x11", + "xdg_desktop_portal", + "libei", + "gtk", +] +wayland = [ + "dep:wayland-client", + "dep:wayland-protocols", + "dep:wayland-protocols-wlr", + "dep:wayland-protocols-misc", + "dep:wayland-protocols-plasma" ] +x11 = [ "dep:x11" ] xdg_desktop_portal = [] libei = [] gtk = ["dep:gtk", "dep:adw"] diff --git a/README.md b/README.md index 7eb3e6e..dafda15 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # Lan Mouse Share + +![image](https://github.com/ferdinandschober/lan-mouse/assets/40996949/99b41c06-2636-4793-9139-98aaf01a766c) + Goal of this project is to be an open-source replacement for proprietary tools like [Synergy](https://symless.com/synergy), [Share Mouse](https://www.sharemouse.com/de/). Focus lies on performance and a clean, manageable implementation that can easily be expanded to support additional backends like e.g. Android, iOS, ... . @@ -7,6 +10,8 @@ Of course ***blazingly fastâ„¢*** and stable, because it's written in rust. For an alternative (with slightly different goals) you may check out [Input Leap](https://github.com/input-leap). +_Now with a gtk frontend_ + ## Configuration Configuration is done through the file `config.toml`, which must be located in the current working directory when @@ -16,32 +21,43 @@ executing lan-mouse. A minimal config file could look like this: ```toml +# example configuration + +# optional port (defaults to 4242) +port = 4242 +# # optional frontend -> defaults to gtk if available +# # possible values are "cli" and "gtk" +# frontend = "gtk" + +# define a client on the right side with host name "iridium" +[right] +# hostname +host_name = "iridium" +# optional list of (known) ip addresses +ips = ["192.168.178.156"] + +# define a client on the left side with IP address 192.168.178.189 [left] -host_name = "my-laptop" +# The hostname is optional: When no hostname is specified, +# at least one ip address needs to be specified. +host_name = "thorium" +# ips for ethernet and wifi +ips = ["192.168.178.189"] ``` Where `left` can be either `left`, `right`, `top` or `bottom`. +> :warning: Note, that with the gtk frontend, the clients from the config +> file are currently ignored. -### Additional options -Additionally -- a preferred backend -- a port override for the default port (4242) - -can be specified. - -Supported backends currently include "wlroots", "x11" and "windows". - -These two options can also be specified via the commandline -options `--backend` and `--port` respectively. ## Build and Run -Build only +Build in release mode: ```sh cargo build --release ``` -Run +Run directly: ```sh cargo run --release ``` @@ -133,12 +149,8 @@ This is to be looked into in the future. (this works natively on sway versions >= 1.8) ## Windows support -Currently windows can receive mouse and keyboard events, however unlike -with the wlroots back-end, - -the scancodes are not translated between keyboard layouts. - -Event emitting is WIP. +Currently windows can receive mouse and keyboard events, +event producing on windows is WIP. ## TODOS @@ -151,11 +163,11 @@ Event emitting is WIP. - [x] Button support - [ ] Latency measurement + logging - [ ] Bandwidth usage approximation + logging -- [ ] Multiple IP addresses -> check which one is reachable +- [x] Multiple IP addresses -> check which one is reachable - [x] Merge server and client -> Both client and server can send and receive events depending on what mouse is used where -- [ ] Liveness tracking (automatically ungrab mouse when client unreachable) +- [x] Liveness tracking (automatically ungrab mouse when client unreachable) - [ ] Clipboard support -- [ ] Graphical frontend (gtk?) +- [x] Graphical frontend (gtk?) - [ ] *Encrytion* - [ ] Gnome Shell Extension (layer shell is not supported) - [ ] respect xdg-config-home for config file location. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..03bb139 --- /dev/null +++ b/build.rs @@ -0,0 +1,9 @@ +fn main() { + // composite_templates + #[cfg(unix)] + glib_build_tools::compile_resources( + &["resources"], + "resources/resources.gresource.xml", + "lan-mouse.gresource", + ); +} diff --git a/config.toml b/config.toml index b327b82..82b19fd 100644 --- a/config.toml +++ b/config.toml @@ -2,19 +2,20 @@ # optional port (defaults to 4242) port = 4242 +# optional frontend -> defaults to gtk if available +# frontend = "gtk" # define a client on the right side with host name "iridium" [right] # hostname host_name = "iridium" # optional list of (known) ip addresses -ips = ["192.168.178.141"] -# optional port (defaults to 4242) -port = 4242 +ips = ["192.168.178.156"] # define a client on the left side with IP address 192.168.178.189 [left] # The hostname is optional: When no hostname is specified, # at least one ip address needs to be specified. host_name = "thorium" -ips = ["192.168.178.189"] +# ips for ethernet and wifi +ips = ["192.168.178.189", "192.168.178.172"] diff --git a/resources/client_row.ui b/resources/client_row.ui new file mode 100644 index 0000000..226fbbe --- /dev/null +++ b/resources/client_row.ui @@ -0,0 +1,74 @@ + + + + diff --git a/resources/mouse-icon.svg b/resources/mouse-icon.svg new file mode 100644 index 0000000..ff8e237 --- /dev/null +++ b/resources/mouse-icon.svg @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/resources.gresource.xml b/resources/resources.gresource.xml new file mode 100644 index 0000000..ae6696b --- /dev/null +++ b/resources/resources.gresource.xml @@ -0,0 +1,12 @@ + + + + window.ui + client_row.ui + style.css + style-dark.css + + + mouse-icon.svg + + diff --git a/resources/style-dark.css b/resources/style-dark.css new file mode 100644 index 0000000..df5c5c3 --- /dev/null +++ b/resources/style-dark.css @@ -0,0 +1,3 @@ +#delete-button { + color: @red_1; +} diff --git a/resources/style.css b/resources/style.css new file mode 100644 index 0000000..0d2d462 --- /dev/null +++ b/resources/style.css @@ -0,0 +1,3 @@ +#delete-button { + color: @red_3; +} diff --git a/resources/window.ui b/resources/window.ui new file mode 100644 index 0000000..84198bd --- /dev/null +++ b/resources/window.ui @@ -0,0 +1,121 @@ + + + + + + + _Close window + window.close + + + + diff --git a/src/backend/producer/wayland.rs b/src/backend/producer/wayland.rs index d76a474..3768850 100644 --- a/src/backend/producer/wayland.rs +++ b/src/backend/producer/wayland.rs @@ -129,6 +129,15 @@ impl Window { } } +impl Drop for Window { + fn drop(&mut self) { + log::debug!("destroying window!"); + self.layer_surface.destroy(); + self.surface.destroy(); + self.buffer.destroy(); + } +} + fn draw(f: &mut File, (width, height): (u32, u32)) { let mut buf = BufWriter::new(f); for _ in 0..height { @@ -301,6 +310,7 @@ impl State { fn add_client(&mut self, client: ClientHandle, pos: Position) { let window = Rc::new(Window::new(&self.g, &self.qh, pos)); + assert!(Rc::strong_count(&window) == 1); self.client_for_window.push((window, client)); } } @@ -414,8 +424,15 @@ impl EventProducer for WaylandEventProducer { fn notify(&mut self, client_event: ClientEvent) { if let ClientEvent::Create(handle, pos) = client_event { self.state.add_client(handle, pos); - self.flush_events(); } + if let ClientEvent::Destroy(handle) = client_event { + if let Some(i) = self.state.client_for_window.iter().position(|(_,c)| *c == handle) { + let w = self.state.client_for_window.remove(i); + self.state.focused = None; + assert!(Rc::strong_count(&w.0) == 1); + } + } + self.flush_events(); } fn release(&mut self) { @@ -466,15 +483,16 @@ impl Dispatch for State { } => { // get client corresponding to the focused surface log::trace!("produce: enter()"); - { - let (window, client) = app + if let Some((window, client)) = app .client_for_window .iter() - .find(|(w, _c)| w.surface == surface) - .unwrap(); - app.focused = Some((window.clone(), *client)); - app.grab(&surface, pointer, serial.clone(), qh); + .find(|(w, _c)| w.surface == surface) { + app.focused = Some((window.clone(), *client)); + app.grab(&surface, pointer, serial.clone(), qh); + } else { + return; + } } let (_, client) = app .client_for_window @@ -641,17 +659,17 @@ impl Dispatch for State { _: &QueueHandle, ) { if let zwlr_layer_surface_v1::Event::Configure { serial, .. } = event { - let (window, _client) = app + if let Some((window, _client)) = app .client_for_window .iter() - .find(|(w, _c)| &w.layer_surface == layer_surface) - .unwrap(); - // client corresponding to the layer_surface - let surface = &window.surface; - let buffer = &window.buffer; - surface.attach(Some(&buffer), 0, 0); - layer_surface.ack_configure(serial); - surface.commit(); + .find(|(w, _c)| &w.layer_surface == layer_surface) { + // client corresponding to the layer_surface + let surface = &window.surface; + let buffer = &window.buffer; + surface.attach(Some(&buffer), 0, 0); + layer_surface.ack_configure(serial); + surface.commit(); + } } } } diff --git a/src/client.rs b/src/client.rs index ef619e9..b0c9738 100644 --- a/src/client.rs +++ b/src/client.rs @@ -121,46 +121,46 @@ impl ClientManager { pub fn last_ping(&self, client: ClientHandle) -> Option { let last_ping = self.last_ping .iter() - .find(|(c,_)| *c == client) - .unwrap().1; + .find(|(c,_)| *c == client)?.1; last_ping.map(|p| p.elapsed()) } pub fn last_seen(&self, client: ClientHandle) -> Option { let last_seen = self.last_seen .iter() - .find(|(c, _)| *c == client) - .unwrap().1; + .find(|(c, _)| *c == client)?.1; last_seen.map(|t| t.elapsed()) } pub fn last_replied(&self, client: ClientHandle) -> Option { let last_replied = self.last_replied .iter() - .find(|(c, _)| *c == client) - .unwrap().1; + .find(|(c, _)| *c == client)?.1; last_replied.map(|t| t.elapsed()) } pub fn reset_last_ping(&mut self, client: ClientHandle) { - self.last_ping + if let Some(c) = self.last_ping .iter_mut() - .find(|(c, _)| *c == client) - .unwrap().1 = Some(Instant::now()); + .find(|(c, _)| *c == client) { + c.1 = Some(Instant::now()); + } } pub fn reset_last_seen(&mut self, client: ClientHandle) { - self.last_seen + if let Some(c) = self.last_seen .iter_mut() - .find(|(c, _)| *c == client) - .unwrap().1 = Some(Instant::now()); + .find(|(c, _)| *c == client) { + c.1 = Some(Instant::now()); + } } pub fn reset_last_replied(&mut self, client: ClientHandle) { - self.last_replied + if let Some(c) = self.last_replied .iter_mut() - .find(|(c, _)| *c == client) - .unwrap().1 = Some(Instant::now()); + .find(|(c, _)| *c == client) { + c.1 = Some(Instant::now()); + } } pub fn get_client(&self, addr: SocketAddr) -> Option { @@ -170,6 +170,15 @@ impl ClientManager { .map(|c| c.handle) } + pub fn remove_client(&mut self, client: ClientHandle) { + if let Some(i) = self.clients.iter().position(|c| c.handle == client) { + self.clients.remove(i); + self.last_ping.remove(i); + self.last_seen.remove(i); + self.last_replied.remove(i); + } + } + fn next_id(&mut self) -> ClientHandle { let handle = self.next_client_id; self.next_client_id += 1; diff --git a/src/config.rs b/src/config.rs index 219e060..fef7966 100644 --- a/src/config.rs +++ b/src/config.rs @@ -64,6 +64,7 @@ fn find_arg(key: &'static str) -> Result, MissingParameter> { Ok(None) } +#[derive(PartialEq, Eq)] pub enum Frontend { Gtk, Cli, @@ -96,6 +97,9 @@ impl Config { }; let frontend = match frontend { + #[cfg(all(unix, feature = "gtk"))] + None => Frontend::Gtk, + #[cfg(any(not(feature = "gtk"), not(unix)))] None => Frontend::Cli, Some(s) => match s.as_str() { "cli" => Frontend::Cli, diff --git a/src/event/server.rs b/src/event/server.rs index 2098258..823f336 100644 --- a/src/event/server.rs +++ b/src/event/server.rs @@ -6,7 +6,7 @@ use mio_signals::{Signals, Signal, SignalSet}; use std::{net::SocketAddr, io::ErrorKind}; -use crate::{client::{ClientEvent, ClientManager, Position}, consumer::EventConsumer, producer::EventProducer, frontend::{FrontendEvent, FrontendAdapter}}; +use crate::{client::{ClientEvent, ClientManager, Position}, consumer::EventConsumer, producer::EventProducer, frontend::{FrontendEvent, FrontendAdapter}, dns}; use super::Event; /// keeps track of state to prevent a feedback loop @@ -94,10 +94,25 @@ impl Server { pub fn add_client(&mut self, addr: HashSet, pos: Position) { let client = self.client_manager.add_client(addr, pos); + log::debug!("add_client {client}"); self.producer.notify(ClientEvent::Create(client, pos)); self.consumer.notify(ClientEvent::Create(client, pos)); } + pub fn remove_client(&mut self, host: String, port: u16) { + if let Ok(ips) = dns::resolve(host.as_str()) { + if let Some(ip) = ips.iter().next() { + let addr = SocketAddr::new(*ip, port); + if let Some(handle) = self.client_manager.get_client(addr) { + log::debug!("remove_client {handle}"); + self.client_manager.remove_client(handle); + self.producer.notify(ClientEvent::Destroy(handle)); + self.consumer.notify(ClientEvent::Destroy(handle)); + } + } + } + } + fn handle_udp_rx(&mut self) { loop { let (event, addr) = match self.receive_event() { @@ -134,6 +149,9 @@ impl Server { if let Err(e) = Self::send_event(&self.socket, Event::Pong(), addr) { log::error!("udp send: {}", e); } + // we release the mouse here, + // since its very likely, that we wont get a release event + self.producer.release(); } (event, addr) => { match self.state { @@ -171,6 +189,7 @@ impl Server { fn handle_producer_rx(&mut self) { let events = self.producer.read_events(); + let mut should_release = false; for (c, e) in events.into_iter() { // in receiving state, only release events // must be transmitted @@ -200,6 +219,11 @@ impl Server { continue } + // release mouse if client didnt respond to the first ping + if last_ping.is_some() && last_ping.unwrap() < Duration::from_secs(1) { + should_release = true; + } + // last ping > 500ms ago -> ping all interfaces self.client_manager.reset_last_ping(c); if let Some(iter) = self.client_manager.get_addrs(c) { @@ -210,27 +234,43 @@ impl Server { log::error!("udp send: {}", e); } } + // send additional release event, in case client is still in sending mode + if let Err(e) = Self::send_event(&self.socket, Event::Release(), addr) { + if e.kind() != ErrorKind::WouldBlock { + log::error!("udp send: {}", e); + } + } } } else { // TODO should repeat dns lookup } } + + if should_release && self.state != State::Receiving { + log::info!("client not responding - releasing pointer"); + self.producer.release(); + self.state = State::Receiving; + } + } fn handle_frontend_rx(&mut self) -> bool { loop { match self.frontend.read_event() { Ok(event) => match event { - FrontendEvent::RequestPortChange(_) => todo!(), - FrontendEvent::RequestClientAdd(addr, pos) => { - self.add_client(HashSet::from_iter(&mut [addr].into_iter()), pos); + FrontendEvent::AddClient(host, port, pos) => { + if let Ok(ips) = dns::resolve(host.as_str()) { + let addrs = ips.iter().map(|i| SocketAddr::new(*i, port)); + self.add_client(HashSet::from_iter(addrs), pos); + } } - FrontendEvent::RequestClientDelete(_) => todo!(), - FrontendEvent::RequestClientUpdate(_) => todo!(), - FrontendEvent::RequestShutdown() => { + FrontendEvent::DelClient(host, port) => self.remove_client(host, port), + FrontendEvent::Shutdown() => { log::info!("terminating gracefully..."); return true; }, + FrontendEvent::ChangePort(_) => todo!(), + FrontendEvent::AddIp(_, _) => todo!(), } Err(e) if e.kind() == ErrorKind::WouldBlock => return false, Err(e) => { diff --git a/src/frontend.rs b/src/frontend.rs index d0bb1ed..a763902 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -1,5 +1,6 @@ use std::io::{Read, Result}; -use std::{str, net::SocketAddr}; +use std::net::IpAddr; +use std::str; #[cfg(unix)] use std::{env, path::{Path, PathBuf}}; @@ -24,11 +25,11 @@ pub mod gtk; #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] pub enum FrontendEvent { - RequestPortChange(u16), - RequestClientAdd(SocketAddr, Position), - RequestClientDelete(Client), - RequestClientUpdate(Client), - RequestShutdown(), + ChangePort(u16), + AddClient(String, u16, Position), + DelClient(String, u16), + AddIp(String, Option), + Shutdown(), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -78,6 +79,7 @@ impl FrontendAdapter { let json = str::from_utf8(&buf) .unwrap() .trim_end_matches(char::from(0)); // remove trailing 0-bytes + log::debug!("{json}"); let event = serde_json::from_str(json).unwrap(); log::debug!("{:?}", event); Ok(event) @@ -117,5 +119,3 @@ impl Drop for FrontendAdapter { std::fs::remove_file(&self.socket_path).unwrap(); } } - -pub trait Frontend { } diff --git a/src/frontend/cli.rs b/src/frontend/cli.rs index 33a22e1..d94f128 100644 --- a/src/frontend/cli.rs +++ b/src/frontend/cli.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use std::{thread, io::Write, net::SocketAddr}; +use std::{thread::{self, JoinHandle}, io::Write}; #[cfg(windows)] use std::net::SocketAddrV4; @@ -8,74 +8,88 @@ use std::{os::unix::net::UnixStream, path::Path, env}; #[cfg(windows)] use std::net::TcpStream; -use crate::client::Position; +use crate::{client::Position, config::DEFAULT_PORT}; -use super::{FrontendEvent, Frontend}; +use super::FrontendEvent; -pub struct CliFrontend; - -impl Frontend for CliFrontend {} - -impl CliFrontend { - pub fn new() -> Result { - #[cfg(unix)] - let socket_path = Path::new(env::var("XDG_RUNTIME_DIR")?.as_str()).join("lan-mouse-socket.sock"); - thread::Builder::new() - .name("cli-frontend".to_string()) - .spawn(move || { - loop { - eprint!("lan-mouse > "); - std::io::stderr().flush().unwrap(); - let mut buf = String::new(); - match std::io::stdin().read_line(&mut buf) { - Ok(len) => { - if let Some(event) = parse_event(buf, len) { - #[cfg(unix)] - let Ok(mut stream) = UnixStream::connect(&socket_path) else { - log::error!("Could not connect to lan-mouse-socket"); - continue; - }; - #[cfg(windows)] - let Ok(mut stream) = TcpStream::connect("127.0.0.1:5252".parse::().unwrap()) else { - log::error!("Could not connect to lan-mouse-server"); - continue; - }; - let json = serde_json::to_string(&event).unwrap(); - if let Err(e) = stream.write(json.as_bytes()) { - log::error!("error sending message: {e}"); - }; - if event == FrontendEvent::RequestShutdown() { - break; - } +pub fn start() -> Result> { + #[cfg(unix)] + let socket_path = Path::new(env::var("XDG_RUNTIME_DIR")?.as_str()).join("lan-mouse-socket.sock"); + Ok(thread::Builder::new() + .name("cli-frontend".to_string()) + .spawn(move || { + loop { + eprint!("lan-mouse > "); + std::io::stderr().flush().unwrap(); + let mut buf = String::new(); + match std::io::stdin().read_line(&mut buf) { + Ok(len) => { + if let Some(event) = parse_cmd(buf, len) { + #[cfg(unix)] + let Ok(mut stream) = UnixStream::connect(&socket_path) else { + log::error!("Could not connect to lan-mouse-socket"); + continue; + }; + #[cfg(windows)] + let Ok(mut stream) = TcpStream::connect("127.0.0.1:5252".parse::().unwrap()) else { + log::error!("Could not connect to lan-mouse-server"); + continue; + }; + let json = serde_json::to_string(&event).unwrap(); + if let Err(e) = stream.write(json.as_bytes()) { + log::error!("error sending message: {e}"); + }; + if event == FrontendEvent::Shutdown() { + break; } } - Err(e) => { - log::error!("{e:?}"); - break - } + } + Err(e) => { + log::error!("{e:?}"); + break } } - }).unwrap(); - Ok(Self {}) - } + } + })?) } -fn parse_event(s: String, len: usize) -> Option { +fn parse_cmd(s: String, len: usize) -> Option { if len == 0 { - return Some(FrontendEvent::RequestShutdown()) + return Some(FrontendEvent::Shutdown()) } let mut l = s.split_whitespace(); let cmd = l.next()?; match cmd { "connect" => { - let addr = match l.next()?.parse() { - Ok(addr) => SocketAddr::V4(addr), + let host = l.next()?.to_owned(); + let pos = match l.next()? { + "right" => Position::Right, + "top" => Position::Top, + "bottom" => Position::Bottom, + _ => Position::Left, + }; + let port = match l.next() { + Some(p) => match p.parse() { + Ok(p) => p, + Err(e) => { + log::error!("{e}"); + return None; + } + } + None => DEFAULT_PORT, + }; + Some(FrontendEvent::AddClient(host, port, pos)) + } + "disconnect" => { + let host = l.next()?.to_owned(); + let port = match l.next()?.parse() { + Ok(p) => p, Err(e) => { - log::error!("parse error: {e}"); + log::error!("{e}"); return None; } }; - Some(FrontendEvent::RequestClientAdd(addr, Position::Left )) + Some(FrontendEvent::DelClient(host, port)) } _ => { log::error!("unknown command: {s}"); diff --git a/src/frontend/gtk.rs b/src/frontend/gtk.rs new file mode 100644 index 0000000..5f8eb5d --- /dev/null +++ b/src/frontend/gtk.rs @@ -0,0 +1,93 @@ +mod window; +mod client_object; +mod client_row; + +use std::{io::Result, thread::{self, JoinHandle}}; + +use crate::frontend::gtk::window::Window; + +use gtk::{prelude::*, IconTheme, gdk::Display, gio::{SimpleAction, SimpleActionGroup}, glib::clone, CssProvider}; +use adw::Application; +use gtk::{gio, glib, prelude::ApplicationExt}; + +use self::client_object::ClientObject; + +pub fn start() -> Result> { + thread::Builder::new() + .name("gtk-thread".into()) + .spawn(gtk_main) +} + +fn gtk_main() -> glib::ExitCode { + gio::resources_register_include!("lan-mouse.gresource") + .expect("Failed to register resources."); + + let app = Application::builder() + .application_id("de.feschber.lan-mouse") + .build(); + + app.connect_startup(|_| load_icons()); + app.connect_startup(|_| load_css()); + app.connect_activate(build_ui); + + app.run() +} + +fn load_css() { + let provider = CssProvider::new(); + provider.load_from_resource("de/feschber/LanMouse/style.css"); + gtk::style_context_add_provider_for_display( + &Display::default().expect("Could not connect to a display."), + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} + +fn load_icons() { + let icon_theme = IconTheme::for_display(&Display::default().expect("Could not connect to a display.")); + icon_theme.add_resource_path("/de/feschber/LanMouse/icons"); +} + +fn build_ui(app: &Application) { + let window = Window::new(app); + let action_client_activate = SimpleAction::new( + "activate-client", + Some(&i32::static_variant_type()), + ); + let action_client_delete = SimpleAction::new( + "delete-client", + Some(&i32::static_variant_type()), + ); + action_client_activate.connect_activate(clone!(@weak window => move |_action, param| { + log::debug!("activate-client"); + let index = param.unwrap() + .get::() + .unwrap(); + let Some(client) = window.clients().item(index as u32) else { + return; + }; + let client = client.downcast_ref::().unwrap(); + window.update_client(client); + })); + action_client_delete.connect_activate(clone!(@weak window => move |_action, param| { + log::debug!("delete-client"); + let index = param.unwrap() + .get::() + .unwrap(); + let Some(client) = window.clients().item(index as u32) else { + return; + }; + let client = client.downcast_ref::().unwrap(); + window.update_client(client); + window.clients().remove(index as u32); + if window.clients().n_items() == 0 { + window.set_placeholder_visible(true); + } + })); + + let actions = SimpleActionGroup::new(); + window.insert_action_group("win", Some(&actions)); + actions.add_action(&action_client_activate); + actions.add_action(&action_client_delete); + window.present(); +} diff --git a/src/frontend/gtk/client_object.rs b/src/frontend/gtk/client_object.rs new file mode 100644 index 0000000..d2f6b24 --- /dev/null +++ b/src/frontend/gtk/client_object.rs @@ -0,0 +1,31 @@ +mod imp; + +use gtk::glib::{self, Object}; +use adw::subclass::prelude::*; + +glib::wrapper! { + pub struct ClientObject(ObjectSubclass); +} + +impl ClientObject { + pub fn new(hostname: String, port: u32, active: bool, position: String) -> Self { + Object::builder() + .property("hostname", hostname) + .property("port", port) + .property("active", active) + .property("position", position) + .build() + } + + pub fn get_data(&self) -> ClientData { + self.imp().data.borrow().clone() + } +} + +#[derive(Default, Clone)] +pub struct ClientData { + pub hostname: String, + pub port: u32, + pub active: bool, + pub position: String, +} diff --git a/src/frontend/gtk/client_object/imp.rs b/src/frontend/gtk/client_object/imp.rs new file mode 100644 index 0000000..4e9dc49 --- /dev/null +++ b/src/frontend/gtk/client_object/imp.rs @@ -0,0 +1,27 @@ +use std::cell::RefCell; + +use glib::Properties; +use gtk::glib; +use gtk::prelude::*; +use gtk::subclass::prelude::*; + +use super::ClientData; + +#[derive(Properties, Default)] +#[properties(wrapper_type = super::ClientObject)] +pub struct ClientObject { + #[property(name = "hostname", get, set, type = String, member = hostname)] + #[property(name = "port", get, set, type = u32, member = port, maximum = u16::MAX as u32)] + #[property(name = "active", get, set, type = bool, member = active)] + #[property(name = "position", get, set, type = String, member = position)] + pub data: RefCell, +} + +#[glib::object_subclass] +impl ObjectSubclass for ClientObject { + const NAME: &'static str = "ClientObject"; + type Type = super::ClientObject; +} + +#[glib::derived_properties] +impl ObjectImpl for ClientObject {} diff --git a/src/frontend/gtk/client_row.rs b/src/frontend/gtk/client_row.rs new file mode 100644 index 0000000..c087c63 --- /dev/null +++ b/src/frontend/gtk/client_row.rs @@ -0,0 +1,98 @@ +mod imp; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::glib::{self, Object}; + +use crate::config::DEFAULT_PORT; + +use super::ClientObject; + +glib::wrapper! { + pub struct ClientRow(ObjectSubclass) + @extends gtk::ListBoxRow, gtk::Widget, adw::PreferencesRow, adw::ExpanderRow, + @implements gtk::Accessible, gtk::Actionable, gtk::Buildable, gtk::ConstraintTarget; +} + +impl ClientRow { + pub fn new(_client_object: &ClientObject) -> Self { + Object::builder() + .build() + } + + pub fn bind(&self, client_object: &ClientObject) { + let mut bindings = self.imp().bindings.borrow_mut(); + + let active_binding = client_object + .bind_property("active", &self.imp().enable_switch.get(), "state") + .bidirectional() + .sync_create() + .build(); + + let hostname_binding = client_object + .bind_property("hostname", &self.imp().hostname.get(), "text") + .transform_from(|_, v: String| { + if v == "" { Some("hostname".into()) } else { Some(v) } + }) + .bidirectional() + .sync_create() + .build(); + + let title_binding = client_object + .bind_property("hostname", self, "title") + .build(); + + let port_binding = client_object + .bind_property("port", &self.imp().port.get(), "text") + .transform_from(|_, v: String| { + if v == "" { + Some(4242) + } else { + Some(v.parse::().unwrap_or(DEFAULT_PORT) as u32) + } + }) + .bidirectional() + .build(); + + let subtitle_binding = client_object + .bind_property("port", self, "subtitle") + .sync_create() + .build(); + + + let position_binding = client_object + .bind_property("position", &self.imp().position.get(), "selected") + .transform_from(|_, v: u32| { + match v { + 1 => Some("right"), + 2 => Some("top"), + 3 => Some("bottom"), + _ => Some("left"), + } + }) + .transform_to(|_, v: String| { + match v.as_str() { + "right" => Some(1), + "top" => Some(2u32), + "bottom" => Some(3u32), + _ => Some(0u32), + } + }) + .bidirectional() + .sync_create() + .build(); + + bindings.push(active_binding); + bindings.push(hostname_binding); + bindings.push(title_binding); + bindings.push(port_binding); + bindings.push(subtitle_binding); + bindings.push(position_binding); + } + + pub fn unbind(&self) { + for binding in self.imp().bindings.borrow_mut().drain(..) { + binding.unbind(); + } + } +} diff --git a/src/frontend/gtk/client_row/imp.rs b/src/frontend/gtk/client_row/imp.rs new file mode 100644 index 0000000..4c72f85 --- /dev/null +++ b/src/frontend/gtk/client_row/imp.rs @@ -0,0 +1,76 @@ +use std::cell::RefCell; + +use glib::{Binding, subclass::InitializingObject}; +use adw::{prelude::*, ComboRow, ActionRow}; +use adw::subclass::prelude::*; +use gtk::glib::clone; +use gtk::{glib, CompositeTemplate, Switch, Button}; + +#[derive(CompositeTemplate, Default)] +#[template(resource = "/de/feschber/LanMouse/client_row.ui")] +pub struct ClientRow { + #[template_child] + pub enable_switch: TemplateChild, + #[template_child] + pub hostname: TemplateChild, + #[template_child] + pub port: TemplateChild, + #[template_child] + pub position: TemplateChild, + #[template_child] + pub delete_row: TemplateChild, + #[template_child] + pub delete_button: TemplateChild, + pub bindings: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for ClientRow { + // `NAME` needs to match `class` attribute of template + const NAME: &'static str = "ClientRow"; + type Type = super::ClientRow; + type ParentType = adw::ExpanderRow; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } +} + +impl ObjectImpl for ClientRow { + fn constructed(&self) { + self.parent_constructed(); + self.delete_button.connect_clicked(clone!(@weak self as row => move |button| { + row.handle_client_delete(button); + })); + } +} + +#[gtk::template_callbacks] +impl ClientRow { + #[template_callback] + fn handle_client_set_state(&self, state: bool, switch: &Switch) -> bool { + let idx = self.obj().index(); + switch.activate_action("win.activate-client", Some(&idx.to_variant())).unwrap(); + switch.set_state(state); + + true // dont run default handler + } + + #[template_callback] + fn handle_client_delete(&self, button: &Button) { + log::debug!("delete button pressed"); + let idx = self.obj().index(); + button.activate_action("win.delete-client", Some(&idx.to_variant())).unwrap(); + } +} + +impl WidgetImpl for ClientRow {} +impl BoxImpl for ClientRow {} +impl ListBoxRowImpl for ClientRow {} +impl PreferencesRowImpl for ClientRow {} +impl ExpanderRowImpl for ClientRow {} diff --git a/src/frontend/gtk/window.rs b/src/frontend/gtk/window.rs new file mode 100644 index 0000000..2e45aa5 --- /dev/null +++ b/src/frontend/gtk/window.rs @@ -0,0 +1,127 @@ +mod imp; + +use std::{path::{Path, PathBuf}, env, process, os::unix::net::UnixStream, io::Write}; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use gtk::{glib, gio, NoSelection}; +use glib::{clone, Object}; + +use crate::{frontend::{gtk::client_object::ClientObject, FrontendEvent}, config::DEFAULT_PORT, client::Position}; + +use super::client_row::ClientRow; + +glib::wrapper! { + pub struct Window(ObjectSubclass) + @extends adw::ApplicationWindow, gtk::Window, gtk::Widget, + @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable, + gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager; +} + +impl Window { + pub(crate) fn new(app: &adw::Application) -> Self { + Object::builder().property("application", app).build() + } + + pub fn clients(&self) -> gio::ListStore { + self.imp() + .clients + .borrow() + .clone() + .expect("Could not get clients") + } + + fn setup_clients(&self) { + let model = gio::ListStore::new::(); + self.imp().clients.replace(Some(model)); + + let selection_model = NoSelection::new(Some(self.clients())); + self.imp().client_list.bind_model( + Some(&selection_model), + clone!(@weak self as window => @default-panic, move |obj| { + let client_object = obj.downcast_ref().expect("Expected object of type `ClientObject`."); + let row = window.create_client_row(client_object); + row.upcast() + }) + ); + } + + /// workaround for a bug in libadwaita that shows an ugly line beneath + /// the last element if a placeholder is set. + /// https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6308 + pub fn set_placeholder_visible(&self, visible: bool) { + let placeholder = self.imp().client_placeholder.get(); + self.imp().client_list.set_placeholder(match visible { + true => Some(&placeholder), + false => None, + }); + } + + fn setup_icon(&self) { + self.set_icon_name(Some("mouse-icon")); + } + + fn create_client_row(&self, client_object: &ClientObject) -> ClientRow { + let row = ClientRow::new(client_object); + row.bind(client_object); + row + } + + fn new_client(&self) { + let client = ClientObject::new(String::from(""), DEFAULT_PORT as u32, false, "left".into()); + self.clients().append(&client); + } + + pub fn update_client(&self, client: &ClientObject) { + let data = client.get_data(); + let socket_path = self.imp().socket_path.borrow(); + let socket_path = socket_path.as_ref().unwrap().as_path(); + let host_name = data.hostname; + let position = match data.position.as_str() { + "left" => Position::Left, + "right" => Position::Right, + "top" => Position::Top, + "bottom" => Position::Bottom, + _ => { + log::error!("invalid position: {}", data.position); + return + } + }; + let port = data.port; + let event = if client.active() { + FrontendEvent::DelClient(host_name, port as u16) + } else { + FrontendEvent::AddClient(host_name, port as u16, position) + }; + let json = serde_json::to_string(&event).unwrap(); + let Ok(mut stream) = UnixStream::connect(socket_path) else { + log::error!("Could not connect to lan-mouse-socket @ {socket_path:?}"); + return; + }; + if let Err(e) = stream.write(json.as_bytes()) { + log::error!("error sending message: {e}"); + }; + } + + fn setup_callbacks(&self) { + self.imp() + .add_client_button + .connect_clicked(clone!(@weak self as window => move |_| { + window.new_client(); + window.set_placeholder_visible(false); + })); + } + + fn connect_stream(&self) { + let xdg_runtime_dir = match env::var("XDG_RUNTIME_DIR") { + Ok(v) => v, + Err(e) => { + log::error!("{e}"); + process::exit(1); + } + }; + let socket_path = Path::new(xdg_runtime_dir.as_str()) + .join("lan-mouse-socket.sock"); + self.imp().socket_path.borrow_mut().replace(PathBuf::from(socket_path)); + } +} diff --git a/src/frontend/gtk/window/imp.rs b/src/frontend/gtk/window/imp.rs new file mode 100644 index 0000000..fb937bd --- /dev/null +++ b/src/frontend/gtk/window/imp.rs @@ -0,0 +1,63 @@ +use std::{cell::{Cell, RefCell}, path::PathBuf}; + +use glib::subclass::InitializingObject; +use adw::{prelude::*, ActionRow}; +use adw::subclass::prelude::*; +use gtk::{glib, Button, CompositeTemplate, ListBox, gio}; + +#[derive(CompositeTemplate, Default)] +#[template(resource = "/de/feschber/LanMouse/window.ui")] +pub struct Window { + pub number: Cell, + #[template_child] + pub add_client_button: TemplateChild