Files
InputRemapper/inputremapper/focus/backends/sway.py
Blomios af10ec8bd1 fix(focus): détecter correctement l'unfocus sur les backends Wayland
Le suivi du focus laissait les injections app-controlled actives lors d'une
perte de focus réelle (fermeture de la fenêtre courante, retour à un bureau
vide). On corrige la détection sur toute la chaîne :

- FocusService: un app_id vide est désormais traité comme un unfocus réel
  (et non un focus transitoire ignoré), ce qui stoppe les injections liées
  à l'application.
- Sway: on ignore les events 'new' (pas un changement de focus) et on relit
  le focus courant sur un event 'title' non focused.
- WlrForeignToplevelBackend: suit _current_handle, le clear sur stop() et
  émet un FocusEvent vide quand le toplevel actuellement focus se ferme.

Tests: test_focus_service + test_focus_watcher (67) OK; lot feature
historique (295) OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 00:42:15 +02:00

220 lines
6.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/>.
"""Focus backend for Sway (and other i3-IPC compatible compositors)."""
from __future__ import annotations
import json
import os
import socket
import struct
from typing import Optional
import gi
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent, OnFocus
from inputremapper.logging.logger import logger
gi.require_version("GLib", "2.0")
from gi.repository import GLib # noqa: E402
# https://i3wm.org/docs/ipc.html
_MAGIC = b"i3-ipc"
_HEADER = struct.Struct("=6sII") # magic, length, type (native byte order)
_MSG_SUBSCRIBE = 2
_MSG_GET_TREE = 4
_EVENT_MASK = 0x80000000
def _pack(message_type: int, payload: bytes = b"") -> bytes:
return _HEADER.pack(_MAGIC, len(payload), message_type) + payload
def _recv_exactly(sock: socket.socket, length: int) -> bytes:
chunks = []
received = 0
while received < length:
chunk = sock.recv(length - received)
if not chunk:
raise ConnectionError("Socket closed while reading")
chunks.append(chunk)
received += len(chunk)
return b"".join(chunks)
def _recv_message(sock: socket.socket) -> tuple[int, bytes]:
header = _recv_exactly(sock, _HEADER.size)
_, length, message_type = _HEADER.unpack(header)
payload = _recv_exactly(sock, length) if length else b""
return message_type, payload
def _container_to_event(container: dict) -> Optional[FocusEvent]:
if not container:
return None
app_id = container.get("app_id")
if not app_id:
# XWayland windows expose their class via window_properties instead.
window_properties = container.get("window_properties") or {}
app_id = window_properties.get("class")
title = container.get("name") or ""
return FocusEvent(app_id=app_id or "", title=title, backend=SwayFocusBackend.name)
def _find_focused(node: dict) -> Optional[dict]:
if node.get("focused"):
return node
for child in node.get("nodes", []) + node.get("floating_nodes", []):
found = _find_focused(child)
if found is not None:
return found
return None
class SwayFocusBackend(FocusBackend):
"""Subscribes to window events over the i3-IPC socket ($SWAYSOCK)."""
name = "sway"
def __init__(self) -> None:
self._socket: Optional[socket.socket] = None
self._watch_id: Optional[int] = None
self._buffer = b""
self._on_focus: Optional[OnFocus] = None
@staticmethod
def _socket_path() -> Optional[str]:
return os.environ.get("SWAYSOCK") or os.environ.get("I3SOCK")
@staticmethod
def is_available() -> bool:
path = SwayFocusBackend._socket_path()
return path is not None and os.path.exists(path)
def start(self, on_focus: OnFocus) -> None:
path = self._socket_path()
if not path:
raise RuntimeError("SWAYSOCK is not set")
self._on_focus = on_focus
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._socket.connect(path)
self._socket.sendall(_pack(_MSG_SUBSCRIBE, json.dumps(["window"]).encode()))
self._socket.setblocking(False)
self._watch_id = GLib.io_add_watch(
self._socket.fileno(),
GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR,
self._on_readable,
)
logger.debug("Sway focus backend listening on %s", path)
def _on_readable(self, _fd: int, condition: int) -> bool:
if condition & (GLib.IO_HUP | GLib.IO_ERR):
logger.error("Sway IPC socket closed")
return False
assert self._socket is not None
try:
data = self._socket.recv(65536)
except BlockingIOError:
return True
except OSError as error:
logger.error("Sway IPC read failed: %s", error)
return False
if not data:
return False
self._buffer += data
self._consume_buffer()
return True
def _consume_buffer(self) -> None:
while len(self._buffer) >= _HEADER.size:
_, length, message_type = _HEADER.unpack(self._buffer[: _HEADER.size])
total = _HEADER.size + length
if len(self._buffer) < total:
break
payload = self._buffer[_HEADER.size : total]
self._buffer = self._buffer[total:]
if message_type & _EVENT_MASK:
self._handle_event(payload)
def _handle_event(self, payload: bytes) -> None:
try:
data = json.loads(payload.decode())
except (json.JSONDecodeError, UnicodeDecodeError):
return
change = data.get("change")
if change not in ("focus", "title"):
return
if change == "title" and not (data.get("container") or {}).get("focused"):
event = self.get_current()
else:
event = _container_to_event(data.get("container") or {})
if event is not None and self._on_focus is not None:
self._on_focus(event)
def stop(self) -> None:
if self._watch_id is not None:
GLib.source_remove(self._watch_id)
self._watch_id = None
if self._socket is not None:
self._socket.close()
self._socket = None
self._buffer = b""
def get_current(self) -> Optional[FocusEvent]:
path = self._socket_path()
if not path:
return None
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(path)
sock.sendall(_pack(_MSG_GET_TREE))
while True:
message_type, payload = _recv_message(sock)
if message_type == _MSG_GET_TREE:
break
except OSError as error:
logger.error("Failed to query Sway tree: %s", error)
return None
try:
tree = json.loads(payload.decode())
except (json.JSONDecodeError, UnicodeDecodeError):
return None
focused = _find_focused(tree)
if focused is None:
return None
return _container_to_event(focused)