feat(quickshell): add Hyprwhspr dictation indicator and SwayFX support

This commit is contained in:
fy59 2026-11-07 22:14:31 +01:00
parent b39ceda43c
commit bc98626c80
13 changed files with 1101 additions and 0 deletions

1
.gitattributes vendored
View file

@ -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

View 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.

View 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.

View 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()

View 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.")

View file

@ -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

View file

@ -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

View file

@ -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
}
}

View file

@ -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});
}
}
}

View file

@ -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 {

View 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()

View 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()

View 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()