python-voiceio 0.8.0__tar.gz → 0.8.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_voiceio-0.8.0/python_voiceio.egg-info → python_voiceio-0.8.1}/PKG-INFO +1 -1
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/pyproject.toml +1 -1
- {python_voiceio-0.8.0 → python_voiceio-0.8.1/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/python_voiceio.egg-info/SOURCES.txt +1 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_app_wiring.py +27 -6
- python_voiceio-0.8.1/tests/test_ibus_ping.py +50 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_robustness.py +29 -0
- python_voiceio-0.8.1/voiceio/__init__.py +1 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/app.py +53 -32
- python_voiceio-0.8.1/voiceio/ibus/__init__.py +33 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/ibus/engine.py +11 -1
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tray/_indicator.py +63 -12
- python_voiceio-0.8.0/voiceio/__init__.py +0 -1
- python_voiceio-0.8.0/voiceio/ibus/__init__.py +0 -8
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/LICENSE +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/README.md +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/setup.cfg +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_audit.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_cli.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_commands.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_config.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_corrections.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_fallback.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_health.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_hints.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_history.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_llm.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_numbers.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_platform.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_postcorrect.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_prompt.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_retention.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_streaming.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_tts.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_vad.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_wizard.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/__main__.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/audit.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/backends.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/cli.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/commands.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/config.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/consent.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/corrections.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/demo.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/feedback.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/health.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hints.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/history.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/llm.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/numbers.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/platform.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/postcorrect.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/prompt.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/recorder.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/retention.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/service.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/streaming.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/vad.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/wizard.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/worker.py +0 -0
|
@@ -274,14 +274,16 @@ class TestEngineLifecycle:
|
|
|
274
274
|
vio._engine_proc = proc
|
|
275
275
|
return vio, proc
|
|
276
276
|
|
|
277
|
-
def
|
|
278
|
-
"""ibus-daemon reaping a dormant engine must
|
|
279
|
-
activate/dormant dance
|
|
277
|
+
def test_idle_engine_death_respawns_without_activation(self):
|
|
278
|
+
"""ibus-daemon reaping a dormant engine must respawn it registered
|
|
279
|
+
but NEVER activated — the activate/dormant dance grabbed the input
|
|
280
|
+
source every cycle, and leaving the engine dead let ibus-daemon
|
|
281
|
+
exec-spawn a socket-stealing duplicate on accidental activation."""
|
|
280
282
|
vio, proc = self._vio_ibus(proc_poll=-15)
|
|
281
283
|
with patch.object(vio, "_ensure_ibus_engine") as ensure, \
|
|
282
284
|
patch.object(vio, "_health_typer_upkeep"):
|
|
283
285
|
vio._check_health()
|
|
284
|
-
ensure.
|
|
286
|
+
ensure.assert_called_once_with(activate=False)
|
|
285
287
|
assert vio._engine_proc is None
|
|
286
288
|
|
|
287
289
|
def test_mid_recording_engine_death_recovered(self):
|
|
@@ -290,7 +292,7 @@ class TestEngineLifecycle:
|
|
|
290
292
|
vio._state = _State.RECORDING
|
|
291
293
|
with patch.object(vio, "_ensure_ibus_engine") as ensure:
|
|
292
294
|
vio._check_health()
|
|
293
|
-
ensure.
|
|
295
|
+
ensure.assert_called_once_with() # default activate=True
|
|
294
296
|
|
|
295
297
|
def test_single_missed_ping_tolerated(self):
|
|
296
298
|
"""One missed ping on a loaded system is not a zombie."""
|
|
@@ -310,9 +312,28 @@ class TestEngineLifecycle:
|
|
|
310
312
|
patch.object(vio, "_health_typer_upkeep"):
|
|
311
313
|
vio._check_health()
|
|
312
314
|
vio._check_health()
|
|
313
|
-
ensure.
|
|
315
|
+
ensure.assert_called_once_with(activate=False)
|
|
314
316
|
proc.kill.assert_called_once()
|
|
315
317
|
|
|
318
|
+
def test_dormant_spawn_never_touches_input_source(self):
|
|
319
|
+
"""activate=False must not run `ibus engine voiceio` or gsettings —
|
|
320
|
+
idle spawns that claimed the source were the focus churn that wiped
|
|
321
|
+
visible preedits and stole the keyboard mid-typing."""
|
|
322
|
+
vio, _, _ = _make_vio()
|
|
323
|
+
with patch("voiceio.app.subprocess.run") as run, \
|
|
324
|
+
patch("voiceio.app.subprocess.Popen") as popen, \
|
|
325
|
+
patch.object(vio, "_kill_stale_engine"), \
|
|
326
|
+
patch("voiceio.ibus.SOCKET_PATH") as sock_path, \
|
|
327
|
+
patch("voiceio.ibus.READY_PATH"):
|
|
328
|
+
sock_path.exists.return_value = True
|
|
329
|
+
run.return_value = MagicMock(returncode=0, stdout="xkb:us::eng\n")
|
|
330
|
+
vio._spawn_ibus_engine(activate=False)
|
|
331
|
+
assert vio._engine_proc is not None # engine process was spawned
|
|
332
|
+
run_cmds = [c[0][0] for c in run.call_args_list]
|
|
333
|
+
popen_cmds = [c[0][0] for c in popen.call_args_list if c[0]]
|
|
334
|
+
assert ["ibus", "engine", "voiceio"] not in run_cmds + popen_cmds
|
|
335
|
+
assert not any("gsettings" in cmd[0] for cmd in run_cmds + popen_cmds)
|
|
336
|
+
|
|
316
337
|
def test_successful_ping_resets_counter(self):
|
|
317
338
|
vio, proc = self._vio_ibus(proc_poll=None)
|
|
318
339
|
vio._engine_ping_fails = 1
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Tests for the shared engine ping used by the daemon health watchdog and
|
|
2
|
+
the engine's own refuse-to-double-run guard (a duplicate engine binding the
|
|
3
|
+
socket path silently starves the healthy instance)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
import voiceio.ibus as ibus_mod
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_no_socket_returns_false(tmp_path, monkeypatch):
|
|
13
|
+
monkeypatch.setattr(ibus_mod, "SOCKET_PATH", tmp_path / "nope.sock")
|
|
14
|
+
assert ibus_mod.ping_engine(timeout=0.2) is False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_healthy_peer_answers_pong(tmp_path, monkeypatch):
|
|
18
|
+
sock_path = tmp_path / "engine.sock"
|
|
19
|
+
server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
20
|
+
server.bind(str(sock_path))
|
|
21
|
+
server.settimeout(2.0)
|
|
22
|
+
|
|
23
|
+
def _serve():
|
|
24
|
+
try:
|
|
25
|
+
data, addr = server.recvfrom(64)
|
|
26
|
+
if data == b"ping" and addr:
|
|
27
|
+
server.sendto(b"pong", addr)
|
|
28
|
+
except OSError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
t = threading.Thread(target=_serve, daemon=True)
|
|
32
|
+
t.start()
|
|
33
|
+
monkeypatch.setattr(ibus_mod, "SOCKET_PATH", sock_path)
|
|
34
|
+
try:
|
|
35
|
+
assert ibus_mod.ping_engine(timeout=1.0) is True
|
|
36
|
+
finally:
|
|
37
|
+
t.join(timeout=2.0)
|
|
38
|
+
server.close()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_unresponsive_socket_returns_false(tmp_path, monkeypatch):
|
|
42
|
+
"""A bound-but-silent socket (starved listener) must read as dead."""
|
|
43
|
+
sock_path = tmp_path / "engine.sock"
|
|
44
|
+
server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
45
|
+
server.bind(str(sock_path))
|
|
46
|
+
monkeypatch.setattr(ibus_mod, "SOCKET_PATH", sock_path)
|
|
47
|
+
try:
|
|
48
|
+
assert ibus_mod.ping_engine(timeout=0.2) is False
|
|
49
|
+
finally:
|
|
50
|
+
server.close()
|
|
@@ -578,6 +578,7 @@ class TestBootRaceAndReProbe:
|
|
|
578
578
|
|
|
579
579
|
with patch.dict("os.environ", {
|
|
580
580
|
"DISPLAY": ":0",
|
|
581
|
+
"XAUTHORITY": "/run/user/1000/.mutter-Xwaylandauth.TEST",
|
|
581
582
|
"WAYLAND_DISPLAY": "wayland-0",
|
|
582
583
|
"XDG_SESSION_TYPE": "wayland",
|
|
583
584
|
"XDG_CURRENT_DESKTOP": "GNOME",
|
|
@@ -592,6 +593,34 @@ class TestBootRaceAndReProbe:
|
|
|
592
593
|
|
|
593
594
|
app_mod._graphical_env_complete = False
|
|
594
595
|
|
|
596
|
+
def test_import_graphical_env_pulls_xauthority(self):
|
|
597
|
+
"""Without the X auth cookie, xclip fails silently inside the daemon
|
|
598
|
+
and clipboard copies fall back to focus-stealing wl-copy — whose
|
|
599
|
+
focus-out discards any visible IBus preedit (the flashing preview)."""
|
|
600
|
+
import os
|
|
601
|
+
|
|
602
|
+
from voiceio import app as app_mod
|
|
603
|
+
from voiceio.app import _import_graphical_env
|
|
604
|
+
|
|
605
|
+
app_mod._graphical_env_complete = False
|
|
606
|
+
env = {
|
|
607
|
+
"DISPLAY": ":0",
|
|
608
|
+
"WAYLAND_DISPLAY": "wayland-0",
|
|
609
|
+
"XDG_SESSION_TYPE": "wayland",
|
|
610
|
+
"XDG_CURRENT_DESKTOP": "GNOME",
|
|
611
|
+
"XDG_SESSION_DESKTOP": "gnome",
|
|
612
|
+
}
|
|
613
|
+
with patch.dict("os.environ", env, clear=True):
|
|
614
|
+
with patch(
|
|
615
|
+
"voiceio.app.subprocess.check_output",
|
|
616
|
+
return_value="XAUTHORITY=/run/user/1000/.mutter-Xwaylandauth.TEST\n",
|
|
617
|
+
):
|
|
618
|
+
_import_graphical_env()
|
|
619
|
+
assert os.environ["XAUTHORITY"] == "/run/user/1000/.mutter-Xwaylandauth.TEST"
|
|
620
|
+
assert app_mod._graphical_env_complete is True
|
|
621
|
+
|
|
622
|
+
app_mod._graphical_env_complete = False
|
|
623
|
+
|
|
595
624
|
def test_try_upgrade_typer_upgrades_from_clipboard_to_ibus(self):
|
|
596
625
|
"""When chain has ibus first and current is clipboard, upgrade."""
|
|
597
626
|
vio, _, _ = _make_vio()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.1"
|
|
@@ -34,6 +34,10 @@ _DEBOUNCE_SECS = 0.8
|
|
|
34
34
|
# IBus input source configuration.
|
|
35
35
|
_GRAPHICAL_ENV_VARS = (
|
|
36
36
|
"DISPLAY",
|
|
37
|
+
# Without the X auth cookie, X11 tools (xclip via the XWayland bridge)
|
|
38
|
+
# fail silently inside the daemon and clipboard copies fall back to
|
|
39
|
+
# wl-copy — whose focus steal discards any visible IBus preedit.
|
|
40
|
+
"XAUTHORITY",
|
|
37
41
|
"WAYLAND_DISPLAY",
|
|
38
42
|
"XDG_SESSION_TYPE",
|
|
39
43
|
"XDG_CURRENT_DESKTOP",
|
|
@@ -893,7 +897,8 @@ class VoiceIO:
|
|
|
893
897
|
return False
|
|
894
898
|
if new_typer.name == "ibus" and self._engine_proc is None:
|
|
895
899
|
try:
|
|
896
|
-
|
|
900
|
+
# subprocess ops, outside the lock; IDLE → never activate
|
|
901
|
+
self._ensure_ibus_engine(activate=False)
|
|
897
902
|
except Exception:
|
|
898
903
|
log.exception("Upgrade (%s): failed to start IBus engine", reason)
|
|
899
904
|
return True
|
|
@@ -919,8 +924,12 @@ class VoiceIO:
|
|
|
919
924
|
except Exception:
|
|
920
925
|
log.exception("IBus engine respawn at record start failed")
|
|
921
926
|
|
|
922
|
-
def _ensure_ibus_engine(self) -> None:
|
|
923
|
-
"""Start the VoiceIO IBus engine
|
|
927
|
+
def _ensure_ibus_engine(self, activate: bool = True) -> None:
|
|
928
|
+
"""Start the VoiceIO IBus engine, optionally pre-creating its instance.
|
|
929
|
+
|
|
930
|
+
``activate=False`` spawns and registers the engine process without
|
|
931
|
+
ever touching the input source — the only safe mode while IDLE.
|
|
932
|
+
Claiming the source (``activate=True``) is reserved for record start.
|
|
924
933
|
|
|
925
934
|
Single-flight: a concurrent spawn (health loop vs record start)
|
|
926
935
|
is skipped rather than queued — two dances would fight over the
|
|
@@ -930,11 +939,11 @@ class VoiceIO:
|
|
|
930
939
|
log.debug("IBus engine spawn already in progress, skipping")
|
|
931
940
|
return
|
|
932
941
|
try:
|
|
933
|
-
self._spawn_ibus_engine()
|
|
942
|
+
self._spawn_ibus_engine(activate)
|
|
934
943
|
finally:
|
|
935
944
|
self._engine_spawn_lock.release()
|
|
936
945
|
|
|
937
|
-
def _spawn_ibus_engine(self) -> None:
|
|
946
|
+
def _spawn_ibus_engine(self, activate: bool = True) -> None:
|
|
938
947
|
from voiceio.ibus import READY_PATH, SOCKET_PATH
|
|
939
948
|
from voiceio.typers.ibus import LAUNCHER_PATH, _ibus_env
|
|
940
949
|
|
|
@@ -984,6 +993,16 @@ class VoiceIO:
|
|
|
984
993
|
log.warning("IBus engine started but socket not found, commands may fail")
|
|
985
994
|
return
|
|
986
995
|
|
|
996
|
+
if not activate:
|
|
997
|
+
# Registered-but-dormant mode (health loop, daemon start): the
|
|
998
|
+
# factory now owns the component name, so ibus-daemon won't
|
|
999
|
+
# exec-spawn a duplicate engine, and an accidental switch onto
|
|
1000
|
+
# the voiceio source lands on a live pass-through engine. The
|
|
1001
|
+
# engine INSTANCE is created on first activation (record start,
|
|
1002
|
+
# or GNOME switching sources) — no input-source claim needed.
|
|
1003
|
+
log.info("VoiceIO IBus engine ready (registered, dormant)")
|
|
1004
|
+
return
|
|
1005
|
+
|
|
987
1006
|
# Remember the source the user is on now so we can return the engine
|
|
988
1007
|
# to dormancy without hardcoding index 0 (which may not be the user's
|
|
989
1008
|
# keyboard). Phase 2 briefly activates voiceio to spawn the instance.
|
|
@@ -1025,22 +1044,12 @@ class VoiceIO:
|
|
|
1025
1044
|
def _ping_ibus_engine(self) -> bool:
|
|
1026
1045
|
"""Check if the IBus engine's socket listener is alive.
|
|
1027
1046
|
|
|
1028
|
-
Returns True if the engine responds to a ping within 1 second.
|
|
1029
1047
|
A False result means the engine process is alive but its socket
|
|
1030
|
-
listener
|
|
1048
|
+
listener is dead or starved (zombie engine) — e.g. a duplicate
|
|
1049
|
+
engine process stole the socket path.
|
|
1031
1050
|
"""
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
try:
|
|
1035
|
-
sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_DGRAM)
|
|
1036
|
-
sock.settimeout(2.0)
|
|
1037
|
-
sock.bind("")
|
|
1038
|
-
sock.sendto(b"ping", str(SOCKET_PATH))
|
|
1039
|
-
data, _ = sock.recvfrom(64)
|
|
1040
|
-
sock.close()
|
|
1041
|
-
return data == b"pong"
|
|
1042
|
-
except (OSError, _socket.timeout):
|
|
1043
|
-
return False
|
|
1051
|
+
from voiceio.ibus import ping_engine
|
|
1052
|
+
return ping_engine(timeout=2.0)
|
|
1044
1053
|
|
|
1045
1054
|
def _detect_input_source_hijack(self) -> None:
|
|
1046
1055
|
"""If the user switched input source away from voiceio mid-recording,
|
|
@@ -1084,7 +1093,7 @@ class VoiceIO:
|
|
|
1084
1093
|
try:
|
|
1085
1094
|
typer = typer_chain.select(self.platform, self.cfg.output.method)
|
|
1086
1095
|
if typer.name == "ibus":
|
|
1087
|
-
self._ensure_ibus_engine()
|
|
1096
|
+
self._ensure_ibus_engine(activate=False)
|
|
1088
1097
|
return typer
|
|
1089
1098
|
except RuntimeError:
|
|
1090
1099
|
pass
|
|
@@ -1388,20 +1397,30 @@ class VoiceIO:
|
|
|
1388
1397
|
|
|
1389
1398
|
# Check IBus engine liveness. A dormant engine dying is NORMAL:
|
|
1390
1399
|
# ibus-daemon reaps deactivated engine processes (SIGTERM ~10s after
|
|
1391
|
-
# the input source switches away).
|
|
1392
|
-
#
|
|
1393
|
-
#
|
|
1394
|
-
#
|
|
1400
|
+
# the input source switches away). Respawn it REGISTERED BUT NEVER
|
|
1401
|
+
# ACTIVATED (activate=False): while our factory owns the component
|
|
1402
|
+
# name, ibus-daemon won't exec-spawn a duplicate engine (a duplicate
|
|
1403
|
+
# rebinds the command socket and silently starves the healthy one),
|
|
1404
|
+
# and an accidental switch onto the voiceio source (Super+Space,
|
|
1405
|
+
# GNOME's per-window source memory) lands on a live pass-through
|
|
1406
|
+
# engine instead of a seconds-long cold spawn that eats keystrokes.
|
|
1407
|
+
# Activation — claiming the input source — happens ONLY at record
|
|
1408
|
+
# start: idle activation was the focus churn that wiped visible
|
|
1409
|
+
# preedits and stole the source mid-typing.
|
|
1395
1410
|
if self._typer.name == "ibus" and self._engine_proc is not None:
|
|
1396
1411
|
if self._engine_proc.poll() is not None:
|
|
1397
1412
|
rc = self._engine_proc.returncode
|
|
1398
1413
|
self._engine_proc = None
|
|
1399
1414
|
self._engine_ping_fails = 0
|
|
1400
1415
|
if self._state == _State.IDLE:
|
|
1401
|
-
log.
|
|
1402
|
-
"IBus engine exited while dormant (rc=%s) —
|
|
1403
|
-
"
|
|
1416
|
+
log.info(
|
|
1417
|
+
"IBus engine exited while dormant (rc=%s) — "
|
|
1418
|
+
"respawning registered, without activation", rc,
|
|
1404
1419
|
)
|
|
1420
|
+
try:
|
|
1421
|
+
self._ensure_ibus_engine(activate=False)
|
|
1422
|
+
except Exception:
|
|
1423
|
+
log.exception("IBus engine respawn failed")
|
|
1405
1424
|
else:
|
|
1406
1425
|
log.warning("IBus engine died mid-recording (rc=%s), restarting", rc)
|
|
1407
1426
|
try:
|
|
@@ -1412,9 +1431,9 @@ class VoiceIO:
|
|
|
1412
1431
|
elif self._state == _State.IDLE:
|
|
1413
1432
|
# Engine process alive — check if its socket listener is
|
|
1414
1433
|
# actually responding. A zombie engine (process alive but
|
|
1415
|
-
#
|
|
1416
|
-
# preedit and commit messages. One missed ping on
|
|
1417
|
-
# system is not a zombie — require two consecutive
|
|
1434
|
+
# socket stolen by a duplicate / listener dead) silently
|
|
1435
|
+
# drops all preedit and commit messages. One missed ping on
|
|
1436
|
+
# a loaded system is not a zombie — require two consecutive.
|
|
1418
1437
|
if not self._ping_ibus_engine():
|
|
1419
1438
|
self._engine_ping_fails += 1
|
|
1420
1439
|
if self._engine_ping_fails < 2:
|
|
@@ -1432,7 +1451,7 @@ class VoiceIO:
|
|
|
1432
1451
|
pass
|
|
1433
1452
|
self._engine_proc = None
|
|
1434
1453
|
try:
|
|
1435
|
-
self._ensure_ibus_engine()
|
|
1454
|
+
self._ensure_ibus_engine(activate=False)
|
|
1436
1455
|
log.info("IBus engine recovered (was zombie)")
|
|
1437
1456
|
except Exception:
|
|
1438
1457
|
log.exception("IBus engine recovery failed")
|
|
@@ -1510,7 +1529,9 @@ class VoiceIO:
|
|
|
1510
1529
|
self._typer.ensure_installed()
|
|
1511
1530
|
except Exception:
|
|
1512
1531
|
log.exception("IBus ensure_installed failed (continuing)")
|
|
1513
|
-
|
|
1532
|
+
# Register the engine but never claim the input source at boot —
|
|
1533
|
+
# the instance is created on first activation (record start).
|
|
1534
|
+
self._ensure_ibus_engine(activate=False)
|
|
1514
1535
|
else:
|
|
1515
1536
|
# IBus daemon may not be ready at startup (race with graphical
|
|
1516
1537
|
# session). If IBus is in the preferred chain but wasn't selected,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""IBus input method engine for VoiceIO."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SOCKET_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.sock"
|
|
8
|
+
READY_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.ready"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ping_engine(timeout: float = 2.0) -> bool:
|
|
12
|
+
"""True if a live engine answers a ping on SOCKET_PATH.
|
|
13
|
+
|
|
14
|
+
Used by the daemon's health watchdog, and by the engine itself at
|
|
15
|
+
startup to refuse to double-run: a second engine binding the socket
|
|
16
|
+
path would silently starve the healthy instance (its listener stays
|
|
17
|
+
blocked on a socket inode that no longer receives anything).
|
|
18
|
+
"""
|
|
19
|
+
import socket
|
|
20
|
+
|
|
21
|
+
if not SOCKET_PATH.exists():
|
|
22
|
+
return False
|
|
23
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
24
|
+
try:
|
|
25
|
+
sock.settimeout(timeout)
|
|
26
|
+
sock.bind("") # Linux autobind: gives us an address for the reply
|
|
27
|
+
sock.sendto(b"ping", str(SOCKET_PATH))
|
|
28
|
+
data, _ = sock.recvfrom(64)
|
|
29
|
+
return data == b"pong"
|
|
30
|
+
except OSError:
|
|
31
|
+
return False
|
|
32
|
+
finally:
|
|
33
|
+
sock.close()
|
|
@@ -24,7 +24,7 @@ import gi
|
|
|
24
24
|
gi.require_version("IBus", "1.0")
|
|
25
25
|
from gi.repository import GLib, GObject, IBus
|
|
26
26
|
|
|
27
|
-
from voiceio.ibus import READY_PATH, SOCKET_PATH
|
|
27
|
+
from voiceio.ibus import READY_PATH, SOCKET_PATH, ping_engine
|
|
28
28
|
from voiceio.ibus.pending import PendingBuffer
|
|
29
29
|
|
|
30
30
|
log = logging.getLogger(__name__)
|
|
@@ -146,6 +146,7 @@ def _socket_listener(mainloop: GLib.MainLoop) -> None:
|
|
|
146
146
|
except socket.timeout:
|
|
147
147
|
continue
|
|
148
148
|
except OSError:
|
|
149
|
+
log.exception("Socket listener error — listener exiting")
|
|
149
150
|
break
|
|
150
151
|
|
|
151
152
|
msg = data.decode("utf-8", errors="replace")
|
|
@@ -229,6 +230,15 @@ def main() -> None:
|
|
|
229
230
|
],
|
|
230
231
|
)
|
|
231
232
|
|
|
233
|
+
# Refuse to double-run. ibus-daemon can exec-spawn its own copy of this
|
|
234
|
+
# engine (component XML) when something activates the voiceio source —
|
|
235
|
+
# e.g. GNOME's per-window source memory. Binding SOCKET_PATH here would
|
|
236
|
+
# steal it from the healthy daemon-spawned engine and silently starve it
|
|
237
|
+
# (alive, but never sees another command or ping → zombie recovery).
|
|
238
|
+
if ping_engine(timeout=0.5):
|
|
239
|
+
log.info("Healthy VoiceIO engine already serving %s — exiting duplicate", SOCKET_PATH)
|
|
240
|
+
return
|
|
241
|
+
|
|
232
242
|
IBus.init()
|
|
233
243
|
bus = IBus.Bus()
|
|
234
244
|
|
|
@@ -49,15 +49,7 @@ def main() -> None:
|
|
|
49
49
|
gi.require_version("AppIndicator3", "0.1")
|
|
50
50
|
from gi.repository import AppIndicator3
|
|
51
51
|
|
|
52
|
-
from gi.repository import GLib, Gtk
|
|
53
|
-
|
|
54
|
-
indicator = AppIndicator3.Indicator.new_with_path(
|
|
55
|
-
"voiceio",
|
|
56
|
-
idle_icon_names[0],
|
|
57
|
-
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
|
|
58
|
-
args.theme_dir,
|
|
59
|
-
)
|
|
60
|
-
indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
|
|
52
|
+
from gi.repository import Gio, GLib, Gtk
|
|
61
53
|
|
|
62
54
|
recording = [False]
|
|
63
55
|
|
|
@@ -105,8 +97,6 @@ def main() -> None:
|
|
|
105
97
|
menu.append(quit_item)
|
|
106
98
|
|
|
107
99
|
menu.show_all()
|
|
108
|
-
indicator.set_menu(menu)
|
|
109
|
-
indicator.set_secondary_activate_target(toggle_item)
|
|
110
100
|
|
|
111
101
|
# Animation state
|
|
112
102
|
anim_frame = [0]
|
|
@@ -114,10 +104,36 @@ def main() -> None:
|
|
|
114
104
|
anim_icons = [rec_icon_names] # current icon set for animation
|
|
115
105
|
anim_interval = [args.interval]
|
|
116
106
|
|
|
107
|
+
indicator = [None]
|
|
108
|
+
ind_serial = [0]
|
|
109
|
+
|
|
110
|
+
def _make_indicator() -> None:
|
|
111
|
+
"""(Re)create the AppIndicator and register it with the watcher.
|
|
112
|
+
|
|
113
|
+
A fresh id per rebuild gives a fresh D-Bus object path, so a
|
|
114
|
+
rebuilt indicator never collides with the half-dead export of
|
|
115
|
+
the previous one.
|
|
116
|
+
"""
|
|
117
|
+
old = indicator[0]
|
|
118
|
+
if old is not None:
|
|
119
|
+
old.set_status(AppIndicator3.IndicatorStatus.PASSIVE)
|
|
120
|
+
ind_serial[0] += 1
|
|
121
|
+
ind_id = "voiceio" if ind_serial[0] == 1 else f"voiceio-{ind_serial[0]}"
|
|
122
|
+
ind = AppIndicator3.Indicator.new_with_path(
|
|
123
|
+
ind_id,
|
|
124
|
+
anim_icons[0][anim_frame[0] % len(anim_icons[0])],
|
|
125
|
+
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
|
|
126
|
+
args.theme_dir,
|
|
127
|
+
)
|
|
128
|
+
ind.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
|
|
129
|
+
ind.set_menu(menu)
|
|
130
|
+
ind.set_secondary_activate_target(toggle_item)
|
|
131
|
+
indicator[0] = ind
|
|
132
|
+
|
|
117
133
|
def _animate() -> bool:
|
|
118
134
|
icons = anim_icons[0]
|
|
119
135
|
frame = anim_frame[0] % len(icons)
|
|
120
|
-
indicator.set_icon_full(icons[frame], "animating")
|
|
136
|
+
indicator[0].set_icon_full(icons[frame], "animating")
|
|
121
137
|
anim_frame[0] = frame + 1
|
|
122
138
|
return True
|
|
123
139
|
|
|
@@ -168,9 +184,44 @@ def main() -> None:
|
|
|
168
184
|
|
|
169
185
|
signal.signal(signal.SIGTERM, lambda *_: GLib.idle_add(Gtk.main_quit))
|
|
170
186
|
|
|
187
|
+
_make_indicator()
|
|
188
|
+
|
|
171
189
|
# Start with idle animation
|
|
172
190
|
_start_animation(idle_icon_names, idle_interval, "Start recording")
|
|
173
191
|
|
|
192
|
+
# Registration watchdog: GNOME Shell's appindicator extension can drop
|
|
193
|
+
# items when it restarts (lock screen, extension reload) — its watcher
|
|
194
|
+
# re-seeks existing items and one D-Bus hiccup during that scan loses
|
|
195
|
+
# the icon for good while this process keeps running. Poll the watcher
|
|
196
|
+
# and rebuild the indicator if our item is gone.
|
|
197
|
+
session_bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
|
198
|
+
|
|
199
|
+
def _registration_ok() -> bool:
|
|
200
|
+
try:
|
|
201
|
+
reply = session_bus.call_sync(
|
|
202
|
+
"org.kde.StatusNotifierWatcher",
|
|
203
|
+
"/StatusNotifierWatcher",
|
|
204
|
+
"org.freedesktop.DBus.Properties",
|
|
205
|
+
"Get",
|
|
206
|
+
GLib.Variant("(ss)", ("org.kde.StatusNotifierWatcher",
|
|
207
|
+
"RegisteredStatusNotifierItems")),
|
|
208
|
+
GLib.VariantType.new("(v)"),
|
|
209
|
+
Gio.DBusCallFlags.NONE,
|
|
210
|
+
2000,
|
|
211
|
+
None,
|
|
212
|
+
)
|
|
213
|
+
except GLib.Error:
|
|
214
|
+
return True # no watcher (extension off / shell starting) — nothing to fix
|
|
215
|
+
items = reply.unpack()[0]
|
|
216
|
+
return any("voiceio" in item for item in items)
|
|
217
|
+
|
|
218
|
+
def _check_registration() -> bool:
|
|
219
|
+
if not _registration_ok():
|
|
220
|
+
_make_indicator()
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
GLib.timeout_add_seconds(60, _check_registration)
|
|
224
|
+
|
|
174
225
|
reader = threading.Thread(target=_stdin_reader, daemon=True)
|
|
175
226
|
reader.start()
|
|
176
227
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.8.0"
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
"""IBus input method engine for VoiceIO."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import os
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
SOCKET_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.sock"
|
|
8
|
-
READY_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.ready"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|