From 3776c4de6ebb52ffbab202542b9f6dec9482ab2e Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 23:50:46 +0530 Subject: [PATCH 1/3] fix(scripts): skip the portal probe on niri in inject-probe The injector never offers the RemoteDesktop portal on niri, but inject-probe ran the portal probe unguarded and reported portal_available: true there. The probe now reuses the injector's _is_niri_session guard: on niri it skips the portal and reports portal_skip_reason "niri" with portal_available false. Off niri the probe runs and reports its answer as before. Fixes #46 --- docs/PERF_BASELINE_PROTOCOL.md | 10 ++-- scripts/measure_perf_baseline.py | 28 +++++---- tests/test_perf_baseline_protocol.py | 90 ++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 14 deletions(-) diff --git a/docs/PERF_BASELINE_PROTOCOL.md b/docs/PERF_BASELINE_PROTOCOL.md index b526b50..b90e534 100644 --- a/docs/PERF_BASELINE_PROTOCOL.md +++ b/docs/PERF_BASELINE_PROTOCOL.md @@ -63,10 +63,12 @@ with the installed build's interpreter. version-probe result and timing, then a session-establishment attempt (fast failure versus hang matters more than the version answer), plus the resolved first rung and a no-emission `wtype ""` spawn cost as a - lower bound. End-to-end key-to-screen latency is deliberately not - taken here: emitting keystrokes into a live desktop from a background - run would disturb the session. It belongs to the hardware checklist - with a dedicated target window. + lower bound. On niri the portal probe is skipped, as the injector skips + it, and the report says so with `portal_skip_reason: "niri"` and + `portal_available: false`. End-to-end key-to-screen latency is + deliberately not taken here: emitting keystrokes into a live desktop + from a background run would disturb the session. It belongs to the + hardware checklist with a dedicated target window. - **Thresholds for the model ladder:** derive free-memory cutoffs from the maximum observed per-rung footprint (transcribe peak, not just load) plus explicit headroom for the compositor and other GPU diff --git a/scripts/measure_perf_baseline.py b/scripts/measure_perf_baseline.py index 2fb326a..ed1ad47 100644 --- a/scripts/measure_perf_baseline.py +++ b/scripts/measure_perf_baseline.py @@ -433,8 +433,11 @@ def cmd_inject_probe(args: argparse.Namespace) -> int: Never emits keystrokes: end-to-end key-to-screen latency would type into the live desktop, so it stays a hardware-checklist item (#32). + + On niri the portal is skipped with the injector's own niri guard: the + injector never offers the portal there, so the report must not claim it. """ - from vocalinux.text_injection.text_injector import TextInjector + from vocalinux.text_injection.text_injector import TextInjector, _is_niri_session probe_text = "the quick brown fox jumps over the lazy dog 0123456789" started = time.monotonic() @@ -462,16 +465,20 @@ def cmd_inject_probe(args: argparse.Namespace) -> int: env=host_env(), ) wtype_roundtrips.append(time.monotonic() - begin) - try: - from vocalinux.text_injection.remote_desktop_portal import RemoteDesktopPortal + portal_available = False + portal_seconds = None + portal_skip_reason = "niri" if _is_niri_session() else None + if portal_skip_reason is None: + try: + from vocalinux.text_injection.remote_desktop_portal import RemoteDesktopPortal - portal_started = time.monotonic() - portal_available = RemoteDesktopPortal().probe() - portal_seconds = time.monotonic() - portal_started - except Exception as exc: # noqa: BLE001 - probe must never fail the pass - portal_available = False - portal_seconds = None - print(f"portal probe raised: {exc}", file=sys.stderr) + portal_started = time.monotonic() + portal_available = RemoteDesktopPortal().probe() + portal_seconds = time.monotonic() - portal_started + except Exception as exc: # noqa: BLE001 - probe must never fail the pass + portal_available = False + portal_seconds = None + print(f"portal probe raised: {exc}", file=sys.stderr) print( json.dumps( { @@ -481,6 +488,7 @@ def cmd_inject_probe(args: argparse.Namespace) -> int: "wtype_empty_roundtrip_stats": describe(wtype_roundtrips), "portal_available": portal_available, "portal_probe_seconds": portal_seconds, + "portal_skip_reason": portal_skip_reason, }, indent=2, sort_keys=True, diff --git a/tests/test_perf_baseline_protocol.py b/tests/test_perf_baseline_protocol.py index c09bfa2..eab4bce 100644 --- a/tests/test_perf_baseline_protocol.py +++ b/tests/test_perf_baseline_protocol.py @@ -7,7 +7,10 @@ helpers tested here. Tests run offline with fixture text only and never touch audio hardware, the GPU, or the network. """ +import argparse import importlib.util +import json +import shutil from pathlib import Path import pytest @@ -134,3 +137,90 @@ DBUS_TOGGLE_LOG = """\ def test_parse_wake_latencies_pairs_dbus_toggle_starts_only() -> None: """Only IDLE toggles open capture; a stop toggle never borrows the next run.""" assert PROTOCOL.parse_wake_latencies(DBUS_TOGGLE_LOG) == pytest.approx([0.146, 0.649]) + + +#: Session variables the injector's niri guard reads, plus Wayland; cleared so +#: the host's own session (this machine runs niri) cannot leak into a test. +_SESSION_ENV = ( + "NIRI_SOCKET", + "XDG_CURRENT_DESKTOP", + "XDG_SESSION_DESKTOP", + "DESKTOP_SESSION", + "WAYLAND_DISPLAY", +) + + +class _FakeInjector: + """Stands in for TextInjector: a Wayland session that selected wtype.""" + + wayland_mode = True + wayland_tool = "wtype" + + +def _run_inject_probe( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + session_env: dict[str, str], + portal_answers: bool, +) -> tuple[dict, list[bool]]: + """Run inject-probe under a fake session; return its report and portal probe calls.""" + from vocalinux.text_injection import remote_desktop_portal, text_injector + + probes: list[bool] = [] + + class _SpyPortal: + def probe(self) -> bool: + probes.append(True) + return portal_answers + + for name in _SESSION_ENV: + monkeypatch.delenv(name, raising=False) + for name, value in session_env.items(): + monkeypatch.setenv(name, value) + monkeypatch.setattr(text_injector, "TextInjector", _FakeInjector) + monkeypatch.setattr(remote_desktop_portal, "RemoteDesktopPortal", _SpyPortal) + monkeypatch.setattr(shutil, "which", lambda _name: None) + assert PROTOCOL.cmd_inject_probe(argparse.Namespace()) == 0 + return json.loads(capsys.readouterr().out), probes + + +@pytest.mark.parametrize( + "session_env", + [ + {"XDG_CURRENT_DESKTOP": "niri"}, + {"NIRI_SOCKET": "/run/user/1000/niri.wayland-1.1.sock"}, + ], +) +def test_inject_probe_never_probes_portal_on_niri( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + session_env: dict[str, str], +) -> None: + """The injector never offers the portal on niri, so the probe must not claim it. + + Both niri signals are covered: the probe reuses the injector's guard, so a + session the injector treats as niri is reported the same way here. + """ + report, probes = _run_inject_probe( + monkeypatch, capsys, session_env=session_env, portal_answers=True + ) + assert probes == [] + assert report["portal_available"] is False + assert report["portal_probe_seconds"] is None + assert report["portal_skip_reason"] == "niri" + + +def test_inject_probe_reports_portal_answer_off_niri( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Off niri the portal is probed and its answer is reported unchanged.""" + report, probes = _run_inject_probe( + monkeypatch, + capsys, + session_env={"XDG_CURRENT_DESKTOP": "GNOME"}, + portal_answers=True, + ) + assert probes == [True] + assert report["portal_available"] is True + assert report["portal_skip_reason"] is None -- 2.54.0 From 2a5710334b286b71bab9651cb5cad7598a2d41f3 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 23:54:07 +0530 Subject: [PATCH 2/3] fix(niri): move the example toggle off Mod+Shift+D Mod+Shift+D is Move Column Right in Dank Material Shell's niri binds (dms/binds.kdl), so the shipped example competed with it silently. The toggle moves to Mod+Shift+M, which is free in niri's stock binds and in DMS's binds. The start and stop examples move off Mod+Shift+E, which is stock niri's quit, to Mod+Shift+S and Mod+Shift+X. niri validate rejects a key bound twice in one binds block but not across blocks, so validation cannot show a conflict. The docs now give a grep over the config tree and niri's embedded stock binds for checking a key before binding it. The overlay pointer is corrected to Mod+Shift+Slash. A test pins the doc and the include to one toggle key that is not Mod+Shift+D. Fixes #45 --- docs/NIRI.md | 35 +++++++++++++++++++++++++++++++---- resources/niri/voculinux.kdl | 10 +++++++--- tests/test_niri_pack.py | 17 +++++++++++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/docs/NIRI.md b/docs/NIRI.md index c3798a2..b1fc2a5 100644 --- a/docs/NIRI.md +++ b/docs/NIRI.md @@ -39,7 +39,7 @@ The shipped bind: ```kdl binds { - Mod+Shift+D repeat=false hotkey-overlay-title="Toggle Voculinux dictation" { + Mod+Shift+M repeat=false hotkey-overlay-title="Toggle Voculinux dictation" { spawn "voculinux" "--toggle"; } } @@ -47,9 +47,11 @@ binds { `repeat=false` is what makes it toggle once per press: without it, holding the key repeats the spawn and toggles dictation back off before you let go. -Pick any key you like (the overlay title keeps it discoverable under -`Mod+H`), and uncomment the `--start` / `--stop` variants in the include -file if you want dedicated keys. +`Mod+Shift+M` was free in niri's stock binds and in Dank Material Shell's +binds when this was written. To use another key, check it first (below). +The bind's title shows in niri's hotkey overlay (`Mod+Shift+Slash`). The +include file's commented `--start` / `--stop` variants use `Mod+Shift+S` and +`Mod+Shift+X`, which were free in the same way. A key is either a compositor bind or an in-app Voculinux shortcut — never both. With External Activation on, do not keep the same key configured @@ -57,6 +59,31 @@ under **Settings → Shortcuts**; with it off, remove the niri bind. A key claimed on both sides fires twice: the compositor toggles over D-Bus while the internal listener fires on its own. +## Check a key before you bind it + +`niri validate` rejects a key bound twice inside one `binds {}` block, but +not a key bound in two blocks: a bind in your include file and one in +`dms/binds.kdl` both pass validation with no warning. So validation cannot +tell you whether a key is free. Check with grep, against your own config and +niri's stock binds. Run this before you copy the include file, or your own +toggle bind will match: + +```bash +KEY_RE='(Mod|Super)\+Shift\+M([[:space:]{]|$)' # the key you want to check +grep -rnE --include='*.kdl' "$KEY_RE" ~/.config/niri/ +strings "$(command -v niri)" | grep -E "^[[:space:]]*(//[[:space:]]*)?$KEY_RE" +``` + +The first command searches your config. `~/.config/niri/` holds +`config.kdl` and the DMS-managed `dms/binds.kdl`, which `config.kdl` +includes. `--include='*.kdl'` skips the `binds.kdl.pre-*` backups. If you +include files from elsewhere, add their paths. The second command searches +the stock defaults embedded in the niri binary. Commented lines match too, so +a hit can be a bind you have commented out. No output from either command +means the key is free. For a hit, remove or move the other bind, because niri does not warn +about a key bound in two places. `Mod` and `Super` are both checked because +niri reads `Mod` as Super on a TTY. + ## First dictation There is no permission prompt on niri. The RemoteDesktop portal is never diff --git a/resources/niri/voculinux.kdl b/resources/niri/voculinux.kdl index 16e610f..d595c6c 100644 --- a/resources/niri/voculinux.kdl +++ b/resources/niri/voculinux.kdl @@ -11,12 +11,16 @@ // // One key is either a compositor bind or an in-app Voculinux shortcut, // never both: do not keep this key configured under Settings → Shortcuts. +// +// The keys below were free in niri's stock binds and in Dank Material Shell's +// binds when this file was written. Before you use one, check it against your +// own config: docs/NIRI.md, "Check a key before you bind it". binds { // Toggle dictation, exactly once per press. repeat=false stops key // repeat from firing the spawn again while the key is held (which // would toggle dictation back off before you let go). - Mod+Shift+D repeat=false hotkey-overlay-title="Toggle Voculinux dictation" { + Mod+Shift+M repeat=false hotkey-overlay-title="Toggle Voculinux dictation" { spawn "voculinux" "--toggle"; } @@ -24,13 +28,13 @@ binds { // Mod+Shift+S repeat=false hotkey-overlay-title="Start Voculinux dictation" { // spawn "voculinux" "--start"; // } - // Mod+Shift+E repeat=false hotkey-overlay-title="Stop Voculinux dictation" { + // Mod+Shift+X repeat=false hotkey-overlay-title="Stop Voculinux dictation" { // spawn "voculinux" "--stop"; // } // Flatpak installs: voculinux is not on the host PATH, so bind the // host D-Bus call instead (same toggle, no extra sandbox per press). - // Mod+Shift+D repeat=false hotkey-overlay-title="Toggle Voculinux dictation" { + // Mod+Shift+M repeat=false hotkey-overlay-title="Toggle Voculinux dictation" { // spawn "gdbus" "call" "--session" "--dest" "com.voculinux.Voculinux" "--object-path" "/com/voculinux/Voculinux" "--method" "com.voculinux.Voculinux.Toggle"; // } } diff --git a/tests/test_niri_pack.py b/tests/test_niri_pack.py index e03cd3e..cca5621 100644 --- a/tests/test_niri_pack.py +++ b/tests/test_niri_pack.py @@ -8,6 +8,7 @@ widening fails loudly. When #32 records DMS evidence, update the doc and this assertion together. """ +import re from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] @@ -18,6 +19,9 @@ USER_GUIDE = REPO_ROOT / "docs" / "USER_GUIDE.md" TRAY_INDICATOR = REPO_ROOT / "src" / "vocalinux" / "ui" / "tray_indicator.py" TOGGLE_SPAWN = 'spawn "voculinux" "--toggle"' +TOGGLE_HEADER = re.compile(r"^(\S+) repeat=false") +#: Move Column Right in Dank Material Shell's binds; the shipped key must not reuse it (#45). +DMS_MOVE_COLUMN_RIGHT_KEY = "Mod+Shift+D" CURRENT_WATCHER = "org.kde.StatusNotifierWatcher" @@ -45,6 +49,19 @@ def test_include_bind_is_non_repeating() -> None: assert "repeat=false" in _read(NIRI_INCLUDE) +def test_doc_and_include_share_a_toggle_key_not_taken_by_dms() -> None: + """The doc's example key is the include file's toggle key, and it is not in use (#45).""" + keys = [ + match.group(1) + for match in (TOGGLE_HEADER.match(line) for line in _active_lines(_read(NIRI_INCLUDE))) + if match + ] + assert len(keys) == 1, "the include file should ship exactly one toggle bind" + key = keys[0] + assert key != DMS_MOVE_COLUMN_RIGHT_KEY + assert f"{key} repeat=false" in _read(NIRI_DOC) + + def test_include_file_uses_no_compositor_internals() -> None: """Only plain spawn actions; no compositor message APIs.""" assert "niri msg" not in _read(NIRI_INCLUDE).lower() -- 2.54.0 From c9d8ee838f0f4d632aef58b94ee448882d943cf0 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Fri, 6 Nov 2026 23:56:47 +0530 Subject: [PATCH 3/3] docs(niri): match key names case-insensitively in the conflict check niri accepts lowercase and mixed-case key names such as mod+shift+m, so the grep in the conflict check must ignore case too. strings comes from binutils, which is now said in the docs. Refs #45 --- docs/NIRI.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/NIRI.md b/docs/NIRI.md index b1fc2a5..da2c34a 100644 --- a/docs/NIRI.md +++ b/docs/NIRI.md @@ -70,11 +70,12 @@ toggle bind will match: ```bash KEY_RE='(Mod|Super)\+Shift\+M([[:space:]{]|$)' # the key you want to check -grep -rnE --include='*.kdl' "$KEY_RE" ~/.config/niri/ -strings "$(command -v niri)" | grep -E "^[[:space:]]*(//[[:space:]]*)?$KEY_RE" +grep -rniE --include='*.kdl' "$KEY_RE" ~/.config/niri/ +strings "$(command -v niri)" | grep -iE "^[[:space:]]*(//[[:space:]]*)?$KEY_RE" ``` -The first command searches your config. `~/.config/niri/` holds +Matching ignores case, because niri also accepts `mod+shift+m`. `strings` +comes from binutils. The first command searches your config. `~/.config/niri/` holds `config.kdl` and the DMS-managed `dms/binds.kdl`, which `config.kdl` includes. `--include='*.kdl'` skips the `binds.kdl.pre-*` backups. If you include files from elsewhere, add their paths. The second command searches -- 2.54.0