summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuval Adam <_@yuv.al>2026-07-24 12:58:51 +0200
committerYuval Adam <_@yuv.al>2026-07-24 12:58:51 +0200
commit6e88c83227a94b331bbb3ac08152fee77611ddd3 (patch)
tree8ba4cab9dbab9da0b1f34f7d8403ef88dd873e64
parent8732722aca10833d8799ada6f82ed334ce2e085d (diff)
Host the IBus engine inside parleyd
-rw-r--r--README.md2
-rw-r--r--data/systemd/parley-ibus.service13
-rw-r--r--packaging/arch/PKGBUILD2
-rw-r--r--pyproject.toml1
-rwxr-xr-xscripts/enable-ibus-test17
-rwxr-xr-xscripts/install-dev6
-rwxr-xr-xscripts/parley-ibus5
-rw-r--r--src/parley/daemon.py9
-rw-r--r--src/parley/desktop.py32
-rw-r--r--src/parley/ibus_engine.py168
10 files changed, 97 insertions, 158 deletions
diff --git a/README.md b/README.md
index 8bf7812..9c2ecdb 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,7 @@ The working development build currently provides:
- collision-safe transcript persistence and clipboard fallback
- D-Bus control through `parleyctl`
- a GNOME Shell 50 top-bar extension
-- a persistent passthrough IBus engine with automatic semantic text insertion
+- a persistent passthrough IBus engine hosted by `parleyd` for automatic semantic text insertion
- local systemd user units and an Arch PKGBUILD
End-to-end IBus insertion is confirmed in Firefox and Ghostty. Broader application, keyboard-layout, compose-key, shortcut, and sensitive-field testing is ongoing; results live in [docs/IBUS_BAKEOFF.md](docs/IBUS_BAKEOFF.md).
diff --git a/data/systemd/parley-ibus.service b/data/systemd/parley-ibus.service
deleted file mode 100644
index c0a89ee..0000000
--- a/data/systemd/parley-ibus.service
+++ /dev/null
@@ -1,13 +0,0 @@
-[Unit]
-Description=Parley IBus insertion engine
-After=graphical-session.target
-PartOf=graphical-session.target
-
-[Service]
-Type=simple
-ExecStart=/usr/bin/parley-ibus
-Restart=on-failure
-RestartSec=2
-
-[Install]
-WantedBy=default.target
diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD
index 7ade6df..983ddc4 100644
--- a/packaging/arch/PKGBUILD
+++ b/packaging/arch/PKGBUILD
@@ -44,8 +44,6 @@ package() {
install -Dm644 data/systemd/parley.service \
"$pkgdir/usr/lib/systemd/user/parley.service"
- install -Dm644 data/systemd/parley-ibus.service \
- "$pkgdir/usr/lib/systemd/user/parley-ibus.service"
install -Dm644 data/dbus/org.parley.Transcription1.service \
"$pkgdir/usr/share/dbus-1/services/org.parley.Transcription1.service"
install -Dm644 src/parley/interfaces/org.parley.Transcription1.xml \
diff --git a/pyproject.toml b/pyproject.toml
index ff0f11a..cf91954 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,6 @@ authors = [{ name = "Parley contributors" }]
[project.scripts]
parley = "parley.cli:main"
-parley-ibus = "parley.ibus_engine:main"
parleyd = "parley.daemon:main"
parleyctl = "parley.ctl:main"
diff --git a/scripts/enable-ibus-test b/scripts/enable-ibus-test
index d95e296..50c3cba 100755
--- a/scripts/enable-ibus-test
+++ b/scripts/enable-ibus-test
@@ -13,13 +13,6 @@ if [[ "$previous" != "parley" ]]; then
printf '%s\n' "$previous" > "$state_dir/previous-ibus-engine"
fi
-systemctl --user start parley-ibus.service
-for _ in {1..20}; do
- busctl --user --quiet list 2>/dev/null | grep -q '^org.parley.IBus1 ' && break
- sleep 0.1
-done
-ibus engine parley >/dev/null 2>&1 || [[ $(ibus engine) == parley ]]
-
cat > "$dropin_dir/ibus-test.conf" <<'EOF'
[Service]
Environment=PARLEY_AUTO_INSERT=1
@@ -28,4 +21,14 @@ EOF
systemctl --user daemon-reload
systemctl --user restart parley.service
+for _ in {1..20}; do
+ ibus engine parley >/dev/null 2>&1 || true
+ [[ $(ibus engine 2>/dev/null || true) == parley ]] && break
+ sleep 0.1
+done
+[[ $(ibus engine 2>/dev/null || true) == parley ]] || {
+ echo "could not select the Parley IBus engine" >&2
+ exit 1
+}
+
echo "Parley IBus test mode enabled (previous engine: $previous)"
diff --git a/scripts/install-dev b/scripts/install-dev
index c5afea9..d21bc46 100755
--- a/scripts/install-dev
+++ b/scripts/install-dev
@@ -11,14 +11,12 @@ cd "$root"
mkdir -p "$unit_dir" "$dbus_dir"
sed "s|^ExecStart=.*|ExecStart=$root/scripts/parleyd|" \
data/systemd/parley.service > "$unit_dir/parley.service"
-sed "s|^ExecStart=.*|ExecStart=$root/scripts/parley-ibus|" \
- data/systemd/parley-ibus.service > "$unit_dir/parley-ibus.service"
install -m644 data/dbus/org.parley.Transcription1.service \
"$dbus_dir/org.parley.Transcription1.service"
systemctl --user daemon-reload
-systemctl --user enable parley.service parley-ibus.service
-systemctl --user restart parley.service parley-ibus.service
+systemctl --user enable parley.service
+systemctl --user restart parley.service
echo "Installed local development service from $root"
echo "Run: $root/scripts/parleyctl status"
diff --git a/scripts/parley-ibus b/scripts/parley-ibus
deleted file mode 100755
index 22ca2c1..0000000
--- a/scripts/parley-ibus
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
-export PYTHONPATH="$root/src${PYTHONPATH:+:$PYTHONPATH}"
-exec /usr/bin/python -m parley.ibus_engine "$@"
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 = """<node>
- <interface name="org.parley.IBus1">
- <method name="CommitText">
- <arg name="text" type="s" direction="in"/>
- <arg name="success" type="b" direction="out"/>
- <arg name="message" type="s" direction="out"/>
- </method>
- </interface>
-</node>"""
+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.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()
- self.factory = IBus.Factory.new(self.ibus.get_connection())
- self.factory.add_engine("parley", ParleyIBusEngine.__gtype__)
- self.component = IBus.Component.new(
+ @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")
+ if not bus.register_component(component):
+ factory.destroy()
+ self._schedule_reconnect()
+ return GLib.SOURCE_REMOVE
- 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()
+ self.bus = bus
+ self.factory = factory
+ self.component = component
+ self._disconnect_signal = bus.connect("disconnected", self._on_disconnected)
return GLib.SOURCE_REMOVE
+ def _on_disconnected(self, bus: IBus.Bus) -> None:
+ self._clear_connection()
+ self._schedule_reconnect()
-def main() -> int:
- try:
- return EngineApplication().run()
- except (GLib.Error, RuntimeError) as error:
- print(f"parley-ibus: {error}", file=sys.stderr)
- return 1
-
+ def _schedule_reconnect(self) -> None:
+ if not self._closed and not self._reconnect_source:
+ self._reconnect_source = GLib.timeout_add_seconds(2, self._connect)
-if __name__ == "__main__":
- raise SystemExit(main())
+ 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