python-voiceio 0.7.0__tar.gz → 0.7.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 (124) hide show
  1. {python_voiceio-0.7.0/python_voiceio.egg-info → python_voiceio-0.7.1}/PKG-INFO +1 -1
  2. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/pyproject.toml +1 -1
  3. {python_voiceio-0.7.0 → python_voiceio-0.7.1/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_app_wiring.py +125 -0
  5. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_clipboard_read.py +30 -2
  6. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_ibus_typer.py +4 -7
  7. python_voiceio-0.7.1/voiceio/__init__.py +1 -0
  8. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/app.py +136 -27
  9. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/clipboard_read.py +18 -2
  10. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/ibus/engine.py +4 -0
  11. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/typers/ibus.py +8 -16
  12. python_voiceio-0.7.0/voiceio/__init__.py +0 -1
  13. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/LICENSE +0 -0
  14. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/README.md +0 -0
  15. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/SOURCES.txt +0 -0
  16. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/dependency_links.txt +0 -0
  17. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/entry_points.txt +0 -0
  18. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/requires.txt +0 -0
  19. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/python_voiceio.egg-info/top_level.txt +0 -0
  20. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/setup.cfg +0 -0
  21. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_adjudicate.py +0 -0
  22. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_audio_quality.py +0 -0
  23. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_audit.py +0 -0
  24. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_autocorrect.py +0 -0
  25. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_autocorrect_state.py +0 -0
  26. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_backend_probes.py +0 -0
  27. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_cli.py +0 -0
  28. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_commands.py +0 -0
  29. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_concurrency_lockdown.py +0 -0
  30. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_config.py +0 -0
  31. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_correct_batch.py +0 -0
  32. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_corrections.py +0 -0
  33. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_fallback.py +0 -0
  34. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_health.py +0 -0
  35. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_hints.py +0 -0
  36. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_history.py +0 -0
  37. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_ibus_pending.py +0 -0
  38. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_llm.py +0 -0
  39. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_llm_api.py +0 -0
  40. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_numbers.py +0 -0
  41. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_platform.py +0 -0
  42. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_postcorrect.py +0 -0
  43. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_postprocess.py +0 -0
  44. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_prebuffer.py +0 -0
  45. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_prompt.py +0 -0
  46. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_recorder_integration.py +0 -0
  47. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_retention.py +0 -0
  48. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_robustness.py +0 -0
  49. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_security_hardening.py +0 -0
  50. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_snapshots.py +0 -0
  51. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_streaming.py +0 -0
  52. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_transcriber.py +0 -0
  53. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_tts.py +0 -0
  54. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_vad.py +0 -0
  55. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_vocabulary.py +0 -0
  56. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_wizard.py +0 -0
  57. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/tests/test_wordfreq.py +0 -0
  58. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/__main__.py +0 -0
  59. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/audit.py +0 -0
  60. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/autocorrect.py +0 -0
  61. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/autocorrect_state.py +0 -0
  62. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/backends.py +0 -0
  63. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/cli.py +0 -0
  64. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/commands.py +0 -0
  65. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/config.py +0 -0
  66. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/consent.py +0 -0
  67. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/corrections.py +0 -0
  68. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/demo.py +0 -0
  69. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/feedback.py +0 -0
  70. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/health.py +0 -0
  71. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/hints.py +0 -0
  72. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/history.py +0 -0
  73. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/hotkeys/__init__.py +0 -0
  74. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/hotkeys/base.py +0 -0
  75. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/hotkeys/chain.py +0 -0
  76. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/hotkeys/evdev.py +0 -0
  77. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/hotkeys/pynput_backend.py +0 -0
  78. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/hotkeys/socket_backend.py +0 -0
  79. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/ibus/__init__.py +0 -0
  80. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/ibus/pending.py +0 -0
  81. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/llm.py +0 -0
  82. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/llm_api.py +0 -0
  83. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/models/__init__.py +0 -0
  84. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/models/silero_vad.onnx +0 -0
  85. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/numbers.py +0 -0
  86. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/pidlock.py +0 -0
  87. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/platform.py +0 -0
  88. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/postcorrect.py +0 -0
  89. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/postprocess.py +0 -0
  90. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/prompt.py +0 -0
  91. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/recorder.py +0 -0
  92. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/retention.py +0 -0
  93. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/service.py +0 -0
  94. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/snapshots.py +0 -0
  95. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/sounds/__init__.py +0 -0
  96. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/sounds/commit.wav +0 -0
  97. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/sounds/start.wav +0 -0
  98. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/sounds/stop.wav +0 -0
  99. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/streaming.py +0 -0
  100. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/transcriber.py +0 -0
  101. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/tray/__init__.py +0 -0
  102. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/tray/_icons.py +0 -0
  103. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/tray/_indicator.py +0 -0
  104. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/tray/_pystray.py +0 -0
  105. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/tts/__init__.py +0 -0
  106. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/tts/base.py +0 -0
  107. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/tts/chain.py +0 -0
  108. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/tts/edge_engine.py +0 -0
  109. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/tts/espeak.py +0 -0
  110. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/tts/piper_engine.py +0 -0
  111. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/tts/player.py +0 -0
  112. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/typers/__init__.py +0 -0
  113. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/typers/base.py +0 -0
  114. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/typers/chain.py +0 -0
  115. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/typers/clipboard.py +0 -0
  116. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/typers/pynput_type.py +0 -0
  117. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/typers/wtype.py +0 -0
  118. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/typers/xdotool.py +0 -0
  119. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/typers/ydotool.py +0 -0
  120. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/vad.py +0 -0
  121. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/vocabulary.py +0 -0
  122. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/wizard.py +0 -0
  123. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/wordfreq.py +0 -0
  124. {python_voiceio-0.7.0 → python_voiceio-0.7.1}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.7.0
3
+ Version: 0.7.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.7.0"
7
+ version = "0.7.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.7.0
3
+ Version: 0.7.1
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -260,3 +260,128 @@ class TestClipboardMirror:
260
260
  vio._copy_result_async("hello world")
261
261
  assert done.wait(timeout=2)
262
262
  mock_copy.assert_called_once_with("hello world")
263
+
264
+
265
+ class TestEngineLifecycle:
266
+ """Dormant-engine reaping is normal; recovery must not steal the source."""
267
+
268
+ def _vio_ibus(self, proc_poll=-15):
269
+ vio, mock_typer, _ = _make_vio()
270
+ mock_typer.name = "ibus"
271
+ proc = MagicMock()
272
+ proc.poll.return_value = proc_poll
273
+ proc.returncode = -15
274
+ vio._engine_proc = proc
275
+ return vio, proc
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)."""
280
+ vio, proc = self._vio_ibus(proc_poll=-15)
281
+ with patch.object(vio, "_ensure_ibus_engine") as ensure, \
282
+ patch.object(vio, "_health_typer_upkeep"):
283
+ vio._check_health()
284
+ ensure.assert_not_called()
285
+ assert vio._engine_proc is None
286
+
287
+ def test_mid_recording_engine_death_recovered(self):
288
+ from voiceio.app import _State
289
+ vio, proc = self._vio_ibus(proc_poll=-15)
290
+ vio._state = _State.RECORDING
291
+ with patch.object(vio, "_ensure_ibus_engine") as ensure:
292
+ vio._check_health()
293
+ ensure.assert_called_once()
294
+
295
+ def test_single_missed_ping_tolerated(self):
296
+ """One missed ping on a loaded system is not a zombie."""
297
+ vio, proc = self._vio_ibus(proc_poll=None)
298
+ with patch.object(vio, "_ping_ibus_engine", return_value=False), \
299
+ patch.object(vio, "_ensure_ibus_engine") as ensure, \
300
+ patch.object(vio, "_health_typer_upkeep"):
301
+ vio._check_health()
302
+ ensure.assert_not_called()
303
+ proc.kill.assert_not_called()
304
+ assert vio._engine_ping_fails == 1
305
+
306
+ def test_two_missed_pings_restart_zombie(self):
307
+ vio, proc = self._vio_ibus(proc_poll=None)
308
+ with patch.object(vio, "_ping_ibus_engine", return_value=False), \
309
+ patch.object(vio, "_ensure_ibus_engine") as ensure, \
310
+ patch.object(vio, "_health_typer_upkeep"):
311
+ vio._check_health()
312
+ vio._check_health()
313
+ ensure.assert_called_once()
314
+ proc.kill.assert_called_once()
315
+
316
+ def test_successful_ping_resets_counter(self):
317
+ vio, proc = self._vio_ibus(proc_poll=None)
318
+ vio._engine_ping_fails = 1
319
+ with patch.object(vio, "_ping_ibus_engine", return_value=True), \
320
+ patch.object(vio, "_ensure_ibus_engine") as ensure, \
321
+ patch.object(vio, "_health_typer_upkeep"):
322
+ vio._check_health()
323
+ ensure.assert_not_called()
324
+ assert vio._engine_ping_fails == 0
325
+
326
+ def test_record_start_respawns_reaped_engine(self):
327
+ vio, mock_typer, _ = _make_vio()
328
+ mock_typer.name = "ibus"
329
+ vio._engine_proc = None # reaped while dormant
330
+ spawned = threading.Event()
331
+ with patch.object(vio, "_ensure_ibus_engine", side_effect=lambda: spawned.set()), \
332
+ patch.object(vio, "_switch_gnome_input_source"):
333
+ vio.on_hotkey() # start recording
334
+ assert spawned.wait(timeout=2), "engine respawn not triggered"
335
+ assert vio.recorder.is_recording
336
+
337
+
338
+ class TestEngineRelease:
339
+ """Going dormant must release ibus's GLOBAL engine, not just gsettings."""
340
+
341
+ def test_release_sets_user_engine_unconditionally(self):
342
+ """Release must not query first: a stale reading during the async
343
+ activation made it skip itself and leave voiceio globally active."""
344
+ vio, _, _ = _make_vio()
345
+ vio._prev_ibus_engine = "xkb:us::eng"
346
+ with patch("voiceio.app.subprocess.run") as run:
347
+ run.return_value = MagicMock(returncode=0)
348
+ vio._release_ibus_engine()
349
+ assert run.call_count == 1
350
+ assert run.call_args[0][0] == ["ibus", "engine", "xkb:us::eng"]
351
+
352
+ def test_fallback_derived_from_sources_when_prev_stale(self):
353
+ """A stale 'voiceio' prev (inherited from a crashed daemon) must not
354
+ be restored; derive the user's xkb layout instead."""
355
+ vio, _, _ = _make_vio()
356
+ vio._prev_ibus_engine = "voiceio"
357
+ with patch("voiceio.app.subprocess.run") as run:
358
+ run.return_value = MagicMock(
359
+ returncode=0, stdout="[('xkb', 'de'), ('ibus', 'voiceio')]\n",
360
+ )
361
+ assert vio._ibus_engine_fallback() == "xkb:de::eng"
362
+
363
+ def test_restore_input_source_releases_engine(self):
364
+ vio, _, _ = _make_vio()
365
+ vio.platform.is_gnome = True
366
+ with patch.object(vio, "_set_gnome_input_source_index"), \
367
+ patch.object(vio, "_release_ibus_engine") as release:
368
+ vio._restore_input_source()
369
+ release.assert_called_once()
370
+
371
+
372
+ def test_activation_claims_global_engine():
373
+ """gsettings 'current' is legacy/no-op on newer GNOME — activation must
374
+ also claim the global engine directly (mirror of the dormant release)."""
375
+ vio, _, _ = _make_vio()
376
+ vio.platform.is_gnome = True
377
+ with patch("voiceio.app.subprocess.run") as run:
378
+ run.side_effect = [
379
+ MagicMock(returncode=0, stdout="[('xkb', 'us'), ('ibus', 'voiceio')]\n"), # sources
380
+ MagicMock(returncode=0, stdout="0\n"), # current (prev)
381
+ MagicMock(returncode=0), # gsettings set current
382
+ MagicMock(returncode=0), # ibus engine voiceio
383
+ ]
384
+ with patch("time.sleep"):
385
+ vio._switch_gnome_input_source("voiceio")
386
+ cmds = [c[0][0] for c in run.call_args_list]
387
+ assert ["ibus", "engine", "voiceio"] in cmds
@@ -94,9 +94,9 @@ def test_read_text_no_tools():
94
94
  # --- copy_text ---
