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.
|
# These upstream unified diffs preserve source whitespace needed by patch.
|
||||||
tools/slurp-keymap-guard.patch whitespace=-blank-at-eol,-space-before-tab
|
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
|
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 keepAwakeController
|
||||||
required property var drawerService
|
required property var drawerService
|
||||||
required property var osdService
|
required property var osdService
|
||||||
|
required property var hyprwhsprService
|
||||||
required property var monitorService
|
required property var monitorService
|
||||||
screen: modelData
|
screen: modelData
|
||||||
FileView {
|
FileView {
|
||||||
|
|
@ -614,6 +615,7 @@ PanelWindow {
|
||||||
// INVARIANT: la zone droite s'étend depuis le bord sans modifier l'ancrage central.
|
// INVARIANT: la zone droite s'étend depuis le bord sans modifier l'ancrage central.
|
||||||
RightStatusArea {
|
RightStatusArea {
|
||||||
id: rightStatusArea
|
id: rightStatusArea
|
||||||
|
hyprwhsprService: bar.hyprwhsprService
|
||||||
anchors.right: monitorIndicator.left
|
anchors.right: monitorIndicator.left
|
||||||
anchors.rightMargin: 5
|
anchors.rightMargin: 5
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import "osd"
|
||||||
import "applications"
|
import "applications"
|
||||||
import "dock"
|
import "dock"
|
||||||
import "systemmonitor"
|
import "systemmonitor"
|
||||||
|
import "status"
|
||||||
|
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
id: shell
|
id: shell
|
||||||
|
|
@ -28,6 +29,8 @@ ShellRoot {
|
||||||
// abonnement window ni son propre ordonnanceur de commandes.
|
// abonnement window ni son propre ordonnanceur de commandes.
|
||||||
DrawerService { id: shellDrawerService; authenticationActive: shell.authenticationActive }
|
DrawerService { id: shellDrawerService; authenticationActive: shell.authenticationActive }
|
||||||
MonitorService { id: shellMonitorService }
|
MonitorService { id: shellMonitorService }
|
||||||
|
// CONTRACT: a single dictation monitor serves every output.
|
||||||
|
HyprwhsprService { id: shellHyprwhsprService }
|
||||||
OsdService { id: shellOsdService; authenticationActive: shell.authenticationActive }
|
OsdService { id: shellOsdService; authenticationActive: shell.authenticationActive }
|
||||||
signal authenticationOpening()
|
signal authenticationOpening()
|
||||||
function choosePolkitScreen(output) {
|
function choosePolkitScreen(output) {
|
||||||
|
|
@ -260,6 +263,7 @@ ShellRoot {
|
||||||
model: Quickshell.screens
|
model: Quickshell.screens
|
||||||
|
|
||||||
Bar {
|
Bar {
|
||||||
|
hyprwhsprService: shellHyprwhsprService
|
||||||
drawerService: shellDrawerService
|
drawerService: shellDrawerService
|
||||||
osdService: shellOsdService
|
osdService: shellOsdService
|
||||||
monitorService: shellMonitorService
|
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 {
|
Row {
|
||||||
id: area
|
id: area
|
||||||
|
required property var hyprwhsprService
|
||||||
required property var barWindow
|
required property var barWindow
|
||||||
required property var wifiDevice
|
required property var wifiDevice
|
||||||
required property var adapter
|
required property var adapter
|
||||||
|
|
@ -40,6 +41,10 @@ Row {
|
||||||
spacing: 6
|
spacing: 6
|
||||||
height: 26
|
height: 26
|
||||||
NetworkIndicator { wifiDevice: area.wifiDevice; onActivated: area.openPageRequested(1) }
|
NetworkIndicator { wifiDevice: area.wifiDevice; onActivated: area.openPageRequested(1) }
|
||||||
|
HyprwhsprIndicator {
|
||||||
|
service: area.hyprwhsprService
|
||||||
|
authenticationActive: area.barWindow.authenticationActive
|
||||||
|
}
|
||||||
BluetoothIndicator { adapter: area.adapter; onActivated: area.openPageRequested(2) }
|
BluetoothIndicator { adapter: area.adapter; onActivated: area.openPageRequested(2) }
|
||||||
BatteryIndicator { id: batteryIndicator; onActivated: area.openPageRequested(5) }
|
BatteryIndicator { id: batteryIndicator; onActivated: area.openPageRequested(5) }
|
||||||
ScrollIcon {
|
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