Compare commits

..

No commits in common. "a556cd56b71e99badd0b1e3abcae9e8dd9bf6092" and "5aeff41d2ce8c91966ce65fd2fa3261f34f3f954" have entirely different histories.

41 changed files with 73 additions and 3160 deletions

View file

@ -270,86 +270,7 @@ Voir le [diagnostic ciblé, les preuves et la commande hôte](session-v2-path-st
- Contenu et processus terminal non restaurés. - Contenu et processus terminal non restaurés.
- Ratios exacts non restaurés. - Ratios exacts non restaurés.
- Fullscreen non pris en charge. Scratchpad pris en charge par le snapshot version 2 pour les feuilles flottantes individuelles ; les groupes restent refusés. - Fullscreen et scratchpad non pris en charge actuellement.
- Multi-output physique non validé. - Multi-output physique non validé.
- Provider Firefox candidat actuel pour le profil supporté ; plusieurs profils - Provider Firefox candidat actuel pour le profil supporté ; plusieurs profils
simultanés ne sont pas pris en charge. simultanés ne sont pas pris en charge.
## Snapshot version 2 — scratchpad (22 octobre 2026)
Les nouvelles captures conservent `schema=labfy.sway.session-v2` et écrivent
`version=2`. Le contrat V1 et `schema-v1.json` restent inchangés : les anciennes
sessions sont lues, validées et restaurées directement, sans conversion ni
invention d’appartenance scratchpad. `schema-v2.json` décrit la nouvelle forme ;
`schema.py` ajoute les contraintes référentielles (unicité, cardinalité, chemins,
focus et association workspace/output) aux contraintes locales du schéma JSON.
Le bundle de développement contient les deux schémas.
Dans V2, `state.scratchpad` vaut `none`, `hidden` ou `shown`. Une feuille
scratchpad est nécessairement flottante, avec son rectangle IPC entier
`x/y/width/height`. Une feuille cachée a `workspace=null` et `tree_path=[]` ;
`__i3_scratch` n’entre jamais dans les workspaces, sorties ou ancres persistés.
Une feuille montrée garde le workspace/output de visibilité. `focus` reste une
référence de slot ou null, jamais une feuille déduite d’un split. Le champ V2
`focus_workspace` conserve le workspace effectivement focalisé, y compris un
workspace vide lorsque toutes les fenêtres sont cachées. Ce seul workspace vide
est admissible sans slot ; son output doit être disponible. Une feuille cachée
ne peut être le focus persistant.
La capture inclut les scratchpads cachés sous l’output interne Sway et les
scratchpads montrés parmi les flottants normaux. Les relectures comparent aussi
app_id, PID, appartenance scratchpad et focus ; un changement donne
`SNAPSHOT_CHANGED`. Les groupes flottants/scratchpad ne sont pas aplatis en
feuilles indépendantes : ils sont refusés précisément. Les fenêtres de même
application sans fournisseur exact restent explicitement interchangeables.
Le mapping privilégie la visibilité déjà observée pour ces slots ; une feuille
scratchpad existante n’est pas exposée pour remplir arbitrairement un slot
ordinaire. Les preuves UUID/singleton restent prioritaires, puis les identités
runtime admises comprennent con_id/PID/starttime/session/placement.
La phase de placement (`plan.placements`) est distincte de la compilation des
ancres de splits. Les scratchpads existants évitent staging et `floating disable`.
Les commandes sont ciblées par l’identité admise ; `scratchpad show` n’est émis
que pour une feuille prouvée cachée et un slot explicitement montré. L’ajout
initial au scratchpad sélectionne le workspace source, même si la cible était
inactive : cette transition est prédite avant IPC. Le journal précède chaque
commande. Les transitions `floating enable` et `scratchpad show` ont une attente
bornée de 300 ms pour laisser passer la configuration Wayland initiale ; cette
attente n’est pas une preuve de stabilité. Toute divergence encore observée
reste un échec. Le rectangle intermédiaire d’un `show` peut être converti par
Sway selon l’output ; le rectangle sauvegardé est ensuite commandé et vérifié
exactement, avec appartenance, visibilité, output, layout, identité et focus.
Les nouveaux journaux Recovery sont en version 2 et suivent l’appartenance
scratchpad. Les journaux V1 restent lisibles ; une observation scratchpad absente
de leurs preuves est refusée, jamais reconstruite implicitement. Après incident,
un scratchpad déjà exactement à son origine peut être reconnu sans IPC. Si son
placement diffère, la reprise conserve les données et donne
`SCRATCHPAD_RECOVERY_REQUIRES_REVIEW` ; elle n’affiche aucune feuille pour tenter
un rollback spéculatif. Les intentions pending et les conflits de focus restent
bloquants. L’ancien journal utilisateur `RECOVERY_REQUIRED` n’est ni modifié ni
acquitté par ce patch.
Le dialogue d’échec de checkpoint montre maintenant le `reason` JSON exact,
avec une explication pour les cas connus, notamment `CAPABILITY_UNSUPPORTED`.
L’erreur conserve l’action en attente : annulation ou choix explicite de quitter
sans sauvegarder restent nécessaires. Aucun code d’erreur n’autorise le logout.
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).

View file

