Avoid background title updates when no shell window list is visible, and skip output and capture invalidation for titles outside compositor decorations. Preserve focused titles, fresh window lists and capture privacy. Request high EGL context priority with fallback, cover rendering and native title behavior in CI, and keep audio recording flowing while video capture is idle.
158 lines
7.9 KiB
Python
158 lines
7.9 KiB
Python
"""Background portal against a private bus and a fake compositor event stream.
|
|
|
|
FERESE_TEST_PORTAL_BACKGROUND=1 python3 -m unittest discover -s scripts/tests -p test_portal_background_isolated.py
|
|
No Wayland session or public D-Bus service is used.
|
|
"""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
|
|
|
|
@unittest.skipUnless(os.environ.get("FERESE_TEST_PORTAL_BACKGROUND") == "1", "private Background portal test is opt-in")
|
|
class BackgroundPortal(unittest.TestCase):
|
|
def test_disconnect_clears_apps_signals_once_and_reconnect_replaces_state(self):
|
|
import gi
|
|
from gi.repository import Gio, GLib
|
|
|
|
repo = Path(__file__).resolve().parents[2]
|
|
binary = repo / os.environ.get("FERESE_TEST_PORTAL_BINARY", "target/debug/xdg-desktop-portal-ferese")
|
|
with tempfile.TemporaryDirectory(prefix="ferese-background-") as temporary:
|
|
root = Path(temporary)
|
|
runtime = root / "runtime"
|
|
runtime.mkdir(mode=0o700)
|
|
address_file = root / "control.sock"
|
|
listener = socket.socket(socket.AF_UNIX)
|
|
listener.bind(str(address_file))
|
|
listener.listen()
|
|
listener.settimeout(.1)
|
|
stop = threading.Event()
|
|
streams = []
|
|
failures = []
|
|
|
|
def read_exact(peer, count):
|
|
data = b""
|
|
while len(data) < count:
|
|
part = peer.recv(count - len(data))
|
|
if not part:
|
|
raise EOFError("IPC peer closed")
|
|
data += part
|
|
return data
|
|
|
|
def send(peer, value):
|
|
body = json.dumps(value).encode()
|
|
peer.sendall(struct.pack(">I", len(body)) + body)
|
|
|
|
def accept():
|
|
while not stop.is_set():
|
|
try:
|
|
peer, _ = listener.accept()
|
|
except socket.timeout:
|
|
continue
|
|
except OSError:
|
|
return
|
|
try:
|
|
peer.settimeout(2)
|
|
size = struct.unpack(">I", read_exact(peer, 4))[0]
|
|
self.assertLessEqual(size, 1024 * 1024)
|
|
request = json.loads(read_exact(peer, size))
|
|
if request["command"] != "event-stream":
|
|
send(peer, {"version": 1, "id": request["id"], "error": {"code": "unsupported", "message": "test peer"}})
|
|
peer.close()
|
|
continue
|
|
send(peer, {"version": 1, "id": request["id"], "result": {"version": 1}})
|
|
streams.append(peer)
|
|
except Exception as error:
|
|
peer.close()
|
|
failures.append(error)
|
|
|
|
worker = threading.Thread(target=accept, daemon=True)
|
|
worker.start()
|
|
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), FERESE_SOCKET=str(address_file),
|
|
XDG_CONFIG_HOME=str(root / "config"), XDG_CACHE_HOME=str(root / "cache"))
|
|
with (root / "portal.log").open("w+") as log:
|
|
bus = subprocess.Popen(["dbus-daemon", "--session", "--nofork", "--print-address=1"],
|
|
stdout=subprocess.PIPE, stderr=log, text=True)
|
|
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
|
|
bus.stdout.close()
|
|
connection = Gio.DBusConnection.new_for_address_sync(env["DBUS_SESSION_BUS_ADDRESS"],
|
|
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
|
|
dbus = "org.freedesktop.DBus"
|
|
# GetAppState is restricted to the frontend's bus owner.
|
|
connection.call_sync(dbus, "/org/freedesktop/DBus", dbus, "RequestName",
|
|
GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), GLib.VariantType("(u)"),
|
|
Gio.DBusCallFlags.NONE, 2000, None)
|
|
backend = subprocess.Popen([str(binary)], env=env, stdout=log, stderr=log)
|
|
name, path, interface = "org.freedesktop.impl.portal.desktop.ferese", "/org/freedesktop/portal/desktop", "org.freedesktop.impl.portal.Background"
|
|
changes = []
|
|
subscription = connection.signal_subscribe(name, interface, "RunningApplicationsChanged", path, None,
|
|
Gio.DBusSignalFlags.NONE, lambda *args: changes.append(True))
|
|
|
|
def until(predicate, timeout=5):
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
while GLib.MainContext.default().iteration(False):
|
|
pass
|
|
self.assertFalse(failures, str(failures))
|
|
self.assertIsNone(backend.poll(), (root / "portal.log").read_text())
|
|
value = predicate()
|
|
if value:
|
|
return value
|
|
time.sleep(.01)
|
|
self.fail((root / "portal.log").read_text())
|
|
|
|
def state():
|
|
return connection.call_sync(name, path, interface, "GetAppState", GLib.Variant("()", ()),
|
|
GLib.VariantType("(a{sv})"), Gio.DBusCallFlags.NO_AUTO_START, 2000, None).unpack()[0]
|
|
|
|
def snapshot(peer, app):
|
|
send(peer, {"version": 1, "generation": 0, "last": True, "type": "snapshot", "desktop": {
|
|
"outputs": [], "workspaces": [], "windows": [{
|
|
"id": 1, "app_id": app, "title": app, "focused": True,
|
|
"workspace": 1, "floating": False, "fullscreen": False}],
|
|
"focus": {"window": 1}, "config": {"revision": 0, "error": None}, "theme": None,
|
|
"lock": {"phase": "unlocked", "sleeping": False}}})
|
|
|
|
try:
|
|
until(lambda: streams)
|
|
snapshot(streams[0], "org.test.Gone")
|
|
until(lambda: state().get("org.test.Gone") == 2 and len(changes) == 1)
|
|
streams[0].shutdown(socket.SHUT_RDWR)
|
|
streams[0].close()
|
|
# Keep the replacement subscription idle until its snapshot.
|
|
# The previous desktop must already be empty during this gap.
|
|
until(lambda: not state() and len(changes) == 2)
|
|
until(lambda: len(streams) >= 2)
|
|
self.assertEqual(state(), {})
|
|
self.assertEqual(len(changes), 2)
|
|
snapshot(streams[1], "org.test.New")
|
|
until(lambda: state() == {"org.test.New": 2} and len(changes) == 3)
|
|
streams[1].shutdown(socket.SHUT_RDWR)
|
|
streams[1].close()
|
|
until(lambda: not state() and len(changes) == 4)
|
|
# A second disconnect before receiving any snapshot is a no-op.
|
|
until(lambda: len(streams) >= 3)
|
|
streams[2].shutdown(socket.SHUT_RDWR)
|
|
streams[2].close()
|
|
until(lambda: len(streams) >= 4)
|
|
self.assertEqual(state(), {})
|
|
self.assertEqual(len(changes), 4)
|
|
finally:
|
|
backend.terminate()
|
|
backend.wait(timeout=5)
|
|
connection.signal_unsubscribe(subscription)
|
|
connection.close_sync(None)
|
|
bus.terminate()
|
|
bus.wait(timeout=5)
|
|
stop.set()
|
|
listener.close()
|
|
worker.join(timeout=3)
|
|
for peer in streams:
|
|
peer.close()
|