From b6fb2b838b058b214d723ea4a1660e546d1b9e3b Mon Sep 17 00:00:00 2001 From: Graham Jenkins <6607975+GrahamJenkins@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:11:31 -0700 Subject: [PATCH] fix(text-injection): use register_component for IBus engine activation (#304) * fix(ibus): add register_component for standalone launch and --xml/--ibus flag handling (#303) * refactor(ibus): consolidate engine metadata and add command_line/request_name checks (#303) --- src/vocalinux/text_injection/ibus_engine.py | 137 +++++++++++++++++--- 1 file changed, 118 insertions(+), 19 deletions(-) diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 49a7ca1..3c4844c 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -68,6 +68,24 @@ ENGINE_LONGNAME = "Vocalinux" ENGINE_DESCRIPTION = "Vocalinux voice dictation (use as default input method)" COMPONENT_NAME = "org.freedesktop.IBus.Vocalinux" +# 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": "GPL-3.0", + "author": "Vocalinux", + "icon": "audio-input-microphone", + "layout": "default", +} +_COMPONENT_META = { + "version": "1.0", + "license": "GPL-3.0", + "author": "Vocalinux", + "homepage": "https://github.com/jatinkrmalik/vocalinux", + "textdomain": "vocalinux", +} + def ensure_ibus_dir() -> None: """Ensure the IBus data directory exists with secure permissions.""" @@ -210,32 +228,38 @@ def start_ibus_daemon(): 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 _get_expected_component_xml() -> str: """Generate the expected component XML content for the current installation.""" - engine_script = Path(__file__).resolve() - python_exec = sys.executable + e = _ENGINE_META + c = _COMPONENT_META return f""" {COMPONENT_NAME} {ENGINE_DESCRIPTION} - {python_exec} {engine_script} - 1.0 - Vocalinux - GPL-3.0 - https://github.com/jatinkrmalik/vocalinux - vocalinux + {_get_exec_command()} + {c['version']} + {c['author']} + {c['license']} + {c['homepage']} + {c['textdomain']} {ENGINE_NAME} - other - GPL-3.0 - Vocalinux - audio-input-microphone - default {ENGINE_LONGNAME} + {e['language']} + {e['license']} + {e['author']} + {e['icon']} + {e['layout']} {ENGINE_DESCRIPTION} - 50 + {ENGINE_RANK} @@ -612,22 +636,63 @@ class VocalinuxEngineApplication: This is used when the engine is launched by IBus as a separate process. """ - def __init__(self): - """Initialize the engine application.""" + 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 Vocalinux 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) - self.factory = IBus.Factory.new(self.bus.get_connection()) + 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"), ) - self.bus.request_name(COMPONENT_NAME, 0) + 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 Vocalinux 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("Vocalinux IBus engine started") # Start the socket server immediately so it's ready for connections @@ -851,8 +916,41 @@ def install_ibus_component(system_wide: bool = False) -> bool: return False +def _get_engines_xml() -> str: + """Return engine XML for IBus --xml discovery. + + IBus invokes `` --xml`` during ``ibus write-cache`` and + ``ibus list-engine`` to discover available engines. The expected + output is a bare ```` block printed to stdout. + """ + e = _ENGINE_META + + return f""" + + {ENGINE_NAME} + {ENGINE_LONGNAME} + {e['language']} + {e['license']} + {e['author']} + {e['icon']} + {e['layout']} + + + {ENGINE_DESCRIPTION} + {ENGINE_RANK} + +""" + + 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", @@ -863,7 +961,8 @@ def main(): return 1 IBus.init() - app = VocalinuxEngineApplication() + exec_by_ibus = "--ibus" in sys.argv + app = VocalinuxEngineApplication(exec_by_ibus=exec_by_ibus) app.run() return 0