feat(swayfx): bridge Firefox video idle inhibition through QuickShell
This commit is contained in:
parent
335bd62552
commit
6cc62d2192
14 changed files with 1172 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
128
portal/README.md
128
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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
411
quickshell/.config/quickshell/labfy-sway/idlebridge/bridge.py
Normal file
411
quickshell/.config/quickshell/labfy-sway/idlebridge/bridge.py
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
21
systemd/.config/systemd/user/labfy-idle-bridge.service
Normal file
21
systemd/.config/systemd/user/labfy-idle-bridge.service
Normal 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
|
||||
|
|
@ -0,0 +1 @@
|
|||
../labfy-idle-bridge.service
|
||||
282
tests/test_idle_bridge.py
Normal file
282
tests/test_idle_bridge.py
Normal 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()
|
||||
Loading…
Reference in a new issue