feat(quickshell): add Hyprwhspr dictation indicator and SwayFX support
This commit is contained in:
parent
b39ceda43c
commit
bc98626c80
13 changed files with 1101 additions and 0 deletions
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -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
|
||||
|
|
|
|||
180
docs/hyprwhspr-swayfx-focus-and-paste-fix-v1.md
Normal file
180
docs/hyprwhspr-swayfx-focus-and-paste-fix-v1.md
Normal file
|
|
@ -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/<pid>/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.
|
||||
99
docs/quickshell-sway-hyprwhspr-integration-v1.md
Normal file
99
docs/quickshell-sway-hyprwhspr-integration-v1.md
Normal file
|
|
@ -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.
|
||||
75
hyprwhspr/integration/install-sway-fix.py
Normal file
75
hyprwhspr/integration/install-sway-fix.py
Normal file
|
|
@ -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()
|
||||
171
hyprwhspr/integration/sway-focus-paste.patch
Normal file
171
hyprwhspr/integration/sway-focus-paste.patch
Normal file
|
|
@ -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('<II', 0, 4))
|
||||
+ header = receive(14)
|
||||
+ length, kind = struct.unpack('<II', header[6:])
|
||||
+ if header[:6] != b'i3-ipc' or kind != 4 or length > 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.")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
184
tests/test_hyprwhspr_sway_focus.py
Normal file
184
tests/test_hyprwhspr_sway_focus.py
Normal file
|
|
@ -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('<II', len(data), 4)+data)
|
||||
with patch.object(self.module.socket, 'socket', return_value=wire):
|
||||
result = self.injector._get_active_window_info()
|
||||
self.assertEqual(wire.sent, b'i3-ipc'+struct.pack('<II', 0, 4))
|
||||
return result
|
||||
|
||||
def window(self, app='kitty', **extra):
|
||||
return dict(id=42, focused=True, app_id=app, name='Synthetic title', **extra)
|
||||
|
||||
def test_kitty(self):
|
||||
info = self.read_tree({'nodes':[self.window()]})
|
||||
self.assertEqual(info['class'], 'kitty')
|
||||
self.assertEqual(self.injector._resolve_paste_chord(info)[0], 'ctrl+shift+v')
|
||||
|
||||
def test_firefox(self):
|
||||
info = self.read_tree({'floating_nodes':[self.window('firefox')]})
|
||||
self.assertEqual(info['source'], 'sway')
|
||||
self.assertFalse(info['xwayland'])
|
||||
self.assertEqual(self.injector._resolve_paste_chord(info)[0], 'ctrl+v')
|
||||
|
||||
def test_chatgpt(self):
|
||||
info = self.read_tree({'nodes':[self.window(None, window=123, window_properties={'class':'Chatgpt'})]})
|
||||
self.assertEqual(info['class'], 'Chatgpt')
|
||||
self.assertTrue(info['xwayland'])
|
||||
self.assertIn('chatgpt', self.injector.focused_window_identifiers(info))
|
||||
self.assertEqual(self.injector._resolve_paste_chord(info)[0], 'ctrl+v')
|
||||
|
||||
def test_stale_xwayland_not_selected(self):
|
||||
info = self.read_tree({'nodes':[dict(self.window(None,window_properties={'class':'Chatgpt'}),focused=False), self.window()]})
|
||||
self.assertEqual(info['class'], 'kitty')
|
||||
|
||||
def test_missing_socket_no_fallback(self):
|
||||
with patch.object(self.module.subprocess, 'run') as run:
|
||||
self.assertIsNone(self.injector._get_active_window_info())
|
||||
run.assert_not_called()
|
||||
|
||||
def test_wire_errors(self):
|
||||
for data in [b'', b'i3-ipc'+struct.pack('<II', 9*1024*1024, 4),
|
||||
b'bad-ip'+struct.pack('<II', 0, 4),
|
||||
b'i3-ipc'+struct.pack('<II', 0, 2),
|
||||
b'i3-ipc'+struct.pack('<II', 1, 4)+b'{']:
|
||||
with self.subTest(data=data[:14]), patch.object(self.module.socket,'socket',return_value=Wire(data)):
|
||||
self.assertIsNone(self.injector._get_sway_window_info())
|
||||
|
||||
def test_timeout(self):
|
||||
with patch.object(self.module.socket,'socket',side_effect=TimeoutError):
|
||||
self.assertIsNone(self.injector._get_sway_window_info())
|
||||
|
||||
def test_other_compositor_unchanged(self):
|
||||
with patch.dict(os.environ), patch.object(self.module.subprocess,'run',return_value=Mock(returncode=0,stdout='{"app_id":"kitty"}')):
|
||||
os.environ.pop('SWAYSOCK',None)
|
||||
self.assertEqual(self.injector._get_active_window_info()['source'],'niri')
|
||||
|
||||
def paste(self, current, expected=None, xfocus='123', xresult=True):
|
||||
i = self.injector
|
||||
i._save_clipboard = Mock(return_value=None)
|
||||
i._copy_text_to_clipboard = Mock(return_value=True)
|
||||
i._get_sway_window_info = Mock(return_value=current)
|
||||
i._send_paste_keys_wtype = Mock(return_value=True)
|
||||
i._send_paste_keys_xdotool = Mock(return_value=xresult)
|
||||
i._send_paste_keys_slow = Mock(return_value=True)
|
||||
i._restore_clipboard = Mock()
|
||||
i._clear_stuck_modifiers = Mock()
|
||||
i._send_enter_if_auto_submit = Mock()
|
||||
with patch.object(self.module.time,'sleep'), patch.object(self.module.subprocess,'run',return_value=Mock(returncode=0,stdout=xfocus)):
|
||||
return i._paste_via_clipboard('Synthetic fixture only', 'ctrl+v', False, False, expected_window=expected or current)
|
||||
|
||||
def info(self, xwayland=False):
|
||||
return dict(sway_id=42, app_id=None if xwayland else 'firefox',
|
||||
**{'class':'Chatgpt' if xwayland else 'firefox'},
|
||||
xwayland=xwayland, x11_window_id=123 if xwayland else None)
|
||||
|
||||
def test_xwayland_transport(self):
|
||||
self.assertTrue(self.paste(self.info(True)))
|
||||
self.injector._send_paste_keys_xdotool.assert_called_once_with('ctrl+v')
|
||||
self.injector._send_paste_keys_wtype.assert_not_called()
|
||||
|
||||
def test_firefox_transport(self):
|
||||
self.assertTrue(self.paste(self.info()))
|
||||
self.injector._send_paste_keys_wtype.assert_called_once_with('ctrl+v')
|
||||
self.injector._send_paste_keys_xdotool.assert_not_called()
|
||||
|
||||
def test_focus_changed_before_dispatch(self):
|
||||
self.assertFalse(self.paste(dict(self.info(), sway_id=99), self.info()))
|
||||
self.injector._send_paste_keys_wtype.assert_not_called()
|
||||
self.injector._send_paste_keys_xdotool.assert_not_called()
|
||||
|
||||
def test_ipc_lost_before_dispatch(self):
|
||||
self.assertFalse(self.paste(None, self.info()))
|
||||
self.injector._send_paste_keys_wtype.assert_not_called()
|
||||
|
||||
def test_x_focus_mismatch(self):
|
||||
self.assertFalse(self.paste(self.info(True), xfocus='456'))
|
||||
self.injector._send_paste_keys_xdotool.assert_not_called()
|
||||
|
||||
def test_xwayland_failure_no_retry(self):
|
||||
self.assertFalse(self.paste(self.info(True), xresult=False))
|
||||
self.injector._send_paste_keys_wtype.assert_not_called()
|
||||
self.injector._send_paste_keys_slow.assert_not_called()
|
||||
|
||||
def test_failure_before_clipboard(self):
|
||||
i = self.injector
|
||||
i._get_active_window_info = Mock(return_value=None)
|
||||
i._copy_text_to_clipboard = Mock()
|
||||
self.assertFalse(i._inject_via_clipboard_and_hotkey('Synthetic fixture'))
|
||||
i._copy_text_to_clipboard.assert_not_called()
|
||||
|
||||
def test_per_application_override(self):
|
||||
self.injector.config_manager = Mock()
|
||||
self.injector.config_manager.get_setting.side_effect = lambda key, default=None: {'applications':{'firefox':{'auto_paste':'ctrl+y'}}}.get(key,default)
|
||||
self.assertEqual(self.injector._resolve_paste_chord({'class':'firefox'}), ('ctrl+y', 'firefox'))
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
123
tests/test_quickshell_hyprwhspr.py
Normal file
123
tests/test_quickshell_hyprwhspr.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Exercise the production QML backend with fake CLI tools, never the microphone."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BACKEND = ROOT / 'quickshell/.config/quickshell/labfy-sway/status'
|
||||
LOCAL_QS = Path.home() / '.local/opt/quickshell-qt612-popup/bin/quickshell'
|
||||
QS = os.environ.get('QUICKSHELL_BIN', str(LOCAL_QS) if LOCAL_QS.exists() else shutil.which('quickshell'))
|
||||
|
||||
|
||||
class DictationTest(unittest.TestCase):
|
||||
def test_production_backend(self):
|
||||
self.run_backend("idle")
|
||||
|
||||
def test_starting_during_recording(self):
|
||||
self.run_backend("recording")
|
||||
|
||||
def run_backend(self, initial):
|
||||
with tempfile.TemporaryDirectory(prefix='qs-dictation-test-') as folder:
|
||||
p = Path(folder)
|
||||
(p/'runtime').mkdir(mode=0o700)
|
||||
(p/'runtime/hyprwhspr').mkdir()
|
||||
# CONTRACT: PATH isolates both CLI names; toggles touch a fake marker
|
||||
# only. No live Hyprwhspr command is reachable from this fixture.
|
||||
tool = '''#!/usr/bin/python3
|
||||
import os, pathlib, sys, time
|
||||
p = pathlib.Path(os.environ['DICTATION_FIXTURE'])
|
||||
mode = (p/'mode').read_text()
|
||||
if pathlib.Path(sys.argv[0]).name == 'systemctl':
|
||||
print('inactive' if mode == 'offline' else 'active')
|
||||
sys.exit(3 if mode == 'offline' else 0)
|
||||
if sys.argv[-1] == 'toggle':
|
||||
with (p/'toggles').open('a') as f: f.write('toggle\\n')
|
||||
if mode == 'reject': print('[ERROR] Service not listening on control FIFO')
|
||||
else:
|
||||
(p/'mode').write_text('recording')
|
||||
print('[SUCCESS] Recording started')
|
||||
else:
|
||||
if mode == 'slow': time.sleep(20)
|
||||
print('[INFO] Status: Recording in progress' if mode == 'recording' else
|
||||
'unexpected' if mode == 'bad' else '[INFO] Status: Idle')
|
||||
'''
|
||||
for name in ['systemctl', 'hyprwhspr']:
|
||||
(p/name).write_text(tool)
|
||||
(p/name).chmod(0o755)
|
||||
(p/'mode').write_text(initial)
|
||||
(p/'shell.qml').write_text('''import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import "BACKEND"
|
||||
ShellRoot {
|
||||
HyprwhsprService { id: backend }
|
||||
FileView { id: mode; path: "FOLDER/mode" }
|
||||
FileView { id: visual; path: "FOLDER/runtime/hyprwhspr/visualizer_state" }
|
||||
property int step: 0
|
||||
property int ticks: 0
|
||||
function check(ok, message) {
|
||||
if (!ok) { console.error("FAIL " + message); Qt.exit(1); }
|
||||
}
|
||||
function setMode(value) { mode.setText(value); }
|
||||
Timer {
|
||||
interval: 100; running: true; repeat: true
|
||||
onTriggered: {
|
||||
if (++ticks > 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()
|
||||
98
tools/hyprwhspr-sway-key-probe.py
Normal file
98
tools/hyprwhspr-sway-key-probe.py
Normal file
|
|
@ -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('<KeyPress>', 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()
|
||||
Loading…
Reference in a new issue