From 6e88c83227a94b331bbb3ac08152fee77611ddd3 Mon Sep 17 00:00:00 2001 From: Yuval Adam <_@yuv.al> Date: Fri, 24 Jul 2026 12:58:51 +0200 Subject: Host the IBus engine inside parleyd --- src/parley/daemon.py | 9 ++- src/parley/desktop.py | 32 --------- src/parley/ibus_engine.py | 176 +++++++++++++++++++++------------------------- 3 files changed, 88 insertions(+), 129 deletions(-) (limited to 'src') diff --git a/src/parley/daemon.py b/src/parley/daemon.py index 5a2cb53..63d4697 100644 --- a/src/parley/daemon.py +++ b/src/parley/daemon.py @@ -13,8 +13,9 @@ from gi.repository import Gio, GLib, GLibUnix from .config import Config from .controller import Controller -from .desktop import copy_text, insert_text_ibus, notify, open_folder +from .desktop import copy_text, notify, open_folder from .errors import ParleyError +from .ibus_engine import IBusIntegration from .state import State @@ -31,6 +32,7 @@ class Daemon: self.node_info = Gio.DBusNodeInfo.new_for_xml(xml) self.interface_info = self.node_info.interfaces[0] self.loop = GLib.MainLoop() + self.ibus = IBusIntegration() self.connection: Gio.DBusConnection | None = None self.registration_id = 0 self.controller = Controller( @@ -56,6 +58,7 @@ class Daemon: self.loop.run() finally: self.controller.close() + self.ibus.close() if self.connection is not None and self.registration_id: self.connection.unregister_object(self.registration_id) Gio.bus_unown_name(owner_id) @@ -153,7 +156,7 @@ class Daemon: backend = self.controller.config.insertion_mode if backend == "ibus": try: - message = insert_text_ibus(text) + message = self.ibus.commit(text) except ParleyError as error: copy_text(text) self._emit( @@ -194,7 +197,7 @@ class Daemon: config = self.controller.config if config.auto_insert and config.insertion_mode == "ibus": try: - message = insert_text_ibus(text) + message = self.ibus.commit(text) except ParleyError as error: self._emit( "InsertionFinished", diff --git a/src/parley/desktop.py b/src/parley/desktop.py index e4c5750..37f8a7f 100644 --- a/src/parley/desktop.py +++ b/src/parley/desktop.py @@ -7,10 +7,6 @@ import subprocess from .errors import ParleyError -IBUS_BUS_NAME = "org.parley.IBus1" -IBUS_OBJECT_PATH = "/org/parley/IBus1" - - class DesktopIntegrationError(ParleyError): pass @@ -37,34 +33,6 @@ def copy_text(text: str) -> None: raise DesktopIntegrationError("xclip could not access the clipboard") -def insert_text_ibus(text: str) -> str: - """Ask the focused Parley IBus engine to commit semantic text.""" - try: - import gi - - gi.require_version("Gio", "2.0") - from gi.repository import Gio, GLib - - connection = Gio.bus_get_sync(Gio.BusType.SESSION, None) - result = connection.call_sync( - IBUS_BUS_NAME, - IBUS_OBJECT_PATH, - IBUS_BUS_NAME, - "CommitText", - GLib.Variant("(s)", (text,)), - GLib.VariantType.new("(bs)"), - Gio.DBusCallFlags.NO_AUTO_START, - 2_000, - None, - ) - except Exception as error: - raise DesktopIntegrationError(f"IBus insertion is unavailable: {error}") from error - success, message = result.unpack() - if not success: - raise DesktopIntegrationError(message) - return message - - def notify(summary: str, body: str) -> None: executable = shutil.which("notify-send") if executable is not None: diff --git a/src/parley/ibus_engine.py b/src/parley/ibus_engine.py index 1de2a11..7f65a99 100644 --- a/src/parley/ibus_engine.py +++ b/src/parley/ibus_engine.py @@ -1,30 +1,16 @@ -"""Minimal persistent passthrough IBus engine for insertion experiments.""" - -import signal -import sys +"""Persistent passthrough IBus engine hosted by the Parley daemon.""" import gi -gi.require_version("Gio", "2.0") gi.require_version("GLib", "2.0") -gi.require_version("GLibUnix", "2.0") gi.require_version("IBus", "1.0") -from gi.repository import Gio, GLib, GLibUnix, GObject, IBus +from gi.repository import GLib, IBus +from .errors import ParleyError -BUS_NAME = "org.parley.IBus1" -OBJECT_PATH = "/org/parley/IBus1" -INTERFACE = BUS_NAME -INTROSPECTION = """ - - - - - - - -""" +class IBusInsertionError(ParleyError): + pass class ParleyIBusEngine(IBus.Engine): @@ -51,24 +37,61 @@ class ParleyIBusEngine(IBus.Engine): ) -> None: self._purpose = purpose - def commit(self, text: str) -> tuple[bool, str]: + def commit(self, text: str) -> str: if self._purpose in (IBus.InputPurpose.PASSWORD, IBus.InputPurpose.PIN): - return False, "refusing insertion into a password or PIN field" + raise IBusInsertionError( + "refusing insertion into a password or PIN field" + ) self.commit_text(IBus.Text.new_from_string(text)) - return True, "text committed through IBus" + return "text committed through IBus" + +class IBusIntegration: + """Register Parley's engine and reconnect after a rare IBus restart.""" -class EngineApplication: def __init__(self) -> None: IBus.init() - self.loop = GLib.MainLoop() - self.ibus = IBus.Bus.new() - if not self.ibus.is_connected(): - raise RuntimeError("could not connect to IBus") - - self.factory = IBus.Factory.new(self.ibus.get_connection()) - self.factory.add_engine("parley", ParleyIBusEngine.__gtype__) - self.component = IBus.Component.new( + self.bus: IBus.Bus | None = None + self.factory: IBus.Factory | None = None + self.component: IBus.Component | None = None + self._disconnect_signal = 0 + self._reconnect_source = 0 + self._closed = False + self._connect() + + @property + def available(self) -> bool: + return self.bus is not None and self.bus.is_connected() + + def commit(self, text: str) -> str: + if not self.available: + raise IBusInsertionError("IBus is unavailable") + engine = ParleyIBusEngine.focused + if engine is None: + raise IBusInsertionError( + "Parley does not own the focused input context" + ) + return engine.commit(text) + + def close(self) -> None: + self._closed = True + if self._reconnect_source: + GLib.source_remove(self._reconnect_source) + self._reconnect_source = 0 + self._clear_connection() + + def _connect(self) -> bool: + self._reconnect_source = 0 + if self._closed: + return GLib.SOURCE_REMOVE + bus = IBus.Bus.new() + if not bus.is_connected(): + self._schedule_reconnect() + return GLib.SOURCE_REMOVE + + factory = IBus.Factory.new(bus.get_connection()) + factory.add_engine("parley", ParleyIBusEngine.__gtype__) + component = IBus.Component.new( "org.parley.IBus", "Parley dictation passthrough engine", "0.1.0", @@ -78,7 +101,7 @@ class EngineApplication: "", "", ) - self.component.add_engine( + component.add_engine( IBus.EngineDesc.new( "parley", "Parley Dictation", @@ -90,67 +113,32 @@ class EngineApplication: "default", ) ) - if not self.ibus.register_component(self.component): - raise RuntimeError("could not register the Parley IBus component") - - self.connection = Gio.bus_get_sync(Gio.BusType.SESSION, None) - node = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION) - self.registration_id = self.connection.register_object( - OBJECT_PATH, node.interfaces[0], self._method_call, None, None - ) - self.owner_id = Gio.bus_own_name_on_connection( - self.connection, BUS_NAME, Gio.BusNameOwnerFlags.NONE, None, None - ) - self.ibus.connect("disconnected", lambda _bus: self.loop.quit()) - GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, self._quit) - GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._quit) - - def run(self) -> int: - print("Parley IBus engine registered", file=sys.stderr) - try: - self.loop.run() - finally: - Gio.bus_unown_name(self.owner_id) - self.connection.unregister_object(self.registration_id) - self.factory.destroy() - return 0 - - def _method_call( - self, - connection: Gio.DBusConnection, - sender: str, - object_path: str, - interface_name: str, - method_name: str, - parameters: GLib.Variant, - invocation: Gio.DBusMethodInvocation, - ) -> None: - if method_name != "CommitText": - invocation.return_dbus_error( - f"{INTERFACE}.Error.UnknownMethod", f"unknown method: {method_name}" - ) - return - engine = ParleyIBusEngine.focused - if engine is None: - invocation.return_value( - GLib.Variant("(bs)", (False, "Parley does not own the focused input context")) - ) - return - success, message = engine.commit(parameters.unpack()[0]) - invocation.return_value(GLib.Variant("(bs)", (success, message))) - - def _quit(self) -> bool: - self.loop.quit() + if not bus.register_component(component): + factory.destroy() + self._schedule_reconnect() + return GLib.SOURCE_REMOVE + + self.bus = bus + self.factory = factory + self.component = component + self._disconnect_signal = bus.connect("disconnected", self._on_disconnected) return GLib.SOURCE_REMOVE - -def main() -> int: - try: - return EngineApplication().run() - except (GLib.Error, RuntimeError) as error: - print(f"parley-ibus: {error}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) + def _on_disconnected(self, bus: IBus.Bus) -> None: + self._clear_connection() + self._schedule_reconnect() + + def _schedule_reconnect(self) -> None: + if not self._closed and not self._reconnect_source: + self._reconnect_source = GLib.timeout_add_seconds(2, self._connect) + + def _clear_connection(self) -> None: + ParleyIBusEngine.focused = None + bus, self.bus = self.bus, None + if bus is not None and self._disconnect_signal: + bus.disconnect(self._disconnect_signal) + self._disconnect_signal = 0 + factory, self.factory = self.factory, None + if factory is not None: + factory.destroy() + self.component = None -- cgit v1.3.1