feat(swayfx): bridge Firefox video idle inhibition through QuickShell

This commit is contained in:
fy59 2026-10-09 06:22:19 +02:00
parent 335bd62552
commit 6cc62d2192
14 changed files with 1172 additions and 2 deletions

View file

@ -19,7 +19,7 @@ mais il ne l'active pas. Les unités effectivement versionnées sous
| session | `gnome-keyring-daemon.service`, `ssh-agent.service`, `wlsunset.service` | agents et services de session |
| desktop | `quickshell-labfy-sway.service`, `labfy-quickshell-updates.service/.timer`, `labfy-removable-media.service` | barre, notifications, vérification des mises à jour et gestion des supports amovibles |
| clipboard | `cliphist-text.service`, `cliphist-image.service` | collecte texte et images Wayland |
| idle | `swayidle.service` | verrouillage, alimentation des sorties et pause média |
| idle | `swayidle.service`, `labfy-idle-bridge.service` | verrouillage, alimentation des sorties et pause média ; relais des demandes applicatives d'inhibition vers QuickShell |
| batterie | `battery-low-notify.service/.timer` | contrôle et notification périodiques |
| maintenance | `clean-makepkg.service` | nettoyage du cache temporaire utilisateur |
| Arch Sentinel | `arch-sentinel-sample.service/.timer` | collecte périodique légère |
@ -33,6 +33,15 @@ et `timers.target.wants/` expriment les
activations utilisateur retenues. `.assets/enabled-user-aux-units.txt` capture
en complément les timers et sockets activés observés sur la machine.
`labfy-idle-bridge.service` est une unité utilisateur voulue par
`wayland-session@sway.desktop.target`, ordonnée après
`quickshell-labfy-sway.service`. Elle fournit le destinataire
`org.freedesktop.ScreenSaver` que le backend GTK du portail sollicite pour
les demandes `Inhibit`. Le helper Python/Gio communique avec QuickShell par
socket privée et ne modifie pas `swayidle.service`. Voir
[portal/README.md](../portal/README.md) pour les cookies, la récupération
après déconnexion et le retour arrière.
## Supports amovibles USB/SD et MTP
`labfy-removable-media.service` appartient au package Stow `systemd` et son

View file

