Add configurable session-scoped global shortcut portal

This commit is contained in:
Samuel Onoja committed 2026-09-29 23:32:41 +01:00
1 parent e71cd88ff8
commit c8ed8e6c30
16 files changed
+1497 -26

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
"""Exercise shortcut connection lifetimes in an isolated nested compositor."""
import json
import os
from pathlib import Path
import signal
import socket
import struct
import subprocess
import tempfile
import time
import unittest
REPO = Path(__file__).resolve().parents[2]
@unittest.skipUnless(os.environ.get("FERESE_TEST_SHORTCUTS") == "1", "requires a Wayland host and a built compositor")
class ShortcutConnections(unittest.TestCase):
def test_disconnect_conflicts_and_configuration_reload(self):
host_runtime = Path(os.environ["XDG_RUNTIME_DIR"])
host_display = Path(os.environ["WAYLAND_DISPLAY"])
if not host_display.is_absolute():
host_display = host_runtime / host_display
with tempfile.TemporaryDirectory(prefix="ferese-shortcut-test-") as directory:
root = Path(directory)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese/config.kdl"
config.parent.mkdir(parents=True)
config.write_text("")
log = (root / "compositor.log").open("w")
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=str(host_display))
env.pop("WAYLAND_SOCKET", None)
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
process = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env,
stdout=log, stderr=log, start_new_session=True)
connections = []
try:
deadline = time.monotonic() + 15
while not (runtime / "ferese/control.sock").exists():
if process.poll() is not None or time.monotonic() >= deadline:
self.fail((root / "compositor.log").read_text())
time.sleep(0.02)
def connect():
connection = socket.socket(socket.AF_UNIX)
connection.settimeout(2)
connection.connect(str(runtime / "ferese/control.sock"))
connections.append(connection)
return connection
def call(connection, command, args=None):
data = json.dumps({"version": 1, "id": 1, "type": "command", "command": command, "args": args}).encode()
connection.sendall(struct.pack(">I", len(data)) + data)
def receive(length):
data = b""
while len(data) < length:
chunk = connection.recv(length - len(data))
if not chunk:
raise AssertionError("IPC connection closed")
data += chunk
return data
length, = struct.unpack(">I", receive(4))
self.assertLessEqual(length, 1024 * 1024)
return json.loads(receive(length))
first = connect()
second = connect()
shortcut = [{"id": "record", "trigger": "Ctrl+Alt+a"}]
self.assertNotIn("error", call(first, "portal-shortcuts-register", shortcut))
conflict = call(second, "portal-shortcuts-register", shortcut)
self.assertEqual(conflict["error"]["code"], "shortcut_conflict")
self.assertTrue(call(second, "portal-shortcuts-poll")["result"]["closed"])
first.close()
deadline = time.monotonic() + 3
while "error" in call(second, "portal-shortcuts-register", shortcut):
self.assertLess(time.monotonic(), deadline, "disconnected owner kept its binding")
time.sleep(0.02)
self.assertEqual(call(second, "portal-shortcuts-poll")["result"]["shortcuts"], shortcut)
config.write_text('binding keys="Ctrl+Alt+a" action="none"\n')
deadline = time.monotonic() + 5
while call(second, "portal-shortcuts-poll")["result"]["shortcuts"]:
self.assertLess(time.monotonic(), deadline, "configuration did not revoke conflicting shortcut")
time.sleep(0.05)
finally:
for connection in connections:
connection.close()
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5)
log.close()
if __name__ == "__main__":
unittest.main()