fix(ibus): preserve user engine for dead keys (#457)
* fix(ibus): preserve user engine for dead keys Keep the VocaLinux IBus engine warm without selecting it as the active keyboard engine. Temporarily switch to the VocaLinux engine only while committing dictated text, then restore the user's previous IBus engine. Wait for the IBus commit to complete before acknowledging socket injection, so engine restoration does not race the commit. This fixes dead-key composition (e.g. ^ + e → ê) on French AZERTY and other layouts that rely on the active IBus/XKB engine for compose state. Fixes #442 Supersedes #446 (rebased onto main after thread-safety hardening in #452) * test(ibus): add coverage for scoped activation error paths Cover previously-untested error paths in inject_text() scoped activation: - switch_engine failure during temporary Vocalinux activation - unknown current engine (get_current_engine returns None) - NO_ENGINE retry when previous engine could not be captured - ConnectionRefusedError exhausting all retries - FileNotFoundError retry-then-succeed and retry-then-fail - Generic exception catch-all
This commit is contained in:
@@ -677,13 +677,22 @@ class VocalinuxEngine(IBus.Engine if IBUS_AVAILABLE else object):
|
||||
# Snapshot active instance to avoid TOCTOU race with do_destroy
|
||||
instance = cls._active_instance
|
||||
if instance:
|
||||
done = threading.Event()
|
||||
injection_result = {"ok": False}
|
||||
|
||||
def do_inject(t, inst=instance):
|
||||
inst.inject_text(t)
|
||||
try:
|
||||
injection_result["ok"] = inst.inject_text(t)
|
||||
finally:
|
||||
done.set()
|
||||
return False # Run only once
|
||||
|
||||
GLib.idle_add(do_inject, text)
|
||||
conn.sendall(b"OK")
|
||||
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")
|
||||
@@ -804,13 +813,13 @@ class IBusTextInjector:
|
||||
Text injector that uses IBus for text injection.
|
||||
|
||||
This class connects to the Vocalinux IBus engine via Unix socket
|
||||
and sends text to be injected. On initialization, it automatically:
|
||||
1. Starts the engine process (registers via D-Bus register_component)
|
||||
2. Saves the current engine and XKB layout
|
||||
3. Switches to the Vocalinux engine
|
||||
4. Restores the XKB layout
|
||||
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 Vocalinux engine, commits the
|
||||
text, and restores the user's previous engine.
|
||||
|
||||
On cleanup (stop), it restores the previous engine and layout.
|
||||
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):
|
||||
@@ -830,6 +839,19 @@ class IBusTextInjector:
|
||||
if auto_activate:
|
||||
self._setup_engine()
|
||||
|
||||
def prepare_engine(self) -> None:
|
||||
"""
|
||||
Start the IBus engine process without selecting it as the active engine.
|
||||
|
||||
The Vocalinux 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.
|
||||
"""
|
||||
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
|
||||
@@ -876,7 +898,7 @@ class IBusTextInjector:
|
||||
layout, variant, option = self._previous_xkb_layout
|
||||
restore_xkb_layout(layout, variant, option)
|
||||
|
||||
def _wait_for_engine_ready(self, max_attempts: int = 8) -> None:
|
||||
def _wait_for_engine_ready(self, max_attempts: int = 8, require_active: bool = True) -> None:
|
||||
delay = 0.25
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
@@ -894,6 +916,10 @@ class IBusTextInjector:
|
||||
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 != "NO_ENGINE":
|
||||
logger.warning(f"Unexpected readiness probe response: {response}")
|
||||
except (socket.timeout, FileNotFoundError, ConnectionRefusedError, OSError) as e:
|
||||
@@ -947,10 +973,25 @@ class IBusTextInjector:
|
||||
|
||||
logger.info(f"Starting IBus text injection: '{text[:20]}...' (length: {len(text)})")
|
||||
|
||||
# Ensure vocalinux engine is active (user may have switched keyboard)
|
||||
if not is_engine_active():
|
||||
logger.debug("Vocalinux engine not active, re-activating...")
|
||||
switch_engine(ENGINE_NAME)
|
||||
restore_engine: Optional[str] = None
|
||||
can_reactivate_engine = is_engine_active()
|
||||
if not can_reactivate_engine:
|
||||
current_engine = get_current_engine()
|
||||
if current_engine:
|
||||
restore_engine = current_engine
|
||||
can_reactivate_engine = True
|
||||
logger.debug(
|
||||
"Temporarily activating Vocalinux IBus engine "
|
||||
f"(will restore {current_engine})"
|
||||
)
|
||||
if not switch_engine(ENGINE_NAME):
|
||||
logger.error("Failed to activate Vocalinux IBus engine for injection")
|
||||
return False
|
||||
else:
|
||||
logger.warning(
|
||||
"Could not determine current IBus engine; trying existing "
|
||||
"Vocalinux engine instance without switching"
|
||||
)
|
||||
|
||||
# Try injection with bounded retries for transient socket/engine races.
|
||||
# This can happen if IBus re-created the engine instance or if the
|
||||
@@ -965,85 +1006,97 @@ class IBusTextInjector:
|
||||
time.sleep(0.3)
|
||||
return True
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
if not SOCKET_PATH.exists():
|
||||
if not is_engine_process_running() and not restart_engine_process():
|
||||
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 Vocalinux 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 == "NO_ENGINE" and attempt < max_attempts - 1:
|
||||
# Engine instance was destroyed (layout switch).
|
||||
# Re-activate to create a new instance and retry.
|
||||
if not can_reactivate_engine:
|
||||
logger.error(
|
||||
"IBus engine instance is not active and the previous "
|
||||
"engine could not be captured safely"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info("Engine instance not active, 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(
|
||||
"IBus engine socket not found on attempt "
|
||||
"Timeout connecting to IBus engine on attempt "
|
||||
f"{attempt + 1}/{max_attempts}; retrying..."
|
||||
)
|
||||
time.sleep(0.2 * (attempt + 1))
|
||||
continue
|
||||
|
||||
logger.error(
|
||||
"IBus engine socket not found. "
|
||||
"Make sure Vocalinux IBus engine is running."
|
||||
)
|
||||
logger.error("Timeout connecting to IBus engine")
|
||||
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 == "NO_ENGINE" and attempt < max_attempts - 1:
|
||||
# Engine instance was destroyed (layout switch).
|
||||
# Re-activate to create a new instance and retry.
|
||||
logger.info("Engine instance not active, re-activating and retrying...")
|
||||
switch_engine(ENGINE_NAME)
|
||||
time.sleep(0.3)
|
||||
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
|
||||
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
|
||||
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}")
|
||||
switch_engine(restore_engine)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -263,7 +263,10 @@ class TextInjector:
|
||||
return
|
||||
|
||||
try:
|
||||
self._ibus_injector._setup_engine()
|
||||
# Keep the Vocalinux IBus process warm without selecting it as the
|
||||
# user's active keyboard engine. Activation is scoped to each text
|
||||
# commit so normal typing keeps layout-specific compose/dead keys.
|
||||
self._ibus_injector.prepare_engine()
|
||||
with self._state_lock:
|
||||
self._ibus_ready = True
|
||||
self._ibus_init_failed = False
|
||||
|
||||
Reference in New Issue
Block a user