@ -82,3 +82,131 @@ utiliser un dossier temporaire et éviter tout écrasement de données réelles.
Les applications GTK/Qt qui utilisent leurs dialogues natifs plutôt que le
portail conservent leur comportement. Ce réglage ne change aucune association
MIME et ne remplace pas les portails d'autres interfaces.
## Vidéo Firefox et inhibition de l'inactivité (diagnostic du 9 octobre 2026)
Dans cette session SwayFX, `swayidle.service` lance `swaylock` après 300 s,
éteint les sorties après 360 s et met les lecteurs en pause après 420 s. Le
verrouillage manuel et la protection `before-sleep` sont indépendants. Le
symptôme historique n'a pas été observé à l'instant exact du verrouillage ;
la configuration et les mesures courtes ci-dessous identifient la chaîne
d'inactivité en cause, sans attribuer rétroactivement chaque verrouillage à
`swayidle`.
La tasse « Maintenir éveillé » de QuickShell est une demande manuelle distincte.
Elle utilise un `IdleInhibitor` sur la barre persistante, qui survit à la
fermeture du Control Center. Dans le test, l'observateur `swayidle` à 6 s ne
s'est pas déclenché pendant que l'utilisateur confirmait la tasse activée ;
après sa désactivation, il s'est déclenché à 6 s sans vidéo. Lors d'une seconde
activation, le marqueur est resté absent avec le Control Center fermé, puis
avec une fenêtre Firefox de test sans vidéo en plein écran. Ce résultat
confirme l'effet de l'inhibition manuelle en fenêtre normale et en plein écran.
Après la seconde désactivation, l'observateur a de nouveau déclenché à 6 s.
L'expiration des durées n'a pas été mesurée ici. La tasse ne doit pas servir de
substitut à la demande propre à Firefox.
Avec Firefox 157.0.1 natif Wayland, une vidéo locale avec image et son produit
une demande `video-playing` vers `org.freedesktop.portal.Inhibit`. Le portail
actif sélectionne GTK par `default=gtk`, lequel reçoit la demande via
`org.freedesktop.impl.portal.Inhibit`. GTK répond à Firefox avec un chemin de
requête, mais son relais vers `org.freedesktop.ScreenSaver` échoue : le journal
du backend indique explicitement l'absence de propriétaire de ce nom D-Bus.
Le code GTK 1.15.3 répond avant de connaître le résultat de ce relais. Une
réponse D-Bus réussie de Firefox ne prouve donc pas que `swayidle` est inhibé.
Avec la tasse désactivée, l'observateur à 6 s s'est déclenché pendant cette
lecture par le portail, comme sans lecture. Les demandes de Firefox sont
libérées à la pause et à la fermeture du média ; l'audio peut conserver son
inhibition environ 10 s après la pause.
Le protocole Wayland natif existe dans SwayFX. Un Firefox de test lancé avec
`MOZ_WAKE_LOCK_TYPE=WaylandIdleInhibit` l'a acquis en lecture avec Firefox
focalisé, en fenêtre normale, en plein écran et après passage du focus au
terminal. Dans ces mesures courtes, l'observateur à 6 s n'a pas déclenché. Ce
mécanisme ne couvre toutefois pas la reprise après pause avec le terminal
focalisé : Firefox 157 tente une nouvelle acquisition, mais ne rapporte pas de
réussite.
Dans l'essai à pause longue, l'observateur a déclenché environ 17 s après la
pause (délai audio puis délai de 6 s), et est resté en état d'inactivité après
la reprise.
Une entrée `org.freedesktop.impl.portal.Inhibit=none` supprimerait le relais
GTK défaillant, sans créer d'inhibition. Elle n'est pas installée tant que
ce cas sans focus échoue ; aucun portail n'a été redémarré et les routes
`FileChooser=termfilechooser`, `ScreenCast=wlr`, `Screenshot=wlr`, `Secret` et
les autres interfaces GTK sont conservées.
Le pont utilisateur décrit ci-dessous fournit désormais le destinataire
`org.freedesktop.ScreenSaver` manquant. La règle `Inhibit=none` n'est pas
installée et aucun portail ni `swayidle` n'a été redémarré.
## Pont ScreenSaver vers QuickShell (9 octobre 2026)
Firefox demande `org.freedesktop.portal.Inhibit`, le backend GTK relaie vers
`org.freedesktop.ScreenSaver.Inhibit`, puis
`labfy-idle-bridge.service` transmet le nombre de demandes valides à
QuickShell. L'`IdleInhibitor` de la barre persistante est actif si la tasse
manuelle **ou** au moins une demande applicative est active. Les deux sources
restent indépendantes : désactiver ou laisser expirer la tasse ne libère pas
une vidéo encore demandée ; une pause vidéo ne change ni la durée ni le
minuteur manuels. La petite indication dans la sous-page Maintenir éveillé
signale une demande applicative sans lui attribuer une identité non vérifiée.
Le helper Python 3 utilise PyGObject/Gio déjà installé, exclusivement sur le
bus de session. Il expose seulement `Inhibit(ss) -> u` et `UnInhibit(u)` sur
`/org/freedesktop/ScreenSaver`, avec introspection. Chaque appel reçoit un
cookie non nul propre à la connexion D-Bus appelante. La libération vérifie
ce propriétaire ; sa disparition libère toutes ses demandes. En pratique,
GTK est ce propriétaire direct : la fermeture du navigateur d'origine dépend
de la fermeture des requêtes portal par GTK. Les tests réels ont observé
`UnInhibit` après pause, fermeture du média et arrêt du navigateur de test.
Le pont ne déduit aucune vidéo d'un titre, d'un flux audio ou de MPRIS.
Mesure avec Firefox de test Wayland, profil et vidéo locale isolés, tasse
désactivée : l'observateur `swayidle` configuré avec un fichier vide et un
marqueur à 6 s déclenche sans lecture. En lecture, il ne déclenche ni en
fenêtre normale avec le terminal focalisé, ni en plein écran. Après deux
pauses longues, il déclenche quand les demandes vidéo et audio sont libérées ;
la reprise sans redonner le focus à Firefox le désactive à nouveau. À la
fermeture du média ou du navigateur de test, il déclenche à nouveau après la
libération des demandes. La demande audio est restée environ 10 s après la
pause dans ces essais, avant le délai de 6 s de l'observateur. Une tasse
manuelle active a maintenu la protection pendant une pause ; sa désactivation
pendant la lecture reprise n'a pas supprimé la protection applicative.
L'expiration réelle d'une durée manuelle et Netflix au délai habituel de 300 s
restent à vérifier séparément. Les demandes applicatives peuvent provenir
d'autres usages que d'une vidéo visible : le pont suit le portail, il ne
classe pas le contenu de la fenêtre.
Le helper envoie un état complet puis des mises à jour ordonnées sur la socket
privée du répertoire `XDG_RUNTIME_DIR`. QuickShell confirme la prise en compte
de chaque état ; cette confirmation n'est pas un accusé du compositeur. Une
déconnexion efface immédiatement la contribution applicative dans QuickShell.
À la reconnexion, le helper renvoie seulement ses demandes encore valides.
Un redémarrage du helper perd ses demandes en mémoire : les applications
doivent renouveler leur requête ; il peut y avoir une interruption temporaire
de protection. Le compteur de cookies seul est conservé dans le répertoire
runtime de la session afin qu'un ancien cookie ne désactive pas une nouvelle
demande après ce redémarrage. Aucun nom d'application, motif ou état de
demande n'est stocké sur disque ni journalisé. Le pont limite les demandes à
256 et les libellés reçus à 512 octets chacun.
Le service utilisateur est voulu par `wayland-session@sway.desktop.target`,
après le service QuickShell. Il acquiert le nom D-Bus sans remplacer un autre
propriétaire et échoue proprement en cas de conflit. GTK utilise
`DO_NOT_AUTO_START` pour ce destinataire : un fichier d'activation D-Bus seul
ne suffirait pas. Le processus reste événementiel, avec une limite mémoire
systemd de 96 Mio. Pour diagnostiquer :
```sh
systemctl --user status labfy-idle-bridge.service
busctl --user introspect org.freedesktop.ScreenSaver /org/freedesktop/ScreenSaver
python ~/.dotfiles/tests/test_idle_bridge.py
```
Pour désactiver le pont, arrêter et désactiver uniquement
`labfy-idle-bridge.service`, puis retirer son lien Stow de
`wayland-session@sway.desktop.target.wants` lors du retour arrière. Le mode
manuel QuickShell reste disponible. Aucun changement de `portals.conf`, de
backend, de `swayidle`, du verrouillage manuel ou de la protection avant
suspension n'est nécessaire. Sans ce pont, le défaut du relais GTK décrit
ci-dessus réapparaît tant qu'aucun autre service ScreenSaver fonctionnel ne
prend le relais.

View file

