diff --git a/.gitattributes b/.gitattributes index e4a3e5eee..5e4dbc107 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ # These upstream unified diffs preserve source whitespace needed by patch. tools/slurp-keymap-guard.patch whitespace=-blank-at-eol,-space-before-tab tools/quickshell-qt-compat-v1/qt612-metatypes-v031.patch whitespace=-blank-at-eol,-space-before-tab +hyprwhspr/integration/sway-focus-paste.patch whitespace=-blank-at-eol,-space-before-tab diff --git a/docs/hyprwhspr-swayfx-focus-and-paste-fix-v1.md b/docs/hyprwhspr-swayfx-focus-and-paste-fix-v1.md new file mode 100644 index 000000000..bc8328da1 --- /dev/null +++ b/docs/hyprwhspr-swayfx-focus-and-paste-fix-v1.md @@ -0,0 +1,180 @@ +# HYPRWHSPR_SWAYFX_FOCUS_AND_PASTE_FIX_V1 + +Implemented and verified on 2026-11-07 against the locally installed Arch +`hyprwhspr 1.52.1-1`. No real dictation was started and no real application +received test text. Manual audio-to-editor acceptance remains pending. + +## Evidence and diagnosis + +The installed `_get_active_window_info` probes Niri, Hyprland, X11/XWayland, +then AT-SPI. It has no Sway detector. Consequently native Kitty and Firefox +report `not detected`, while the XWayland ChatGPT reports class `Chatgpt`. +Kitty then resolves the fallback Ctrl+V instead of its terminal Ctrl+Shift+V. +This explains the observed terminal control character; no global paste_mode +setting is needed. + +Sway IPC was tested with Kitty, ChatGPT and Firefox individually focused, +restoring the preceding focus after the audit. It correctly returns: + +| Application | app_id | window_properties.class | Transport | +|---|---|---|---| +| Kitty | kitty | absent | native Wayland | +| Firefox | firefox | absent | native Wayland | +| ChatGPT | absent | Chatgpt | XWayland | + +The service's own `/proc//environ` contains: + +- SWAYSOCK=/run/user/1000/sway-ipc.1000.2363.sock +- WAYLAND_DISPLAY=wayland-1 +- DISPLAY=:0 +- XDG_RUNTIME_DIR=/run/user/1000 + +A `swaymsg get_tree` and corrected `config focused-window` executed with this +service environment succeed for all three applications. No global systemd +environment import or socket workaround was required. The socket's PID-based +path is session-specific and is not hard-coded by the patch. + +For ChatGPT, source inspection shows wtype is the normal Wayland injection +transport; ydotool is a fallback. A disposable XWayland Tk widget reproduced +the following with actual synthetic keys: + +| Transport | Return | Key event received | +|---|---|---| +| Installed wtype chord | success / exit 0 | keysym `1`, Control mask 4 | +| Patched XWayland dispatch | success | Control_L, then keysym `v`, Control mask 4 | + +Adding 50 ms delays to wtype did not correct the wrong keysym. This establishes +a reproducible XWayland keymap/keysym transport failure despite a successful +wtype exit. It is a concrete explanation for the ChatGPT symptom, not proof +that ChatGPT itself has now accepted a real transcript. A manual ChatGPT test +is still required. Clipboard contents were not inspected or logged. The retained +probe blocks all default key bindings and mocks clipboard copy/save/restore. + +The OSD source already sets Gtk4LayerShell.KeyboardMode.NONE. No focus-stealing +OSD window appeared in the audit; focus loss remains guarded at delivery time. +Clipboard writes and delayed restoration already use the same clipboard lock; +no demonstrated concurrency defect required changing that mechanism. Firefox +already worked according to the user and retains native wtype + Ctrl+V. + +## Patch and installation + +`hyprwhspr/integration/sway-focus-paste.patch` adds an authoritative Sway +backend using the i3 IPC GET_TREE protocol directly: little-endian fixed header, +500 ms socket deadline, 8 MiB response bound, iterative nodes/floating_nodes +traversal. It uses focused, app_id, window_properties.class, name and X11 window +ID. It does not invoke a shell, create a poller, or log titles/transcripts. + +When SWAYSOCK is set, unavailable/malformed IPC fails closed rather than +falling through to stale XWayland focus. Other compositor detection is retained +when SWAYSOCK is absent. Existing per-application auto_paste rules and automatic +terminal detection are preserved. + +Delivery snapshots the current Sway identity, then checks it again after +clipboard publication/settling, immediately before dispatch. Focus change or +IPC failure aborts the paste. No previous window is activated. For an +identified XWayland target it additionally verifies xdotool getactivewindow +against Sway's X11 window ID, then uses the existing XTest Ctrl+V implementation. +It never retries another transport after a possibly dispatched X11 chord. +Native Kitty keeps Ctrl+Shift+V; native Firefox keeps Ctrl+V through wtype. +The focus check and keyboard dispatch are separate operations, not an atomic +compositor transaction. + +No pacman-owned file was edited. The installer verifies exact SHA-256 baselines: + +- text_injector.py: e6bb5d4291d83cbd189d33bee1dc062b466d59b6a0fb1e1da170dba9b648ca29 +- /usr/bin/hyprwhspr: bfe5516566ac12b2f9958109f395d6504ef17dc3ac200eea8779f502a9896f22 + +It copies the installed lib source into +`~/.local/share/hyprwhspr-swayfx/1.52.1-sway-v1/`, applies the patch with zero +fuzz, and links other package resources. Its launcher retains the packaged +interpreter/environment routing. A SHA guard refuses execution if the original +injector changes after a package update; rebase/review is then required. + +The user CLI symlink `~/.local/bin/hyprwhspr` selects this same launcher. +The new drop-in +`~/.config/systemd/user/hyprwhspr.service.d/50-sway-focus-paste.conf` overrides +only ExecStart. Existing config and the pre-existing modified service unit were +preserved. One preparation snapshot was retained as +`1.52.1-sway-v1-before-validation`; it is not active. + +Reproduction: + +```sh +python ~/.dotfiles/hyprwhspr/integration/install-sway-fix.py +# When dictation and transcription are idle: +systemctl --user daemon-reload +systemctl --user restart hyprwhspr.service +``` + +Installer rerun/idempotency was verified. The controlled restart completed; +the daemon reports Vulkan (AMD/Intel), large-v3-turbo and READY. NRestarts=0. +Copilot SUPER+SHIFT+F23, push_to_talk, language fr, audio settings, backend and +model remain unchanged. QuickShell was not edited; its existing IPC reports +idle/Dictée inactive after the restart. + +## soundfile + +The warning originates in FeedbackMixin._report_missing_dependencies running +inside `~/.local/share/hyprwhspr/venv/bin/python`, Python 3.14, with system site +packages enabled. The runtime executable resolves to /usr/bin/python3.14, but +sys.prefix correctly identifies this user venv. soundfile was absent there. + +Installed only in that venv, without sudo or dependency upgrades: + +```sh +~/.local/share/hyprwhspr/venv/bin/python -m pip install --no-deps soundfile==0.13.1 +``` + +Import now succeeds (0.13.1); missing_imports('vulkan') returns an empty tuple. +The restarted service no longer emits the missing-module error. The backend +and model were not replaced. + +## Validation and remaining acceptance + +- 16 automated production-method tests pass. They first apply the actual patch + to installed upstream in a temporary directory, then cover identities/chords, + fragmented/invalid/oversized/truncated IPC, timeout, socket unavailability, + stale X focus, delivery-time focus change/loss, XWayland/native routing, + no retry after X dispatch failure, per-app override and non-Sway fallback. +- Safe real XWayland key probe passes; retained at + `tools/hyprwhspr-sway-key-probe.py`. It restores focus and terminates its own + fixture, with no clipboard access or real text delivery. +- Corrected live CLI detection succeeds for all three apps using the service + environment. User window titles were omitted from the report. +- Python compilation, launcher bash syntax and git diff --check pass. +- Single daemon and original QuickShell instance remain; no fixture or probe + processes remain. No new scheduler, persistent worker or poller was added. +- No automated tests remain failing. Initial pre-fix focus and XWayland key + failures are the reproduced defects described above. + +Manual checks: use Copilot to dictate a harmless sentence into an empty Kitty +prompt (do not press Enter), a Firefox test field and ChatGPT's input without +submitting. Verify correct accented text, normal transcription/insertion, +QuickShell synchronization and repeated dictation. Actual audio-to-application +insertion remains unclaimed until these tests are performed. + +## Rollback + +When dictation/transcription are idle, remove only the two newly generated +selectors, then restart the original service: + +```sh +unlink ~/.config/systemd/user/hyprwhspr.service.d/50-sway-focus-paste.conf +unlink ~/.local/bin/hyprwhspr +systemctl --user daemon-reload +systemctl --user restart hyprwhspr.service +``` + +The original unit's ExecStart=/usr/lib/hyprwhspr/bin/hyprwhspr takes effect again. +The user-owned source snapshots can remain for diagnosis; no package restore, +Git restore or cleanup is required. soundfile can safely remain. If its removal +is desired separately, use the same venv's `python -m pip uninstall soundfile`; +it was absent before this task. Preserve all other config/unit modifications. + +## Upstream opportunity + +A focused upstream contribution is appropriate: Sway identity detection, +fail-closed delivery-time focus checks and the independently reproduced wtype +XWayland keysym failure with targeted X11 dispatch. Keep the local installer and +systemd paths out of the upstream code patch. No issue, PR or publication was +created. diff --git a/docs/quickshell-sway-hyprwhspr-integration-v1.md b/docs/quickshell-sway-hyprwhspr-integration-v1.md new file mode 100644 index 000000000..3f1f0df0e --- /dev/null +++ b/docs/quickshell-sway-hyprwhspr-integration-v1.md @@ -0,0 +1,99 @@ +# QUICKSHELL_SWAY_HYPRWHSPR_INTEGRATION_V1 + +Integration implemented on 2026-11-07. Real recording tests remain manual. + +## Local audit + +- Arch package `hyprwhspr 1.52.1-1`; `hyprwhspr --version` prints `hyprwhspr unknown`. +- System QuickShell 0.3.2; running instance uses 0.3.2 from + `~/.local/opt/quickshell-qt612-popup/bin/quickshell` (labfy local Qt 6.12 popup fix). +- `hyprwhspr --help` and `hyprwhspr record --help` both print the top-level help. +- `record status` stdout is exactly `[INFO] Status: Idle\n`, stderr empty, + exit 0. This remains true with the service stopped and with an absent runtime + directory. One local measurement: 98 ms; `systemctl --user is-active + hyprwhspr.service` took 7 ms and returned `inactive\n`, exit 3. +- Installed CLI source emits `[INFO] Status: Recording in progress\n` when + `$XDG_RUNTIME_DIR/hyprwhspr/recording_status` contains `true`. + This recording output was verified in installed source and mocked tests, + not by recording audio. +- `record toggle` sends normal start/stop commands. Installed source can print + `[ERROR]` while returning 0; the backend checks both code and expected output. +- No Hyprwhspr binding found in Sway config/bind or `/etc/sway/config.d`. + User config selects `push_to_talk`, with no primary shortcut override; + installed default is `SUPER+ALT+D`. Actual keyboard operation remains manual. +- User service was inactive before and after integration. No service/config, + shortcut, model or transcription changes were made. + +## Architecture and behavior + +One `HyprwhsprService` in ShellRoot serves every Bar. Each RightStatusArea +places a 26 x 26 indicator between network and Bluetooth. Nerd icon is 18 px, +corner radius 4 px, with existing Theme and StatusTooltip components. +No extra bar, terminal or action panel is created; the existing tooltip +surface is reused for hover text. + +The backend serializes availability checks, status reads and toggles using +one asynchronous Process. Availability must be confirmed before status is +accepted. Recording is only confirmed by the exact installed CLI response; +there is no locally inverted recording boolean. The click lock lasts through +command completion and the confirming read. Clicks are ignored during +authentication. Errors never masquerade as recording. + +Native recording_status notifications accelerate external keyboard and automatic +changes. A single-shot timer starts after the preceding cycle finishes: +1000 ms at idle/error, 400 ms at recording/processing, plus command latency. +At most one pending refresh is retained. Each command uses GNU timeout with +3 seconds before TERM and another second before KILL. No shell is invoked. +A whole read cycle can require two commands; refresh timing is not a hard +real-time guarantee. + +`visualizer_state` is an additional native, optional signal. Installed +`mic_osd/runner.py` writes processing/error/success labels and clears the file; +FileView watches it without reading any transcript. A confirmed recording +response has precedence. With CLI idle, `processing` displays a yellow mic and +`Transcription en cours`; `error` displays a red warning. Missing marker, +`success`, or another label does not invent a processing state. The service +availability gate also applies to these labels. This mechanism depends on +Hyprwhspr publishing its OSD marker and is not an independent transcription API. + +Idle: muted grey mic, `Dictée inactive`. Recording: Theme.red (Mocha #f38ba8), +`Dictée en cours`. Processing: Theme.yellow (Mocha #f9e2af). +Unavailable/unreadable/command failure: red warning with a short fixed message. +No animation. No command output, audio or transcript is logged by the backend. +A read-only `dictationStatus.state` IPC returns status/tooltip/busy/cadence only. + +## Validation + +- qmllint against installed Qt imports: all five changed/added QML files pass. +- git diff --check: pass. +- Production backend tested offscreen using isolated fake CLI executables and + a temporary runtime directory; no windows and no live microphone access. +- Tests cover startup idle and already recording, external recording/stop, + unavailable service/recovery, duplicate clicks, CLI failure with exit 0, + unknown output, native processing/end, bounded slow command and recovery. +- Live existing bar reloaded successfully. IPC correctly reports service + unavailable; no service restart or stop was performed. +- Captured bar region visually inspected: red warning between network and + Bluetooth; other indicators remain visible. Radius/size/theme preserved. +- One live QuickShell instance remains, PID 2478 at validation time. Temporary + offscreen test processes exit after testing. A stalled mocked command is + bounded and repeated refresh requests coalesce. +- Existing log warnings about ControlCenter/DateCenter Keys attachments and + I3 socket reconnection are outside this change; no new QML load error seen. +- Full live idle/recording/transcription, hover interaction and service outage + during actual dictation have not been manually exercised. + +## Manual acceptance checklist + +1. With Hyprwhspr running and idle, verify grey microphone and idle tooltip. +2. Left click: verify red only after recording is actually confirmed. +3. Left click again: normal transcription and insertion; never cancel. +4. Start with the existing keyboard shortcut: red updates automatically. +5. Stop with the keyboard: indicator follows actual state. +6. If native processing marker is published, verify yellow then idle/error. +7. When convenient, stop/restart the service and verify red warning/recovery + without interruption or duplication of QuickShell. + +Do not mark these steps passed until performed with user authorization. +Pre-existing trier-photos changes and its untracked backup are unrelated and +were preserved. No staging, commits or publication performed. diff --git a/hyprwhspr/integration/install-sway-fix.py b/hyprwhspr/integration/install-sway-fix.py new file mode 100644 index 000000000..4f67f4d39 --- /dev/null +++ b/hyprwhspr/integration/install-sway-fix.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Install a pinned user-owned Hyprwhspr overlay; never edit pacman files. + +WHY: both CLI and daemon must see the same detector. Keep the original package +and existing service/config intact; one removable systemd drop-in selects it. +CONTRACT: refuse unknown upstream sources and unmanaged destination files. +""" +import hashlib +from pathlib import Path +import shutil +import subprocess +import tempfile + +SOURCE = Path('/usr/lib/hyprwhspr') +INJECTOR_SHA = 'e6bb5d4291d83cbd189d33bee1dc062b466d59b6a0fb1e1da170dba9b648ca29' +LAUNCHER_SHA = 'bfe5516566ac12b2f9958109f395d6504ef17dc3ac200eea8779f502a9896f22' +MARKER = '# HYPRWHSPR_SWAYFX_FOCUS_AND_PASTE_FIX_V1' + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def install(): + if digest(SOURCE/'lib/src/text_injector.py') != INJECTOR_SHA or digest(Path('/usr/bin/hyprwhspr')) != LAUNCHER_SHA: + raise SystemExit('Upstream files changed: rebase/review the patch before installation.') + home = Path.home() + base = home/'.local/share/hyprwhspr-swayfx' + destination = base/'1.52.1-sway-v1' + link = home/'.local/bin/hyprwhspr' + dropin = home/'.config/systemd/user/hyprwhspr.service.d/50-sway-focus-paste.conf' + if (link.exists() or link.is_symlink()) and (not link.is_symlink() or link.readlink() != destination/'launcher'): + raise SystemExit(f'Refusing unmanaged launcher: {link}') + if dropin.exists() and MARKER not in dropin.read_text(): + raise SystemExit(f'Refusing unmanaged drop-in: {dropin}') + base.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='prepare-', dir=base) as folder: + stage = Path(folder)/'overlay' + stage.mkdir() + for child in SOURCE.iterdir(): + if child.name == 'lib': + shutil.copytree(child, stage/'lib', ignore=shutil.ignore_patterns('__pycache__', '*.pyc')) + else: + (stage/child.name).symlink_to(child) + subprocess.run(['patch', '--batch', '--fuzz=0', '-p1', '-i', + str(Path(__file__).with_name('sway-focus-paste.patch').resolve())], + cwd=stage, check=True, capture_output=True) + launcher = Path('/usr/bin/hyprwhspr').read_text().replace( + 'PACKAGE_ROOT="/usr/lib/hyprwhspr"', f'PACKAGE_ROOT="{destination}"') + # Fail closed after a package update instead of silently mixing versions. + guard = f'''{MARKER} +upstream_digest=$(sha256sum /usr/lib/hyprwhspr/lib/src/text_injector.py) +if [[ "${{upstream_digest%% *}}" != "{INJECTOR_SHA}" ]]; then + echo 'Hyprwhspr Sway overlay requires rebase after package update' >&2 + exit 1 +fi +''' + launcher = launcher.replace('#!/bin/bash\n', '#!/bin/bash\n'+guard, 1) + (stage/'launcher').write_text(launcher) + (stage/'launcher').chmod(0o755) + if destination.exists(): + if digest(destination/'lib/src/text_injector.py') != digest(stage/'lib/src/text_injector.py') or digest(destination/'launcher') != digest(stage/'launcher'): + raise SystemExit('Existing overlay differs; preserve it and choose a new reviewed version.') + else: + stage.rename(destination) + link.parent.mkdir(parents=True, exist_ok=True) + if not link.is_symlink(): + link.symlink_to(destination/'launcher') + dropin.parent.mkdir(parents=True, exist_ok=True) + dropin.write_text(f'{MARKER}\n[Service]\nExecStart=\nExecStart="{destination}/launcher"\n') + print(f'Installed {destination}; service restart is a separate controlled action.') + + +if __name__ == '__main__': + install() diff --git a/hyprwhspr/integration/sway-focus-paste.patch b/hyprwhspr/integration/sway-focus-paste.patch new file mode 100644 index 000000000..fc987dafd --- /dev/null +++ b/hyprwhspr/integration/sway-focus-paste.patch @@ -0,0 +1,171 @@ +--- a/lib/src/text_injector.py ++++ b/lib/src/text_injector.py +@@ -13,6 +13,8 @@ + import json + import ast + import math ++import socket ++import struct + from dataclasses import dataclass, field + from enum import Enum + from typing import Optional, Dict, Any, List, Tuple +@@ -397,7 +399,7 @@ + self.session_type = os.environ.get('XDG_SESSION_TYPE', '').lower() + self.ydotool_available = self._check_ydotool() + self.wtype_available = not self._is_x11_session() and shutil.which('wtype') is not None +- self.xdotool_available = self._is_x11_session() and shutil.which('xdotool') is not None ++ self.xdotool_available = (self._is_x11_session() or bool(os.environ.get('SWAYSOCK'))) and shutil.which('xdotool') is not None + self._hyprland_shortcut_syntax = None + self._warned_invalid_ydotool_modifier_overrides = set() + +@@ -621,8 +623,73 @@ + return False + return bool(self.config_manager.get_setting('prefer_clipboard_paste', False)) + ++ def _get_sway_window_info(self) -> Optional[Dict[str, Any]]: ++ """Read Sway's authoritative focus without consulting stale X11 focus. ++ ++ CONTRACT: bounded IPC (500 ms total, 8 MiB maximum), fixed i3 header, ++ iterative tree traversal, no window titles or clipboard data in logs. ++ A missing/invalid focus is failure, never permission to guess a target. ++ """ ++ path = os.environ.get('SWAYSOCK') ++ if not path: ++ return None ++ deadline = time.monotonic() + 0.5 ++ try: ++ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as ipc: ++ def receive(count): ++ data = bytearray() ++ while len(data) < count: ++ remaining = deadline - time.monotonic() ++ if remaining <= 0: ++ raise TimeoutError('Sway IPC deadline') ++ ipc.settimeout(remaining) ++ part = ipc.recv(min(count - len(data), 65536)) ++ if not part: ++ raise ValueError('Sway IPC truncated') ++ data.extend(part) ++ return data ++ ipc.settimeout(0.5) ++ ipc.connect(path) ++ ipc.sendall(b'i3-ipc' + struct.pack(' 8 * 1024 * 1024: ++ return None ++ tree = json.loads(receive(length)) ++ pending = [tree] ++ while pending: ++ node = pending.pop() ++ if not isinstance(node, dict): ++ return None ++ props = node.get('window_properties') or {} ++ if not isinstance(props, dict): ++ return None ++ app_id = node.get('app_id') ++ wm_class = props.get('class') ++ if node.get('focused') is True and (app_id or wm_class): ++ identity = app_id or wm_class ++ if not isinstance(identity, str) or not isinstance(node.get('id'), int): ++ return None ++ return {'class': identity, 'app_id': app_id, ++ 'title': node.get('name') or '', 'source': 'sway', ++ 'sway_id': node['id'], 'xwayland': not bool(app_id), ++ 'x11_window_id': node.get('window')} ++ for key in ('nodes', 'floating_nodes'): ++ children = node.get(key, []) ++ if not isinstance(children, list): ++ return None ++ pending.extend(children) ++ except (OSError, ValueError, TypeError, RecursionError, struct.error): ++ pass ++ return None ++ + def _get_active_window_info(self) -> Optional[Dict[str, Any]]: + """Get active window info, trying multiple compositor APIs.""" ++ # WHY: under Sway, XWayland's last active window can be stale while a ++ # native Wayland terminal is focused. Never fall through on IPC failure. ++ if os.environ.get('SWAYSOCK'): ++ return self._get_sway_window_info() ++ + # Niri + try: + result = subprocess.run( +@@ -1821,12 +1888,16 @@ + ) -> bool: + """Copy text to clipboard, then trigger the compositor-native paste path.""" + try: +- window_lookup_needed = self._active_window_lookup_needed() ++ window_lookup_needed = bool(os.environ.get('SWAYSOCK')) or self._active_window_lookup_needed() + window_info = ( + self._get_active_window_info() + if window_lookup_needed + else None + ) ++ if os.environ.get('SWAYSOCK') and window_info is None: ++ # Fail closed before publishing potentially private text. ++ log("Sway focus unavailable; delivery not dispatched.") ++ return False + gnome_wayland_session = self._is_gnome_wayland_session() + paste_chord, app_match = self._resolve_paste_chord(window_info) + if ( +@@ -1888,14 +1959,14 @@ + with self._clipboard_lock: + if stream is not None: + return self._paste_via_clipboard( +- text, paste_chord, gnome_wayland_session, auto_submit, stream=stream) +- return self._paste_via_clipboard(text, paste_chord, gnome_wayland_session, auto_submit) ++ text, paste_chord, gnome_wayland_session, auto_submit, stream=stream, expected_window=window_info) ++ return self._paste_via_clipboard(text, paste_chord, gnome_wayland_session, auto_submit, expected_window=window_info) + + except Exception as e: + log(f"Clipboard+hotkey injection failed: {e}") + return False + +- def _paste_via_clipboard(self, text, paste_chord, gnome_wayland_session, auto_submit, stream=None): ++ def _paste_via_clipboard(self, text, paste_chord, gnome_wayland_session, auto_submit, stream=None, expected_window=None): + """Deliver while owning _clipboard_lock, excluding background restores. + + With a stream session, the clipboard is saved before its first chunk +@@ -1931,9 +2002,39 @@ + # taken when direct typing was skipped for a non-US layout / non-ASCII text. + if stream is not None and stream.cancelled.is_set(): + return False ++ sway_xwayland = False ++ if os.environ.get('SWAYSOCK'): ++ # WHY: clipboard publication/settling may take seconds. Recheck ++ # after it, at delivery time; never paste into a changed focus or ++ # activate a previous window. A failed check leaves text recoverable. ++ current = self._get_sway_window_info() ++ if not expected_window or not current or any( ++ current.get(key) != expected_window.get(key) ++ for key in ('sway_id', 'class', 'app_id', 'x11_window_id') ++ ): ++ log("Sway focus changed or unavailable; paste not dispatched.") ++ return False ++ sway_xwayland = current.get('xwayland', False) ++ if sway_xwayland: ++ # CONTRACT: XTest is used only for Sway-confirmed XWayland. ++ # wtype can exit 0 while XWayland receives a different keysym ++ # after its temporary keymap. Verify X focus before XTest. ++ try: ++ active = subprocess.run(['xdotool', 'getactivewindow'], ++ capture_output=True, text=True, timeout=0.5) ++ if active.returncode or int(active.stdout.strip()) != current.get('x11_window_id'): ++ return False ++ except (OSError, ValueError, subprocess.TimeoutExpired): ++ return False + pasted = False + hyprland_ydotool = self._use_hyprland_ydotool() +- if hyprland_ydotool: ++ if sway_xwayland: ++ # Do not retry another transport after a possibly dispatched key. ++ pasted = bool(getattr(self, 'xdotool_available', False)) and self._send_paste_keys_xdotool(paste_chord) ++ if not pasted: ++ log("Sway XWayland paste failed; not retrying another transport.") ++ return False ++ elif hyprland_ydotool: + pasted = self.ydotool_available and self._send_paste_keys_slow(paste_chord) + if not pasted: + log("Hyprland ydotool paste failed; text is on the clipboard. Not trying another backend.") diff --git a/quickshell/.config/quickshell/labfy-sway/Bar.qml b/quickshell/.config/quickshell/labfy-sway/Bar.qml index 091c3fcab..c0bf4f92f 100644 --- a/quickshell/.config/quickshell/labfy-sway/Bar.qml +++ b/quickshell/.config/quickshell/labfy-sway/Bar.qml @@ -30,6 +30,7 @@ PanelWindow { required property var keepAwakeController required property var drawerService required property var osdService + required property var hyprwhsprService required property var monitorService screen: modelData FileView { @@ -614,6 +615,7 @@ PanelWindow { // INVARIANT: la zone droite s'étend depuis le bord sans modifier l'ancrage central. RightStatusArea { id: rightStatusArea + hyprwhsprService: bar.hyprwhsprService anchors.right: monitorIndicator.left anchors.rightMargin: 5 anchors.verticalCenter: parent.verticalCenter diff --git a/quickshell/.config/quickshell/labfy-sway/shell.qml b/quickshell/.config/quickshell/labfy-sway/shell.qml index 140d40d65..8360d2522 100644 --- a/quickshell/.config/quickshell/labfy-sway/shell.qml +++ b/quickshell/.config/quickshell/labfy-sway/shell.qml @@ -13,6 +13,7 @@ import "osd" import "applications" import "dock" import "systemmonitor" +import "status" ShellRoot { id: shell @@ -28,6 +29,8 @@ ShellRoot { // abonnement window ni son propre ordonnanceur de commandes. DrawerService { id: shellDrawerService; authenticationActive: shell.authenticationActive } MonitorService { id: shellMonitorService } + // CONTRACT: a single dictation monitor serves every output. + HyprwhsprService { id: shellHyprwhsprService } OsdService { id: shellOsdService; authenticationActive: shell.authenticationActive } signal authenticationOpening() function choosePolkitScreen(output) { @@ -260,6 +263,7 @@ ShellRoot { model: Quickshell.screens Bar { + hyprwhsprService: shellHyprwhsprService drawerService: shellDrawerService osdService: shellOsdService monitorService: shellMonitorService diff --git a/quickshell/.config/quickshell/labfy-sway/status/HyprwhsprIndicator.qml b/quickshell/.config/quickshell/labfy-sway/status/HyprwhsprIndicator.qml new file mode 100644 index 000000000..bffc1244c --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/status/HyprwhsprIndicator.qml @@ -0,0 +1,37 @@ +import QtQuick +import "../components" +import "../theme" + +Item { + id: indicator + required property var service + property bool authenticationActive: false + width: 26; height: 26 + Rectangle { + anchors.fill: parent + radius: 4 + color: pointer.containsMouse ? Theme.buttonHover : "transparent" + } + NerdIcon { + anchors.centerIn: parent + font.pixelSize: 18 + text: indicator.service.state === "error" ? "󰀪" : "󰍬" + color: indicator.service.state === "error" || indicator.service.state === "recording" + ? Theme.red : indicator.service.state === "processing" ? Theme.yellow : Theme.overlay2 + } + MouseArea { + id: pointer + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton + onClicked: { + if (!indicator.authenticationActive && !indicator.service.commandBusy) + indicator.service.toggle(); + } + } + StatusTooltip { + target: indicator + hovered: pointer.containsMouse && !indicator.authenticationActive + message: indicator.service.tooltip + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/status/HyprwhsprService.qml b/quickshell/.config/quickshell/labfy-sway/status/HyprwhsprService.qml new file mode 100644 index 000000000..0679d4bb0 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/status/HyprwhsprService.qml @@ -0,0 +1,122 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +Scope { + id: service + property string state: "error" + property string problem: "Vérification de Hyprwhspr" + property string phase: "" + property bool commandBusy: false + property bool refreshPending: false + property string commandError: "" + readonly property string tooltip: problem || (state === "recording" + ? "Dictée en cours" : state === "processing" ? "Transcription en cours" : "Dictée inactive") + readonly property int pollInterval: state === "recording" || state === "processing" ? 400 : 1000 + + // WHY: status returns Idle/0 even with no daemon. Service availability is + // checked first; only exact installed CLI responses can confirm recording. + // INVARIANT: one child at a time, no queued polls and no optimistic toggle. + function launch(kind, argv) { + phase = kind; + runner.command = ["timeout", "--signal=TERM", "--kill-after=1s", "3s"].concat(argv); + runner.running = true; + } + function refresh() { + if (runner.running) { refreshPending = true; return; } + poll.stop(); + refreshPending = false; + launch("availability", ["systemctl", "--user", "is-active", "hyprwhspr.service"]); + } + function toggle() { + if (commandBusy) return; + commandBusy = true; + commandError = ""; + poll.stop(); + // CONTRACT: defer a click until an in-flight read exits; never overlap + // commands, and retain the click lock until the confirming read ends. + if (!runner.running) launch("toggle", ["hyprwhspr", "record", "toggle"]); + } + function finish() { + phase = ""; + commandBusy = false; + if (commandError) { state = "error"; problem = commandError; commandError = ""; } + if (refreshPending) refresh(); + else poll.restart(); + } + function completed(code, output) { + if (commandBusy && phase !== "toggle" && phase !== "confirm" && phase !== "confirmStatus") { + launch("toggle", ["hyprwhspr", "record", "toggle"]); + return; + } + if (phase === "toggle") { + // WHY: installed CLI reports send_control failures as [ERROR] but + // can exit 0. Never equate exit success with daemon confirmation. + if (code !== 0 || output.indexOf("[ERROR]") >= 0 + || !/^\[SUCCESS\] Recording (started|stopped)$/.test(output)) + commandError = "Échec de la commande Hyprwhspr"; + launch("confirm", ["systemctl", "--user", "is-active", "hyprwhspr.service"]); + } else if (phase === "availability" || phase === "confirm") { + if (code !== 0 || output !== "active") { + state = "error"; + problem = "Service Hyprwhspr indisponible"; + finish(); + } else { + launch("status", ["hyprwhspr", "record", "status"]); + // Distinguish confirmation from an interrupted background read. + if (commandBusy) phase = "confirmStatus"; + } + } else { + if (code === 0 && output === "[INFO] Status: Recording in progress") { + state = "recording"; problem = ""; + } else if (code === 0 && output === "[INFO] Status: Idle") { + // CONTRACT: only the native OSD marker proves processing; an + // absent marker means no observable transcription state. + const visual = visualizer.text().trim(); + state = visual === "processing" ? "processing" : visual === "error" ? "error" : "idle"; + problem = visual === "error" ? "Erreur de transcription Hyprwhspr" : ""; + } else { + state = "error"; problem = "État Hyprwhspr illisible ou délai dépassé"; + } + finish(); + } + } + Process { + id: runner + // CONTRACT: capture control/status text only; never log stdout/stderr, + // audio or transcription. timeout bounds each invocation's lifetime. + stdout: StdioCollector { id: output; waitForEnd: true } + stderr: StdioCollector { } + onExited: (code, status) => service.completed(code, output.text.trim()) + } + FileView { + // Native change notification accelerates keyboard/automatic stops. + // Polling is still required for missing/recreated files and service loss. + path: Quickshell.env("XDG_RUNTIME_DIR") ? Quickshell.env("XDG_RUNTIME_DIR") + + "/hyprwhspr/recording_status" : "" + watchChanges: true + printErrors: false + onFileChanged: { reload(); service.refresh(); } + } + FileView { + id: visualizer + path: Quickshell.env("XDG_RUNTIME_DIR") ? Quickshell.env("XDG_RUNTIME_DIR") + + "/hyprwhspr/visualizer_state" : "" + watchChanges: true + printErrors: false + // WHY: this file contains state labels only. Never read transcript_preview. + onFileChanged: { reload(); service.refresh(); } + onLoaded: service.refresh() + } + Timer { id: poll; interval: service.pollInterval; onTriggered: service.refresh() } + Component.onCompleted: refresh() + IpcHandler { + target: "dictationStatus" + // Read-only diagnosis: no IPC entry point can start a recording. + function state(): string { + return JSON.stringify({state: service.state, tooltip: service.tooltip, + busy: service.commandBusy, running: runner.running, + interval: service.pollInterval}); + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml index 5c6055b62..53f165af3 100644 --- a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml +++ b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml @@ -5,6 +5,7 @@ import "StatusColorRoles.js" as StatusColorRoles Row { id: area + required property var hyprwhsprService required property var barWindow required property var wifiDevice required property var adapter @@ -40,6 +41,10 @@ Row { spacing: 6 height: 26 NetworkIndicator { wifiDevice: area.wifiDevice; onActivated: area.openPageRequested(1) } + HyprwhsprIndicator { + service: area.hyprwhsprService + authenticationActive: area.barWindow.authenticationActive + } BluetoothIndicator { adapter: area.adapter; onActivated: area.openPageRequested(2) } BatteryIndicator { id: batteryIndicator; onActivated: area.openPageRequested(5) } ScrollIcon { diff --git a/tests/test_hyprwhspr_sway_focus.py b/tests/test_hyprwhspr_sway_focus.py new file mode 100644 index 000000000..95be2caf8 --- /dev/null +++ b/tests/test_hyprwhspr_sway_focus.py @@ -0,0 +1,184 @@ +"""Apply the maintained patch to installed upstream, then test real patched methods. + +No audio, clipboard, keyboard event, window focus or service mutation occurs. +""" +import importlib.util +import json +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = Path('/usr/lib/hyprwhspr/lib/src') + + +class Wire: + def __init__(self, data): self.data = data + def __enter__(self): return self + def __exit__(self, *args): pass + def settimeout(self, value): pass + def connect(self, path): pass + def sendall(self, data): self.sent = data + def recv(self, count): + part, self.data = self.data[:min(count, 7)], self.data[min(count, 7):] + return part + + +class FocusTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.folder = tempfile.TemporaryDirectory() + root = Path(cls.folder.name) + (root/'lib/src').mkdir(parents=True) + shutil.copy2(SOURCE/'text_injector.py', root/'lib/src/text_injector.py') + subprocess.run(['patch', '--batch', '--fuzz=0', '-p1', '-i', + str(ROOT/'hyprwhspr/integration/sway-focus-paste.patch')], + cwd=root, check=True, capture_output=True) + sys.path.insert(0, str(SOURCE)) + spec = importlib.util.spec_from_file_location('patched_injector', root/'lib/src/text_injector.py') + cls.module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = cls.module + spec.loader.exec_module(cls.module) + + @classmethod + def tearDownClass(cls): + cls.folder.cleanup() + sys.path.remove(str(SOURCE)) + + def setUp(self): + self.env = patch.dict(os.environ, SWAYSOCK='/fake/sway.sock') + self.env.start(); self.addCleanup(self.env.stop) + self.injector = self.module.TextInjector.__new__(self.module.TextInjector) + self.injector.config_manager = None + self.injector.wtype_available = True + self.injector.ydotool_available = True + self.injector.xdotool_available = True + self.injector._is_hyprland_session = Mock(return_value=False) + self.injector._is_x11_session = Mock(return_value=False) + self.injector._is_gnome_wayland_session = Mock(return_value=False) + self.logs = patch.object(self.module, 'log') + self.logs.start(); self.addCleanup(self.logs.stop) + + def read_tree(self, tree): + data = json.dumps(tree).encode() + wire = Wire(b'i3-ipc'+struct.pack(' 180) { console.error("FAIL deadline"); Qt.exit(1); } + if (backend.phase !== "" || backend.commandBusy) return; + if (step === 0) { + check(backend.state === "INITIAL", "initial state"); + setMode("recording"); backend.refresh(); + } else if (step === 1) { + check(backend.state === "recording", "external recording"); + setMode("idle"); backend.refresh(); + } else if (step === 2) { + check(backend.state === "idle", "external stop"); + setMode("offline"); backend.refresh(); + } else if (step === 3) { + check(backend.state === "error", "offline"); + setMode("idle"); backend.refresh(); + } else if (step === 4) { + check(backend.state === "idle", "restart"); + backend.toggle(); backend.toggle(); + } else if (step === 5) { + check(backend.state === "recording", "confirmed toggle"); + setMode("reject"); backend.toggle(); + } else if (step === 6) { + check(backend.state === "error", "zero exit command failure"); + setMode("bad"); backend.refresh(); + } else if (step === 7) { + check(backend.state === "error", "unknown status"); + setMode("idle"); visual.setText("processing"); backend.refresh(); + } else if (step === 8) { + check(backend.state === "processing", "native processing"); + check(backend.pollInterval === 400, "processing cadence"); + visual.setText("success"); backend.refresh(); + } else if (step === 9) { + check(backend.state === "idle", "native processing end"); + setMode("slow"); backend.refresh(); backend.refresh(); backend.refresh(); + } else if (step === 10) { + check(backend.state === "error", "bounded slow command"); + setMode("idle"); backend.refresh(); + } else { + check(backend.state === "idle", "recovery after timeout"); + console.info("PASS backend transitions"); Qt.quit(); + } + step++; + } + } +} +'''.replace('INITIAL', initial).replace('BACKEND', BACKEND.as_uri()).replace('FOLDER', str(p))) + env = dict(os.environ, PATH=f'{p}:/usr/bin', DICTATION_FIXTURE=str(p), + QT_QPA_PLATFORM='offscreen', XDG_RUNTIME_DIR=str(p/'runtime')) + result = subprocess.run([QS, '-p', str(p), '--no-color'], env=env, + capture_output=True, text=True, timeout=25) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('PASS backend transitions', result.stdout + result.stderr) + self.assertEqual((p/'toggles').read_text().splitlines(), ['toggle', 'toggle']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/hyprwhspr-sway-key-probe.py b/tools/hyprwhspr-sway-key-probe.py new file mode 100644 index 000000000..855e235d6 --- /dev/null +++ b/tools/hyprwhspr-sway-key-probe.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Opt-in real key test in a disposable XWayland widget; never read clipboard. + +Every fixture key binding returns break, so even a paste chord cannot invoke +clipboard_get or a normal editor action. Text-copy methods are mocked in the +production injector. Only keysyms/modifier masks are reported. +""" +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import time +from unittest.mock import Mock + + +def fixture(result, title): + import tkinter as tk + window = tk.Tk(className='HyprwhsprSafeFixture') + window.title(title) + text = tk.Text(window) + text.insert('1.0', 'Synthetic key test; clipboard access disabled') + text.pack(); text.focus_set() + events = [] + def key(event): + events.append({'keysym': event.keysym, 'state': event.state}) + Path(result).write_text(json.dumps(events)) + return 'break' + text.bind('', key) + window.after(15000, window.destroy) + window.mainloop() + + +def nodes(node): + yield node + for key in ('nodes', 'floating_nodes'): + for child in node.get(key, []): + yield from nodes(child) + + +def tree(): + return list(nodes(json.loads(subprocess.check_output(['swaymsg', '-t', 'get_tree', '-r'])))) + + +def focus(identifier): + subprocess.run(['swaymsg', f'[con_id={identifier}] focus'], check=True, capture_output=True, timeout=2) + + +def probe(): + overlay = Path.home()/'.local/share/hyprwhspr-swayfx/1.52.1-sway-v1/lib/src' + sys.path.insert(0, str(overlay)) + from text_injector import TextInjector + original = next(n['id'] for n in tree() if n.get('focused')) + with tempfile.TemporaryDirectory(prefix='hyprwhspr-keys-') as folder: + result = Path(folder)/'events.json' + title = f'Hyprwhspr safe key fixture {Path(folder).name}' + child = subprocess.Popen([sys.executable, __file__, '--fixture', str(result), title]) + injector = None + try: + deadline = time.monotonic()+5 + target = None + while time.monotonic() < deadline: + target = next((n for n in tree() if n.get('name') == title), None) + if target: break + time.sleep(.1) + if not target: raise RuntimeError('Fixture did not map') + focus(target['id']) + injector = TextInjector() + info = injector._get_active_window_info() + if not info or not info.get('xwayland') or info['sway_id'] != target['id']: + raise RuntimeError('Fixture is not focused XWayland') + injector._save_clipboard = Mock(return_value=None) + injector._copy_text_to_clipboard = Mock(return_value=True) + injector._restore_clipboard = Mock() + for label, action in [ + ('upstream-wtype', lambda: injector._send_paste_keys_wtype('ctrl+v')), + ('patched-dispatch', lambda: injector._paste_via_clipboard( + 'Synthetic text only', 'ctrl+v', False, False, expected_window=info))]: + if injector._get_sway_window_info() != info: + raise RuntimeError('Focus changed; refusing test') + previous = len(json.loads(result.read_text())) if result.exists() else 0 + ok = action(); time.sleep(.15) + events = json.loads(result.read_text())[previous:] if result.exists() else [] + print(label, ok, events) + if label == 'patched-dispatch' and not (ok and any(e['keysym']=='v' and e['state'] & 4 for e in events)): + raise RuntimeError('Patched Ctrl+V not observed') + finally: + if injector: injector.close() + child.terminate() + try: child.wait(timeout=3) + except subprocess.TimeoutExpired: + child.kill(); child.wait(timeout=3) + focus(original) + + +if __name__ == '__main__': + if len(sys.argv) > 1 and sys.argv[1] == '--fixture': fixture(*sys.argv[2:]) + else: probe()