hyprcorrect_platform/linux/hotkey.rs
1//! Global trigger via a Hyprland inline keybind + signals.
2//!
3//! At startup the daemon adds an inline Hyprland keybind whose `exec`
4//! reads the daemon's PID file and `kill -USR1`s that PID
5//! specifically. Hyprland intercepts the chord — terminals and other
6//! focused apps never see it — and the daemon catches the signal as
7//! [`HotkeyEvent::Trigger`].
8//!
9//! The PID-file-based targeting is deliberate: `pkill -x hyprcorrect`
10//! would match the prefs subprocess too (it shares the daemon's
11//! binary name and therefore its `/proc/PID/comm`) and silently
12//! terminate the prefs window when the user pressed the chord. The
13//! file is written by the daemon at startup and removed on shutdown
14//! — see [`hyprcorrect_core::runtime`].
15//!
16//! `SIGHUP` arrives as [`HotkeyEvent::Reload`] and is the prefs
17//! window's signal to the running daemon that the config has
18//! changed.
19//!
20//! Hyprland-specific. The cross-compositor route is the
21//! `GlobalShortcuts` portal (DESIGN.md); that has its own auto-bind
22//! limitation on `xdg-desktop-portal-hyprland` today, so we'll revisit
23//! it together with M3's portable backends.
24
25use std::process::Command;
26use std::sync::mpsc::{self, Receiver, Sender};
27
28use hyprcorrect_core::{Chord, runtime};
29use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM, SIGUSR1, SIGUSR2};
30use signal_hook::iterator::Signals;
31
32/// A daemon-level event driven by the operating-system signal stream.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HotkeyEvent {
35 /// `SIGUSR1` — the trigger chord fired. Run `fix-last-word`.
36 Trigger,
37 /// `SIGHUP` — the user saved the config. Reload it and rebind the
38 /// trigger if the chord changed.
39 Reload,
40 /// `SIGUSR2` — the prefs window entered chord-capture mode and
41 /// wants Hyprland to stop intercepting the chord so the prefs
42 /// window can see the key press. The daemon temporarily
43 /// uninstalls its bind; `Reload` reinstalls it after capture.
44 Release,
45 /// `SIGTERM` / `SIGINT` — the daemon should shut down cleanly so
46 /// the Hyprland bind and PID file are removed.
47 Shutdown,
48}
49
50/// An error registering the Hyprland keybind or signal handler.
51#[derive(Debug, thiserror::Error)]
52pub enum HotkeyError {
53 /// `hyprctl` could not bind the trigger chord.
54 #[error("hyprctl could not bind the trigger chord: {0}")]
55 Hyprctl(String),
56 /// `hyprctl` could not unbind the trigger chord.
57 #[error("hyprctl could not unbind the trigger chord: {0}")]
58 HyprctlUnbind(String),
59 /// Could not install the signal handler.
60 #[error("could not install signal handler: {0}")]
61 Signal(String),
62 /// Could not spawn the signal-listener thread.
63 #[error("could not spawn the signal-listener thread: {0}")]
64 Thread(String),
65}
66
67/// Install the Hyprland inline keybind for the given chord, tagged
68/// with an `action` label ("word", "sentence", "review", …).
69///
70/// The bind's `exec` writes the action label to the runtime action
71/// file and then `kill -USR1`s the daemon — the daemon reads the
72/// label in its trigger handler to pick which fix to run. Hyprland's
73/// `exec` already wraps the command in `sh -c`, so shell
74/// substitution (`>`, `&&`, `$(...)`) works without extra quoting.
75///
76/// Idempotent: first runs `hyprctl keyword unbind` for the same
77/// chord so a previous (uncleanly-shut-down) daemon's bind doesn't
78/// leave duplicates behind.
79///
80/// # Errors
81///
82/// See [`HotkeyError`].
83pub fn install_bind(chord: &Chord, action: &str) -> Result<(), HotkeyError> {
84 let _ = uninstall_bind(chord);
85 let pid_path = runtime::pid_path();
86 let action_path = runtime::action_path();
87 let bind_value = format!(
88 "{mods}, {key}, exec, printf %s {action} > {action_path} && kill -USR1 $(cat {pid_path})",
89 mods = chord.hyprland_modifiers(),
90 key = chord.hyprland_key(),
91 action_path = action_path.display(),
92 pid_path = pid_path.display(),
93 );
94 let output = Command::new("hyprctl")
95 .args(["keyword", "bind", &bind_value])
96 .output()
97 .map_err(|e| HotkeyError::Hyprctl(format!("invoke hyprctl: {e}")))?;
98 if !output.status.success() {
99 let stderr = String::from_utf8_lossy(&output.stderr);
100 let stdout = String::from_utf8_lossy(&output.stdout);
101 return Err(HotkeyError::Hyprctl(format!(
102 "hyprctl exited non-zero — stdout: {stdout} stderr: {stderr}"
103 )));
104 }
105 Ok(())
106}
107
108/// Remove the Hyprland inline keybind for the given chord. Calling
109/// this for an unbound chord is silently fine.
110///
111/// # Errors
112///
113/// Returns [`HotkeyError::HyprctlUnbind`] only on `hyprctl` invocation
114/// failure (not on "nothing to unbind").
115pub fn uninstall_bind(chord: &Chord) -> Result<(), HotkeyError> {
116 let unbind_value = format!(
117 "{mods}, {key}",
118 mods = chord.hyprland_modifiers(),
119 key = chord.hyprland_key(),
120 );
121 let output = Command::new("hyprctl")
122 .args(["keyword", "unbind", &unbind_value])
123 .output()
124 .map_err(|e| HotkeyError::HyprctlUnbind(format!("invoke hyprctl: {e}")))?;
125 if !output.status.success() {
126 let stderr = String::from_utf8_lossy(&output.stderr);
127 return Err(HotkeyError::HyprctlUnbind(stderr.into_owned()));
128 }
129 Ok(())
130}
131
132/// Start the signal listener.
133///
134/// Installs handlers for `SIGUSR1` (trigger), `SIGHUP` (reload), and
135/// `SIGTERM` / `SIGINT` (shutdown) and returns a receiver of
136/// [`HotkeyEvent`]s. The shutdown signals let the daemon clean up its
137/// Hyprland bind and PID file even when killed via `pkill` or Ctrl-C.
138///
139/// # Errors
140///
141/// See [`HotkeyError`].
142pub fn signal_channel() -> Result<Receiver<HotkeyEvent>, HotkeyError> {
143 let mut signals = Signals::new([SIGUSR1, SIGUSR2, SIGHUP, SIGTERM, SIGINT])
144 .map_err(|e| HotkeyError::Signal(e.to_string()))?;
145 let (tx, rx) = mpsc::channel();
146 std::thread::Builder::new()
147 .name("hyprcorrect-signal".into())
148 .spawn(move || forward_signals(&mut signals, &tx))
149 .map_err(|e| HotkeyError::Thread(e.to_string()))?;
150 Ok(rx)
151}
152
153fn forward_signals(signals: &mut Signals, tx: &Sender<HotkeyEvent>) {
154 for signal in signals.forever() {
155 let event = match signal {
156 SIGUSR1 => HotkeyEvent::Trigger,
157 SIGUSR2 => HotkeyEvent::Release,
158 SIGHUP => HotkeyEvent::Reload,
159 SIGTERM | SIGINT => HotkeyEvent::Shutdown,
160 _ => continue,
161 };
162 if tx.send(event).is_err() {
163 break; // receiver dropped — daemon is shutting down
164 }
165 }
166}