95
95
 
96
96
  def test_copy_text_wayland():
97
- """Wayland copies via wl-copy with text on stdin."""
97
+ """Wayland without xclip copies via wl-copy with text on stdin."""
98
98
  with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="wayland")), \
99
- patch("shutil.which", return_value="/usr/bin/wl-copy"), \
99
+ patch("shutil.which", lambda t: "/usr/bin/wl-copy" if t == "wl-copy" else None), \
100
100
  patch("subprocess.run") as mock_run:
101
101
  mock_run.return_value = MagicMock(returncode=0)
102
102
  from voiceio.clipboard_read import copy_text
@@ -143,3 +143,31 @@ def test_copy_text_command_failure():
143
143
  mock_run.return_value = MagicMock(returncode=1)
144
144
  from voiceio.clipboard_read import copy_text
145
145
  assert copy_text("hello") is False
146
+
147
+
148
+ def test_copy_text_wayland_prefers_xclip_bridge():
149
+ """On Wayland prefer xclip (XWayland bridge): wl-copy can steal focus
150
+ on GNOME, and a focus-out discards a visible IBus preedit."""
151
+ with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="wayland")), \
152
+ patch("shutil.which", lambda t: f"/usr/bin/{t}" if t in ("xclip", "wl-copy") else None), \
153
+ patch("subprocess.run") as mock_run:
154
+ mock_run.return_value = MagicMock(returncode=0)
155
+ from voiceio.clipboard_read import copy_text
156
+ assert copy_text("hello") is True
157
+ assert mock_run.call_args[0][0][0] == "xclip"
158
+
159
+
160
+ def test_copy_text_wayland_falls_back_to_wl_copy():
161
+ """xclip failing (no DISPLAY etc.) falls through to wl-copy."""
162
+ calls = []
163
+
164
+ def run(cmd, **kw):
165
+ calls.append(cmd[0])
166
+ return MagicMock(returncode=1 if cmd[0] == "xclip" else 0)
167
+
168
+ with patch("voiceio.clipboard_read.detect", return_value=_mock_platform(display_server="wayland")), \
169
+ patch("shutil.which", lambda t: f"/usr/bin/{t}" if t in ("xclip", "wl-copy") else None), \
170
+ patch("subprocess.run", side_effect=run):
171
+ from voiceio.clipboard_read import copy_text
172
+ assert copy_text("hello") is True
173
+ assert calls == ["xclip", "wl-copy"]
@@ -103,16 +103,13 @@ class TestIBusTyper:
103
103
  typer.delete_chars(10) # should not raise
