Ajoute la configuration des associations application/preset, le service de suivi du focus, l'intégration GUI et les fichiers d'installation nécessaires. Ajoute les tests unitaires ciblés des bindings, du service de focus, des migrations et des composants GUI associés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
146 lines
5.9 KiB
Python
146 lines
5.9 KiB
Python
# -*- coding: utf-8 -*-
|
|
# input-remapper - GUI for device specific keyboard mappings
|
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
|
#
|
|
# This file is part of input-remapper.
|
|
#
|
|
# input-remapper is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# input-remapper is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
"""GUI-side client for the user-level focus-service (``inputremapper.Focus``).
|
|
|
|
The focus-service exposes a small interface on the session bus. This client
|
|
wraps the dasbus proxy and stays defensive: when the service is not running (the
|
|
feature might be disabled, or the environment might not support focus detection)
|
|
every call degrades gracefully instead of raising, so the GUI keeps working.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from typing import Any, Callable, Dict, Optional
|
|
|
|
from dasbus.connection import SessionMessageBus
|
|
|
|
from inputremapper.bin.process_utils import ProcessUtils
|
|
from inputremapper.logging.logger import logger
|
|
|
|
FOCUS_SERVICE_NAME = "inputremapper.Focus"
|
|
FOCUS_OBJECT_PATH = "/inputremapper/Focus"
|
|
FOCUS_SERVICE_EXECUTABLE = "input-remapper-focus-service"
|
|
|
|
|
|
def ensure_focus_service_running() -> None:
|
|
"""Launch the user-level focus-service if it is not already running.
|
|
|
|
Mirrors how the GUI starts the reader-service, but the focus-service runs as
|
|
the logged-in user (no pkexec / no root).
|
|
"""
|
|
if ProcessUtils.count_python_processes(FOCUS_SERVICE_EXECUTABLE) >= 1:
|
|
logger.info("Found an input-remapper-focus-service to already be running")
|
|
return
|
|
|
|
try:
|
|
# start_new_session detaches the service from the GUI process group so it
|
|
# keeps running and is not torn down together with the GUI.
|
|
subprocess.Popen( # pylint: disable=consider-using-with
|
|
[FOCUS_SERVICE_EXECUTABLE],
|
|
start_new_session=True,
|
|
)
|
|
logger.info("Started the input-remapper-focus-service")
|
|
except Exception as error: # pylint: disable=broad-except
|
|
logger.error("Failed to start the focus-service: %s", error)
|
|
|
|
|
|
class FocusServiceClient:
|
|
"""Thin, defensive wrapper around the focus-service session-bus interface."""
|
|
|
|
def __init__(self, bus: Optional[SessionMessageBus] = None) -> None:
|
|
self._bus = bus or SessionMessageBus()
|
|
self._proxy: Any = None
|
|
self._focus_changed_handler: Optional[Callable[[str], None]] = None
|
|
|
|
def _get_proxy(self) -> Any:
|
|
if self._proxy is None:
|
|
self._proxy = self._bus.get_proxy(FOCUS_SERVICE_NAME, FOCUS_OBJECT_PATH)
|
|
return self._proxy
|
|
|
|
def _reset(self) -> None:
|
|
"""Drop the cached proxy so the next call reconnects."""
|
|
self._proxy = None
|
|
self._focus_changed_handler = None
|
|
|
|
def get_status(self) -> Optional[Dict[str, Any]]:
|
|
"""Return the service status as a dict, or None if it is unreachable."""
|
|
try:
|
|
return json.loads(self._get_proxy().get_status())
|
|
except Exception as error: # pylint: disable=broad-except
|
|
logger.debug("focus-service get_status failed: %s", error)
|
|
self._reset()
|
|
return None
|
|
|
|
def get_focused_app(self) -> Dict[str, str]:
|
|
"""Return the currently focused window as {"app_id", "title"}."""
|
|
try:
|
|
return json.loads(self._get_proxy().get_focused_app())
|
|
except Exception as error: # pylint: disable=broad-except
|
|
logger.debug("focus-service get_focused_app failed: %s", error)
|
|
self._reset()
|
|
return {"app_id": "", "title": ""}
|
|
|
|
def reload(self) -> None:
|
|
"""Ask the service to re-read the config from disk and reconcile."""
|
|
try:
|
|
self._get_proxy().reload()
|
|
except Exception as error: # pylint: disable=broad-except
|
|
logger.debug("focus-service reload failed: %s", error)
|
|
self._reset()
|
|
|
|
def set_enabled(self, enabled: bool) -> None:
|
|
"""Toggle focus-driven binding at runtime (does not write the config)."""
|
|
try:
|
|
self._get_proxy().set_enabled(enabled)
|
|
except Exception as error: # pylint: disable=broad-except
|
|
logger.debug("focus-service set_enabled failed: %s", error)
|
|
self._reset()
|
|
|
|
def connect_focus_changed(self, callback: Callable[[Dict[str, str]], None]) -> bool:
|
|
"""Subscribe to the focus_changed signal. Returns whether it succeeded."""
|
|
|
|
def handler(app_json: str) -> None:
|
|
try:
|
|
callback(json.loads(app_json))
|
|
except (ValueError, TypeError) as error:
|
|
logger.error("Invalid focus_changed payload %r: %s", app_json, error)
|
|
|
|
try:
|
|
self._get_proxy().focus_changed.connect(handler)
|
|
self._focus_changed_handler = handler
|
|
return True
|
|
except Exception as error: # pylint: disable=broad-except
|
|
logger.debug("focus-service focus_changed.connect failed: %s", error)
|
|
self._reset()
|
|
return False
|
|
|
|
def disconnect_focus_changed(self) -> None:
|
|
"""Unsubscribe from the focus_changed signal."""
|
|
if self._focus_changed_handler is None:
|
|
return
|
|
try:
|
|
self._get_proxy().focus_changed.disconnect(self._focus_changed_handler)
|
|
except Exception as error: # pylint: disable=broad-except
|
|
logger.debug("focus-service focus_changed.disconnect failed: %s", error)
|
|
finally:
|
|
self._focus_changed_handler = None
|