diff --git a/README.md b/README.md index 5425a54..669a1de 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ from a real Logitech device. One executable, nothing to install: the signed driver files are embedded in it and deployed on first start. +This is really useful when anti-cheat software decides to block your laptop's touchpad for some reason lol. + ## Requirements - Windows 10 or 11 @@ -28,15 +30,38 @@ Run `InputRedirect.exe` as administrator and choose an option: | `2` | Redirect keyboard | | `3` | Stop everything | | `4` | Re-create the virtual devices | -| `D` | Remove the driver | +| `R` | Remove the driver | | `Q` | Quit | `1` and `2` are switches - press again to turn them off. Closing the window turns the redirect off. -After `D` the machine has to restart before the driver is fully gone; the tool +After `R` the machine has to restart before the driver is fully gone; the tool offers it and reminds you on the next start if you skipped it. +## Command line + +You do not have to open the menu. Started from a terminal, InputRedirect can +switch a redirect on straight away - handy for shortcuts, scheduled tasks and +scripts: + +``` +Usage: + InputRedirect [options] + +Options: + -m, --mouse redirect the mouse buttons + -k, --keyboard redirect the keyboard + -r, --remove-driver remove the driver + -h, --help show this help +``` + +With `--mouse`, `--keyboard`, or both (short `-m` / `-k`), the tool starts as +usual, switches on what you asked for and prints one line saying what is +running - no menu. Closing the window or pressing Ctrl+C switches everything +back, exactly as the menu does. `--remove-driver` (short `-r`) runs the same +removal as the `R` key. + ## Building ```sh diff --git a/src/app/cli.rs b/src/app/cli.rs new file mode 100644 index 0000000..8fdc8e6 --- /dev/null +++ b/src/app/cli.rs @@ -0,0 +1,249 @@ +//! Reading what the command line asks for. +//! +//! With no arguments `InputRedirect` shows its menu as it always has. Given +//! `--mouse` / `-m`, `--keyboard` / `-k`, or both, it switches those redirects +//! on at once and stays out of the way: no menu is drawn, one green line says +//! what is running, and closing the window or pressing Ctrl+C switches +//! everything back, exactly as the menu does. `--remove-driver` / `-r` takes +//! the driver back out - the same flow the menu's `R` runs - and `--help` / +//! `-h` lists the flags and exits. + +use crate::error::{Error, Result}; + +const MOUSE_FLAGS: [&str; 2] = ["--mouse", "-m"]; +const KEYBOARD_FLAGS: [&str; 2] = ["--keyboard", "-k"]; +const REMOVE_FLAGS: [&str; 2] = ["--remove-driver", "-r"]; +const HELP_FLAGS: [&str; 2] = ["--help", "-h"]; + +/// The usage text `--help` prints. It is shown before the console is prepared +/// for drawing, so it carries no colour or glyphs: just the flags and what +/// each one does. +pub const HELP: &str = "\ +InputRedirect - send your typing and your clicks through the real signed driver. + +Usage: + InputRedirect [options] + +Options: + -m, --mouse redirect the mouse buttons + -k, --keyboard redirect the keyboard + -r, --remove-driver remove the driver + -h, --help show this help + +With no options the interactive menu opens. Closing the window or pressing +Ctrl+C switches everything back and ends the program."; + +/// What the command line asked the program to do. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Request { + /// No flags were given: open the interactive menu. + Menu, + /// Switch on exactly these redirects and skip the menu. + Redirect(Requested), + /// Take the driver back out of Windows, then end. + RemoveDriver, + /// Print the usage text and leave, without touching anything else. + Help, +} + +/// Which redirects the command line switched on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Requested { + pub mouse: bool, + pub keyboard: bool, +} + +impl Requested { + /// The single line the flag mode prints in place of the menu, naming + /// exactly which redirects are running. + #[must_use] + pub fn active_message(self) -> &'static str { + match (self.mouse, self.keyboard) { + (true, true) => "Mouse and keyboard redirect active", + (true, false) => "Mouse redirect active", + (false, true) => "Keyboard redirect active", + // `parse` returns `Request::Menu` when nothing was asked for, so + // this arm is never reached; it keeps the match total, no panic. + (false, false) => "Nothing is redirected", + } + } +} + +/// Reads what the arguments ask for, with the program name already removed. +/// +/// An unrecognised argument is refused rather than ignored: a misspelt +/// `--mouse` that quietly opened the menu instead would look like the flag +/// does nothing. When more than one thing is asked for, the request that does +/// the least damage if the rest were a mistake wins: `--help` only prints, so +/// it comes first, and `--remove-driver` ends the program, so it comes before +/// the redirects it would otherwise switch on only to tear straight down. +pub fn parse(arguments: I) -> Result +where + I: IntoIterator, +{ + let mut requested = Requested::default(); + let mut help = false; + let mut remove = false; + + for argument in arguments { + let argument = argument.as_str(); + + if HELP_FLAGS.contains(&argument) { + help = true; + } else if REMOVE_FLAGS.contains(&argument) { + remove = true; + } else if MOUSE_FLAGS.contains(&argument) { + requested.mouse = true; + } else if KEYBOARD_FLAGS.contains(&argument) { + requested.keyboard = true; + } else { + return Err(Error::Usage(format!("unknown argument {argument:?}"))); + } + } + + if help { + Ok(Request::Help) + } else if remove { + Ok(Request::RemoveDriver) + } else if requested.mouse || requested.keyboard { + Ok(Request::Redirect(requested)) + } else { + Ok(Request::Menu) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_args(arguments: &[&str]) -> Result { + parse(arguments.iter().copied().map(String::from)) + } + + fn requested(mouse: bool, keyboard: bool) -> Requested { + Requested { mouse, keyboard } + } + + fn redirect(mouse: bool, keyboard: bool) -> Request { + Request::Redirect(requested(mouse, keyboard)) + } + + #[test] + fn no_arguments_means_the_menu() { + assert_eq!(parse_args(&[]).unwrap(), Request::Menu); + } + + #[test] + fn the_mouse_flag_switches_the_mouse_on_and_leaves_the_keyboard_alone() { + let request = parse_args(&["--mouse"]).unwrap(); + + assert_eq!(request, redirect(true, false)); + } + + #[test] + fn the_keyboard_flag_switches_the_keyboard_on_and_leaves_the_mouse_alone() { + let request = parse_args(&["--keyboard"]).unwrap(); + + assert_eq!(request, redirect(false, true)); + } + + #[test] + fn the_short_mouse_flag_means_the_same_as_the_long_one() { + assert_eq!(parse_args(&["-m"]).unwrap(), redirect(true, false)); + } + + #[test] + fn the_short_keyboard_flag_means_the_same_as_the_long_one() { + assert_eq!(parse_args(&["-k"]).unwrap(), redirect(false, true)); + } + + #[test] + fn the_two_flags_together_switch_both_on() { + let request = parse_args(&["--mouse", "--keyboard"]).unwrap(); + + assert_eq!(request, redirect(true, true)); + } + + #[test] + fn the_short_flags_can_be_combined_too() { + let request = parse_args(&["-m", "-k"]).unwrap(); + + assert_eq!(request, redirect(true, true)); + } + + #[test] + fn the_order_of_the_flags_does_not_matter() { + let one_way = parse_args(&["--mouse", "--keyboard"]).unwrap(); + let other_way = parse_args(&["--keyboard", "--mouse"]).unwrap(); + + assert_eq!(one_way, other_way); + } + + #[test] + fn a_flag_given_twice_is_still_just_on() { + let request = parse_args(&["--mouse", "-m"]).unwrap(); + + assert_eq!(request, redirect(true, false)); + } + + #[test] + fn either_spelling_of_help_asks_for_help() { + assert_eq!(parse_args(&["--help"]).unwrap(), Request::Help); + assert_eq!(parse_args(&["-h"]).unwrap(), Request::Help); + } + + #[test] + fn either_spelling_of_remove_asks_to_remove_the_driver() { + let long = parse_args(&["--remove-driver"]).unwrap(); + let short = parse_args(&["-r"]).unwrap(); + + assert_eq!(long, Request::RemoveDriver); + assert_eq!(short, Request::RemoveDriver); + } + + #[test] + fn help_wins_over_a_redirect_alongside_it() { + let after_mouse = parse_args(&["--mouse", "--help"]).unwrap(); + let before_keyboard = parse_args(&["-h", "--keyboard"]).unwrap(); + + assert_eq!(after_mouse, Request::Help); + assert_eq!(before_keyboard, Request::Help); + } + + #[test] + fn remove_wins_over_a_redirect_alongside_it() { + let with_mouse = parse_args(&["--mouse", "-r"]).unwrap(); + let with_keyboard = parse_args(&["-r", "--keyboard"]).unwrap(); + + assert_eq!(with_mouse, Request::RemoveDriver); + assert_eq!(with_keyboard, Request::RemoveDriver); + } + + #[test] + fn help_wins_over_removing_the_driver_too() { + let request = parse_args(&["--remove-driver", "--help"]).unwrap(); + + assert_eq!(request, Request::Help); + } + + #[test] + fn an_unknown_argument_is_refused() { + assert!(parse_args(&["--trackball"]).is_err()); + } + + #[test] + fn an_unknown_argument_after_a_good_one_is_still_refused() { + assert!(parse_args(&["--mouse", "--trackball"]).is_err()); + } + + #[test] + fn the_active_line_names_exactly_what_is_running() { + let mouse = requested(true, false); + let keyboard = requested(false, true); + let both = requested(true, true); + + assert_eq!(mouse.active_message(), "Mouse redirect active"); + assert_eq!(keyboard.active_message(), "Keyboard redirect active"); + assert_eq!(both.active_message(), "Mouse and keyboard redirect active"); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 3b56cac..6cbfc54 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,7 @@ //! The program as the user experiences it: a screen, a menu and a loop. mod actions; +mod cli; mod exit; mod instance; @@ -8,6 +9,8 @@ use std::sync::Arc; use std::thread::sleep; use std::time::Duration; +use windows_registry::LOCAL_MACHINE; + use crate::driver::{self, Driver, Step}; use crate::error::{Error, Result}; use crate::redirect::Engine; @@ -16,6 +19,9 @@ use crate::ui::{self, Command, Dashboard, MenuKey, Screen, Tone}; /// Long enough for the setup lines to be read before the screen takes over. const SETTLE: Duration = Duration::from_millis(600); +/// The service whose presence means the driver package is installed. +const DRIVER_SERVICE_KEY: &str = r"SYSTEM\CurrentControlSet\Services\logi_joy_bus_enum"; + /// Why the program stopped. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Outcome { @@ -40,11 +46,39 @@ impl App { } /// Brings the driver up and then serves the menu until the user leaves. + /// + /// `--help` / `-h` prints the list of flags and exits before anything is + /// claimed. `--remove-driver` / `-r` removes an installed driver without + /// installing one when there is nothing to remove. `--mouse` / `-m` or + /// `--keyboard` / `-k` switch those redirects on and wait without a menu + /// instead; see `run_headless`. pub fn run(mut self) -> Result { + // Read before anything is claimed or installed, so a misspelt flag + // fails on the spot and `--help` answers without a driver. + let request = cli::parse(std::env::args().skip(1))?; + + // Help touches nothing else: it is printed and the program leaves. + if request == cli::Request::Help { + show_help(); + return Ok(Outcome::Finished); + } + // Held for the whole session: dropping it is what lets the next copy // of the program start. let _only_copy = instance::SingleInstance::claim().ok_or(Error::AlreadyRunning)?; + let restart_pending = driver::is_restart_pending(); + + // Driver::connect installs a missing package. A removal request must + // never create the very thing it was asked to take away, so answer the + // no-op before preparing the console or calling start. A pending + // restart is different: removal already happened and still has to be + // finished, so the existing restart screen below takes precedence. + if request == cli::Request::RemoveDriver && !restart_pending && !driver_is_installed() { + println!("InputRedirect: no driver is installed, so there is nothing to remove."); + return Ok(Outcome::Finished); + } + ui::claim_console(); // From here on the window can be closed at any moment, and the @@ -56,7 +90,7 @@ impl App { // non-volatile would leave it set past the reboot that should have // cleared it - and offering a restart that cannot help, every start // from now on, is the one outcome reboot.rs set out to avoid. - if driver::is_restart_pending() { + if restart_pending { if driver::is_running() { driver::clear_restart_pending(); } else { @@ -66,6 +100,26 @@ impl App { self.start()?; + // Asked to remove the driver from the command line: run the very flow + // the menu's R does - it confirms, removes, and offers the restart - + // then ends, rather than dropping into the menu afterwards. + if request == cli::Request::RemoveDriver { + if let Some(outcome) = self.remove_driver() { + return Ok(outcome); + } + + // In menu mode `say` is rendered by the next redraw. There is no + // next redraw here, so report the declined operation directly. + self.screen.report(Tone::Muted, "Nothing was removed"); + return Ok(Outcome::Finished); + } + + // The command line, not the menu, is driving: switch on what it asked + // for and wait it out. + if let cli::Request::Redirect(requested) = request { + return Ok(self.run_headless(requested)); + } + loop { self.redraw(); @@ -75,7 +129,7 @@ impl App { MenuKey::Tick => {} MenuKey::Unknown => self .screen - .say(Tone::Warning, "Unknown key. Use 1, 2, 3, 4, D or Q."), + .say(Tone::Warning, "Unknown key. Use 1, 2, 3, 4, R or Q."), MenuKey::Chosen(command) => { if let Some(outcome) = self.carry_out(command) { return Ok(outcome); @@ -99,6 +153,35 @@ impl App { None } + /// Switches on the redirects named on the command line, says which ones in + /// one green line, and then waits without drawing a menu or reading a key. + /// + /// The only ways out are the window closing and Ctrl+C. Windows delivers + /// both to the console control handler set up in `exit`, which switches + /// the redirects back and ends the process - the same path closing the + /// menu takes, so there is nothing to tear down here. + fn run_headless(&self, requested: cli::Requested) -> Outcome { + if let Some(engine) = self.engine.as_ref() { + if requested.mouse { + engine.set_mouse(true); + } + if requested.keyboard { + engine.set_keyboard(true); + } + } + + // A blank line sets this apart from the setup lines that scrolled + // past, so the one line worth reading stands on its own. + self.screen.blank(); + self.screen.report(Tone::Done, requested.active_message()); + + // Park rather than spin: there is nothing to do until the control + // handler ends the process, and a spurious wake just parks again. + loop { + std::thread::park(); + } + } + fn start(&mut self) -> Result<()> { self.screen.banner(); @@ -187,3 +270,19 @@ impl Drop for App { self.driver = None; } } + +/// Whether the driver package exists without starting or installing it. +fn driver_is_installed() -> bool { + LOCAL_MACHINE + .options() + .read() + .open(DRIVER_SERVICE_KEY) + .is_ok() +} + +/// Prints the command-line help and returns. Help is shown before the console +/// is prepared for drawing, so it is plain text, and it does not wait for a +/// key: a command-line tool that was asked for its usage prints it and exits. +fn show_help() { + println!("{}", cli::HELP); +} diff --git a/src/error.rs b/src/error.rs index cc382e2..e3e280b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -49,4 +49,10 @@ pub enum Error { #[error("the input hooks could not be installed: {0}")] Hook(String), + + /// An argument on the command line was not understood. Kept apart from the + /// driver failures because nothing was attempted: the program refuses the + /// line before it touches the console or the driver. + #[error("{0}")] + Usage(String), } diff --git a/src/main.rs b/src/main.rs index 9cb4548..fb7ce61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ const EXIT_NOT_ELEVATED: u8 = 1; const EXIT_FAILED: u8 = 2; const EXIT_RESTART_REQUIRED: u8 = 3; const EXIT_ALREADY_RUNNING: u8 = 4; +const EXIT_USAGE: u8 = 5; fn main() -> ExitCode { match App::new().run() { @@ -43,6 +44,11 @@ fn give_up(error: &Error) -> ExitCode { eprintln!("Switch to that window, or close it before starting again."); EXIT_ALREADY_RUNNING } + Error::Usage(message) => { + eprintln!("InputRedirect: {message}."); + eprintln!("Run InputRedirect --help for usage."); + EXIT_USAGE + } Error::RestartRequired(reason) => { eprintln!("InputRedirect cannot start yet: {reason}."); eprintln!("Restart the computer, then start InputRedirect again."); diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index 37eba6f..47f7ebe 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -123,7 +123,7 @@ fn command_for(scan_code: u16) -> Option { 0x03 | 0x50 => Command::ToggleKeyboard, // 2 0x04 | 0x51 => Command::StopEverything, // 3 0x05 | 0x4B => Command::RecreateDevices, // 4 - 0x20 => Command::RemoveDriver, // d + 0x13 => Command::RemoveDriver, // r 0x10 | 0x01 => Command::Quit, // q, escape _ => return None, }; @@ -218,7 +218,7 @@ mod tests { assert_eq!(command_for(0x03), Some(Command::ToggleKeyboard)); assert_eq!(command_for(0x04), Some(Command::StopEverything)); assert_eq!(command_for(0x05), Some(Command::RecreateDevices)); - assert_eq!(command_for(0x20), Some(Command::RemoveDriver)); + assert_eq!(command_for(0x13), Some(Command::RemoveDriver)); assert_eq!(command_for(0x10), Some(Command::Quit)); } diff --git a/src/ui/screen.rs b/src/ui/screen.rs index b9b1286..02ee07d 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -209,7 +209,7 @@ fn menu(dashboard: Dashboard) -> String { )); text.push_str(&theme::action('3', "stop everything")); text.push_str(&theme::action('4', "re-create virtual devices")); - text.push_str(&theme::action('D', "remove driver")); + text.push_str(&theme::action('R', "remove driver")); text.push_str(&theme::action('Q', "quit")); text.push_str(theme::BLANK); @@ -275,7 +275,7 @@ mod tests { ("[2]", "redirect keyboard"), ("[3]", "stop everything"), ("[4]", "re-create virtual devices"), - ("[D]", "remove driver"), + ("[R]", "remove driver"), ("[Q]", "quit"), ] { assert!(line_with(&frame, key).contains(label), "{key}");