104
104
 
105
105
  def test_commit_copies_to_clipboard(self, mock_socket):
106
- """commit_text also copies text to clipboard as backup."""
106
+ """commit_text also copies text to clipboard as backup (via the
107
+ focus-safe clipboard_read.copy_text, never wl-copy directly)."""
107
108
  typer = IBusTyper()
108
- typer._wl_copy = "/usr/bin/wl-copy"
109
- mock_proc = MagicMock()
110
- mock_proc.stdin = MagicMock()
111
- with patch("voiceio.typers.ibus.subprocess.Popen", return_value=mock_proc), \
109
+ with patch("voiceio.clipboard_read.copy_text", return_value=True) as mock_copy, \
112
110
  patch("voiceio.typers.ibus.subprocess.run"):
113
111
  typer.commit_text("Hello")
114
- mock_proc.stdin.write.assert_called_once_with(b"Hello")
115
- mock_proc.stdin.close.assert_called_once()
112
+ mock_copy.assert_called_once_with("Hello")
116
113
 
117
114
  def test_probe_no_ibus(self):
118
115
  with patch("voiceio.typers.ibus.shutil.which", return_value=None):
@@ -0,0 +1 @@
1
+ __version__ = "0.7.1"
@@ -234,6 +234,11 @@ class VoiceIO:
234
234
  # IBus engine management
