secure push

This commit is contained in:
fy59 2026-10-09 17:32:37 +02:00
parent 2bafbcd2be
commit 02236cfdeb
24 changed files with 1366 additions and 47 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View file

@ -143,8 +143,11 @@ installée et aucun portail ni `swayidle` n'a été redémarré.
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
QuickShell. L'`IdleInhibitor` de la barre persistante est actif si le maintien
manuel **ou** au moins une demande applicative est actif. La tasse reste
visible sans fond dans la barre : elle est rouge dès qu'une de ces sources est
active, et neutre sinon. Son infobulle distingue les sources et la durée
manuelle restante lorsqu'elle existe. 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é
@ -176,6 +179,108 @@ 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.
Contrôle ciblé du 9 octobre 2026 : une activation manuelle de 30 minutes,
Control Center fermé, a conservé la tasse et la surface de barre pendant
130 secondes. Un observateur `swayidle` temporaire avec configuration vide,
seul un marqueur privé à 8 s et aucune action de verrouillage ou d'extinction,
ne s'est pas déclenché. Le même contrôle de 130 secondes en mode « Jusqu'à
désactivation », avec un nouveau marqueur, a donné le même résultat. Les
relevés à 0, 30, 55, 65, 90 et 130 secondes ont conservé le PID QuickShell
2349 et un seul chargement de configuration. La sous-page confirmait le mode
illimité après la seconde mesure. Ces contrôles établissent le fonctionnement
au-delà d'une minute dans la session stable ; ils ne mesurent ni l'expiration
complète à 30 minutes ni une demande applicative concurrente durant ces deux
phases. Après désactivation manuelle, un contrôle négatif neuf à 6 s a fini
par déclencher son marqueur ; les marqueurs des phases actives n'étaient donc
pas simplement inopérants.
Le journal utilisateur de la session précédente montre des rechargements
QuickShell à 08:01:31 et 08:02:15 sous le même PID 14333. Les fichiers
`WorkspaceOverview.qml` et `Bar.qml` portent des modifications à ces secondes.
À cette date, un rechargement reconstruisait `ShellRoot` avec le maintien
manuel initialisé à `false` ; il pouvait donc retirer la protection même si
le PID ne changeait pas. Faute d'horodatage du constat initial, ce lien restait
une explication plausible, sans attribution prouvée. La correction V5
ci-dessous conserve désormais ce choix lors des rechargements QML ; un arrêt
complet du processus reste distinct et libère l'inhibition.
Un rechargement ultérieur, pendant la mise à jour de la tasse, a révélé un
second défaut distinct : le nouveau `SocketServer` ouvrait le chemin fixe
avant la destruction de l'ancien. L'ancien retirait ensuite ce chemin ; le
helper ne pouvait plus se reconnecter et répondait `bridge unavailable or
full` aux nouvelles demandes. `IdleBridgeReceiver.qml` diffère maintenant
l'ouverture de la socket de 250 ms après le chargement QML. Une demande
applicative temporaire a été acquise puis libérée avec succès ; la tasse est
devenue rouge pendant la demande, et un observateur neuf à 6 s n'a déclenché
qu'après sa libération. Le même résultat a été vérifié avec un rechargement
pendant que la demande restait active : chemin de socket présent, demande
retransmise, tasse rouge et inhibition effective. Ce défaut concernait la
reconnexion automatique, pas l'échéance du minuteur manuel.
Après ces changements QML, deux nouveaux contrôles réels de 130 secondes
ont été effectués, l'un à 30 minutes et l'autre en mode illimité. À chacun
des relevés 0, 30, 55, 65, 90 et 130 s, la tasse est restée rouge, la surface
de barre était présente, le PID 2349 et le nombre de chargements étaient
stables, et un observateur à 8 s avec marqueur neuf n'avait pas déclenché.
Enfin, une demande D-Bus temporaire acquise puis libérée pendant le mode
manuel illimité n'a ni masqué la tasse ni libéré l'inhibition manuelle. Après
désactivation du mode illimité de test, la tasse est redevenue neutre et un
dernier observateur à 6 s a déclenché son marqueur privé. L'état manuel a
ainsi été ramené à sa valeur initiale.
## Maintien manuel après rechargement QML (contrôle V5, 9 octobre 2026)
Le signalement situait un nouveau verrouillage vers 13:36–13:40, sans heure
exacte. Le processus QuickShell 2349, lancé à 09:23:01, avait rechargé sa
configuration pour la dernière fois à 13:24:37 avant ce signalement.
`swayidle.service` utilisait toujours 300 s pour `swaylock`, 360 s pour
l'extinction, 420 s pour la pause média et `before-sleep` pour le verrouillage
avant suspension. Aucun fichier de configuration implicite de `swayidle`
n'était présent. Le journal contient deux succès `swaymsg` sous l'identifiant
`swayidle` à 13:36:40 et 13:38:41, compatibles avec l'extinction et la reprise
de sortie. Il ne consignait ni la sélection manuelle, ni l'état de
l'inhibiteur au moment du verrouillage : la chaîne précise de cet incident
reste donc non prouvée. L'état observé après déverrouillage n'a pas été traité
comme une mesure rétroactive.
Le code actif avant V5 plaçait le booléen manuel dans `ShellRoot` avec la
valeur initiale `false`. V5 utilise `PersistentProperties` avec une identité
stable pour conserver seulement l'activation, la durée choisie et l'échéance
absolue. Une expiration pendant un rechargement est effacée au chargement.
Les demandes applicatives restent resynchronisées depuis le pont, sans
partager le stockage manuel. Les deux rechargements réels à 14:00:34 et
14:01:09 ont restauré le mode illimité sous le même PID. Lors du second,
le nouvel hôte de l'inhibiteur était mappé et `enabled=true` avant la
destruction journalisée de l'ancien hôte, environ 24 ms après. Il n'existe
pas d'accusé d'inhibition du compositeur ; ces transitions décrivent le
cycle QML, et l'observation `swayidle` ci-dessous teste son effet réel.
Le mode illimité a été activé depuis le bouton à 13:47:51, panneau fermé.
Un observateur `swayidle` sur `wayland-1` et `seat0`, avec fichier de
configuration vide, respect normal des inhibiteurs et seule action de
création d'un marqueur privé à 8 s, n'a pas déclenché entre 13:48:55 et
13:59:17, soit 10 min 22 s sans rechargement. Un nouvel observateur de même
type n'a pas déclenché entre 13:59:34 et 14:11:28 ; son dernier rechargement
réel était à 14:01:09, soit 10 min 19 s avant la fin de la mesure. Aucun
`swaylock` n'était présent lors des relevés. Ces mesures dépassent 300 et
360 s dans la session réelle ; elles ne transforment pas l'icône seule en
preuve de protection. L'observateur ne sait pas mesurer les entrées humaines :
la confirmation de leur absence pendant ces fenêtres reste à obtenir.
Le contrôle de libération volontaire du mode manuel reste à terminer. À
14:19:36, le diagnostic indiquait encore `manual: true` et zéro demande
applicative ; aucun marqueur négatif n'avait donc pu être obtenu. Les deux
observateurs temporaires ont été arrêtés sans modifier `swayidle.service`.
Le mécanisme `PersistentProperties` ne sauvegarde rien après l'arrêt ou le
crash du processus QuickShell ; une nouvelle session repart désactivée. Une
échéance limitée est exprimée en temps civil, afin de survivre au
rechargement : un changement manuel de l'horloge système peut donc modifier
la durée effective. Le premier chargement de V5 depuis l'ancien code ne
pouvait pas récupérer le booléen non conservé ; le mode a été réactivé
volontairement pour les mesures ci-dessus. Le verrouillage manuel et celui
avant suspension restent indépendants.
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

View file

