feat(post-processing): pipe transcriptions through an optional postprocessing script (#479)
* feat(post-processing): pipe transcriptions through user-defined script Add a configurable post-processing step that passes each transcription result to an external executable via stdin and uses its stdout as the text to type. Enables custom transforms (formatting, LLM rewriting, etc.) without modifying Vocalinux. Falls back to original text on script failure or timeout. * test(post-processor): add unit tests and extract apply_post_processing Move config-reading logic from main.py closure into apply_post_processing() in post_processor.py so it can be tested independently. Add 11 unit tests covering PostProcessor.process() and apply_post_processing(). * docs(post-processing): document post-processing script feature * fix formatting of one file * fix(post-processing): run the user script with a stripped host environment Rebase compat: the host_process AST guard requires every subprocess spawned for a host binary to pass env=host_env() so an AppImage's LD_LIBRARY_PATH/GI_TYPELIB_PATH/PYTHONHOME do not leak in. Also give apply_post_processing() a typed ConfigManager parameter and keep text_callback_wrapper's local variable typed as str for mypy. * fix(post-processing): preserve paragraph breaks and tighten script handling Take script stdout verbatim instead of rstripping every trailing newline, so the "new paragraph" voice command's break survives a passthrough script. A single trailing newline is still dropped, but only when the transcription had none — it is almost always just the line terminator echo/print-style tools append. Also stop writing transformed output to the log (it can carry sensitive dictation into retained logs), catch only the expected process/OS/encoding failures instead of every Exception, and add the missing signature annotations. * fix(main): run the post-processing script off the recognition thread A user script may take seconds per segment, and text callbacks run on the recognition thread that drains the bounded audio-segment queue; stalling it drops queued dictation once the queue fills. Submit post-processing and injection to a dedicated single-worker executor so the backlog waits instead of losing speech, while keeping injection order. On timeout or failure the worker falls back to the original text, and an unexpected error is logged with a traceback and still injects the original rather than silently dropping the segment. Also pin the callback tests' get_str mock to no-script so they exercise the synchronous path deterministically, and add a callback test proving transformed text (including preserved paragraph breaks) reaches the injector. * docs(post-processing): annotate new callbacks and clarify stdout contract Add the missing type annotations on the new Settings page callbacks per the repository guide, and document that script stdout is used verbatim (a lone trailing newline added by line-oriented tools is dropped only when the transcription had none) and that scripts run on a dedicated worker. * test(main): annotate new post-processing callback tests * fix(main): bind queued dictation to its target app and drain worker on quit Route every transcription segment through the single post-processing worker (a pass-through when no script is configured) so clearing the script path mid-queue can no longer let a later segment overtake an earlier one still running. A segment that can sit in the backlog — a script is configured or a job is still running — is bound to the application focused when it was dictated; the worker drops it if focus has verifiably moved to another application, so delayed dictation cannot land in the wrong app. Wire the worker into the tray quit path via an on_quit hook: it clears the acceptance flag, cancels pending jobs, and waits — bounded by the script's own timeout — so nothing injects after the injector stops. * fix(main): keep voice actions ordered and probe focus off the recognition thread - Queue voice-command actions on the post-processing worker so an action can no longer overtake dictated text still waiting to be injected. - Move the submit-time focus probe onto a dedicated executor; the recognition thread only submits the probe, and the job joins the result at inject time, so compositor tools can no longer stall consumption of the bounded audio-segment queue. - Re-check the quit flag after the inject-time focus re-probe, then shut both workers down without waiting on a running script so quitting does not freeze on the script timeout. * fix(main): capture submit-time focus on dedicated threads and serialize quit - Replace the shared focus-probe executor with a fresh daemon thread per submission so a slow earlier probe cannot delay a later one past the focus change it is meant to capture; jobs read the result off a one-slot queue at inject time. - Give queued voice actions the same submit-time focus binding as text segments so a queued shortcut is dropped instead of fired into a newly focused application. - Hold injection_lock across the accept-check plus inject/action call so quit waits only for an in-flight injection instead of racing it. - Queue the IDLE reset of last_injected_text behind pending worker jobs so a queued 'delete that' still sees the text it targets. * fix(main): keep actions instant on a free worker and bound quit wait - An action now starts a submit-time focus probe only when it actually queues behind other jobs; with a free worker there is no delay window to bind against, so the probe's compositor calls are skipped and the action stays immediate even when a script is configured. - Probe and executor submission now happen inside pending_jobs_lock so queue order matches callback order — a job that saw an empty queue can no longer slip behind one that arrived while it was submitting. - Every inject-path subprocess call (xdotool window/key/type, wtype and ydotool type) is bounded by a timeout, and the quit hook's injection_lock wait is capped, so quitting can no longer freeze on a stalled injector subprocess. * fix(main): bind every action to submit-time focus without slowing it down - Every voice action now starts a submit-time focus probe: submission never guarantees immediate execution, so skipping the binding left a focus-change window in which a shortcut could fire into another app. The worker waits on the probe for at most one second — milliseconds on a healthy desktop — so immediate actions stay immediate while a genuinely stalled probe falls back to the injector's own targeting. - Quit waits for an in-flight injection without a cap again: every inject-path subprocess is individually bounded, so the wait always ends with the injection and a long transcription is never cut off. - The wtype/ydotool typing timeout is now derived from the configured per-key delay (a generous multiple), so a legitimately slow type can never be cut mid-text; a timed-out type no longer falls through to the clipboard fallback, which would duplicate the partial fragment. * fix(main): drop unverified actions and make quit-time injection abortable - An action whose submit-time focus probe cannot answer inside its one-second bound is now dropped instead of running unverified: a stalled probe can no longer leave a shortcut pointing at whatever application happens to be focused. - Quit asks the injector to abort in-flight injections, and injection paths honour it: the xdotool chunk loop stops between chunks, the wtype/ydotool type call runs as a polling Popen that can be killed, and paste/shortcut/backspace entry points exit early. The quit hook still waits for an in-flight injection, but the wait is bounded by at most one already-running subprocess call — never the injection's full length — and no subprocess is ever stopped mid-call by a timeout cap on the wait itself. - Annotate the remaining untyped test helpers. * fix(main): wait out slow focus probes; restore run() in wayland typing - An action no longer races its submit-time focus probe on a one-second bound: the job waits for the probe's own answer, whose compositor calls already carry per-call timeouts and short-circuit absent tools. A slow but living probe delays the command instead of discarding it, so a valid "undo" can no longer be lost to timing alone, and focus verification still decides every drop. - Wayland typing goes back to subprocess.run so tests keep intercepting the same entry point. Abortability is preserved by typing in 200-character chunks with the shutdown check between calls — a whole-string call could not be interrupted, but no single chunk holds the injector for more than its own scaled budget. - The abort flag gains a class-level default so injectors built without __init__ in tests still answer the checks; each real instance gets its own event in __init__. - Update call-shape assertions for the typed-call timeout and finish annotating the test methods touched by the callback refactor. * fix(inject): never retype text a failed chunked call already sent - _PartiallyTyped carries the confirmed-delivered prefix length out of the chunked type loops, so every downstream fallback continues from the untyped remainder: the Wayland-to-xdotool switch types only the tail, and the clipboard fallback copies only the tail, never the whole string a second time. - The xdotool retry loop gains the same accounting — a retry now resumes at the first untyped chunk instead of restarting at the top and duplicating everything the failed attempt already delivered. - Annotate the remaining test signature the repository guide requires. * fix(inject): report partial typing as failure even when clipboard lands A chunked type call that dies mid-text leaves only a prefix on screen, and the remainder-fallback keeps it that way: it copies the tail for a manual paste but now returns False, so the caller no longer records the full transcription as injected and a following "delete that" cannot erase text that was never typed. * fix(inject): keep deletion state aligned with delivered text inject_text now records how many characters a call confirmed on screen as last_typed_count: the whole length on success, the typed prefix after a partial failure, -1 when the delivered amount is unknowable. The transcription caller uses it to keep last_injected_text honest — a following "delete that" erases the typed prefix or nothing, never the previous segment or text that never arrived. * fix(inject): never replay input a timed-out portal job may have delivered - RemoteDesktopPortalError.delivered=None marks an unreported job's count as unknowable; all three portal fallbacks demote and skip instead of replaying, and text injection surfaces as TimeoutExpired so no full-text clipboard copy is offered - _PartiallyTyped counts raised after a portal demote are rebased onto the original text (typed_prefix) so the caller clips the real prefix - evdev: a consumed pure-modifier press is withheld and replayed ahead of the first non-target press, so holding RightAlt for AltGr composition still reaches the application - history: extend_latest gains an expected_latest CAS guard so a late segment from an older worker merges only into its own session's snippet, never into a newer session's entry - quit path aborts in-flight injections before waiting on the lock so quitting cannot freeze behind a long transcription's typing budget - tests: stop _ensure_ydotoold leaking a real Popen in Wayland tests * fix(shortcut): replay withheld modifiers across devices and after SYN_DROPPED - Replay withheld modifier presses from every device, not only the fd the character arrived on: split keyboards deliver AltGr and the character on separate devices, and a per-fd check forwarded a plain character. - Let the post-SYN_DROPPED resync run when only withheld modifiers exist for the fd, so a press whose release the dropped burst ate is pruned instead of replaying a stale modifier on the next ordinary key. * fix(history,pads): keep late segments pad-bound deliveries and pad deletions correct - Track the worker behind the ended session's snippet so late segments from an older worker cannot merge into a newer session's entry - Skip the focus-change drop for dictation-pad segments and pad-bound editing commands, which never target the focused application - Refuse 'delete that' when a manual pad edit blurred the segment boundaries instead of deleting the stale recorded length * style: drop unneeded nonlocal for mutated worker set * fix(shortcut): share one evdev device layer across shortcut listeners Each KeyboardShortcutManager built its own EvdevKeyboardBackend, and on evdev every backend opened and grabbed the same physical keyboards: the first grabber received all events and the rest received none, so per-language shortcuts could never fire on Wayland. All backends now bind to a process-wide EvdevDeviceHub that opens each keyboard once, owns the grabs and the paired uinput clones, and fans every event out to each registered engine. An event is re-emitted to applications only when no engine consumed it, so suppression semantics stay the same while per-engine matching state (combo progress, withheld modifiers, swallowed presses) remains independent. * fix(history): own late segments per worker and decide pad routing once - ended_worker_snippets maps each ended session's worker to its own (snippet, commit epoch): late deliveries merge into their session's entry however many sessions have committed since, so one session's stragglers never split across Recent Snippets entries. - The leftover-segment commit reads the live history epoch inside the session lock: a mid-session clear() no longer makes the stale ended epoch refuse valid post-clear dictation. - The pad-bound routing decision is captured once per job and passed to the injector/action handler, so a toggle flip between the focus check and delivery cannot reroute text or shortcuts into the focused app. - Dead recognition workers are pruned from the tracking set and map so long runs don't accumulate finished threads. - The custom-terms switch now suppresses GTK's own state-set on save failure so it shows the value that actually persisted. * fix(shortcut): serialize evdev device layer teardown against restart The last listener's unregister released the engine lock before joining the monitor thread and closing devices, so a new listener registering in that gap opened keyboards the stale teardown then closed — it stayed active but dead, and briefly grabbed keyboards stopped forwarding input. A lifecycle lock now covers the whole close/open transition in register, unregister, and the monitor's unexpected-exit teardown, so a cold start can only open devices after the previous generation finished closing (also making the new generation's grabs succeed). A generation stamp tells a monitor thread that outlived its join() its incarnation is over even when running has gone True again. * fix(injection): log focus-probe failures and narrow clipboard fallback catch A crashed focus probe turned into an unknown result with no logged error, indistinguishable from unavailable focus information; the partial-typing clipboard fallback caught every exception. Log the probe failure with context and narrow the clipboard catch to the exceptions the fallback path can actually raise. * fix(shortcut): join the evdev monitor outside the lifecycle lock If the monitor exited unexpectedly while the last listener unregistered, its cleanup queued on the lifecycle lock that unregister() held for the join — stalling shutdown for the join's full 2s timeout. The close now finishes inside the lock and the join waits after it is released, so the monitor's own cleanup can run and exit instead of queueing behind it. Closing before the join also dies the monitor's read/select on the closed fds right away. A _closed gate on _open_keyboard_device keeps a rescan from opening a device between detach and close that nothing would ever close. * fix(injection): narrow focus-probe catch to the raisable exception set get_focused_window() reports Optional and never raises; the catch now covers only what can realistically escape the queue write and logs it with context, keeping the waiting job released. * fix(shortcut): tag evdev device fds with the generation that opened them A monitor that outlived join() could still touch a newer generation's devices: the loop re-checked the generation only at the top, so mid-iteration it could dispatch from, forward to, or remove devices a restarted listener had just opened. Every fd is now tagged with the generation that opened it, and the tag is verified at each point a monitor could mutate shared state — fd lookup for dispatch, clone forwarding, forwarder release, device removal, and rescan opens — while dispatch re-checks the generation between events and the per-fd loop breaks on it. Cold starts bump the generation before opening so fresh devices are tagged for the new generation. Devices an older generation opened stay tagged stale, so its reader leaves them alone. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
co-authored by
Jatin K Malik
parent
dca937fc21
commit
d11bd1bf0d
@@ -148,8 +148,7 @@ src/vocalinux/
|
||||
├── speech_recognition/
|
||||
│ ├── recognition_manager.py # whisper.cpp / Whisper / Vosk / remote
|
||||
│ ├── command_processor.py # voice commands
|
||||
│ ├── diarization.py # TinyDiarize per-speaker file transcription
|
||||
│ ├── diarization.py # --transcribe-file / tray "Transcribe audio file"
|
||||
│ ├── diarization.py # TinyDiarize per-speaker file transcription (--transcribe-file / tray)
|
||||
│ ├── silero_vad.py
|
||||
│ └── data/ # bundled silero_vad.onnx
|
||||
├── text_injection/
|
||||
@@ -157,8 +156,7 @@ src/vocalinux/
|
||||
│ └── ibus_engine.py # Wayland IBus injection
|
||||
├── ui/
|
||||
│ ├── tray_indicator.py, settings_dialog.py, first_run_dialog.py
|
||||
│ ├── transcript_dialog.py # speaker-tagged transcript viewer
|
||||
│ ├── transcript_dialog.py # speaker-attributed transcript viewer/export
|
||||
│ ├── transcript_dialog.py # speaker-tagged transcript viewer/export
|
||||
│ ├── config_manager.py, action_handler.py, audio_feedback.py
|
||||
│ ├── autostart_manager.py, keyboard_shortcuts.py
|
||||
│ ├── logging_dialog.py, logging_manager.py
|
||||
|
||||
@@ -306,7 +306,7 @@ vocalinux --start-minimized
|
||||
|
||||
### Configuration
|
||||
|
||||
Stored at `~/.config/vocalinux/config.json`. Prefer the Settings dialog for day-to-day changes. **Settings → Speech Model** starts with a simple setup; expand **Advanced** for engine, size, and specialization.
|
||||
Stored at `~/.config/vocalinux/config.json`. Prefer the Settings dialog for day-to-day changes. **Settings → Speech Model** starts with a simple setup; expand **Advanced** for engine, size, and specialization. Set `post_processing.script_path` to an executable to transform each transcription result (stdin → stdout) before it is injected; leave empty to disable.
|
||||
|
||||
Neural VAD (Silero) is used when `onnxruntime` is available; install via `pip install "vocalinux[vad]"` for manual/PyPI installs. The installer attempts this automatically.
|
||||
|
||||
|
||||
@@ -247,6 +247,30 @@ transcript corrections.
|
||||
|
||||
`--transcribe-file` uses the TinyDiarize model (`small.en-tdrz`) and needs it downloaded first — the tray "Transcribe Audio File…" entry fetches it on demand.
|
||||
|
||||
### Post-Processing
|
||||
|
||||
Vocalinux can pipe each transcription result through a user-defined script before injecting it into your application. This lets you apply custom transformations — for example, grammar correction, abbreviation expansion, or domain-specific formatting.
|
||||
|
||||
**To configure:**
|
||||
1. Open Settings from the tray icon menu (right-click)
|
||||
2. Go to the **Post-Processing** tab
|
||||
3. Enter the path to your script, or click **Browse…** to select it
|
||||
4. Leave the field empty to disable post-processing
|
||||
|
||||
**Script contract:**
|
||||
- The script receives the transcription on **stdin**
|
||||
- It must write the replacement text to **stdout**
|
||||
- stdout is injected verbatim — trailing newlines are preserved (e.g. paragraph breaks), except a single trailing newline that line-oriented tools like `echo` add when the transcription had none
|
||||
- A non-zero exit code or a script that times out (10 s) causes the original text to be used unchanged
|
||||
- Scripts run on a dedicated worker so a slow script cannot interrupt dictation
|
||||
|
||||
**Example** — a shell script that uppercases everything:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
tr '[:lower:]' '[:upper:]'
|
||||
```
|
||||
Make the script executable (`chmod +x`) before setting the path in Vocalinux.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
```bash
|
||||
|
||||
+411
-91
@@ -6,9 +6,11 @@ Main entry point for Vocalinux application.
|
||||
import argparse
|
||||
import atexit
|
||||
import logging
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from typing import Optional
|
||||
|
||||
from .utils.vosk_model_info import SUPPORTED_LANGUAGES
|
||||
@@ -357,6 +359,7 @@ def main():
|
||||
|
||||
# Check for single instance BEFORE any initialization
|
||||
from . import single_instance
|
||||
from .post_processor import apply_post_processing
|
||||
|
||||
if not single_instance.acquire_lock():
|
||||
# Another instance is already running - show notification and exit
|
||||
@@ -424,7 +427,7 @@ def main():
|
||||
from .common_types import RecognitionState
|
||||
from .custom_dictionary import CustomDictionaryManager
|
||||
from .speech_recognition import recognition_manager
|
||||
from .text_injection import text_injector
|
||||
from .text_injection import focused_window, text_injector
|
||||
from .ui import tray_indicator
|
||||
from .ui.action_handler import ActionHandler
|
||||
from .ui.config_manager import get_shared_config_manager
|
||||
@@ -630,15 +633,19 @@ def main():
|
||||
# TranscriptionHistory.add), so late segments extend that entry
|
||||
# rather than whichever snippet happens to be newest.
|
||||
latest_snippet_id: Optional[int] = None
|
||||
# Worker thread that produced the most-recently-ended session's
|
||||
# segments; deliveries on that same thread may extend its committed
|
||||
# snippet once the next session has closed, so an older worker
|
||||
# finishing two sessions later cannot leak into a newer entry.
|
||||
ended_session_worker: Optional[threading.Thread] = None
|
||||
# Commit epoch of latest_snippet_id, for guarded late merges.
|
||||
latest_snippet_epoch = transcription_history.epoch
|
||||
# Snippet each ended session's worker owns, as
|
||||
# ``worker -> (snippet_id, commit epoch)``: a worker's late deliveries
|
||||
# merge into its own session's entry however many sessions have
|
||||
# committed since, so one session's stragglers can never leak into a
|
||||
# newer entry or split across several snippets.
|
||||
ended_worker_snippets: dict[threading.Thread, tuple[int, int]] = {}
|
||||
# Every worker that has delivered in-session segments; a delivery on
|
||||
# a thread never associated with a session is treated as the
|
||||
# just-ended session's trailing decode, while a worker seen producing
|
||||
# an earlier session can never merge into a newer entry.
|
||||
# an earlier session can never merge into a newer entry. Dead
|
||||
# workers are pruned so long runs don't accumulate finished threads.
|
||||
session_workers_seen: set[threading.Thread] = set()
|
||||
# Clear epoch the most-recently-ended session was committed under;
|
||||
# late segments merging into its snippet are judged against it.
|
||||
@@ -724,7 +731,8 @@ def main():
|
||||
captured after the clear are kept, so dictation that continues
|
||||
across a clear is still recoverable.
|
||||
"""
|
||||
nonlocal session_worker, latest_snippet_id, ended_session_worker
|
||||
nonlocal session_worker, latest_snippet_id, latest_snippet_epoch
|
||||
nonlocal session_workers_seen, ended_worker_snippets
|
||||
if not transcription_history.enabled:
|
||||
return
|
||||
segment = _normalize_segment_text(segment)
|
||||
@@ -735,6 +743,12 @@ def main():
|
||||
# any other thread is a leftover from an older session.
|
||||
current_worker = getattr(speech_engine, "recognition_thread", None)
|
||||
with session_lock:
|
||||
# Dead workers cannot deliver again; drop them so long runs
|
||||
# don't accumulate finished threads.
|
||||
session_workers_seen = {t for t in session_workers_seen if t.is_alive()}
|
||||
ended_worker_snippets = {
|
||||
w: s for w, s in ended_worker_snippets.items() if w.is_alive()
|
||||
}
|
||||
if started_at <= transcription_history.cleared_at:
|
||||
# Captured before the last clear — must not re-enter.
|
||||
return
|
||||
@@ -769,26 +783,27 @@ def main():
|
||||
session_segments.append((segment, started_at))
|
||||
return
|
||||
# Late segment from a session that already ended: merge into
|
||||
# its own snippet by id — a newer session may already have
|
||||
# committed on top, so the newest entry is not the target.
|
||||
# Two kinds are admitted: trickles on the ended session's own
|
||||
# worker thread (the engine's decode finishing after IDLE),
|
||||
# and any straggler while a newer session is still open —
|
||||
# the open session has not committed yet, so the tracked id
|
||||
# still points to the ended session's own snippet. An older
|
||||
# worker delivering after that newer session committed forms
|
||||
# its own entry rather than growing the wrong snippet. Both
|
||||
# writes are guarded by the ended session's epoch, so a
|
||||
# clear() landing between that commit and this delivery
|
||||
# still refuses the text.
|
||||
# its own session's snippet, found by the worker delivering
|
||||
# it — a newer session may already have committed on top, so
|
||||
# the newest entry is not the target. A delivery on a thread
|
||||
# never associated with a session is treated as the
|
||||
# just-ended session's trailing decode while a session is
|
||||
# still open; a worker seen producing an earlier session can
|
||||
# never merge into a newer entry. Every write is guarded by
|
||||
# the snippet's own commit epoch, so a clear() landing
|
||||
# between that commit and this delivery still refuses the
|
||||
# text.
|
||||
owner = ended_worker_snippets.get(worker)
|
||||
if owner is not None and transcription_history.extend_entry(
|
||||
owner[0], segment, expected_epoch=owner[1]
|
||||
):
|
||||
return
|
||||
if (
|
||||
latest_snippet_id is not None
|
||||
and (
|
||||
worker is ended_session_worker
|
||||
or (session_open and worker not in session_workers_seen)
|
||||
)
|
||||
and session_open
|
||||
and worker not in session_workers_seen
|
||||
and transcription_history.extend_entry(
|
||||
latest_snippet_id, segment, expected_epoch=ended_session_epoch
|
||||
latest_snippet_id, segment, expected_epoch=latest_snippet_epoch
|
||||
)
|
||||
):
|
||||
return
|
||||
@@ -797,27 +812,20 @@ def main():
|
||||
snippet_id = transcription_history.add(segment, expected_epoch=ended_session_epoch)
|
||||
if snippet_id is not None:
|
||||
latest_snippet_id = snippet_id
|
||||
ended_session_worker = worker
|
||||
latest_snippet_epoch = ended_session_epoch
|
||||
ended_worker_snippets[worker] = (snippet_id, ended_session_epoch)
|
||||
session_workers_seen.add(worker)
|
||||
|
||||
def text_callback_wrapper(text: str) -> None:
|
||||
"""Bridge between speech engine text events and the text injector.
|
||||
|
||||
Called on the recognition thread with each finalised transcription
|
||||
segment. Strips leading whitespace and trailing spaces/tabs (but
|
||||
preserves trailing newlines from voice commands), then either
|
||||
appends a trailing space (default) or uses the legacy in-session
|
||||
leading-space separator, and injects via TextInjector.
|
||||
def inject_transcription(text_to_inject: str, to_pad: Optional[bool] = None) -> None:
|
||||
"""Apply the separator rules and inject one finalised segment.
|
||||
|
||||
Args:
|
||||
text: Raw transcription segment from the speech engine.
|
||||
text_to_inject: Post-processed text ready for the text injector.
|
||||
to_pad: Destination decided when the job's focus check ran;
|
||||
the delivery must reuse that same decision — re-reading
|
||||
the live toggle here could disagree with the check and
|
||||
send the text somewhere it was never verified for.
|
||||
"""
|
||||
# Preserve trailing newlines ("new line" / "new paragraph"); only
|
||||
# strip spaces/tabs that whisper sometimes wraps around tokens.
|
||||
text_to_inject = _normalize_segment_text(text)
|
||||
if not text_to_inject:
|
||||
return
|
||||
|
||||
# Read from disk so the Settings toggle applies without restart.
|
||||
append_trailing_space = _should_append_trailing_space()
|
||||
|
||||
@@ -834,7 +842,7 @@ def main():
|
||||
text_to_inject = " " + text_to_inject
|
||||
logger.debug("Added space separator before new segment")
|
||||
|
||||
captured = dictate_to_pad_enabled()
|
||||
captured = dictate_to_pad_enabled() if to_pad is None else to_pad
|
||||
if captured:
|
||||
# In-app capture: skip cross-application injection entirely
|
||||
# and land the text in the pad instead (#726).
|
||||
@@ -845,19 +853,357 @@ def main():
|
||||
if success:
|
||||
action_handler.set_last_injected_text(text_to_inject)
|
||||
last_injected["to_pad"] = captured
|
||||
else:
|
||||
# Deletion state must match what reached the app: a partial
|
||||
# failure counts the confirmed prefix, an unknowable count
|
||||
# (-1) clears it, and 0 leaves the previous segment's state —
|
||||
# a failed injection types nothing, so "delete that" still
|
||||
# means the segment before it.
|
||||
typed = text_system.last_typed_count
|
||||
if isinstance(typed, int) and typed > 0:
|
||||
action_handler.set_last_injected_text(text_to_inject[:typed])
|
||||
elif isinstance(typed, int) and typed < 0:
|
||||
action_handler.set_last_injected_text("")
|
||||
|
||||
# Post-processing runs a user executable that may take seconds per
|
||||
# segment. Running it on the recognition thread would stall the
|
||||
# consumer of the bounded audio-segment queue, and once that fills,
|
||||
# queued dictation is dropped. A dedicated worker applies the script
|
||||
# and injects in order; its unbounded backlog waits instead of losing
|
||||
# speech, and a timed-out script falls back to the original text.
|
||||
#
|
||||
# Every segment goes through this one worker — when no script is
|
||||
# configured apply_post_processing is a pass-through — so clearing the
|
||||
# script path mid-queue can never let a later segment overtake an
|
||||
# earlier one still waiting behind a running script.
|
||||
post_processing_executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="vocalinux-post-processing"
|
||||
)
|
||||
# Cleared by the quit hook so a queued or in-flight job drops its
|
||||
# segment instead of injecting while the injector is being stopped.
|
||||
accepting_injections = threading.Event()
|
||||
accepting_injections.set()
|
||||
# Serialises the flag check and the inject call itself: quitting can
|
||||
# wait out an injection already in progress while never blocking on a
|
||||
# queued or running script.
|
||||
injection_lock = threading.Lock()
|
||||
# Segments that may sit in the backlog (a script is configured, or a
|
||||
# job is still running) are bound to the app focused at submit time;
|
||||
# the worker drops them if focus has moved to another application.
|
||||
pending_jobs = 0
|
||||
pending_jobs_lock = threading.Lock()
|
||||
|
||||
def _focused_app_unchanged(
|
||||
target: Optional[focused_window.FocusedWindow],
|
||||
) -> bool:
|
||||
"""Return True unless focus verifiably moved to a different app.
|
||||
|
||||
A missing baseline or a failed re-probe stays permissive: without
|
||||
a reliable identity the segment keeps the injector's own targeting.
|
||||
"""
|
||||
if target is None:
|
||||
return True
|
||||
current = focused_window.get_focused_window()
|
||||
if current is None:
|
||||
return True
|
||||
return current.identity_blob() == target.identity_blob()
|
||||
|
||||
def _start_focus_probe() -> queue.Queue[Optional[focused_window.FocusedWindow]]:
|
||||
"""Probe the focused window now, off the calling thread.
|
||||
|
||||
Focus probes shell out to compositor tools under their own
|
||||
timeouts, so each runs on a fresh daemon thread: the recognition
|
||||
thread never waits on them, and — unlike a shared worker — an
|
||||
earlier slow probe cannot delay this one past the focus change it
|
||||
is meant to capture.
|
||||
"""
|
||||
probe_result: queue.Queue[Optional[focused_window.FocusedWindow]] = queue.Queue(
|
||||
maxsize=1
|
||||
)
|
||||
|
||||
def probe() -> None:
|
||||
try:
|
||||
probe_result.put(focused_window.get_focused_window())
|
||||
except (OSError, RuntimeError, queue.Full) as exc:
|
||||
# get_focused_window() reports Optional and should never
|
||||
# raise; if one escapes anyway the failed probe is
|
||||
# indistinguishable from unavailable focus information
|
||||
# unless it is logged — and the waiting job must still
|
||||
# be released.
|
||||
logger.exception("Focus probe crashed unexpectedly: %s", exc)
|
||||
probe_result.put(None)
|
||||
|
||||
threading.Thread(target=probe, daemon=True, name="vocalinux-focus-probe").start()
|
||||
return probe_result
|
||||
|
||||
def _probe_result(
|
||||
probe: Optional[queue.Queue[Optional[focused_window.FocusedWindow]]],
|
||||
) -> Optional[focused_window.FocusedWindow]:
|
||||
"""Return the focus identity a submit-time probe captured.
|
||||
|
||||
The probe thread starts the moment the segment is submitted, so
|
||||
joining it here adds no wait beyond the probe's own runtime.
|
||||
"""
|
||||
if probe is None:
|
||||
return None
|
||||
return probe.get()
|
||||
|
||||
def post_process_and_inject(
|
||||
text_to_inject: str,
|
||||
target_probe: Optional[queue.Queue[Optional[focused_window.FocusedWindow]]],
|
||||
) -> None:
|
||||
"""Apply the configured post-processing script, then inject.
|
||||
|
||||
Runs on the post-processing worker, never the recognition thread.
|
||||
An unexpected failure falls back to the unprocessed segment so
|
||||
dictation is never lost inside the worker.
|
||||
|
||||
Args:
|
||||
text_to_inject: Finalised transcription segment.
|
||||
target_probe: Probe of the app focused when the segment was
|
||||
dictated; the segment is dropped if focus has since moved
|
||||
elsewhere. None keeps the injector's own targeting.
|
||||
"""
|
||||
nonlocal pending_jobs
|
||||
try:
|
||||
if not accepting_injections.is_set():
|
||||
return
|
||||
try:
|
||||
processed_text = apply_post_processing(text_to_inject, config_manager)
|
||||
except Exception:
|
||||
logger.exception("Post-processing raised unexpectedly; injecting original text")
|
||||
processed_text = text_to_inject
|
||||
if processed_text is None or not accepting_injections.is_set():
|
||||
return
|
||||
# Pad-bound segments land in the dictation pad regardless of
|
||||
# where focus sits, so a focus change since the segment was
|
||||
# dictated must not drop them. The destination is decided
|
||||
# once here and passed to the injector: re-reading the
|
||||
# toggle at delivery could flip it after the check was
|
||||
# skipped and send the segment to the focused application.
|
||||
to_pad = dictate_to_pad_enabled()
|
||||
if not to_pad and not _focused_app_unchanged(_probe_result(target_probe)):
|
||||
logger.info("Dropping queued segment: focus moved to another application")
|
||||
return
|
||||
with injection_lock:
|
||||
if not accepting_injections.is_set():
|
||||
return
|
||||
inject_transcription(processed_text, to_pad)
|
||||
finally:
|
||||
with pending_jobs_lock:
|
||||
pending_jobs -= 1
|
||||
|
||||
def _run_action(
|
||||
action: str,
|
||||
target_probe: Optional[queue.Queue[Optional[focused_window.FocusedWindow]]],
|
||||
) -> bool:
|
||||
"""Run one voice-command action on the post-processing worker.
|
||||
|
||||
Actions send keystrokes through the same injector as transcription
|
||||
text, so they queue on the same worker in spoken order and are
|
||||
bound to the app focused when the command was issued: even a
|
||||
submission that looks immediate can run after a context switch,
|
||||
so the binding applies to every action. The job waits on the
|
||||
probe for its full duration rather than racing it — the probe's
|
||||
compositor calls carry their own one-second timeouts and
|
||||
short-circuit on tools that are absent, so it answers in
|
||||
milliseconds on a healthy desktop and always terminates; a valid
|
||||
command is therefore never discarded over timing, and the
|
||||
verified result is the only thing that can drop it.
|
||||
"""
|
||||
nonlocal pending_jobs
|
||||
try:
|
||||
if not accepting_injections.is_set():
|
||||
return False
|
||||
# Pad-bound actions run on the dictation pad no matter which
|
||||
# application is focused, so a focus change since the command
|
||||
# was issued must not drop them; only app-bound deliveries are
|
||||
# focus-checked.
|
||||
if action == "delete_last":
|
||||
targets_pad = bool(last_injected["to_pad"])
|
||||
else:
|
||||
targets_pad = dictate_to_pad_enabled()
|
||||
if not targets_pad and not _focused_app_unchanged(_probe_result(target_probe)):
|
||||
logger.info("Dropping action: focus moved to another application")
|
||||
return False
|
||||
with injection_lock:
|
||||
if not accepting_injections.is_set():
|
||||
return False
|
||||
# "delete that" follows the segment it removes: the pad
|
||||
# when the last delivered text went there (even if capture
|
||||
# was toggled off since) and the focused application when
|
||||
# it was injected (even if capture was toggled on). Every
|
||||
# other editing command is handled pad-side while capturing
|
||||
# so its shortcuts never leak into whichever application
|
||||
# holds focus.
|
||||
if action == "delete_last":
|
||||
if not action_handler.last_injected_text:
|
||||
return True
|
||||
if last_injected["to_pad"]:
|
||||
# Only the pad's own segment bookkeeping may size
|
||||
# the deletion: a manual edit or a pad "undo"
|
||||
# blurs the boundaries (last_segment is None), and
|
||||
# falling back to the recorded text's length could
|
||||
# erase characters the user typed after dictating.
|
||||
# Refuse rather than misdelete — the tracking is
|
||||
# still cleared so a repeated command cannot retry
|
||||
# the stale length.
|
||||
target = dictation_pad.last_segment
|
||||
if target is None:
|
||||
action_handler.set_last_injected_text("")
|
||||
last_injected["to_pad"] = False
|
||||
return True
|
||||
deleted = dictation_pad.delete_last_chars(len(target))
|
||||
if deleted:
|
||||
action_handler.set_last_injected_text("")
|
||||
last_injected["to_pad"] = False
|
||||
return True
|
||||
handled_app: bool = action_handler.handle_action(action)
|
||||
return handled_app
|
||||
if targets_pad:
|
||||
handled = bool(dictation_pad.handle_action(action))
|
||||
if handled and action in ("undo", "redo") and last_injected["to_pad"]:
|
||||
# Pad history moved: retarget "delete that" at the
|
||||
# segment now at the pad's tail. Keep the pad
|
||||
# destination even when the tail is empty — a
|
||||
# redo can restore it.
|
||||
action_handler.set_last_injected_text(dictation_pad.last_segment or "")
|
||||
return handled
|
||||
handled_app = action_handler.handle_action(action)
|
||||
return handled_app
|
||||
finally:
|
||||
with pending_jobs_lock:
|
||||
pending_jobs -= 1
|
||||
|
||||
def action_callback_wrapper(action: str) -> Optional[Future]:
|
||||
"""Queue a voice-command action behind any pending text jobs.
|
||||
|
||||
Args:
|
||||
action: Voice-command action from the speech engine.
|
||||
|
||||
Returns:
|
||||
The queued worker future, or None while the application is
|
||||
quitting. Callers such as tests can wait on it; the speech
|
||||
engine ignores the return value.
|
||||
"""
|
||||
nonlocal pending_jobs
|
||||
if not accepting_injections.is_set():
|
||||
return None
|
||||
# Every action is bound to the app focused at submit time:
|
||||
# submission does not guarantee immediate execution, so there is
|
||||
# always a focus-change window between the two. The probe runs
|
||||
# off this thread and the worker only waits on it briefly, so
|
||||
# the binding costs nothing on a healthy desktop.
|
||||
# Probing and submitting happen inside the lock so queue order
|
||||
# matches the order these callbacks ran in — a job that saw an
|
||||
# empty queue cannot end up waiting behind one that arrived while
|
||||
# its submission was still in flight.
|
||||
with pending_jobs_lock:
|
||||
pending_jobs += 1
|
||||
target_probe = _start_focus_probe()
|
||||
try:
|
||||
future: Future = post_processing_executor.submit(
|
||||
_run_action, action, target_probe
|
||||
)
|
||||
except RuntimeError:
|
||||
# The quit path already shut the worker down.
|
||||
pending_jobs -= 1
|
||||
return None
|
||||
return future
|
||||
|
||||
def _reset_last_injected() -> None:
|
||||
"""Clear the last-injected buffer on the post-processing worker."""
|
||||
action_handler.set_last_injected_text("")
|
||||
last_injected["to_pad"] = False
|
||||
|
||||
def _shutdown_post_processing() -> None:
|
||||
"""Stop the post-processing worker for application quit.
|
||||
|
||||
Clearing the flag makes queued or in-flight jobs drop their
|
||||
results, and pending submissions are cancelled without waiting on
|
||||
a running script — quit must not freeze the tray on the script's
|
||||
own timeout. An injection already in progress is asked to abort
|
||||
so the lock wait stays bounded by a single chunk's subprocess
|
||||
timeout instead of a long transcription's whole budget, and the
|
||||
injector is never stopped mid-inject.
|
||||
"""
|
||||
accepting_injections.clear()
|
||||
text_system.abort_injections()
|
||||
with injection_lock:
|
||||
pass
|
||||
post_processing_executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
def text_callback_wrapper(text: str) -> Optional[Future]:
|
||||
"""Bridge between speech engine text events and the text injector.
|
||||
|
||||
Called on the recognition thread with each finalised transcription
|
||||
segment. Strips leading whitespace and trailing spaces/tabs (but
|
||||
preserves trailing newlines from voice commands), then hands the
|
||||
segment to the single post-processing worker — a pass-through when
|
||||
no script is configured — which applies the separator rules and
|
||||
injects via TextInjector in spoken order.
|
||||
|
||||
Args:
|
||||
text: Raw transcription segment from the speech engine.
|
||||
|
||||
Returns:
|
||||
The queued worker future, or None when the segment was dropped
|
||||
before submission (an empty segment, or the application is
|
||||
quitting). Callers such as tests can wait on it; the speech
|
||||
engine ignores the return value.
|
||||
"""
|
||||
nonlocal pending_jobs
|
||||
# Preserve trailing newlines ("new line" / "new paragraph"); only
|
||||
# strip spaces/tabs that whisper sometimes wraps around tokens.
|
||||
text_to_inject = _normalize_segment_text(text)
|
||||
if not text_to_inject or not accepting_injections.is_set():
|
||||
return None
|
||||
|
||||
# A backlog or a configured script means this segment can inject
|
||||
# long after it was dictated; bind it to the app it targets now so
|
||||
# it cannot land in whatever the user switched to meanwhile. The
|
||||
# probe runs off this thread: compositor tools could otherwise
|
||||
# stall the consumer of the bounded audio-segment queue for seconds
|
||||
# per segment.
|
||||
script_configured = bool(config_manager.get_str("post_processing", "script_path", ""))
|
||||
with pending_jobs_lock:
|
||||
may_queue = pending_jobs > 0 or script_configured
|
||||
pending_jobs += 1
|
||||
target_probe = _start_focus_probe() if may_queue else None
|
||||
try:
|
||||
future: Future = post_processing_executor.submit(
|
||||
post_process_and_inject, text_to_inject, target_probe
|
||||
)
|
||||
except RuntimeError:
|
||||
# The quit path already shut the worker down.
|
||||
pending_jobs -= 1
|
||||
return None
|
||||
return future
|
||||
|
||||
def on_state_change(state: RecognitionState) -> None:
|
||||
"""Reset the last-injected buffer when a listening session ends.
|
||||
|
||||
Also commits the just-finished dictation session to the
|
||||
transcription history as a single snippet.
|
||||
transcription history as a single snippet. With jobs still queued
|
||||
the reset is queued behind them, so a "delete that" action
|
||||
recognised just before the session ended still sees the text it
|
||||
refers to.
|
||||
"""
|
||||
nonlocal session_open, session_worker, latest_snippet_id
|
||||
nonlocal ended_session_epoch, ended_session_worker, session_started_floor
|
||||
nonlocal ended_session_epoch, session_started_floor, latest_snippet_epoch
|
||||
if state in (RecognitionState.IDLE, RecognitionState.ERROR):
|
||||
if state == RecognitionState.IDLE:
|
||||
action_handler.set_last_injected_text("")
|
||||
last_injected["to_pad"] = False
|
||||
with pending_jobs_lock:
|
||||
backlog = pending_jobs > 0
|
||||
if backlog and accepting_injections.is_set():
|
||||
try:
|
||||
post_processing_executor.submit(_reset_last_injected)
|
||||
except RuntimeError:
|
||||
action_handler.set_last_injected_text("")
|
||||
last_injected["to_pad"] = False
|
||||
else:
|
||||
action_handler.set_last_injected_text("")
|
||||
last_injected["to_pad"] = False
|
||||
with session_lock:
|
||||
session_open = False
|
||||
closing_worker = session_worker
|
||||
@@ -879,7 +1225,12 @@ def main():
|
||||
latest_snippet_id = transcription_history.add(
|
||||
joined, expected_epoch=ended_session_epoch
|
||||
)
|
||||
ended_session_worker = closing_worker
|
||||
latest_snippet_epoch = ended_session_epoch
|
||||
if latest_snippet_id is not None and closing_worker is not None:
|
||||
ended_worker_snippets[closing_worker] = (
|
||||
latest_snippet_id,
|
||||
ended_session_epoch,
|
||||
)
|
||||
else:
|
||||
with session_lock:
|
||||
if not session_open:
|
||||
@@ -893,15 +1244,25 @@ def main():
|
||||
# those captured after the last clear().
|
||||
if session_segments:
|
||||
cleared_at = transcription_history.cleared_at
|
||||
# A clear() during the unclosed session bumped the
|
||||
# epoch since the previous close — judge against
|
||||
# the live epoch, not the stale ended one, or
|
||||
# valid post-clear dictation is refused.
|
||||
stray_epoch = transcription_history.epoch
|
||||
latest_snippet_id = transcription_history.add(
|
||||
" ".join(
|
||||
text
|
||||
for text, started_at in session_segments
|
||||
if started_at > cleared_at
|
||||
),
|
||||
expected_epoch=ended_session_epoch,
|
||||
expected_epoch=stray_epoch,
|
||||
)
|
||||
ended_session_worker = leftover_worker
|
||||
latest_snippet_epoch = stray_epoch
|
||||
if latest_snippet_id is not None and leftover_worker is not None:
|
||||
ended_worker_snippets[leftover_worker] = (
|
||||
latest_snippet_id,
|
||||
stray_epoch,
|
||||
)
|
||||
session_segments.clear()
|
||||
|
||||
# Connect speech recognition to text injection and action handling.
|
||||
@@ -910,48 +1271,6 @@ def main():
|
||||
# injection keeps the plain text callback.
|
||||
speech_engine.register_segment_callback(record_history_segment)
|
||||
|
||||
def action_callback_wrapper(action: str) -> bool:
|
||||
"""Route editing voice commands to the pad while capture is on.
|
||||
|
||||
"delete that" follows the segment it removes: the pad when the
|
||||
last delivered text went there (even if capture was toggled off
|
||||
since) and the focused application when it was injected (even if
|
||||
capture was toggled on). Every other editing command is handled
|
||||
pad-side while capturing so its shortcuts never leak into
|
||||
whichever application holds focus.
|
||||
"""
|
||||
if action == "delete_last":
|
||||
if not action_handler.last_injected_text:
|
||||
return True
|
||||
if last_injected["to_pad"]:
|
||||
# Only the pad's own segment bookkeeping may size the
|
||||
# deletion: a manual edit or a pad "undo" blurs the
|
||||
# boundaries (last_segment is None), and falling back to
|
||||
# the recorded text's length could erase characters the
|
||||
# user typed after dictating. Refuse rather than
|
||||
# misdelete — the tracking is still cleared so a repeated
|
||||
# command cannot retry the stale length.
|
||||
target = dictation_pad.last_segment
|
||||
if target is None:
|
||||
action_handler.set_last_injected_text("")
|
||||
last_injected["to_pad"] = False
|
||||
return True
|
||||
deleted = dictation_pad.delete_last_chars(len(target))
|
||||
if deleted:
|
||||
action_handler.set_last_injected_text("")
|
||||
last_injected["to_pad"] = False
|
||||
return True
|
||||
return bool(action_handler.handle_action(action))
|
||||
if dictate_to_pad_enabled():
|
||||
handled = bool(dictation_pad.handle_action(action))
|
||||
if handled and action in ("undo", "redo") and last_injected["to_pad"]:
|
||||
# Pad history moved: retarget "delete that" at the segment
|
||||
# now at the pad's tail. Keep the pad destination even
|
||||
# when the tail is empty — a redo can restore it.
|
||||
action_handler.set_last_injected_text(dictation_pad.last_segment or "")
|
||||
return handled
|
||||
return bool(action_handler.handle_action(action))
|
||||
|
||||
speech_engine.register_text_callback(text_callback_wrapper)
|
||||
speech_engine.register_action_callback(action_callback_wrapper)
|
||||
speech_engine.register_state_callback(on_state_change)
|
||||
@@ -961,6 +1280,7 @@ def main():
|
||||
speech_engine=speech_engine,
|
||||
text_injector=text_system,
|
||||
transcription_history=transcription_history,
|
||||
on_quit=_shutdown_post_processing,
|
||||
dictation_pad=dictation_pad,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Post-processing of transcribed text via an external executable."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from .utils.host_process import host_env
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ui.config_manager import ConfigManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostProcessor:
|
||||
"""Pipes transcribed text through a user-defined executable and returns the output."""
|
||||
|
||||
def __init__(self, script_path: str) -> None:
|
||||
self.script_path = script_path
|
||||
|
||||
def process(self, text: str) -> str:
|
||||
"""Run the script with text on stdin; return stdout. Falls back to original on failure."""
|
||||
logger.info("Running post-processor: %s", self.script_path)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self.script_path],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
env=host_env(),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, UnicodeError) as e:
|
||||
logger.warning("Post-processor failed: %s", e)
|
||||
return text
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.warning("Post-processor exited %d: %s", result.returncode, result.stderr.strip())
|
||||
return text
|
||||
|
||||
# Success is logged without the output text: transformed dictation can
|
||||
# be sensitive, and logs are retained and exportable.
|
||||
logger.debug("Post-processor succeeded, %d bytes out", len(result.stdout))
|
||||
return self._strip_convenience_newline(text, result.stdout)
|
||||
|
||||
@staticmethod
|
||||
def _strip_convenience_newline(original: str, transformed: str) -> str:
|
||||
"""Drop one trailing newline a script may add, keeping meaningful ones.
|
||||
|
||||
Line-oriented tools (``echo``, ``print``) terminate their output with a
|
||||
newline the input never had; injecting it would press Return after
|
||||
every segment. Newlines beyond that — like the ``\\n\\n`` a "new
|
||||
paragraph" voice command leaves — are part of the text and stay.
|
||||
"""
|
||||
if transformed.endswith("\n") and not original.endswith("\n"):
|
||||
return transformed[:-1]
|
||||
return transformed
|
||||
|
||||
|
||||
def apply_post_processing(text: str, config_manager: "ConfigManager") -> Optional[str]:
|
||||
"""Apply post-processing script to text if configured.
|
||||
|
||||
Returns processed text, or None to signal the caller should skip injection.
|
||||
Returns original text unchanged when no script is configured.
|
||||
"""
|
||||
script_path = config_manager.get_str("post_processing", "script_path", "")
|
||||
if not script_path:
|
||||
return text
|
||||
result = PostProcessor(script_path).process(text)
|
||||
return result if result else None
|
||||
@@ -52,10 +52,12 @@ class RemoteDesktopPortalError(RuntimeError):
|
||||
failure -- characters for ``inject_text``, taps for ``tap_keysym`` and
|
||||
steps for ``send_shortcut`` -- so the caller can retry only the
|
||||
remainder through a fallback backend instead of replaying the whole
|
||||
request.
|
||||
request. ``None`` means the delivery could not be tracked -- a timed-out
|
||||
job that never reported back may or may not have emitted portal events,
|
||||
so the caller must not replay any part of the request.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str = "", delivered: int = 0) -> None:
|
||||
def __init__(self, message: str = "", delivered: Optional[int] = 0) -> None:
|
||||
super().__init__(message)
|
||||
self.delivered = delivered
|
||||
|
||||
@@ -382,7 +384,10 @@ class RemoteDesktopPortal:
|
||||
if "result" in box:
|
||||
return box["result"]
|
||||
error = box.get("error")
|
||||
delivered = getattr(error, "delivered", 0) if error is not None else 0
|
||||
# A job that never reported back may still have delivered part of
|
||||
# the request before noticing the cancel -- None marks that count
|
||||
# as unknowable so the caller does not replay delivered input.
|
||||
delivered = getattr(error, "delivered", 0) if error is not None else None
|
||||
raise RemoteDesktopPortalError("Timed out waiting for the portal", delivered=delivered)
|
||||
if "error" in box:
|
||||
error = box["error"]
|
||||
@@ -628,7 +633,7 @@ class RemoteDesktopPortal:
|
||||
delivered += 1
|
||||
self._notify_key_state(keysym, _KEY_RELEASED)
|
||||
except RemoteDesktopPortalError as e:
|
||||
e.delivered = max(e.delivered, delivered)
|
||||
e.delivered = max(e.delivered or 0, delivered)
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RemoteDesktopPortalError(str(e), delivered=delivered) from e
|
||||
@@ -654,7 +659,7 @@ class RemoteDesktopPortal:
|
||||
delivered = index + 1
|
||||
self._notify_key_state(keysym, _KEY_RELEASED)
|
||||
except RemoteDesktopPortalError as e:
|
||||
e.delivered = max(e.delivered, delivered)
|
||||
e.delivered = max(e.delivered or 0, delivered)
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RemoteDesktopPortalError(str(e), delivered=delivered) from e
|
||||
@@ -687,7 +692,7 @@ class RemoteDesktopPortal:
|
||||
for modifier in reversed(modifier_keysyms):
|
||||
self._notify_key_state(modifier, _KEY_RELEASED)
|
||||
except RemoteDesktopPortalError as e:
|
||||
e.delivered = max(e.delivered, delivered)
|
||||
e.delivered = max(e.delivered or 0, delivered)
|
||||
self._release_held_modifiers(modifier_keysyms)
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -25,7 +25,13 @@ from .ibus_engine import (
|
||||
is_ibus_available,
|
||||
is_ibus_daemon_running,
|
||||
)
|
||||
from .remote_desktop_portal import KEYSYM_BACKSPACE, RemoteDesktopPortal, RemoteDesktopPortalError
|
||||
from .remote_desktop_portal import (
|
||||
KEYSYM_BACKSPACE,
|
||||
RemoteDesktopPortal,
|
||||
RemoteDesktopPortalError,
|
||||
)
|
||||
|
||||
_PORTAL_SUBMIT_TIMEOUT_S = 195.0 # portal's _START_TIMEOUT_S + _REQUEST_TIMEOUT_S
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,6 +97,22 @@ class DesktopEnvironment(Enum):
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class _InjectionAborted(Exception):
|
||||
"""Raised inside injection helpers when shutdown cuts an injection short."""
|
||||
|
||||
|
||||
class _PartiallyTyped(subprocess.CalledProcessError):
|
||||
"""A chunked type call delivered a prefix of the text before failing.
|
||||
|
||||
``typed`` counts the characters already on screen so a fallback can
|
||||
continue from the remainder instead of typing them a second time.
|
||||
"""
|
||||
|
||||
def __init__(self, typed: int, cause: subprocess.CalledProcessError) -> None:
|
||||
super().__init__(cause.returncode, cause.cmd, output=cause.output, stderr=cause.stderr)
|
||||
self.typed = typed
|
||||
|
||||
|
||||
class TextInjector:
|
||||
"""
|
||||
Class for injecting text into the active application.
|
||||
@@ -99,6 +121,16 @@ class TextInjector:
|
||||
application window, supporting both X11 and Wayland environments.
|
||||
"""
|
||||
|
||||
# Class-level fallback so objects built without __init__ (test helpers
|
||||
# using __new__) still answer the abort checks; __init__ rebinds a fresh
|
||||
# per-instance event, so the shared default is never the one that is set.
|
||||
_abort_injections: threading.Event = threading.Event()
|
||||
|
||||
# Characters confirmed delivered by the most recent inject_text call:
|
||||
# the full length on success, the confirmed prefix after a partial
|
||||
# failure, -1 when delivery is unknowable, 0 when nothing was typed.
|
||||
last_typed_count: int = 0
|
||||
|
||||
def __init__(self, wayland_mode: bool = False):
|
||||
"""
|
||||
Initialize the text injector.
|
||||
@@ -115,6 +147,8 @@ class TextInjector:
|
||||
self._ibus_init_failed = False
|
||||
self._ibus_init_thread: Optional[threading.Thread] = None
|
||||
self._state_lock = threading.Lock()
|
||||
self._abort_injections = threading.Event()
|
||||
self.last_typed_count = 0
|
||||
self._clipboard_tool_health = {}
|
||||
self._clipboard_timeout = 0.35
|
||||
# Overlapping ydotool pastes: bump generation to cancel stale restores;
|
||||
@@ -1028,13 +1062,15 @@ class TextInjector:
|
||||
self.environment = DesktopEnvironment.WAYLAND
|
||||
logger.info(log_message, *args)
|
||||
|
||||
def _try_inject_with_portal(self, text: str) -> Tuple[bool, str]:
|
||||
def _try_inject_with_portal(self, text: str) -> Tuple[bool, Optional[str]]:
|
||||
"""Send text through the RemoteDesktop portal.
|
||||
|
||||
Returns ``(True, "")`` on success. On failure the second element is
|
||||
the part of ``text`` the portal had not typed yet -- the whole string
|
||||
when it never got started, the tail when it failed mid-string -- so
|
||||
the fallback backend does not duplicate delivered characters.
|
||||
the fallback backend does not duplicate delivered characters. It is
|
||||
``None`` when the job timed out without reporting its count: whatever
|
||||
reached the compositor is then unknowable and nothing may be replayed.
|
||||
"""
|
||||
portal = getattr(self, "_portal", None)
|
||||
if portal is None:
|
||||
@@ -1043,16 +1079,20 @@ class TextInjector:
|
||||
return bool(portal.inject_text(text)), ""
|
||||
except RemoteDesktopPortalError as e:
|
||||
logger.warning(f"RemoteDesktop portal injection failed: {e}")
|
||||
if e.delivered is None:
|
||||
return False, None
|
||||
return False, text[e.delivered :]
|
||||
except Exception as e:
|
||||
logger.warning(f"RemoteDesktop portal injection failed: {e}")
|
||||
return False, text
|
||||
|
||||
def _try_portal_keypress(self, keysym: int, count: int = 1) -> Tuple[bool, int]:
|
||||
def _try_portal_keypress(self, keysym: int, count: int = 1) -> Tuple[bool, Optional[int]]:
|
||||
"""Tap one keysym ``count`` times through the portal.
|
||||
|
||||
Returns ``(True, 0)`` on success; on failure the taps still owed, so
|
||||
the fallback does not re-send presses the portal already delivered.
|
||||
``None`` means a timed-out job never reported its count, so no part
|
||||
of the request may be replayed.
|
||||
"""
|
||||
portal = getattr(self, "_portal", None)
|
||||
if portal is None:
|
||||
@@ -1062,6 +1102,8 @@ class TextInjector:
|
||||
return True, 0
|
||||
except RemoteDesktopPortalError as e:
|
||||
logger.warning(f"RemoteDesktop portal key event failed: {e}")
|
||||
if e.delivered is None:
|
||||
return False, None
|
||||
return False, max(0, count - e.delivered)
|
||||
except Exception as e:
|
||||
logger.warning(f"RemoteDesktop portal key event failed: {e}")
|
||||
@@ -1069,11 +1111,13 @@ class TextInjector:
|
||||
|
||||
def _try_portal_shortcut(
|
||||
self, steps: List[Tuple[List[str], str]]
|
||||
) -> Tuple[bool, List[Tuple[List[str], str]]]:
|
||||
) -> Tuple[bool, Optional[List[Tuple[List[str], str]]]]:
|
||||
"""Send parsed shortcut steps through the portal.
|
||||
|
||||
Returns ``(True, [])`` on success; on failure the steps still to
|
||||
send, so the fallback does not re-run steps that already fired.
|
||||
``None`` means a timed-out job never reported its count, so no step
|
||||
may be replayed.
|
||||
"""
|
||||
portal = getattr(self, "_portal", None)
|
||||
if portal is None:
|
||||
@@ -1083,6 +1127,8 @@ class TextInjector:
|
||||
return True, []
|
||||
except RemoteDesktopPortalError as e:
|
||||
logger.warning(f"RemoteDesktop portal shortcut failed: {e}")
|
||||
if e.delivered is None:
|
||||
return False, None
|
||||
return False, list(steps[e.delivered :])
|
||||
except Exception as e:
|
||||
logger.warning(f"RemoteDesktop portal shortcut failed: {e}")
|
||||
@@ -1515,6 +1561,17 @@ class TextInjector:
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not show clipboard notification: {e}")
|
||||
|
||||
def abort_injections(self) -> None:
|
||||
"""Ask in-flight injections to stop at their next safe boundary.
|
||||
|
||||
Called on application quit so a worker holding the injection lock
|
||||
finishes promptly: chunk loops and the Wayland typing call poll the
|
||||
flag and raise ``_InjectionAborted`` instead of running to
|
||||
completion, while every subprocess that is already in flight still
|
||||
returns on its own timeout.
|
||||
"""
|
||||
self._abort_injections.set()
|
||||
|
||||
def inject_text(self, text: str) -> bool:
|
||||
"""
|
||||
Inject text into the currently focused application.
|
||||
@@ -1525,10 +1582,15 @@ class TextInjector:
|
||||
Returns:
|
||||
True if injection was successful, False otherwise
|
||||
"""
|
||||
self.last_typed_count = 0
|
||||
|
||||
if not text or not text.strip():
|
||||
logger.debug("Empty text provided, skipping injection")
|
||||
return True
|
||||
|
||||
if self._abort_injections.is_set():
|
||||
return False
|
||||
|
||||
logger.info(f"Starting text injection: '{text}' (length: {len(text)})")
|
||||
logger.debug(f"Environment: {self.environment}")
|
||||
|
||||
@@ -1591,6 +1653,11 @@ class TextInjector:
|
||||
try:
|
||||
self._inject_with_wayland_tool(text)
|
||||
except subprocess.CalledProcessError as e:
|
||||
# A chunked type call may have delivered a prefix; the
|
||||
# fallback continues from the untyped remainder so text
|
||||
# already on screen is never sent a second time.
|
||||
typed = e.typed if isinstance(e, _PartiallyTyped) else 0
|
||||
remaining = text[typed:]
|
||||
stderr_msg = e.stderr.strip() if e.stderr else "No stderr output"
|
||||
unsupported_wayland = (
|
||||
"compositor does not support" in str(e).lower()
|
||||
@@ -1614,7 +1681,11 @@ class TextInjector:
|
||||
)
|
||||
with self._state_lock:
|
||||
self.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
||||
self._inject_with_xdotool(text)
|
||||
try:
|
||||
self._inject_with_xdotool(remaining)
|
||||
except _PartiallyTyped as nested:
|
||||
# Keep the count relative to the original text.
|
||||
raise _PartiallyTyped(typed + nested.typed, nested) from nested
|
||||
else:
|
||||
raise
|
||||
logger.info("Text injection completed successfully")
|
||||
@@ -1626,7 +1697,44 @@ class TextInjector:
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
self.last_typed_count = len(text)
|
||||
return True
|
||||
except _InjectionAborted:
|
||||
self.last_typed_count = -1
|
||||
logger.info("Injection aborted by shutdown")
|
||||
return False
|
||||
except _PartiallyTyped as e:
|
||||
# A prefix is already on screen; the clipboard fallback must hold
|
||||
# only the untyped remainder or a manual paste duplicates it.
|
||||
# Reporting success would mark the whole transcription injected,
|
||||
# so "delete that" could erase text before the typed prefix.
|
||||
logger.error(f"Text injection failed after a prefix was typed: {e}")
|
||||
self.last_typed_count = e.typed
|
||||
remaining = text[e.typed :]
|
||||
try:
|
||||
if self._copy_to_clipboard(remaining):
|
||||
logger.info(
|
||||
"Remaining text copied to clipboard as fallback - user can paste manually"
|
||||
)
|
||||
self._show_clipboard_fallback_notification()
|
||||
except (OSError, subprocess.SubprocessError, RuntimeError) as clipboard_error:
|
||||
logger.debug(f"Clipboard fallback also failed: {clipboard_error}")
|
||||
|
||||
try:
|
||||
from ..ui.audio_feedback import play_error_sound
|
||||
|
||||
play_error_sound()
|
||||
except ImportError:
|
||||
logger.warning("Could not import audio feedback module")
|
||||
return False
|
||||
except subprocess.TimeoutExpired as e:
|
||||
# A type call that ran past its bound may have delivered only part
|
||||
# of the text; putting the full text on the clipboard would let a
|
||||
# manual paste duplicate the fragment already typed. How much
|
||||
# arrived is unknowable, so the caller must not trust any count.
|
||||
self.last_typed_count = -1
|
||||
logger.error(f"Text injection timed out, text may be partially typed: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to inject text: {e}", exc_info=True)
|
||||
|
||||
@@ -1635,7 +1743,7 @@ class TextInjector:
|
||||
logger.info("Text copied to clipboard as fallback - user can paste manually")
|
||||
self._show_clipboard_fallback_notification()
|
||||
return True
|
||||
except Exception as clipboard_error:
|
||||
except (OSError, subprocess.SubprocessError, RuntimeError) as clipboard_error:
|
||||
logger.debug(f"Clipboard fallback also failed: {clipboard_error}")
|
||||
|
||||
try:
|
||||
@@ -1687,6 +1795,7 @@ class TextInjector:
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
if active_window.returncode == 0 and active_window.stdout.strip():
|
||||
@@ -1698,6 +1807,7 @@ class TextInjector:
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
# Wait a moment for the focus to take effect
|
||||
time.sleep(0.2)
|
||||
@@ -1709,6 +1819,9 @@ class TextInjector:
|
||||
max_retries = 2
|
||||
logger.debug(f"Starting xdotool injection with {max_retries} max retries")
|
||||
|
||||
# Retries resume at the first untyped chunk: restarting from the
|
||||
# top would duplicate the chunks a failed attempt already sent.
|
||||
typed = 0
|
||||
for retry in range(max_retries + 1):
|
||||
try:
|
||||
# Inject in smaller chunks to avoid issues with very long text
|
||||
@@ -1718,7 +1831,9 @@ class TextInjector:
|
||||
f"Splitting text into {total_chunks} chunks of max {chunk_size} chars"
|
||||
)
|
||||
|
||||
for i in range(0, len(text), chunk_size):
|
||||
for i in range(typed, len(text), chunk_size):
|
||||
if self._abort_injections.is_set():
|
||||
raise _InjectionAborted
|
||||
chunk = text[i : i + chunk_size]
|
||||
chunk_num = (i // chunk_size) + 1
|
||||
|
||||
@@ -1734,6 +1849,7 @@ class TextInjector:
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
typed = min(i + chunk_size, len(text))
|
||||
|
||||
# Add a larger delay between chunks
|
||||
if i + chunk_size < len(text):
|
||||
@@ -1752,7 +1868,9 @@ class TextInjector:
|
||||
time.sleep(0.5) # Wait before retry
|
||||
else:
|
||||
logger.error(f"Final attempt failed: {chunk_error.stderr}")
|
||||
raise # Re-raise on final attempt
|
||||
# typed counts only completed chunks, so the
|
||||
# clipboard fallback gets the untyped remainder.
|
||||
raise _PartiallyTyped(typed, chunk_error) from chunk_error
|
||||
except subprocess.TimeoutExpired:
|
||||
if retry < max_retries:
|
||||
logger.warning(
|
||||
@@ -1784,6 +1902,7 @@ class TextInjector:
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
timeout=2,
|
||||
)
|
||||
except Exception:
|
||||
pass # Ignore any errors from this command
|
||||
@@ -1961,6 +2080,8 @@ class TextInjector:
|
||||
"terminal" if use_terminal_paste else "standard",
|
||||
paste_cmd,
|
||||
)
|
||||
if self._abort_injections.is_set():
|
||||
raise _InjectionAborted
|
||||
subprocess.run(
|
||||
paste_cmd,
|
||||
check=True,
|
||||
@@ -2452,6 +2573,11 @@ class TextInjector:
|
||||
# still-held physical modifier, so the wait stays ahead of every tool.
|
||||
self._wait_for_modifiers_released()
|
||||
|
||||
# Characters the portal confirmed before a demote: partial-type counts
|
||||
# raised later are relative to the shortened text and must be rebased
|
||||
# by this prefix or the caller replays delivered input.
|
||||
typed_prefix = 0
|
||||
|
||||
if self.wayland_tool == "portal":
|
||||
portal_ok, remaining_text = self._try_inject_with_portal(text)
|
||||
if portal_ok:
|
||||
@@ -2460,10 +2586,21 @@ class TextInjector:
|
||||
raise RuntimeError(
|
||||
"RemoteDesktop portal injection failed and no Wayland fallback is available"
|
||||
)
|
||||
if remaining_text is None:
|
||||
# The timed-out job never reported its delivered count, so
|
||||
# retyping any part could duplicate what already arrived.
|
||||
# Surface as a timeout: the caller drops the injection rather
|
||||
# than offering a clipboard copy of the full text.
|
||||
raise subprocess.TimeoutExpired(cmd="portal", timeout=_PORTAL_SUBMIT_TIMEOUT_S)
|
||||
if not remaining_text:
|
||||
return
|
||||
typed_prefix = len(text) - len(remaining_text)
|
||||
if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL:
|
||||
self._inject_with_xdotool(remaining_text)
|
||||
try:
|
||||
self._inject_with_xdotool(remaining_text)
|
||||
except _PartiallyTyped as nested:
|
||||
# Keep the count relative to the original text.
|
||||
raise _PartiallyTyped(typed_prefix + nested.typed, nested) from nested
|
||||
return
|
||||
text = remaining_text
|
||||
|
||||
@@ -2488,21 +2625,48 @@ class TextInjector:
|
||||
"(character-by-character; text may be scrambled on non-US layouts)"
|
||||
)
|
||||
|
||||
if self.wayland_tool == "wtype":
|
||||
cmd = ["wtype", text]
|
||||
else: # ydotool
|
||||
# Keep key-delay > 0 to avoid Shift-leak ("Can you" -> "CAN YOu").
|
||||
# Low delay so fallback typing finishes quickly for long phrases.
|
||||
key_delay = os.environ.get("VOCALINUX_YDOTOOL_KEY_DELAY", "2")
|
||||
cmd = ["ydotool", "type", "--key-delay", key_delay, text]
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True, stderr=subprocess.PIPE, text=True, env=host_env())
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Re-raise with stderr preserved for better diagnostics
|
||||
raise subprocess.CalledProcessError(
|
||||
e.returncode, e.cmd, output=e.output, stderr=e.stderr
|
||||
) from e
|
||||
# Type in chunks so shutdown can abort between subprocess calls — one
|
||||
# whole-string call could not be interrupted for its full
|
||||
# length-scaled duration. Each chunk's budget is a generous multiple
|
||||
# of its expected duration: a legitimately slow type must never be cut
|
||||
# mid-text — only a stall far beyond it is.
|
||||
chunk_size = 200
|
||||
for i in range(0, len(text), chunk_size):
|
||||
if self._abort_injections.is_set():
|
||||
raise _InjectionAborted
|
||||
chunk = text[i : i + chunk_size]
|
||||
if self.wayland_tool == "wtype":
|
||||
cmd = ["wtype", chunk]
|
||||
# wtype types at compositor pace; the budget scales with the
|
||||
# chunk length so only a wedged process can ever hit it.
|
||||
type_timeout = max(5, len(chunk) * 0.05)
|
||||
else: # ydotool
|
||||
# Keep key-delay > 0 to avoid Shift-leak ("Can you" -> "CAN YOu").
|
||||
# Low delay so fallback typing finishes quickly for long phrases.
|
||||
key_delay = os.environ.get("VOCALINUX_YDOTOOL_KEY_DELAY", "2")
|
||||
cmd = ["ydotool", "type", "--key-delay", key_delay, chunk]
|
||||
type_timeout = max(5, len(chunk) * self._key_delay_seconds(key_delay) * 4)
|
||||
try:
|
||||
subprocess.run(
|
||||
cmd,
|
||||
check=True,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=type_timeout,
|
||||
env=host_env(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"{self.wayland_tool} type timed out; text may be partially typed")
|
||||
raise
|
||||
except subprocess.CalledProcessError as e:
|
||||
if i > 0:
|
||||
# Earlier chunks are already on screen; let the caller
|
||||
# continue from the untyped remainder.
|
||||
raise _PartiallyTyped(typed_prefix + i, e) from e
|
||||
# Re-raise with stderr preserved for better diagnostics
|
||||
raise subprocess.CalledProcessError(
|
||||
e.returncode, e.cmd, output=e.output, stderr=e.stderr
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
f"Text injected using {self.wayland_tool}: '{text[:20]}...' ({len(text)} chars)"
|
||||
@@ -2520,6 +2684,9 @@ class TextInjector:
|
||||
"""
|
||||
logger.debug(f"Injecting keyboard shortcut: {shortcut}")
|
||||
|
||||
if self._abort_injections.is_set():
|
||||
return False
|
||||
|
||||
try:
|
||||
if (
|
||||
self.environment == DesktopEnvironment.X11
|
||||
@@ -2562,12 +2729,22 @@ class TextInjector:
|
||||
|
||||
try:
|
||||
cmd = ["xdotool", "key", "--clearmodifiers", shortcut]
|
||||
subprocess.run(cmd, env=host_env(env), check=True, stderr=subprocess.PIPE, text=True)
|
||||
subprocess.run(
|
||||
cmd,
|
||||
env=host_env(env),
|
||||
check=True,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
logger.debug(f"Keyboard shortcut '{shortcut}' injected successfully")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"xdotool shortcut error: {e.stderr}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"xdotool shortcut timed out: '{shortcut}'")
|
||||
return False
|
||||
|
||||
def _inject_shortcut_with_wayland_tool(self, shortcut: str) -> bool:
|
||||
"""
|
||||
@@ -2589,12 +2766,21 @@ class TextInjector:
|
||||
# wait it out first -- same reason and placement as _inject_with_wayland_tool.
|
||||
self._wait_for_modifiers_released()
|
||||
|
||||
# Characters the portal confirmed before a demote: partial-type counts
|
||||
# raised later are relative to the shortened text and must be rebased
|
||||
# by this prefix or the caller replays delivered input.
|
||||
typed_prefix = 0
|
||||
|
||||
if self.wayland_tool == "portal":
|
||||
portal_ok, remaining_steps = self._try_portal_shortcut(steps)
|
||||
if portal_ok:
|
||||
return True
|
||||
if not self._demote_portal_backend():
|
||||
return False
|
||||
if remaining_steps is None:
|
||||
# Untracked delivery: replaying the shortcut could trigger
|
||||
# actions the timed-out job already sent.
|
||||
return False
|
||||
if not remaining_steps:
|
||||
return True
|
||||
steps = remaining_steps
|
||||
@@ -2821,6 +3007,9 @@ class TextInjector:
|
||||
if count <= 0:
|
||||
return True
|
||||
|
||||
if self._abort_injections.is_set():
|
||||
return False
|
||||
|
||||
logger.debug(f"Sending {count} backspace key event(s)")
|
||||
|
||||
if (
|
||||
@@ -2869,6 +3058,10 @@ class TextInjector:
|
||||
return True
|
||||
if not self._demote_portal_backend():
|
||||
return False
|
||||
if remaining is None:
|
||||
# Untracked delivery: replaying presses could delete text the
|
||||
# timed-out job already removed.
|
||||
return False
|
||||
if remaining <= 0:
|
||||
return True
|
||||
return self.press_backspace(remaining)
|
||||
|
||||
@@ -207,6 +207,9 @@ DEFAULT_CONFIG = {
|
||||
"enabled": True, # Keep recent dictation snippets in the tray menu
|
||||
"max_items": 10, # How many snippets to retain (in-memory only, cleared on quit)
|
||||
},
|
||||
"post_processing": {
|
||||
"script_path": "", # Path to executable; empty = disabled
|
||||
},
|
||||
"dictionary": {
|
||||
# Preserve the custom-dictionary contract used by the accessibility scanner.
|
||||
"enabled": False,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2706,6 +2706,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
SettingsPage("performance", "Performance", "power-profile-performance-symbolic"),
|
||||
SettingsPage("application", "Application", "preferences-system-symbolic"),
|
||||
SettingsPage("advanced", "Advanced", "applications-engineering-symbolic"),
|
||||
SettingsPage("post-processing", "Post-Processing", "utilities-terminal-symbolic"),
|
||||
SettingsPage("about", "About", "help-about-symbolic"),
|
||||
]
|
||||
pages_by_name = {page.name: page for page in self._pages}
|
||||
@@ -2720,6 +2721,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.power_tab = pages_by_name["performance"].box
|
||||
self.general_tab = pages_by_name["application"].box
|
||||
self.advanced_tab = pages_by_name["advanced"].box
|
||||
self.post_processing_tab = pages_by_name["post-processing"].box
|
||||
self.about_tab = pages_by_name["about"].box
|
||||
|
||||
# Each page is wrapped in a vertical ScrolledWindow: without one, the
|
||||
@@ -2814,6 +2816,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self._build_gpu_section()
|
||||
self._build_general_section()
|
||||
self._build_advanced_section()
|
||||
self._build_post_processing_section()
|
||||
self._build_about_section()
|
||||
self._build_sidebar_footer(sidebar_box)
|
||||
|
||||
@@ -3567,6 +3570,10 @@ class SettingsDialog(Gtk.Dialog):
|
||||
return False
|
||||
if not self.dictionary_manager.set_terms_enabled(bool(state)):
|
||||
self.dictionary_feedback_label.set_text("Could not save custom terms setting.")
|
||||
self._refresh_dictionary_ui()
|
||||
# Returning True suppresses GTK's own state-set so the switch
|
||||
# shows the value that actually persisted, not the requested one.
|
||||
return True
|
||||
self._refresh_dictionary_ui()
|
||||
return False
|
||||
|
||||
@@ -6638,6 +6645,63 @@ class SettingsDialog(Gtk.Dialog):
|
||||
|
||||
self.gpu_device_combo.connect("changed", self._on_advanced_param_changed)
|
||||
|
||||
def _build_post_processing_section(self) -> None:
|
||||
"""Build the Post-Processing section."""
|
||||
group = PreferencesGroup(
|
||||
title="Post-Processing Script",
|
||||
description=(
|
||||
"Run an executable on each transcription result. "
|
||||
"The script receives the text on stdin and must write the replacement text to stdout."
|
||||
),
|
||||
)
|
||||
|
||||
path_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
self.post_processor_entry = Gtk.Entry()
|
||||
self.post_processor_entry.set_placeholder_text("/path/to/script.sh")
|
||||
self.post_processor_entry.set_tooltip_text(
|
||||
"Path to an executable that transforms transcribed text"
|
||||
)
|
||||
self.post_processor_entry.set_hexpand(True)
|
||||
|
||||
browse_button = Gtk.Button(label="Browse…")
|
||||
browse_button.connect("clicked", self._on_post_processor_browse_clicked)
|
||||
|
||||
path_box.pack_start(self.post_processor_entry, True, True, 0)
|
||||
path_box.pack_start(browse_button, False, False, 0)
|
||||
|
||||
script_row = PreferenceRow(
|
||||
title="Script Path",
|
||||
subtitle="Leave empty to disable post-processing",
|
||||
widget=path_box,
|
||||
)
|
||||
group.add_row(script_row)
|
||||
self.post_processing_tab.pack_start(group, False, False, 0)
|
||||
|
||||
self.post_processor_entry.connect("changed", self._on_post_processor_script_changed)
|
||||
|
||||
def _on_post_processor_browse_clicked(self, widget: Gtk.Button) -> None:
|
||||
dialog = Gtk.FileChooserDialog(
|
||||
title="Select Post-Processing Script",
|
||||
parent=self,
|
||||
action=Gtk.FileChooserAction.OPEN,
|
||||
)
|
||||
dialog.add_buttons(
|
||||
Gtk.STOCK_CANCEL,
|
||||
Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_OPEN,
|
||||
Gtk.ResponseType.OK,
|
||||
)
|
||||
if dialog.run() == Gtk.ResponseType.OK:
|
||||
self.post_processor_entry.set_text(dialog.get_filename())
|
||||
dialog.destroy()
|
||||
|
||||
def _on_post_processor_script_changed(self, widget: Gtk.Entry) -> None:
|
||||
if self._initializing or self._applying_settings:
|
||||
return
|
||||
path = widget.get_text().strip()
|
||||
self.config_manager.set("post_processing", "script_path", path)
|
||||
self.config_manager.save_config()
|
||||
|
||||
def _build_remote_server_section(self):
|
||||
"""Build the Remote Server configuration section (shown when Remote API engine is selected)."""
|
||||
self.remote_server_group = PreferencesGroup(
|
||||
@@ -7319,6 +7383,9 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.disable_internal_hotkey_switch.set_active(disable_internal_hotkey)
|
||||
self._update_internal_hotkey_sensitivity(disable_internal_hotkey)
|
||||
|
||||
post_processing_settings = self.config_manager.get_settings().get("post_processing", {})
|
||||
self.post_processor_entry.set_text(post_processing_settings.get("script_path", ""))
|
||||
|
||||
available_engines = get_available_engines()
|
||||
available_count = 0
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ class TrayIndicator:
|
||||
speech_engine: SpeechRecognitionManagerProtocol,
|
||||
text_injector: TextInjectorProtocol,
|
||||
transcription_history: Optional[TranscriptionHistory] = None,
|
||||
on_quit: Optional[Callable[[], None]] = None,
|
||||
dictation_pad: Optional[Any] = None,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -159,12 +160,16 @@ class TrayIndicator:
|
||||
text_injector: The text injector instance
|
||||
transcription_history: Optional in-memory store of recent dictation
|
||||
snippets. When provided, a "Recent Snippets" submenu is shown.
|
||||
on_quit: Optional hook run during _quit before the text injector
|
||||
is stopped (drains the post-processing worker so a queued or
|
||||
in-flight segment cannot inject into a torn-down app)
|
||||
dictation_pad: Optional in-app Dictation Pad window the tray menu
|
||||
can open (the Wayland-safe dictation fallback, #726)
|
||||
"""
|
||||
self.speech_engine = speech_engine
|
||||
self.text_injector = text_injector
|
||||
self.transcription_history = transcription_history
|
||||
self._on_quit = on_quit
|
||||
self.dictation_pad = dictation_pad
|
||||
# Shared with main() and the settings dialog: separate instances would
|
||||
# overwrite each other's saves with stale in-memory copies.
|
||||
@@ -388,11 +393,11 @@ class TrayIndicator:
|
||||
def _setup_language_shortcuts(self) -> None:
|
||||
"""(Re)build a listener per configured language shortcut (#805).
|
||||
|
||||
Each entry gets its own KeyboardShortcutManager because the backends
|
||||
open the input devices read-only and never grab them, so parallel
|
||||
listeners do not interfere. A binding whose gesture collides with the
|
||||
main shortcut or an earlier language binding is skipped: the same
|
||||
gesture cannot fire both.
|
||||
Each entry gets its own KeyboardShortcutManager; on evdev the
|
||||
backends all share one process-wide device layer, so parallel
|
||||
listeners observe the same keyboards without competing grabs. A
|
||||
binding whose gesture collides with the main shortcut or an earlier
|
||||
language binding is skipped: the same gesture cannot fire both.
|
||||
"""
|
||||
# Same stop-first protection as _setup_keyboard_shortcuts: rebuilding
|
||||
# below removes the release callback a held push-to-talk key needs to
|
||||
@@ -1789,6 +1794,15 @@ class TrayIndicator:
|
||||
self.overlay.destroy()
|
||||
self.overlay = None
|
||||
|
||||
# Drain the post-processing worker before the injector stops: queued
|
||||
# segments are cancelled and a running job drops its result, so no
|
||||
# injection can land once the injector is gone.
|
||||
if getattr(self, "_on_quit", None) is not None:
|
||||
try:
|
||||
self._on_quit()
|
||||
except Exception:
|
||||
logger.error("Error draining post-processing worker while quitting", exc_info=True)
|
||||
|
||||
# Stop the text injector (restores previous IBus engine)
|
||||
if hasattr(self, "text_injector") and self.text_injector is not None:
|
||||
self.text_injector.stop()
|
||||
|
||||
@@ -769,6 +769,10 @@ class TestConfigManager(unittest.TestCase):
|
||||
new_config_manager = ConfigManager()
|
||||
self.assertFalse(new_config_manager.is_sound_effects_enabled())
|
||||
|
||||
def test_post_processing_script_path_empty_by_default(self) -> None:
|
||||
config_manager = ConfigManager()
|
||||
self.assertEqual(config_manager.get_str("post_processing", "script_path", ""), "")
|
||||
|
||||
|
||||
class TestTypedAccessors(unittest.TestCase):
|
||||
"""Tests for typed config accessors."""
|
||||
|
||||
@@ -23,6 +23,7 @@ from vocalinux.ui.keyboard_backends.evdev_backend import (
|
||||
DEVICE_RESCAN_SECONDS,
|
||||
EVDEV_AVAILABLE,
|
||||
MODIFIER_KEY_CODES,
|
||||
EvdevDeviceHub,
|
||||
EvdevKeyboardBackend,
|
||||
device_has_key,
|
||||
device_has_modifier_key,
|
||||
@@ -865,7 +866,7 @@ class TestEvdevKeyboardBackendHotplug:
|
||||
backend.devices = [device]
|
||||
backend.device_fds = [10]
|
||||
backend._remove_keyboard_device = MagicMock(
|
||||
side_effect=lambda fd, device: setattr(backend, "running", False)
|
||||
side_effect=lambda fd, device, generation: setattr(backend, "running", False)
|
||||
)
|
||||
|
||||
with (
|
||||
@@ -880,7 +881,9 @@ class TestEvdevKeyboardBackendHotplug:
|
||||
):
|
||||
backend._monitor_devices()
|
||||
|
||||
backend._remove_keyboard_device.assert_called_once_with(10, device)
|
||||
backend._remove_keyboard_device.assert_called_once_with(
|
||||
10, device, backend._hub._generation
|
||||
)
|
||||
|
||||
def test_monitor_handles_fd_lookup_error(self):
|
||||
"""Test fd lookup errors do not try to remove an unknown device."""
|
||||
@@ -1641,3 +1644,409 @@ class TestEvdevGrabAndForwarding:
|
||||
|
||||
backend._handle_key_event.assert_not_called()
|
||||
forwarder.write_event.assert_called_once_with(other_event)
|
||||
|
||||
def test_pure_modifier_press_stays_consumed(self) -> None:
|
||||
"""A real PTT hold never reaches the application."""
|
||||
backend = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
|
||||
fd = 10
|
||||
backend._forwarders[fd] = MagicMock()
|
||||
backend._forwarded_held[fd] = set()
|
||||
backend._withheld_modifier[fd] = {}
|
||||
|
||||
assert backend._event_is_shortcut(fd, self._key_event(100, 1)) is True
|
||||
assert backend._event_is_shortcut(fd, self._key_event(100, 0)) is True
|
||||
backend._forwarders[fd].write_event.assert_not_called()
|
||||
|
||||
def test_altgr_chord_replays_withheld_press(self) -> None:
|
||||
"""RightAlt held for composition replays its press and release."""
|
||||
backend = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
|
||||
fd = 10
|
||||
forwarder = MagicMock()
|
||||
backend._forwarders[fd] = forwarder
|
||||
backend._forwarded_held[fd] = set()
|
||||
backend._withheld_modifier[fd] = {}
|
||||
|
||||
press = self._key_event(100, 1) # KEY_RIGHTALT down
|
||||
key_e = self._key_event(18, 1) # KEY_E down (AltGr+e)
|
||||
release = self._key_event(100, 0) # KEY_RIGHTALT up
|
||||
|
||||
assert backend._event_is_shortcut(fd, press) is True
|
||||
forwarder.write_event.assert_not_called()
|
||||
|
||||
assert backend._event_is_shortcut(fd, key_e) is False
|
||||
forwarder.write_event.assert_called_once_with(press)
|
||||
assert 100 in backend._forwarded_held[fd]
|
||||
|
||||
assert backend._event_is_shortcut(fd, release) is True
|
||||
# The replayed modifier's release passes through so the clone's key
|
||||
# state matches the physical keyboard.
|
||||
assert forwarder.write_event.call_count == 2
|
||||
forwarder.write_event.assert_called_with(release)
|
||||
|
||||
def test_withheld_state_drops_on_device_removal(self) -> None:
|
||||
"""Disconnecting a keyboard forgets its withheld modifier presses."""
|
||||
backend = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
|
||||
device = MagicMock()
|
||||
device.fileno.return_value = 10
|
||||
forwarder = MagicMock()
|
||||
backend.devices = [device]
|
||||
backend.device_fds = [10]
|
||||
backend.device_paths = {"/dev/input/event0"}
|
||||
backend._device_paths_by_fd = {10: "/dev/input/event0"}
|
||||
backend._forwarders = {10: forwarder}
|
||||
backend._withheld_modifier[10] = {100: [self._key_event(100, 1), False]}
|
||||
|
||||
backend._remove_keyboard_device(10, device)
|
||||
|
||||
assert backend._withheld_modifier == {}
|
||||
|
||||
def test_split_keyboard_altgr_replays_modifier_from_other_device(self) -> None:
|
||||
"""A chord spanning two devices still replays the withheld modifier.
|
||||
|
||||
On a split keyboard RightAlt arrives on one device and the character
|
||||
key on another: the modifier is withheld under its own fd, so the
|
||||
replay must scan every device — checking only the character key's fd
|
||||
would forward a plain character and lose the AltGr composition.
|
||||
"""
|
||||
backend = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
|
||||
mod_fd, char_fd = 10, 20
|
||||
mod_forwarder, char_forwarder = MagicMock(), MagicMock()
|
||||
backend._forwarders = {mod_fd: mod_forwarder, char_fd: char_forwarder}
|
||||
backend._forwarded_held = {mod_fd: set(), char_fd: set()}
|
||||
backend._withheld_modifier = {mod_fd: {}, char_fd: {}}
|
||||
|
||||
press = self._key_event(100, 1) # KEY_RIGHTALT down on the left half
|
||||
key_e = self._key_event(18, 1) # KEY_E down on the right half
|
||||
|
||||
assert backend._event_is_shortcut(mod_fd, press) is True
|
||||
mod_forwarder.write_event.assert_not_called()
|
||||
|
||||
assert backend._event_is_shortcut(char_fd, key_e) is False
|
||||
# The modifier press is replayed through ITS OWN device's clone —
|
||||
# before the character is forwarded — so the app sees AltGr+e.
|
||||
mod_forwarder.write_event.assert_called_once_with(press)
|
||||
assert 100 in backend._forwarded_held[mod_fd]
|
||||
|
||||
def test_syn_dropped_prunes_unreplayed_withheld_modifier(self) -> None:
|
||||
"""A withheld press whose release SYN_DROPPED ate leaves no stale entry.
|
||||
|
||||
When the modifier was never replayed the clone holds nothing, so the
|
||||
old early return skipped the withheld-state cleanup entirely and a
|
||||
later ordinary press would replay a modifier that is not held.
|
||||
"""
|
||||
backend = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
|
||||
fd = 10
|
||||
device = MagicMock()
|
||||
device.active_keys.return_value = [] # modifier already released
|
||||
backend._forwarders = {fd: MagicMock()}
|
||||
backend._forwarded_held = {fd: set()} # nothing forwarded
|
||||
backend._withheld_modifier = {fd: {100: [self._key_event(100, 1), False]}}
|
||||
|
||||
backend._resync_clone_key_state(fd, device)
|
||||
|
||||
assert backend._withheld_modifier[fd] == {}
|
||||
|
||||
|
||||
class TestSharedEvdevDeviceLayer:
|
||||
"""Several backends share one evdev reader instead of competing grabs.
|
||||
|
||||
Regression tests for the "language listeners compete for keyboards"
|
||||
finding: each EvdevKeyboardBackend used to open and grab its own
|
||||
InputDevice on the same keyboard, so the first grabber won and every
|
||||
other listener received no events — language shortcuts could never
|
||||
fire on Wayland.
|
||||
"""
|
||||
|
||||
def _key_event(self, code: int, value: int = 1) -> MagicMock:
|
||||
return MagicMock(type=ecodes.EV_KEY, code=code, value=value)
|
||||
|
||||
def _registered(self, *engines: EvdevKeyboardBackend) -> EvdevDeviceHub:
|
||||
"""Register engines on the shared hub without opening devices."""
|
||||
hub = engines[0]._hub
|
||||
hub.running = True
|
||||
for engine in engines:
|
||||
assert hub.register(engine) is True
|
||||
return hub
|
||||
|
||||
def test_backends_share_one_device_layer(self) -> None:
|
||||
"""Every backend binds to the same process-wide hub."""
|
||||
first = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
second = EvdevKeyboardBackend(shortcut="alt+d")
|
||||
|
||||
assert first._hub is second._hub
|
||||
assert second.devices is first.devices
|
||||
assert second._forwarders is first._forwarders
|
||||
assert second._device_paths_by_fd is first._device_paths_by_fd
|
||||
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.UInput")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.InputDevice")
|
||||
@patch("vocalinux.ui.keyboard_backends.evdev_backend.find_keyboard_devices")
|
||||
def test_second_start_registers_without_reopening_devices(
|
||||
self, mock_find: Mock, mock_input_device: Mock, mock_uinput: Mock
|
||||
) -> None:
|
||||
"""The first backend opens and grabs once; later ones just register."""
|
||||
mock_find.return_value = ["/dev/input/event0"]
|
||||
device = MagicMock()
|
||||
device.name = "test-kbd"
|
||||
device.fileno.return_value = 10
|
||||
device.capabilities.return_value = {}
|
||||
mock_input_device.return_value = device
|
||||
forwarder = MagicMock()
|
||||
forwarder.device.path = "/dev/input/event9"
|
||||
mock_uinput.return_value = forwarder
|
||||
|
||||
first = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
with patch("select.select", return_value=([], [], [])):
|
||||
assert first.start() is True
|
||||
second = EvdevKeyboardBackend(shortcut="alt+d")
|
||||
assert second.start() is True
|
||||
|
||||
assert mock_input_device.call_count == 1 # device opened once
|
||||
assert device.grab.call_count == 1 # grabbed once
|
||||
assert set(first._hub._engine_snapshot()) == {first, second}
|
||||
|
||||
second.stop()
|
||||
assert first._hub.running is True # layer outlives one listener
|
||||
first.stop()
|
||||
assert first._hub.running is False
|
||||
device.close.assert_called()
|
||||
|
||||
def test_two_engines_both_fire_on_one_device(self) -> None:
|
||||
"""Events on a shared keyboard reach every registered engine."""
|
||||
german = EvdevKeyboardBackend(shortcut="ctrl+ctrl", mode="toggle")
|
||||
french = EvdevKeyboardBackend(shortcut="alt+d", mode="toggle")
|
||||
hub = self._registered(german, french)
|
||||
|
||||
german_fired = MagicMock()
|
||||
french_fired = MagicMock()
|
||||
german.register_toggle_callback(german_fired)
|
||||
french.register_toggle_callback(french_fired)
|
||||
|
||||
device = MagicMock()
|
||||
fd = 10
|
||||
device.read.return_value = [
|
||||
self._key_event(29, 1), # ctrl press (german tap 1)
|
||||
self._key_event(29, 0), # ctrl release
|
||||
self._key_event(29, 1), # ctrl press (german double-tap)
|
||||
self._key_event(56, 1), # alt press (french modifier)
|
||||
self._key_event(32, 1), # d press (french combo main key)
|
||||
]
|
||||
|
||||
hub._dispatch_events(fd, device)
|
||||
|
||||
time.sleep(0.1)
|
||||
german_fired.assert_called_once()
|
||||
french_fired.assert_called_once()
|
||||
|
||||
def test_shortcut_events_consumed_once_across_engines(self) -> None:
|
||||
"""An event claimed by any engine never reaches the application."""
|
||||
pure = EvdevKeyboardBackend(shortcut="right_alt+right_alt")
|
||||
combo = EvdevKeyboardBackend(shortcut="ctrl+f", mode="toggle")
|
||||
hub = self._registered(pure, combo)
|
||||
fd = 10
|
||||
forwarder = MagicMock()
|
||||
hub._forwarders = {fd: forwarder}
|
||||
hub._forwarded_held = {fd: set()}
|
||||
|
||||
ctrl_press = self._key_event(29, 1)
|
||||
f_press = self._key_event(33, 1)
|
||||
a_press = self._key_event(30, 1)
|
||||
ralt_press = self._key_event(100, 1)
|
||||
ralt_release = self._key_event(100, 0)
|
||||
device = MagicMock()
|
||||
device.read.return_value = [ctrl_press, f_press, a_press, ralt_press, ralt_release]
|
||||
|
||||
hub._dispatch_events(fd, device)
|
||||
|
||||
# f is consumed by the combo engine while ctrl is held, both right-alt
|
||||
# events by the pure-modifier engine; unrelated keys still pass through.
|
||||
forwarded = [call.args[0] for call in forwarder.write_event.call_args_list]
|
||||
assert forwarded == [ctrl_press, a_press]
|
||||
|
||||
def test_unregister_shuts_down_only_when_last_engine_leaves(self) -> None:
|
||||
"""The shared layer stays up until its last listener stops."""
|
||||
first = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
second = EvdevKeyboardBackend(shortcut="alt+d")
|
||||
hub = self._registered(first, second)
|
||||
first.active = True
|
||||
second.active = True
|
||||
device = MagicMock()
|
||||
hub.devices = [device]
|
||||
|
||||
first.stop()
|
||||
assert hub.running is True
|
||||
device.close.assert_not_called()
|
||||
|
||||
second.stop()
|
||||
assert hub.running is False
|
||||
assert hub.devices == []
|
||||
device.close.assert_called_once()
|
||||
|
||||
def test_dispatch_reaches_only_registered_engines(self) -> None:
|
||||
"""A backend that never started observes nothing."""
|
||||
active = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
idle = EvdevKeyboardBackend(shortcut="alt+d")
|
||||
hub = self._registered(active)
|
||||
active._handle_key_event = MagicMock()
|
||||
idle._handle_key_event = MagicMock()
|
||||
|
||||
device = MagicMock()
|
||||
device.read.return_value = [self._key_event(29, 1)]
|
||||
hub._dispatch_events(10, device)
|
||||
|
||||
active._handle_key_event.assert_called_once()
|
||||
idle._handle_key_event.assert_not_called()
|
||||
|
||||
def test_register_waits_for_in_flight_teardown(self) -> None:
|
||||
"""Devices opened after teardown starts are never closed by it.
|
||||
|
||||
Regression test for "keyboard listener can lose devices": the last
|
||||
engine's unregister used to release the lock before closing, so a
|
||||
new register could open keyboards in the gap only for the stale
|
||||
teardown to close them — the new listener stayed active but dead.
|
||||
"""
|
||||
hub = EvdevDeviceHub()
|
||||
old_engine = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
new_engine = EvdevKeyboardBackend(shortcut="alt+d")
|
||||
old_device = MagicMock()
|
||||
hub.running = True
|
||||
hub._engines.add(old_engine)
|
||||
hub.devices = [old_device]
|
||||
|
||||
teardown_started = threading.Event()
|
||||
finish_teardown = threading.Event()
|
||||
|
||||
def blocking_close() -> None:
|
||||
teardown_started.set()
|
||||
finish_teardown.wait(timeout=5.0)
|
||||
|
||||
hub._close_all_devices = blocking_close
|
||||
|
||||
unregister_done = threading.Event()
|
||||
|
||||
def run_unregister() -> None:
|
||||
hub.unregister(old_engine)
|
||||
unregister_done.set()
|
||||
|
||||
threading.Thread(target=run_unregister, daemon=True).start()
|
||||
assert teardown_started.wait(timeout=5.0)
|
||||
|
||||
new_device = MagicMock()
|
||||
new_device.fileno.return_value = 42
|
||||
result: list[bool] = []
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.ui.keyboard_backends.evdev_backend.find_keyboard_devices",
|
||||
return_value=["/dev/input/event9"],
|
||||
),
|
||||
patch(
|
||||
"vocalinux.ui.keyboard_backends.evdev_backend.InputDevice",
|
||||
return_value=new_device,
|
||||
),
|
||||
patch(
|
||||
"vocalinux.ui.keyboard_backends.evdev_backend.UInput",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
):
|
||||
register_thread = threading.Thread(
|
||||
target=lambda: result.append(hub.register(new_engine)),
|
||||
daemon=True,
|
||||
)
|
||||
register_thread.start()
|
||||
register_thread.join(timeout=0.5)
|
||||
assert register_thread.is_alive() # blocked while teardown runs
|
||||
|
||||
finish_teardown.set()
|
||||
register_thread.join(timeout=5.0)
|
||||
assert result == [True]
|
||||
new_device.close.assert_not_called()
|
||||
assert hub.running is True
|
||||
assert hub.devices == [new_device]
|
||||
assert new_engine in hub._engines
|
||||
assert old_engine not in hub._engines
|
||||
|
||||
assert unregister_done.wait(timeout=5.0)
|
||||
hub.reset()
|
||||
|
||||
def test_unregister_joins_monitor_outside_the_lifecycle_lock(self) -> None:
|
||||
"""The monitor join must not hold the lock its cleanup queues on.
|
||||
|
||||
Regression test for "monitor cleanup delays shutdown": joining under
|
||||
_lifecycle_lock stalled the full 2s timeout whenever the monitor's
|
||||
unexpected-exit cleanup raced in for the same lock.
|
||||
"""
|
||||
hub = EvdevDeviceHub()
|
||||
engine = EvdevKeyboardBackend(shortcut="ctrl+ctrl")
|
||||
hub.running = True
|
||||
hub._engines.add(engine)
|
||||
monitor = MagicMock()
|
||||
hub.monitor_thread = monitor
|
||||
|
||||
lock_was_free = threading.Event()
|
||||
|
||||
def join_and_probe(timeout: float) -> None:
|
||||
if hub._lifecycle_lock.acquire(blocking=False):
|
||||
lock_was_free.set()
|
||||
hub._lifecycle_lock.release()
|
||||
|
||||
monitor.join = join_and_probe
|
||||
|
||||
hub.unregister(engine)
|
||||
|
||||
assert lock_was_free.is_set()
|
||||
assert hub.running is False
|
||||
assert hub.monitor_thread is None
|
||||
assert engine not in hub._engines
|
||||
hub.reset()
|
||||
|
||||
def test_stale_monitor_leaves_new_generation_devices_alone(self) -> None:
|
||||
"""A monitor that outlived its generation can't touch new devices.
|
||||
|
||||
Regression test for "old monitor reaches new devices": once the
|
||||
generation advanced, every fd the new listener opened is off-limits
|
||||
to the stale reader — no dispatch, no forward, no removal — even
|
||||
though its fd number is readable and mapped.
|
||||
"""
|
||||
hub = EvdevDeviceHub()
|
||||
hub.running = True
|
||||
hub._generation = 6
|
||||
|
||||
new_device = MagicMock()
|
||||
new_device.fileno.return_value = 10
|
||||
new_device.name = "External Keyboard"
|
||||
new_clone = MagicMock()
|
||||
hub.devices = [new_device]
|
||||
hub.device_fds = [10]
|
||||
hub.device_paths = {"/dev/input/event4"}
|
||||
hub._device_paths_by_fd = {10: "/dev/input/event4"}
|
||||
hub._forwarders = {10: new_clone}
|
||||
hub._fd_generation = {10: 6}
|
||||
hub._dispatch_events = MagicMock()
|
||||
|
||||
def select_then_stop(read_fds, write_fds, error_fds, timeout):
|
||||
hub.running = False
|
||||
return [10], [], []
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.ui.keyboard_backends.evdev_backend.time.monotonic",
|
||||
side_effect=[0.0, 0.0],
|
||||
),
|
||||
patch(
|
||||
"vocalinux.ui.keyboard_backends.evdev_backend.select.select",
|
||||
side_effect=select_then_stop,
|
||||
),
|
||||
):
|
||||
hub._monitor_devices(generation=5)
|
||||
|
||||
hub._dispatch_events.assert_not_called()
|
||||
|
||||
hub._forward_event(10, MagicMock(type=1, code=30, value=1), generation=5)
|
||||
new_clone.write_event.assert_not_called()
|
||||
|
||||
hub._remove_keyboard_device(10, new_device, generation=5)
|
||||
new_device.close.assert_not_called()
|
||||
assert hub.devices == [new_device]
|
||||
assert hub.device_fds == [10]
|
||||
|
||||
hub.reset()
|
||||
|
||||
@@ -1004,3 +1004,22 @@ def test_refresh_rebuilds_immediately_when_idle() -> None:
|
||||
assert tray._language_shortcuts_refresh_pending is False
|
||||
manager_class.assert_called_once_with(shortcut="alt+d", mode="toggle")
|
||||
manager.start.assert_called_once()
|
||||
|
||||
|
||||
def test_language_shortcut_managers_share_the_evdev_device_layer() -> None:
|
||||
"""Two per-language managers must not spawn competing evdev readers.
|
||||
|
||||
Regression test for PR #479 review: each manager used to open and grab
|
||||
its own InputDevice on the same keyboard, so only the first grabber
|
||||
ever saw events and language shortcuts could not fire on Wayland.
|
||||
"""
|
||||
from vocalinux.ui.keyboard_backends.evdev_backend import EvdevKeyboardBackend
|
||||
from vocalinux.ui.keyboard_shortcuts import KeyboardShortcutManager
|
||||
|
||||
with patch.object(EvdevKeyboardBackend, "is_available", return_value=True):
|
||||
german = KeyboardShortcutManager(backend="evdev", shortcut="alt+d", mode="toggle")
|
||||
french = KeyboardShortcutManager(backend="evdev", shortcut="ctrl+alt+f", mode="toggle")
|
||||
|
||||
assert isinstance(german.backend_instance, EvdevKeyboardBackend)
|
||||
assert isinstance(french.backend_instance, EvdevKeyboardBackend)
|
||||
assert french.backend_instance._hub is german.backend_instance._hub
|
||||
|
||||
+490
-52
@@ -197,6 +197,7 @@ class TestMainModule(unittest.TestCase):
|
||||
"general": {"first_run": False},
|
||||
}
|
||||
mock_config_instance.get_model_size_for_engine.return_value = "medium"
|
||||
mock_config_instance.get_str.return_value = "" # no post-processing script
|
||||
mock_config_instance.is_dictate_to_pad_enabled.return_value = False
|
||||
mock_config_manager.return_value = mock_config_instance
|
||||
|
||||
@@ -262,6 +263,7 @@ class TestMainModule(unittest.TestCase):
|
||||
speech_engine=mock_speech_instance,
|
||||
text_injector=mock_text_instance,
|
||||
transcription_history=ANY,
|
||||
on_quit=ANY,
|
||||
dictation_pad=mock_pad_instance,
|
||||
)
|
||||
# The pad starts from the shared manager's live capture flag.
|
||||
@@ -273,10 +275,11 @@ class TestMainModule(unittest.TestCase):
|
||||
mock_speech_instance.register_text_callback.assert_called_once()
|
||||
mock_speech_instance.register_segment_callback.assert_called_once()
|
||||
mock_speech_instance.register_action_callback.assert_called_once()
|
||||
# The registered wrapper delegates non-pad actions to ActionHandler.
|
||||
action_callback = mock_speech_instance.register_action_callback.call_args.args[0]
|
||||
action_callback("undo")
|
||||
mock_action_instance.handle_action.assert_called_once_with("undo")
|
||||
# The registered action callback queues the action onto the
|
||||
# post-processing worker, which dispatches to the action handler.
|
||||
action_cb = mock_speech_instance.register_action_callback.call_args.args[0]
|
||||
action_cb("select_all").result(timeout=10)
|
||||
mock_action_instance.handle_action.assert_called_once_with("select_all")
|
||||
mock_speech_instance.register_state_callback.assert_called_once()
|
||||
|
||||
# Verify the tray indicator was started
|
||||
@@ -305,6 +308,7 @@ class TestMainModule(unittest.TestCase):
|
||||
"speech_recognition": {},
|
||||
"general": {"first_run": False},
|
||||
}
|
||||
mock_config_instance.get_str.return_value = "" # no post-processing script
|
||||
mock_config_instance.is_dictate_to_pad_enabled.return_value = False
|
||||
mock_config_manager.return_value = mock_config_instance
|
||||
|
||||
@@ -332,17 +336,19 @@ class TestMainModule(unittest.TestCase):
|
||||
text_callback = mock_speech_instance.register_text_callback.call_args.args[0]
|
||||
state_callback = mock_speech_instance.register_state_callback.call_args.args[0]
|
||||
|
||||
text_callback("Hello.")
|
||||
# Injection now happens on the post-processing worker; the returned
|
||||
# future drains it synchronously.
|
||||
text_callback("Hello.").result(timeout=10)
|
||||
state_callback(RecognitionState.PROCESSING)
|
||||
state_callback(RecognitionState.LISTENING)
|
||||
text_callback("World")
|
||||
text_callback("World").result(timeout=10)
|
||||
|
||||
calls = [call.args[0] for call in mock_text_instance.inject_text.call_args_list]
|
||||
self.assertEqual(calls, ["Hello. ", "World "])
|
||||
|
||||
state_callback(RecognitionState.IDLE)
|
||||
mock_text_instance.inject_text.reset_mock()
|
||||
text_callback("Next session")
|
||||
text_callback("Next session").result(timeout=10)
|
||||
# Trailing space persists in the previous field; next session starts clean
|
||||
# (no leading space) but still gets its own trailing space.
|
||||
mock_text_instance.inject_text.assert_called_once_with("Next session ")
|
||||
@@ -369,6 +375,7 @@ class TestMainModule(unittest.TestCase):
|
||||
mock_config_instance.get.side_effect = lambda section, key, default=None: (
|
||||
auto_capitalize if section == "text_injection" and key == "auto_capitalize" else default
|
||||
)
|
||||
mock_config_instance.get_str.return_value = "" # no post-processing script
|
||||
mock_config_instance.is_dictate_to_pad_enabled.return_value = False
|
||||
mock_config_manager.return_value = mock_config_instance
|
||||
|
||||
@@ -424,7 +431,7 @@ class TestMainModule(unittest.TestCase):
|
||||
mock_check_deps=mock_check_deps,
|
||||
)
|
||||
|
||||
text_callback("hello world. goodbye")
|
||||
text_callback("hello world. goodbye").result(timeout=10)
|
||||
mock_text_instance.inject_text.assert_called_once_with("Hello world. Goodbye ")
|
||||
|
||||
@patch("vocalinux.main.check_dependencies")
|
||||
@@ -453,7 +460,7 @@ class TestMainModule(unittest.TestCase):
|
||||
mock_check_deps=mock_check_deps,
|
||||
)
|
||||
|
||||
text_callback("hello world. goodbye")
|
||||
text_callback("hello world. goodbye").result(timeout=10)
|
||||
mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye ")
|
||||
|
||||
@patch("vocalinux.main.check_dependencies")
|
||||
@@ -482,7 +489,7 @@ class TestMainModule(unittest.TestCase):
|
||||
mock_check_deps=mock_check_deps,
|
||||
)
|
||||
|
||||
text_callback("hello world. goodbye")
|
||||
text_callback("hello world. goodbye").result(timeout=10)
|
||||
mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye ")
|
||||
|
||||
@patch("vocalinux.main.check_dependencies")
|
||||
@@ -1278,6 +1285,7 @@ def _boot_main_callbacks(
|
||||
append_trailing_space: bool = True,
|
||||
inject_ok: bool = True,
|
||||
dictate_to_pad: bool = False,
|
||||
post_script: str = "",
|
||||
) -> SimpleNamespace:
|
||||
"""Boot main() with mocked deps; return the registered callbacks and mocks.
|
||||
|
||||
@@ -1297,6 +1305,7 @@ def _boot_main_callbacks(
|
||||
"general": {"first_run": False},
|
||||
}
|
||||
mock_config.get.return_value = False # auto_capitalize off
|
||||
mock_config.get_str.return_value = post_script
|
||||
mock_config.is_dictate_to_pad_enabled.return_value = dictate_to_pad
|
||||
mock_config_cls.return_value = mock_config
|
||||
|
||||
@@ -1327,6 +1336,15 @@ def _boot_main_callbacks(
|
||||
return_value=append_trailing_space,
|
||||
)
|
||||
)
|
||||
# Probing the focused window shells out to compositor tools; keep the
|
||||
# callback tests deterministic by making the probe report "unknown",
|
||||
# which keeps the permissive injection path.
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
return_value=None,
|
||||
)
|
||||
)
|
||||
mock_parse = stack.enter_context(patch("vocalinux.main.parse_arguments"))
|
||||
stack.enter_context(patch("sys.argv", ["vocalinux"]))
|
||||
|
||||
@@ -1358,50 +1376,358 @@ def _boot_main_callbacks(
|
||||
pad=mock_pad,
|
||||
pad_cls=mock_pad_cls,
|
||||
config=mock_config,
|
||||
mock_text=mock_text,
|
||||
mock_config=mock_config,
|
||||
mock_speech=mock_speech,
|
||||
mock_tray_cls=mock_tray_cls,
|
||||
)
|
||||
|
||||
|
||||
class TestMainCallbackTrailingSpaceEdges(unittest.TestCase):
|
||||
"""Exercise trailing-space edge paths through the real main() callback."""
|
||||
|
||||
def _boot_under_patches(
|
||||
self,
|
||||
*,
|
||||
append_trailing_space: bool = True,
|
||||
inject_ok: bool = True,
|
||||
post_script: str = "",
|
||||
) -> SimpleNamespace:
|
||||
"""Boot main() under mocks; the shared helper does the wiring."""
|
||||
return _boot_main_callbacks(
|
||||
append_trailing_space=append_trailing_space,
|
||||
inject_ok=inject_ok,
|
||||
post_script=post_script,
|
||||
)
|
||||
|
||||
def test_whitespace_only_is_skipped_through_main(self) -> None:
|
||||
boot = _boot_main_callbacks()
|
||||
"""Whitespace-only segments return without touching the injector."""
|
||||
boot = self._boot_under_patches()
|
||||
try:
|
||||
boot.text_cb(" \t ")
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
self.assertIsNone(boot.text_cb(" \t "))
|
||||
boot.mock_text.inject_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_newline_segment_skips_trailing_space_through_main(self) -> None:
|
||||
boot = _boot_main_callbacks()
|
||||
boot = self._boot_under_patches()
|
||||
try:
|
||||
boot.text_cb("Hello.\n")
|
||||
boot.text_cb("Hello.\n").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_called_once_with("Hello.\n")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_legacy_mode_adds_leading_space_in_session(self) -> None:
|
||||
boot = _boot_main_callbacks(append_trailing_space=False)
|
||||
boot = self._boot_under_patches(append_trailing_space=False)
|
||||
try:
|
||||
boot.text_cb("Hello.")
|
||||
boot.text_cb("World")
|
||||
boot.text_cb("Hello.").result(timeout=10)
|
||||
boot.text_cb("World").result(timeout=10)
|
||||
calls = [c.args[0] for c in boot.text_system.inject_text.call_args_list]
|
||||
self.assertEqual(calls, ["Hello.", " World"])
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_failed_inject_does_not_remember_text(self) -> None:
|
||||
boot = _boot_main_callbacks(append_trailing_space=False, inject_ok=False)
|
||||
boot = self._boot_under_patches(append_trailing_space=False, inject_ok=False)
|
||||
try:
|
||||
boot.text_cb("Hello.")
|
||||
boot.text_cb("Hello.").result(timeout=10)
|
||||
boot.text_system.inject_text.reset_mock()
|
||||
boot.text_system.inject_text.return_value = True
|
||||
boot.text_cb("World")
|
||||
boot.text_cb("World").result(timeout=10)
|
||||
# Failure means last_injected stays empty; next segment has no leading space
|
||||
boot.text_system.inject_text.assert_called_once_with("World")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_post_processing_transform_reaches_injector(self) -> None:
|
||||
"""Configured script output is what gets injected, spacing rules included."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
try:
|
||||
run_result = MagicMock()
|
||||
run_result.returncode = 0
|
||||
run_result.stderr = ""
|
||||
run_result.stdout = "TRANSFORMED"
|
||||
with patch("vocalinux.post_processor.subprocess.run", return_value=run_result):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_called_once_with("TRANSFORMED ")
|
||||
|
||||
# A transformed paragraph break keeps its newlines (and so
|
||||
# skips the appended trailing space like any "\n" ending).
|
||||
boot.text_system.inject_text.reset_mock()
|
||||
run_result.stdout = "PARA ONE.\n\n"
|
||||
boot.text_cb("para one.\n\n").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_called_once_with("PARA ONE.\n\n")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_post_processing_empty_output_skips_injection(self) -> None:
|
||||
"""A script that emits nothing swallows the segment — nothing injected."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
try:
|
||||
run_result = MagicMock()
|
||||
run_result.returncode = 0
|
||||
run_result.stderr = ""
|
||||
run_result.stdout = ""
|
||||
with patch("vocalinux.post_processor.subprocess.run", return_value=run_result):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_segment_queues_behind_running_script_when_script_cleared(self) -> None:
|
||||
"""Clearing the script path mid-queue cannot let a later segment overtake."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
started = threading.Event()
|
||||
gate = threading.Event()
|
||||
|
||||
def blocked_run(cmd: list, **kwargs: object) -> MagicMock:
|
||||
started.set()
|
||||
gate.wait(timeout=10)
|
||||
return MagicMock(returncode=0, stdout="PROCESSED FIRST", stderr="")
|
||||
|
||||
try:
|
||||
with patch("vocalinux.post_processor.subprocess.run", side_effect=blocked_run):
|
||||
first = boot.text_cb("first")
|
||||
self.assertTrue(started.wait(timeout=5))
|
||||
# User disables the script while the first segment still runs;
|
||||
# the second must still queue behind it, not inject directly.
|
||||
boot.mock_config.get_str.return_value = ""
|
||||
second = boot.text_cb("second")
|
||||
gate.set()
|
||||
self.assertIsNotNone(first)
|
||||
self.assertIsNotNone(second)
|
||||
first.result(timeout=10)
|
||||
second.result(timeout=10)
|
||||
calls = [c.args[0] for c in boot.text_system.inject_text.call_args_list]
|
||||
self.assertEqual(calls, ["PROCESSED FIRST ", "second "])
|
||||
finally:
|
||||
gate.set()
|
||||
boot.stack.close()
|
||||
|
||||
def test_queued_segment_dropped_when_focus_moves(self) -> None:
|
||||
"""A segment that outlived its target app is dropped, not injected."""
|
||||
from vocalinux.text_injection.focused_window import FocusedWindow
|
||||
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
try:
|
||||
window_a = FocusedWindow(app_id="editor", wm_class="Editor", process_name="editor")
|
||||
window_b = FocusedWindow(app_id="browser", wm_class="Browser", process_name="browser")
|
||||
run_result = MagicMock(returncode=0, stdout="OUT", stderr="")
|
||||
# First probe (submit time) sees the editor; the worker's re-probe
|
||||
# after the script finds the browser — different application.
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=[window_a, window_b, window_b, window_b],
|
||||
),
|
||||
patch("vocalinux.post_processor.subprocess.run", return_value=run_result),
|
||||
):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_same_app_focus_change_still_injects(self) -> None:
|
||||
"""Focus probe returning the same app keeps the segment deliverable."""
|
||||
from vocalinux.text_injection.focused_window import FocusedWindow
|
||||
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
try:
|
||||
window = FocusedWindow(app_id="editor", process_name="editor")
|
||||
# Two editor windows differ only by title — same application.
|
||||
other_doc = FocusedWindow(app_id="editor", title="other.py", process_name="editor")
|
||||
run_result = MagicMock(returncode=0, stdout="hello", stderr="")
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=[window, other_doc, other_doc],
|
||||
),
|
||||
patch("vocalinux.post_processor.subprocess.run", return_value=run_result),
|
||||
):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_called_once_with("hello ")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_voice_action_queues_behind_pending_text(self) -> None:
|
||||
"""A voice action cannot overtake a segment still waiting in the worker."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
started = threading.Event()
|
||||
gate = threading.Event()
|
||||
|
||||
def blocked_run(cmd: list, **kwargs: object) -> MagicMock:
|
||||
started.set()
|
||||
gate.wait(timeout=10)
|
||||
return MagicMock(returncode=0, stdout="PROCESSED", stderr="")
|
||||
|
||||
try:
|
||||
with patch("vocalinux.post_processor.subprocess.run", side_effect=blocked_run):
|
||||
text_future = boot.text_cb("hello")
|
||||
self.assertTrue(started.wait(timeout=5))
|
||||
# The action is issued while the script still holds the worker;
|
||||
# it must run only after the queued text has been injected.
|
||||
action_future = boot.action_cb("select_all")
|
||||
gate.set()
|
||||
text_future.result(timeout=10)
|
||||
action_future.result(timeout=10)
|
||||
call_names = [c[0] for c in boot.text_system.mock_calls]
|
||||
self.assertEqual(call_names, ["inject_text", "_inject_keyboard_shortcut"])
|
||||
finally:
|
||||
gate.set()
|
||||
boot.stack.close()
|
||||
|
||||
def test_voice_action_without_script_still_uses_worker(self) -> None:
|
||||
"""With no script configured, actions still queue behind pending text."""
|
||||
boot = self._boot_under_patches()
|
||||
try:
|
||||
action_future = boot.action_cb("select_all")
|
||||
self.assertIsNotNone(action_future)
|
||||
action_future.result(timeout=10)
|
||||
boot.text_system._inject_keyboard_shortcut.assert_called_once_with("ctrl+a")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_idle_reset_waits_for_queued_delete_action(self) -> None:
|
||||
"""A 'delete that' queued before IDLE must still see the text it targets."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
started = threading.Event()
|
||||
gate = threading.Event()
|
||||
|
||||
def blocked_run(cmd: list, **kwargs: object) -> MagicMock:
|
||||
started.set()
|
||||
gate.wait(timeout=10)
|
||||
return MagicMock(returncode=0, stdout="HELLO", stderr="")
|
||||
|
||||
try:
|
||||
with patch("vocalinux.post_processor.subprocess.run", side_effect=blocked_run):
|
||||
text_future = boot.text_cb("hello")
|
||||
self.assertTrue(started.wait(timeout=5))
|
||||
delete_future = boot.action_cb("delete_last")
|
||||
# The session ends while both jobs are queued; the reset must
|
||||
# run only after the delete action consumed last_injected_text.
|
||||
boot.state_cb(RecognitionState.IDLE)
|
||||
gate.set()
|
||||
text_future.result(timeout=10)
|
||||
delete_future.result(timeout=10)
|
||||
boot.text_system.press_backspace.assert_called_once_with(len("HELLO "))
|
||||
finally:
|
||||
gate.set()
|
||||
boot.stack.close()
|
||||
|
||||
def test_action_waits_for_slow_probe(self) -> None:
|
||||
"""A stalled focus probe delays the action but never discards it."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
stall = threading.Event()
|
||||
|
||||
def stalled_probe() -> None:
|
||||
stall.wait(30)
|
||||
return None
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=stalled_probe,
|
||||
):
|
||||
action_future = boot.action_cb("select_all")
|
||||
self.assertIsNotNone(action_future)
|
||||
# While the probe has not answered the job cannot run the
|
||||
# shortcut; once it answers, the command must still fire.
|
||||
self.assertFalse(action_future.done())
|
||||
stall.set()
|
||||
action_future.result(timeout=10)
|
||||
boot.text_system._inject_keyboard_shortcut.assert_called_once_with("ctrl+a")
|
||||
finally:
|
||||
stall.set()
|
||||
boot.stack.close()
|
||||
|
||||
def test_queued_action_dropped_when_focus_moves(self) -> None:
|
||||
"""A queued shortcut is dropped, not fired into the newly focused app."""
|
||||
from vocalinux.text_injection.focused_window import FocusedWindow
|
||||
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
started = threading.Event()
|
||||
gate = threading.Event()
|
||||
probes_done = threading.Event()
|
||||
probe_calls: list = []
|
||||
|
||||
def blocked_run(cmd: list, **kwargs: object) -> MagicMock:
|
||||
started.set()
|
||||
gate.wait(timeout=10)
|
||||
return MagicMock(returncode=0, stdout="OUT", stderr="")
|
||||
|
||||
try:
|
||||
window_a = FocusedWindow(app_id="editor", wm_class="Editor", process_name="editor")
|
||||
window_b = FocusedWindow(app_id="browser", wm_class="Browser", process_name="browser")
|
||||
window_iter = iter([window_a, window_a, window_b, window_b, window_b, window_b])
|
||||
|
||||
def fake_probe() -> FocusedWindow:
|
||||
# Both submit-time probes capture the editor; the jobs' later
|
||||
# re-probes then see the browser.
|
||||
probe_calls.append(1)
|
||||
if len(probe_calls) == 2:
|
||||
probes_done.set()
|
||||
return next(window_iter)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=fake_probe,
|
||||
),
|
||||
patch("vocalinux.post_processor.subprocess.run", side_effect=blocked_run),
|
||||
):
|
||||
text_future = boot.text_cb("hello")
|
||||
self.assertTrue(started.wait(timeout=5))
|
||||
action_future = boot.action_cb("select_all")
|
||||
# Wait for both submit-time probes so the re-probes in the
|
||||
# queued jobs deterministically observe the new application.
|
||||
self.assertTrue(probes_done.wait(timeout=5))
|
||||
gate.set()
|
||||
text_future.result(timeout=10)
|
||||
action_future.result(timeout=10)
|
||||
boot.text_system._inject_keyboard_shortcut.assert_not_called()
|
||||
finally:
|
||||
gate.set()
|
||||
boot.stack.close()
|
||||
|
||||
def test_quit_hook_stops_worker_and_blocks_new_submissions(self) -> None:
|
||||
"""The tray's on_quit hook drains the worker and rejects new segments."""
|
||||
boot = self._boot_under_patches()
|
||||
try:
|
||||
on_quit = boot.mock_tray_cls.call_args.kwargs["on_quit"]
|
||||
on_quit()
|
||||
self.assertIsNone(boot.text_cb("hello"))
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_running_job_drops_result_during_quit(self) -> None:
|
||||
"""A script mid-flight when quit begins cannot inject afterwards."""
|
||||
boot = self._boot_under_patches(post_script="/fake/script.sh")
|
||||
started = threading.Event()
|
||||
gate = threading.Event()
|
||||
|
||||
def blocked_run(cmd: list, **kwargs: object) -> MagicMock:
|
||||
started.set()
|
||||
gate.wait(timeout=10)
|
||||
return MagicMock(returncode=0, stdout="TOO LATE", stderr="")
|
||||
|
||||
try:
|
||||
with patch("vocalinux.post_processor.subprocess.run", side_effect=blocked_run):
|
||||
future = boot.text_cb("hello")
|
||||
self.assertTrue(started.wait(timeout=5))
|
||||
quit_thread = threading.Thread(
|
||||
target=boot.mock_tray_cls.call_args.kwargs["on_quit"]
|
||||
)
|
||||
quit_thread.start()
|
||||
gate.set()
|
||||
quit_thread.join(timeout=10)
|
||||
self.assertFalse(quit_thread.is_alive())
|
||||
future.result(timeout=10)
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
gate.set()
|
||||
boot.stack.close()
|
||||
|
||||
|
||||
class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
"""Pad capture routing through the real main() callback wrappers (#726).
|
||||
@@ -1413,7 +1739,7 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
def test_capture_routes_transcription_to_pad_not_injector(self) -> None:
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.text_cb("hello")
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.pad.append_text.assert_called_once_with("hello ")
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
@@ -1423,8 +1749,8 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.pad.last_segment = "hello "
|
||||
boot.text_cb("hello")
|
||||
boot.action_cb("delete_last")
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.action_cb("delete_last").result(timeout=10)
|
||||
boot.pad.delete_last_chars.assert_called_once_with(len("hello "))
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
finally:
|
||||
@@ -1446,7 +1772,7 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
"paste",
|
||||
):
|
||||
boot.pad.handle_action.reset_mock()
|
||||
self.assertTrue(boot.action_cb(action))
|
||||
self.assertTrue(boot.action_cb(action).result(timeout=10))
|
||||
boot.pad.handle_action.assert_called_once_with(action)
|
||||
boot.text_system._inject_keyboard_shortcut.assert_not_called()
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
@@ -1459,9 +1785,9 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
try:
|
||||
# Dictated into the app; capture switched on afterwards: the
|
||||
# backspaces must still go to the app that received the text.
|
||||
boot.text_cb("into app")
|
||||
boot.text_cb("into app").result(timeout=10)
|
||||
boot.config.is_dictate_to_pad_enabled.return_value = True
|
||||
boot.action_cb("delete_last")
|
||||
boot.action_cb("delete_last").result(timeout=10)
|
||||
boot.text_system.press_backspace.assert_called_once_with(len("into app "))
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
|
||||
@@ -1470,9 +1796,9 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
boot.config.is_dictate_to_pad_enabled.return_value = True
|
||||
boot.pad.last_segment = "into pad "
|
||||
boot.pad.delete_last_chars.return_value = len("into pad ")
|
||||
boot.text_cb("into pad")
|
||||
boot.text_cb("into pad").result(timeout=10)
|
||||
boot.config.is_dictate_to_pad_enabled.return_value = False
|
||||
boot.action_cb("delete_last")
|
||||
boot.action_cb("delete_last").result(timeout=10)
|
||||
boot.pad.delete_last_chars.assert_called_once_with(len("into pad "))
|
||||
self.assertEqual(boot.text_system.press_backspace.call_count, 1)
|
||||
finally:
|
||||
@@ -1482,11 +1808,11 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
"""Routing follows the shared manager's live value, not a snapshot."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=False)
|
||||
try:
|
||||
boot.text_cb("to app")
|
||||
boot.text_cb("to app").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_called_once_with("to app ")
|
||||
|
||||
boot.config.is_dictate_to_pad_enabled.return_value = True
|
||||
boot.text_cb("to pad")
|
||||
boot.text_cb("to pad").result(timeout=10)
|
||||
boot.pad.append_text.assert_called_once_with("to pad ")
|
||||
self.assertEqual(boot.text_system.inject_text.call_count, 1)
|
||||
finally:
|
||||
@@ -1495,7 +1821,7 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
def test_delete_that_with_empty_history_is_swallowed(self) -> None:
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
self.assertTrue(boot.action_cb("delete_last"))
|
||||
self.assertTrue(boot.action_cb("delete_last").result(timeout=10))
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
finally:
|
||||
@@ -1504,9 +1830,9 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
def test_idle_state_resets_delete_that_target(self) -> None:
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.text_cb("pad text")
|
||||
boot.text_cb("pad text").result(timeout=10)
|
||||
boot.state_cb(RecognitionState.IDLE)
|
||||
boot.action_cb("delete_last")
|
||||
boot.action_cb("delete_last").result(timeout=10)
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
finally:
|
||||
@@ -1516,16 +1842,16 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
"""Undoing the last pad segment must not leave a stale delete length."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.text_cb("hello")
|
||||
boot.text_cb("world")
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.text_cb("world").result(timeout=10)
|
||||
# The pad reports the surviving segment after its own undo pops
|
||||
# "world "; the next delete targets it, not the stale segment.
|
||||
boot.pad.last_segment = "hello "
|
||||
boot.pad.handle_action.return_value = True
|
||||
self.assertTrue(boot.action_cb("undo"))
|
||||
self.assertTrue(boot.action_cb("undo").result(timeout=10))
|
||||
boot.pad.handle_action.assert_called_once_with("undo")
|
||||
|
||||
boot.action_cb("delete_last")
|
||||
boot.action_cb("delete_last").result(timeout=10)
|
||||
boot.pad.delete_last_chars.assert_called_once_with(len("hello "))
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
finally:
|
||||
@@ -1535,35 +1861,145 @@ class TestPadRoutingCallbacks(unittest.TestCase):
|
||||
"""When pad undo removes the last segment, "delete that" is a no-op."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.text_cb("only")
|
||||
boot.text_cb("only").result(timeout=10)
|
||||
boot.pad.last_segment = None
|
||||
boot.pad.handle_action.return_value = True
|
||||
self.assertTrue(boot.action_cb("undo"))
|
||||
self.assertTrue(boot.action_cb("undo").result(timeout=10))
|
||||
|
||||
self.assertTrue(boot.action_cb("delete_last"))
|
||||
self.assertTrue(boot.action_cb("delete_last").result(timeout=10))
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_delete_that_after_manual_pad_edit_is_a_no_op(self) -> None:
|
||||
"""A manual edit blurs segment boundaries: "delete that" must not
|
||||
erase user-typed text with the stale recorded length."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
# The widget's own edit cleared the tracked segment boundaries.
|
||||
boot.pad.last_segment = None
|
||||
self.assertTrue(boot.action_cb("delete_last").result(timeout=10))
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
# Tracking stays cleared, so a repeat cannot retry the stale length.
|
||||
self.assertTrue(boot.action_cb("delete_last").result(timeout=10))
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_pad_bound_segment_survives_focus_change(self) -> None:
|
||||
"""Pad-bound text is not dropped when focus moved since the dictate."""
|
||||
# A configured script arms the submit-time focus probe.
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True, post_script="/bin/cat")
|
||||
try:
|
||||
app_a = MagicMock()
|
||||
app_a.identity_blob.return_value = "app-a"
|
||||
app_b = MagicMock()
|
||||
app_b.identity_blob.return_value = "app-b"
|
||||
with patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=[app_a, app_b],
|
||||
):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.pad.append_text.assert_called_once_with("hello ")
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_segment_destination_decided_once_between_check_and_delivery(self) -> None:
|
||||
"""A toggle flip between the focus check and delivery cannot reroute
|
||||
a pad-bound segment into whichever application holds focus."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True, post_script="/bin/cat")
|
||||
try:
|
||||
# The routing decision reads the toggle once; a stale re-read at
|
||||
# delivery would see it off and inject into the focused app.
|
||||
boot.config.is_dictate_to_pad_enabled.side_effect = [True, False]
|
||||
app_a = MagicMock()
|
||||
app_a.identity_blob.return_value = "app-a"
|
||||
with patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
return_value=app_a,
|
||||
):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.pad.append_text.assert_called_once_with("hello ")
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_action_destination_decided_once_between_check_and_delivery(self) -> None:
|
||||
"""The same single decision binds a pad-targeted editing command."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.config.is_dictate_to_pad_enabled.side_effect = [True, False]
|
||||
boot.pad.handle_action.return_value = True
|
||||
app_a = MagicMock()
|
||||
app_a.identity_blob.return_value = "app-a"
|
||||
with patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
return_value=app_a,
|
||||
):
|
||||
self.assertTrue(boot.action_cb("select_all").result(timeout=10))
|
||||
boot.pad.handle_action.assert_called_once_with("select_all")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_app_bound_segment_drops_on_focus_change(self) -> None:
|
||||
"""App-bound text is still dropped when focus moved since the dictate."""
|
||||
# A configured script arms the submit-time focus probe.
|
||||
boot = _boot_main_callbacks(dictate_to_pad=False, post_script="/bin/cat")
|
||||
try:
|
||||
app_a = MagicMock()
|
||||
app_a.identity_blob.return_value = "app-a"
|
||||
app_b = MagicMock()
|
||||
app_b.identity_blob.return_value = "app-b"
|
||||
with patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=[app_a, app_b],
|
||||
):
|
||||
boot.text_cb("hello").result(timeout=10)
|
||||
boot.text_system.inject_text.assert_not_called()
|
||||
boot.pad.append_text.assert_not_called()
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_pad_bound_action_survives_focus_change(self) -> None:
|
||||
"""Pad-bound editing commands are not dropped on a focus change."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.pad.handle_action.return_value = True
|
||||
app_a = MagicMock()
|
||||
app_a.identity_blob.return_value = "app-a"
|
||||
app_b = MagicMock()
|
||||
app_b.identity_blob.return_value = "app-b"
|
||||
with patch(
|
||||
"vocalinux.text_injection.focused_window.get_focused_window",
|
||||
side_effect=[app_a, app_b],
|
||||
):
|
||||
self.assertTrue(boot.action_cb("undo").result(timeout=10))
|
||||
boot.pad.handle_action.assert_called_once_with("undo")
|
||||
finally:
|
||||
boot.stack.close()
|
||||
|
||||
def test_pad_redo_restores_delete_target(self) -> None:
|
||||
"""A redo that brings the segment back must re-arm "delete that"."""
|
||||
boot = _boot_main_callbacks(dictate_to_pad=True)
|
||||
try:
|
||||
boot.text_cb("only")
|
||||
boot.text_cb("only").result(timeout=10)
|
||||
boot.pad.handle_action.return_value = True
|
||||
|
||||
# Undo to an empty pad clears the target but keeps the pad as the
|
||||
# last dictation destination.
|
||||
boot.pad.last_segment = None
|
||||
self.assertTrue(boot.action_cb("undo"))
|
||||
self.assertTrue(boot.action_cb("delete_last"))
|
||||
self.assertTrue(boot.action_cb("undo").result(timeout=10))
|
||||
self.assertTrue(boot.action_cb("delete_last").result(timeout=10))
|
||||
boot.pad.delete_last_chars.assert_not_called()
|
||||
|
||||
# Redo restores the segment; the next delete reaches the pad.
|
||||
boot.pad.last_segment = "only"
|
||||
self.assertTrue(boot.action_cb("redo"))
|
||||
self.assertTrue(boot.action_cb("delete_last"))
|
||||
self.assertTrue(boot.action_cb("redo").result(timeout=10))
|
||||
self.assertTrue(boot.action_cb("delete_last").result(timeout=10))
|
||||
boot.pad.delete_last_chars.assert_called_once_with(len("only"))
|
||||
boot.text_system.press_backspace.assert_not_called()
|
||||
finally:
|
||||
@@ -1771,15 +2207,17 @@ class TestSessionHistoryRecording(unittest.TestCase):
|
||||
segment_cb("second", time.monotonic())
|
||||
state_cb(RecognitionState.IDLE)
|
||||
|
||||
# The older session's worker decodes last: its text belongs to
|
||||
# the first session, not to the currently-newest entry.
|
||||
# The older session's worker decodes last: its text still belongs
|
||||
# to the first session — worker ownership extends that session's
|
||||
# own snippet rather than the currently-newest entry or an
|
||||
# orphan one.
|
||||
on_old_worker("late tail")
|
||||
self.assertEqual(history.get_all(), ["late tail", "second", "first"])
|
||||
self.assertEqual(history.get_all(), ["second", "first late tail"])
|
||||
|
||||
# A later straggler on that same worker still extends the
|
||||
# snippet it owns.
|
||||
# A later straggler on that same worker still extends the same
|
||||
# snippet it owns — one session never splits across entries.
|
||||
on_old_worker("more tail")
|
||||
self.assertEqual(history.get_all(), ["late tail more tail", "second", "first"])
|
||||
self.assertEqual(history.get_all(), ["second", "first late tail more tail"])
|
||||
finally:
|
||||
close_old_worker()
|
||||
stack.close()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests for PostProcessor and apply_post_processing."""
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from vocalinux.post_processor import PostProcessor, apply_post_processing
|
||||
from vocalinux.utils.host_process import host_env
|
||||
|
||||
|
||||
def _make_run_result(returncode: int = 0, stdout: str = "", stderr: str = "") -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.returncode = returncode
|
||||
result.stdout = stdout
|
||||
result.stderr = stderr
|
||||
return result
|
||||
|
||||
|
||||
class TestPostProcessor(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.patch_run = patch("vocalinux.post_processor.subprocess.run")
|
||||
self.mock_run = self.patch_run.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.patch_run.stop()
|
||||
|
||||
def test_convenience_newline_stripped_when_input_had_none(self) -> None:
|
||||
self.mock_run.return_value = _make_run_result(stdout="hello\n")
|
||||
result = PostProcessor("/script.sh").process("hello")
|
||||
self.assertEqual(result, "hello")
|
||||
|
||||
def test_internal_newlines_preserved(self) -> None:
|
||||
self.mock_run.return_value = _make_run_result(stdout="a\nb\n")
|
||||
result = PostProcessor("/script.sh").process("a\nb")
|
||||
self.assertEqual(result, "a\nb")
|
||||
|
||||
def test_paragraph_break_preserved_verbatim(self) -> None:
|
||||
"""A passthrough script must not eat the "\n\n" from "new paragraph"."""
|
||||
self.mock_run.return_value = _make_run_result(stdout="end of paragraph.\n\n")
|
||||
result = PostProcessor("/script.sh").process("end of paragraph.\n\n")
|
||||
self.assertEqual(result, "end of paragraph.\n\n")
|
||||
|
||||
def test_script_added_paragraph_survives_convenience_strip(self) -> None:
|
||||
"""Only one trailing newline is treated as a line terminator."""
|
||||
self.mock_run.return_value = _make_run_result(stdout="a\n\n")
|
||||
result = PostProcessor("/script.sh").process("a")
|
||||
self.assertEqual(result, "a\n")
|
||||
|
||||
def test_empty_stdout_returns_empty_string(self) -> None:
|
||||
self.mock_run.return_value = _make_run_result(stdout="")
|
||||
result = PostProcessor("/script.sh").process("original")
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_nonzero_exit_returns_original(self) -> None:
|
||||
self.mock_run.return_value = _make_run_result(returncode=1, stderr="oops")
|
||||
result = PostProcessor("/script.sh").process("original")
|
||||
self.assertEqual(result, "original")
|
||||
|
||||
def test_timeout_returns_original(self) -> None:
|
||||
self.mock_run.side_effect = subprocess.TimeoutExpired(cmd="/script.sh", timeout=10)
|
||||
result = PostProcessor("/script.sh").process("original")
|
||||
self.assertEqual(result, "original")
|
||||
|
||||
def test_os_error_returns_original(self) -> None:
|
||||
self.mock_run.side_effect = OSError("not found")
|
||||
result = PostProcessor("/script.sh").process("original")
|
||||
self.assertEqual(result, "original")
|
||||
|
||||
def test_subprocess_called_with_correct_args(self) -> None:
|
||||
self.mock_run.return_value = _make_run_result(stdout="out")
|
||||
PostProcessor("/my/script.sh").process("input text")
|
||||
self.mock_run.assert_called_once_with(
|
||||
["/my/script.sh"],
|
||||
input="input text",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
env=host_env(),
|
||||
)
|
||||
|
||||
|
||||
class TestApplyPostProcessing(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.patch_processor = patch("vocalinux.post_processor.PostProcessor")
|
||||
self.mock_processor_cls = self.patch_processor.start()
|
||||
self.mock_processor = MagicMock()
|
||||
self.mock_processor_cls.return_value = self.mock_processor
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.patch_processor.stop()
|
||||
|
||||
def _config(self, script_path: str) -> MagicMock:
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_str.return_value = script_path
|
||||
return config_manager
|
||||
|
||||
def test_no_script_returns_original_text(self) -> None:
|
||||
config_manager = self._config("")
|
||||
result = apply_post_processing("hello", config_manager)
|
||||
self.assertEqual(result, "hello")
|
||||
self.mock_processor_cls.assert_not_called()
|
||||
|
||||
def test_script_configured_returns_processed_text(self) -> None:
|
||||
self.mock_processor.process.return_value = "modified"
|
||||
result = apply_post_processing("hello", self._config("/s.sh"))
|
||||
self.assertEqual(result, "modified")
|
||||
|
||||
def test_script_returns_empty_string_gives_none(self) -> None:
|
||||
self.mock_processor.process.return_value = ""
|
||||
result = apply_post_processing("hello", self._config("/s.sh"))
|
||||
self.assertIsNone(result)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for the RemoteDesktop portal injection backend and its selection."""
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -722,6 +723,85 @@ class TestPortalInjectionPaths:
|
||||
assert injector._inject_shortcut_with_wayland_tool("Home+shift+End") is True
|
||||
assert mock_run.call_args[0][0] == ["wtype", "-M", "shift", "-k", "End", "-m", "shift"]
|
||||
|
||||
def test_partial_type_rebases_onto_portal_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A fallback's typed count must include what the portal delivered."""
|
||||
injector = _bare_injector(environment=DesktopEnvironment.WAYLAND, wayland_tool="portal")
|
||||
injector._portal = MagicMock()
|
||||
# The portal typed 2 chars before the error; the fallback gets "x"*248.
|
||||
injector._portal.inject_text.side_effect = RemoteDesktopPortalError("boom", delivered=2)
|
||||
monkeypatch.setattr("shutil.which", lambda c: "/usr/bin/wtype" if c == "wtype" else None)
|
||||
|
||||
calls = []
|
||||
|
||||
def wtype_side_effect(cmd: list, **kwargs: object) -> MagicMock:
|
||||
calls.append(cmd)
|
||||
if len(calls) == 2:
|
||||
raise subprocess.CalledProcessError(1, cmd, stderr="wedged")
|
||||
return MagicMock(returncode=0, stderr="")
|
||||
|
||||
with (
|
||||
patch.object(injector, "_wait_for_modifiers_released"),
|
||||
patch("subprocess.run", side_effect=wtype_side_effect),
|
||||
):
|
||||
with pytest.raises(ti._PartiallyTyped) as exc_info:
|
||||
injector._inject_with_wayland_tool("x" * 250)
|
||||
# The error count is against the original 250-char text, so the
|
||||
# caller clips the typed prefix instead of replaying it.
|
||||
assert exc_info.value.typed == 2 + 200
|
||||
|
||||
def test_inject_untracked_timeout_replays_nothing(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A portal timeout with no delivered count must not retype anything."""
|
||||
injector = _bare_injector(environment=DesktopEnvironment.WAYLAND, wayland_tool="portal")
|
||||
injector._portal = MagicMock()
|
||||
injector._portal.inject_text.side_effect = RemoteDesktopPortalError(
|
||||
"Timed out waiting for the portal", delivered=None
|
||||
)
|
||||
monkeypatch.setattr("shutil.which", lambda c: "/usr/bin/wtype" if c == "wtype" else None)
|
||||
with (
|
||||
patch.object(injector, "_wait_for_modifiers_released"),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
injector._inject_with_wayland_tool("hello")
|
||||
# The broken portal is demoted, but no fallback retypes the text.
|
||||
assert injector.wayland_tool == "wtype"
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_backspace_untracked_timeout_replays_nothing(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
injector = _bare_injector(environment=DesktopEnvironment.WAYLAND, wayland_tool="portal")
|
||||
injector._portal = MagicMock()
|
||||
injector._portal.tap_keysym.side_effect = RemoteDesktopPortalError(
|
||||
"Timed out waiting for the portal", delivered=None
|
||||
)
|
||||
monkeypatch.setattr("shutil.which", lambda c: "/usr/bin/wtype" if c == "wtype" else None)
|
||||
with (
|
||||
patch.object(injector, "_wait_for_modifiers_released"),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
assert injector.press_backspace(3) is False
|
||||
assert injector.wayland_tool == "wtype"
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_shortcut_untracked_timeout_replays_nothing(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
injector = _bare_injector(environment=DesktopEnvironment.WAYLAND, wayland_tool="portal")
|
||||
injector._portal = MagicMock()
|
||||
injector._portal.send_shortcut.side_effect = RemoteDesktopPortalError(
|
||||
"Timed out waiting for the portal", delivered=None
|
||||
)
|
||||
monkeypatch.setattr("shutil.which", lambda c: "/usr/bin/wtype" if c == "wtype" else None)
|
||||
with (
|
||||
patch.object(injector, "_wait_for_modifiers_released"),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
assert injector._inject_shortcut_with_wayland_tool("ctrl+a") is False
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_shortcut_routes_to_portal(self) -> None:
|
||||
injector = _bare_injector(environment=DesktopEnvironment.WAYLAND, wayland_tool="portal")
|
||||
injector._portal = MagicMock()
|
||||
|
||||
@@ -275,6 +275,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
check=True,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=mock.ANY,
|
||||
env=mock.ANY,
|
||||
)
|
||||
|
||||
@@ -289,6 +290,8 @@ class TestTextInjector(unittest.TestCase):
|
||||
# Initialize injector
|
||||
injector = TextInjector()
|
||||
self.assertEqual(injector.wayland_tool, "ydotool")
|
||||
# _ensure_ydotoold spawns a real Popen, which patch("subprocess.run") misses.
|
||||
injector._ensure_ydotoold = MagicMock(return_value=True)
|
||||
|
||||
# Inject text
|
||||
injector.inject_text("Hello world")
|
||||
@@ -300,6 +303,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
check=True,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=mock.ANY,
|
||||
env=mock.ANY,
|
||||
)
|
||||
|
||||
@@ -566,7 +570,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
"""Test that inject_text returns False on failure."""
|
||||
injector = TextInjector()
|
||||
|
||||
self.mock_subprocess.side_effect = Exception("Injection failed")
|
||||
self.mock_subprocess.side_effect = subprocess.CalledProcessError(1, "xdotool")
|
||||
|
||||
result = injector.inject_text("Test")
|
||||
self.assertFalse(result)
|
||||
@@ -647,6 +651,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
injector = TextInjector()
|
||||
injector.wayland_tool = "ydotool"
|
||||
injector.environment = DesktopEnvironment.WAYLAND
|
||||
injector._ensure_ydotoold = MagicMock(return_value=True)
|
||||
|
||||
# Reset call list so init calls don't interfere
|
||||
mock_run.reset_mock()
|
||||
@@ -686,6 +691,7 @@ class TestTextInjector(unittest.TestCase):
|
||||
injector = TextInjector()
|
||||
injector.wayland_tool = "ydotool"
|
||||
injector.environment = DesktopEnvironment.WAYLAND
|
||||
injector._ensure_ydotoold = MagicMock(return_value=True)
|
||||
|
||||
mock_run.reset_mock()
|
||||
injector._inject_with_wayland_tool("Hello world")
|
||||
@@ -1402,7 +1408,7 @@ class TestTextInjectorEdgeCases(unittest.TestCase):
|
||||
injector = TextInjector()
|
||||
|
||||
# Make xdotool fail to trigger error path
|
||||
self.mock_subprocess.side_effect = Exception("Test error")
|
||||
self.mock_subprocess.side_effect = subprocess.CalledProcessError(1, "xdotool")
|
||||
|
||||
# Mock the audio import to fail
|
||||
with patch.dict("sys.modules", {"vocalinux.ui.audio_feedback": None}):
|
||||
|
||||
@@ -163,6 +163,23 @@ class TestTranscriptionHistory(unittest.TestCase):
|
||||
history.extend_latest("b")
|
||||
self.assertEqual(calls, [1])
|
||||
|
||||
def test_extend_entry_targets_by_id(self) -> None:
|
||||
"""A late segment lands in its own session's entry even when a newer
|
||||
session has committed a snippet on top of it."""
|
||||
history = TranscriptionHistory()
|
||||
first_id = history.add("session one")
|
||||
history.add("session two")
|
||||
self.assertTrue(history.extend_entry(first_id, "tail"))
|
||||
self.assertEqual(history.get_all(), ["session two", "session one tail"])
|
||||
|
||||
def test_extend_entry_unknown_id_refuses(self) -> None:
|
||||
"""An evicted or never-recorded id is refused so the caller falls
|
||||
back to recording the late text as its own snippet."""
|
||||
history = TranscriptionHistory()
|
||||
history.add("session one")
|
||||
self.assertFalse(history.extend_entry(9999, "tail"))
|
||||
self.assertEqual(history.get_all(), ["session one"])
|
||||
|
||||
def test_clear(self) -> None:
|
||||
history = TranscriptionHistory()
|
||||
history.add("a")
|
||||
|
||||
Reference in New Issue
Block a user