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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| batterie | `battery-low-notify.service/.timer` | contrôle et notification périodiques |
|
||||||
| maintenance | `clean-makepkg.service` | nettoyage du cache temporaire utilisateur |
|
| maintenance | `clean-makepkg.service` | nettoyage du cache temporaire utilisateur |
|
||||||
| Arch Sentinel | `arch-sentinel-sample.service/.timer` | collecte périodique légère |
|
| 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
|
activations utilisateur retenues. `.assets/enabled-user-aux-units.txt` capture
|
||||||
en complément les timers et sockets activés observés sur la machine.
|
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
|
## Supports amovibles USB/SD et MTP
|
||||||
|
|
||||||
`labfy-removable-media.service` appartient au package Stow `systemd` et son
|
`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
|
Les applications GTK/Qt qui utilisent leurs dialogues natifs plutôt que le
|
||||||
portail conservent leur comportement. Ce réglage ne change aucune association
|
portail conservent leur comportement. Ce réglage ne change aucune association
|
||||||
MIME et ne remplace pas les portails d'autres interfaces.
|
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 startupHost
|
||||||
required property bool resizeMode
|
required property bool resizeMode
|
||||||
required property var applicationCoordinator
|
required property var applicationCoordinator
|
||||||
|
required property var keepAwakeController
|
||||||
screen: modelData
|
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 {
|
anchors {
|
||||||
left: true
|
left: true
|
||||||
right: true
|
right: true
|
||||||
|
|
@ -421,6 +431,7 @@ PanelWindow {
|
||||||
adapter: controlCenter.adapter
|
adapter: controlCenter.adapter
|
||||||
controlCenterOpen: controlCenter.visible
|
controlCenterOpen: controlCenter.visible
|
||||||
audioBrightnessController: controlCenter
|
audioBrightnessController: controlCenter
|
||||||
|
keepAwakeController: bar.keepAwakeController
|
||||||
clipboardOpen: clipboardLoader.active && !!clipboardLoader.item && !clipboardLoader.item.closing
|
clipboardOpen: clipboardLoader.active && !!clipboardLoader.item && !clipboardLoader.item.closing
|
||||||
onOpenPageRequested: page => {
|
onOpenPageRequested: page => {
|
||||||
bar.closeApplications();
|
bar.closeApplications();
|
||||||
|
|
@ -460,6 +471,7 @@ PanelWindow {
|
||||||
ControlCenter {
|
ControlCenter {
|
||||||
id: controlCenter
|
id: controlCenter
|
||||||
barWindow: bar
|
barWindow: bar
|
||||||
|
keepAwakeController: bar.keepAwakeController
|
||||||
onThresholdApplied: rightStatusArea.refreshBatteryThreshold()
|
onThresholdApplied: rightStatusArea.refreshBatteryThreshold()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ Bar
|
||||||
│ ├── Network
|
│ ├── Network
|
||||||
│ ├── Bluetooth
|
│ ├── Bluetooth
|
||||||
│ ├── Battery
|
│ ├── Battery
|
||||||
|
│ ├── Maintenir éveillé (si actif)
|
||||||
│ ├── Updates
|
│ ├── Updates
|
||||||
│ ├── Supports amovibles
|
│ ├── Supports amovibles
|
||||||
│ ├── Presse-papiers
|
│ ├── Presse-papiers
|
||||||
|
|
@ -37,12 +38,34 @@ Bar
|
||||||
├── Volume
|
├── Volume
|
||||||
├── Brightness
|
├── Brightness
|
||||||
├── Power Profile
|
├── Power Profile
|
||||||
|
├── Maintenir éveillé
|
||||||
├── Apparence
|
├── Apparence
|
||||||
│ └── Fond d'écran
|
│ └── Fond d'écran
|
||||||
├── Battery limit
|
├── Battery limit
|
||||||
└── Session
|
└── 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
|
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é.
|
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
|
L'architecture, les raccourcis, les tests isolés et le retour vers Wofi sont
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,13 @@ PopupWindow {
|
||||||
id: popup
|
id: popup
|
||||||
|
|
||||||
required property var barWindow
|
required property var barWindow
|
||||||
|
required property var keepAwakeController
|
||||||
// CONTRACT: indices externes historiques conservés ; tout nouveau routage
|
// CONTRACT: indices externes historiques conservés ; tout nouveau routage
|
||||||
// passe par ces noms afin de ne pas multiplier les indices StackLayout.
|
// passe par ces noms afin de ne pas multiplier les indices StackLayout.
|
||||||
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
|
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
|
||||||
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8,
|
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8,
|
||||||
nightLight: 9, sunMode: 10, locationTime: 11, sessionManager: 12,
|
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
|
property int currentPage: pages.main
|
||||||
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
||||||
signal thresholdApplied()
|
signal thresholdApplied()
|
||||||
|
|
@ -176,6 +177,31 @@ PopupWindow {
|
||||||
BrightnessSlider { id: brightnessSlider; width: parent.width }
|
BrightnessSlider { id: brightnessSlider; width: parent.width }
|
||||||
PowerProfile { 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 {
|
Rectangle {
|
||||||
width: parent.width; height: 38; radius: 4
|
width: parent.width; height: 38; radius: 4
|
||||||
color: appearancePointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
|
color: appearancePointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
|
||||||
|
|
@ -336,6 +362,11 @@ PopupWindow {
|
||||||
volumeControl: volumeSlider
|
volumeControl: volumeSlider
|
||||||
onBackRequested: popup.openPage("main")
|
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.Io
|
||||||
import Quickshell.I3
|
import Quickshell.I3
|
||||||
import "notifications"
|
import "notifications"
|
||||||
|
import "idlebridge"
|
||||||
import "theme"
|
import "theme"
|
||||||
|
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
id: shell
|
id: shell
|
||||||
property bool resizeMode: false
|
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
|
// CONTRACT: one owner across all output bars; opening a menu asks every
|
||||||
// bar to release its other transient panels before the overlay appears.
|
// bar to release its other transient panels before the overlay appears.
|
||||||
property string applicationsOutput: ""
|
property string applicationsOutput: ""
|
||||||
|
|
@ -78,6 +115,7 @@ ShellRoot {
|
||||||
|
|
||||||
Bar {
|
Bar {
|
||||||
applicationCoordinator: shell
|
applicationCoordinator: shell
|
||||||
|
keepAwakeController: shell
|
||||||
resizeMode: shell.resizeMode
|
resizeMode: shell.resizeMode
|
||||||
notificationService: rootNotificationService
|
notificationService: rootNotificationService
|
||||||
// Une seule barre possède le chooser, même en configuration multi-écran.
|
// 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 controlCenterOpen
|
||||||
required property bool clipboardOpen
|
required property bool clipboardOpen
|
||||||
required property var audioBrightnessController
|
required property var audioBrightnessController
|
||||||
|
required property var keepAwakeController
|
||||||
signal openPageRequested(int page)
|
signal openPageRequested(int page)
|
||||||
signal toggleControlCenterRequested()
|
signal toggleControlCenterRequested()
|
||||||
signal toggleRemovableMediaRequested()
|
signal toggleRemovableMediaRequested()
|
||||||
|
|
@ -61,6 +62,12 @@ Row {
|
||||||
Row {
|
Row {
|
||||||
spacing: 5
|
spacing: 5
|
||||||
height: 26
|
height: 26
|
||||||
|
KeepAwakeIndicator {
|
||||||
|
active: area.keepAwakeController.keepAwake
|
||||||
|
remainingSeconds: area.keepAwakeController.keepAwakeRemainingSeconds
|
||||||
|
unlimited: area.keepAwakeController.keepAwakeMinutes === 0
|
||||||
|
onActivated: area.openPageRequested(16)
|
||||||
|
}
|
||||||
UpdatesIndicator { }
|
UpdatesIndicator { }
|
||||||
RemovableMediaIndicator {
|
RemovableMediaIndicator {
|
||||||
id: 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