From 6cc62d2192db007ef3c4a65d6daa9d366e65cdc3 Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 9 Oct 2026 06:22:19 +0200 Subject: [PATCH] feat(swayfx): bridge Firefox video idle inhibition through QuickShell --- docs/systemd.md | 11 +- portal/README.md | 128 ++++++ .../.config/quickshell/labfy-sway/Bar.qml | 12 + .../.config/quickshell/labfy-sway/README.md | 23 + .../controlcenter/ControlCenter.qml | 33 +- .../controlcenter/KeepAwakePage.qml | 102 +++++ .../idlebridge/IdleBridgeReceiver.qml | 66 +++ .../labfy-sway/idlebridge/bridge.py | 411 ++++++++++++++++++ .../.config/quickshell/labfy-sway/shell.qml | 38 ++ .../labfy-sway/status/KeepAwakeIndicator.qml | 39 ++ .../labfy-sway/status/RightStatusArea.qml | 7 + .../systemd/user/labfy-idle-bridge.service | 21 + .../labfy-idle-bridge.service | 1 + tests/test_idle_bridge.py | 282 ++++++++++++ 14 files changed, 1172 insertions(+), 2 deletions(-) create mode 100644 quickshell/.config/quickshell/labfy-sway/controlcenter/KeepAwakePage.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/idlebridge/IdleBridgeReceiver.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/idlebridge/bridge.py create mode 100644 quickshell/.config/quickshell/labfy-sway/status/KeepAwakeIndicator.qml create mode 100644 systemd/.config/systemd/user/labfy-idle-bridge.service create mode 120000 systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/labfy-idle-bridge.service create mode 100644 tests/test_idle_bridge.py diff --git a/docs/systemd.md b/docs/systemd.md index 504d5a36b..e4b266b4a 100644 --- a/docs/systemd.md +++ b/docs/systemd.md @@ -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 diff --git a/portal/README.md b/portal/README.md index 8878e6cad..097856179 100644 --- a/portal/README.md +++ b/portal/README.md @@ -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. diff --git a/quickshell/.config/quickshell/labfy-sway/Bar.qml b/quickshell/.config/quickshell/labfy-sway/Bar.qml index 3814725b3..6ccdf3399 100644 --- a/quickshell/.config/quickshell/labfy-sway/Bar.qml +++ b/quickshell/.config/quickshell/labfy-sway/Bar.qml @@ -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() } diff --git a/quickshell/.config/quickshell/labfy-sway/README.md b/quickshell/.config/quickshell/labfy-sway/README.md index f6feb764e..de8c4273a 100644 --- a/quickshell/.config/quickshell/labfy-sway/README.md +++ b/quickshell/.config/quickshell/labfy-sway/README.md @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml index 45ff5f165..857b57455 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml @@ -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") + } } } } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/KeepAwakePage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/KeepAwakePage.qml new file mode 100644 index 000000000..0562c8d8d --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/KeepAwakePage.qml @@ -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() + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/idlebridge/IdleBridgeReceiver.qml b/quickshell/.config/quickshell/labfy-sway/idlebridge/IdleBridgeReceiver.qml new file mode 100644 index 000000000..19eb60bc1 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/idlebridge/IdleBridgeReceiver.qml @@ -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) } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/idlebridge/bridge.py b/quickshell/.config/quickshell/labfy-sway/idlebridge/bridge.py new file mode 100644 index 000000000..700ee504c --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/idlebridge/bridge.py @@ -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 = """ + + + + + + + + + + +""" + + +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) diff --git a/quickshell/.config/quickshell/labfy-sway/shell.qml b/quickshell/.config/quickshell/labfy-sway/shell.qml index 69ef22a91..f2a8136bb 100644 --- a/quickshell/.config/quickshell/labfy-sway/shell.qml +++ b/quickshell/.config/quickshell/labfy-sway/shell.qml @@ -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. diff --git a/quickshell/.config/quickshell/labfy-sway/status/KeepAwakeIndicator.qml b/quickshell/.config/quickshell/labfy-sway/status/KeepAwakeIndicator.qml new file mode 100644 index 000000000..bb33bf824 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/status/KeepAwakeIndicator.qml @@ -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" + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml index 0608b077c..ac342a83a 100644 --- a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml +++ b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml @@ -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 diff --git a/systemd/.config/systemd/user/labfy-idle-bridge.service b/systemd/.config/systemd/user/labfy-idle-bridge.service new file mode 100644 index 000000000..78e2f6026 --- /dev/null +++ b/systemd/.config/systemd/user/labfy-idle-bridge.service @@ -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 diff --git a/systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/labfy-idle-bridge.service b/systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/labfy-idle-bridge.service new file mode 120000 index 000000000..97ab6deac --- /dev/null +++ b/systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/labfy-idle-bridge.service @@ -0,0 +1 @@ +../labfy-idle-bridge.service \ No newline at end of file diff --git a/tests/test_idle_bridge.py b/tests/test_idle_bridge.py new file mode 100644 index 000000000..d4efa6069 --- /dev/null +++ b/tests/test_idle_bridge.py @@ -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()