@ -27,16 +27,44 @@ PanelWindow {
required property var applicationCoordinator
required property bool authenticationActive
required property var keepAwakeController
required property var drawerService
required property var osdService
screen: modelData
function closeDrawer() { drawerIndicator.close(false) }
function toggleDrawer() { drawerIndicator.toggle() }
// 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 {
id: idleInhibitor
window: bar
enabled: bar.startupHost && (bar.keepAwakeController.keepAwake
|| bar.keepAwakeController.applicationRequestCount > 0)
// WHY: les transitions seules permettent de dater un éventuel trou
// d'inhibition entre deux générations, sans journal par frame.
onEnabledChanged: console.info("Inhibiteur activé", enabled,
"génération", bar.keepAwakeController.keepAwakeGeneration,
"ms", Date.now())
onWindowChanged: console.info("Surface inhibiteur présente", window === bar,
"génération", bar.keepAwakeController.keepAwakeGeneration,
"ms", Date.now())
}
// CONTRACT: le mapping observé ne vaut pas accusé d'inhibition du compositeur.
onBackingWindowVisibleChanged: {
if (startupHost) console.info("Surface barre mappée", backingWindowVisible,
"génération", keepAwakeController.keepAwakeGeneration,
"ms", Date.now());
}
Component.onCompleted: {
if (startupHost) console.info("Hôte inhibiteur créé", idleInhibitor.enabled,
backingWindowVisible, "génération",
keepAwakeController.keepAwakeGeneration, "ms", Date.now());
}
Component.onDestruction: {
if (startupHost) console.info("Hôte inhibiteur détruit", "génération",
keepAwakeController.keepAwakeGeneration, "ms", Date.now());
}
anchors {
@ -115,6 +143,7 @@ PanelWindow {
dateCenter.requestClose();
windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia();
bar.closeDrawer();
}
function onApplicationsOpening(output) {
bar.closeOverview();
@ -123,6 +152,7 @@ PanelWindow {
dateCenter.requestClose();
windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia();
bar.closeDrawer();
}
}
IpcHandler {
@ -171,6 +201,7 @@ PanelWindow {
dateCenter.requestClose();
windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia();
bar.closeDrawer();
clipboardLoader.active = true;
}
return true;
@ -181,26 +212,41 @@ PanelWindow {
function closeOverview() {
overviewRequested = false;
if (overviewLoader.item) overviewLoader.item.dismiss();
return true;
}
function toggleOverview() {
function openOverview() {
if (authenticationActive) return false;
if (overviewRequested || overviewLoader.active) { closeOverview(); return true; }
// INVARIANT: deux ouvertures ne créent ni une seconde capture ni une fermeture.
if (overviewRequested || overviewLoader.active) return true;
closeApplications();
closeClipboard();
controlCenter.visible = false;
dateCenter.requestClose();
windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia();
bar.closeDrawer();
overviewRequested = true;
if (!overviewCapture.running) overviewCapture.running = true;
return true;
}
function toggleOverview() {
if (authenticationActive) return false;
if (overviewRequested || overviewLoader.active) return closeOverview();
return openOverview();
}
property bool overviewRequested: false
readonly property string overviewBackend: (Quickshell.env("XDG_CONFIG_HOME") ||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/overview/backend.py"
IpcHandler {
target: "overviewUi-" + bar.screen.name
function toggle(): bool { return bar.toggleOverview(); }
// CONTRACT: chaque geste demande un état explicite. La fermeture retire
// la demande avant qu'une capture asynchrone puisse afficher la vue.
function open(): bool { return bar.openOverview(); }
function close(): bool { return bar.closeOverview(); }
// CONTRACT: le dispatcher ne lit pas la liste potentiellement longue
// des cartes pour attendre la libération du panneau.
function active(): bool { return bar.overviewRequested || overviewLoader.active; }
function state(): string {
return JSON.stringify({ loaded: overviewLoader.active,
visible: !!overviewLoader.item && overviewLoader.item.visible,
@ -308,6 +354,16 @@ PanelWindow {
} : null });
}
}
IpcHandler {
target: "drawerUi-" + bar.screen.name
function toggle(): bool { bar.toggleDrawer(); return true; }
function close(): bool { bar.closeDrawer(); return true; }
function state(): string {
return JSON.stringify({count: bar.drawerService ? bar.drawerService.entries.length : 0,
open: drawerIndicator.open, busy: bar.drawerService ? bar.drawerService.busy : false,
error: bar.drawerService ? bar.drawerService.error : ""});
}
}
LazyLoader {
id: clipboardLoader
active: false
@ -327,10 +383,23 @@ PanelWindow {
controlCenter.visible = false;
windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia();
bar.closeDrawer();
if (opening) dateCenter.requestOpen();
else dateCenter.requestClose();
}
// CONTRACT: le tiroir prend le focus clavier sur une seule sortie et
// libère tous les autres panneaux transitoires avant son ouverture.
function prepareDrawer() {
closeApplications();
closeOverview();
closeClipboard();
controlCenter.visible = false;
dateCenter.requestClose();
windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia();
}
Item {
id: leftArea
anchors.left: parent.left
@ -391,11 +460,26 @@ PanelWindow {
}
}
// CONTRACT: une seule instance, dans le même groupe de navigation que
// les workspaces ; sa largeur n'occupe aucun espace si le tiroir est vide.
DrawerIndicator {
id: drawerIndicator
anchors.left: resizeIndicator.right
anchors.leftMargin: visible && resizeIndicator.visible ? 6 : 0
anchors.verticalCenter: parent.verticalCenter
service: bar.drawerService
barWindow: bar
output: bar.screen.name
authenticationActive: bar.authenticationActive
onOpened: bar.prepareDrawer()
}
Workspaces {
id: workspaces
screen: bar.screen
anchors.left: resizeIndicator.right
anchors.leftMargin: resizeIndicator.visible ? 6 : 0
anchors.left: drawerIndicator.right
anchors.leftMargin: drawerIndicator.visible ? 6
: resizeIndicator.visible ? 6 : 0
anchors.verticalCenter: parent.verticalCenter
}
@ -411,6 +495,8 @@ PanelWindow {
WindowStrip {
id: windowStrip
screen: bar.screen
drawerService: bar.drawerService
authenticationActive: bar.authenticationActive
anchors.left: workspacesSeparator.right
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
@ -422,6 +508,7 @@ PanelWindow {
controlCenter.visible = false;
dateCenter.requestClose();
rightStatusArea.closeRemovableMedia();
bar.closeDrawer();
}
}
}
@ -465,6 +552,7 @@ PanelWindow {
dateCenter.requestClose();
windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia();
bar.closeDrawer();
controlCenter.currentPage = page;
controlCenter.visible = true;
}
@ -476,6 +564,7 @@ PanelWindow {
dateCenter.requestClose();
windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia();
bar.closeDrawer();
controlCenter.currentPage = 0;
controlCenter.visible = opening;
}
@ -488,6 +577,7 @@ PanelWindow {
dateCenter.requestClose();
windowStrip.closeMenu();
rightStatusArea.setRemovableMediaOpen(opening);
bar.closeDrawer();
}
onToggleClipboardRequested: bar.toggleClipboard()
}
@ -501,6 +591,8 @@ PanelWindow {
onVisibleChanged: bar.osdService.setPanel(bar.screen, visible, currentPage)
onCurrentPageChanged: bar.osdService.setPanel(bar.screen, visible, currentPage)
}
// CONTRACT: seul l'hôte existant transmet les lectures partagées ; les
// autres barres ne multiplient pas les événements lors d'un changement.
Connections {
target: controlCenter
function snapshot() {

View file

@ -46,15 +46,20 @@ Bar
```
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
1 heure, 2 heures ou **Jusqu’à désactivation**. L’icône reste présente même
quand le mode est désactivé ; sa couleur et son infobulle indiquent la demande
manuelle ou applicative, 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é
l’inhibiteur Wayland, même avec plusieurs écrans. L’icône ne prouve pas à elle
seule que le compositeur respecte l’inhibiteur. 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
QuickShell : un rechargement QML conserve la sélection manuelle et l’échéance
initiale grâce à `PersistentProperties`, tandis qu’un arrêt ou redémarrage du
processus libère l’inhibition. Une nouvelle session graphique repart aussi
désactivée. 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
@ -302,7 +307,7 @@ pas. Les états rares sont vérifiés avec des données isolées par
| Ancien bloc | QuickShell |
| --- | --- |
| scratchpad indicator | volontairement non repris |
| scratchpad indicator | tiroir natif SwayFX, voir [scratchpad/README.md](scratchpad/README.md) |
| focused_window | WindowStrip |
| music | Date Center MPRIS |
| bluetooth | status + Control Center |

View file

@ -52,6 +52,21 @@ Item {
font.pixelSize: 12
}
Text {
width: parent.width
visible: page.controller.keepAwake
// CONTRACT: cette durée décrit seulement la sélection manuelle ;
// les demandes applicatives n'ont pas d'échéance connue ici.
text: page.controller.keepAwakeMinutes === 0
? "Maintien manuel · jusqu’à désactivation"
: "Maintien manuel · "
+ Math.ceil(page.controller.keepAwakeRemainingSeconds / 60)
+ " min restantes"
wrapMode: Text.Wrap
color: Theme.secondaryForeground
font.pixelSize: 12
}
Repeater {
model: [
{ label: "30 minutes", minutes: 30 },

View file

@ -11,6 +11,20 @@ Item {
property var currentSocket: null
property string generation: ""
property int lastSequence: 0
property bool socketReady: false
// WHY: QuickShell construit la nouvelle configuration avant de détruire
// l'ancienne. Deux SocketServer sur le même chemin peuvent alors se
// croiser : l'ancien supprime le fichier de socket que le nouveau vient
// de créer. Le premier tick après le rechargement laisse finir la
// destruction précédente avant de publier le nouveau chemin. Le helper
// reconnecte alors ses demandes encore valides depuis son état en mémoire.
Timer {
interval: 250
running: true
repeat: false
onTriggered: bridge.socketReady = true
}
function receive(socket, line) {
if (socket !== currentSocket || line.length > 512) {
@ -31,6 +45,8 @@ Item {
}
generation = frame.generation;
lastSequence = frame.sequence;
if (applicationRequestCount !== frame.count)
console.info("Demandes applicatives", frame.count, "ms", Date.now());
applicationRequestCount = frame.count;
// CONTRACT: this acknowledges assignment of the QuickShell state,
// not a compositor confirmation (idle-inhibit-v1 has no such ack).
@ -40,21 +56,29 @@ Item {
}
SocketServer {
active: true
active: bridge.socketReady
path: Quickshell.env("LABFY_IDLE_BRIDGE_SOCKET")
|| (Quickshell.env("XDG_RUNTIME_DIR") + "/labfy-idle-bridge.sock")
handler: Socket {
id: peer
onConnectedChanged: {
if (connected) {
// CONTRACT: journaliser seulement les transitions du
// pont, jamais les libellés privés reçus par le helper.
console.info("Pont d'inhibition connecté", "ms", Date.now());
if (bridge.currentSocket && bridge.currentSocket !== peer)
bridge.currentSocket.connected = false;
bridge.currentSocket = peer;
if (bridge.applicationRequestCount !== 0)
console.info("Demandes applicatives", 0, "raison reconnexion", "ms", Date.now());
bridge.applicationRequestCount = 0;
bridge.generation = "";
bridge.lastSequence = 0;
} else if (bridge.currentSocket === peer) {
console.info("Pont d'inhibition déconnecté", "ms", Date.now());
bridge.currentSocket = null;
if (bridge.applicationRequestCount !== 0)
console.info("Demandes applicatives", 0, "raison déconnexion", "ms", Date.now());
bridge.applicationRequestCount = 0;
bridge.generation = "";
bridge.lastSequence = 0;

View file

@ -13,6 +13,40 @@ La surface `labfy-setting-osd` suspend seulement une nouvelle capture
automatique tant qu'elle est visible ; les miniatures déjà validées restent
disponibles.
## Gestes à trois doigts (SwayFX)
Sur le touchpad ASUS `2362:12311:ASCP1201:00_093A:3017_Touchpad`, les
`bindgesture` natifs du mode `default` déclenchent : haut → `open`, bas →
`close`, gauche → `workspace next_on_output`, droite →
`workspace prev_on_output`. Les directions désignent le mouvement physique des
doigts. Les autres nombres de doigts, dont le défilement à deux doigts, ne sont
pas liés ici. `--exact` n'est pas imposé avant l'essai matériel des diagonales.
Le script ponctuel `~/.config/sway/scripts/touchpad-gestures` choisit la sortie
du workspace focalisé à l'instant du geste. Il interroge `polkitUi state` et
ignore ces quatre gestes pendant une demande d'authentification. Haut et bas
appellent respectivement `overviewUi-<sortie> open` et `close` ; ils ne passent
pas par `toggle`. Une fermeture annule aussi l'ouverture différée après la
capture. Si l'Overview est ouverte, le dispatcher la ferme puis attend
`overviewUi-<sortie> active=false` avant de naviguer. La navigation parcourt
seulement les workspaces existants de la sortie, dans l'ordre et avec le
bouclage natifs de Sway ; aucun workspace vide n'est
créé et aucune fenêtre n'est déplacée. `Super+W` garde son `toggle`.
Ces liaisons appartiennent seulement au mode `default` : en mode `resize`, elles
ne s'exécutent pas et le mode n'est pas quitté automatiquement. Les gestes
Sway ne sont pas garantis au-dessus d'une barre layer-shell : la barre
QuickShell peut recevoir le swipe à la place du compositeur. Ce cas demande
un essai physique distinct. Le focus exclusif de l'Overview reste nécessaire
au clavier ; la réception du swipe au-dessus de ses cartes et de son fond doit
également être confirmée sur le touchpad réel.
Pour retirer uniquement ces gestes, supprimer les quatre lignes `bindgesture`
marquées dans `~/.config/sway/bind`, puis lancer `swaymsg reload`. Le dispatcher
et les opérations IPC explicites peuvent ensuite être supprimés s'ils ne sont
plus utilisés ; les raccourcis clavier et le reste de l'Overview continuent de
fonctionner.
## Provenance des miniatures
`grim -o <sortie> -s 0.25 -t jpeg -q 72` prend un **snapshot réel de la sortie

View file

@ -21,6 +21,7 @@ PanelWindow {
property string actionName: ""
property var dirtyCaptures: []
property bool skipDismissCaptures: false
property bool closing: false
property var pendingWindowNumbers: []
property var activeWindowNumbers: []
property int windowCaptureCursor: 0
@ -47,6 +48,10 @@ PanelWindow {
color: "transparent"
function dismiss() {
if (closing) return;
// INVARIANT: relâcher pendant la fermeture ne valide aucun glissement.
closing = true;
dropTarget = 0;
visible = false;
// WHY: grim inclurait cette surface overlay. Après sa disparition,
// seules les sorties visibles dont l'arbre a changé sont recapturées.
@ -576,6 +581,7 @@ PanelWindow {
overview.selectedWorkspace = workspaceCard.number;
}
onClicked: mouse => {
if (overview.closing) return;
overview.selectedWindow = windowVisual.index + 1;
if (mouse.button === Qt.RightButton) {
overview.moveTarget = workspaceCard.number === 10 ? 1 : workspaceCard.number + 1;
@ -590,6 +596,7 @@ PanelWindow {
overview.dropTarget = target !== workspaceCard.number ? target : 0;
}
onReleased: mouse => {
if (overview.closing) return;
if (drag.active) {
const point = windowPointer.mapToItem(keyboardRoot, mouse.x, mouse.y);
const target = overview.workspaceAt(point.x, point.y);

View file

@ -174,7 +174,7 @@ def state():
overlay_outputs = [item["name"] for item in outputs if item.get("active")
and any(surface.get("namespace") in {
"labfy-workspace-overview", "labfy-applications-menu",
"labfy-setting-osd"}
"labfy-scratchpad-drawer", "labfy-setting-osd"}
for surface in item.get("layer_shell_surfaces") or [])]
workspace_nodes = {}

View file

@ -0,0 +1,86 @@
import QtQuick
import Quickshell
import Quickshell.I3
import Quickshell.I3._Ipc
import Quickshell.Io
Item {
id: service
property var entries: []
property int focusedId: 0
property string error: ""
property bool authenticationActive: false
property bool reading: false
property bool pending: false
property bool busy: actionProcess.running
readonly property string backend: (Quickshell.env("XDG_CONFIG_HOME") ||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/scratchpad/backend.py"
// CONTRACT: une seule file d'opérations dans le ShellRoot sérialise même
// deux clics arrivés depuis des barres différentes. Le backend relit Sway.
function act(action, id, output) {
if (authenticationActive || actionProcess.running || !Number.isSafeInteger(id) || id <= 0)
return false;
error = "";
actionProcess.command = ["python3", "-B", backend, action, String(id), output || ""];
actionProcess.running = true;
return true;
}
// WHY: un titre animé ne doit pas repousser indéfiniment le relevé ; la
// première notification ouvre une fenêtre de coalescence fixe.
function refresh() { if (!refreshDelay.running) refreshDelay.start(); }
Timer {
id: refreshDelay
interval: 90
onTriggered: {
if (service.reading) service.pending = true;
else {
service.reading = true;
listProcess.running = true;
}
}
}
Process {
id: listProcess
command: ["python3", "-B", service.backend, "list"]
stdout: StdioCollector { id: listOutput; waitForEnd: true }
onExited: (code, status) => {
if (code === 0) {
try {
const state = JSON.parse(listOutput.text);
service.entries = state.entries;
service.focusedId = state.focused;
}
catch (error) { console.warn("Tiroir Sway illisible :", error); }
}
service.reading = false;
if (service.pending) {
service.pending = false;
service.refresh();
}
}
}
Process {
id: actionProcess
stdout: StdioCollector { id: actionOutput; waitForEnd: true }
onExited: (code, status) => {
if (code !== 0) {
try { service.error = JSON.parse(actionOutput.text).error; }
catch (_) { service.error = "Action sur le tiroir impossible"; }
}
service.refresh();
}
}
I3IpcListener {
subscriptions: ["window"]
onIpcEvent: event => service.refresh()
}
Connections {
target: I3
function onConnected() { service.refresh(); }
function onRawEvent(event) {
if (event.type === "workspace" || event.type === "output") service.refresh();
}
}
Component.onCompleted: refresh()
}

View file

@ -0,0 +1,57 @@
# Tiroir de fenêtres SwayFX V1
Le tiroir utilise le scratchpad natif de SwayFX. `DrawerService.qml` conserve un
modèle partagé entre les sorties : un relevé `get_tree` au démarrage et après les
événements `window`, `workspace` ou `output` suffit. Aucun inventaire persistant
n'est créé. Les fenêtres Wayland (`app_id`) et Xwayland (`window_properties.class`)
sont identifiées par `con_id`. Le compteur inclut les membres masqués sous
`__i3_scratch` et les membres affichés sur un workspace ordinaire, reconnus
par `scratchpad_state`.
Dans `WindowStrip`, le menu de la fenêtre choisie propose **Ranger dans le
tiroir**. Le raccourci existant `Super+Ctrl+I` range le conteneur focalisé et
`Super+I` garde le cycle scratchpad natif ; aucun raccourci ni geste n'a changé.
L'indicateur Teal se place à gauche, immédiatement avant les workspaces et
après `Resize` lorsqu'il est actif. Il n'ajoute aucun séparateur et n'apparaît
que si le tiroir contient au moins une fenêtre. Son panneau s'ouvre sous
l'icône sur la sortie de la barre cliquée. Clic
pour ouvrir ou fermer ; flèches haut/bas pour choisir une entrée, gauche/droite
pour choisir l'action, Entrée pour exécuter, Échap pour annuler et rendre le
focus précédent.
**Afficher** déploie une entrée sur le workspace visible de la sortie de la
barre choisie et lui donne le focus ; si elle s'y trouve déjà, l'action se
limite au focus. **Masquer** la remet dans le scratchpad sans cycle. **Remettre
ici** retire son appartenance au scratchpad, la place sur ce workspace et
conserve le flottement. SwayFX garde l'appartenance après un simple `move` :
le retrait utilise `floating disable` puis `floating enable`, sans appliquer
de layout au workspace entier. L'ancienne place exacte dans la mosaïque n'est
pas restaurée. Une fenêtre rangée affichée reste comptée comme membre.
Les commandes ciblées relisent l'arbre avant et après, valident la fenêtre et
le workspace visible sur la sortie, puis vérifient l'état final. Les noms
contenant espaces, point-virgule ou un type de guillemet sont cités suivant
la grammaire Sway. Par prudence, les noms contenant `$`, un contrôle ou les
deux types de guillemets sont refusés. Les conteneurs multi-applications du
scratchpad sont visibles mais leurs actions individuelles sont désactivées.
Le menu n'ouvre pas et les commandes n'avancent pas durant Polkit. Une seule
opération est en vol dans QuickShell ; le backend ne dépend pas du focus du
client pour identifier la fenêtre. Le panneau possède une surface nommée
`labfy-scratchpad-drawer`, exclue des captures automatiques Overview.
Tests : `python3 tests/quickshell/test_scratchpad_drawer.py`,
`python3 tests/overview/test_backend.py`, `qmllint`, `git diff --check`.
Le cycle complet, les répétitions idempotentes, la sortie depuis l'état masqué,
la navigation clavier et le retour du focus ont été vérifiés avec Zenity
uniquement. Les comportements multi-écrans sont conçus autour du nom de
sortie mais n'ont pas été essayés physiquement.
Capture rapprochée du placement gauche avec une fenêtre Zenity synthétique
rangée et l'entrée déjà présente de l'utilisateur, sans déplacer cette dernière :
[quickshell-scratchpad-left-placement.png](../../../../../docs/assets/screenshots/quickshell-scratchpad-left-placement.png).
Retour arrière complet du tiroir : retirer l'intégration `DrawerService` de
`shell.qml`, les propriétés et callbacks `drawerService` de `Bar.qml` et
`WindowStrip.qml`, l'entrée de `WindowMenu.qml`, le namespace de
`overview/backend.py`, puis les nouveaux fichiers `scratchpad/` et
`status/DrawerIndicator.qml`. Les raccourcis Sway restent inchangés.

View file

@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Vue et commandes ponctuelles du scratchpad natif SwayFX."""
import argparse
import json
import re
import subprocess
import sys
class DrawerError(Exception):
pass
def sway(kind):
result = subprocess.run(["swaymsg", "-r", "-t", kind], capture_output=True,
text=True, timeout=5, check=True)
return json.loads(result.stdout)
def command(value):
# CONTRACT: argv évite le shell, mais Sway possède sa propre grammaire.
# Seuls des con_id numériques et des noms de workspace cités y entrent.
result = subprocess.run(["swaymsg", "-r", value], capture_output=True,
text=True, timeout=5, check=True)
replies = json.loads(result.stdout)
if not isinstance(replies, list) or not replies or not all(
item.get("success") is True for item in replies):
raise DrawerError("Commande Sway refusée : " + str(replies))
def safe_command(value):
# CONTRACT: une demande Polkit arrivée entre le clic et l'IPC empêche
# encore la transition ; une lecture indisponible échoue sans déplacement.
try:
result = subprocess.run(["qs", "-c", "labfy-sway", "ipc", "call",
"polkitUi", "state"], capture_output=True,
text=True, timeout=3, check=True)
state = json.loads(result.stdout)
except (OSError, ValueError, subprocess.SubprocessError) as error:
raise DrawerError("État Polkit indisponible") from error
if state.get("active") or state.get("dialog"):
raise DrawerError("Authentification en cours")
command(value)
def quoted_workspace(name):
if (not isinstance(name, str) or not name or len(name) > 256
or any(ord(char) < 32 or char == "$" for char in name)):
raise DrawerError("Nom de workspace invalide")
# Sway garde littéralement un backslash devant un guillemet dans l'IPC.
# Choisir l'autre délimiteur conserve le nom exact et protège , et ;.
if '"' not in name:
return '"' + name + '"'
if "'" not in name:
return "'" + name + "'"
raise DrawerError("Nom de workspace contenant les deux types de guillemets non pris en charge")
def walk(node, workspace=None, scratch=False, parent=None):
if node.get("type") == "workspace":
workspace = node.get("name")
scratch = scratch or node.get("scratchpad_state") not in (None, "none")
yield node, workspace, scratch, parent
for child in (node.get("nodes") or []) + (node.get("floating_nodes") or []):
yield from walk(child, workspace, scratch, node)
def application_count(root):
return sum(bool(node.get("app_id") or (node.get("window_properties") or {}).get("class"))
for node, _, _, _ in walk(root)
if node.get("type") in ("con", "floating_con"))
def members(tree):
result = []
def visit(node, workspace=None, scratch_root=None):
if node.get("type") == "workspace":
workspace = node.get("name")
if scratch_root is None and node.get("scratchpad_state") not in (None, "none"):
scratch_root = node
if node.get("type") not in ("con", "floating_con"):
app = None
else:
app = node.get("app_id") or (node.get("window_properties") or {}).get("class")
if app and scratch_root is not None:
# Un groupe scratchpad est une seule unité de déplacement. L'UI le
# signale et bloque les actions individuelles ambiguës.
result.append({"id": node["id"], "app": app, "title": node.get("name") or app,
"workspace": workspace, "shown": workspace != "__i3_scratch",
"group": application_count(scratch_root) > 1,
"border": node.get("border")})
for child in (node.get("nodes") or []) + (node.get("floating_nodes") or []):
visit(child, workspace, scratch_root)
visit(tree)
return result
def target_workspace(output):
workspaces = sway("get_workspaces")
target = next((item for item in workspaces if item.get("output") == output
and item.get("visible") is True), None)
if target is None:
raise DrawerError("Aucun workspace visible sur cette sortie")
return target["name"]
def valid_app(tree, con_id):
found = next(((node, parent, workspace) for node, workspace, _, parent in walk(tree)
if node.get("id") == con_id), None)
if found is None:
raise DrawerError("La fenêtre a disparu")
node, parent, workspace = found
app = node.get("app_id") or (node.get("window_properties") or {}).get("class")
if node.get("type") not in ("con", "floating_con") or not app:
raise DrawerError("Ce conteneur n'est pas une fenêtre applicative")
if (node.get("nodes") or node.get("floating_nodes")):
raise DrawerError("Un groupe de fenêtres ne peut pas être rangé comme une seule fenêtre")
# Sway remonte au conteneur flottant parent pour certaines commandes
# ciblées. Refuser un parent multi-app avant de déplacer tout le groupe.
if parent and parent.get("type") in ("con", "floating_con") and application_count(parent) > 1:
raise DrawerError("Fenêtre dans un groupe : déplacement individuel indisponible")
return node, parent, workspace
def operate(action, con_id, output):
tree = sway("get_tree")
node, parent, workspace = valid_app(tree, con_id)
found = next((item for item in members(tree) if item["id"] == con_id), None)
if action == "store":
if found is None:
safe_command(f"[con_id={con_id}] move scratchpad")
elif found["group"]:
raise DrawerError("Groupe scratchpad : action individuelle désactivée")
elif found["shown"]:
safe_command(f"[con_id={con_id}] move scratchpad")
else:
if found is None:
raise DrawerError("Cette fenêtre n'appartient plus au tiroir")
if found["group"]:
raise DrawerError("Groupe scratchpad : action individuelle désactivée")
destination = target_workspace(output)
if action == "show":
if found["shown"] and workspace == destination:
safe_command(f"[con_id={con_id}] focus")
else:
safe_command("workspace " + quoted_workspace(destination))
safe_command(f"[con_id={con_id}] scratchpad show")
safe_command(f"[con_id={con_id}] focus")
elif action == "hide":
if found["shown"]:
# scratchpad show sur un autre workspace déplace la fenêtre au
# lieu de la masquer : move scratchpad est la transition directe.
safe_command(f"[con_id={con_id}] move scratchpad")
elif action == "release":
safe_command(f"[con_id={con_id}] move container to workspace "
+ quoted_workspace(destination))
# Sway conserve scratchpad_state après un simple move. Son retrait
# natif passe par floating disable ; réactiver ensuite le flottement
# restitue la politique visible sans toucher au layout du workspace.
safe_command(f"[con_id={con_id}] floating disable")
safe_command(f"[con_id={con_id}] floating enable")
safe_command(f"[con_id={con_id}] focus")
else:
raise DrawerError("Action inconnue")
final_tree = sway("get_tree")
after = next((item for item in members(final_tree) if item["id"] == con_id), None)
if action == "store" and after is None or action == "release" and after is not None:
raise DrawerError("L'état final du tiroir ne correspond pas à l'action")
if action in ("show", "hide") and (after is None or after["shown"] != (action == "show")):
raise DrawerError("La visibilité finale ne correspond pas à l'action")
if action == "store" and (after is None or after["shown"]):
raise DrawerError("La fenêtre n'est pas masquée dans le tiroir")
if action == "show" and after["workspace"] != destination:
raise DrawerError("La fenêtre n'est pas sur le workspace choisi")
if action == "release":
final_node, _, final_workspace = valid_app(final_tree, con_id)
if final_workspace != destination or final_node.get("floating") not in ("user_on", "auto_on"):
raise DrawerError("La fenêtre n'est pas flottante sur le workspace choisi")
return {"ok": True, "member": after is not None, "shown": bool(after and after["shown"])}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=["list", "store", "show", "hide", "release"])
parser.add_argument("con_id", nargs="?", type=int)
parser.add_argument("output", nargs="?")
args = parser.parse_args()
if args.action == "list":
tree = sway("get_tree")
focused = next((node["id"] for node, _, _, _ in walk(tree)
if node.get("focused") is True), 0)
print(json.dumps({"entries": members(tree), "focused": focused}, ensure_ascii=False))
return
if args.con_id is None or args.con_id <= 0 or (args.action != "store" and not args.output):
raise DrawerError("Arguments invalides")
print(json.dumps(operate(args.action, args.con_id, args.output)))
if __name__ == "__main__":
try:
main()
except (DrawerError, subprocess.SubprocessError, ValueError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False))
sys.exit(1)

View file

@ -8,16 +8,23 @@ import "notifications"
import "idlebridge"
import "theme"
import "polkit"
import "scratchpad"
import "osd"
ShellRoot {
id: shell
// WHY: distingue les générations QML du PID lors d'un incident. La
// valeur est purement technique et n'est pas conservée au rechargement.
readonly property string keepAwakeGeneration: String(Date.now())
// CONTRACT: one PolkitAgent owns the native request queue for the entire
// user session. A dialog is constructed only for its current flow.
PolkitAgent { id: polkitAgent }
OsdService { id: shellOsdService; authenticationActive: shell.authenticationActive }
property var polkitScreen: null
readonly property bool authenticationActive: polkitAgent.flow !== null
// CONTRACT: le tiroir est global ; aucune barre n'installe son propre
// abonnement window ni son propre ordonnanceur de commandes.
DrawerService { id: shellDrawerService; authenticationActive: shell.authenticationActive }
OsdService { id: shellOsdService; authenticationActive: shell.authenticationActive }
signal authenticationOpening()
function choosePolkitScreen(output) {
const screens = Quickshell.screens;
@ -98,41 +105,82 @@ ShellRoot {
if (polkitAgent.flow) beginPolkitPresentation();
}
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
// CONTRACT: seules la sélection manuelle et son échéance passent d'une
// génération QML à la suivante. Ni socket ni objet Wayland n'est conservé.
// Un arrêt du processus ou une nouvelle session repartent désactivés.
PersistentProperties {
id: manualKeepAwake
reloadableId: "manualKeepAwake"
property bool active: false
property int minutes: 0
property real deadlineEpochMs: 0
onLoaded: shell.refreshKeepAwake("chargement")
}
readonly property bool keepAwake: manualKeepAwake.active
readonly property int keepAwakeMinutes: manualKeepAwake.minutes
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
IpcHandler {
target: "keepAwakeDiagnostics"
function state(): string {
// CONTRACT: diagnostic local, sans identité d'application ni
// contenu de fenêtre. L'état applicatif vient toujours du pont.
return JSON.stringify({ generation: shell.keepAwakeGeneration,
manual: shell.keepAwake, minutes: shell.keepAwakeMinutes,
deadlineEpochMs: manualKeepAwake.deadlineEpochMs,
remainingSeconds: shell.keepAwakeRemainingSeconds,
applicationRequests: shell.applicationRequestCount });
}
}
function setKeepAwake(minutes) {
if (![0, 30, 60, 120].includes(minutes)) return false;
keepAwake = true;
keepAwakeMinutes = minutes;
// INVARIANT: l'échéance est écrite avant l'activation, afin qu'un
// rechargement ne redémarre jamais une durée entière.
manualKeepAwake.deadlineEpochMs = minutes ? Date.now() + minutes * 60000 : 0;
manualKeepAwake.minutes = minutes;
manualKeepAwake.active = true;
keepAwakeRemainingSeconds = minutes * 60;
if (minutes > 0) keepAwakeElapsed.restartMs();
console.info("Maintien manuel activé", minutes === 0 ? "illimité" : minutes + " min",
"échéance", manualKeepAwake.deadlineEpochMs);
return true;
}
function clearKeepAwake() {
keepAwake = false;
keepAwakeMinutes = 0;
function clearKeepAwake(reason) {
if (manualKeepAwake.active)
console.info("Maintien manuel désactivé", reason || "action utilisateur");
manualKeepAwake.active = false;
manualKeepAwake.minutes = 0;
manualKeepAwake.deadlineEpochMs = 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 }
function refreshKeepAwake(reason) {
if (!manualKeepAwake.active) return;
if (manualKeepAwake.minutes === 0) {
keepAwakeRemainingSeconds = 0;
} else {
// WHY: une date absolue survit au rechargement, contrairement à
// ElapsedTimer. Elle empêche la réactivation d'une durée expirée.
const remaining = Math.ceil((manualKeepAwake.deadlineEpochMs - Date.now()) / 1000);
if (remaining <= 0) {
clearKeepAwake("échéance atteinte pendant " + reason);
return;
}
keepAwakeRemainingSeconds = remaining;
}
if (reason === "chargement")
console.info("Maintien manuel restauré", manualKeepAwake.minutes === 0
? "illimité" : manualKeepAwake.minutes + " min",
"échéance", manualKeepAwake.deadlineEpochMs);
}
// CONTRACT: un seul tick dans ShellRoot publie le temps restant. Chaque
// barre lit cet état ; aucune n'avance séparément le compte à rebours.
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;
}
onTriggered: shell.refreshKeepAwake("minuteur")
}
// CONTRACT: one owner across all output bars; opening a menu asks every
// bar to release its other transient panels before the overlay appears.
@ -202,6 +250,7 @@ ShellRoot {
model: Quickshell.screens
Bar {
drawerService: shellDrawerService
osdService: shellOsdService
applicationCoordinator: shell
authenticationActive: shell.authenticationActive

View file

@ -0,0 +1,195 @@
import QtQuick
import Quickshell
import Quickshell.I3
import Quickshell.Wayland
import "../components"
import "../theme"
Item {
id: indicator
required property var service
required property var barWindow
required property string output
required property bool authenticationActive
property int previousFocus: 0
property int selectedIndex: 0
property int selectedAction: 0
property real anchorX: 8
readonly property bool open: popup.visible
signal opened()
visible: !!service && service.entries.length > 0
width: visible ? iconRow.implicitWidth + 4 : 0
height: 26
onVisibleChanged: if (!visible) popup.visible = false
function close(restore) {
if (!popup.visible) return;
popup.visible = false;
if (restore && !authenticationActive && previousFocus > 0)
Qt.callLater(() => I3.dispatch("[con_id=" + previousFocus + "] focus"));
}
function toggle() {
if (authenticationActive) return;
if (popup.visible) { close(true); return; }
opened();
previousFocus = service.focusedId;
selectedIndex = 0;
selectedAction = 0;
service.refresh();
// Le panneau et la barre utilisent la même sortie. Les coordonnées
// locales de la barre plus sa marge évitent toute origine globale.
anchorX = mapToItem(null, 0, 0).x + barWindow.margins.left;
popup.visible = true;
Qt.callLater(() => keys.forceActiveFocus());
}
function perform(entry, action) {
if (!entry || entry.group || service.busy || authenticationActive) return;
// WHY: la surface popup libère le focus avant scratchpad show/focus.
close(false);
Qt.callLater(() => service.act(action, entry.id, output));
}
Rectangle {
anchors.fill: parent
radius: 4
color: popup.visible || pointer.containsMouse ? Theme.border : "transparent"
}
Row {
id: iconRow
anchors.centerIn: parent
spacing: 3
NerdIcon { text: "󰅖"; color: Theme.teal; font.pixelSize: 19 }
Text {
anchors.verticalCenter: parent.verticalCenter
text: indicator.service ? String(indicator.service.entries.length) : ""
color: Theme.foreground
font.pixelSize: 11
}
}
MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: indicator.toggle() }
StatusTooltip { target: indicator; hovered: pointer.containsMouse && !popup.visible
message: "Tiroir de fenêtres" }
PanelWindow {
id: popup
screen: indicator.barWindow.screen
anchors { top: true; bottom: true; left: true; right: true }
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
aboveWindows: true
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
WlrLayershell.namespace: "labfy-scratchpad-drawer"
visible: false
color: "transparent"
MouseArea { anchors.fill: parent; onClicked: indicator.close(true) }
Rectangle {
anchors.top: parent.top
anchors.topMargin: 40
x: Math.max(8, Math.min(indicator.anchorX, popup.width - width - 8))
width: Math.min(420, Math.max(0, popup.width - 16))
height: Math.max(0, Math.min(540, popup.height - 48, body.implicitHeight + 24))
radius: 5
color: Theme.popupBackground
border.color: Theme.outline
FocusScope {
id: keys
anchors.fill: parent
focus: true
Keys.onPressed: event => {
const count = indicator.service ? indicator.service.entries.length : 0;
if (event.key === Qt.Key_Escape) indicator.close(true);
else if (event.key === Qt.Key_Down && count)
indicator.selectedIndex = (indicator.selectedIndex + 1) % count;
else if (event.key === Qt.Key_Up && count)
indicator.selectedIndex = (indicator.selectedIndex + count - 1) % count;
else if (event.key === Qt.Key_Left || event.key === Qt.Key_Right)
indicator.selectedAction = 1 - indicator.selectedAction;
else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
const entry = indicator.service.entries[indicator.selectedIndex];
if (entry) indicator.perform(entry, indicator.selectedAction === 1
? "release" : entry.shown ? "hide" : "show");
} else return;
event.accepted = true;
}
Flickable {
anchors.fill: parent
anchors.margins: 12
contentWidth: width
contentHeight: body.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: body
width: parent.width
spacing: 8
Text { text: "Tiroir de fenêtres"; color: Theme.foreground
font.pixelSize: 15; font.bold: true }
Text { visible: !!indicator.service && indicator.service.error.length > 0
text: indicator.service ? indicator.service.error : ""; color: Theme.red
width: parent.width; wrapMode: Text.Wrap }
Repeater {
model: indicator.service ? indicator.service.entries : []
Rectangle {
required property var modelData
required property int index
readonly property var desktop: DesktopEntries.heuristicLookup(modelData.app)
width: body.width
height: 68
radius: 4
color: index === indicator.selectedIndex ? Theme.border : Theme.buttonBackground
Image {
id: appIcon
x: 8; y: 8; width: 26; height: 26
source: desktop && desktop.icon
? (desktop.icon.startsWith("/") ? "file://" + desktop.icon
: Quickshell.iconPath(desktop.icon, "application-x-executable"))
: Quickshell.iconPath("application-x-executable")
fillMode: Image.PreserveAspectFit
asynchronous: true
}
Text { anchors.centerIn: appIcon; visible: appIcon.status === Image.Error
text: "󰀻"; color: Theme.teal; font.pixelSize: 19 }
Text { x: 42; y: 6; width: parent.width - 50
text: desktop ? desktop.name : modelData.app
color: Theme.foreground; font.bold: true; font.pixelSize: 12
elide: Text.ElideRight; textFormat: Text.PlainText }
Text { x: 42; y: 24; width: parent.width - 50
text: modelData.title; color: Theme.secondaryForeground
font.pixelSize: 11; elide: Text.ElideRight; textFormat: Text.PlainText }
Text { x: 8; y: 49
text: modelData.group ? "Groupe : action individuelle indisponible"
: modelData.shown ? "Affichée" : "Masquée"
color: Theme.secondaryForeground; font.pixelSize: 10 }
Rectangle {
x: parent.width - 205; y: 44; width: 85; height: 20; radius: 3
color: actionPointer.containsMouse ? Theme.buttonHover : Theme.border
border.width: index === indicator.selectedIndex
&& indicator.selectedAction === 0 ? 1 : 0
border.color: Theme.teal
Text { anchors.centerIn: parent; text: modelData.shown ? "Masquer" : "Afficher"
color: Theme.foreground; font.pixelSize: 10 }
MouseArea { id: actionPointer; anchors.fill: parent; hoverEnabled: true
enabled: !modelData.group && !indicator.service.busy
onClicked: indicator.perform(modelData, modelData.shown ? "hide" : "show") }
}
Rectangle {
x: parent.width - 114; y: 44; width: 106; height: 20; radius: 3
color: releasePointer.containsMouse ? Theme.buttonHover : Theme.border
border.width: index === indicator.selectedIndex
&& indicator.selectedAction === 1 ? 1 : 0
border.color: Theme.teal
Text { anchors.centerIn: parent; text: "Remettre ici"
color: Theme.foreground; font.pixelSize: 10 }
MouseArea { id: releasePointer; anchors.fill: parent; hoverEnabled: true
enabled: !modelData.group && !indicator.service.busy
onClicked: indicator.perform(modelData, "release") }
}
}
}
}
}
}
}
}
}

View file

@ -4,24 +4,28 @@ import "../theme"
Item {
id: indicator
required property bool active
required property bool manualActive
required property bool automaticActive
required property bool unlimited
required property int remainingSeconds
signal activated()
width: active ? 26 : 0
// CONTRACT: la tasse occupe toujours sa place ; sa couleur décrit l'état
// combiné sans modifier les deux propriétaires de l'inhibition Wayland.
readonly property bool active: manualActive || automaticActive
readonly property string detail: manualActive
? "Maintien manuel · " + (unlimited ? "jusqu’à désactivation"
: Math.ceil(remainingSeconds / 60) + " min restantes")
+ (automaticActive ? "\nMaintien automatique demandé par une application" : "")
: automaticActive ? "Maintien automatique demandé par une application"
: "Maintenir éveillé · désactivé"
width: 26
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
color: indicator.active ? Theme.red : Theme.foreground
}
MouseArea {
id: pointer
@ -32,8 +36,6 @@ Item {
StatusTooltip {
target: indicator
hovered: pointer.containsMouse
message: indicator.unlimited ? "Maintenir éveillé · jusqu’à désactivation"
: "Maintenir éveillé · " + Math.ceil(indicator.remainingSeconds / 60)
+ " min restantes"
message: indicator.detail
}
}

View file

@ -70,7 +70,8 @@ Row {
spacing: 5
height: 26
KeepAwakeIndicator {
active: area.keepAwakeController.keepAwake
manualActive: area.keepAwakeController.keepAwake
automaticActive: area.keepAwakeController.applicationRequestCount > 0
remainingSeconds: area.keepAwakeController.keepAwakeRemainingSeconds
unlimited: area.keepAwakeController.keepAwakeMinutes === 0
onActivated: area.openPageRequested(16)

View file

@ -9,6 +9,7 @@ PopupWindow {
property var anchorItem: null
property var selectedWindow: null
property var windows: []
signal storeRequested(int id)
readonly property var appWindows: selectedWindow
? windows.filter(window => window.appKey === selectedWindow.appKey) : []
readonly property var entry: selectedWindow
@ -92,6 +93,31 @@ PopupWindow {
}
}
Rectangle {
width: content.width
height: 32
radius: 4
color: storePointer.containsMouse ? Theme.border : Theme.buttonBackground
Text {
anchors.left: parent.left
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Ranger dans le tiroir"
color: Theme.foreground
font.pixelSize: 12
}
MouseArea {
id: storePointer
anchors.fill: parent
hoverEnabled: true
onClicked: {
const id = menu.selectedWindow ? menu.selectedWindow.id : 0;
menu.visible = false;
if (id > 0) menu.storeRequested(id);
}
}
}
Text {
visible: menu.actions.length > 0
text: "Actions"

View file

@ -8,6 +8,8 @@ Item {
id: strip
required property var screen
required property var drawerService
required property bool authenticationActive
required property int maxWidth
property var tree: null
property bool componentReady: false
@ -50,7 +52,7 @@ Item {
function collect(container) {
const appId = container.app_id
|| (container.window_properties && container.window_properties.class) || "";
if (container.type === "con" && appId) {
if ((container.type === "con" || container.type === "floating_con") && appId) {
found.push({
id: container.id,
title: container.name || appId,
@ -120,6 +122,7 @@ Item {
}
function showMenu(item, windowInfo) {
if (authenticationActive) return;
if (windowMenu.visible && menuWindow && menuWindow.id === windowInfo.id) {
windowMenu.visible = false;
return;
@ -240,5 +243,9 @@ Item {
anchorItem: strip.menuAnchor
selectedWindow: strip.menuWindow
windows: strip.windows
onStoreRequested: id => {
strip.closeMenu();
strip.drawerService.act("store", id, strip.screen.name);
}
}
}

View file

@ -110,6 +110,13 @@ bindsym $mod+s layout stacking
# Super+W appartient à l'Overview ; conserver tabbed sur Super+Shift+W.
bindsym $mod+Shift+w layout tabbed
bindsym $mod+w exec ~/.config/sway/scripts/overview-quickshell
# CONTRACT: seuls les swipes ASUS à trois doigts déclenchent ces actions.
# Un geste natif achevé émet une commande ; les diagonales restent à essayer
# sur le vrai pavé avant d'imposer --exact.
bindgesture --input-device=2362:12311:ASCP1201:00_093A:3017_Touchpad swipe:3:up exec ~/.config/sway/scripts/touchpad-gestures open
bindgesture --input-device=2362:12311:ASCP1201:00_093A:3017_Touchpad swipe:3:down exec ~/.config/sway/scripts/touchpad-gestures close
bindgesture --input-device=2362:12311:ASCP1201:00_093A:3017_Touchpad swipe:3:left exec ~/.config/sway/scripts/touchpad-gestures next
bindgesture --input-device=2362:12311:ASCP1201:00_093A:3017_Touchpad swipe:3:right exec ~/.config/sway/scripts/touchpad-gestures prev
bindsym $mod+e layout toggle split
# Make the current focus fullscreen

View file

@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Traiter un geste Sway natif sans service d'entrée permanent."""
import json
import subprocess
import sys
import time
TIMEOUT = 1.5
def run(*args):
# CONTRACT: IPC borné avec liste d'arguments ; un échec ne présume jamais
# l'état d'authentification et n'envoie pas de commande workspace.
result = subprocess.run(args, capture_output=True, text=True, timeout=TIMEOUT,
check=True)
return result.stdout.strip()
def ipc(target, operation):
return run("quickshell", "ipc", "-c", "labfy-sway", "call", target, operation)
def state(target):
return json.loads(ipc(target, "state"))
def focused_output():
workspaces = json.loads(run("swaymsg", "-t", "get_workspaces", "-r"))
focused = [item for item in workspaces if item.get("focused") is True]
if len(focused) != 1 or not isinstance(focused[0].get("output"), str):
raise ValueError("Aucune sortie focalisée unique")
return focused[0]["output"]
def authentication_idle():
auth = state("polkitUi")
# INVARIANT: une réponse absente ou incomplète ne vaut jamais permission
# de changer de panneau ou de workspace.
return (auth.get("registered") is True and auth.get("active") is False
and auth.get("dialog") is False and auth.get("queryRunning") is False)
def dispatch(action):
# WHY: seul l'état de l'agent Polkit fait foi ; titres et focus ne
# permettent pas d'identifier une demande d'authentification.
if not authentication_idle():
return 0
output = focused_output()
target = "overviewUi-" + output
if action == "open":
return 0 if ipc(target, "open") == "true" else 1
if action == "close":
return 0 if ipc(target, "close") == "true" else 1
# CONTRACT: Sway ne change de workspace existant sur la sortie focalisée
# qu'après la libération de l'Overview. Aucun conteneur n'est déplacé.
if ipc(target, "active") == "true":
if ipc(target, "close") != "true":
return 1
deadline = time.monotonic() + TIMEOUT
while time.monotonic() < deadline:
if ipc(target, "active") == "false":
break
time.sleep(0.02)
else:
return 1
# Un aller-retour au compositeur suit la disparition du panneau.
run("swaymsg", "-t", "get_version", "-r")
# Une demande peut débuter durant la fermeture : vérifier de nouveau
# l'agent juste avant la commande workspace.
if not authentication_idle():
return 0
command = "next_on_output" if action == "next" else "prev_on_output"
replies = json.loads(run("swaymsg", "-r", "workspace", command))
return 0 if replies and all(reply.get("success") for reply in replies) else 1
def main(argv):
if len(argv) != 2 or argv[1] not in {"open", "close", "next", "prev"}:
return 2
try:
return dispatch(argv[1])
except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError,
json.JSONDecodeError):
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv))

View file

@ -0,0 +1,101 @@
"""Contrôles du dispatcher sans geste matériel ni effet sur la session."""
import importlib.machinery
import importlib.util
import json
from pathlib import Path
import subprocess
import unittest
from unittest.mock import patch
SCRIPT = Path(__file__).resolve().parents[2] / "sway/.config/sway/scripts/touchpad-gestures"
loader = importlib.machinery.SourceFileLoader("touchpad_gestures", str(SCRIPT))
spec = importlib.util.spec_from_loader(loader.name, loader)
gesture = importlib.util.module_from_spec(spec)
loader.exec_module(gesture)
class TouchpadGestureTests(unittest.TestCase):
def run_action(self, action, overview=None, auth=None, output="eDP-1"):
overview = overview or {"loaded": False, "requested": False}
auth = auth or {"registered": True, "active": False, "dialog": False,
"queryRunning": False}
calls = []
def fake_run(*args):
calls.append(args)
if args[:3] == ("swaymsg", "-t", "get_workspaces"):
return json.dumps([{"focused": False, "output": "HDMI-A-1"},
{"focused": True, "output": output}])
if args[:3] == ("swaymsg", "-t", "get_version"):
return '{}'
if args[:2] == ("swaymsg", "-r"):
return '[{"success":true}]'
raise AssertionError(args)
def fake_state(target):
calls.append(("state", target))
return auth if target == "polkitUi" else overview
def fake_ipc(target, operation):
calls.append(("ipc", target, operation))
if operation == "active":
return "true" if overview["loaded"] or overview["requested"] else "false"
if operation == "close":
overview["loaded"] = False
overview["requested"] = False
return "true"
with patch.object(gesture, "run", fake_run), \
patch.object(gesture, "state", fake_state), \
patch.object(gesture, "ipc", fake_ipc):
result = gesture.dispatch(action)
return result, calls
def test_open_and_close_use_explicit_ipc(self):
for action in ("open", "close"):
with self.subTest(action=action):
result, calls = self.run_action(action)
self.assertEqual(result, 0)
self.assertIn(("ipc", "overviewUi-eDP-1", action), calls)
self.assertFalse(any(call[:2] == ("swaymsg", "-r") for call in calls))
def test_horizontal_closes_before_native_workspace_command(self):
result, calls = self.run_action("next", {"loaded": True, "requested": True})
self.assertEqual(result, 0)
self.assertLess(calls.index(("ipc", "overviewUi-eDP-1", "close")),
calls.index(("swaymsg", "-r", "workspace", "next_on_output")))
self.assertIn(("swaymsg", "-t", "get_version", "-r"), calls)
self.assertFalse(any("move" in str(call) for call in calls))
def test_previous_uses_current_output_and_no_move(self):
result, calls = self.run_action("prev")
self.assertEqual(result, 0)
self.assertIn(("swaymsg", "-r", "workspace", "prev_on_output"), calls)
self.assertFalse(any("move" in str(call) for call in calls))
def test_overview_targets_focused_output_not_first_output(self):
result, calls = self.run_action("open", output="DP-2")
self.assertEqual(result, 0)
self.assertIn(("ipc", "overviewUi-DP-2", "open"), calls)
def test_polkit_blocks_all_actions(self):
for action in ("open", "close", "next", "prev"):
with self.subTest(action=action):
result, calls = self.run_action(action, auth={"registered": True, "active": True})
self.assertEqual(result, 0)
self.assertEqual(calls, [("state", "polkitUi")])
def test_incomplete_polkit_state_fails_closed(self):
result, calls = self.run_action("next", auth={"registered": True})
self.assertEqual(result, 0)
self.assertEqual(calls, [("state", "polkitUi")])
def test_ipc_failure_is_bounded_and_fails_closed(self):
with patch.object(gesture, "state", side_effect=subprocess.TimeoutExpired("quickshell", 1.5)):
self.assertEqual(gesture.main(["touchpad-gestures", "next"]), 1)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,94 @@
"""Contrats du modèle scratchpad Sway, sans toucher au bureau réel."""
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
BACKEND = (Path(__file__).resolve().parents[2] / "quickshell/.config/quickshell"
/ "labfy-sway/scratchpad/backend.py")
spec = importlib.util.spec_from_file_location("drawer_backend", BACKEND)
drawer = importlib.util.module_from_spec(spec)
spec.loader.exec_module(drawer)
def app(cid, name="Application", app_id="demo", state="none"):
return {"id": cid, "type": "con", "name": name, "app_id": app_id,
"scratchpad_state": state, "nodes": [], "floating_nodes": []}
def tree(*nodes):
return {"id": 1, "type": "root", "nodes": [
{"id": 2, "type": "output", "name": "__i3", "nodes": [
{"id": 3, "type": "workspace", "name": "__i3_scratch",
"nodes": [], "floating_nodes": [n for n in nodes if n.get("hidden")]}]},
{"id": 4, "type": "output", "name": "DP-2", "nodes": [
{"id": 5, "type": "workspace", "name": 'Travail ; "A"',
"nodes": [], "floating_nodes": [n for n in nodes if not n.get("hidden")]}]}]}
class DrawerModelTest(unittest.TestCase):
def test_empty_and_duplicate_app_identity(self):
self.assertEqual(drawer.members(tree()), [])
a, b = app(20, "Premier", state="fresh"), app(21, "Second", state="fresh")
a["hidden"] = True
result = drawer.members(tree(a, b))
self.assertEqual([(x["id"], x["shown"]) for x in result], [(20, False), (21, True)])
def test_group_is_blocked_even_when_apps_are_nested(self):
group = {"id": 10, "type": "floating_con", "scratchpad_state": "fresh",
"nodes": [{"id": 11, "type": "con", "nodes": [app(20)],
"floating_nodes": [app(21)]}], "floating_nodes": []}
group["hidden"] = True
self.assertTrue(all(x["group"] for x in drawer.members(tree(group))))
def test_workspace_quoting_and_missing_target(self):
self.assertEqual(drawer.quoted_workspace('Travail ; "A"'), "'Travail ; \"A\"'")
with self.assertRaises(drawer.DrawerError):
drawer.quoted_workspace("A 'B' and \"C\"")
with self.assertRaises(drawer.DrawerError):
drawer.valid_app(tree(), 99)
with patch.object(drawer, "sway", return_value=[{"name": "A", "output": "DP-2", "visible": True}]):
with self.assertRaises(drawer.DrawerError):
drawer.target_workspace("HDMI-A-1")
def test_non_app_container_is_rejected(self):
group = {"id": 30, "type": "con", "nodes": [app(31)], "floating_nodes": []}
with self.assertRaises(drawer.DrawerError):
drawer.valid_app(tree(group), 30)
def test_store_rejects_multi_app_parent(self):
group = {"id": 30, "type": "floating_con", "nodes": [app(31), app(32)],
"floating_nodes": []}
with self.assertRaises(drawer.DrawerError):
drawer.valid_app(tree(group), 31)
def test_repeated_show_only_focuses_and_repeated_hide_sends_nothing(self):
displayed = tree(app(20, state="fresh"))
hidden = app(20, state="fresh")
hidden["hidden"] = True
hidden_tree = tree(hidden)
workspaces = [{"name": 'Travail ; "A"', "output": "DP-2", "visible": True}]
def answer(kind, current):
return current if kind == "get_tree" else workspaces
with patch.object(drawer, "sway", side_effect=lambda kind: answer(kind, displayed)), \
patch.object(drawer, "safe_command") as sent:
self.assertTrue(drawer.operate("show", 20, "DP-2")["shown"])
sent.assert_called_once_with("[con_id=20] focus")
with patch.object(drawer, "sway", side_effect=lambda kind: answer(kind, hidden_tree)), \
patch.object(drawer, "safe_command") as sent:
self.assertFalse(drawer.operate("hide", 20, "DP-2")["shown"])
sent.assert_not_called()
def test_rejected_command_does_not_report_success(self):
with patch.object(drawer, "sway", return_value=tree(app(20))), \
patch.object(drawer, "safe_command", side_effect=drawer.DrawerError("refusée")):
with self.assertRaisesRegex(drawer.DrawerError, "refusée"):
drawer.operate("store", 20, "DP-2")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,113 @@
// Run with node tests/test_keep_awake_timer.js.
// CONTRACT: execute production function bodies with a controlled clock;
// only PersistentProperties are shared between simulated ShellRoot generations.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const vm = require("node:vm");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const source = fs.readFileSync(path.join(root, "quickshell/.config/quickshell/labfy-sway/shell.qml"), "utf8");
const bar = fs.readFileSync(path.join(root, "quickshell/.config/quickshell/labfy-sway/Bar.qml"), "utf8");
const page = fs.readFileSync(path.join(root, "quickshell/.config/quickshell/labfy-sway/controlcenter/KeepAwakePage.qml"), "utf8");
const indicator = fs.readFileSync(path.join(root, "quickshell/.config/quickshell/labfy-sway/status/KeepAwakeIndicator.qml"), "utf8");
function body(signature) {
const marker = `function ${signature}`;
const start = source.indexOf(marker);
assert.notEqual(start, -1);
const open = source.indexOf("{", start + marker.length);
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === "{") depth++;
else if (source[i] === "}" && --depth === 0) return source.slice(open + 1, i);
}
throw new Error(`unclosed ${signature}`);
}
const set = body("setKeepAwake(minutes)");
const clear = body("clearKeepAwake(reason)");
const refresh = body("refreshKeepAwake(reason)");
function fixture() {
let now = 0;
const saved = { active: false, minutes: 0, deadlineEpochMs: 0 };
function generation(requestCount = 0) {
const state = { manualKeepAwake: saved, keepAwakeRemainingSeconds: 0,
applicationRequestCount: requestCount, Date: { now: () => now },
console: { info() {} } };
Object.defineProperties(state, {
keepAwake: { get: () => saved.active },
keepAwakeMinutes: { get: () => saved.minutes }
});
state.shell = state;
vm.createContext(state);
vm.runInContext(`function setKeepAwake(minutes) {${set}}
function clearKeepAwake(reason) {${clear}}
function refreshKeepAwake(reason) {${refresh}}`, state);
state.refreshKeepAwake("chargement");
return state;
}
return { generation, advance(ms) { now = ms; } };
}
for (const seconds of [60, 130, 300, 360, 600]) {
const clock = fixture();
let state = clock.generation();
assert.equal(state.setKeepAwake(0), true);
clock.advance(seconds * 1000);
state = clock.generation();
assert.equal(state.keepAwake, true, `unlimited at ${seconds}s`);
}
for (const minutes of [30, 60, 120]) {
const clock = fixture();
let state = clock.generation();
state.setKeepAwake(minutes);
const deadline = state.manualKeepAwake.deadlineEpochMs;
clock.advance(minutes * 30000);
state = clock.generation();
assert.equal(state.keepAwake, true);
assert.equal(state.manualKeepAwake.deadlineEpochMs, deadline);
assert.equal(state.keepAwakeRemainingSeconds, minutes * 30);
clock.advance(deadline - 1);
state.refreshKeepAwake("minuteur");
assert.equal(state.keepAwake, true);
assert.equal(state.keepAwakeRemainingSeconds, 1);
clock.advance(deadline);
state.refreshKeepAwake("minuteur");
assert.equal(state.keepAwake, false);
assert.equal(state.keepAwakeRemainingSeconds, 0);
}
{
const clock = fixture();
let state = clock.generation();
state.setKeepAwake(0);
for (let i = 1; i <= 3; i++) {
clock.advance(i * 130000);
state = clock.generation();
assert.equal(state.keepAwake, true);
}
state.applicationRequestCount = 1;
state.clearKeepAwake();
assert.equal(state.keepAwake, false);
assert.equal(state.applicationRequestCount, 1);
}
{
const clock = fixture();
let state = clock.generation(1);
state.setKeepAwake(30);
clock.advance(1800000);
state = clock.generation(1);
assert.equal(state.keepAwake, false, "expired during reload");
assert.equal(state.applicationRequestCount, 1);
}
assert.match(source, /PersistentProperties\s*\{[\s\S]*?reloadableId:\s*"manualKeepAwake"/);
assert.match(source, /running:\s*shell\.keepAwake\s*&&\s*shell\.keepAwakeMinutes\s*>\s*0/);
assert.equal((source.match(/function refreshKeepAwake\(reason\)/g) || []).length, 1);
assert.doesNotMatch(page, /Component\.onCompleted|Timer\s*\{|ElapsedTimer/);
assert.match(bar, /enabled:\s*bar\.startupHost\s*&&\s*\(bar\.keepAwakeController\.keepAwake\s*\|\|\s*bar\.keepAwakeController\.applicationRequestCount\s*>\s*0\)/);
assert.match(indicator, /readonly property bool active: manualActive \|\| automaticActive/);
assert.match(indicator, /width: 26/);
console.log("keep-awake timer: controlled-clock and reload checks passed");