Commit Graph
289 Commits
Author SHA1 Message Date
0ddb127437 fix(commands): keep line breaks from standalone voice commands (#958)
* fix(commands): keep line breaks from standalone voice commands

Two stacked defects ate the newline "new line" / "new paragraph"
produce when spoken alone:

* _normalize_segment_text stripped all leading whitespace, so the
  command's leading newline never reached the injector (a clean
  "new line" segment was dropped entirely).
* whisper.cpp transcripts arrive punctuated ("New line."), which
  missed the exact-match ladder and left a stray "." after the
  substituted break, injected as ". ".

Normalize now strips only spaces/tabs, and the generic path
substitutes on a punctuation-stripped core when the whole segment
is command phrases, so engine edge punctuation cannot leak while
real text keeps it.

Fixes #955

* fix(injection): deliver newline-only segments to the focused app

inject_text treated whitespace-only text as empty and returned early,
so the "\n" a standalone "new line" now correctly produces was
discarded before any backend ran ("Empty text provided, skipping
injection").  Only a truly empty segment is skipped; whitespace-only
input is typed/pasted verbatim (xdotool types "\n" as Return).

Space/tab-only segments never reach this point: segment normalization
strips them upstream.

Fixes #955

* fix(injection): keep skipping space/tab-only segments

The previous commit let every whitespace-only string through, which
also delivered bare spaces and tabs a caller never wants typed.
Narrow the skip to text with no content outside space/tab so "\n"
still reaches the backend while "   " stays a no-op (the pre-existing
test_empty_text_injection contract).

Fixes #955

* fix(injection): deliver newline segments over IBus too

IBusTextInjector.inject_text had the same whitespace guard and dropped
"\n" before it reached the engine socket; narrow it the same way so
a newline-only segment is sent while space/tab-only stays a no-op.

Also let capitalize_sentences uppercase the first letter after leading
whitespace, so Vosk + auto_capitalize keeps working on "\n hello".

Covers Greptile's findings: real-socket test for the IBus path, the
capitalize regression via the main() callback seam, and type hints on
the new injector tests.

Fixes #955

* test(injection): add type hints to new IBus socket test

AGENTS.md requires hints on every signature; the Greptile review
flagged the new test for missing them.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-15 16:54:05 -07:00
devin-ai-integration[bot]GitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Jatin K MalikDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
64032fb999 fix(snap): drop uinput plug denied by the Snap Store (#960)
* fix(snap): drop uinput plug denied by the Snap Store

The Snap Store rejected revisions 8-10 over the super-privileged uinput
plug (allow-installation), and reviewers denied the grant request on
forum.snapcraft.io/t/53528, pointing at the RemoteDesktop portal path
the app already prefers. Remove the plug and the uinput-only ydotool so
revisions pass automatic review again; Wayland typing continues through
the RemoteDesktop portal (already the default backend ahead of ydotool),
wtype, IBus Wayland on KDE, and clipboard paste.

KDE Plasma loses its correct-case ydotool path on the store snap (KWin's
portal mangles keysyms on some layouts, KDE bug 489021); the v0.18.x
GitHub .snap still declares uinput for KDE users who sideload it.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(text-injection): stop telling snap users to connect uinput

The Store snap no longer declares the plug, so the warning pointed at a
command that cannot work. The Snap hints now explain that only the
v0.18.x GitHub sideload declares uinput, and that native Wayland typing
uses the RemoteDesktop portal or IBus Wayland on KDE.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Jatin K Malik <jk.malik@yahoo.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-15 15:16:10 -07:00
devin-ai-integration[bot]andGitHub cd388b2e93 fix(evdev): strip SW_RFKILL_ALL from uinput clone capabilities (#947)
The evdev uinput clone copied EV_SW capabilities verbatim. "ThinkPad Extra Buttons" (thinkpad_acpi) declares SW_RFKILL_ALL, and a fresh uinput device reports every switch as 0. The kernel rfkill-input handler reads a 0 killswitch as radios-off, so every clone creation soft-blocked all radios at startup and again after every resume.

Strip SW_RFKILL_ALL from the clone capability list, dropping EV_SW entirely when it was the only switch. Other switches stay declared so real switch events still forward; a forwarded SW_RFKILL_ALL write is dropped by the kernel for the undeclared code.

Fixes #946
2026-10-12 18:07:50 -07:00
AlexThunder1989andGitHub b0fefd6ec1 fix(evdev): leave held keyboards ungrabbed at startup (#941)
Solid fix, this was leaving keys stuck on the desktop whenever someone was typing while the app started.
2026-10-11 23:50:43 -07:00
Sergey SilaevandGitHub 05ae041220 test(installer): cover the remote curl-to-bash install path end to end (#898)
Nice, this locks in the remote install path and keeps the model size check working on Python 3.13.
2026-10-11 23:33:47 -07:00
devin-ai-integration[bot]andGitHub 4beb58c585 fix(packaging): build bundled pywhispercpp without -march=native (#939)
Good catch, this is what was crashing the AppImage and Flatpak on older CPUs.
2026-10-11 23:07:16 -07:00
AlexThunder1989andGitHub 9d667dba3f feat(overlay): live mic orb with audio-reactive pulse and ripple (#940)
Looks great, the live level feedback makes the overlay feel a lot more responsive.
2026-10-11 23:07:07 -07:00
e9598d64ce fix(installer): keep piped script on stdin and prompt via /dev/tty (#935)
* fix(installer): keep piped script on stdin and prompt via /dev/tty

`curl | bash` left bash reading the installer itself from stdin, and the
detection block's `exec < /dev/tty` (from #315) closed that pipe about 300
lines into a 3000-line script. curl died with error 23 writing to the
closed pipe, and bash read the rest of the installer from the terminal as
commands instead of finishing the script.

stdin now stays on the pipe and a reachable terminal is recorded in
TERMINAL_PROMPTS. Every interactive prompt goes through read_prompt, which
reads /dev/tty explicitly only for that read, so user input still works in
a terminal while the script body keeps flowing through the pipe. The
interactive-mode gate now checks TERMINAL_PROMPTS rather than `-t 0`,
which also lets `--interactive` work under a pipe.

Regression coverage: the remote-install gate's second run now pipes the
bootstrap in under a `script` pty (the literal `cat | bash` shape a
curl user takes), and new tests run install.sh in a pty with the script on
a pipe to assert the mode and engine prompts still answer on the terminal.

Fixes #933

* fix(installer): drain the piped bootstrap before the handoff exec

A curl|bash bootstrap leaves its unread tail on stdin; the tagged installer
reads its commands from a file, so nothing drained the pipe and a still-
streaming curl reported error 23 when the installer exited. Drain stdin to
EOF before exec (Greptile P2 on #935).

Gate: drip-feed the piped run so the producer is mid-write at the handoff
like real curl, and run it under pipefail so a producer killed early fails
the run. Tests: pass --tag so a slow release lookup cannot eat the window,
wait for the engine prompt before Ctrl+C, and quote the installer path.

* test(installer): propagate a killed producer's status in the piped gate

A cat dying mid-drip (SIGPIPE when the consumer abandons the pipe) was
swallowed by the loop and the final rm, so drip returned success and
pipefail could not see the download failure the arm exists to catch.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-11 17:37:51 -07:00
908fbb6072 fix(evdev): monitor remapper virtual keyboards ungrabbed (#932)
* fix(evdev): monitor remapper virtual keyboards ungrabbed

On Wayland the device hub grabs every discovered keyboard and clones it
via uinput so the dictation shortcut can be suppressed. With a remapper
like keyd running, the hub grabbed its virtual keyboard too, and the
remapper grabbed the clone in turn (keyd's default [ids] * matches it):
forwarded keys went to the remapper instead of the compositor, so users
could not type at all, and each forwarded event looped back through the
chain.

Detect virtual keyboards by their sysfs device path (uinput-spawned
devices have no physical parent and live under /devices/virtual/input),
falling back to known remapper names when sysfs is unreadable, and
monitor them ungrabbed. Their events still reach the listener, so
shortcuts bound to them keep working; only suppression is lost, which a
grab could never provide anyway since the remapper re-emits on a device
we cannot hold. keyd reports BUS_USB for its virtual devices, so the
bus type cannot discriminate; the sysfs path can.

Fixes #930

* test(evdev): annotate new #930 test mocks

Satisfies the repo rule that every function signature carries type
hints, including test fixtures.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-08 16:02:57 -07:00
devin-ai-integration[bot]andGitHub 6595b73c55 fix(ui): unbreak Initial Prompt TextView and dedupe save-dialog .txt (#927)
Two small UI fixes found during the v0.18.1 smoke test.

* Settings > Advanced > Initial Prompt never accepted typed input: the wrapping GtkListBoxRow grabs keyboard focus on button press and steals it back from the GtkTextView. Mark the row non-focusable.
* Transcript and log save dialogs could return a doubled .txt.txt name; new paths.collapse_repeated_extension helper normalizes it, with a replace-confirmation when the collapsed path already exists.
2026-10-07 22:30:51 -07:00
8ce85f1e9e fix(whispercpp): reuse an on-disk same-size weight at startup (#923)
* fix(whispercpp): reuse an on-disk same-size weight at startup

The Settings picker already lets a downloaded same-size model stand in
when the language-derived sibling is absent, but the startup path never
applied it: a headless config with language=en-us and model_size=medium
demanded ggml-medium.en.bin while ggml-medium.bin sat in the models
directory and loads fine.

Move the stand-in into whispercpp_model_info.on_disk_stand_in so both
paths share it, and give resolve_whispercpp_variant a prefer_on_disk
flag that only language-derived results use: pinned variants and stored
specializations still load exactly what they name, the .en preference
still wins when that file is present, and checksum verification is
untouched. get_model_size_for_engine opts in so startup and headless
launches match the Settings semantics.

Fixes #916

* fix(whispercpp): keep is_model_downloaded free of makedirs

get_model_path creates the model directory for callers that are about to
write a file, but is_model_downloaded is a read-only probe: on a fresh
profile its makedirs ran inside tests that stub os.makedirs for the
config directory, breaking them, and a bare existence check should not
mutate the filesystem anyway. Resolve the file path without creating
directories in the probe; get_model_path keeps creating for writers.

Refs #916

* test(whispercpp): annotate the stand-in fixture's return type

AGENTS.md requires type hints on every signature; _downloaded returns the
context manager from patch.object.

Refs #916

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 18:51:54 -07:00
295386f08b fix(text-injection): end option parsing before typed text (#922)
* fix(text-injection): end option parsing before typed text

A 20-char xdotool chunk, or a 200-char wtype/ydotool chunk, that starts
with '-' (a word hyphenated across the chunk boundary) is parsed as a
flag: 'xdotool type --clearmodifiers -stickation' exits 1 with
'type: unrecognized option' and the rest of the dictation is dropped.

Pass '--' before the text argument in the xdotool, wtype, and ydotool
type invocations so the remaining argument is always typed literally.

Fixes #921

* test(portal-fallback): expect the wtype option terminator

The partial-type fallback assertion predates the -- terminator and still
expected the bare ['wtype', 'llo'] argv.

Refs #921

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 18:51:49 -07:00
d021e64813 fix(dictation-pad): keep pad window out of the compositor ghost state (#912)
* fix(dictation-pad): keep pad window out of the compositor ghost state

On GNOME Wayland an idle, fully covered toplevel gets shelved by the
compositor: it stays in the window list but renders nothing and takes
no input. Keep the pad floating above other windows and pinned to all
workspaces so it cannot be shelved, track iconify/obscure state events,
deiconify on show, and repaint when visibility returns.

Fixes #896

* test(dictation-pad): scope Window return_value patch in shelving test

* fix(dictation-pad): detect Wayland shelving and recreate dead surface

set_keep_above is a stub in GTK3's Wayland backend, so a covered idle pad
still gets shelved by the compositor on GNOME/Wayland: it stays in the
window list but draws nothing and takes no input, and present() cannot
revive it.

Add a frame-callback watchdog: each tick callback stamps the clock and a
GLib timeout flags a mapped window whose frames went cold as shelved. A
shelved pad is then recreated (hide + show_all) in show_pad, the same
path the user's manual Close + tray reopen takes.

Keep keep_above/stick for X11 where they are honored, plus the iconify
and visibility-event handling from the previous commit.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 18:07:22 -07:00
devin-ai-integration[bot]GitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Jatin K MalikJatin K MalikDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
06f475cf95 fix(evdev): require a real keyboard key in /proc keyboard discovery (#918)
* fix(evdev): require a real keyboard key in /proc keyboard discovery

Any nonzero B: KEY bit qualified a device as a keyboard, so power/sleep
buttons, the ACPI video bus, vendor hotkey blocks and pointer-button
interfaces were all grabbed and given (vocalinux) uinput clones, leaving
duplicate logical devices that confuse compositor input settings.

Require a modifier or KEY_A in the KEY bitmap — the same rule the evdev
fallback path already applies — and update the test fixtures to a real
keyboard bitmap so they exercise the intended gate.

Fixes #915

* fix(evdev): read /proc bitmaps at the kernel's word width

Capability bitmap words are unsigned longs — 32 bits on 32-bit kernels
(armv7l, i686), not 64. Parse the whole bitmap through _bitmap_to_int()
sized by platform.machine() so high key codes like KEY_RIGHTMETA land
correctly on both widths. Give pointer-test mocks real keyboard keys so
the pointer check — not the keyboard-key gate — drops them.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
Co-authored-by: Jatin K Malik <jk.malik@yahoo.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-07 18:03:50 -07:00
devin-ai-integration[bot]GitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Jatin K MalikJatin K MalikDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ecd4ae8388 fix(evdev): skip multitouch-only touchpads in keyboard discovery (#917)
* fix(evdev): skip multitouch-only touchpads in keyboard discovery

Devices reporting only ABS_MT_POSITION_X/Y (no single-touch ABS_X/Y)
evaded the pointer-axis check, so multitouch touchpads like the Goodix
GXTP5100 were grabbed and cloned — breaking pointer input while running.

Fixes #914

* fix(evdev): read /proc bitmaps at the kernel's word width

Capability bitmap words are unsigned longs — 32 bits on 32-bit kernels
(armv7l, i686), not 64. Parse the whole bitmap through _bitmap_to_int()
sized by platform.machine() so MT axis bits and any future high bits
land correctly on both widths. Give the fallback MT-mock a real key so
the MT-axes check is the only thing that can drop it.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
Co-authored-by: Jatin K Malik <jk.malik@yahoo.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-07 17:55:25 -07:00
622d0e054d fix(text-injection): detect KWin 6 VirtualKeyboard property (#919)
* fix(text-injection): detect KWin 6 VirtualKeyboard property

On KDE Plasma 6 the org.kde.kwin.VirtualKeyboard 'enabled' property no
longer exists; the setting is reported as 'available'. Query 'available'
first, keep 'enabled' as the Plasma 5 fallback, and accept both the real
(<true>,) gdbus output and the doubly wrapped (<<true>>,) form.

With the bridge check working, KDE sessions with Virtual Keyboard set to
"IBus Wayland" can safely use scoped IBus injection again: the
leftover-daemon trap of #752 is exactly what a confirmed VK bridge
disambiguates. KDE without a confirmed VK stays excluded.

KWin's RemoteDesktop portal scrambles letter case, so in auto mode on
KDE sessions an installed ydotool is preferred over the portal -- the
ordering used before the portal backend existed.

Fixes #911

* fix(text-injection): keep KDE portal fallback ahead of daemonless ydotool

On KDE, when ydotool is installed but cannot run (no daemon, no
/dev/uinput access) and wtype is absent, the daemonless-ydotool branch
fired before the portal fallback -- selecting a backend that fails at
injection time while a working portal sat unused. The portal fallback
now precedes the daemonless branch; working ydotool still outranks the
portal on KDE.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:54:56 -07:00
devin-ai-integration[bot]GitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Jatin K MalikDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
9eba80b53d fix(packaging): declare python3-pysocks in deb and rpm deps (#920)
* fix(packaging): declare python3-pysocks in deb and rpm deps

#909 added PySocks as a runtime dependency for SOCKS5 proxy support but
only covered the AUR PKGBUILD — the nfpm deb and rpm packages never
declared python3-pysocks, so
test_every_project_dependency_is_covered_in_both_formats fails on main
and on every PR's merge check. Add it to both depends lists and the
test's DEB/RPM_PROVIDES maps.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(packaging): deb name is python3-socks

Debian/Ubuntu package PySocks as python3-socks (1.7.1+dfsg); the rpm
name stays python3-pysocks — Fedora's python3-socks is a different
library (python-socks). Verified in debian:12, ubuntu:24.04 and
fedora:42 containers.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Jatin K Malik <jk.malik@yahoo.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-07 17:45:06 -07:00
329a7d7f6a fix(settings): stage Advanced model spec until Download is confirmed (#908)
* fix(settings): stage Advanced model spec until Download is confirmed

Picker changes in Speech Model -> Advanced staged a spec but immediately triggered a model download for every intermediate selection. Gate the auto-apply download path so a missing model only stages: a Download action on the model info card now runs apply_settings() as the single explicit confirmation. Simple-mode questions keep the instant apply-and-download flow.

Fixes #894

* fix(settings): repaint model info card after a completed download

The post-download paths repopulated pickers and resynced the engine but never repainted the info card, so a finished download left the amber status and the Download action visible for a model already on disk. The cancel path already repainted via the idle resync; give success its own.

* fix(settings): keep staged spec through picker rebuilds and save non-model edits

* fix(settings): persist non-model edits without a model-reloading reconfigure

* fix(settings): live-apply non-model edits, keep whispercpp params saved-only while staged

* fix(settings): live-set changed VAD/timeout attrs without reconfigure while staged

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:17:08 -07:00
775863fd30 feat(settings): add proxy support for model downloads and update checks (#909)
* feat(settings): add proxy support for model downloads and update checks

Add a Proxy page to Settings (above About) with Off / System / Manual
modes. Manual mode supports SOCKS5 and HTTPS (HTTP CONNECT) proxies
with optional username/password auth; the password is stored in
config.json in plaintext like the other settings. System mode honors
the *_proxy environment variables and falls back to the GNOME
org.gnome.system.proxy manual configuration.

The resolved proxy is applied to every in-app outbound path that
fetches models or checks updates: the shared requests download path in
recognition_manager (_stream_model_download, used by the whisper.cpp,
whisper, Vosk, Parakeet and faster-whisper downloaders) and the
release checks in update_checker. A small Test Connection button on
the Proxy page verifies reachability through the configured proxy.

SOCKS5 needs PySocks, added as a runtime dependency; uv.lock, the
requirements/*.txt exports and the Flatpak dependency manifest are
regenerated by just lock (with a fix in sync_flatpak_deps.py for
packages like PySocks that publish more than one universal wheel).

Fixes #655

* fix(proxy): bracket IPv6 hosts, escape test errors, declare pysocks for AUR

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:16:48 -07:00
442a646a57 feat(history): add opt-in JSONL persistence for transcripts (#907)
* feat(history): add opt-in JSONL persistence for transcripts

Adds history.persist (default off) mirroring the bounded snippet list to
history.jsonl under the XDG data directory so transcripts survive
restarts. Entries carry timestamp, duration, language and model when
known. Clearing history truncates the file, turning persistence off
deletes it, and disabling history removes it so nothing is retained
anywhere. Settings gains a Keep on Disk switch and a Clear button in the
Transcription History group.

Fixes #758

* test(history): rebind real tempfile when siblings mock sys.modules

test_recognition_manager and test_speech_recognition replace
sys.modules["tempfile"] with a MagicMock at import time, so the new
persistence tests were sharing one mock-named store path in full-suite
runs. Guard the TemporaryDirectory binding the same way
test_whisper_model_info already does.

* fix(history): sweep stale temp files, survive damaged stores, and keep session fields on late commits

* fix(history): reject lone-surrogate records and survive encode errors on write

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:13:40 -07:00
ff57244dce ci(install): gate the remote install path end to end (#906)
* ci(install): gate the remote install path end to end

A curl|bash user takes a different path than the local install gate
covers: bootstrap outside a checkout, tag selection, clone/fetch of the
selected tag, then handoff to the tagged installer with the original
arguments. Add scripts/install-remote-test.sh, which publishes HEAD as a
tag in a local mirror, runs install.sh against it twice (fresh clone,
then fetch+reset of the existing clone), and fails closed when the
clone's HEAD is not the selected tag commit. install.sh gains a
VOCALINUX_REPO_URL override so the mirror can stand in for the public
remote. Wire a single-distro job into the distro matrix and a just
remote-install-gate recipe.

Refs #701

* ci(install): publish the gate tag as an orphan from HEAD's tree

CI checks out with fetch-depth 1, and git refuses to push or fetch a
shallow boundary commit into a ref. Build the tag on an orphan commit
created from HEAD's tree instead, and make tree equality the fail-closed
check that installer, modules and exports are this commit's files.

* test(install): force the re-run arm to prove fetch and reset

Review feedback on the remote-install gate: the second run reused a clone
already on the tag, so a broken fetch or reset would still pass. Move the
clone onto a descendant commit and delete its local tag ref before the
re-run, so only a real fetch + reset restores them. Also list the new
recipe in AGENTS.md's command guide.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:12:47 -07:00
6a6cc93919 feat(tray): remove Start on Login and About from the tray menu (#905)
* feat(tray): remove Start on Login and About from the tray menu

Both entries already live in Settings: the autostart switch is in the
general page and About is a dedicated sidebar page, so the tray items
are redundant. View Logs stays.

Removes the Start on Login checkbox item along with its
_update_autostart_checkbox/_on_autostart_toggled wiring and the now
unused _add_menu_checkbox helper, plus the About item and
_on_about_clicked. _show_settings_page("about") stays for the Update
Available item. autostart_manager is untouched since Settings uses it.

Fixes #654

* fix(settings): sync autostart switch with the real desktop entry

The tray's removed _update_autostart_checkbox also reconciled
general.autostart with the actual login desktop entry at startup. Move
that check into the settings load so the Start on Login switch still
reflects reality when the entry changes outside the app.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:12:01 -07:00
26c4f9052b fix(settings): place test dictation output above its controls (#904)
Reveal the transcription readout at the top of the sidebar footer so it
grows into the empty sidebar space above the Idle row instead of pushing
the Test Dictation button down when it appears.

Fixes #677

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:10:35 -07:00
4b7d57c077 feat(packaging): build thin .deb and .rpm and attach them to releases (#913)
* feat(packaging): build thin .deb and .rpm with nfpm and attach them to releases

Adds packaging/native: an nfpm config that produces a .deb and an .rpm
from the release wheel. The packages stay thin - Python, GTK, PyGObject
and the typelibs are distro Depends; only pywhispercpp and pynput are
vendored because the first is unpackaged on both distros and the second
is missing on Fedora. The Debian 12 build image and the nfpm download
are pinned in tool_checksums.txt.

just native-packages builds both in the pinned container, and
native-smoke installs the built package per distro: debian:12 and
ubuntu:24.04 exercise the .deb, fedora:42 the .rpm, then each runs
vocalinux --version plus an import smoke of the app and the vendored
extension. unified-pipeline.yml gates on the same three-distro matrix,
and release.yml attaches both arches to the release and covers them in
SHA256SUMS and the provenance manifest.

Refs #600

* fix(packaging): preserve launcher args and document Wayland input group

The launcher joined $* into an unquoted exec, re-splitting file paths
that contain spaces before argparse saw them. Exec "$@" directly and
re-quote each argument for the inner shell of 'sg input -c'.

On Wayland the packages could not grant the 'input' group membership
evdev hotkeys need; name the usermod step and its caveat in INSTALL.md
and the release notes.

* test(packaging): add return types to the native packaging tests

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 17:10:00 -07:00
Jatin K MalikandGitHub d53300841d fix(appimage): reuse the Vulkan pywhispercpp build across CI runs (#903)
* fix(appimage): reuse the Vulkan pywhispercpp build across CI runs

The x86_64 Build AppImage step spends most of its time recompiling
pywhispercpp with Vulkan. That compile depends on the base image,
tool_checksums.txt, and the pywhispercpp pin, not on the commit.
Cache the native tree under one key shared by CI, Nightly, and Release.

* fix(appimage): reject a partial Vulkan cache and key the build tools

A failed copy could leave a tree that still contained libggml-vulkan.so,
and the next run would reuse it. Check each copy, and keep the previous
cache until the new one is in place.

The cache id now also covers the pinned cmake and ninja export and the
Vulkan compile flags. The hit path is what the build calls, and a test
runs it with a stub compile command.

* chore(ci): rerun AppImage against the saved Vulkan cache

The review fix changed the native cache id, so run 37586979294
compiled pywhispercpp again and saved
appimage-native-X64-ebf478b87986e2f6b9d80c97c2f5869fe3120c2bcd48f5d8f5a65fe06ec03015.
This commit leaves the base image, tool checksums, pywhispercpp pin,
installer tool pins, and Vulkan compile flags alone.
2026-10-07 01:04:18 -07:00
af1029dc60 fix(evdev): skip pointer-motion devices in keyboard discovery (#902)
* fix(evdev): skip pointer-motion devices in keyboard discovery

find_keyboard_devices() treated any device with a nonzero EV_KEY bitmap
as a keyboard, which includes Logitech mice (G502, MX Master 3) whose
button interfaces report key bits. The hub then grabbed them (EVIOCGRAB)
and re-emitted every event, including EV_REL pointer motion, through a
paired uinput clone — piping all cursor movement through the reader
thread and showing up as periodic cursor stutter.

Discovery now excludes devices that report pointer motion (REL_X+REL_Y
or ABS_X+ABS_Y) in both the /proc/bus/input/devices parser and the
evdev.list_devices() fallback. Wheel-only devices (REL_WHEEL) and real
keyboards are unaffected, and a mouse's separate keyboard interface
node keeps working for shortcuts.

Fixes #900

* test(evdev): annotate new device-discovery tests

* chore: drop accidentally committed pr_body.md

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-07 00:10:50 -07:00
Jatin K MalikandGitHub 758f640787 fix(uninstall): stop deleting files in the caller's directory (#901)
* fix(uninstall): stop deleting files in the caller's directory

The curl uninstall command runs uninstall.sh from $PWD, and the script
treated that directory as a source checkout. It deleted venv, build, dist,
and Python bytecode there, then reported success.

Those paths are removed only when uninstall.sh itself lives in a Vocalinux
checkout, and only inside that checkout.

Fixes #897

* fix(uninstall): keep dev metadata and drop the false cleanup error

Tests put a pgrep and kill stub first on PATH, so they cannot signal a
Vocalinux process that is already running. egg-info cleanup stays under
src/ and the checkout root, and the bytecode walk prunes .venv. Artifact
cleanup runs before the curl clone is deleted, so an uninstall from
~/.local/share/vocalinux-install no longer prints an error and then success.
2026-10-07 00:06:37 -07:00
96e012d9bd fix(evdev): keep key suppression when /dev/uinput is write-only (#893)
* fix(evdev): keep key suppression when /dev/uinput is write-only

python-evdev opens /dev/uinput O_RDWR, but the kernel only needs write
access for the uinput setup ioctls and event injection. Installs that
still carry the original 80-dotool.rules line (MODE=0620, write-only
for the input group) therefore fail UInput() with EACCES and fall back
to ungrabbed monitoring, where the dictation shortcut is detected but
also reaches the focused application. Retry the clone on an O_WRONLY
descriptor via a small _WriteOnlyUInput subclass so suppression keeps
working there (refs #871).

Also populate _withheld_modifier lazily in _event_is_shortcut: the
AltGr-replay machinery was dead code because nothing created the
per-fd map, so a withheld modifier press was never replayed and
composition (e.g. RightAlt+e) lost its modifier.

* fix(evdev): byte-truncate clone names and cover the write-only path

- _clone_device_name now truncates the encoded name by bytes: the
  kernel limit is 79 bytes, and a long multibyte device name could push
  the " (vocalinux)" suffix past the cut so rescan would not skip the
  clone.
- _WriteOnlyUInput closes the fd explicitly when _prepare_events fails
  before the _uinput calls; the helpers close it themselves on any
  later failure.
- Tests now instantiate the real subclass with mocked OS/native calls
  (open flags, setup sequence, write/write_event/syn ordering, fd
  cleanup, name truncation).

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-10-01 23:45:49 -07:00
4497bfae6c fix(logs): let the View Logs dialog close via titlebar-X (#892)
* fix(logs): let the View Logs dialog close via titlebar-X

_on_response only destroyed on ResponseType.CLOSE and ignored
DELETE_EVENT, so the Logs window could not be dismissed with the
window-manager close button — unlike Settings, which handles both. A
window force-destroyed behind the dialog then crashed quit with a
BadWindow error instead of an orderly shutdown.

* test(logs): cover _on_response behavior; annotate its signature

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 05:55:33 -07:00
d11bd1bf0d 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>
2026-09-30 03:55:17 -07:00
dca937fc21 feat(dictionary): combined custom dictionary with corrections (#890)
* feat(dictionary): combined custom dictionary with corrections

Rebased onto latest main (conflict-resolution squash for PR #818).

* fix(dictionary): stabilize the terms and corrections file contracts

- corrections_path() stays at the fixed config_dir() location promised by the file contract instead of following a custom or --dictionary-file terms path, which could strand or duplicate corrections; a leftover file beside the terms file is still read once and copied over.

- _atomic_write() preserves the replaced file's permission mode instead of resetting it to the process umask.

- build_initial_prompt() streams terms and stops at the configured cap instead of reading and processing the whole file for every segment.

* fix(settings): restore the selected dictionary pane after a search

Searching can auto-switch the Custom Dictionary management pane to reveal a match, but clearing the query only restored widget visibility. Record the pane selection in the search baseline snapshot and restore it alongside the previous page so users return to the pane they chose.

* fix(dictionary): close the remaining custom dictionary file-contract gaps

- A --dictionary-file session override no longer imports a corrections
  file found beside the override into the standard config directory;
  the override scopes to terms only.
- Terms reads validate the whole file before yielding: corrupt UTF-8
  mid-file now yields no terms at all, matching the invalid status
  Settings reports instead of leaking a partial dictionary to
  recognition.
- The terms read is bounded by MAX_TERMS_FILE_BYTES (1 MiB); a larger
  file is invalid everywhere with a dedicated status instead of being
  read on every segment.

* fix(dictionary): keep usable terms prefix on oversized files and legacy corrections under overrides

* fix(dictionary): cap yielded terms and keep added terms visible on oversized files

* fix(dictionary): keep added terms visible without dropping scanner-owned lines

* fix(dictionary): keep oversized terms manageable and honor mid-search pane switches

- terms list gains a Show all / Show first 500 toggle; adding a term
  beyond the cap auto-expands so the new row renders with a Remove
  button instead of being invisible until the file is hand-edited
- a manual dictionary pane switch during an active search drops the
  saved pre-search pane so clearing the query no longer rolls back the
  user's newer selection
- annotate the new settings tests and type model_info so the parakeet
  branch stops adding fresh mypy errors

* fix(settings): do not auto-expand terms on add past display cap

Rebuilding up to thousands of term rows on the UI thread stalls the
dialog; the overflow button already shows the true count, so the added
term stays one click away.

* test(dictionary): stub segment_callbacks on bare manager for history dispatch

* fix(dictionary): stream oversized terms files when building the prompt

build_initial_prompt decoded the whole 1 MiB read window before every
transcription segment even though it only needs the first max_words
terms. _iter_terms_for_prompt now reads oversized files line by line and
stops at the prompt limit, keeping the same contract: complete leading
lines only, invalid UTF-8 inside the usable window yields nothing.

* fix(history): only the ended session's worker may extend its snippet

A late segment could extend the newest history entry regardless of which
session produced it, so a worker finishing after the next dictation
session leaked its text into that session's entry. Track the worker that
owned the ended session and require late segments to arrive on the same
thread before merging; anything else forms its own snippet.

* fix(speech): transcribe queued segments the reload worker never drained

The recognition worker can exit during stop_recognition's bounded
audio-thread join, so the released key's final buffer lands in the queue
after the worker is gone and was silently dropped when the reload
teardown replaced the queue. Drain whatever remains before swapping it
— except on capture failure or an explicit cancel, which still discard
the session's leftovers.

* fix(settings): build term rows in idle slices and pin hidden additions

Two problems shared one cause. Rebuilding up to 4096 rows synchronously
stalled the dialog, and a term appended past the 500-row cap reported
success while staying invisible. Rows now build in bounded GLib idle
slices guarded by a refresh token, and a saved term outside the visible
window is pinned to the top of the capped list until it disappears or
becomes visible on its own. Adding under "show all" scrolls to the new
term instead.

* test(engine): annotate new test method return types

* fix(ci): match a candidate row that leads the latest track block

snapcraft status normally prints candidate as a continuation line under
the latest track (channel in column 1). If candidate ever leads the
track block it rides the track line instead (channel in column 3), and
the resolver missed it and refused to promote. Match both layouts in
the resolve and the pre-release re-verify.

* fix(dictionary): bound prompt reads and scroll to landed terms

- _iter_terms_for_prompt now reads each line via readline() capped by the
  remaining byte window, so an overlong line can never pull more than
  MAX_TERMS_FILE_BYTES into memory
- the prompt path keeps decoding every complete line inside the usable
  window after the term limit is collected, so invalid UTF-8 anywhere in
  the window still yields no terms and matches the reader's invalid status
- under "show all" Settings scrolls to the index where an added term
  landed (prepended or appended), verified against the row's label,
  instead of always scrolling to the end

* fix(history,reload): keep late-segment ownership and drain stamped tuples

- Track session_workers_seen so a worker from an earlier session can
  never merge into a newer snippet while a session is open
- Unpack (segment, language) queue tuples in _transcribe_queued_segments;
  passing the tuple as audio raised TypeError and lost final speech
- Refuse 'delete that' when a manual pad edit blurred segment boundaries
- Translate term-row coordinates into the scrolled card's space so the
  scroll actually reaches rows below the fold
- Type _on_dictionary_pane_changed's varargs

* style: drop unneeded nonlocal for mutated worker set

---------

Co-authored-by: Graham <graham@thegeeky.ninja>
2026-09-30 02:33:49 -07:00
047b379bd2 feat(audio): add PipeWire capture path with system-audio sources (#889)
* feat(audio): add PipeWire capture path with system-audio sources

Enumerate PipeWire sinks via pw-dump and expose their monitors as
"System audio: <name>" capture devices at negative device indices
(-2..), alongside the existing PortAudio microphones. Recording,
device test, and stream-reconnection paths route to a
pw-record-backed stream shaped like a PyAudio stream, so the VAD and
silence-detection loop is unchanged.

Playback ducking is skipped when the selected source is a monitor,
since ducking the default sink would also duck the captured audio.

Closes #751

* test(audio): restore stashed numpy instead of re-importing it

test_audio_input imports numpy lazily; deleting the MagicMock that
earlier test modules leave in sys.modules re-imported a second numpy
module object whose lazy submodule attributes (linalg, testing) were
unpopulated while sys.modules kept the first object's entries. The
next test file to reach for numpy.testing hit a __getattr__ import
loop (RecursionError in TestDownmixToMono on CI).

Restore the conftest-stashed _vocalinux_real_numpy instead, matching
the pattern tests/test_recognition_manager_device_config.py already
uses.

* fix(pipewire): drop PyAudio requirement for PipeWire sources, rebind source after reconnect

* docs(pipewire): restore PipeWireCaptureSource class docstring

* fix(audio): initialize PyAudio for mic reconnection after PipeWire session

A session started on a PipeWire sink never creates a PyAudio instance;
when capture later reconnects onto a PortAudio device the source needs
one to reopen, and a failed open dereferenced None calling terminate.

* fix(audio): harden PipeWire source resolution, reads, and cleanup

- resolve stored names by node.name/description; a stored name that no
  longer resolves never falls back to a positional index
- require pw-record before listing system-audio sources in the picker
- bound pw-record chunk reads with a stall watchdog so stopping cannot
  block on a wedged stream
- close the reconnection probe stream when its verification read fails
- log cleanup failures instead of swallowing them; narrow exception
  handlers in enumeration and PyAudio reconnection init
- annotate test helpers and signatures per repo style

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 02:07:41 -07:00
9898bb8dd9 feat(ui): in-app Dictation Pad for Wayland-safe dictation (#887)
* feat(ui): add in-app Dictation Pad for Wayland-safe dictation

Dictated text can now be routed into a text box inside Vocalinux's own
window instead of being injected into other applications — an explicit
fallback for desktops where cross-app injection is unreliable or blocked
(notably Wayland). The pad opens from the tray menu, captures dictation
when enabled via Settings or its own checkbox, and lets the user copy
text out by hand. 'delete that' removes the last segment in-pad.

Closes #726.

* fix(ui): consult live config for pad auto-reveal and Copy All

The reveal gate used the controller's enabled flag, which only syncs on
pad show or its own checkbox — enabling capture via the Settings switch
while the pad was closed silently buffered dictation with no reveal.
_apply_append now reads dictate_to_pad from the shared config.

Also: Copy All copies the widget text (dictation plus in-pad edits), and
re-showing the pad no longer does a wholesale set_text — the view stays
incrementally in sync, so manual edits survive hide/reopen.

* fix(ui): keep dictation pad capture and commands inside the pad

Address all Greptile findings on PR #887:

- Read the capture toggle from the shared ConfigManager instead of
  re-parsing config.json per segment, so a torn mid-write read can no
  longer fail-open and send dictation to the focused app
- Sync edits made directly in the pad widget back into the controller,
  keeping delete semantics consistent with the visible text
- Stamp queued widget operations with a generation tag so a Clear drops
  appends still sitting in the GTK idle queue
- Route undo/redo, select all/line/word/paragraph and cut/copy/paste to
  pad-local implementations while capturing, so their shortcuts never
  reach whichever application holds focus
- Track where the last segment was delivered so "delete that" follows
  the text rather than the current capture toggle
- Annotate every new signature and add callback-level tests that pin the
  routing, queue, and undo behavior

* fix(ui): keep pad controller, queued ops, and delete target in sync

Manual edits could overwrite queued dictation in the controller because
GTK input events outrank idle callbacks; the changed handler now replays
pending widget ops before syncing the view. The controller also tracks
dictated segments across undo snapshots, so a pad undo pops the "delete
that" target instead of leaving a stale length. Catches in the pad are
narrowed to the GTK/config error set per repo style, and missing type
annotations are added.

* fix(ui): close flush races in pad idle queue and delete retarget

Flushed idle ops stayed scheduled with GLib and ran a second time,
duplicating appends; each queued op now only acts while its entry is
still pending. A queued full-view refresh could also erase a manual
edit that landed first, so the changed-handler flush now drops stale
set-text ops instead of replaying them. And clearing the pad
destination on undo-to-empty kept a redo from restoring the "delete
that" target; the destination flag is preserved so redo re-arms it.

* fix(ui): replay pad idle ops before edits land, not after

Flushing in the changed handler still let queued appends leapfrog the
keystroke that fired it, and a stale dispatch could cancel a later
queued op with identical text. Pending entries now match by identity,
and the replay moved to insert-text/delete-range — signals that fire
before the edit is applied — so queued dictation and deletes keep true
chronological order against manual edits.

* fix(history): target late segments to their own snippet by id

A straggler from a just-ended session could merge into whichever snippet
was newest when it arrived, and audio captured before a session opened
was attributed to that session while decoding on its worker.

- TranscriptionHistory.add returns the snippet id and the new
  extend_entry() extends a specific entry, replacing the positional
  extend_latest target in the late-segment path
- session_started_floor stamps when a session opens; in-session
  attribution also requires the capture to postdate it
- test_capture_ceiling closes the mic-test window at callback restore so
  test speech still decoding can no longer leak into history

* fix(settings): keep deferred edits from overwriting a newer choice

The deferred-persist filter treated "config still equals the baseline" as
untouched, so a reopened dialog that applied a key back to its baseline
value could still lose it to the closed dialog worker.

- Record the generation of the apply that last wrote each advanced key;
  a pending edit now survives only when no newer apply wrote that key
- A worker apply whose snapshot a newer apply predates now skips its
  reconfigure and save entirely, so collection order — not lock order —
  decides which snapshot wins

* fix(settings): skip stale applies before stopping dictation

The superseded-apply check ran after stop_recognition, so a stale worker still interrupted a live dictation session. Recognition now stops inside the apply lock, after the generation check.

* fix(shortcuts): keep language edits from breaking dictation

Two per-language shortcut fixes (#805):

- A binding edit landing while a session is live no longer stops
  recognition. The dialog's per-keystroke persist still saves the new
  binding, but the tray defers the listener rebuild to the next IDLE
  transition, so a held push-to-talk keeps its release callback and a
  toggle session is never cut off mid-utterance.
- A row edited onto a key another row holds can no longer displace that
  row's binding. Each row's remembered shortcut is reserved for its own
  row before claims run in row order, so the stealing edit is rejected
  and the held binding survives.
- Persists that resolve to the already-saved bindings (mid-edit
  keystrokes, rejected duplicates, reverted fields) skip the save and
  the listener rebuild entirely.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 01:35:21 -07:00
dbe0215aad feat(shortcuts): add language switching shortcuts, fix PTT races, and add dictation history (#880)
* feat(shortcuts): per-language dictation shortcuts (#805)

Bind extra keys that start dictation in a specific catalog language for
one utterance, then restore the configured language. Bindings live in
shortcuts.language_shortcuts as [{shortcut, language}], normalized
against the language catalog and the backend's shortcut parser.

The tray spawns one KeyboardShortcutManager per binding in the active
mode; toggle presses route through start_recognition_with_language, and
push-to-talk arms press/release the same way. The manager keeps the
override one-shot: it applies before the utterance starts (outranking
follow-keyboard-layout) and unwinds on the next IDLE/ERROR transition
or when a mid-dictation reconfigure picks a new language. Engines that
cannot re-language without a reload (VOSK, Parakeet, English-only
whisper.cpp) refuse with the error cue and a notification instead of
dictating in the wrong language.

Settings gains a Language Shortcuts group under Shortcuts: each row is
a language picker, an editable key entry, a Record button that retargets
the shared key-capture, and a remove button; edits persist immediately
and rebuild the live listeners. A non-dict shortcuts section now rebuilds
instead of crashing config load.

* fix(shortcuts): class-level default for language shortcut listeners

TrayIndicator stubs built with __new__ (e.g. test_suspend_handler)
skip __init__ but still run _quit(); give _language_shortcut_managers
a class-level default so teardown iterates an empty list.

* Fix Greptile review findings on per-language shortcuts (#805)

- Bind each dictation session's language for transcription workers so the
  one-shot restore cannot rewrite a queued segment's language
- Judge a shortcut's capability against the model the layout refresh will
  serve (whisper.cpp sibling swap) and refuse requests the engine drops
- Add _oneshot_language_restore to the reconfigure snapshot and settle a
  revived pending restore on failure
- Dedupe language bindings by gesture (modifier reordering, enter/return
  alias, pure-modifier side sets) instead of by string
- Stop per-language listeners under external activation and never re-arm
- Gate push-to-talk release on the binding that started the session, and
  stop a held hold before rebuilding listeners
- Keep a row's last valid binding during partial edits, report rejected
  duplicates, cancel a capture aimed at a removed row, disarm the previous
  Record target, persist a recorded key once, and add the missing type
  hints

* fix(shortcuts): resolve remaining Greptile race findings on PR #880

- Stamp each queued audio segment with the session language it was
  recorded under, so a worker that outlives stop_recognition's joins
  transcribes each utterance in its own language rather than whatever a
  newer session stored in _session_language.
- Serialize push-to-talk press/release behind _ptt_lock: a release
  landing while start_recognition runs now waits for the owner claim
  instead of being dropped, so a quick key tap cannot leave the
  microphone running.
- Add the missing return type hint on _stop_language_shortcut_managers.

* test(shortcuts): stub _segment_started_at on bare manager stubs

* fix(shortcuts): carry segment language as attribute, default language on bare managers

* fix(shortcuts): keep (segment, language) queue tuples, stamp segment.language too

* fix(settings): apply history toggle and limit to the live store

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 01:05:18 -07:00
ca40f7d8b0 fix(text-injection): keep Alt+Shift/Win+Space layout switching on GNOME Wayland (#876)
* fix(text-injection): restore full GNOME XKB map on XWayland after inject

sync_xwayland_layout_from_gnome wrote only the single active input
source via 'setxkbmap -layout <one>'. That left XWayland with a
one-group keymap: Alt+Shift 'grp:' toggles and the group index Mutter
drives for Win+Space became no-ops for every X11/XWayland client until
relogin.

Apply GNOME's complete XKB map instead: every xkb input source in
'sources' order plus all 'xkb-options', and only when the live rules
differ (which also keeps inject from resetting the active group).

Refs #848

* fix(text-injection): harden XWayland repair against bad reads and non-GNOME sessions

Three Greptile P1s on f005b6c:

- _read_gnome_input_sources_key now returns [] for a successfully read
  empty list, reserving None for unreadable keys. _get_gnome_xkb_keymap
  bails when the xkb-options read fails instead of treating it as an
  empty list, so a transient gsettings failure can no longer make the
  repair clear live XWayland options via 'setxkbmap -option ""'.

- _get_gnome_xkb_keymap restores the GNOME-session guard the previous
  source reader had, so a stale org.gnome.desktop.input-sources schema
  on another Wayland desktop is never applied to that desktop's
  XWayland server.

- _query_xserver_xkb_rules catches OSError, not just FileNotFoundError:
  launch failures like PermissionError run inside inject_text's finally
  and must not surface after a successful commit.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 00:48:17 -07:00
8ebc4ce2ba feat(diarization): per-speaker transcripts via TinyDiarize + --transcribe-file (#884)
* feat(transcribe): add TinyDiarize file transcription surface

Catalog small.en-tdrz (hosted at akashmjn/tinydiarize-whisper.cpp,
pinned commit) while keeping it out of every dictation-model selection
path: its [SPEAKER_TURN] markup would type noise into the focused
window. The checksum generator resolves side-repo models at their own
pinned revision, parakeet-style.

Add speech_recognition.diarization (strict-WAV fast path with ffmpeg
fallback, tdrz transcription, speaker-turn parsing, timestamped
formatting) and surface it through a tray Transcribe Audio File flow
with on-demand model download plus a headless --transcribe-file CLI.
TranscriptDialog offers copy and save of the diarized transcript.

Closes #756

* docs(user-guide): document --transcribe-file

* fix(transcribe): pass tdrz_enable as a pywhispercpp kwarg

pywhispercpp's Model takes decode params as keyword arguments; a
params= dict crashes on the C-level whisper_full_params object. Also
let --transcribe-file report unexpected failures cleanly instead of
dumping a traceback.

* fix(tray): dismiss the tdrz download dialog on response

* test(diarization): pin real wave/numpy/tempfile around sibling mocks

Several test modules replace sys.modules entries with MagicMocks at
import time and never restore them, so diarization's wave/numpy/
tempfile bindings resolved to mocks when this file ran after them in
CI. An autouse fixture reimports the real stdlib modules per test, the
same remedy test_audio_feedback already applies for wave.

* fix(transcribe): verify tdrz model, preload libs, gate formats on ffmpeg

- Hash the on-disk TinyDiarize model against the pinned digest before
  native code maps it, matching the dictation load path
- Extract the bundled-lib preloader to utils/pywhispercpp_loader.py and
  preload before the lazy pywhispercpp import so the headless
  --transcribe-file path works on source-built installs without RPATH
- Offer only WAV in the file picker when ffmpeg is absent; name the
  dependency in the filter label
- Type the new tray/diarization callbacks; document --transcribe-file
  and the new modules in AGENTS.md

* style(audio): finish type annotations on capture helpers and download path

_setup_alsa_error_handler now declares Optional[Any] and its ctypes
callback gets concrete parameter types; the remaining capture helpers
take audio: Any (PyAudio has no importable type) and test_audio_input's
device_index becomes Optional[int], which also clears the preexisting
implicit-Optional error. _download_whispercpp_model gains -> None and
its HF url is narrowed to str so the concatenation and download call
stop adding new mypy findings.

* style(tests): wrap long monkeypatch line in preload test

* refactor(main): narrow transcribe-file errors and add file context

* style(ui): annotate TranscriptDialog constructor

* fix(diarize): log worker tracebacks, isort test helpers, document --transcribe-file

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 00:44:55 -07:00
b4babcaeca feat(packaging): AUR -bin package publishing with per-arch digests (#879)
* feat(aur): add vocalinux-bin PKGBUILD shipping the AppImage

Closes #817

* fix(aur): hard-dep injection tools, pin real digests, gate the -bin package

* fix(aur): pin both-arch -bin digests and build the binpkg as builder

updpkgsums in the publish action only regenerates the x86_64 sum array, leaving aarch64 stale after every tag; release.yml now pins both AppImage digests itself and publishes with updpkgsums off. The AUR gate also created $WORKDIR/binpkg as root after handing WORKDIR to builder, so makepkg could not write there. AUR.md now matches the published -bin package.

* fix(ci): wait for aarch64 AppImage before publishing vocalinux-bin

publish-aur only needed build-and-release, so on a tag the digest-pinning
step could curl the aarch64 AppImage before build-appimage-arm64 attached
it — a 404 that failed the job between the source and -bin publishes.

Split the -bin bump/pin/publish steps into publish-aur-bin, which needs
[publish-aur, build-appimage-arm64]: the source package still ships as
early as before while the binary package only runs once both AppImages
exist on the release.

* fix(ci): ship -bin local sources and wait for the aarch64 asset on AUR publish

Split the -bin bump/pin/publish into publish-aur-bin needing
[publish-aur, build-appimage-arm64] so the digest-pinning step can no
longer curl a 404 for the not-yet-attached aarch64 AppImage mid-release,
and publish with asset_dir so the PKGBUILD's local sources
(vocalinux.desktop, vocalinux.svg, LICENSE) reach the AUR repo instead
of a lone PKGBUILD users cannot build.

* style(tests): apply black formatting to aur packaging test

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 00:27:28 -07:00
5c031e64fb feat(settings): bilingual dictation support with deferred settings edits (#424)
* feat: add configurable Whisper language candidates

* fix(settings): defer language candidate edits and tolerate list config

The candidates entry applied settings on every keystroke, restarting the
whisper.cpp model per character, and edits typed while an apply held the
guard were dropped so the saved value could differ from the entry. Defer
application like the initial prompt: mark edits dirty and apply on page
change or close; _finish_auto_apply re-applies edits that landed mid-apply,
and _persist_pending_text_edits saves them if the dialog closed first.

A JSON list in config also crashed the dialog on open because
Gtk.Entry.set_text needs a string; populate the entry through the engine's
_normalize_language_candidates so both stored forms render the same text.

* fix(recognition): validate whisper.cpp language candidates before selection

auto_detect_language's probability map names every language the model can
return, but candidate selection scored configured codes against it with a
0.0 default, so a typo or "auto" could still win the max() and reach
transcription. Only codes present in the probability map are eligible now;
unsupported codes are logged and skipped, and when none qualify the
unrestricted detection result is used instead of forcing a code the model
never scored.

Also add the missing input annotation on _normalize_language_candidates.

* fix(settings): persist deferred text edits off the GTK main loop

_finish_auto_apply runs on the main loop and called
_persist_pending_text_edits synchronously, so reconfigure() restarted
the model in-process and froze the UI when the dialog closed mid-apply.
Run the reconfigure + config save on a worker thread, matching the
normal apply path.

Also annotate _on_language_candidates_changed's signature.

* fix(settings): guard deferred saves and annotate new candidate tests

* fix(settings): reconcile the engine to winning settings after a mid-restart apply

* fix(settings): drop superseded deferred edits instead of saving them

A dialog closed mid-apply stashes its pending text edits for a worker to
persist after the running apply finishes. The worker's mtime check could
not tell the in-flight apply from a newer one started in a reopened
dialog, so the older snapshot could reconfigure the shared engine and
save over the settings that won.

Serialize applies and the deferred persist on a shared lock and order
them with a generation counter bumped when an apply begins. The worker
now skips reconfigure and the save entirely when a newer apply has
begun, and skips the restart when the running apply already landed the
same values.

* fix(settings): merge deferred text edits per key instead of dropping them

Closing the dialog mid-apply stashes the typed prompt/candidate values
with the apply's generation. When a reopened dialog applies newer
settings first, the generation check used to discard the whole stash —
losing the closed dialog's edits even where the newer apply never
touched those keys.

Persist keeps a pre-edit baseline per key: after a newer apply lands,
only keys it actually changed are dropped; the rest still reconfigure
the engine and save, so a stale value can never reach the shared engine
while untouched edits are no longer lost.

* refactor(settings): narrow deferred-edit persist exception with context

* chore: retrigger ci

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 00:16:44 -07:00
b71164a214 feat(tray): add recent dictation snippets history menu 🤖🤖🤖 (#487)
* feat(tray): add recent dictation snippets history menu

Add a "Recent Snippets" submenu to the tray indicator that lists recent
dictation sessions. Clicking a snippet copies its full text to the
clipboard for easy re-pasting; a "Clear History" entry empties the list.

Snippets are recorded on recognition, independent of whether text
injection succeeds, so text can be recovered even when injection silently
no-ops (e.g. on some Wayland compositors). One snippet corresponds to one
dictation session (everything said between start and stop); voice commands
are not recorded since they take a separate callback path.

History is kept in memory only (newest-first, capped, never written to
disk) and clears on quit — a deliberate privacy choice for a tool that
sees everything dictated.

Adds a "history" config section (enabled, max_items) and matching
Settings UI under General. Includes unit tests for the new
TranscriptionHistory store.

* fix(history): sanitize saved max_items to keep startup robust

A hand-edited config.json can store a non-numeric or out-of-range
history.max_items. Coerce the value on load (and in TranscriptionHistory
itself) with a positive-int fallback so a malformed preference warns and
falls back to the default instead of aborting startup or crashing the
Settings dialog spin button.

* fix(history): fold late-arriving segments into their originating session

stop_recognition() only waits on the recognition worker for a bounded
time before reporting IDLE, so a slow final segment can still fire its
text callback afterwards — landing in the next session's pending
snippet, or starting a phantom entry of its own.

Track the open session (state transitions, its worker thread, and its
pending segments) under a lock and file each segment by origin: while a
session is open only its own worker's segments accumulate into it; a
segment arriving after finalization extends that session's committed
snippet via the new TranscriptionHistory.extend_latest(), and a segment
from a session that produced nothing forms its own entry instead of
polluting the next one. ERROR now also finalizes the session, so a
partial result is still recorded and the tracker never wedges open.

Covered by new tests driving the callback and state wiring directly:
mid-session LISTENING/PROCESSING transitions, back-to-back sessions,
late callbacks from a lingering worker thread, disabled history, and
invalid saved limits.

* style(ui): type the new history handlers in tray and settings

* fix(history): keep cleared snippets from reappearing via late segments

The recognition worker can still emit a final segment after the manager's
bounded stop wait reported IDLE. Once the user cleared the history, that
late text could not extend the now-empty history and was appended as a
new entry — resurrecting snippets the user explicitly removed.

Give TranscriptionHistory a clear epoch: clear() (and disabling, which
clears) bumps it even when the store is already empty. add() and
extend_latest() accept an expected_epoch and refuse stale epochs
atomically under the store lock, so the check cannot race the clear.

main.py tracks the epoch each session opened under and the epoch of the
most recently ended session. Session commits and every late-segment
write are guarded by them, so text produced before a clear — whether a
session's whole snippet, its straggler segments, or leftovers committed
on the next session's start — cannot re-enter history afterwards.

* test(history): cover clear-epoch rejection and type new signatures

Add regression tests for the cleared-history race: a late segment after
clear() must not reappear, a session ending after a mid-session clear
leaves no snippet, and add/extend_latest refuse a stale expected_epoch
while unguarded writes keep working.

Annotate the new signatures per the repo guide: _boot's extra_settings
and return tuple, every test in the new TestSessionHistoryRecording and
history-menu tray classes, and all methods of the history store tests.

* fix(main): keep post-clear dictation by rebinding the session epoch

* fix(history): drop the boundary segment when a clear lands mid-session

* fix: scope dictation history to post-clear audio via capture-time stamps

The engine now tags each queued audio segment with the monotonic time
capture began and reports it to a new segment_callbacks hook. History
recording drops segments whose capture started before the last clear,
so pre-clear audio that decodes late can no longer resurrect cleared
entries or leak into a later session, while text dictated after a
mid-session clear is kept instead of being discarded by a stale epoch
check.

* fix(settings): keep Test Dictation speech out of transcription history

The mic test swaps text callbacks for its preview, but history recording
runs on segment callbacks, so test speech still landed in Recent
Snippets. The test now saves and clears the segment callback list
alongside the text list — restoring both on success and on the
start-failure path. register/unregister_segment_callback also gain the
missing return annotations and the engine gets symmetric
get/set_segment_callbacks accessors.

* fix(settings): floor test-dictation speech instead of swapping callbacks

Instead of clearing the segment callback list during the mic test —
which would have dropped leftovers from a still-finishing dictation —
the engine exposes test_capture_floor: segments captured at or after
the stamp are test speech and stay out of Recent Snippets, while
earlier-session leftovers continue to file normally.

* refactor(history): narrow change-callback exception surface

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-30 00:06:51 -07:00
f36c3f6ae5 feat(text_injection): add RemoteDesktop portal injection backend (#885)
* feat(text_injection): add RemoteDesktop portal injection backend

Wayland offers exactly one sanctioned key-injection path:
org.freedesktop.portal.RemoteDesktop's NotifyKeyboardKeysym. It reaches
native clients on compositors with no input-method-v2 bridge, works from
inside the Flatpak sandbox without /dev/uinput or a helper daemon, and
delivers keysyms so non-US layouts survive -- unlike ydotool's evdev
keycodes.

RemoteDesktopPortal is a synchronous client over a dedicated worker
thread holding a private bus connection and GLib context, which keeps
Request::Response delivery deterministic. Sessions are created lazily on
first use so startup probing only reads the interface version; v2 portal
sessions persist a restore token (mode 0600 under data_dir) so the
permission prompt appears once.

Backend selection now prefers the portal on Wayland: it is probed at
runtime ahead of ydotool/wtype/xdotool in autodetection, is pinnable via
VOCALINUX_FORCE_BACKEND=portal or text_injection.backend, and demotes to
the previous order on runtime failure. Flatpak still needs --device=all
for evdev hotkeys, but uinput is only a fallback path now.

Closes #750

* fix(text_injection): resolve Greptile findings on RemoteDesktop portal injection

- Move persist_mode/restore_token to SelectDevices options (portal v2
  sends them there, not CreateSession); a bad token is ignored rather
  than sinking the session, so drop the stale-token retry paths that
  could issue a second Start after a user cancel
- Track delivered units on RemoteDesktopPortalError so Wayland
  fallbacks retry only the undelivered tail instead of retyping the
  prefix (text chars, backspace taps, shortcut steps)
- Give queued portal jobs a single deadline budget across
  CreateSession/SelectDevices/Start waits; on submit timeout the job
  is cancelled via a Gio.Cancellable plus abort checkpoints so the
  worker cannot keep emitting keys after the caller gave up
- Close sessions whose Start never succeeded
- Release held shortcut modifiers on mid-step failure
- Skip the portal probe when an installed wtype/ydotool backend is
  explicitly pinned
- Type annotations on the new signatures and touched tests

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 23:55:27 -07:00
24fddc4344 chore(release): add snap-promote workflow for gated stable releases (#881)
* chore(release): add snap-promote workflow for gated stable releases

Issue #783 option 2 (safer): v* tags keep auto-publishing the snap to
edge + candidate in release.yml, and latest/stable is promoted by a
manual snap-promote.yml dispatch after candidate QA.

The dispatch resolves the revision on latest/candidate and refuses to
promote unless its version matches the tag, so a typo or stale candidate
fails the run instead of shipping the wrong revision. Declaring the
snap-stable environment with required reviewers adds an approval gate
on top of the dispatch.

Fixes #783

* fix(ci): serialize snap promotions and re-verify candidate

awk used the gawk-only \S which mawk reads literally, so the latest-track candidate row never matched. The promote step also trusted a revision resolved minutes earlier, letting an overlapping promotion downgrade stable. Serializes dispatches and re-resolves the candidate right before release.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 23:18:38 -07:00
5e531cbac0 refactor(audio): extract capture source from recognition manager (#883)
* refactor(audio): extract capture source from recognition manager

Move audio capture out of the silence-segmented dictation loop into
vocalinux.audio.capture: a reusable PortAudioCaptureSource that owns
device resolution, format negotiation, downmixing, resampling, and
stream lifecycle (open/read/reopen/close). The dictation loop now
consumes 16 kHz mono chunks and keeps only segmentation policy (VAD,
silence counters, buffer limits, reconnection backoff).

This unblocks alternate capture sources (PipeWire system-audio capture
for #751, tdrz long-form output for #756) which need the same capture
mechanics without dictation's silence segmentation.

Behavior is identical: the same helpers run in the same order, moved
verbatim. They are re-exported from recognition_manager for existing
imports, and a CaptureSource protocol is declared in common_types.py.
Tests repoint their patches of the moved helpers to the new module.

Closes #760

* fix(audio): annotate capture API and keep a superseded session's source

* fix(audio): reset retry counters when a new capture session starts

* fix(audio): keep last-error timestamp across capture sessions

The new-session reset wiped _last_audio_error_time, so an error within five seconds of the previous session's error always took the reconnect path instead of failing fast. Only the attempt count is per-session; the timestamp is also the rapid-failure throttle.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 23:04:43 -07:00
9e6a03cd20 feat(settings): make the settings category list scrollable (#886)
* feat(settings): make the settings category list scrollable

Wrap the sidebar category ListBox in a Gtk.ScrolledWindow (matching the
page wrappers) so the dialog can shrink vertically without clipping
categories and new pages do not pin the minimum window height.

Fixes #678

* fix(settings): reveal the selected sidebar row in the scroller

* fix(settings): retry sidebar scroll until the selected row is laid out

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:59:58 -07:00
ff108595fd fix(hotkeys): consume the dictation shortcut so it stops leaking to apps (#873)
* fix(hotkeys): consume the dictation shortcut so it stops leaking to apps

Grab each monitored keyboard with EVIOCGRAB and re-emit every event the
shortcut does not own through a paired uinput clone. The configured
shortcut key is now consumed by Vocalinux instead of also reaching the
focused app (right-alt opening menus in Thunderbird/Firefox, F11/F12
triggering browser actions).

- Pure-modifier gestures reserve the modifier's events entirely; bare
  function-key shortcuts consume that key; modifier+key combos consume
  the main key only while the combo owns it, so typing and chords like
  Alt+Tab keep working and injected text is untouched
- BUS_VIRTUAL devices (our clones, ydotool, remappers) are never grabbed
- EV_REP is stripped from clones and repeats are forwarded verbatim, so
  repeat rate is preserved without doubling
- Feedback event types (EV_LED/EV_SND/EV_FF) are never re-injected
- Falls back to ungrabbed listening when /dev/uinput or the grab is
  unavailable, warning once that keys will also reach the app

Fixes #871

* test(hotkeys): cover grab/forward edge cases

Race cleanup on duplicate open, forwarder close errors during stop and
device removal, mid-SYN_DROPPED forwarding, write failures, and the
combo consume edge cases (unowned repeat/release, missing main key).

* fix(installer): grant read on /dev/uinput so key suppression works

The evdev backend's uinput clone opens /dev/uinput O_RDWR, but the
installed 80-dotool.rules gave the input group write-only access
(0620), so shortcut suppression silently fell back to ungrabbed
monitoring on stock installs. Bump the rule to 0660 and refresh it when
the installed copy differs so existing installs pick up the fix.

* fix(hotkeys): harden evdev grab/forward lifecycle and udev install

- release the device grab and close the clone when a uinput write fails
- close all devices if the monitor exits unexpectedly (releases grabs)
- monitor virtual devices too; skip only our own uinput clones
- track consumed combo presses per device so releases stay paired
- after SYN_DROPPED, release keys the clone still believes are held
- append the uinput udev rule instead of overwriting local rules
- annotate new signatures and narrow the clone-creation catch

* fix(hotkeys): reconcile swallowed combo releases after SYN_DROPPED

- resync the swallowed-press pairing even when the clone holds no keys;
  a dropped combo release otherwise leaves the next ordinary press
  consumed, eating a keystroke
- narrow every new close-cleanup catch to OSError/IOError/RuntimeError
  with debug context instead of blanket Exception passes

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:59:35 -07:00
004696bbe0 refactor(settings): consolidate dialog guard flags and widget construction (#878)
* refactor(settings): consolidate dialog guard flags and widget construction

The settings dialog grew the same work in several places. This gathers
the shared pieces into module helpers with no behavior change:

- _suppressed(): a context manager for the try/finally flag dance the
  handlers used to open-code for _populating_models,
  _processing_language_change, _simple_syncing, and _simple_driving.
  The apply-guard handoff and the nested resync keep their explicit
  save/restore because they do not fit a lexical block.
- _handlers_suppressed(): one name for the
  "initializing or applying" check every handler repeated on entry.
- _set_no_show_all_visible(): the clear-flag / show / re-arm sequence
  for rows created hidden, used by the second-language row and the
  custom-shortcut row.
- _add_switch_row(), _combo_chrome(), _new_spin(),
  _connect_picker_entry(): the repeated switch-row, tooltip +
  scroll-guard, spin-button, and picker-entry wiring.
- _undownloaded_model_info() and _make_download_dialog(): the
  per-engine download gate both apply paths carried verbatim.

Flags stay plain booleans and helpers stay module-level so existing
tests keep working unchanged.

* fix(settings): annotate download dialog builder and align visibility docs

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:59:18 -07:00
ba721779c3 fix(downloads): abort blocked model fetches on cancel (#888)
* fix(downloads): abort blocked model fetches on cancel

requests.get() has no way to abort while it is still resolving,
connecting, or waiting for response headers — which is what an
unreachable download server looks like. The cancel flag was only
checked between response chunks, so Cancel sat until the request
timed out. The blocking open now runs on a helper thread the caller
polls every 200 ms; on cancel it raises at once while the abandoned
attempt stays bounded by the existing timeouts.

The settings dialog's download workers also reported "Model ready to
use" whenever the apply returned True, but reconfigure() can no-op
when the selection matches live state — success without a download.
They now pass force_reinit so a reported success means the engine
actually initialized the downloaded model.

Fixes #679

* fix(downloads): bound cancel openers, close late responses

- Track blocked request opener threads and give each attempt its own stop
  event so a cancel-and-retry cannot stack unaccounted requests and a
  response landing after cancel is closed instead of leaked
- Catch requests.exceptions.RequestException in the opener instead of a
  bare Exception
- Close the streaming response on the mid-stream cancel path
- Make the blocked-open test helper cancellable and reap tracked openers;
  check each download worker call site for force_reinit separately; add
  missing return annotations on new test helpers and methods

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:52:12 -07:00
c9ea6c7de7 feat(flatpak): publish a signed self-hosted OSTree remote on releases (#875)
* feat(flatpak): publish a signed self-hosted OSTree remote on releases (#785)

release.yml gains publish-flatpak-remote: it imports the per-arch
.flatpak bundles into an OSTree repo, signs commits and the summary
with a provisioned GPG key, regenerates static deltas and the
vocahq.flatpakrepo/index page, and pushes to VocaHQ/vocalinux-flatpak
served by GitHub Pages — the Homebrew-tap analogue, so installs
auto-update via flatpak update instead of per-release downloads.

The job is secret-gated like publish-aur (FLATPAK_GPG_PRIVATE_KEY +
FLATPAK_REPO_TOKEN) and skips itself while unconfigured. Prior repo
objects are pulled back in from gh-pages so clients get incremental
deltas. The manifest now declares branch: stable and the flatpakrepo
declares DefaultBranch=stable + RuntimeRepo for the Flathub GNOME
runtime.

Docs: new docs/FLATPAK_REMOTE.md (one-time setup, rotation,
verification); README, docs/INSTALL.md, packaging/flatpak/README.md
and the release notes now lead with the remote and keep release
bundles as the no-auto-update fallback. tests/test_release_integrity.py
pins the job's shape.

Closes #785

* fix(flatpak-remote): publish a .flatpakref and gate the remote to stable tags

- needs build-and-release so the remote cannot lead the GitHub Release
- stable tags only: prereleases must not reach `flatpak update`
- probe gh-pages before the empty-repo fallback: a transient checkout
  failure no longer discards remote history
- publish com.vocalinux.Vocalinux.flatpakref (RuntimeRepo lives there, not
  on .flatpakrepo) plus vocahq.gpg, so clean installs resolve the GNOME
  runtime and key rotation works via remote-modify --gpg-import
- gate the release-notes remote instructions on the publish actually
  running; the bundle path stays primary otherwise
- document rotation via remote-modify --gpg-import / remote-delete
  --force and the explicit Flathub setup step

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:45:49 -07:00
d29c884782 fix(settings): gray out Dictation Tone while sound effects are off (#877)
* fix(settings): gray out Dictation Tone while sound effects are off

The Enable Sound Effects switch mutes every dictation cue, so the tone
picker changed nothing while it was off. The row now goes insensitive
with the switch and follows the state loaded from config, matching the
keep-alive timeout and auto-pause sensitivity pattern.

Also bump the pre-commit isort pin 5.13.2 -> 8.0.1 to match uv.lock: the
two versions disagree on single-name import wrapping, so the old hook
collapsed imports the locked isort (and CI) then expanded back.

Fixes #849

* test(settings): cover tone-row sensitivity on settings load

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:43:56 -07:00
Jatin K MalikandGitHub 77def71354 fix(audio): stop passing explicit index when zero devices enumerate (#891)
* fix(audio): stop passing explicit index when zero devices enumerate

With zero audio devices, _resolve_valid_input_device returned the saved preferred_index, so _open_capture_stream opened input_device_index=<stale> — a device that cannot exist — and Pa_OpenStream aborted the whole process (SIGSEGV) before the clean IOError->ERROR path could run. Zero enumerated devices now returns None so PyAudio falls back to the system default and fails catchably; a negative count (enumeration failure) still passes the preferred index through.

* test(audio): cover capture path omitting device index

* test(audio): cover saved-device zero-enumeration capture path

* test(audio): annotate new resolver tests
2026-09-29 22:26:16 -07:00
a89966a744 feat(recognition): record while an idle model reloads (#851)
* feat(recognition): buffer audio during model reload

* fix(recognition): recover buffered reload failures

* test(recognition): cover buffered reload edge cases

* fix(recognition): narrow buffered reload errors

* fix(settings): wait for IDLE before restoring Test Dictation callbacks

Buffered reload's stop_recognition() returns while PROCESSING. Test
Dictation must keep its text callbacks until recognition reaches IDLE
so the test utterance cannot inject into the focused app.

* style: apply Black formatting to PR 851 files

* fix(settings): cancel buffered reload before Test Dictation timeout restore

On the ~3min idle-wait timeout, do not restore live injector callbacks while
a buffered reload worker may still deliver text. Cancel via
_cancel_reload_recording off the GTK thread, then restore; if cancel is
unavailable or fails, leave test callbacks installed and show an error.
Add return/param annotations and regression coverage.

* fix(settings): keep Test Dictation waiting if buffered cancel fails

After the idle-wait timeout, if cancel is unavailable or raises, keep
polling with test callbacks and _test_active instead of marking the test
done. Ending early left the test callback as the only text destination,
so later normal dictation could land in the hidden test buffer. Restore
live injectors only once recognition reaches IDLE.

* fix(settings): reset Test Dictation timeout-cancel flag per session

Clear _test_timeout_cancel_attempted when a test starts, when entering
the buffered idle-wait, and when restoring UI so a later Test Dictation
can still cancel after timeout. Narrow the cancel-worker except clause
to AttributeError/OSError/RuntimeError/TypeError so unrelated defects
are not treated as ordinary cancel failure.

* fix(recognition): signal capture completion when buffered capture dies

A dead microphone during a buffered model reload left should_record=false
with no stop_recognition handoff, so the reload worker blocked forever on
_capture_finished.wait(). Signal the event from the audio thread's failure
teardown paths instead.

Refs VocaHQ/vocalinux#851

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
2026-09-29 22:02:56 -07:00