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.
Files changed (126) hide show
  1. {python_voiceio-0.8.0/python_voiceio.egg-info → python_voiceio-0.8.1}/PKG-INFO +1 -1
  2. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/pyproject.toml +1 -1
  3. {python_voiceio-0.8.0 → python_voiceio-0.8.1/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/python_voiceio.egg-info/SOURCES.txt +1 -0
  5. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_app_wiring.py +27 -6
  6. python_voiceio-0.8.1/tests/test_ibus_ping.py +50 -0
  7. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_robustness.py +29 -0
  8. python_voiceio-0.8.1/voiceio/__init__.py +1 -0
  9. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/app.py +53 -32
  10. python_voiceio-0.8.1/voiceio/ibus/__init__.py +33 -0
  11. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/ibus/engine.py +11 -1
  12. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tray/_indicator.py +63 -12
  13. python_voiceio-0.8.0/voiceio/__init__.py +0 -1
  14. python_voiceio-0.8.0/voiceio/ibus/__init__.py +0 -8
  15. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/LICENSE +0 -0
  16. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/README.md +0 -0
  17. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/python_voiceio.egg-info/dependency_links.txt +0 -0
  18. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/python_voiceio.egg-info/entry_points.txt +0 -0
  19. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/python_voiceio.egg-info/requires.txt +0 -0
  20. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/python_voiceio.egg-info/top_level.txt +0 -0
  21. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/setup.cfg +0 -0
  22. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_adjudicate.py +0 -0
  23. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_audio_quality.py +0 -0
  24. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_audit.py +0 -0
  25. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_autocorrect.py +0 -0
  26. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_autocorrect_state.py +0 -0
  27. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_backend_probes.py +0 -0
  28. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_cli.py +0 -0
  29. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_clipboard_read.py +0 -0
  30. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_commands.py +0 -0
  31. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_concurrency_lockdown.py +0 -0
  32. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_config.py +0 -0
  33. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_correct_batch.py +0 -0
  34. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_corrections.py +0 -0
  35. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_fallback.py +0 -0
  36. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_health.py +0 -0
  37. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_hints.py +0 -0
  38. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_history.py +0 -0
  39. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_ibus_pending.py +0 -0
  40. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_ibus_typer.py +0 -0
  41. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_llm.py +0 -0
  42. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_llm_api.py +0 -0
  43. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_numbers.py +0 -0
  44. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_platform.py +0 -0
  45. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_postcorrect.py +0 -0
  46. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_postprocess.py +0 -0
  47. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_prebuffer.py +0 -0
  48. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_prompt.py +0 -0
  49. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_recorder_integration.py +0 -0
  50. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_retention.py +0 -0
  51. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_security_hardening.py +0 -0
  52. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_snapshots.py +0 -0
  53. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_streaming.py +0 -0
  54. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_transcriber.py +0 -0
  55. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_tts.py +0 -0
  56. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_vad.py +0 -0
  57. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_vocabulary.py +0 -0
  58. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_wizard.py +0 -0
  59. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/tests/test_wordfreq.py +0 -0
  60. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/__main__.py +0 -0
  61. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/audit.py +0 -0
  62. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/autocorrect.py +0 -0
  63. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/autocorrect_state.py +0 -0
  64. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/backends.py +0 -0
  65. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/cli.py +0 -0
  66. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/clipboard_read.py +0 -0
  67. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/commands.py +0 -0
  68. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/config.py +0 -0
  69. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/consent.py +0 -0
  70. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/corrections.py +0 -0
  71. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/demo.py +0 -0
  72. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/feedback.py +0 -0
  73. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/health.py +0 -0
  74. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hints.py +0 -0
  75. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/history.py +0 -0
  76. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hotkeys/__init__.py +0 -0
  77. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hotkeys/base.py +0 -0
  78. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hotkeys/chain.py +0 -0
  79. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hotkeys/evdev.py +0 -0
  80. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hotkeys/pynput_backend.py +0 -0
  81. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/hotkeys/socket_backend.py +0 -0
  82. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/ibus/pending.py +0 -0
  83. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/llm.py +0 -0
  84. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/llm_api.py +0 -0
  85. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/models/__init__.py +0 -0
  86. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/models/silero_vad.onnx +0 -0
  87. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/numbers.py +0 -0
  88. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/pidlock.py +0 -0
  89. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/platform.py +0 -0
  90. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/postcorrect.py +0 -0
  91. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/postprocess.py +0 -0
  92. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/prompt.py +0 -0
  93. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/recorder.py +0 -0
  94. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/retention.py +0 -0
  95. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/service.py +0 -0
  96. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/snapshots.py +0 -0
  97. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/sounds/__init__.py +0 -0
  98. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/sounds/commit.wav +0 -0
  99. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/sounds/start.wav +0 -0
  100. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/sounds/stop.wav +0 -0
  101. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/streaming.py +0 -0
  102. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/transcriber.py +0 -0
  103. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tray/__init__.py +0 -0
  104. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tray/_icons.py +0 -0
  105. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tray/_pystray.py +0 -0
  106. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/__init__.py +0 -0
  107. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/base.py +0 -0
  108. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/chain.py +0 -0
  109. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/edge_engine.py +0 -0
  110. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/espeak.py +0 -0
  111. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/piper_engine.py +0 -0
  112. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/tts/player.py +0 -0
  113. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/__init__.py +0 -0
  114. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/base.py +0 -0
  115. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/chain.py +0 -0
  116. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/clipboard.py +0 -0
  117. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/ibus.py +0 -0
  118. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/pynput_type.py +0 -0
  119. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/wtype.py +0 -0
  120. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/xdotool.py +0 -0
  121. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/typers/ydotool.py +0 -0
  122. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/vad.py +0 -0
  123. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/vocabulary.py +0 -0
  124. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/wizard.py +0 -0
  125. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/wordfreq.py +0 -0
  126. {python_voiceio-0.8.0 → python_voiceio-0.8.1}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.8.0"
7
+ version = "0.8.1"
8
8
  description = "Voice dictation for Linux. Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -26,6 +26,7 @@ tests/test_health.py
26
26
  tests/test_hints.py
27
27
  tests/test_history.py
28
28
  tests/test_ibus_pending.py
29
+ tests/test_ibus_ping.py
29
30
  tests/test_ibus_typer.py
30
31
  tests/test_llm.py
31
32
  tests/test_llm_api.py
@@ -274,14 +274,16 @@ class TestEngineLifecycle:
274
274
  vio._engine_proc = proc
275
275
  return vio, proc
276
276
 
277
- def test_idle_engine_death_not_recovered(self):
278
- """ibus-daemon reaping a dormant engine must NOT trigger the
279
- activate/dormant dance (it grabbed the input source every cycle)."""
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.assert_not_called()
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.assert_called_once()
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.assert_called_once()
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
- self._ensure_ibus_engine() # subprocess ops, outside the lock
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 and activate it.
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 / GLib loop is dead (zombie engine).
1048
+ listener is dead or starved (zombie engine) — e.g. a duplicate
1049
+ engine process stole the socket path.
1031
1050
  """
1032
- import socket as _socket
1033
- from voiceio.ibus import SOCKET_PATH
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). Eagerly respawning here created a
1392
- # spawn→activate→dormant→reap loop that grabbed the user's input
1393
- # source every cycle the focus churn wiped any visible preedit
1394
- # (flickering underline). Respawn lazily at the next record start.
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.debug(
1402
- "IBus engine exited while dormant (rc=%s) — normal "
1403
- "reaping, will respawn at next recording", rc,
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
- # GLib loop stuck / socket thread dead) silently drops all
1416
- # preedit and commit messages. One missed ping on a loaded
1417
- # system is not a zombie — require two consecutive misses.
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
- self._ensure_ibus_engine()
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