Files
voculinux/src/vocalinux/text_injection/ibus_engine.py
T
soubarnak ea7be5f111 feat(identity): rename user-visible identity to voculinux with one-time legacy import
The fork takes its own name everywhere a user can see it: binaries
(voculinux, voculinux-gui), desktop entry, autostart, tray, D-Bus
namespace (com.voculinux.Voculinux, no alias for the old name),
config/data/cache dirs, single-instance lock, IBus engine identity,
tray icons, resource prefixes, VOCULINUX_* environment names, and the
update-checker user agent. The internal Python module stays vocalinux
and the distribution metadata keeps the upstream URLs as provenance.

On first run a previous Vocalinux install's config.json, custom
dictionary, and portal restore token are copied once into the new
locations (new config always wins, no merge, no write-back); models
re-resolve through the pinned-checksum download path. An offline
naming-sweep test proves no old-name strings remain on user-visible
surfaces outside the allowlist.

Refs #18
2026-11-06 01:29:39 +05:30

1906 lines
74 KiB
Python

"""
IBus engine for Voculinux text injection.
This module provides an IBus input method engine that acts as a transparent
proxy - it passes all keystrokes through normally while allowing Voculinux
to inject text directly via commit_text().
The engine should be set as the user's default input method. It will:
1. Pass all keyboard input through unchanged
2. Listen for text injection requests via a Unix socket or file
3. Commit injected text directly without switching engines
This is the preferred method for Wayland environments as it works universally
without requiring compositor-specific protocols.
"""
import functools
import logging
import os
import signal
import socket
import struct
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any, Callable, Mapping, Optional
# Package import when loaded normally; absolute / inline fallbacks when this
# file is executed by path (Voculinux start_engine_process / IBus component exec).
try:
from ..app_identity import (
APP_NAME,
COMPONENT_NAME,
ENGINE_DESCRIPTION,
ENGINE_LONGNAME,
ENGINE_NAME,
IBUS_DIR_NAME,
IBUS_TEXTDOMAIN,
)
from ..utils.host_process import host_env
from ..utils.paths import xdg_data_home
except ImportError: # pragma: no cover - exercised via script-path subprocess
try:
from vocalinux.app_identity import (
APP_NAME,
COMPONENT_NAME,
ENGINE_DESCRIPTION,
ENGINE_LONGNAME,
ENGINE_NAME,
IBUS_DIR_NAME,
IBUS_TEXTDOMAIN,
)
from vocalinux.utils.host_process import host_env
from vocalinux.utils.paths import xdg_data_home
except ImportError:
# Last resort: same semantics as the vocalinux.utils originals, except
# that host_env cannot strip a bundle it can no longer import.
# Identity literals below keep in step with voculinux/app_identity.py.
APP_NAME = "Voculinux"
COMPONENT_NAME = "org.freedesktop.IBus.Voculinux"
ENGINE_DESCRIPTION = "Voculinux voice dictation (use as default input method)"
ENGINE_LONGNAME = "Voculinux"
ENGINE_NAME = "voculinux"
IBUS_DIR_NAME = "voculinux-ibus"
IBUS_TEXTDOMAIN = "voculinux"
def host_env(base: Optional[Mapping[str, str]] = None) -> dict[str, str]:
return dict(os.environ if base is None else base)
def xdg_data_home() -> str:
return os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
logger = logging.getLogger(__name__)
class IBusSetupError(RuntimeError):
"""
Exception raised when IBus engine setup fails.
This exception is raised when the IBus text injector cannot be properly
initialized, allowing the caller to fall back to alternative text
injection methods.
"""
pass
# Check if IBus is available
try:
import gi
gi.require_version("IBus", "1.0")
from gi.repository import GLib, GObject, IBus
IBUS_AVAILABLE = True
except (ImportError, ValueError) as e:
logger.debug(f"IBus not available: {e}")
IBUS_AVAILABLE = False
IBus = None
GLib = None
GObject = None
# File paths for communication ($XDG_DATA_HOME, defaults to ~/.local/share).
# Identity strings come from vocalinux/app_identity.py (see the import above);
# the homepage below is the retained upstream URL, never renamed.
VOCULINUX_IBUS_DIR = Path(xdg_data_home()) / IBUS_DIR_NAME
SOCKET_PATH = VOCULINUX_IBUS_DIR / "inject.sock"
PID_FILE = VOCULINUX_IBUS_DIR / "engine.pid"
# Shared metadata used by component XML, --xml output, and runtime registration.
# Kept in one place to prevent drift between the three consumers.
ENGINE_RANK = 50
_ENGINE_META = {
"language": "other",
"license": "AGPL-3.0",
"author": APP_NAME,
"icon": "audio-input-microphone",
"layout": "default",
}
_COMPONENT_META = {
"version": "1.0",
"license": "AGPL-3.0",
"author": APP_NAME,
"homepage": "https://github.com/VocaHQ/vocalinux",
"textdomain": IBUS_TEXTDOMAIN,
}
def ensure_ibus_dir() -> None:
"""Ensure the IBus data directory exists with secure permissions."""
VOCULINUX_IBUS_DIR.mkdir(parents=True, exist_ok=True)
# Secure the directory - only owner can access (prevents socket hijacking)
VOCULINUX_IBUS_DIR.chmod(0o700)
def verify_peer_credentials(conn: socket.socket) -> bool:
"""
Verify that the connecting process belongs to the same user.
Uses SO_PEERCRED to get the UID of the peer process and compares
it to our own UID. This prevents other users from injecting text.
Args:
conn: The connected socket
Returns:
True if peer is same user, False otherwise
"""
try:
# SO_PEERCRED returns a struct with pid, uid, gid
# struct ucred { pid_t pid; uid_t uid; gid_t gid; }
cred = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("iII"))
pid, uid, gid = struct.unpack("iII", cred)
my_uid = os.getuid()
if uid != my_uid:
logger.warning(f"Rejected connection from UID {uid} (expected {my_uid})")
return False
logger.debug(f"Accepted connection from PID {pid}, UID {uid}")
return True
except (OSError, struct.error) as e:
logger.error(f"Failed to verify peer credentials: {e}")
return False
def is_ibus_available() -> bool:
"""Check if IBus is available on the system."""
return IBUS_AVAILABLE
def is_ibus_daemon_running() -> bool:
"""
Check if the IBus daemon (ibus-daemon) is currently running.
This is important because on some desktop environments (e.g., Fedora KDE),
the IBus daemon is not started by default. Attempting to set up IBus
when the daemon isn't running will fail.
Returns:
True if ibus-daemon is running, False otherwise
"""
try:
result = subprocess.run(
["pgrep", "-x", "ibus-daemon"], capture_output=True, timeout=2, env=host_env()
)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
def _is_real_ibus_engine(engine: Optional[str]) -> bool:
"""Return True only for a real IBus IM engine, not a bare XKB layout.
IBus reports a bare ``xkb:*`` engine (e.g. ``xkb:us::eng``) when it is doing
keyboard-layout passthrough with no input-method client attached. In that
state ``commit_text()`` succeeds at the IBus layer but never reaches the
focused application, so treating it as active causes a silent no-op on
Wayland (see issue #478). Real IM engines (anthy, libpinyin, hangul, ...) do
not use the ``xkb:`` prefix.
"""
if not engine:
return False
engine = engine.strip()
if not engine or engine.lower() == "no global engine":
return False
return not engine.startswith("xkb:")
def is_ibus_active_input_method() -> bool:
"""
Check if IBus is the currently active input method.
Checks environment variables and, where they are not set (common on
Wayland), whether the IBus daemon is running with an engine.
On Wayland there is an extra requirement: IBus can only deliver
``commit_text()`` to applications through a *real* IM engine. A bare
``xkb:*`` engine is a layout passthrough, so on compositors that don't fully
bridge IBus to native clients (niri, sway, and even GNOME/Mutter with only
an ``xkb`` engine) injection silently reaches nothing. We therefore require a
real engine on Wayland regardless of how IBus was detected (issue #478). On
X11/XWayland IBus reaches apps through XIM, so that check is not applied
there.
Returns:
True if IBus appears to be the active input method, False otherwise
"""
gtk_im = os.environ.get("GTK_IM_MODULE", "").lower()
qt_im = os.environ.get("QT_IM_MODULE", "").lower()
xmodifiers = os.environ.get("XMODIFIERS", "").lower()
ibus_via_env = ("ibus" in gtk_im) or ("ibus" in qt_im) or ("@im=ibus" in xmodifiers)
# If another (non-IBus) input method is explicitly configured, respect it.
if not ibus_via_env and (
gtk_im or qt_im or (xmodifiers not in ("", "@im=none") and "@im=" in xmodifiers)
):
logger.debug(
"Another input method is explicitly configured "
f"(GTK_IM_MODULE={gtk_im or 'not set'}, "
f"QT_IM_MODULE={qt_im or 'not set'}, "
f"XMODIFIERS={xmodifiers or 'not set'})"
)
return False
daemon_running = is_ibus_daemon_running()
# Neither an IBus env-var signal nor a running daemon -> IBus is not active.
if not ibus_via_env and not daemon_running:
logger.debug(
"IBus does not appear to be the active input method "
f"(GTK_IM_MODULE={gtk_im or 'not set'}, "
f"QT_IM_MODULE={qt_im or 'not set'}, "
f"XMODIFIERS={xmodifiers or 'not set'}, daemon running: False)"
)
return False
if _is_wayland_session():
# On Wayland a bare xkb:* engine can't deliver commits to apps, so
# require a real IM engine regardless of how IBus was detected (#478).
engine = get_current_engine() if daemon_running else None
if not _is_real_ibus_engine(engine):
logger.debug(
"IBus is present on Wayland but the current engine "
f"({engine or 'none'}) is a bare XKB layout or unset; "
"commit_text() would not reach applications, so treating IBus "
"as inactive (see #478)."
)
return False
logger.debug(f"IBus detected as active input method on Wayland (engine: {engine})")
return True
# X11 / XWayland: IBus reaches apps via XIM only if those apps actually
# talk to IBus. Env vars are the signal. A leftover or self-started daemon
# with a bare xkb engine on KDE is not the session IM; commit_text() then
# reports success while Kate/Qt/GTK get nothing (issue #752).
if ibus_via_env:
logger.debug("IBus detected as active input method via environment variables")
return True
if _is_kde_session():
logger.debug(
"KDE session without IBus IM env; not treating a running ibus-daemon "
"as the session IM (see #752)"
)
return False
engine = get_current_engine()
if engine:
logger.debug(
"IBus detected as active input method via running daemon "
f"(no env vars set, current engine: {engine})"
)
return True
logger.debug("IBus does not appear to be the active input method (daemon has no engine)")
return False
def start_ibus_daemon():
"""
Start the IBus daemon if it's not already running.
X11 only, and only when this session already opted into IBus via
GTK_IM_MODULE / QT_IM_MODULE / XMODIFIERS. On Wayland we must not spawn
``ibus-daemon -x -d -r``: that XIM-mode daemon is not bridged to the
compositor, but still makes ``is_ibus_daemon_running()`` return True and
leads to silent no-op injection (issue #574). The same happens on KDE
(and other non-IBus X11 sessions) if we start a daemon the apps never
talk to (issue #752). The compositor/DE must own the IBus instance.
Returns:
True if daemon was started or already running, False on failure
"""
if is_ibus_daemon_running():
return True
if _is_wayland_session():
# XIM -x is not compositor-bridged; spawning it only fools the pgrep check (#574).
logger.debug("Not starting ibus-daemon on Wayland (see #574)")
return False
if not _ibus_im_env_configured():
logger.debug(
"Not starting ibus-daemon: this session has no IBus IM env "
"(GTK_IM_MODULE / QT_IM_MODULE / XMODIFIERS). A self-started "
"daemon reports inject success while apps ignore it (#752, #574)."
)
return False
if not is_ibus_available():
return False
try:
# Start ibus-daemon in the background with XIM support
# -x: Enable XIM (X Input Method)
# -d: Run as daemon
# -r: Replace existing daemon
subprocess.Popen(
["ibus-daemon", "-x", "-d", "-r"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
env=host_env(),
)
# Give daemon time to start
time.sleep(0.5)
return is_ibus_daemon_running()
except FileNotFoundError:
# ibus-daemon not found
return False
except Exception:
return False
def _get_exec_command() -> str:
"""Return the engine exec command for component XML and runtime registration."""
engine_script = Path(__file__).resolve()
return f"{sys.executable} {engine_script} --ibus"
def is_engine_active() -> bool:
"""Check if the Voculinux IBus engine is currently active."""
try:
import subprocess
result = subprocess.run(
["ibus", "engine"], capture_output=True, text=True, timeout=5, env=host_env()
)
return result.returncode == 0 and ENGINE_NAME in result.stdout.strip()
except (subprocess.SubprocessError, FileNotFoundError):
return False
def _is_gnome_session() -> bool:
"""Check if the current session is running under GNOME."""
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "")
return "GNOME" in desktop.upper()
def _read_gnome_input_sources_key(key: str) -> Optional[list]:
"""Return the parsed list for a gsettings org.gnome.desktop.input-sources key.
Returns None when the key cannot be read or parsed. A successfully read
empty list comes back as ``[]`` so callers can tell a failed read from a
configured-empty list.
"""
try:
result = subprocess.run(
["gsettings", "get", "org.gnome.desktop.input-sources", key],
capture_output=True,
text=True,
timeout=5,
env=host_env(),
)
if result.returncode != 0:
logger.debug(f"gsettings get {key} failed; not using GNOME fallback")
return None
import ast
sources_text = result.stdout.strip()
# gsettings prints a type annotation on empty arrays — "@a(ss) []" for
# input sources, "@as []" for xkb-options — which literal_eval rejects.
if sources_text.startswith("@"):
sources_text = sources_text.split(" ", 1)[1] if " " in sources_text else "[]"
sources = ast.literal_eval(sources_text)
if not isinstance(sources, (list, tuple)):
return None
return list(sources)
except (subprocess.SubprocessError, OSError, TypeError, ValueError, SyntaxError) as e:
logger.debug(f"GNOME fallback via gsettings failed: {e}")
return None
def _get_gnome_current_source() -> Optional[tuple[str, str]]:
"""Return GNOME's current input source from the MRU list, or from the
configured source list when GNOME has not recorded an MRU entry yet."""
if not _is_gnome_session():
return None
sources = _read_gnome_input_sources_key("mru-sources")
if not sources:
logger.debug("GNOME mru-sources is empty; falling back to the configured sources list")
sources = _read_gnome_input_sources_key("sources")
if not sources:
logger.debug("GNOME mru-sources and sources are both empty or invalid")
return None
source = sources[0]
if not isinstance(source, (list, tuple)) or len(source) != 2:
logger.debug("GNOME current input source has an invalid shape")
return None
source_type, source_id = source
if not isinstance(source_type, str) or not isinstance(source_id, str) or not source_id:
logger.debug("GNOME current input source has invalid values")
return None
return source_type, source_id
def _resolve_registered_xkb_engine(source_id: str) -> Optional[str]:
"""Resolve a GNOME XKB source to a registered IBus engine ID."""
layout, _, variant = source_id.partition("+")
if not layout:
return None
try:
result = subprocess.run(
["ibus", "list-engine", "--name-only"],
capture_output=True,
text=True,
timeout=5,
env=host_env(),
)
if result.returncode != 0:
logger.debug("ibus list-engine failed; cannot verify the GNOME XKB source")
return None
matches = []
for engine_name in result.stdout.splitlines():
engine_name = engine_name.strip()
parts = engine_name.split(":")
if len(parts) == 4 and parts[0] == "xkb" and parts[1] == layout and parts[2] == variant:
matches.append(engine_name)
if matches:
# Multiple engine IDs can share an identical XKB layout/variant and
# differ only in language metadata. The first registered match keeps
# the same keyboard map without fabricating an engine name.
logger.debug(f"GNOME XKB source '{source_id}' maps to registered engine '{matches[0]}'")
return matches[0]
logger.debug(f"GNOME XKB source '{source_id}' has no registered IBus engine")
except (subprocess.SubprocessError, OSError) as e:
logger.debug(f"Could not list registered IBus engines: {e}")
return None
def get_current_engine_gnome_fallback() -> Optional[str]:
"""
Get a verified IBus engine for GNOME's current input source.
On GNOME/Wayland, IBus may have no global engine (input sources are managed
by Mutter, not ibus-daemon). GNOME's ``current`` key is deprecated and
ignored, so the first entry in ``mru-sources`` is used as the current
source, falling back to ``sources`` (GNOME's static configured list) when
no MRU entry has been recorded yet.
Explicit IBus source IDs are returned directly. XKB source IDs are matched
against registered IBus engines; names such as ``xkb:cn::`` are never
fabricated because a failed restoration can leave Voculinux active.
Returns:
A verified IBus engine ID, or None if the current source cannot be
mapped reliably to one.
"""
source = _get_gnome_current_source()
if not source:
return None
source_type, source_id = source
if source_type == "ibus":
logger.debug(f"GNOME fallback: current source is IBus engine '{source_id}'")
return source_id
if source_type == "xkb":
return _resolve_registered_xkb_engine(source_id)
logger.debug(f"GNOME fallback: unknown source type '{source_type}'")
return None
def get_current_engine() -> Optional[str]:
"""
Get the currently active IBus engine name.
On GNOME/Wayland, IBus may have no global engine set (Mutter manages input
sources). In that case ``ibus engine`` can return a misleading
``xkb:us::eng`` default. GNOME sources are accepted only when they are
explicit IBus IDs or map to a registered XKB engine.
Returns:
The IBus engine name, or None if it cannot be determined.
"""
try:
result = subprocess.run(
["ibus", "engine"], capture_output=True, text=True, timeout=5, env=host_env()
)
# Check for "No global engine" in stderr (ibus-daemon has no
# global engine set — common on GNOME/Wayland where Mutter manages
# input sources).
stderr_lower = result.stderr.lower() if result.stderr else ""
if "no global engine" in stderr_lower:
logger.debug(
"ibus engine reports 'No global engine'; "
"checking GNOME's most recent IBus source"
)
return get_current_engine_gnome_fallback()
if result.returncode == 0:
engine = result.stdout.strip()
if not engine:
return None
# On GNOME, ``ibus engine`` can return ``xkb:us::eng`` as a
# default fallback even when the actual layout differs. Detect
# it and try the gsettings fallback first (see #497, #699).
if engine == "xkb:us::eng" and _is_gnome_session():
logger.debug(
"ibus engine returned 'xkb:us::eng' on GNOME/Wayland; "
"attempting GNOME gsettings fallback (see #497)"
)
gnome_engine = get_current_engine_gnome_fallback()
if gnome_engine:
logger.debug(
f"GNOME gsettings reports different engine: "
f"{gnome_engine} (was {engine})"
)
return gnome_engine
logger.debug(
"GNOME did not report a restorable IBus source; "
"ignoring the suspicious xkb:us::eng default"
)
return None
return engine
except (subprocess.SubprocessError, FileNotFoundError):
pass
return None
def _query_active_engine() -> Optional[str]:
"""
Query the engine ibus-daemon actually reports, with no GNOME fallbacks.
Unlike :func:`get_current_engine`, this never consults gsettings, so it
can distinguish a really-active engine from a cleared global engine —
``None`` means either "No global engine" or that the query failed.
"""
try:
result = subprocess.run(
["ibus", "engine"], capture_output=True, text=True, timeout=5, env=host_env()
)
stderr_lower = result.stderr.lower() if result.stderr else ""
if "no global engine" in stderr_lower:
return None
if result.returncode == 0:
return result.stdout.strip() or None
except (subprocess.SubprocessError, FileNotFoundError):
pass
return None
def _ibus_im_env_configured() -> bool:
"""True if the process env says IBus is the session input method."""
gtk_im = os.environ.get("GTK_IM_MODULE", "").lower()
qt_im = os.environ.get("QT_IM_MODULE", "").lower()
xmodifiers = os.environ.get("XMODIFIERS", "").lower()
return ("ibus" in gtk_im) or ("ibus" in qt_im) or ("@im=ibus" in xmodifiers)
def _is_kde_session() -> bool:
"""Check if the current session is KDE Plasma."""
if os.environ.get("KDE_FULL_SESSION", "").lower() == "true":
return True
desktop = " ".join(
os.environ.get(var, "") for var in ("XDG_CURRENT_DESKTOP", "DESKTOP_SESSION", "GDMSESSION")
).lower()
return "kde" in desktop or "plasma" in desktop
def _is_wayland_session() -> bool:
"""Check if the current session is running under Wayland.
GTK/Qt apps on Plasma Wayland often keep XDG_SESSION_TYPE=x11. A live
WAYLAND_DISPLAY socket is the reliable hint (issue #752).
"""
if os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland":
return True
return bool(os.environ.get("WAYLAND_DISPLAY"))
def get_current_xkb_layout() -> tuple:
"""
Get the current XKB keyboard layout, variant, and options.
This queries the system's current keyboard configuration using setxkbmap.
This is important because IBus may not reflect the actual XKB layout,
especially when the user configured their keyboard via desktop environment
settings or setxkbmap directly rather than through IBus.
On Wayland, setxkbmap does not reflect the compositor's keyboard state
and can return incorrect results. Returns empty values in that case to
signal that XKB layout management should be skipped.
Returns:
A tuple of (layout, variant, option). Returns ("", "", "") on Wayland
or on error, ("us", "", "") as X11 default.
"""
if _is_wayland_session():
logger.debug(
"Wayland session detected — skipping setxkbmap query. "
"Keyboard layout is managed by the Wayland compositor."
)
return "", "", ""
try:
result = subprocess.run(
["setxkbmap", "-query"], capture_output=True, text=True, timeout=2, env=host_env()
)
if result.returncode == 0:
layout, variant, option = "us", "", ""
for line in result.stdout.split("\n"):
line = line.strip()
if line.startswith("layout:"):
layout = line.split(":", 1)[1].strip()
elif line.startswith("variant:"):
variant = line.split(":", 1)[1].strip()
elif line.startswith("options:"):
option = line.split(":", 1)[1].strip()
logger.debug(f"Current XKB layout: {layout}, variant: {variant}, option: {option}")
return layout, variant, option
except (subprocess.SubprocessError, FileNotFoundError) as e:
logger.debug(f"Could not query XKB layout: {e}")
return "us", "", ""
def restore_xkb_layout(layout: str, variant: str = "", option: str = "") -> bool:
"""
Restore the XKB keyboard layout.
This is used to ensure the user's keyboard layout is preserved
after IBus engine operations that might change it.
Args:
layout: The XKB layout to set (e.g., "us", "es", "de")
variant: Optional layout variant
option: Optional layout options
Returns:
True if successful, False otherwise
"""
if not layout:
return False
# On Wayland the compositor owns the keyboard layout. setxkbmap only reaches
# the XWayland X11 server, so re-applying a layout here (or the historical
# "us" default when nothing was captured) silently switches XWayland — and
# therefore XWayland/Electron apps — to the wrong layout, while native
# Wayland apps and localectl stay correct. Skip it on Wayland and let the
# compositor manage the layout. See issue #474.
if _is_wayland_session():
logger.debug(
"Wayland session detected — skipping setxkbmap; the compositor "
"manages the keyboard layout (see #474)."
)
return False
try:
cmd = ["setxkbmap", "-layout", layout]
if variant:
cmd.extend(["-variant", variant])
if option:
# Clear existing options first, then set new ones
cmd = ["setxkbmap", "-option", ""] + cmd[1:]
cmd.extend(["-option", option])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=2, env=host_env())
if result.returncode == 0:
logger.info(f"Restored XKB layout: {layout} (variant: {variant}, option: {option})")
return True
else:
logger.warning(f"Failed to restore XKB layout: {result.stderr}")
except (subprocess.SubprocessError, FileNotFoundError) as e:
logger.error(f"Could not restore XKB layout: {e}")
return False
def _x11_display_available() -> bool:
"""True when an X11 display is advertised (XWayland or native X)."""
display = os.environ.get("DISPLAY")
return bool(display and display.strip())
def _get_gnome_xkb_keymap() -> Optional[tuple[list, list, list]]:
"""Return GNOME's complete XKB map as (layouts, variants, options).
Layouts and variants come from every ``xkb`` entry in GNOME's ``sources``
list, kept in list order — that is also the XKB group order Mutter
signals to XWayland on an input-source switch. Options come from the
``xkb-options`` key (e.g. ``grp:alt_shift_toggle``).
"""
if not _is_gnome_session():
return None
sources = _read_gnome_input_sources_key("sources")
if not sources:
return None
layouts: list = []
variants: list = []
for source in sources:
if not isinstance(source, (list, tuple)) or len(source) != 2:
continue
source_type, source_id = source
if source_type != "xkb" or not isinstance(source_id, str) or not source_id:
continue
layout, _, variant = source_id.partition("+")
if layout:
layouts.append(layout)
variants.append(variant)
if not layouts:
return None
# The repair write clears XKB options with 'setxkbmap -option ""' before
# re-adding them, so a failed options read must not look like an empty
# list — it would drop the live XWayland options it could not see.
options_list = _read_gnome_input_sources_key("xkb-options")
if options_list is None:
logger.debug("GNOME xkb-options unreadable; not syncing the XWayland map")
return None
options = [option for option in options_list if isinstance(option, str) and option]
return layouts, variants, options
def _query_xserver_xkb_rules() -> Optional[tuple[str, str, str]]:
"""Return (layout, variant, options) of the X server's current keymap.
Unlike ``get_current_xkb_layout`` this queries regardless of session
type: on Wayland it reads the XWayland map, which is what we repair.
"""
try:
result = subprocess.run(
["setxkbmap", "-query"], capture_output=True, text=True, timeout=2, env=host_env()
)
except (subprocess.SubprocessError, OSError) as e:
logger.debug(f"Could not query the X server XKB rules: {e}")
return None
if result.returncode != 0:
return None
layout, variant, options = "", "", ""
for line in result.stdout.splitlines():
key, sep, value = line.partition(":")
if not sep:
continue
if key.strip() == "layout":
layout = value.strip()
elif key.strip() == "variant":
variant = value.strip()
elif key.strip() == "options":
options = value.strip()
return layout, variant, options
def _xkb_list(value: str) -> list:
"""Split a setxkbmap comma list into non-empty entries."""
return [part for part in value.split(",") if part]
def sync_xwayland_layout_from_gnome() -> bool:
"""Restore GNOME's complete XKB keymap on XWayland after scoped inject.
On GNOME Wayland, Mutter hands XWayland one keymap holding every
configured layout as a group plus all ``xkb-options``; input-source
switches only move the active group inside it. The Voculinux engine
round-trip (``ibus engine xkb:*`` applies a single-layout map) — and any
plain ``setxkbmap -layout X`` — leaves XWayland with a one-group map:
XKB group toggles like ``grp:alt_shift_toggle`` have no second group to
reach, and Mutter's group-index updates address a group that no longer
exists, so X11 clients lose both Alt+Shift and Super+Space switching
until relogin while native Wayland apps keep working (#848).
To repair that — and keep the #738 guarantee that XWayland follows the
live GNOME source after the engine round-trip — write the *full* GNOME
configuration: every ``xkb`` source in ``sources`` order plus all
``xkb-options``. The write only happens when the live rules differ;
rewriting an already-correct map would reset the active group to the
first layout. After a repair write the active group starts at the first
source until Mutter's next group event, which any keypress or focus
change produces.
Non-GNOME sessions and native Wayland sessions with no X DISPLAY are
left alone — a gsettings schema copied onto another desktop can hold
stale sources that do not describe that desktop's keymap.
"""
if not _is_wayland_session():
return False
if not _x11_display_available():
logger.debug("No X DISPLAY; skipping XWayland layout sync (see #738)")
return False
keymap = _get_gnome_xkb_keymap()
if not keymap:
return False
layouts, variants, options = keymap
current = _query_xserver_xkb_rules()
if current is not None:
cur_layout, cur_variant, cur_options = current
cur_variants = cur_variant.split(",") if cur_variant else []
cur_variants += [""] * (len(layouts) - len(cur_variants))
if (
_xkb_list(cur_layout) == layouts
and cur_variants[: len(layouts)] == variants
and sorted(_xkb_list(cur_options)) == sorted(options)
):
logger.debug("XWayland already has GNOME's complete XKB map; not rewriting it")
return True
# Clear inherited XKB options first: setxkbmap otherwise merges new
# options into whatever the server already had.
cmd = ["setxkbmap", "-option", ""]
for option in options:
cmd.extend(["-option", option])
cmd.extend(["-layout", ",".join(layouts)])
if any(variants):
cmd.extend(["-variant", ",".join(variants)])
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=2,
env=host_env(),
)
if result.returncode == 0:
logger.debug(
f"Synced XWayland keymap to GNOME XKB sources "
f"({','.join(layouts)}, options: {','.join(options)}) (see #738, #848)"
)
return True
logger.debug(f"setxkbmap failed while syncing XWayland: {result.stderr}")
except (subprocess.SubprocessError, FileNotFoundError, OSError) as e:
logger.debug(f"Could not sync XWayland layout: {e}")
return False
def _invoke_parent_destroy(parent_destroy: Callable[[], None], instance: object) -> None:
"""Call the parent engine destroy, working around PyGObject's GType binding.
PyGObject binds the ``do_destroy`` vfunc to the GType rather than to the
instance, so ``super().do_destroy()`` raises
``TypeError: IBus.Object.destroy() takes exactly 1 argument (0 given)``.
Retry with the instance explicitly.
The TypeError path rather than an unconditional ``IBus.Object.destroy``
keeps this working if a future PyGObject binds the vfunc correctly.
"""
try:
parent_destroy()
except TypeError:
IBus.Object.destroy(instance)
def _handle_engine_destroy(
active_instance: Optional["VocalinuxEngine"],
current_instance: object,
ibus_available: bool,
super_destroy: Optional[Callable[[], None]] = None,
) -> Optional["VocalinuxEngine"]:
"""Compute next active engine state and invoke optional parent destroy."""
next_active_instance = active_instance
if active_instance is current_instance:
next_active_instance = None
if ibus_available and super_destroy is not None:
# The caller assigns our return value to _active_instance and clears the
# focus event, so a failing parent destroy must not skip that teardown:
# otherwise a destroyed engine stays registered as active with focus set.
try:
super_destroy()
except Exception:
logger.exception("Parent engine destroy failed; continuing teardown")
return next_active_instance
def _ibus_ping_status(active_instance: object, focus_event: threading.Event) -> bytes:
"""Readiness bytes for the inject socket PING probe (#523).
FOCUSED: bound to a client context (safe to commit).
OK: engine instance exists but FocusIn has not arrived yet.
NO_ENGINE: no active instance.
"""
# Use identity checks: VocalinuxEngine may subclass MagicMock in tests,
# so truthiness on the instance is unreliable.
if active_instance is None:
return b"NO_ENGINE"
if focus_event.is_set():
return b"FOCUSED"
return b"OK"
def _ibus_ensure_client_focus(
active_instance: object,
focus_event: threading.Event,
timeout: float,
) -> bool:
"""Wait for FocusIn before commit_text when needed (#523).
Returns True when a client context is focused (or already was).
Returns False when there is no instance or FocusIn times out.
"""
if active_instance is None:
return False
if focus_event.is_set():
return True
return focus_event.wait(timeout=timeout)
def switch_engine(engine_name: str) -> bool:
"""Switch to the specified IBus engine."""
import time
try:
import subprocess
subprocess.run(
["ibus", "engine", engine_name],
capture_output=True,
text=True,
timeout=5,
env=host_env(),
)
# ibus engine command may return non-zero even on success
# So we verify by checking the current engine
time.sleep(0.2)
current = get_current_engine()
if current == engine_name:
return True
else:
logger.warning(f"Engine switch failed: expected {engine_name}, got {current}")
return False
except (subprocess.SubprocessError, FileNotFoundError) as e:
logger.error(f"Failed to switch IBus engine: {e}")
return False
def is_engine_process_running() -> bool:
"""Check if the Voculinux IBus engine process is running."""
try:
if not PID_FILE.exists():
return False
pid = int(PID_FILE.read_text().strip())
# Check if process exists and is our engine
os.kill(pid, 0) # Raises OSError if process doesn't exist
# Verify it's actually our process by checking cmdline
cmdline_path = Path(f"/proc/{pid}/cmdline")
if cmdline_path.exists():
cmdline = cmdline_path.read_text()
return "ibus_engine.py" in cmdline and "voculinux" in cmdline
return True # Process exists but can't verify cmdline
except (OSError, ValueError, FileNotFoundError):
# Process doesn't exist or PID file is invalid
if PID_FILE.exists():
PID_FILE.unlink() # Clean up stale PID file
return False
def start_engine_process() -> bool:
"""
Start the IBus engine process in the background.
This should be called by Voculinux on startup to ensure the engine
is running before attempting to switch to it.
Returns:
True if the engine was started or is already running, False otherwise
"""
import time
if is_engine_process_running():
logger.debug("IBus engine process already running")
return True
engine_script = Path(__file__).resolve()
logger.info(f"Starting IBus engine process: {engine_script}")
try:
# Same interpreter, environment untouched: this child is ours, and in an
# AppImage host_env() would strip the very paths it needs to find its
# stdlib, packages and GI stack.
env = os.environ.copy()
# Ensure PYTHONPATH includes current site-packages if in a venv
if hasattr(sys, "prefix") and sys.prefix != sys.base_prefix:
# We're in a virtual environment - ensure it's preserved
env["VIRTUAL_ENV"] = sys.prefix
# Keep PATH so the venv's bin is first
# Write PID + stderr log under the IBus data dir. A log file (not a pipe)
# keeps early import/startup failures visible without SIGPIPE risk if the
# engine keeps running and continues to log after we return.
ensure_ibus_dir()
stderr_log = VOCULINUX_IBUS_DIR / "engine.stderr.log"
with open(stderr_log, "w", encoding="utf-8") as stderr_fh:
process = subprocess.Popen(
[sys.executable, str(engine_script)],
stdout=subprocess.DEVNULL,
stderr=stderr_fh,
start_new_session=True,
env=env,
)
PID_FILE.write_text(str(process.pid))
# Wait a bit for the engine to start
for _ in range(10):
time.sleep(0.2)
if is_engine_process_running():
logger.info("IBus engine process started successfully")
return True
exit_code = process.poll()
if exit_code is None:
# Still alive but not recognized as our engine (stale PID race, etc.).
process.terminate()
try:
process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
process.kill()
try:
process.wait(timeout=0.5)
except subprocess.TimeoutExpired:
pass
exit_code = process.returncode
stderr_text = ""
try:
stderr_text = stderr_log.read_text(encoding="utf-8", errors="replace")
except OSError:
pass
if stderr_text and stderr_text.strip():
logger.error(
"IBus engine process failed to start (exit=%s): %s",
exit_code,
stderr_text.strip()[:2000],
)
else:
logger.error(
"IBus engine process failed to start (exit=%s, no stderr)",
exit_code,
)
if PID_FILE.exists():
try:
PID_FILE.unlink()
except OSError:
pass
return False
except Exception as e:
logger.error(f"Failed to start IBus engine process: {e}")
return False
def stop_engine_process(wait_timeout: float = 2.0) -> None:
"""Stop the IBus engine process if running.
Waits for the process to actually exit so callers that restore the user's
IBus engine afterward run after ``register_component`` teardown has been
observed by ibus-daemon (issue #558).
"""
try:
if not PID_FILE.exists():
logger.debug("No PID file found, engine not running")
return
pid = int(PID_FILE.read_text().strip())
# Verify it's our process before killing
cmdline_path = Path(f"/proc/{pid}/cmdline")
if cmdline_path.exists():
cmdline = cmdline_path.read_text()
if "ibus_engine.py" not in cmdline or "voculinux" not in cmdline:
logger.warning(f"PID {pid} is not our engine process, skipping kill")
PID_FILE.unlink()
return
os.kill(pid, signal.SIGTERM)
# Wait for exit: IBus clears/repairs GlobalEngine on component destroy,
# which only runs after the process actually disconnects (#558).
deadline = time.monotonic() + max(wait_timeout, 0.0)
while time.monotonic() < deadline:
try:
os.kill(pid, 0)
except OSError:
break
time.sleep(0.05)
else:
# Still alive — escalate so teardown (and GlobalEngine repair) finishes.
try:
os.kill(pid, signal.SIGKILL)
except OSError:
pass
kill_deadline = time.monotonic() + 0.5
while time.monotonic() < kill_deadline:
try:
os.kill(pid, 0)
except OSError:
break
time.sleep(0.05)
logger.info(f"IBus engine process (PID {pid}) stopped")
PID_FILE.unlink()
except (OSError, ValueError, FileNotFoundError) as e:
logger.debug(f"Failed to stop IBus engine process: {e}")
if PID_FILE.exists():
PID_FILE.unlink()
IBusEngineBase: Any = IBus.Engine if IBUS_AVAILABLE else object
class VocalinuxEngine(IBusEngineBase):
"""
IBus proxy engine for Voculinux text injection.
This engine acts as a transparent proxy:
- All keyboard input passes through unchanged
- Text injection requests are received via Unix socket
- Injected text is committed directly to the focused application
Users should set this as their default input method for seamless
voice dictation support.
"""
if IBUS_AVAILABLE:
__gtype_name__ = "VocalinuxEngine"
# Class-level reference to active engine instance
_active_instance: Optional["VocalinuxEngine"] = None
# Set only after do_focus_in binds the engine to a client input context.
# do_enable alone is not enough for commit_text delivery (#523).
_focus_event: threading.Event = threading.Event()
_socket_server: Optional[threading.Thread] = None
_server_socket: Optional[socket.socket] = None
_server_running: bool = False
# Max wait for FocusIn before committing text (cold first activation).
_FOCUS_WAIT_TIMEOUT_S: float = 1.0
def __init__(self):
"""Initialize the Voculinux IBus engine."""
if IBUS_AVAILABLE:
super().__init__()
logger.debug("VocalinuxEngine instance created")
def do_enable(self) -> None:
"""Called when the engine is enabled/selected."""
logger.info("VocalinuxEngine enabled - ready for text injection")
VocalinuxEngine._active_instance = self
# do_enable does not imply a focused client context; wait for do_focus_in
# before commit_text (#523).
# Start socket server if not already running
if VocalinuxEngine._socket_server is None:
self._start_socket_server()
def do_disable(self) -> None:
"""Called when the engine is disabled/deselected."""
logger.debug("VocalinuxEngine disabled (keeping instance for injection)")
# NOTE: We intentionally do NOT clear _active_instance here.
# IBus calls do_disable when focus changes between windows, but we still
# want to be able to inject text. The engine process keeps running and
# the instance remains valid for text injection.
def do_destroy(self) -> None:
"""Called when the engine instance is destroyed by IBus (e.g. layout switch)."""
logger.debug("VocalinuxEngine instance destroyed")
super_destroy: Optional[Callable[[], None]] = None
if IBUS_AVAILABLE:
super_destroy = functools.partial(_invoke_parent_destroy, super().do_destroy, self)
was_active = VocalinuxEngine._active_instance is self
VocalinuxEngine._active_instance = _handle_engine_destroy(
VocalinuxEngine._active_instance,
self,
IBUS_AVAILABLE,
super_destroy,
)
if was_active:
VocalinuxEngine._focus_event.clear()
def do_focus_in(self) -> None:
"""Called when the engine gains focus on a client input context."""
logger.debug("VocalinuxEngine focus in")
VocalinuxEngine._active_instance = self
VocalinuxEngine._focus_event.set()
def do_focus_out(self) -> None:
"""Called when the engine loses focus on a client input context."""
logger.debug("VocalinuxEngine focus out")
VocalinuxEngine._focus_event.clear()
def do_process_key_event(self, keyval: int, keycode: int, state: int) -> bool:
"""
Process key events.
We pass through ALL keys unchanged since this is a proxy engine.
The actual keyboard layout handling is done by the system.
"""
return False # Don't consume any keys - pass through
def inject_text(self, text: str) -> bool:
"""
Inject text into the currently focused application.
Args:
text: The text to inject
Returns:
True if injection was successful, False otherwise
"""
if not text:
return True
try:
logger.debug(f"Injecting text: {text[:50]}...")
self.commit_text(IBus.Text.new_from_string(text))
logger.info(f"Text injected via IBus: '{text[:20]}...' ({len(text)} chars)")
return True
except Exception as e:
logger.error(f"Failed to inject text: {e}")
return False
@classmethod
def _start_socket_server(cls) -> None:
"""Start the Unix socket server for receiving injection requests."""
ensure_ibus_dir()
# Remove existing socket
if SOCKET_PATH.exists():
SOCKET_PATH.unlink()
cls._server_running = True
def server_thread(): # pragma: no cover
try:
cls._server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
cls._server_socket.bind(str(SOCKET_PATH))
cls._server_socket.listen(1)
SOCKET_PATH.chmod(0o600) # Only user can access
logger.info(f"Socket server listening on {SOCKET_PATH}")
while cls._server_running:
try:
conn, _ = cls._server_socket.accept()
with conn:
# Verify peer is same user (defense in depth)
if not verify_peer_credentials(conn):
conn.sendall(b"UNAUTHORIZED")
continue
data = conn.recv(65536) # Max text size
if data:
if data == b"\x00PING":
conn.sendall(
_ibus_ping_status(cls._active_instance, cls._focus_event)
)
continue
text = data.decode("utf-8")
# Wait for FocusIn before commit. On cold first
# activation, do_enable can set _active_instance
# before mutter binds the client context; committing
# then reports success while text is dropped (#523).
if (
cls._active_instance is not None
and not _ibus_ensure_client_focus(
cls._active_instance,
cls._focus_event,
cls._FOCUS_WAIT_TIMEOUT_S,
)
):
logger.warning(
"Timed out waiting for IBus FocusIn " "before text commit"
)
conn.sendall(b"NO_FOCUS")
continue
# Snapshot after focus wait (FocusIn may set a new instance)
instance = cls._active_instance
if instance:
done = threading.Event()
injection_result = {"ok": False}
def do_inject(t, inst=instance):
try:
injection_result["ok"] = inst.inject_text(t)
finally:
done.set()
return False # Run only once
GLib.idle_add(do_inject, text)
if done.wait(timeout=3.0):
conn.sendall(b"OK" if injection_result["ok"] else b"ERROR")
else:
logger.error("Timed out waiting for IBus text commit")
conn.sendall(b"TIMEOUT")
else:
logger.warning("No active engine instance")
conn.sendall(b"NO_ENGINE")
except OSError as e:
# Check if we're shutting down
if not cls._server_running:
logger.debug("Socket server shutting down")
break
logger.error(f"Socket connection error: {e}")
except Exception as e:
logger.error(f"Socket connection error: {e}")
except Exception as e:
if cls._server_running:
logger.error(f"Socket server error: {e}")
cls._socket_server = threading.Thread(target=server_thread, daemon=True)
cls._socket_server.start()
@classmethod
def stop_socket_server(cls) -> None:
"""Stop the socket server."""
cls._server_running = False
if cls._server_socket:
try:
cls._server_socket.close()
except Exception:
pass
cls._server_socket = None
if SOCKET_PATH.exists():
SOCKET_PATH.unlink()
class VocalinuxEngineApplication:
"""
Application wrapper for running the Voculinux IBus engine.
This is used when the engine is launched by IBus as a separate process.
"""
def __init__(self, exec_by_ibus: bool = False):
"""Initialize the engine application.
Args:
exec_by_ibus: True when IBus launched this process (via --ibus flag).
False when Voculinux launched it directly.
"""
if not IBUS_AVAILABLE:
raise RuntimeError("IBus is not available")
self.mainloop = GLib.MainLoop()
self.bus = IBus.Bus()
if not self.bus.is_connected():
logger.error("IBus.Bus() is NOT connected — engine cannot register")
raise RuntimeError("IBus bus not connected")
self.bus.connect("disconnected", self._on_disconnected)
conn = self.bus.get_connection()
if conn is None:
logger.error("bus.get_connection() returned None")
raise RuntimeError("IBus bus connection is None")
self.factory = IBus.Factory.new(conn)
self.factory.add_engine(
ENGINE_NAME,
GObject.type_from_name("VocalinuxEngine"),
)
if exec_by_ibus:
# IBus launched us — it already knows about our component,
# just claim the well-known D-Bus name.
if not self.bus.request_name(COMPONENT_NAME, 0):
logger.error("bus.request_name() failed")
raise RuntimeError("Failed to acquire IBus D-Bus name")
else:
# Launched by Voculinux directly — register the full component
# so IBus discovers our engine without having launched us.
component = IBus.Component(
name=COMPONENT_NAME,
description=ENGINE_DESCRIPTION,
command_line=_get_exec_command(),
**_COMPONENT_META,
)
engine_desc = IBus.EngineDesc(
name=ENGINE_NAME,
longname=ENGINE_LONGNAME,
description=ENGINE_DESCRIPTION,
rank=ENGINE_RANK,
**_ENGINE_META,
)
component.add_engine(engine_desc)
if not self.bus.register_component(component):
logger.error("bus.register_component() failed")
raise RuntimeError("Failed to register IBus component")
logger.info("Registered component with IBus (standalone mode)")
logger.info("Voculinux IBus engine started")
# Start the socket server immediately so it's ready for connections
# even before the engine is activated via `ibus engine voculinux`
VocalinuxEngine._start_socket_server()
def _on_disconnected(self, bus: "IBus.Bus") -> None:
"""Handle IBus disconnection."""
logger.info("IBus disconnected, exiting")
VocalinuxEngine.stop_socket_server()
self.mainloop.quit()
def run(self) -> None:
"""Run the engine main loop."""
self.mainloop.run()
class IBusTextInjector:
"""
Text injector that uses IBus for text injection.
This class connects to the Voculinux IBus engine via Unix socket
and sends text to be injected. The normal TextInjector warmup path starts
the engine process without selecting it as the user's active IBus engine.
Each injection temporarily switches to the Voculinux engine, commits the
text, and restores the user's previous engine.
The auto_activate=True path still performs the older eager activation flow
for compatibility with direct callers and setup tests.
"""
def __init__(self, auto_activate: bool = True):
"""
Initialize the IBus text injector.
Args:
auto_activate: If True, automatically install and activate the engine
"""
if not IBUS_AVAILABLE:
raise RuntimeError("IBus is not available")
ensure_ibus_dir()
self._previous_engine: Optional[str] = None
# Empty until a real X11 layout is captured. A non-empty default (e.g.
# "us") would make stop()/restore re-apply a layout that was never
# captured, flipping non-US users to us. See issue #474.
self._previous_xkb_layout: Optional[tuple[str, str, str]] = ("", "", "")
if auto_activate:
self._setup_engine()
def prepare_engine(self) -> None:
"""
Start the IBus engine process without selecting it as the active engine.
The Voculinux engine is only needed while committing dictated text. Keeping
the user's normal IBus/XKB engine active preserves dead-key composition and
layout-specific behavior during ordinary typing.
Captures the current engine for shutdown restore only. Injection still
resolves the restore target at commit time so a stale warmup value is not
used mid-session (see #623); shutdown needs a fallback when quit leaves
Voculinux selected after a failed inject restore (#558).
"""
if not is_engine_active():
captured = get_current_engine()
if captured and captured != ENGINE_NAME:
self._previous_engine = captured
logger.debug(f"Captured current engine for shutdown restore: {captured}")
if not start_engine_process():
raise IBusSetupError("Failed to start IBus engine process. Check logs for details.")
self._wait_for_engine_ready(require_active=False)
def _setup_engine(self) -> None:
"""Install and activate the IBus engine."""
# Start the engine process — it calls register_component() via
# D-Bus which makes the engine available even when the IBus daemon
# doesn't scan ~/.local/share/ibus/component/.
# Follow-up to PR #304: register_component() is the reliable path,
# so we no longer gate on is_engine_registered() / ibus list-engine.
if not start_engine_process():
raise IBusSetupError("Failed to start IBus engine process. Check logs for details.")
# Capture current XKB layout before switching engines
# This is critical for preserving the user's keyboard layout
# when IBus engine switching might override it
self._previous_xkb_layout = get_current_xkb_layout()
logger.debug(
f"Captured XKB layout: {self._previous_xkb_layout[0]}, "
f"variant: {self._previous_xkb_layout[1]}, option: {self._previous_xkb_layout[2]}"
)
# Save current engine and switch to Voculinux
if not is_engine_active():
self._previous_engine = get_current_engine()
if self._previous_engine:
logger.info(f"Saving current engine: {self._previous_engine}")
logger.info("Activating Voculinux IBus engine...")
if switch_engine(ENGINE_NAME):
logger.info("Voculinux IBus engine activated")
else:
raise IBusSetupError(
"Failed to activate Voculinux IBus engine. "
"Try manually: ibus engine voculinux"
)
self._wait_for_engine_ready()
# Restore the user's XKB layout immediately after engine activation.
# Switching to the Voculinux IBus engine can override the system
# keyboard layout (e.g. Spanish, French AZERTY) with the engine's
# default layout. Re-applying the captured XKB layout ensures the
# user's keyboard keeps working correctly while Voculinux is active.
# See issue #292.
if self._previous_xkb_layout:
layout, variant, option = self._previous_xkb_layout
restore_xkb_layout(layout, variant, option)
def _wait_for_engine_ready(self, max_attempts: int = 8, require_active: bool = True) -> None:
delay = 0.25
for attempt in range(max_attempts):
try:
if not SOCKET_PATH.exists():
raise FileNotFoundError("IBus engine socket not found")
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(2.0)
sock.connect(str(SOCKET_PATH))
sock.sendall(b"\x00PING")
response = sock.recv(64).decode("utf-8")
# FOCUSED = client context ready; OK = instance exists (not yet focused).
if response in ("OK", "FOCUSED"):
logger.debug(f"IBus engine readiness probe succeeded on attempt {attempt + 1}")
return
if response == "NO_ENGINE" and not require_active:
logger.debug("IBus engine socket is ready; active engine instance not required")
return
if response not in ("NO_ENGINE", "OK", "FOCUSED"):
logger.warning(f"Unexpected readiness probe response: {response}")
except (socket.timeout, FileNotFoundError, ConnectionRefusedError, OSError) as e:
logger.debug(f"IBus readiness probe attempt {attempt + 1} failed: {e}")
if attempt < max_attempts - 1:
time.sleep(delay)
delay = min(delay * 2, 2.0)
raise IBusSetupError(
"Voculinux IBus engine did not become ready after startup. "
"Try manually: ibus engine voculinux"
)
def stop(self) -> None:
"""
Stop the IBus text injector and restore previous engine and XKB layout.
Call this when Voculinux is shutting down. If no valid engine was
captured before injection (e.g. on GNOME/Wayland with no global IBus
engine), restoration is skipped to avoid switching the user to a
wrong layout (see issue #497).
Engine restoration must run *after* ``stop_engine_process()`` has
observed process exit. Killing the process that called
``register_component`` makes IBus run ``check_global_engine()``, which
only searches ``register_engine_list`` and treats XML engines such as
``xkb:es::spa`` as missing, clearing the global engine even when
Voculinux was never selected (issue #558).
Prefer the *live* engine at quit when it is a real non-Voculinux
engine, so a user who changed input source after warmup is not
switched back to a stale cache. Fall back to ``_previous_engine`` only
when the live engine is Voculinux or unavailable (failed inject restore).
"""
current = get_current_engine()
restore_engine: Optional[str]
if current and current != ENGINE_NAME:
restore_engine = current
else:
restore_engine = self._previous_engine
if restore_engine == ENGINE_NAME:
restore_engine = None
# Stop the engine process first and wait for exit — teardown can clear
# IBus GlobalEngine (#558). Restore must land after that check.
stop_engine_process()
if restore_engine:
# `ibus engine <name>` applies that engine's XKB map server-wide.
# Re-check the daemon (not the GNOME fallback) after teardown and
# skip the write when a real engine is already active — rewriting
# it collapses GNOME's multi-layout map (#954). The same skip
# applies when teardown left the engine cleared but the restore
# target is an xkb:* source on GNOME: gnome-shell owns that map
# and the write would only collapse it. A cleared global engine
# for a real IM engine still gets restored (#558).
live = _query_active_engine()
if live and live != ENGINE_NAME:
logger.info(f"Skipping engine restore — live engine already active: {live}")
elif live is None and _is_gnome_session() and restore_engine.startswith("xkb:"):
logger.info(
"Skipping engine restore — engine cleared and GNOME owns "
f"the XKB map; not rewriting {restore_engine}"
)
else:
logger.info(f"Restoring previous engine after teardown: {restore_engine}")
restored = False
for attempt in range(3):
if switch_engine(restore_engine):
restored = True
break
time.sleep(0.15 * (attempt + 1))
if not restored:
logger.error(f"Failed to restore IBus engine after teardown: {restore_engine}")
else:
logger.info(
"Skipping engine restoration — no restorable engine was available at shutdown"
)
self._previous_engine = None
# XKB last: switching engines can override layout (#292). Re-apply after
# the post-teardown engine restore so we don't lose the user's map.
if self._previous_xkb_layout:
layout, variant, option = self._previous_xkb_layout
if layout:
logger.info(f"Restoring XKB layout: {layout}")
restore_xkb_layout(layout, variant, option)
self._previous_xkb_layout = None
def inject_text(self, text: str) -> bool:
"""
Inject text using the IBus engine.
Args:
text: The text to inject
Returns:
True if injection was successful, False otherwise
"""
# Same contract as TextInjector.inject_text: space/tab-only input has
# no key to send, but a newline-only segment from "new line" must
# reach the engine socket (the engine commits it as text) (#955).
if not text or not text.strip(" \t"):
logger.debug("Empty text provided, skipping injection")
return True
logger.info(f"Starting IBus text injection: '{text[:20]}...' (length: {len(text)})")
restore_engine: Optional[str] = None
# Capture before any engine switch. Switching to Voculinux can override
# the system XKB map to us; scoped injection must restore it after
# switching back (#292, #664). Empty on Wayland (no-op restore).
captured_xkb = get_current_xkb_layout()
if not is_engine_active():
current_engine = get_current_engine()
if not current_engine:
logger.warning(
"Could not determine a restorable IBus engine; "
"using the non-IBus text injection fallback"
)
return False
restore_engine = current_engine
# Always refresh the shutdown fallback to the live engine so a later
# quit does not restore a stale warmup value (Bugbot on #643 / #558).
self._previous_engine = current_engine
logger.debug(
f"Temporarily activating Voculinux IBus engine (will restore {current_engine})"
)
if not switch_engine(ENGINE_NAME):
logger.error("Failed to activate Voculinux IBus engine for injection")
return False
# Try injection with bounded retries for transient socket/engine races.
# This can happen if IBus re-created the engine instance or if the
# engine process/socket is still coming up when dictation ends.
max_attempts = 3
def restart_engine_process() -> bool:
logger.warning("IBus engine process is not running, restarting...")
if not start_engine_process():
logger.error("Failed to restart IBus engine process")
return False
time.sleep(0.3)
return True
try:
for attempt in range(max_attempts):
try:
if not SOCKET_PATH.exists():
if not is_engine_process_running() and not restart_engine_process():
return False
if attempt < max_attempts - 1:
logger.warning(
"IBus engine socket not found on attempt "
f"{attempt + 1}/{max_attempts}; retrying..."
)
time.sleep(0.2 * (attempt + 1))
continue
logger.error(
"IBus engine socket not found. "
"Make sure Voculinux IBus engine is running."
)
return False
# Connect to engine socket and send text
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(5.0)
sock.connect(str(SOCKET_PATH))
sock.sendall(text.encode("utf-8"))
# Wait for response
response = sock.recv(64).decode("utf-8")
if response == "OK":
logger.debug("Text injection successful")
return True
elif response in ("NO_ENGINE", "NO_FOCUS") and attempt < max_attempts - 1:
# NO_ENGINE: instance destroyed (layout switch).
# NO_FOCUS: selected but FocusIn never arrived (#523).
# Re-activate and retry.
logger.info(
f"Engine not ready for commit ({response}); "
"re-activating and retrying..."
)
switch_engine(ENGINE_NAME)
time.sleep(0.3)
continue
else:
logger.error(f"Text injection failed: {response}")
return False
except socket.timeout:
if attempt < max_attempts - 1:
logger.warning(
"Timeout connecting to IBus engine on attempt "
f"{attempt + 1}/{max_attempts}; retrying..."
)
time.sleep(0.2 * (attempt + 1))
continue
logger.error("Timeout connecting to IBus engine")
return False
except ConnectionRefusedError as e:
if attempt < max_attempts - 1:
logger.warning(
"IBus engine refused connection on attempt "
f"{attempt + 1}/{max_attempts}: {e}. Retrying..."
)
if not is_engine_process_running() and not restart_engine_process():
return False
time.sleep(0.2 * (attempt + 1))
continue
logger.error(f"Failed to inject text via IBus: {e}")
return False
except FileNotFoundError:
if attempt < max_attempts - 1:
logger.warning(
"IBus engine socket disappeared on attempt "
f"{attempt + 1}/{max_attempts}; retrying..."
)
if not is_engine_process_running() and not restart_engine_process():
return False
time.sleep(0.2 * (attempt + 1))
continue
logger.error("IBus engine socket not found")
return False
except Exception as e:
logger.error(f"Failed to inject text via IBus: {e}")
return False
finally:
if restore_engine:
logger.debug(f"Restoring IBus engine after injection: {restore_engine}")
if not switch_engine(restore_engine):
logger.error(f"Failed to restore IBus engine after injection: {restore_engine}")
else:
logger.debug(f"Restored IBus engine after injection: {restore_engine}")
else:
logger.debug(
"Skipping engine restoration after injection — "
"no valid engine was captured (see #497)"
)
# Engine switch can leave XKB on us even after restoring the IBus
# engine (setxkbmap vs IBus). Re-apply the pre-switch map on X11;
# Wayland capture is empty so restore_xkb_layout is a no-op (#474, #664).
layout, variant, option = captured_xkb
if layout:
restore_xkb_layout(layout, variant, option)
else:
# Compositor is source of truth. Restore GNOME's full XKB
# configuration on XWayland so neither a mid-recording
# Super+Space (#738) nor a single-layout map left by the
# engine round-trip (#848) can strand X apps on a stale or
# incomplete keymap. Do not replay the empty capture and do
# not go through restore_xkb_layout.
sync_xwayland_layout_from_gnome()
return False
def _get_engines_xml() -> str:
"""Return engine XML for IBus --xml discovery.
IBus invokes ``<exec> --xml`` during ``ibus write-cache`` and
``ibus list-engine`` to discover available engines. The expected
output is a bare ``<engines>`` block printed to stdout.
"""
e = _ENGINE_META
return f"""<engines>
<engine>
<name>{ENGINE_NAME}</name>
<longname>{ENGINE_LONGNAME}</longname>
<language>{e['language']}</language>
<license>{e['license']}</license>
<author>{e['author']}</author>
<icon>{e['icon']}</icon>
<layout>{e['layout']}</layout>
<layout_variant />
<layout_option />
<description>{ENGINE_DESCRIPTION}</description>
<rank>{ENGINE_RANK}</rank>
</engine>
</engines>"""
def main():
"""Entry point when run as IBus engine process."""
# IBus calls the exec with --xml to discover engines during
# ibus write-cache and ibus list-engine. Respond and exit
# immediately — do not enter the GLib main loop.
if "--xml" in sys.argv:
print(_get_engines_xml())
return 0
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
if not IBUS_AVAILABLE:
logger.error("IBus is not available")
return 1
IBus.init()
exec_by_ibus = "--ibus" in sys.argv
app = VocalinuxEngineApplication(exec_by_ibus=exec_by_ibus)
app.run()
return 0
if __name__ == "__main__":
sys.exit(main())