@ -1,219 +0,0 @@
# Session V2 — focus et récupération, 22 octobre 2026
## Portée et état
Patch isolé sur `fix/session-v2-focus-recovery-20261022`, worktree
`/home/fy59/Documents/Development/labfy-session-v2-focus-recovery-20261022`,
base `5aeff41d2ce8c91966ce65fd2fa3261f34f3f954`. Le dépôt principal était propre
à cette base. Aucun commit, déploiement, redémarrage ou IPC mutant sur le bureau.
Les tests utilisent des arbres inertes ou des compositeurs enfants headless privés.
Les trois journaux réels ont été lus et leurs SHA-256 vérifiés inchangés.
Le choix réel `startup.json=new` ne donne aucune autorisation de reprise.
La fausse classification d'un changement de chemin est reproduite et corrigée
pour les nouveaux journaux disposant d'une preuve de structure. L'ordre de fin
du helper d'ancres est corrigé et les frontières du watchdog sont journalisées.
**La cause historique exacte du focus Firefox → Kitty reste non démontrée.**
Ce patch ne constitue donc pas une preuve de correction définitive du login réel.
Aucune divergence n'est réinterprétée comme un succès.
## Chronologie établie de l'incident
Le rapport réel indique trois applications observées, trois slots remplis,
trois ancres créées/permutées/nettoyées, un helper suspendu/repris et l'arbre
vérifié. `final_tree_verified=true` est une étape validée, pas la preuve que
l'arbre est resté identique après reprise.
Le journal conserve `operations=18`, une dernière intention `[con_id=8] focus`,
`focus_expected={con_id:8,workspace:"1"}` et `focus_pending=false`. Dans le code
exécuté, ces deux derniers champs ne sont publiés qu'après le retour réussi de
RUN_COMMAND et une observation de focus conforme. Cela prouve une observation
de Firefox après la commande ; cela ne prouve pas l'absence d'input concurrent.
La séquence source était :
1. Verrou V2 et refus d'une récupération active/ambiguë ; prewarm des applications,
identités/PID/starttime, puis preuve de focus stable sur deux arbres.
2. Création de Recovery et baseline, lancement du helper d'ancres sans surface,
découverte et suspension vérifiée d'autotiling via pidfd.
3. Réadmission stricte du lot, déplacements au stage, construction des ancres,
résolution Firefox, permutations et retrait des seules ancres possédées.
4. Vérifications de structure, identités, outputs et flottants ; choix du focus
final depuis le checkpoint ou l'origine prouvée ; commande vers Firefox8.
5. `guard.resume()` envoie SIGCONT et attend seulement l'état non stoppé du helper.
6. `_verify()` refait notamment l'appel provider et `validate_final()`. Sa lecture
`final-observation` constate Kitty7, enregistre la première divergence et échoue.
7. L'appel **normal** suivant à `guard.finish()` n'a donc pas encore été atteint.
Le finally appelle ensuite finish sur erreur, puis la récupération conservatrice.
Le premier conflit `after-command/final-observation` désigne une validation,
**pas une commande focus qui aurait échoué**. La fenêtre causale est postérieure
à l'observation conforme de Firefox et antérieure à cette lecture. Elle contient
la reprise des helpers et l'attente provider. L'incident ne permet pas de choisir
entre un helper, un autre client IPC, une activation Wayland/layer ou un input
utilisateur. Il ne démontre pas un défaut du déplacement initial.
## Autotiling, Sway et interface
L'autotiling installé est lancé par `exec autotiling` dans l'autostart Sway.
Son handler `switch_splitting` traite WINDOW/MODE, relit le focus puis envoie
`splith`/`splitv` sans critère con_id. SIGCONT peut donc libérer un backlog et une
commande déjà préparée ; l'ACK actuel ne prouve aucun drainage. Le code ne demande
pas directement de focus. Les splits peuvent insérer un parent unaire ; le code
Sway de `container_split` conserve le focus de son enfant lorsqu'il était focalisé.
La seule présence d'autotiling ne démontre donc pas la transition vers Kitty.
Une restauration avec le véritable autotiling dans un Lab privé réussit avant
et après patch.
Les erreurs réelles `'NoneType' object has no attribute 'type'` sont visibles
à 06:11:58.214 et 06:11:58.390, mais aussi avant à 06:11:53.835/.836. Le handler
appelle `output_name(con)` avant de vérifier `con` : un `find_focused()` nul peut
produire précisément cette erreur. Il n'existe ni traceback ni trace de commandes
ou d'input permettant d'en déduire une commande de focus causale.
Les règles Sway du dépôt ne refocalisent pas Firefox/Kitty. Le script historique
`layout-workspace1.sh` contient des commandes focus sur d'autres identités, sans
invocation dans l'autostart examiné. L'abonné d'opacité commande l'opacité et les
bordures. Le provider Firefox change `titlePreface`, sans `focused:true` ; un
événement titre peut toutefois réveiller autotiling. L'UI retire sa layer avant
le backend et vérifie le retrait sur deux ticks ; cela ne constitue pas une
barrière atomique sur tous les clients Wayland. `startup.py` n'effectue aucun IPC
mutant et le choix `new` ne déclenche aucune reprise. Ces composants restent inchangés.
Références primaires : [IPC Sway 1.12](https://github.com/swaywm/sway/blob/1.12/sway/sway-ipc.7.scd),
[autotiling](https://github.com/nwg-piotr/autotiling/blob/master/autotiling/main.py).
Les sources locales SwayFX `sway/tree/container.c`, `sway/input/seatop_default.c`
et `sway/ipc-server.c` ont aussi été inspectées. RUN_COMMAND/GET_TREE séquentiels
ne sont pas un compare-and-swap du focus et ne fournissent pas sa provenance.
## Protocole corrigé
Après le nettoyage/vérification du layout, l'executor observe le focus,
puis appelle `guard.prepare()`. Le watchdog refuse s'il reste une ancre et
quitte son helper ; il acquitte seulement après preuve de sortie sur le pidfd.
Cela se produit pendant la suspension possédée, avant le focus final et SIGCONT.
La vérification `Anchors.owned` commence par PID/app_id : les fenêtres applicatives
peuvent être vérifiées après la sortie/récolte du helper, sans dépendre d'un zombie.
Une candidate possédée exige toujours son pidfd exact.
Le moteur contrôle le focus après prepare, émet la commande finale sous les
contrôles Recovery existants, observe avant/après resume, puis avant/après finish.
La clôture préparée ne ferme plus d'ancre et n'écrit plus de quit tardif.
Les chemins d'erreur conservent le nettoyage des seules ancres possédées et la
reprise de tous les helpers dont le watchdog possède la suspension. Un helper
initialement stoppé n'est jamais repris.
Les six observations `focus_boundaries` sont durables et bornées à huit ; elles
localisent un premier constat, sans attribuer une cause. L'intention conserve
désormais explicitement `focus_after` après réponse et observation conformes.
Toute divergence refuse la suite ; aucun rattrapage/refocus automatique n'est
ajouté. Les contrôles finaux après reprise et après finish restent présents.
**Limite :** prepare est une barrière sur le processus possédé, pas un ACK
coopératif d'autotiling. Un helper repris peut toujours commander Sway. Une
nouvelle instance non découverte, un autre client ou l'input restent concurrents.
Le patch contrôle ces divergences observables et les refuse ; il ne prétend pas
établir une causalité atomique ou empêcher tout changement après la dernière lecture.
## Récupération canonique
L'ancien `_recover` exigeait `current == expected`, chemins bruts inclus.
Il pouvait donc annoncer `IDENTITY_OR_MEMBERSHIP_CHANGED` pour `[0] → [0,0]`
ou `[1] → [1,0]`, alors que la validation finale ignorait déjà ces chemins et
contrôlait ordre/placement/layout. La reproduction inerte échoue avant patch
avec exactement deux rejets Firefox/Kitty, puis passe après correction.
Les nouveaux journaux V1 contiennent une extension facultative
`structure_expected`, capturée sur l'arbre confirmé après commande. Elle utilise
la même normalisation qu'Anchors : seuls les splits unaires splith/splitv/none
sont contractés. Les feuilles sont les vrais con_id ; branches ordonnées,
layouts tabbed/stacked et appartenance aux workspaces restent significatifs.
La preuve est bornée en profondeur, nombre de branches et workspaces et soumise
à la limite durable JSON existante. Aucune version de Project DB n'est concernée.
Avant de réconcilier des chemins, Recovery exige :
- Même session et aucune intention fenêtre/focus pending.
- Ensemble exact des fenêtres gérées et étrangères, sans admission par app_id.
- Égalité complète con_id/PID/starttime/app_id, workspace/output, flottement et
géométrie flottante pertinente pour tout le lot.
- Égalité de la structure canonique à la preuve durable antérieure.
Un changement de branche/layout/ordre reste refusé même si les chemins bruts
n'ont pas changé ; chaque commande de récupération recontrôle la structure.
Seuls les chemins `expected` sont réconciliés. Origin, before, intention et
premier conflit restent conservés ; `path_reconciliations` garde les chemins
avant/après (quatre lots maximum, puis refus plutôt qu'écrasement).
Le contrôle strict avant chaque commande relit toujours l'état exact.
Les journaux historiques restent lisibles mais **sans fabrication rétroactive de
preuve structurelle**. Une différence de chemin sans preuve devient
`PATH_EQUIVALENCE_NOT_PROVEN`, une vraie différence de placement/identité reste
`IDENTITY_OR_MEMBERSHIP_CHANGED`, une structure différente devient
`STRUCTURE_OR_ORDER_CHANGED`. Aucun journal réel n'a été réécrit.
Reconnaître sans IPC une fenêtre déjà à son origine n'efface pas le conflit de
focus : le reçu reste `RECOVERY_REQUIRED` avec `FOCUS_RECOVERY_NOT_PROVEN`.
Ce patch ne débloque donc pas automatiquement la transaction réelle déjà refusée.
## Validation et preuves
Les commandes, résultats détaillés et empreintes sont conservés dans
`build/focus-recovery/`, `build/real-focus-env/` et `build/session-v2-tests/` du
worktree. Ces artefacts sont locaux, non déployés. Commande de validation composée :
```sh
LABFY_TEST_RENDERER=pixman python -B tests/session_v2/validate_login_focus.py
```
Le runner impose HOME/XDG privés et retire les sockets/bus de la session réelle.
Chaque Lab lance SwayFX 0.6 / Sway 1.12 sur un socket enfant distinct avec Pixman.
Les applications et UUID Firefox sont synthétiques. Les injections « helper »
utilisent une commande déterministe du contrôleur après reprise ; elles ne
prétendent pas reproduire une commande focus du véritable autotiling.
Les scénarios positifs exigent success, slots/permutations complets, arbre exact,
focus hors stage, recovery complete et toutes les postconditions. Les négatifs
exigent un refus, le maintien des clients et de l'incident, et l'absence de
refocalisation. Les tests d'interruption conservent les intentions pending.
`test_bootstrap.py` utilise désormais un temporaire court pour ne pas dépasser
la limite AF_UNIX dans un worktree profond ; aucun test n'a été supprimé.
Résultats du dernier contrôle composé : **29 tests focus/recovery, 71 tests
backend, 13 tests QML Python, 5 contrôles QtTest et 15 scénarios de login headless**.
Les cinq variantes de `path_stability_runtime.py` et les dix variantes de
`crashes.py` passent également. Les scénarios de succès exigent toutes les
postconditions ; les injections tardives exigent RECOVERY_FOCUS_MISMATCH après
les trois swaps, sans refocalisation.
Un élargissement à `runtime.py` a validé les 28 permutations de layouts ainsi
que les cas de performance/multi-workspace précédant le flottant. **La suite
complète ne passe pas** : sa fixture GTK flottante repasse de 400×240 à 122×82
après le resize, d'où un refus de postcondition/état. La sonde privée identique
échoue aussi contre la baseline non modifiée (`floating-baseline.log`) ; le
patch échoue avec le même retour spontané de taille (`floating-patched.log`).
Aucune tolérance géométrique ni suppression de ce test n'a été ajoutée. Ce
résultat ne permet pas de revendiquer une validation complète des flottants ;
les contrôles continuent de refuser leur géométrie divergente. Les deux cas
d'identité après le flottant dans cette suite n'ont pas été atteints.
## Validation proposée au prochain login — autorisation humaine requise
1. Faire relire le diff ; autoriser séparément son intégration/déploiement et un
essai réel au prochain login. Rien de cela n'a été exécuté par cette mission.
2. Conserver les journaux/empreintes de l'incident actuel. Un traitement de son
`RECOVERY_REQUIRED` nécessite un plan nominatif et une autorisation distincte ;
ne pas l'effacer ni utiliser le choix `new` pour le reprendre.
3. Au login autorisé, relever versions, checkpoint voulu, session/socket, outputs
et helpers ; utiliser uniquement le choix Restaurer explicite, une seule fois.
Pour le cas nominal, laisser les interactions se terminer avant la transaction.
4. Conserver tentative/journal/reçu, six frontières focus et arbres avant/après.
Vérifier identité runtime de chaque fenêtre, slots/ordre/layout/outputs,
ancres absentes, helpers repris, focus convenu hors stage et recovery complete.
5. En cas d'écart, arrêter l'essai et conserver son premier constat. Ne pas
cliquer Réessayer en boucle, refocaliser via script, relancer apply ou supprimer
RECOVERY_REQUIRED. Comparer les frontières et compléter par une trace IPC/input
explicitement autorisée pour discriminer les causes restantes.
6. Un essai séparé avec interaction volontaire doit conserver le focus extérieur
et refuser toute mutation après le conflit observable ; il demande lui aussi
une autorisation explicite. Un résultat headless ne valide pas le login physique.

View file

@ -1,291 +0,0 @@
# 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.

View file

@ -1,8 +1,5 @@
# Premier login V2 : chemins de feuilles après Guard # Premier login V2 : chemins de feuilles après Guard
Suite du diagnostic : [focus et récupération du 22 octobre](session-v2-focus-recovery-fix-v1.md).
Cette page conserve la portée et les résultats de la correction précédente.
## Résultat et portée ## Résultat et portée
Correction minimale dans la seule copie Correction minimale dans la seule copie

View file

@ -1,180 +0,0 @@
# SESSION_V2_SCRATCHPAD_SAVE_AND_RESTORE_V1
Travail local du 22 octobre 2026 dans
`labfy-session-v2-scratchpad-20261022`, branche
`feat/session-v2-scratchpad-20261022`, base `5aeff41d`.
Les correctifs focus/recovery préexistants restent présents. Aucun commit,
push, déploiement, reload QuickShell ou changement de configuration utilisateur.
## Résultat et fichiers
- `session_v2/snapshot.py`, `schema.py`, `schema-v2.json`, `model.py` :
capture version 2, scratchpad `none/hidden/shown`, rectangle IPC, focus de slot
et workspace focalisé explicite. Une fenêtre cachée a `workspace=null` ;
l’espace interne `__i3_scratch` n’est jamais persisté comme workspace cible.
Les groupes de fenêtres scratchpad restent refusés. Aucun titre ni argv
n’est capturé. `schema-v1.json` reste inchangé.
- `layout.py`, `slots.py`, `executor.py`, `cli.py` : phase de placements
descriptive, mapping conservant la visibilité des slots interchangeables,
staging évité pour les scratchpads existants, commandes con_id ciblées,
géométrie et état final vérifiés. Les nouvelles fenêtres arrivent par le
prewarm existant, depuis le catalogue statique de confiance.
- `recovery.py` : nouveaux journaux version 2 avec appartenance scratchpad,
lecture conservatrice des journaux V1, prédiction de focus avant chaque
commande, refus d’une feuille dont la commande affecterait un ancêtre
scratchpad non admis. Aucun conflit ni pending n’est effacé pour progresser.
- `controlcenter/SessionExitGate.qml` : `reason` JSON visible, explications,
action de sortie toujours bloquée après échec jusqu’à un choix humain.
Le ControlCenter fournit déjà ce JSON ; le diagnostic se perdait dans Gate
qui privilégiait le stderr générique.
- `tools/build-session-v2.py` : inclusion des deux schémas dans le bundle.
- Tests : `test_scratchpad.py`, `scratchpad_runtime.py`,
`validate_scratchpad.py`, extension de `qml/tst_exit_v2.qml`.
Les fonctions existantes `Guard.prepare`, les frontières de vérification finale,
la normalisation canonique des chemins et les tests focus/recovery initiaux
sont conservés. Le présent delta ne change pas la politique d’autotiling.
## Preuves et couverture
Le SwayFX installé échoue au démarrage headless dans ce sandbox faute de DRM.
Un paquet Sway 1.12 Arch a été extrait sous `/tmp/labfy-scratchpad-tool`, sans
installation système ni substitution de bibliothèque. `ldd` résout son
`libwlroots-0.20.so` installé sans dépendance manquante. Son renderer Pixman a
permis la campagne privée. Provenance :
[paquet Arch](https://archlinux.org/packages/extra/x86_64/sway/).
La prédiction du nouvel ajout scratchpad est étayée par le
[code Sway 1.12 root.c](https://github.com/swaywm/sway/blob/1.12/sway/tree/root.c)
et les observations privées : l’ajout initial refocalise le workspace source,
même si la cible était inactive. Le
[code scratchpad.c](https://github.com/swaywm/sway/blob/1.12/sway/commands/scratchpad.c)
explique également pourquoi `scratchpad show` est un toggle à ne jamais
émettre sans preuve de visibilité préalable.
La suite backend comprend 83 tests, dont 12 nouveaux tests scratchpad :
- capture/relecture déterministes, Store nommé et checkpoint automatique
temporaires, compatibilité des snapshots V1 ;
- Kitty caché + autre Kitty visible, montré et focalisé, plusieurs cachés,
workspace vide focalisé, splits et flottants ;
- références, types et versions stricts, JSON Schema V2, focus caché interdit ;
- scratchpad modifié entre lectures, fausse identité PID, output déplacé,
focus ambigu, intention interrompue conservée et groupe non admis ;
- mapping sans exposition d’un pair caché et prédiction de focus avant IPC.
Autres suites : 29 tests focus Python, 13 tests QML via PySide6, 6 résultats
QtTest (4 tests métier et initialisation/nettoyage). Les tests QML vérifient
notamment `CAPABILITY_UNSUPPORTED`, `SNAPSHOT_CHANGED`, `RECOVERY_REQUIRED` et
un code inconnu, avec zéro commande de sortie après échec.
La campagne `scratchpad_runtime.py` utilise des Kitty réels uniquement sous
le socket enfant privé. Elle conserve arbres JSON et PNG avant/après :
1. deux Kitty du même app_id (un caché et un en split), un scratchpad montré,
un flottant ordinaire et un autre split ;
2. capture, Store temporaire, perturbation puis restauration ; égalité exacte
des géométries et états, focus final vérifié ;
3. show/hide explicite après restauration, sans perte d’appartenance ;
4. reconstitution de l’appartenance depuis des fenêtres ordinaires ;
5. toutes les fenêtres cachées et workspace vide focalisé ;
6. disparition puis relancement réel des deux Kitty par le catalogue statique
TEST_ONLY, restauration cachée et second apply sans double lancement.
Les PNG avant/après de la campagne privée ont été inspectés. Ils montrent le
même arrangement ; ce sont des terminaux synthétiques noirs exécutant `sleep`,
pas une preuve de contenu applicatif ou d’une session utilisateur physique.
La trace de commandes vérifie qu’aucun move vers un workspace ni `scratchpad
show` ne cible la feuille destinée à rester cachée pendant le premier apply.
Régressions privées supplémentaires retenues sous
`/tmp/labfy-scratchpad-regressions-mivwhgzo/` : scénario Limusic V1 et refus
absent/dupliqué ; quatre scénarios de stabilité des chemins ; huit scénarios
login/focus (normal, workspace vide, fenêtre tierce, deux focus tardifs,
map dans le gap IPC, placement provider, crash en milieu d’intention).
Tous ont leurs succès ou refus attendus. Seul le nettoyage du harness a été
adapté dans un wrapper temporaire pour ne jamais envoyer SIGCONT.
Aucun scénario envoyant réellement SIGSTOP/SIGCONT à autotiling n’a été
exécuté. Son protocole est testé par simulation ; les tests headless réels
utilisent Guard sans autotiling. Aucun socket utilisateur ni fichier sous
`/run/user/1000/labfy-sway` n’est utilisé par les validations.
## Limites assumées
- **Enregistrer Kitty ne ressuscite pas btop.** Le processus interne, la commande,
le titre et le contenu du terminal ne font pas partie du snapshot. Le lancement
de production reste `/usr/bin/kitty` issu du catalogue. Le `sleep` du test vient
exclusivement d’un catalogue statique TEST_ONLY, jamais des données capturées.
- Les slots de même application sans provider exact restent interchangeables,
sans promesse de retrouver le rôle sémantique d’un terminal particulier.
- Les groupes scratchpad, fullscreen et géométries refusées par le client ou
le compositeur produisent un échec précis, pas un succès partiel.
- Pour une feuille cachée, le rectangle sauvegardé est le rectangle IPC caché.
L’ancienne transformation d’output interne à Sway n’est pas persistée. Un
futur `show` manuel peut adapter ses coordonnées à l’output alors actif.
- Le `show` intermédiaire d’une feuille explicitement montrée laisse Sway
convertir le rectangle à l’output ; la géométrie sauvegardée est ensuite
appliquée et vérifiée exactement. Le délai de 300 ms des transitions Wayland
ne remplace aucune preuve. Une modification tardive reste refusée.
- Une reprise après incident ne reconstruit pas automatiquement un scratchpad
dont le placement diffère de l’origine prouvée : reçu
`SCRATCHPAD_RECOVERY_REQUIRES_REVIEW`, fenêtres et journal conservés.
- Pas de validation de login physique, de multi-output physique, de SwayFX avec
DRM, ni de contenu Firefox/btop. Les contrôles IPC ne constituent pas un CAS.
- Aucun nouveau checkpoint réel n’a été écrit. L’ancien état utilisateur
`RECOVERY_REQUIRED` demeure bloquant ; ce patch ne l’acquitte pas.
## Reproduction sûre et future validation
Depuis ce worktree, avec un binaire Sway compatible disponible :
```sh
python -B tests/session_v2/validate_scratchpad.py \
--sway /tmp/labfy-scratchpad-tool/usr/bin/sway --renderer pixman
```
Le validateur crée ses HOME/XDG sous `/tmp`, retire les variables graphiques
héritées et lance exclusivement un compositeur headless avec configuration
TEST_ONLY. Il conserve les logs et le chemin des arbres/PNG dans son répertoire
de résultats. Aucun accès privilégié n’est requis. Les clients et compositeurs
enfants sont arrêtés par le nettoyage du laboratoire ; aucun signal de
suspension/reprise n’est envoyé.
Pour la future recette humaine : examiner d’abord le diff et les preuves
privées ; reproduire avec plusieurs fenêtres de terminal identifiables et un
scratchpad montré/caché dans un compositeur privé. Toute recette sur le bureau
réel exigera une autorisation séparée et la résolution humaine préalable du
journal ancien. Ne pas effacer ce journal pour débloquer apply, ne pas tester
via logout/reboot et ne pas recharger QuickShell pour cette vérification.
Vérifier séparément la sauvegarde d’une fenêtre Kitty et l’absence de promesse
sur le redémarrage de btop.
## Résultats finaux conservés
- Validateur reproductible : `/tmp/labfy-scratchpad-validation-imopv2no/` ;
tous les codes retour sont zéro (backend, focus, QML Python/Qt, runtime,
diff-check).
- Campagne réelle finale : `/tmp/labfy-session-v2-TEST_ONLY-1gtxan7m/` ;
snapshots, arbres, commandes, résultats, PNG avant/après/show/hide,
nouvelles appartenances, toutes cachées et lancement Kitty à froid.
- Bundle local candidat : `/tmp/labfy-scratchpad-final-evidence/bundle/` ;
compilation C17 stricte, schémas V1/V2 embarqués, non installé/non signé.
- Relecture locale du delta et vérification des patches initiaux : les diffs
initiaux hors executor/recovery restent identiques octet pour octet ; dans
ces deux fichiers, les mécanismes initiaux sont conservés et les suites de
non-régression passent. Cela ne remplace pas la revue humaine avant commit.
- `qmllint` du composant Gate, parsing AST Python, contrôle des deux JSON
Schemas et `git diff --check` : succès.
- Paquet Sway de test, SHA-256 :
`5704cfc9eba9804116c665cb4450352bf5a240c1a8300b36aca3218116648443`.
### Revue complémentaire du focus de layer-shell
Le dialogue de sortie QuickShell peut rendre le focus de GET_TREE non
observable. Dans ce cas, le snapshot V2 garde explicitement
focus=null et focus_workspace=null, sans inférer de fenêtre depuis les
piles internes. Plusieurs noeuds simultanément marqués focused restent
refusés avec SNAPSHOT_FOCUS_AMBIGUOUS. Les tests vérifient cette
distinction et le round-trip du Store temporaire.

View file

@ -28,31 +28,8 @@ PanelWindow {
property int windowCount: 0 property int windowCount: 0
property int workspaceCount: 0 property int workspaceCount: 0
readonly property bool busy: SessionV2Service.busy || watchingBackend 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() { function restoreRequested() {
if (!keyringReady || !interactionArmed || busy || releasing || (attempted && !retryAllowed)) return; if (!interactionArmed || busy || releasing || (attempted && !retryAllowed)) return;
if (!SessionV2Service.prepareRestore(popup)) return; if (!SessionV2Service.prepareRestore(popup)) return;
attempted = true; attempted = true;
failed = false; failed = false;
@ -67,11 +44,7 @@ PanelWindow {
releasePoll.start(); releasePoll.start();
} }
function newSessionRequested() { function newSessionRequested() {
if (interactionArmed && !busy) { if (interactionArmed && !busy) SessionV2Service.run("ack-new", "", "", popup);
unlockRelease.stop(); unlockingRequested = false;
SessionV2Service.cancelKeyring();
SessionV2Service.run("ack-new", "", "", popup);
}
} }
function releaseFailed() { function releaseFailed() {
releasePoll.stop(); releasePoll.stop();
@ -86,12 +59,6 @@ PanelWindow {
} }
function fail(value) { function fail(value) {
inProgress = false; 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; failed = true;
restored = false; restored = false;
retryAllowed = !!value && value.status === "DEFERRED"; retryAllowed = !!value && value.status === "DEFERRED";
@ -171,7 +138,6 @@ PanelWindow {
SessionV2Service.run("restore-status", "", "", popup); SessionV2Service.run("restore-status", "", "", popup);
} }
if (popup.visible) { if (popup.visible) {
SessionV2Service.checkKeyring(false);
popup.windowCount = value.windows || 0; popup.windowCount = value.windows || 0;
popup.workspaceCount = value.workspaces || 0; popup.workspaceCount = value.workspaces || 0;
} }
@ -201,7 +167,7 @@ PanelWindow {
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0 exclusiveZone: 0
aboveWindows: true aboveWindows: true
focusable: visible && !unlockingRequested && !SessionV2Service.keyringBusy && SessionV2Service.keyringState !== "KEYRING_UNLOCKING" && !awaitingUserFocus && !releasing && !inProgress && !restored focusable: visible && !awaitingUserFocus && !releasing && !inProgress && !restored
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "labfy-session-startup-v2" WlrLayershell.namespace: "labfy-session-startup-v2"
visible: false visible: false
@ -258,11 +224,7 @@ PanelWindow {
text: popup.failed ? popup.failureMessage text: popup.failed ? popup.failureMessage
: popup.restored ? "Session restaurée" : popup.restored ? "Session restaurée"
: popup.busy && popup.attempted ? "Restauration en cours…" : popup.busy && popup.attempted ? "Restauration en cours…"
: popup.keyringReady ? "Une session précédente est disponible." : popup.windowCount + " fenêtres • " + popup.workspaceCount + " espaces"
: 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 color: Theme.foreground
wrapMode: Text.Wrap wrapMode: Text.Wrap
} }
@ -288,15 +250,9 @@ PanelWindow {
onClicked: popup.newSessionRequested() onClicked: popup.newSessionRequested()
} }
ActionButton { ActionButton {
visible: !popup.keyringReady visible: !popup.failed || popup.retryAllowed
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" label: popup.retryAllowed ? "Réessayer" : "Restaurer"
enabled: popup.keyringReady && popup.interactionArmed && !popup.busy && (!popup.attempted || popup.retryAllowed) enabled: popup.interactionArmed && !popup.busy && (!popup.attempted || popup.retryAllowed)
onClicked: popup.restoreRequested() onClicked: popup.restoreRequested()
} }
} }

View file

@ -59,20 +59,7 @@ QtObject {
return; return;
} }
pendingAction = completedAction; pendingAction = completedAction;
// CONTRACT: backend reason is diagnostic data, never exit authority. errorMessage = stderrText.trim() || "Le backend de sauvegarde a échoué.";
// Keep the action gated until saved or an explicit human bypass.
const reason = response && typeof response.reason === "string"
&& /^[A-Z][A-Z0-9_]{0,95}$/.test(response.reason) ? response.reason : "";
const explanations = {
CAPABILITY_UNSUPPORTED: "Un état de fenêtre n’est pas pris en charge par le backend installé (par exemple fullscreen ou scratchpad avec un ancien backend).",
SNAPSHOT_CHANGED: "Les fenêtres ont changé pendant la capture. Réessayez une fois leur état stabilisé.",
SNAPSHOT_FOCUS_AMBIGUOUS: "Sway ne fournit pas un focus unique pour cette capture.",
FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE: "Le fournisseur d’identité Firefox nécessaire à la sauvegarde est indisponible.",
RECOVERY_REQUIRED: "Une ancienne restauration exige une inspection humaine. Elle ne sera pas reprise automatiquement.",
SCRATCHPAD_CONTAINER_UNSUPPORTED: "Ce scratchpad contient un groupe de fenêtres dont la restauration sûre n’est pas prise en charge."
};
errorMessage = reason ? reason + " — " + (explanations[reason] || "Le backend a refusé la sauvegarde. Conservez la session ouverte et examinez ce code.")
: stderrText.trim() || "Le backend de sauvegarde a échoué sans code de diagnostic.";
checkpointFailed(); checkpointFailed();
} }

View file

@ -23,12 +23,6 @@ Item {
if (owner !== page) return; if (owner !== page) return;
if (kind === "list") { if (okay) page.sessions = value.sessions; return; } if (kind === "list") { if (okay) page.sessions = value.sessions; return; }
if (kind === "apply") { 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"; if (okay && Protocol.restoreSucceeded(value)) page.feedback = "Session restaurée";
else { else {
page.restoreFailed = true; page.restoreFailed = true;

View file

@ -30,12 +30,8 @@ class Anchors:
def identity(self, slot): return "labfy-v2-anchor-" + self.tx + "-" + slot def identity(self, slot): return "labfy-v2-anchor-" + self.tx + "-" + slot
def owned(self, node): def owned(self, node):
# WHY : prepare a terminé/reapé le helper avant les vérifications
# finales. CONTRACT : une fenêtre étrangère n'exige pas un PID vivant
# du helper ; toute candidate possédée exige encore son pidfd exact.
if node.get("pid") != self.process.pid or node.get("app_id") not in self.allowed: return False
signal.pidfd_send_signal(self.pidfd, 0) signal.pidfd_send_signal(self.pidfd, 0)
return True return node.get("pid") == self.process.pid and node.get("app_id") in self.allowed
def anchor(self, slot): def anchor(self, slot):
require(self.active, "TRANSACTION_INACTIVE") require(self.active, "TRANSACTION_INACTIVE")

View file

@ -15,7 +15,7 @@ from .ipc import Sway
from .applications import Catalog from .applications import Catalog
from .firefox_identity import FirefoxProvider from .firefox_identity import FirefoxProvider
from .snapshot import capture from .snapshot import capture
from .layout import compile_plan, compile_placements from .layout import compile_plan
from .executor import Executor from .executor import Executor
from .observability import validate as validate_report from .observability import validate as validate_report
from .startup import acknowledge, status as startup_status from .startup import acknowledge, status as startup_status
@ -56,7 +56,7 @@ def migration(path):
def main(argv=None): def main(argv=None):
parser = argparse.ArgumentParser(description="Backend de restauration de session V2") parser = argparse.ArgumentParser(description="Backend de restauration de session V2")
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("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("name", nargs="?") parser.add_argument("name", nargs="?")
parser.add_argument("--execute", action="store_true") parser.add_argument("--execute", action="store_true")
parser.add_argument("--stdout", action="store_true") parser.add_argument("--stdout", action="store_true")
@ -67,18 +67,13 @@ def main(argv=None):
parser.add_argument("--anchor-binary", default=str(Path(__file__).parent / "libexec/labfy-v2-anchor")) parser.add_argument("--anchor-binary", default=str(Path(__file__).parent / "libexec/labfy-v2-anchor"))
args = parser.parse_args(argv) args = parser.parse_args(argv)
try: try:
if args.command in ("keyring-unlock", "capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"): if args.command in ("capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"):
require(args.execute, "EXECUTE_REQUIRED") require(args.execute, "EXECUTE_REQUIRED")
if args.command == "recovery-status": if args.command == "recovery-status":
from .recovery import recovery_status from .recovery import recovery_status
print(json.dumps(recovery_status(readonly_runtime()), ensure_ascii=False)) print(json.dumps(recovery_status(readonly_runtime()), ensure_ascii=False))
return 0 return 0
state, runtime = paths() 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) store = Store(state)
command = args.command command = args.command
if command == "list": result = {"sessions": store.list()} if command == "list": result = {"sessions": store.list()}
@ -88,10 +83,7 @@ def main(argv=None):
try: result = read(runtime / "session-v2-restore-attempt.json", validate_report) try: result = read(runtime / "session-v2-restore-attempt.json", validate_report)
except FileNotFoundError: result = {"status": "idle", "reason": "NO_RESTORE_ATTEMPT"} except FileNotFoundError: result = {"status": "idle", "reason": "NO_RESTORE_ATTEMPT"}
elif command == "analyze-v1-migration": result = migration(args.name) elif command == "analyze-v1-migration": result = migration(args.name)
elif command == "plan": elif command == "plan": result = {"operations": [asdict(x) for x in compile_plan(store.load(args.name), "0" * 32)]}
data = store.load(args.name)
result = {"operations": [asdict(x) for x in compile_plan(data, "0" * 32)],
"placements": compile_placements(data)}
else: else:
sway = Sway(args.socket) sway = Sway(args.socket)
catalog = Catalog() catalog = Catalog()
@ -110,13 +102,7 @@ def main(argv=None):
result = data if command == "capture" else {"status": "saved"} result = data if command == "capture" else {"status": "saved"}
else: else:
data = store.last() if command == "apply-last" else store.load(args.name) data = store.last() if command == "apply-last" else store.load(args.name)
from .keyring import admission, require_ready result = Executor(sway, catalog, provider, runtime, args.anchor_binary).apply(data, execute=True)
# 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)) print(json.dumps(result, ensure_ascii=False))
return 0 return 0
except Failure as exc: except Failure as exc:

View file

@ -18,8 +18,7 @@ from .guard import Guard
from .observability import Attempt from .observability import Attempt
from .startup import record_deferred from .startup import record_deferred
from .slots import assign from .slots import assign
from .layout import compile_placements from .recovery import Recovery, RecordedSway, resume_pending, process_start, focus_proof, placement, observe, recovery_status
from .recovery import Recovery, RecordedSway, resume_pending, process_start, focus_proof, placement, observe, recovery_status, scratchpad_state
def process_owner(pid): def process_owner(pid):
@ -218,9 +217,7 @@ class Executor:
recovery.admit(n) recovery.admit(n)
self.sway.command(f'[con_id={n["id"]}] move container to workspace ' + json.dumps(stage)) self.sway.command(f'[con_id={n["id"]}] move container to workspace ' + json.dumps(stage))
for n in targets.values(): for n in targets.values():
# Hidden scratchpads stay detached throughout staging; neither stage_node(n)
# workspace moves nor floating-disable may expose them.
if prepared[n["id"]]["scratchpad"] == "none": stage_node(n)
def progress(phase, count): def progress(phase, count):
values = {"phase": phase} values = {"phase": phase}
if phase == "anchor": values["anchors_created"] = count if phase == "anchor": values["anchors_created"] = count
@ -267,7 +264,7 @@ class Executor:
identity = self.provider.resolve(expected, groups[app["application_id"]]) if expected else self.provider.capture(groups[app["application_id"]]) identity = self.provider.resolve(expected, groups[app["application_id"]]) if expected else self.provider.capture(groups[app["application_id"]])
attempt.data["providers"] = [PROVIDER] attempt.data["providers"] = [PROVIDER]
attempt.data["identity_resolutions"] = [{"uuid": k, "con_id": v} for k, v in identity.items()] attempt.data["identity_resolutions"] = [{"uuid": k, "con_id": v} for k, v in identity.items()]
mapping.update(assign(members, groups[app["application_id"]], identity, pinned)) mapping.update(assign(members, groups[app["application_id"]], identity))
# CONTRACT : le provider ne peut introduire une admission hors du # CONTRACT : le provider ne peut introduire une admission hors du
# lot phase0, même si un remplacement conserve la cardinalité. # lot phase0, même si un remplacement conserve la cardinalité.
check_batch() check_batch()
@ -286,7 +283,6 @@ class Executor:
for n in captured.values(): for n in captured.values():
recovery.admit(n, pinned[n["id"]]) recovery.admit(n, pinned[n["id"]])
for n in captured.values(): for n in captured.values():
if recovery.snapshot(n["id"])["scratchpad"] != "none": continue
if location(self.sway.tree(), n["id"])[0] != stage: stage_node(n) if location(self.sway.tree(), n["id"])[0] != stage: stage_node(n)
if n.get("type") == "floating_con" or n.get("floating") in ("user_on", "auto_on"): if n.get("type") == "floating_con" or n.get("floating") in ("user_on", "auto_on"):
self.sway.command(f'[con_id={n["id"]}] floating disable') self.sway.command(f'[con_id={n["id"]}] floating disable')
@ -294,7 +290,6 @@ class Executor:
for slot in data["window_slots"]: for slot in data["window_slots"]:
if slot["state"]["floating"]: continue if slot["state"]["floating"]: continue
target = captured[mapping[slot["slot_id"]]] target = captured[mapping[slot["slot_id"]]]
require(recovery.snapshot(target["id"])["scratchpad"] == "none", "SCRATCHPAD_ASSIGNMENT_UNPROVEN")
anchors.swap(slot["slot_id"], target, slot["workspace"], stage, specs[slot["application_id"]].app_ids) anchors.swap(slot["slot_id"], target, slot["workspace"], stage, specs[slot["application_id"]].app_ids)
attempt.update(phase="swap", anchors_swapped=attempt.data["anchors_swapped"] + 1) attempt.update(phase="swap", anchors_swapped=attempt.data["anchors_swapped"] + 1)
self.progress("swap", attempt.data["anchors_swapped"]) self.progress("swap", attempt.data["anchors_swapped"])
@ -308,13 +303,6 @@ class Executor:
self._verify(data, mapping, specs, anchors) self._verify(data, mapping, specs, anchors)
attempt.data["timings_ms"]["verification"] = (time.monotonic() - start) * 1000 attempt.data["timings_ms"]["verification"] = (time.monotonic() - start) * 1000
attempt.update(final_tree_verified=True, slots_filled=len(mapping), phase="focus") attempt.update(final_tree_verified=True, slots_filled=len(mapping), phase="focus")
# WHY : quitter le helper après SIGCONT ajoute une transition
# Wayland pendant la validation finale. CONTRACT : finir sa vie
# possédée sous suspension, contrôler le focus avant de le choisir.
# INVARIANT : aucune divergence ne donne droit à refocaliser.
recovery.boundary("before-guard-prepare")
guard.prepare()
recovery.boundary("after-guard-prepare")
# WHY : restaurer le checkpoint ne possède pas le focus d'un client # WHY : restaurer le checkpoint ne possède pas le focus d'un client
# tiers déjà actif. CONTRACT : son identité/emplacement de baseline # tiers déjà actif. CONTRACT : son identité/emplacement de baseline
# reste inchangé ; le focus du snapshot ne remplace que celui du lot. # reste inchangé ; le focus du snapshot ne remplace que celui du lot.
@ -324,7 +312,7 @@ class Executor:
if foreign_focus is not None: if foreign_focus is not None:
require(placement(recovery.snapshot(origin_focus)) == placement(foreign_focus), "FOREIGN_WINDOW_CHANGED") require(placement(recovery.snapshot(origin_focus)) == placement(foreign_focus), "FOREIGN_WINDOW_CHANGED")
target_focus = origin_focus target_focus = origin_focus
if target_focus is None and origin_focus is not None and data.get("focus_workspace") is None: if target_focus is None and origin_focus is not None:
# focus=null préserve la feuille réellement prouvée après mapping. # focus=null préserve la feuille réellement prouvée après mapping.
# L'identité complète reste celle de Recovery, jamais celle d'un # L'identité complète reste celle de Recovery, jamais celle d'un
# app_id ou de la pile de focus d'un split arbitraire. # app_id ou de la pile de focus d'un split arbitraire.
@ -334,25 +322,20 @@ class Executor:
"RECOVERY_FOCUS_IDENTITY_CHANGED") "RECOVERY_FOCUS_IDENTITY_CHANGED")
target_focus = origin_focus target_focus = origin_focus
if target_focus is not None: if target_focus is not None:
require(recovery.snapshot(target_focus)["scratchpad"] != "hidden", "RESTORE_FOCUS_UNAVAILABLE")
self.sway.command(f'[con_id={target_focus}] focus') self.sway.command(f'[con_id={target_focus}] focus')
else: else:
origin_workspace = data.get("focus_workspace") or recovery.data["focus_origin"]["workspace"] origin_workspace = recovery.data["focus_origin"]["workspace"]
require((origin_focus is None or data.get("focus_workspace")) and origin_workspace and origin_workspace != stage, "RESTORE_FOCUS_UNAVAILABLE") require(origin_focus is None and origin_workspace and origin_workspace != stage, "RESTORE_FOCUS_UNAVAILABLE")
self.sway.command("workspace " + json.dumps(origin_workspace)) self.sway.command("workspace " + json.dumps(origin_workspace))
final_focus = recovery.data["focus_expected"] final_focus = recovery.data["focus_expected"]
require(final_focus["workspace"] != stage, "FINAL_FOCUS_IN_STAGE") require(final_focus["workspace"] != stage, "FINAL_FOCUS_IN_STAGE")
recovery.boundary("before-helper-resume")
answer = guard.resume() answer = guard.resume()
attempt.update(helpers_resumed=answer["helpers_resumed"], phase="final-verification") attempt.update(helpers_resumed=answer["helpers_resumed"], phase="final-verification")
recovery.boundary("after-helper-resume")
self.progress("after-resume", len(mapping)) self.progress("after-resume", len(mapping))
self._verify(data, mapping, specs, anchors) self._verify(data, mapping, specs, anchors)
recovery.check_command_focus(self.sway.tree(), final_focus, "final-verification") recovery.check_command_focus(self.sway.tree(), final_focus, "final-verification")
recovery.boundary("before-guard-finish")
guard.finish() guard.finish()
guard = None guard = None
recovery.boundary("after-guard-finish")
self._verify(data, mapping, specs, anchors) self._verify(data, mapping, specs, anchors)
recovery.complete(lambda tree: self._verify_structure(data, mapping, specs, anchors, tree)) recovery.complete(lambda tree: self._verify_structure(data, mapping, specs, anchors, tree))
attempt.update(final_focus_verified=True, status="success", reason="SUCCESS", phase="complete") attempt.update(final_focus_verified=True, status="success", reason="SUCCESS", phase="complete")
@ -387,30 +370,11 @@ class Executor:
pass pass
def _floating(self, data, mapping, captured): def _floating(self, data, mapping, captured):
slots = {s["slot_id"]: s for s in data["window_slots"]} for s in data["window_slots"]:
for placement_op in compile_placements(data): if not s["state"]["floating"]: continue
s = slots[placement_op["slot_id"]]
target = mapping[s["slot_id"]] target = mapping[s["slot_id"]]
n = next((n for n in self.sway.windows() if n["id"] == target), None) n = next((n for n in self.sway.windows() if n["id"] == target), None)
require(n and n.get("pid") == captured[target].get("pid"), "TARGET_IDENTITY_CHANGED") require(n and n.get("pid") == captured[target].get("pid"), "TARGET_IDENTITY_CHANGED")
scratch = s["state"]["scratchpad"]
current = self.sway.recovery.snapshot(target)
if scratch in ("hidden", "shown"):
prefix = f'[con_id={target}] '
if not current["floating"]: self.sway.command(prefix + "floating enable")
if current["scratchpad"] != "hidden": self.sway.command(prefix + "move scratchpad")
if scratch == "shown":
self.sway.command("workspace " + json.dumps(s["workspace"]))
output = next(w["output"] for w in data["workspaces"] if w["workspace"] == s["workspace"])
self.sway.command("move workspace to output " + json.dumps(output))
self.sway.command(prefix + "scratchpad show")
# Hidden geometry can be set without showing the leaf. IPC rect
# is the contract, not the transform of a future user show.
g = s["state"]["geometry"]
self.sway.command(prefix + f'resize set width {g["width"]} px height {g["height"]} px')
self.sway.command(prefix + f'move absolute position {g["x"]} px {g["y"]} px')
continue
require(current["scratchpad"] == "none", "SCRATCHPAD_ASSIGNMENT_UNPROVEN")
self.sway.command(f'[con_id={target}] move container to workspace ' + json.dumps(s["workspace"])) self.sway.command(f'[con_id={target}] move container to workspace ' + json.dumps(s["workspace"]))
# Sway rattache une fenêtre déplacée en coordonnées à l'espace # Sway rattache une fenêtre déplacée en coordonnées à l'espace
# visible de l'output : rendre la cible visible avant sa géométrie. # visible de l'output : rendre la cible visible avant sa géométrie.
@ -422,14 +386,6 @@ class Executor:
self.sway.command(f'[con_id={target}] resize set width {g["width"]} px height {g["height"]} px') self.sway.command(f'[con_id={target}] resize set width {g["width"]} px height {g["height"]} px')
self.sway.command(f'[con_id={target}] move absolute position {g["x"]} px {g["y"]} px') self.sway.command(f'[con_id={target}] move absolute position {g["x"]} px {g["y"]} px')
# V2 can capture an empty focused workspace alongside hidden leaves.
# WHY : after launch/staging Sway may destroy it. CONTRACT : recreate
# only that explicitly persisted workspace/output, never __i3_scratch.
for w in data["workspaces"]:
if w["root"] is None and not any(s["workspace"] == w["workspace"] for s in data["window_slots"]):
self.sway.command("workspace " + json.dumps(w["workspace"]))
self.sway.command("move workspace to output " + json.dumps(w["output"]))
def _verify(self, data, mapping, specs, anchors): def _verify(self, data, mapping, specs, anchors):
# WHY : capture est une frontière externe potentiellement lente. # WHY : capture est une frontière externe potentiellement lente.
# CONTRACT : UUID exact conservé, puis nouvelle observation complète ; # CONTRACT : UUID exact conservé, puis nouvelle observation complète ;
@ -468,9 +424,7 @@ class Executor:
for s in data["window_slots"]: for s in data["window_slots"]:
n = next((n for n in nodes if n["id"] == mapping[s["slot_id"]]), None) n = next((n for n in nodes if n["id"] == mapping[s["slot_id"]]), None)
require(n and n.get("app_id") in specs[s["application_id"]].app_ids, "TARGET_IDENTITY_CHANGED") require(n and n.get("app_id") in specs[s["application_id"]].app_ids, "TARGET_IDENTITY_CHANGED")
scratch = s["state"]["scratchpad"]
require(scratchpad_state(n, location(tree, n["id"])[0]) == ("none" if scratch is False else scratch), "FINAL_SCRATCHPAD_MISMATCH")
if s["state"]["floating"]: if s["state"]["floating"]:
require(location(tree, n["id"])[0] == ("__i3_scratch" if scratch == "hidden" else s["workspace"]) and n.get("floating") in ("auto_on", "user_on") and require(location(tree, n["id"])[0] == s["workspace"] and n.get("floating") in ("auto_on", "user_on") and
all(n["rect"][k] == v for k, v in s["state"]["geometry"].items()), "FLOATING_GEOMETRY_MISMATCH") all(n["rect"][k] == v for k, v in s["state"]["geometry"].items()), "FLOATING_GEOMETRY_MISMATCH")
return tree return tree

View file

@ -97,18 +97,6 @@ class Guard:
self.process.wait(timeout=15) self.process.wait(timeout=15)
raise raise
def prepare(self):
"""Le helper d'ancres doit être sorti avant focus final et SIGCONT.
CONTRACT : ACK après disparition prouvée par pidfd, pendant notre
suspension ; finish ne provoque plus cette sortie après la reprise.
"""
self.parent.sendall(b"prepare")
self.parent.settimeout(15)
answer = loads(self.parent.recv(4096), 4096)
require(answer.get("status") == "prepared", "GUARD_RECOVERY_FAILED")
return answer
def resume(self): def resume(self):
self.parent.sendall(b"resume") self.parent.sendall(b"resume")
self.parent.settimeout(15) self.parent.settimeout(15)
@ -152,7 +140,6 @@ def run(config):
stopped = [] stopped = []
counts = {"helpers_suspended": 0, "helpers_resumed": 0} counts = {"helpers_suspended": 0, "helpers_resumed": 0}
normal = False normal = False
prepared = False
sway = Sway(config["socket"]) sway = Sway(config["socket"])
def resume(): def resume():
return resume_helpers(stopped, config["socket"], counts) return resume_helpers(stopped, config["socket"], counts)
@ -173,19 +160,7 @@ def run(config):
if config["engine_fd"] in readable: break if config["engine_fd"] in readable: break
if control in readable: if control in readable:
message = control.recv(16) message = control.recv(16)
if message == b"prepare":
require(not prepared and not any(n.get("pid") == config["anchor_pid"] and
n.get("app_id") in config["allowed"] for n in sway.windows()), "ANCHOR_CLEANUP_FAILED")
os.write(config["writer_fd"], b"quit\n")
# WHY : la réponse à kill et l'écriture quit ne prouvent pas
# la fin Wayland du processus. CONTRACT : pidfd hérité exact,
# attente bornée, aucun SIGCONT avant cette barrière.
until(lambda: bool(select.select([config["anchor_fd"]], [], [], 0)[0]), 3)
prepared = True
control.sendall(b'{"status":"prepared"}')
continue
if message == b"resume": if message == b"resume":
require(prepared, "GUARD_PROTOCOL_INVALID")
require(resume(), "GUARD_RECOVERY_FAILED") require(resume(), "GUARD_RECOVERY_FAILED")
control.sendall(json.dumps({"status": "resumed", **counts}).encode()) control.sendall(json.dumps({"status": "resumed", **counts}).encode())
continue continue
@ -196,13 +171,10 @@ def run(config):
until(lambda: bool(select.select([config["engine_fd"]], [], [], 0)[0]), 3) until(lambda: bool(select.select([config["engine_fd"]], [], [], 0)[0]), 3)
break break
def owned(n): def owned(n):
if n.get("pid") != config["anchor_pid"] or n.get("app_id") not in config["allowed"]: return False
signal.pidfd_send_signal(config["anchor_fd"], 0) signal.pidfd_send_signal(config["anchor_fd"], 0)
return True return n.get("pid") == config["anchor_pid"] and n.get("app_id") in config["allowed"]
cleaned = 0 cleaned = 0
# CONTRACT : après prepare, aucune fermeture ni écriture quit tardive. for old in sway.windows():
# Le pidfd a déjà prouvé la sortie ; ne pas dépendre d'un zombie non reapé.
for old in ([] if prepared else sway.windows()):
if not owned(old): continue if not owned(old): continue
fresh = next((n for n in sway.windows() if n["id"] == old["id"]), None) fresh = next((n for n in sway.windows() if n["id"] == old["id"]), None)
if fresh and owned(fresh): if fresh and owned(fresh):
@ -214,9 +186,8 @@ def run(config):
else: else:
sway.command(f'[con_id={fresh["id"]}] kill') sway.command(f'[con_id={fresh["id"]}] kill')
cleaned += 1 cleaned += 1
if not prepared: until(lambda: not any(owned(n) for n in sway.windows()))
until(lambda: not any(owned(n) for n in sway.windows())) os.write(config["writer_fd"], b"quit\n")
os.write(config["writer_fd"], b"quit\n")
# WHY : autotiling repris peut restructurer le stage avant la reprise # WHY : autotiling repris peut restructurer le stage avant la reprise
# et rendre son appartenance ambiguë. Finir nos obligations de fenêtres # et rendre son appartenance ambiguë. Finir nos obligations de fenêtres
# pendant la suspension possédée, puis toujours reprendre les helpers. # pendant la suspension possédée, puis toujours reprendre les helpers.

View file

@ -1,198 +0,0 @@
"""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'])

View file

@ -44,18 +44,3 @@ def compile_plan(data, tx):
emit("create", leaves(t.root)[0]) emit("create", leaves(t.root)[0])
build(t.root, root=True) build(t.root, root=True)
return tuple(plan) return tuple(plan)
def compile_placements(data):
"""Deterministic placement phase after tiling anchors, including scratchpad.
CONTRACT : this is descriptive data, not executable launch authority. Hidden
leaves have no workspace destination. V1 false means only ordinary floating;
the historical document itself is never rewritten or silently downgraded.
"""
from .schema import validate
validate(data)
return tuple({"slot_id": s["slot_id"], "workspace": s["workspace"],
"scratchpad": s["state"]["scratchpad"] or "none",
"geometry": dict(s["state"]["geometry"])}
for s in data["window_slots"] if s["state"]["floating"])

View file

@ -16,7 +16,7 @@ class Application:
class WindowSlot: class WindowSlot:
slot_id: str slot_id: str
application_id: str application_id: str
workspace: str | None workspace: str
tree_path: tuple tree_path: tuple
state: dict state: dict
identity_requirement: str identity_requirement: str

View file

@ -7,10 +7,9 @@ INVARIANT : aucun kill client, aucune propriété déduite du nom du workspace.
import copy import copy
import json import json
import re import re
import time
from pathlib import Path from pathlib import Path
from .errors import require from .errors import require
from .ipc import walk, location, workspace, until, canonical from .ipc import walk, location, workspace, until
from .storage import atomic, read from .storage import atomic, read
JOURNAL = "session-v2-recovery.json" JOURNAL = "session-v2-recovery.json"
@ -23,13 +22,6 @@ def process_start(pid):
def observe(tree, node, start): def observe(tree, node, start):
# Commands addressed to a leaf can act on its floating/scratchpad ancestor
# in Sway. Such a group is outside the single-surface ownership contract.
parents = {c["id"]: n for n in walk(tree) for c in n.get("nodes", []) + n.get("floating_nodes", [])}
ancestor = parents.get(node["id"])
while ancestor and ancestor.get("type") not in ("workspace", "output", "root"):
require(ancestor.get("scratchpad_state", "none") == "none", "SCRATCHPAD_CONTAINER_UNSUPPORTED")
ancestor = parents.get(ancestor["id"])
where = location(tree, node["id"]) where = location(tree, node["id"])
require(where and where[0], "RECOVERY_LOCATION_MISSING") require(where and where[0], "RECOVERY_LOCATION_MISSING")
output = next((o["name"] for o in tree.get("nodes", []) output = next((o["name"] for o in tree.get("nodes", [])
@ -38,51 +30,18 @@ def observe(tree, node, start):
floating = node.get("type") == "floating_con" or node.get("floating") in ("user_on", "auto_on") floating = node.get("type") == "floating_con" or node.get("floating") in ("user_on", "auto_on")
return {"con_id": node["id"], "app_id": node.get("app_id"), "pid": node.get("pid"), return {"con_id": node["id"], "app_id": node.get("app_id"), "pid": node.get("pid"),
"starttime": start(node["pid"]), "workspace": where[0], "output": output, "starttime": start(node["pid"]), "workspace": where[0], "output": output,
"path": list(where[1]), "scratchpad": scratchpad_state(node, where[0]), "floating": floating, "geometry": dict(node["rect"]) if floating else None} "path": list(where[1]), "floating": floating, "geometry": dict(node["rect"]) if floating else None}
def scratchpad_state(node, workspace_name):
"""CONTRACT : appartenance IPC et visibilité sont deux preuves distinctes."""
raw = node.get("scratchpad_state", "none")
require(raw in ("none", "fresh", "changed"), "SCRATCHPAD_STATE_UNSUPPORTED")
return "none" if raw == "none" else ("hidden" if workspace_name == "__i3_scratch" else "shown")
def placement(state): def placement(state):
return {k: v for k, v in state.items() if k != "path"} if state is not None else None return {k: v for k, v in state.items() if k != "path"} if state is not None else None
def structure(tree):
"""Preuve de topologie, avec feuilles con_id et branches ordonnées.
CONTRACT : seule la contraction des splits unaires h/v/none déjà utilisée
par Anchors est équivalente. Tabbed/stacked, branches, ordre et appartenance
restent significatifs. Les flottants sont comparés par placement exact.
WHY : un chemin brut ne permet pas de distinguer compaction et réordonnancement.
"""
identities = {n["id"]: n["id"] for n in walk(tree) if n.get("app_id") or n.get("window")}
return {n["name"]: canonical(tree, n["name"], identities) for n in walk(tree)
if n.get("type") == "workspace" and n.get("nodes")}
def command_postcondition(state, target, operation, tree): def command_postcondition(state, target, operation, tree):
expected = copy.deepcopy(state) expected = copy.deepcopy(state)
fields = ["con_id", "app_id", "pid", "starttime", "workspace", "output", "floating", "geometry"] fields = ["con_id", "app_id", "pid", "starttime", "workspace", "output", "floating", "geometry"]
if "scratchpad" in expected: fields.append("scratchpad")
if expected["con_id"] == target: if expected["con_id"] == target:
if operation == "move scratchpad": if operation.startswith("move container to workspace "):
require(state["floating"], "SCRATCHPAD_FLOATING_REQUIRED")
expected.update(workspace="__i3_scratch", output="__i3", scratchpad="hidden")
elif operation == "scratchpad show":
require(state.get("scratchpad") == "hidden", "SCRATCHPAD_SHOW_UNPROVEN")
name = focused(tree)["workspace"]
require(name and name != "__i3_scratch", "RESTORE_FOCUS_UNAVAILABLE")
expected.update(workspace=name, output=next(o["name"] for o in tree["nodes"]
if any(w.get("type") == "workspace" and w.get("name") == name for w in walk(o))), scratchpad="shown")
# Output-coordinate conversion is compositor-owned. The exact saved
# geometry is set and checked afterwards; identity/focus stay strict.
fields.remove("geometry")
elif operation.startswith("move container to workspace "):
expected["workspace"] = json.loads(operation.removeprefix("move container to workspace ")) expected["workspace"] = json.loads(operation.removeprefix("move container to workspace "))
destination = workspace(tree, expected["workspace"]) destination = workspace(tree, expected["workspace"])
if destination: if destination:
@ -98,9 +57,7 @@ def command_postcondition(state, target, operation, tree):
expected["floating"] = operation == "floating enable" expected["floating"] = operation == "floating enable"
if expected["floating"]: if expected["floating"]:
if not state["floating"]: fields.remove("geometry") if not state["floating"]: fields.remove("geometry")
else: else: expected["geometry"] = None
expected["geometry"] = None
if "scratchpad" in expected: expected["scratchpad"] = "none"
elif operation.startswith("resize set "): elif operation.startswith("resize set "):
values = [int(v) for v in re.findall(r"-?\d+", operation)] values = [int(v) for v in re.findall(r"-?\d+", operation)]
g = expected["geometry"] g = expected["geometry"]
@ -205,26 +162,7 @@ def command_focus(tree, target, operation):
ws = workspace(tree, before["workspace"]) ws = workspace(tree, before["workspace"])
if leaves == [target] and ws and all(n["id"] == target for n in walk(ws) if n.get("app_id") or n.get("window")): if leaves == [target] and ws and all(n["id"] == target for n in walk(ws) if n.get("app_id") or n.get("window")):
return {"con_id": None, "workspace": before["workspace"]} return {"con_id": None, "workspace": before["workspace"]}
if operation == "scratchpad show": if operation == "focus": return at(target)
require(target in nodes and scratchpad_state(nodes[target], location(tree, target)[0]) == "hidden", "SCRATCHPAD_SHOW_UNPROVEN")
return {"con_id": target, "workspace": before["workspace"]}
if operation == "move scratchpad":
require(target in nodes and (nodes[target].get("app_id") or nodes[target].get("window")), "SCRATCHPAD_CONTAINER_UNSUPPORTED")
source = location(tree, target)[0]
if source == "__i3_scratch": return before
if nodes[target].get("scratchpad_state", "none") == "none":
# root_scratchpad_add_container always selects the inactive node of
# the source workspace, even when an unrelated workspace had focus.
# With floating leaf ownership there is no tiled parent fallback.
require(nodes[target].get("floating") in ("auto_on", "user_on"), "SCRATCHPAD_FLOATING_REQUIRED")
ws = workspace(tree, source)
return inactive(ws, target) or {"con_id": None, "workspace": source}
# Floating leaves only: the removal uses the same inactive focus stack
# as move-to-workspace. No split ancestor is implicitly admitted.
return command_focus(tree, target, 'move container to workspace "__i3_scratch"')
if operation == "focus":
require(target in nodes and location(tree, target)[0] != "__i3_scratch", "FOCUS_HIDDEN_SCRATCHPAD")
return at(target)
if operation.startswith("workspace "): if operation.startswith("workspace "):
name = json.loads(operation.removeprefix("workspace ")) name = json.loads(operation.removeprefix("workspace "))
ws = workspace(tree, name) ws = workspace(tree, name)
@ -247,7 +185,7 @@ def command_focus(tree, target, operation):
def validate(value): def validate(value):
require(type(value) is dict and value.get("schema") == "labfy.session-v2-recovery" and type(value.get("version")) is int and value.get("version") in (1, 2), require(type(value) is dict and value.get("schema") == "labfy.session-v2-recovery" and value.get("version") == 1,
"RECOVERY_SCHEMA") "RECOVERY_SCHEMA")
require(value.get("status") in ("active", "complete", "recovered", "required"), "RECOVERY_SCHEMA") require(value.get("status") in ("active", "complete", "recovered", "required"), "RECOVERY_SCHEMA")
require(type(value.get("windows")) is list and len(value["windows"]) <= 256, "RECOVERY_SCHEMA") require(type(value.get("windows")) is list and len(value["windows"]) <= 256, "RECOVERY_SCHEMA")
@ -258,16 +196,12 @@ def validate(value):
require(value.get("stage") == "labfy-v2-stage-" + value["transaction_id"], "RECOVERY_SCHEMA") require(value.get("stage") == "labfy-v2-stage-" + value["transaction_id"], "RECOVERY_SCHEMA")
def text(v): return type(v) is str and 0 < len(v) <= 256 and not any(ord(c) < 32 for c in v) def text(v): return type(v) is str and 0 < len(v) <= 256 and not any(ord(c) < 32 for c in v)
def state(v): def state(v):
require(type(v) is dict and set(v) == ({"con_id", "app_id", "pid", "starttime", "workspace", "output", "floating", "geometry", "path"} | ({"scratchpad"} if value["version"] == 2 else set())), "RECOVERY_SCHEMA") require(type(v) is dict and set(v) == {"con_id", "app_id", "pid", "starttime", "workspace", "output", "floating", "geometry", "path"}, "RECOVERY_SCHEMA")
for k in ("con_id", "pid", "starttime"): for k in ("con_id", "pid", "starttime"):
require(type(v[k]) is int and 0 < v[k] < 2**63, "RECOVERY_SCHEMA") require(type(v[k]) is int and 0 < v[k] < 2**63, "RECOVERY_SCHEMA")
require(v["app_id"] is None or text(v["app_id"]), "RECOVERY_SCHEMA") require(v["app_id"] is None or text(v["app_id"]), "RECOVERY_SCHEMA")
require(text(v["workspace"]) and text(v["output"]) and type(v["floating"]) is bool, "RECOVERY_SCHEMA") require(text(v["workspace"]) and text(v["output"]) and type(v["floating"]) is bool, "RECOVERY_SCHEMA")
require(type(v["path"]) is list and len(v["path"]) <= 64 and all(type(i) is int and 0 <= i <= 256 for i in v["path"]), "RECOVERY_SCHEMA") require(type(v["path"]) is list and len(v["path"]) <= 64 and all(type(i) is int and 0 <= i <= 256 for i in v["path"]), "RECOVERY_SCHEMA")
if value["version"] == 2:
require(v["scratchpad"] in ("none", "hidden", "shown") and
(v["scratchpad"] == "none" or v["floating"]) and
((v["workspace"] == "__i3_scratch") == (v["scratchpad"] == "hidden")), "RECOVERY_SCHEMA")
g = v["geometry"] g = v["geometry"]
require((not v["floating"] and g is None) or (type(g) is dict and set(g) == {"x", "y", "width", "height"} require((not v["floating"] and g is None) or (type(g) is dict and set(g) == {"x", "y", "width", "height"}
and all(type(x) is int and abs(x) < 2**31 for x in g.values())), "RECOVERY_SCHEMA") and all(type(x) is int and abs(x) < 2**31 for x in g.values())), "RECOVERY_SCHEMA")
@ -285,48 +219,7 @@ def validate(value):
require(type(f) is dict and set(f) == {"con_id", "workspace"}, "RECOVERY_SCHEMA") require(type(f) is dict and set(f) == {"con_id", "workspace"}, "RECOVERY_SCHEMA")
require(f["con_id"] is None or (type(f["con_id"]) is int and 0 < f["con_id"] < 2**63), "RECOVERY_SCHEMA") require(f["con_id"] is None or (type(f["con_id"]) is int and 0 < f["con_id"] < 2**63), "RECOVERY_SCHEMA")
require(f["workspace"] is None or text(f["workspace"]), "RECOVERY_SCHEMA") require(f["workspace"] is None or text(f["workspace"]), "RECOVERY_SCHEMA")
if "focus_boundaries" in value:
rows = value["focus_boundaries"]
require(type(rows) is list and len(rows) <= 8, "RECOVERY_SCHEMA")
for row in rows:
require(type(row) is dict and set(row) == {"boundary", "expected", "observed"} and
text(row["boundary"]), "RECOVERY_SCHEMA")
for key in ("expected", "observed"):
f = row[key]
require(type(f) is dict and set(f) == {"con_id", "workspace"}, "RECOVERY_SCHEMA")
require(f["con_id"] is None or (type(f["con_id"]) is int and 0 < f["con_id"] < 2**63), "RECOVERY_SCHEMA")
require(f["workspace"] is None or text(f["workspace"]), "RECOVERY_SCHEMA")
for key in ("focus_pending", "focus_conflict"): require(type(value.get(key)) is bool, "RECOVERY_SCHEMA") for key in ("focus_pending", "focus_conflict"): require(type(value.get(key)) is bool, "RECOVERY_SCHEMA")
if "path_reconciliations" in value:
rows = value["path_reconciliations"]
require(type(rows) is list and len(rows) <= 4, "RECOVERY_SCHEMA")
for batch in rows:
require(type(batch) is list and len(batch) <= 256, "RECOVERY_SCHEMA")
for row in batch:
require(type(row) is dict and set(row) == {"con_id", "before", "after"} and
type(row["con_id"]) is int and 0 < row["con_id"] < 2**63, "RECOVERY_SCHEMA")
for key in ("before", "after"):
path = row[key]
require(type(path) is list and len(path) <= 64 and
all(type(i) is int and 0 <= i <= 256 for i in path), "RECOVERY_SCHEMA")
# CONTRACT : extension facultative V1 ; l'absence dans un ancien journal
# n'autorise jamais à reconstruire une preuve depuis l'arbre post-incident.
if "structure_expected" in value:
shapes = value["structure_expected"]
require(type(shapes) is dict and len(shapes) <= 256 and all(text(k) for k in shapes), "RECOVERY_SCHEMA")
budget = [4096]
def shape(node, depth=0):
budget[0] -= 1
require(budget[0] >= 0 and depth <= 64, "RECOVERY_SCHEMA")
if type(node) is int:
require(0 < node < 2**63, "RECOVERY_SCHEMA")
return
require(type(node) is dict and len(node) == 1, "RECOVERY_SCHEMA")
layout, children = next(iter(node.items()))
require(layout in ("splith", "splitv", "none", "tabbed", "stacked") and
type(children) is list and len(children) <= 256, "RECOVERY_SCHEMA")
for child in children: shape(child, depth + 1)
for node in shapes.values(): shape(node)
return value return value
@ -343,7 +236,7 @@ class Recovery:
tree = sway.tree() tree = sway.tree()
if proof is not None: if proof is not None:
require(sway.session == proof[0] and focused(tree) == proof[1][0], "RESTORE_FOCUS_UNSTABLE") require(sway.session == proof[0] and focused(tree) == proof[1][0], "RESTORE_FOCUS_UNSTABLE")
self.data = {"schema": "labfy.session-v2-recovery", "version": 2, self.data = {"schema": "labfy.session-v2-recovery", "version": 1,
"session": sway.session, "transaction_id": tx, "stage": stage, "session": sway.session, "transaction_id": tx, "stage": stage,
"status": "active", "windows": [], "foreign": [], "operations": 0, "status": "active", "windows": [], "foreign": [], "operations": 0,
"focus_origin": focused(tree), "focus_expected": focused(tree), "focus_origin": focused(tree), "focus_expected": focused(tree),
@ -364,12 +257,7 @@ class Recovery:
def snapshot(self, identity, tree=None): def snapshot(self, identity, tree=None):
tree = self.sway.tree() if tree is None else tree tree = self.sway.tree() if tree is None else tree
node = next((n for n in walk(tree) if n["id"] == identity and (n.get("app_id") or n.get("window"))), None) node = next((n for n in walk(tree) if n["id"] == identity and (n.get("app_id") or n.get("window"))), None)
state = observe(tree, node, self.start) if node else None return observe(tree, node, self.start) if node else None
if state and self.data["version"] == 1:
# Historical journals never proved scratchpad membership. Refuse an
# observed scratchpad rather than synthesizing absent evidence.
require(state.pop("scratchpad") == "none", "LEGACY_SCRATCHPAD_UNPROVEN")
return state
def pin(self, node): def pin(self, node):
"""Observation runtime avant une attente externe, sans autorité de focus. """Observation runtime avant une attente externe, sans autorité de focus.
@ -459,14 +347,6 @@ class Recovery:
self.data["operations"] += 1 self.data["operations"] += 1
self.publish() self.publish()
answer = self.sway.command(command) answer = self.sway.command(command)
if operation in ("floating enable", "scratchpad show"):
# WHY : IPC reports pending rect before Wayland configure/commit;
# a late initial configure can otherwise undo the next resize.
# CONTRACT : bounded drain only where geometry is explicitly
# compositor-owned; all predicted fields and focus are still checked
# below, before publishing the confirmed observation. No retries of
# a divergent command and no relaxation of final geometry equality.
time.sleep(.3)
if operation == "kill" and target is not None: if operation == "kill" and target is not None:
# Une réponse à kill n'est pas le retrait Wayland. Attendre l'anchor # Une réponse à kill n'est pas le retrait Wayland. Attendre l'anchor
# possédée évite de journaliser un chemin encore transitoire. # possédée évite de journaliser un chemin encore transitoire.
@ -485,28 +365,10 @@ class Recovery:
require(all(after[k] == v for k, v in expected_after[str(after["con_id"])].items()), "RECOVERY_POSTCONDITION_FAILED") require(all(after[k] == v for k, v in expected_after[str(after["con_id"])].items()), "RECOVERY_POSTCONDITION_FAILED")
entry.update(expected=after, pending=False) entry.update(expected=after, pending=False)
self.check_command_focus(tree, expected_focus, command) self.check_command_focus(tree, expected_focus, command)
# CONTRACT : cette preuve est post-réponse RUN_COMMAND ; elle décrit self.data.update(focus_expected=focused(tree), focus_pending=False)
# l'observation, pas une exclusion de l'input concurrent.
self.data["intention"]["focus_after"] = focused(tree)
self.data.update(focus_expected=focused(tree), focus_pending=False, structure_expected=structure(tree))
self.publish() self.publish()
return answer return answer
def boundary(self, name):
"""Observation durable bornée aux transitions du watchdog.
WHY : SIGCONT acquitté ne prouve pas le drainage des événements d'un
client IPC. CONTRACT : comparer avant toute nouvelle mutation ; aucune
attribution helper/utilisateur ni nouvelle autorité de focus.
"""
tree = self.sway.tree()
rows = self.data.setdefault("focus_boundaries", [])
require(len(rows) < 8, "RECOVERY_OPERATION_LIMIT")
rows.append({"boundary": name, "expected": copy.deepcopy(self.data["focus_expected"]),
"observed": focused(tree)})
self.publish()
self.check_command_focus(tree, self.data["focus_expected"], name)
def verify(self): def verify(self):
tree = self.sway.tree() tree = self.sway.tree()
ws = workspace(tree, self.data["stage"]) ws = workspace(tree, self.data["stage"])
@ -580,8 +442,6 @@ class Recovery:
expected = command_postcondition(observed, target, operation, tree) if observed else None expected = command_postcondition(observed, target, operation, tree) if observed else None
expected_focus = command_focus(tree, target, operation) expected_focus = command_focus(tree, target, operation)
self.check_focus_preservation(tree, expected_focus, text) self.check_focus_preservation(tree, expected_focus, text)
require("structure_expected" not in self.data or structure(tree) == self.data["structure_expected"],
"RECOVERY_STRUCTURE_CHANGED")
require(self.data["operations"] < 4096 and len(text) <= 2048, "RECOVERY_OPERATION_LIMIT") require(self.data["operations"] < 4096 and len(text) <= 2048, "RECOVERY_OPERATION_LIMIT")
entry = next((e for e in self.data["windows"] if observed and e["origin"]["con_id"] == observed["con_id"]), None) entry = next((e for e in self.data["windows"] if observed and e["origin"]["con_id"] == observed["con_id"]), None)
if entry is not None: if entry is not None:
@ -607,7 +467,7 @@ class Recovery:
self.data["intention"]["observed_after"] = after self.data["intention"]["observed_after"] = after
self.check_command_focus(tree, expected_focus, text) self.check_command_focus(tree, expected_focus, text)
self.data["intention"]["focus_after"] = focused(tree) self.data["intention"]["focus_after"] = focused(tree)
self.data.update(focus_expected=focused(tree), focus_pending=False, structure_expected=structure(tree)) self.data.update(focus_expected=focused(tree), focus_pending=False)
self.publish() self.publish()
def check_focus_preservation(self, tree, expected, command): def check_focus_preservation(self, tree, expected, command):
@ -685,35 +545,6 @@ class Recovery:
"reason": "RECOVERY_OBSERVATION_UNAVAILABLE"} for e in durable["windows"] if not e["recovered"]] "reason": "RECOVERY_OBSERVATION_UNAVAILABLE"} for e in durable["windows"] if not e["recovered"]]
return self.receipt("RECOVERY_REQUIRED", windows, ["RECOVERY_OBSERVATION_UNAVAILABLE"]) return self.receipt("RECOVERY_REQUIRED", windows, ["RECOVERY_OBSERVATION_UNAVAILABLE"])
def equivalent_paths(self, tree):
"""Réconcilier seulement une équivalence structurelle déjà prouvée.
INVARIANT : lot entier, identités, placement et branches doivent rester
exacts ; aucune intention pending n'est réinterprétée. L'origine et les
preuves d'incident restent immuables, même pour une reprise sans IPC.
"""
if (self.data["focus_pending"] or any(e["pending"] for e in self.data["windows"]) or
"structure_expected" not in self.data or structure(tree) != self.data["structure_expected"]):
return
expected = [e["expected"] for e in self.data["windows"]] + self.data["foreign"]
nodes = [n for n in walk(tree) if n.get("app_id") or n.get("window")]
if {n["id"] for n in nodes} != {s["con_id"] for s in expected}: return
current = {n["id"]: observe(tree, n, self.start) for n in nodes}
if any(placement(current[s["con_id"]]) != placement(s) for s in expected): return
changed = [{"con_id": e["origin"]["con_id"], "before": list(e["expected"]["path"]),
"after": list(current[e["origin"]["con_id"]]["path"])} for e in self.data["windows"]
if e["expected"]["path"] != current[e["origin"]["con_id"]]["path"]]
if not changed: return
# WHY : réconcilier expected ne doit pas effacer la preuve exacte de la
# commande confirmée. CONTRACT : historique avant/après durable et borné ;
# à saturation refuser la normalisation, jamais écraser un ancien lot.
history = self.data.setdefault("path_reconciliations", [])
if len(history) >= 4: return
history.append(changed)
for entry in self.data["windows"]:
entry["expected"] = current[entry["origin"]["con_id"]]
self.publish()
def _recover(self): def _recover(self):
if self.data["status"] in ("complete", "recovered"): if self.data["status"] in ("complete", "recovered"):
return {"status": self.data["status"], "windows": [], "issues": []} return {"status": self.data["status"], "windows": [], "issues": []}
@ -724,23 +555,15 @@ class Recovery:
"workspace": origin["workspace"], "output": origin["output"], "reason": reason, "workspace": origin["workspace"], "output": origin["output"], "reason": reason,
"inspect": "swaymsg -t get_tree -r", "stage": self.data["stage"], "inspect": "swaymsg -t get_tree -r", "stage": self.data["stage"],
"instruction": "Vérifier identité et emplacement avec get_tree ; après autorisation humaine seulement, " "instruction": "Vérifier identité et emplacement avec get_tree ; après autorisation humaine seulement, "
+ ("Scratchpad : examiner le journal avant toute commande." if origin.get("scratchpad", "none") != "none" else + f'[con_id={origin["con_id"]}] move container to workspace ' + json.dumps(origin["workspace"])})
f'[con_id={origin["con_id"]}] move container to workspace ' + json.dumps(origin["workspace"]))})
if self.sway.session != self.data["session"]: if self.sway.session != self.data["session"]:
for e in self.data["windows"]: issue(e, "SESSION_CHANGED") for e in self.data["windows"]: issue(e, "SESSION_CHANGED")
issues.append("SESSION_CHANGED") issues.append("SESSION_CHANGED")
else: else:
tree = self.sway.tree() recovery_focus = focused(self.sway.tree())
recovery_focus = focused(tree)
if recovery_focus != self.data["focus_expected"]: if recovery_focus != self.data["focus_expected"]:
self.record_focus_conflict({"phase": "recovery-entry", "command": None, self.record_focus_conflict({"phase": "recovery-entry", "command": None,
"before": self.data["focus_expected"], "observed": recovery_focus}) "before": self.data["focus_expected"], "observed": recovery_focus})
# WHY : un changement de layout peut garder tous les chemins bruts
# identiques. CONTRACT : la preuve canonique, lorsqu'elle existe,
# doit aussi précéder une reprise, même sans normalisation de chemin.
structure_changed = ("structure_expected" in self.data and
structure(tree) != self.data["structure_expected"])
self.equivalent_paths(tree)
for e in self.data["windows"]: for e in self.data["windows"]:
if e["recovered"]: continue if e["recovered"]: continue
try: try:
@ -750,22 +573,8 @@ class Recovery:
self.publish() self.publish()
continue continue
if e["pending"]: issue(e, "AMBIGUOUS_INTENTION"); continue if e["pending"]: issue(e, "AMBIGUOUS_INTENTION"); continue
if structure_changed: issue(e, "STRUCTURE_OR_ORDER_CHANGED"); continue if current != e["expected"]: issue(e, "IDENTITY_OR_MEMBERSHIP_CHANGED"); continue
if current != e["expected"]:
issue(e, "PATH_EQUIVALENCE_NOT_PROVEN" if placement(current) == placement(e["expected"])
else "IDENTITY_OR_MEMBERSHIP_CHANGED")
continue
origin = e["origin"] origin = e["origin"]
if origin.get("scratchpad", "none") != "none" or current.get("scratchpad", "none") != "none":
if placement(current) == placement(origin):
e.update(recovered=True, pending=False)
self.publish()
else:
# WHY : no original output transform is persisted for
# a hidden leaf. CONTRACT : keep the client and exact
# journal; never show it as a speculative rollback.
issue(e, "SCRATCHPAD_RECOVERY_REQUIRES_REVIEW")
continue
active = {o["name"] for o in self.sway.outputs() if o.get("active")} active = {o["name"] for o in self.sway.outputs() if o.get("active")}
if origin["output"] not in active: issue(e, "OUTPUT_UNAVAILABLE"); continue if origin["output"] not in active: issue(e, "OUTPUT_UNAVAILABLE"); continue
existing = workspace(self.sway.tree(), origin["workspace"]) existing = workspace(self.sway.tree(), origin["workspace"])

View file

@ -1,495 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:labfy:sway:session-v2:2",
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"version",
"metadata",
"outputs",
"applications",
"window_slots",
"workspaces",
"focus",
"focus_workspace"
],
"properties": {
"schema": {
"const": "labfy.sway.session-v2"
},
"version": {
"const": 2,
"type": "integer"
},
"metadata": {
"type": "object",
"additionalProperties": false,
"required": [
"created_ns",
"sway_session",
"reason"
],
"properties": {
"created_ns": {
"type": "integer",
"minimum": 0,
"maximum": 9223372036854775807
},
"sway_session": {
"$ref": "#/$defs/id"
},
"reason": {
"enum": [
"manual",
"logout",
"reboot",
"poweroff"
]
}
}
},
"outputs": {
"type": "array",
"maxItems": 256,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
"properties": {
"name": {
"$ref": "#/$defs/place"
}
}
}
},
"applications": {
"type": "array",
"maxItems": 256,
"items": {
"$ref": "#/$defs/application"
}
},
"window_slots": {
"type": "array",
"maxItems": 256,
"items": {
"$ref": "#/$defs/slot"
}
},
"workspaces": {
"type": "array",
"maxItems": 256,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"workspace",
"output",
"root"
],
"properties": {
"workspace": {
"$ref": "#/$defs/place"
},
"output": {
"$ref": "#/$defs/place"
},
"root": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/$defs/tree"
}
]
}
}
}
},
"focus": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/$defs/id"
}
]
},
"focus_workspace": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/$defs/place"
}
]
}
},
"$defs": {
"id": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$"
},
"place": {
"type": "string",
"pattern": "^[A-Za-z0-9 _:-]{1,64}$",
"not": {
"const": "__i3_scratch"
}
},
"application": {
"type": "object",
"additionalProperties": false,
"required": [
"application_id",
"desktop_entry",
"strategy",
"expected_windows",
"managed_by",
"identity_provider"
],
"properties": {
"application_id": {
"$ref": "#/$defs/id"
},
"desktop_entry": {
"allOf": [
{
"$ref": "#/$defs/id"
},
{
"pattern": "\\.desktop$"
}
]
},
"strategy": {
"enum": [
"managed-autostart",
"single-window",
"multi-instance",
"terminal",
"browser-self-restore",
"unknown"
]
},
"expected_windows": {
"type": "integer",
"minimum": 1,
"maximum": 256
},
"managed_by": {
"enum": [
null,
"autostart",
"sway-autostart"
]
},
"identity_provider": {
"enum": [
null,
"firefox-session-window-uuid"
]
}
}
},
"geometry": {
"type": "object",
"additionalProperties": false,
"required": [
"x",
"y",
"width",
"height"
],
"properties": {
"x": {
"type": "integer",
"minimum": -100000,
"maximum": 100000
},
"y": {
"type": "integer",
"minimum": -100000,
"maximum": 100000
},
"width": {
"type": "integer",
"minimum": 1,
"maximum": 32768
},
"height": {
"type": "integer",
"minimum": 1,
"maximum": 32768
}
}
},
"evidence": {
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"additionalProperties": false,
"required": [
"type",
"id"
],
"properties": {
"type": {
"const": "firefox-session-window-uuid"
},
"id": {
"type": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"type",
"id"
],
"properties": {
"type": {
"const": "application-singleton"
},
"id": {
"$ref": "#/$defs/id"
}
}
}
]
},
"slot": {
"type": "object",
"additionalProperties": false,
"required": [
"slot_id",
"application_id",
"workspace",
"tree_path",
"state",
"identity_requirement",
"identity_evidence"
],
"properties": {
"slot_id": {
"$ref": "#/$defs/id"
},
"application_id": {
"$ref": "#/$defs/id"
},
"workspace": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/$defs/place"
}
]
},
"tree_path": {
"type": "array",
"maxItems": 32,
"items": {
"type": "integer",
"minimum": 0,
"maximum": 31
}
},
"identity_requirement": {
"enum": [
"exact",
"best-effort",
"interchangeable"
]
},
"identity_evidence": {
"$ref": "#/$defs/evidence"
},
"state": {
"type": "object",
"additionalProperties": false,
"required": [
"floating",
"geometry",
"fullscreen",
"scratchpad"
],
"properties": {
"floating": {
"type": "boolean"
},
"geometry": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/$defs/geometry"
}
]
},
"fullscreen": {
"const": false
},
"scratchpad": {
"enum": [
"none",
"hidden",
"shown"
]
}
}
}
},
"allOf": [
{
"if": {
"properties": {
"state": {
"properties": {
"scratchpad": {
"const": "hidden"
}
}
}
}
},
"then": {
"properties": {
"workspace": {
"type": "null"
}
}
},
"else": {
"properties": {
"workspace": {
"$ref": "#/$defs/place"
}
}
}
},
{
"if": {
"properties": {
"state": {
"properties": {
"scratchpad": {
"enum": [
"hidden",
"shown"
]
}
}
}
}
},
"then": {
"properties": {
"state": {
"properties": {
"floating": {
"const": true
}
}
}
}
}
},
{
"if": {
"properties": {
"state": {
"properties": {
"floating": {
"const": true
}
}
}
}
},
"then": {
"properties": {
"tree_path": {
"maxItems": 0
},
"state": {
"properties": {
"geometry": {
"$ref": "#/$defs/geometry"
}
}
}
}
},
"else": {
"properties": {
"state": {
"properties": {
"geometry": {
"type": "null"
}
}
}
}
}
}
]
},
"tree": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": [
"slot_id"
],
"properties": {
"slot_id": {
"$ref": "#/$defs/id"
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": [
"layout",
"children"
],
"properties": {
"layout": {
"enum": [
"splith",
"splitv",
"tabbed",
"stacked"
]
},
"children": {
"type": "array",
"minItems": 2,
"maxItems": 32,
"items": {
"$ref": "#/$defs/tree"
}
}
}
}
]
}
}
}

View file

@ -32,8 +32,8 @@ def array(value, maximum=256):
def validate(data): def validate(data):
fields(data, "schema version metadata outputs applications window_slots workspaces focus" + (" focus_workspace" if type(data) is dict and data.get("version") == 2 else "")) fields(data, "schema version metadata outputs applications window_slots workspaces focus")
require(data["schema"] == SCHEMA and type(data["version"]) is int and data["version"] in (1, 2), "SCHEMA_VERSION") require(data["schema"] == SCHEMA and type(data["version"]) is int and data["version"] == 1, "SCHEMA_VERSION")
fields(data["metadata"], "created_ns sway_session reason") fields(data["metadata"], "created_ns sway_session reason")
integer(data["metadata"]["created_ns"], 0, 2**63 - 1) integer(data["metadata"]["created_ns"], 0, 2**63 - 1)
text(data["metadata"]["sway_session"]) text(data["metadata"]["sway_session"])
@ -43,7 +43,6 @@ def validate(data):
for o in data["outputs"]: for o in data["outputs"]:
fields(o, "name") fields(o, "name")
text(o["name"], PLACE) text(o["name"], PLACE)
require(data["version"] == 1 or o["name"] != "__i3_scratch", "SCRATCHPAD_WORKSPACE")
require(o["name"] not in outputs, "DUPLICATE_OUTPUT") require(o["name"] not in outputs, "DUPLICATE_OUTPUT")
outputs[o["name"]] = o outputs[o["name"]] = o
for a in data["applications"]: for a in data["applications"]:
@ -61,7 +60,7 @@ def validate(data):
fields(s, "slot_id application_id workspace tree_path state identity_requirement identity_evidence") fields(s, "slot_id application_id workspace tree_path state identity_requirement identity_evidence")
text(s["slot_id"]) text(s["slot_id"])
text(s["application_id"]) text(s["application_id"])
if s["workspace"] is not None: text(s["workspace"], PLACE) text(s["workspace"], PLACE)
array(s["tree_path"], 32) array(s["tree_path"], 32)
for i in s["tree_path"]: integer(i, 0, 31) for i in s["tree_path"]: integer(i, 0, 31)
require(s["application_id"] in apps and s["slot_id"] not in slots, "SLOT_REFERENCE") require(s["application_id"] in apps and s["slot_id"] not in slots, "SLOT_REFERENCE")
@ -69,18 +68,8 @@ def validate(data):
state = s["state"] state = s["state"]
fields(state, "floating geometry fullscreen scratchpad") fields(state, "floating geometry fullscreen scratchpad")
require(type(state["floating"]) is bool, "SCHEMA_BOOLEAN") require(type(state["floating"]) is bool, "SCHEMA_BOOLEAN")
require(type(state["fullscreen"]) is bool and state["fullscreen"] is False, require(type(state["fullscreen"]) is bool and state["fullscreen"] is False and state["scratchpad"] is False,
"CAPABILITY_UNSUPPORTED") "CAPABILITY_UNSUPPORTED")
# CONTRACT : V1 reste immuable. V2 distingue appartenance et visibilité ;
# le workspace interne Sway n'est jamais une destination persistante.
if data["version"] == 1:
require(state["scratchpad"] is False, "CAPABILITY_UNSUPPORTED")
else:
require(type(state["scratchpad"]) is str and state["scratchpad"] in ("none", "hidden", "shown"), "SCHEMA_SCRATCHPAD")
require(state["scratchpad"] == "none" or state["floating"], "SCRATCHPAD_FLOATING_REQUIRED")
hidden = state["scratchpad"] == "hidden"
require((s["workspace"] is None) == hidden, "SCRATCHPAD_WORKSPACE")
require(s["workspace"] != "__i3_scratch", "SCRATCHPAD_WORKSPACE")
if state["floating"]: if state["floating"]:
fields(state["geometry"], "x y width height") fields(state["geometry"], "x y width height")
for k in ("x", "y"): integer(state["geometry"][k], -100000, 100000) for k in ("x", "y"): integer(state["geometry"][k], -100000, 100000)
@ -119,24 +108,18 @@ def validate(data):
for w in data["workspaces"]: for w in data["workspaces"]:
fields(w, "workspace output root") fields(w, "workspace output root")
text(w["workspace"], PLACE) text(w["workspace"], PLACE)
require(w["workspace"] != "__i3_scratch", "SCRATCHPAD_WORKSPACE")
text(w["output"], PLACE) text(w["output"], PLACE)
require(w["workspace"] not in workspaces and w["output"] in outputs, "WORKSPACE_REFERENCE") require(w["workspace"] not in workspaces and w["output"] in outputs, "WORKSPACE_REFERENCE")
workspaces[w["workspace"]] = w workspaces[w["workspace"]] = w
tree(w["root"], w["workspace"]) tree(w["root"], w["workspace"])
for s in slots.values(): for s in slots.values():
require(s["state"]["scratchpad"] == "hidden" or s["workspace"] in workspaces and (s["state"]["floating"] or s["slot_id"] in seen), "UNPLACED_SLOT") require(s["workspace"] in workspaces and (s["state"]["floating"] or s["slot_id"] in seen), "UNPLACED_SLOT")
for name in workspaces: for name in workspaces:
require(any(s["workspace"] == name for s in slots.values()) or data.get("focus_workspace") == name, "EMPTY_WORKSPACE_UNSUPPORTED") require(any(s["workspace"] == name for s in slots.values()), "EMPTY_WORKSPACE_UNSUPPORTED")
for a in apps.values(): for a in apps.values():
members = [s for s in slots.values() if s["application_id"] == a["application_id"]] members = [s for s in slots.values() if s["application_id"] == a["application_id"]]
require(len(members) == a["expected_windows"], "APPLICATION_CARDINALITY") require(len(members) == a["expected_windows"], "APPLICATION_CARDINALITY")
uuids = [s["identity_evidence"]["id"] for s in members if s["identity_evidence"] and s["identity_evidence"]["type"] == PROVIDER] uuids = [s["identity_evidence"]["id"] for s in members if s["identity_evidence"] and s["identity_evidence"]["type"] == PROVIDER]
require(len(uuids) == len(set(uuids)), "EXACT_WINDOW_IDENTITY_COLLISION") require(len(uuids) == len(set(uuids)), "EXACT_WINDOW_IDENTITY_COLLISION")
require(data["focus"] is None or type(data["focus"]) is str and data["focus"] in slots, "FOCUS_REFERENCE") require(data["focus"] is None or type(data["focus"]) is str and data["focus"] in slots, "FOCUS_REFERENCE")
require(data["focus"] is None or slots[data["focus"]]["state"]["scratchpad"] != "hidden", "FOCUS_HIDDEN_SCRATCHPAD")
if data["version"] == 2:
name = data["focus_workspace"]
require(name is None or type(name) is str and name in workspaces, "FOCUS_WORKSPACE_REFERENCE")
require(data["focus"] is None or name is not None and slots[data["focus"]]["workspace"] == name, "FOCUS_WORKSPACE_REFERENCE")
return data return data

View file

@ -3,7 +3,7 @@ from .errors import require
from .schema import PROVIDER from .schema import PROVIDER
def assign(slots, nodes, identities=None, states=None): def assign(slots, nodes, identities=None):
require(len(nodes) >= len(slots), "APPLICATION_WINDOWS_INCOMPLETE") require(len(nodes) >= len(slots), "APPLICATION_WINDOWS_INCOMPLETE")
require(len(nodes) <= len(slots), "UNEXPECTED_EXTRA_WINDOW") require(len(nodes) <= len(slots), "UNEXPECTED_EXTRA_WINDOW")
result, remaining = {}, {n["id"] for n in nodes} result, remaining = {}, {n["id"] for n in nodes}
@ -21,19 +21,7 @@ def assign(slots, nodes, identities=None, states=None):
require(target in remaining, "EXACT_WINDOW_IDENTITY_COLLISION") require(target in remaining, "EXACT_WINDOW_IDENTITY_COLLISION")
result[s["slot_id"]] = target result[s["slot_id"]] = target
remaining.remove(target) remaining.remove(target)
# WHY : interchangeable is an explicit loss of semantic identity, never a # Ordre numérique runtime déterministe, jamais une preuve sémantique.
# permission to expose an arbitrary hidden peer of the same application. for s, target in zip(sorted((s for s in slots if s["slot_id"] not in result), key=lambda x: x["slot_id"]), sorted(remaining)):
# CONTRACT : preserve matching visibility first, deterministic runtime tie
# break; exact UUID/singleton assignments above remain authoritative.
states = states or {}
pending = sorted((s for s in slots if s["slot_id"] not in result),
key=lambda s: (s.get("state", {}).get("scratchpad", "none") != "hidden", s["slot_id"]))
for s in pending:
desired = s.get("state", {}).get("scratchpad", "none") or "none"
matching = [i for i in remaining if states.get(i, {}).get("scratchpad", "none") == desired]
candidates = matching or [i for i in remaining if states.get(i, {}).get("scratchpad", "none") == "none"]
require(candidates, "SCRATCHPAD_ASSIGNMENT_UNPROVEN")
target = min(candidates)
result[s["slot_id"]] = target result[s["slot_id"]] = target
remaining.remove(target)
return result return result

View file

@ -2,7 +2,7 @@
import time import time
from .errors import require from .errors import require
from .schema import validate, PROVIDER from .schema import validate, PROVIDER
from .ipc import walk, location from .ipc import walk
def capture(sway, catalog, provider, reason="manual"): def capture(sway, catalog, provider, reason="manual"):
@ -11,14 +11,13 @@ def capture(sway, catalog, provider, reason="manual"):
# Les titres, tokens et métadonnées applicatives ne participent pas à # Les titres, tokens et métadonnées applicatives ne participent pas à
# cette empreinte éphémère. Détecter aussi déplacement/focus durant capture. # cette empreinte éphémère. Détecter aussi déplacement/focus durant capture.
return (node.get("id"), node.get("layout"), node.get("name") if node.get("type") in ("workspace", "output") else None, return (node.get("id"), node.get("layout"), node.get("name") if node.get("type") in ("workspace", "output") else None,
node.get("app_id"), node.get("window"), node.get("pid"), node.get("scratchpad_state", "none"), node.get("focused"), node.get("fullscreen_mode"), node.get("floating"), node.get("rect"), node.get("focused"), node.get("fullscreen_mode"), node.get("floating"), node.get("rect"),
tuple(topology(c) for c in node.get("nodes", [])), tuple(topology(c) for c in node.get("floating_nodes", []))) tuple(topology(c) for c in node.get("nodes", [])), tuple(topology(c) for c in node.get("floating_nodes", [])))
nodes = [n for n in walk(tree) if n.get("app_id") or n.get("window")] nodes = [n for n in walk(tree) if n.get("app_id") or n.get("window")]
require(len(nodes) <= 256, "WINDOW_LIMIT") require(len(nodes) <= 256, "WINDOW_LIMIT")
specs = {} specs = {}
for n in nodes: for n in nodes:
require(not n.get("fullscreen_mode"), "CAPABILITY_UNSUPPORTED") require(not n.get("fullscreen_mode") and n.get("scratchpad_state", "none") == "none", "CAPABILITY_UNSUPPORTED")
require(n.get("scratchpad_state", "none") in ("none", "fresh", "changed"), "SCRATCHPAD_STATE_UNSUPPORTED")
specs[n["id"]] = catalog.classify(n) specs[n["id"]] = catalog.classify(n)
apps = {} apps = {}
identities = {} identities = {}
@ -33,16 +32,6 @@ def capture(sway, catalog, provider, reason="manual"):
identities.update({v: k for k, v in provider.capture(members).items()}) identities.update({v: k for k, v in provider.capture(members).items()})
slots, workspaces = [], [] slots, workspaces = [], []
focused = None focused = None
selected = [n for n in walk(tree) if n.get("focused")]
# Une layer-shell (notamment le dialogue de logout) peut prendre le focus
# et ne laisser aucune feuille/workspace marqué focused dans GET_TREE.
# Capturer explicitement l'inconnu, jamais déduire une cible d'une pile IPC.
require(len(selected) <= 1, "SNAPSHOT_FOCUS_AMBIGUOUS")
focus_workspace = None
if selected:
focus_location = location(tree, selected[0]["id"])
require(focus_location and focus_location[0] and focus_location[0] != "__i3_scratch", "SNAPSHOT_FOCUS_AMBIGUOUS")
focus_workspace = focus_location[0]
def leaf(n, workspace, path, floating): def leaf(n, workspace, path, floating):
nonlocal focused nonlocal focused
app = specs[n["id"]] app = specs[n["id"]]
@ -54,14 +43,9 @@ def capture(sway, catalog, provider, reason="manual"):
requirement, evidence = "exact", {"type": "application-singleton", "id": app.application_id} requirement, evidence = "exact", {"type": "application-singleton", "id": app.application_id}
else: requirement, evidence = "interchangeable", None else: requirement, evidence = "interchangeable", None
if n.get("focused"): focused = slot_id if n.get("focused"): focused = slot_id
scratchpad = "none" if n.get("scratchpad_state", "none") == "none" else ("hidden" if workspace is None else "shown")
require(workspace is not None or scratchpad == "hidden", "SCRATCHPAD_STATE_UNSUPPORTED")
require(scratchpad == "none" or floating, "SCRATCHPAD_FLOATING_REQUIRED")
if floating:
require(type(n.get("rect")) is dict and all(k in n["rect"] for k in ("x", "y", "width", "height")), "SNAPSHOT_GEOMETRY_MISSING")
geometry = {k: n["rect"][k] for k in ("x", "y", "width", "height")} if floating else None geometry = {k: n["rect"][k] for k in ("x", "y", "width", "height")} if floating else None
slots.append({"slot_id": slot_id, "application_id": app.application_id, "workspace": workspace, slots.append({"slot_id": slot_id, "application_id": app.application_id, "workspace": workspace,
"tree_path": list(path), "state": {"floating": floating, "geometry": geometry, "fullscreen": False, "scratchpad": scratchpad}, "tree_path": list(path), "state": {"floating": floating, "geometry": geometry, "fullscreen": False, "scratchpad": False},
"identity_requirement": requirement, "identity_evidence": evidence}) "identity_requirement": requirement, "identity_evidence": evidence})
return {"slot_id": slot_id} return {"slot_id": slot_id}
def convert(n, workspace, path=()): def convert(n, workspace, path=()):
@ -75,27 +59,20 @@ def capture(sway, catalog, provider, reason="manual"):
outputs = [] outputs = []
for output in tree.get("nodes", []): for output in tree.get("nodes", []):
for ws in output.get("nodes", []): for ws in output.get("nodes", []):
if ws.get("type") != "workspace" or not (ws.get("nodes") or ws.get("floating_nodes") or ws.get("name") == focus_workspace): continue if ws.get("type") != "workspace" or not (ws.get("nodes") or ws.get("floating_nodes")): continue
if ws["name"] == "__i3_scratch": require(ws["name"] != "__i3_scratch", "CAPABILITY_UNSUPPORTED")
require(not ws.get("nodes"), "SCRATCHPAD_CONTAINER_UNSUPPORTED")
for f in ws.get("floating_nodes", []):
require(f.get("app_id") or f.get("window"), "SCRATCHPAD_CONTAINER_UNSUPPORTED")
leaf(f, None, (), True)
continue
root = convert(ws, ws["name"]) root = convert(ws, ws["name"])
for f in ws.get("floating_nodes", []): for f in ws.get("floating_nodes", []):
children = [n for n in walk(f) if n.get("app_id") or n.get("window")] children = [n for n in walk(f) if n.get("app_id") or n.get("window")]
require(len(children) == 1, "FLOATING_CONTAINER_UNSUPPORTED") require(len(children) == 1, "FLOATING_CONTAINER_UNSUPPORTED")
require(children[0] is f, "FLOATING_CONTAINER_UNSUPPORTED")
leaf(children[0], ws["name"], (), True) leaf(children[0], ws["name"], (), True)
workspaces.append({"workspace": ws["name"], "output": output["name"], "root": root}) workspaces.append({"workspace": ws["name"], "output": output["name"], "root": root})
if output["name"] not in outputs: outputs.append(output["name"]) if output["name"] not in outputs: outputs.append(output["name"])
require(len(slots) == len(nodes), "SNAPSHOT_UNPLACED_WINDOW")
# Relecture : aucun checkpoint hybride si une fenêtre apparaît/disparaît. # Relecture : aucun checkpoint hybride si une fenêtre apparaît/disparaît.
require({n["id"] for n in sway.windows()} == set(specs), "SNAPSHOT_CHANGED") require({n["id"] for n in sway.windows()} == set(specs), "SNAPSHOT_CHANGED")
require(topology(sway.tree()) == topology(tree), "SNAPSHOT_CHANGED") require(topology(sway.tree()) == topology(tree), "SNAPSHOT_CHANGED")
data = {"schema": "labfy.sway.session-v2", "version": 2, data = {"schema": "labfy.sway.session-v2", "version": 1,
"metadata": {"created_ns": time.time_ns(), "sway_session": sway.session, "reason": reason}, "metadata": {"created_ns": time.time_ns(), "sway_session": sway.session, "reason": reason},
"outputs": [{"name": n} for n in outputs], "applications": list(apps.values()), "outputs": [{"name": n} for n in outputs], "applications": list(apps.values()),
"window_slots": slots, "workspaces": workspaces, "focus": focused, "focus_workspace": focus_workspace} "window_slots": slots, "workspaces": workspaces, "focus": focused}
return validate(data) return validate(data)

View file

@ -11,37 +11,6 @@ QtObject {
property var client: null property var client: null
property bool startupChooserVisible: false property bool startupChooserVisible: false
readonly property bool busy: operation !== "" || backendProcess.running 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) signal completed(var owner, string kind, var value, bool okay)
// Diagnostic lecture seule ; aucun endpoint IPC ne peut cliquer Restore. // Diagnostic lecture seule ; aucun endpoint IPC ne peut cliquer Restore.
@ -49,14 +18,14 @@ QtObject {
target: "sessionV2" target: "sessionV2"
function status(): string { function status(): string {
return JSON.stringify({backend: "v2", busy: service.busy, return JSON.stringify({backend: "v2", busy: service.busy,
operation: service.operation, keyringState: service.keyringState, keyringReason: service.keyringReason, startupChooserCount: service.startupChooserVisible ? 1 : 0}); operation: service.operation, startupChooserCount: service.startupChooserVisible ? 1 : 0});
} }
} }
// Réserver la sérialisation avant de retirer la surface. Le Timer du // Réserver la sérialisation avant de retirer la surface. Le Timer du
// dialogue n'est pas propriétaire d'une seconde commande backend. // dialogue n'est pas propriétaire d'une seconde commande backend.
function prepareRestore(owner) { function prepareRestore(owner) {
if (busy || !owner || !keyringReady) return false; if (busy || !owner) return false;
client = owner; client = owner;
operation = "release-focus"; operation = "release-focus";
return true; return true;
@ -77,7 +46,6 @@ QtObject {
// INVARIANT : un unique Process sérialise les commandes V2 de QuickShell. // INVARIANT : un unique Process sérialise les commandes V2 de QuickShell.
function run(kind, name, extra, owner) { function run(kind, name, extra, owner) {
if (busy) return false; 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"]; 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; if (allowed.indexOf(kind) < 0) return false;
const argv = ["python3", "-B", backend]; const argv = ["python3", "-B", backend];

View file

@ -186,7 +186,6 @@ class HelperAnchors:
class HelperGuard: class HelperGuard:
ready = dict(helpers_suspended=0) ready = dict(helpers_suspended=0)
def __init__(self, *args): pass def __init__(self, *args): pass
def prepare(self): return dict(status="prepared")
def resume(self): return dict(helpers_resumed=0) def resume(self): return dict(helpers_resumed=0)
def finish(self): return dict(helpers_resumed=0, anchors_cleaned=0) def finish(self): return dict(helpers_resumed=0, anchors_cleaned=0)
@ -221,39 +220,6 @@ class ExtendedTests(unittest.TestCase):
self.assertEqual(focused(sway.tree()), dict(con_id=6, workspace='1')) self.assertEqual(focused(sway.tree()), dict(con_id=6, workspace='1'))
self.assertEqual(recovery_status(tmp)['status'], 'complete') self.assertEqual(recovery_status(tmp)['status'], 'complete')
self.assertFalse(status(Path(tmp), sway.session, fixture(), True)['deferred']) self.assertFalse(status(Path(tmp), sway.session, fixture(), True)['deferred'])
def test_watchdog_boundary_focus_changes_refuse_without_refocusing(self):
for phase, boundary in (('prepare', 'after-guard-prepare'),
('resume', 'after-helper-resume'),
('finish', 'after-guard-finish')):
with self.subTest(phase=phase), tempfile.TemporaryDirectory() as tmp:
sway = MutationSway(6)
sway.runtime = Path(tmp)
class ChangedGuard(HelperGuard):
def change(self):
for n in walk(sway.value): n['focused'] = n['id'] == 5
def prepare(self):
if phase == 'prepare': self.change()
return super().prepare()
def resume(self):
if phase == 'resume': self.change()
return super().resume()
def finish(self):
if phase == 'finish': self.change()
return super().finish()
with patch('session_v2.executor.Anchors', HelperAnchors), patch('session_v2.executor.Guard', ChangedGuard):
with self.assertRaisesRegex(Failure, 'RECOVERY_FOCUS_MISMATCH'):
Executor(sway, Catalog(), None, Path(tmp), '/forbidden', process_identity=lambda pid: pid).apply(fixture(), True)
report = read(Path(tmp)/'session-v2-restore-attempt.json')
journal = read(Path(tmp)/JOURNAL)
self.assertTrue(report['final_tree_verified'])
self.assertFalse(report['final_focus_verified'])
self.assertEqual(journal['focus_conflict_observation']['command'], boundary)
self.assertEqual(journal['focus_boundaries'][-1]['boundary'], boundary)
self.assertEqual(focused(sway.tree()), dict(con_id=5, workspace='10'))
# After the unexpected focus, even recovery emits no refocus.
expected = 0 if phase == 'prepare' else 1
self.assertEqual(sum(text == '[con_id=6] focus' for text in sway.commands), expected)
def test_snapshot_focus_retained_when_baseline_belongs_to_batch(self): def test_snapshot_focus_retained_when_baseline_belongs_to_batch(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
sway = MutationSway(5) sway = MutationSway(5)

View file

@ -60,14 +60,6 @@ QtObject {
property QtObject stdout property QtObject stdout
property QtObject stderr property QtObject stderr
signal exited(int exitCode, int exitStatus) 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/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 }') (self.dir / 'sessionui/IpcHandler.qml').write_text('import QtQuick\nQtObject { property string target }')
@ -93,8 +85,6 @@ Item {
self.assertIsNotNone(self.root, '\n'.join(e.toString() for e in self.component.errors())) self.assertIsNotNone(self.root, '\n'.join(e.toString() for e in self.component.errors()))
self.prompt = self.root.findChild(QObject, 'prompt') self.prompt = self.root.findChild(QObject, 'prompt')
self.service = self.root.property('service') 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): def tearDown(self):
self.root.deleteLater(); self.engine.deleteLater(); QTest.qWait(1); self.tmp.cleanup() self.root.deleteLater(); self.engine.deleteLater(); QTest.qWait(1); self.tmp.cleanup()
def call(self, name): QMetaObject.invokeMethod(self.prompt, name, Qt.DirectConnection) def call(self, name): QMetaObject.invokeMethod(self.prompt, name, Qt.DirectConnection)
@ -250,24 +240,4 @@ Item {
self.finish('startup-status', dict(eligible=True, claimed=True, interrupted=True)) self.finish('startup-status', dict(eligible=True, claimed=True, interrupted=True))
self.assertEqual(self.calls()[-1]['kind'], 'restore-status') 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() if __name__ == '__main__': unittest.main()

View file

@ -1,66 +0,0 @@
#!/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 = '''<node>
<interface name="org.freedesktop.Secret.Service">
<method name="ReadAlias"><arg type="s" direction="in"/><arg type="o" direction="out"/></method>
<method name="Unlock"><arg type="ao" direction="in"/><arg type="ao" direction="out"/><arg type="o" direction="out"/></method>
</interface>
<interface name="org.freedesktop.Secret.Collection"><property name="Locked" type="b" access="read"/></interface>
<interface name="org.freedesktop.Secret.Prompt">
<method name="Prompt"><arg type="s" direction="in"/></method><method name="Dismiss"/>
<signal name="Completed"><arg type="b"/><arg type="v"/></signal>
</interface></node>'''
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()

View file

@ -1,58 +0,0 @@
"""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()

View file

@ -1,126 +0,0 @@
"""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()

View file

@ -13,7 +13,6 @@ from unittest.mock import patch
from common import * from common import *
from lab import Lab, Surfaces, build_helper from lab import Lab, Surfaces, build_helper
from session_v2.executor import Executor from session_v2.executor import Executor
from session_v2.guard import Guard
from session_v2.applications import launch as real_launch from session_v2.applications import launch as real_launch
from dataclasses import replace from dataclasses import replace
from session_v2.recovery import JOURNAL, focused, process_start, Recovery, recovery_status from session_v2.recovery import JOURNAL, focused, process_start, Recovery, recovery_status
@ -123,7 +122,7 @@ def run_case(case):
return value return value
foreign = Surfaces(lab, binary) foreign = Surfaces(lab, binary)
foreign_node = None foreign_node = None
if case in ('focus-third-party', 'preserve-foreign', 'focus-external-late'): if case in ('focus-third-party', 'preserve-foreign'):
lab.command('workspace "TEST_ONLY_OTHER"') lab.command('workspace "TEST_ONLY_OTHER"')
foreign_node = foreign.create('TEST_ONLY_foreign') foreign_node = foreign.create('TEST_ONLY_foreign')
lab.command('workspace "1"') lab.command('workspace "1"')
@ -190,36 +189,6 @@ def run_case(case):
return {UUID: nodes[0]['id']} return {UUID: nodes[0]['id']}
def capture(self, nodes): return {UUID: nodes[0]['id']} def capture(self, nodes): return {UUID: nodes[0]['id']}
runtime = private_dir(lab.directory / 'runtime') runtime = private_dir(lab.directory / 'runtime')
helper = lab.spawn(['/usr/bin/python', '/usr/bin/autotiling', '--debug']) if case == 'autotiling' else None
def helpers(path):
# Only this private child is eligible. Never discover the host's
# /proc helpers, including when running an actual autotiling.
assert path == lab.socket
return [dict(pid=helper.pid, fd=os.pidfd_open(helper.pid), script='/usr/bin/autotiling')] if helper else []
class ObservedGuard(Guard):
def prepare(self):
answer = super().prepare()
assert self.anchors.process.poll() is not None, 'ANCHOR_NOT_EXITED_BEFORE_RESUME'
evidence['anchor_exited_before_resume'] = True
return answer
def __init__(self, sway, anchors, *args, **kwargs):
self.anchors = anchors
super().__init__(sway, anchors, *args, **kwargs)
def resume(self):
answer = super().resume()
if case in ('focus-helper-late', 'focus-external-late'):
kitty = next(n for n in sway.windows() if n['app_id'] == 'kitty')
target = foreign_node['id'] if case == 'focus-external-late' else kitty['id']
lab.command(f'[con_id={target}] focus')
evidence['injected_focus'] = focused(sway.tree())
return answer
def finish(self):
answer = super().finish()
if case == 'focus-finish-late':
kitty = next(n for n in sway.windows() if n['app_id'] == 'kitty')
lab.command(f'[con_id={kitty["id"]}] focus')
evidence['injected_focus'] = focused(sway.tree())
return answer
raw_command = sway.command raw_command = sway.command
def command(text): def command(text):
row = {'command': text, 'ns': time.monotonic_ns(), 'before': focused(sway.tree())} row = {'command': text, 'ns': time.monotonic_ns(), 'before': focused(sway.tree())}
@ -241,7 +210,7 @@ def run_case(case):
finally: row['after'] = focused(sway.tree()) finally: row['after'] = focused(sway.tree())
sway.command = command sway.command = command
try: try:
with patch('session_v2.executor.launch', side_effect=launch_surface), patch('session_v2.guard.discover', side_effect=helpers), patch('session_v2.executor.Guard', ObservedGuard): with patch('session_v2.executor.launch', side_effect=launch_surface), patch('session_v2.guard.discover', return_value=[]):
if case == 'launch-failure-retry': if case == 'launch-failure-retry':
bad_catalog = Catalog(tuple(replace(spec, argv=(str(lab.directory / 'TEST_ONLY_NO_EXECUTABLE'),)) bad_catalog = Catalog(tuple(replace(spec, argv=(str(lab.directory / 'TEST_ONLY_NO_EXECUTABLE'),))
if spec.application_id == 'firefox' else spec if spec.application_id == 'firefox' else spec
@ -276,7 +245,7 @@ def run_case(case):
# Aucun kill d'un client ; les kill du guard sont sur le helper. # Aucun kill d'un client ; les kill du guard sont sur le helper.
assert not any(row['command'] == f'[con_id={music["id"]}] kill' for row in evidence['commands']) assert not any(row['command'] == f'[con_id={music["id"]}] kill' for row in evidence['commands'])
assert not any(n['app_id'].startswith('labfy-v2-anchor-') for n in sway.windows()) assert not any(n['app_id'].startswith('labfy-v2-anchor-') for n in sway.windows())
if case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign', 'autotiling'): if case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign'):
# Le contrat de recette exige un vrai succès. Un refus sûr # Le contrat de recette exige un vrai succès. Un refus sûr
# est conservé comme BLOCKED et retourne un code non nul. # est conservé comme BLOCKED et retourne un code non nul.
evidence['requirement_met'] = (evidence['outcome'] == 'success' and evidence['requirement_met'] = (evidence['outcome'] == 'success' and
@ -292,18 +261,8 @@ def run_case(case):
assert focused(sway.tree()) == (evidence['preserved_focus'] if case in ('preserve-empty-workspace', 'preserve-foreign') else {'con_id': firefox['id'], 'workspace': '1'}) assert focused(sway.tree()) == (evidence['preserved_focus'] if case in ('preserve-empty-workspace', 'preserve-foreign') else {'con_id': firefox['id'], 'workspace': '1'})
assert not any(location(sway.tree(), n['id'])[0] == journal['stage'] for n in sway.windows()) assert not any(location(sway.tree(), n['id'])[0] == journal['stage'] for n in sway.windows())
assert focused(sway.tree())['workspace'] != journal['stage'] assert focused(sway.tree())['workspace'] != journal['stage']
elif case in ('focus-helper-late', 'focus-external-late', 'focus-finish-late'):
assert evidence['outcome'] == 'RECOVERY_FOCUS_MISMATCH', evidence['outcome']
assert report['anchors_swapped'] == 3 and report['slots_filled'] == 3
assert report['final_tree_verified'] and not report['final_focus_verified']
assert journal['status'] == 'required' and journal['focus_conflict']
assert focused(sway.tree()) == evidence['injected_focus']
boundary = 'after-guard-finish' if case == 'focus-finish-late' else 'after-helper-resume'
assert journal['focus_conflict_observation']['command'] == boundary
assert recovery_status(runtime)['status'] == 'RECOVERY_REQUIRED'
evidence['requirement_met'] = True
else: else:
expected = {'unowned-map' : 'APPLICATION_MEMBERSHIP_CHANGED', expected = {'unowned-map': 'APPLICATION_MEMBERSHIP_CHANGED',
'provider-placement': 'TARGET_OBSERVATION_CHANGED', 'provider-placement': 'TARGET_OBSERVATION_CHANGED',
'pinned-map': 'APPLICATION_MEMBERSHIP_CHANGED', 'pinned-map': 'APPLICATION_MEMBERSHIP_CHANGED',
'map-in-ipc-gap': 'RECOVERY_FOCUS_MISMATCH', 'map-in-ipc-gap': 'RECOVERY_FOCUS_MISMATCH',
@ -341,7 +300,7 @@ def run_case(case):
def main(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('--case', choices=['natural','launch-failure-retry','preserve-empty-workspace','preserve-foreign','focus-third-party','focus-same-client','unowned-map','pinned-map','map-in-ipc-gap','provider-placement','crash-mid-ipc','autotiling','focus-helper-late','focus-external-late','focus-finish-late'], default='natural') parser.add_argument('--case', choices=['natural','launch-failure-retry','preserve-empty-workspace','preserve-foreign','focus-third-party','focus-same-client','unowned-map','pinned-map','map-in-ipc-gap','provider-placement','crash-mid-ipc'], default='natural')
args = parser.parse_args() args = parser.parse_args()
row = run_case(args.case) row = run_case(args.case)
print(json.dumps({'scenario': args.case, 'requirement_met': row['requirement_met'], print(json.dumps({'scenario': args.case, 'requirement_met': row['requirement_met'],

View file

@ -21,7 +21,6 @@ log = Path(os.environ['LABFY_UI_LOG'])
with log.open('a') as s: s.write(cmd + '\\n') with log.open('a') as s: s.write(cmd + '\\n')
mode = os.environ['LABFY_UI_MODE'] mode = os.environ['LABFY_UI_MODE']
if cmd == 'startup-status': r = {'eligible': mode != 'claimed', 'claimed': mode != 'claimed', 'windows': 3, 'workspaces': 2} 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': 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} 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'} if mode == 'failure': r = {'status':'failed', 'reason':'APPLICATION_WINDOWS_INCOMPLETE'}
@ -87,9 +86,7 @@ ShellRoot {
def main(): def main():
rows = [] rows = []
from test_keyring import KeyringTests with Lab() as lab:
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'): for mode in ('success', 'failure', 'invalid', 'new', 'claimed', 'real', 'deferred-real'):
config = lab.directory / mode / 'config' config = lab.directory / mode / 'config'
base = config / 'quickshell/labfy-sway' base = config / 'quickshell/labfy-sway'

View file

@ -41,19 +41,6 @@ TestCase {
compare(spy.count, 0); compare(spy.count, 0);
} }
} }
function test_backend_reason_is_visible_and_logout_stays_gated() {
for (const reason of ["CAPABILITY_UNSUPPORTED", "SNAPSHOT_CHANGED", "RECOVERY_REQUIRED", "UNKNOWN_BACKEND_REASON"]) {
const g = gate();
const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"});
g.requestAction("logout"); g.confirmAction("logout");
g.checkpointFinished(2, 0, JSON.stringify({status: "failed", reason: reason}), "Le checkpoint V2 a échoué.");
verify(g.errorMessage.indexOf(reason) >= 0);
verify(g.errorMessage.length > reason.length + 10);
compare(spy.count, 0);
compare(g.pendingAction.id, "logout");
g.cancel(); compare(spy.count, 0);
}
}
function test_quit_without_saving_requires_explicit_call() { function test_quit_without_saving_requires_explicit_call() {
const g = gate(); const g = gate();
const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"}); const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"});

View file

@ -1,133 +0,0 @@
"""Private real compositor only; no inherited user socket, no helper suspension.
Retains synthetic trees and screenshots under /tmp. SIGCONT is deliberately not
used by this runner, even during cleanup. No installed configuration is read.
"""
import json
import subprocess
from unittest.mock import patch
from common import *
from lab import Lab as BaseLab, Surfaces, build_helper
from session_v2.snapshot import capture
from session_v2.ipc import Sway, location
from session_v2.executor import Executor
from session_v2.checkpoint import Store
from session_v2.storage import private_dir
from session_v2.recovery import Recovery
class Lab(BaseLab):
def __exit__(self, *_):
for proc in reversed(self.children):
if proc.poll() is None:
proc.terminate()
try: proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill(); proc.wait(timeout=3)
for log in self.logs: log.close()
def main():
with Lab() as lab:
print(lab.directory, flush=True)
binary = build(lab.directory)
sway = Sway(lab.socket)
commands = []
raw_command = sway.command
def recorded(text):
commands.append(text)
return raw_command(text)
sway.command = recorded
# Production catalog structure, synthetic IDs. No arbitrary argv launches.
static_kitty = ('/usr/bin/kitty', '--config', 'NONE', '--class', 'TEST_ONLY_kitty',
'--title', 'TEST_ONLY', '-o', 'linux_display_server=wayland',
'-o', 'confirm_os_window_close=0', '/usr/bin/sleep', '180')
cat = Catalog((Spec('kitty', 'kitty.desktop', ('TEST_ONLY_kitty',), 'terminal', static_kitty),
*(Spec(n, n+'.desktop', ('TEST_ONLY_'+n,), 'managed-autostart', ())
for n in ('shown', 'float', 'split'))))
lab.command('workspace "TEST_ONLY_TARGET"')
ids = [lab.kitty('TEST_ONLY_'+n) for n in ('kitty','kitty','shown','float','split')]
def command(i, operation): lab.command(f'[con_id={i}] '+operation)
for i in ids[:1]+ids[2:4]:
command(i, 'floating enable')
command(ids[0], 'move scratchpad')
command(ids[2], 'move scratchpad')
command(ids[2], 'scratchpad show')
# Allow GTK's real configure/ack cycle before the measured snapshot.
time.sleep(.3)
for i in ids[:1]+ids[2:4]:
command(i, 'resize set width 400 px height 240 px')
command(i, 'move absolute position 30 px 40 px')
command(ids[1], 'focus')
time.sleep(.3)
def evidence(name):
(lab.directory/(name+'.json')).write_text(json.dumps(sway.tree(),indent=2))
subprocess.run(['grim','-o','HEADLESS-1',str(lab.directory/(name+'.png'))],env=lab.env,check=True)
evidence('before')
data = capture(sway, cat, None)
store = Store(private_dir(lab.directory/'store'))
store.save('scratch',data); store.checkpoint(data)
assert store.load('scratch') == store.last() == data
(lab.directory/'snapshot.json').write_text(json.dumps(data,indent=2))
command(ids[2], 'move scratchpad')
command(ids[3], 'floating disable')
evidence('disturbed')
result = Executor(sway,cat,None,private_dir(lab.directory/'runtime'),binary,lab.env).apply(store.last(),True)
evidence('after')
(lab.directory/'result.json').write_text(json.dumps(result,indent=2))
(lab.directory/'commands.json').write_text(json.dumps(commands,indent=2))
assert not any(c.startswith(f'[con_id={ids[0]}] ') and ('workspace' in c or 'scratchpad show' in c) for c in commands)
assert result['status'] == 'success'
assert location(sway.tree(),ids[0])[0] == '__i3_scratch'
# User-like explicit show/hide after restore: prove real membership.
command(ids[0], 'scratchpad show'); time.sleep(.2); evidence('user-show')
command(ids[0], 'move scratchpad'); time.sleep(.2); evidence('user-hide')
assert location(sway.tree(),ids[0])[0] == '__i3_scratch'
# A new-session-like placement: membership must be created from normal
# leaves, with the same pinned processes and trusted static catalog.
command(ids[0], 'scratchpad show'); command(ids[0], 'floating disable')
command(ids[2], 'floating disable')
time.sleep(.3)
second = Executor(sway,cat,None,private_dir(lab.directory/'runtime-new-membership'),binary,lab.env).apply(data,True)
assert second['status'] == 'success'
evidence('new-membership')
# All hidden, with an explicitly focused empty workspace, remains a
# restorable snapshot and never persists the internal scratch workspace.
for i in ids: command(i, 'move scratchpad')
lab.command('workspace "TEST_ONLY_EMPTY"'); time.sleep(.3)
all_hidden = capture(sway,cat,None)
assert all_hidden['focus'] is None and all_hidden['focus_workspace'] == 'TEST_ONLY_EMPTY'
assert all(s['state']['scratchpad']=='hidden' for s in all_hidden['window_slots'])
third = Executor(sway,cat,None,private_dir(lab.directory/'runtime-all-hidden'),binary,lab.env).apply(all_hidden,True)
assert third['status'] == 'success'
evidence('all-hidden')
(lab.directory/'all-hidden-snapshot.json').write_text(json.dumps(all_hidden,indent=2))
# Real prewarm launches through a static test catalog, with fresh PIDs.
# Track returned children solely for TEST_ONLY cleanup; no launch is
# derived from snapshot data, titles or the former terminal process.
from session_v2.applications import launch as real_launch
old = {n['id'] for n in sway.windows() if n['app_id']=='TEST_ONLY_kitty'}
for i in old: command(i, 'kill')
lab.wait_absent(old)
launched = []
def track_launch(spec, count, env):
children = real_launch(spec,count,env)
lab.children.extend(children);launched.extend(children)
return children
with patch('session_v2.executor.launch',side_effect=track_launch):
engine=Executor(sway,cat,None,private_dir(lab.directory/'runtime-cold-kitty'),binary,lab.env)
cold=engine.apply(all_hidden,True)
assert cold['status']=='success' and len(launched)==2
assert not old.intersection(n['id'] for n in sway.windows())
assert all(location(sway.tree(),n['id'])[0]=='__i3_scratch' for n in sway.windows() if n['app_id']=='TEST_ONLY_kitty')
# A further apply reuses the admitted surfaces; no duplicate launch.
again=engine.apply(all_hidden,True)
assert again['status']=='success' and len(launched)==2
evidence('cold-kitty')
(lab.directory/'cold-result.json').write_text(json.dumps(cold,indent=2))
print('PASS save/store/restore/show/hide/new-membership/all-hidden/cold-kitty/no-duplicate', flush=True)
if __name__ == '__main__': main()

View file

@ -22,9 +22,9 @@ def frame(value):
class BootstrapTests(unittest.TestCase): class BootstrapTests(unittest.TestCase):
def setUp(self): def setUp(self):
# AF_UNIX est borné à 108 octets : un worktree profond ne doit pas base = REPO / "build/bootstrap-fixtures"
# rendre ces sockets TEST_ONLY dépendants du chemin du dépôt. base.mkdir(parents=True, exist_ok=True)
self.tmp = tempfile.TemporaryDirectory(prefix="labfy-v2-bootstrap-") self.tmp = tempfile.TemporaryDirectory(dir=base)
self.addCleanup(self.tmp.cleanup) self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name) self.root = Path(self.tmp.name)
for name in ("home", "state", "runtime"): for name in ("home", "state", "runtime"):

View file

@ -1,133 +0,0 @@
"""Incidents du 22 octobre : arbres inertes et helper headless privé seulement."""
import unittest
import tempfile
from unittest.mock import patch
from common import *
from test_prewarm import Sway
from session_v2.recovery import Recovery, JOURNAL, focused
from session_v2.ipc import walk, workspace
from session_v2.storage import read
from session_v2.errors import Failure
class FocusRecoveryTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.runtime = Path(self.tmp.name)
self.sway = Sway()
for ws in self.sway.value['nodes'][0]['nodes']: ws['layout'] = 'splith'
self.sway.add('firefox', 8, focus=True)
self.sway.add('kitty', 7, focus=False)
self.recovery = Recovery(self.sway, self.runtime, 'b'*32, 'labfy-v2-stage-'+'b'*32, start=lambda pid: 123)
for n in self.sway.windows(): self.recovery.admit(n)
self.recovery.baseline({'firefox', 'kitty', 'limusic-app'})
# Production command, independently simulated focus effect, no socket.
def command(text):
self.sway.commands.append(text)
self.assertEqual(text, '[con_id=8] focus')
for n in walk(self.sway.value): n['focused'] = n['id'] == 8
self.sway.command = command
self.recovery.command('[con_id=8] focus')
def unary(self):
ws = workspace(self.sway.value, '1')
ws['nodes'] = [dict(id=80+i, type='con', layout='splitv', nodes=[n], focused=False)
for i, n in enumerate(ws['nodes'])]
def test_firefox_command_proven_then_kitty_late_conflict_is_preserved(self):
self.assertFalse(self.recovery.data['focus_pending'])
for n in walk(self.sway.value): n['focused'] = n['id'] == 7
with self.assertRaisesRegex(Failure, 'RECOVERY_FOCUS_MISMATCH'):
self.recovery.validate_final(self.sway.tree())
first = read(self.runtime/JOURNAL)['focus_conflict_observation']
self.assertEqual(first['before'], dict(con_id=8, workspace='1'))
self.assertEqual(first['observed'], dict(con_id=7, workspace='1'))
self.sway.commands.clear()
receipt = Recovery(self.sway, self.runtime, start=lambda pid: 123).recover()
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
self.assertIn('FOCUS_RECOVERY_NOT_PROVEN', receipt['issues'])
self.assertEqual(self.sway.commands, [])
self.assertEqual(read(self.runtime/JOURNAL)['focus_conflict_observation'], first)
def test_equivalent_unary_paths_are_not_identity_changes(self):
self.unary()
# A conflict forbids IPC, but exact already-at-origin windows can be
# recognized without stealing the user's new focus or clearing incident.
self.recovery.record_focus_conflict({'phase': 'TEST_ONLY'})
self.sway.commands.clear()
receipt = Recovery(self.sway, self.runtime, start=lambda pid: 123).recover()
self.assertEqual(receipt['windows'], [])
durable = read(self.runtime/JOURNAL)
paths = {row['con_id']: row for row in durable['path_reconciliations'][0]}
self.assertEqual(paths[8], dict(con_id=8, before=[0], after=[0, 0]))
self.assertEqual(paths[7], dict(con_id=7, before=[1], after=[1, 0]))
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
self.assertEqual(self.sway.commands, [])
def test_path_evidence_limit_refuses_without_overwriting_history(self):
self.recovery.data['path_reconciliations'] = [[] for _ in range(4)]
self.unary()
self.recovery.record_focus_conflict({'phase': 'TEST_ONLY'})
receipt = self.recovery.recover()
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
self.assertTrue(any(w['reason'] == 'PATH_EQUIVALENCE_NOT_PROVEN' for w in receipt['windows']))
self.assertEqual(read(self.runtime/JOURNAL)['path_reconciliations'], [[], [], [], []])
def test_layout_change_with_identical_raw_paths_still_refuses(self):
workspace(self.sway.value, '1')['layout'] = 'splitv'
self.sway.commands.clear()
receipt = self.recovery.recover()
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
self.assertTrue(any(e['reason'] == 'STRUCTURE_OR_ORDER_CHANGED' for e in receipt['windows']))
self.assertEqual(self.sway.commands, [])
def test_interrupted_intention_preserves_all_evidence(self):
self.unary()
self.recovery.data['windows'][1]['pending'] = True
self.recovery.data['focus_pending'] = True
self.recovery.publish()
intention = copy.deepcopy(self.recovery.data['intention'])
expected = copy.deepcopy(self.recovery.data['windows'][1]['expected'])
self.sway.commands.clear()
receipt = Recovery(self.sway, self.runtime, start=lambda pid: 123).recover()
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
self.assertTrue(any(e['reason'] == 'AMBIGUOUS_INTENTION' for e in receipt['windows']))
durable = read(self.runtime/JOURNAL)
self.assertEqual(durable['intention'], intention)
self.assertEqual(durable['windows'][1]['expected'], expected)
self.assertTrue(durable['focus_pending'])
self.assertEqual(self.sway.commands, [])
def test_real_identity_order_workspace_and_branch_changes_still_refuse(self):
for mode in ('pid', 'starttime', 'app_id', 'con_id', 'order', 'workspace', 'output', 'branch', 'tabbed', 'geometry', 'floating', 'pending', 'legacy'):
with self.subTest(mode=mode):
tree = copy.deepcopy(self.sway.value)
saved = copy.deepcopy(self.recovery.data)
self.unary()
nodes = {n['id']: n for n in walk(self.sway.value)}
if mode == 'pid': nodes[8]['pid'] += 1
if mode == 'starttime': self.recovery.start = lambda pid: 999
if mode == 'app_id': nodes[8]['app_id'] = 'kitty'
if mode == 'con_id': nodes[8]['id'] = 88
if mode == 'order': workspace(self.sway.value, '1')['nodes'].reverse()
if mode == 'workspace': workspace(self.sway.value, '1')['name'] = 'OTHER'
if mode == 'output': self.sway.value['nodes'][0]['name'] = 'OTHER'
if mode == 'branch': workspace(self.sway.value, '1')['layout'] = 'splitv'
if mode == 'tabbed': nodes[80]['layout'] = 'tabbed'
if mode == 'geometry':
nodes[8].update(floating='user_on', rect=dict(x=0, y=0, width=200, height=100))
if mode == 'floating': nodes[8]['floating'] = 'user_on'
if mode == 'pending': self.recovery.data['windows'][1]['pending'] = True
if mode == 'legacy': self.recovery.data.pop('structure_expected', None)
self.recovery.record_focus_conflict({'phase': 'TEST_ONLY'})
self.sway.commands.clear()
receipt = self.recovery.recover()
self.assertEqual(receipt['status'], 'RECOVERY_REQUIRED')
self.assertTrue(receipt['windows'])
self.assertEqual(self.sway.commands, [])
self.sway.value, self.recovery.data = tree, saved
self.recovery.start = lambda pid: 123
if __name__ == '__main__': unittest.main()

View file

@ -1,74 +0,0 @@
"""Watchdog réel sur socketpair/pipes possédés ; aucun signal de session."""
import threading
import socket
import tempfile
import unittest
from unittest.mock import patch
from common import *
from session_v2.guard import run
from session_v2.storage import atomic, loads
class GuardProtocolTests(unittest.TestCase):
def exercise(self, messages):
with tempfile.TemporaryDirectory() as directory:
report = Path(directory)/'attempt.json'
atomic(report, {'status': 'running', 'anchors_cleaned': 0})
parent, child = socket.socketpair(type=socket.SOCK_SEQPACKET)
parent.settimeout(5)
engine_r, engine_w = os.pipe()
anchor_r, anchor_w = os.pipe()
writer_r, writer_w = os.pipe()
rows, replies, errors = [], [], []
def helper():
# A real pipe models the quit consumed before process exit.
rows.append(('quit', os.read(writer_r, 64)))
os.close(anchor_w)
worker = threading.Thread(target=helper)
worker.start()
config = dict(control=child.detach(), engine_fd=engine_r, anchor_fd=anchor_r,
writer_fd=writer_w, anchor_pid=99999999, allowed=[], helpers=[],
socket='/TEST_ONLY', report=str(report), deadline=5)
class Sway:
def __init__(self, path): pass
def windows(self): return []
def resume(*args): rows.append(('resume', None)); return True
def guarded():
try: run(config)
except BaseException as exc: errors.append(exc)
try:
with patch('session_v2.guard.Sway', Sway), \
patch('session_v2.guard.resume_helpers', side_effect=resume), \
patch('session_v2.guard.signal.pidfd_send_signal'), \
patch('session_v2.guard.resume_pending') as recovery:
guard = threading.Thread(target=guarded)
guard.start()
replies.append(loads(parent.recv(4096)))
for message in messages:
parent.sendall(message)
replies.append(loads(parent.recv(4096)))
if message == b'prepare':
self.assertEqual(replies[-1]['status'], 'prepared')
self.assertEqual(rows, [('quit', b'quit\n')])
if message == b'resume' and b'prepare' in messages:
self.assertEqual(replies[-1]['status'], 'resumed')
guard.join(5)
self.assertFalse(guard.is_alive())
self.assertEqual(errors, [])
if messages == [b'prepare', b'resume', b'finish']: recovery.assert_not_called()
finally:
parent.close()
worker.join(5)
for fd in (engine_r, engine_w, anchor_r, writer_r): os.close(fd)
if b'prepare' in messages: self.assertEqual(rows[0], ('quit', b'quit\n'))
return replies
def test_prepare_quits_before_resume_and_finish_has_no_delayed_exit(self):
replies = self.exercise([b'prepare', b'resume', b'finish'])
self.assertEqual([r['status'] for r in replies], ['ready', 'prepared', 'resumed', 'finished'])
def test_resume_without_prepare_refuses_and_still_cleans_owned_helper(self):
self.assertEqual(self.exercise([b'resume'])[-1]['status'], 'failed')
if __name__ == '__main__': unittest.main()

View file

@ -1,152 +0,0 @@
"""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('<busconfig><type>session</type><listen>unix:tmpdir=' + tmp + '</listen>'
'<auth>EXTERNAL</auth><policy context="default"><allow own="*"/>'
'<allow send_destination="*"/><allow receive_sender="*"/></policy></busconfig>')
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()

View file

@ -71,7 +71,7 @@ class BoundaryTests(unittest.TestCase):
parent['nodes'].remove(node) parent['nodes'].remove(node)
break break
stage = json.loads(operation.removeprefix('move container to workspace ')) stage = json.loads(operation.removeprefix('move container to workspace '))
destination = dict(id=90, type='workspace', name=stage, layout='splith', nodes=[node]) destination = dict(id=90, type='workspace', name=stage, nodes=[node])
self.sway.value['nodes'][0]['nodes'].append(destination) self.sway.value['nodes'][0]['nodes'].append(destination)
for n in walk(self.sway.value): for n in walk(self.sway.value):
n['focused'] = n['id'] == focus['con_id'] if focus['con_id'] is not None else n.get('name') == focus['workspace'] n['focused'] = n['id'] == focus['con_id'] if focus['con_id'] is not None else n.get('name') == focus['workspace']

View file

@ -105,7 +105,6 @@ class PrewarmTests(unittest.TestCase):
self.assertEqual(seen['deferred_reason'], 'APPLICATION_LAUNCH_FAILED') self.assertEqual(seen['deferred_reason'], 'APPLICATION_LAUNCH_FAILED')
output = io.StringIO() output = io.StringIO()
with patch('session_v2.cli.paths', return_value=(self.runtime/'state', self.runtime)), \ 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.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): 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) self.assertEqual(main(['apply-last', '--execute', '--socket', '/TEST_ONLY']), 2)

View file

@ -1,204 +0,0 @@
"""Scratchpad contracts against production capture/store/journal; inert IPC."""
import json
import tempfile
import unittest
from unittest.mock import patch
from common import *
from test_prewarm import Sway
from session_v2.snapshot import capture
from session_v2.checkpoint import Store
from session_v2.storage import read, private_dir
from session_v2.ipc import walk, workspace
from session_v2.errors import Failure
from session_v2.recovery import Recovery, JOURNAL, observe, command_focus, validate as validate_recovery
from session_v2.slots import assign
from session_v2.layout import compile_plan, compile_placements
def source():
sway = Sway()
for ws in sway.value['nodes'][0]['nodes']:
ws['layout'] = 'splith'; ws['nodes'] = []
sway.add('kitty', 7, focus=True)
sway.add('kitty', 8, focus=False)
sway.add('limusic-app', 9, focus=False)
root = sway.value
hidden = dict(id=80, type='workspace', name='__i3_scratch', layout='splith', nodes=[], floating_nodes=[], focused=False)
root['nodes'].insert(0, dict(id=81, type='output', name='__i3', nodes=[hidden]))
ws = workspace(root, '1')
n = next(n for n in ws['nodes'] if n['id'] == 8)
ws['nodes'].remove(n)
n.update(scratchpad_state='fresh', floating='user_on', rect=dict(x=301,y=106,width=800,height=500))
hidden['floating_nodes'].append(n)
return sway
class ScratchpadTests(unittest.TestCase):
def test_capture_store_reload_and_no_internal_workspace(self):
sway = source()
d = capture(sway, Catalog(), None)
self.assertEqual(d['version'], 2)
self.assertEqual(len(d['window_slots']), 3)
hidden = next(s for s in d['window_slots'] if s['state']['scratchpad'] == 'hidden')
self.assertIsNone(hidden['workspace'])
self.assertEqual(hidden['state']['geometry'], dict(x=301,y=106,width=800,height=500))
self.assertEqual(hidden['identity_requirement'], 'interchangeable')
self.assertEqual(d['focus'], next(s['slot_id'] for s in d['window_slots'] if s['application_id']=='kitty' and s['workspace']=='1'))
self.assertNotIn('__i3_scratch', [o['name'] for o in d['outputs']])
self.assertTrue(all(op.workspace != '__i3_scratch' for op in compile_plan(d,'a'*32)))
self.assertEqual(compile_placements(d)[0]['scratchpad'], 'hidden')
self.assertIsNone(compile_placements(d)[0]['workspace'])
with tempfile.TemporaryDirectory() as tmp:
store = Store(Path(tmp)); store.save('test',d); store.checkpoint(d)
self.assertEqual(store.last(),d); self.assertEqual(store.load('test'),d)
old = fixture(); store.save('old',old)
self.assertEqual(store.load('old'),old)
def test_capture_is_deterministic_for_a_stable_tree(self):
sway=source()
with patch('session_v2.snapshot.time.time_ns',return_value=123):
first=capture(sway,Catalog(),None)
self.assertEqual(first,capture(sway,Catalog(),None))
raw=json.dumps(first)
self.assertNotIn('con_id',raw);self.assertNotIn('starttime',raw)
self.assertNotIn('__i3_scratch',raw)
def test_shown_and_multiple_hidden(self):
sway=source(); hidden=workspace(sway.value,'__i3_scratch'); ws=workspace(sway.value,'1')
shown=hidden['floating_nodes'].pop(); ws.setdefault('floating_nodes',[]).append(shown)
for n in walk(sway.value): n['focused']=n['id']==8
d=capture(sway,Catalog(),None)
slot=next(s for s in d['window_slots'] if s['state']['scratchpad']=='shown')
self.assertEqual(slot['workspace'],'1'); self.assertEqual(d['focus'],slot['slot_id'])
for n in list(ws['nodes']):
n.update(floating='user_on',scratchpad_state='fresh',focused=False,rect=dict(x=0,y=0,width=100,height=100))
hidden['floating_nodes'].append(n)
ws['nodes']=[]
d=capture(sway,Catalog(),None)
self.assertEqual(sum(s['state']['scratchpad']=='hidden' for s in d['window_slots']),2)
def test_change_between_reads_and_ambiguous_focus_refuse(self):
sway=source(); original=sway.windows
def changed():
result=original(); next(n for n in walk(sway.value) if n['id']==8)['scratchpad_state']='none'
return result
sway.windows=changed
with self.assertRaisesRegex(Failure,'SNAPSHOT_CHANGED'):capture(sway,Catalog(),None)
sway=source(); next(n for n in walk(sway.value) if n['id']==8)['focused']=True
with self.assertRaisesRegex(Failure,'SNAPSHOT_FOCUS_AMBIGUOUS'):capture(sway,Catalog(),None)
def test_strict_versions_and_scratchpad_references(self):
d=capture(source(),Catalog(),None)
hidden=next(i for i,s in enumerate(d['window_slots']) if s['workspace'] is None)
mutations=[lambda v:v.update(version=1), lambda v:v['window_slots'][hidden].update(workspace='__i3_scratch'),
lambda v:v.update(focus=v['window_slots'][hidden]['slot_id']),
lambda v:v['window_slots'][hidden]['state'].update(scratchpad=True),
lambda v:v['window_slots'][hidden]['state'].update(floating=False)]
for change in mutations:
v=copy.deepcopy(d);change(v)
with self.assertRaises(Failure):validate(v)
try: import jsonschema
except ImportError: self.skipTest('jsonschema unavailable')
contract=json.loads((BACKEND/'session_v2/schema-v2.json').read_text())
jsonschema.Draft202012Validator(contract).validate(d)
def test_runtime_visibility_assignment_never_exposes_hidden_peer(self):
sway=source(); d=capture(sway,Catalog(),None)
slots=[s for s in d['window_slots'] if s['application_id']=='kitty']
nodes=[n for n in sway.windows() if n['app_id']=='kitty']
states={n['id']:observe(sway.tree(),n,lambda pid:123) for n in nodes}
m=assign(slots,nodes,states=states)
hidden=next(s for s in slots if s['workspace'] is None)
self.assertEqual(m[hidden['slot_id']],8)
for s in slots:s['state']['scratchpad']='none'
with self.assertRaisesRegex(Failure,'SCRATCHPAD_ASSIGNMENT_UNPROVEN'):assign(slots,nodes,states=states)
def test_journal_detects_identity_output_scratchpad_and_interruption(self):
for change in ('identity','output','scratchpad','pending'):
with self.subTest(change=change), tempfile.TemporaryDirectory() as tmp:
sway=source(); r=Recovery(sway,tmp,'a'*32,'labfy-v2-stage-'+'a'*32,start=lambda pid:123)
for n in sway.windows():r.admit(n)
r.baseline({'kitty','limusic-app'})
if change=='identity': next(n for n in walk(sway.value) if n['id']==8)['pid']=999
elif change=='output':sway.value['nodes'][0]['name']='moved'
elif change=='scratchpad': next(n for n in walk(sway.value) if n['id']==8)['scratchpad_state']='none'
else:r.data['windows'][0]['pending']=True;r.data['focus_pending']=True;r.publish()
receipt=r.recover()
self.assertEqual(receipt['status'],'RECOVERY_REQUIRED');self.assertEqual(sway.commands,[])
durable=read(Path(tmp)/JOURNAL,validate_recovery)
if change=='pending':self.assertTrue(durable['focus_pending'])
def test_hidden_focus_is_rejected_before_command(self):
sway=source()
with self.assertRaisesRegex(Failure,'FOCUS_HIDDEN_SCRATCHPAD'):command_focus(sway.tree(),8,'focus')
def test_initial_scratchpad_add_predicts_source_workspace_focus(self):
sway=source(); ws=workspace(sway.value,'__i3_scratch')
n=ws['floating_nodes'].pop(); n['scratchpad_state']='none'
workspace(sway.value,'10')['floating_nodes']=[n]
# Actual root_scratchpad_add_container semantics: even an unfocused
# floating leaf causes source-workspace selection. Not focus-neutral.
self.assertEqual(command_focus(sway.tree(),8,'move scratchpad'),dict(con_id=None,workspace='10'))
self.assertEqual(command_focus(source().tree(),8,'scratchpad show'),dict(con_id=8,workspace='1'))
def test_group_ancestor_is_not_admitted_by_leaf_identity(self):
sway=source(); ws=workspace(sway.value,'__i3_scratch')
n=ws['floating_nodes'].pop(); n['scratchpad_state']='none'
ws['floating_nodes']=[dict(id=99,type='con',scratchpad_state='fresh',nodes=[n])]
with self.assertRaisesRegex(Failure,'SCRATCHPAD_CONTAINER_UNSUPPORTED'):
observe(sway.tree(),n,lambda pid:123)
with self.assertRaisesRegex(Failure,'SCRATCHPAD_CONTAINER_UNSUPPORTED'):
capture(sway,Catalog(),None)
def test_layer_shell_can_leave_no_sway_focus(self):
# Le panneau de déconnexion peut prendre le focus Wayland et ne
# laisser aucune feuille Sway marquée focused. L'absence est une
# observation légitime, distincte de plusieurs candidats ambigus.
sway = source()
for n in walk(sway.value): n['focused'] = False
result = capture(sway, Catalog(), None)
self.assertIsNone(result['focus'])
self.assertIsNone(result['focus_workspace'])
self.assertTrue(any(s['state']['scratchpad'] == 'hidden' for s in result['window_slots']))
with tempfile.TemporaryDirectory() as tmp:
store = Store(Path(tmp))
store.checkpoint(result)
self.assertEqual(store.last(), result)
try:
import jsonschema
except ImportError:
return
contract = json.loads((BACKEND/'session_v2/schema-v2.json').read_text())
jsonschema.validate(result, contract)
def test_multiple_focused_nodes_are_still_refused(self):
sway = source()
for n in walk(sway.value):
if n.get('id') == 9: n['focused'] = True
with self.assertRaisesRegex(Failure, 'SNAPSHOT_FOCUS_AMBIGUOUS'):
capture(sway, Catalog(), None)
def test_empty_focus_workspace_is_persisted(self):
sway=source()
for n in walk(sway.value): n['focused']=n['id']==3
d=capture(sway,Catalog(),None)
self.assertIsNone(d['focus']);self.assertEqual(d['focus_workspace'],'10')
self.assertIn('10',[w['workspace'] for w in d['workspaces']])
broken=copy.deepcopy(d);broken['focus_workspace']='absent'
with self.assertRaises(Failure):validate(broken)
def test_legacy_journal_not_reinterpreted(self):
sway=source()
with tempfile.TemporaryDirectory() as tmp:
r=Recovery(sway,tmp,'a'*32,'labfy-v2-stage-'+'a'*32,start=lambda pid:123)
r.admit(next(n for n in sway.windows() if n['id']==7))
r.data['version']=1
for e in r.data['windows']:
for key in ('origin','expected'): e[key].pop('scratchpad',None)
r.publish()
old=Recovery(sway,tmp,start=lambda pid:123)
self.assertNotIn('scratchpad',old.snapshot(7))
with self.assertRaisesRegex(Failure,'LEGACY_SCRATCHPAD_UNPROVEN'):old.snapshot(8)
if __name__=='__main__':unittest.main()

View file

@ -1,52 +0,0 @@
#!/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())

View file

@ -29,7 +29,7 @@ tasks = {
'checkpoint-fixture': [sys.executable,'-B','-c',"import sys; sys.path.insert(0, 'tests/session_v2'); from login_focus import checkpoint; print(checkpoint()['focus'])"], 'checkpoint-fixture': [sys.executable,'-B','-c',"import sys; sys.path.insert(0, 'tests/session_v2'); from login_focus import checkpoint; print(checkpoint()['focus'])"],
'diff-check': ['git','diff','--check'], 'diff-check': ['git','diff','--check'],
} }
for case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign', 'focus-third-party', 'focus-same-client', 'unowned-map', 'pinned-map', 'map-in-ipc-gap', 'provider-placement', 'crash-mid-ipc', 'autotiling', 'focus-helper-late', 'focus-external-late', 'focus-finish-late'): for case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign', 'focus-third-party', 'focus-same-client', 'unowned-map', 'pinned-map', 'map-in-ipc-gap', 'provider-placement', 'crash-mid-ipc'):
tasks['login-'+case] = [sys.executable,'-B','tests/session_v2/login_focus.py','--case',case] tasks['login-'+case] = [sys.executable,'-B','tests/session_v2/login_focus.py','--case',case]
def run(item): def run(item):
name, argv = item name, argv = item

View file

@ -1,55 +0,0 @@
#!/usr/bin/env python3
"""Reproducible isolated scratchpad validation; never uses the desktop socket."""
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, help='absolute headless-capable Sway executable')
parser.add_argument('--renderer', default='pixman', choices=('pixman', 'gles2'))
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 existing absolute executable')
root = Path(__file__).resolve().parents[2]
output = Path(tempfile.mkdtemp(prefix='labfy-scratchpad-validation-', dir='/tmp'))
# CONTRACT : positive environment list; no graphical bus/socket, user state,
# test override for backend imports or configuration reaches subprocesses.
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': args.renderer,
'LABFY_SESSION_V2_EVIDENCE': str(output / 'evidence')}
for key in ('HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR'):
path = output / key
path.mkdir(mode=0o700)
env[key] = str(path)
checks = {
'backend': [sys.executable, '-B', '-m', 'unittest', 'discover', '-s', 'tests/session_v2', '-p', 'test_*.py'],
'focus': [sys.executable, '-B', 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_focus.py'],
'qml-python': [sys.executable, '-B', 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py'],
'qml-qt': ['/usr/lib/qt6/bin/qmltestrunner', '-input', 'tests/session_v2/qml', '-o', '-,txt'],
'scratchpad-runtime': [sys.executable, '-B', 'tests/session_v2/scratchpad_runtime.py'],
'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:
result = subprocess.run(argv, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=180)
results[name] = result.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())

View file

@ -47,8 +47,7 @@ def build(output, extension_only=False):
for name in ("session-v2.py", "session-v2-native-host.py"): for name in ("session-v2.py", "session-v2-native-host.py"):
archive.add(SOURCE / name, arcname=name) archive.add(SOURCE / name, arcname=name)
for p in sorted((SOURCE / "session_v2").glob("*.py")): archive.add(p, arcname="session_v2/" + p.name) for p in sorted((SOURCE / "session_v2").glob("*.py")): archive.add(p, arcname="session_v2/" + p.name)
for version in (1, 2): archive.add(SOURCE / "session_v2/schema-v1.json", arcname="session_v2/schema-v1.json")
archive.add(SOURCE / f"session_v2/schema-v{version}.json", arcname=f"session_v2/schema-v{version}.json")
archive.add(binary, arcname="session_v2/libexec/labfy-v2-anchor") archive.add(binary, arcname="session_v2/libexec/labfy-v2-anchor")
archive.add(ROOT / "firefox-extension/session-v2/native-host.json.in", arcname="native-host.json.in") archive.add(ROOT / "firefox-extension/session-v2/native-host.json.in", arcname="native-host.json.in")
manifest = {"signed": False, "installed": False, "files": {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in (binary, xpi, bundle)}} manifest = {"signed": False, "installed": False, "files": {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in (binary, xpi, bundle)}}