fix(snap): add hardware-observe so hotkeys can read input devices (#858)

Snap confinement denies /proc/bus/input/devices unless hardware-observe
is connected. Declare the plug, tell users to connect it next to
raw-input, and update the in-app hints.

Fixes #857
This commit is contained in:
Jatin K Malik
2026-09-23 00:09:54 -07:00
committed by GitHub
parent acd90fdd05
commit 5b4e2c3572
8 changed files with 69 additions and 6 deletions
+1
View File
@@ -181,6 +181,7 @@ Listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux). `stable` is s
sudo snap install vocalinux --edge
sudo snap connect vocalinux:audio-record # if mic is not auto-connected
sudo snap connect vocalinux:raw-input # global keyboard shortcuts (evdev)
sudo snap connect vocalinux:hardware-observe # list keyboards (/proc/bus/input/devices)
sudo snap connect vocalinux:uinput # native Wayland typing (ydotool)
```
+2
View File
@@ -130,6 +130,7 @@ Store install, once Canonical lists the revision:
sudo snap install vocalinux --edge
sudo snap connect vocalinux:audio-record # if mic is not auto-connected
sudo snap connect vocalinux:raw-input # global keyboard shortcuts (evdev)
sudo snap connect vocalinux:hardware-observe # list keyboards (/proc/bus/input/devices)
sudo snap connect vocalinux:uinput # native Wayland typing (ydotool)
```
@@ -141,6 +142,7 @@ Sideload the GitHub `.snap` (amd64) while the Store is waiting:
sudo snap install --dangerous ./vocalinux_0.17.0_amd64.snap
sudo snap connect vocalinux:audio-record
sudo snap connect vocalinux:raw-input
sudo snap connect vocalinux:hardware-observe
sudo snap connect vocalinux:uinput
```
+5 -1
View File
@@ -30,6 +30,7 @@ description: |
Sandbox notes:
- Global hotkeys need: sudo snap connect vocalinux:raw-input
and sudo snap connect vocalinux:hardware-observe
- Native Wayland typing needs: sudo snap connect vocalinux:uinput
(v0.17.0 ships the plug; v0.16.2 edge (rev 7) has no uinput plug; refresh first)
- Microphone may need: sudo snap connect vocalinux:audio-record
@@ -57,10 +58,13 @@ apps:
- home # optional access outside snap HOME (shared files)
- network # download speech models on first run
- network-bind # optional remote_api engine
# Global hotkeys via python-evdev (/proc/bus/input + /dev/input/event*).
# Global hotkeys via python-evdev (/dev/input/event*).
# Super-privileged: not auto-connected; user must:
# sudo snap connect vocalinux:raw-input
- raw-input
# Needed for /proc/bus/input/devices; not auto-connected:
# sudo snap connect vocalinux:hardware-observe
- hardware-observe
# ydotool writes to /dev/uinput so dictation reaches native Wayland apps.
# Super-privileged: not auto-connected; user must:
# sudo snap connect vocalinux:uinput
@@ -127,8 +127,8 @@ def find_keyboard_devices() -> list[str]:
Prefers /proc/bus/input/devices (host installs). Falls back to
``evdev.list_devices()`` when that path cannot be read (common under snap
confinement even with raw-input, which grants /dev/input/event* but may
still deny /proc/bus/input/devices).
confinement: raw-input grants /dev/input/event*, while hardware-observe
is the plug that grants /proc/bus/input/devices).
Returns:
List of device paths for keyboard devices
@@ -424,7 +424,8 @@ class EvdevKeyboardBackend(KeyboardBackend):
return (
"Snap is missing input-device access. "
"Connect once, then restart:\n"
"sudo snap connect vocalinux:raw-input"
"sudo snap connect vocalinux:raw-input\n"
"sudo snap connect vocalinux:hardware-observe"
)
return (
"Add your user to the 'input' group and log out/in:\n"
@@ -432,11 +433,13 @@ class EvdevKeyboardBackend(KeyboardBackend):
)
return None
# No devices found — snap without raw-input often cannot list anything.
# No devices found: snap without raw-input and hardware-observe
# often cannot list anything.
if in_snap:
return (
"Snap is missing input-device access. Connect once, then restart:\n"
"sudo snap connect vocalinux:raw-input"
"sudo snap connect vocalinux:raw-input\n"
"sudo snap connect vocalinux:hardware-observe"
)
try:
+1
View File
@@ -102,6 +102,7 @@ class KeyboardShortcutManager:
logger.warning("=" * 60)
logger.warning("Global hotkeys read /dev/input (evdev). Connect once:")
logger.warning(" sudo snap connect vocalinux:raw-input")
logger.warning(" sudo snap connect vocalinux:hardware-observe")
logger.warning("Then restart Vocalinux. Until then, use the system tray.")
logger.warning("=" * 60)
return
+26
View File
@@ -394,6 +394,32 @@ class TestEvdevKeyboardBackendPermissionHint:
result = backend.get_permission_hint()
assert result is None
@patch.dict("os.environ", {"SNAP": "/snap/vocalinux/x1"}, clear=False)
@patch("vocalinux.ui.keyboard_backends.evdev_backend.EVDEV_AVAILABLE", True)
@patch("vocalinux.ui.keyboard_backends.evdev_backend.find_keyboard_devices")
def test_permission_hint_snap_no_devices(self, mock_find_devices):
"""Snap with no listed devices should ask for both input plugs."""
mock_find_devices.return_value = []
backend = EvdevKeyboardBackend()
result = backend.get_permission_hint()
assert result is not None
assert "raw-input" in result
assert "hardware-observe" in result
@patch.dict("os.environ", {"SNAP": "/snap/vocalinux/x1"}, clear=False)
@patch("vocalinux.ui.keyboard_backends.evdev_backend.EVDEV_AVAILABLE", True)
@patch("vocalinux.ui.keyboard_backends.evdev_backend.find_keyboard_devices")
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
def test_permission_hint_snap_permission_denied(self, mock_input_device, mock_find_devices):
"""Snap that cannot open /dev/input should ask for both input plugs."""
mock_find_devices.return_value = ["/dev/input/event0"]
mock_input_device.side_effect = OSError(errno.EACCES, "Permission denied")
backend = EvdevKeyboardBackend()
result = backend.get_permission_hint()
assert result is not None
assert "raw-input" in result
assert "hardware-observe" in result
class TestEvdevKeyboardBackendStart:
"""Test start() method."""
+11
View File
@@ -354,6 +354,17 @@ class TestKeyboardShortcuts(unittest.TestCase):
warning_calls = [str(call) for call in mock_logger.warning.call_args_list]
self.assertTrue(any("Permission issue" in str(call) for call in warning_calls))
def test_unavailable_hints_snap_lists_both_connects(self):
"""Snap log banner should list both raw-input and hardware-observe."""
self.mock_create_backend.return_value = None
with patch.dict("os.environ", {"SNAP": "/snap/vocalinux/x1"}, clear=False):
with patch("vocalinux.ui.keyboard_shortcuts.logger") as mock_logger:
KeyboardShortcutManager()
messages = " ".join(str(call) for call in mock_logger.warning.call_args_list)
self.assertIn("raw-input", messages)
self.assertIn("hardware-observe", messages)
class TestPynputBackend(unittest.TestCase):
"""Test cases for the pynput backend specifically."""
+15
View File
@@ -24,11 +24,16 @@ def test_snapcraft_recipe_and_gui_assets() -> None:
plugs = set((doc.get("apps") or {}).get("vocalinux", {}).get("plugs") or [])
assert "raw-input" in plugs
assert "hardware-observe" in plugs
assert "audio-record" in plugs
assert "uinput" in plugs
stage = doc["parts"]["vocalinux"].get("stage-packages") or []
assert "ydotool" in stage
recipe_text = SNAPCRAFT_YAML.read_text(encoding="utf-8")
assert "sudo snap connect vocalinux:raw-input" in recipe_text
assert "sudo snap connect vocalinux:hardware-observe" in recipe_text
assert DESKTOP_FILE.is_file()
assert SNAP_PNG.is_file()
assert SNAP_PNG.stat().st_size > 0
@@ -50,6 +55,16 @@ def test_snap_puts_gnome_platform_first_on_ld_library_path() -> None:
assert ld_path.endswith(":$LD_LIBRARY_PATH")
def test_snap_docs_tell_users_to_connect_hardware_observe() -> None:
"""Hotkeys need hardware-observe for /proc/bus/input/devices (#857)."""
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
install = (REPO_ROOT / "docs" / "INSTALL.md").read_text(encoding="utf-8")
snapcraft = SNAPCRAFT_YAML.read_text(encoding="utf-8")
for text in (readme, install, snapcraft):
assert "sudo snap connect vocalinux:raw-input" in text
assert "sudo snap connect vocalinux:hardware-observe" in text
def test_snap_docs_warn_that_0162_has_no_uinput_plug() -> None:
"""v0.16.2 edge (rev 7) has no uinput plug; the connect command must not stand alone."""
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")