@ -25,8 +25,18 @@ PanelWindow {
required property bool startupHost
required property bool resizeMode
required property var applicationCoordinator
required property var keepAwakeController
screen: modelData
// INVARIANT: une seule surface de barre porte l'inhibiteur, même avec
// plusieurs sorties. La tasse et les demandes applicatives sont deux
// propriétaires indépendants ; la libération de l'un préserve l'autre.
IdleInhibitor {
window: bar
enabled: bar.startupHost && (bar.keepAwakeController.keepAwake
|| bar.keepAwakeController.applicationRequestCount > 0)
}
anchors {
left: true
right: true
@ -421,6 +431,7 @@ PanelWindow {
adapter: controlCenter.adapter
controlCenterOpen: controlCenter.visible
audioBrightnessController: controlCenter
keepAwakeController: bar.keepAwakeController
clipboardOpen: clipboardLoader.active && !!clipboardLoader.item && !clipboardLoader.item.closing
onOpenPageRequested: page => {
bar.closeApplications();
@ -460,6 +471,7 @@ PanelWindow {
ControlCenter {
id: controlCenter
barWindow: bar
keepAwakeController: bar.keepAwakeController
onThresholdApplied: rightStatusArea.refreshBatteryThreshold()
}

View file

@ -26,6 +26,7 @@ Bar
│ ├── Network
│ ├── Bluetooth
│ ├── Battery
│ ├── Maintenir éveillé (si actif)
│ ├── Updates
│ ├── Supports amovibles
│ ├── Presse-papiers
@ -37,12 +38,34 @@ Bar
├── Volume
├── Brightness
├── Power Profile
├── Maintenir éveillé
├── Apparence
│ └── Fond d'écran
├── Battery limit
└── Session
```
Dans **Réglages rapides → Maintenir éveillé**, sélectionner 30 minutes,
1 heure, 2 heures ou **Jusqu’à désactivation**. L’icône de la barre apparaît
pendant l’inhibition ; son infobulle indique le temps restant et un clic rouvre
la page pour changer la durée ou désactiver le mode. Une seule barre porte
l’inhibiteur Wayland, même avec plusieurs écrans. Le mode empêche l’inactivité
détectée par le compositeur, donc les actions automatiques de `swayidle`
(verrouillage et extinction de l’écran) tant que l’inhibition est respectée.
Il ne bloque pas le verrouillage manuel, la veille demandée explicitement ni
le verrouillage avant mise en veille. L’état est propre au processus
QuickShell : son arrêt ou redémarrage libère l’inhibition. La configuration et
le cycle de vie de `swayidle.service` ne sont pas modifiés.
Les demandes automatiques des applications arrivent séparément par le pont
`org.freedesktop.ScreenSaver` décrit dans [portal/README.md](../../../../portal/README.md).
Le même inhibiteur de la barre reste actif tant que la tasse manuelle ou au
moins une demande applicative valide est présente. La sous-page indique une
demande automatique sans changer la durée de la tasse ; désactiver la tasse
ne retire pas la demande d'une vidéo encore en lecture. La disparition du
helper efface sa seule contribution applicative, tandis que la tasse garde
son propre état.
L'icône **Presse-papiers** ouvre le panneau d'historique sous la zone droite de
la barre ; `Mod+Alt+V` conserve le même basculement sur l'écran focalisé.
L'architecture, les raccourcis, les tests isolés et le retour vers Wofi sont

View file

@ -12,12 +12,13 @@ PopupWindow {
id: popup
required property var barWindow
required property var keepAwakeController
// CONTRACT: indices externes historiques conservés ; tout nouveau routage
// passe par ces noms afin de ne pas multiplier les indices StackLayout.
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8,
nightLight: 9, sunMode: 10, locationTime: 11, sessionManager: 12,
checkpointFailure: 13, avatar: 14, audio: 15 })
checkpointFailure: 13, avatar: 14, audio: 15, keepAwake: 16 })
property int currentPage: pages.main
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
signal thresholdApplied()
@ -176,6 +177,31 @@ PopupWindow {
BrightnessSlider { id: brightnessSlider; width: parent.width }
PowerProfile { width: parent.width }
Rectangle {
width: parent.width; height: 38; radius: 4
color: popup.keepAwakeController.keepAwake ? Theme.accent
: keepAwakePointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
NerdIcon {
anchors.left: parent.left; anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: "󰅶"
color: popup.keepAwakeController.keepAwake ? Theme.onAccent : Theme.accent
font.pixelSize: 18
}
Text {
anchors.left: parent.left; anchors.leftMargin: 42
anchors.verticalCenter: parent.verticalCenter
text: "Maintenir éveillé"
color: popup.keepAwakeController.keepAwake ? Theme.onAccent : Theme.foreground
font.pixelSize: 13
}
MouseArea {
id: keepAwakePointer
anchors.fill: parent; hoverEnabled: true
onClicked: popup.openPage("keepAwake")
}
}
Rectangle {
width: parent.width; height: 38; radius: 4
color: appearancePointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
@ -336,6 +362,11 @@ PopupWindow {
volumeControl: volumeSlider
onBackRequested: popup.openPage("main")
}
// Append-only: les indices publics des pages précédentes restent stables.
KeepAwakePage {
controller: popup.keepAwakeController
onBackRequested: popup.openPage("main")
}
}
}
}

View file

@ -0,0 +1,102 @@
import QtQuick
import "../components"
import "../theme"
Item {
id: page
required property var controller
signal backRequested()
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: 10
Item {
width: parent.width; height: 28
NerdIcon {
id: backIcon
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: ""
}
Text {
anchors.left: backIcon.right; anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Maintenir éveillé"
color: Theme.foreground
font.pixelSize: 16; font.bold: true
}
MouseArea {
anchors.left: parent.left; anchors.top: parent.top
anchors.bottom: parent.bottom; width: 190
onClicked: page.backRequested()
}
}
Text {
width: parent.width
text: "Empêche le verrouillage automatique et l’extinction de l’écran."
wrapMode: Text.Wrap
color: Theme.secondaryForeground
font.pixelSize: 12
}
Text {
width: parent.width
visible: page.controller.applicationRequestCount > 0
text: "Maintien automatique demandé par une application"
wrapMode: Text.Wrap
color: Theme.secondaryForeground
font.pixelSize: 12
}
Repeater {
model: [
{ label: "30 minutes", minutes: 30 },
{ label: "1 heure", minutes: 60 },
{ label: "2 heures", minutes: 120 },
{ label: "Jusqu’à désactivation", minutes: 0 }
]
delegate: Rectangle {
required property var modelData
width: page.width; height: 44; radius: 4
readonly property bool selected: page.controller.keepAwake
&& page.controller.keepAwakeMinutes === modelData.minutes
color: selected ? Theme.accent
: optionPointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
Text {
anchors.left: parent.left; anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: modelData.label
color: selected ? Theme.onAccent : Theme.foreground
font.pixelSize: 13
}
MouseArea {
id: optionPointer
anchors.fill: parent; hoverEnabled: true
onClicked: page.controller.setKeepAwake(modelData.minutes)
}
}
}
Rectangle {
width: parent.width; height: 44; radius: 4
visible: page.controller.keepAwake
color: stopPointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
Text {
anchors.left: parent.left; anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: "Désactiver"
color: Theme.foreground
font.pixelSize: 13
}
MouseArea {
id: stopPointer
anchors.fill: parent; hoverEnabled: true
onClicked: page.controller.clearKeepAwake()
}
}
}
}

View file

@ -0,0 +1,66 @@
import QtQuick
import Quickshell
import Quickshell.Io
Item {
id: bridge
// CONTRACT: only the live, acknowledged helper connection contributes
// application requests. A reload or disconnect drops its contribution;
// the manual keep-awake selection is owned elsewhere in ShellRoot.
property int applicationRequestCount: 0
property var currentSocket: null
property string generation: ""
property int lastSequence: 0
function receive(socket, line) {
if (socket !== currentSocket || line.length > 512) {
socket.connected = false;
return;
}
let frame;
try { frame = JSON.parse(line); }
catch (_) { socket.connected = false; return; }
if (!frame || frame.type !== "snapshot" || frame.version !== 1
|| typeof frame.generation !== "string"
|| !/^[0-9a-f]{32}$/.test(frame.generation)
|| !Number.isSafeInteger(frame.sequence) || frame.sequence <= lastSequence
|| !Number.isInteger(frame.count) || frame.count < 0 || frame.count > 256
|| (generation && frame.generation !== generation)) {
socket.connected = false;
return;
}
generation = frame.generation;
lastSequence = frame.sequence;
applicationRequestCount = frame.count;
// CONTRACT: this acknowledges assignment of the QuickShell state,
// not a compositor confirmation (idle-inhibit-v1 has no such ack).
socket.write(JSON.stringify({ type: "ack", generation: generation,
sequence: lastSequence, applied: true }) + "\n");
socket.flush();
}
SocketServer {
active: true
path: Quickshell.env("LABFY_IDLE_BRIDGE_SOCKET")
|| (Quickshell.env("XDG_RUNTIME_DIR") + "/labfy-idle-bridge.sock")
handler: Socket {
id: peer
onConnectedChanged: {
if (connected) {
if (bridge.currentSocket && bridge.currentSocket !== peer)
bridge.currentSocket.connected = false;
bridge.currentSocket = peer;
bridge.applicationRequestCount = 0;
bridge.generation = "";
bridge.lastSequence = 0;
} else if (bridge.currentSocket === peer) {
bridge.currentSocket = null;
bridge.applicationRequestCount = 0;
bridge.generation = "";
bridge.lastSequence = 0;
}
}
parser: SplitParser { onRead: line => bridge.receive(peer, line) }
}
}
}

View file

@ -0,0 +1,411 @@
#!/usr/bin/env python3
"""Bridge the session ScreenSaver inhibit API to QuickShell's Wayland surface.
CONTRACT: this process owns cookies and D-Bus caller lifetimes; QuickShell owns
the only Wayland IdleInhibitor. No request state is persisted across restarts.
"""
import json
import os
import socket
import stat
import struct
import sys
import uuid
from collections import deque
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
NAME = "org.freedesktop.ScreenSaver"
PATH = "/org/freedesktop/ScreenSaver"
IFACE = NAME
MAX_REQUESTS = 256
MAX_QUEUE = 256
MAX_TEXT_BYTES = 512
MAX_FRAME = 4096
ACK_SECONDS = 3
XML = """<node>
<interface name="org.freedesktop.ScreenSaver">
<method name="Inhibit">
<arg name="application_name" type="s" direction="in"/>
<arg name="reason_for_inhibit" type="s" direction="in"/>
<arg name="cookie" type="u" direction="out"/>
</method>
<method name="UnInhibit">
<arg name="cookie" type="u" direction="in"/>
</method>
</interface>
</node>"""
class Bridge:
def __init__(self, socket_path, runtime):
self.socket_path = socket_path
self.runtime = runtime
self.counter_path = os.path.join(runtime, "labfy-idle-bridge.counter")
self.bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
self.loop = GLib.MainLoop()
self.requests = {} # cookie -> unique D-Bus sender; labels are never retained.
self.queue = deque()
self.current = None
self.dirty = False
self.sock = None
self.watch = None
self.retry = None
self.ack_timer = None
self.inbuf = bytearray()
self.outbuf = bytearray()
self.generation = uuid.uuid4().hex
self.sequence = 0
self.awaiting_sequence = None
self.ready = False
# CONTRACT: only the next cookie number, never an inhibition request,
# persists in the session runtime dir. A stale UnInhibit after helper
# restart cannot release a newer request in the same D-Bus session.
self.next_cookie = self.read_counter() + 1
def read_counter(self):
try:
fd = os.open(self.counter_path, os.O_RDONLY | os.O_NOFOLLOW)
except FileNotFoundError:
return 0
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_size > 11:
raise RuntimeError("invalid cookie counter file")
data = os.read(fd, 12)
if not data.endswith(b"\n") or not data[:-1].isdigit():
raise RuntimeError("corrupt cookie counter file")
value = int(data[:-1])
if value > 0xFFFFFFFF:
raise RuntimeError("cookie counter out of range")
return value
finally:
os.close(fd)
def persist_cookie(self, cookie):
# WHY: write/rename/fsync precedes the D-Bus success reply. A crash
# can skip a number but cannot roll back a cookie already returned.
temporary = self.counter_path + f".tmp.{os.getpid()}"
data = f"{cookie}\n".encode("ascii")
try:
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
if os.write(fd, data) != len(data):
raise OSError("short cookie counter write")
os.fsync(fd)
finally:
os.close(fd)
os.replace(temporary, self.counter_path)
except OSError:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
raise
directory = os.open(self.runtime, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
def has_owner(self, name):
reply = self.bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "NameHasOwner",
GLib.Variant("(s)", (name,)), GLib.VariantType.new("(b)"),
Gio.DBusCallFlags.NONE, 1000, None)
return reply.unpack()[0]
def start(self):
if self.has_owner(NAME):
raise NameConflict("org.freedesktop.ScreenSaver already has an owner")
info = Gio.DBusNodeInfo.new_for_xml(XML).interfaces[0]
self.bus.register_object(PATH, info, self.on_method, None, None)
# WHY: GTK's proxy uses DO_NOT_AUTO_START. A session service must own
# the name proactively; RequestName without REPLACE_EXISTING cannot
# steal it from another implementation in the check/acquire race.
reply = self.bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "RequestName",
GLib.Variant("(su)", (NAME, 4)),
GLib.VariantType.new("(u)"),
Gio.DBusCallFlags.NONE, 1000, None)
if reply.unpack()[0] != 1:
raise NameConflict("org.freedesktop.ScreenSaver name acquisition failed")
self.bus.signal_subscribe("org.freedesktop.DBus", "org.freedesktop.DBus",
"NameOwnerChanged", "/org/freedesktop/DBus", None,
Gio.DBusSignalFlags.NONE, self.on_name_owner_changed)
self.bus.connect("closed", self.on_bus_closed)
self.connect_socket()
self.loop.run()
def on_bus_closed(self, *_args):
self.loop.quit()
@staticmethod
def fail(invocation, name, message):
try:
invocation.return_dbus_error(name, message)
except Exception:
pass # The unique D-Bus caller may already have disconnected.
def on_method(self, _connection, sender, _path, _interface, method, params, invocation):
if method == "Inhibit":
app, reason = params.unpack()
if len(app.encode("utf-8")) > MAX_TEXT_BYTES or len(reason.encode("utf-8")) > MAX_TEXT_BYTES:
return self.fail(invocation, "org.freedesktop.DBus.Error.LimitsExceeded", "label too long")
if not self.ready or len(self.queue) >= MAX_QUEUE or len(self.requests) >= MAX_REQUESTS:
return self.fail(invocation, "org.freedesktop.DBus.Error.LimitsExceeded"
if self.ready else "org.freedesktop.DBus.Error.NoReply",
"bridge unavailable or full")
self.queue.append({"kind": "add", "sender": sender, "invocation": invocation})
self.advance()
elif method == "UnInhibit":
cookie = params.unpack()[0]
owner = self.requests.get(cookie)
if owner is None:
return self.fail(invocation, "org.freedesktop.DBus.Error.InvalidArgs", "unknown cookie")
if owner != sender:
return self.fail(invocation, "org.freedesktop.DBus.Error.AccessDenied", "cookie owner differs")
if not self.ready:
# CONTRACT: never retain an orphan merely because QuickShell
# is down. Its socket disconnect already cleared the surface.
del self.requests[cookie]
self.dirty = True
return self.fail(invocation, "org.freedesktop.DBus.Error.NoReply",
"QuickShell unavailable during release")
if len(self.queue) >= MAX_QUEUE:
return self.fail(invocation, "org.freedesktop.DBus.Error.LimitsExceeded", "bridge queue full")
self.queue.append({"kind": "remove", "sender": sender,
"cookie": cookie, "invocation": invocation})
self.advance()
def advance(self):
if self.current or self.awaiting_sequence is not None or not self.ready:
return
if self.dirty:
self.dirty = False
self.snapshot(None)
return
while self.queue:
op = self.queue.popleft()
if not self.has_owner(op["sender"]):
self.fail(op["invocation"], "org.freedesktop.DBus.Error.NameHasNoOwner",
"requester disconnected")
continue
if op["kind"] == "add":
if len(self.requests) >= MAX_REQUESTS or self.next_cookie > 0xFFFFFFFF:
self.fail(op["invocation"], "org.freedesktop.DBus.Error.LimitsExceeded",
"cookie capacity exhausted")
continue
op["cookie"] = self.next_cookie
self.next_cookie += 1
try:
self.persist_cookie(op["cookie"])
except OSError:
self.fail(op["invocation"], "org.freedesktop.DBus.Error.Failed",
"cookie counter could not be persisted")
continue
self.requests[op["cookie"]] = op["sender"]
else:
if self.requests.get(op["cookie"]) != op["sender"]:
self.fail(op["invocation"], "org.freedesktop.DBus.Error.InvalidArgs",
"cookie already released")
continue
del self.requests[op["cookie"]]
self.current = op
self.snapshot(op)
return
def snapshot(self, op):
if not self.sock:
return self.disconnect()
self.sequence += 1
self.awaiting_sequence = self.sequence
frame = {"type": "snapshot", "version": 1, "generation": self.generation,
"sequence": self.sequence, "count": len(self.requests)}
self.outbuf.extend((json.dumps(frame, separators=(",", ":")) + "\n").encode())
self.update_watch()
self.ack_timer = GLib.timeout_add_seconds(ACK_SECONDS, self.on_ack_timeout)
def on_ack_timeout(self):
self.ack_timer = None
self.disconnect()
return GLib.SOURCE_REMOVE
def on_name_owner_changed(self, _bus, _sender, _path, _interface, _signal, params):
name, _old, new = params.unpack()
if not name.startswith(":") or new:
return
old_count = len(self.requests)
for cookie, owner in list(self.requests.items()):
if owner == name:
del self.requests[cookie]
for op in list(self.queue):
if op["sender"] == name:
self.queue.remove(op)
self.fail(op["invocation"], "org.freedesktop.DBus.Error.NameHasNoOwner",
"requester disconnected")
if self.current and self.current["sender"] == name:
self.current["cancelled"] = True
if len(self.requests) != old_count:
self.dirty = True
self.advance()
def connect_socket(self):
self.retry = None
if self.sock:
return GLib.SOURCE_REMOVE
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(0.15)
try:
sock.connect(self.socket_path)
# The runtime directory is 0700, and SO_PEERCRED also rejects a
# misdirected path served by a different Unix uid.
uid = struct.unpack("3i", sock.getsockopt(socket.SOL_SOCKET,
socket.SO_PEERCRED, struct.calcsize("3i")))[1]
if uid != os.getuid():
raise PermissionError("QuickShell socket uid differs")
except (OSError, PermissionError):
sock.close()
self.retry = GLib.timeout_add_seconds(1, self.connect_socket)
return GLib.SOURCE_REMOVE
sock.setblocking(False)
self.sock = sock
self.ready = False
self.inbuf.clear()
self.outbuf.clear()
self.update_watch()
self.snapshot(None) # Complete initial state before accepting calls.
return GLib.SOURCE_REMOVE
def update_watch(self):
if self.watch:
GLib.source_remove(self.watch)
condition = GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR
if self.outbuf:
condition |= GLib.IO_OUT
self.watch = GLib.io_add_watch(self.sock.fileno(), condition, self.on_socket_event)
def on_socket_event(self, _fd, condition):
if condition & (GLib.IO_HUP | GLib.IO_ERR):
self.disconnect()
return GLib.SOURCE_REMOVE
try:
if condition & GLib.IO_OUT and self.outbuf:
sent = self.sock.send(self.outbuf)
del self.outbuf[:sent]
if condition & GLib.IO_IN:
chunk = self.sock.recv(1024)
if not chunk:
self.disconnect()
return GLib.SOURCE_REMOVE
self.inbuf.extend(chunk)
if len(self.inbuf) > MAX_FRAME:
self.disconnect()
return GLib.SOURCE_REMOVE
while b"\n" in self.inbuf:
line, _, rest = self.inbuf.partition(b"\n")
self.inbuf = bytearray(rest)
self.on_frame(line)
except (OSError, ValueError):
self.disconnect()
return GLib.SOURCE_REMOVE
if self.sock:
self.update_watch()
return GLib.SOURCE_REMOVE
def on_frame(self, line):
try:
frame = json.loads(line)
except (ValueError, UnicodeDecodeError):
return self.disconnect()
if (not isinstance(frame, dict) or frame.get("type") != "ack"
or frame.get("generation") != self.generation
or type(frame.get("sequence")) is not int
or frame["sequence"] != self.awaiting_sequence
or frame.get("applied") is not True):
return self.disconnect()
if self.ack_timer:
GLib.source_remove(self.ack_timer)
self.ack_timer = None
self.awaiting_sequence = None
op = self.current
self.current = None
if op is None:
self.ready = True
elif op.get("cancelled") or not self.has_owner(op["sender"]):
if op["kind"] == "add":
self.requests.pop(op["cookie"], None)
self.dirty = True
self.fail(op["invocation"], "org.freedesktop.DBus.Error.NameHasNoOwner",
"requester disconnected")
elif op["kind"] == "add":
op["invocation"].return_value(GLib.Variant("(u)", (op["cookie"],)))
else:
op["invocation"].return_value(GLib.Variant("()", ()))
self.advance()
def disconnect(self):
if self.ack_timer:
GLib.source_remove(self.ack_timer)
self.ack_timer = None
if self.watch:
GLib.source_remove(self.watch)
self.watch = None
if self.sock:
self.sock.close()
self.sock = None
self.ready = False
self.awaiting_sequence = None
self.inbuf.clear()
self.outbuf.clear()
# WHY: QuickShell clears the application contribution on disconnect.
# Roll back an unacknowledged transaction before the next full snapshot.
if self.current:
op = self.current
self.current = None
if op["kind"] == "add":
self.requests.pop(op["cookie"], None)
elif not op.get("cancelled") and self.has_owner(op["sender"]):
self.requests[op["cookie"]] = op["sender"]
self.fail(op["invocation"], "org.freedesktop.DBus.Error.NoReply",
"QuickShell did not acknowledge")
while self.queue:
op = self.queue.popleft()
self.fail(op["invocation"], "org.freedesktop.DBus.Error.NoReply",
"QuickShell unavailable")
self.dirty = False
if not self.retry:
self.retry = GLib.timeout_add_seconds(1, self.connect_socket)
class NameConflict(RuntimeError):
pass
def main():
runtime = os.environ.get("XDG_RUNTIME_DIR", "")
if not runtime or not os.path.isabs(runtime):
raise RuntimeError("XDG_RUNTIME_DIR must be absolute")
info = os.stat(runtime)
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
raise RuntimeError("XDG_RUNTIME_DIR must be private to this user")
path = os.environ.get("LABFY_IDLE_BRIDGE_SOCKET", os.path.join(runtime, "labfy-idle-bridge.sock"))
if not os.path.isabs(path):
raise RuntimeError("socket path must be absolute")
Bridge(path, runtime).start()
if __name__ == "__main__":
try:
main()
except NameConflict as error:
print(f"idle bridge: {error}", file=sys.stderr)
sys.exit(2)
except (GLib.Error, RuntimeError) as error:
print(f"idle bridge: {error}", file=sys.stderr)
sys.exit(1)

View file

@ -4,11 +4,48 @@ import Quickshell
import Quickshell.Io
import Quickshell.I3
import "notifications"
import "idlebridge"
import "theme"
ShellRoot {
id: shell
property bool resizeMode: false
// CONTRACT: une seule sélection pour toutes les barres. L'inhibition est
// volontairement éphémère : un redémarrage de QuickShell la libère.
property bool keepAwake: false
property int keepAwakeMinutes: 0
property int keepAwakeRemainingSeconds: 0
// INVARIANT: the helper's application requests never change the user's
// manual duration or its monotonic expiry timer.
IdleBridgeReceiver { id: idleBridge }
readonly property int applicationRequestCount: idleBridge.applicationRequestCount
function setKeepAwake(minutes) {
if (![0, 30, 60, 120].includes(minutes)) return false;
keepAwake = true;
keepAwakeMinutes = minutes;
keepAwakeRemainingSeconds = minutes * 60;
if (minutes > 0) keepAwakeElapsed.restartMs();
return true;
}
function clearKeepAwake() {
keepAwake = false;
keepAwakeMinutes = 0;
keepAwakeRemainingSeconds = 0;
}
// WHY: l'horloge monotone évite qu'un changement d'heure allonge ou
// raccourcisse une durée choisie. Le tick ne sert qu'à publier l'affichage.
ElapsedTimer { id: keepAwakeElapsed }
Timer {
interval: 1000
repeat: true
running: shell.keepAwake && shell.keepAwakeMinutes > 0
onTriggered: {
const remaining = shell.keepAwakeMinutes * 60
- Math.floor(keepAwakeElapsed.elapsedMs() / 1000);
if (remaining <= 0) shell.clearKeepAwake();
else shell.keepAwakeRemainingSeconds = remaining;
}
}
// CONTRACT: one owner across all output bars; opening a menu asks every
// bar to release its other transient panels before the overlay appears.
property string applicationsOutput: ""
@ -78,6 +115,7 @@ ShellRoot {
Bar {
applicationCoordinator: shell
keepAwakeController: shell
resizeMode: shell.resizeMode
notificationService: rootNotificationService
// Une seule barre possède le chooser, même en configuration multi-écran.

View file

@ -0,0 +1,39 @@
import QtQuick
import "../components"
import "../theme"
Item {
id: indicator
required property bool active
required property bool unlimited
required property int remainingSeconds
signal activated()
width: active ? 26 : 0
height: 26
visible: active
Rectangle {
anchors.fill: parent
radius: 4
color: pointer.containsMouse ? Theme.buttonHover : Theme.emphasisBackground
}
NerdIcon {
anchors.centerIn: parent
text: "󰅶"
font.pixelSize: 22
color: Theme.accentForeground
}
MouseArea {
id: pointer
anchors.fill: parent
hoverEnabled: true
onClicked: indicator.activated()
}
StatusTooltip {
target: indicator
hovered: pointer.containsMouse
message: indicator.unlimited ? "Maintenir éveillé · jusqu’à désactivation"
: "Maintenir éveillé · " + Math.ceil(indicator.remainingSeconds / 60)
+ " min restantes"
}
}

View file

@ -11,6 +11,7 @@ Row {
required property bool controlCenterOpen
required property bool clipboardOpen
required property var audioBrightnessController
required property var keepAwakeController
signal openPageRequested(int page)
signal toggleControlCenterRequested()
signal toggleRemovableMediaRequested()
@ -61,6 +62,12 @@ Row {
Row {
spacing: 5
height: 26
KeepAwakeIndicator {
active: area.keepAwakeController.keepAwake
remainingSeconds: area.keepAwakeController.keepAwakeRemainingSeconds
unlimited: area.keepAwakeController.keepAwakeMinutes === 0
onActivated: area.openPageRequested(16)
}
UpdatesIndicator { }
RemovableMediaIndicator {
id: removableMediaIndicator

View file

@ -0,0 +1,21 @@
[Unit]
Description=Session ScreenSaver inhibit bridge for QuickShell
PartOf=wayland-session@sway.desktop.target
After=wayland-session@sway.desktop.target quickshell-labfy-sway.service
StartLimitIntervalSec=60
StartLimitBurst=3
[Service]
Type=simple
ExecStart=/usr/bin/python3 %h/.config/quickshell/labfy-sway/idlebridge/bridge.py
Restart=on-failure
RestartPreventExitStatus=2
RestartSec=3s
UMask=0077
MemoryMax=96M
TasksMax=16
LimitNOFILE=64
NoNewPrivileges=yes
[Install]
WantedBy=wayland-session@sway.desktop.target

View file

@ -0,0 +1 @@
../labfy-idle-bridge.service

282
tests/test_idle_bridge.py Normal file
View file

@ -0,0 +1,282 @@
"""Isolated session-bus tests for the ScreenSaver/QuickShell bridge.
Run with ``python tests/test_idle_bridge.py``; it creates its own private bus.
The fake QuickShell owns only a private Unix socket; no daily bus is touched.
"""
import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
ROOT = Path(__file__).resolve().parents[1]
BRIDGE = ROOT / "quickshell/.config/quickshell/labfy-sway/idlebridge/bridge.py"
NAME = "org.freedesktop.ScreenSaver"
PATH = "/org/freedesktop/ScreenSaver"
class FakeShell:
def __init__(self, path):
self.path = str(path)
self.listener = socket.socket(socket.AF_UNIX)
self.listener.bind(self.path)
self.listener.listen(2)
self.listener.settimeout(0.25)
self.lock = threading.Condition()
self.counts = []
self.connection = None
self.running = True
self.ack = True
self.pending = []
self.thread = threading.Thread(target=self.run, daemon=True)
self.thread.start()
def run(self):
while self.running:
try:
conn, _ = self.listener.accept()
except socket.timeout:
continue
except OSError:
return
conn.settimeout(0.25)
with self.lock:
self.connection = conn
self.counts.append(0) # QuickShell drops state on connect.
self.lock.notify_all()
data = bytearray()
while self.running:
try:
chunk = conn.recv(1024)
if not chunk:
break
data.extend(chunk)
while b"\n" in data:
line, _, rest = data.partition(b"\n")
data = bytearray(rest)
frame = json.loads(line)
with self.lock:
self.counts.append(frame["count"])
answer = {"type": "ack", "generation": frame["generation"],
"sequence": frame["sequence"], "applied": True}
if self.ack:
conn.sendall((json.dumps(answer) + "\n").encode())
else:
self.pending.append(answer)
self.lock.notify_all()
except socket.timeout:
continue
except OSError:
break
with self.lock:
if self.connection is conn:
self.connection = None
self.counts.append(0) # QuickShell drops state on disconnect.
self.lock.notify_all()
conn.close()
def wait_count(self, count, after=0, timeout=5):
end = time.monotonic() + timeout
with self.lock:
while True:
if count in self.counts[after:]:
return len(self.counts)
remaining = end - time.monotonic()
if remaining <= 0:
raise AssertionError(f"count {count} absent after {after}: {self.counts}")
self.lock.wait(remaining)
def release_ack(self):
with self.lock:
while self.pending and self.connection:
frame = self.pending.pop(0)
self.connection.sendall((json.dumps(frame) + "\n").encode())
self.ack = True
def disconnect(self):
with self.lock:
if self.connection:
try:
self.connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
def close(self):
self.running = False
self.disconnect()
self.listener.close()
self.thread.join(timeout=2)
def bus_connection():
return Gio.DBusConnection.new_for_address_sync(
os.environ["DBUS_SESSION_BUS_ADDRESS"],
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT
| Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
def call(conn, method, signature, args, result):
return conn.call_sync(NAME, PATH, NAME, method, GLib.Variant(signature, args),
GLib.VariantType.new(result), Gio.DBusCallFlags.NONE, 3000, None).unpack()
class BridgeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="idle-bridge-test-")
self.path = Path(self.temp.name) / "shell.sock"
self.shell = FakeShell(self.path)
env = dict(os.environ, LABFY_IDLE_BRIDGE_SOCKET=str(self.path), XDG_RUNTIME_DIR=self.temp.name)
self.proc = subprocess.Popen([sys.executable, str(BRIDGE)], env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
self.a = bus_connection()
self.b = bus_connection()
end = time.monotonic() + 5
while time.monotonic() < end:
try:
if bus_connection().call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus",
"org.freedesktop.DBus", "NameHasOwner", GLib.Variant("(s)", (NAME,)),
GLib.VariantType.new("(b)"), Gio.DBusCallFlags.NONE, 500, None).unpack()[0]:
break
except GLib.Error:
pass
time.sleep(0.05)
else:
raise AssertionError(f"bridge did not acquire name: {self.proc.poll()}")
self.shell.wait_count(0)
# Wait for the initial snapshot acknowledgment to reach the helper.
for _ in range(30):
try:
call(self.a, "Inhibit", "(ss)", ("test", "one"), "(u)")
self.shell.wait_count(1)
break
except GLib.Error:
time.sleep(0.05)
else:
raise AssertionError("bridge did not become ready")
# The setup request belongs to client A and is removed before each test.
first = max(self.shell.counts)
self.assertEqual(first, 1)
# The only active cookie is obtained from the last test call by using
# a dedicated second fresh setup in each test would be wasteful. Close
# A and reopen it, relying on NameOwnerChanged cleanup.
self.a.close_sync(None)
self.shell.wait_count(0, after=2)
self.a = bus_connection()
def tearDown(self):
for conn in (self.a, self.b):
try:
conn.close_sync(None)
except GLib.Error:
pass
self.proc.terminate()
try:
self.proc.communicate(timeout=3)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.communicate()
self.shell.close()
self.temp.cleanup()
def test_independent_cookies_and_ownership(self):
start = len(self.shell.counts)
one = call(self.a, "Inhibit", "(ss)", ("", ""), "(u)")[0]
self.shell.wait_count(1, start)
two = call(self.a, "Inhibit", "(ss)", ("same", "same"), "(u)")[0]
self.shell.wait_count(2, start)
three = call(self.b, "Inhibit", "(ss)", ("same", "same"), "(u)")[0]
self.shell.wait_count(3, start)
self.assertEqual(len({one, two, three}), 3)
self.assertTrue(all((one, two, three)))
with self.assertRaises(GLib.Error):
call(self.b, "UnInhibit", "(u)", (one,), "()")
self.assertEqual(self.shell.counts[-1], 3)
call(self.a, "UnInhibit", "(u)", (one,), "()")
self.shell.wait_count(2, start)
with self.assertRaises(GLib.Error):
call(self.a, "UnInhibit", "(u)", (one,), "()")
with self.assertRaises(GLib.Error):
call(self.a, "UnInhibit", "(u)", (0,), "()")
self.a.close_sync(None)
self.shell.wait_count(1, start)
call(self.b, "UnInhibit", "(u)", (three,), "()")
self.shell.wait_count(0, start)
self.a = bus_connection()
def test_reconnect_and_conflict(self):
cookie = call(self.a, "Inhibit", "(ss)", ("test", "hold"), "(u)")[0]
self.shell.wait_count(1)
rival = subprocess.run([sys.executable, str(BRIDGE)], env=dict(os.environ,
LABFY_IDLE_BRIDGE_SOCKET=str(self.path), XDG_RUNTIME_DIR=self.temp.name),
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=3)
self.assertEqual(rival.returncode, 2)
self.shell.disconnect()
self.shell.wait_count(0, after=len(self.shell.counts) - 1)
self.shell.wait_count(1, after=len(self.shell.counts) - 1, timeout=5)
call(self.a, "UnInhibit", "(u)", (cookie,), "()")
self.shell.wait_count(0)
def test_cancel_before_ack(self):
with self.shell.lock:
self.shell.ack = False
# Async D-Bus call lets the test close the caller while QuickShell has
# seen the provisional count but has not acknowledged it.
done = threading.Event()
self.a.call(NAME, PATH, NAME, "Inhibit", GLib.Variant("(ss)", ("test", "hold")),
GLib.VariantType.new("(u)"), Gio.DBusCallFlags.NONE, 3000, None,
lambda *_args: done.set())
mark = len(self.shell.counts)
self.shell.wait_count(1, mark)
self.a.close_sync(None)
self.a = bus_connection()
self.shell.release_ack()
self.shell.wait_count(0, mark)
def test_unavailable_shell_fails_new_acquisition(self):
self.shell.close()
time.sleep(0.4)
with self.assertRaises(GLib.Error):
call(self.a, "Inhibit", "(ss)", ("test", "hold"), "(u)")
def test_cookie_not_reused_after_bridge_restart(self):
first = call(self.a, "Inhibit", "(ss)", ("test", "hold"), "(u)")[0]
call(self.a, "UnInhibit", "(u)", (first,), "()")
self.proc.terminate()
self.proc.communicate(timeout=3)
env = dict(os.environ, LABFY_IDLE_BRIDGE_SOCKET=str(self.path), XDG_RUNTIME_DIR=self.temp.name)
self.proc = subprocess.Popen([sys.executable, str(BRIDGE)], env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
end = time.monotonic() + 5
while time.monotonic() < end:
try:
second = call(self.a, "Inhibit", "(ss)", ("test", "again"), "(u)")[0]
break
except GLib.Error:
time.sleep(0.05)
else:
raise AssertionError("restarted bridge did not become ready")
self.assertGreater(second, first)
with self.assertRaises(GLib.Error):
call(self.a, "UnInhibit", "(u)", (first,), "()")
call(self.a, "UnInhibit", "(u)", (second,), "()")
if __name__ == "__main__":
if os.environ.get("LABFY_IDLE_BRIDGE_PRIVATE_BUS") != "1":
result = subprocess.run(["dbus-run-session", "--", "env",
"LABFY_IDLE_BRIDGE_PRIVATE_BUS=1", sys.executable,
str(Path(__file__).resolve()), *sys.argv[1:]])
sys.exit(result.returncode)
unittest.main()