diff --git a/docs/session-restore-v2.md b/docs/session-restore-v2.md
index d814d6ba1..de6469fbc 100644
--- a/docs/session-restore-v2.md
+++ b/docs/session-restore-v2.md
@@ -338,3 +338,18 @@ sans sauvegarder restent nécessaires. Aucun code d’erreur n’autorise le log
Validation et limites détaillées :
[rapport scratchpad](session-v2-scratchpad-save-and-restore-v1.md).
+
+
+## Précondition Secret Service avant restauration — 22 octobre 2026
+
+Le parcours utilisateur ajoute une barrière D-Bus indépendante du checkpoint.
+Les CLI `apply-last` et `apply` exigent la collection default attendue réellement
+déverrouillée ; le chooser propose son déverrouillage officiel et conserve le
+clic explicite Restaurer après READY. Nouvelle session reste disponible sans
+trousseau. Cette précondition ne modifie aucune postcondition de l'Executor,
+aucun schéma de checkpoint/Recovery et ne résout pas automatiquement un
+`RECOVERY_REQUIRED`. Une activation tardive garde les refus de focus existants.
+
+Implémentation, audit PAM (aucun patch applicable établi), tests isolés,
+installation et rollback :
+[trousseau avant restauration](session-v2-keyring-before-restore-v1.md).
diff --git a/docs/session-v2-keyring-before-restore-v1.md b/docs/session-v2-keyring-before-restore-v1.md
new file mode 100644
index 000000000..25b11c20e
--- /dev/null
+++ b/docs/session-v2-keyring-before-restore-v1.md
@@ -0,0 +1,291 @@
+# Session V2 — trousseau avant restauration (22 octobre 2026)
+
+État : implémentation et validation isolées ; **non déployé**, vrai login non
+validé. Base `d16aa69f`, branche `fix/session-v2-keyring-before-restore-20261022`.
+Le dépôt principal était propre ; aucun autre worktree n'a été modifié.
+
+## Diagnostic et preuves disponibles
+
+À 08:01:35 CEST, systemd démarre GNOME Keyring ; QuickShell démarre à
+08:01:38. À 08:01:48 le prompter reçoit BeginPrompting, crée GcrPromptDialog,
+puis commence un password prompt. L'incident fourni situe le démarrage du
+prompt à 08:01:48.235 et le rapport d'échec à 08:01:48.482. Le focus attendu
+Firefox/con_id 10 est remplacé par con_id 18 après les six frontières du guard.
+Ces événements concordent avec une activation du trousseau pendant la fenêtre
+de validation finale. **L'identité historique de con_id 18 et la causalité ne
+sont pas démontrées.** Aucune capture d'arbre contemporaine avec PID/starttime
+ne permet ici de les établir. Même limite pour l'occurrence de 06:11:59.
+
+Les abonnements IPC Sway indiquent la fenêtre devenue active, pas l'origine
+(user input ou commande d'un autre client IPC) d'un changement de focus. Un
+app_id seul n'est pas une preuve de causalité. Une future investigation peut
+corréler les événements fenêtre/workspace horodatés, con_id, PID/starttime et
+les événements du prompter ; elle doit laisser « non attribué » ce qui le reste.
+Ne pas activer de trace d'échange secret pour ce diagnostic.
+
+## Audit de l'installation réelle (lecture seule)
+
+- `/etc/pam.d/greetd` : securetty, nologin, puis inclusions auth/account/session
+ de system-local-login. Aucune règle gnome-keyring. SHA-256 observé :
+ `993a3096c2b113e6800f2abbd5d4233ebf1a97eef423990d3187d665d3490b92`.
+- Inclusions : system-local-login → system-login → system-auth. pam_unix utilise
+ try_first_pass ; pam_systemd est dans la chaîne session avant le retour à
+ greetd. Les sauts numériques de system-auth ne sautent pas une règle ajoutée
+ après l'inclusion dans greetd. Rien de cette pile n'est modifié.
+- Le greeter installé dans `/usr/local/libexec/labfy-greeter` répond aux messages
+ PAM avec post_auth_message_response, sur la même conversation greetd. Il ne
+ réauthentifie pas le mot de passe localement. Le code installé est antérieur
+ au sélecteur de session du dépôt mais possède ce même transport des réponses.
+- Module présent : `/usr/lib/security/pam_gnome_keyring.so`, paquet
+ gnome-keyring `1:51.1-1`, greetd `0.10.3-2`, python-gobject `3.58.1-1`.
+- Secret Service appartient à gnome-keyring-daemon. Un processus observé :
+ `gnome-keyring-daemon --foreground --components=pkcs11,secrets,ssh`.
+ ReadAlias(default) renvoie `/org/freedesktop/secrets/collection/Default_5fkeyring` ;
+ ReadAlias(login) renvoie `/`. Locked est false lors de la relecture finale
+ (cet état courant ne prouve pas son état pendant l’incident). Aucun contenu des fichiers du trousseau n'a été lu.
+ Les noms présents sont Default_keyring.keyring, default et user.keystore.
+- L'unité utilisateur masque celle du paquet. Elle omet Requires=socket et le
+ `--control-directory=%t/keyring` explicite de l'unité distribuée. Son lien
+ d'activation est dans default.target.wants, même si Install mentionne
+ graphical-session.target. Le socket `%t/keyring/control` est actif.
+- D-Bus possède aussi une entrée d'activation `--start --foreground
+ --components=secrets`. Les fichiers XDG autostart du paquet utilisent --start
+ et OnlyShowIn=GNOME;Unity;MATE;Cinnamon. Ils ne démontrent donc pas un second
+ lancement sous Sway. Aucun autre lancement n'a été trouvé dans les sources
+ Sway/QuickShell/greetd ; **une seule instance observée ne prouve pas l'absence
+ de toute course de démarrage**.
+
+### Décision PAM : pas de patch applicable dans cette mission
+
+L'intégration habituelle place auth optional pam_gnome_keyring.so après
+l'authentification principale et session optional pam_gnome_keyring.so
+auto_start après l'inclusion de session (donc après pam_systemd). Le transport
+du greeter et PAM_AUTHTOK sont structurellement compatibles avec ce schéma.
+Cela n'établit pas que Default_keyring soit le trousseau login, ni que son mot
+de passe soit identique à celui du compte. Ces faits ne sont pas testés avec
+un mot de passe dans cette mission.
+
+Le module GNOME cible le trousseau **login**, identifié en interne par CKA_ID.
+Son chemin de déverrouillage peut le créer s'il manque. L'alias login est absent,
+aucun fichier login n'est listé, et le rôle interne de Default_keyring n'est pas
+prouvé. Activer auto_start pourrait donc provoquer une création et ne démontre
+pas le déverrouillage du default existant. Ce mécanisme est vérifié dans le
+[code GNOME Keyring 51.1, unlock_or_create_login](https://raw.githubusercontent.com/GNOME/gnome-keyring/51.1/daemon/login/gkd-login.c).
+La [documentation PAM GNOME](https://wiki.gnome.org/Projects/GnomeKeyring/Pam)
+décrit également la cible login et les règles usuelles. **Aucun fichier PAM candidat à installer
+n'est livré**, aucun essai PAM réel n'est effectué. Une validation statique de
+la syntaxe de deux règles ne validerait pas ces conditions opérationnelles.
+
+Le contrôle optional protège normalement la décision d'authentification
+principale contre un échec du module ; il ne garantit pas à lui seul l'absence
+d'attente ni d'effets de bord. Ne pas prendre cette analyse pour une preuve
+qu'un changement PAM quelconque est sans risque.
+
+Une future tranche PAM exige une autorisation distincte, la confirmation
+humaine du trousseau cible sans enregistrer le mot de passe, et une étude de
+l'unité/socket du paquet comme propriétaire unique du démon. Si un patch
+compatible est alors établi, l'application privilégiée doit sauvegarder le
+fichier root:root/0644, contrôler le hash de départ, installer atomiquement la
+copie validée et conserver un accès de secours hors greeter. Le rollback doit
+réinstaller exactement la sauvegarde, sans modifier les inclusions système.
+Aucune de ces opérations ni aucun logout/reboot n'a été exécuté ici.
+
+Aucune modification de l'unité utilisateur n'est nécessaire à la barrière
+D-Bus. La normalisation de cette unité avec une future intégration PAM doit
+être validée au login ; elle n'est pas appliquée sur le bureau actif.
+
+## Contrat de la barrière
+
+`session_v2/keyring.py` utilise PyGObject/Gio et une connexion D-Bus privée :
+
+1. Résoudre/activer Secret Service avec une attente bornée, puis fixer son
+ propriétaire D-Bus unique pour toutes les opérations.
+2. ReadAlias(default) doit correspondre à la collection auditée ci-dessus.
+ Default absent/différent est un refus explicite. Si login existe, il doit
+ désigner la même collection. Login absent autorise le parcours manuel ; il
+ ne vaut jamais preuve de compatibilité PAM. Une installation utilisant une
+ autre collection doit faire réauditer cette constante avant intégration.
+3. Lire org.freedesktop.Secret.Collection.Locked par Properties.Get. READY
+ exige un vrai booléen false, avec propriétaire inchangé.
+4. Un clic Déverrouiller appelle Service.Unlock. S'il retourne un Prompt,
+ s'abonner à Completed **avant** Prompt(""). Le window-id vide présente un
+ prompt non parenté, sans prétendre à un handle X11/Wayland inexistant.
+5. Garder la connexion vivante, attendre Completed non annulé, puis relire
+ alias/propriétaire/Locked. Une fin de prompt avec Locked=true refuse READY.
+6. Limites : connexion/auth D-Bus 2 s, appels individuels au plus 2 s, budget
+ de lecture 8 s, opération interactive 90 s, nettoyage Dismiss au plus 1 s,
+ puis relecture avec budget 8 s. Un timeout n'autorise rien. Un mot de passe
+ incorrect peut maintenir le prompt GNOME ouvert jusqu'à correction,
+ annulation ou timeout ; le client n'essaie jamais de deviner cette cause.
+7. Dismiss/fermeture de la connexion ne concernent que le prompt possédé. Le
+ traitement SIGTERM réveille la boucle GLib et permet ce nettoyage. Un arrêt
+ brutal du processus ferme sa connexion ; il ne laisse aucune autorisation
+ persistante. Aucun fichier READY/token n'est produit.
+
+Les commandes `keyring-status` et `keyring-unlock --execute` retournent
+uniquement un état KEYRING_* et un code de diagnostic fixe. Aucun OpenSession,
+GetSecret(s), SearchItems, changement de mot de passe, création de collection
+ou lecture de fichier de trousseau n'est utilisé. Les erreurs D-Bus libres et
+le payload de résultat de Completed ne sont pas journalisés.
+
+### Concurrence et transactions
+
+Le verrou privé `session-v2/keyring.lock` sérialise les helpers entre processus.
+Une seconde demande voit KEYRING_UNLOCKING/KEYRING_OR_RESTORE_BUSY et ne crée
+pas un second prompt. Avant Unlock, le helper vérifie aussi le verrou de
+transaction existant (y compris celui hérité par un guard survivant).
+
+Les CLI `apply-last` **et** `apply` prennent keyring.lock, relisent D-Bus sans
+ouvrir de prompt, puis appellent l'Executor inchangé en gardant le verrou
+jusqu'à son retour. Ordre : keyring.lock puis transaction.lock. Aucune voie
+CLI de restauration n'est autorisée par le seul booléen du QML. Un refus de
+barrière ne publie aucun Attempt/Recovery et ne nettoie aucun ancien journal.
+Les appels internes directs à Executor restent une API de moteur ; ils ne
+constituent pas une commande utilisateur de restauration.
+
+La décision « Nouvelle session » conserve uniquement le verrou de transaction
+historique. Elle reste donc disponible pendant une attente du trousseau.
+QuickShell arrête son helper lors de ce choix. Un autre helper déjà en cours
+n'est pas tué par une instance qui ne le possède pas.
+
+### QuickShell
+
+Le singleton démarre sa lecture indépendamment du checkpoint. Le chooser
+reconstruit aussi l'état backend au redémarrage ; un déverrouillage détenu par
+une autre instance est observé par polling borné des commandes, jamais relancé.
+Les erreurs et annulations proposent une nouvelle action explicite. Le panneau
+retire sa capture clavier avant le prompt natif ; il conserve le bouton
+Nouvelle session. Après READY, seul un clic Restaurer appelle le chemin
+historique de retrait de surface, prewarm, Guard et validation finale.
+
+Le gestionnaire de sessions nommées reçoit également le refus du backend et
+l'affiche sans charger un ancien rapport d'échec. Les commandes de diagnostic,
+checkpoint, sauvegarde, nouvelle session et ouverture normale du bureau ne
+dépendent pas d'un trousseau disponible.
+
+## Limite tardive et invariants conservés
+
+Secret Service peut reverrouiller une collection à tout instant. Le verrou de
+la barrière ne contrôle pas les autres applications, un autre trousseau ou
+un autre client Secret Service. Il ne garantit donc pas qu'aucun prompt ne
+pourra jamais apparaître après l'admission. Un prompt déjà demandé par un
+client externe n'est pas notre objet Prompt et ne peut être annulé arbitrairement.
+La [spécification Secret Service](https://specifications.freedesktop.org/secret-service/latest/unlocking.html) précise cette course inhérente.
+
+L'initialisation du default nécessaire à notre parcours est traitée avant la
+restauration. Si une activation tardive interfère néanmoins, les mêmes preuves
+con_id/PID/starttime/workspace/output et les mêmes postconditions de focus
+refusent la transaction. **Aucun focus observé n'est remplacé pour fabriquer
+un succès.** L'expérience headless crée une surface synthétique après
+`after-guard-finish` et exige RECOVERY_FOCUS_MISMATCH, final_focus_verified=false
+et RECOVERY_REQUIRED. Elle ne prétend pas identifier le dialogue de 08:01:48.
+
+Checkpoint V1, scratchpad V2, Guard.prepare, journaux et schémas Recovery,
+placement et validations de focus ne sont pas modifiés. Les tests qui isolent
+le prewarm simulent explicitement la nouvelle précondition ; les nouveaux tests
+D-Bus et UI testent sa vraie implémentation séparément.
+
+## Reproduction de la validation
+
+Depuis ce worktree :
+
+```sh
+python -B tests/session_v2/validate_keyring.py --sway /chemin/absolu/vers/sway-headless
+```
+
+Dépendances locales : Python/PyGObject/Gio, dbus-daemon, QuickShell/Qt/PySide6,
+Sway pouvant démarrer headless avec pixman, outils GTK/compilateur déjà exigés
+par les fixtures Session V2, kitty et grim. Aucun paquet système n'a été
+installé. Le SwayFX installé ne convient pas au laboratoire sans DRM ; le
+binaire Sway de test conservé par la mission précédente a été réutilisé :
+`/tmp/labfy-scratchpad-tool/usr/bin/sway`. Son paquet avait le SHA-256
+`5704cfc9eba9804116c665cb4450352bf5a240c1a8300b36aca3218116648443`.
+
+Le runner impose une liste positive d'environnement, HOME et XDG privés.
+Chaque test graphique réel crée son propre compositeur ; chaque service simulé
+possède son bus privé. Aucun socket du bureau ni état de session utilisateur
+n'est transmis. Les tests QML unitaires emploient des surfaces Item simulées et
+Qt offscreen ; ils ne mappent aucune fenêtre sur un bureau réel.
+
+La matrice couvre les 20 cas demandés : déverrouillé ; verrouillé/Prompt ;
+accepté avec relecture ; annulé ; mot de passe incorrect simulé par l'absence de
+fin ; fin sans déverrouillage ; timeout RPC et prompt ; service absent ; default
+absent/différent et login incompatible ; redémarrage helper et vrai QuickShell ;
+deux demandes concurrentes ; refus apply-last ; absence d'autorestauration ;
+nouvelle session ; surface tardive ; focus final stable ; RECOVERY_REQUIRED ;
+régressions scratchpad/backend/focus ; erreurs contenant une sentinelle sensible
+exclue des sorties et fichiers ; login sans aide PAM. Les scénarios de mot de
+passe sont simulés : aucun mot de passe réel ou d'essai n'est manipulé.
+
+Validation finale : les onze lots réussissent (93 tests backend, 29 tests de
+focus, 15 tests QML unitaires, 6 résultats Qt dont initialisation/nettoyage,
+8 scénarios UI de trousseau, 2 scénarios moteur stable/tardif, recette scratchpad,
+7 scénarios UI V2 existants, 5 scénarios P0 et 5 de stabilité des chemins).
+Résultats et logs : `/tmp/labfy-keyring-validation-y8xyqh17/results.json` et ses
+fichiers `.log`. Les chemins des laboratoires/surfaces synthétiques sont dans
+ces logs. Après revue, les huit tests spécifiques D-Bus ont également été
+repassés avec activation des services système désactivée dans le bus simulé :
+`/tmp/labfy-keyring-privatebus-final.log`. Le test d'absence affirme NameHasOwner=false. Les avertissements PyGObject/Python de dépréciation ne sont pas des
+erreurs ; aucune sortie ne contient de secret. Le runner est livré pour
+reproduire les résultats si `/tmp` est nettoyé.
+
+## Installation contrôlée et rollback
+
+**Aucune installation n'a eu lieu.** Les fichiers de la configuration active
+sont reliés aux sources du dépôt principal. Les modifier pendant que QuickShell
+les surveille pourrait provoquer un rechargement. Ne pas appliquer le patch
+sur ces sources pendant la session graphique réelle.
+
+Après revue et autorisation humaine de l'intégration et de la fermeture de la
+session, depuis un TTY de secours sans QuickShell actif :
+
+1. Vérifier les changements postérieurs dans `/home/fy59/.dotfiles` et vérifier
+ la base. Conserver une copie privée de tous les fichiers concernés et du
+ manifeste de hashes. Ne pas toucher aux autres worktrees.
+2. Examiner le patch complet livré (il inclut les nouveaux fichiers) et faire
+ `git apply --check /chemin/changes.patch` depuis le dépôt principal. Un refus
+ impose de réconcilier les changements ; ne jamais forcer/écraser.
+3. Appliquer ce patch seulement après ce contrôle. Ne faire ni commit ni push
+ automatiquement. Vérifier la résolution des liens de configuration existants,
+ et `git diff --check`. Aucune modification PAM/systemd n'est à appliquer.
+4. Vérifier `python -c 'from gi.repository import Gio, GLib'`. Si la dépendance
+ manque, reporter l'installation ; ne pas lancer un gestionnaire de paquets
+ sans autorisation. L'indisponibilité est un refus de restauration, pas un
+ refus d'ouverture du bureau.
+5. Au prochain login autorisé, suivre le scénario ci-dessous. Ne pas redémarrer
+ le greeter ou le bureau pour « tester » depuis la session en cours.
+
+Rollback, également hors du QuickShell actif :
+
+```sh
+cd /home/fy59/.dotfiles
+git apply --reverse --check /chemin/changes.patch
+git apply --reverse /chemin/changes.patch
+```
+
+En présence de dérive, restaurer seulement les fichiers sauvegardés après
+comparaison avec le manifeste et revue du delta ; ne pas faire reset/restore/
+stash/clean. Les fichiers nouveaux du patch doivent être retirés uniquement
+si leurs hashes correspondent encore à la livraison. Aucune donnée de
+checkpoint, aucun journal Recovery et aucun trousseau ne sont supprimés ou
+modifiés par l'installation/rollback. PAM et systemd restent identiques.
+
+## Prochain vrai login — autorisation humaine requise
+
+Ce scénario est proposé et **n'a pas été exécuté** :
+
+1. Autoriser l'intégration et une fermeture normale de la session, avec TTY de
+ secours disponible ; conserver les journaux et checkpoints existants.
+2. S'authentifier normalement via greetd. Sans PAM ajouté, un default verrouillé
+ doit afficher Déverrouiller et Nouvelle session, sans restauration possible.
+3. Déverrouiller dans le seul dialogue GNOME. N'enregistrer aucun secret et
+ n'activer aucune option créant/modifiant des trousseaux. Vérifier READY et
+ l'absence de lancement automatique ; cliquer ensuite explicitement Restaurer.
+4. Après retrait du chooser, vérifier le rapport final : succès seulement si
+ arbre **et** focus sont validés, slots et nettoyage complets. Observer le
+ scratchpad sans corriger un focus inattendu pour rendre le test positif.
+5. Lors d'une autre connexion autorisée, tester annulation et Nouvelle session.
+ Une panne du service doit laisser ouvrir le bureau et refuser la restauration.
+6. Si un prompt tardif apparaît, conserver l'échec et RECOVERY_REQUIRED,
+ horodatages et identités observables. Ne jamais effacer le journal pour
+ relancer artificiellement. Une trace de focus seule ne prouve pas l'auteur.
diff --git a/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml b/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml
index 759535523..e145ec3f5 100644
--- a/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml
+++ b/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml
@@ -28,8 +28,31 @@ PanelWindow {
property int windowCount: 0
property int workspaceCount: 0
readonly property bool busy: SessionV2Service.busy || watchingBackend
+ property bool unlockingRequested: false
+ readonly property bool keyringReady: SessionV2Service.keyringReady
+ function unlockRequested() {
+ if (!interactionArmed || busy || SessionV2Service.keyringBusy || unlockingRequested) return;
+ // CONTRACT: release exclusive keyboard before presenting the native
+ // prompt. Keep New session clickable throughout the unlock operation.
+ unlockingRequested = true;
+ unlockRelease.start();
+ }
+ Timer {
+ id: unlockRelease
+ interval: 50; repeat: true
+ onTriggered: {
+ if (popup.focusable) return;
+ stop(); popup.unlockingRequested = false;
+ SessionV2Service.checkKeyring(true);
+ }
+ }
+ Timer {
+ interval: 2000; repeat: true
+ running: popup.visible && SessionV2Service.keyringState === "KEYRING_UNLOCKING" && !popup.releasing && !popup.unlockingRequested
+ onTriggered: SessionV2Service.checkKeyring(false)
+ }
function restoreRequested() {
- if (!interactionArmed || busy || releasing || (attempted && !retryAllowed)) return;
+ if (!keyringReady || !interactionArmed || busy || releasing || (attempted && !retryAllowed)) return;
if (!SessionV2Service.prepareRestore(popup)) return;
attempted = true;
failed = false;
@@ -44,7 +67,11 @@ PanelWindow {
releasePoll.start();
}
function newSessionRequested() {
- if (interactionArmed && !busy) SessionV2Service.run("ack-new", "", "", popup);
+ if (interactionArmed && !busy) {
+ unlockRelease.stop(); unlockingRequested = false;
+ SessionV2Service.cancelKeyring();
+ SessionV2Service.run("ack-new", "", "", popup);
+ }
}
function releaseFailed() {
releasePoll.stop();
@@ -59,6 +86,12 @@ PanelWindow {
}
function fail(value) {
inProgress = false;
+ if (value && /^KEYRING_/.test(value.reason)) {
+ // Admission failed before Executor: preserve the explicit retry
+ // path without fabricating a restore attempt or clearing Recovery.
+ attempted = false; failed = false; restored = false;
+ visible = true; SessionV2Service.checkKeyring(false); return;
+ }
failed = true;
restored = false;
retryAllowed = !!value && value.status === "DEFERRED";
@@ -138,6 +171,7 @@ PanelWindow {
SessionV2Service.run("restore-status", "", "", popup);
}
if (popup.visible) {
+ SessionV2Service.checkKeyring(false);
popup.windowCount = value.windows || 0;
popup.workspaceCount = value.workspaces || 0;
}
@@ -167,7 +201,7 @@ PanelWindow {
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
aboveWindows: true
- focusable: visible && !awaitingUserFocus && !releasing && !inProgress && !restored
+ focusable: visible && !unlockingRequested && !SessionV2Service.keyringBusy && SessionV2Service.keyringState !== "KEYRING_UNLOCKING" && !awaitingUserFocus && !releasing && !inProgress && !restored
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "labfy-session-startup-v2"
visible: false
@@ -224,7 +258,11 @@ PanelWindow {
text: popup.failed ? popup.failureMessage
: popup.restored ? "Session restaurée"
: popup.busy && popup.attempted ? "Restauration en cours…"
- : popup.windowCount + " fenêtres • " + popup.workspaceCount + " espaces"
+ : popup.keyringReady ? "Une session précédente est disponible."
+ : SessionV2Service.keyringState === "KEYRING_CANCELLED" ? "Déverrouillage annulé. Réessayez ou choisissez Nouvelle session."
+ : SessionV2Service.keyringState === "KEYRING_UNAVAILABLE" ? "Trousseau indisponible. Réessayez ou choisissez Nouvelle session. (" + SessionV2Service.keyringReason + ")"
+ : SessionV2Service.keyringState === "KEYRING_UNLOCKING" ? "Déverrouillage du trousseau en cours…"
+ : "Le trousseau doit être déverrouillé avant la restauration de votre session."
color: Theme.foreground
wrapMode: Text.Wrap
}
@@ -250,9 +288,15 @@ PanelWindow {
onClicked: popup.newSessionRequested()
}
ActionButton {
- visible: !popup.failed || popup.retryAllowed
+ visible: !popup.keyringReady
+ label: "Déverrouiller le trousseau"
+ enabled: popup.interactionArmed && !popup.busy && !SessionV2Service.keyringBusy && !popup.unlockingRequested
+ onClicked: popup.unlockRequested()
+ }
+ ActionButton {
+ visible: popup.keyringReady && (!popup.failed || popup.retryAllowed)
label: popup.retryAllowed ? "Réessayer" : "Restaurer"
- enabled: popup.interactionArmed && !popup.busy && (!popup.attempted || popup.retryAllowed)
+ enabled: popup.keyringReady && popup.interactionArmed && !popup.busy && (!popup.attempted || popup.retryAllowed)
onClicked: popup.restoreRequested()
}
}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManagerV2.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManagerV2.qml
index 741841b4a..b0a12c045 100644
--- a/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManagerV2.qml
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManagerV2.qml
@@ -23,6 +23,12 @@ Item {
if (owner !== page) return;
if (kind === "list") { if (okay) page.sessions = value.sessions; return; }
if (kind === "apply") {
+ if (value && /^KEYRING_/.test(value.reason)) {
+ // Admission precedes Attempt: never show an older report
+ // as the explanation for a refused keyring precondition.
+ page.feedback = "Déverrouillez le trousseau, puis cliquez à nouveau Restaurer. (" + value.reason + ")";
+ return;
+ }
if (okay && Protocol.restoreSucceeded(value)) page.feedback = "Session restaurée";
else {
page.restoreFailed = true;
diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py b/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py
index b5b41ea24..b5febc28c 100644
--- a/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py
+++ b/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py
@@ -56,7 +56,7 @@ def migration(path):
def main(argv=None):
parser = argparse.ArgumentParser(description="Backend de restauration de session V2")
- parser.add_argument("command", choices=("capture", "save", "list", "show", "delete", "plan", "apply", "checkpoint-last", "startup-status", "acknowledge-startup", "apply-last", "restore-status", "recovery-status", "analyze-v1-migration"))
+ parser.add_argument("command", choices=("keyring-status", "keyring-unlock", "capture", "save", "list", "show", "delete", "plan", "apply", "checkpoint-last", "startup-status", "acknowledge-startup", "apply-last", "restore-status", "recovery-status", "analyze-v1-migration"))
parser.add_argument("name", nargs="?")
parser.add_argument("--execute", action="store_true")
parser.add_argument("--stdout", action="store_true")
@@ -67,13 +67,18 @@ def main(argv=None):
parser.add_argument("--anchor-binary", default=str(Path(__file__).parent / "libexec/labfy-v2-anchor"))
args = parser.parse_args(argv)
try:
- if args.command in ("capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"):
+ if args.command in ("keyring-unlock", "capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"):
require(args.execute, "EXECUTE_REQUIRED")
if args.command == "recovery-status":
from .recovery import recovery_status
print(json.dumps(recovery_status(readonly_runtime()), ensure_ascii=False))
return 0
state, runtime = paths()
+ if args.command in ("keyring-status", "keyring-unlock"):
+ from .keyring import command as keyring_command
+ value = keyring_command(runtime, args.command == "keyring-unlock")
+ print(json.dumps(value))
+ return 0
store = Store(state)
command = args.command
if command == "list": result = {"sessions": store.list()}
@@ -105,7 +110,13 @@ def main(argv=None):
result = data if command == "capture" else {"status": "saved"}
else:
data = store.last() if command == "apply-last" else store.load(args.name)
- result = Executor(sway, catalog, provider, runtime, args.anchor_binary).apply(data, execute=True)
+ from .keyring import admission, require_ready
+ # WHY: QML readiness is advisory and can be stale after restart.
+ # INVARIANT: gate failure creates no restore/recovery attempt;
+ # the unchanged Executor still proves every postcondition.
+ with admission(runtime):
+ require_ready()
+ result = Executor(sway, catalog, provider, runtime, args.anchor_binary).apply(data, execute=True)
print(json.dumps(result, ensure_ascii=False))
return 0
except Failure as exc:
diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/keyring.py b/quickshell/.config/quickshell/labfy-sway/session_v2/keyring.py
new file mode 100644
index 000000000..5f0991257
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/session_v2/keyring.py
@@ -0,0 +1,198 @@
+"""Secret Service readiness, without ever opening a secret session or reading items.
+
+WHY: daemon existence and a vanished prompt do not prove collection readiness.
+CONTRACT: default must be the audited collection; an existing login alias must
+agree. Missing login is supported (manual unlock), never proof of PAM success.
+INVARIANT: no cached readiness, passwords, item metadata or free-form D-Bus errors
+are persisted/logged. A private connection owns and dismisses only its prompt.
+"""
+from contextlib import contextmanager
+import math
+import signal
+import time
+from .errors import Failure, require
+from .storage import lock, private_dir
+
+BUS = 'org.freedesktop.secrets'
+ROOT = '/org/freedesktop/secrets'
+SERVICE = 'org.freedesktop.Secret.Service'
+COLLECTION = 'org.freedesktop.Secret.Collection'
+PROMPT = 'org.freedesktop.Secret.Prompt'
+EXPECTED = ROOT + '/collection/Default_5fkeyring'
+
+
+def result(state, reason):
+ return {'state': 'KEYRING_' + state, 'reason': reason}
+
+
+class Secrets:
+ def __init__(self, timeout=8):
+ # Lazy import keeps existing backend diagnostics usable without PyGObject.
+ from gi.repository import Gio, GLib, GLibUnix
+ self.Gio, self.GLib, self.GLibUnix = Gio, GLib, GLibUnix
+ self.deadline = time.monotonic() + timeout
+ address = Gio.dbus_address_get_for_bus_sync(Gio.BusType.SESSION, None)
+ # Bound connection/authentication too, not only subsequent RPC calls.
+ loop, cancellation, opened = GLib.MainLoop(), Gio.Cancellable(), []
+ expired = []
+ def connected(_source, response, _data):
+ try:
+ bus = Gio.DBusConnection.new_for_address_finish(response)
+ if expired: bus.close_sync(None)
+ else: opened.append(bus)
+ except GLib.Error: pass
+ loop.quit()
+ def expire():
+ expired.append(True)
+ cancellation.cancel()
+ loop.quit()
+ return False
+ timer = GLib.timeout_add(2000, expire)
+ try:
+ Gio.DBusConnection.new_for_address(address,
+ Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT |
+ Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None,
+ cancellation, connected, None)
+ loop.run()
+ finally:
+ if GLib.MainContext.default().find_source_by_id(timer): GLib.source_remove(timer)
+ require(not expired, 'KEYRING_TIMEOUT')
+ require(opened, 'KEYRING_BUS_UNAVAILABLE')
+ self.bus = opened[0]
+ self.owner = None
+ try:
+ # Calls after activation are pinned to a unique owner. A replacement
+ # service must never inherit readiness from this connection.
+ try: self.owner = self.get_owner()
+ except GLib.Error:
+ self.call('org.freedesktop.DBus', '/org/freedesktop/DBus',
+ 'org.freedesktop.DBus', 'StartServiceByName', '(su)', (BUS, 0), '(u)')
+ self.owner = self.get_owner()
+ except BaseException:
+ self.close()
+ raise
+
+ def call(self, destination, path, interface, method, signature, args, reply):
+ remaining = self.deadline - time.monotonic()
+ require(remaining > 0, 'KEYRING_TIMEOUT')
+ return self.bus.call_sync(destination, path, interface, method,
+ self.GLib.Variant(signature, args) if signature else None,
+ self.GLib.VariantType.new(reply), self.Gio.DBusCallFlags.NO_AUTO_START,
+ max(1, min(2000, math.ceil(remaining * 1000))), None).unpack()
+
+ def get_owner(self):
+ return self.call('org.freedesktop.DBus', '/org/freedesktop/DBus',
+ 'org.freedesktop.DBus', 'GetNameOwner', '(s)', (BUS,), '(s)')[0]
+
+ def alias(self, name):
+ return self.call(self.owner, ROOT, SERVICE, 'ReadAlias', '(s)', (name,), '(o)')[0]
+
+ def inspect(self):
+ require(self.get_owner() == self.owner, 'KEYRING_OWNER_CHANGED')
+ collection = self.alias('default')
+ require(collection != '/', 'KEYRING_DEFAULT_MISSING')
+ require(collection == EXPECTED, 'KEYRING_COLLECTION_MISMATCH')
+ login = self.alias('login')
+ require(login in ('/', collection), 'KEYRING_LOGIN_MISMATCH')
+ value = self.call(self.owner, collection, 'org.freedesktop.DBus.Properties',
+ 'Get', '(ss)', (COLLECTION, 'Locked'), '(v)')[0]
+ require(type(value) is bool, 'KEYRING_INVALID_LOCKED')
+ require(self.get_owner() == self.owner, 'KEYRING_OWNER_CHANGED')
+ return value
+
+ def unlock(self, timeout=90):
+ self.deadline = time.monotonic() + timeout
+ unlocked, prompt = self.call(self.owner, ROOT, SERVICE, 'Unlock', '(ao)', ([EXPECTED],), '(aoo)')
+ if prompt == '/': return
+ loop = self.GLib.MainLoop()
+ completed = []
+ def receive(_bus, _sender, _path, _iface, _signal, parameters):
+ # Do not retain/inspect the operation-specific variant payload.
+ completed.append(parameters.get_child_value(0).get_boolean())
+ loop.quit()
+ subscription = self.bus.signal_subscribe(self.owner, PROMPT, 'Completed', prompt,
+ None, self.Gio.DBusSignalFlags.NONE, receive)
+ timer = self.GLib.timeout_add(max(1, math.ceil((self.deadline - time.monotonic()) * 1000)),
+ lambda: (loop.quit(), False)[1])
+ def cancel():
+ completed.append(True)
+ loop.quit()
+ return False
+ # GLib owns the prompt wait: Python signal handlers alone are not
+ # dispatched while its native loop is idle. Termination must wake it.
+ termination = self.GLibUnix.signal_add(self.GLib.PRIORITY_DEFAULT, signal.SIGTERM, cancel)
+ try:
+ # Empty window-id is the portable unparented prompt. QuickShell
+ # releases its exclusive keyboard surface before this call.
+ self.call(self.owner, prompt, PROMPT, 'Prompt', '(s)', ('',), '()')
+ if not completed: loop.run()
+ require(completed, 'KEYRING_TIMEOUT')
+ require(completed[0] is False, 'KEYRING_CANCELLED')
+ finally:
+ for source in (timer, termination):
+ if self.GLib.MainContext.default().find_source_by_id(source):
+ self.GLib.source_remove(source)
+ self.bus.signal_unsubscribe(subscription)
+ # Best effort cleanup also runs on timeout/termination. Closing the
+ # private connection invalidates outstanding client-owned prompts.
+ try:
+ self.deadline = time.monotonic() + 1
+ self.call(self.owner, prompt, PROMPT, 'Dismiss', None, (), '()')
+ except (Failure, self.GLib.Error): pass
+
+ def close(self):
+ self.bus.close_sync(None)
+
+
+def check(unlock=False, factory=Secrets, timeout=90):
+ connection = None
+ try:
+ connection = factory()
+ if not connection.inspect(): return result('READY', 'KEYRING_UNLOCKED')
+ if not unlock: return result('LOCKED', 'KEYRING_LOCKED')
+ connection.unlock(timeout)
+ connection.deadline = time.monotonic() + 8
+ if connection.inspect(): return result('LOCKED', 'KEYRING_STILL_LOCKED')
+ return result('READY', 'KEYRING_UNLOCKED')
+ except Failure as exc:
+ return result('CANCELLED' if exc.code == 'KEYRING_CANCELLED' else 'UNAVAILABLE', exc.code)
+ except ImportError:
+ return result('UNAVAILABLE', 'KEYRING_DEPENDENCY_MISSING')
+ except Exception:
+ # Never expose remote error text; it may contain user-supplied material.
+ return result('UNAVAILABLE', 'KEYRING_DBUS_ERROR')
+ finally:
+ if connection is not None:
+ try: connection.close()
+ except Exception: pass
+
+
+@contextmanager
+def admission(runtime):
+ """Serialize unlock and CLI restoration across processes/QuickShell restarts.
+
+ CONTRACT: acquire before the existing transaction lock, hold through apply.
+ No boolean, token or file survives as authorization for a future apply.
+ New-session acknowledgement uses only the existing transaction lock.
+ """
+ with lock(private_dir(runtime / 'session-v2'), 'keyring.lock'):
+ yield
+
+
+def command(runtime, unlock=False):
+ try:
+ with admission(runtime):
+ # An executor/guard predating this gate can still own the old lock.
+ # Check it before any Unlock; don't hold it during user interaction.
+ with lock(private_dir(runtime / 'session-v2')): pass
+ return check(unlock)
+ except KeyboardInterrupt:
+ return result('CANCELLED', 'KEYRING_CANCELLED')
+ except Failure as exc:
+ if exc.code == 'TRANSACTION_BUSY': return result('UNLOCKING', 'KEYRING_OR_RESTORE_BUSY')
+ raise
+
+
+def require_ready():
+ value = check()
+ require(value['state'] == 'KEYRING_READY', value['reason'])
diff --git a/quickshell/.config/quickshell/labfy-sway/sessionui/SessionV2Service.qml b/quickshell/.config/quickshell/labfy-sway/sessionui/SessionV2Service.qml
index da4aa209d..f13d13d6b 100644
--- a/quickshell/.config/quickshell/labfy-sway/sessionui/SessionV2Service.qml
+++ b/quickshell/.config/quickshell/labfy-sway/sessionui/SessionV2Service.qml
@@ -11,6 +11,37 @@ QtObject {
property var client: null
property bool startupChooserVisible: false
readonly property bool busy: operation !== "" || backendProcess.running
+ // WHY: state is reconstructed from Secret Service, never persisted in QML.
+ // CONTRACT: readiness only enables a click; CLI rechecks under admission.
+ property string keyringState: "KEYRING_CHECKING"
+ property string keyringReason: ""
+ readonly property bool keyringBusy: keyringProcess.running
+ readonly property bool keyringReady: keyringState === "KEYRING_READY" && !keyringBusy
+ function checkKeyring(unlock) {
+ if (keyringBusy || busy) return false;
+ keyringState = unlock ? "KEYRING_UNLOCKING" : "KEYRING_CHECKING";
+ const argv = ["python3", "-B", backend, unlock ? "keyring-unlock" : "keyring-status"];
+ if (unlock) argv.push("--execute");
+ keyringProcess.command = argv;
+ keyringProcess.running = true;
+ return true;
+ }
+ Component.onCompleted: checkKeyring(false)
+ function cancelKeyring() { keyringProcess.running = false; keyringState = "KEYRING_CHECKING"; }
+ property Process keyringProcess: Process {
+ running: false
+ stdout: StdioCollector { id: keyringOutput; waitForEnd: true }
+ // No remote errors or arbitrary stderr are displayed or journaled.
+ stderr: StdioCollector { waitForEnd: true }
+ onExited: (exitCode, exitStatus) => {
+ let value = null;
+ try { value = JSON.parse(keyringOutput.text); } catch (_) {}
+ const states = ["KEYRING_READY", "KEYRING_LOCKED", "KEYRING_CANCELLED", "KEYRING_UNAVAILABLE", "KEYRING_UNLOCKING"];
+ service.keyringState = exitCode === 0 && exitStatus === 0 && value && states.indexOf(value.state) >= 0
+ ? value.state : "KEYRING_UNAVAILABLE";
+ service.keyringReason = value && /^KEYRING_[A-Z_]{1,48}$/.test(value.reason) ? value.reason : "KEYRING_BACKEND_ERROR";
+ }
+ }
signal completed(var owner, string kind, var value, bool okay)
// Diagnostic lecture seule ; aucun endpoint IPC ne peut cliquer Restore.
@@ -18,14 +49,14 @@ QtObject {
target: "sessionV2"
function status(): string {
return JSON.stringify({backend: "v2", busy: service.busy,
- operation: service.operation, startupChooserCount: service.startupChooserVisible ? 1 : 0});
+ operation: service.operation, keyringState: service.keyringState, keyringReason: service.keyringReason, startupChooserCount: service.startupChooserVisible ? 1 : 0});
}
}
// Réserver la sérialisation avant de retirer la surface. Le Timer du
// dialogue n'est pas propriétaire d'une seconde commande backend.
function prepareRestore(owner) {
- if (busy || !owner) return false;
+ if (busy || !owner || !keyringReady) return false;
client = owner;
operation = "release-focus";
return true;
@@ -46,6 +77,7 @@ QtObject {
// INVARIANT : un unique Process sérialise les commandes V2 de QuickShell.
function run(kind, name, extra, owner) {
if (busy) return false;
+ if (kind === "apply-last" && !keyringReady) return false;
const allowed = ["startup-status", "ack-new", "ack-restored", "apply-last", "apply", "list", "show", "save", "delete", "plan", "restore-status", "checkpoint-last"];
if (allowed.indexOf(kind) < 0) return false;
const argv = ["python3", "-B", backend];
diff --git a/quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py b/quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py
index db3e2e94c..09ea3c3e5 100644
--- a/quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py
+++ b/quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py
@@ -60,6 +60,14 @@ QtObject {
property QtObject stdout
property QtObject stderr
signal exited(int exitCode, int exitStatus)
+ // This focus fixture models only an already unlocked service. Private-bus
+ // and headless keyring tests exercise the real transport/prompt lifecycle.
+ onRunningChanged: {
+ if (running && command.indexOf("keyring-status") >= 0) Qt.callLater(() => {
+ stdout.text = JSON.stringify({state:"KEYRING_READY", reason:"KEYRING_UNLOCKED"});
+ running = false; exited(0, 0);
+ });
+ }
}''')
(self.dir / 'sessionui/StdioCollector.qml').write_text('import QtQuick\nQtObject { property bool waitForEnd; property string text: "" }')
(self.dir / 'sessionui/IpcHandler.qml').write_text('import QtQuick\nQtObject { property string target }')
@@ -85,6 +93,8 @@ Item {
self.assertIsNotNone(self.root, '\n'.join(e.toString() for e in self.component.errors()))
self.prompt = self.root.findChild(QObject, 'prompt')
self.service = self.root.property('service')
+ QTest.qWait(5) # finish the asynchronous fixture's initial D-Bus check
+ self.service.setProperty('keyringState', 'KEYRING_READY')
def tearDown(self):
self.root.deleteLater(); self.engine.deleteLater(); QTest.qWait(1); self.tmp.cleanup()
def call(self, name): QMetaObject.invokeMethod(self.prompt, name, Qt.DirectConnection)
@@ -240,4 +250,24 @@ Item {
self.finish('startup-status', dict(eligible=True, claimed=True, interrupted=True))
self.assertEqual(self.calls()[-1]['kind'], 'restore-status')
+ def test_keyring_gate_requires_ready_and_explicit_click(self):
+ self.service.setProperty('keyringState', 'KEYRING_LOCKED')
+ self.open(); self.call('restoreRequested'); QTest.qWait(100)
+ self.assertFalse(self.prompt.property('attempted'))
+ self.assertEqual(self.calls(), [])
+ self.service.setProperty('keyringState', 'KEYRING_READY')
+ QTest.qWait(150)
+ self.assertEqual(self.calls(), [])
+ self.call('restoreRequested'); QTest.qWait(100)
+ self.assertEqual([x['kind'] for x in self.calls()], ['apply-last'])
+
+ def test_new_session_during_keyring_unlock_remains_accessible(self):
+ self.service.setProperty('keyringState', 'KEYRING_LOCKED')
+ self.open(); self.call('unlockRequested'); QTest.qWait(100)
+ self.assertTrue(self.service.property('keyringBusy'))
+ self.assertFalse(self.prompt.property('focusable'))
+ self.call('newSessionRequested')
+ self.assertEqual([x['kind'] for x in self.calls()], ['ack-new'])
+ self.assertFalse(self.service.property('keyringBusy'))
+
if __name__ == '__main__': unittest.main()
diff --git a/tests/session_v2/fixtures/secret_service.py b/tests/session_v2/fixtures/secret_service.py
new file mode 100644
index 000000000..e76f38879
--- /dev/null
+++ b/tests/session_v2/fixtures/secret_service.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+"""TEST_ONLY Secret Service. No secrets/items/passwords exist in this fixture."""
+import sys
+from gi.repository import Gio, GLib
+
+mode = sys.argv[1]
+root = '/org/freedesktop/secrets'
+collection = root + '/collection/Default_5fkeyring'
+prompt = root + '/prompt/test'
+locked = mode != 'ready'
+xml = '''
+
+
+
+
+
+
+
+
+'''
+bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
+bus.call_sync('org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus',
+ 'RequestName', GLib.Variant('(su)', ('org.freedesktop.secrets', 0)),
+ GLib.VariantType.new('(u)'), Gio.DBusCallFlags.NONE, 2000, None)
+
+def method(bus, sender, path, interface, name, args, invocation):
+ global locked
+ if name == 'ReadAlias':
+ if mode == 'error':
+ invocation.return_dbus_error('org.freedesktop.Secret.Error.Failed', 'TEST_ONLY_SENSITIVE_SENTINEL')
+ return
+ if mode == 'dbus-timeout': return # deliberately leave request unanswered
+ alias = args.unpack()[0]
+ value = collection if alias == 'default' else '/'
+ if mode == 'missing': value = '/'
+ if mode == 'different' and alias == 'default': value = root + '/collection/other'
+ if mode == 'login-different' and alias == 'login': value = root + '/collection/login'
+ invocation.return_value(GLib.Variant('(o)', (value,)))
+ elif name == 'Unlock':
+ print('UNLOCK', flush=True)
+ if mode == 'immediate':
+ locked = False
+ invocation.return_value(GLib.Variant('(aoo)', ([collection], '/')))
+ else: invocation.return_value(GLib.Variant('(aoo)', ([], prompt)))
+ elif name == 'Prompt':
+ print('PROMPT', flush=True)
+ invocation.return_value(GLib.Variant('()', ()))
+ def complete():
+ global locked
+ if mode not in ('still-locked', 'wrong-password', 'cancel'): locked = False
+ bus.emit_signal(sender, prompt, interface, 'Completed',
+ GLib.Variant('(bv)', (mode == 'cancel', GLib.Variant('ao', [collection]))))
+ return False
+ if mode not in ('timeout', 'wrong-password'):
+ GLib.timeout_add(400 if mode == 'slow' else 10, complete)
+ elif name == 'Dismiss':
+ print('DISMISS', flush=True)
+ invocation.return_value(GLib.Variant('()', ()))
+
+def prop(*_): return GLib.Variant('b', locked)
+
+node = Gio.DBusNodeInfo.new_for_xml(xml)
+for path, iface in ((root, 0), (collection, 1), (prompt, 2)):
+ bus.register_object(path, node.interfaces[iface], method, prop, None)
+print('READY', flush=True)
+GLib.MainLoop().run()
diff --git a/tests/session_v2/keyring_runtime.py b/tests/session_v2/keyring_runtime.py
new file mode 100644
index 000000000..0a2673fd9
--- /dev/null
+++ b/tests/session_v2/keyring_runtime.py
@@ -0,0 +1,58 @@
+"""Late synthetic keyring-like surface: real focus proof must still fail.
+
+CONTRACT: the surface is TEST_ONLY and does not establish the historical
+identity of con_id 18. No event infers user-vs-IPC causality from app_id.
+"""
+import json
+from unittest.mock import patch
+from common import *
+from lab import Lab, Surfaces, build_helper
+from session_v2.executor import Executor
+from session_v2.recovery import Recovery, recovery_status
+from session_v2.ipc import Sway
+from session_v2.errors import Failure
+from session_v2.storage import private_dir, read
+
+
+def main():
+ for late in (False, True):
+ with Lab() as lab:
+ print(lab.directory, flush=True)
+ helper = build_helper(lab.directory)
+ apps = Surfaces(lab, helper)
+ lab.command('workspace TEST_ONLY_TARGET')
+ apps.create('TEST_ONLY_app-A')
+ data = fixture('A')
+ sway = Sway(lab.socket)
+ runtime = private_dir(lab.directory / 'runtime')
+ binary = build(lab.directory)
+ original = Recovery.boundary
+ observed = []
+ def boundary(recovery, phase):
+ original(recovery, phase)
+ if phase == 'after-guard-finish' and late:
+ # Same placement in the lifecycle as the historical race.
+ # A real compositor maps/focuses this independent surface.
+ observed.append(apps.create('TEST_ONLY_keyring_prompt'))
+ engine = Executor(sway, catalog(data), None, runtime, binary, lab.env)
+ with patch.object(Recovery, 'boundary', boundary):
+ if late:
+ try: engine.apply(data, True)
+ except Failure as exc: assert exc.code == 'RECOVERY_FOCUS_MISMATCH', exc.code
+ else: raise AssertionError('LATE_FOCUS_WAS_ACCEPTED')
+ else:
+ value = engine.apply(data, True)
+ assert value['status'] == 'success' and value['final_focus_verified']
+ report = read(runtime / 'session-v2-restore-attempt.json')
+ if late:
+ assert report['status'] == 'failed' and report['reason'] == 'RECOVERY_FOCUS_MISMATCH', report
+ assert not report['final_focus_verified']
+ assert recovery_status(runtime)['status'] == 'RECOVERY_REQUIRED'
+ # Attribution remains an observation; no focus correction to
+ # manufacture success after an unexpected late activation.
+ assert observed[0]['app_id'] == 'TEST_ONLY_keyring_prompt'
+ print('late-refused' if late else 'stable-final-focus', 'PASS', flush=True)
+ apps.close()
+
+
+if __name__ == '__main__': main()
diff --git a/tests/session_v2/keyring_ui.py b/tests/session_v2/keyring_ui.py
new file mode 100644
index 000000000..f3737ac6f
--- /dev/null
+++ b/tests/session_v2/keyring_ui.py
@@ -0,0 +1,126 @@
+"""Actual chooser/Secret Service client on private Sway and private D-Bus only."""
+import json
+import shutil
+import subprocess
+from common import *
+from lab import Lab
+from test_keyring import KeyringTests
+
+SOURCE = BACKEND
+HARNESS = '''//@ pragma UseQApplication
+import QtQuick
+import Quickshell
+import "sessionui"
+ShellRoot {
+ PanelWindow { id: bar; visible: false }
+ SessionStartupPromptV2 { id: prompt; barWindow: bar; startupHost: true }
+ Timer { interval: 1250; running: true; onTriggered: {
+ if (!prompt.interactionArmed) { console.error("KEYRING_UI_FAIL_NOT_ARMED"); Qt.quit(); return; }
+ if (Quickshell.env("CASE") !== "ready") {
+ prompt.restoreRequested();
+ if (prompt.attempted) { console.error("KEYRING_UI_FAIL_BYPASS"); Qt.quit(); return; }
+ if (Quickshell.env("CASE") !== "restart-check") prompt.unlockRequested();
+ }
+ } }
+ Timer { interval: 2300; running: true; onTriggered: {
+ const mode = Quickshell.env("CASE");
+ if (prompt.attempted || prompt.inProgress) { console.error("KEYRING_UI_FAIL_AUTORESTORE"); Qt.quit(); return; }
+ if (mode === "ready" || mode === "accept") {
+ if (!prompt.keyringReady) { console.error("KEYRING_UI_FAIL_NOT_READY"); Qt.quit(); return; }
+ prompt.restoreRequested();
+ } else {
+ if (prompt.keyringReady) { console.error("KEYRING_UI_FAIL_FALSE_READY"); Qt.quit(); return; }
+ prompt.newSessionRequested();
+ }
+ } }
+ Timer { interval: 3700; running: true; onTriggered: {
+ const mode = Quickshell.env("CASE");
+ const okay = mode === "ready" || mode === "accept" ? prompt.restored && !prompt.visible : !prompt.visible && !prompt.attempted;
+ console.log(okay ? "KEYRING_UI_PASS" : "KEYRING_UI_FAIL_FINAL"); Qt.quit();
+ } }
+}
+'''
+# The chooser and gate are production. Only restoration/checkpoint discovery are
+# synthetic here; actual Executor/focus behavior is tested in keyring_runtime.py.
+WRAPPER = '''import sys, json, os
+from pathlib import Path
+sys.path.insert(0, SOURCE)
+from session_v2.cli import main
+cmd=sys.argv[1]
+with Path(os.environ['CALLS']).open('a') as log: log.write(cmd+'\\n')
+if cmd.startswith('keyring-'): sys.exit(main())
+if cmd == 'startup-status': value={'eligible':True,'claimed':True,'windows':4,'workspaces':2}
+elif cmd == 'apply-last': value={'schema':'labfy.sway.session-v2-restore-attempt','version':1,'status':'success','transaction_id':'a'*32,'identity_level':'exact','final_tree_verified':True,'final_focus_verified':True,'slots_expected':4,'slots_filled':4,'applications_expected':3,'applications_observed':3,'anchors_created':3,'anchors_cleaned':3,'helpers_suspended':0,'helpers_resumed':0}
+elif cmd == 'acknowledge-startup': value={'status':'acknowledged','choice':sys.argv[sys.argv.index('--choice')+1]}
+else: raise RuntimeError('TEST_ONLY_UNEXPECTED')
+print(json.dumps(value))
+'''
+
+
+def main():
+ with Lab() as lab:
+ print(lab.directory, flush=True)
+ for mode in ('ready', 'accept', 'cancel', 'timeout', 'absent', 'still-locked', 'different'):
+ with KeyringTests().service(mode) as (busenv, _):
+ base = lab.directory / mode / 'config/quickshell/labfy-sway'
+ base.mkdir(parents=True)
+ for item in ('sessionui', 'controlcenter', 'theme'):
+ shutil.copytree(SOURCE / item, base / item)
+ shutil.copy(SOURCE / 'SessionStartupPromptV2.qml', base)
+ (base / 'shell.qml').write_text(HARNESS)
+ (base / 'session-v2.py').write_text(WRAPPER.replace('SOURCE', repr(str(SOURCE))))
+ calls = lab.directory / (mode + '.calls')
+ env = {**lab.env, 'DBUS_SESSION_BUS_ADDRESS': busenv['DBUS_SESSION_BUS_ADDRESS'],
+ 'XDG_CONFIG_HOME': str(base.parents[1]), 'XDG_RUNTIME_DIR': busenv['XDG_RUNTIME_DIR'],
+ 'WAYLAND_DISPLAY': str(lab.directory / lab.env['WAYLAND_DISPLAY']),
+ 'CASE': mode, 'CALLS': str(calls)}
+ proc = subprocess.run(['qs', '-p', str(base), '--no-color'], env=env,
+ capture_output=True, text=True, timeout=12)
+ output = proc.stdout + proc.stderr
+ (lab.directory / (mode + '.qml.log')).write_text(output)
+ assert 'KEYRING_UI_PASS' in output and 'KEYRING_UI_FAIL' not in output, output
+ seen = calls.read_text().splitlines()
+ assert seen.count('apply-last') == (1 if mode in ('ready', 'accept') else 0), seen
+ assert seen.count('acknowledge-startup') == 1, seen
+ print(mode, 'PASS', flush=True)
+ # Real QuickShell process destruction, not just a reset QML boolean.
+ with KeyringTests().service('timeout') as (busenv, server):
+ base = lab.directory / 'restart/config/quickshell/labfy-sway'
+ base.mkdir(parents=True)
+ for item in ('sessionui', 'controlcenter', 'theme'):
+ shutil.copytree(SOURCE / item, base / item)
+ shutil.copy(SOURCE / 'SessionStartupPromptV2.qml', base)
+ (base / 'shell.qml').write_text(HARNESS)
+ (base / 'session-v2.py').write_text(WRAPPER.replace('SOURCE', repr(str(SOURCE))))
+ calls = lab.directory / 'restart.calls'
+ env = {**lab.env, 'DBUS_SESSION_BUS_ADDRESS': busenv['DBUS_SESSION_BUS_ADDRESS'],
+ 'XDG_CONFIG_HOME': str(base.parents[1]), 'XDG_RUNTIME_DIR': busenv['XDG_RUNTIME_DIR'],
+ 'WAYLAND_DISPLAY': str(lab.directory / lab.env['WAYLAND_DISPLAY']),
+ 'CASE': 'timeout', 'CALLS': str(calls)}
+ first = subprocess.Popen(['qs', '-p', str(base), '--no-color'], env=env,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+ try:
+ import select
+ # Read raw pipe so TextIO buffering cannot hide the second line.
+ observed = b''
+ deadline = time.monotonic() + 8
+ while b'PROMPT\n' not in observed and time.monotonic() < deadline:
+ if select.select([server.stdout], [], [], .2)[0]:
+ observed += os.read(server.stdout.fileno(), 4096)
+ assert b'UNLOCK\n' in observed and b'PROMPT\n' in observed, observed
+ finally:
+ first.terminate()
+ first.communicate(timeout=5)
+ # A replacement shell reconstructs locked/busy from backend/D-Bus;
+ # it must not reissue Unlock or Restore just because it restarted.
+ env['CASE'] = 'restart-check'
+ restarted = subprocess.run(['qs', '-p', str(base), '--no-color'], env=env,
+ capture_output=True, text=True, timeout=12)
+ output = restarted.stdout + restarted.stderr
+ assert 'KEYRING_UI_PASS' in output and 'KEYRING_UI_FAIL' not in output, output
+ seen = calls.read_text().splitlines()
+ assert seen.count('keyring-unlock') == 1 and 'apply-last' not in seen, seen
+ print('quickshell-restart-during-unlock PASS', flush=True)
+
+
+if __name__ == '__main__': main()
diff --git a/tests/session_v2/phase3b_ui.py b/tests/session_v2/phase3b_ui.py
index e315a6445..1d833b8c6 100644
--- a/tests/session_v2/phase3b_ui.py
+++ b/tests/session_v2/phase3b_ui.py
@@ -21,6 +21,7 @@ log = Path(os.environ['LABFY_UI_LOG'])
with log.open('a') as s: s.write(cmd + '\\n')
mode = os.environ['LABFY_UI_MODE']
if cmd == 'startup-status': r = {'eligible': mode != 'claimed', 'claimed': mode != 'claimed', 'windows': 3, 'workspaces': 2}
+elif cmd == 'keyring-status': r = {'state': 'KEYRING_READY', 'reason': 'KEYRING_UNLOCKED'}
elif cmd == 'apply-last':
r = {'schema':'labfy.sway.session-v2-restore-attempt', 'version':1, 'status':'success', 'transaction_id':'a'*32, 'identity_level':'exact', 'final_tree_verified':mode != 'invalid', 'final_focus_verified':True, 'slots_expected':3, 'slots_filled':3, 'applications_expected':2, 'applications_observed':2, 'anchors_created':3, 'anchors_cleaned':3, 'helpers_suspended':1, 'helpers_resumed':1}
if mode == 'failure': r = {'status':'failed', 'reason':'APPLICATION_WINDOWS_INCOMPLETE'}
@@ -86,7 +87,9 @@ ShellRoot {
def main():
rows = []
- with Lab() as lab:
+ from test_keyring import KeyringTests
+ with KeyringTests().service('ready') as (busenv, _), Lab() as lab:
+ lab.env['DBUS_SESSION_BUS_ADDRESS'] = busenv['DBUS_SESSION_BUS_ADDRESS']
for mode in ('success', 'failure', 'invalid', 'new', 'claimed', 'real', 'deferred-real'):
config = lab.directory / mode / 'config'
base = config / 'quickshell/labfy-sway'
diff --git a/tests/session_v2/test_keyring.py b/tests/session_v2/test_keyring.py
new file mode 100644
index 000000000..825b77b22
--- /dev/null
+++ b/tests/session_v2/test_keyring.py
@@ -0,0 +1,152 @@
+"""Real Gio client against a private mock bus; never accesses the desktop bus."""
+import contextlib
+import io
+import json
+import subprocess
+import tempfile
+import unittest
+from unittest.mock import patch
+from common import BACKEND, REPO, Path, os, sys
+from session_v2 import keyring, cli
+from session_v2.errors import Failure
+from session_v2.storage import private_dir, lock
+
+
+class KeyringTests(unittest.TestCase):
+ @contextlib.contextmanager
+ def service(self, mode):
+ with tempfile.TemporaryDirectory(prefix='labfy-keyring-TEST_ONLY-') as tmp:
+ env = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1'}
+ for name in ('HOME', 'XDG_RUNTIME_DIR', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME'):
+ path = Path(tmp) / name; path.mkdir(mode=0o700); env[name] = str(path)
+ # No standard service directories: an "absent" fixture must not
+ # accidentally activate the installed real GNOME Keyring daemon.
+ config = Path(tmp) / 'bus.conf'
+ config.write_text('sessionunix:tmpdir=' + tmp + ''
+ 'EXTERNAL'
+ '')
+ daemon = subprocess.Popen(['dbus-daemon', '--config-file=' + str(config), '--nofork', '--print-address=1'],
+ env=env, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
+ server = None
+ try:
+ address = daemon.stdout.readline().strip()
+ env['DBUS_SESSION_BUS_ADDRESS'] = address
+ if mode != 'absent':
+ server = subprocess.Popen([sys.executable, '-B', str(REPO / 'tests/session_v2/fixtures/secret_service.py'), mode],
+ env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ self.assertEqual(server.stdout.readline().strip(), 'READY')
+ with patch.dict(os.environ, env, clear=True):
+ yield env, server
+ finally:
+ for proc in (server, daemon):
+ if proc is not None:
+ proc.terminate()
+ try: proc.communicate(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill(); proc.communicate(timeout=5)
+ raise
+
+ def test_actual_dbus_states(self):
+ cases = [('ready', False, 'READY'), ('locked', False, 'LOCKED'),
+ ('accept', True, 'READY'), ('immediate', True, 'READY'),
+ ('cancel', True, 'CANCELLED'), ('still-locked', True, 'LOCKED'),
+ ('wrong-password', True, 'UNAVAILABLE'), ('timeout', True, 'UNAVAILABLE'),
+ ('absent', False, 'UNAVAILABLE'), ('missing', False, 'UNAVAILABLE'),
+ ('different', False, 'UNAVAILABLE'), ('login-different', False, 'UNAVAILABLE'),
+ ('dbus-timeout', False, 'UNAVAILABLE'), ('error', False, 'UNAVAILABLE')]
+ for mode, unlock, expected in cases:
+ with self.subTest(mode=mode), self.service(mode):
+ value = keyring.check(unlock, timeout=.15)
+ self.assertEqual(value['state'], 'KEYRING_' + expected, value)
+ self.assertEqual(set(value), {'state', 'reason'})
+ if mode == 'absent':
+ owner = subprocess.run(['busctl', '--address=' + os.environ['DBUS_SESSION_BUS_ADDRESS'],
+ 'call', 'org.freedesktop.DBus', '/org/freedesktop/DBus', 'org.freedesktop.DBus',
+ 'NameHasOwner', 's', keyring.BUS], capture_output=True, text=True, timeout=5)
+ self.assertEqual(owner.stdout.strip(), 'b false')
+
+ def test_two_processes_and_restart(self):
+ with self.service('timeout') as (env, server):
+ argv = [sys.executable, '-B', str(BACKEND / 'session-v2.py'), 'keyring-unlock', '--execute']
+ child = subprocess.Popen(argv, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ try:
+ self.assertEqual(server.stdout.readline().strip(), 'UNLOCK')
+ self.assertEqual(server.stdout.readline().strip(), 'PROMPT')
+ # A new QuickShell/CLI must discover busy from the kernel lock.
+ status = subprocess.run([*argv[:3], 'keyring-status'], env=env, capture_output=True, text=True, timeout=10)
+ self.assertEqual(json.loads(status.stdout)['state'], 'KEYRING_UNLOCKING')
+ duplicate = subprocess.run(argv, env=env, capture_output=True, text=True, timeout=10)
+ self.assertEqual(json.loads(duplicate.stdout)['state'], 'KEYRING_UNLOCKING')
+ runtime = Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway'
+ from session_v2.startup import acknowledge
+ self.assertEqual(acknowledge(runtime, 'TEST_ONLY', 'new')['choice'], 'new')
+ finally:
+ child.terminate()
+ try: child.communicate(timeout=5)
+ except subprocess.TimeoutExpired:
+ child.kill(); child.communicate(timeout=5)
+ raise
+ status = subprocess.run([*argv[:3], 'keyring-status'], env=env, capture_output=True, text=True, timeout=10)
+ self.assertEqual(json.loads(status.stdout)['state'], 'KEYRING_LOCKED')
+ # No readiness/cache/secret persists; only private lock and startup decision.
+ files = list((Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway').rglob('*'))
+ self.assertTrue(all(p.name in ('session-v2', 'keyring.lock', 'transaction.lock', 'startup.json') for p in files))
+
+ def test_apply_last_cannot_bypass_gate(self):
+ with self.service('locked'):
+ with patch.object(cli, 'Store') as store, patch.object(cli, 'Sway'), \
+ patch.object(cli, 'Catalog'), patch.object(cli, 'FirefoxProvider'), \
+ patch.object(cli, 'Executor') as executor, contextlib.redirect_stdout(io.StringIO()) as out:
+ store.return_value.last.return_value = {}
+ self.assertEqual(cli.main(['apply-last', '--execute']), 2)
+ self.assertEqual(json.loads(out.getvalue())['reason'], 'KEYRING_LOCKED')
+ executor.assert_not_called()
+
+ def test_ready_preserves_executor_result_and_recovery_required(self):
+ for code in (None, 'RECOVERY_REQUIRED', 'RECOVERY_FOCUS_MISMATCH'):
+ with self.subTest(code=code), self.service('ready'):
+ with patch.object(cli, 'Store'), patch.object(cli, 'Sway'), patch.object(cli, 'Catalog'), \
+ patch.object(cli, 'FirefoxProvider'), patch.object(cli, 'Executor') as executor, \
+ contextlib.redirect_stdout(io.StringIO()) as out:
+ if code: executor.return_value.apply.side_effect = Failure(code)
+ else: executor.return_value.apply.return_value = {'status': 'TEST_ONLY_EXECUTOR_RESULT'}
+ rc = cli.main(['apply-last', '--execute'])
+ self.assertEqual(rc, 2 if code else 0)
+ self.assertEqual(json.loads(out.getvalue()), {'status': 'failed', 'reason': code} if code else {'status': 'TEST_ONLY_EXECUTOR_RESULT'})
+ executor.return_value.apply.assert_called_once()
+
+ def test_unlock_never_restores(self):
+ with self.service('accept'):
+ with patch.object(cli, 'Executor') as executor, contextlib.redirect_stdout(io.StringIO()) as out:
+ self.assertEqual(cli.main(['keyring-unlock', '--execute']), 0)
+ self.assertEqual(json.loads(out.getvalue())['state'], 'KEYRING_READY')
+ executor.assert_not_called()
+
+ def test_remote_error_text_is_never_logged_or_persisted(self):
+ with self.service('error') as (env, _):
+ value = subprocess.run([sys.executable, '-B', str(BACKEND / 'session-v2.py'), 'keyring-status'],
+ env=env, capture_output=True, text=True, timeout=10)
+ self.assertNotIn('TEST_ONLY_SENSITIVE_SENTINEL', value.stdout + value.stderr)
+ self.assertEqual(json.loads(value.stdout)['state'], 'KEYRING_UNAVAILABLE')
+ for path in Path(env['XDG_RUNTIME_DIR']).rglob('*'):
+ if path.is_file(): self.assertNotIn(b'TEST_ONLY_SENSITIVE_SENTINEL', path.read_bytes())
+
+ def test_apply_is_excluded_during_unlock(self):
+ with self.service('ready') as (env, _):
+ runtime = private_dir(Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway')
+ with keyring.admission(runtime), patch.object(cli, 'Store'), patch.object(cli, 'Sway'), \
+ patch.object(cli, 'Catalog'), patch.object(cli, 'FirefoxProvider'), \
+ patch.object(cli, 'Executor') as executor, contextlib.redirect_stdout(io.StringIO()) as out:
+ self.assertEqual(cli.main(['apply-last', '--execute']), 2)
+ self.assertEqual(json.loads(out.getvalue())['reason'], 'TRANSACTION_BUSY')
+ executor.assert_not_called()
+
+ def test_transaction_prevents_prompt(self):
+ with self.service('locked') as (env, server):
+ runtime = private_dir(Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway')
+ with lock(private_dir(runtime / 'session-v2')):
+ self.assertEqual(keyring.command(runtime, True)['state'], 'KEYRING_UNLOCKING')
+ self.assertEqual(keyring.check()['state'], 'KEYRING_LOCKED')
+
+
+if __name__ == '__main__': unittest.main()
diff --git a/tests/session_v2/test_prewarm.py b/tests/session_v2/test_prewarm.py
index 171b5a668..df4e9ecf3 100644
--- a/tests/session_v2/test_prewarm.py
+++ b/tests/session_v2/test_prewarm.py
@@ -105,6 +105,7 @@ class PrewarmTests(unittest.TestCase):
self.assertEqual(seen['deferred_reason'], 'APPLICATION_LAUNCH_FAILED')
output = io.StringIO()
with patch('session_v2.cli.paths', return_value=(self.runtime/'state', self.runtime)), \
+ patch('session_v2.keyring.require_ready'), \
patch('session_v2.cli.Store'), patch('session_v2.cli.Sway'), patch('session_v2.cli.FirefoxProvider'), \
patch('session_v2.cli.Executor.apply', side_effect=Deferred('APPLICATION_LAUNCH_FAILED')), redirect_stdout(output):
self.assertEqual(main(['apply-last', '--execute', '--socket', '/TEST_ONLY']), 2)
diff --git a/tests/session_v2/validate_keyring.py b/tests/session_v2/validate_keyring.py
new file mode 100644
index 000000000..b066fe09e
--- /dev/null
+++ b/tests/session_v2/validate_keyring.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+"""Reproducible keyring tranche: isolated buses, HOME/XDG and private Sway only."""
+import argparse
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--sway', required=True, type=Path)
+ args = parser.parse_args()
+ if not args.sway.is_absolute() or not args.sway.is_file() or not os.access(args.sway, os.X_OK):
+ parser.error('--sway must be an absolute executable (headless-capable)')
+ root = Path(__file__).resolve().parents[2]
+ output = Path(tempfile.mkdtemp(prefix='labfy-keyring-validation-'))
+ # No SWAYSOCK, graphical bus, user configurations, secrets or session are
+ # inherited. Every graphical test creates its own child headless compositor.
+ env = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1',
+ 'QT_QPA_PLATFORM': 'offscreen', 'QT_QUICK_BACKEND': 'software',
+ 'LABFY_TEST_SWAY': str(args.sway), 'LABFY_TEST_RENDERER': 'pixman',
+ 'LABFY_SESSION_V2_EVIDENCE': str(output / 'evidence')}
+ for key in ('HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR'):
+ target = output / key; target.mkdir(mode=0o700); env[key] = str(target)
+ py = [sys.executable, '-B']
+ checks = {
+ 'backend': [*py, '-m', 'unittest', 'discover', '-s', 'tests/session_v2', '-p', 'test_*.py'],
+ 'focus': [*py, 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_focus.py'],
+ 'qml-unit': [*py, 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py'],
+ 'qml-qt': ['/usr/lib/qt6/bin/qmltestrunner', '-input', 'tests/session_v2/qml', '-o', '-,txt'],
+ **{name: [*py, 'tests/session_v2/' + filename + '.py'] for name, filename in (
+ ('keyring-ui', 'keyring_ui'), ('keyring-runtime', 'keyring_runtime'),
+ ('scratchpad-runtime', 'scratchpad_runtime'), ('phase3b-ui', 'phase3b_ui'),
+ ('p0-runtime', 'p0_runtime'), ('path-runtime', 'path_stability_runtime'))},
+ 'diff-check': ['git', 'diff', '--check'],
+ }
+ results = {}
+ print(output, flush=True)
+ for name, argv in checks.items():
+ with (output / (name + '.log')).open('w') as log:
+ try: results[name] = subprocess.run(argv, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=180).returncode
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ log.write(str(exc) + '\n'); results[name] = 'UNAVAILABLE_OR_TIMEOUT'
+ print(name, results[name], flush=True)
+ (output / 'results.json').write_text(json.dumps(results, indent=2) + '\n')
+ return int(any(value != 0 for value in results.values()))
+
+
+if __name__ == '__main__': sys.exit(main())