Skip to content

Commit 9cedcc9

Browse files
authored
Merge pull request #77 from JRufer/claude/mint-21-keybind-detection-v01u8t
Deliver hotkeys on Mint, and stop offering gestures that cannot work
2 parents f2a8cfc + c6d94fe commit 9cedcc9

19 files changed

Lines changed: 1495 additions & 196 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/voxctrl-dbus/src/lib.rs‎

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,18 @@ mod linux {
4444

4545
pub struct DictationInterface {
4646
pub state: Arc<Mutex<AppState>>,
47-
/// Channels to send control commands back to the app coordinator
48-
pub start_tx: tokio::sync::mpsc::Sender<()>,
47+
/// Channels to send control commands back to the app coordinator. The
48+
/// start channel carries the id of the binding that asked for it, so a
49+
/// desktop shortcut can dictate into that binding's own targets. Empty
50+
/// means "whichever binding the app would use by default".
51+
pub start_tx: tokio::sync::mpsc::Sender<String>,
4952
pub stop_tx: tokio::sync::mpsc::Sender<()>,
5053
}
5154

5255
#[interface(name = "ai.voxctrl.Dictation")]
5356
impl DictationInterface {
5457
async fn start_recording(&self) -> zbus::fdo::Result<()> {
55-
let _ = self.start_tx.send(()).await;
58+
let _ = self.start_tx.send(String::new()).await;
5659
Ok(())
5760
}
5861

@@ -62,16 +65,17 @@ mod linux {
6265
}
6366

6467
async fn toggle_recording(&self) -> zbus::fdo::Result<()> {
65-
let status = {
66-
let guard = self.state.lock().await;
67-
guard.status.clone()
68-
};
69-
if status == DictationStatus::Recording {
70-
let _ = self.stop_tx.send(()).await;
71-
} else {
72-
let _ = self.start_tx.send(()).await;
73-
}
74-
Ok(())
68+
self.toggle(String::new()).await
69+
}
70+
71+
/// Toggle dictation for one specific binding.
72+
///
73+
/// This is what a Cinnamon/MATE native shortcut calls: the desktop owns
74+
/// the key grab there, so the only way VoxCtrl learns *which* of the
75+
/// user's bindings fired — and therefore which targets the text goes to
76+
/// — is for the shortcut to name it.
77+
async fn toggle_binding(&self, binding_id: String) -> zbus::fdo::Result<()> {
78+
self.toggle(binding_id).await
7579
}
7680

7781
async fn get_status(&self) -> zbus::fdo::Result<String> {
@@ -91,9 +95,25 @@ mod linux {
9195
async fn text_injected(ctx: &SignalContext<'_>, text: &str) -> zbus::Result<()>;
9296
}
9397

98+
impl DictationInterface {
99+
/// Start `binding_id` if idle, stop whatever is running if not.
100+
async fn toggle(&self, binding_id: String) -> zbus::fdo::Result<()> {
101+
let status = {
102+
let guard = self.state.lock().await;
103+
guard.status.clone()
104+
};
105+
if status == DictationStatus::Recording {
106+
let _ = self.stop_tx.send(()).await;
107+
} else {
108+
let _ = self.start_tx.send(binding_id).await;
109+
}
110+
Ok(())
111+
}
112+
}
113+
94114
pub async fn start_service(
95115
state: Arc<Mutex<AppState>>,
96-
start_tx: tokio::sync::mpsc::Sender<()>,
116+
start_tx: tokio::sync::mpsc::Sender<String>,
97117
stop_tx: tokio::sync::mpsc::Sender<()>,
98118
) -> Result<Connection> {
99119
let iface = DictationInterface {
@@ -141,7 +161,7 @@ pub use linux::{emit_status_changed, emit_text_injected, start_service};
141161
#[cfg(not(target_os = "linux"))]
142162
pub async fn start_service(
143163
_state: Arc<Mutex<AppState>>,
144-
_start_tx: tokio::sync::mpsc::Sender<()>,
164+
_start_tx: tokio::sync::mpsc::Sender<String>,
145165
_stop_tx: tokio::sync::mpsc::Sender<()>,
146166
) -> Result<()> {
147167
tracing::warn!("DBus service not available on this platform");

‎crates/voxctrl-hotkeys/Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ crossbeam-channel = { workspace = true }
1616

1717
[target.'cfg(target_os = "linux")'.dependencies]
1818
evdev = "0.12"
19+
# X11 raw key events (XInput2): global shortcuts on the X11 desktops that serve
20+
# no portal, with no permission setup. Pure Rust, so the AppImage needs no
21+
# libX11 from the host.
22+
x11rb = { version = "0.13", features = ["xinput"] }
1923
# The XDG GlobalShortcuts portal: the compositor owns the key grab, so VoxCtrl
2024
# receives its own shortcuts and never reads a keyboard device.
2125
ashpd = { workspace = true }

‎crates/voxctrl-hotkeys/src/health.rs‎

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ use std::sync::{
1414
Mutex,
1515
};
1616

17+
use voxctrl_routing::GestureType;
18+
1719
/// Which mechanism is delivering shortcuts.
1820
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
1921
#[serde(rename_all = "snake_case")]
@@ -23,6 +25,13 @@ pub enum Backend {
2325
/// `org.freedesktop.portal.GlobalShortcuts` — the compositor owns the key
2426
/// grab and VoxCtrl reads no input devices.
2527
Portal,
28+
/// X11 raw key events (XInput2). Needs no permissions, and sees every
29+
/// press and release, so every gesture style works.
30+
X11,
31+
/// A native Cinnamon/MATE custom shortcut that pokes VoxCtrl over D-Bus.
32+
/// The desktop runs a command on key-press and never reports the release,
33+
/// so this can only ever serve `toggle`.
34+
MintDbus,
2635
/// Reading `/dev/input/event*` directly. Only reachable when the user has
2736
/// already granted this process access to input devices.
2837
Evdev,
@@ -32,6 +41,45 @@ pub enum Backend {
3241
None,
3342
}
3443

44+
/// Gesture styles a backend that only learns about key *presses* can serve.
45+
///
46+
/// A desktop that runs a command on key-down tells VoxCtrl nothing on key-up,
47+
/// so there is no release to end a hold with and no way to tell a tap from a
48+
/// hold. Only `toggle` survives that.
49+
const PRESS_ONLY_GESTURES: &[GestureType] = &[GestureType::Toggle];
50+
51+
/// Every gesture style, for the backends that see raw presses and releases.
52+
const ALL_GESTURES: &[GestureType] = &[
53+
GestureType::Hold,
54+
GestureType::Toggle,
55+
GestureType::DoubleTap,
56+
GestureType::DoubleTapHold,
57+
];
58+
59+
impl Backend {
60+
/// Gesture styles this backend can actually deliver.
61+
///
62+
/// The settings UI offers exactly these, so a user is never given a choice
63+
/// that silently does nothing on their desktop.
64+
pub fn gestures(self) -> &'static [GestureType] {
65+
match self {
66+
Self::MintDbus => PRESS_ONLY_GESTURES,
67+
// `Starting` and `None` are not verdicts about what this machine can
68+
// do — one has not finished deciding and the other is broken in a
69+
// way the setup window explains properly. Narrowing the choices on
70+
// either would hide gestures that do work here.
71+
Self::Portal | Self::X11 | Self::Evdev | Self::WindowsHook | Self::Starting
72+
| Self::None => ALL_GESTURES,
73+
}
74+
}
75+
76+
/// True when VoxCtrl watches the key stream itself, so a trigger need not be
77+
/// expressible as a desktop accelerator — bare modifiers included.
78+
pub fn sees_raw_keys(self) -> bool {
79+
matches!(self, Self::X11 | Self::Evdev | Self::WindowsHook)
80+
}
81+
}
82+
3583
/// One shortcut as the compositor actually bound it.
3684
#[derive(Debug, Clone, serde::Serialize)]
3785
pub struct BoundShortcut {
@@ -56,6 +104,10 @@ pub struct ListenerHealth {
56104
backend: Mutex<Option<Backend>>,
57105
/// Why the portal could not be used, if it could not.
58106
portal_error: Mutex<Option<String>>,
107+
/// Why the X11 backend could not be used, if it could not. Separate from
108+
/// the portal's reason: "no shortcuts portal" and "not an X11 session" are
109+
/// different facts and a user on Wayland-Cinnamon needs both.
110+
x11_error: Mutex<Option<String>>,
59111
/// The portal is present and answered, but refused the session. A different
60112
/// problem from "this desktop has no portal", and it needs different advice.
61113
portal_refused: AtomicBool,
@@ -110,6 +162,17 @@ impl ListenerHealth {
110162
self.portal_refused.load(Ordering::Relaxed)
111163
}
112164

165+
/// Why the X11 backend was not used. `None` means it was, or was never
166+
/// reached because something better answered first.
167+
pub fn x11_error(&self) -> Option<String> {
168+
self.x11_error.lock().ok().and_then(|e| e.clone())
169+
}
170+
171+
/// Gesture styles the running backend can deliver.
172+
pub fn gestures(&self) -> &'static [GestureType] {
173+
self.backend().gestures()
174+
}
175+
113176
pub fn bound_shortcuts(&self) -> Vec<BoundShortcut> {
114177
self.bound_shortcuts.lock().map(|s| s.clone()).unwrap_or_default()
115178
}
@@ -120,7 +183,7 @@ impl ListenerHealth {
120183
return true;
121184
}
122185
match self.backend() {
123-
Backend::Portal | Backend::WindowsHook => true,
186+
Backend::Portal | Backend::WindowsHook | Backend::X11 | Backend::MintDbus => true,
124187
Backend::Evdev => self.keyboards_open() > 0,
125188
// Still deciding which backend to use — don't report a problem yet.
126189
Backend::Starting => true,
@@ -137,7 +200,10 @@ impl ListenerHealth {
137200

138201
/// Shortcuts are working without VoxCtrl having any access to input devices.
139202
pub fn is_private(&self) -> bool {
140-
matches!(self.backend(), Backend::Portal | Backend::WindowsHook)
203+
matches!(
204+
self.backend(),
205+
Backend::Portal | Backend::WindowsHook | Backend::MintDbus
206+
)
141207
}
142208

143209
pub fn set_supported(&self, supported: bool) {
@@ -162,6 +228,12 @@ impl ListenerHealth {
162228
}
163229
}
164230

231+
pub fn set_x11_error(&self, error: String) {
232+
if let Ok(mut e) = self.x11_error.lock() {
233+
*e = Some(error);
234+
}
235+
}
236+
165237
pub fn set_portal_refused(&self, refused: bool) {
166238
self.portal_refused.store(refused, Ordering::Relaxed);
167239
}
@@ -302,4 +374,57 @@ mod tests {
302374
assert!(!h.is_active());
303375
assert!(h.bound_shortcuts().is_empty());
304376
}
377+
378+
#[test]
379+
fn the_x11_backend_is_active_and_serves_every_gesture() {
380+
let h = ListenerHealth::default();
381+
h.set_supported(true);
382+
h.set_portal_error("no such interface".to_string());
383+
h.set_backend(Backend::X11);
384+
385+
assert!(h.is_active());
386+
assert!(!h.is_private(), "raw X11 key events are every keystroke");
387+
assert!(
388+
!h.permission_blocked(),
389+
"X11 raw events need no device permission, so nothing is blocked"
390+
);
391+
assert_eq!(h.gestures().len(), 4);
392+
assert!(h.backend().sees_raw_keys());
393+
}
394+
395+
#[test]
396+
fn a_press_only_backend_advertises_toggle_and_nothing_else() {
397+
// A Cinnamon custom shortcut runs a command on key-down and reports no
398+
// release: a hold has no end and a tap cannot be told from a hold. The
399+
// settings UI offers exactly what this returns, so getting it wrong
400+
// means offering a gesture that silently does nothing.
401+
let h = ListenerHealth::default();
402+
h.set_supported(true);
403+
h.set_backend(Backend::MintDbus);
404+
405+
assert_eq!(h.gestures(), &[GestureType::Toggle]);
406+
assert!(h.is_active());
407+
assert!(h.is_private(), "the desktop holds the grab, VoxCtrl reads nothing");
408+
assert!(
409+
!h.backend().sees_raw_keys(),
410+
"a bare modifier cannot be registered as a desktop accelerator"
411+
);
412+
}
413+
414+
#[test]
415+
fn a_backend_that_watches_keys_itself_never_hides_a_gesture() {
416+
// Hiding a style on these would take away something that works.
417+
for backend in [Backend::X11, Backend::Evdev, Backend::WindowsHook, Backend::Portal] {
418+
assert_eq!(backend.gestures().len(), 4, "{backend:?} lost a gesture");
419+
}
420+
}
421+
422+
#[test]
423+
fn an_undecided_or_broken_backend_still_offers_every_gesture() {
424+
// Neither is a verdict about what this machine can do, and an empty
425+
// gesture list would leave the settings UI with nothing to show.
426+
for backend in [Backend::Starting, Backend::None] {
427+
assert_eq!(backend.gestures().len(), 4, "{backend:?} narrowed the choices");
428+
}
429+
}
305430
}

‎crates/voxctrl-hotkeys/src/lib.rs‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ pub mod trigger;
77
mod linux;
88
#[cfg(target_os = "linux")]
99
pub mod portal;
10+
#[cfg(target_os = "linux")]
11+
mod x11;
1012
#[cfg(target_os = "windows")]
1113
mod windows;
1214

@@ -34,11 +36,12 @@ pub fn channel() -> (GestureSender, GestureReceiver) {
3436
/// the returned handle.
3537
///
3638
/// On Linux this prefers the XDG desktop portal, where the compositor owns the
37-
/// key grab and VoxCtrl is told nothing except that its own shortcut fired. The
38-
/// evdev fallback is only used when the portal is unavailable *and* the user
39-
/// has already given this process access to input devices — VoxCtrl never asks
40-
/// for that access, because granting it lets every program running as the user
41-
/// read the keyboard, not just this one.
39+
/// key grab and VoxCtrl is told nothing except that its own shortcut fired.
40+
/// Failing that it reads X11 raw key events, which any X client may ask for and
41+
/// which need no setup. The evdev fallback is only used when neither is
42+
/// available *and* the user has already given this process access to input
43+
/// devices — VoxCtrl never asks for that access, because granting it lets every
44+
/// program running as the user read the keyboard, not just this one.
4245
///
4346
/// `ListenerHandle::health` reports which of those happened, so the app can say
4447
/// so at launch instead of failing silently.

0 commit comments

Comments
 (0)