From 5b4e2c357283d2407175a0c71d76051fb7f6f715 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Wed, 23 Sep 2026 00:09:54 -0700 Subject: [PATCH] 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 --- README.md | 1 + docs/INSTALL.md | 2 ++ snap/snapcraft.yaml | 6 ++++- .../ui/keyboard_backends/evdev_backend.py | 13 ++++++---- src/vocalinux/ui/keyboard_shortcuts.py | 1 + tests/test_evdev_backend_ext.py | 26 +++++++++++++++++++ tests/test_keyboard_shortcuts.py | 11 ++++++++ tests/test_snap_packaging.py | 15 +++++++++++ 8 files changed, 69 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 63ee00c..a97f9dc 100644 --- a/README.md +++ b/README.md @@ -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) ``` diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 55ba41a..7d8537f 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 ``` diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 1e676cf..55e3450 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -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 diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index 62c195c..62c4ef3 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -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: diff --git a/src/vocalinux/ui/keyboard_shortcuts.py b/src/vocalinux/ui/keyboard_shortcuts.py index 1f2a549..4e04141 100644 --- a/src/vocalinux/ui/keyboard_shortcuts.py +++ b/src/vocalinux/ui/keyboard_shortcuts.py @@ -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 diff --git a/tests/test_evdev_backend_ext.py b/tests/test_evdev_backend_ext.py index a8ddfec..46613d1 100644 --- a/tests/test_evdev_backend_ext.py +++ b/tests/test_evdev_backend_ext.py @@ -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.""" diff --git a/tests/test_keyboard_shortcuts.py b/tests/test_keyboard_shortcuts.py index 740cc0d..f7e7663 100644 --- a/tests/test_keyboard_shortcuts.py +++ b/tests/test_keyboard_shortcuts.py @@ -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.""" diff --git a/tests/test_snap_packaging.py b/tests/test_snap_packaging.py index 911a767..43b2629 100644 --- a/tests/test_snap_packaging.py +++ b/tests/test_snap_packaging.py @@ -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")