235
235
  self._prev_ibus_engine: str | None = None
236
236
  self._engine_proc: subprocess.Popen | None = None
237
+ # Engine lifecycle: consecutive missed pings (one miss on a busy
238
+ # system is not a zombie) and a guard against concurrent spawns
239
+ # (health loop vs record-start).
240
+ self._engine_ping_fails = 0
241
+ self._engine_spawn_lock = threading.Lock()
237
242
  self._shutdown = threading.Event()
238
243
  # IBus input-source ownership. We claim the GNOME input source ONLY
239
244
  # while a recording is live (RECORDING/FINALIZING) and restore whatever
@@ -366,6 +371,15 @@ class VoiceIO:
366
371
 
367
372
  self._state = _State.RECORDING
368
373
  self._ibus_session_fallback = False
374
+ if self._typer.name == "ibus" and self._engine_proc is None:
375
+ # Engine was reaped while dormant (normal — see _check_health).
376
+ # Respawn in the background; the engine's pending buffer holds
377
+ # preedits until the instance is ready, and with state=RECORDING
378
+ # the spawn leaves the voiceio source active instead of yanking
379
+ # it back to dormant mid-recording.
380
+ threading.Thread(
381
+ target=self._respawn_engine_for_recording, daemon=True,
382
+ ).start()
369
383
  self._activate_ibus()
