From d16aa69f9abef88b0cde1bae484e02fe57f9de37 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 22 Oct 2026 07:33:22 +0200 Subject: [PATCH] feat(session-v2): save and restore Sway scratchpad with guarded recovery --- docs/session-restore-v2.md | 66 ++- docs/session-v2-focus-recovery-fix-v1.md | 219 ++++++++ docs/session-v2-path-stability-diagnostic.md | 3 + ...ssion-v2-scratchpad-save-and-restore-v1.md | 180 +++++++ .../controlcenter/SessionExitGate.qml | 15 +- .../labfy-sway/session_v2/anchors.py | 6 +- .../quickshell/labfy-sway/session_v2/cli.py | 7 +- .../labfy-sway/session_v2/executor.py | 64 ++- .../quickshell/labfy-sway/session_v2/guard.py | 37 +- .../labfy-sway/session_v2/layout.py | 15 + .../quickshell/labfy-sway/session_v2/model.py | 2 +- .../labfy-sway/session_v2/recovery.py | 219 +++++++- .../labfy-sway/session_v2/schema-v2.json | 495 ++++++++++++++++++ .../labfy-sway/session_v2/schema.py | 29 +- .../quickshell/labfy-sway/session_v2/slots.py | 18 +- .../labfy-sway/session_v2/snapshot.py | 39 +- .../labfy-sway/tests/focus/test_focus.py | 34 ++ tests/session_v2/login_focus.py | 51 +- tests/session_v2/qml/tst_exit_v2.qml | 13 + tests/session_v2/scratchpad_runtime.py | 133 +++++ tests/session_v2/test_bootstrap.py | 6 +- tests/session_v2/test_focus_recovery.py | 133 +++++ tests/session_v2/test_guard_protocol.py | 74 +++ tests/session_v2/test_p0_boundaries.py | 2 +- tests/session_v2/test_scratchpad.py | 204 ++++++++ tests/session_v2/validate_login_focus.py | 2 +- tests/session_v2/validate_scratchpad.py | 55 ++ tools/build-session-v2.py | 3 +- 28 files changed, 2063 insertions(+), 61 deletions(-) create mode 100644 docs/session-v2-focus-recovery-fix-v1.md create mode 100644 docs/session-v2-scratchpad-save-and-restore-v1.md create mode 100644 quickshell/.config/quickshell/labfy-sway/session_v2/schema-v2.json create mode 100644 tests/session_v2/scratchpad_runtime.py create mode 100644 tests/session_v2/test_focus_recovery.py create mode 100644 tests/session_v2/test_guard_protocol.py create mode 100644 tests/session_v2/test_scratchpad.py create mode 100644 tests/session_v2/validate_scratchpad.py diff --git a/docs/session-restore-v2.md b/docs/session-restore-v2.md index ae3b86644..d814d6ba1 100644 --- a/docs/session-restore-v2.md +++ b/docs/session-restore-v2.md @@ -270,7 +270,71 @@ Voir le [diagnostic ciblé, les preuves et la commande hôte](session-v2-path-st - Contenu et processus terminal 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é. - Provider Firefox candidat actuel pour le profil supporté ; plusieurs profils 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). diff --git a/docs/session-v2-focus-recovery-fix-v1.md b/docs/session-v2-focus-recovery-fix-v1.md new file mode 100644 index 000000000..2725029de --- /dev/null +++ b/docs/session-v2-focus-recovery-fix-v1.md @@ -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. diff --git a/docs/session-v2-path-stability-diagnostic.md b/docs/session-v2-path-stability-diagnostic.md index 6df9d2860..17a03919b 100644 --- a/docs/session-v2-path-stability-diagnostic.md +++ b/docs/session-v2-path-stability-diagnostic.md @@ -1,5 +1,8 @@ # 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 Correction minimale dans la seule copie diff --git a/docs/session-v2-scratchpad-save-and-restore-v1.md b/docs/session-v2-scratchpad-save-and-restore-v1.md new file mode 100644 index 000000000..972f06c02 --- /dev/null +++ b/docs/session-v2-scratchpad-save-and-restore-v1.md @@ -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. diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionExitGate.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionExitGate.qml index 92e13cdad..3a9d6ab5e 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionExitGate.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionExitGate.qml @@ -59,7 +59,20 @@ QtObject { return; } 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(); } diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py b/quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py index f061e9e08..a33c205b1 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py @@ -30,8 +30,12 @@ class Anchors: def identity(self, slot): return "labfy-v2-anchor-" + self.tx + "-" + slot 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) - return node.get("pid") == self.process.pid and node.get("app_id") in self.allowed + return True def anchor(self, slot): require(self.active, "TRANSACTION_INACTIVE") diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py b/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py index 66f50925e..b5b41ea24 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py @@ -15,7 +15,7 @@ from .ipc import Sway from .applications import Catalog from .firefox_identity import FirefoxProvider from .snapshot import capture -from .layout import compile_plan +from .layout import compile_plan, compile_placements from .executor import Executor from .observability import validate as validate_report from .startup import acknowledge, status as startup_status @@ -83,7 +83,10 @@ def main(argv=None): try: result = read(runtime / "session-v2-restore-attempt.json", validate_report) except FileNotFoundError: result = {"status": "idle", "reason": "NO_RESTORE_ATTEMPT"} 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: sway = Sway(args.socket) catalog = Catalog() diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py b/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py index e3fa9a20f..10c2d17a0 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py @@ -18,7 +18,8 @@ from .guard import Guard from .observability import Attempt from .startup import record_deferred 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): @@ -217,7 +218,9 @@ class Executor: recovery.admit(n) self.sway.command(f'[con_id={n["id"]}] move container to workspace ' + json.dumps(stage)) 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): values = {"phase": phase} 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"]]) attempt.data["providers"] = [PROVIDER] 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 # lot phase0, même si un remplacement conserve la cardinalité. check_batch() @@ -283,6 +286,7 @@ class Executor: for n in captured.values(): recovery.admit(n, pinned[n["id"]]) 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 n.get("type") == "floating_con" or n.get("floating") in ("user_on", "auto_on"): self.sway.command(f'[con_id={n["id"]}] floating disable') @@ -290,6 +294,7 @@ class Executor: for slot in data["window_slots"]: if slot["state"]["floating"]: continue 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) attempt.update(phase="swap", anchors_swapped=attempt.data["anchors_swapped"] + 1) self.progress("swap", attempt.data["anchors_swapped"]) @@ -303,6 +308,13 @@ class Executor: self._verify(data, mapping, specs, anchors) attempt.data["timings_ms"]["verification"] = (time.monotonic() - start) * 1000 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 # tiers déjà actif. CONTRACT : son identité/emplacement de baseline # reste inchangé ; le focus du snapshot ne remplace que celui du lot. @@ -312,7 +324,7 @@ class Executor: if foreign_focus is not None: require(placement(recovery.snapshot(origin_focus)) == placement(foreign_focus), "FOREIGN_WINDOW_CHANGED") 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. # L'identité complète reste celle de Recovery, jamais celle d'un # app_id ou de la pile de focus d'un split arbitraire. @@ -322,20 +334,25 @@ class Executor: "RECOVERY_FOCUS_IDENTITY_CHANGED") target_focus = origin_focus 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') else: - origin_workspace = recovery.data["focus_origin"]["workspace"] - require(origin_focus is None and origin_workspace and origin_workspace != stage, "RESTORE_FOCUS_UNAVAILABLE") + origin_workspace = data.get("focus_workspace") or recovery.data["focus_origin"]["workspace"] + require((origin_focus is None or data.get("focus_workspace")) and origin_workspace and origin_workspace != stage, "RESTORE_FOCUS_UNAVAILABLE") self.sway.command("workspace " + json.dumps(origin_workspace)) final_focus = recovery.data["focus_expected"] require(final_focus["workspace"] != stage, "FINAL_FOCUS_IN_STAGE") + recovery.boundary("before-helper-resume") answer = guard.resume() attempt.update(helpers_resumed=answer["helpers_resumed"], phase="final-verification") + recovery.boundary("after-helper-resume") self.progress("after-resume", len(mapping)) self._verify(data, mapping, specs, anchors) recovery.check_command_focus(self.sway.tree(), final_focus, "final-verification") + recovery.boundary("before-guard-finish") guard.finish() guard = None + recovery.boundary("after-guard-finish") self._verify(data, mapping, specs, anchors) recovery.complete(lambda tree: self._verify_structure(data, mapping, specs, anchors, tree)) attempt.update(final_focus_verified=True, status="success", reason="SUCCESS", phase="complete") @@ -370,11 +387,30 @@ class Executor: pass def _floating(self, data, mapping, captured): - for s in data["window_slots"]: - if not s["state"]["floating"]: continue + slots = {s["slot_id"]: s for s in data["window_slots"]} + for placement_op in compile_placements(data): + s = slots[placement_op["slot_id"]] target = mapping[s["slot_id"]] 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") + 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"])) # 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. @@ -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}] 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): # WHY : capture est une frontière externe potentiellement lente. # CONTRACT : UUID exact conservé, puis nouvelle observation complète ; @@ -424,7 +468,9 @@ class Executor: for s in data["window_slots"]: 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") + 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"]: - 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") return tree diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/guard.py b/quickshell/.config/quickshell/labfy-sway/session_v2/guard.py index eecf3c3d9..9c55b2cb7 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/guard.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/guard.py @@ -97,6 +97,18 @@ class Guard: self.process.wait(timeout=15) 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): self.parent.sendall(b"resume") self.parent.settimeout(15) @@ -140,6 +152,7 @@ def run(config): stopped = [] counts = {"helpers_suspended": 0, "helpers_resumed": 0} normal = False + prepared = False sway = Sway(config["socket"]) def resume(): return resume_helpers(stopped, config["socket"], counts) @@ -160,7 +173,19 @@ def run(config): if config["engine_fd"] in readable: break if control in readable: 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": + require(prepared, "GUARD_PROTOCOL_INVALID") require(resume(), "GUARD_RECOVERY_FAILED") control.sendall(json.dumps({"status": "resumed", **counts}).encode()) continue @@ -171,10 +196,13 @@ def run(config): until(lambda: bool(select.select([config["engine_fd"]], [], [], 0)[0]), 3) break 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) - return n.get("pid") == config["anchor_pid"] and n.get("app_id") in config["allowed"] + return True 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 fresh = next((n for n in sway.windows() if n["id"] == old["id"]), None) if fresh and owned(fresh): @@ -186,8 +214,9 @@ def run(config): else: sway.command(f'[con_id={fresh["id"]}] kill') cleaned += 1 - until(lambda: not any(owned(n) for n in sway.windows())) - os.write(config["writer_fd"], b"quit\n") + if not prepared: + 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 # et rendre son appartenance ambiguë. Finir nos obligations de fenêtres # pendant la suspension possédée, puis toujours reprendre les helpers. diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/layout.py b/quickshell/.config/quickshell/labfy-sway/session_v2/layout.py index 5913c51ff..e6334ba6c 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/layout.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/layout.py @@ -44,3 +44,18 @@ def compile_plan(data, tx): emit("create", leaves(t.root)[0]) build(t.root, root=True) 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"]) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/model.py b/quickshell/.config/quickshell/labfy-sway/session_v2/model.py index f897477fd..380cefa2f 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/model.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/model.py @@ -16,7 +16,7 @@ class Application: class WindowSlot: slot_id: str application_id: str - workspace: str + workspace: str | None tree_path: tuple state: dict identity_requirement: str diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/recovery.py b/quickshell/.config/quickshell/labfy-sway/session_v2/recovery.py index de8536e85..ce9571aaf 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/recovery.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/recovery.py @@ -7,9 +7,10 @@ INVARIANT : aucun kill client, aucune propriété déduite du nom du workspace. import copy import json import re +import time from pathlib import Path from .errors import require -from .ipc import walk, location, workspace, until +from .ipc import walk, location, workspace, until, canonical from .storage import atomic, read JOURNAL = "session-v2-recovery.json" @@ -22,6 +23,13 @@ def process_start(pid): 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"]) require(where and where[0], "RECOVERY_LOCATION_MISSING") 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") return {"con_id": node["id"], "app_id": node.get("app_id"), "pid": node.get("pid"), "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): 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): expected = copy.deepcopy(state) fields = ["con_id", "app_id", "pid", "starttime", "workspace", "output", "floating", "geometry"] + if "scratchpad" in expected: fields.append("scratchpad") 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 ")) destination = workspace(tree, expected["workspace"]) if destination: @@ -57,7 +98,9 @@ def command_postcondition(state, target, operation, tree): expected["floating"] = operation == "floating enable" if expected["floating"]: 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 "): values = [int(v) for v in re.findall(r"-?\d+", operation)] g = expected["geometry"] @@ -162,7 +205,26 @@ def command_focus(tree, target, operation): 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")): 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 "): name = json.loads(operation.removeprefix("workspace ")) ws = workspace(tree, name) @@ -185,7 +247,7 @@ def command_focus(tree, target, operation): 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") 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") @@ -196,12 +258,16 @@ def validate(value): 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 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"): 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(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") + 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"] 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") @@ -219,7 +285,48 @@ def validate(value): 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") + 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") + 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 @@ -236,7 +343,7 @@ class Recovery: tree = sway.tree() if proof is not None: 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, "status": "active", "windows": [], "foreign": [], "operations": 0, "focus_origin": focused(tree), "focus_expected": focused(tree), @@ -257,7 +364,12 @@ class Recovery: def snapshot(self, identity, tree=None): 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) - 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): """Observation runtime avant une attente externe, sans autorité de focus. @@ -347,6 +459,14 @@ class Recovery: self.data["operations"] += 1 self.publish() 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: # Une réponse à kill n'est pas le retrait Wayland. Attendre l'anchor # 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") entry.update(expected=after, pending=False) 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() 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): tree = self.sway.tree() ws = workspace(tree, self.data["stage"]) @@ -442,6 +580,8 @@ class Recovery: expected = command_postcondition(observed, target, operation, tree) if observed else None expected_focus = command_focus(tree, target, operation) 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") 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: @@ -467,7 +607,7 @@ class Recovery: self.data["intention"]["observed_after"] = after self.check_command_focus(tree, expected_focus, text) 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() 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"]] 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): if self.data["status"] in ("complete", "recovered"): return {"status": self.data["status"], "windows": [], "issues": []} @@ -555,15 +724,23 @@ class Recovery: "workspace": origin["workspace"], "output": origin["output"], "reason": reason, "inspect": "swaymsg -t get_tree -r", "stage": self.data["stage"], "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"]: for e in self.data["windows"]: issue(e, "SESSION_CHANGED") issues.append("SESSION_CHANGED") else: - recovery_focus = focused(self.sway.tree()) + tree = self.sway.tree() + recovery_focus = focused(tree) if recovery_focus != self.data["focus_expected"]: self.record_focus_conflict({"phase": "recovery-entry", "command": None, "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"]: if e["recovered"]: continue try: @@ -573,8 +750,22 @@ class Recovery: self.publish() 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"] + 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")} if origin["output"] not in active: issue(e, "OUTPUT_UNAVAILABLE"); continue existing = workspace(self.sway.tree(), origin["workspace"]) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v2.json b/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v2.json new file mode 100644 index 000000000..021c17eeb --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v2.json @@ -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" + } + } + } + } + ] + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py b/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py index 151acd099..1f43d6eae 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py @@ -32,8 +32,8 @@ def array(value, maximum=256): def validate(data): - fields(data, "schema version metadata outputs applications window_slots workspaces focus") - require(data["schema"] == SCHEMA and type(data["version"]) is int and data["version"] == 1, "SCHEMA_VERSION") + 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"] in (1, 2), "SCHEMA_VERSION") fields(data["metadata"], "created_ns sway_session reason") integer(data["metadata"]["created_ns"], 0, 2**63 - 1) text(data["metadata"]["sway_session"]) @@ -43,6 +43,7 @@ def validate(data): for o in data["outputs"]: fields(o, "name") text(o["name"], PLACE) + require(data["version"] == 1 or o["name"] != "__i3_scratch", "SCRATCHPAD_WORKSPACE") require(o["name"] not in outputs, "DUPLICATE_OUTPUT") outputs[o["name"]] = o 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") text(s["slot_id"]) text(s["application_id"]) - text(s["workspace"], PLACE) + if s["workspace"] is not None: text(s["workspace"], PLACE) array(s["tree_path"], 32) 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") @@ -68,8 +69,18 @@ def validate(data): state = s["state"] fields(state, "floating geometry fullscreen scratchpad") 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") + # 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"]: fields(state["geometry"], "x y width height") for k in ("x", "y"): integer(state["geometry"][k], -100000, 100000) @@ -108,18 +119,24 @@ def validate(data): for w in data["workspaces"]: fields(w, "workspace output root") text(w["workspace"], PLACE) + require(w["workspace"] != "__i3_scratch", "SCRATCHPAD_WORKSPACE") text(w["output"], PLACE) require(w["workspace"] not in workspaces and w["output"] in outputs, "WORKSPACE_REFERENCE") workspaces[w["workspace"]] = w tree(w["root"], w["workspace"]) 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: - 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(): members = [s for s in slots.values() if s["application_id"] == a["application_id"]] 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] 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 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 diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/slots.py b/quickshell/.config/quickshell/labfy-sway/session_v2/slots.py index a1d0a29a8..33848461c 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/slots.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/slots.py @@ -3,7 +3,7 @@ from .errors import require 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), "UNEXPECTED_EXTRA_WINDOW") 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") result[s["slot_id"]] = target remaining.remove(target) - # Ordre numérique runtime déterministe, jamais une preuve sémantique. - 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)): + # WHY : interchangeable is an explicit loss of semantic identity, never a + # 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 + remaining.remove(target) return result diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py b/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py index e1300f05d..3f6780b6c 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py @@ -2,7 +2,7 @@ import time from .errors import require from .schema import validate, PROVIDER -from .ipc import walk +from .ipc import walk, location 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 à # 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, - 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", []))) nodes = [n for n in walk(tree) if n.get("app_id") or n.get("window")] require(len(nodes) <= 256, "WINDOW_LIMIT") specs = {} 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) apps = {} identities = {} @@ -32,6 +33,16 @@ def capture(sway, catalog, provider, reason="manual"): identities.update({v: k for k, v in provider.capture(members).items()}) slots, workspaces = [], [] 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): nonlocal focused 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} else: requirement, evidence = "interchangeable", None 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 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}) return {"slot_id": slot_id} def convert(n, workspace, path=()): @@ -59,20 +75,27 @@ def capture(sway, catalog, provider, reason="manual"): outputs = [] for output in tree.get("nodes", []): for ws in output.get("nodes", []): - if ws.get("type") != "workspace" or not (ws.get("nodes") or ws.get("floating_nodes")): continue - require(ws["name"] != "__i3_scratch", "CAPABILITY_UNSUPPORTED") + if ws.get("type") != "workspace" or not (ws.get("nodes") or ws.get("floating_nodes") or ws.get("name") == focus_workspace): continue + 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"]) for f in ws.get("floating_nodes", []): children = [n for n in walk(f) if n.get("app_id") or n.get("window")] require(len(children) == 1, "FLOATING_CONTAINER_UNSUPPORTED") + require(children[0] is f, "FLOATING_CONTAINER_UNSUPPORTED") leaf(children[0], ws["name"], (), True) workspaces.append({"workspace": ws["name"], "output": output["name"], "root": root}) 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. require({n["id"] for n in sway.windows()} == set(specs), "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}, "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) diff --git a/quickshell/.config/quickshell/labfy-sway/tests/focus/test_focus.py b/quickshell/.config/quickshell/labfy-sway/tests/focus/test_focus.py index 8dea74190..8d454fe4e 100644 --- a/quickshell/.config/quickshell/labfy-sway/tests/focus/test_focus.py +++ b/quickshell/.config/quickshell/labfy-sway/tests/focus/test_focus.py @@ -186,6 +186,7 @@ class HelperAnchors: class HelperGuard: ready = dict(helpers_suspended=0) def __init__(self, *args): pass + def prepare(self): return dict(status="prepared") def resume(self): return dict(helpers_resumed=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(recovery_status(tmp)['status'], 'complete') 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): with tempfile.TemporaryDirectory() as tmp: sway = MutationSway(5) diff --git a/tests/session_v2/login_focus.py b/tests/session_v2/login_focus.py index f1b6a6f12..23303d22f 100644 --- a/tests/session_v2/login_focus.py +++ b/tests/session_v2/login_focus.py @@ -13,6 +13,7 @@ from unittest.mock import patch from common import * from lab import Lab, Surfaces, build_helper from session_v2.executor import Executor +from session_v2.guard import Guard from session_v2.applications import launch as real_launch from dataclasses import replace from session_v2.recovery import JOURNAL, focused, process_start, Recovery, recovery_status @@ -122,7 +123,7 @@ def run_case(case): return value foreign = Surfaces(lab, binary) 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"') foreign_node = foreign.create('TEST_ONLY_foreign') lab.command('workspace "1"') @@ -189,6 +190,36 @@ def run_case(case): return {UUID: nodes[0]['id']} def capture(self, nodes): return {UUID: nodes[0]['id']} 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 def command(text): 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()) sway.command = command 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': bad_catalog = Catalog(tuple(replace(spec, argv=(str(lab.directory / 'TEST_ONLY_NO_EXECUTABLE'),)) 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. 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()) - 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 # est conservé comme BLOCKED et retourne un code non nul. 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 not any(location(sway.tree(), n['id'])[0] == journal['stage'] for n in sway.windows()) 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: - expected = {'unowned-map': 'APPLICATION_MEMBERSHIP_CHANGED', + expected = {'unowned-map' : 'APPLICATION_MEMBERSHIP_CHANGED', 'provider-placement': 'TARGET_OBSERVATION_CHANGED', 'pinned-map': 'APPLICATION_MEMBERSHIP_CHANGED', 'map-in-ipc-gap': 'RECOVERY_FOCUS_MISMATCH', @@ -300,7 +341,7 @@ def run_case(case): def main(): 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() row = run_case(args.case) print(json.dumps({'scenario': args.case, 'requirement_met': row['requirement_met'], diff --git a/tests/session_v2/qml/tst_exit_v2.qml b/tests/session_v2/qml/tst_exit_v2.qml index 03e5cd3f9..b1e698606 100644 --- a/tests/session_v2/qml/tst_exit_v2.qml +++ b/tests/session_v2/qml/tst_exit_v2.qml @@ -41,6 +41,19 @@ TestCase { 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() { const g = gate(); const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"}); diff --git a/tests/session_v2/scratchpad_runtime.py b/tests/session_v2/scratchpad_runtime.py new file mode 100644 index 000000000..45d44b54a --- /dev/null +++ b/tests/session_v2/scratchpad_runtime.py @@ -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() diff --git a/tests/session_v2/test_bootstrap.py b/tests/session_v2/test_bootstrap.py index dce5e5d32..c5159dc1b 100644 --- a/tests/session_v2/test_bootstrap.py +++ b/tests/session_v2/test_bootstrap.py @@ -22,9 +22,9 @@ def frame(value): class BootstrapTests(unittest.TestCase): def setUp(self): - base = REPO / "build/bootstrap-fixtures" - base.mkdir(parents=True, exist_ok=True) - self.tmp = tempfile.TemporaryDirectory(dir=base) + # AF_UNIX est borné à 108 octets : un worktree profond ne doit pas + # rendre ces sockets TEST_ONLY dépendants du chemin du dépôt. + self.tmp = tempfile.TemporaryDirectory(prefix="labfy-v2-bootstrap-") self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) for name in ("home", "state", "runtime"): diff --git a/tests/session_v2/test_focus_recovery.py b/tests/session_v2/test_focus_recovery.py new file mode 100644 index 000000000..82a2dd717 --- /dev/null +++ b/tests/session_v2/test_focus_recovery.py @@ -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() diff --git a/tests/session_v2/test_guard_protocol.py b/tests/session_v2/test_guard_protocol.py new file mode 100644 index 000000000..e436870da --- /dev/null +++ b/tests/session_v2/test_guard_protocol.py @@ -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() diff --git a/tests/session_v2/test_p0_boundaries.py b/tests/session_v2/test_p0_boundaries.py index 42f4c17dc..0d84b2e87 100644 --- a/tests/session_v2/test_p0_boundaries.py +++ b/tests/session_v2/test_p0_boundaries.py @@ -71,7 +71,7 @@ class BoundaryTests(unittest.TestCase): parent['nodes'].remove(node) break 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) 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'] diff --git a/tests/session_v2/test_scratchpad.py b/tests/session_v2/test_scratchpad.py new file mode 100644 index 000000000..8ed24d559 --- /dev/null +++ b/tests/session_v2/test_scratchpad.py @@ -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() diff --git a/tests/session_v2/validate_login_focus.py b/tests/session_v2/validate_login_focus.py index 8f31fc6df..30dac47a0 100644 --- a/tests/session_v2/validate_login_focus.py +++ b/tests/session_v2/validate_login_focus.py @@ -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'])"], '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] def run(item): name, argv = item diff --git a/tests/session_v2/validate_scratchpad.py b/tests/session_v2/validate_scratchpad.py new file mode 100644 index 000000000..b749d2e3e --- /dev/null +++ b/tests/session_v2/validate_scratchpad.py @@ -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()) diff --git a/tools/build-session-v2.py b/tools/build-session-v2.py index ec1e2923a..f8ac44a80 100755 --- a/tools/build-session-v2.py +++ b/tools/build-session-v2.py @@ -47,7 +47,8 @@ def build(output, extension_only=False): for name in ("session-v2.py", "session-v2-native-host.py"): archive.add(SOURCE / name, arcname=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(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)}}