* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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
* 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>
Rebased onto latest main (conflict-resolution squash for PR #568).
Co-authored-by: Alexander <ab@digital-benefits.de>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(settings): run local VocaGateway from Speech Engine (podman-first)
Add an optional Local VocaGateway panel next to Remote Server so Linux users
can start the pinned v0.1.0 compose project with podman (docker fallback),
watch Live/Pairable/Ready status, pair a phone without loopback QR URLs, and
preset remote_api to the gateway transcriptions endpoint. Stop preserves
volumes; Flatpak fails closed; tray offers Stop only when this session manages
the gateway. Default on-device engine stays whisper.cpp.
* fix(gateway-embed): drop nonexistent compose cpu profile
VocaGateway v0.1.0 exposes default service gateway plus native/cuda/vulkan
profiles, not cpu. Start and stop the default service without --profile, keep
the latest image pin rejection, and prefer save_config for the remote_api preset.
* fix(gateway-embed): keep compose --profile cpu per embed contract
v0.1.0 has no cpu profile entry, but Compose still starts the unprofiled
gateway service when --profile cpu is set, and keeps cuda/vulkan/native off.
That matches the frozen desktop embed contract and stays forward-compatible.
* fix(gateway-embed): harden embed path for settings and compose
Keep podman/docker probing off the GTK thread, reject link-local and
loopback public URLs for phone QR, sanitize compose .env image overrides,
and cap bootstrap token reads so a hostile file cannot blow memory.
* fix(gateway-embed): keep LAN toggle and phone QR in sync
When Allow LAN flips while compose is already managed, rewrite publish
host and PUBLIC_URL and recreate the gateway before Pairable comes back.
Also reject default docker/podman bridge addresses for phone QR.
* test(gateway-embed): pin bridge and LAN-gate pairing cases
Cover 172.17.0.2 / link-local not becoming Pairable, and the mid-run
LAN toggle path that must wait for compose republish before advertising
a phone URL.
* style(gateway-embed): black format embed modules
* fix(gateway-embed): reject short-form loopback and localhost.* for QR
Align Pairable/QR host checks with vocagateway desktop-embed policy so
127.1 / localhost.* never become phone-reachable display URLs.
* fix(gateway-embed): detach Settings status listener on destroy
Gateway poll callbacks were left registered after Settings closed, so
idle_add could touch destroyed GTK widgets while compose stayed up.
* fix(gateway-embed): pass host_env to bridge ip probe
_bridge_iface_ips shells out to host ip; without host_env the AST
gate fails and AppImage bundles can hand the wrong GLib to ip.
* fix(packaging): ship gateway_embed in the AppImage
The explicit setuptools packages list omitted the new package, so the
bundle booted from source trees but died with ModuleNotFoundError on
every distro boot test.
* style(packaging): black the gateway_embed package guard
* fix(settings): pack local VocaGateway into the Advanced island
The rebase onto main auto-merged textually onto the simple Speech Model
page. Keep the gateway card on advanced_box, next to Remote Server.
* fix(gateway-embed): treat missing Compose as unavailable
When podman or docker is present but Compose is not, stop advertising Run.
Return empty compose_args, show an install-Compose hint, and cover it with a unit test.
Also add module loggers on the new gateway_embed modules and annotate the
gateway Settings/tray callbacks Greptile flagged. just lock found no lockfile drift.
* fix(gateway-embed): keep Allow LAN bind matched to the switch
Snapshot the LAN flag for start/republish, record what compose actually
got, then republish again if the switch moved mid-flight so 0.0.0.0 never
lingers while Allow LAN reads off.
* fix(gateway-embed): detect leftover compose after Quit
Quit does not stop compose, so a LAN-published gateway can keep running.
Settings and the tray treated that as Stopped because managed_by_us is
only process memory. Probe compose after runtime detection, show Stop
when it is actually up, and let Stop work even if this session did not
start it.
* fix(gateway-embed): restore LAN switch from leftover compose
Quit leaves compose running, so the next launch now reads
VOCAGATEWAY_PUBLISH_HOST from the embed .env, adopts the project as
managed, and keeps Allow LAN honest so republish can close 0.0.0.0.
* fix(gateway-embed): restore .env if LAN republish fails
compose recreate can fail after write_env_file already pointed
VOCAGATEWAY_PUBLISH_HOST at loopback. Restore the previous .env so
orphan adopt still sees a live 0.0.0.0 bind, and snap Allow LAN back
to the running publish mode.
* style(gateway-embed): type helper args and black MagicMock
* refactor(types): move Readable protocol to common_types
AGENTS.md keeps Protocol interfaces in common_types. Pairing now imports
the shared Readable surface instead of a local _Readable helper.
* fix(gateway-embed): satisfy mypy on rebased embed code
* fix(audio): keep duck restore point when a set reports failure
* feat(ui): add floating glowing dictation overlay
Show a bottom-center non-focus overlay while recognition is LISTENING
or PROCESSING so users get a clear visual cue beyond the tray icon.
Optional GtkLayerShell on Wayland; plain always-on-top GTK fallback
elsewhere. Configurable via ui.show_overlay (default on) and Settings.
* fix(test): isolate dictation overlay tests from GTK pollution
Overlay unit tests no longer construct real GTK windows or import
tray_indicator, which was loading gi.repository early and breaking
later tests that mock GI (single-instance and suspend GLib checks).
* docs: add dictation overlay screenshots
Capture the floating glowing orb in listening (green) and processing
(amber) states for the PR and README screenshots section.
* test(ui): raise dictation overlay coverage for Codecov patch gate
Exercise GTK init, draw/animation, positioning, layer-shell fallbacks,
tray overlay wiring, settings toggle, and config edge cases so patch
coverage on the floating overlay clears the 80% Codecov target.
* test(tray): call overlay helpers via unbound methods
Avoid instance-level MagicMock shadowing of set_overlay_enabled under
some import/mock orderings (seen on CI Python 3.10).
* test(tray): inject explicit config mock for overlay helpers
Assign a MagicMock config_manager on the instance so assertions do not
depend on the constructor patch (which can leave a real ConfigManager
under some Python versions / import orders).
* refactor(ui): ponytail pass on dictation overlay
Drop the hand-drawn mic glyph, simplify the Cairo clear/pulse path,
reuse ConfigManager get_bool/set, tighten tray overlay helpers, and
delete string-grep structural tests that proved nothing.
* fix(ui): pin overlay to bottom strip and stop stealing focus
Use a full-width bottom strip (layer-shell left/right/bottom anchors, or
resize+move fallback) so the orb is bottom-center on Wayland. Map the
window once and toggle opacity instead of hide/show so compositors do not
re-activate it and yank focus out of the editor (which broke IBus inject).
* fix(appimage): seed GtkLayerShell typelib for dictation overlay
* fix(ui): address greptile findings on dictation overlay
* fix(appimage): keep layer-shell typelib optional for direct builds
* feat(audio): lower other audio while dictating
Opt-in setting lowers the default PipeWire or PulseAudio sink while the
microphone is open, then restores that exact level. It stays off by default.
* fix(test): keep pytest.approx working when numpy is mocked
Other test modules replace sys.modules["numpy"] with a MagicMock at import
and leave it there. pytest.approx then crashes on isinstance(..., numpy.bool_).
* fix(audio): restore every channel and do not duck if it cannot be undone
A failed restore-point write no longer lowers the sink. PulseAudio puts
each channel back, and a dead microphone restores playback instead of
waiting for an explicit stop. The recognition test patches the real
audio module when an older test left a mock behind.
* fix(test): put the audio-feedback mock back after the ducking test
The recognition test has to patch the real module, but later tests still
expect the mock that test_text_injector installs. Restore that mock when
the test ends, and annotate the new helpers.
* fix(test): annotate the playback-duck tests
AGENTS.md requires a type hint on every new function. These tests were the
ones still missing them.
* fix(audio): skip arming playback duck after recording already failed
* chore: retrigger review after rebase
* feat(text-injection): let config.json pin the injection backend (#476)
Autodetection has to infer whether IBus commits actually reach the
focused application, and it cannot verify that: commit_text() reports
success even when the compositor drops the text. When the inference is
wrong the failure is silent, and dictation appears to work in XWayland
windows while doing nothing in native ones.
VOCALINUX_FORCE_BACKEND already provides an escape hatch, but it is an
environment variable: it covers a single run, is undocumented outside
the test suite, and a user whose compositor is misdetected has to wrap
the launcher in a shell script to make the choice stick.
Add a persistent `text_injection.backend` setting alongside it, accepting
the same ibus/wtype/ydotool/auto values. The environment variable still
wins so a backend can be A/B-tested for one run without touching the
saved configuration, and the startup log now names which of the two
pinned the choice so a stale setting is diagnosable from --debug output.
Reads the file directly rather than through ConfigManager, keeping this
package independent of the UI layer, exactly as _should_copy_to_clipboard
already does. An unreadable or corrupt config falls back to autodetection
instead of blocking startup, and an unrecognised value is ignored with a
warning so a typo cannot silently pin the wrong backend.
Defaults to "auto", so existing installs behave exactly as before.
* fix(text-injection): tell an explicit VOCALINUX_FORCE_BACKEND=auto from unset
The variable is meant to override the saved text_injection.backend pin for a
single run, which is what makes the two paths A/B-testable without editing
config. Setting it to auto -- the value that means "autodetect this time" --
did not do that: the reader collapsed unset and an explicit auto into the same
"auto" return, so the caller read both as "nothing was forced" and fell through
to the saved pin. The pin won, and there was no way to ask for autodetection
for one run short of editing the config file the variable exists to avoid.
Read the variable as three states instead: unset, an explicit auto, or a
backend name. Only the unset state consults config, so an explicit auto now
short-circuits to autodetection.
An unrecognised value is deliberately treated as unset rather than as auto. A
typo means the user failed to override their preference, not that they asked to
discard it, so falling through to a valid saved pin is the kinder reading.
_forced_backend() keeps its signature and its collapsed return, so its existing
callers and tests are untouched; the new three-state reading lives alongside it
and is what the preference resolver consumes.
* feat(text-injection): accept an xdotool pin and warn when ibus overrides the denylist
Two gaps in the pinnable backend set.
xdotool was not accepted, even though on X11 it is the tool that actually does
the typing. Users were told to pin a *Wayland* backend to mean "not IBus", and
get xdotool as a side effect. Naming it directly is what they were reaching for.
On Wayland it needs no special handling: the value skips IBus like any other
non-ibus pin and falls through to the existing XWayland fallback, so no branch
is added to the selection chain.
The accepted values now live in one tuple, because four places listed them --
both readers and both "expected ..." messages -- and a fifth value would have
had to be added to all four. The two messages derive from the tuple, so they
cannot fall behind it.
Pinning ibus also silently bypasses the compositor denylist. That check exists
because a denylisted compositor makes IBus report a commit as delivered while
the text never arrives (#478, #485), so a pin is the one setting that can put a
user back into a failure with no symptom other than nothing happening. The pin
is still honoured -- an override that ignores you is worse than one that warns
-- but it now says so at warning level, names the compositor, and gives the
remedy. It stays quiet when the compositor genuinely bridges IBus, including a
denylisted one running the ibus-wayland bridge, and on X11 where the denylist
does not apply.
The check is evaluated once. The guard chain short-circuits on the pin, so a
pinned run never reaches the existing compositor branch, and this is its first
and only call -- worth knowing because the check can shell out to pgrep or
gdbus. A test asserts the call count so that stays true.
Documents xdotool in the value table and rewrites the X11 paragraph, which
described xdotool as the implicit result of pinning something else rather than
as a value you can name.
* fix(text-injection): say so when the pinned backend is not the one in use
A pin could be quietly ignored. Pinning a tool that is not installed fell
through to autodetection and typed with whatever it found, under a log line
that had already announced the pin was overriding autodetection. Pinning
xdotool on Wayland did nothing at all, since the selection chain reaches
ydotool and wtype first. Pinning ibus where IBus support is unavailable skipped
the whole IBus branch. In each case the user set a preference, saw no
complaint, and got something else.
These are one condition, not three: whatever ended up doing the typing is not
what was pinned. The check compares the pin against the backend read back from
final state, so a cause nobody has thought of yet is covered without another
branch. It runs at the end of construction rather than inside
_check_dependencies, which returns early on two paths and which the wtype probe
can still override afterwards. The reason clause is added only where a generic
check answers it -- the binary is absent, or IBus is unavailable -- and omitted
otherwise rather than guessing.
Reporting the source separately was letting the environment variable and the
config setting disagree. _check_dependencies resolved the backend, then
re-derived which source had won by asking again -- so the environment was
parsed twice per construction, and a malformed value was reported to the user
twice. Resolution now returns the backend and its source together, which fixes
the duplicate warning and means the source named in a log line is the one that
actually chose the backend. _backend_preference() keeps its signature and reads
the first element.
Two log lines also claimed VOCALINUX_FORCE_BACKEND regardless of where the pin
came from, so a config-set backend was reported as an environment variable the
user had never set. They now name the real source.
The new warning is a startup diagnostic, not a live invariant: a tool switched
later by _try_recover_from_fallback is not reported again. Noted in the
docstring so the boundary is deliberate.
* docs(user-guide): file the injection-backend section under Troubleshooting
The section sat under Customization, directly beneath GPU Acceleration --
where the paragraph immediately above it reads "To check which backend is
being used" about Vulkan and CUDA. The word means something else there, and
two adjacent sections using it for different things is a bad place to explain
either.
It also is not a customization option. It exists because a compositor can make
IBus report a commit as delivered while nothing arrives (#478, #485), and
someone hitting that reads Troubleshooting, not Customization. The section
already opens on the symptom, so it needs no rewriting to belong there.
Pure reorder: no wording changes, and the heading text is unchanged so any
existing link still resolves.
* test(text-injection): cover the pin paths that skip the selection chain
_check_dependencies had three uncovered branches, all reachable by pinning a
backend and none of them exercised: the two early returns that hand over to
IBus before a tool is chosen, and the daemonless-ydotool fallback a wtype pin
lands on when wtype is absent. The early returns matter most, since the pin
check runs at the end of construction precisely so those paths cannot skip
it, and nothing proved that.
Adds the ydotool counterpart to the missing-binary case, so a check keyed on
one tool name cannot pass by coincidence; an X11 test for the guide's claim
that any non-ibus pin turns the IBus path off, with a control asserting IBus
is constructed without a pin so the assertion is the pin's doing; and an
end-to-end explicit-auto case, which until now was only proven at the
resolver rather than through selection.
The pin log line said it was "overriding backend autodetection" before
anything had been checked. An unavailable pin falls through to autodetection,
so that line announced an outcome it could not know and then read as
contradicting the "was not applied" warning. It now states the request only.
_run_with_pin gains ydotoold_ready, defaulting to the previous behaviour so
existing callers are unchanged.
* fix(text-injection): report a config file the backend pin cannot be read from
A corrupt config.json disappeared into logger.debug. The setting is
hand-edited -- that is the workflow the user guide documents -- so a stray
comma is a likely way to reach this, and staying quiet left the user with
autodetection and the silent IBus miss the pin was set to avoid, with nothing
to tell that apart from the pin not working. Unknown values already warned;
an unusable file now does too, naming the file and the parse error.
The bare handler was also catching AttributeError by accident. Valid JSON
that is not an object -- a bare list, string, number or null -- reaches the
lookup and raises there rather than at parse time, and so does a
text_injection section that is not an object, where the top level is a
mapping and the section is not. Both are now shape checks rather than
exception handling, which is what lets the handler narrow to OSError and
ValueError without those five shapes escaping it.
Locating the file moves out of the try. It fixes the handler being unable to
name the path it failed on, and it is a deliberate boundary: config_dir()
resolving a path is not a failure this should paper over, so it is no longer
guarded here at all.
A missing config.json stays silent, which is the default install.
* test(text-injection): stop the ibus-pin tests depending on an installed ydotoold
The bridged-compositor and X11 cases asserted that a run logs no warnings at
all. That is broader than what they are about, and _run_ibus_pin patches
shutil.which to claim every tool exists without patching the probe that then
shells out to one, so the real _ensure_ydotoold ran and warned whenever
/usr/bin/ydotoold was absent. The tests passed on a machine that happens to
have it installed and failed on the CI runners that do not.
The assertions now name the warning they are about, and the helper patches
_ensure_ydotoold the way its sibling _run_with_pin already did, so nothing in
this class of test reads the host's installed packages.
* fix(text-injection): report pending IBus initialization state
* docs(text-injection): clarify backend pin behavior
* fix(config): preserve a hand-edited backend across unrelated saves
ConfigManager writes its full in-memory configuration on every save. A user who edits text_injection.backend in config.json while Vocalinux is running therefore loses that choice when an unrelated Settings or tray save writes the stale default back.
Remember only the backend value observed at load or after a successful save. If the in-memory value is unchanged, preserve the current value from disk; if it changed through ConfigManager, treat that as the intentional write. Missing and explicit auto both reset to auto.
The save aborts rather than overwriting a file whose JSON or text_injection shape cannot be read safely. This is deliberately per-key: no recursive configuration merge or injection-layer dependency is added.
* style(text-injection): remove redundant typing suppression
* fix(config): keep invalid UTF-8 config from blocking startup
* fix(config): preserve malformed backend sections on unrelated saves
* fix(config): retain text setting changes after malformed config
* test(text-injection): annotate backend pin test helpers
The transfer ending is not the dialog's work ending. The checksum still has
to run and the engine still has to load the file, seconds of it on a large
model, and until now that window showed a bar parked at 100% with the
downloader's "Complete!" under it and Cancel as the only button. It reads as
a finished download waiting to be confirmed, or as a hang, and the one
button on offer is the opposite of what the user wants to press.
- ModelDownloadDialog: a fraction of 1.0 no longer paints a finished-looking
bar. It bounces the bar, names the step, says the download is done but the
work is not, and greys out the Cancel button, which cannot interrupt
verification or a model load anyway. Only set_complete() says "Complete!"
and offers OK.
- whisper.cpp: report "Verifying model..." before hashing a file that can be
hundreds of megabytes, the same step the Vosk path has always reported, and
"Loading model..." before backend detection and the load. The tray's
download notification reads the same statuses.
- tests: add return annotations on the new finishing-feedback helpers and
tests (Greptile P2).
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
* fix(site): align Phone chip, Cost copy, and privacy with GA
Use the official Android beta / iOS TestFlight chip on #ecosystem.
Replace Free forever with Free and open source on privacy and open-source.
Disclose vocalinux.com Google Analytics and scope no-telemetry claims to the installed app.
Fixes#867
* fix(site): make privacy GA disclosure match the live gtag
Spell out page views, the client-ID cookie, and IP/device processing for
G-7NBBNJNNQ7. Align Data Collection with PRODUCT.md, drop the hidden
telemetry weasel, and correct config.json plus the voice-storage row.
* refactor(installer): split implementation into sourced modules
* test(installer): harden module path coverage
* fix(installer): qualify interactive privacy and engine claims
The guided installer overstated local-only privacy and ranked
whisper.cpp without evidence. Match PRODUCT.md: on-device local
engines, optional Remote API, and the ~33 language catalog plus
Auto-detect.
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
* feat(parakeet): add optional verified Orukeet model
* fix(parakeet): recover from invalid release manifests
* chore: mark Orukeet tip verified
Empty signed tip so the ready-bar Verified gate clears on contributor unsigned history.
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
* feat(language): follow the active keyboard layout while dictating
Adds a "Follow keyboard layout" switch to Settings -> Speech Model ->
Advanced. With it on, the active keyboard layout is re-read at the start
of every dictation and the matching language is used, so someone who
switches layouts to work in two languages gets the right one without
opening Settings.
#796 already seeded the language once on first run, but from the
*configured* primary layout, which never changes as the user switches
between them: a "fr,us" setup reports fr forever. detect_active_keyboard_
layout() reads GNOME's input sources instead. It takes mru-sources rather
than the current index, because that index goes stale -- observed still
reading 0 after a switch to the second layout -- which is why the IBus
injection path already trusts the MRU list (#497, #738).
An active source that is an IBus engine rather than an xkb layout is a
real answer that cannot be mapped, so the language is left to the engine's
own auto-detect. Borrowing the configured xkb layout there would pin
English for someone typing Japanese through mozc-jp.
The mode is a switch over the language picker rather than an entry inside
it, since it is a mode and not a language. While on, the picker is
disabled and shows what the layout resolves to; turning it off pins that.
A language change no longer re-initialises the engine when the loaded
model takes the language per utterance, which is the whole whisper
family, so following a layout costs a settings read rather than a model
reload. VOSK loads one model per language, Parakeet ignores the picker,
and an English-only model has no non-English weights, so Settings refuses
the mode for all three rather than offering a switch that does nothing.
Fixes#821
* fix(language): keep follow mode armed and map IBus engines
Swap English-only whisper.cpp weights to the multilingual sibling while
passing the layout sentinel into reconfigure, so follow mode stays armed.
Map common GNOME IBus engines onto catalogue languages instead of
leaving them unmapped.
* fix(language): stop IBus hyphen false-maps and gate the sibling swap
The last-token hyphen heuristic mapped mozc-us and foo-us to English.
Restrict it to *-jp/*-kr and name Mozc's extra engines as Japanese.
Only whisper.cpp may consult the whisper.cpp download catalog.
* fix(language): keep multilingual weights while following layout
The picker shows the active layout while follow is on, often en-us.
Deriving whisper.cpp .en from that display made Settings refuse the
mode as English-only, then the next auto-apply persisted a concrete
language and disarmed follow. Use LANGUAGE_FOLLOWS_LAYOUT for .en vs
multilingual derivation instead, including on the load path before
the switch is synced.
* fix(language): type follow helpers and log sibling-swap failures
* fix(test): stub follow-layout model rec so mocked psutil does not blow up
follow-mode English-only guard calls get_recommended_whispercpp_model, which
reads RAM through psutil. Full CI leaves a MagicMock in sys.modules["psutil"],
so ram_gb >= 16 raises TypeError. Patch the settings_dialog alias to return a
multilingual tiny rec so the assertion still holds without real RAM.
* fix(language): catch specific errors on layout lookup and sibling swap
* fix(language): swallow more sibling-swap reconfigure failures
ImportError, TypeError, AttributeError, and ChecksumError from the
multilingual swap must not abort dictation or disarm follow mode.
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
* fix(settings): speech model follow-ups from #834
Keep the Unused downloads expander in sync with search, recommend Faster
Whisper .en weights for English, parse nvidia-smi MiB VRAM, stop simple
mode from clobbering Faster Whisper/Parakeet, and ceil RAM so ~7.4 GiB
counts as 8.
* fix(settings): keep FW .en picker ids and test unused search
Represent Faster Whisper English catalog variants in the Settings size
combo so tray-saved small.en is not rejected and overwritten. Add
behavioral unused-downloads search coverage for match/nonmatch/clear.
* fix(shortcuts): record unmapped F19/F24 and XF86-aliased F13–F23
* fix(shortcuts): keep split-keyboard modifiers while recording
The recorder only looked at the keyboard that sent the function key, so a modifier held on the other half was dropped. A dead device also stayed in the watch list, which left F19/F24 uncapturable until Record was restarted.
Union held keys across every open keyboard before committing, and drop a device on hangup or read error while scanning for a replacement.
* fix(shortcuts): keep capture tests off real input devices
CI opened real /dev/input paths because the harness patched a stale evdev module, so io watches never attached. Patch the live module, and move the capture device protocol to common_types.
* fix(tests): patch the recorder's own GLib globals
test_settings_dialog reimports the dialog module and leaves the replacement in sys.modules. Recorder methods still close over the original dict, so the harness never recorded timeouts or io watches.
* fix(vosk): refuse to load a model that would exceed available memory
vosk.Model() allocates the whole model in one spike during loading,
including the RNNLM rescoring component some models ship, with no way
to check the requirement up front or load incrementally. Walk the
model directory, compare its on-disk size against psutil's available
memory with a 1.5x margin, and raise before calling into the native
loader instead of letting the kernel OOM killer take the process down
mid-load.
Fixes#676
* fix(vosk): match memory safety margin to the documented peak
greptile flagged the guard's own comment: loading can spike to ~2x the
model's on-disk size, but the margin only required 1.5x available memory,
so a load between those two multiples could still trigger the OOM this
guard exists to prevent.
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
Shared NAT hits the unauthenticated api.github.com quota and the
update check logged a WARNING that looked like a failure. On 403
or 429, look up the latest stable tag from the releases page
JSON, and stay quiet if that also fails. Nightly still refuses
to treat the stable latest tag as a nightly.
Fixes#845
Snap confinement denies /proc/bus/input/devices unless hardware-observe
is connected. Declare the plug, tell users to connect it next to
raw-input, and update the in-app hints.
Fixes#857
* fix(settings): drop Test Dictation scrolled-window stripe
GTK3 ScrolledWindow defaults to an IN etched shadow, which GNOME/Wayland
renders as a thin vertical stripe on the left of the Test Dictation
revealer even when the buffer is empty. Call set_shadow_type(NONE) to
match the other ScrolledWindows in this dialog.
Fixes#847
* Add missing test return annotation
* test(injection): pin the ydotoold probe so local runs match CI
* chore(pytest): filter the three known warnings out of the summary
* fix(pytest): stop hiding save_settings deprecation globally
Keep the ignore on the one test that still exercises the deprecated alias so the rest of the suite can surface new save_settings() calls.
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
The gsettings fallback for the bogus xkb:us::eng default only fired on
GNOME/Wayland; on GNOME/X11 the default is accepted and written back
into the X keymap, permanently switching the user off their real
layout (Hungarian, Brazilian ABNT2, etc).
_get_gnome_current_source() also had no fallback when mru-sources is
empty (a fresh session with a single input source never populates the
MRU list), so even with the guard fixed the recovery path still
returned None. It now falls back to the static sources list.
Fixes#699
* fix(release): attach the snap to GitHub Releases
v0.17.0 packed the snap, then Store review of uinput failed the job, so
the file never reached the tag. Build and attach first. A Store hold is
a warning, not a failed release. SHA256SUMS covers the .snap.
snap-backfill.yml rebuilds an existing tag, appends the snap to the
manifest, and attests only that file.
* fix(release): keep snap tags out of the shell and fail real Store errors
Pass the tag through the environment like verify-release.yml. Treat only
an allow-installation review as a successful Store publish. snap-backfill
runs verify_release.py after it rewrites SHA256SUMS.
* style(test): format snap-backfill shell-injection guard with black
The family table was going stale on every sibling release. Keep project
links only. Also say honestly that the 0.17.0 snap is still waiting on
Store review of the uinput plug.
Minor on the stable line. Faster Whisper and Parakeet extras, Speech Model simple setup, Snap uinput, Flatpak bundles on the tag, and QA fixes from the release branch.
Fixes tracked in the PR. Post-release follow-ups: #834.
* fix(deps): export and cap every engine extra
* fix(deps): docstring main and tighten export pairing tests
Give check_exports.main the required public docstring, annotate the
dependency export test helpers, and require --extra to sit with its -o
on the same just lock line (whisper pip-compile stays special-cased).
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
* fix(audio): open 3-8ch HDA mics at native layout (#813)
HDA analog capture often reports 4 channels. Opening below that native
count can abort PortAudio after read with free(): corrupted unsorted
chunks (same class of bug as #666/#673).
Try the reported channel count first when it is between 3 and 8, then
2ch and 1ch. Pulse devices that advertise 32/128 channels still open
stereo-first. Downmix N channels to mono so engines keep seeing 1ch.
Virtual-device filtering for pipewire/default (#629) is unchanged.
* fix(audio): downmix HDA capture from first channel pair
Averaging all N HDA channels halves speech when only the front pair is live.
Keep stereo mean-of-both, and for 3+ channels average channels 0 and 1.
* fix(audio): pick loudest channel for N-ch downmix
First-pair-only downmix silenced mics on ch2+. For 3+ channels, select
the loudest channel by per-buffer mean-square so speech stays at full
level whether it lands on the front or rear pair. Stereo still averages
both channels.
* fix(audio): sticky N-ch downmix channel for open stream
Pin the loudest-channel pick for the life of a capture stream so a
noise burst on another input cannot switch the mic mid-utterance.
* fix(audio): gate sticky downmix lock on speech energy
Don't pin the N-ch sticky channel from a silent/ambient first buffer;
only lock once the loudest channel clears a documented mean-square floor.
* style: isort sticky-lock import in device config tests
* fix(ui): follow OS dark/light on native GTK installs
AppImage already maps portal/gsettings color-scheme into GTK_THEME.
AUR and install.sh use system GTK3, which ignores prefer-dark. Set
gtk-application-prefer-dark-theme from the same signals, and skip when
GTK_THEME is already set so AppImage and user overrides stay in charge.
Fixes#816
* docs: portal no-preference still checks gsettings
The helper already falls back after a portal 0 or a failed Read.
The module doc said that left GTK defaults, which is only true
after gsettings also comes up empty.
The builder action restore-keys is `flatpak-builder-${arch}`. Our key
was `flatpak-builder-${hash}`, which the action stores as
`flatpak-builder-${hash}-x86_64`. That does not start with
`flatpak-builder-x86_64`, so a packaging hash change (a lock refresh,
or merging one) discarded the whole .flatpak-builder cache and
recompiled numpy and pywhispercpp from source, ~18 min instead of ~4.
Put ${{ matrix.arch }} in the key first so the fallback matches. Then
flatpak-builder skips unchanged modules and only rebuilds what the
hash change actually touched.
PRs no longer watch src/**. python-test already covers those, and the
expensive compile does not. push to main still does, so a sandbox
install break fails the default branch.
* fix(text-injection): route XWayland xdotool fallback through clipboard paste
xdotool type simulates keypresses against the active X keyboard layout, so
a character the layout doesn't map comes out as the wrong glyph or garbled
(#657). The native ydotool path already avoids this by pasting via the
clipboard instead of typing (_inject_with_wayland_tool); the XWayland
xdotool fallback (_inject_with_xdotool) never got the same treatment.
Prefer clipboard paste on that fallback too when ydotool is installed,
falling back to raw xdotool type only when no paste tool is available or
the paste itself fails.
Fixes#657
* fix(text-injection): X11 clipboard paste for the XWayland fallback
jatinkrmalik's review on #680 pointed out the AppImage build does not
bundle ydotool, so the prior fix's ydotool clipboard paste never ran on
the actual reported setup; it fell through to the same layout-dependent
xdotool type as before. It also flagged a real paste-target mismatch:
an XWayland window reads the X11 CLIPBOARD selection, not the Wayland
clipboard wl-copy/ydotool write to.
The XWayland fallback now copies via xclip/xsel and pastes with
xdotool key --clearmodifiers ctrl+v when either is installed, waiting
for held modifiers to release first (the same wait already used by the
native ydotool path). ydotool clipboard paste stays as a second
fallback when xclip/xsel are absent; xdotool type is the last resort.
New regression test drives inject_text() end-to-end with no ydotool on
PATH, reproducing the reported AppImage/GNOME log rather than calling
the fallback method directly, so it also covers the recovery check
that could otherwise skip this path.
* fix(text-injection): thread x11_only through clipboard read/clear so restore matches the paste backend
jatinkrmalik's rebase pulled in the layout-aware paste work from #788,
and greptile's review on the merged branch caught a real gap in this
PR's own XWayland path: _copy_to_clipboard already takes x11_only to
write the X11 CLIPBOARD via xclip/xsel (since that is what an xdotool
paste actually reads), but _read_clipboard and _clear_clipboard never
gained the same parameter. On a Wayland host, _read_clipboard prefers
wl-paste, so the "previous clipboard" saved before an XWayland paste
could be read from an unrelated Wayland clipboard, and restoring it
back through _copy_to_clipboard with no x11_only wrote to that same
wrong backend, leaving the X11 CLIPBOARD stuck on the dictated text.
Threads x11_only through both helpers and every restore call site
(the immediate-failure path and the delayed _restore closure), so an
XWayland paste captures, clears, and restores the same X11 CLIPBOARD
selection it wrote to.
* fix(text-injection): annotate clipboard helpers and XWayland paste tests
Greptile P2 on #680: give `_get_clipboard_tools` a return annotation and
mark the new XWayland clipboard-paste regression tests `-> None`.
* fix(text-injection): derive XWayland paste from environment
Drop the ydotool/wl-copy second fallback on WAYLAND_XDOTOOL (wrong
clipboard, and rc=0 is not a paste) and the x11_only flag stack. Paste
picks xclip/xsel + xdotool from environment; user-facing copy stays
on the session clipboard.
* fix(text-injection): always pass host_env() to the paste chord
The AST guard only recognizes a direct host_env() call. The ternary
around it looked like we were handing the AppImage library path to
xdotool.
* test(text-injection): give XWayland injector stubs clipboard restore state
_inject_with_xdotool now pastes first, and that path reads
_clipboard_restore_target. The __new__ helper in test_text_injector_ext
never set it.
* fix(text-injection): skip clipboard restore on a stale paste failure
If a newer paste has already copied its text, the older paste's failure
handler must not write its saved clipboard back. Success restores already
honored the generation counter; the failure path did not.
* fix(text-injection): keep original clipboard across a stale paste failure
Publish the restore target as soon as copy succeeds (first writer wins)
so an overlapping paste inherits it. If the older paste then fails, the
newer restore still writes the pre-first clipboard, not intermediate
dictated text.
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
* fix(snap): inject into native Wayland apps via ydotool
Edge rev 7 only ships wtype and xdotool. GNOME rejects the virtual
keyboard protocol, so the snap falls back to xdotool and types only
into XWayland windows. Native GTK/Qt apps get nothing.
Stage ydotool, request the uinput plug, and prefer ydotool when wtype
is rejected. Distro ydotool 0.1.x still needs a writable /dev/uinput;
log `snap connect vocalinux:uinput` when the Snap cannot open it.
* docs(snap): v0.16.2 edge has no uinput plug
The store snap (0.16.2, latest/edge rev 7) does not declare uinput.
`sudo snap connect vocalinux:uinput` fails with "has no plug named
uinput" and dictation only reaches XWayland apps. Spell that out in
README, INSTALL, UPDATE, the website changelog, and the store copy so
people do not run the 0.17 connect command against rev 7.
* docs(snap): drop the unconfirmed 0.17 version from uinput notes
Greptile P2: the tree is still 0.16.2 and the release process does not
name the next tag. Tell people to refresh until `snap info` lists the
uinput plug, instead of waiting on a version we have not cut.
core24 desktop-launch rebuilds gdk-pixbuf-loaders.cache from the first
LD_LIBRARY_PATH directory that ends in the arch triplet. Stage-packages
drop a gdk-pixbuf tree without libpixbufloader_svg.so at
$SNAP/usr/lib/<triplet>, ahead of gnome-platform, so About-page SVGs
fail with "Image type svg is not supported".
Fixes the same core24 GTK3 gap documented at:
https://forum.snapcraft.io/t/missing-svg-support-for-gtk3-in-core24/48851
* fix(packaging): derive the Flatpak and AUR dependency sets from the lock
* test(packaging): add return annotations on new packaging tests
Greptile asked for -> None on the new tests, matching AGENTS.md.
* test(packaging): type parametrized Flatpak source arguments
Greptile asked for type hints on the source parameter, matching AGENTS.md.
---------
Co-authored-by: jatinkrmalik <jatinkrmalik@gmail.com>
* feat(shortcuts): allow bare function keys for push-to-talk
Accept F1–F24 shortcuts without a modifier so dictation can use a
dedicated key (e.g. F10) instead of Shift/Ctrl/Alt combos that fire
while typing. Settings shortcut capture records bare function keys too.
* fix(shortcuts): evdev availability for bare function keys
Check EV_KEY capabilities for bare F1-F24 shortcuts instead of an empty
modifier name. Add availability regression tests and type hints on new tests.
* refactor(shortcuts): fold bare F-keys into ShortcutSpec
Teach primary_modifier to return the bare key so parse_shortcut and the
backend stop special-casing empty modifiers. Capture now asks the parser
instead of re-encoding the F-key allow-list, and evdev shares one
capability probe for modifiers and main keys.
---------
Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
Homepage family strip still pinned the old Win tag. Match PRODUCT.md and
the published VocaWin release. Keep the unsigned / SmartScreen line.
Fixes#811.
* fix(tray): refresh PTT icon when AppIndicator reuses a path
Right-Alt push-to-talk turned the tray red only on the first hold.
Later holds dictated fine but left the icon gray until restart.
StatusNotifier hosts often skip a redraw when IconName returns to an
absolute path already used in the session. Nudge IconThemePath before
set_icon_full so gray-red-gray-red actually turns red again.
* test(tray): assert alternating IconThemePath nudge args
Greptile P2: the PTT redraw regression only checked set_icon_theme_path
call count, so identical paths would still pass. Assert the alternating
ICON_DIR / ICON_DIR+os.sep arguments that drive the host reload.