370
384
  self._corrections.load() # hot-reload corrections on each recording
371
385
  self._refresh_hotwords() # hot-reload vocabulary (mtime-cached)
@@ -898,8 +912,29 @@ class VoiceIO:
898
912
 
899
913
  # ── IBus engine lifecycle ───────────────────────────────────────────
900
914
 
915
+ def _respawn_engine_for_recording(self) -> None:
916
+ """Background lazy respawn at record start. Never raises."""
917
+ try:
918
+ self._ensure_ibus_engine()
919
+ except Exception:
920
+ log.exception("IBus engine respawn at record start failed")
921
+
901
922
  def _ensure_ibus_engine(self) -> None:
902
- """Start the VoiceIO IBus engine and activate it."""
923
+ """Start the VoiceIO IBus engine and activate it.
924
+
925
+ Single-flight: a concurrent spawn (health loop vs record start)
926
+ is skipped rather than queued — two dances would fight over the
927
+ input source.
928
+ """
929
+ if not self._engine_spawn_lock.acquire(blocking=False):
930
+ log.debug("IBus engine spawn already in progress, skipping")
931
+ return
932
+ try:
933
+ self._spawn_ibus_engine()
934
+ finally:
935
+ self._engine_spawn_lock.release()
936
+
937
+ def _spawn_ibus_engine(self) -> None:
903
938
  from voiceio.ibus import READY_PATH, SOCKET_PATH
904
939
  from voiceio.typers.ibus import LAUNCHER_PATH, _ibus_env
905
940
 
@@ -976,9 +1011,16 @@ class VoiceIO:
976
1011
  except subprocess.TimeoutExpired:
977
1012
  activate_proc.kill()
978
1013
 
979
- # Return to dormancy on the user's real source (not hardcoded 0).
980
- self._set_gnome_input_source_index(dormant_idx if dormant_idx is not None else 0)
981
- log.info("VoiceIO IBus engine ready (dormant until recording)")
1014
+ # Return to dormancy on the user's real source (not hardcoded 0)
1015
+ # unless a recording claimed the source while we were spawning:
1016
+ # yanking it away now would kill the live preedit for that whole
1017
+ # recording (the "underlined text never appears" symptom).
1018
+ if self._state == _State.IDLE:
1019
+ self._set_gnome_input_source_index(dormant_idx if dormant_idx is not None else 0)
1020
+ self._release_ibus_engine()
1021
+ log.info("VoiceIO IBus engine ready (dormant until recording)")
1022
+ else:
1023
+ log.info("VoiceIO IBus engine ready (recording live — staying active)")
982
1024
 
983
1025
  def _ping_ibus_engine(self) -> bool:
984
1026
  """Check if the IBus engine's socket listener is alive.
@@ -991,7 +1033,7 @@ class VoiceIO:
991
1033
  from voiceio.ibus import SOCKET_PATH
992
1034
  try:
993
1035
  sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_DGRAM)
994
- sock.settimeout(1.0)
1036
+ sock.settimeout(2.0)
995
1037
  sock.bind("")
996
1038
  sock.sendto(b"ping", str(SOCKET_PATH))
997
1039
  data, _ = sock.recvfrom(64)
@@ -1091,6 +1133,49 @@ class VoiceIO:
1091
1133
  self._set_gnome_input_source_index(idx if idx is not None else 0)
1092
1134
  self._prev_input_source_index = None
1093
1135
  self._voiceio_source_index = None
1136
+ self._release_ibus_engine()
1137
+
1138
+ def _ibus_engine_fallback(self) -> str:
1139
+ """The user's non-voiceio ibus engine name, for releasing the engine."""
1140
+ if self._prev_ibus_engine and self._prev_ibus_engine != "voiceio":
1141
+ return self._prev_ibus_engine
1142
+ try:
1143
+ result = subprocess.run(
1144
+ ["gsettings", "get", "org.gnome.desktop.input-sources", "sources"],
1145
+ capture_output=True, text=True, timeout=3,
1146
+ )
1147
+ import ast
1148
+ for kind, name in ast.literal_eval(result.stdout.strip()):
1149
+ if kind == "xkb":
1150
+ return f"xkb:{name}::eng"
1151
+ except Exception:
1152
+ pass
1153
+ return "xkb:us::eng"
1154
+
1155
+ def _release_ibus_engine(self) -> None:
1156
+ """Point ibus's GLOBAL engine back at the user's layout when dormant.
1157
+
1158
+ Restoring the GNOME input source is not enough: the spawn dance sets
1159
+ the global engine directly (`ibus engine voiceio`), and a gsettings
1160
+ write that doesn't change the value never makes GNOME push an engine
1161
+ change. The stale global engine then routes EVERY input context
1162
+ through voiceio while "dormant" — constant focus churn that discards
1163
+ any visible preedit, surviving daemon restarts.
1164
+ """
1165
+ from voiceio.typers.ibus import _ibus_env
1166
+ try:
1167
+ # Unconditional: querying first races the (async) activation —
1168
+ # a stale reading made the release skip itself and left voiceio
1169
+ # active. Going dormant means the user's engine should be the
1170
+ # global one; setting it is idempotent.
1171
+ fallback = self._ibus_engine_fallback()
1172
+ subprocess.run(
1173
+ ["ibus", "engine", fallback],
1174
+ capture_output=True, timeout=3, env=_ibus_env(),
1175
+ )
1176
+ log.debug("Released ibus global engine to %s", fallback)
1177
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1178
+ pass
1094
1179
 
1095
1180
  def _switch_gnome_input_source(self, engine_name: str) -> None:
1096
1181
  if not self.platform.is_gnome:
@@ -1124,6 +1209,16 @@ class VoiceIO:
1124
1209
  "current", str(i)],
1125
1210
  capture_output=True, timeout=3,
1126
1211
  )
1212
+ # The gsettings write alone is a no-op on newer GNOME
1213
+ # (the 'current' key is legacy) — activation only ever
1214
+ # worked while the global engine was accidentally stuck
1215
+ # on voiceio. Claim it explicitly, mirroring
1216
+ # _release_ibus_engine() on deactivation.
1217
+ from voiceio.typers.ibus import _ibus_env
1218
+ subprocess.run(
1219
+ ["ibus", "engine", engine_name],
1220
+ capture_output=True, timeout=3, env=_ibus_env(),
1221
+ )
1127
1222
  log.info("Switched GNOME input source to index %d (%s)", i, engine_name)
1128
1223
  time.sleep(0.5)
1129
1224
  return
@@ -1216,17 +1311,13 @@ class VoiceIO:
1216
1311
  # of ibus) — the preferred backend may be available now.
1217
1312
  self._try_upgrade_typer(reason="resume")
1218
1313
 
1219
- # IBus: restart the engine only if its process died. Never re-force the
1220
- # active input source on resume activation happens on record-start.
1314
+ # IBus: an engine that died during sleep is respawned lazily at the
1315
+ # next record start (same as health-loop reaping respawning here
1316
+ # would grab the input source right as the user unlocks).
1221
1317
  if self._typer.name == "ibus" and self._engine_proc is not None:
1222
1318
  if self._engine_proc.poll() is not None:
1223
- # Engine process died during sleep
1319
+ log.debug("Resume: IBus engine died during sleep — will respawn at next recording")
1224
1320
  self._engine_proc = None
1225
- try:
1226
- self._ensure_ibus_engine()
1227
- log.info("Resume: IBus engine restarted")
1228
- except Exception:
1229
- log.exception("Resume: IBus engine restart failed")
1230
1321
 
1231
1322
  # Tray: restart if subprocess died
1232
1323
  if self.cfg.tray.enabled and not tray.is_alive():
@@ -1295,29 +1386,45 @@ class VoiceIO:
1295
1386
  if self._state == _State.IDLE:
1296
1387
  self._health_typer_upkeep()
1297
1388
 
1298
- # Check IBus engine liveness. When IDLE we ONLY restart a dead/zombie
1299
- # engine process we never re-force the active input source, so we
1300
- # don't fight users running a real IME. When a recording is live we
1301
- # watch for the user switching source away from us and fall back.
1389
+ # Check IBus engine liveness. A dormant engine dying is NORMAL:
1390
+ # 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.
1302
1395
  if self._typer.name == "ibus" and self._engine_proc is not None:
1303
1396
  if self._engine_proc.poll() is not None:
1304
- log.warning("IBus engine process died (rc=%d), restarting",
1305
- self._engine_proc.returncode)
1397
+ rc = self._engine_proc.returncode
1306
1398
  self._engine_proc = None
1307
- try:
1308
- self._ensure_ibus_engine()
1309
- log.info("IBus engine recovered")
1310
- except Exception:
1311
- log.exception("IBus engine recovery failed")
1399
+ self._engine_ping_fails = 0
1400
+ 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,
1404
+ )
1405
+ else:
1406
+ log.warning("IBus engine died mid-recording (rc=%s), restarting", rc)
1407
+ try:
1408
+ self._ensure_ibus_engine()
1409
+ log.info("IBus engine recovered")
1410
+ except Exception:
1411
+ log.exception("IBus engine recovery failed")
1312
1412
  elif self._state == _State.IDLE:
1313
1413
  # Engine process alive — check if its socket listener is
1314
1414
  # actually responding. A zombie engine (process alive but
1315
1415
  # GLib loop stuck / socket thread dead) silently drops all
1316
- # preedit and commit messages, causing the "transcription
1317
- # works but no text appears" symptom. Restart it, but do NOT
1318
- # touch the active input source.
1416
+ # preedit and commit messages. One missed ping on a loaded
1417
+ # system is not a zombie require two consecutive misses.
1319
1418
  if not self._ping_ibus_engine():
1419
+ self._engine_ping_fails += 1
1420
+ if self._engine_ping_fails < 2:
1421
+ log.debug(
1422
+ "IBus engine missed a ping (%d/2) — busy system?",
1423
+ self._engine_ping_fails,
1424
+ )
1425
+ return
1320
1426
  log.warning("IBus engine not responding to ping, restarting")
1427
+ self._engine_ping_fails = 0
1321
1428
  self._engine_proc.kill()
1322
1429
  try:
1323
1430
  self._engine_proc.wait(timeout=3)
@@ -1329,6 +1436,8 @@ class VoiceIO:
1329
1436
  log.info("IBus engine recovered (was zombie)")
1330
1437
  except Exception:
1331
1438
  log.exception("IBus engine recovery failed")
1439
+ else:
1440
+ self._engine_ping_fails = 0
1332
1441
  else:
1333
1442
  # RECORDING / FINALIZING: don't re-force the source; if the
1334
1443
  # user switched away from voiceio, fall back to clipboard.
