Compare commits
2 commits
5aeff41d2c
...
a556cd56b7
| Author | SHA1 | Date | |
|---|---|---|---|
| a556cd56b7 | |||
| d16aa69f9a |
41 changed files with 3160 additions and 73 deletions
|
|
@ -270,7 +270,86 @@ 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 et scratchpad non pris en charge actuellement.
|
- Fullscreen non pris en charge. Scratchpad pris en charge par le snapshot version 2 pour les feuilles flottantes individuelles ; les groupes restent refusés.
|
||||||
- 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).
|
||||||
|
|
|
||||||
219
docs/session-v2-focus-recovery-fix-v1.md
Normal file
219
docs/session-v2-focus-recovery-fix-v1.md
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
# 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.
|
||||||
291
docs/session-v2-keyring-before-restore-v1.md
Normal file
291
docs/session-v2-keyring-before-restore-v1.md
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
# Session V2 — trousseau avant restauration (22 octobre 2026)
|
||||||
|
|
||||||
|
État : implémentation et validation isolées ; **non déployé**, vrai login non
|
||||||
|
validé. Base `d16aa69f`, branche `fix/session-v2-keyring-before-restore-20261022`.
|
||||||
|
Le dépôt principal était propre ; aucun autre worktree n'a été modifié.
|
||||||
|
|
||||||
|
## Diagnostic et preuves disponibles
|
||||||
|
|
||||||
|
À 08:01:35 CEST, systemd démarre GNOME Keyring ; QuickShell démarre à
|
||||||
|
08:01:38. À 08:01:48 le prompter reçoit BeginPrompting, crée GcrPromptDialog,
|
||||||
|
puis commence un password prompt. L'incident fourni situe le démarrage du
|
||||||
|
prompt à 08:01:48.235 et le rapport d'échec à 08:01:48.482. Le focus attendu
|
||||||
|
Firefox/con_id 10 est remplacé par con_id 18 après les six frontières du guard.
|
||||||
|
Ces événements concordent avec une activation du trousseau pendant la fenêtre
|
||||||
|
de validation finale. **L'identité historique de con_id 18 et la causalité ne
|
||||||
|
sont pas démontrées.** Aucune capture d'arbre contemporaine avec PID/starttime
|
||||||
|
ne permet ici de les établir. Même limite pour l'occurrence de 06:11:59.
|
||||||
|
|
||||||
|
Les abonnements IPC Sway indiquent la fenêtre devenue active, pas l'origine
|
||||||
|
(user input ou commande d'un autre client IPC) d'un changement de focus. Un
|
||||||
|
app_id seul n'est pas une preuve de causalité. Une future investigation peut
|
||||||
|
corréler les événements fenêtre/workspace horodatés, con_id, PID/starttime et
|
||||||
|
les événements du prompter ; elle doit laisser « non attribué » ce qui le reste.
|
||||||
|
Ne pas activer de trace d'échange secret pour ce diagnostic.
|
||||||
|
|
||||||
|
## Audit de l'installation réelle (lecture seule)
|
||||||
|
|
||||||
|
- `/etc/pam.d/greetd` : securetty, nologin, puis inclusions auth/account/session
|
||||||
|
de system-local-login. Aucune règle gnome-keyring. SHA-256 observé :
|
||||||
|
`993a3096c2b113e6800f2abbd5d4233ebf1a97eef423990d3187d665d3490b92`.
|
||||||
|
- Inclusions : system-local-login → system-login → system-auth. pam_unix utilise
|
||||||
|
try_first_pass ; pam_systemd est dans la chaîne session avant le retour à
|
||||||
|
greetd. Les sauts numériques de system-auth ne sautent pas une règle ajoutée
|
||||||
|
après l'inclusion dans greetd. Rien de cette pile n'est modifié.
|
||||||
|
- Le greeter installé dans `/usr/local/libexec/labfy-greeter` répond aux messages
|
||||||
|
PAM avec post_auth_message_response, sur la même conversation greetd. Il ne
|
||||||
|
réauthentifie pas le mot de passe localement. Le code installé est antérieur
|
||||||
|
au sélecteur de session du dépôt mais possède ce même transport des réponses.
|
||||||
|
- Module présent : `/usr/lib/security/pam_gnome_keyring.so`, paquet
|
||||||
|
gnome-keyring `1:51.1-1`, greetd `0.10.3-2`, python-gobject `3.58.1-1`.
|
||||||
|
- Secret Service appartient à gnome-keyring-daemon. Un processus observé :
|
||||||
|
`gnome-keyring-daemon --foreground --components=pkcs11,secrets,ssh`.
|
||||||
|
ReadAlias(default) renvoie `/org/freedesktop/secrets/collection/Default_5fkeyring` ;
|
||||||
|
ReadAlias(login) renvoie `/`. Locked est false lors de la relecture finale
|
||||||
|
(cet état courant ne prouve pas son état pendant l’incident). Aucun contenu des fichiers du trousseau n'a été lu.
|
||||||
|
Les noms présents sont Default_keyring.keyring, default et user.keystore.
|
||||||
|
- L'unité utilisateur masque celle du paquet. Elle omet Requires=socket et le
|
||||||
|
`--control-directory=%t/keyring` explicite de l'unité distribuée. Son lien
|
||||||
|
d'activation est dans default.target.wants, même si Install mentionne
|
||||||
|
graphical-session.target. Le socket `%t/keyring/control` est actif.
|
||||||
|
- D-Bus possède aussi une entrée d'activation `--start --foreground
|
||||||
|
--components=secrets`. Les fichiers XDG autostart du paquet utilisent --start
|
||||||
|
et OnlyShowIn=GNOME;Unity;MATE;Cinnamon. Ils ne démontrent donc pas un second
|
||||||
|
lancement sous Sway. Aucun autre lancement n'a été trouvé dans les sources
|
||||||
|
Sway/QuickShell/greetd ; **une seule instance observée ne prouve pas l'absence
|
||||||
|
de toute course de démarrage**.
|
||||||
|
|
||||||
|
### Décision PAM : pas de patch applicable dans cette mission
|
||||||
|
|
||||||
|
L'intégration habituelle place auth optional pam_gnome_keyring.so après
|
||||||
|
l'authentification principale et session optional pam_gnome_keyring.so
|
||||||
|
auto_start après l'inclusion de session (donc après pam_systemd). Le transport
|
||||||
|
du greeter et PAM_AUTHTOK sont structurellement compatibles avec ce schéma.
|
||||||
|
Cela n'établit pas que Default_keyring soit le trousseau login, ni que son mot
|
||||||
|
de passe soit identique à celui du compte. Ces faits ne sont pas testés avec
|
||||||
|
un mot de passe dans cette mission.
|
||||||
|
|
||||||
|
Le module GNOME cible le trousseau **login**, identifié en interne par CKA_ID.
|
||||||
|
Son chemin de déverrouillage peut le créer s'il manque. L'alias login est absent,
|
||||||
|
aucun fichier login n'est listé, et le rôle interne de Default_keyring n'est pas
|
||||||
|
prouvé. Activer auto_start pourrait donc provoquer une création et ne démontre
|
||||||
|
pas le déverrouillage du default existant. Ce mécanisme est vérifié dans le
|
||||||
|
[code GNOME Keyring 51.1, unlock_or_create_login](https://raw.githubusercontent.com/GNOME/gnome-keyring/51.1/daemon/login/gkd-login.c).
|
||||||
|
La [documentation PAM GNOME](https://wiki.gnome.org/Projects/GnomeKeyring/Pam)
|
||||||
|
décrit également la cible login et les règles usuelles. **Aucun fichier PAM candidat à installer
|
||||||
|
n'est livré**, aucun essai PAM réel n'est effectué. Une validation statique de
|
||||||
|
la syntaxe de deux règles ne validerait pas ces conditions opérationnelles.
|
||||||
|
|
||||||
|
Le contrôle optional protège normalement la décision d'authentification
|
||||||
|
principale contre un échec du module ; il ne garantit pas à lui seul l'absence
|
||||||
|
d'attente ni d'effets de bord. Ne pas prendre cette analyse pour une preuve
|
||||||
|
qu'un changement PAM quelconque est sans risque.
|
||||||
|
|
||||||
|
Une future tranche PAM exige une autorisation distincte, la confirmation
|
||||||
|
humaine du trousseau cible sans enregistrer le mot de passe, et une étude de
|
||||||
|
l'unité/socket du paquet comme propriétaire unique du démon. Si un patch
|
||||||
|
compatible est alors établi, l'application privilégiée doit sauvegarder le
|
||||||
|
fichier root:root/0644, contrôler le hash de départ, installer atomiquement la
|
||||||
|
copie validée et conserver un accès de secours hors greeter. Le rollback doit
|
||||||
|
réinstaller exactement la sauvegarde, sans modifier les inclusions système.
|
||||||
|
Aucune de ces opérations ni aucun logout/reboot n'a été exécuté ici.
|
||||||
|
|
||||||
|
Aucune modification de l'unité utilisateur n'est nécessaire à la barrière
|
||||||
|
D-Bus. La normalisation de cette unité avec une future intégration PAM doit
|
||||||
|
être validée au login ; elle n'est pas appliquée sur le bureau actif.
|
||||||
|
|
||||||
|
## Contrat de la barrière
|
||||||
|
|
||||||
|
`session_v2/keyring.py` utilise PyGObject/Gio et une connexion D-Bus privée :
|
||||||
|
|
||||||
|
1. Résoudre/activer Secret Service avec une attente bornée, puis fixer son
|
||||||
|
propriétaire D-Bus unique pour toutes les opérations.
|
||||||
|
2. ReadAlias(default) doit correspondre à la collection auditée ci-dessus.
|
||||||
|
Default absent/différent est un refus explicite. Si login existe, il doit
|
||||||
|
désigner la même collection. Login absent autorise le parcours manuel ; il
|
||||||
|
ne vaut jamais preuve de compatibilité PAM. Une installation utilisant une
|
||||||
|
autre collection doit faire réauditer cette constante avant intégration.
|
||||||
|
3. Lire org.freedesktop.Secret.Collection.Locked par Properties.Get. READY
|
||||||
|
exige un vrai booléen false, avec propriétaire inchangé.
|
||||||
|
4. Un clic Déverrouiller appelle Service.Unlock. S'il retourne un Prompt,
|
||||||
|
s'abonner à Completed **avant** Prompt(""). Le window-id vide présente un
|
||||||
|
prompt non parenté, sans prétendre à un handle X11/Wayland inexistant.
|
||||||
|
5. Garder la connexion vivante, attendre Completed non annulé, puis relire
|
||||||
|
alias/propriétaire/Locked. Une fin de prompt avec Locked=true refuse READY.
|
||||||
|
6. Limites : connexion/auth D-Bus 2 s, appels individuels au plus 2 s, budget
|
||||||
|
de lecture 8 s, opération interactive 90 s, nettoyage Dismiss au plus 1 s,
|
||||||
|
puis relecture avec budget 8 s. Un timeout n'autorise rien. Un mot de passe
|
||||||
|
incorrect peut maintenir le prompt GNOME ouvert jusqu'à correction,
|
||||||
|
annulation ou timeout ; le client n'essaie jamais de deviner cette cause.
|
||||||
|
7. Dismiss/fermeture de la connexion ne concernent que le prompt possédé. Le
|
||||||
|
traitement SIGTERM réveille la boucle GLib et permet ce nettoyage. Un arrêt
|
||||||
|
brutal du processus ferme sa connexion ; il ne laisse aucune autorisation
|
||||||
|
persistante. Aucun fichier READY/token n'est produit.
|
||||||
|
|
||||||
|
Les commandes `keyring-status` et `keyring-unlock --execute` retournent
|
||||||
|
uniquement un état KEYRING_* et un code de diagnostic fixe. Aucun OpenSession,
|
||||||
|
GetSecret(s), SearchItems, changement de mot de passe, création de collection
|
||||||
|
ou lecture de fichier de trousseau n'est utilisé. Les erreurs D-Bus libres et
|
||||||
|
le payload de résultat de Completed ne sont pas journalisés.
|
||||||
|
|
||||||
|
### Concurrence et transactions
|
||||||
|
|
||||||
|
Le verrou privé `session-v2/keyring.lock` sérialise les helpers entre processus.
|
||||||
|
Une seconde demande voit KEYRING_UNLOCKING/KEYRING_OR_RESTORE_BUSY et ne crée
|
||||||
|
pas un second prompt. Avant Unlock, le helper vérifie aussi le verrou de
|
||||||
|
transaction existant (y compris celui hérité par un guard survivant).
|
||||||
|
|
||||||
|
Les CLI `apply-last` **et** `apply` prennent keyring.lock, relisent D-Bus sans
|
||||||
|
ouvrir de prompt, puis appellent l'Executor inchangé en gardant le verrou
|
||||||
|
jusqu'à son retour. Ordre : keyring.lock puis transaction.lock. Aucune voie
|
||||||
|
CLI de restauration n'est autorisée par le seul booléen du QML. Un refus de
|
||||||
|
barrière ne publie aucun Attempt/Recovery et ne nettoie aucun ancien journal.
|
||||||
|
Les appels internes directs à Executor restent une API de moteur ; ils ne
|
||||||
|
constituent pas une commande utilisateur de restauration.
|
||||||
|
|
||||||
|
La décision « Nouvelle session » conserve uniquement le verrou de transaction
|
||||||
|
historique. Elle reste donc disponible pendant une attente du trousseau.
|
||||||
|
QuickShell arrête son helper lors de ce choix. Un autre helper déjà en cours
|
||||||
|
n'est pas tué par une instance qui ne le possède pas.
|
||||||
|
|
||||||
|
### QuickShell
|
||||||
|
|
||||||
|
Le singleton démarre sa lecture indépendamment du checkpoint. Le chooser
|
||||||
|
reconstruit aussi l'état backend au redémarrage ; un déverrouillage détenu par
|
||||||
|
une autre instance est observé par polling borné des commandes, jamais relancé.
|
||||||
|
Les erreurs et annulations proposent une nouvelle action explicite. Le panneau
|
||||||
|
retire sa capture clavier avant le prompt natif ; il conserve le bouton
|
||||||
|
Nouvelle session. Après READY, seul un clic Restaurer appelle le chemin
|
||||||
|
historique de retrait de surface, prewarm, Guard et validation finale.
|
||||||
|
|
||||||
|
Le gestionnaire de sessions nommées reçoit également le refus du backend et
|
||||||
|
l'affiche sans charger un ancien rapport d'échec. Les commandes de diagnostic,
|
||||||
|
checkpoint, sauvegarde, nouvelle session et ouverture normale du bureau ne
|
||||||
|
dépendent pas d'un trousseau disponible.
|
||||||
|
|
||||||
|
## Limite tardive et invariants conservés
|
||||||
|
|
||||||
|
Secret Service peut reverrouiller une collection à tout instant. Le verrou de
|
||||||
|
la barrière ne contrôle pas les autres applications, un autre trousseau ou
|
||||||
|
un autre client Secret Service. Il ne garantit donc pas qu'aucun prompt ne
|
||||||
|
pourra jamais apparaître après l'admission. Un prompt déjà demandé par un
|
||||||
|
client externe n'est pas notre objet Prompt et ne peut être annulé arbitrairement.
|
||||||
|
La [spécification Secret Service](https://specifications.freedesktop.org/secret-service/latest/unlocking.html) précise cette course inhérente.
|
||||||
|
|
||||||
|
L'initialisation du default nécessaire à notre parcours est traitée avant la
|
||||||
|
restauration. Si une activation tardive interfère néanmoins, les mêmes preuves
|
||||||
|
con_id/PID/starttime/workspace/output et les mêmes postconditions de focus
|
||||||
|
refusent la transaction. **Aucun focus observé n'est remplacé pour fabriquer
|
||||||
|
un succès.** L'expérience headless crée une surface synthétique après
|
||||||
|
`after-guard-finish` et exige RECOVERY_FOCUS_MISMATCH, final_focus_verified=false
|
||||||
|
et RECOVERY_REQUIRED. Elle ne prétend pas identifier le dialogue de 08:01:48.
|
||||||
|
|
||||||
|
Checkpoint V1, scratchpad V2, Guard.prepare, journaux et schémas Recovery,
|
||||||
|
placement et validations de focus ne sont pas modifiés. Les tests qui isolent
|
||||||
|
le prewarm simulent explicitement la nouvelle précondition ; les nouveaux tests
|
||||||
|
D-Bus et UI testent sa vraie implémentation séparément.
|
||||||
|
|
||||||
|
## Reproduction de la validation
|
||||||
|
|
||||||
|
Depuis ce worktree :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -B tests/session_v2/validate_keyring.py --sway /chemin/absolu/vers/sway-headless
|
||||||
|
```
|
||||||
|
|
||||||
|
Dépendances locales : Python/PyGObject/Gio, dbus-daemon, QuickShell/Qt/PySide6,
|
||||||
|
Sway pouvant démarrer headless avec pixman, outils GTK/compilateur déjà exigés
|
||||||
|
par les fixtures Session V2, kitty et grim. Aucun paquet système n'a été
|
||||||
|
installé. Le SwayFX installé ne convient pas au laboratoire sans DRM ; le
|
||||||
|
binaire Sway de test conservé par la mission précédente a été réutilisé :
|
||||||
|
`/tmp/labfy-scratchpad-tool/usr/bin/sway`. Son paquet avait le SHA-256
|
||||||
|
`5704cfc9eba9804116c665cb4450352bf5a240c1a8300b36aca3218116648443`.
|
||||||
|
|
||||||
|
Le runner impose une liste positive d'environnement, HOME et XDG privés.
|
||||||
|
Chaque test graphique réel crée son propre compositeur ; chaque service simulé
|
||||||
|
possède son bus privé. Aucun socket du bureau ni état de session utilisateur
|
||||||
|
n'est transmis. Les tests QML unitaires emploient des surfaces Item simulées et
|
||||||
|
Qt offscreen ; ils ne mappent aucune fenêtre sur un bureau réel.
|
||||||
|
|
||||||
|
La matrice couvre les 20 cas demandés : déverrouillé ; verrouillé/Prompt ;
|
||||||
|
accepté avec relecture ; annulé ; mot de passe incorrect simulé par l'absence de
|
||||||
|
fin ; fin sans déverrouillage ; timeout RPC et prompt ; service absent ; default
|
||||||
|
absent/différent et login incompatible ; redémarrage helper et vrai QuickShell ;
|
||||||
|
deux demandes concurrentes ; refus apply-last ; absence d'autorestauration ;
|
||||||
|
nouvelle session ; surface tardive ; focus final stable ; RECOVERY_REQUIRED ;
|
||||||
|
régressions scratchpad/backend/focus ; erreurs contenant une sentinelle sensible
|
||||||
|
exclue des sorties et fichiers ; login sans aide PAM. Les scénarios de mot de
|
||||||
|
passe sont simulés : aucun mot de passe réel ou d'essai n'est manipulé.
|
||||||
|
|
||||||
|
Validation finale : les onze lots réussissent (93 tests backend, 29 tests de
|
||||||
|
focus, 15 tests QML unitaires, 6 résultats Qt dont initialisation/nettoyage,
|
||||||
|
8 scénarios UI de trousseau, 2 scénarios moteur stable/tardif, recette scratchpad,
|
||||||
|
7 scénarios UI V2 existants, 5 scénarios P0 et 5 de stabilité des chemins).
|
||||||
|
Résultats et logs : `/tmp/labfy-keyring-validation-y8xyqh17/results.json` et ses
|
||||||
|
fichiers `.log`. Les chemins des laboratoires/surfaces synthétiques sont dans
|
||||||
|
ces logs. Après revue, les huit tests spécifiques D-Bus ont également été
|
||||||
|
repassés avec activation des services système désactivée dans le bus simulé :
|
||||||
|
`/tmp/labfy-keyring-privatebus-final.log`. Le test d'absence affirme NameHasOwner=false. Les avertissements PyGObject/Python de dépréciation ne sont pas des
|
||||||
|
erreurs ; aucune sortie ne contient de secret. Le runner est livré pour
|
||||||
|
reproduire les résultats si `/tmp` est nettoyé.
|
||||||
|
|
||||||
|
## Installation contrôlée et rollback
|
||||||
|
|
||||||
|
**Aucune installation n'a eu lieu.** Les fichiers de la configuration active
|
||||||
|
sont reliés aux sources du dépôt principal. Les modifier pendant que QuickShell
|
||||||
|
les surveille pourrait provoquer un rechargement. Ne pas appliquer le patch
|
||||||
|
sur ces sources pendant la session graphique réelle.
|
||||||
|
|
||||||
|
Après revue et autorisation humaine de l'intégration et de la fermeture de la
|
||||||
|
session, depuis un TTY de secours sans QuickShell actif :
|
||||||
|
|
||||||
|
1. Vérifier les changements postérieurs dans `/home/fy59/.dotfiles` et vérifier
|
||||||
|
la base. Conserver une copie privée de tous les fichiers concernés et du
|
||||||
|
manifeste de hashes. Ne pas toucher aux autres worktrees.
|
||||||
|
2. Examiner le patch complet livré (il inclut les nouveaux fichiers) et faire
|
||||||
|
`git apply --check /chemin/changes.patch` depuis le dépôt principal. Un refus
|
||||||
|
impose de réconcilier les changements ; ne jamais forcer/écraser.
|
||||||
|
3. Appliquer ce patch seulement après ce contrôle. Ne faire ni commit ni push
|
||||||
|
automatiquement. Vérifier la résolution des liens de configuration existants,
|
||||||
|
et `git diff --check`. Aucune modification PAM/systemd n'est à appliquer.
|
||||||
|
4. Vérifier `python -c 'from gi.repository import Gio, GLib'`. Si la dépendance
|
||||||
|
manque, reporter l'installation ; ne pas lancer un gestionnaire de paquets
|
||||||
|
sans autorisation. L'indisponibilité est un refus de restauration, pas un
|
||||||
|
refus d'ouverture du bureau.
|
||||||
|
5. Au prochain login autorisé, suivre le scénario ci-dessous. Ne pas redémarrer
|
||||||
|
le greeter ou le bureau pour « tester » depuis la session en cours.
|
||||||
|
|
||||||
|
Rollback, également hors du QuickShell actif :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /home/fy59/.dotfiles
|
||||||
|
git apply --reverse --check /chemin/changes.patch
|
||||||
|
git apply --reverse /chemin/changes.patch
|
||||||
|
```
|
||||||
|
|
||||||
|
En présence de dérive, restaurer seulement les fichiers sauvegardés après
|
||||||
|
comparaison avec le manifeste et revue du delta ; ne pas faire reset/restore/
|
||||||
|
stash/clean. Les fichiers nouveaux du patch doivent être retirés uniquement
|
||||||
|
si leurs hashes correspondent encore à la livraison. Aucune donnée de
|
||||||
|
checkpoint, aucun journal Recovery et aucun trousseau ne sont supprimés ou
|
||||||
|
modifiés par l'installation/rollback. PAM et systemd restent identiques.
|
||||||
|
|
||||||
|
## Prochain vrai login — autorisation humaine requise
|
||||||
|
|
||||||
|
Ce scénario est proposé et **n'a pas été exécuté** :
|
||||||
|
|
||||||
|
1. Autoriser l'intégration et une fermeture normale de la session, avec TTY de
|
||||||
|
secours disponible ; conserver les journaux et checkpoints existants.
|
||||||
|
2. S'authentifier normalement via greetd. Sans PAM ajouté, un default verrouillé
|
||||||
|
doit afficher Déverrouiller et Nouvelle session, sans restauration possible.
|
||||||
|
3. Déverrouiller dans le seul dialogue GNOME. N'enregistrer aucun secret et
|
||||||
|
n'activer aucune option créant/modifiant des trousseaux. Vérifier READY et
|
||||||
|
l'absence de lancement automatique ; cliquer ensuite explicitement Restaurer.
|
||||||
|
4. Après retrait du chooser, vérifier le rapport final : succès seulement si
|
||||||
|
arbre **et** focus sont validés, slots et nettoyage complets. Observer le
|
||||||
|
scratchpad sans corriger un focus inattendu pour rendre le test positif.
|
||||||
|
5. Lors d'une autre connexion autorisée, tester annulation et Nouvelle session.
|
||||||
|
Une panne du service doit laisser ouvrir le bureau et refuser la restauration.
|
||||||
|
6. Si un prompt tardif apparaît, conserver l'échec et RECOVERY_REQUIRED,
|
||||||
|
horodatages et identités observables. Ne jamais effacer le journal pour
|
||||||
|
relancer artificiellement. Une trace de focus seule ne prouve pas l'auteur.
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
# 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
|
||||||
|
|
|
||||||
180
docs/session-v2-scratchpad-save-and-restore-v1.md
Normal file
180
docs/session-v2-scratchpad-save-and-restore-v1.md
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -28,8 +28,31 @@ 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 (!interactionArmed || busy || releasing || (attempted && !retryAllowed)) return;
|
if (!keyringReady || !interactionArmed || busy || releasing || (attempted && !retryAllowed)) return;
|
||||||
if (!SessionV2Service.prepareRestore(popup)) return;
|
if (!SessionV2Service.prepareRestore(popup)) return;
|
||||||
attempted = true;
|
attempted = true;
|
||||||
failed = false;
|
failed = false;
|
||||||
|
|
@ -44,7 +67,11 @@ PanelWindow {
|
||||||
releasePoll.start();
|
releasePoll.start();
|
||||||
}
|
}
|
||||||
function newSessionRequested() {
|
function newSessionRequested() {
|
||||||
if (interactionArmed && !busy) SessionV2Service.run("ack-new", "", "", popup);
|
if (interactionArmed && !busy) {
|
||||||
|
unlockRelease.stop(); unlockingRequested = false;
|
||||||
|
SessionV2Service.cancelKeyring();
|
||||||
|
SessionV2Service.run("ack-new", "", "", popup);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function releaseFailed() {
|
function releaseFailed() {
|
||||||
releasePoll.stop();
|
releasePoll.stop();
|
||||||
|
|
@ -59,6 +86,12 @@ 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";
|
||||||
|
|
@ -138,6 +171,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -167,7 +201,7 @@ PanelWindow {
|
||||||
exclusionMode: ExclusionMode.Ignore
|
exclusionMode: ExclusionMode.Ignore
|
||||||
exclusiveZone: 0
|
exclusiveZone: 0
|
||||||
aboveWindows: true
|
aboveWindows: true
|
||||||
focusable: visible && !awaitingUserFocus && !releasing && !inProgress && !restored
|
focusable: visible && !unlockingRequested && !SessionV2Service.keyringBusy && SessionV2Service.keyringState !== "KEYRING_UNLOCKING" && !awaitingUserFocus && !releasing && !inProgress && !restored
|
||||||
WlrLayershell.layer: WlrLayer.Overlay
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
WlrLayershell.namespace: "labfy-session-startup-v2"
|
WlrLayershell.namespace: "labfy-session-startup-v2"
|
||||||
visible: false
|
visible: false
|
||||||
|
|
@ -224,7 +258,11 @@ 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.windowCount + " fenêtres • " + popup.workspaceCount + " espaces"
|
: popup.keyringReady ? "Une session précédente est disponible."
|
||||||
|
: SessionV2Service.keyringState === "KEYRING_CANCELLED" ? "Déverrouillage annulé. Réessayez ou choisissez Nouvelle session."
|
||||||
|
: SessionV2Service.keyringState === "KEYRING_UNAVAILABLE" ? "Trousseau indisponible. Réessayez ou choisissez Nouvelle session. (" + SessionV2Service.keyringReason + ")"
|
||||||
|
: SessionV2Service.keyringState === "KEYRING_UNLOCKING" ? "Déverrouillage du trousseau en cours…"
|
||||||
|
: "Le trousseau doit être déverrouillé avant la restauration de votre session."
|
||||||
color: Theme.foreground
|
color: Theme.foreground
|
||||||
wrapMode: Text.Wrap
|
wrapMode: Text.Wrap
|
||||||
}
|
}
|
||||||
|
|
@ -250,9 +288,15 @@ PanelWindow {
|
||||||
onClicked: popup.newSessionRequested()
|
onClicked: popup.newSessionRequested()
|
||||||
}
|
}
|
||||||
ActionButton {
|
ActionButton {
|
||||||
visible: !popup.failed || popup.retryAllowed
|
visible: !popup.keyringReady
|
||||||
|
label: "Déverrouiller le trousseau"
|
||||||
|
enabled: popup.interactionArmed && !popup.busy && !SessionV2Service.keyringBusy && !popup.unlockingRequested
|
||||||
|
onClicked: popup.unlockRequested()
|
||||||
|
}
|
||||||
|
ActionButton {
|
||||||
|
visible: popup.keyringReady && (!popup.failed || popup.retryAllowed)
|
||||||
label: popup.retryAllowed ? "Réessayer" : "Restaurer"
|
label: popup.retryAllowed ? "Réessayer" : "Restaurer"
|
||||||
enabled: popup.interactionArmed && !popup.busy && (!popup.attempted || popup.retryAllowed)
|
enabled: popup.keyringReady && popup.interactionArmed && !popup.busy && (!popup.attempted || popup.retryAllowed)
|
||||||
onClicked: popup.restoreRequested()
|
onClicked: popup.restoreRequested()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,20 @@ QtObject {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingAction = completedAction;
|
pendingAction = completedAction;
|
||||||
errorMessage = stderrText.trim() || "Le backend de sauvegarde a échoué.";
|
// CONTRACT: backend reason is diagnostic data, never exit authority.
|
||||||
|
// 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,12 @@ 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 node.get("pid") == self.process.pid and node.get("app_id") in self.allowed
|
return True
|
||||||
|
|
||||||
def anchor(self, slot):
|
def anchor(self, slot):
|
||||||
require(self.active, "TRANSACTION_INACTIVE")
|
require(self.active, "TRANSACTION_INACTIVE")
|
||||||
|
|
|
||||||
|
|
@ -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
|
from .layout import compile_plan, compile_placements
|
||||||
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=("capture", "save", "list", "show", "delete", "plan", "apply", "checkpoint-last", "startup-status", "acknowledge-startup", "apply-last", "restore-status", "recovery-status", "analyze-v1-migration"))
|
parser.add_argument("command", choices=("keyring-status", "keyring-unlock", "capture", "save", "list", "show", "delete", "plan", "apply", "checkpoint-last", "startup-status", "acknowledge-startup", "apply-last", "restore-status", "recovery-status", "analyze-v1-migration"))
|
||||||
parser.add_argument("name", nargs="?")
|
parser.add_argument("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,13 +67,18 @@ 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 ("capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"):
|
if args.command in ("keyring-unlock", "capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"):
|
||||||
require(args.execute, "EXECUTE_REQUIRED")
|
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()}
|
||||||
|
|
@ -83,7 +88,10 @@ 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": result = {"operations": [asdict(x) for x in compile_plan(store.load(args.name), "0" * 32)]}
|
elif command == "plan":
|
||||||
|
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()
|
||||||
|
|
@ -102,7 +110,13 @@ 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)
|
||||||
result = Executor(sway, catalog, provider, runtime, args.anchor_binary).apply(data, execute=True)
|
from .keyring import admission, require_ready
|
||||||
|
# WHY: QML readiness is advisory and can be stale after restart.
|
||||||
|
# INVARIANT: gate failure creates no restore/recovery attempt;
|
||||||
|
# the unchanged Executor still proves every postcondition.
|
||||||
|
with admission(runtime):
|
||||||
|
require_ready()
|
||||||
|
result = Executor(sway, catalog, provider, runtime, args.anchor_binary).apply(data, execute=True)
|
||||||
print(json.dumps(result, ensure_ascii=False))
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
return 0
|
return 0
|
||||||
except Failure as exc:
|
except Failure as exc:
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ 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 .recovery import Recovery, RecordedSway, resume_pending, process_start, focus_proof, placement, observe, recovery_status
|
from .layout import compile_placements
|
||||||
|
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):
|
||||||
|
|
@ -217,7 +218,9 @@ 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():
|
||||||
stage_node(n)
|
# Hidden scratchpads stay detached throughout staging; neither
|
||||||
|
# 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
|
||||||
|
|
@ -264,7 +267,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))
|
mapping.update(assign(members, groups[app["application_id"]], identity, pinned))
|
||||||
# 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()
|
||||||
|
|
@ -283,6 +286,7 @@ 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')
|
||||||
|
|
@ -290,6 +294,7 @@ 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"])
|
||||||
|
|
@ -303,6 +308,13 @@ 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.
|
||||||
|
|
@ -312,7 +324,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:
|
if target_focus is None and origin_focus is not None and data.get("focus_workspace") is 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.
|
||||||
|
|
@ -322,20 +334,25 @@ 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 = recovery.data["focus_origin"]["workspace"]
|
origin_workspace = data.get("focus_workspace") or recovery.data["focus_origin"]["workspace"]
|
||||||
require(origin_focus is None and origin_workspace and origin_workspace != stage, "RESTORE_FOCUS_UNAVAILABLE")
|
require((origin_focus is None or data.get("focus_workspace")) 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")
|
||||||
|
|
@ -370,11 +387,30 @@ class Executor:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _floating(self, data, mapping, captured):
|
def _floating(self, data, mapping, captured):
|
||||||
for s in data["window_slots"]:
|
slots = {s["slot_id"]: s for s in data["window_slots"]}
|
||||||
if not s["state"]["floating"]: continue
|
for placement_op in compile_placements(data):
|
||||||
|
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.
|
||||||
|
|
@ -386,6 +422,14 @@ 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 ;
|
||||||
|
|
@ -424,7 +468,9 @@ 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] == s["workspace"] and n.get("floating") in ("auto_on", "user_on") and
|
require(location(tree, n["id"])[0] == ("__i3_scratch" if scratch == "hidden" else 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
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,18 @@ 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)
|
||||||
|
|
@ -140,6 +152,7 @@ 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)
|
||||||
|
|
@ -160,7 +173,19 @@ 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
|
||||||
|
|
@ -171,10 +196,13 @@ 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 n.get("pid") == config["anchor_pid"] and n.get("app_id") in config["allowed"]
|
return True
|
||||||
cleaned = 0
|
cleaned = 0
|
||||||
for old in sway.windows():
|
# CONTRACT : après prepare, aucune fermeture ni écriture quit tardive.
|
||||||
|
# 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):
|
||||||
|
|
@ -186,8 +214,9 @@ 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
|
||||||
until(lambda: not any(owned(n) for n in sway.windows()))
|
if not prepared:
|
||||||
os.write(config["writer_fd"], b"quit\n")
|
until(lambda: not any(owned(n) for n in sway.windows()))
|
||||||
|
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.
|
||||||
|
|
|
||||||
198
quickshell/.config/quickshell/labfy-sway/session_v2/keyring.py
Normal file
198
quickshell/.config/quickshell/labfy-sway/session_v2/keyring.py
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
"""Secret Service readiness, without ever opening a secret session or reading items.
|
||||||
|
|
||||||
|
WHY: daemon existence and a vanished prompt do not prove collection readiness.
|
||||||
|
CONTRACT: default must be the audited collection; an existing login alias must
|
||||||
|
agree. Missing login is supported (manual unlock), never proof of PAM success.
|
||||||
|
INVARIANT: no cached readiness, passwords, item metadata or free-form D-Bus errors
|
||||||
|
are persisted/logged. A private connection owns and dismisses only its prompt.
|
||||||
|
"""
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import math
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
from .errors import Failure, require
|
||||||
|
from .storage import lock, private_dir
|
||||||
|
|
||||||
|
BUS = 'org.freedesktop.secrets'
|
||||||
|
ROOT = '/org/freedesktop/secrets'
|
||||||
|
SERVICE = 'org.freedesktop.Secret.Service'
|
||||||
|
COLLECTION = 'org.freedesktop.Secret.Collection'
|
||||||
|
PROMPT = 'org.freedesktop.Secret.Prompt'
|
||||||
|
EXPECTED = ROOT + '/collection/Default_5fkeyring'
|
||||||
|
|
||||||
|
|
||||||
|
def result(state, reason):
|
||||||
|
return {'state': 'KEYRING_' + state, 'reason': reason}
|
||||||
|
|
||||||
|
|
||||||
|
class Secrets:
|
||||||
|
def __init__(self, timeout=8):
|
||||||
|
# Lazy import keeps existing backend diagnostics usable without PyGObject.
|
||||||
|
from gi.repository import Gio, GLib, GLibUnix
|
||||||
|
self.Gio, self.GLib, self.GLibUnix = Gio, GLib, GLibUnix
|
||||||
|
self.deadline = time.monotonic() + timeout
|
||||||
|
address = Gio.dbus_address_get_for_bus_sync(Gio.BusType.SESSION, None)
|
||||||
|
# Bound connection/authentication too, not only subsequent RPC calls.
|
||||||
|
loop, cancellation, opened = GLib.MainLoop(), Gio.Cancellable(), []
|
||||||
|
expired = []
|
||||||
|
def connected(_source, response, _data):
|
||||||
|
try:
|
||||||
|
bus = Gio.DBusConnection.new_for_address_finish(response)
|
||||||
|
if expired: bus.close_sync(None)
|
||||||
|
else: opened.append(bus)
|
||||||
|
except GLib.Error: pass
|
||||||
|
loop.quit()
|
||||||
|
def expire():
|
||||||
|
expired.append(True)
|
||||||
|
cancellation.cancel()
|
||||||
|
loop.quit()
|
||||||
|
return False
|
||||||
|
timer = GLib.timeout_add(2000, expire)
|
||||||
|
try:
|
||||||
|
Gio.DBusConnection.new_for_address(address,
|
||||||
|
Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT |
|
||||||
|
Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None,
|
||||||
|
cancellation, connected, None)
|
||||||
|
loop.run()
|
||||||
|
finally:
|
||||||
|
if GLib.MainContext.default().find_source_by_id(timer): GLib.source_remove(timer)
|
||||||
|
require(not expired, 'KEYRING_TIMEOUT')
|
||||||
|
require(opened, 'KEYRING_BUS_UNAVAILABLE')
|
||||||
|
self.bus = opened[0]
|
||||||
|
self.owner = None
|
||||||
|
try:
|
||||||
|
# Calls after activation are pinned to a unique owner. A replacement
|
||||||
|
# service must never inherit readiness from this connection.
|
||||||
|
try: self.owner = self.get_owner()
|
||||||
|
except GLib.Error:
|
||||||
|
self.call('org.freedesktop.DBus', '/org/freedesktop/DBus',
|
||||||
|
'org.freedesktop.DBus', 'StartServiceByName', '(su)', (BUS, 0), '(u)')
|
||||||
|
self.owner = self.get_owner()
|
||||||
|
except BaseException:
|
||||||
|
self.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def call(self, destination, path, interface, method, signature, args, reply):
|
||||||
|
remaining = self.deadline - time.monotonic()
|
||||||
|
require(remaining > 0, 'KEYRING_TIMEOUT')
|
||||||
|
return self.bus.call_sync(destination, path, interface, method,
|
||||||
|
self.GLib.Variant(signature, args) if signature else None,
|
||||||
|
self.GLib.VariantType.new(reply), self.Gio.DBusCallFlags.NO_AUTO_START,
|
||||||
|
max(1, min(2000, math.ceil(remaining * 1000))), None).unpack()
|
||||||
|
|
||||||
|
def get_owner(self):
|
||||||
|
return self.call('org.freedesktop.DBus', '/org/freedesktop/DBus',
|
||||||
|
'org.freedesktop.DBus', 'GetNameOwner', '(s)', (BUS,), '(s)')[0]
|
||||||
|
|
||||||
|
def alias(self, name):
|
||||||
|
return self.call(self.owner, ROOT, SERVICE, 'ReadAlias', '(s)', (name,), '(o)')[0]
|
||||||
|
|
||||||
|
def inspect(self):
|
||||||
|
require(self.get_owner() == self.owner, 'KEYRING_OWNER_CHANGED')
|
||||||
|
collection = self.alias('default')
|
||||||
|
require(collection != '/', 'KEYRING_DEFAULT_MISSING')
|
||||||
|
require(collection == EXPECTED, 'KEYRING_COLLECTION_MISMATCH')
|
||||||
|
login = self.alias('login')
|
||||||
|
require(login in ('/', collection), 'KEYRING_LOGIN_MISMATCH')
|
||||||
|
value = self.call(self.owner, collection, 'org.freedesktop.DBus.Properties',
|
||||||
|
'Get', '(ss)', (COLLECTION, 'Locked'), '(v)')[0]
|
||||||
|
require(type(value) is bool, 'KEYRING_INVALID_LOCKED')
|
||||||
|
require(self.get_owner() == self.owner, 'KEYRING_OWNER_CHANGED')
|
||||||
|
return value
|
||||||
|
|
||||||
|
def unlock(self, timeout=90):
|
||||||
|
self.deadline = time.monotonic() + timeout
|
||||||
|
unlocked, prompt = self.call(self.owner, ROOT, SERVICE, 'Unlock', '(ao)', ([EXPECTED],), '(aoo)')
|
||||||
|
if prompt == '/': return
|
||||||
|
loop = self.GLib.MainLoop()
|
||||||
|
completed = []
|
||||||
|
def receive(_bus, _sender, _path, _iface, _signal, parameters):
|
||||||
|
# Do not retain/inspect the operation-specific variant payload.
|
||||||
|
completed.append(parameters.get_child_value(0).get_boolean())
|
||||||
|
loop.quit()
|
||||||
|
subscription = self.bus.signal_subscribe(self.owner, PROMPT, 'Completed', prompt,
|
||||||
|
None, self.Gio.DBusSignalFlags.NONE, receive)
|
||||||
|
timer = self.GLib.timeout_add(max(1, math.ceil((self.deadline - time.monotonic()) * 1000)),
|
||||||
|
lambda: (loop.quit(), False)[1])
|
||||||
|
def cancel():
|
||||||
|
completed.append(True)
|
||||||
|
loop.quit()
|
||||||
|
return False
|
||||||
|
# GLib owns the prompt wait: Python signal handlers alone are not
|
||||||
|
# dispatched while its native loop is idle. Termination must wake it.
|
||||||
|
termination = self.GLibUnix.signal_add(self.GLib.PRIORITY_DEFAULT, signal.SIGTERM, cancel)
|
||||||
|
try:
|
||||||
|
# Empty window-id is the portable unparented prompt. QuickShell
|
||||||
|
# releases its exclusive keyboard surface before this call.
|
||||||
|
self.call(self.owner, prompt, PROMPT, 'Prompt', '(s)', ('',), '()')
|
||||||
|
if not completed: loop.run()
|
||||||
|
require(completed, 'KEYRING_TIMEOUT')
|
||||||
|
require(completed[0] is False, 'KEYRING_CANCELLED')
|
||||||
|
finally:
|
||||||
|
for source in (timer, termination):
|
||||||
|
if self.GLib.MainContext.default().find_source_by_id(source):
|
||||||
|
self.GLib.source_remove(source)
|
||||||
|
self.bus.signal_unsubscribe(subscription)
|
||||||
|
# Best effort cleanup also runs on timeout/termination. Closing the
|
||||||
|
# private connection invalidates outstanding client-owned prompts.
|
||||||
|
try:
|
||||||
|
self.deadline = time.monotonic() + 1
|
||||||
|
self.call(self.owner, prompt, PROMPT, 'Dismiss', None, (), '()')
|
||||||
|
except (Failure, self.GLib.Error): pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.bus.close_sync(None)
|
||||||
|
|
||||||
|
|
||||||
|
def check(unlock=False, factory=Secrets, timeout=90):
|
||||||
|
connection = None
|
||||||
|
try:
|
||||||
|
connection = factory()
|
||||||
|
if not connection.inspect(): return result('READY', 'KEYRING_UNLOCKED')
|
||||||
|
if not unlock: return result('LOCKED', 'KEYRING_LOCKED')
|
||||||
|
connection.unlock(timeout)
|
||||||
|
connection.deadline = time.monotonic() + 8
|
||||||
|
if connection.inspect(): return result('LOCKED', 'KEYRING_STILL_LOCKED')
|
||||||
|
return result('READY', 'KEYRING_UNLOCKED')
|
||||||
|
except Failure as exc:
|
||||||
|
return result('CANCELLED' if exc.code == 'KEYRING_CANCELLED' else 'UNAVAILABLE', exc.code)
|
||||||
|
except ImportError:
|
||||||
|
return result('UNAVAILABLE', 'KEYRING_DEPENDENCY_MISSING')
|
||||||
|
except Exception:
|
||||||
|
# Never expose remote error text; it may contain user-supplied material.
|
||||||
|
return result('UNAVAILABLE', 'KEYRING_DBUS_ERROR')
|
||||||
|
finally:
|
||||||
|
if connection is not None:
|
||||||
|
try: connection.close()
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def admission(runtime):
|
||||||
|
"""Serialize unlock and CLI restoration across processes/QuickShell restarts.
|
||||||
|
|
||||||
|
CONTRACT: acquire before the existing transaction lock, hold through apply.
|
||||||
|
No boolean, token or file survives as authorization for a future apply.
|
||||||
|
New-session acknowledgement uses only the existing transaction lock.
|
||||||
|
"""
|
||||||
|
with lock(private_dir(runtime / 'session-v2'), 'keyring.lock'):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def command(runtime, unlock=False):
|
||||||
|
try:
|
||||||
|
with admission(runtime):
|
||||||
|
# An executor/guard predating this gate can still own the old lock.
|
||||||
|
# Check it before any Unlock; don't hold it during user interaction.
|
||||||
|
with lock(private_dir(runtime / 'session-v2')): pass
|
||||||
|
return check(unlock)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return result('CANCELLED', 'KEYRING_CANCELLED')
|
||||||
|
except Failure as exc:
|
||||||
|
if exc.code == 'TRANSACTION_BUSY': return result('UNLOCKING', 'KEYRING_OR_RESTORE_BUSY')
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def require_ready():
|
||||||
|
value = check()
|
||||||
|
require(value['state'] == 'KEYRING_READY', value['reason'])
|
||||||
|
|
@ -44,3 +44,18 @@ 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"])
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ class Application:
|
||||||
class WindowSlot:
|
class WindowSlot:
|
||||||
slot_id: str
|
slot_id: str
|
||||||
application_id: str
|
application_id: str
|
||||||
workspace: str
|
workspace: str | None
|
||||||
tree_path: tuple
|
tree_path: tuple
|
||||||
state: dict
|
state: dict
|
||||||
identity_requirement: str
|
identity_requirement: str
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ 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
|
from .ipc import walk, location, workspace, until, canonical
|
||||||
from .storage import atomic, read
|
from .storage import atomic, read
|
||||||
|
|
||||||
JOURNAL = "session-v2-recovery.json"
|
JOURNAL = "session-v2-recovery.json"
|
||||||
|
|
@ -22,6 +23,13 @@ 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", [])
|
||||||
|
|
@ -30,18 +38,51 @@ 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]), "floating": floating, "geometry": dict(node["rect"]) if floating else None}
|
"path": list(where[1]), "scratchpad": scratchpad_state(node, where[0]), "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.startswith("move container to workspace "):
|
if operation == "move scratchpad":
|
||||||
|
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:
|
||||||
|
|
@ -57,7 +98,9 @@ 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: expected["geometry"] = None
|
else:
|
||||||
|
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"]
|
||||||
|
|
@ -162,7 +205,26 @@ 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 == "focus": return at(target)
|
if operation == "scratchpad show":
|
||||||
|
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)
|
||||||
|
|
@ -185,7 +247,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 value.get("version") == 1,
|
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),
|
||||||
"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")
|
||||||
|
|
@ -196,12 +258,16 @@ 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"}, "RECOVERY_SCHEMA")
|
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")
|
||||||
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")
|
||||||
|
|
@ -219,7 +285,48 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -236,7 +343,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": 1,
|
self.data = {"schema": "labfy.session-v2-recovery", "version": 2,
|
||||||
"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),
|
||||||
|
|
@ -257,7 +364,12 @@ 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)
|
||||||
return observe(tree, node, self.start) if node else None
|
state = 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.
|
||||||
|
|
@ -347,6 +459,14 @@ 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.
|
||||||
|
|
@ -365,10 +485,28 @@ 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)
|
||||||
self.data.update(focus_expected=focused(tree), focus_pending=False)
|
# CONTRACT : cette preuve est post-réponse RUN_COMMAND ; elle décrit
|
||||||
|
# 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"])
|
||||||
|
|
@ -442,6 +580,8 @@ 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:
|
||||||
|
|
@ -467,7 +607,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)
|
self.data.update(focus_expected=focused(tree), focus_pending=False, structure_expected=structure(tree))
|
||||||
self.publish()
|
self.publish()
|
||||||
|
|
||||||
def check_focus_preservation(self, tree, expected, command):
|
def check_focus_preservation(self, tree, expected, command):
|
||||||
|
|
@ -545,6 +685,35 @@ 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": []}
|
||||||
|
|
@ -555,15 +724,23 @@ 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, "
|
||||||
+ f'[con_id={origin["con_id"]}] move container to workspace ' + json.dumps(origin["workspace"])})
|
+ ("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"]))})
|
||||||
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:
|
||||||
recovery_focus = focused(self.sway.tree())
|
tree = 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:
|
||||||
|
|
@ -573,8 +750,22 @@ 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 current != e["expected"]: issue(e, "IDENTITY_OR_MEMBERSHIP_CHANGED"); continue
|
if structure_changed: issue(e, "STRUCTURE_OR_ORDER_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"])
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,495 @@
|
||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
fields(data, "schema version metadata outputs applications window_slots workspaces focus" + (" focus_workspace" if type(data) is dict and data.get("version") == 2 else ""))
|
||||||
require(data["schema"] == SCHEMA and type(data["version"]) is int and data["version"] == 1, "SCHEMA_VERSION")
|
require(data["schema"] == SCHEMA and type(data["version"]) is int and data["version"] in (1, 2), "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,6 +43,7 @@ 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"]:
|
||||||
|
|
@ -60,7 +61,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"])
|
||||||
text(s["workspace"], PLACE)
|
if s["workspace"] is not None: 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")
|
||||||
|
|
@ -68,8 +69,18 @@ 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 and state["scratchpad"] is False,
|
require(type(state["fullscreen"]) is bool and state["fullscreen"] 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)
|
||||||
|
|
@ -108,18 +119,24 @@ 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["workspace"] in workspaces and (s["state"]["floating"] or s["slot_id"] in seen), "UNPLACED_SLOT")
|
require(s["state"]["scratchpad"] == "hidden" or 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()), "EMPTY_WORKSPACE_UNSUPPORTED")
|
require(any(s["workspace"] == name for s in slots.values()) or data.get("focus_workspace") == name, "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
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from .errors import require
|
||||||
from .schema import PROVIDER
|
from .schema import PROVIDER
|
||||||
|
|
||||||
|
|
||||||
def assign(slots, nodes, identities=None):
|
def assign(slots, nodes, identities=None, states=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,7 +21,19 @@ def assign(slots, nodes, identities=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)
|
||||||
# Ordre numérique runtime déterministe, jamais une preuve sémantique.
|
# WHY : interchangeable is an explicit loss of semantic identity, never a
|
||||||
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)):
|
# permission to expose an arbitrary hidden peer of the same application.
|
||||||
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
from .ipc import walk, location
|
||||||
|
|
||||||
|
|
||||||
def capture(sway, catalog, provider, reason="manual"):
|
def capture(sway, catalog, provider, reason="manual"):
|
||||||
|
|
@ -11,13 +11,14 @@ 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("focused"), node.get("fullscreen_mode"), node.get("floating"), node.get("rect"),
|
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"),
|
||||||
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") and n.get("scratchpad_state", "none") == "none", "CAPABILITY_UNSUPPORTED")
|
require(not n.get("fullscreen_mode"), "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 = {}
|
||||||
|
|
@ -32,6 +33,16 @@ 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"]]
|
||||||
|
|
@ -43,9 +54,14 @@ 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": False},
|
"tree_path": list(path), "state": {"floating": floating, "geometry": geometry, "fullscreen": False, "scratchpad": scratchpad},
|
||||||
"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=()):
|
||||||
|
|
@ -59,20 +75,27 @@ 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")): continue
|
if ws.get("type") != "workspace" or not (ws.get("nodes") or ws.get("floating_nodes") or ws.get("name") == focus_workspace): continue
|
||||||
require(ws["name"] != "__i3_scratch", "CAPABILITY_UNSUPPORTED")
|
if ws["name"] == "__i3_scratch":
|
||||||
|
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": 1,
|
data = {"schema": "labfy.sway.session-v2", "version": 2,
|
||||||
"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}
|
"window_slots": slots, "workspaces": workspaces, "focus": focused, "focus_workspace": focus_workspace}
|
||||||
return validate(data)
|
return validate(data)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,37 @@ 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.
|
||||||
|
|
@ -18,14 +49,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, startupChooserCount: service.startupChooserVisible ? 1 : 0});
|
operation: service.operation, keyringState: service.keyringState, keyringReason: service.keyringReason, startupChooserCount: service.startupChooserVisible ? 1 : 0});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Réserver la sérialisation avant de retirer la surface. Le Timer du
|
// 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) return false;
|
if (busy || !owner || !keyringReady) return false;
|
||||||
client = owner;
|
client = owner;
|
||||||
operation = "release-focus";
|
operation = "release-focus";
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -46,6 +77,7 @@ 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];
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,7 @@ 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)
|
||||||
|
|
||||||
|
|
@ -220,6 +221,39 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,14 @@ 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 }')
|
||||||
|
|
@ -85,6 +93,8 @@ 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)
|
||||||
|
|
@ -240,4 +250,24 @@ 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()
|
||||||
|
|
|
||||||
66
tests/session_v2/fixtures/secret_service.py
Normal file
66
tests/session_v2/fixtures/secret_service.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""TEST_ONLY Secret Service. No secrets/items/passwords exist in this fixture."""
|
||||||
|
import sys
|
||||||
|
from gi.repository import Gio, GLib
|
||||||
|
|
||||||
|
mode = sys.argv[1]
|
||||||
|
root = '/org/freedesktop/secrets'
|
||||||
|
collection = root + '/collection/Default_5fkeyring'
|
||||||
|
prompt = root + '/prompt/test'
|
||||||
|
locked = mode != 'ready'
|
||||||
|
xml = '''<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()
|
||||||
58
tests/session_v2/keyring_runtime.py
Normal file
58
tests/session_v2/keyring_runtime.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Late synthetic keyring-like surface: real focus proof must still fail.
|
||||||
|
|
||||||
|
CONTRACT: the surface is TEST_ONLY and does not establish the historical
|
||||||
|
identity of con_id 18. No event infers user-vs-IPC causality from app_id.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
from common import *
|
||||||
|
from lab import Lab, Surfaces, build_helper
|
||||||
|
from session_v2.executor import Executor
|
||||||
|
from session_v2.recovery import Recovery, recovery_status
|
||||||
|
from session_v2.ipc import Sway
|
||||||
|
from session_v2.errors import Failure
|
||||||
|
from session_v2.storage import private_dir, read
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
for late in (False, True):
|
||||||
|
with Lab() as lab:
|
||||||
|
print(lab.directory, flush=True)
|
||||||
|
helper = build_helper(lab.directory)
|
||||||
|
apps = Surfaces(lab, helper)
|
||||||
|
lab.command('workspace TEST_ONLY_TARGET')
|
||||||
|
apps.create('TEST_ONLY_app-A')
|
||||||
|
data = fixture('A')
|
||||||
|
sway = Sway(lab.socket)
|
||||||
|
runtime = private_dir(lab.directory / 'runtime')
|
||||||
|
binary = build(lab.directory)
|
||||||
|
original = Recovery.boundary
|
||||||
|
observed = []
|
||||||
|
def boundary(recovery, phase):
|
||||||
|
original(recovery, phase)
|
||||||
|
if phase == 'after-guard-finish' and late:
|
||||||
|
# Same placement in the lifecycle as the historical race.
|
||||||
|
# A real compositor maps/focuses this independent surface.
|
||||||
|
observed.append(apps.create('TEST_ONLY_keyring_prompt'))
|
||||||
|
engine = Executor(sway, catalog(data), None, runtime, binary, lab.env)
|
||||||
|
with patch.object(Recovery, 'boundary', boundary):
|
||||||
|
if late:
|
||||||
|
try: engine.apply(data, True)
|
||||||
|
except Failure as exc: assert exc.code == 'RECOVERY_FOCUS_MISMATCH', exc.code
|
||||||
|
else: raise AssertionError('LATE_FOCUS_WAS_ACCEPTED')
|
||||||
|
else:
|
||||||
|
value = engine.apply(data, True)
|
||||||
|
assert value['status'] == 'success' and value['final_focus_verified']
|
||||||
|
report = read(runtime / 'session-v2-restore-attempt.json')
|
||||||
|
if late:
|
||||||
|
assert report['status'] == 'failed' and report['reason'] == 'RECOVERY_FOCUS_MISMATCH', report
|
||||||
|
assert not report['final_focus_verified']
|
||||||
|
assert recovery_status(runtime)['status'] == 'RECOVERY_REQUIRED'
|
||||||
|
# Attribution remains an observation; no focus correction to
|
||||||
|
# manufacture success after an unexpected late activation.
|
||||||
|
assert observed[0]['app_id'] == 'TEST_ONLY_keyring_prompt'
|
||||||
|
print('late-refused' if late else 'stable-final-focus', 'PASS', flush=True)
|
||||||
|
apps.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': main()
|
||||||
126
tests/session_v2/keyring_ui.py
Normal file
126
tests/session_v2/keyring_ui.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""Actual chooser/Secret Service client on private Sway and private D-Bus only."""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from common import *
|
||||||
|
from lab import Lab
|
||||||
|
from test_keyring import KeyringTests
|
||||||
|
|
||||||
|
SOURCE = BACKEND
|
||||||
|
HARNESS = '''//@ pragma UseQApplication
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import "sessionui"
|
||||||
|
ShellRoot {
|
||||||
|
PanelWindow { id: bar; visible: false }
|
||||||
|
SessionStartupPromptV2 { id: prompt; barWindow: bar; startupHost: true }
|
||||||
|
Timer { interval: 1250; running: true; onTriggered: {
|
||||||
|
if (!prompt.interactionArmed) { console.error("KEYRING_UI_FAIL_NOT_ARMED"); Qt.quit(); return; }
|
||||||
|
if (Quickshell.env("CASE") !== "ready") {
|
||||||
|
prompt.restoreRequested();
|
||||||
|
if (prompt.attempted) { console.error("KEYRING_UI_FAIL_BYPASS"); Qt.quit(); return; }
|
||||||
|
if (Quickshell.env("CASE") !== "restart-check") prompt.unlockRequested();
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
Timer { interval: 2300; running: true; onTriggered: {
|
||||||
|
const mode = Quickshell.env("CASE");
|
||||||
|
if (prompt.attempted || prompt.inProgress) { console.error("KEYRING_UI_FAIL_AUTORESTORE"); Qt.quit(); return; }
|
||||||
|
if (mode === "ready" || mode === "accept") {
|
||||||
|
if (!prompt.keyringReady) { console.error("KEYRING_UI_FAIL_NOT_READY"); Qt.quit(); return; }
|
||||||
|
prompt.restoreRequested();
|
||||||
|
} else {
|
||||||
|
if (prompt.keyringReady) { console.error("KEYRING_UI_FAIL_FALSE_READY"); Qt.quit(); return; }
|
||||||
|
prompt.newSessionRequested();
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
Timer { interval: 3700; running: true; onTriggered: {
|
||||||
|
const mode = Quickshell.env("CASE");
|
||||||
|
const okay = mode === "ready" || mode === "accept" ? prompt.restored && !prompt.visible : !prompt.visible && !prompt.attempted;
|
||||||
|
console.log(okay ? "KEYRING_UI_PASS" : "KEYRING_UI_FAIL_FINAL"); Qt.quit();
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
# The chooser and gate are production. Only restoration/checkpoint discovery are
|
||||||
|
# synthetic here; actual Executor/focus behavior is tested in keyring_runtime.py.
|
||||||
|
WRAPPER = '''import sys, json, os
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, SOURCE)
|
||||||
|
from session_v2.cli import main
|
||||||
|
cmd=sys.argv[1]
|
||||||
|
with Path(os.environ['CALLS']).open('a') as log: log.write(cmd+'\\n')
|
||||||
|
if cmd.startswith('keyring-'): sys.exit(main())
|
||||||
|
if cmd == 'startup-status': value={'eligible':True,'claimed':True,'windows':4,'workspaces':2}
|
||||||
|
elif cmd == 'apply-last': value={'schema':'labfy.sway.session-v2-restore-attempt','version':1,'status':'success','transaction_id':'a'*32,'identity_level':'exact','final_tree_verified':True,'final_focus_verified':True,'slots_expected':4,'slots_filled':4,'applications_expected':3,'applications_observed':3,'anchors_created':3,'anchors_cleaned':3,'helpers_suspended':0,'helpers_resumed':0}
|
||||||
|
elif cmd == 'acknowledge-startup': value={'status':'acknowledged','choice':sys.argv[sys.argv.index('--choice')+1]}
|
||||||
|
else: raise RuntimeError('TEST_ONLY_UNEXPECTED')
|
||||||
|
print(json.dumps(value))
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with Lab() as lab:
|
||||||
|
print(lab.directory, flush=True)
|
||||||
|
for mode in ('ready', 'accept', 'cancel', 'timeout', 'absent', 'still-locked', 'different'):
|
||||||
|
with KeyringTests().service(mode) as (busenv, _):
|
||||||
|
base = lab.directory / mode / 'config/quickshell/labfy-sway'
|
||||||
|
base.mkdir(parents=True)
|
||||||
|
for item in ('sessionui', 'controlcenter', 'theme'):
|
||||||
|
shutil.copytree(SOURCE / item, base / item)
|
||||||
|
shutil.copy(SOURCE / 'SessionStartupPromptV2.qml', base)
|
||||||
|
(base / 'shell.qml').write_text(HARNESS)
|
||||||
|
(base / 'session-v2.py').write_text(WRAPPER.replace('SOURCE', repr(str(SOURCE))))
|
||||||
|
calls = lab.directory / (mode + '.calls')
|
||||||
|
env = {**lab.env, 'DBUS_SESSION_BUS_ADDRESS': busenv['DBUS_SESSION_BUS_ADDRESS'],
|
||||||
|
'XDG_CONFIG_HOME': str(base.parents[1]), 'XDG_RUNTIME_DIR': busenv['XDG_RUNTIME_DIR'],
|
||||||
|
'WAYLAND_DISPLAY': str(lab.directory / lab.env['WAYLAND_DISPLAY']),
|
||||||
|
'CASE': mode, 'CALLS': str(calls)}
|
||||||
|
proc = subprocess.run(['qs', '-p', str(base), '--no-color'], env=env,
|
||||||
|
capture_output=True, text=True, timeout=12)
|
||||||
|
output = proc.stdout + proc.stderr
|
||||||
|
(lab.directory / (mode + '.qml.log')).write_text(output)
|
||||||
|
assert 'KEYRING_UI_PASS' in output and 'KEYRING_UI_FAIL' not in output, output
|
||||||
|
seen = calls.read_text().splitlines()
|
||||||
|
assert seen.count('apply-last') == (1 if mode in ('ready', 'accept') else 0), seen
|
||||||
|
assert seen.count('acknowledge-startup') == 1, seen
|
||||||
|
print(mode, 'PASS', flush=True)
|
||||||
|
# Real QuickShell process destruction, not just a reset QML boolean.
|
||||||
|
with KeyringTests().service('timeout') as (busenv, server):
|
||||||
|
base = lab.directory / 'restart/config/quickshell/labfy-sway'
|
||||||
|
base.mkdir(parents=True)
|
||||||
|
for item in ('sessionui', 'controlcenter', 'theme'):
|
||||||
|
shutil.copytree(SOURCE / item, base / item)
|
||||||
|
shutil.copy(SOURCE / 'SessionStartupPromptV2.qml', base)
|
||||||
|
(base / 'shell.qml').write_text(HARNESS)
|
||||||
|
(base / 'session-v2.py').write_text(WRAPPER.replace('SOURCE', repr(str(SOURCE))))
|
||||||
|
calls = lab.directory / 'restart.calls'
|
||||||
|
env = {**lab.env, 'DBUS_SESSION_BUS_ADDRESS': busenv['DBUS_SESSION_BUS_ADDRESS'],
|
||||||
|
'XDG_CONFIG_HOME': str(base.parents[1]), 'XDG_RUNTIME_DIR': busenv['XDG_RUNTIME_DIR'],
|
||||||
|
'WAYLAND_DISPLAY': str(lab.directory / lab.env['WAYLAND_DISPLAY']),
|
||||||
|
'CASE': 'timeout', 'CALLS': str(calls)}
|
||||||
|
first = subprocess.Popen(['qs', '-p', str(base), '--no-color'], env=env,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||||
|
try:
|
||||||
|
import select
|
||||||
|
# Read raw pipe so TextIO buffering cannot hide the second line.
|
||||||
|
observed = b''
|
||||||
|
deadline = time.monotonic() + 8
|
||||||
|
while b'PROMPT\n' not in observed and time.monotonic() < deadline:
|
||||||
|
if select.select([server.stdout], [], [], .2)[0]:
|
||||||
|
observed += os.read(server.stdout.fileno(), 4096)
|
||||||
|
assert b'UNLOCK\n' in observed and b'PROMPT\n' in observed, observed
|
||||||
|
finally:
|
||||||
|
first.terminate()
|
||||||
|
first.communicate(timeout=5)
|
||||||
|
# A replacement shell reconstructs locked/busy from backend/D-Bus;
|
||||||
|
# it must not reissue Unlock or Restore just because it restarted.
|
||||||
|
env['CASE'] = 'restart-check'
|
||||||
|
restarted = subprocess.run(['qs', '-p', str(base), '--no-color'], env=env,
|
||||||
|
capture_output=True, text=True, timeout=12)
|
||||||
|
output = restarted.stdout + restarted.stderr
|
||||||
|
assert 'KEYRING_UI_PASS' in output and 'KEYRING_UI_FAIL' not in output, output
|
||||||
|
seen = calls.read_text().splitlines()
|
||||||
|
assert seen.count('keyring-unlock') == 1 and 'apply-last' not in seen, seen
|
||||||
|
print('quickshell-restart-during-unlock PASS', flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': main()
|
||||||
|
|
@ -13,6 +13,7 @@ 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
|
||||||
|
|
@ -122,7 +123,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'):
|
if case in ('focus-third-party', 'preserve-foreign', 'focus-external-late'):
|
||||||
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"')
|
||||||
|
|
@ -189,6 +190,36 @@ 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())}
|
||||||
|
|
@ -210,7 +241,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', return_value=[]):
|
with patch('session_v2.executor.launch', side_effect=launch_surface), patch('session_v2.guard.discover', side_effect=helpers), patch('session_v2.executor.Guard', ObservedGuard):
|
||||||
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
|
||||||
|
|
@ -245,7 +276,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'):
|
if case in ('natural', 'launch-failure-retry', 'preserve-empty-workspace', 'preserve-foreign', 'autotiling'):
|
||||||
# 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
|
||||||
|
|
@ -261,8 +292,18 @@ 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',
|
||||||
|
|
@ -300,7 +341,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'], 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','autotiling','focus-helper-late','focus-external-late','focus-finish-late'], 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'],
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ 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'}
|
||||||
|
|
@ -86,7 +87,9 @@ ShellRoot {
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
rows = []
|
rows = []
|
||||||
with Lab() as lab:
|
from test_keyring import KeyringTests
|
||||||
|
with KeyringTests().service('ready') as (busenv, _), Lab() as lab:
|
||||||
|
lab.env['DBUS_SESSION_BUS_ADDRESS'] = busenv['DBUS_SESSION_BUS_ADDRESS']
|
||||||
for mode in ('success', 'failure', 'invalid', 'new', 'claimed', 'real', 'deferred-real'):
|
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'
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,19 @@ 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"});
|
||||||
|
|
|
||||||
133
tests/session_v2/scratchpad_runtime.py
Normal file
133
tests/session_v2/scratchpad_runtime.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""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()
|
||||||
|
|
@ -22,9 +22,9 @@ def frame(value):
|
||||||
|
|
||||||
class BootstrapTests(unittest.TestCase):
|
class BootstrapTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
base = REPO / "build/bootstrap-fixtures"
|
# AF_UNIX est borné à 108 octets : un worktree profond ne doit pas
|
||||||
base.mkdir(parents=True, exist_ok=True)
|
# rendre ces sockets TEST_ONLY dépendants du chemin du dépôt.
|
||||||
self.tmp = tempfile.TemporaryDirectory(dir=base)
|
self.tmp = tempfile.TemporaryDirectory(prefix="labfy-v2-bootstrap-")
|
||||||
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"):
|
||||||
|
|
|
||||||
133
tests/session_v2/test_focus_recovery.py
Normal file
133
tests/session_v2/test_focus_recovery.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""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()
|
||||||
74
tests/session_v2/test_guard_protocol.py
Normal file
74
tests/session_v2/test_guard_protocol.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""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()
|
||||||
152
tests/session_v2/test_keyring.py
Normal file
152
tests/session_v2/test_keyring.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
"""Real Gio client against a private mock bus; never accesses the desktop bus."""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from common import BACKEND, REPO, Path, os, sys
|
||||||
|
from session_v2 import keyring, cli
|
||||||
|
from session_v2.errors import Failure
|
||||||
|
from session_v2.storage import private_dir, lock
|
||||||
|
|
||||||
|
|
||||||
|
class KeyringTests(unittest.TestCase):
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def service(self, mode):
|
||||||
|
with tempfile.TemporaryDirectory(prefix='labfy-keyring-TEST_ONLY-') as tmp:
|
||||||
|
env = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1'}
|
||||||
|
for name in ('HOME', 'XDG_RUNTIME_DIR', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME'):
|
||||||
|
path = Path(tmp) / name; path.mkdir(mode=0o700); env[name] = str(path)
|
||||||
|
# No standard service directories: an "absent" fixture must not
|
||||||
|
# accidentally activate the installed real GNOME Keyring daemon.
|
||||||
|
config = Path(tmp) / 'bus.conf'
|
||||||
|
config.write_text('<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()
|
||||||
|
|
@ -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, nodes=[node])
|
destination = dict(id=90, type='workspace', name=stage, layout='splith', 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']
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ 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)
|
||||||
|
|
|
||||||
204
tests/session_v2/test_scratchpad.py
Normal file
204
tests/session_v2/test_scratchpad.py
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"""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()
|
||||||
52
tests/session_v2/validate_keyring.py
Normal file
52
tests/session_v2/validate_keyring.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reproducible keyring tranche: isolated buses, HOME/XDG and private Sway only."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--sway', required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.sway.is_absolute() or not args.sway.is_file() or not os.access(args.sway, os.X_OK):
|
||||||
|
parser.error('--sway must be an absolute executable (headless-capable)')
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
output = Path(tempfile.mkdtemp(prefix='labfy-keyring-validation-'))
|
||||||
|
# No SWAYSOCK, graphical bus, user configurations, secrets or session are
|
||||||
|
# inherited. Every graphical test creates its own child headless compositor.
|
||||||
|
env = {'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1',
|
||||||
|
'QT_QPA_PLATFORM': 'offscreen', 'QT_QUICK_BACKEND': 'software',
|
||||||
|
'LABFY_TEST_SWAY': str(args.sway), 'LABFY_TEST_RENDERER': 'pixman',
|
||||||
|
'LABFY_SESSION_V2_EVIDENCE': str(output / 'evidence')}
|
||||||
|
for key in ('HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR'):
|
||||||
|
target = output / key; target.mkdir(mode=0o700); env[key] = str(target)
|
||||||
|
py = [sys.executable, '-B']
|
||||||
|
checks = {
|
||||||
|
'backend': [*py, '-m', 'unittest', 'discover', '-s', 'tests/session_v2', '-p', 'test_*.py'],
|
||||||
|
'focus': [*py, 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_focus.py'],
|
||||||
|
'qml-unit': [*py, 'quickshell/.config/quickshell/labfy-sway/tests/focus/test_qml.py'],
|
||||||
|
'qml-qt': ['/usr/lib/qt6/bin/qmltestrunner', '-input', 'tests/session_v2/qml', '-o', '-,txt'],
|
||||||
|
**{name: [*py, 'tests/session_v2/' + filename + '.py'] for name, filename in (
|
||||||
|
('keyring-ui', 'keyring_ui'), ('keyring-runtime', 'keyring_runtime'),
|
||||||
|
('scratchpad-runtime', 'scratchpad_runtime'), ('phase3b-ui', 'phase3b_ui'),
|
||||||
|
('p0-runtime', 'p0_runtime'), ('path-runtime', 'path_stability_runtime'))},
|
||||||
|
'diff-check': ['git', 'diff', '--check'],
|
||||||
|
}
|
||||||
|
results = {}
|
||||||
|
print(output, flush=True)
|
||||||
|
for name, argv in checks.items():
|
||||||
|
with (output / (name + '.log')).open('w') as log:
|
||||||
|
try: results[name] = subprocess.run(argv, cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=180).returncode
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
log.write(str(exc) + '\n'); results[name] = 'UNAVAILABLE_OR_TIMEOUT'
|
||||||
|
print(name, results[name], flush=True)
|
||||||
|
(output / 'results.json').write_text(json.dumps(results, indent=2) + '\n')
|
||||||
|
return int(any(value != 0 for value in results.values()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': sys.exit(main())
|
||||||
|
|
@ -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'):
|
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'):
|
||||||
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
|
||||||
|
|
|
||||||
55
tests/session_v2/validate_scratchpad.py
Normal file
55
tests/session_v2/validate_scratchpad.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/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())
|
||||||
|
|
@ -47,7 +47,8 @@ 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)
|
||||||
archive.add(SOURCE / "session_v2/schema-v1.json", arcname="session_v2/schema-v1.json")
|
for version in (1, 2):
|
||||||
|
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)}}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue