python-voiceio 0.2.0__py3-none-any.whl

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.
voiceio/typers/base.py ADDED
@@ -0,0 +1,44 @@
1
+ """Base protocol and types for text injection backends."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol, runtime_checkable
5
+
6
+ from voiceio.backends import ProbeResult
7
+
8
+ __all__ = ["ProbeResult", "TyperBackend", "StreamingTyper"]
9
+
10
+
11
+ @runtime_checkable
12
+ class TyperBackend(Protocol):
13
+ """Protocol for text injection backends."""
14
+
15
+ name: str
16
+
17
+ def probe(self) -> ProbeResult:
18
+ """Check if this backend can work on the current system."""
19
+ ...
20
+
21
+ def type_text(self, text: str) -> None:
22
+ """Type text at the current cursor position."""
23
+ ...
24
+
25
+ def delete_chars(self, n: int) -> None:
26
+ """Delete n characters before cursor."""
27
+ ...
28
+
29
+
30
+ @runtime_checkable
31
+ class StreamingTyper(TyperBackend, Protocol):
32
+ """Extended protocol for backends that support preedit-based streaming."""
33
+
34
+ def update_preedit(self, text: str) -> None:
35
+ """Show text as preedit preview (can be freely replaced)."""
36
+ ...
37
+
38
+ def commit_text(self, text: str) -> None:
39
+ """Clear preedit and commit final text."""
40
+ ...
41
+
42
+ def clear_preedit(self) -> None:
43
+ """Clear preedit without committing."""
44
+ ...
@@ -0,0 +1,79 @@
1
+ """Fallback chain for typer backends."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import TYPE_CHECKING
6
+
7
+ from voiceio.backends import ProbeResult
8
+ from voiceio.typers.base import TyperBackend
9
+
10
+ if TYPE_CHECKING:
11
+ from voiceio.platform import Platform
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+ # Preference order: (display_server, desktop) -> backend list
16
+ _CHAINS: dict[tuple[str, str], list[str]] = {
17
+ ("x11", "*"): ["ibus", "xdotool", "clipboard"],
18
+ ("wayland", "gnome"): ["ibus", "ydotool", "clipboard"],
19
+ ("wayland", "kde"): ["ibus", "ydotool", "clipboard"],
20
+ ("wayland", "sway"): ["ibus", "wtype", "ydotool", "clipboard"],
21
+ ("wayland", "hyprland"): ["ibus", "wtype", "ydotool", "clipboard"],
22
+ ("wayland", "*"): ["ibus", "ydotool", "wtype", "clipboard"],
23
+ ("quartz", "*"): ["pynput", "clipboard"],
24
+ }
25
+
26
+
27
+ def _get_chain(platform: Platform) -> list[str]:
28
+ """Get the preference chain for this platform."""
29
+ key = (platform.display_server, platform.desktop)
30
+ if key in _CHAINS:
31
+ return _CHAINS[key]
32
+ key = (platform.display_server, "*")
33
+ if key in _CHAINS:
34
+ return _CHAINS[key]
35
+ return ["clipboard"]
36
+
37
+
38
+ def resolve(platform: Platform, override: str | None = None, **kwargs) -> list[tuple[str, TyperBackend | None, ProbeResult]]:
39
+ """Probe backends in preference order."""
40
+ from voiceio.typers import create_typer_backend
41
+
42
+ if override and override != "auto":
43
+ backend = create_typer_backend(override, platform, **kwargs)
44
+ result = backend.probe()
45
+ return [(override, backend, result)]
46
+
47
+ chain = _get_chain(platform)
48
+ results = []
49
+ for name in chain:
50
+ try:
51
+ backend = create_typer_backend(name, platform, **kwargs)
52
+ result = backend.probe()
53
+ results.append((name, backend, result))
54
+ except Exception as e:
55
+ log.debug("Failed to create typer '%s': %s", name, e)
56
+ results.append((name, None, ProbeResult(ok=False, reason=str(e))))
57
+
58
+ return results
59
+
60
+
61
+ def select(platform: Platform, override: str | None = None, **kwargs) -> TyperBackend:
62
+ """Select the first working typer backend.
63
+
64
+ Raises RuntimeError if none work.
65
+ """
66
+ results = resolve(platform, override, **kwargs)
67
+
68
+ for name, backend, probe in results:
69
+ if probe.ok and backend is not None:
70
+ log.info("Selected typer backend: %s", name)
71
+ return backend
72
+ log.debug("Typer backend '%s' unavailable: %s", name, probe.reason)
73
+
74
+ reasons = [f" {name}: {probe.reason}" for name, _, probe in results if not probe.ok]
75
+ hints = [probe.fix_hint for _, _, probe in results if probe.fix_hint]
76
+ msg = "No working typer backend found:\n" + "\n".join(reasons)
77
+ if hints:
78
+ msg += "\n\nTo fix:\n" + "\n".join(f" - {h}" for h in hints)
79
+ raise RuntimeError(msg)
@@ -0,0 +1,110 @@
1
+ """Clipboard-based text injection, the universal fallback."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+
10
+ from voiceio.backends import ProbeResult
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ class ClipboardTyper:
16
+ """Type text by copying to clipboard and simulating Ctrl+V / Cmd+V."""
17
+
18
+ name = "clipboard"
19
+
20
+ def __init__(self, platform=None):
21
+ self._copy_cmd: list[str] | None = None
22
+ self._paste_tool: list[str] | None = None
23
+ self._delete_tool: list[str] | None = None
24
+ self._tools_resolved = False
25
+
26
+ def _resolve_tools(self) -> None:
27
+ """Detect available tools once and cache."""
28
+ if self._tools_resolved:
29
+ return
30
+ self._tools_resolved = True
31
+
32
+ if sys.platform == "darwin":
33
+ if shutil.which("pbcopy"):
34
+ self._copy_cmd = ["pbcopy"]
35
+ return
36
+
37
+ session = os.environ.get("XDG_SESSION_TYPE", "")
38
+ if session == "wayland" or os.environ.get("WAYLAND_DISPLAY"):
39
+ if shutil.which("wl-copy"):
40
+ self._copy_cmd = ["wl-copy", "--"]
41
+ if shutil.which("ydotool"):
42
+ self._paste_tool = ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"]
43
+ self._delete_tool = ["ydotool"]
44
+ elif shutil.which("wtype"):
45
+ self._paste_tool = ["wtype", "-M", "ctrl", "-k", "v", "-m", "ctrl"]
46
+ self._delete_tool = ["wtype"]
47
+ else:
48
+ if shutil.which("xclip") and shutil.which("xdotool"):
49
+ self._copy_cmd = ["xclip", "-selection", "clipboard"]
50
+ self._paste_tool = ["xdotool", "key", "--clearmodifiers", "ctrl+v"]
51
+ self._delete_tool = ["xdotool"]
52
+
53
+ def probe(self) -> ProbeResult:
54
+ self._resolve_tools()
55
+ if self._copy_cmd is None or (sys.platform != "darwin" and self._paste_tool is None):
56
+ return ProbeResult(
57
+ ok=False,
58
+ reason="No clipboard tool found",
59
+ fix_hint="Install xclip (X11), wl-copy (Wayland), or pbcopy (macOS).",
60
+ )
61
+ return ProbeResult(ok=True)
62
+
63
+ def type_text(self, text: str) -> None:
64
+ if not text:
65
+ return
66
+ self._resolve_tools()
67
+
68
+ if sys.platform == "darwin":
69
+ subprocess.run(["pbcopy"], input=text.encode(), check=True, capture_output=True)
70
+ subprocess.run(
71
+ ["osascript", "-e", 'tell application "System Events" to keystroke "v" using command down'],
72
+ check=True, capture_output=True,
73
+ )
74
+ return
75
+
76
+ if self._copy_cmd is None:
77
+ raise RuntimeError("No clipboard tools available")
78
+
79
+ subprocess.run(self._copy_cmd, input=text.encode(), check=True, capture_output=True)
80
+ if self._paste_tool:
81
+ subprocess.run(self._paste_tool, check=True, capture_output=True)
82
+
83
+ def delete_chars(self, n: int) -> None:
84
+ if n <= 0:
85
+ return
86
+ self._resolve_tools()
87
+
88
+ if self._delete_tool and self._delete_tool[0] == "xdotool":
89
+ subprocess.run(
90
+ ["xdotool", "key", "--clearmodifiers", "--delay", "12"] + ["BackSpace"] * n,
91
+ check=True, capture_output=True,
92
+ )
93
+ elif self._delete_tool and self._delete_tool[0] == "ydotool":
94
+ # Batch all backspaces into one subprocess call
95
+ keys = []
96
+ for _ in range(n):
97
+ keys.extend(["14:1", "14:0"])
98
+ subprocess.run(["ydotool", "key"] + keys, check=True, capture_output=True)
99
+ elif self._delete_tool and self._delete_tool[0] == "wtype":
100
+ # Batch: -k BackSpace -k BackSpace ...
101
+ args = ["wtype"]
102
+ for _ in range(n):
103
+ args.extend(["-k", "BackSpace"])
104
+ subprocess.run(args, check=True, capture_output=True)
105
+ elif sys.platform == "darwin":
106
+ for _ in range(n):
107
+ subprocess.run(
108
+ ["osascript", "-e", 'tell application "System Events" to key code 51'],
109
+ check=True, capture_output=True,
110
+ )
voiceio/typers/ibus.py ADDED
@@ -0,0 +1,389 @@
1
+ """IBus text injection backend: atomic text insertion via input method."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import socket
8
+ import subprocess
9
+ import stat
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from voiceio.backends import ProbeResult
14
+ from voiceio.ibus import SOCKET_PATH
15
+
16
+ log = logging.getLogger(__name__)
17
+ COMPONENT_DIR = Path.home() / ".local" / "share" / "ibus" / "component"
18
+ COMPONENT_DEST = COMPONENT_DIR / "voiceio.xml"
19
+ LAUNCHER_DIR = Path.home() / ".local" / "share" / "voiceio"
20
+ LAUNCHER_PATH = LAUNCHER_DIR / "voiceio-ibus-engine"
21
+ PING_TIMEOUT = 2.0
22
+
23
+
24
+ def _find_system_python() -> str:
25
+ """Find the system Python 3 interpreter.
26
+
27
+ IBus GObject bindings (gi) are system packages, so we need the system
28
+ Python, not a venv or pipx Python.
29
+ """
30
+ # Check well-known system locations first (never a venv)
31
+ for path in ("/usr/bin/python3", "/usr/bin/python"):
32
+ if os.path.isfile(path) and os.access(path, os.X_OK):
33
+ return path
34
+ # Fallback: whatever python3 resolves to, excluding venvs
35
+ which = shutil.which("python3")
36
+ if which and "venv" not in which and "pipx" not in which:
37
+ return which
38
+ return "/usr/bin/python3"
39
+
40
+
41
+ # Resolved once at import time so probe() / install_component() use the same value.
42
+ SYSTEM_PYTHON = _find_system_python()
43
+
44
+
45
+ def _gnome_source_configured() -> bool:
46
+ """Check if voiceio is in GNOME input sources."""
47
+ from voiceio.platform import detect
48
+ if not detect().is_gnome:
49
+ return True # non-GNOME: no input source needed
50
+ try:
51
+ result = subprocess.run(
52
+ ["gsettings", "get", "org.gnome.desktop.input-sources", "sources"],
53
+ capture_output=True, text=True, timeout=3,
54
+ )
55
+ return result.returncode == 0 and "('ibus', 'voiceio')" in result.stdout
56
+ except (FileNotFoundError, subprocess.TimeoutExpired):
57
+ return True # can't check, assume OK
58
+
59
+
60
+ def _has_ibus_gi() -> bool:
61
+ """Check if system Python has IBus GObject bindings."""
62
+ try:
63
+ result = subprocess.run(
64
+ [SYSTEM_PYTHON, "-c", "import gi; gi.require_version('IBus', '1.0')"],
65
+ capture_output=True, timeout=5,
66
+ )
67
+ return result.returncode == 0
68
+ except (FileNotFoundError, subprocess.TimeoutExpired):
69
+ return False
70
+
71
+
72
+ def _ibus_daemon_running() -> bool:
73
+ """Check if the IBus daemon (ibus-daemon) is running."""
74
+ try:
75
+ result = subprocess.run(
76
+ ["ibus", "address"], capture_output=True, text=True, timeout=3,
77
+ )
78
+ return result.returncode == 0 and bool(result.stdout.strip())
79
+ except (FileNotFoundError, subprocess.TimeoutExpired):
80
+ return False
81
+
82
+
83
+ def _component_installed() -> bool:
84
+ """Check if the VoiceIO IBus component XML is installed."""
85
+ return COMPONENT_DEST.exists() and LAUNCHER_PATH.exists()
86
+
87
+
88
+ def _get_pkg_root() -> str:
89
+ """Get the directory that contains the ``voiceio`` package.
90
+
91
+ For a normal pip/pipx install this is the site-packages directory.
92
+ For an editable (``pip install -e .``) or bare git-clone this is the
93
+ project source root. Either way, adding the returned path to
94
+ PYTHONPATH lets ``python -m voiceio.ibus.engine`` work.
95
+ """
96
+ # Path(__file__) -> .../voiceio/typers/ibus.py
97
+ # .parent -> .../voiceio/typers/
98
+ # .parent -> .../voiceio/ (the package dir)
99
+ # .parent -> .../ (site-packages or source root)
100
+ return str(Path(__file__).resolve().parent.parent.parent)
101
+
102
+
103
+ def install_component() -> bool:
104
+ """Install the IBus engine launcher and component XML.
105
+
106
+ Creates a launcher script (with PYTHONPATH baked in) and a component XML
107
+ that points to it, then refreshes the IBus cache and restarts IBus.
108
+
109
+ Returns True if installed successfully.
110
+ """
111
+ pkg_root = _get_pkg_root()
112
+
113
+ # The Python that is running *right now* has voiceio installed. Record
114
+ # its site-packages (or source root) so the launcher can find voiceio
115
+ # even when launched by the system Python (which needs GObject/IBus
116
+ # bindings from the system packages).
117
+ #
118
+ # We also record the *current* Python as a fallback. If the system
119
+ # Python cannot import voiceio, the launcher will try the installing
120
+ # Python directly.
121
+ installing_python = sys.executable or "python3"
122
+
123
+ # Create launcher script
124
+ LAUNCHER_DIR.mkdir(parents=True, exist_ok=True)
125
+ launcher_content = f"""#!/bin/bash
126
+ # Generated by voiceio. Do not edit manually.
127
+ # voiceio package root baked in at install time:
128
+ VOICEIO_PKG_ROOT="{pkg_root}"
129
+
130
+ # Prefer the system Python (has GObject / IBus bindings).
131
+ SYSTEM_PY="{SYSTEM_PYTHON}"
132
+ # The Python that installed voiceio (fallback).
133
+ INSTALL_PY="{installing_python}"
134
+
135
+ # Add voiceio's package root so the system Python can import it.
136
+ export PYTHONPATH="${{VOICEIO_PKG_ROOT}}:${{PYTHONPATH}}"
137
+
138
+ # Try system Python first (needed for gi / IBus bindings).
139
+ if "$SYSTEM_PY" -c "import voiceio" 2>/dev/null; then
140
+ exec "$SYSTEM_PY" -m voiceio.ibus.engine "$@"
141
+ fi
142
+
143
+ # Fallback: the Python that installed voiceio (may be a venv/pipx Python
144
+ # that already has gi accessible via system site-packages).
145
+ exec "$INSTALL_PY" -m voiceio.ibus.engine "$@"
146
+ """
147
+ LAUNCHER_PATH.write_text(launcher_content)
148
+ LAUNCHER_PATH.chmod(LAUNCHER_PATH.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
149
+ log.info("Installed IBus engine launcher to %s", LAUNCHER_PATH)
150
+
151
+ # Write component XML pointing to launcher
152
+ component_xml = f"""<?xml version="1.0" encoding="utf-8"?>
153
+ <component>
154
+ <name>org.voiceio.ibus</name>
155
+ <description>VoiceIO voice input</description>
156
+ <exec>{LAUNCHER_PATH}</exec>
157
+ <version>1.0</version>
158
+ <author>voiceio</author>
159
+ <license>MIT</license>
160
+ <textdomain>voiceio</textdomain>
161
+ <engines>
162
+ <engine>
163
+ <name>voiceio</name>
164
+ <language>other</language>
165
+ <license>MIT</license>
166
+ <author>voiceio</author>
167
+ <layout>us</layout>
168
+ <longname>VoiceIO</longname>
169
+ <description>Voice-to-text input</description>
170
+ <rank>0</rank>
171
+ </engine>
172
+ </engines>
173
+ </component>
174
+ """
175
+ COMPONENT_DIR.mkdir(parents=True, exist_ok=True)
176
+ COMPONENT_DEST.write_text(component_xml)
177
+ log.info("Installed IBus component to %s", COMPONENT_DEST)
178
+
179
+ # IBus needs IBUS_COMPONENT_PATH to scan ~/.local/share/ibus/component/
180
+ ibus_env = _ibus_env()
181
+
182
+ # Refresh IBus cache and restart so it discovers the new engine
183
+ try:
184
+ subprocess.run(
185
+ ["ibus", "write-cache"], capture_output=True, timeout=5, env=ibus_env,
186
+ )
187
+ except (FileNotFoundError, subprocess.TimeoutExpired):
188
+ pass
189
+
190
+ try:
191
+ subprocess.run(
192
+ ["ibus", "restart"], capture_output=True, timeout=5, env=ibus_env,
193
+ )
194
+ log.info("IBus restarted with IBUS_COMPONENT_PATH")
195
+ except (FileNotFoundError, subprocess.TimeoutExpired):
196
+ log.warning("Could not restart IBus. Restart manually: ibus restart")
197
+
198
+ # Persist IBUS_COMPONENT_PATH so it survives reboot
199
+ _persist_ibus_env()
200
+
201
+ return True
202
+
203
+
204
+ def _ibus_env() -> dict[str, str]:
205
+ """Build environment with IBUS_COMPONENT_PATH including user component dir."""
206
+ env = os.environ.copy()
207
+ system_dir = "/usr/share/ibus/component"
208
+ user_dir = str(COMPONENT_DIR)
209
+ env["IBUS_COMPONENT_PATH"] = f"{user_dir}:{system_dir}"
210
+ return env
211
+
212
+
213
+ def _persist_ibus_env() -> None:
214
+ """Write IBUS_COMPONENT_PATH to environment.d so it survives reboot.
215
+
216
+ See systemd environment.d(5). Files in ~/.config/environment.d/ are
217
+ loaded by systemd user services and login sessions.
218
+ """
219
+ env_dir = Path.home() / ".config" / "environment.d"
220
+ env_file = env_dir / "voiceio.conf"
221
+ system_dir = "/usr/share/ibus/component"
222
+ user_dir = str(COMPONENT_DIR)
223
+ content = f"IBUS_COMPONENT_PATH={user_dir}:{system_dir}\n"
224
+
225
+ try:
226
+ if env_file.exists() and env_file.read_text() == content:
227
+ return # already persisted
228
+ env_dir.mkdir(parents=True, exist_ok=True)
229
+ env_file.write_text(content)
230
+ log.info("Persisted IBUS_COMPONENT_PATH to %s", env_file)
231
+ except OSError as e:
232
+ log.debug("Could not persist IBUS_COMPONENT_PATH: %s", e)
233
+
234
+
235
+ def _ensure_gnome_input_source() -> None:
236
+ """Add ('ibus', 'voiceio') to GNOME input sources if running GNOME."""
237
+ from voiceio.platform import detect
238
+ if not detect().is_gnome:
239
+ return
240
+
241
+ try:
242
+ result = subprocess.run(
243
+ ["gsettings", "get", "org.gnome.desktop.input-sources", "sources"],
244
+ capture_output=True, text=True, timeout=3,
245
+ )
246
+ if result.returncode != 0:
247
+ return
248
+ current = result.stdout.strip()
249
+ if "('ibus', 'voiceio')" in current:
250
+ log.debug("VoiceIO already in GNOME input sources")
251
+ return
252
+
253
+ # Parse current sources and append voiceio
254
+ # Format: [('xkb', 'us'), ('xkb', 'fr')]
255
+ if current.startswith("@as"):
256
+ # Empty array
257
+ new_sources = "[('ibus', 'voiceio')]"
258
+ elif current == "[]":
259
+ new_sources = "[('ibus', 'voiceio')]"
260
+ else:
261
+ # Insert voiceio at the end (keep current keyboard as default)
262
+ new_sources = current.rstrip("]") + ", ('ibus', 'voiceio')]"
263
+
264
+ subprocess.run(
265
+ ["gsettings", "set", "org.gnome.desktop.input-sources", "sources", new_sources],
266
+ capture_output=True, text=True, timeout=3,
267
+ )
268
+ log.info("Added VoiceIO to GNOME input sources: %s", new_sources)
269
+ except (FileNotFoundError, subprocess.TimeoutExpired):
270
+ pass
271
+
272
+
273
+ class IBusTyper:
274
+ """IBus preedit for streaming preview, IBus commit for final text.
275
+
276
+ Also copies committed text to clipboard so terminal users (where IBus
277
+ doesn't reach) can Ctrl+Shift+V to paste.
278
+ """
279
+
280
+ name = "ibus"
281
+
282
+ def __init__(self, platform=None, **kwargs):
283
+ self._wl_copy = shutil.which("wl-copy")
284
+ self._sock: socket.socket | None = None
285
+ self._wl_copy_proc: subprocess.Popen | None = None
286
+
287
+ def probe(self) -> ProbeResult:
288
+ if not shutil.which("ibus"):
289
+ return ProbeResult(
290
+ ok=False,
291
+ reason="ibus not installed",
292
+ fix_hint="sudo apt install ibus",
293
+ )
294
+
295
+ if not _has_ibus_gi():
296
+ return ProbeResult(
297
+ ok=False,
298
+ reason="IBus Python bindings not available for system Python",
299
+ fix_hint="sudo apt install gir1.2-ibus-1.0 python3-gi",
300
+ )
301
+
302
+ if not _ibus_daemon_running():
303
+ return ProbeResult(
304
+ ok=False,
305
+ reason="IBus daemon not running",
306
+ fix_hint="ibus-daemon -drxR",
307
+ )
308
+
309
+ if not _component_installed():
310
+ if install_component():
311
+ log.info("Auto-installed IBus component")
312
+ else:
313
+ return ProbeResult(
314
+ ok=False,
315
+ reason="VoiceIO IBus component not installed",
316
+ fix_hint="Run 'voiceio setup' to install",
317
+ )
318
+
319
+ if not _gnome_source_configured():
320
+ return ProbeResult(
321
+ ok=False,
322
+ reason="VoiceIO not in GNOME input sources",
323
+ fix_hint="Run 'voiceio setup' to configure",
324
+ )
325
+
326
+ return ProbeResult(ok=True)
327
+
328
+ def type_text(self, text: str) -> None:
329
+ if not text:
330
+ return
331
+ self._send(f"commit:{text}")
332
+ self._copy_to_clipboard(text)
333
+
334
+ def delete_chars(self, n: int) -> None:
335
+ pass # Not needed: preedit handles corrections atomically
336
+
337
+ def update_preedit(self, text: str) -> None:
338
+ self._send(f"preedit:{text}")
339
+
340
+ def commit_text(self, text: str) -> None:
341
+ """Clear preedit and commit via IBus. Also copies to clipboard."""
342
+ if not text:
343
+ self._send("clear")
344
+ return
345
+ log.debug("Committing via IBus (%d chars)", len(text))
346
+ self._send(f"commit:{text}")
347
+ self._copy_to_clipboard(text)
348
+
349
+ def clear_preedit(self) -> None:
350
+ self._send("clear")
351
+
352
+ def _send(self, msg: str) -> None:
353
+ """Send a command to the IBus engine via Unix DGRAM socket."""
354
+ try:
355
+ if self._sock is None:
356
+ self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
357
+ self._sock.sendto(msg.encode("utf-8"), str(SOCKET_PATH))
358
+ except OSError as e:
359
+ log.warning("IBus engine not reachable: %s", e)
360
+ # Reset socket on error so next call retries
361
+ if self._sock is not None:
362
+ self._sock.close()
363
+ self._sock = None
364
+
365
+ def _copy_to_clipboard(self, text: str) -> None:
366
+ """Copy text to clipboard as backup for non-IBus apps (terminals).
367
+
368
+ Fire-and-forget. wl-copy stays alive to serve paste requests.
369
+ Kill previous instance to avoid accumulating processes.
370
+ """
371
+ if not self._wl_copy:
372
+ return
373
+ # Kill previous wl-copy (it's replaced by the new one)
374
+ if self._wl_copy_proc is not None:
375
+ try:
376
+ self._wl_copy_proc.kill()
377
+ except OSError:
378
+ pass
379
+ try:
380
+ proc = subprocess.Popen(
381
+ ["wl-copy"],
382
+ stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
383
+ stderr=subprocess.DEVNULL,
384
+ )
385
+ proc.stdin.write(text.encode())
386
+ proc.stdin.close()
387
+ self._wl_copy_proc = proc
388
+ except OSError as e:
389
+ log.debug("wl-copy failed to start: %s", e)
@@ -0,0 +1,51 @@
1
+ """Pynput text injection backend for macOS (and X11 fallback)."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+
6
+ from voiceio.backends import ProbeResult
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+
11
+ class PynputTyper:
12
+ """Type text via pynput keyboard controller."""
13
+
14
+ name = "pynput"
15
+
16
+ def __init__(self):
17
+ self._controller = None
18
+
19
+ def _get_controller(self):
20
+ if self._controller is None:
21
+ from pynput.keyboard import Controller
22
+ self._controller = Controller()
23
+ return self._controller
24
+
25
+ def probe(self) -> ProbeResult:
26
+ try:
27
+ from pynput.keyboard import Controller # noqa: F401
28
+ except ImportError:
29
+ return ProbeResult(ok=False, reason="pynput not installed",
30
+ fix_hint="pip install pynput")
31
+
32
+ import os
33
+ session = os.environ.get("XDG_SESSION_TYPE", "")
34
+ if session == "wayland":
35
+ return ProbeResult(ok=False, reason="pynput typing does not work on Wayland")
36
+
37
+ return ProbeResult(ok=True)
38
+
39
+ def type_text(self, text: str) -> None:
40
+ if not text:
41
+ return
42
+ self._get_controller().type(text)
43
+
44
+ def delete_chars(self, n: int) -> None:
45
+ if n <= 0:
46
+ return
47
+ from pynput.keyboard import Key
48
+ kb = self._get_controller()
49
+ for _ in range(n):
50
+ kb.press(Key.backspace)
51
+ kb.release(Key.backspace)