@@ -70,10 +70,19 @@ def read_text() -> str | None:
70
70
 
71
71
 
72
72
  def _copy_via(cmd: list[str], text: str) -> bool:
73
- """Pipe text into a copy command. Returns True on success."""
73
+ """Pipe text into a copy command. Returns True on success.
74
+
75
+ stdout/stderr go to DEVNULL, NOT pipes: xclip daemonizes (forks a child
76
+ that owns the selection and inherits the fds), so captured pipes never
77
+ reach EOF and run() would block for the full timeout, then report a
78
+ bogus failure — which used to trigger the wl-copy fallback and its
79
+ focus steal on every single copy.
80
+ """
74
81
  try:
75
82
  result = subprocess.run(
76
- cmd, input=text.encode(), capture_output=True, timeout=_TIMEOUT,
83
+ cmd, input=text.encode(),
84
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
85
+ timeout=_TIMEOUT,
77
86
  )
78
87
  return result.returncode == 0
79
88
  except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
@@ -103,6 +112,13 @@ def copy_text(text: str) -> bool:
103
112
  return _copy_via(["pbcopy"], text)
104
113
 
105
114
  if p.is_wayland:
115
+ # Prefer xclip via the XWayland clipboard bridge: wl-copy can
116
+ # momentarily steal keyboard focus on GNOME (fallback surface),
117
+ # and a focus-out discards any visible IBus preedit — streaming
118
+ # milestone copies were wiping the underlined preview one frame
119
+ # after it painted. X11 selections never touch Wayland focus.
120
+ if shutil.which("xclip") and _copy_via(["xclip", "-selection", "clipboard"], text):
121
+ return True
106
122
  if shutil.which("wl-copy"):
107
123
  return _copy_via(["wl-copy", "--"], text)
108
124
  else:
@@ -43,9 +43,13 @@ class VoiceIOEngine(IBus.Engine):
43
43
  log.info("VoiceIOEngine instance created (path=%s)", kwargs.get("object_path"))
44
44
 
45
45
  def do_focus_in(self):
46
+ # Focus transitions also discard any visible preedit (IBus default
47
+ # mode), so log them — a flickering preedit is usually focus churn.
48
+ log.debug("focus_in")
46
49
  self._focused = True
47
50
 
48
51
  def do_focus_out(self):
52
+ log.debug("focus_out (preedit discarded by client if visible)")
49
53
  self._focused = False
50
54
 
51
55
  def do_process_key_event(self, keyval, keycode, state):
@@ -476,25 +476,17 @@ class IBusTyper:
476
476
  def _copy_to_clipboard(self, text: str) -> None:
477
477
  """Copy text to clipboard as backup for non-IBus apps (terminals).
478
478
 
479
- Fire-and-forget. wl-copy stays alive to serve paste requests.
480
- Kill previous instance to avoid accumulating processes.
479
+ Routed through clipboard_read.copy_text (xclip / XWayland bridge
480
+ first): wl-copy momentarily steals keyboard focus on GNOME, and the
481
+ focus-out discards any visible preedit.
481
482
  """
482
- if not self._wl_copy:
483
- return
484
- # Kill previous wl-copy (it's replaced by the new one)
483
+ # Kill any wl-copy left over from the pre-xclip implementation.
485
484
  if self._wl_copy_proc is not None:
486
485
  try:
487
486
  self._wl_copy_proc.kill()
488
487
  except OSError:
489
488
  pass
490
- try:
491
- proc = subprocess.Popen(
492
- ["wl-copy"],
493
- stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
494
- stderr=subprocess.DEVNULL,
495
- )
496
- proc.stdin.write(text.encode())
497
- proc.stdin.close()
498
- self._wl_copy_proc = proc
499
- except OSError as e:
500
- log.debug("wl-copy failed to start: %s", e)
489
+ self._wl_copy_proc = None
490
+ from voiceio import clipboard_read
491
+ if not clipboard_read.copy_text(text):
492
+ log.debug("Clipboard copy failed (no working tool)")
@@ -1 +0,0 @@
1
- __version__ = "0.7.0"
File without changes
File without changes
File without changes