diff --git a/.gitignore b/.gitignore index d441ac8b8..587a728a2 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,8 @@ bin/.local/bin/trainlog-bt-watch bin/.local/bin/trainlog-btd bin/.local/bin/trainlog-sync-once bin/.local/bin/trainlog-syncd + +# Session V2 : sorties locales reproductibles, jamais distribuées par Git. +/build/session-v2/ +/build/session-v2-tests/ +/quickshell/.config/quickshell/labfy-sway/session_v2/libexec/ diff --git a/README.md b/README.md index 4f8625f19..c55dd30fa 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,18 @@ inventaires et validations sont décrits dans le | [Recovery](docs/recovery.md) | Reconstruction complète après perte du disque | | [Security](docs/security.md) | Secrets exclus et règles de publication | +## Session Restore V2 + +QuickShell crée un checkpoint avant logout, reboot ou poweroff. Au login suivant, +un chooser unique restaure la session en un clic, sans autre interaction. +Firefox s’appuie sur son companion signé et un native host de corrélation ; +Limusic reste géré par l’autostart Sway. La fenêtre terminal est restaurée, +mais pas son contenu ni ses processus. Ratios exacts, fullscreen et scratchpad +ne sont pas pris en charge ; le multi-output physique n’est pas validé et le +provider Firefox actuel reste limité au profil supporté. + +Voir [installation, fonctionnement et tests V2](docs/session-restore-v2.md). + ## ✦ Mirrors | Rôle | Dépôt | diff --git a/docs/session-restore-v1.md b/docs/session-restore-v1.md index 8e671ee31..2bb0a8739 100644 --- a/docs/session-restore-v1.md +++ b/docs/session-restore-v1.md @@ -1,697 +1,11 @@ -# Restore Plan et Executor V1 — STEP19A/STEP19B/STEP19C +# Référence historique V1 -## Statut et architecture +Le runtime V1 a été retiré après la recette réelle réussie de Session Restore V2. +Ce document ne décrit plus de commandes exécutables. Utiliser exclusivement +[Session Restore V2](session-restore-v2.md). -**Le planner STEP19A ne restaure jamais.** L'exécution contrôlée appartient -exclusivement au module séparé STEP19B décrit à la fin de ce document. - -STEP19A charge un Session Snapshot V1 persistant, observe la session Sway -actuelle avec les quatre lectures STEP18, puis produit un plan JSON. Il ne -lance ni ne tue aucun processus, ne change aucun workspace, ne déplace aucune -fenêtre et n'envoie aucune commande Sway mutatrice. L'exécuteur appartient à -STEP19B ; le Spatial Canvas appartient à STEP20. - -```text -snapshot persistant validé + Session Snapshot V1 live validé - ↓ -session_restore.py (fonction pure) - ↓ -labfy.sway.restore-plan / version 1 -``` - -Le point d'entrée reste : - -```bash -session_snapshot.py plan -``` - -`session_snapshot.py` possède le chargement sûr, le verrou partagé, la -validation persistante et la collecte IPC. `session_restore.py` ne possède ni -I/O fichier, ni subprocess, ni API Sway. Il reçoit deux documents normalisés -et réutilise donc sans duplication la classification, la résolution -DesktopEntry et l'inventaire live de STEP18. - -## Enveloppe Restore Plan V1 - -```json -{ - "schema": "labfy.sway.restore-plan", - "version": 1, - "read_only": true, - "execution_supported": false, - "source_session": "dev", - "source": {}, - "live": {}, - "policy": {}, - "matches": [], - "unmatched_source_windows": [], - "actions": [], - "diagnostics": {} -} -``` - -Le planner n'ajoute pas d'heure courante. Les seuls timestamps viennent de ses -entrées. Un même couple snapshot/live produit donc le même plan. - -Chaque action contient au minimum : - -```text -action_id -action -phase -source_window -snapshot_identity -depends_on[] -``` - -`depends_on` exprime les chaînes futures, par exemple lancement, placement, -arbre puis focus. Il n'exécute pas ces opérations. - -## Inventaire live et identité runtime - -`live.windows[]` conserve : - -```text -runtime.con_id, runtime.pid, workspace, output -app_id, class, instance, desktop_entry, executable_basename -classification.category, classification.managed_by -floating, fullscreen_mode, scratchpad, focused, title_hint -``` - -Les PID et `con_id` sont exposés comme observations pour un futur exécuteur -dans la session courante, mais `policy.runtime_identity_fields` est vide. Ils -ne participent jamais au matching persistant : ils changent, et peuvent même -être réutilisés numériquement, après logout ou reboot. - -## Matching one-to-one - -Une arête snapshot→live n'existe que si une preuve d'identité applicative -existe. Les preuves, par poids décroissant, sont : - -1. même DesktopEntry résolue ; -2. même `app_id` ; -3. même classe XWayland/`StartupWMClass` ; -4. même instance ; -5. même basename exécutable compatible. - -Deux `app_id` explicites et différentes interdisent un match fondé seulement -sur un basename. La résolution DesktopEntry n'est pas réimplémentée : elle -provient de `resolve_desktop_entry()` STEP18 pour le snapshot comme pour le -live. - -Le workspace identique, le même état floating et un `title_hint` identique -servent seulement de départage. Un titre ne crée jamais un candidat et n'est -jamais une identité obligatoire. - -Le planner trie globalement toutes les arêtes par preuve, puis applique un -matching one-to-one. Une fenêtre live ne peut satisfaire qu'une fenêtre -sauvegardée. Pour deux Firefox sauvegardées et une live, une seule est -réutilisée ; l'autre reste absente. L'ordre snapshot ne sert qu'au dernier -départage. - -## Confiance - -```text -exact DesktopEntry identique + app_id ou classe identique -high DesktopEntry, app_id ou classe exacte sans preuve combinée -medium instance ou basename compatible -ambiguous plusieurs arêtes de meilleur score subsistent -unresolved aucune preuve suffisante -``` - -`exact` n'est jamais déduit du PID, du `con_id`, du titre seul ou de la seule -position dans l'arbre. - -## Classification et lancement futur - -### Applications utilisateur - -Une application absente n'est proposée au lancement que si sa DesktopEntry a -été résolue avec confiance STEP18 `exact`. Une identité heuristique ou -absente produit `manual-required` ; aucune ligne de commande n'est inventée. - -Le futur backend STEP19B est figé conceptuellement comme : - -```json -{ - "backend": "uwsm-app-desktop-entry", - "argv": ["uwsm", "app", "--", "firefox.desktop"], - "execute_in_step19a": false -} -``` - -L'audit de `uwsm 0.27.0` confirme que `uwsm app` accepte un exécutable, un -identifiant DesktopEntry ou son chemin, et lance l'application comme scope ou -service dans une slice graphique. Sa documentation recommande ce mécanisme -pour ne pas accumuler les applications dans l'unité du compositeur. STEP19B -devra donc préférer l'identifiant DesktopEntry via `uwsm app` à `gtk-launch`, -`gio launch` ou un enfant direct de Sway. STEP19A ne l'exécute jamais. - -Le lancement ne promet pas de restaurer l'état interne d'un terminal, les -onglets d'un navigateur ou le contenu propre à une application. - -### Applications gérées par autostart - -Une vue présente est réutilisée normalement. Une vue absente produit -`skip-autostart-managed`, jamais `launch-application`, car `sway-autostart` -possède son lancement. C'est notamment le contrat de `limusic-app` issu de -`exec limusic-app`. STEP19B devra attendre ou demander une intervention au -lieu de créer une double instance. - -### Infrastructure et daemons - -`session-infrastructure` et `daemon` ne produisent jamais de lancement. -QuickShell, Polkit, autotiling, wlsunset, les services systemd utilisateur et -le listener de transparence restent possédés par leur sous-système. Une -absence inattendue produit `manual-required`. - -## Workspaces et outputs - -Chaque action conserve `desired_workspace` et `desired_output`. Une fenêtre -live au mauvais endroit produit `reuse-window`, puis `move-to-workspace`, sans -lancement supplémentaire. - -Les outputs sont appariés par nom de connecteur. La politique V1 est : - -```text -connector name preferred -missing output = deferred-no-fallback -``` - -Si le connecteur sauvegardé est absent, le planner produit -`manual-required(reason=missing-output)` et n'invente ni écran ni fallback. Le -lancement d'une application absente est lui aussi différé dans ce cas. - -## Arbre Sway natif - -Le planner compare une signature sémantique par vue : layout/orientation du -workspace, chaîne des containers parents, layout/orientation/percent des -parents, branche, index et percent de la fenêtre. Les `container_id` runtime -sont exclus. - -Une différence produit `restore-tree-position` avec -`capability=partially-supported`. Sway natif sait déplacer, splitter, choisir -un layout et ajuster des proportions, mais V1 ne prétend pas reconstruire -atomiquement tout arbre arbitraire. STEP19B devra valider ses primitives et -replanifier après l'apparition des fenêtres. - -Seul `layout_engine=sway-native` est accepté. `spatial-canvas-v1` reste hors -périmètre jusqu'à STEP20. - -## Floating, fullscreen et scratchpad - -Une différence live/source produit une intention distincte : - -```text -restore-floating -restore-fullscreen -restore-scratchpad-hidden -restore-scratchpad-visible -``` - -Une fenêtre live présente dans le scratchpad alors que le snapshot ne le -demande pas produit `manual-required`, plutôt qu'une hypothèse destructrice. - -## Ordre et focus - -Le focus est toujours la dernière phase. Il n'est ajouté que si le focus live -diffère ou si une intention antérieure pourrait le perturber. - -```text -1. match-managed : réutiliser/différer/bloquer -2. launch : intentions de lancement exactes -3. wait-match : réservé au rematching STEP19B -4. workspace : placement workspace/output -5. tree-layout : arbre Sway natif -6. floating -7. fullscreen -8. scratchpad -9. focus -``` - -Le validateur refuse une dépendance future, un recul de phase ou une action -après le focus. - -## Actions V1 et refus explicites - -```text -reuse-window -launch-application -skip-autostart-managed -move-to-workspace -restore-tree-position -restore-floating -restore-fullscreen -restore-scratchpad-hidden -restore-scratchpad-visible -restore-focus -manual-required -``` - -`manual-required` porte notamment les raisons suivantes : - -```text -no-exact-launch-identity -missing-output -non-restorable-category -live-window-unexpected-scratchpad -``` - -Dire que l'automatisation ne sait pas est préférable à une commande inventée. - -## Garanties et limites STEP19A - -Le module planner n'importe pas `subprocess`, ne contient pas `swaymsg`, et ne -possède aucune fonction de lancement, d'écriture ou de suppression. Le CLI -n'appelle que : - -```text -show_session(name) # lecture et validation persistante -collect(autostart) # quatre IPC read-only STEP18 -build_restore_plan() # fonction pure -``` - -Le plan expose : - -```text -read_only = true -execution_supported = false -mutating_commands_executed = 0 -applications_launched = 0 -snapshots_written = 0 -``` - -Le planner n'effectue aucune attente de fenêtre, aucune commande Sway -mutatrice, aucune restauration d'état interne, aucun fallback multi-output, -aucune UI QML et aucun comportement Spatial Canvas. - -## Executor V1 — STEP19B - -### Frontière d'effets et CLI - -`session_restore.py` reste le moteur pur. `session_executor.py` est la seule -frontière autorisée à ouvrir le lock d'exécution et à lancer des argv directs. - -```text -Session Snapshot V1 validé - ↓ -Restore Planner pur - ↓ -Restore Plan V1 frais - ↓ -Session Executor V1 - ↓ -uwsm app + Sway IPC ciblé -``` - -La mutation nécessite une intention explicite difficile à déclencher par -accident : - -```bash -session_snapshot.py apply --execute -``` - -`apply ` sans `--execute` retourne -`RESTORE_EXECUTION_REQUIRES_EXPLICIT_EXECUTE` sans charger l'exécuteur. Le CLI -n'accepte jamais un fichier Restore Plan comme autorité. Chaque apply exécute -obligatoirement : - -```text -load snapshot → collect live → build fresh plan → validate → preflight → execute -``` - -Le snapshot est hashé avant et après l'opération. Une différence produit un -échec explicite ; l'exécuteur ne possède aucune API d'écriture de snapshot. - -### Capability gate intégrale - -Avant le premier lancement ou la première commande Sway, l'exécuteur examine -toutes les actions. Le sous-ensemble STEP19B est : - -```text -reuse-window no-op revalidé -launch-application uwsm app + attente/rematching -skip-autostart-managed no-op explicite, propriétaire inchangé -move-to-workspace workspace numérique seulement -restore-floating enable/disable déterministe -restore-fullscreen modes 0 et 1 seulement -restore-focus toujours dernière mutation -``` - -Ces actions bloquent l'ensemble du plan avec -`RESTORE_BLOCKED_UNSUPPORTED_ACTION` et une liste précise, sans aucune mutation : - -```text -restore-tree-position -restore-scratchpad-hidden -restore-scratchpad-visible -manual-required -toute action inconnue -``` - -Un matching runtime `ambiguous`, un workspace non numérique et un mode -fullscreen non prouvé bloquent eux aussi le plan. Une fenêtre absente n'a pas -encore de position d'arbre observable ; le planner n'émet donc une différence -`restore-tree-position` que pour une fenêtre déjà matchée. STEP19C pourra -replanifier l'arbre après apparition. - -### Lock privé - -Un apply détient un `flock(LOCK_EX|LOCK_NB)` sur `.restore.lock` pendant toute -la séquence load/plan/execute/rehash. Le fichier est régulier, ouvert sans -suivre les symlinks et forcé à `0600` dans le répertoire sessions `0700`. Le -fichier peut persister ; la fermeture du FD libère le lock. Aucun daemon ou -scheduler supplémentaire n'est créé. - -### DesktopEntry et lancement UWSM - -`launch.argv` dans le plan reste purement descriptif et n'est jamais exécuté. -Pour chaque lancement, l'exécuteur vérifie : - -```text -action = launch-application -backend = uwsm-app-desktop-entry -classification = user-application -restore_identity.confidence = exact -DesktopEntry ID syntaxiquement bornée -résolution exacte identique dans les sources XDG courantes -``` - -Il reconstruit ensuite lui-même exactement : - -```python -["uwsm", "app", "--", desktop_entry] -``` - -Il n'utilise ni shell, ni `Sway exec`, ni `gtk-launch`, ni `gio launch`. Comme -`uwsm app` peut rester attaché pendant toute la vie de l'application, le -processus est lancé avec un argv direct, sorties vers `DEVNULL` et une nouvelle -session de processus. Son PID et son exit status ne prouvent jamais le succès. - -### Apparition et matching post-launch - -L'exécuteur conserve l'inventaire des `con_id` avant lancement, puis poll -`get_tree` via la collecte Session Snapshot V1 pendant au plus 10 secondes. -Seules les nouvelles fenêtres sont candidates. Il réutilise directement -`session_restore.match_windows()` ; il ne possède aucun second matcher. - -Zéro candidate compatible conduit à `RESTORE_LAUNCH_TIMEOUT`. Plusieurs -candidates indiscernables conduisent à `RESTORE_LAUNCH_AMBIGUOUS`. Le PID du -processus lancé n'intervient pas, ce qui couvre aussi une application qui -demanderait à une instance existante de créer une nouvelle fenêtre. - -### Revalidation runtime et commandes ciblées - -Avant chaque mutation, une nouvelle collecte doit retrouver le même `con_id`, -une identité application compatible et un workspace connu. Cela interdit de -cibler silencieusement un `con_id` disparu ou recyclé. Après la commande, une -attente bornée à une seconde relit l'arbre jusqu'à observer la postcondition, -en revalidant l'identité à chaque lecture. Elle absorbe uniquement le délai de -visibilité IPC ; son expiration reste un échec critique. - -Les workspaces exécutables sont des chaînes numériques canoniques positives. -Les noms libres restent hors périmètre afin qu'aucun quoting Sway non prouvé -ne devienne une surface d'injection. Un déplacement reconstruit uniquement : - -```text -[con_id=] move container to workspace number -``` - -Floating utilise exclusivement `floating enable` ou `floating disable`. -L'audit de `sway(5)` 1.12.0 prouve `fullscreen enable|disable [global]` : V1 -supporte `fullscreen_mode=0` avec `fullscreen disable` et le mode workspace -`fullscreen_mode=1` avec `fullscreen enable`. Le mode global et toute autre -valeur sont bloqués. Aucun état ne recourt à `toggle`. - -Le focus utilise `[con_id=] focus`, après la même revalidation, et reste la -dernière mutation. La disparition de la cible ne focalise jamais une autre -fenêtre par défaut. - -### Rapport, arrêt et absence de rollback magique - -Chaque action du rapport possède `pending`, `success`, `skipped`, `failed` ou -`blocked`, avec un `reason`. L'exécution s'arrête à la première divergence -critique et laisse les actions suivantes `pending`. - -Les lancements et mutations Sway ne sont pas transactionnels. Executor V1 ne -promet aucun rollback global : sa politique est un preflight total strict, -des commandes ciblées, une vérification après chaque commande, puis un arrêt -immédiat au premier écart. - -### Validation contrôlée STEP19B - -La validation réelle utilise exclusivement une Kitty créée pour le test sur -un workspace numérique libre. Le scénario A réutilise la fenêtre existante et -la replace par `con_id`, sans nouveau lancement. Le scénario B ferme seulement -cette fenêtre, lance `kitty.desktop` via UWSM, observe une nouvelle fenêtre, -la rematche et la replace. Le cleanup ferme uniquement le nouveau `con_id`, -supprime uniquement le snapshot de validation et restaure le focus utilisateur -initial lorsqu'il existe encore. - -Les fenêtres non marquées `TEST_ONLY` sont comparées avant/après sur -`con_id`, workspace, floating et fullscreen. L'arbre complet, le scratchpad -avancé, le Spatial Canvas et toute UI QML restent hors STEP19B. - -## Tree Restore Compiler et scratchpad — STEP19C - -### Audit local Sway 1.12 / SwayFX 0.6 - -L'implémentation STEP19C est fondée sur la documentation réellement installée -et sur des expériences `get_tree` avec des Kitty `TEST_ONLY`, pas sur une -supposition i3. La version validée est : - -```text -swayfx version 0.6 (based on sway 1.12.0) -swaymsg version 0.6 (based on sway 1.12.0) -``` - -`sway(5)` confirme localement : - -```text -layout default|splith|splitv|stacking|tabbed -split vertical|horizontal|none|toggle -focus parent|child -move left|right|up|down -move container to mark -mark --add|--replace [--toggle] -unmark [] -resize ... px|ppt -move container to scratchpad -scratchpad show -``` - -L'IPC expose le layout `stacked`, tandis que la commande correspondante est -`layout stacking`. Cette conversion est une table locale fermée. `scratchpad -show` sans critère est cyclique et n'est jamais utilisé ; STEP19C reconstruit -toujours `[con_id=] scratchpad show`. - -### Matrice de capacités - -| Élément | Niveau STEP19C | Contrat | -|---|---|---| -| Racine `splith` | `supported-subset` | Container racine homogène, fenêtres toutes matchées | -| Racine `splitv` | `supported-subset` | Même contrat | -| Racine `tabbed` | `supported-subset` | Même contrat, layout final relu | -| Racine `stacked` | `supported-subset` | Commande locale `stacking`, layout IPC `stacked` | -| Ordre des enfants | `supported-subset` | Insertion ciblée après une fenêtre de référence | -| `H[A,V[B,C]]` et symétrique | `supported-subset` | Un seul container imbriqué, deux feuilles, splits orthogonaux | -| Arbre imbriqué arbitraire | `unsupported` | Bloqué avant mutation, jamais aplati silencieusement | -| Proportions `percent` | `unsupported` | Observées mais aucune promesse de restitution V1 | -| Branche floating | `supported-subset` | Préservée, jamais compilée comme enfant tiling | -| Scratchpad caché | `supported-subset` | `move scratchpad` ciblé + relecture V1 | -| Scratchpad visible | `supported-subset` | Workspace cible numérique déjà focalisé + show ciblé | -| Spatial Canvas | `unsupported/out-of-scope` | Aucun comportement `spatial-canvas-v1` | - -Cette matrice ne signifie pas « arbitrary Sway tree restore ». Un changement -de style entre des fenêtres directement sous le workspace et un container -racine explicite, plusieurs containers imbriqués, un nesting en tête, des -splits imbriqués redondants ou un arbre live déjà imbriqué hors forme cible -sont refusés. - -### Architecture et représentation normalisée - -```text -Session Snapshot V1 target - + -Session Snapshot V1 live frais - + -matching source_window → runtime con_id - ↓ -session_tree_restore.py (pur) - ↓ -Tree Restore compilation - ↓ -operations[] typées - ↓ -session_executor.py (effets externes) -``` - -`TreeNode` ne conserve que `kind`, identité de fenêtre sauvegardée, layout, -percent observé et enfants. Il ne prend jamais `container_id`, `con_id` ou PID -sauvegardé comme identité persistante. Le lien live est reconstruit uniquement -depuis le matching frais du planner et le `con_id` observé dans cette session. - -Le compilateur valide d'abord toutes les références V1, l'unicité des feuilles, -la branche tiling/floating, les layouts et la forme. Il compare ensuite les -arbres normalisés. Un arbre identique produit zéro opération. Pour le -sous-ensemble supporté, il produit seulement : - -```text -set-container-layout -set-split-orientation -move-relative(relation=after) -``` - -Les opérations scratchpad sont portées par les actions du plan : - -```text -move-to-scratchpad -show-scratchpad-window -``` - -Elles ne sont jamais des chaînes Sway provenant du snapshot. Les enums sont -validés puis traduits par l'exécuteur en argv directs, sans shell. - -### Ordre et marks temporaires - -`move left/right` n'est pas une primitive d'ordre universelle : sur la version -locale, elle peut extraire une vue d'un container `tabbed` ou `stacked`. -STEP19C utilise donc un mark temporaire uniquement pour `move-relative` : - -```text -[reference] mark --add __labfy_restore_ -[source] move container to mark __labfy_restore_ -[reference] unmark __labfy_restore_ -``` - -Le préfixe est réservé, le suffixe contient 64 bits aléatoires encodés en hex, -et jusqu'à huit candidats sont comparés à tous les marks live avant création. -Le mark est ajouté uniquement à une fenêtre cible déjà revalidée, n'utilise -jamais `--replace`, et est supprimé dans un `finally`. Son apparition et sa -disparition sont relues. Une fuite produit `RESTORE_TEMPORARY_MARK_LEAK`. - -L'ordre cible est construit en gardant le premier enfant comme ancre, puis en -insérant chaque enfant suivant après son prédécesseur cible. Cette règle est -déterministe et fonctionne indépendamment de la direction géométrique du -layout. - -### Nesting supporté - -Le nesting V1 supporté contient exactement un container interne de deux -fenêtres, précédé d'au moins une feuille, avec orientations orthogonales : - -```text -H[A,V[B,C]] -V[A,H[B,C]] -``` - -Le compilateur remet d'abord l'ordre plat, applique le layout racine, crée le -split interne sur B, puis insère C après B via le mark temporaire. `get_tree` -doit ensuite être exactement égal à la topologie cible normalisée. Un arbre -plus profond ou une transition depuis un arbre live imbriqué non reconnu est -`unsupported-live-nested-tree` et bloque le plan entier. - -### Proportions - -Sway 1.12 expose `resize ... ppt`, mais les ajustements sont relatifs aux -frères, arrondis par la géométrie disponible et dépendants de l'ordre des -redimensionnements. Les expériences STEP19C ne justifient donc pas une -restitution générale déterministe des `percent`. Le compilateur conserve les -valeurs pour le diagnostic mais publie : - -```text -percent_capability = unsupported -``` - -Aucune commande `resize` n'est émise et le rapport ne prétend pas restaurer -les proportions exactes ou approchées. - -### Preflight global et dérive - -Avant la première mutation, le capability gate vérifie toutes les actions, -les identités de lancement, les workspaces/outputs, les modes fullscreen, les -cibles scratchpad et la compilation complète d'arbre. Pour chaque workspace -compilé : - -```text -toutes les fenêtres target sont matchées -aucune fenêtre tiling live inattendue n'existe -aucune fenêtre floating ne devient tiling -layout et forme sont dans le sous-ensemble -toutes les opérations sont typées et validées -aucune action manual-required ne subsiste -``` - -Une seule erreur rend le plan entier `RESTORE_BLOCKED_UNSUPPORTED_ACTION` -avant lancement ou mutation. - -Entre deux mutations d'arbre, l'exécuteur recalcule une empreinte contenant le -layout du workspace, les parents, les layouts et l'ordre des fenêtres ciblées. -Elle doit être égale à la postcondition de l'opération précédente. Une action -utilisateur concurrente produit `RESTORE_TREE_DRIFTED` et la commande suivante -n'est pas lancée. Après chaque opération, une nouvelle empreinte doit devenir -visible ; enfin le compilateur est relancé sur le live final et doit produire -zéro opération avec une topologie target/live égale. - -### Scratchpad ciblé et isolation utilisateur - -Le snapshot V1 distingue : - -```text -member=true, visibility=hidden, workspace=null, branch=scratchpad -member=true, visibility=visible, workspace=, branch=floating -``` - -Pour une cible cachée, STEP19C exécute `move scratchpad` si la fenêtre est -hors scratchpad ou visible, puis exige la représentation cachée ci-dessus. -Pour une cible visible, une fenêtre hors scratchpad est d'abord admise, puis -`[con_id=] scratchpad show` est envoyé uniquement à cette fenêtre. Le -workspace numérique cible doit déjà être focalisé au preflight ; sinon le -placement de la vue visible serait ambigu et tout le plan est bloqué. - -Les autres fenêtres scratchpad, y compris celles de l'utilisateur, ne sont ni -cyclées ni marquées. Une cible disparue, une identité changée ou un matching -multiple ambigu arrête l'exécution. Cette stratégie reste sûre même avec -plusieurs membres scratchpad, car aucune commande `scratchpad show` globale -n'existe dans l'exécuteur. - -### Limite non transactionnelle - -Les commandes Sway restent non transactionnelles. STEP19C maximise la sûreté -par preflight intégral, revalidation avant chaque commande, postcondition après -chaque mutation, marks nettoyés et arrêt au premier écart. Il ne promet ni -rollback global, ni restauration d'un arbre arbitraire, ni reconstruction des -proportions, ni UI. `spatial-canvas-v1` demeure entièrement hors périmètre. - -## STEP19D — Gestionnaire graphique QuickShell - -Le Control Center expose une page dédiée sous la page système `Session`. La -page système et ses commandes de verrouillage, veille, déconnexion UWSM, -redémarrage et extinction restent distinctes. La navigation du gestionnaire -est `Sessions → Détail → Aperçu` et chaque retour remonte d'un niveau. - -Le QML reste une frontière d'interface. Il appelle exclusivement -`session_snapshot.py` avec les commandes publiques `list`, `show`, `save`, -`delete`, `plan` et `apply --execute`. Le chemin déployé est résolu par -`$XDG_CONFIG_HOME/quickshell/labfy-sway/session/session_snapshot.py`, avec le -repli XDG standard `$HOME/.config/...`. Aucun chemin utilisateur ou chemin de -checkout n'est figé dans le QML. - -Un unique `Process` asynchrone sérialise les opérations. Ses commandes sont -toujours des tableaux argv directs ; aucun shell, aucune interpolation de nom -de session et aucune commande Sway ne sont accessibles à l'UI. `stdout` JSON -et `stderr` diagnostic sont collectés séparément. Un résultat n'est accepté -que si le processus termine avec succès et si le JSON attendu est entier et -structurellement cohérent. Pendant `save`, `delete` ou `apply`, les autres -mutations restent désactivées. - -`plan` est un aperçu strictement read-only. Après confirmation, `apply ---execute` recharge le snapshot, observe un live frais, reconstruit le plan et -refait le preflight : le QML n'exécute jamais les actions du plan et ne peut -pas transformer un ancien aperçu en autorisation. Les limites sur l'état -interne des applications et les proportions de splits sont affichées avant la -confirmation. - -Il n'existe aucun déclencheur de sauvegarde ou restauration au chargement, à -la connexion, à la déconnexion ou sur minuterie. Toutes les mutations sont la -conséquence immédiate d'une action utilisateur confirmée. +Les anciennes sessions et snapshots utilisateur sous XDG_STATE_HOME sont +conservés. Aucune migration ni suppression de données n’a été effectuée. +Les sources et documents V1 antérieurs au nettoyage sont archivés dans la +branche locale de recherche `refactor/session-restore-v2`, sans publication. +L’historique Git public conserve les versions V1 précédemment publiées. diff --git a/docs/session-restore-v2.md b/docs/session-restore-v2.md new file mode 100644 index 000000000..70010a392 --- /dev/null +++ b/docs/session-restore-v2.md @@ -0,0 +1,115 @@ +# Session Restore V2 + +La recette réelle du 7 octobre 2026 a réussi : logout, checkpoint V2, arrêt par +UWSM, login, chooser unique et un clic **Restaurer**. Aucune interaction après +ce clic ; arbre, focus final et identité Firefox exacte vérifiés. La validation +visuelle utilisateur est PASS. Les preuves détaillées restent dans la branche +locale de recherche ; elles ne sont pas distribuées dans ce dépôt. + +## Utilisation + +Le menu power conserve Verrouiller, Suspendre, Déconnexion, Redémarrer et +Éteindre. Déconnexion sauvegarde un checkpoint V2 avant `uwsm stop` ; Redémarrer +et Éteindre sauvegardent avant leur action systemd. Un échec de checkpoint +permet d’annuler ou de choisir explicitement de quitter sans sauvegarder. + +Au login suivant, le chooser propose Restaurer ou Nouvelle session. Un clic +Restaurer lance la transaction jusqu’au résultat final, sans étape Firefox +manuelle ni deuxième restauration. Le chooser est acquitté durablement ; un +redémarrage QuickShell dans la même session ne le réaffiche pas. + +Le gestionnaire de sessions permet sauvegarde, liste, aperçu structurel, +suppression et restauration V2. L’aperçu ne modifie aucune fenêtre. Le backend +fournit aussi `show` pour inspecter une session. Les noms sont limités à 64 +caractères ASCII alphanumériques, tirets et underscores, commençant par un +caractère alphanumérique. Ne pas publier les sessions ou rapports runtime. + +## Installation reproductible + +Prérequis : Python 3, Sway compatible, QuickShell/Qt 6, GTK3, compilateur C17, +`pkg-config`, autotiling, Kitty, Firefox et Limusic déjà configuré par l’autostart +Sway. Le catalogue est `session_v2/applications.py`. Limusic utilise +`strategy=managed-autostart`, `managed_by=sway-autostart` et aucun argv Labfy. + +Depuis la racine du dépôt : + +```sh +python3 -B tools/build-session-v2.py +install -Dm755 build/session-v2/labfy-v2-anchor \ + quickshell/.config/quickshell/labfy-sway/session_v2/libexec/labfy-v2-anchor +``` + +Le build ne déploie ni extension ni préférence. Le helper compilé et les archives +restent ignorés par Git. Déployer le package Stow `quickshell` selon le guide +habituel, puis suivre [la documentation du companion Firefox](../firefox-extension/session-v2/README.md). +L’extension signée déjà installée n’a pas besoin d’être réinstallée pour ce nettoyage. + +Versions de la recette : Firefox 157.0, SwayFX 0.6, QuickShell 0.3.1, +autotiling 1.9.4, Python 3.14.7, Qt Declarative 6.11.2 et GTK3 3.24.52. + +## Architecture et invariants + +`sessionui/SessionV2Service.qml` est l’unique propriétaire des commandes UI. +`Bar.qml` instancie `SessionStartupPromptV2` ; `shell.qml` désigne une seule barre +hôte. `ControlCenter.qml` utilise `SessionManagerV2` et `SessionExitGate` avec +le statut de checkpoint `saved`. Aucune surface active ne dépend du moteur V1. + +Le backend capture applications, slots et structure ; il reconstruit par ancres +et swaps, puis vérifie arbre, absence d’ancres et focus final. Le guard possède +la suspension/reprise des helpers et leur récupération en cas d’interruption. +Les observations runtime ne sont pas des identités persistantes. Firefox garde +la propriété de ses fenêtres/onglets ; le companion fournit l’identité exacte. +Kitty restaure uniquement la fenêtre. Les échecs sont explicites ; aucune +approximation d’identité Firefox n’est substituée silencieusement. + +Les checkpoints et sessions sont privés sous +`${XDG_STATE_HOME:-$HOME/.local/state}/labfy-sway/session-v2`. Les transactions, +locks, rapports et observations du provider restent sous `$XDG_RUNTIME_DIR`. +Les anciennes données utilisateur V1 sont conservées. `analyze-v1-migration` +est une analyse en lecture seule, sans conversion automatique ni moteur V1. +Les anciens documents V1 sont des références historiques non exécutables. + +## Diagnostics et validation + +```sh +python3 -B "$HOME/.config/quickshell/labfy-sway/session-v2.py" restore-status +qs ipc -c labfy-sway call sessionV2 status +systemctl --user is-active quickshell-labfy-sway.service +``` + +Le rapport de succès doit indiquer `status=success`, les deux vérifications +finales vraies, autant d’ancres nettoyées que créées et autant de helpers repris +que suspendus. Le diagnostic QuickShell doit retourner `backend=v2`, `busy=false`. +Le rapport backend contient des identités privées : ne pas le joindre brut à +un ticket public. Ne jamais relancer un restore pour simplement lire son statut. + +Tests sur compositeur headless privé, sans logout ni accès au bureau utilisateur : + +```sh +python3 -B -m unittest discover -s tests/session_v2 -p 'test_*.py' +python3 -B tests/session_v2/runtime.py +python3 -B tests/session_v2/failures.py +python3 -B tests/session_v2/crashes.py +python3 -B tests/session_v2/security.py +python3 -B tests/session_v2/phase3b_runtime.py +python3 -B tests/session_v2/phase3b_ui.py +QT_QPA_PLATFORM=offscreen /usr/lib/qt6/bin/qmltestrunner \ + -input tests/session_v2/qml -o -,txt +``` + +Les tests Python nécessitent `jsonschema` ; les scénarios UI nécessitent QuickShell +et QtTest. Les résultats synthétiques restent sous `build/session-v2-tests`, +ou dans `LABFY_SESSION_V2_EVIDENCE`. Les fixtures ne lancent que leur propre +compositeur et leurs enfants TEST_ONLY. Le build helper C17 utilise +`-Wall -Wextra -Werror`. Les métadonnées QuickShell peuvent produire des +avertissements qmllint ; le chargement réel et les scénarios Qt complètent +le contrôle syntaxique, sans prétendre à zéro avertissement. + +## Limites conservées + +- Contenu et processus terminal non restaurés. +- Ratios exacts non restaurés. +- Fullscreen et scratchpad non pris en charge actuellement. +- Multi-output physique non validé. +- Provider Firefox candidat actuel pour le profil supporté ; plusieurs profils + simultanés ne sont pas pris en charge. diff --git a/docs/session-snapshot-v1.md b/docs/session-snapshot-v1.md index cf665cbd6..2bb0a8739 100644 --- a/docs/session-snapshot-v1.md +++ b/docs/session-snapshot-v1.md @@ -1,398 +1,11 @@ -# Session Snapshot V1 — STEP18A / STEP18B +# Référence historique V1 -## Objet et périmètre +Le runtime V1 a été retiré après la recette réelle réussie de Session Restore V2. +Ce document ne décrit plus de commandes exécutables. Utiliser exclusivement +[Session Restore V2](session-restore-v2.md). -STEP18A capture et valide en lecture seule l'état de session observable par -Sway. STEP18B sauvegarde cet état de façon persistante. **Aucune restauration -n'est implémentée.** Le collecteur ne déplace pas de conteneur, ne change pas -le focus, ne lance ni ne tue de processus. STEP19 possédera la restauration et -son interface ; STEP20, le layout spatial 2D. - -Le collecteur autonome se trouve dans -`quickshell/.config/quickshell/labfy-sway/session/session_snapshot.py`. Il est -séparé de QML : - -```bash -python ~/.config/quickshell/labfy-sway/session/session_snapshot.py --stdout -``` - -`--stdout` reste la capture non persistante de STEP18A. Les commandes -persistantes explicites de STEP18B sont documentées plus bas. - -## Sources et audit de disponibilité - -Les seules sources IPC sont `get_version`, `get_outputs`, `get_workspaces` et -`get_tree`. Les seules variables lues sont `XDG_CURRENT_DESKTOP`, -`XDG_SESSION_TYPE` et `DESKTOP_SESSION` lorsqu'elles existent. Le collecteur -ne lit jamais `/proc/*/environ` ni `/proc/*/cmdline`. Il peut lire le lien -`/proc//exe`, puis ne conserve que son basename. - -L'audit du 5 octobre 2026 a observé SwayFX `0.6`, fondé sur Sway `1.12.0`, en -session `wayland`, avec `XDG_CURRENT_DESKTOP=sway:wlroots:swayfx` et sans -`DESKTOP_SESSION` exporté au processus de collecte. - -| Donnée IPC | Disponibilité | Volatilité | Utilité V1 | Traitement / protection | -| --- | --- | --- | --- | --- | -| output `name` | disponible | peut changer selon le connecteur | rattachement workspace et meilleur indice local | conservé ; pas supposé toujours présent au reboot | -| output `active`, `focused` | disponible | volatile | état et focus observés | conservés | -| output `rect`, `scale`, `transform`, `current_mode` | disponible | volatile | topologie multi-écran future | conservés ; refresh renommé `refresh_millihz` | -| output make/model/serial | disponible | relativement stable mais privé et parfois faux | non nécessaire au contrat actuel | exclus | -| workspace `name`, `num`, `output` | disponible | nom/output modifiables | identité locale et rattachement | conservés | -| workspace `focused`, `visible`, `urgent`, `rect` | disponible | volatile | restitution future et diagnostic | conservés | -| ordre workspace | disponible par l'ordre IPC | volatile | ordre réel observé | `order` explicite, à partir de zéro | -| container `layout`, `orientation` | disponible | volatile | reconstruction `splith`, `splitv`, `tabbed`, `stacked` | conservés | -| container ordre, parent/enfants, `floating_nodes` | disponible | volatile | structure indispensable | normalisés en références ordonnées | -| container `percent` | disponible, parfois `null` | volatile | proportions relatives | conservé | -| `rect` | disponible | volatile | géométrie effective, surtout floating | conservé | -| `deco_rect`, `window_rect`, `geometry` | disponibles | volatiles | redondants ou décoratifs pour V1 | exclus ; `rect` et `percent` suffisent au contrat actuel | -| `focus`, `focused` | disponibles | volatile | chaîne de focus observée | focus final normalisé ; listes runtime brutes exclues | -| `fullscreen_mode` | disponible | volatile | restauration future | conservé sur chaque fenêtre | -| `urgent`, `marks` | disponibles | volatiles | état utilisateur et correspondance possible | conservés | -| `app_id` | disponible pour Wayland | peut évoluer entre versions | indice d'identité principal | conservé sans le déclarer universellement stable | -| `window_properties.class/instance` | disponible pour XWayland | peut évoluer | identité XWayland | seuls `class` et `instance` sont conservés | -| `pid`, `con_id` | disponibles | strictement runtime | diagnostic de capture seulement | isolés sous `runtime`, jamais clés de restauration | -| `name` / titre | disponible | très volatile et potentiellement privé | indice diagnostique seulement | `title_hint`, exclu de l'équivalence structurelle | -| `scratchpad_state` | disponible sur les vues | volatile | membre/visible/caché | normalisé sous `scratchpad` | -| arbres output/root bruts | disponibles | contiennent beaucoup de runtime | diagnostic ponctuel seulement | jamais persistés dans V1 | - -Une propriété absente vaut `null` lorsque l'absence est sémantique. Les -rectangles sont toujours des objets entiers `{x, y, width, height}`. V1 ne -sérialise aucune structure Python ou native opaque. - -## Enveloppe et version - -Chaque document porte cette identité immuable : - -```json -{ - "schema": "labfy.sway.session-snapshot", - "version": 1 -} -``` - -La racine contient exactement les domaines contractuels suivants (des champs -compatibles pourront être ajoutés à une version mineure du producteur, mais un -lecteur doit rejeter une autre valeur de `version`) : - -```text -snapshot -├── schema, version -├── metadata -├── compositor -├── outputs[] -├── workspaces[] -├── containers[] -├── windows[] -├── focus -├── restore_hints -└── diagnostics -``` - -### `metadata` - -- `captured_at` : UTC ISO 8601 ; volatile ; -- `session_type` : valeur allowlistée, normalement `wayland` ; -- `desktop` : `XDG_CURRENT_DESKTOP`, ou repli `DESKTOP_SESSION`. - -Le hostname est volontairement omis, comme le username, le chemin HOME, -l'adresse IP, la MAC et toute identité réseau. - -### `compositor` - -- `variant`, `version`, `sway_version` viennent de `get_version` ; -- `layout_engine` vaut `sway-native` en V1. - -`layout_engine` est le point d'extension réservé à `spatial-canvas-v1` pour -STEP20. V1 n'invente ni `canvas_x`, ni `canvas_y`, ni viewport. - -### `outputs[]` - -Chaque output contient `name`, `active`, `focused`, `rect`, `scale`, -`transform` et `current_mode`. Le mode est `null` ou contient `width`, -`height`, `refresh_millihz`. - -Le nom de connecteur est l'indice préféré, non une identité universelle. -`make/model/serial` ne sont pas des clés obligatoires. Une restauration future -devra vérifier les outputs présents, appliquer une stratégie de repli encore -à définir si un écran a disparu, puis rattacher ou redistribuer les -workspaces. V1 enregistre explicitement `missing_output_policy=deferred` : il -ne prétend pas résoudre ce cas. - -### `workspaces[]` - -Chaque workspace contient : - -```text -name, num, output, order, focused, visible, urgent, rect, -layout, orientation, roots.tiling[], roots.floating[] -``` - -`num=-1` représente le comportement IPC des workspaces nommés. Un workspace -vide possède deux listes de racines vides. Chaque référence de racine vaut -`{"kind":"container|window","id":"..."}`. L'ordre des tableaux est -l'ordre Sway : il est contractuel dans le snapshot. - -### `containers[]` - -Un conteneur structurel contient : - -```text -container_id, runtime_con_id, workspace, output, -parent_container_id, tree_position, -layout, orientation, percent, rect, -focused, urgent, marks, -children[], floating_children[] -``` - -`container_id` (`c1`, `c2`, …) est local au document. Les deux listes -d'enfants conservent séparément l'ordre des branches `nodes` et -`floating_nodes`. Les layouts reconnus sont `none`, `splith`, `splitv`, -`stacked`, `tabbed`, `output` et `dockarea`. Les conteneurs ne sont pas aplatis -en une liste de fenêtres : leurs relations permettent de reconstruire -l'arbre, les groupes tabbed/stacked et les proportions. - -### `windows[]` - -Chaque vraie vue feuille contient : - -```text -window_id, snapshot_identity, runtime, -workspace, output, -app_id, xwayland.class, xwayland.instance, -executable_basename, title_hint, -restore_identity, restore_adapter, classification, -focused, urgent, floating, fullscreen_mode, -rect, percent, marks, scratchpad, tree_position -``` - -`window_id` et `snapshot_identity` sont locaux à cette capture. Les doublons -restent des fenêtres distinctes : par exemple `firefox#1` et `firefox#2`. -L'ordinal dépend de l'ordre déterministe de parcours de l'arbre ; ce n'est pas -une identité durable. `runtime.con_id` et `runtime.pid` décrivent seulement le -processus et le conteneur observés. Ils ne doivent jamais être utilisés après -un reboot. - -`floating` combine la branche de l'arbre et l'état `auto_on/user_on`. -`rect`, `workspace` et `output` donnent les informations géométriques utiles -aux fenêtres flottantes. `fullscreen_mode` conserve la valeur entière IPC -(zéro : non fullscreen). Aucune fenêtre n'est déplacée pour compléter ces -données. - -`tree_position` contient `parent_container_id`, `branch` et `index`. Une -fenêtre directement sous un workspace a un parent `null`. Une fenêtre de -scratchpad cachée n'est artificiellement rattachée à aucun workspace/output. - -## Identité restaurable et DesktopEntry - -Une fenêtre, un processus applicatif et une DesktopEntry sont trois niveaux -différents : plusieurs fenêtres peuvent appartenir au même processus ou à -plusieurs processus de la même application ; une DesktopEntry décrit un mode -de lancement, pas une fenêtre. Le collecteur ne fusionne jamais les fenêtres -ayant le même `app_id`. - -`restore_identity` contient `app_id`, `class`, `instance`, `desktop_entry`, -`confidence` et `matched_by`. La recherche déterministe parcourt les -répertoires XDG, avec priorité aux entrées utilisateur en cas de même nom : - -1. stem exact de `app_id.desktop`, `class.desktop` ou `instance.desktop` ; -2. correspondance exacte à `StartupWMClass` ; -3. stem unique égal au basename exécutable après normalisation (`heuristic`) ; -4. sinon `desktop_entry=null`, `confidence=unresolved`. - -Les niveaux sont `exact`, `heuristic`, `unresolved`. Une ambiguïté ne produit -jamais de correspondance. La valeur `Exec` des fichiers desktop n'est ni -nécessaire ni persistée. - -`restore_adapter` vaut actuellement `terminal`, `generic-desktop` ou -`unknown`. `kitty` ne permet pas de distinguer shell, Neovim, Yazi, SSH ou une -commande utilisateur : STEP18A n'essaie pas de le deviner. Les futurs -adaptateurs `browser` ou `custom` sont réservés à une politique ultérieure. - -## Autostart et infrastructure - -Le filtre primaire reste l'arbre Sway : un daemon sans fenêtre n'entre pas -dans `windows[]`. Le fichier Sway `autostart` est seulement analysé pour -classifier les programmes directs, sans exécuter leurs commandes. La -taxonomie est : - -- `user-application` ; -- `autostart-managed-application` avec `managed_by=sway-autostart` ; -- `session-infrastructure` ; -- `daemon` ; -- `unknown`. - -`limusic-app`, qui possède une vraie fenêtre, est classée application gérée -par `sway-autostart`. Une restauration future devra donc éviter une seconde -instance. QuickShell, l'agent Polkit, `autotiling`, le listener de -transparence, `wlsunset` et les services systemd user ne deviennent pas des -applications restaurables simplement parce que leur processus existe. La -classification ne lance rien et n'est pas encore une politique de relance. - -## Scratchpad et focus - -Sway expose le workspace interne `__i3_scratch`. Il est exclu de -`workspaces[]`, mais ses racines ordonnées apparaissent dans -`restore_hints.scratchpad_roots`. Chaque fenêtre porte : - -- `member` ; -- `visibility` : `hidden`, `visible` ou `not-applicable` ; -- la valeur IPC `state` (`none`, `fresh`, `changed` lorsqu'elle existe). - -Une fenêtre scratchpad cachée se trouve dans `__i3_scratch`; une vue membre -affichée dans un workspace réel garde un état scratchpad non `none`. L'audit -réel STEP18A a observé `__i3_scratch` vide : le cas caché est donc validé par -fixture synthétique, pas par manipulation de la session. - -`focus` référence l'output et le workspace par leur nom, et la feuille ou le -conteneur par son ID local au snapshot. Ces références expriment l'état -observé ; la stratégie de restitution du focus appartient à STEP19. - -## Validation et déterminisme - -Le validateur interne vérifie schema/version, collections et objets -obligatoires, unicité des IDs, ordre workspace contigu, layouts admis, -relations output/workspace, références arbre, parents et cibles du focus. Le -JSON n'est émis qu'après validation. Aucune dépendance Python externe n'est -requise. - -À état Sway inchangé, l'ordre de parcours IPC et du scan DesktopEntry est -déterministe. L'équivalence structurelle exclut seulement les chemins listés -dans `diagnostics.volatile_fields` : - -```text -metadata.captured_at -diagnostics.collection_duration_ms -windows[].title_hint -``` - -Les titres restent utiles comme indices manuels mais une animation de titre, -une page Web ou un document différent ne change pas l'identité structurelle. -Les champs runtime restent inclus dans cette comparaison : à état réellement -inchangé ils doivent rester identiques. - -## Persistance STEP18B - -### Emplacement, nom et métadonnées - -Un titre peut révéler un document ou un site. Les snapshots sont donc de -l'état utilisateur et vont exclusivement dans : - -```text -${XDG_STATE_HOME:-$HOME/.local/state}/labfy-sway/sessions/ -``` - -Lorsque `XDG_STATE_HOME` est défini et non vide, la cible est -`$XDG_STATE_HOME/labfy-sway/sessions/`. Sinon elle est -`$HOME/.local/state/labfy-sway/sessions/`. Une valeur relative de -`XDG_STATE_HOME` est refusée. Les répertoires gérés `labfy-sway/` et -`sessions/` sont forcés à `0700`; les snapshots et le verrou sont forcés à -`0600`, indépendamment de l'umask. Rien n'est écrit dans `~/.config`, le dépôt -Git, `Documents` ou `/tmp` pour le fonctionnement normal. - -Le nom logique est la clé persistante V1. Il contient de 1 à 64 caractères -ASCII parmi `A-Z`, `a-z`, `0-9`, `-` et `_`. Il n'existe pas de `session_id` -distinct en V1 : en ajouter un serait redondant tant que le renommage n'est pas -contracté. Le nom ne devient jamais un chemin libre ; l'implémentation dérive -elle-même le seul fichier `.json`. Les séparateurs, points, espaces, -NUL, chaînes vides et traversées sont refusés. - -Un document persistant conserve l'enveloppe -`labfy.sway.session-snapshot`/version `1` et ajoute à `metadata` : - -- `session_name`, la clé logique validée ; -- `created_at`, fixé lors de la première sauvegarde valide ; -- `updated_at`, avancé à chaque publication ; -- `captured_at`, l'instant de la nouvelle capture Sway. - -Une mise à jour réutilise exactement le même fichier. Elle ne crée aucune -rotation ou copie suffixée. Si la cible régulière est corrompue, un `save` -explicitement adressé peut la remplacer par un nouveau snapshot valide ; il -ne prétend alors pas récupérer son ancien `created_at`. - -### CLI - -```bash -session_snapshot.py --stdout -session_snapshot.py save -session_snapshot.py list -session_snapshot.py show -session_snapshot.py delete -session_snapshot.py plan -``` - -`--compact` produit du JSON compact. `save` collecte et publie ; `list` émet un -tableau d'objets `{name, updated_at, windows, workspaces}` ; `show` retourne le -document strictement validé ; `delete` supprime uniquement le fichier régulier -dérivé du nom validé. `plan` charge ce document, collecte un second Snapshot -V1 live puis appelle le planner pur décrit dans `docs/session-restore-v1.md`. -Il produit uniquement des intentions : aucune commande `restore`, `launch`, -`move`, `focus` ou `apply` n'est exécutée. - -### Cohérence IPC, verrouillage et publication atomique - -Les quatre requêtes IPC ne sont pas transactionnelles. Pour `save`, une -violation relationnelle produite par cet intervalle est retentée au plus trois -fois, avec 50 ms entre tentatives. Les erreurs IPC ordinaires ne sont pas -masquées par ces retries. Après trois graphes incohérents, la commande retourne -`SAVE_FAILED_INCONSISTENT_STATE` et ne touche pas au snapshot précédent. - -Un verrou global persistant `.sessions.lock`, fichier régulier privé ouvert -avec `O_NOFOLLOW`, est pris par `flock`: exclusif pour `save`/`delete`, partagé -pour `list`/`show`. Il sérialise deux mises à jour simultanées sans daemon ni -nouvelle file de travaux. - -La publication suit ce contrat : - -```text -snapshot validé en mémoire -→ temporaire aléatoire dans sessions/ en 0600 et O_EXCL/O_NOFOLLOW -→ écriture complète -→ fsync du fichier -→ relecture bornée, comparaison des octets, parsing et validation -→ nouvelle vérification de la cible -→ os.replace atomique dans le même répertoire -→ fsync du répertoire -``` - -Le temporaire propre à l'opération est supprimé en cas d'échec normal. Une -interruption avant `os.replace` laisse l'ancien fichier inchangé. Les chemins -de cible, temporaire, verrou et suppression sont utilisés relativement au -descripteur ouvert du répertoire. Les répertoires gérés et fichiers cibles ne -peuvent pas être des symlinks ; une cible symlink ou non régulière est refusée, -donc elle ne peut rediriger ni écriture ni suppression vers un autre chemin. - -### Chargement, corruption, version et limite - -Chaque lecture ouvre un fichier régulier avec `O_NOFOLLOW`, contrôle son mode -`0600`, sa taille, son JSON, son schema, sa version, ses métadonnées -persistantes et toutes les relations V1. La limite est de 8 MiB -(`8388608` octets), très supérieure aux captures usuelles d'environ 10 KiB. -Elle est appliquée avant parsing et pendant la lecture afin de borner également -un fichier qui grandit. - -`show` échoue explicitement sur corruption. `list` ignore chaque fichier -invalide indépendamment et signale un avertissement sur stderr sans perdre les -autres sauvegardes valides. Un schema inconnu retourne -`unsupported snapshot schema`; une version autre que `1` retourne -`unsupported snapshot version` et n'est jamais interprétée comme V1. - -### Confidentialité - -Sont explicitement exclus : username, HOME absolu, hostname par défaut, -IP/MAC, make/model/serial des écrans, contenu d'environnement, lignes de -commande, arguments, tokens, mots de passe, cookies, URL privées, valeur -`Exec` des DesktopEntry, arbres IPC bruts et chemins complets d'exécutables. -`title_hint` demeure potentiellement privé et justifie à lui seul les modes -`0700`/`0600`. - -## Limites V1 - -V1 ne sait pas restaurer ou relancer une application, retrouver le contenu d'un terminal, -regrouper sûrement des processus en application, redistribuer un workspace si -un écran manque, restaurer le focus, interpréter un titre, faire une rotation -de snapshots ni représenter le Spatial Canvas. Ces limites sont explicites et -n'affaiblissent pas la capture normalisée de STEP18A. - -Il n'existe ni sauvegarde automatique à la fermeture, ni hook logout, ni -autostart de gestionnaire de session, ni UI QuickShell dans STEP18B. Ces -politiques et toute mutation Sway restent hors périmètre. +Les anciennes sessions et snapshots utilisateur sous XDG_STATE_HOME sont +conservés. Aucune migration ni suppression de données n’a été effectuée. +Les sources et documents V1 antérieurs au nettoyage sont archivés dans la +branche locale de recherche `refactor/session-restore-v2`, sans publication. +L’historique Git public conserve les versions V1 précédemment publiées. diff --git a/firefox-extension/session-v2/README.md b/firefox-extension/session-v2/README.md new file mode 100644 index 000000000..d98778760 --- /dev/null +++ b/firefox-extension/session-v2/README.md @@ -0,0 +1,57 @@ +# Companion Firefox Session Restore V2 + +Sources de l’extension utilisée par la recette réelle validée. ID : +`session-v2@labfy.org`, version 1.0.0. Host : `org.labfy.session_v2_identity`. +Permissions limitées à `sessions` et `nativeMessaging` : aucun accès à la +navigation, aux cookies, à l’historique ou au contenu des onglets. + +## Build et signature + +Depuis la racine du dépôt, `python3 -B tools/build-session-v2.py` construit +`build/session-v2/labfy-session-v2-unsigned.xpi`. Le contenu du XPI est +déterministe ; le bundle backend tar.gz est seulement une sortie locale. +Aucun binaire ou XPI signé n’est versionné. + +La distribution retenue est **AMO unlisted**, signée par Mozilla, puis installée +localement avec consentement explicite. Envoyer le XPI non signé via le portail +AMO de développement, choisir la distribution non répertoriée, récupérer le +package signé et l’installer depuis le gestionnaire d’extensions Firefox. +Les identifiants AMO restent hors du dépôt. Ne pas désactiver l’exigence de +signature Firefox Release. Firefox 157 est la version minimale du manifeste. + +Sur le profil utilisateur validé, l’extension signée est déjà active et +`browser.startup.page=3` est déjà configuré. Le nettoyage V1 ne modifie ni +installation, ni permissions, ni préférence. Pour une nouvelle installation, +le propriétaire du profil doit choisir explicitement la réouverture des +fenêtres et onglets précédents. + +## Native host + +Après déploiement Stow, rendre le template `native-host.json.in` en remplaçant +`@ABSOLUTE_NATIVE_HOST_LAUNCHER@` par le chemin absolu de +`$HOME/.config/quickshell/labfy-sway/session-v2-native-host.py`. +Conserver le nom du host, le type `stdio` et l’allowlist exacte de l’extension. +Le wrapper doit rester exécutable. + +Sur l’installation Linux validée, le manifeste est +`${XDG_CONFIG_HOME:-$HOME/.config}/mozilla/native-messaging-hosts/org.labfy.session_v2_identity.json`. +Le chemin historique `$HOME/.mozilla` pointe vers cette arborescence. Sur un +autre packaging Firefox, vérifier le répertoire Native Messaging utilisé avant +installation. Créer le manifeste avec permissions 0600 ; ne pas écraser un +host actif lors d’une simple mise à jour de documentation. + +Le host accepte seulement le protocole `map-window` avec UUID et jeton runtime. +Les messages sont bornés à 4096 octets ; la sortie standard est réservée au +framing Native Messaging. Le host lit l’arbre Sway pour corréler le préfixe +éphémère, sans exécuter de commande Sway mutatrice. L’extension retire ce +préfixe après corrélation. Le provider exige des observations récentes et un +publisher vivant ; une identité absente ou ambiguë produit un échec explicite. + +Les UUID persistants appartiennent aux fenêtres Firefox ; les observations et +identifiants de processus restent privés dans le runtime. Ne pas publier ces +fichiers. Le provider demeure le candidat validé pour le profil supporté ; le +multi-profil n’est pas pris en charge. Les fenêtres et onglets sont restaurés +par Firefox, pas par une lecture de sessionstore côté Labfy. + +Voir [Session Restore V2](../../docs/session-restore-v2.md) pour le cycle complet, +les tests et les limites. diff --git a/firefox-extension/session-v2/background.js b/firefox-extension/session-v2/background.js new file mode 100644 index 000000000..d4174d7e6 --- /dev/null +++ b/firefox-extension/session-v2/background.js @@ -0,0 +1,92 @@ +/* Identité locale, aucune donnée de navigation. Pas de canal de commande du + * laboratoire. Le host n'accepte que map-window et ne peut piloter Firefox. + * L'UUID appartient à la fenêtre logique, pas à son adresse runtime windowId. + */ +"use strict"; +const KEY = "labfy.session.window_uuid"; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +let port = null; +let pending = null; +let busy = false; + +function connect() { + if (port) return port; + const current = browser.runtime.connectNative("org.labfy.session_v2_identity"); + port = current; + current.onMessage.addListener(answer => { + if (port !== current) return; + if (answer.event === "capture") { cycle(); return; } + if (!pending) return; + const p = pending; + pending = null; + p.resolve(answer); + }); + current.onDisconnect.addListener(() => { + if (port !== current) return; + port = null; + if (pending) { pending.reject(new Error("PROVIDER_UNAVAILABLE")); pending = null; } + }); + return port; +} +async function request(message) { + const p = connect(); + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (pending) { pending = null; p.disconnect(); port = null; reject(new Error("PROVIDER_TIMEOUT")); } + }, 5000); + pending = {resolve: v => { clearTimeout(timer); resolve(v); }, reject: e => { clearTimeout(timer); reject(e); }}; + try { p.postMessage(message); } + catch (e) { clearTimeout(timer); pending = null; reject(e); } + }); +} +async function ids() { + const windows = await browser.windows.getAll({populate: false, windowTypes: ["normal"]}); + if (windows.length > 256) throw new Error("WINDOW_LIMIT"); + return windows.map(w => w.id); +} +async function cycle() { + if (busy) return; + busy = true; + try { + const rows = []; + for (const id of await ids()) { + let value = await browser.sessions.getWindowValue(id, KEY); + if (value === undefined) { + // Relecture avant attribution ; une ancienne UUID absente du snapshot + // restera un échec exact, jamais une correspondance par ordre. + await sleep(200); + value = await browser.sessions.getWindowValue(id, KEY); + if (value === undefined) { + value = crypto.randomUUID(); + await browser.sessions.setWindowValue(id, KEY, value); + } + } + if (!UUID.test(value)) throw new Error("INVALID_UUID"); + rows.push({id, uuid: value}); + } + // Les doublons sont également refusés par le provider. Les publier permet + // un diagnostic de collision au lieu d'un silence ambigu. + for (const row of rows) { + const runtime_token = crypto.randomUUID().replaceAll("-", ""); + try { + await browser.windows.update(row.id, {titlePreface: `[LABFY:${runtime_token}] `}); + const answer = await request({op: "map-window", uuid: row.uuid, runtime_token}); + if (answer.result !== "OK" || answer.uuid !== row.uuid || !Number.isSafeInteger(answer.con_id)) throw new Error("PROVIDER_ERROR"); + } finally { + await browser.windows.update(row.id, {titlePreface: ""}); + } + } + } catch (_) { + // Aucune chaîne d'erreur externe ou donnée de page dans un log. + } finally { busy = false; } +} +async function start() { + for (const id of await ids()) { + try { await browser.windows.update(id, {titlePreface: ""}); } catch (_) {} + } + connect(); + // Reconnexion seulement : pas de titlePreface ni requête Sway en idle. + setInterval(() => { if (!port) connect(); }, 1000); +} +start().catch(() => {}); diff --git a/firefox-extension/session-v2/manifest.json b/firefox-extension/session-v2/manifest.json new file mode 100644 index 000000000..fd7039d1e --- /dev/null +++ b/firefox-extension/session-v2/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 2, + "name": "Labfy — Identité des fenêtres", + "version": "1.0.0", + "description": "Identité locale des fenêtres pour restaurer leur disposition Sway.", + "browser_specific_settings": { + "gecko": {"id": "session-v2@labfy.org", "strict_min_version": "157.0", "data_collection_permissions": {"required": ["none"]}} + }, + "permissions": ["sessions", "nativeMessaging"], + "background": {"scripts": ["background.js"], "persistent": true} +} diff --git a/firefox-extension/session-v2/native-host.json.in b/firefox-extension/session-v2/native-host.json.in new file mode 100644 index 000000000..a38281ea2 --- /dev/null +++ b/firefox-extension/session-v2/native-host.json.in @@ -0,0 +1,7 @@ +{ + "name": "org.labfy.session_v2_identity", + "description": "Labfy Session V2 — corrélation locale", + "path": "@ABSOLUTE_NATIVE_HOST_LAUNCHER@", + "type": "stdio", + "allowed_extensions": ["session-v2@labfy.org"] +} diff --git a/quickshell/.config/quickshell/labfy-sway/Bar.qml b/quickshell/.config/quickshell/labfy-sway/Bar.qml index 5216704b0..78146f437 100644 --- a/quickshell/.config/quickshell/labfy-sway/Bar.qml +++ b/quickshell/.config/quickshell/labfy-sway/Bar.qml @@ -16,6 +16,7 @@ PanelWindow { required property var modelData required property var notificationService + required property bool startupHost screen: modelData anchors { @@ -139,4 +140,8 @@ PanelWindow { barWindow: bar service: bar.notificationService } + SessionStartupPromptV2 { + barWindow: bar + startupHost: bar.startupHost + } } diff --git a/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml b/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml new file mode 100644 index 000000000..69d108b1d --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml @@ -0,0 +1,150 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import "controlcenter" +import "theme" +import "sessionui" +import "sessionui/Protocol.js" as Protocol + +PanelWindow { + id: popup + required property var barWindow + required property bool startupHost + property bool interactionArmed: false + property bool failed: false + property bool restored: false + property bool detailsVisible: false + property bool attempted: false + property string technicalDetails: "" + property int windowCount: 0 + property int workspaceCount: 0 + readonly property bool busy: SessionV2Service.busy + function restoreRequested() { + if (!interactionArmed || busy || attempted || failed) return; + if (SessionV2Service.run("apply-last", "", "", popup)) attempted = true; + } + function newSessionRequested() { + if (interactionArmed && !busy) SessionV2Service.run("ack-new", "", "", popup); + } + function fail() { + failed = true; + restored = false; + // Détails provient uniquement de Restore Attempt V2, jamais d'un log. + SessionV2Service.run("restore-status", "", "", popup); + } + Timer { + interval: 350 + running: popup.startupHost + onTriggered: SessionV2Service.run("startup-status", "", "", popup) + } + Timer { id: arm; interval: 750; onTriggered: popup.interactionArmed = popup.visible } + Timer { id: closeSuccess; interval: 600; onTriggered: popup.visible = false } + Connections { + target: SessionV2Service + function onCompleted(owner, kind, value, okay) { + if (owner !== popup) return; + if (kind === "startup-status") { + popup.visible = okay && value.eligible === true && value.claimed === true; + if (popup.visible) { + popup.windowCount = value.windows || 0; + popup.workspaceCount = value.workspaces || 0; + } + } else if (kind === "apply-last") { + if (okay && Protocol.restoreSucceeded(value)) + SessionV2Service.run("ack-restored", "", value.transaction_id, popup); + else popup.fail(); + } else if (kind === "ack-restored") { + if (okay && value.status === "acknowledged" && value.choice === "restored") { + popup.restored = true; + closeSuccess.start(); + } else popup.fail(); + } else if (kind === "ack-new") { + if (okay && value.status === "acknowledged") popup.visible = false; + else popup.fail(); + } else if (kind === "restore-status") { + popup.technicalDetails = okay ? Protocol.details(value) : "Rapport de restauration indisponible."; + } + } + } + + // Layer-shell, jamais un PopupWindow grabbing au nouveau login. + screen: barWindow.screen + anchors { top: true; bottom: true; left: true; right: true } + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + aboveWindows: true + focusable: true + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "labfy-session-startup-v2" + visible: false + color: "transparent" + onVisibleChanged: { + if (startupHost) SessionV2Service.startupChooserVisible = visible; + interactionArmed = false; + if (visible) arm.restart(); else arm.stop(); + } + FocusScope { + id: modal + anchors.fill: parent + focus: popup.visible + // Aucun focus par défaut sur Restore. Les touches en transit n'agissent + // pas, et Escape ne vaut jamais une demande de restauration. + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape || event.key === Qt.Key_Enter + || event.key === Qt.Key_Return || event.key === Qt.Key_Space) event.accepted = true; + } + Rectangle { anchors.fill: parent; color: Qt.rgba(0, 0, 0, 0.65) } + MouseArea { anchors.fill: parent } + Rectangle { + anchors.centerIn: parent + width: Math.min(460, Math.max(280, modal.width - 32)) + height: content.implicitHeight + 40 + color: Theme.popupBackground + radius: 8 + border.color: Theme.lavender + Column { + id: content + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 20 } + spacing: 14 + Text { text: "Session précédente"; color: Theme.foreground; font.pixelSize: 19; font.bold: true } + Text { + width: parent.width + text: popup.failed ? "Impossible de restaurer complètement la session." + : popup.restored ? "Session restaurée" + : popup.busy && popup.attempted ? "Restauration en cours…" + : popup.windowCount + " fenêtres • " + popup.workspaceCount + " espaces" + color: Theme.foreground + wrapMode: Text.Wrap + } + Text { + width: parent.width + visible: popup.detailsVisible + text: popup.technicalDetails + color: Theme.secondaryForeground + wrapMode: Text.Wrap + } + Row { + spacing: 10 + visible: !popup.restored && (!popup.attempted || popup.failed) + ActionButton { + visible: popup.failed + label: "Détails" + enabled: !popup.busy + onClicked: popup.detailsVisible = !popup.detailsVisible + } + ActionButton { + label: "Nouvelle session" + enabled: popup.interactionArmed && !popup.busy + onClicked: popup.newSessionRequested() + } + ActionButton { + visible: !popup.failed + label: "Restaurer" + enabled: popup.interactionArmed && !popup.busy && !popup.attempted + onClicked: popup.restoreRequested() + } + } + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml index 99c65aa97..62c9c776d 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml @@ -1,10 +1,12 @@ import QtQuick import QtQuick.Layouts import Quickshell +import Quickshell.Io import Quickshell.Networking import Quickshell.Bluetooth import "../components" import "../theme" +import "../sessionui" PopupWindow { id: popup @@ -14,11 +16,17 @@ PopupWindow { // passe par ces noms afin de ne pas multiplier les indices StackLayout. readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3, confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8, - nightLight: 9, sunMode: 10, locationTime: 11, sessionManager: 12 }) + nightLight: 9, sunMode: 10, locationTime: 11, sessionManager: 12, + checkpointFailure: 13 }) property int currentPage: pages.main function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; } signal thresholdApplied() - property var pendingAction: null + readonly property var pendingAction: sessionExitGate.pendingAction + readonly property string checkpointError: sessionExitGate.errorMessage + readonly property string configHome: Quickshell.env("XDG_CONFIG_HOME") + || ((Quickshell.env("HOME") || "") + "/.config") + readonly property string sessionBackend: configHome + + "/quickshell/labfy-sway/session-v2.py" // Une seule table associe les libellés, confirmations et commandes de session. readonly property var sessionActions: [ { id: "lock", label: "Verrouiller", icon: "", accent: Theme.accent, command: ["swaylock"] }, @@ -29,14 +37,17 @@ PopupWindow { { id: "logout", label: "Déconnexion", icon: "", accent: Theme.urgent, confirmTitle: "Se déconnecter ?", description: "La session SwayFX en cours sera fermée.", + checkpointReason: "logout", command: ["uwsm", "stop"] }, { id: "reboot", label: "Redémarrer", icon: "", accent: Theme.warning, confirmTitle: "Redémarrer l'ordinateur ?", description: "La session en cours sera fermée.", + checkpointReason: "reboot", command: ["systemctl", "reboot"] }, { id: "poweroff", label: "Éteindre", icon: "", accent: Theme.danger, confirmTitle: "Éteindre l'ordinateur ?", description: "La session en cours sera fermée.", + checkpointReason: "poweroff", command: ["systemctl", "poweroff"] } ] readonly property var wifiDevice: Networking.devices.values.find(device => device.type === DeviceType.Wifi) || null @@ -45,28 +56,51 @@ PopupWindow { // Une nouvelle ouverture recommence sur MAIN ; les pages libèrent leurs scans à la fermeture. onVisibleChanged: if (!visible) { openPage("main"); - pendingAction = null; + if (!sessionExitGate.busy) sessionExitGate.cancel(); } function requestSessionAction(id) { - const selected = sessionActions.find(item => item.id === id); - if (!selected) return; - if (id === "lock") { - executeSessionAction(id); - return; - } - pendingAction = selected; - openPage("confirmation"); + sessionExitGate.requestAction(id); } - function executeSessionAction(id) { - const selected = sessionActions.find(item => item.id === id); - if (!selected) return; - // Fermer le popup avant toute commande qui peut verrouiller ou terminer la session. - visible = false; - // CONTRACT: UWSM possède le cycle de vie de Sway ; son arrêt doit donc - // passer par la commande déclarée, comme les autres actions de session. - if (selected.command) Quickshell.execDetached(selected.command); + function confirmSessionAction(id) { + sessionExitGate.confirmAction(id); + } + + function quitWithoutCheckpoint() { + sessionExitGate.quitWithoutCheckpoint(); + } + + SessionExitGate { + id: sessionExitGate + actions: popup.sessionActions + checkpointSuccessStatus: "saved" + onConfirmationRequested: popup.openPage("confirmation") + onCheckpointRequested: reason => { + // WHY: Sway IPC doit rester vivant jusqu'à la fin de la capture. + // CONTRACT: argv direct, puis aucune action système avant validation du résultat. + if (!SessionV2Service.run("checkpoint-last", "", reason, popup)) + sessionExitGate.checkpointFinished(2, 0, "{}", "Une opération de session est déjà en cours."); + } + onCommandRequested: command => { + // CONTRACT: UWSM possède le cycle de vie de Sway ; lock/suspend et + // les sorties utilisent toujours exactement l'argv de sessionActions. + popup.visible = false; + Quickshell.execDetached(command); + } + onCheckpointFailed: { + popup.openPage("checkpointFailure"); + popup.visible = true; + } + } + + Connections { + target: SessionV2Service + function onCompleted(owner, kind, value, okay) { + if (owner === popup && kind === "checkpoint-last") + sessionExitGate.checkpointFinished(okay ? 0 : 2, 0, + JSON.stringify(value), okay ? "" : "Le checkpoint V2 a échoué."); + } } anchor.window: barWindow @@ -193,12 +227,12 @@ PopupWindow { actionInfo: popup.pendingAction || ({ id: "", label: "", confirmTitle: "", description: "", accent: Theme.foreground }) onCancelled: { - popup.pendingAction = null; + sessionExitGate.cancel(); popup.openPage("session"); } onConfirmed: id => { if (popup.pendingAction && popup.pendingAction.id === id) - popup.executeSessionAction(id); + popup.confirmSessionAction(id); } } @@ -235,10 +269,52 @@ PopupWindow { LocationTimePage { onBackRequested: popup.openPage("appearance") } - SessionManager { + SessionManagerV2 { activePage: popup.visible && popup.currentPage === popup.pages.sessionManager onBackRequested: popup.openPage("session") } + + Column { + id: checkpointFailurePage + spacing: 16 + + Text { + width: parent.width + text: "La session n’a pas pu être sauvegardée." + color: Theme.warningForeground + font.pixelSize: 16 + font.bold: true + wrapMode: Text.Wrap + } + Text { + width: parent.width + text: popup.checkpointError + color: Theme.secondaryForeground + font.pixelSize: 11 + wrapMode: Text.Wrap + } + Row { + spacing: 8 + ActionButton { + label: "Annuler" + enabled: !sessionExitGate.busy + onClicked: { + sessionExitGate.cancel(); + popup.openPage("session"); + } + } + ActionButton { + danger: true + enabled: !sessionExitGate.busy + label: popup.pendingAction && popup.pendingAction.id === "reboot" + ? "Redémarrer sans sauvegarder" + : popup.pendingAction && popup.pendingAction.id === "poweroff" + ? "Éteindre sans sauvegarder" + : "Quitter sans sauvegarder" + onClicked: popup.quitWithoutCheckpoint() + } + } + } } } } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionExitGate.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionExitGate.qml new file mode 100644 index 000000000..92e13cdad --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionExitGate.qml @@ -0,0 +1,80 @@ +import QtQuick + +QtObject { + id: gate + + required property var actions + property string checkpointSuccessStatus: "success" + property var pendingAction: null + property var checkpointAction: null + property bool busy: false + property string errorMessage: "" + + signal confirmationRequested() + signal checkpointRequested(string reason) + signal commandRequested(var command) + signal checkpointFailed() + + function actionFor(id) { + return actions.find(item => item.id === id); + } + + function requestAction(id) { + const selected = actionFor(id); + if (!selected || busy) return; + if (id === "lock") { + commandRequested(selected.command); + return; + } + pendingAction = selected; + confirmationRequested(); + } + + function confirmAction(id) { + if (!pendingAction || pendingAction.id !== id || busy) return; + if (!pendingAction.checkpointReason) { + commandRequested(pendingAction.command); + return; + } + // WHY: le popup peut perdre sa visibilité pendant le Process asynchrone. + // CONTRACT: l'action confirmée reste l'autorité jusqu'au résultat du checkpoint. + // INVARIANT: aucune commande destructive n'est émise avant un succès JSON exit 0. + checkpointAction = pendingAction; + errorMessage = ""; + busy = true; + checkpointRequested(checkpointAction.checkpointReason); + } + + function checkpointFinished(exitCode, exitStatus, stdoutText, stderrText) { + if (!busy || !checkpointAction) return; + let response = undefined; + try { response = JSON.parse(stdoutText); } catch (_) {} + const completedAction = checkpointAction; + busy = false; + checkpointAction = null; + if (exitCode === 0 && exitStatus === 0 && response + && response.status === checkpointSuccessStatus) { + pendingAction = null; + commandRequested(completedAction.command); + return; + } + pendingAction = completedAction; + errorMessage = stderrText.trim() || "Le backend de sauvegarde a échoué."; + checkpointFailed(); + } + + function cancel() { + if (busy) return; + pendingAction = null; + checkpointAction = null; + errorMessage = ""; + } + + function quitWithoutCheckpoint() { + if (!pendingAction || busy) return; + const command = pendingAction.command; + pendingAction = null; + checkpointAction = null; + commandRequested(command); + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManager.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManager.qml deleted file mode 100644 index c677959d2..000000000 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManager.qml +++ /dev/null @@ -1,593 +0,0 @@ -import QtQuick -import QtQuick.Controls -import Quickshell -import Quickshell.Io -import "../components" -import "../theme" - -Item { - id: page - - required property bool activePage - signal backRequested() - - // WHY: la configuration peut être déployée pour n'importe quel utilisateur. - // CONTRACT: le backend est celui de la configuration QuickShell XDG active. - // INVARIANT: aucun nom utilisateur ni chemin de dépôt n'est encodé ici. - readonly property string configHome: Quickshell.env("XDG_CONFIG_HOME") - || ((Quickshell.env("HOME") || "") + "/.config") - readonly property string backend: configHome - + "/quickshell/labfy-sway/session/session_snapshot.py" - - property string view: "list" - property string phase: "idle" - property string operation: "" - property string pendingName: "" - property string nameDraft: "" - property string errorMessage: "" - property string technicalDetails: "" - property string feedback: "" - property var sessions: [] - property var selectedSession: null - property var snapshot: null - property var plan: null - property var restoreReport: null - readonly property bool busy: backendProcess.running || phase === "running" - readonly property bool mutatingBusy: busy - && ["save", "delete", "apply"].includes(operation) - readonly property bool validName: /^[A-Za-z0-9_-]{1,64}$/.test(nameDraft) - - function commandFor(kind, name) { - // CONTRACT: chaque argument reste une valeur argv indépendante ; le nom - // de session ne traverse jamais une interpolation ou un shell. - const argv = ["python3", backend, "--compact"]; - if (kind === "apply") argv.push("--execute"); - argv.push(kind); - if (name) argv.push(name); - return argv; - } - - function run(kind, name) { - // WHY: les locks backend protègent les données, mais pas le double-clic. - // CONTRACT: un unique Process porte list/show/plan et les mutations. - // INVARIANT: aucune nouvelle opération ne démarre tant qu'il est occupé. - if (busy) return false; - operation = kind; - pendingName = name || ""; - phase = "running"; - if (kind !== "list") { - errorMessage = ""; - technicalDetails = ""; - } - if (kind !== "list") feedback = ""; - backendProcess.command = commandFor(kind, pendingName); - backendProcess.running = true; - return true; - } - - function refresh() { run("list", ""); } - - function openCreate() { - if (busy) return; - nameDraft = ""; - errorMessage = ""; - view = "create"; - } - - function requestSave() { - if (!validName || busy) return; - const exists = sessions.some(item => item.name === nameDraft); - view = exists ? "duplicate" : "create"; - if (!exists) run("save", nameDraft); - } - - function openDetails(item) { - if (busy || !item) return; - selectedSession = item; - snapshot = null; - view = "details"; - run("show", item.name); - } - - function preview() { - if (!selectedSession || busy) return; - plan = null; - view = "plan"; - run("plan", selectedSession.name); - } - - function goBack() { - errorMessage = ""; - technicalDetails = ""; - if (view === "list") { - backRequested(); - } else if (["create", "duplicate", "details"].includes(view)) { - view = "list"; - } else if (["plan", "deleteConfirm"].includes(view)) { - view = "details"; - } else if (view === "restoreConfirm") { - view = "plan"; - } else { - view = plan ? "plan" : "details"; - } - } - - function parseJson(text) { - try { return JSON.parse(text); } - catch (_) { return undefined; } - } - - function errorFor(raw) { - const value = raw || "Erreur backend inconnue"; - if (value.includes("RESTORE_ALREADY_RUNNING")) - return "Une restauration est déjà en cours."; - if (value.includes("RESTORE_TREE_DRIFTED")) - return "La disposition des fenêtres a changé pendant la restauration. Aucun traitement supplémentaire n’a été effectué."; - if (value.includes("RESTORE_LAUNCH_TIMEOUT")) - return "Une application n’a pas créé sa fenêtre dans le délai prévu."; - if (value.includes("RESTORE_LAUNCH_AMBIGUOUS")) - return "La nouvelle fenêtre ne peut pas être identifiée sans ambiguïté."; - if (value.includes("RESTORE_BLOCKED_UNSUPPORTED_ACTION")) - return "Le preflight a bloqué des actions non prises en charge."; - if (value.includes("session introuvable")) - return "Cette session n’existe plus."; - return "L’opération a échoué."; - } - - function finishFailure(stdoutText, stderrText) { - const parsed = parseJson(stdoutText); - const raw = (parsed && parsed.reason) || stderrText.trim() || stdoutText.trim(); - phase = "error"; - errorMessage = errorFor(raw); - technicalDetails = raw; - if (operation === "apply") { - restoreReport = parsed || ({ status: "failed", reason: raw }); - view = "result"; - } else if ((operation === "show" || operation === "plan") - && raw.includes("session introuvable")) { - selectedSession = null; - snapshot = null; - plan = null; - view = "list"; - Qt.callLater(refresh); - } - } - - function acceptSuccess(value) { - if (operation === "list") { - if (!Array.isArray(value)) return false; - sessions = value; - } else if (operation === "show") { - if (!value || !value.metadata || !Array.isArray(value.windows) - || !Array.isArray(value.workspaces)) return false; - snapshot = value; - } else if (operation === "plan") { - if (!value || value.read_only !== true || !Array.isArray(value.actions)) return false; - plan = value; - } else if (operation === "save") { - if (!value || value.name !== pendingName) return false; - feedback = "Session sauvegardée"; - view = "list"; - Qt.callLater(refresh); - } else if (operation === "delete") { - if (!value || value.deleted !== pendingName) return false; - selectedSession = null; - snapshot = null; - plan = null; - feedback = "Session supprimée"; - view = "list"; - Qt.callLater(refresh); - } else if (operation === "apply") { - if (!value || value.status !== "success") return false; - restoreReport = value; - view = "result"; - } - return true; - } - - function formatDate(value) { - const date = new Date(value); - if (isNaN(date.getTime())) return value || "Date inconnue"; - const now = new Date(); - const sameDay = date.getFullYear() === now.getFullYear() - && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(); - const time = date.toLocaleTimeString(Qt.locale(), "HH:mm"); - return sameDay ? "Mis à jour aujourd’hui à " + time - : "Mis à jour le " + date.toLocaleDateString(Qt.locale(), "dd/MM/yyyy") + " à " + time; - } - - function plural(count, singular, pluralValue) { - return count + " " + (count === 1 ? singular : pluralValue); - } - - function applicationLabel(item) { - if (!item) return "Application inconnue"; - const identity = item.restore_identity || {}; - const desktop = identity.desktop_entry || item.desktop_entry; - if (desktop) { - const stem = desktop.replace(/\.desktop$/, ""); - return stem.charAt(0).toUpperCase() + stem.slice(1); - } - return identity.app_id || item.app_id || identity.class - || (item.xwayland && item.xwayland.class) - || item.executable_basename || "Application inconnue"; - } - - function countAction(kind) { - if (!plan || !Array.isArray(plan.actions)) return 0; - return plan.actions.filter(item => item.action === kind).length; - } - - function isBlocking(item) { - return item && (item.action === "manual-required" || item.confidence === "ambiguous"); - } - - function blockerCount() { - if (!plan || !Array.isArray(plan.actions)) return 0; - return plan.actions.filter(item => isBlocking(item)).length; - } - - function groupFor(action) { - if (action === "reuse-window") return "RÉUTILISER"; - if (action === "launch-application") return "LANCER"; - if (action === "move-to-workspace") return "DÉPLACER"; - if (action === "restore-tree-position") return "LAYOUT"; - if (action === "restore-floating") return "FLOATING"; - if (action === "restore-fullscreen") return "FULLSCREEN"; - if (action.indexOf("restore-scratchpad") === 0) return "SCRATCHPAD"; - if (action === "restore-focus") return "FOCUS"; - if (action === "manual-required") return "INTERVENTION MANUELLE"; - return "AUTRE"; - } - - function actionText(item) { - const label = item.desktop_entry - ? applicationLabel({ desktop_entry: item.desktop_entry }) - : (item.snapshot_identity || item.source_window || "Fenêtre"); - if (item.action === "reuse-window" && item.confidence === "ambiguous") - return "! " + label + " : correspondance ambiguë"; - if (item.action === "reuse-window") return "✓ " + label; - if (item.action === "launch-application") return "+ " + label; - if (item.action === "move-to-workspace") return "→ " + label + " vers WS " + item.desired_workspace; - if (item.action === "restore-tree-position") return "▦ Restaurer la disposition de " + label; - if (item.action === "restore-floating") return "◫ Restaurer le mode flottant de " + label; - if (item.action === "restore-fullscreen") return "󰊓 Restaurer le plein écran de " + label; - if (item.action === "restore-scratchpad-hidden") return "↘ " + label + " → cachée"; - if (item.action === "restore-scratchpad-visible") return "↗ " + label + " → visible"; - if (item.action === "restore-focus") return "◎ Rendre le focus à " + label; - if (item.reason === "missing-output") return "! Écran " + (item.desired_output || "requis") + " absent"; - if (item.reason === "no-exact-launch-identity") return "! Application " + label + " non identifiable"; - return "! " + label + " : " + (item.reason || "intervention requise"); - } - - function resultCount(key) { - return restoreReport && Number.isInteger(restoreReport[key]) ? restoreReport[key] : 0; - } - - onActivePageChanged: { - if (activePage) { - view = "list"; - errorMessage = ""; - technicalDetails = ""; - feedback = ""; - selectedSession = null; - snapshot = null; - plan = null; - restoreReport = null; - refresh(); - } - } - - Process { - id: backendProcess - running: false - // CONTRACT: stdout reste un document JSON complet ; stderr ne participe - // jamais au parsing et conserve les diagnostics destinés à l'utilisateur. - stdout: StdioCollector { id: backendOutput; waitForEnd: true } - stderr: StdioCollector { id: backendError; waitForEnd: true } - onExited: (exitCode, exitStatus) => { - const stdoutText = backendOutput.text; - const stderrText = backendError.text; - if (exitCode !== 0 || exitStatus !== 0) { - page.finishFailure(stdoutText, stderrText); - return; - } - const value = page.parseJson(stdoutText); - if (value === undefined || !page.acceptSuccess(value)) { - page.phase = "error"; - page.errorMessage = "Réponse invalide du gestionnaire de sessions."; - page.technicalDetails = stderrText.trim() || "JSON stdout invalide ou inattendu"; - return; - } - page.phase = "success"; - if (stderrText.trim().length > 0) - page.errorMessage = stderrText.trim(); - } - } - - Column { - anchors.fill: parent - spacing: 10 - - Row { - width: parent.width - height: 30 - spacing: 8 - ActionButton { label: ""; enabled: !page.busy; onClicked: page.goBack() } - Text { - width: parent.width - saveButton.width - 82 - height: 30 - verticalAlignment: Text.AlignVCenter - text: page.view === "list" ? "Sessions" - : page.view === "details" || page.view === "deleteConfirm" ? "Détail de la session" - : page.view === "plan" || page.view === "restoreConfirm" ? "Aperçu de restauration" - : page.view === "result" ? "Restauration" : "Sauvegarder une session" - color: Theme.foreground - font.pixelSize: 16 - font.bold: true - elide: Text.ElideRight - } - ActionButton { - id: saveButton - visible: page.view === "list" - enabled: !page.busy - label: "Sauvegarder" - onClicked: page.openCreate() - } - } - - Text { - width: parent.width - visible: page.busy - text: page.operation === "apply" ? "Restauration en cours…" - : page.operation === "list" ? "Actualisation…" : "Traitement en cours…" - color: Theme.secondaryForeground - font.pixelSize: 12 - } - Text { - width: parent.width - visible: page.errorMessage.length > 0 && page.view !== "result" - text: page.errorMessage - wrapMode: Text.Wrap - color: Theme.warningForeground - font.pixelSize: 12 - } - Text { - width: parent.width - visible: page.feedback.length > 0 && page.view === "list" - text: page.feedback - wrapMode: Text.Wrap - color: Theme.successForeground - font.pixelSize: 12 - } - - Loader { - width: parent.width - height: parent.height - y - sourceComponent: page.view === "list" ? listComponent - : page.view === "create" ? createComponent - : page.view === "duplicate" ? duplicateComponent - : page.view === "details" ? detailsComponent - : page.view === "deleteConfirm" ? deleteComponent - : page.view === "plan" ? planComponent - : page.view === "restoreConfirm" ? restoreConfirmComponent - : resultComponent - } - } - - Component { - id: listComponent - Item { - Text { - anchors.centerIn: parent - visible: !page.busy && page.sessions.length === 0 - text: "Aucune session sauvegardée" - color: Theme.secondaryForeground - font.pixelSize: 14 - } - ActionButton { - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.verticalCenter - anchors.topMargin: 24 - visible: !page.busy && page.sessions.length === 0 - label: "Sauvegarder la session actuelle" - onClicked: page.openCreate() - } - ScrollView { - anchors.fill: parent - visible: page.sessions.length > 0 - clip: true - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - Column { - width: page.width - 14 - spacing: 8 - Repeater { - model: page.sessions - delegate: Rectangle { - required property var modelData - width: parent.width - height: 72 - radius: 4 - color: sessionPointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground - Column { - anchors.left: parent.left; anchors.leftMargin: 12 - anchors.right: parent.right; anchors.rightMargin: 34 - anchors.verticalCenter: parent.verticalCenter; spacing: 3 - Text { width: parent.width; text: modelData.name; elide: Text.ElideRight; color: Theme.foreground; font.pixelSize: 13; font.bold: true } - Text { text: page.plural(modelData.windows, "fenêtre", "fenêtres") + " • " + page.plural(modelData.workspaces, "espace", "espaces"); color: Theme.secondaryForeground; font.pixelSize: 11 } - Text { width: parent.width; text: page.formatDate(modelData.updated_at); elide: Text.ElideRight; color: Theme.disabledForeground; font.pixelSize: 10 } - } - Text { anchors.right: parent.right; anchors.rightMargin: 12; anchors.verticalCenter: parent.verticalCenter; text: ""; color: Theme.accentForeground; font.pixelSize: 13 } - MouseArea { id: sessionPointer; anchors.fill: parent; hoverEnabled: true; enabled: !page.busy; onClicked: page.openDetails(modelData) } - } - } - } - } - } - } - - Component { - id: createComponent - Column { - spacing: 12 - Text { text: "Nom de la session"; color: Theme.foreground; font.pixelSize: 13; font.bold: true } - TextField { - id: nameInput - width: parent.width - text: page.nameDraft - maximumLength: 64 - placeholderText: "Développement" - enabled: !page.busy - onTextEdited: page.nameDraft = text - onAccepted: page.requestSave() - } - Text { - width: parent.width - text: page.nameDraft.length === 0 ? "1 à 64 caractères : A-Z, a-z, 0-9, - et _." - : page.validName ? "Nom valide" : "Utiliser uniquement A-Z, a-z, 0-9, - et _ (64 caractères maximum)." - color: page.nameDraft.length === 0 || page.validName ? Theme.secondaryForeground : Theme.warningForeground - wrapMode: Text.Wrap; font.pixelSize: 11 - } - ActionButton { label: "Sauvegarder"; enabled: page.validName && !page.busy; onClicked: page.requestSave() } - } - } - - Component { - id: duplicateComponent - Column { - spacing: 16 - Text { width: parent.width; text: "La session existe déjà.\nMettre à jour cette sauvegarde ?"; wrapMode: Text.Wrap; color: Theme.foreground; font.pixelSize: 14; font.bold: true } - Row { - spacing: 8 - ActionButton { label: "Annuler"; enabled: !page.busy; onClicked: page.view = "create" } - ActionButton { label: "Mettre à jour"; enabled: !page.busy; onClicked: page.run("save", page.nameDraft) } - } - } - } - - Component { - id: detailsComponent - ScrollView { - clip: true - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - Column { - width: page.width - 14 - spacing: 9 - Text { width: parent.width; text: page.selectedSession ? page.selectedSession.name : ""; wrapMode: Text.Wrap; color: Theme.foreground; font.pixelSize: 17; font.bold: true } - Text { visible: !!page.snapshot; text: "Créée : " + page.formatDate(page.snapshot ? page.snapshot.metadata.created_at : "").replace("Mis à jour ", ""); color: Theme.secondaryForeground; font.pixelSize: 11 } - Text { visible: !!page.snapshot; text: "Mise à jour : " + page.formatDate(page.snapshot ? page.snapshot.metadata.updated_at : "").replace("Mis à jour ", ""); color: Theme.secondaryForeground; font.pixelSize: 11 } - Text { visible: !!page.snapshot; text: page.plural(page.snapshot ? page.snapshot.workspaces.length : 0, "espace", "espaces") + " • " + page.plural(page.snapshot ? page.snapshot.windows.length : 0, "fenêtre", "fenêtres"); color: Theme.foreground; font.pixelSize: 12 } - Text { visible: !!page.snapshot; text: "Moteur de disposition : " + (page.snapshot ? page.snapshot.compositor.layout_engine : ""); color: Theme.secondaryForeground; font.pixelSize: 11 } - Text { visible: !!page.snapshot; text: "FENÊTRES"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true } - Repeater { - model: page.snapshot ? page.snapshot.windows : [] - delegate: Rectangle { - required property var modelData - width: parent.width; height: 38; radius: 4; color: Theme.buttonBackground - Text { anchors.left: parent.left; anchors.leftMargin: 9; anchors.verticalCenter: parent.verticalCenter; width: parent.width - 90; text: page.applicationLabel(modelData); elide: Text.ElideRight; color: Theme.foreground; font.pixelSize: 12 } - Text { anchors.right: parent.right; anchors.rightMargin: 9; anchors.verticalCenter: parent.verticalCenter; text: modelData.workspace ? "WS " + modelData.workspace : modelData.scratchpad && modelData.scratchpad.member ? "Scratchpad" : "—"; color: Theme.secondaryForeground; font.pixelSize: 11 } - } - } - Row { - visible: !!page.snapshot - spacing: 8 - ActionButton { label: "Mettre à jour"; enabled: !page.mutatingBusy && !page.busy; onClicked: page.run("save", page.selectedSession.name) } - ActionButton { label: "Prévisualiser"; enabled: !page.busy; onClicked: page.preview() } - ActionButton { label: "Supprimer"; danger: true; enabled: !page.mutatingBusy && !page.busy; onClicked: page.view = "deleteConfirm" } - } - } - } - } - - Component { - id: deleteComponent - Column { - spacing: 16 - Text { width: parent.width; text: "Supprimer « " + (page.selectedSession ? page.selectedSession.name : "") + " » ?"; wrapMode: Text.Wrap; color: Theme.danger; font.pixelSize: 15; font.bold: true } - Text { width: parent.width; text: "Cette sauvegarde sera définitivement supprimée."; wrapMode: Text.Wrap; color: Theme.secondaryForeground; font.pixelSize: 12 } - Row { - spacing: 8 - ActionButton { label: "Annuler"; enabled: !page.busy; onClicked: page.view = "details" } - ActionButton { label: "Supprimer"; danger: true; enabled: !page.busy; onClicked: page.run("delete", page.selectedSession.name) } - } - } - } - - Component { - id: planComponent - Column { - spacing: 8 - Text { visible: !!page.plan && page.blockerCount() > 0; width: parent.width; text: "Restauration incomplète"; color: Theme.warningForeground; font.pixelSize: 13; font.bold: true } - Grid { - visible: !!page.plan - columns: 3; spacing: 5 - Repeater { - model: page.plan ? [ - ["Réutilisées", page.countAction("reuse-window")], ["À lancer", page.countAction("launch-application")], ["À déplacer", page.countAction("move-to-workspace")], - ["Layout", page.countAction("restore-tree-position")], ["Floating", page.countAction("restore-floating")], ["Fullscreen", page.countAction("restore-fullscreen")], - ["Scratchpad", page.countAction("restore-scratchpad-hidden") + page.countAction("restore-scratchpad-visible")], ["Focus", page.countAction("restore-focus")], ["Intervention", page.blockerCount()] - ] : [] - delegate: Rectangle { - required property var modelData - width: (page.width - 24) / 3; height: 48; radius: 4; color: Theme.buttonBackground - Column { anchors.centerIn: parent; Text { anchors.horizontalCenter: parent.horizontalCenter; text: modelData[1]; color: Theme.foreground; font.pixelSize: 15; font.bold: true } Text { anchors.horizontalCenter: parent.horizontalCenter; text: modelData[0]; color: Theme.secondaryForeground; font.pixelSize: 9 } } - } - } - } - Text { visible: !!page.plan; text: "ACTIONS"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true } - ScrollView { - width: parent.width; height: 245; visible: !!page.plan; clip: true - ScrollBar.horizontal.policy: ScrollBar.AlwaysOff - Column { - width: page.width - 14; spacing: 4 - Repeater { - model: page.plan ? page.plan.actions : [] - delegate: Rectangle { - required property var modelData - width: parent.width; height: actionText.implicitHeight + 20; radius: 4 - color: page.isBlocking(modelData) ? Theme.buttonHover : Theme.buttonBackground - Column { anchors.left: parent.left; anchors.leftMargin: 9; anchors.right: parent.right; anchors.rightMargin: 9; anchors.verticalCenter: parent.verticalCenter; spacing: 2 - Text { text: page.groupFor(modelData.action); color: page.isBlocking(modelData) ? Theme.warningForeground : Theme.accentForeground; font.pixelSize: 9; font.bold: true } - Text { id: actionText; width: parent.width; text: page.actionText(modelData); wrapMode: Text.Wrap; color: Theme.foreground; font.pixelSize: 11 } - } - } - } - } - } - Text { visible: !!page.plan; width: parent.width; wrapMode: Text.Wrap; text: "La restauration replace les fenêtres et le layout. L’état interne des applications n’est pas garanti (onglets, documents, contenu d’un terminal, etc.). Les proportions exactes des splits ne sont pas restaurées."; color: Theme.secondaryForeground; font.pixelSize: 10 } - Text { visible: !!page.plan; width: parent.width; wrapMode: Text.Wrap; text: "L’état sera revérifié au moment de la restauration. La sauvegarde utilisée sera son état actuel au moment de l’exécution."; color: Theme.secondaryForeground; font.pixelSize: 10 } - ActionButton { visible: !!page.plan; label: "Restaurer"; enabled: !page.busy; onClicked: page.view = "restoreConfirm" } - } - } - - Component { - id: restoreConfirmComponent - Column { - spacing: 16 - Text { width: parent.width; text: "Restaurer « " + (page.selectedSession ? page.selectedSession.name : "") + " » ?"; wrapMode: Text.Wrap; color: Theme.foreground; font.pixelSize: 15; font.bold: true } - Text { width: parent.width; text: "Les applications manquantes pourront être lancées et les fenêtres existantes déplacées."; wrapMode: Text.Wrap; color: Theme.secondaryForeground; font.pixelSize: 12 } - Text { width: parent.width; text: "Un plan frais et le preflight seront recalculés avant toute action."; wrapMode: Text.Wrap; color: Theme.secondaryForeground; font.pixelSize: 11 } - Row { - spacing: 8 - ActionButton { label: "Annuler"; enabled: !page.busy; onClicked: page.view = "plan" } - ActionButton { label: "Restaurer"; enabled: !page.busy; onClicked: page.run("apply", page.selectedSession.name) } - } - } - } - - Component { - id: resultComponent - Column { - spacing: 12 - Text { width: parent.width; text: page.restoreReport && page.restoreReport.status === "success" ? "Session restaurée" : "Restauration interrompue"; color: page.restoreReport && page.restoreReport.status === "success" ? Theme.successForeground : Theme.warningForeground; font.pixelSize: 16; font.bold: true } - Text { visible: page.restoreReport && page.restoreReport.status === "success"; text: page.plural(page.resultCount("applications_launched"), "application lancée", "applications lancées") + " • " + page.plural(page.resultCount("mutating_commands_executed"), "action exécutée", "actions exécutées"); color: Theme.foreground; font.pixelSize: 12 } - Text { visible: page.errorMessage.length > 0; width: parent.width; text: page.errorMessage; wrapMode: Text.Wrap; color: Theme.warningForeground; font.pixelSize: 12 } - Text { visible: page.technicalDetails.length > 0; width: parent.width; text: "Détails : " + page.technicalDetails; wrapMode: Text.Wrap; color: Theme.disabledForeground; font.pixelSize: 10 } - Row { - spacing: 8 - ActionButton { label: "Fermer"; enabled: !page.busy; onClicked: { page.view = "details"; page.restoreReport = null; } } - ActionButton { label: "Actualiser"; enabled: !page.busy; onClicked: { page.view = "list"; page.restoreReport = null; page.refresh(); } } - } - } - } -} diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManagerV2.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManagerV2.qml new file mode 100644 index 000000000..741841b4a --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManagerV2.qml @@ -0,0 +1,106 @@ +import QtQuick +import QtQuick.Controls +import "../theme" +import "../sessionui" +import "../sessionui/Protocol.js" as Protocol + +Item { + id: page + required property bool activePage + signal backRequested() + property var sessions: [] + property string selected: "" + property string feedback: "" + property string details: "" + property bool restoreFailed: false + property bool detailsVisible: false + readonly property bool busy: SessionV2Service.busy + implicitHeight: content.implicitHeight + onActivePageChanged: if (activePage && !busy) SessionV2Service.run("list", "", "", page) + Connections { + target: SessionV2Service + function onCompleted(owner, kind, value, okay) { + if (owner !== page) return; + if (kind === "list") { if (okay) page.sessions = value.sessions; return; } + if (kind === "apply") { + if (okay && Protocol.restoreSucceeded(value)) page.feedback = "Session restaurée"; + else { + page.restoreFailed = true; + page.feedback = "Impossible de restaurer complètement la session."; + SessionV2Service.run("restore-status", "", "", page); + } + } else if (kind === "restore-status") page.details = Protocol.details(value); + else if (!okay) page.feedback = "L’opération n’a pas pu aboutir."; + else if (kind === "plan") page.feedback = "Plan structurel : " + value.operations.length + " opérations. Aucune fenêtre modifiée."; + else { + page.feedback = kind === "save" ? "Session sauvegardée" : "Session supprimée"; + SessionV2Service.run("list", "", "", page); + } + } + } + Column { + id: content + width: parent.width + spacing: 12 + Text { text: "Gestionnaire de sessions V2"; color: Theme.foreground; font.pixelSize: 17; font.bold: true } + Text { width: parent.width; text: page.feedback; visible: text.length > 0; color: Theme.foreground; wrapMode: Text.Wrap } + Text { width: parent.width; text: page.details; visible: page.detailsVisible; color: Theme.secondaryForeground; wrapMode: Text.Wrap } + Column { + width: parent.width + spacing: 10 + visible: !page.restoreFailed + TextField { + id: nameField + width: parent.width + placeholderText: "Nom de la session" + enabled: !page.busy + maximumLength: 64 + } + ActionButton { + label: "Sauvegarder" + enabled: !page.busy && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(nameField.text) + && page.sessions.indexOf(nameField.text) < 0 + onClicked: SessionV2Service.run("save", nameField.text, "", page) + } + ComboBox { + id: selection + width: parent.width + model: page.sessions + enabled: !page.busy + onCurrentTextChanged: page.selected = currentText + } + Row { + spacing: 8 + ActionButton { + label: "Aperçu" + enabled: !page.busy && page.selected.length > 0 + onClicked: SessionV2Service.run("plan", page.selected, "", page) + } + ActionButton { + label: "Restaurer" + enabled: !page.busy && page.selected.length > 0 + onClicked: { page.feedback = "Restauration en cours…"; SessionV2Service.run("apply", page.selected, "", page); } + } + ActionButton { + label: "Supprimer" + enabled: !page.busy && page.selected.length > 0 + onClicked: SessionV2Service.run("delete", page.selected, "", page) + } + } + } + Row { + spacing: 8 + ActionButton { + visible: page.restoreFailed + label: "Détails" + enabled: !page.busy + onClicked: page.detailsVisible = !page.detailsVisible + } + ActionButton { + label: page.restoreFailed ? "Nouvelle session" : "Retour" + enabled: !page.busy + onClicked: { page.restoreFailed = false; page.feedback = ""; page.detailsVisible = false; page.backRequested(); } + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/session-v2-native-host.py b/quickshell/.config/quickshell/labfy-sway/session-v2-native-host.py new file mode 100755 index 000000000..516053c20 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session-v2-native-host.py @@ -0,0 +1,8 @@ +#!/usr/bin/python3 +"""Entrée native messaging ; jamais lancée via une commande fournie par message.""" +import sys +sys.dont_write_bytecode = True +from session_v2.native_host import main +from session_v2.errors import Failure +try: main() +except (Failure, OSError): sys.exit(1) diff --git a/quickshell/.config/quickshell/labfy-sway/session-v2.py b/quickshell/.config/quickshell/labfy-sway/session-v2.py new file mode 100755 index 000000000..e971feb19 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session-v2.py @@ -0,0 +1,6 @@ +#!/usr/bin/python3 +"""Entrée V2 indépendante ; aucun import QuickShell/V1.""" +import sys +sys.dont_write_bytecode = True +from session_v2.cli import main +sys.exit(main()) diff --git a/quickshell/.config/quickshell/labfy-sway/session/session_executor.py b/quickshell/.config/quickshell/labfy-sway/session/session_executor.py deleted file mode 100644 index e9f7b1d9b..000000000 --- a/quickshell/.config/quickshell/labfy-sway/session/session_executor.py +++ /dev/null @@ -1,978 +0,0 @@ -#!/usr/bin/env python3 -"""Exécuter le sous-ensemble contrôlé STEP19B/STEP19C d'un plan V1 frais.""" - -from __future__ import annotations - -import contextlib -import copy -import fcntl -import hashlib -import importlib.util -import json -import os -from pathlib import Path -import re -import secrets -import stat -import subprocess -import time -from typing import Any, Callable, Iterator, Mapping, Sequence - - -REPORT_SCHEMA = "labfy.sway.restore-execution" -REPORT_VERSION = 1 -RESTORE_LOCK_FILENAME = ".restore.lock" -LAUNCH_BACKEND = "uwsm-app-desktop-entry" -DEFAULT_WINDOW_TIMEOUT_SECONDS = 10.0 -POLL_INTERVAL_SECONDS = 0.1 -POSTCONDITION_TIMEOUT_SECONDS = 1.0 -SUPPORTED_ACTIONS = { - "reuse-window", - "launch-application", - "skip-autostart-managed", - "move-to-workspace", - "restore-floating", - "restore-fullscreen", - "restore-tree-position", - "restore-scratchpad-hidden", - "restore-scratchpad-visible", - "restore-focus", -} -UNSUPPORTED_ACTIONS = { - "manual-required", -} -NUMERIC_WORKSPACE = re.compile(r"\A[1-9][0-9]*\Z") -DESKTOP_ENTRY_ID = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._-]*\.desktop\Z") - - -class RestoreExecutionError(RuntimeError): - """Échec contrôlé portant un code stable et un motif auditables.""" - - def __init__(self, code: str, reason: str): - super().__init__(f"{code}: {reason}") - self.code = code - self.reason = reason - - -def snapshot_digest(snapshot: Mapping[str, Any]) -> str: - """Hasher le document chargé, indépendamment de son formatage JSON sur disque.""" - payload = json.dumps(snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(payload.encode("utf-8")).hexdigest() - - -def _default_runner(argv: Sequence[str], timeout: float) -> Any: - """CONTRACT: exécuter un argv construit localement, sans interpréteur shell.""" - if argv and argv[0] == "uwsm": - # WHY: uwsm app peut rester attaché pendant toute la vie de - # l'application. Attendre son exit bloquerait avant même le polling - # Sway ; son PID n'est en outre jamais l'identité de la future vue. - # CONTRACT: l'enfant direct reçoit uniquement l'argv allowlisté, sans - # shell et sans capture bornée à gérer. Le CLI STEP19B est éphémère. - # INVARIANT: seul le matching d'une nouvelle fenêtre prouve le succès. - return subprocess.Popen( # type: ignore[return-value] - list(argv), - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - close_fds=True, - start_new_session=True, - ) - return subprocess.run( - list(argv), - check=True, - capture_output=True, - text=True, - timeout=timeout, - ) - - -def _direct_uwsm_command(desktop_entry: str) -> list[str]: - """Reconstruire l'unique backend autorisé sans consulter launch.argv.""" - return ["uwsm", "app", "--", desktop_entry] - - -def _direct_sway_command(con_id: int, operation: str) -> list[str]: - """Cibler une observation runtime validée ; operation est toujours une constante locale.""" - return ["swaymsg", "-r", f"[con_id={con_id}] {operation}"] - - -def _load_tree_compiler() -> Any: - """Charger le compilateur pur voisin sans lui donner d'effet externe.""" - path = Path(__file__).with_name("session_tree_restore.py") - spec = importlib.util.spec_from_file_location("labfy_session_tree_restore", path) - if spec is None or spec.loader is None: - raise RestoreExecutionError("RESTORE_TREE_COMPILER_UNAVAILABLE", path.name) - module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) - except (OSError, ImportError, SyntaxError) as exc: - raise RestoreExecutionError("RESTORE_TREE_COMPILER_UNAVAILABLE", str(exc)) from exc - return module - - -def _source_windows(source: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]: - return { - str(window.get("window_id")): window - for window in source.get("windows", []) - if isinstance(window, Mapping) - } - - -def _runtime_con_id(window: Mapping[str, Any]) -> int | None: - runtime = window.get("runtime") - runtime = runtime if isinstance(runtime, Mapping) else {} - value = runtime.get("con_id") - return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None - - -def _find_con_id(live: Mapping[str, Any], con_id: int) -> Mapping[str, Any] | None: - return next( - ( - window - for window in live.get("windows", []) - if isinstance(window, Mapping) and _runtime_con_id(window) == con_id - ), - None, - ) - - -def _compatible_match( - planner_module: Any, - saved: Mapping[str, Any], - candidate: Mapping[str, Any], -) -> bool: - matches, unmatched = planner_module.match_windows([saved], [candidate]) - saved_id = str(saved.get("window_id")) - evidence = matches.get(saved_id) - return not unmatched and evidence is not None and evidence.get("confidence") != "ambiguous" - - -def _revalidate_runtime( - source_window: Mapping[str, Any], - con_id: int, - *, - collector: Callable[[], dict[str, Any]], - planner_module: Any, - allow_scratchpad: bool = False, -) -> tuple[Mapping[str, Any], dict[str, Any]]: - """Refuser disparition, recyclage d'id, identité divergente ou workspace inconnu.""" - live = collector() - candidate = _find_con_id(live, con_id) - if candidate is None: - raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"con_id disparu: {con_id}") - if not _compatible_match(planner_module, source_window, candidate): - raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"identité modifiée pour con_id={con_id}") - scratchpad = candidate.get("scratchpad") - scratch_member = isinstance(scratchpad, Mapping) and scratchpad.get("member") is True - if ( - (not isinstance(candidate.get("workspace"), str) or not candidate.get("workspace")) - and not (allow_scratchpad and scratch_member) - ): - raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"workspace inconnu pour con_id={con_id}") - return candidate, live - - -def _validate_desktop_entry( - saved: Mapping[str, Any], - action: Mapping[str, Any], - *, - desktop_entries: Sequence[Mapping[str, str | None]], - resolve_desktop_entry: Callable[..., dict[str, Any]], -) -> str: - """Recalculer l'identité exacte contre les sources XDG actuelles.""" - desktop_entry = action.get("desktop_entry") - launch = action.get("launch") - launch = launch if isinstance(launch, Mapping) else {} - classification = saved.get("classification") - classification = classification if isinstance(classification, Mapping) else {} - identity = saved.get("restore_identity") - identity = identity if isinstance(identity, Mapping) else {} - if ( - not isinstance(desktop_entry, str) - or DESKTOP_ENTRY_ID.fullmatch(desktop_entry) is None - or launch.get("backend") != LAUNCH_BACKEND - or classification.get("category") != "user-application" - or identity.get("confidence") != "exact" - or identity.get("desktop_entry") != desktop_entry - ): - raise RestoreExecutionError( - "RESTORE_DESKTOP_ENTRY_REJECTED", - f"identité de lancement non exacte: {action.get('source_window')}", - ) - xwayland = saved.get("xwayland") - xwayland = xwayland if isinstance(xwayland, Mapping) else {} - current = resolve_desktop_entry( - saved.get("app_id"), - xwayland, - saved.get("executable_basename"), - desktop_entries, - ) - if current.get("confidence") != "exact" or current.get("desktop_entry") != desktop_entry: - raise RestoreExecutionError( - "RESTORE_DESKTOP_ENTRY_REJECTED", - f"DesktopEntry absente, ambiguë ou devenue incompatible: {desktop_entry}", - ) - return desktop_entry - - -def _blocking_finding(action: Mapping[str, Any]) -> dict[str, Any] | None: - action_type = action.get("action") - action_id = action.get("action_id") - if action_type not in SUPPORTED_ACTIONS: - return { - "action_id": action_id, - "action": action_type, - "reason": action.get("reason") or "unsupported-action-step19b", - } - if action_type == "reuse-window" and action.get("confidence") == "ambiguous": - return { - "action_id": action_id, - "action": action_type, - "reason": "ambiguous-runtime-match", - } - if action_type == "move-to-workspace": - workspace = action.get("desired_workspace") - if not isinstance(workspace, str) or NUMERIC_WORKSPACE.fullmatch(workspace) is None: - return { - "action_id": action_id, - "action": action_type, - "reason": "unsupported-non-numeric-workspace", - "desired_workspace": workspace, - } - if action_type == "restore-fullscreen" and action.get("desired_fullscreen_mode") not in {0, 1}: - return { - "action_id": action_id, - "action": action_type, - "reason": "unsupported-fullscreen-mode", - "desired_fullscreen_mode": action.get("desired_fullscreen_mode"), - } - if action_type in {"restore-scratchpad-hidden", "restore-scratchpad-visible"}: - desired = action.get("desired") - visibility = desired.get("visibility") if isinstance(desired, Mapping) else None - expected = "hidden" if action_type.endswith("hidden") else "visible" - if not isinstance(desired, Mapping) or desired.get("member") is not True or visibility != expected: - return { - "action_id": action_id, - "action": action_type, - "reason": "invalid-scratchpad-target", - } - return None - - -def capability_gate( - source: Mapping[str, Any], - plan: Mapping[str, Any], - live: Mapping[str, Any], - *, - planner_module: Any, - tree_module: Any, - desktop_entries: Sequence[Mapping[str, str | None]], - resolve_desktop_entry: Callable[..., dict[str, Any]], -) -> Mapping[str, Any]: - """Examiner tout le plan avant la première mutation, sans exception partielle.""" - windows = _source_windows(source) - findings: list[dict[str, Any]] = [] - for action in plan.get("actions", []): - if not isinstance(action, Mapping): - findings.append({"action_id": None, "action": None, "reason": "invalid-action"}) - continue - finding = _blocking_finding(action) - if finding is not None: - findings.append(finding) - continue - saved = windows.get(str(action.get("source_window"))) - if saved is None: - findings.append({ - "action_id": action.get("action_id"), - "action": action.get("action"), - "reason": "source-window-missing", - }) - continue - if action.get("action") == "launch-application": - try: - _validate_desktop_entry( - saved, - action, - desktop_entries=desktop_entries, - resolve_desktop_entry=resolve_desktop_entry, - ) - except RestoreExecutionError as exc: - findings.append({ - "action_id": action.get("action_id"), - "action": action.get("action"), - "reason": exc.reason, - }) - if action.get("action") == "reuse-window": - live_window = action.get("live_window") - if not isinstance(live_window, Mapping) or _runtime_con_id(live_window) is None: - findings.append({ - "action_id": action.get("action_id"), - "action": action.get("action"), - "reason": "missing-runtime-con-id", - }) - if action.get("action") == "restore-scratchpad-visible": - desired_workspace = saved.get("workspace") - live_focus = live.get("focus") - focused_workspace = live_focus.get("workspace") if isinstance(live_focus, Mapping) else None - if ( - not isinstance(desired_workspace, str) - or NUMERIC_WORKSPACE.fullmatch(desired_workspace) is None - or focused_workspace != desired_workspace - ): - findings.append({ - "action_id": action.get("action_id"), - "action": action.get("action"), - "reason": "scratchpad-visible-requires-focused-target-workspace", - }) - try: - planner_module.validate_restore_plan(plan) - except Exception as exc: - findings.append({"action_id": None, "action": None, "reason": f"invalid-plan: {exc}"}) - if any( - isinstance(action, Mapping) and action.get("action") == "restore-tree-position" - for action in plan.get("actions", []) - ): - try: - tree_compilation = tree_module.compile_tree_restore(source, live, plan) - tree_module.validate_compilation(tree_compilation) - except Exception as exc: - findings.append({ - "action_id": None, - "action": "restore-tree-position", - "reason": f"tree-preflight: {exc}", - }) - tree_compilation = {"capability": "unsupported", "operations": [], "workspaces": []} - else: - tree_compilation = { - "capability": "supported-subset", - "percent_capability": "unsupported", - "operations": [], - "workspaces": [], - } - if findings: - raise RestoreExecutionError( - "RESTORE_BLOCKED_UNSUPPORTED_ACTION", - json.dumps(findings, ensure_ascii=False, sort_keys=True), - ) - return tree_compilation - - -@contextlib.contextmanager -def restore_lock(directory: Path) -> Iterator[None]: - """Détenir un flock exclusif non bloquant sur un fichier privé persistant.""" - directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC - if hasattr(os, "O_NOFOLLOW"): - directory_flags |= os.O_NOFOLLOW - try: - directory_fd = os.open(directory, directory_flags) - except OSError as exc: - raise RestoreExecutionError("RESTORE_LOCK_UNSAFE", f"répertoire sessions non sûr: {exc}") from exc - if stat.S_IMODE(os.fstat(directory_fd).st_mode) != 0o700: - os.close(directory_fd) - raise RestoreExecutionError("RESTORE_LOCK_UNSAFE", "répertoire sessions non privé") - flags = os.O_RDWR | os.O_CREAT | os.O_CLOEXEC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - lock_fd = -1 - try: - try: - lock_fd = os.open(RESTORE_LOCK_FILENAME, flags, 0o600, dir_fd=directory_fd) - except OSError as exc: - raise RestoreExecutionError("RESTORE_LOCK_UNSAFE", f"restore lock non sûr: {exc}") from exc - info = os.fstat(lock_fd) - if not stat.S_ISREG(info.st_mode): - raise RestoreExecutionError("RESTORE_LOCK_UNSAFE", "restore lock non régulier") - os.fchmod(lock_fd, 0o600) - try: - fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError as exc: - raise RestoreExecutionError("RESTORE_ALREADY_RUNNING", "une restauration est déjà active") from exc - yield - finally: - if lock_fd >= 0: - try: - fcntl.flock(lock_fd, fcntl.LOCK_UN) - finally: - os.close(lock_fd) - os.close(directory_fd) - - -def _parse_sway_success(completed: Any) -> None: - try: - payload = json.loads(completed.stdout) - except (AttributeError, TypeError, json.JSONDecodeError) as exc: - raise RestoreExecutionError("RESTORE_SWAY_COMMAND_FAILED", "réponse swaymsg JSON invalide") from exc - if not isinstance(payload, list) or not payload or not all( - isinstance(item, Mapping) and item.get("success") is True for item in payload - ): - raise RestoreExecutionError("RESTORE_SWAY_COMMAND_FAILED", f"commande refusée: {payload!r}") - - -def _wait_for_launched_window( - saved: Mapping[str, Any], - before_ids: set[int], - *, - collector: Callable[[], dict[str, Any]], - planner_module: Any, - timeout: float, - monotonic: Callable[[], float], - sleeper: Callable[[float], None], -) -> tuple[int, Mapping[str, Any]]: - deadline = monotonic() + timeout - while True: - live = collector() - candidates = [ - window - for window in live.get("windows", []) - if isinstance(window, Mapping) - and _runtime_con_id(window) is not None - and _runtime_con_id(window) not in before_ids - and _compatible_match(planner_module, saved, window) - ] - if candidates: - matches, _unmatched = planner_module.match_windows([saved], candidates) - match = matches.get(str(saved.get("window_id"))) - if match is None: - pass - elif match.get("confidence") == "ambiguous": - raise RestoreExecutionError( - "RESTORE_LAUNCH_AMBIGUOUS", - f"plusieurs nouvelles fenêtres indiscernables: {saved.get('window_id')}", - ) - else: - chosen = candidates[int(match["live_index"])] - con_id = _runtime_con_id(chosen) - if con_id is not None: - return con_id, chosen - if monotonic() >= deadline: - raise RestoreExecutionError( - "RESTORE_LAUNCH_TIMEOUT", - f"aucune nouvelle fenêtre compatible: {saved.get('window_id')}", - ) - sleeper(POLL_INTERVAL_SECONDS) - - -def _action_status(action: Mapping[str, Any]) -> dict[str, Any]: - return { - "action_id": action.get("action_id"), - "action": action.get("action"), - "source_window": action.get("source_window"), - "status": "pending", - "reason": None, - } - - -def _wait_for_postcondition( - saved: Mapping[str, Any], - con_id: int, - predicate: Callable[[Mapping[str, Any]], bool], - failure_reason: str, - *, - collector: Callable[[], dict[str, Any]], - planner_module: Any, - monotonic: Callable[[], float], - sleeper: Callable[[float], None], - allow_scratchpad: bool = False, -) -> Mapping[str, Any]: - """Attendre brièvement la visibilité IPC d'une mutation déjà acceptée.""" - deadline = monotonic() + POSTCONDITION_TIMEOUT_SECONDS - while True: - candidate, _live = _revalidate_runtime( - saved, - con_id, - collector=collector, - planner_module=planner_module, - allow_scratchpad=allow_scratchpad, - ) - if predicate(candidate): - return candidate - if monotonic() >= deadline: - raise RestoreExecutionError("RESTORE_POSTCONDITION_FAILED", failure_reason) - sleeper(POLL_INTERVAL_SECONDS) - - -def _tree_argv( - operation: Mapping[str, Any], - runtime_by_source: Mapping[str, int], - tree_module: Any, -) -> list[str]: - """Construire une commande fermée depuis une opération prévalidée.""" - tree_module.validate_operation(operation) - source_id = str(operation["source_window"]) - con_id = runtime_by_source.get(source_id) - if con_id is None: - raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"fenêtre arbre absente: {source_id}") - kind = operation["operation"] - if kind == "set-container-layout": - command_layout = "stacking" if operation["layout"] == "stacked" else operation["layout"] - sway_operation = f"layout {command_layout}" - elif kind == "set-split-orientation": - sway_operation = "split horizontal" if operation["layout"] == "splith" else "split vertical" - else: - raise RestoreExecutionError("RESTORE_TREE_OPERATION_INVALID", "move-relative requiert un mark temporaire") - return _direct_sway_command(con_id, sway_operation) - - -def _all_live_marks(live: Mapping[str, Any]) -> set[str]: - result: set[str] = set() - for collection in (live.get("containers", []), live.get("windows", [])): - for item in collection if isinstance(collection, list) else []: - if isinstance(item, Mapping): - result.update(mark for mark in item.get("marks", []) if isinstance(mark, str)) - return result - - -def _move_relative_with_mark( - operation: Mapping[str, Any], - runtime_by_source: Mapping[str, int], - *, - collector: Callable[[], dict[str, Any]], - runner: Callable[[Sequence[str], float], Any], - monotonic: Callable[[], float], - sleeper: Callable[[float], None], -) -> int: - """Insérer une vue après une référence exacte sans direction géométrique.""" - source_con = runtime_by_source.get(str(operation["source_window"])) - reference_con = runtime_by_source.get(str(operation["reference_window"])) - if source_con is None or reference_con is None or source_con == reference_con: - raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", "référence move-relative invalide") - before = collector() - existing = _all_live_marks(before) - mark = "" - for _attempt in range(8): - candidate = f"__labfy_restore_{secrets.token_hex(8)}" - if candidate not in existing: - mark = candidate - break - if not mark: - raise RestoreExecutionError("RESTORE_TEMPORARY_MARK_COLLISION", "aucun mark unique disponible") - - mark_created = False - commands = 0 - try: - completed = runner(_direct_sway_command(reference_con, f"mark --add {mark}"), 5.0) - _parse_sway_success(completed) - commands += 1 - mark_created = True - marked = collector() - reference = _find_con_id(marked, reference_con) - if reference is None or mark not in reference.get("marks", []): - raise RestoreExecutionError("RESTORE_POSTCONDITION_FAILED", "mark temporaire absent") - completed = runner(_direct_sway_command(source_con, f"move container to mark {mark}"), 5.0) - _parse_sway_success(completed) - commands += 1 - finally: - if mark_created: - try: - completed = runner(_direct_sway_command(reference_con, f"unmark {mark}"), 5.0) - _parse_sway_success(completed) - commands += 1 - except (OSError, subprocess.SubprocessError, RestoreExecutionError): - # Le caller vérifiera l'absence du mark ; l'erreur originale - # reste prioritaire si le move lui-même a échoué. - pass - deadline = monotonic() + POSTCONDITION_TIMEOUT_SECONDS - while True: - after = collector() - if mark not in _all_live_marks(after): - return commands - if monotonic() >= deadline: - raise RestoreExecutionError("RESTORE_TEMPORARY_MARK_LEAK", mark) - sleeper(POLL_INTERVAL_SECONDS) - - -def _execute_tree_compilation( - source: Mapping[str, Any], - plan: Mapping[str, Any], - compilation: Mapping[str, Any], - runtime_by_source: Mapping[str, int], - *, - collector: Callable[[], dict[str, Any]], - planner_module: Any, - tree_module: Any, - runner: Callable[[Sequence[str], float], Any], - monotonic: Callable[[], float], - sleeper: Callable[[float], None], -) -> int: - """Exécuter le bytecode typé avec détection de dérive entre mutations.""" - source_windows = _source_windows(source) - last_fingerprint: dict[str, Any] = {} - commands = 0 - for operation in compilation.get("operations", []): - workspace = str(operation["workspace"]) - before = collector() - for source_id, con_id in runtime_by_source.items(): - saved = source_windows.get(source_id) - if saved is not None and saved.get("workspace") == workspace: - candidate = _find_con_id(before, con_id) - if candidate is None or not _compatible_match(planner_module, saved, candidate): - raise RestoreExecutionError( - "RESTORE_RUNTIME_DIVERGED", - f"inventaire arbre modifié: {source_id}", - ) - fingerprint = tree_module.tree_fingerprint(before, workspace, runtime_by_source) - if workspace in last_fingerprint and fingerprint != last_fingerprint[workspace]: - raise RestoreExecutionError( - "RESTORE_TREE_DRIFTED", - f"arbre modifié entre deux opérations: workspace {workspace}", - ) - if operation["operation"] == "move-relative": - commands += _move_relative_with_mark( - operation, - runtime_by_source, - collector=collector, - runner=runner, - monotonic=monotonic, - sleeper=sleeper, - ) - else: - completed = runner(_tree_argv(operation, runtime_by_source, tree_module), 5.0) - _parse_sway_success(completed) - commands += 1 - deadline = monotonic() + POSTCONDITION_TIMEOUT_SECONDS - while True: - after = collector() - changed = tree_module.tree_fingerprint(after, workspace, runtime_by_source) - if changed != fingerprint: - last_fingerprint[workspace] = changed - break - if monotonic() >= deadline: - raise RestoreExecutionError( - "RESTORE_POSTCONDITION_FAILED", - f"opération arbre sans effet observable: {operation['operation']}", - ) - sleeper(POLL_INTERVAL_SECONDS) - - final_live = collector() - try: - remaining = tree_module.compile_tree_restore(source, final_live, plan) - tree_module.validate_compilation(remaining) - except Exception as exc: - raise RestoreExecutionError("RESTORE_POSTCONDITION_FAILED", f"arbre final invalide: {exc}") from exc - if remaining.get("operations"): - raise RestoreExecutionError("RESTORE_POSTCONDITION_FAILED", "arbre cible non atteint") - for workspace in remaining.get("workspaces", []): - if workspace.get("target_tree") != workspace.get("live_tree"): - raise RestoreExecutionError("RESTORE_POSTCONDITION_FAILED", "topologie finale différente") - return commands - - -def execute_fresh_plan( - source: Mapping[str, Any], - plan: Mapping[str, Any], - *, - collector: Callable[[], dict[str, Any]], - planner_module: Any, - desktop_entries: Sequence[Mapping[str, str | None]], - resolve_desktop_entry: Callable[..., dict[str, Any]], - runner: Callable[[Sequence[str], float], Any] = _default_runner, - timeout: float = DEFAULT_WINDOW_TIMEOUT_SECONDS, - monotonic: Callable[[], float] = time.monotonic, - sleeper: Callable[[float], None] = time.sleep, - tree_module: Any | None = None, -) -> dict[str, Any]: - """Appliquer séquentiellement un plan déjà préflighté et arrêter au premier écart.""" - tree_module = _load_tree_compiler() if tree_module is None else tree_module - preflight_live = collector() - tree_compilation = capability_gate( - source, - plan, - preflight_live, - planner_module=planner_module, - tree_module=tree_module, - desktop_entries=desktop_entries, - resolve_desktop_entry=resolve_desktop_entry, - ) - windows = _source_windows(source) - statuses = [_action_status(action) for action in plan["actions"]] - runtime_by_source: dict[str, int] = {} - initial_ids = { - con_id - for window in plan.get("live", {}).get("windows", []) - if isinstance(window, Mapping) - for con_id in [_runtime_con_id(window)] - if con_id is not None - } - applications_launched = 0 - mutating_commands = 0 - stopped_reason: str | None = None - tree_executed = False - - for action, status in zip(plan["actions"], statuses): - action_type = action["action"] - source_id = str(action["source_window"]) - saved = windows[source_id] - try: - if action_type == "reuse-window": - con_id = _runtime_con_id(action["live_window"]) - assert con_id is not None - saved_scratchpad = saved.get("scratchpad") - _revalidate_runtime( - saved, - con_id, - collector=collector, - planner_module=planner_module, - allow_scratchpad=( - isinstance(saved_scratchpad, Mapping) - and saved_scratchpad.get("member") is True - ), - ) - runtime_by_source[source_id] = con_id - status.update(status="success", reason="fenêtre live réutilisée et revalidée") - elif action_type == "skip-autostart-managed": - status.update(status="skipped", reason="lancement possédé par sway-autostart") - elif action_type == "launch-application": - desktop_entry = _validate_desktop_entry( - saved, - action, - desktop_entries=desktop_entries, - resolve_desktop_entry=resolve_desktop_entry, - ) - # CONTRACT: l'inventaire de référence est pris immédiatement - # avant le lancement, et non réutilisé depuis le plan. Toute - # apparition/disparition imprévue depuis le plan force l'arrêt. - before_launch = collector() - before_launch_ids = { - con_id - for window in before_launch.get("windows", []) - if isinstance(window, Mapping) - for con_id in [_runtime_con_id(window)] - if con_id is not None - } - expected_ids = initial_ids | set(runtime_by_source.values()) - if before_launch_ids != expected_ids: - raise RestoreExecutionError( - "RESTORE_RUNTIME_DIVERGED", - "inventaire con_id modifié avant lancement", - ) - completed = runner(_direct_uwsm_command(desktop_entry), timeout) - if getattr(completed, "returncode", 0) not in {None, 0}: - raise RestoreExecutionError("RESTORE_LAUNCH_FAILED", f"uwsm a retourné {completed.returncode}") - applications_launched += 1 - con_id, _window = _wait_for_launched_window( - saved, - before_launch_ids, - collector=collector, - planner_module=planner_module, - timeout=timeout, - monotonic=monotonic, - sleeper=sleeper, - ) - runtime_by_source[source_id] = con_id - status.update(status="success", reason=f"nouvelle fenêtre compatible con_id={con_id}") - else: - con_id = runtime_by_source.get(source_id) - if con_id is None: - raise RestoreExecutionError( - "RESTORE_RUNTIME_DIVERGED", - f"aucun con_id validé pour {source_id}", - ) - saved_scratchpad = saved.get("scratchpad") - _revalidate_runtime( - saved, - con_id, - collector=collector, - planner_module=planner_module, - allow_scratchpad=( - isinstance(saved_scratchpad, Mapping) - and saved_scratchpad.get("member") is True - ), - ) - if action_type == "restore-tree-position": - if not tree_executed: - mutating_commands += _execute_tree_compilation( - source, - plan, - tree_compilation, - runtime_by_source, - collector=collector, - planner_module=planner_module, - tree_module=tree_module, - runner=runner, - monotonic=monotonic, - sleeper=sleeper, - ) - tree_executed = True - status.update(status="success", reason="topologie Sway cible confirmée") - continue - if action_type == "move-to-workspace": - workspace = str(int(action["desired_workspace"])) - operation = f"move container to workspace number {workspace}" - elif action_type == "restore-floating": - operation = "floating enable" if action["desired_floating"] else "floating disable" - elif action_type == "restore-fullscreen": - operation = "fullscreen enable" if action["desired_fullscreen_mode"] == 1 else "fullscreen disable" - elif action_type in {"restore-scratchpad-hidden", "restore-scratchpad-visible"}: - current = collector() - candidate = _find_con_id(current, con_id) - if candidate is None or not _compatible_match(planner_module, saved, candidate): - raise RestoreExecutionError("RESTORE_RUNTIME_DIVERGED", f"scratchpad cible absente: {con_id}") - scratch = candidate.get("scratchpad") - scratch = scratch if isinstance(scratch, Mapping) else {} - must_hide = ( - action_type == "restore-scratchpad-hidden" - and scratch.get("visibility") != "hidden" - ) - must_admit = ( - action_type == "restore-scratchpad-visible" - and scratch.get("member") is not True - ) - if must_hide or must_admit: - completed = runner(_direct_sway_command(con_id, "move scratchpad"), 5.0) - _parse_sway_success(completed) - mutating_commands += 1 - _wait_for_postcondition( - saved, - con_id, - lambda item: isinstance(item.get("scratchpad"), Mapping) - and item["scratchpad"].get("member") is True - and item["scratchpad"].get("visibility") == "hidden", - "fenêtre non déplacée vers le scratchpad caché", - collector=collector, - planner_module=planner_module, - monotonic=monotonic, - sleeper=sleeper, - allow_scratchpad=True, - ) - scratch = {"member": True, "visibility": "hidden"} - if ( - action_type == "restore-scratchpad-visible" - and scratch.get("visibility") != "visible" - ): - completed = runner(_direct_sway_command(con_id, "scratchpad show"), 5.0) - _parse_sway_success(completed) - mutating_commands += 1 - _wait_for_postcondition( - saved, - con_id, - lambda item: isinstance(item.get("scratchpad"), Mapping) - and item["scratchpad"].get("member") is True - and item["scratchpad"].get("visibility") == "visible", - "fenêtre scratchpad ciblée non visible", - collector=collector, - planner_module=planner_module, - monotonic=monotonic, - sleeper=sleeper, - allow_scratchpad=True, - ) - status.update(status="success", reason="scratchpad ciblé confirmé") - continue - else: - operation = "focus" - completed = runner(_direct_sway_command(con_id, operation), 5.0) - _parse_sway_success(completed) - mutating_commands += 1 - if action_type == "move-to-workspace": - predicate = lambda item: item.get("workspace") == workspace - failure_reason = "workspace cible non atteint" - elif action_type == "restore-floating": - predicate = lambda item: bool(item.get("floating")) == bool(action["desired_floating"]) - failure_reason = "état floating non atteint" - elif action_type == "restore-fullscreen": - predicate = lambda item: int(item.get("fullscreen_mode") or 0) == action["desired_fullscreen_mode"] - failure_reason = "état fullscreen non atteint" - else: - predicate = lambda item: bool(item.get("focused")) - failure_reason = "focus cible non atteint" - _wait_for_postcondition( - saved, - con_id, - predicate, - failure_reason, - collector=collector, - planner_module=planner_module, - monotonic=monotonic, - sleeper=sleeper, - ) - status.update(status="success", reason="postcondition Sway confirmée") - except (OSError, subprocess.SubprocessError, RestoreExecutionError, AssertionError) as exc: - if isinstance(exc, RestoreExecutionError): - stopped_reason = str(exc) - else: - stopped_reason = f"RESTORE_EXTERNAL_COMMAND_FAILED: {exc}" - status.update(status="failed", reason=stopped_reason) - break - - return { - "actions": statuses, - "applications_launched": applications_launched, - "mutating_commands_executed": mutating_commands, - "status": "failed" if stopped_reason else "success", - "reason": stopped_reason, - "runtime_windows": dict(sorted(runtime_by_source.items())), - } - - -def apply_session( - name: str, - autostart_path: Path | None, - *, - sessions_path: Path, - snapshot_loader: Callable[[str], dict[str, Any]], - live_collector: Callable[[Path | None], dict[str, Any]], - planner_module: Any, - desktop_loader: Callable[[], Sequence[Mapping[str, str | None]]], - resolve_desktop_entry: Callable[..., dict[str, Any]], - runner: Callable[[Sequence[str], float], Any] = _default_runner, - timeout: float = DEFAULT_WINDOW_TIMEOUT_SECONDS, - monotonic: Callable[[], float] = time.monotonic, - sleeper: Callable[[float], None] = time.sleep, -) -> dict[str, Any]: - """Lock, snapshot frais, live frais, plan frais, gate puis exécution contrôlée.""" - report: dict[str, Any] = { - "schema": REPORT_SCHEMA, - "version": REPORT_VERSION, - "source_session": name, - "status": "blocked", - "reason": None, - "snapshot_hash_before": None, - "snapshot_hash_after": None, - "snapshot_unchanged": False, - "plan": None, - "actions": [], - "applications_launched": 0, - "mutating_commands_executed": 0, - "rollback": "not-transactional-stop-on-first-critical-divergence", - } - try: - with restore_lock(sessions_path): - source = snapshot_loader(name) - report["snapshot_hash_before"] = snapshot_digest(source) - live = live_collector(autostart_path) - plan = planner_module.build_restore_plan(source, live) - report["plan"] = copy.deepcopy(plan) - result = execute_fresh_plan( - source, - plan, - collector=lambda: live_collector(autostart_path), - planner_module=planner_module, - desktop_entries=desktop_loader(), - resolve_desktop_entry=resolve_desktop_entry, - runner=runner, - timeout=timeout, - monotonic=monotonic, - sleeper=sleeper, - ) - report.update(result) - after = snapshot_loader(name) - report["snapshot_hash_after"] = snapshot_digest(after) - report["snapshot_unchanged"] = report["snapshot_hash_after"] == report["snapshot_hash_before"] - if not report["snapshot_unchanged"]: - report["status"] = "failed" - report["reason"] = "RESTORE_SNAPSHOT_CHANGED: snapshot source modifié pendant apply" - except RestoreExecutionError as exc: - report["status"] = "blocked" - report["reason"] = str(exc) - if isinstance(report.get("plan"), Mapping): - report["actions"] = [] - for action in report["plan"].get("actions", []): - status = _action_status(action) - finding = _blocking_finding(action) - if finding is not None: - status.update(status="blocked", reason=finding["reason"]) - report["actions"].append(status) - if report["snapshot_hash_before"] is not None: - after = snapshot_loader(name) - report["snapshot_hash_after"] = snapshot_digest(after) - report["snapshot_unchanged"] = report["snapshot_hash_after"] == report["snapshot_hash_before"] - return report diff --git a/quickshell/.config/quickshell/labfy-sway/session/session_restore.py b/quickshell/.config/quickshell/labfy-sway/session/session_restore.py deleted file mode 100644 index f7395154f..000000000 --- a/quickshell/.config/quickshell/labfy-sway/session/session_restore.py +++ /dev/null @@ -1,639 +0,0 @@ -#!/usr/bin/env python3 -"""Construire, sans l'exécuter, un Restore Plan V1 déterministe.""" - -from __future__ import annotations - -from collections import Counter -import copy -from typing import Any, Mapping, Sequence - - -PLAN_SCHEMA = "labfy.sway.restore-plan" -PLAN_VERSION = 1 -SNAPSHOT_SCHEMA = "labfy.sway.session-snapshot" -SNAPSHOT_VERSION = 1 -LAYOUT_ENGINE = "sway-native" - -# CONTRACT: cet ordre est l'interface entre le planner STEP19A et le futur -# exécuteur STEP19B. Une phase tardive ne peut jamais précéder sa dépendance. -PHASES = ( - "match-managed", - "launch", - "wait-match", - "workspace", - "tree-layout", - "floating", - "fullscreen", - "scratchpad", - "focus", -) -PHASE_INDEX = {name: index for index, name in enumerate(PHASES)} -ACTION_TYPES = { - "reuse-window", - "launch-application", - "skip-autostart-managed", - "move-to-workspace", - "restore-floating", - "restore-fullscreen", - "restore-tree-position", - "restore-scratchpad-hidden", - "restore-scratchpad-visible", - "restore-focus", - "manual-required", -} -CONFIDENCE = {"exact", "high", "medium", "ambiguous", "unresolved"} -NON_RESTORABLE_CATEGORIES = {"session-infrastructure", "daemon"} - - -class RestorePlanError(RuntimeError): - """Violation du contrat du planner read-only.""" - - -def _require(condition: bool, message: str) -> None: - if not condition: - raise RestorePlanError(f"restore plan V1 invalide: {message}") - - -def _text(value: Any) -> str | None: - return value if isinstance(value, str) and value else None - - -def _fold(value: Any) -> str | None: - text = _text(value) - return text.casefold() if text else None - - -def _identity(window: Mapping[str, Any]) -> dict[str, str | None]: - restore = window.get("restore_identity") - restore = restore if isinstance(restore, Mapping) else {} - xwayland = window.get("xwayland") - xwayland = xwayland if isinstance(xwayland, Mapping) else {} - return { - "desktop_entry": _text(restore.get("desktop_entry")), - "app_id": _text(window.get("app_id")) or _text(restore.get("app_id")), - "class": _text(xwayland.get("class")) or _text(restore.get("class")), - "instance": _text(xwayland.get("instance")) or _text(restore.get("instance")), - "executable_basename": _text(window.get("executable_basename")), - } - - -def _stable_window_key(window: Mapping[str, Any]) -> tuple[str, ...]: - """Départager sans PID ni con_id, qui ne sont jamais des identités.""" - identity = _identity(window) - return tuple( - _fold(value) or "" - for value in ( - identity["desktop_entry"], - identity["app_id"], - identity["class"], - identity["instance"], - identity["executable_basename"], - window.get("workspace"), - window.get("output"), - window.get("title_hint"), - window.get("snapshot_identity"), - window.get("window_id"), - ) - ) - - -def _candidate(saved: Mapping[str, Any], live: Mapping[str, Any]) -> dict[str, Any] | None: - """Noter une preuve d'identité ; titre/workspace ne sont que des hints.""" - wanted = _identity(saved) - current = _identity(live) - saved_app = _fold(wanted["app_id"]) - live_app = _fold(current["app_id"]) - saved_class = _fold(wanted["class"]) - live_class = _fold(current["class"]) - desktop_equal = bool( - _fold(wanted["desktop_entry"]) - and _fold(wanted["desktop_entry"]) == _fold(current["desktop_entry"]) - ) - app_equal = bool(saved_app and saved_app == live_app) - class_equal = bool(saved_class and saved_class == live_class) - instance_equal = bool( - _fold(wanted["instance"]) - and _fold(wanted["instance"]) == _fold(current["instance"]) - ) - executable_equal = bool( - _fold(wanted["executable_basename"]) - and _fold(wanted["executable_basename"]) == _fold(current["executable_basename"]) - ) - - # Une contradiction app_id forte interdit qu'un simple basename ou titre - # fusionne deux applications différentes. - if saved_app and live_app and saved_app != live_app and not desktop_equal: - return None - if not any((desktop_equal, app_equal, class_equal, instance_equal, executable_equal)): - return None - - matched_by: list[str] = [] - score = 0 - confidence = "medium" - if desktop_equal: - score += 500 - matched_by.append("desktop-entry") - if app_equal: - score += 400 - matched_by.append("app-id") - if class_equal: - score += 350 - matched_by.append("class") - if instance_equal: - score += 250 - matched_by.append("instance") - if executable_equal: - score += 150 - matched_by.append("executable-basename") - - if desktop_equal and (app_equal or class_equal): - confidence = "exact" - elif desktop_equal or app_equal or class_equal: - confidence = "high" - - # Les hints ne peuvent jamais créer un candidat ; ils départagent - # seulement plusieurs fenêtres d'une identité application déjà prouvée. - if saved.get("workspace") == live.get("workspace"): - score += 20 - if bool(saved.get("floating")) == bool(live.get("floating")): - score += 10 - if _fold(saved.get("title_hint")) and _fold(saved.get("title_hint")) == _fold(live.get("title_hint")): - score += 5 - matched_by.append("title-hint-tiebreak") - return {"score": score, "confidence": confidence, "matched_by": matched_by} - - -def match_windows( - saved_windows: Sequence[Mapping[str, Any]], - live_windows: Sequence[Mapping[str, Any]], -) -> tuple[dict[str, dict[str, Any]], list[str]]: - """Produire un matching un-à-un stable ; une vue live n'est jamais réutilisée deux fois.""" - # WHY: une boucle source gloutonne ferait prendre Firefox-B par Firefox-A - # avant que son titre puisse départager les deux vues. - # CONTRACT: les meilleures preuves globales gagnent ; l'ordre snapshot ne - # sert qu'après identité, workspace, floating et title_hint. - edges: list[tuple[int, int, tuple[str, ...], int, dict[str, Any]]] = [] - for saved_index, saved in enumerate(saved_windows): - for live_index, live in enumerate(live_windows): - evidence = _candidate(saved, live) - if evidence is not None: - edges.append( - (-evidence["score"], saved_index, _stable_window_key(live), live_index, evidence) - ) - edges.sort() - used_saved: set[int] = set() - used_live: set[int] = set() - matches: dict[str, dict[str, Any]] = {} - for chosen in edges: - score, saved_index, _stable, live_index, evidence = chosen - if saved_index in used_saved or live_index in used_live: - continue - used_saved.add(saved_index) - used_live.add(live_index) - saved_id = str(saved_windows[saved_index].get("window_id")) - equal_competitors = sum( - 1 for item in edges - if item[0] == score and (item[1] == saved_index or item[3] == live_index) - ) - confidence = "ambiguous" if equal_competitors > 1 else evidence["confidence"] - matches[saved_id] = { - "saved_index": saved_index, - "live_index": live_index, - "confidence": confidence, - "matched_by": evidence["matched_by"], - "score": -score, - } - unmatched = [ - str(saved.get("window_id")) - for index, saved in enumerate(saved_windows) - if index not in used_saved - ] - return matches, unmatched - - -def _container_path(snapshot: Mapping[str, Any], window: Mapping[str, Any]) -> list[dict[str, Any]]: - containers = { - item.get("container_id"): item - for item in snapshot.get("containers", []) - if isinstance(item, Mapping) - } - position = window.get("tree_position") - position = position if isinstance(position, Mapping) else {} - parent = position.get("parent_container_id") - path: list[dict[str, Any]] = [] - seen: set[str] = set() - while isinstance(parent, str) and parent in containers and parent not in seen: - seen.add(parent) - item = containers[parent] - item_position = item.get("tree_position") - item_position = item_position if isinstance(item_position, Mapping) else {} - path.append({ - "layout": item.get("layout"), - "orientation": item.get("orientation"), - "branch": item_position.get("branch"), - "index": item_position.get("index"), - "percent": item.get("percent"), - }) - parent = item.get("parent_container_id") - path.reverse() - return path - - -def _placement(snapshot: Mapping[str, Any], window: Mapping[str, Any]) -> dict[str, Any]: - workspaces = { - item.get("name"): item - for item in snapshot.get("workspaces", []) - if isinstance(item, Mapping) - } - workspace = workspaces.get(window.get("workspace"), {}) - position = window.get("tree_position") - position = position if isinstance(position, Mapping) else {} - return { - "workspace_layout": workspace.get("layout"), - "workspace_orientation": workspace.get("orientation"), - "container_path": _container_path(snapshot, window), - "branch": position.get("branch"), - "index": position.get("index"), - "percent": window.get("percent"), - } - - -def _window_inventory(window: Mapping[str, Any]) -> dict[str, Any]: - runtime = window.get("runtime") - runtime = runtime if isinstance(runtime, Mapping) else {} - classification = window.get("classification") - classification = classification if isinstance(classification, Mapping) else {} - return { - "window_id": window.get("window_id"), - "snapshot_identity": window.get("snapshot_identity"), - "runtime": {"con_id": runtime.get("con_id"), "pid": runtime.get("pid")}, - "workspace": window.get("workspace"), - "output": window.get("output"), - **_identity(window), - "classification": { - "category": classification.get("category"), - "managed_by": classification.get("managed_by"), - }, - "floating": bool(window.get("floating")), - "fullscreen_mode": window.get("fullscreen_mode"), - "scratchpad": copy.deepcopy(window.get("scratchpad")), - "focused": bool(window.get("focused")), - "title_hint": window.get("title_hint"), - } - - -def _validate_input(snapshot: Mapping[str, Any], label: str, *, persistent: bool) -> None: - _require(snapshot.get("schema") == SNAPSHOT_SCHEMA, f"{label}.schema") - _require(snapshot.get("version") == SNAPSHOT_VERSION, f"{label}.version") - compositor = snapshot.get("compositor") - _require(isinstance(compositor, Mapping), f"{label}.compositor") - _require(compositor.get("layout_engine") == LAYOUT_ENGINE, f"{label}.layout_engine") - _require(isinstance(snapshot.get("windows"), list), f"{label}.windows") - _require(isinstance(snapshot.get("outputs"), list), f"{label}.outputs") - _require(isinstance(snapshot.get("workspaces"), list), f"{label}.workspaces") - metadata = snapshot.get("metadata") - _require(isinstance(metadata, Mapping), f"{label}.metadata") - if persistent: - _require(bool(_text(metadata.get("session_name"))), f"{label}.metadata.session_name") - - -def _draft( - key: str, - action: str, - phase: str, - saved: Mapping[str, Any] | None, - *, - depends: Sequence[str] = (), - **payload: Any, -) -> dict[str, Any]: - result = { - "_key": key, - "_depends": list(depends), - "action": action, - "phase": phase, - "source_window": saved.get("window_id") if saved else None, - "snapshot_identity": saved.get("snapshot_identity") if saved else None, - **payload, - } - return result - - -def _finalize_actions(drafts: list[dict[str, Any]]) -> list[dict[str, Any]]: - drafts.sort(key=lambda item: (PHASE_INDEX[item["phase"]], item.pop("_order"), item["action"], item["_key"])) - key_to_id = {item["_key"]: f"a{index:03d}" for index, item in enumerate(drafts, 1)} - actions: list[dict[str, Any]] = [] - for item in drafts: - result = {key: value for key, value in item.items() if not key.startswith("_")} - result["action_id"] = key_to_id[item["_key"]] - result["depends_on"] = [key_to_id[key] for key in item["_depends"] if key in key_to_id] - actions.append(result) - return actions - - -def validate_restore_plan(plan: Mapping[str, Any]) -> None: - """Valider l'ordre et les dépendances sans aucun effet externe.""" - _require(plan.get("schema") == PLAN_SCHEMA, "schema") - _require(plan.get("version") == PLAN_VERSION, "version") - _require(plan.get("read_only") is True, "read_only") - _require(plan.get("execution_supported") is False, "execution_supported") - actions = plan.get("actions") - _require(isinstance(actions, list), "actions") - known: set[str] = set() - last_phase = -1 - focus_seen = False - for index, action in enumerate(actions): - _require(isinstance(action, Mapping), f"actions[{index}]") - action_id = action.get("action_id") - _require(isinstance(action_id, str) and action_id not in known, "action_id") - _require(action.get("action") in ACTION_TYPES, "action type") - phase = action.get("phase") - _require(phase in PHASE_INDEX and PHASE_INDEX[phase] >= last_phase, "ordre phases") - last_phase = PHASE_INDEX[phase] - dependencies = action.get("depends_on") - _require(isinstance(dependencies, list) and all(item in known for item in dependencies), "depends_on") - if focus_seen: - _require(action.get("action") == "restore-focus", "focus doit rester dernier") - if action.get("action") == "restore-focus": - focus_seen = True - confidence = action.get("confidence") - if confidence is not None: - _require(confidence in CONFIDENCE, "confidence") - known.add(action_id) - - -def build_restore_plan(source: Mapping[str, Any], live: Mapping[str, Any]) -> dict[str, Any]: - """Comparer deux snapshots validés et retourner uniquement des intentions.""" - _validate_input(source, "source", persistent=True) - _validate_input(live, "live", persistent=False) - source_windows = [item for item in source["windows"] if isinstance(item, Mapping)] - live_windows = [item for item in live["windows"] if isinstance(item, Mapping)] - matches, unmatched = match_windows(source_windows, live_windows) - live_outputs = { - item.get("name") - for item in live["outputs"] - if isinstance(item, Mapping) and item.get("active") is True - } - live_workspaces = { - item.get("name") - for item in live["workspaces"] - if isinstance(item, Mapping) - } - live_focused = live.get("focus", {}).get("window_id") if isinstance(live.get("focus"), Mapping) else None - drafts: list[dict[str, Any]] = [] - base_keys: dict[str, str] = {} - matched_live_by_saved: dict[str, Mapping[str, Any]] = {} - - def add(item: dict[str, Any], order: int) -> str: - item["_order"] = order - drafts.append(item) - return item["_key"] - - for order, saved in enumerate(source_windows): - saved_id = str(saved.get("window_id")) - desired_output = saved.get("output") - desired_workspace = saved.get("workspace") - category = saved.get("classification", {}).get("category") if isinstance(saved.get("classification"), Mapping) else "unknown" - resolution = saved.get("restore_identity") - resolution = resolution if isinstance(resolution, Mapping) else {} - match = matches.get(saved_id) - live_window: Mapping[str, Any] | None = None - - if match is not None: - live_window = live_windows[match["live_index"]] - matched_live_by_saved[saved_id] = live_window - key = f"reuse:{saved_id}" - base_keys[saved_id] = add(_draft( - key, - "reuse-window", - "match-managed", - saved, - confidence=match["confidence"], - matched_by=match["matched_by"], - live_window=_window_inventory(live_window), - ), order) - elif desired_output is not None and desired_output not in live_outputs: - key = f"manual-output:{saved_id}" - base_keys[saved_id] = add(_draft( - key, - "manual-required", - "match-managed", - saved, - confidence="unresolved", - reason="missing-output", - desired_output=desired_output, - policy="deferred-no-fallback", - ), order) - continue - elif category == "autostart-managed-application": - key = f"managed:{saved_id}" - base_keys[saved_id] = add(_draft( - key, - "skip-autostart-managed", - "match-managed", - saved, - confidence="unresolved", - reason="launch-owned-by-sway-autostart", - managed_by=saved.get("classification", {}).get("managed_by"), - ), order) - continue - elif category in NON_RESTORABLE_CATEGORIES: - key = f"manual-category:{saved_id}" - base_keys[saved_id] = add(_draft( - key, - "manual-required", - "match-managed", - saved, - confidence="unresolved", - reason="non-restorable-category", - category=category, - ), order) - continue - elif category == "user-application" and resolution.get("confidence") == "exact" and _text(resolution.get("desktop_entry")): - key = f"launch:{saved_id}" - desktop_entry = resolution["desktop_entry"] - base_keys[saved_id] = add(_draft( - key, - "launch-application", - "launch", - saved, - confidence="exact", - desktop_entry=desktop_entry, - launch={ - "backend": "uwsm-app-desktop-entry", - "argv": ["uwsm", "app", "--", desktop_entry], - "execute_in_step19a": False, - }, - limitations=["application-internal-state-not-restored"], - ), order) - else: - key = f"manual-identity:{saved_id}" - base_keys[saved_id] = add(_draft( - key, - "manual-required", - "match-managed", - saved, - confidence="unresolved", - reason="no-exact-launch-identity", - restore_identity=copy.deepcopy(resolution), - ), order) - continue - - base = base_keys[saved_id] - current_workspace = live_window.get("workspace") if live_window else None - current_output = live_window.get("output") if live_window else None - saved_scratchpad = saved.get("scratchpad") - saved_scratchpad = saved_scratchpad if isinstance(saved_scratchpad, Mapping) else {} - scratchpad_target = saved_scratchpad.get("member") is True - if desired_output is not None and desired_output not in live_outputs: - add(_draft( - f"manual-output:{saved_id}", "manual-required", "workspace", saved, - depends=(base,), confidence="unresolved", reason="missing-output", - desired_output=desired_output, policy="deferred-no-fallback", - ), order) - continue - if ( - not scratchpad_target - and (live_window is None or current_workspace != desired_workspace or current_output != desired_output) - ): - add(_draft( - f"workspace:{saved_id}", "move-to-workspace", "workspace", saved, - depends=(base,), desired_workspace=desired_workspace, desired_output=desired_output, - workspace_exists=desired_workspace in live_workspaces, - current_workspace=current_workspace, current_output=current_output, - ), order) - - desired_placement = _placement(source, saved) - current_placement = _placement(live, live_window) if live_window else None - # WHY: une fenêtre absente n'a aucune position live à comparer. Émettre - # immédiatement restore-tree-position bloquerait tout lancement STEP19B, - # même pour une simple racine de workspace. STEP19C pourra replanifier - # la topologie une fois la nouvelle fenêtre réellement observable. - # CONTRACT: le planner ne demande une reconstruction d'arbre que pour - # une fenêtre déjà matchée dont la structure diffère effectivement. - # INVARIANT: l'absence n'est jamais assimilée à une structure divergente. - if ( - live_window is not None - and saved_scratchpad.get("member") is not True - and current_placement != desired_placement - ): - add(_draft( - f"tree:{saved_id}", "restore-tree-position", "tree-layout", saved, - depends=(base,), capability="partially-supported", - desired=desired_placement, current=current_placement, - reason="sway-native-tree-diff", - ), order) - - if live_window is None: - current_floating = False - current_fullscreen = 0 - current_scratchpad: Mapping[str, Any] = {} - else: - current_floating = bool(live_window.get("floating")) - current_fullscreen = int(live_window.get("fullscreen_mode") or 0) - raw_scratchpad = live_window.get("scratchpad") - current_scratchpad = raw_scratchpad if isinstance(raw_scratchpad, Mapping) else {} - if current_floating != bool(saved.get("floating")): - add(_draft( - f"floating:{saved_id}", "restore-floating", "floating", saved, - depends=(base,), desired_floating=bool(saved.get("floating")), - current_floating=current_floating, - ), order) - desired_fullscreen = int(saved.get("fullscreen_mode") or 0) - if current_fullscreen != desired_fullscreen: - add(_draft( - f"fullscreen:{saved_id}", "restore-fullscreen", "fullscreen", saved, - depends=(base,), desired_fullscreen_mode=desired_fullscreen, - current_fullscreen_mode=current_fullscreen, - ), order) - desired_scratchpad = saved.get("scratchpad") - desired_scratchpad = desired_scratchpad if isinstance(desired_scratchpad, Mapping) else {} - if desired_scratchpad.get("member") is True and dict(current_scratchpad) != dict(desired_scratchpad): - visibility = desired_scratchpad.get("visibility") - action = "restore-scratchpad-hidden" if visibility == "hidden" else "restore-scratchpad-visible" - add(_draft( - f"scratch:{saved_id}", action, "scratchpad", saved, - depends=(base,), desired=copy.deepcopy(desired_scratchpad), - current=copy.deepcopy(current_scratchpad), - ), order) - elif desired_scratchpad.get("member") is not True and current_scratchpad.get("member") is True: - add(_draft( - f"manual-scratch:{saved_id}", "manual-required", "scratchpad", saved, - depends=(base,), confidence="unresolved", reason="live-window-unexpected-scratchpad", - ), order) - - source_focus = source.get("focus") - source_focus = source_focus if isinstance(source_focus, Mapping) else {} - focus_saved_id = source_focus.get("window_id") - if isinstance(focus_saved_id, str) and focus_saved_id in base_keys: - focus_live = matched_live_by_saved.get(focus_saved_id) - focus_live_id = focus_live.get("window_id") if focus_live else None - mutations = [ - item["_key"] for item in drafts - if item["action"] not in {"reuse-window", "skip-autostart-managed", "manual-required"} - ] - if focus_live_id != live_focused or mutations: - dependencies = tuple( - item["_key"] for item in drafts - if item["action"] != "manual-required" - ) - add(_draft( - f"focus:{focus_saved_id}", "restore-focus", "focus", - next((item for item in source_windows if item.get("window_id") == focus_saved_id), None), - depends=dependencies, desired_focus_window=focus_saved_id, - current_live_focus=live_focused, - ), len(source_windows)) - - actions = _finalize_actions(drafts) - plan = { - "schema": PLAN_SCHEMA, - "version": PLAN_VERSION, - "read_only": True, - "execution_supported": False, - "source_session": source["metadata"]["session_name"], - "source": { - "schema": source["schema"], - "version": source["version"], - "captured_at": source["metadata"].get("captured_at"), - "layout_engine": source["compositor"]["layout_engine"], - }, - "live": { - "captured_at": live["metadata"].get("captured_at"), - "outputs": [item.get("name") for item in live["outputs"] if isinstance(item, Mapping) and item.get("active")], - "workspaces": [item.get("name") for item in live["workspaces"] if isinstance(item, Mapping)], - "windows": [_window_inventory(item) for item in live_windows], - }, - "policy": { - "runtime_identity_fields": [], - "runtime_observation_fields": ["runtime.pid", "runtime.con_id"], - "missing_output": "deferred-no-fallback", - "autostart_owner": "sway-autostart", - "launch_backend_step19b": "uwsm-app-desktop-entry", - "phase_order": list(PHASES), - "spatial_canvas": "out-of-scope-step20", - }, - "matches": [ - { - "source_window": saved_id, - "live_window": live_windows[data["live_index"]].get("window_id"), - "confidence": data["confidence"], - "matched_by": data["matched_by"], - } - for saved_id, data in sorted(matches.items(), key=lambda item: item[1]["saved_index"]) - ], - "unmatched_source_windows": unmatched, - "actions": actions, - "diagnostics": { - "action_counts": dict(sorted(Counter(item["action"] for item in actions).items())), - "match_confidence_counts": dict(sorted(Counter(item["confidence"] for item in plan_matches(matches)).items())), - "mutating_commands_executed": 0, - "applications_launched": 0, - "snapshots_written": 0, - }, - } - validate_restore_plan(plan) - return plan - - -def plan_matches(matches: Mapping[str, Mapping[str, Any]]) -> list[dict[str, Any]]: - """Projection minuscule utilisée pour les diagnostics déterministes.""" - return [{"confidence": item["confidence"]} for item in matches.values()] diff --git a/quickshell/.config/quickshell/labfy-sway/session/session_snapshot.py b/quickshell/.config/quickshell/labfy-sway/session/session_snapshot.py deleted file mode 100755 index 424952998..000000000 --- a/quickshell/.config/quickshell/labfy-sway/session/session_snapshot.py +++ /dev/null @@ -1,1292 +0,0 @@ -#!/usr/bin/env python3 -"""Capture en lecture seule un Session Snapshot V1 normalisé de Sway.""" - -from __future__ import annotations - -import argparse -import contextlib -import configparser -import datetime as dt -import errno -import fcntl -import importlib.util -import json -import os -from pathlib import Path -import re -import secrets -import shlex -import stat -import subprocess -import sys -import time -from typing import Any, Callable, Iterator, Mapping, Sequence - - -SCHEMA = "labfy.sway.session-snapshot" -VERSION = 1 -LAYOUT_ENGINE = "sway-native" -# WHY: le snapshot contient seulement une reconstruction Sway native ; STEP20 -# pourra ajouter un autre moteur sans redéfinir les coordonnées inexistantes. -# CONTRACT: schema/version/layout_engine sont les discriminants persistants V1. -# INVARIANT: aucune valeur runtime (PID/con_id) n'est une identité durable. -LAYOUTS = {"none", "splith", "splitv", "stacked", "tabbed", "output", "dockarea"} -CONFIDENCE = {"exact", "heuristic", "unresolved"} -RECT_KEYS = ("x", "y", "width", "height") -SESSION_NAME = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") -SNAPSHOT_SUFFIX = ".json" -LOCK_FILENAME = ".sessions.lock" -MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024 -SAVE_ATTEMPTS = 3 -SAVE_RETRY_DELAY_SECONDS = 0.05 -DAEMON_NAMES = { - "autotiling", - "inactive-windows-transparency.py", - "wlsunset", -} -INFRASTRUCTURE_NAMES = { - "polkit-gnome-authentication-agent-1", - "qs", - "quickshell", -} - - -class SnapshotError(RuntimeError): - """Erreur de collecte ou violation du contrat V1.""" - - -def utc_now() -> str: - """Retourner un instant UTC stable et sérialisable à la milliseconde.""" - return dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") - - -def normalize_session_name(name: str) -> str: - """Valider la clé logique V1 sans jamais la transformer en chemin libre.""" - if not isinstance(name, str) or SESSION_NAME.fullmatch(name) is None: - raise SnapshotError( - "nom de session invalide: utiliser 1 à 64 caractères parmi A-Z, a-z, 0-9, - et _" - ) - return name - - -def _snapshot_filename(name: str) -> str: - return f"{normalize_session_name(name)}{SNAPSHOT_SUFFIX}" - - -def _ensure_child_directory(parent_fd: int, name: str) -> int: - """Créer puis ouvrir un répertoire géré sans suivre de lien symbolique.""" - try: - os.mkdir(name, mode=0o700, dir_fd=parent_fd) - except FileExistsError: - pass - flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - try: - fd = os.open(name, flags, dir_fd=parent_fd) - except OSError as exc: - raise SnapshotError(f"répertoire géré non sûr: {name}: {exc}") from exc - try: - info = os.fstat(fd) - if not stat.S_ISDIR(info.st_mode): - raise SnapshotError(f"répertoire géré non sûr: {name}") - # CONTRACT: une umask permissive ou un ancien mode ne doit jamais - # exposer les titres privés contenus dans les snapshots. - os.fchmod(fd, 0o700) - if stat.S_IMODE(os.fstat(fd).st_mode) != 0o700: - raise SnapshotError(f"permissions répertoire invalides: {name}") - return fd - except BaseException: - os.close(fd) - raise - - -def sessions_directory( - environment: Mapping[str, str] | None = None, - home: Path | None = None, -) -> Path: - """Résoudre et créer la cible XDG privée des sessions persistantes.""" - env = os.environ if environment is None else environment - home_path = Path.home() if home is None else home - configured = env.get("XDG_STATE_HOME") - root = Path(configured) if configured else home_path / ".local/state" - if not root.is_absolute(): - raise SnapshotError("XDG_STATE_HOME doit être un chemin absolu") - try: - root.mkdir(mode=0o700, parents=True, exist_ok=True) - root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) - except OSError as exc: - raise SnapshotError(f"impossible d'ouvrir XDG state: {exc}") from exc - try: - app_fd = _ensure_child_directory(root_fd, "labfy-sway") - try: - sessions_fd = _ensure_child_directory(app_fd, "sessions") - os.close(sessions_fd) - finally: - os.close(app_fd) - finally: - os.close(root_fd) - return root / "labfy-sway/sessions" - - -def _open_sessions_directory(path: Path) -> int: - flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - try: - fd = os.open(path, flags) - except OSError as exc: - raise SnapshotError(f"répertoire sessions non sûr: {exc}") from exc - if stat.S_IMODE(os.fstat(fd).st_mode) != 0o700: - os.close(fd) - raise SnapshotError("permissions du répertoire sessions différentes de 0700") - return fd - - -@contextlib.contextmanager -def _sessions_lock(directory_fd: int, *, exclusive: bool) -> Iterator[None]: - """Sérialiser publications/suppressions et stabiliser lectures multi-fichiers.""" - flags = os.O_RDWR | os.O_CREAT | os.O_CLOEXEC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - try: - lock_fd = os.open(LOCK_FILENAME, flags, 0o600, dir_fd=directory_fd) - except OSError as exc: - raise SnapshotError(f"verrou sessions non sûr: {exc}") from exc - try: - info = os.fstat(lock_fd) - if not stat.S_ISREG(info.st_mode): - raise SnapshotError("verrou sessions non régulier") - os.fchmod(lock_fd, 0o600) - fcntl.flock(lock_fd, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) - yield - finally: - try: - fcntl.flock(lock_fd, fcntl.LOCK_UN) - finally: - os.close(lock_fd) - - -def _ipc(message_type: str) -> Any: - """CONTRACT: interroger Sway sans commande mutatrice ni shell intermédiaire.""" - try: - completed = subprocess.run( - ["swaymsg", "-t", message_type, "-r"], - check=True, - capture_output=True, - text=True, - timeout=5, - ) - return json.loads(completed.stdout) - except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as exc: - raise SnapshotError(f"échec de swaymsg -t {message_type}: {exc}") from exc - - -def _rect(value: Any) -> dict[str, int]: - source = value if isinstance(value, Mapping) else {} - return {key: int(source.get(key, 0)) for key in RECT_KEYS} - - -def _nullable_number(value: Any) -> float | None: - if value is None: - return None - return float(value) - - -def _basename_from_pid(pid: Any) -> str | None: - """SECURITY: lire uniquement le lien exe et ne jamais persister son chemin.""" - if not isinstance(pid, int) or pid <= 0: - return None - try: - return Path(os.readlink(f"/proc/{pid}/exe")).name or None - except (OSError, ValueError): - return None - - -def _desktop_paths() -> list[Path]: - """Ordre déterministe ; les entrées utilisateur précèdent les entrées système.""" - data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) - data_dirs = [Path(item) for item in os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":") if item] - roots = [data_home / "applications", *(item / "applications" for item in data_dirs)] - seen: set[str] = set() - result: list[Path] = [] - for root in roots: - key = str(root) - if key not in seen: - seen.add(key) - result.append(root) - return result - - -def load_desktop_entries(roots: Sequence[Path] | None = None) -> list[dict[str, str | None]]: - """Indexer les champs publics nécessaires, sans conserver Exec ni chemins locaux.""" - entries: list[dict[str, str | None]] = [] - claimed_ids: set[str] = set() - for root in roots or _desktop_paths(): - if not root.is_dir(): - continue - for path in sorted(root.glob("*.desktop"), key=lambda item: item.name.casefold()): - desktop_id = path.name - if desktop_id.casefold() in claimed_ids: - continue - parser = configparser.ConfigParser(interpolation=None, strict=False) - try: - parser.read(path, encoding="utf-8") - section = parser["Desktop Entry"] - except (OSError, UnicodeError, configparser.Error, KeyError): - continue - if section.get("Hidden", "false").casefold() == "true": - continue - claimed_ids.add(desktop_id.casefold()) - entries.append({ - "desktop_entry": desktop_id, - "stem": path.stem, - "startup_wm_class": section.get("StartupWMClass"), - "name": section.get("Name"), - }) - return entries - - -def _identity_candidates(app_id: str | None, window_properties: Mapping[str, Any]) -> list[str]: - values = [app_id, window_properties.get("class"), window_properties.get("instance")] - return [value for value in values if isinstance(value, str) and value.strip()] - - -def resolve_desktop_entry( - app_id: str | None, - window_properties: Mapping[str, Any], - executable_basename: str | None, - entries: Sequence[Mapping[str, str | None]], -) -> dict[str, Any]: - """Appliquer une chaîne de preuves explicite ; ne jamais inventer une entrée.""" - candidates = _identity_candidates(app_id, window_properties) - lowered = {candidate.casefold() for candidate in candidates} - for entry in entries: - if str(entry.get("stem") or "").casefold() in lowered: - return {"desktop_entry": entry["desktop_entry"], "confidence": "exact", "matched_by": "desktop-id"} - for entry in entries: - startup = entry.get("startup_wm_class") - if startup and startup.casefold() in lowered: - return {"desktop_entry": entry["desktop_entry"], "confidence": "exact", "matched_by": "StartupWMClass"} - - # Une heuristique n'est admise que si un basename normalisé désigne une entrée unique. - if executable_basename: - normalized = re.sub(r"[^a-z0-9]+", "", executable_basename.casefold()) - matches = [ - entry for entry in entries - if re.sub(r"[^a-z0-9]+", "", str(entry.get("stem") or "").casefold()) == normalized - ] - if len(matches) == 1: - return {"desktop_entry": matches[0]["desktop_entry"], "confidence": "heuristic", "matched_by": "executable-basename"} - return {"desktop_entry": None, "confidence": "unresolved", "matched_by": None} - - -def parse_sway_autostart(path: Path | None) -> set[str]: - """Extraire seulement le programme direct des directives exec de l'autostart.""" - managed: set[str] = set() - if path is None or not path.is_file(): - return managed - try: - lines = path.read_text(encoding="utf-8").splitlines() - except (OSError, UnicodeError): - return managed - for raw in lines: - line = raw.strip() - if not line.startswith("exec "): - continue - try: - words = shlex.split(line[5:], comments=True) - except ValueError: - continue - while words and words[0].startswith("--"): - words.pop(0) - if not words: - continue - program = Path(words[0]).name - if program in {"env", "sh", "bash", "zsh", "python", "python3"}: - if program in {"python", "python3"} and len(words) > 1: - program = Path(words[1]).name - else: - continue - managed.add(program) - return managed - - -def _application_classification( - app_id: str | None, - executable_basename: str | None, - managed: set[str], -) -> dict[str, str | None]: - names = {name for name in (app_id, executable_basename) if name} - if names & DAEMON_NAMES: - return {"category": "daemon", "managed_by": None} - if names & INFRASTRUCTURE_NAMES: - return {"category": "session-infrastructure", "managed_by": None} - if names & managed: - return {"category": "autostart-managed-application", "managed_by": "sway-autostart"} - if names: - return {"category": "user-application", "managed_by": None} - return {"category": "unknown", "managed_by": None} - - -class Normalizer: - """Transformer l'arbre runtime en relations V1 stables dans un snapshot.""" - - def __init__(self, desktop_entries: Sequence[Mapping[str, str | None]], managed: set[str]): - self.desktop_entries = desktop_entries - self.managed = managed - self.containers: list[dict[str, Any]] = [] - self.windows: list[dict[str, Any]] = [] - self._container_seq = 0 - self._window_seq = 0 - self._app_ordinals: dict[str, int] = {} - self.focused_container: str | None = None - self.focused_window: str | None = None - - def _window( - self, - node: Mapping[str, Any], - workspace: str | None, - output: str | None, - parent_id: str | None, - branch: str, - index: int, - hidden_scratchpad: bool, - ) -> dict[str, str]: - self._window_seq += 1 - window_id = f"w{self._window_seq}" - app_id = node.get("app_id") if isinstance(node.get("app_id"), str) else None - props_source = node.get("window_properties") - props = props_source if isinstance(props_source, Mapping) else {} - xclass = props.get("class") if isinstance(props.get("class"), str) else None - instance = props.get("instance") if isinstance(props.get("instance"), str) else None - pid = node.get("pid") if isinstance(node.get("pid"), int) else None - executable = _basename_from_pid(pid) - resolution = resolve_desktop_entry(app_id, props, executable, self.desktop_entries) - identity_key = app_id or xclass or instance or executable or "unknown" - ordinal = self._app_ordinals.get(identity_key, 0) + 1 - self._app_ordinals[identity_key] = ordinal - scratch_state = node.get("scratchpad_state") - scratch_member = hidden_scratchpad or scratch_state not in (None, "none") - classification = _application_classification(app_id, executable, self.managed) - floating_raw = node.get("floating") - floating = branch == "floating" or floating_raw in {"auto_on", "user_on"} - window = { - "window_id": window_id, - "snapshot_identity": f"{identity_key}#{ordinal}", - "runtime": {"con_id": node.get("id"), "pid": pid}, - "workspace": workspace, - "output": output, - "app_id": app_id, - "xwayland": {"class": xclass, "instance": instance}, - "executable_basename": executable, - "title_hint": node.get("name") if isinstance(node.get("name"), str) else None, - "restore_identity": { - "app_id": app_id, - "class": xclass, - "instance": instance, - **resolution, - }, - "restore_adapter": "terminal" if app_id == "kitty" or xclass == "kitty" else "generic-desktop" if resolution["desktop_entry"] else "unknown", - "classification": classification, - "focused": bool(node.get("focused", False)), - "urgent": bool(node.get("urgent", False)), - "floating": floating, - "fullscreen_mode": int(node.get("fullscreen_mode") or 0), - "rect": _rect(node.get("rect")), - "percent": _nullable_number(node.get("percent")), - "marks": [mark for mark in node.get("marks", []) if isinstance(mark, str)], - "scratchpad": { - "member": scratch_member, - "visibility": "hidden" if hidden_scratchpad else "visible" if scratch_member else "not-applicable", - "state": scratch_state if isinstance(scratch_state, str) else None, - }, - "tree_position": {"parent_container_id": parent_id, "branch": branch, "index": index}, - } - self.windows.append(window) - if window["focused"]: - self.focused_window = window_id - return {"kind": "window", "id": window_id} - - def visit( - self, - node: Mapping[str, Any], - workspace: str | None, - output: str | None, - parent_id: str | None, - branch: str, - index: int, - hidden_scratchpad: bool = False, - ) -> dict[str, str]: - # WHY: les branches séparées portent une sémantique que perdrait une - # liste plate de fenêtres. - # CONTRACT: l'ordre de nodes et floating_nodes est conservé tel quel. - # INVARIANT: chaque référence produite cible exactement un objet V1. - nodes = node.get("nodes") if isinstance(node.get("nodes"), list) else [] - floating_nodes = node.get("floating_nodes") if isinstance(node.get("floating_nodes"), list) else [] - has_window_identity = any(node.get(key) is not None for key in ("app_id", "window", "window_properties", "pid")) - if not nodes and not floating_nodes and has_window_identity: - return self._window(node, workspace, output, parent_id, branch, index, hidden_scratchpad) - - self._container_seq += 1 - container_id = f"c{self._container_seq}" - container: dict[str, Any] = { - "container_id": container_id, - "runtime_con_id": node.get("id"), - "workspace": workspace, - "output": output, - "parent_container_id": parent_id, - "tree_position": {"branch": branch, "index": index}, - "layout": node.get("layout") if node.get("layout") in LAYOUTS else "none", - "orientation": node.get("orientation") if isinstance(node.get("orientation"), str) else "none", - "percent": _nullable_number(node.get("percent")), - "rect": _rect(node.get("rect")), - "focused": bool(node.get("focused", False)), - "urgent": bool(node.get("urgent", False)), - "marks": [mark for mark in node.get("marks", []) if isinstance(mark, str)], - "children": [], - "floating_children": [], - } - self.containers.append(container) - if container["focused"]: - self.focused_container = container_id - container["children"] = [ - self.visit(child, workspace, output, container_id, "tiling", child_index, hidden_scratchpad) - for child_index, child in enumerate(nodes) - if isinstance(child, Mapping) - ] - container["floating_children"] = [ - self.visit(child, workspace, output, container_id, "floating", child_index, hidden_scratchpad) - for child_index, child in enumerate(floating_nodes) - if isinstance(child, Mapping) - ] - return {"kind": "container", "id": container_id} - - -def _find_tree_output(tree: Mapping[str, Any], name: str) -> Mapping[str, Any] | None: - for node in tree.get("nodes", []): - if isinstance(node, Mapping) and node.get("type") == "output" and node.get("name") == name: - return node - return None - - -def _find_workspace(output_node: Mapping[str, Any], name: str) -> Mapping[str, Any] | None: - for node in output_node.get("nodes", []): - if isinstance(node, Mapping) and node.get("type") == "workspace" and node.get("name") == name: - return node - return None - - -def _mode(output: Mapping[str, Any]) -> dict[str, int] | None: - current = output.get("current_mode") - if not isinstance(current, Mapping): - return None - return {"width": int(current.get("width", 0)), "height": int(current.get("height", 0)), "refresh_millihz": int(current.get("refresh", 0))} - - -def build_snapshot( - version: Mapping[str, Any], - outputs_raw: Sequence[Mapping[str, Any]], - workspaces_raw: Sequence[Mapping[str, Any]], - tree: Mapping[str, Any], - *, - captured_at: str | None = None, - environment: Mapping[str, str] | None = None, - desktop_entries: Sequence[Mapping[str, str | None]] = (), - managed_autostart: set[str] | None = None, -) -> dict[str, Any]: - normalizer = Normalizer(desktop_entries, managed_autostart or set()) - outputs: list[dict[str, Any]] = [] - workspaces: list[dict[str, Any]] = [] - workspace_roots: dict[str, dict[str, list[dict[str, str]]]] = {} - - for output in outputs_raw: - outputs.append({ - "name": output.get("name"), - "active": bool(output.get("active", False)), - "focused": bool(output.get("focused", False)), - "rect": _rect(output.get("rect")), - "scale": float(output.get("scale", 1.0)), - "transform": output.get("transform") if isinstance(output.get("transform"), str) else "normal", - "current_mode": _mode(output), - }) - - for order, workspace in enumerate(workspaces_raw): - name = str(workspace.get("name")) - output_name = str(workspace.get("output")) - tree_output = _find_tree_output(tree, output_name) - tree_workspace = _find_workspace(tree_output, name) if tree_output else None - tiling: list[dict[str, str]] = [] - floating: list[dict[str, str]] = [] - if tree_workspace: - for index, node in enumerate(tree_workspace.get("nodes", [])): - if isinstance(node, Mapping): - tiling.append(normalizer.visit(node, name, output_name, None, "tiling", index)) - for index, node in enumerate(tree_workspace.get("floating_nodes", [])): - if isinstance(node, Mapping): - floating.append(normalizer.visit(node, name, output_name, None, "floating", index)) - workspace_roots[name] = {"tiling": tiling, "floating": floating} - workspaces.append({ - "name": name, - "num": int(workspace.get("num", -1)), - "output": output_name, - "order": order, - "focused": bool(workspace.get("focused", False)), - "visible": bool(workspace.get("visible", False)), - "urgent": bool(workspace.get("urgent", False)), - "rect": _rect(workspace.get("rect")), - "layout": tree_workspace.get("layout", workspace.get("layout", "none")) if tree_workspace else workspace.get("layout", "none"), - "orientation": tree_workspace.get("orientation", workspace.get("orientation", "none")) if tree_workspace else workspace.get("orientation", "none"), - "roots": workspace_roots[name], - }) - - # WHY: __i3_scratch est une racine technique, pas un workspace restaurable. - # CONTRACT: ses vues restent représentées et ne reçoivent pas un faux output. - # INVARIANT: aucune observation ne déplace une vue vers ou depuis le scratchpad. - scratch_output = _find_tree_output(tree, "__i3") - scratch_workspace = _find_workspace(scratch_output, "__i3_scratch") if scratch_output else None - scratch_roots: list[dict[str, str]] = [] - if scratch_workspace: - scratch_nodes = [*scratch_workspace.get("nodes", []), *scratch_workspace.get("floating_nodes", [])] - for index, node in enumerate(scratch_nodes): - if isinstance(node, Mapping): - scratch_roots.append(normalizer.visit(node, None, None, None, "scratchpad", index, True)) - - env = environment or {} - captured = captured_at or utc_now() - identity_counts = {key: 0 for key in CONFIDENCE} - for window in normalizer.windows: - identity_counts[window["restore_identity"]["confidence"]] += 1 - focused_output = next((item["name"] for item in outputs if item["focused"]), None) - focused_workspace = next((item["name"] for item in workspaces if item["focused"]), None) - snapshot = { - "schema": SCHEMA, - "version": VERSION, - "metadata": { - "captured_at": captured, - "session_type": env.get("XDG_SESSION_TYPE"), - "desktop": env.get("XDG_CURRENT_DESKTOP") or env.get("DESKTOP_SESSION"), - }, - "compositor": { - "variant": version.get("variant"), - "version": version.get("human_readable"), - "sway_version": version.get("sway_original_version"), - "layout_engine": LAYOUT_ENGINE, - }, - "outputs": outputs, - "workspaces": workspaces, - "containers": normalizer.containers, - "windows": normalizer.windows, - "focus": { - "output": focused_output, - "workspace": focused_workspace, - "container_id": normalizer.focused_container, - "window_id": normalizer.focused_window, - }, - "restore_hints": { - "output_matching": "connector-name-preferred-with-future-fallback", - "missing_output_policy": "deferred", - "scratchpad_roots": scratch_roots, - }, - "diagnostics": { - "identity_counts": identity_counts, - "volatile_fields": [ - "metadata.captured_at", - "diagnostics.collection_duration_ms", - "windows[].title_hint", - ], - "source": ["get_version", "get_outputs", "get_workspaces", "get_tree"], - }, - } - validate_snapshot(snapshot) - return snapshot - - -def _require(condition: bool, message: str) -> None: - if not condition: - raise SnapshotError(f"snapshot V1 invalide: {message}") - - -def validate_snapshot(snapshot: Mapping[str, Any]) -> None: - """Valider types, bornes et toutes les relations entre collections V1.""" - # WHY: STEP18B ne devra jamais publier un graphe partiel ou incohérent. - # CONTRACT: toutes les références sont internes au document validé. - # INVARIANT: la validation est pure et n'interroge pas la session. - _require(snapshot.get("schema") == SCHEMA, "schema") - _require(snapshot.get("version") == VERSION, "version") - for key in ("metadata", "compositor", "focus", "restore_hints", "diagnostics"): - _require(isinstance(snapshot.get(key), Mapping), key) - for key in ("outputs", "workspaces", "containers", "windows"): - _require(isinstance(snapshot.get(key), list), key) - - metadata = snapshot["metadata"] - _require(all(key in metadata for key in ("captured_at", "session_type", "desktop")), "metadata champs") - _require(isinstance(metadata["captured_at"], str) and bool(metadata["captured_at"]), "metadata.captured_at") - _require(metadata["session_type"] is None or isinstance(metadata["session_type"], str), "metadata.session_type") - _require(metadata["desktop"] is None or isinstance(metadata["desktop"], str), "metadata.desktop") - compositor = snapshot["compositor"] - _require(all(key in compositor for key in ("variant", "version", "sway_version", "layout_engine")), "compositor champs") - _require(compositor["layout_engine"] == LAYOUT_ENGINE, "compositor.layout_engine") - _require(all(compositor[key] is None or isinstance(compositor[key], str) for key in ("variant", "version", "sway_version")), "compositor types") - - output_names = [item.get("name") for item in snapshot["outputs"]] - workspace_names = [item.get("name") for item in snapshot["workspaces"]] - container_ids = [item.get("container_id") for item in snapshot["containers"]] - window_ids = [item.get("window_id") for item in snapshot["windows"]] - _require(all(isinstance(item, str) and item for item in output_names), "output.name") - _require(len(output_names) == len(set(output_names)), "output.name dupliqué") - _require(len(workspace_names) == len(set(workspace_names)), "workspace.name dupliqué") - _require(len(container_ids) == len(set(container_ids)), "container_id dupliqué") - _require(len(window_ids) == len(set(window_ids)), "window_id dupliqué") - _require([item.get("order") for item in snapshot["workspaces"]] == list(range(len(workspace_names))), "workspace.order") - all_refs = set(container_ids) | set(window_ids) - container_by_id = {item["container_id"]: item for item in snapshot["containers"]} - window_by_id = {item["window_id"]: item for item in snapshot["windows"]} - object_by_id = {**container_by_id, **window_by_id} - - def validate_rect(value: Any, field: str) -> None: - _require(isinstance(value, Mapping), field) - _require(set(value) == set(RECT_KEYS), f"{field} clés") - _require(all(isinstance(value[key], int) and not isinstance(value[key], bool) for key in RECT_KEYS), f"{field} types") - - for output in snapshot["outputs"]: - _require(all(key in output for key in ("name", "active", "focused", "rect", "scale", "transform", "current_mode")), "output champs") - _require(isinstance(output["active"], bool) and isinstance(output["focused"], bool), "output booléens") - validate_rect(output["rect"], "output.rect") - _require(isinstance(output["scale"], (int, float)) and not isinstance(output["scale"], bool), "output.scale") - _require(isinstance(output["transform"], str), "output.transform") - mode = output["current_mode"] - _require(mode is None or ( - isinstance(mode, Mapping) - and set(mode) == {"width", "height", "refresh_millihz"} - and all(isinstance(item, int) and not isinstance(item, bool) for item in mode.values()) - ), "output.current_mode") - - def validate_ref(ref: Any) -> None: - _require(isinstance(ref, Mapping), "référence arbre") - _require(ref.get("kind") in {"container", "window"}, "référence kind") - _require(ref.get("id") in all_refs, "référence absente") - _require((ref["kind"] == "container") == (ref["id"] in set(container_ids)), "référence kind/id") - - for workspace in snapshot["workspaces"]: - _require(all(key in workspace for key in ("name", "num", "output", "order", "focused", "visible", "urgent", "rect", "layout", "orientation", "roots")), "workspace champs") - _require(workspace.get("output") in output_names, "workspace.output") - _require(workspace.get("layout") in LAYOUTS, "workspace.layout") - _require(isinstance(workspace.get("num"), int) and isinstance(workspace.get("order"), int), "workspace num/order") - _require(all(isinstance(workspace.get(key), bool) for key in ("focused", "visible", "urgent")), "workspace booléens") - validate_rect(workspace.get("rect"), "workspace.rect") - for branch in ("tiling", "floating"): - _require(isinstance(workspace.get("roots", {}).get(branch), list), f"workspace.roots.{branch}") - for ref in workspace["roots"][branch]: - validate_ref(ref) - for container in snapshot["containers"]: - _require(all(key in container for key in ("container_id", "runtime_con_id", "workspace", "output", "parent_container_id", "tree_position", "layout", "orientation", "percent", "rect", "focused", "urgent", "marks", "children", "floating_children")), "container champs") - _require(container.get("workspace") is None or container.get("workspace") in workspace_names, "container.workspace") - _require(container.get("output") is None or container.get("output") in output_names, "container.output") - _require(container.get("parent_container_id") is None or container.get("parent_container_id") in container_ids, "container.parent") - _require(container.get("layout") in LAYOUTS, "container.layout") - _require(isinstance(container.get("orientation"), str), "container.orientation") - _require(container.get("percent") is None or isinstance(container.get("percent"), (int, float)), "container.percent") - validate_rect(container.get("rect"), "container.rect") - for branch in ("children", "floating_children"): - _require(isinstance(container.get(branch), list), f"container.{branch}") - for ref in container[branch]: - validate_ref(ref) - for window in snapshot["windows"]: - _require(all(key in window for key in ("window_id", "snapshot_identity", "runtime", "workspace", "output", "app_id", "xwayland", "executable_basename", "title_hint", "restore_identity", "restore_adapter", "classification", "focused", "urgent", "floating", "fullscreen_mode", "rect", "percent", "marks", "scratchpad", "tree_position")), "window champs") - _require(window.get("workspace") is None or window.get("workspace") in workspace_names, "window.workspace") - _require(window.get("output") is None or window.get("output") in output_names, "window.output") - identity = window.get("restore_identity") - _require(isinstance(identity, Mapping) and identity.get("confidence") in CONFIDENCE, "restore_identity") - _require(all(key in identity for key in ("app_id", "class", "instance", "desktop_entry", "confidence", "matched_by")), "restore_identity champs") - _require(window.get("restore_adapter") in {"generic-desktop", "terminal", "browser", "custom", "unknown"}, "restore_adapter") - classification = window.get("classification") - _require(isinstance(classification, Mapping) and classification.get("category") in {"user-application", "autostart-managed-application", "session-infrastructure", "daemon", "unknown"}, "classification") - _require(all(isinstance(window.get(key), bool) for key in ("focused", "urgent", "floating")), "window booléens") - _require(isinstance(window.get("fullscreen_mode"), int) and not isinstance(window.get("fullscreen_mode"), bool), "window.fullscreen_mode") - validate_rect(window.get("rect"), "window.rect") - position = window.get("tree_position") - _require(isinstance(position, Mapping), "tree_position") - parent = position.get("parent_container_id") - _require(parent is None or parent in container_ids, "tree_position.parent_container_id") - _require(isinstance(window.get("runtime"), Mapping), "window.runtime") - _require(set(window["runtime"]) == {"con_id", "pid"}, "window.runtime champs") - _require(all(window["runtime"][key] is None or (isinstance(window["runtime"][key], int) and not isinstance(window["runtime"][key], bool)) for key in ("con_id", "pid")), "window.runtime types") - scratchpad = window.get("scratchpad") - _require(isinstance(scratchpad, Mapping) and isinstance(scratchpad.get("member"), bool), "window.scratchpad") - _require(scratchpad.get("visibility") in {"hidden", "visible", "not-applicable"}, "scratchpad.visibility") - focus = snapshot["focus"] - _require(set(focus) == {"output", "workspace", "container_id", "window_id"}, "focus champs") - _require(focus.get("output") is None or focus.get("output") in output_names, "focus.output") - _require(focus.get("workspace") is None or focus.get("workspace") in workspace_names, "focus.workspace") - _require(focus.get("container_id") is None or focus.get("container_id") in container_ids, "focus.container_id") - _require(focus.get("window_id") is None or focus.get("window_id") in window_ids, "focus.window_id") - - # WHY: une référence valide mais dupliquée ferait relancer/replacer deux fois - # la même vue ; un objet orphelin perdrait une partie de la session. - # CONTRACT: chaque objet a exactement une entrée depuis une racine ou un parent. - # INVARIANT: parent, branche et index concordent avec l'arête entrante. - incoming = {object_id: 0 for object_id in all_refs} - - def register(ref: Mapping[str, Any], parent: str | None, branch: str, index: int) -> None: - validate_ref(ref) - object_id = ref["id"] - incoming[object_id] += 1 - item = object_by_id[object_id] - expected_parent = item.get("parent_container_id") if ref["kind"] == "container" else item["tree_position"].get("parent_container_id") - _require(expected_parent == parent, "arête/parent") - position = item["tree_position"] - _require(position.get("branch") == branch and position.get("index") == index, "arête position") - - for workspace in snapshot["workspaces"]: - for branch in ("tiling", "floating"): - for index, ref in enumerate(workspace["roots"][branch]): - register(ref, None, branch, index) - restore_hints = snapshot["restore_hints"] - _require(all(key in restore_hints for key in ("output_matching", "missing_output_policy", "scratchpad_roots")), "restore_hints champs") - _require(restore_hints["output_matching"] == "connector-name-preferred-with-future-fallback", "restore_hints.output_matching") - _require(restore_hints["missing_output_policy"] == "deferred", "restore_hints.missing_output_policy") - scratch_roots = restore_hints.get("scratchpad_roots") - _require(isinstance(scratch_roots, list), "restore_hints.scratchpad_roots") - for index, ref in enumerate(scratch_roots): - register(ref, None, "scratchpad", index) - for container in snapshot["containers"]: - for key, branch in (("children", "tiling"), ("floating_children", "floating")): - for index, ref in enumerate(container[key]): - register(ref, container["container_id"], branch, index) - _require(all(count == 1 for count in incoming.values()), "objet orphelin ou référencé plusieurs fois") - - # Les comptes d'identité servent au gate réel et doivent être dérivables. - diagnostics = snapshot["diagnostics"] - _require(diagnostics.get("source") == ["get_version", "get_outputs", "get_workspaces", "get_tree"], "diagnostics.source") - _require(isinstance(diagnostics.get("volatile_fields"), list) and all(isinstance(item, str) for item in diagnostics["volatile_fields"]), "diagnostics.volatile_fields") - counts = diagnostics.get("identity_counts") - _require(isinstance(counts, Mapping) and set(counts) == CONFIDENCE, "diagnostics.identity_counts") - expected_counts = {key: 0 for key in CONFIDENCE} - for window in snapshot["windows"]: - expected_counts[window["restore_identity"]["confidence"]] += 1 - _require(dict(counts) == expected_counts, "diagnostics.identity_counts valeurs") - - -def structural_projection(snapshot: Mapping[str, Any]) -> dict[str, Any]: - """Projection déterministe : les indices purement volatils n'y participent pas.""" - projected = json.loads(json.dumps(snapshot)) - for field in ("captured_at", "created_at", "updated_at"): - projected["metadata"].pop(field, None) - projected["diagnostics"].pop("collection_duration_ms", None) - for window in projected["windows"]: - window.pop("title_hint", None) - return projected - - -def collect(autostart_path: Path | None = None) -> dict[str, Any]: - """Collecter exactement les quatre sources IPC autorisées et l'allowlist env.""" - environment = { - key: os.environ[key] - for key in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_TYPE", "DESKTOP_SESSION") - if key in os.environ - } - return build_snapshot( - _ipc("get_version"), - _ipc("get_outputs"), - _ipc("get_workspaces"), - _ipc("get_tree"), - environment=environment, - desktop_entries=load_desktop_entries(), - managed_autostart=parse_sway_autostart(autostart_path), - ) - - -def collect_consistent( - autostart_path: Path | None = None, - *, - attempts: int = SAVE_ATTEMPTS, - retry_delay: float = SAVE_RETRY_DELAY_SECONDS, - collector: Callable[[Path | None], dict[str, Any]] | None = None, -) -> dict[str, Any]: - """Réessayer uniquement les graphes IPC relationnellement incohérents.""" - if attempts < 1: - raise ValueError("attempts doit être positif") - operation = collect if collector is None else collector - last_error: SnapshotError | None = None - for attempt in range(attempts): - try: - snapshot = operation(autostart_path) - validate_snapshot(snapshot) - return snapshot - except SnapshotError as exc: - if not str(exc).startswith("snapshot V1 invalide:"): - raise - last_error = exc - if attempt + 1 < attempts and retry_delay > 0: - time.sleep(retry_delay) - raise SnapshotError(f"SAVE_FAILED_INCONSISTENT_STATE: {last_error}") from last_error - - -def _validate_persistent_snapshot(snapshot: Mapping[str, Any], expected_name: str | None = None) -> None: - """Valider l'extension compatible de métadonnées propre à STEP18B.""" - validate_snapshot(snapshot) - metadata = snapshot["metadata"] - for key in ("session_name", "created_at", "updated_at"): - _require(isinstance(metadata.get(key), str) and bool(metadata[key]), f"metadata.{key}") - normalized = normalize_session_name(metadata["session_name"]) - _require(normalized == metadata["session_name"], "metadata.session_name") - parsed: dict[str, dt.datetime] = {} - for key in ("created_at", "updated_at"): - try: - parsed[key] = dt.datetime.fromisoformat(metadata[key].replace("Z", "+00:00")) - except ValueError as exc: - raise SnapshotError(f"snapshot V1 invalide: metadata.{key} UTC") from exc - _require( - parsed[key].tzinfo is not None and parsed[key].utcoffset() == dt.timedelta(0), - f"metadata.{key} UTC", - ) - _require(parsed["updated_at"] >= parsed["created_at"], "metadata.updated_at antérieur à created_at") - if expected_name is not None: - _require(normalized == expected_name, "nom fichier/session") - - -def _read_all_bounded(fd: int, size: int) -> bytes: - if size > MAX_SNAPSHOT_BYTES: - raise SnapshotError(f"snapshot trop volumineux (limite {MAX_SNAPSHOT_BYTES} octets)") - chunks: list[bytes] = [] - remaining = MAX_SNAPSHOT_BYTES + 1 - while remaining > 0: - chunk = os.read(fd, min(65536, remaining)) - if not chunk: - break - chunks.append(chunk) - remaining -= len(chunk) - payload = b"".join(chunks) - if len(payload) > MAX_SNAPSHOT_BYTES: - raise SnapshotError(f"snapshot trop volumineux (limite {MAX_SNAPSHOT_BYTES} octets)") - return payload - - -def _load_snapshot_at(directory_fd: int, filename: str, expected_name: str) -> dict[str, Any]: - """Lire un fichier régulier borné sans suivre de lien symbolique.""" - flags = os.O_RDONLY | os.O_CLOEXEC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - try: - fd = os.open(filename, flags, dir_fd=directory_fd) - except FileNotFoundError: - raise - except OSError as exc: - if exc.errno == errno.ELOOP: - raise SnapshotError(f"snapshot refusé (lien symbolique): {expected_name}") from exc - raise SnapshotError(f"lecture snapshot impossible: {expected_name}: {exc}") from exc - try: - info = os.fstat(fd) - if not stat.S_ISREG(info.st_mode): - raise SnapshotError(f"snapshot refusé (type non régulier): {expected_name}") - if stat.S_IMODE(info.st_mode) != 0o600: - raise SnapshotError(f"permissions snapshot différentes de 0600: {expected_name}") - payload = _read_all_bounded(fd, info.st_size) - finally: - os.close(fd) - try: - value = json.loads(payload) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise SnapshotError(f"JSON snapshot invalide: {expected_name}: {exc}") from exc - if not isinstance(value, Mapping): - raise SnapshotError(f"snapshot invalide: racine JSON non objet: {expected_name}") - if value.get("schema") != SCHEMA: - raise SnapshotError(f"unsupported snapshot schema: {value.get('schema')!r}") - if value.get("version") != VERSION: - raise SnapshotError(f"unsupported snapshot version: {value.get('version')!r}") - snapshot = dict(value) - _validate_persistent_snapshot(snapshot, expected_name) - return snapshot - - -def _serialize_snapshot(snapshot: Mapping[str, Any]) -> bytes: - payload = (json.dumps(snapshot, ensure_ascii=False, sort_keys=True, indent=2) + "\n").encode("utf-8") - if len(payload) > MAX_SNAPSHOT_BYTES: - raise SnapshotError(f"snapshot trop volumineux (limite {MAX_SNAPSHOT_BYTES} octets)") - return payload - - -def _write_all(fd: int, payload: bytes) -> None: - view = memoryview(payload) - while view: - written = os.write(fd, view) - if written <= 0: - raise SnapshotError("écriture temporaire incomplète") - view = view[written:] - - -def _target_is_safe(directory_fd: int, filename: str) -> None: - try: - info = os.stat(filename, dir_fd=directory_fd, follow_symlinks=False) - except FileNotFoundError: - return - if stat.S_ISLNK(info.st_mode): - raise SnapshotError("cible snapshot refusée: lien symbolique") - if not stat.S_ISREG(info.st_mode): - raise SnapshotError("cible snapshot refusée: type non régulier") - - -def _atomic_publish( - directory_fd: int, - filename: str, - snapshot: Mapping[str, Any], - *, - before_replace: Callable[[str], None] | None = None, -) -> None: - """Publier un snapshot complet sans exposer d'état intermédiaire.""" - payload = _serialize_snapshot(snapshot) - temporary = f".{filename}.{os.getpid()}.{secrets.token_hex(8)}.tmp" - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC - if hasattr(os, "O_NOFOLLOW"): - flags |= os.O_NOFOLLOW - fd = -1 - try: - fd = os.open(temporary, flags, 0o600, dir_fd=directory_fd) - os.fchmod(fd, 0o600) - _write_all(fd, payload) - os.fsync(fd) - os.close(fd) - fd = -1 - - # CONTRACT: relire le fichier réellement écrit avant publication ; une - # corruption locale ou un test d'interruption ne touche jamais l'ancien. - check_flags = os.O_RDONLY | os.O_CLOEXEC - if hasattr(os, "O_NOFOLLOW"): - check_flags |= os.O_NOFOLLOW - check_fd = os.open(temporary, check_flags, dir_fd=directory_fd) - try: - written = _read_all_bounded(check_fd, os.fstat(check_fd).st_size) - finally: - os.close(check_fd) - if written != payload: - raise SnapshotError("validation du temporaire échouée") - decoded = json.loads(written) - _validate_persistent_snapshot(decoded, snapshot["metadata"]["session_name"]) - _target_is_safe(directory_fd, filename) - if before_replace is not None: - before_replace(temporary) - os.replace(temporary, filename, src_dir_fd=directory_fd, dst_dir_fd=directory_fd) - os.fsync(directory_fd) - except (OSError, json.JSONDecodeError) as exc: - raise SnapshotError(f"publication atomique échouée: {exc}") from exc - finally: - if fd >= 0: - os.close(fd) - try: - os.unlink(temporary, dir_fd=directory_fd) - except FileNotFoundError: - pass - - -def _next_updated_at(previous: str | None) -> str: - current = utc_now() - if previous is None or current > previous: - return current - try: - parsed = dt.datetime.fromisoformat(previous.replace("Z", "+00:00")) - except ValueError: - return current - return (parsed + dt.timedelta(milliseconds=1)).isoformat(timespec="milliseconds").replace("+00:00", "Z") - - -def save_session( - name: str, - autostart_path: Path | None = None, - *, - environment: Mapping[str, str] | None = None, - home: Path | None = None, - collector: Callable[[Path | None], dict[str, Any]] | None = None, - retry_delay: float = SAVE_RETRY_DELAY_SECONDS, - before_replace: Callable[[str], None] | None = None, -) -> dict[str, Any]: - """Collecter, valider et remplacer atomiquement une session nommée.""" - normalized = normalize_session_name(name) - started = time.monotonic() - snapshot = collect_consistent( - autostart_path, - retry_delay=retry_delay, - collector=collector, - ) - snapshot["diagnostics"]["collection_duration_ms"] = round((time.monotonic() - started) * 1000, 3) - validate_snapshot(snapshot) - directory = sessions_directory(environment, home) - directory_fd = _open_sessions_directory(directory) - filename = _snapshot_filename(normalized) - try: - with _sessions_lock(directory_fd, exclusive=True): - _target_is_safe(directory_fd, filename) - existing: dict[str, Any] | None = None - try: - existing = _load_snapshot_at(directory_fd, filename, normalized) - except FileNotFoundError: - pass - except SnapshotError: - # CONTRACT: un save explicitement ciblé peut réparer un JSON - # corrompu, mais jamais contourner un lien ou type de fichier. - existing = None - updated_at = _next_updated_at(existing["metadata"]["updated_at"] if existing else None) - snapshot["metadata"].update({ - "session_name": normalized, - "created_at": existing["metadata"]["created_at"] if existing else updated_at, - "updated_at": updated_at, - }) - _validate_persistent_snapshot(snapshot, normalized) - _atomic_publish( - directory_fd, - filename, - snapshot, - before_replace=before_replace, - ) - finally: - os.close(directory_fd) - return snapshot - - -def show_session( - name: str, - *, - environment: Mapping[str, str] | None = None, - home: Path | None = None, -) -> dict[str, Any]: - normalized = normalize_session_name(name) - directory = sessions_directory(environment, home) - directory_fd = _open_sessions_directory(directory) - try: - with _sessions_lock(directory_fd, exclusive=False): - try: - return _load_snapshot_at(directory_fd, _snapshot_filename(normalized), normalized) - except FileNotFoundError as exc: - raise SnapshotError(f"session introuvable: {normalized}") from exc - finally: - os.close(directory_fd) - - -def list_sessions( - *, - environment: Mapping[str, str] | None = None, - home: Path | None = None, -) -> tuple[list[dict[str, Any]], list[str]]: - directory = sessions_directory(environment, home) - directory_fd = _open_sessions_directory(directory) - sessions: list[dict[str, Any]] = [] - warnings: list[str] = [] - try: - with _sessions_lock(directory_fd, exclusive=False): - for filename in sorted(os.listdir(directory_fd)): - if not filename.endswith(SNAPSHOT_SUFFIX): - continue - name = filename[: -len(SNAPSHOT_SUFFIX)] - try: - normalized = normalize_session_name(name) - snapshot = _load_snapshot_at(directory_fd, filename, normalized) - except (SnapshotError, FileNotFoundError) as exc: - warnings.append(f"snapshot ignoré {filename}: {exc}") - continue - sessions.append({ - "name": normalized, - "updated_at": snapshot["metadata"]["updated_at"], - "windows": len(snapshot["windows"]), - "workspaces": len(snapshot["workspaces"]), - }) - finally: - os.close(directory_fd) - return sessions, warnings - - -def delete_session( - name: str, - *, - environment: Mapping[str, str] | None = None, - home: Path | None = None, -) -> None: - normalized = normalize_session_name(name) - directory = sessions_directory(environment, home) - directory_fd = _open_sessions_directory(directory) - filename = _snapshot_filename(normalized) - try: - with _sessions_lock(directory_fd, exclusive=True): - _target_is_safe(directory_fd, filename) - try: - os.unlink(filename, dir_fd=directory_fd) - except FileNotFoundError as exc: - raise SnapshotError(f"session introuvable: {normalized}") from exc - os.fsync(directory_fd) - finally: - os.close(directory_fd) - - -def _load_restore_planner() -> Callable[[Mapping[str, Any], Mapping[str, Any]], dict[str, Any]]: - """Charger le planner voisin sans lui donner d'accès implicite au backend.""" - path = Path(__file__).with_name("session_restore.py") - spec = importlib.util.spec_from_file_location("labfy_session_restore", path) - if spec is None or spec.loader is None: - raise SnapshotError("planner de restauration indisponible") - module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) - except (OSError, ImportError, SyntaxError) as exc: - raise SnapshotError(f"chargement du planner impossible: {exc}") from exc - planner = getattr(module, "build_restore_plan", None) - if not callable(planner): - raise SnapshotError("planner de restauration invalide") - return planner - - -def _load_restore_modules() -> tuple[Any, Any]: - """Charger explicitement planner pur et frontière d'effets STEP19B.""" - planner_path = Path(__file__).with_name("session_restore.py") - executor_path = Path(__file__).with_name("session_executor.py") - - def load(name: str, path: Path) -> Any: - spec = importlib.util.spec_from_file_location(name, path) - if spec is None or spec.loader is None: - raise SnapshotError(f"module de restauration indisponible: {path.name}") - module = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(module) - except (OSError, ImportError, SyntaxError) as exc: - raise SnapshotError(f"chargement de {path.name} impossible: {exc}") from exc - return module - - return load("labfy_session_restore_apply", planner_path), load("labfy_session_executor", executor_path) - - -def apply_session( - name: str, - autostart_path: Path | None = None, - *, - snapshot_loader: Callable[[str], dict[str, Any]] | None = None, - live_collector: Callable[[Path | None], dict[str, Any]] | None = None, - sessions_path: Path | None = None, - planner_module: Any | None = None, - executor_module: Any | None = None, - desktop_loader: Callable[[], Sequence[Mapping[str, str | None]]] | None = None, - runner: Callable[[Sequence[str], float], Any] | None = None, - timeout: float = 10.0, -) -> dict[str, Any]: - """Construire puis exécuter un plan frais sous le verrou STEP19B.""" - # WHY: un plan persistant pourrait viser des con_id recyclés. L'API apply - # n'accepte donc qu'un nom de snapshot et recollecte toujours Sway. - # CONTRACT: l'exécuteur détient le lock pendant load→plan→execute→rehash. - # INVARIANT: aucun plan fourni par l'appelant n'est une autorité exécutable. - normalized = normalize_session_name(name) - if planner_module is None or executor_module is None: - loaded_planner, loaded_executor = _load_restore_modules() - planner_module = loaded_planner if planner_module is None else planner_module - executor_module = loaded_executor if executor_module is None else executor_module - kwargs: dict[str, Any] = { - "sessions_path": sessions_directory() if sessions_path is None else sessions_path, - "snapshot_loader": show_session if snapshot_loader is None else snapshot_loader, - "live_collector": collect if live_collector is None else live_collector, - "planner_module": planner_module, - "desktop_loader": load_desktop_entries if desktop_loader is None else desktop_loader, - "resolve_desktop_entry": resolve_desktop_entry, - "timeout": timeout, - } - if runner is not None: - kwargs["runner"] = runner - return executor_module.apply_session(normalized, autostart_path, **kwargs) - - -def plan_session( - name: str, - autostart_path: Path | None = None, - *, - snapshot_loader: Callable[[str], dict[str, Any]] | None = None, - live_collector: Callable[[Path | None], dict[str, Any]] | None = None, - planner: Callable[[Mapping[str, Any], Mapping[str, Any]], dict[str, Any]] | None = None, -) -> dict[str, Any]: - """Charger deux états validés et produire un plan sans jamais l'exécuter.""" - # WHY: STEP19A doit réutiliser exactement la validation persistante et la - # résolution DesktopEntry STEP18, sans ouvrir une seconde voie divergente. - # CONTRACT: loader et collector sont en lecture seule ; le planner est pur. - # INVARIANT: aucune commande applicative ou Sway mutatrice n'est accessible ici. - load = show_session if snapshot_loader is None else snapshot_loader - observe = collect if live_collector is None else live_collector - build = _load_restore_planner() if planner is None else planner - source = load(normalize_session_name(name)) - live = observe(autostart_path) - validate_snapshot(live) - try: - return build(source, live) - except Exception as exc: - if exc.__class__.__name__ != "RestorePlanError": - raise - raise SnapshotError(str(exc)) from exc - - -def main(argv: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Snapshots persistants et restauration contrôlée pour Sway") - parser.add_argument("--stdout", action="store_true", help="émettre le JSON validé sur stdout") - parser.add_argument("--compact", action="store_true", help="émettre un JSON compact") - parser.add_argument( - "--execute", - action="store_true", - help="autoriser explicitement les mutations, uniquement avec apply", - ) - parser.add_argument( - "--autostart", - type=Path, - default=Path.home() / ".config/sway/autostart", - help="fichier Sway autostart utilisé seulement pour la classification", - ) - parser.add_argument("command", nargs="?", choices=("save", "list", "show", "delete", "plan", "apply")) - parser.add_argument("name", nargs="?") - args = parser.parse_args(argv) - if args.stdout and args.command is not None: - parser.error("--stdout ne peut pas être combiné avec une commande persistante") - if not args.stdout and args.command is None: - parser.error("utiliser --stdout ou une commande save/list/show/delete/plan/apply") - if args.command in {"save", "show", "delete", "plan", "apply"} and args.name is None: - parser.error(f"{args.command} requiert un nom de session") - if (args.stdout or args.command == "list") and args.name is not None: - parser.error("nom de session inattendu") - if args.execute and args.command != "apply": - parser.error("--execute est réservé à la commande apply") - if args.command == "apply" and not args.execute: - print("RESTORE_EXECUTION_REQUIRES_EXPLICIT_EXECUTE: ajouter --execute", file=sys.stderr) - return 1 - try: - if args.stdout: - started = time.monotonic() - result: Any = collect(args.autostart) - result["diagnostics"]["collection_duration_ms"] = round((time.monotonic() - started) * 1000, 3) - validate_snapshot(result) - elif args.command == "save": - snapshot = save_session(args.name, args.autostart) - result = { - "name": snapshot["metadata"]["session_name"], - "created_at": snapshot["metadata"]["created_at"], - "updated_at": snapshot["metadata"]["updated_at"], - "windows": len(snapshot["windows"]), - "workspaces": len(snapshot["workspaces"]), - } - elif args.command == "list": - result, warnings = list_sessions() - for warning in warnings: - print(warning, file=sys.stderr) - elif args.command == "show": - result = show_session(args.name) - elif args.command == "plan": - result = plan_session(args.name, args.autostart) - elif args.command == "apply": - result = apply_session(args.name, args.autostart) - else: - delete_session(args.name) - result = {"deleted": args.name} - except SnapshotError as exc: - print(str(exc), file=sys.stderr) - return 1 - if args.compact: - json.dump(result, sys.stdout, ensure_ascii=False, sort_keys=True, separators=(",", ":")) - else: - json.dump(result, sys.stdout, ensure_ascii=False, sort_keys=True, indent=2) - sys.stdout.write("\n") - if args.command == "apply" and result.get("status") != "success": - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/quickshell/.config/quickshell/labfy-sway/session/session_tree_restore.py b/quickshell/.config/quickshell/labfy-sway/session/session_tree_restore.py deleted file mode 100644 index 84bd6b34c..000000000 --- a/quickshell/.config/quickshell/labfy-sway/session/session_tree_restore.py +++ /dev/null @@ -1,423 +0,0 @@ -#!/usr/bin/env python3 -"""Compiler un sous-ensemble prouvé d'arbres Sway en opérations typées.""" - -from __future__ import annotations - -import copy -import re -from typing import Any, Mapping, NamedTuple, Sequence - - -SUPPORTED_LAYOUTS = {"splith", "splitv", "tabbed", "stacked"} -SPLIT_LAYOUTS = {"splith", "splitv"} -SAFE_SOURCE_ID = re.compile(r"\Aw[1-9][0-9]*\Z") -SAFE_WORKSPACE = re.compile(r"\A[1-9][0-9]*\Z") - - -class TreeRestoreError(RuntimeError): - """Topologie invalide ou extérieure au sous-ensemble STEP19C.""" - - def __init__(self, reason: str): - super().__init__(reason) - self.reason = reason - - -class TreeNode(NamedTuple): - """Nœud indépendant des identifiants de containers Sway runtime.""" - - kind: str - key: str | None - layout: str | None - percent: float | None - children: tuple["TreeNode", ...] = () - - -def _require(condition: bool, reason: str) -> None: - if not condition: - raise TreeRestoreError(reason) - - -def _number(value: Any) -> float | None: - if value is None: - return None - _require(isinstance(value, (int, float)) and not isinstance(value, bool), "invalid-percent") - result = float(value) - _require(0.0 <= result <= 1.0, "invalid-percent") - return result - - -def _collections(snapshot: Mapping[str, Any]) -> tuple[dict[str, Mapping[str, Any]], dict[str, Mapping[str, Any]]]: - containers = { - str(item.get("container_id")): item - for item in snapshot.get("containers", []) - if isinstance(item, Mapping) and isinstance(item.get("container_id"), str) - } - windows = { - str(item.get("window_id")): item - for item in snapshot.get("windows", []) - if isinstance(item, Mapping) and isinstance(item.get("window_id"), str) - } - return containers, windows - - -def _node_from_ref( - ref: Mapping[str, Any], - containers: Mapping[str, Mapping[str, Any]], - windows: Mapping[str, Mapping[str, Any]], - window_keys: Mapping[str, str], - seen: set[str], -) -> TreeNode: - kind = ref.get("kind") - object_id = ref.get("id") - _require(kind in {"container", "window"} and isinstance(object_id, str), "invalid-tree-reference") - _require(object_id not in seen, "duplicate-tree-reference") - seen.add(object_id) - if kind == "window": - _require(object_id in windows and object_id in window_keys, "missing-target-window") - window = windows[object_id] - _require(window.get("floating") is False, "floating-window-in-tiling-branch") - return TreeNode("window", window_keys[object_id], None, _number(window.get("percent"))) - _require(object_id in containers, "missing-target-container") - container = containers[object_id] - layout = container.get("layout") - _require(layout in SUPPORTED_LAYOUTS, "unsupported-layout") - floating_children = container.get("floating_children") - _require(isinstance(floating_children, list) and not floating_children, "floating-child-in-tiling-container") - children = container.get("children") - _require(isinstance(children, list) and children, "empty-or-invalid-container") - return TreeNode( - "container", - None, - str(layout), - _number(container.get("percent")), - tuple( - _node_from_ref(child, containers, windows, window_keys, seen) - for child in children - if isinstance(child, Mapping) - ), - ) - - -def normalized_workspace_tree( - snapshot: Mapping[str, Any], - workspace_name: str, - window_keys: Mapping[str, str], -) -> tuple[TreeNode, ...]: - """Projeter uniquement la branche tiling en identités persistantes/matchées.""" - _require(SAFE_WORKSPACE.fullmatch(workspace_name) is not None, "unsupported-non-numeric-workspace") - workspace = next( - ( - item for item in snapshot.get("workspaces", []) - if isinstance(item, Mapping) and item.get("name") == workspace_name - ), - None, - ) - _require(workspace is not None, "missing-workspace") - roots = workspace.get("roots") - roots = roots if isinstance(roots, Mapping) else {} - tiling = roots.get("tiling") - _require(isinstance(tiling, list), "invalid-workspace-roots") - containers, windows = _collections(snapshot) - seen: set[str] = set() - return tuple( - _node_from_ref(ref, containers, windows, window_keys, seen) - for ref in tiling - if isinstance(ref, Mapping) - ) - - -def _leaves(nodes: Sequence[TreeNode]) -> list[str]: - result: list[str] = [] - for node in nodes: - if node.kind == "window": - assert node.key is not None - result.append(node.key) - else: - result.extend(_leaves(node.children)) - return result - - -def _shape(nodes: Sequence[TreeNode]) -> Any: - return [ - ("window", node.key) - if node.kind == "window" - else ("container", node.layout, _shape(node.children)) - for node in nodes - ] - - -def _workspace_layout(snapshot: Mapping[str, Any], workspace_name: str) -> str: - workspace = next( - ( - item for item in snapshot.get("workspaces", []) - if isinstance(item, Mapping) and item.get("name") == workspace_name - ), - None, - ) - _require(workspace is not None and workspace.get("layout") in SUPPORTED_LAYOUTS, "unsupported-workspace-layout") - return str(workspace["layout"]) - - -def _flat_parent(nodes: Sequence[TreeNode], workspace_layout: str) -> tuple[str, list[str], str]: - """Retourner layout, ordre et style de racine pour une fratrie sans nesting.""" - if nodes and all(node.kind == "window" for node in nodes): - return workspace_layout, _leaves(nodes), "workspace-direct" - if len(nodes) == 1 and nodes[0].kind == "container" and all( - child.kind == "window" for child in nodes[0].children - ): - container = nodes[0] - assert container.layout is not None - return container.layout, _leaves(container.children), "container-root" - raise TreeRestoreError("unsupported-nested-tree") - - -def _target_shape(nodes: Sequence[TreeNode], workspace_layout: str) -> dict[str, Any]: - try: - layout, order, style = _flat_parent(nodes, workspace_layout) - return {"kind": "flat", "layout": layout, "order": order, "style": style} - except TreeRestoreError: - pass - - outer: TreeNode | None = None - style = "workspace-direct" - children: Sequence[TreeNode] = nodes - if len(nodes) == 1 and nodes[0].kind == "container": - outer = nodes[0] - children = outer.children - style = "container-root" - nested_indexes = [index for index, child in enumerate(children) if child.kind == "container"] - _require(len(nested_indexes) == 1, "unsupported-nested-tree") - nested_index = nested_indexes[0] - nested = children[nested_index] - _require(all(child.kind == "window" for child in children if child is not nested), "unsupported-nested-tree") - _require(len(nested.children) == 2 and all(child.kind == "window" for child in nested.children), "unsupported-nested-tree") - outer_layout = outer.layout if outer is not None else workspace_layout - _require(outer_layout in SPLIT_LAYOUTS and nested.layout in SPLIT_LAYOUTS, "unsupported-nested-layout") - _require(outer_layout != nested.layout, "redundant-nested-split") - _require(nested_index > 0, "unsupported-leading-nested-container") - return { - "kind": "two-level", - "layout": outer_layout, - "nested_layout": nested.layout, - "nested_index": nested_index, - "order": _leaves(children), - "nested_order": _leaves(nested.children), - "style": style, - } - - -def _operation(kind: str, workspace: str, **values: Any) -> dict[str, Any]: - # CONTRACT: ces dictionnaires sont une somme fermée ; aucune chaîne Sway - # provenant d'un snapshot n'est transportée jusqu'à l'exécuteur. - return {"operation": kind, "workspace": workspace, **values} - - -def _bubble_order(workspace: str, current: list[str], target: Sequence[str]) -> list[dict[str, Any]]: - operations: list[dict[str, Any]] = [] - # WHY: move left/right peut extraire une vue d'un container tabbed/stacked. - # CONTRACT: move-to-mark insère la source après une référence exacte ; le - # premier enfant cible sert donc d'ancre et chaque suivant est réinséré. - for wanted_index in range(1, len(target)): - wanted = target[wanted_index] - reference = target[wanted_index - 1] - current_index = current.index(wanted) - reference_index = current.index(reference) - if current_index == reference_index + 1: - continue - operations.append(_operation( - "move-relative", - workspace, - source_window=wanted, - relation="after", - reference_window=reference, - )) - current.pop(current_index) - reference_index = current.index(reference) - current.insert(reference_index + 1, wanted) - return operations - - -def _workspace_names_for_tree_actions( - source: Mapping[str, Any], - plan: Mapping[str, Any], -) -> list[str]: - source_windows = { - str(item.get("window_id")): item - for item in source.get("windows", []) - if isinstance(item, Mapping) - } - result: set[str] = set() - for action in plan.get("actions", []): - if not isinstance(action, Mapping) or action.get("action") != "restore-tree-position": - continue - window = source_windows.get(str(action.get("source_window"))) - workspace = window.get("workspace") if window else None - _require(isinstance(workspace, str), "tree-action-without-workspace") - result.add(workspace) - return sorted(result, key=lambda value: int(value)) - - -def compile_tree_restore( - source: Mapping[str, Any], - live: Mapping[str, Any], - plan: Mapping[str, Any], -) -> dict[str, Any]: - """Compiler sans effet externe le sous-ensemble localement démontré. - - WHY: les containers et PID sauvegardés sont volatils ; seules les fenêtres - rematchées relient les deux arbres. CONTRACT: toute fenêtre tiling du - workspace doit appartenir au target, sinon le preflight bloque. INVARIANT: - la sortie est déterministe et ne contient aucune commande libre. - """ - mappings: dict[str, int] = {} - live_id_by_con: dict[int, str] = {} - for action in plan.get("actions", []): - if not isinstance(action, Mapping) or action.get("action") != "reuse-window": - continue - source_id = action.get("source_window") - live_window = action.get("live_window") - runtime = live_window.get("runtime") if isinstance(live_window, Mapping) else None - con_id = runtime.get("con_id") if isinstance(runtime, Mapping) else None - _require( - isinstance(source_id, str) - and SAFE_SOURCE_ID.fullmatch(source_id) is not None - and isinstance(con_id, int) - and not isinstance(con_id, bool) - and con_id > 0, - "invalid-runtime-mapping", - ) - _require(con_id not in live_id_by_con, "duplicate-runtime-window") - mappings[source_id] = con_id - live_id_by_con[con_id] = source_id - - source_windows = { - str(item.get("window_id")): item - for item in source.get("windows", []) - if isinstance(item, Mapping) - } - live_windows = { - str(item.get("window_id")): item - for item in live.get("windows", []) - if isinstance(item, Mapping) - } - source_keys = {window_id: window_id for window_id in source_windows} - live_keys: dict[str, str] = {} - for live_id, window in live_windows.items(): - runtime = window.get("runtime") - con_id = runtime.get("con_id") if isinstance(runtime, Mapping) else None - if isinstance(con_id, int) and con_id in live_id_by_con: - live_keys[live_id] = live_id_by_con[con_id] - - all_operations: list[dict[str, Any]] = [] - workspaces: list[dict[str, Any]] = [] - for workspace in _workspace_names_for_tree_actions(source, plan): - target_nodes = normalized_workspace_tree(source, workspace, source_keys) - live_nodes = normalized_workspace_tree(live, workspace, live_keys) - target_workspace_layout = _workspace_layout(source, workspace) - live_workspace_layout = _workspace_layout(live, workspace) - target_leaves = _leaves(target_nodes) - live_leaves = _leaves(live_nodes) - _require(target_leaves, "empty-target-tree") - _require(set(target_leaves) <= set(mappings), "missing-target-window") - _require(set(live_leaves) == set(target_leaves), "unexpected-live-window") - _require(len(live_leaves) == len(set(live_leaves)), "duplicate-live-window") - target = _target_shape(target_nodes, target_workspace_layout) - identical = ( - _shape(target_nodes) == _shape(live_nodes) - and (target["style"] != "workspace-direct" or target_workspace_layout == live_workspace_layout) - ) - operations: list[dict[str, Any]] = [] - if not identical: - try: - live_layout, live_order, live_style = _flat_parent(live_nodes, live_workspace_layout) - except TreeRestoreError as exc: - raise TreeRestoreError("unsupported-live-nested-tree") from exc - _require(target["style"] == live_style, "unsupported-root-style-transition") - operations.extend(_bubble_order(workspace, list(live_order), target["order"])) - anchor = target["order"][0] - if live_layout != target["layout"]: - operations.append(_operation( - "set-container-layout", - workspace, - source_window=anchor, - layout=target["layout"], - )) - if target["kind"] == "two-level": - nested_first, nested_second = target["nested_order"] - operations.append(_operation( - "set-split-orientation", - workspace, - source_window=nested_first, - layout=target["nested_layout"], - )) - operations.append(_operation( - "move-relative", - workspace, - source_window=nested_second, - relation="after", - reference_window=nested_first, - )) - workspaces.append({ - "workspace": workspace, - "capability": "supported-subset", - "target_shape": copy.deepcopy(target), - "target_tree": _shape(target_nodes), - "live_tree": _shape(live_nodes), - "runtime_con_ids": {key: mappings[key] for key in target_leaves}, - "percent_capability": "unsupported", - "operations": copy.deepcopy(operations), - }) - all_operations.extend(operations) - return { - "capability": "supported-subset", - "percent_capability": "unsupported", - "workspaces": workspaces, - "operations": all_operations, - } - - -def validate_operation(operation: Mapping[str, Any]) -> None: - """Refuser toute valeur hors de la somme typée avant construction argv.""" - kind = operation.get("operation") - _require(kind in {"set-container-layout", "set-split-orientation", "move-relative"}, "invalid-operation") - workspace = operation.get("workspace") - source = operation.get("source_window") - _require(isinstance(workspace, str) and SAFE_WORKSPACE.fullmatch(workspace) is not None, "invalid-operation-workspace") - _require(isinstance(source, str) and SAFE_SOURCE_ID.fullmatch(source) is not None, "invalid-operation-window") - if kind in {"set-container-layout", "set-split-orientation"}: - layout = operation.get("layout") - allowed = SUPPORTED_LAYOUTS if kind == "set-container-layout" else SPLIT_LAYOUTS - _require(layout in allowed, "invalid-operation-layout") - else: - _require(operation.get("relation") == "after", "invalid-operation-relation") - reference = operation.get("reference_window") - _require(isinstance(reference, str) and SAFE_SOURCE_ID.fullmatch(reference) is not None, "invalid-operation-reference") - - -def validate_compilation(compilation: Mapping[str, Any]) -> None: - _require(compilation.get("capability") == "supported-subset", "invalid-capability") - operations = compilation.get("operations") - _require(isinstance(operations, list), "invalid-operations") - for operation in operations: - _require(isinstance(operation, Mapping), "invalid-operation") - validate_operation(operation) - - -def tree_fingerprint( - live: Mapping[str, Any], - workspace: str, - runtime_by_source: Mapping[str, int], -) -> Any: - """Empreinte structurelle pour détecter une dérive entre deux mutations.""" - live_keys: dict[str, str] = {} - source_by_con = {con_id: source_id for source_id, con_id in runtime_by_source.items()} - for item in live.get("windows", []): - if not isinstance(item, Mapping): - continue - runtime = item.get("runtime") - con_id = runtime.get("con_id") if isinstance(runtime, Mapping) else None - window_id = item.get("window_id") - if isinstance(con_id, int) and con_id in source_by_con and isinstance(window_id, str): - live_keys[window_id] = source_by_con[con_id] - nodes = normalized_workspace_tree(live, workspace, live_keys) - return (_workspace_layout(live, workspace), _shape(nodes)) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/__init__.py b/quickshell/.config/quickshell/labfy-sway/session_v2/__init__.py new file mode 100644 index 000000000..d45ee30fb --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/__init__.py @@ -0,0 +1 @@ +"""Session V2 : backend indépendant, jamais importé par la V1.""" diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/anchor_helper.c b/quickshell/.config/quickshell/labfy-sway/session_v2/anchor_helper.c new file mode 100644 index 000000000..fb6391954 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/anchor_helper.c @@ -0,0 +1,44 @@ +/* WHY : une seule application possédée fournit les feuilles temporaires. + * CONTRACT : stdin privé, create / quit, 256 fenêtres maximum. + * INVARIANT : aucun lancement applicatif ni accès aux fenêtres d'autrui. + */ +#include +#include +#include +#include + +static unsigned created; +static gboolean input(GIOChannel *channel, GIOCondition condition, gpointer data) { + (void)channel; (void)data; + if (condition & (G_IO_HUP | G_IO_ERR)) { gtk_main_quit(); return FALSE; } + char buffer[256]; + if (!fgets(buffer, sizeof buffer, stdin)) { gtk_main_quit(); return FALSE; } + if (!strcmp(buffer, "quit\n")) { gtk_main_quit(); return FALSE; } + if (created >= 256 || !g_regex_match_simple( + "^create labfy-v2-anchor-[a-f0-9]{32}-[A-Za-z0-9_.-]{1,96}\\n$", buffer, 0, 0)) { + gtk_main_quit(); return FALSE; + } + buffer[strcspn(buffer, "\n")] = 0; + GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_title(GTK_WINDOW(window), "Restauration de session…"); + gtk_window_set_default_size(GTK_WINDOW(window), 120, 80); + gtk_widget_set_size_request(window, 1, 1); + gtk_widget_show_all(window); + /* GTK remplace app_id au mapping : publier notre identité ensuite. */ + gdk_wayland_window_set_application_id(gtk_widget_get_window(window), buffer + 7); + gdk_display_flush(gtk_widget_get_display(window)); + ++created; + return TRUE; +} + +int main(int argc, char **argv) { + g_set_prgname("labfy-v2-anchor"); + gtk_init(&argc, &argv); + if (!GDK_IS_WAYLAND_DISPLAY(gdk_display_get_default())) return 2; + setvbuf(stdin, NULL, _IONBF, 0); + GIOChannel *channel = g_io_channel_unix_new(0); + g_io_add_watch(channel, G_IO_IN | G_IO_HUP | G_IO_ERR, input, NULL); + gtk_main(); + g_io_channel_unref(channel); + return 0; +} diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py b/quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py new file mode 100644 index 000000000..b5371d9b8 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py @@ -0,0 +1,109 @@ +"""Ressources temporaires avec PID runtime épinglé et identité exacte. + +La propriété ne découle jamais d'un préfixe. Tout kill est précédé d'une nouvelle +lecture et d'un contrôle pidfd + PID du helper + app_id autorisé de la transaction. +""" +import json +import os +from pathlib import Path +import signal +import subprocess +import time +from .errors import require +from .ipc import until, canonical, location +from .layout import compile_plan, trees +from .model import normalized + + +class Anchors: + def __init__(self, sway, data, tx, executable, env=None): + require(Path(executable).is_absolute() and Path(executable).is_file(), "ANCHOR_HELPER_UNAVAILABLE") + self.sway, self.data, self.tx = sway, data, tx + self.plan = compile_plan(data, tx) + self.ids = {} + self.active = True + self.process = subprocess.Popen([str(executable)], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, env=env, start_new_session=True) + self.pidfd = os.pidfd_open(self.process.pid) + self.allowed = {self.identity(s["slot_id"]) for s in data["window_slots"] if not s["state"]["floating"]} + + def identity(self, slot): return "labfy-v2-anchor-" + self.tx + "-" + slot + + def owned(self, node): + signal.pidfd_send_signal(self.pidfd, 0) + return node.get("pid") == self.process.pid and node.get("app_id") in self.allowed + + def anchor(self, slot): + require(self.active, "TRANSACTION_INACTIVE") + found = [n for n in self.sway.windows() if n.get("app_id") == self.identity(slot)] + require(len(found) == 1 and self.owned(found[0]), "ANCHOR_OWNERSHIP_INVALID") + return found[0] + + def mutate(self, slot, operation): + node = self.anchor(slot) + self.sway.command(f'[con_id={node["id"]}] {operation}') + + def build(self, progress=lambda *_: None): + outputs = {w["workspace"]: w["output"] for w in self.data["workspaces"]} + for op in self.plan: + if op.kind == "workspace": + self.sway.command("workspace " + json.dumps(op.workspace)) + self.sway.command("move workspace to output " + json.dumps(outputs[op.workspace])) + elif op.kind == "create": + self.process.stdin.write(("create " + self.identity(op.slot_id) + "\n").encode()) + self.process.stdin.flush() + until(lambda: any(n.get("app_id") == self.identity(op.slot_id) for n in self.sway.windows())) + self.ids[op.slot_id] = self.anchor(op.slot_id)["id"] + progress("anchor", len(self.ids)) + elif op.kind == "focus": self.mutate(op.slot_id, "focus") + elif op.kind == "layout": + self.mutate(op.slot_id, "focus") + layout = "stacking" if op.layout == "stacked" else op.layout + if op.root: self.mutate(op.slot_id, "layout " + layout) + else: + self.mutate(op.slot_id, "split " + ("v" if op.layout == "splitv" else "h")) + if op.layout in ("tabbed", "stacked"): self.mutate(op.slot_id, "layout " + layout) + else: require(False, "ANCHOR_PLAN_INVALID") + self.verify(self.ids) + progress("all-anchors", len(self.ids)) + + def verify(self, mapping): + tree = self.sway.tree() + reverse = {v: k for k, v in mapping.items()} + for t in trees(self.data): + require(canonical(tree, t.workspace, reverse) == normalized(t.root), "FINAL_TREE_MISMATCH") + + def swap(self, slot, target, target_workspace, stage, allowed_app_ids): + anchor = self.anchor(slot) + before = self.sway.tree() + live = next((n for n in self.sway.windows() if n["id"] == target["id"]), None) + require(live and live.get("pid") == target.get("pid") and live.get("app_id") == target.get("app_id") and live.get("app_id") in allowed_app_ids, + "TARGET_IDENTITY_CHANGED") + prior_a, prior_t = location(before, anchor["id"]), location(before, target["id"]) + require(prior_a and prior_a[0] == target_workspace and prior_t and prior_t[0] == stage, "SWAP_WORKSPACE_CHANGED") + self.sway.command(f'[con_id={live["id"]}] swap container with con_id {anchor["id"]}') + after = self.sway.tree() + require(location(after, live["id"]) == prior_a and location(after, anchor["id"]) == prior_t, "SWAP_POSTCONDITION_FAILED") + self.anchor(slot) + + def cleanup(self, progress=lambda *_: None): + progress("before-cleanup", 0) + count = 0 + for old in self.sway.windows(): + if not self.owned(old): continue + fresh = next((n for n in self.sway.windows() if n["id"] == old["id"]), None) + if fresh and self.owned(fresh): + self.sway.command(f'[con_id={fresh["id"]}] kill') + count += 1 + progress("cleanup", count) + until(lambda: not any(self.owned(n) for n in self.sway.windows())) + return count + + def close(self): + self.active = False + if not self.process.stdin.closed: self.process.stdin.close() + try: self.process.wait(timeout=3) + except subprocess.TimeoutExpired: + signal.pidfd_send_signal(self.pidfd, signal.SIGTERM) + self.process.wait(timeout=3) + os.close(self.pidfd) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/applications.py b/quickshell/.config/quickshell/labfy-sway/session_v2/applications.py new file mode 100644 index 000000000..255f4521b --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/applications.py @@ -0,0 +1,75 @@ +"""Politique au niveau Application ; argv viennent du catalogue de confiance. + +Le snapshot sélectionne seulement une entrée exacte du catalogue. Il ne peut +introduire aucun exécutable, argument, commande shell ni commande de terminal. +""" +from dataclasses import dataclass +import subprocess +from .errors import require, Failure + + +@dataclass(frozen=True) +class Spec: + application_id: str + desktop_entry: str + app_ids: tuple + strategy: str + argv: tuple + provider: str | None = None + managed_by: str | None = None + + +DEFAULT = ( + Spec("limusic", "limusic.desktop", ("limusic-app",), "managed-autostart", (), managed_by="sway-autostart"), + Spec("firefox", "firefox.desktop", ("firefox", "org.mozilla.firefox"), "browser-self-restore", ("/usr/bin/firefox",), "firefox-session-window-uuid"), + Spec("kitty", "kitty.desktop", ("kitty",), "terminal", ("/usr/bin/kitty",)), +) + + +class Catalog: + def __init__(self, specs=DEFAULT): + self.specs = {s.application_id: s for s in specs} + require(len(self.specs) == len(specs), "CATALOG_DUPLICATE") + ids = [v for s in specs for v in s.app_ids] + require(len(ids) == len(set(ids)), "CATALOG_AMBIGUOUS") + + def classify(self, node): + found = [s for s in self.specs.values() if node.get("app_id") in s.app_ids] + require(len(found) == 1, "APPLICATION_UNSUPPORTED") + return found[0] + + def validate(self, app): + require(app["application_id"] in self.specs, "APPLICATION_UNSUPPORTED") + s = self.specs[app["application_id"]] + require(app["desktop_entry"] == s.desktop_entry and app["strategy"] == s.strategy and app["identity_provider"] == s.provider, + "APPLICATION_POLICY_MISMATCH") + require(s.strategy != "unknown", "APPLICATION_UNSUPPORTED") + owner = (s.managed_by or "autostart") if s.strategy == "managed-autostart" else None + require(app["managed_by"] == owner, "APPLICATION_POLICY_MISMATCH") + return s + + +def launch_count(app, observed): + n = app["expected_windows"] + require(0 <= observed <= n, "UNEXPECTED_EXTRA_WINDOW") + strategy = app["strategy"] + if strategy == "managed-autostart": return 0 + if strategy == "single-window": + require(n == 1, "APPLICATION_POLICY_MISMATCH") + return 1 - observed + if strategy == "browser-self-restore": + # Une instance déjà présente n'autorise jamais une deuxième ouverture. + return 1 if observed == 0 else 0 + if strategy in ("terminal", "multi-instance"): return n - observed + raise Failure("APPLICATION_UNSUPPORTED") + + +def launch(spec, count, env=None): + require(type(count) is int and 0 <= count <= 256, "LAUNCH_BOUNDS") + children = [] + for _ in range(count): + # Les applications ne sont pas des ressources jetables : jamais tuées + # automatiquement lors d'un échec de restauration. + children.append(subprocess.Popen(list(spec.argv), stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, start_new_session=True)) + return children diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/checkpoint.py b/quickshell/.config/quickshell/labfy-sway/session_v2/checkpoint.py new file mode 100644 index 000000000..47661ddfe --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/checkpoint.py @@ -0,0 +1,34 @@ +"""Namespace distinct de V1 ; noms non interprétés comme chemins.""" +import re +from . import schema +from .storage import atomic, private_dir, read, sync_dir, lock +from .errors import require + + +class Store: + def __init__(self, root): + self.root = private_dir(root) + self.sessions = private_dir(self.root / "sessions") + self.automatic = private_dir(self.root / "automatic") + + def path(self, name): + require(type(name) is str and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", name), "SESSION_NAME_INVALID") + return self.sessions / (name + ".json") + + def save(self, name, data): + with lock(self.root, "store.lock"): atomic(self.path(name), data, schema.validate) + + def load(self, name): return read(self.path(name), schema.validate) + def list(self): return sorted(p.stem for p in self.sessions.glob("*.json") if not p.is_symlink()) + + def delete(self, name): + with lock(self.root, "store.lock"): + path = self.path(name) + read(path, schema.validate) + path.unlink() + sync_dir(self.sessions) + + def last(self): return read(self.automatic / "last.json", schema.validate) + + def checkpoint(self, data): + with lock(self.root, "store.lock"): atomic(self.automatic / "last.json", data, schema.validate) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py b/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py new file mode 100644 index 000000000..23b170dc6 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/cli.py @@ -0,0 +1,103 @@ +"""CLI backend ; pas d'appel logout/reboot, pas d'intégration QuickShell V1.""" +import argparse +from dataclasses import asdict +import json +import os +from pathlib import Path +import sys +import uuid +from .errors import Failure, require +from .storage import paths, read, atomic, lock, private_dir +from .schema import validate +from .checkpoint import Store +from .ipc import Sway +from .applications import Catalog +from .firefox_identity import FirefoxProvider +from .snapshot import capture +from .layout import compile_plan +from .executor import Executor +from .observability import validate as validate_report +from .startup import acknowledge + + +def migration(path): + value = read(path) + require(type(value) is dict, "V1_FORMAT_UNRECOGNIZED") + require(value.get("schema") == "labfy.sway.session-snapshot" and type(value.get("version")) is int and value["version"] == 1, "V1_FORMAT_UNRECOGNIZED") + # Analyse conservatrice, jamais de conversion ni copie des identités runtime. + windows = value.get("windows", []) + require(type(windows) is list and len(windows) <= 256, "V1_FORMAT_UNRECOGNIZED") + require(all(type(w) is dict for w in windows), "V1_FORMAT_UNRECOGNIZED") + require(all(type(w.get("scratchpad", {})) is dict for w in windows), "V1_FORMAT_UNRECOGNIZED") + known = all(w.get("app_id") in ("kitty", "firefox", "org.mozilla.firefox") for w in windows) + unsupported = any(w.get("fullscreen_mode") or w.get("scratchpad", {}).get("member") for w in windows) + classification = "unsupported" if not known or unsupported or not windows else "needs exact identity" if any(w.get("app_id") in ("firefox", "org.mozilla.firefox") for w in windows) else "migratable" + return {"classification": classification, "windows": len(windows), "migratable": classification == "migratable", + "reason": "READ_ONLY_ANALYSIS_NO_CONVERSION"} + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Backend de restauration de session V2") + parser.add_argument("command", choices=("capture", "save", "list", "show", "delete", "plan", "apply", "checkpoint-last", "startup-status", "acknowledge-startup", "apply-last", "restore-status", "analyze-v1-migration")) + parser.add_argument("name", nargs="?") + parser.add_argument("--execute", action="store_true") + parser.add_argument("--stdout", action="store_true") + parser.add_argument("--choice", choices=("new", "restored")) + parser.add_argument("--transaction-id") + parser.add_argument("--reason", choices=("logout", "reboot", "poweroff"), default="logout") + parser.add_argument("--socket", default=os.environ.get("SWAYSOCK", "")) + parser.add_argument("--anchor-binary", default=str(Path(__file__).parent / "libexec/labfy-v2-anchor")) + args = parser.parse_args(argv) + try: + if args.command in ("capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"): + require(args.execute, "EXECUTE_REQUIRED") + state, runtime = paths() + store = Store(state) + command = args.command + if command == "list": result = {"sessions": store.list()} + elif command == "show": result = store.load(args.name) + elif command == "delete": store.delete(args.name); result = {"status": "deleted"} + elif command == "restore-status": + 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)]} + else: + sway = Sway(args.socket) + catalog = Catalog() + provider = FirefoxProvider(sway, runtime) + if command == "acknowledge-startup": + result = acknowledge(runtime, sway.session, args.choice, args.transaction_id) + elif command == "startup-status": + try: last = store.last() + except FileNotFoundError: last = None + if last is None: + result = {"eligible": False, "claimed": False} + else: + marker = private_dir(runtime / "session-v2") / "startup.json" + with lock(marker.parent): + seen = read(marker) if marker.exists() else {} + eligible = last["metadata"]["sway_session"] != sway.session and seen.get("session") != sway.session + if args.execute and eligible: atomic(marker, {"session": sway.session}) + result = {"eligible": eligible, "claimed": bool(args.execute and eligible)} + result.update(windows=len(last['window_slots']), workspaces=len(last['workspaces'])) + elif command in ("capture", "save", "checkpoint-last"): + with lock(private_dir(runtime / "session-v2")): + data = capture(sway, catalog, provider, args.reason if command == "checkpoint-last" else "manual") + if command == "save": store.save(args.name, data) + elif command == "checkpoint-last": store.checkpoint(data) + result = data if command == "capture" else {"status": "saved"} + else: + data = store.last() if command == "apply-last" else store.load(args.name) + result = Executor(sway, catalog, provider, runtime, args.anchor_binary).apply(data, execute=True) + print(json.dumps(result, ensure_ascii=False)) + return 0 + except Failure as exc: + print(json.dumps({"status": "failed", "reason": exc.code})) + return 2 + except (OSError, TypeError, ValueError): + print(json.dumps({"status": "failed", "reason": "BACKEND_IO_OR_FORMAT_ERROR"})) + return 2 + + +if __name__ == "__main__": sys.exit(main()) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/errors.py b/quickshell/.config/quickshell/labfy-sway/session_v2/errors.py new file mode 100644 index 000000000..79ec588c8 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/errors.py @@ -0,0 +1,9 @@ +class Failure(Exception): + """Codes stables uniquement ; aucune exception externe ni contenu utilisateur.""" + def __init__(self, code): + self.code = code + super().__init__(code) + + +def require(condition, code): + if not condition: raise Failure(code) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py b/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py new file mode 100644 index 000000000..8a07b5bd3 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py @@ -0,0 +1,197 @@ +"""Transaction de restauration fail-safe, sans promesse de rollback ACID. + +Les applications sont conservées après erreur. Seul le helper possédé peut être +supprimé. La vérification de l'arbre n'est jamais remplacée par la cardinalité. +""" +import json +import time +import uuid +from pathlib import Path +from .errors import Failure, require +from .schema import validate, PROVIDER +from .storage import lock, private_dir +from .ipc import location, workspace, walk, until +from .applications import launch_count, launch +from .anchors import Anchors +from .guard import Guard +from .observability import Attempt +from .slots import assign + + +def preflight(data, sway, catalog, provider): + validate(data) + active = {o["name"] for o in sway.outputs() if o.get("active")} + require({o["name"] for o in data["outputs"]}.issubset(active), "OUTPUT_UNAVAILABLE") + specs = {a["application_id"]: catalog.validate(a) for a in data["applications"]} + if any(a["identity_provider"] == PROVIDER for a in data["applications"]): + require(provider is not None, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE") + nodes = sway.windows() + admitted = set() + for a in data["applications"]: + members = [n for n in nodes if n.get("app_id") in specs[a["application_id"]].app_ids] + launch_count(a, len(members)) + admitted.update(n["id"] for n in members) + tree = sway.tree() + for w in data["workspaces"]: + ws = workspace(tree, w["workspace"]) + if ws: + require(all(n["id"] in admitted for n in walk(ws) if n.get("app_id") or n.get("window")), "WORKSPACE_OCCUPIED") + return specs + + +class Executor: + def __init__(self, sway, catalog, provider, runtime, anchor_binary, env=None, progress=None): + self.sway, self.catalog, self.provider = sway, catalog, provider + self.runtime = private_dir(runtime) + self.binary, self.env = anchor_binary, env + self.progress = progress or (lambda *_: None) + + def apply(self, data, execute=False, timeout=30): + require(execute is True, "EXECUTE_REQUIRED") + require(type(timeout) in (int, float) and 0 < timeout <= 60, "TIMEOUT_INVALID") + with lock(private_dir(self.runtime / "session-v2")) as lock_fd: + return self._apply(data, timeout, lock_fd) + + def _apply(self, data, timeout, lock_fd): + validate(data) + tx = uuid.uuid4().hex + attempt = Attempt(self.runtime / "session-v2-restore-attempt.json", tx, data) + anchors, guard = None, None + mapping = {} + stage = "labfy-v2-stage-" + tx + try: + specs = preflight(data, self.sway, self.catalog, self.provider) + attempt.update(phase="guard") + anchors = Anchors(self.sway, data, tx, self.binary, self.env) + guard = Guard(self.sway, anchors, self.runtime, attempt.path, lock_fd) + attempt.update(helpers_suspended=guard.ready["helpers_suspended"]) + all_ids = {a for s in specs.values() for a in s.app_ids} + def stage_node(n): + fresh = next((x for x in self.sway.windows() if x["id"] == n["id"]), None) + require(fresh and fresh.get("pid") == n.get("pid") and fresh.get("app_id") == n.get("app_id") and fresh.get("app_id") in all_ids, + "TARGET_IDENTITY_CHANGED") + self.sway.command(f'[con_id={n["id"]}] move container to workspace ' + json.dumps(stage)) + for n in self.sway.windows(): + if n.get("app_id") in all_ids: stage_node(n) + def progress(phase, count): + values = {"phase": phase} + if phase == "anchor": values["anchors_created"] = count + elif phase == "cleanup": values["anchors_cleaned"] = count + attempt.update(**values) + self.progress(phase, count) + start = time.monotonic() + anchors.build(progress) + attempt.data["timings_ms"]["anchor_build"] = (time.monotonic() - start) * 1000 + attempt.update(phase="launch") + self.sway.command("workspace " + json.dumps(stage)) + children = [] + start = time.monotonic() + for app in data["applications"]: + spec = specs[app["application_id"]] + count = sum(n.get("app_id") in spec.app_ids for n in self.sway.windows()) + children.extend(launch(spec, launch_count(app, count), self.env)) + end = time.monotonic() + timeout + groups = {} + while True: + nodes = self.sway.windows() + groups = {a["application_id"]: [n for n in nodes if n.get("app_id") in specs[a["application_id"]].app_ids] for a in data["applications"]} + require(all(len(groups[a["application_id"]]) <= a["expected_windows"] for a in data["applications"]), "UNEXPECTED_EXTRA_WINDOW") + if all(len(groups[a["application_id"]]) == a["expected_windows"] for a in data["applications"]): break + require(time.monotonic() < end, "APPLICATION_WINDOWS_INCOMPLETE") + time.sleep(.025) + attempt.data["timings_ms"]["application_wait"] = (time.monotonic() - start) * 1000 + attempt.update(phase="resolve", applications_observed=len(groups)) + for app in data["applications"]: + members = [s for s in data["window_slots"] if s["application_id"] == app["application_id"]] + identity = None + if app["identity_provider"] == PROVIDER: + expected = [s["identity_evidence"]["id"] for s in members if s["identity_requirement"] == "exact"] + 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)) + captured = {n["id"]: n for group in groups.values() for n in group} + for n in captured.values(): + 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') + start = time.monotonic() + for slot in data["window_slots"]: + if slot["state"]["floating"]: continue + target = captured[mapping[slot["slot_id"]]] + 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"]) + attempt.data["timings_ms"]["swap"] = (time.monotonic() - start) * 1000 + start = time.monotonic() + anchors.cleanup(progress) + attempt.data["timings_ms"]["cleanup"] = (time.monotonic() - start) * 1000 + start = time.monotonic() + anchors.verify(mapping) + self._floating(data, mapping, captured) + 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") + if data["focus"]: + self.sway.command(f'[con_id={mapping[data["focus"]]}] focus') + answer = guard.resume() + attempt.update(helpers_resumed=answer["helpers_resumed"], phase="final-verification") + self.progress("after-resume", len(mapping)) + self._verify(data, mapping, specs, anchors) + require(data["focus"] is None or any(n["id"] == mapping[data["focus"]] and n.get("focused") for n in self.sway.windows()), "FINAL_FOCUS_MISMATCH") + attempt.update(final_focus_verified=True, status="success", reason="SUCCESS", phase="complete") + guard.finish() + guard = None + return attempt.data + except Exception as exc: + code = exc.code if isinstance(exc, Failure) else "RESTORE_INTERNAL_ERROR" + attempt.update(status="failed", reason=code, phase="cleanup-error") + raise Failure(code) from None + finally: + if guard is not None: + try: + answer = guard.finish() + attempt.update(helpers_resumed=answer["helpers_resumed"], anchors_cleaned=attempt.data["anchors_cleaned"] + answer["anchors_cleaned"]) + except Exception: attempt.update(status="failed", reason="GUARD_RECOVERY_FAILED") + if anchors is not None: anchors.close() + + def _floating(self, data, mapping, captured): + for s in data["window_slots"]: + if not s["state"]["floating"]: continue + 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") + 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. + 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(f'[con_id={target}] floating enable') + g = s["state"]["geometry"] + 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') + + def _verify(self, data, mapping, specs, anchors): + anchors.verify(mapping) + output_for = {w["name"]: o["name"] for o in self.sway.tree().get("nodes", []) + for w in walk(o) if w.get("type") == "workspace"} + require(all(output_for.get(w["workspace"]) == w["output"] for w in data["workspaces"]), "FINAL_OUTPUT_MISMATCH") + nodes = self.sway.windows() + for a in data["applications"]: + observed = sum(n.get("app_id") in specs[a["application_id"]].app_ids for n in nodes) + require(observed >= a["expected_windows"], "APPLICATION_WINDOWS_INCOMPLETE") + require(observed <= a["expected_windows"], "UNEXPECTED_EXTRA_WINDOW") + 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") + if s["state"]["floating"]: + require(location(self.sway.tree(), n["id"])[0] == s["workspace"] and n.get("floating") in ("auto_on", "user_on") and + all(n["rect"][k] == v for k, v in s["state"]["geometry"].items()), "FLOATING_GEOMETRY_MISMATCH") + for a in data["applications"]: + if a["identity_provider"] != PROVIDER: continue + members = [n for n in nodes if n.get("app_id") in specs[a["application_id"]].app_ids] + identities = self.provider.capture(members) + for s in data["window_slots"]: + if s["application_id"] == a["application_id"] and s["identity_requirement"] == "exact": + require(identities.get(s["identity_evidence"]["id"]) == mapping[s["slot_id"]], "EXACT_WINDOW_IDENTITY_MISSING") diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/firefox_identity.py b/quickshell/.config/quickshell/labfy-sway/session_v2/firefox_identity.py new file mode 100644 index 000000000..f8c8ea8d6 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/firefox_identity.py @@ -0,0 +1,89 @@ +"""Interface provider ; observations runtime privées, vivantes et périssables. + +Le moteur ne connaît ni WebExtension ni titrePreface. Le provider exige un mapping +complet du jeu courant de surfaces, avec publisher vivant, puis un arbre stable. +Les fichiers runtime ne sont jamais des identités persistantes du snapshot. +""" +from abc import ABC, abstractmethod +import os +from pathlib import Path +import signal +import time +from .errors import Failure, require +from .schema import UUID, PROVIDER, fields, integer +from .storage import private_dir, read, atomic +from .ipc import until + + +class IdentityProvider(ABC): + @abstractmethod + def available(self): pass + @abstractmethod + def capture(self, nodes, timeout=6): pass + @abstractmethod + def resolve(self, expected, nodes, timeout=6): pass + def verify(self, mapping, nodes): return self.capture(nodes) == mapping + + +def process_start(pid): return Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()[19] + + +def validate_record(r): + fields(r, "schema version session uuid con_id observed_ns publisher_pid publisher_start") + require(r["schema"] == PROVIDER and type(r["version"]) is int and r["version"] == 1, "PROVIDER_RECORD_INVALID") + require(type(r["uuid"]) is str and UUID.fullmatch(r["uuid"]), "INVALID_UUID") + for key in ("con_id", "observed_ns", "publisher_pid"): integer(r[key], 1, 2**63 - 1) + require(type(r["publisher_start"]) is str and r["publisher_start"].isdigit(), "PROVIDER_RECORD_INVALID") + return r + + +class FirefoxProvider(IdentityProvider): + def __init__(self, sway, runtime): + self.sway = sway + self.directory = private_dir(Path(runtime) / "session-v2/firefox" / sway.session) + + def available(self): + for p in list(self.directory.glob("*.json"))[:257]: + if not p.stem.isdigit(): continue + try: + if self._valid(p): return True + except (OSError, Failure): continue + return False + + def _valid(self, path): + r = read(path, validate_record) + require(r["session"] == self.sway.session and 0 <= time.monotonic_ns() - r["observed_ns"] < 3_000_000_000, "PROVIDER_STALE") + require(Path(f'/proc/{r["publisher_pid"]}').stat().st_uid == os.getuid(), "PROVIDER_STALE") + fd = os.pidfd_open(r["publisher_pid"]) + try: + require(process_start(r["publisher_pid"]) == r["publisher_start"], "PROVIDER_STALE") + signal.pidfd_send_signal(fd, 0) + finally: os.close(fd) + return r + + def capture(self, nodes, timeout=6): + expected = {n["id"] for n in nodes} + if not expected: return {} + requested = time.monotonic_ns() + atomic(self.directory / "request.json", {"expires_ns": requested + int(timeout * 1e9)}) + def collect(): + try: + rows = [self._valid(self.directory / f"{i}.json") for i in sorted(expected)] + if any(r["observed_ns"] < requested for r in rows): return None + except (OSError, Failure): return None + require(len({r["publisher_pid"] for r in rows}) == 1, "PROVIDER_MULTIPLE_PROFILES_UNSUPPORTED") + require(len({r["uuid"] for r in rows}) == len(rows), "EXACT_WINDOW_IDENTITY_COLLISION") + live = {n["id"]: n for n in self.sway.windows()} + require(expected.issubset(live), "APPLICATION_WINDOWS_INCOMPLETE") + # Ne livrer qu'après retrait du préfixe, sans conserver les titres. + if any((live[i].get("name") or "").startswith("[LABFY:") for i in expected): return None + return {r["uuid"]: r["con_id"] for r in rows} + try: return until(collect, timeout, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE") + finally: atomic(self.directory / "request.json", {"expires_ns": 0}) + + def resolve(self, expected, nodes, timeout=6): + result = self.capture(nodes, timeout) + require(set(expected).issubset(result), "EXACT_WINDOW_IDENTITY_MISSING") + # Les autres fenêtres peuvent correspondre à des slots explicitement + # interchangeables ; la cardinalité applicative est vérifiée par l'executor. + return result diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/guard.py b/quickshell/.config/quickshell/labfy-sway/session_v2/guard.py new file mode 100644 index 000000000..373d7e7f0 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/guard.py @@ -0,0 +1,212 @@ +"""Watchdog externe : reprise ne dépendant jamais du finally de l'executor. + +CONTRACT : PIDFD hérités, UID/executable/script/SWAYSOCK vérifiés avant signal. +Le lock de transaction est hérité : pas de tentative concurrente pendant recovery. +INVARIANT : un helper initialement stoppé n'est jamais repris par Labfy. +Limite : la mort simultanée du watchdog/compositeur n'est pas une transaction ACID. +""" +import json +import os +from pathlib import Path +import select +import shutil +import signal +import socket +import subprocess +import sys +import time +from .errors import require +from .ipc import Sway, until +from .storage import atomic, read, loads + + +def helper_state(pid): return Path(f"/proc/{pid}/status").read_text().split("State:\t", 1)[1][0] + + +def verify_helper(pid, sway_socket, script): + root = Path(f"/proc/{pid}") + require(root.stat().st_uid == os.getuid(), "HELPER_UID_INVALID") + argv = (root / "cmdline").read_bytes().split(b"\0") + expected_exe = str(Path(sys.executable).resolve()) + require(str((root / "exe").resolve()) == expected_exe, "HELPER_EXECUTABLE_INVALID") + require(len(argv) > 1 and os.fsdecode(argv[1]) == script, "HELPER_SCRIPT_INVALID") + with (root / "environ").open("rb") as stream: env = stream.read(65537) + require(len(env) <= 65536 and (b"SWAYSOCK=" + os.fsencode(sway_socket)) in env.split(b"\0"), "HELPER_SESSION_INVALID") + + +def discover(sway_socket): + script = shutil.which("autotiling") + if not script: return [] + script = os.path.abspath(script) + helpers = [] + for root in Path("/proc").iterdir(): + if not root.name.isdigit(): continue + fd = None + try: + verify_helper(int(root.name), sway_socket, script) + fd = os.pidfd_open(int(root.name)) + verify_helper(int(root.name), sway_socket, script) + helpers.append({"pid": int(root.name), "fd": fd, "script": script}) + fd = None + except Exception as exc: + # Les autres processus ne sont ni des erreurs de restore, ni ciblés. + from .errors import Failure + if not isinstance(exc, (OSError, ValueError, Failure)): raise + finally: + if fd is not None: os.close(fd) + if len(helpers) > 8: + for h in helpers: os.close(h["fd"]) + require(False, "HELPER_COUNT_LIMIT") + return helpers + + +class Guard: + def __init__(self, sway, anchors, runtime, report_path, lock_fd, deadline=120): + self.helpers = discover(sway.path) + self.parent, child = socket.socketpair(type=socket.SOCK_SEQPACKET) + engine_fd = os.pidfd_open(os.getpid()) + read_fd, write_fd = os.pipe() + config = {"socket": sway.path, "engine_fd": engine_fd, "anchor_pid": anchors.process.pid, + "anchor_fd": anchors.pidfd, "writer_fd": anchors.process.stdin.fileno(), "allowed": sorted(anchors.allowed), + "helpers": self.helpers, "control": child.fileno(), "report": str(report_path), "deadline": deadline} + fds = (read_fd, engine_fd, anchors.pidfd, anchors.process.stdin.fileno(), child.fileno(), lock_fd, + *(h["fd"] for h in self.helpers)) + try: + self.process = subprocess.Popen([sys.executable, "-B", "-m", "session_v2.guard", str(read_fd)], + pass_fds=fds, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, + env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parent.parent)}) + except Exception: + os.close(write_fd) + self.parent.close() + for h in self.helpers: os.close(h["fd"]) + raise + finally: + os.close(read_fd) + os.close(engine_fd) + child.close() + with os.fdopen(write_fd, "wb") as stream: stream.write(json.dumps(config).encode()) + self.parent.settimeout(10) + try: + self.ready = loads(self.parent.recv(4096), 4096) + require(self.ready.get("status") == "ready", "GUARD_START_FAILED") + except Exception: + # EOF réveille le guard même si l'executor n'a pas reçu l'ACK. + self.parent.close() + for h in self.helpers: os.close(h["fd"]) + self.process.wait(timeout=15) + raise + + def resume(self): + self.parent.sendall(b"resume") + self.parent.settimeout(15) + answer = loads(self.parent.recv(4096), 4096) + require(answer.get("status") == "resumed", "GUARD_RECOVERY_FAILED") + return answer + + def finish(self): + self.parent.sendall(b"finish") + self.parent.settimeout(15) + answer = loads(self.parent.recv(4096), 4096) + self.process.wait(timeout=5) + self.parent.close() + for h in self.helpers: os.close(h["fd"]) + require(answer.get("status") == "finished", "GUARD_RECOVERY_FAILED") + return answer + + +def resume_helpers(stopped, sway_socket, counts): + """Une identité devenue invalide ne doit pas empêcher les autres reprises. + + CONTRACT : ne jamais signaler un processus non vérifié ; tenter tous les + helpers encore dus, puis signaler un échec global sans perdre leurs états. + """ + okay = True + for h in stopped: + if h.get("resumed"): continue + try: + verify_helper(h["pid"], sway_socket, h["script"]) + signal.pidfd_send_signal(h["fd"], signal.SIGCONT) + until(lambda: helper_state(h["pid"]) not in ("T", "t"), 2) + h["resumed"] = True + counts["helpers_resumed"] += 1 + except (ProcessLookupError, FileNotFoundError): h["resumed"] = True + except Exception: okay = False + return okay + + +def run(config): + control = socket.socket(fileno=config["control"]) + stopped = [] + counts = {"helpers_suspended": 0, "helpers_resumed": 0} + normal = False + sway = Sway(config["socket"]) + def resume(): + return resume_helpers(stopped, config["socket"], counts) + try: + for h in config["helpers"]: + verify_helper(h["pid"], config["socket"], h["script"]) + if helper_state(h["pid"]) in ("T", "t"): continue + # Enregistrer l'obligation avant SIGSTOP : une exception après signal + # ne doit pas perdre la responsabilité de reprise. + stopped.append(h) + signal.pidfd_send_signal(h["fd"], signal.SIGSTOP) + until(lambda: helper_state(h["pid"]) in ("T", "t"), 2) + counts["helpers_suspended"] += 1 + control.sendall(json.dumps({"status": "ready", **counts}).encode()) + end = time.monotonic() + config["deadline"] + while True: + readable, _, _ = select.select([config["engine_fd"], control], [], [], max(0, end - time.monotonic())) + if config["engine_fd"] in readable: break + if control in readable: + message = control.recv(16) + if message == b"resume": + require(resume(), "GUARD_RECOVERY_FAILED") + control.sendall(json.dumps({"status": "resumed", **counts}).encode()) + continue + normal = message == b"finish" + break + if not readable: + signal.pidfd_send_signal(config["engine_fd"], signal.SIGKILL) + until(lambda: bool(select.select([config["engine_fd"]], [], [], 0)[0]), 3) + break + require(resume(), "GUARD_RECOVERY_FAILED") + def owned(n): + signal.pidfd_send_signal(config["anchor_fd"], 0) + return n.get("pid") == config["anchor_pid"] and n.get("app_id") in config["allowed"] + cleaned = 0 + for old in 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): + 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 normal: + data = read(config["report"]) + if data["status"] != "success": + data.update(status="interrupted", phase="recovered", reason="EXECUTOR_INTERRUPTED") + data.update(**counts) + data["anchors_cleaned"] += cleaned + atomic(config["report"], data) + else: control.sendall(json.dumps({"status": "finished", "anchors_cleaned": cleaned, **counts}).encode()) + except Exception: + resume() + # Arrêter seulement le helper possédé retire aussi ses anchors orphelins. + try: signal.pidfd_send_signal(config["anchor_fd"], signal.SIGTERM) + except ProcessLookupError: pass + try: + data = read(config["report"]) + data.update(status="interrupted", phase="guard-error", reason="GUARD_RECOVERY_FAILED", **counts) + atomic(config["report"], data) + control.sendall(b'{"status":"failed"}') + except (OSError, BrokenPipeError): pass + finally: + resume() + control.close() + os.close(config["writer_fd"]) + + +if __name__ == "__main__": + with os.fdopen(int(sys.argv[1]), "rb") as stream: config = loads(stream.read(65537), 65536) + run(config) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/ipc.py b/quickshell/.config/quickshell/labfy-sway/session_v2/ipc.py new file mode 100644 index 000000000..b029c158a --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/ipc.py @@ -0,0 +1,91 @@ +"""Sway IPC explicite et borné. Le socket identifie la session, pas un PID persisté.""" +import hashlib +import json +import os +import socket +import stat +import struct +import time +from .errors import Failure, require + + +def walk(node): + yield node + for c in node.get("nodes", []) + node.get("floating_nodes", []): yield from walk(c) + + +def until(fn, timeout=5, code="RUNTIME_TIMEOUT"): + end = time.monotonic() + timeout + while True: + value = fn() + if value: return value + if time.monotonic() >= end: raise Failure(code) + time.sleep(.025) + + +class Sway: + def __init__(self, path): + require(type(path) is str and os.path.isabs(path), "SWAY_SOCKET_INVALID") + st = os.lstat(path) + require(stat.S_ISSOCK(st.st_mode) and st.st_uid == os.getuid(), "SWAY_SOCKET_INVALID") + self.path = path + self.identity = (st.st_dev, st.st_ino) + self.session = hashlib.sha256(f"{path}:{st.st_dev}:{st.st_ino}:{st.st_ctime_ns}".encode()).hexdigest() + + def request(self, kind, payload=b""): + st = os.lstat(self.path) + require((st.st_dev, st.st_ino) == self.identity and st.st_uid == os.getuid(), "SWAY_SESSION_CHANGED") + with socket.socket(socket.AF_UNIX) as connection: + end = time.monotonic() + 3 + connection.settimeout(3) + connection.connect(self.path) + _, uid, _ = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + require(uid == os.getuid(), "SWAY_PEER_INVALID") + connection.sendall(struct.pack("<6sII", b"i3-ipc", len(payload), kind) + payload) + def take(n): + result = bytearray() + while len(result) < n: + require(time.monotonic() < end, "IPC_TIMEOUT") + connection.settimeout(max(.001, end - time.monotonic())) + part = connection.recv(n - len(result)) + require(part, "IPC_CLOSED") + result.extend(part) + return result + magic, size, answer = struct.unpack("<6sII", take(14)) + require(magic == b"i3-ipc" and answer == kind and size <= 4 * 1024 * 1024, "IPC_BOUNDS") + return json.loads(take(size)) + + def tree(self): return self.request(4) + def outputs(self): return self.request(3) + def windows(self): return [n for n in walk(self.tree()) if n.get("app_id") or n.get("window")] + def command(self, text): + result = self.request(0, text.encode()) + require(result and all(r.get("success") for r in result), "SWAY_COMMAND_FAILED") + + +def location(tree, identity): + def find(node, ws=None, path=()): + if node.get("type") == "workspace": ws, path = node["name"], () + if node["id"] == identity: return ws, path + for i, c in enumerate(node.get("nodes", [])): + found = find(c, ws, path + (i,)) + if found: return found + for c in node.get("floating_nodes", []): + found = find(c, ws, ()) + if found: return found + return find(tree) + + +def workspace(tree, name): + return next((n for n in walk(tree) if n.get("type") == "workspace" and n.get("name") == name), None) + + +def canonical(tree, name, mapping): + ws = workspace(tree, name) + if not ws or not ws["nodes"]: return None + def norm(node): + if node.get("app_id") or node.get("window"): return mapping.get(node["id"], "UNEXPECTED") + children = [norm(c) for c in node.get("nodes", [])] + if len(children) == 1 and node["layout"] in ("splith", "splitv", "none"): return children[0] + return {node["layout"]: children} + return norm(ws) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/layout.py b/quickshell/.config/quickshell/labfy-sway/session_v2/layout.py new file mode 100644 index 000000000..5913c51ff --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/layout.py @@ -0,0 +1,46 @@ +"""Compilation pure, top-down, arité conservée ; aucune opération de lancement.""" +from dataclasses import dataclass +from .model import SplitNode, leaves, WorkspaceTree, decode_tree + + +@dataclass(frozen=True) +class Operation: + kind: str + transaction_id: str + workspace: str + slot_id: str = "" + layout: str = "" + parent: tuple = () + child_order: int = 0 + root: bool = False + + +def trees(data): return [WorkspaceTree(w["workspace"], w["output"], decode_tree(w["root"])) for w in data["workspaces"]] + + +def compile_plan(data, tx): + from .schema import validate + import re + from .errors import require + validate(data) + require(re.fullmatch(r"[0-9a-f]{32}", tx), "TRANSACTION_ID_INVALID") + plan = [] + for t in trees(data): + def emit(kind, slot="", layout="", parent=(), order=0, root=False): + plan.append(Operation(kind, tx, t.workspace, slot, layout, parent, order, root)) + def build(node, path=(), root=False): + if not isinstance(node, SplitNode): return + first = leaves(node.children[0])[0] + emit("layout", first, node.layout, path, root=root) + previous = first + for i, child in enumerate(node.children[1:], 1): + emit("focus", previous) + seed = leaves(child)[0] + emit("create", seed, parent=path, order=i) + previous = seed + for i, child in enumerate(node.children): build(child, path + (i,)) + if t.root is not None: + emit("workspace") + emit("create", leaves(t.root)[0]) + build(t.root, root=True) + return tuple(plan) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/model.py b/quickshell/.config/quickshell/labfy-sway/session_v2/model.py new file mode 100644 index 000000000..f897477fd --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/model.py @@ -0,0 +1,59 @@ +"""Identités persistantes pures. Aucun PID, con_id ni commande exécutable.""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Application: + application_id: str + desktop_entry: str + strategy: str + expected_windows: int + managed_by: str | None + identity_provider: str | None + + +@dataclass(frozen=True) +class WindowSlot: + slot_id: str + application_id: str + workspace: str + tree_path: tuple + state: dict + identity_requirement: str + identity_evidence: dict | None + + +@dataclass(frozen=True) +class WindowSlotRef: + slot_id: str + + +@dataclass(frozen=True) +class SplitNode: + layout: str + children: tuple + + +@dataclass(frozen=True) +class WorkspaceTree: + workspace: str + output: str + root: object + + +def decode_tree(value): + if value is None: return None + if "slot_id" in value: return WindowSlotRef(value["slot_id"]) + return SplitNode(value["layout"], tuple(decode_tree(c) for c in value["children"])) + + +def leaves(node): + if node is None: return [] + if isinstance(node, WindowSlotRef): return [node.slot_id] + return [s for c in node.children for s in leaves(c)] + + +def normalized(node): + if node is None: return None + if isinstance(node, WindowSlotRef): return node.slot_id + return {node.layout: [normalized(c) for c in node.children]} diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/native_host.py b/quickshell/.config/quickshell/labfy-sway/session_v2/native_host.py new file mode 100644 index 000000000..a88472439 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/native_host.py @@ -0,0 +1,111 @@ +"""Host production candidat : seul map-window ; Sway GET_TREE exclusivement. + +WHY : corrélation par jeton sans lire le suffixe sémantique ni l'exporter. +CONTRACT : message natif <=4096, clés exactes, aucun shell/argv reçu. +INVARIANT : publication privée runtime seulement, sans URL/titre/environnement. +""" +import json +import os +import re +import select +import struct +import sys +import time +from .errors import require, Failure +from .schema import UUID, PROVIDER +from .storage import loads, atomic, paths, read +from .ipc import Sway +from .firefox_identity import FirefoxProvider, process_start, validate_record + +EXTENSION_ID = "session-v2@labfy.org" +APP_IDS = {"firefox", "org.mozilla.firefox"} + + +def validate(message): + require(type(message) is dict and set(message) == {"op", "uuid", "runtime_token"}, "MESSAGE_SCHEMA") + require(message["op"] == "map-window", "OP_NOT_ALLOWED") + require(type(message["uuid"]) is str and UUID.fullmatch(message["uuid"]), "INVALID_UUID") + require(type(message["runtime_token"]) is str and re.fullmatch(r"[0-9a-f]{32}", message["runtime_token"]), "INVALID_TOKEN") + return message + + +def take(stream, n, timeout=5): + end, result = time.monotonic() + timeout, bytearray() + while len(result) < n: + require(time.monotonic() < end and select.select([stream], [], [], max(0, end - time.monotonic()))[0], "MESSAGE_TIMEOUT") + data = stream.read(n - len(result)) + if not data: raise EOFError() + result.extend(data) + return bytes(result) + + +def receive(stream): + size, = struct.unpack("=I", take(stream, 4)) + require(0 < size <= 4096, "MESSAGE_BOUNDS") + return validate(loads(take(stream, size), 4096)) + + +def send(stream, value): + raw = json.dumps(value, separators=(",", ":")).encode() + require(len(raw) <= 4096, "MESSAGE_BOUNDS") + stream.write(struct.pack("=I", len(raw)) + raw) + stream.flush() + + +def correlate(sway, message): + validate(message) + prefix = "[LABFY:" + message["runtime_token"] + "] " + end = time.monotonic() + 2 + while True: + found = [n["id"] for n in sway.windows() if n.get("app_id") in APP_IDS and (n.get("name") or "").startswith(prefix)] + require(len(found) <= 1, "EXACT_WINDOW_IDENTITY_COLLISION") + if found: return found[0] + require(time.monotonic() < end, "CORRELATION_TIMEOUT") + time.sleep(.02) + + +def main(): + # Firefox transmet manifest + ID. Le manifest allowlist est l'autorité ; + # l'ID est aussi contrôlé ici, sans accepter de paramètres de configuration. + require(len(sys.argv) == 3 and sys.argv[2] == EXTENSION_ID, "EXTENSION_NOT_ALLOWED") + sway = Sway(os.environ.get("SWAYSOCK", "")) + _, runtime = paths() + provider = FirefoxProvider(sway, runtime) + last_request = 0 + while True: + try: + # Le host ne demande une corrélation que pendant une requête locale + # bornée du moteur ; aucun clignotement de préfixe en idle. + if not select.select([sys.stdin.buffer.raw], [], [], .1)[0]: + try: + request = read(provider.directory / "request.json") + now = time.monotonic_ns() + if type(request) is dict and set(request) == {"expires_ns"} and type(request["expires_ns"]) is int and now < request["expires_ns"] <= now + 10_000_000_000 and now - last_request > 500_000_000: + send(sys.stdout.buffer, {"event": "capture"}) + last_request = now + except FileNotFoundError: pass + continue + message = receive(sys.stdin.buffer.raw) + identity = correlate(sway, message) + record = {"schema": PROVIDER, "version": 1, "session": sway.session, "uuid": message["uuid"], + "con_id": identity, "observed_ns": time.monotonic_ns(), "publisher_pid": os.getpid(), + "publisher_start": process_start(os.getpid())} + atomic(provider.directory / f"{identity}.json", record, validate_record) + # Collecte des seules observations périmées de ce namespace possédé. + for p in list(provider.directory.glob("*.json"))[:512]: + if not p.stem.isdigit() or p.name == f"{identity}.json": continue + if time.time() - p.stat().st_mtime > 10: p.unlink() + send(sys.stdout.buffer, {"result": "OK", "uuid": message["uuid"], "con_id": identity}) + except EOFError: return + except Failure as exc: + send(sys.stdout.buffer, {"result": exc.code}) + # Après un framing invalide il n'existe aucun point de resynchronisation. + if exc.code.startswith(("MESSAGE_", "JSON_")): return + except (OSError, ValueError, RecursionError): + send(sys.stdout.buffer, {"result": "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE"}) + return + + +if __name__ == "__main__": + try: main() + except (Failure, OSError): sys.exit(1) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/observability.py b/quickshell/.config/quickshell/labfy-sway/session_v2/observability.py new file mode 100644 index 000000000..0b95ec902 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/observability.py @@ -0,0 +1,50 @@ +"""Journal runtime borné, sans texte externe ni contenu de navigation.""" +from .storage import atomic +from .errors import require +import math +import re +from .schema import UUID, PROVIDER + +FIELDS = "schema version transaction_id phase status reason applications_expected applications_observed slots_expected slots_filled anchors_created anchors_swapped anchors_cleaned helpers_suspended helpers_resumed providers identity_resolutions identity_level final_tree_verified final_focus_verified timings_ms".split() + + +def validate(value): + require(type(value) is dict and set(value) == set(FIELDS), "REPORT_SCHEMA") + require(value["schema"] == "labfy.sway.session-v2-restore-attempt" and type(value["version"]) is int and value["version"] == 1, "REPORT_SCHEMA") + require(type(value["transaction_id"]) is str and re.fullmatch(r"[a-f0-9]{32}", value["transaction_id"]), "REPORT_SCHEMA") + require(value["status"] in ("running", "success", "failed", "interrupted"), "REPORT_SCHEMA") + for key in ("phase", "reason"): + require(type(value[key]) is str and re.fullmatch(r"[A-Za-z_-]{1,64}", value[key]), "REPORT_SCHEMA") + for key in ("applications_expected", "applications_observed", "slots_expected", "slots_filled", "anchors_created", "anchors_swapped", "anchors_cleaned", "helpers_suspended", "helpers_resumed"): + require(type(value[key]) is int and 0 <= value[key] <= 256, "REPORT_SCHEMA") + require(value["providers"] in ([], [PROVIDER]), "REPORT_SCHEMA") + require(value["identity_level"] in ("exact", "interchangeable", "best-effort"), "REPORT_SCHEMA") + for key in ("final_tree_verified", "final_focus_verified"): require(type(value[key]) is bool, "REPORT_SCHEMA") + require(type(value["identity_resolutions"]) is list and len(value["identity_resolutions"]) <= 256, "REPORT_SCHEMA") + for r in value["identity_resolutions"]: + require(type(r) is dict and set(r) == {"uuid", "con_id"} and type(r["uuid"]) is str and UUID.fullmatch(r["uuid"]) and + type(r["con_id"]) is int and 0 < r["con_id"] < 2**63, "REPORT_SCHEMA") + require(type(value["timings_ms"]) is dict and set(value["timings_ms"]).issubset({"anchor_build", "swap", "cleanup", "verification", "application_wait"}), "REPORT_SCHEMA") + for v in value["timings_ms"].values(): require(type(v) in (float, int) and math.isfinite(v) and 0 <= v <= 1e6, "REPORT_SCHEMA") + return value + + +class Attempt: + def __init__(self, path, tx, data): + self.path = path + self.data = {"schema": "labfy.sway.session-v2-restore-attempt", "version": 1, + "transaction_id": tx, "phase": "validate", "status": "running", "reason": "NONE", + "applications_expected": len(data["applications"]), "applications_observed": 0, + "slots_expected": len(data["window_slots"]), "slots_filled": 0, + "anchors_created": 0, "anchors_swapped": 0, "anchors_cleaned": 0, + "helpers_suspended": 0, "helpers_resumed": 0, "providers": [], "identity_resolutions": [], + "identity_level": "best-effort" if any(s["identity_requirement"] == "best-effort" for s in data["window_slots"]) else + "interchangeable" if any(s["identity_requirement"] == "interchangeable" for s in data["window_slots"]) else "exact", + "final_tree_verified": False, "final_focus_verified": False, "timings_ms": {}} + self.publish() + + def publish(self): atomic(self.path, self.data, validate) + def update(self, **values): + require(set(values).issubset(FIELDS), "REPORT_SCHEMA") + self.data.update(values) + self.publish() diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v1.json b/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v1.json new file mode 100644 index 000000000..0e31d1e0b --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v1.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:labfy:sway:session-v2:1", + "type": "object", "additionalProperties": false, + "required": ["schema", "version", "metadata", "outputs", "applications", "window_slots", "workspaces", "focus"], + "properties": { + "schema": {"const": "labfy.sway.session-v2"}, "version": {"const": 1, "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"}]} + }, + "$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}$"}, + "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": {"$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": {"const": false}}} + } + }, + "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 new file mode 100644 index 000000000..151acd099 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py @@ -0,0 +1,125 @@ +"""Validation versionnée, exacte et référentielle avant toute mutation. + +Les bornes contrôlent le travail local ; aucun ancien identifiant runtime n'est +accepté. Un état non implémenté est refusé, jamais silencieusement oublié. +""" +import re +from .errors import require + +SCHEMA = "labfy.sway.session-v2" +LAYOUTS = {"splith", "splitv", "tabbed", "stacked"} +STRATEGIES = {"managed-autostart", "single-window", "multi-instance", "terminal", "browser-self-restore", "unknown"} +PROVIDER = "firefox-session-window-uuid" +UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\Z") +IDENT = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,95}\Z") +PLACE = re.compile(r"[A-Za-z0-9 _:-]{1,64}\Z") + + +def fields(value, names): + require(type(value) is dict and set(value) == set(names.split()), "SCHEMA_FIELDS") + + +def integer(value, low, high): + require(type(value) is int and low <= value <= high, "SCHEMA_INTEGER") + + +def text(value, pattern=IDENT): + require(type(value) is str and pattern.fullmatch(value), "SCHEMA_STRING") + + +def array(value, maximum=256): + require(type(value) is list and len(value) <= maximum, "SCHEMA_ARRAY") + + +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["metadata"], "created_ns sway_session reason") + integer(data["metadata"]["created_ns"], 0, 2**63 - 1) + text(data["metadata"]["sway_session"]) + require(data["metadata"]["reason"] in ("manual", "logout", "reboot", "poweroff"), "SCHEMA_REASON") + for key in ("outputs", "applications", "window_slots", "workspaces"): array(data[key]) + outputs, apps, slots, workspaces = {}, {}, {}, {} + for o in data["outputs"]: + fields(o, "name") + text(o["name"], PLACE) + require(o["name"] not in outputs, "DUPLICATE_OUTPUT") + outputs[o["name"]] = o + for a in data["applications"]: + fields(a, "application_id desktop_entry strategy expected_windows managed_by identity_provider") + text(a["application_id"]) + text(a["desktop_entry"]) + require(a["desktop_entry"].endswith(".desktop"), "SCHEMA_DESKTOP_ENTRY") + require(type(a["strategy"]) is str and a["strategy"] in STRATEGIES, "SCHEMA_STRATEGY") + integer(a["expected_windows"], 1, 256) + require(a["managed_by"] in (None, "autostart", "sway-autostart"), "SCHEMA_MANAGED_BY") + require(a["identity_provider"] in (None, PROVIDER), "SCHEMA_PROVIDER") + require(a["application_id"] not in apps, "DUPLICATE_APPLICATION") + apps[a["application_id"]] = a + for s in data["window_slots"]: + 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) + 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") + require(s["identity_requirement"] in ("exact", "best-effort", "interchangeable"), "SCHEMA_IDENTITY") + 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, + "CAPABILITY_UNSUPPORTED") + if state["floating"]: + fields(state["geometry"], "x y width height") + for k in ("x", "y"): integer(state["geometry"][k], -100000, 100000) + for k in ("width", "height"): integer(state["geometry"][k], 1, 32768) + require(s["tree_path"] == [], "FLOATING_TREE_PATH") + else: require(state["geometry"] is None, "TILING_GEOMETRY") + evidence = s["identity_evidence"] + if s["identity_requirement"] == "exact": + fields(evidence, "type id") + if evidence["type"] == PROVIDER: + require(type(evidence["id"]) is str and UUID.fullmatch(evidence["id"]), "INVALID_UUID") + require(apps[s["application_id"]]["identity_provider"] == PROVIDER, "PROVIDER_REFERENCE") + else: + require(evidence["type"] == "application-singleton" and evidence["id"] == s["application_id"] and + apps[s["application_id"]]["expected_windows"] == 1, "EXACT_IDENTITY_UNSUPPORTED") + else: require(evidence is None, "SCHEMA_IDENTITY") + slots[s["slot_id"]] = s + seen = set() + def tree(node, workspace, path=()): + require(len(path) <= 32, "TREE_DEPTH") + if node is None: return + require(type(node) is dict, "TREE_NODE") + if "slot_id" in node: + fields(node, "slot_id") + text(node["slot_id"]) + require(node["slot_id"] in slots and node["slot_id"] not in seen, "TREE_SLOT_REFERENCE") + s = slots[node["slot_id"]] + require(not s["state"]["floating"] and s["workspace"] == workspace and s["tree_path"] == list(path), "TREE_SLOT_POSITION") + seen.add(node["slot_id"]) + else: + fields(node, "layout children") + require(type(node["layout"]) is str and node["layout"] in LAYOUTS, "TREE_LAYOUT") + array(node["children"], 32) + require(len(node["children"]) >= 2 and all(c is not None for c in node["children"]), "TREE_ARITY") + for i, c in enumerate(node["children"]): tree(c, workspace, path + (i,)) + for w in data["workspaces"]: + fields(w, "workspace output root") + text(w["workspace"], PLACE) + 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") + for name in workspaces: + require(any(s["workspace"] == name for s in slots.values()), "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") + return data diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/slots.py b/quickshell/.config/quickshell/labfy-sway/session_v2/slots.py new file mode 100644 index 000000000..a1d0a29a8 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/slots.py @@ -0,0 +1,27 @@ +"""Affectation : exact reste exact ; best-effort est toujours signalé.""" +from .errors import require +from .schema import PROVIDER + + +def assign(slots, nodes, identities=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} + identities = identities or {} + require(len(set(identities.values())) == len(identities), "EXACT_WINDOW_IDENTITY_COLLISION") + for s in slots: + if s["identity_requirement"] != "exact": continue + evidence = s["identity_evidence"] + if evidence["type"] == PROVIDER: + require(evidence["id"] in identities, "EXACT_WINDOW_IDENTITY_MISSING") + target = identities[evidence["id"]] + else: + require(len(nodes) == 1, "EXACT_IDENTITY_UNSUPPORTED") + target = nodes[0]["id"] + 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)): + result[s["slot_id"]] = 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 new file mode 100644 index 000000000..e1300f05d --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py @@ -0,0 +1,78 @@ +"""Capture minimale, sans nom de fenêtre, chemin de document ou argv.""" +import time +from .errors import require +from .schema import validate, PROVIDER +from .ipc import walk + + +def capture(sway, catalog, provider, reason="manual"): + tree = sway.tree() + def topology(node): + # 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"), + 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") + specs[n["id"]] = catalog.classify(n) + apps = {} + identities = {} + for s in specs.values(): + if s.application_id in apps: continue + members = [n for n in nodes if specs[n["id"]].application_id == s.application_id] + apps[s.application_id] = {"application_id": s.application_id, "desktop_entry": s.desktop_entry, + "strategy": s.strategy, "expected_windows": len(members), + "managed_by": (s.managed_by or "autostart") if s.strategy == "managed-autostart" else None, "identity_provider": s.provider} + if s.provider == PROVIDER: + require(provider is not None, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE") + identities.update({v: k for k, v in provider.capture(members).items()}) + slots, workspaces = [], [] + focused = None + def leaf(n, workspace, path, floating): + nonlocal focused + app = specs[n["id"]] + slot_id = "slot-" + str(len(slots) + 1) + if app.provider == PROVIDER: + require(n["id"] in identities, "EXACT_WINDOW_IDENTITY_MISSING") + requirement, evidence = "exact", {"type": PROVIDER, "id": identities[n["id"]]} + elif apps[app.application_id]["expected_windows"] == 1: + requirement, evidence = "exact", {"type": "application-singleton", "id": app.application_id} + else: requirement, evidence = "interchangeable", None + if n.get("focused"): focused = slot_id + 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}, + "identity_requirement": requirement, "identity_evidence": evidence}) + return {"slot_id": slot_id} + def convert(n, workspace, path=()): + if n.get("app_id") or n.get("window"): return leaf(n, workspace, path, False) + children = n.get("nodes", []) + if not children: return None + if len(children) == 1: + require(n["layout"] in ("splith", "splitv", "none"), "SINGLE_CHILD_LAYOUT_UNSUPPORTED") + return convert(children[0], workspace, path) + return {"layout": n["layout"], "children": [convert(c, workspace, path + (i,)) for i, c in enumerate(children)]} + 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") + 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") + 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"]) + # 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, + "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} + return validate(data) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/startup.py b/quickshell/.config/quickshell/labfy-sway/session_v2/startup.py new file mode 100644 index 000000000..afbff128b --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/startup.py @@ -0,0 +1,31 @@ +"""Décision persistante par instance Sway, distincte du booléen de l'UI. + +CONTRACT : restored exige le rapport final de la transaction reçue par l'UI. +Aucune décision ne ferme de fenêtre ni ne déclenche de restauration. +""" +from .errors import require +from .storage import private_dir, lock, read, atomic +from .observability import validate + + +def successful(report): + validate(report) + return (report['status'] == 'success' and report['final_tree_verified'] and report['final_focus_verified'] + and report['slots_expected'] == report['slots_filled'] + and report['applications_expected'] == report['applications_observed'] + and report['anchors_created'] == report['anchors_cleaned'] + and report['helpers_suspended'] == report['helpers_resumed']) + + +def acknowledge(runtime, session, choice, transaction_id=None): + require(choice in ('new', 'restored'), 'STARTUP_CHOICE_INVALID') + directory = private_dir(runtime / 'session-v2') + marker = directory / 'startup.json' + with lock(directory): + if choice == 'restored': + claimed = read(marker) + report = read(runtime / 'session-v2-restore-attempt.json', validate) + require(claimed.get('session') == session and transaction_id == report['transaction_id'] + and successful(report), 'RESTORE_ACKNOWLEDGEMENT_FAILED') + atomic(marker, {'session': session, 'choice': choice, 'transaction_id': transaction_id if choice == 'restored' else None}) + return {'status': 'acknowledged', 'choice': choice} diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/storage.py b/quickshell/.config/quickshell/labfy-sway/session_v2/storage.py new file mode 100644 index 000000000..db1c1aa76 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/storage.py @@ -0,0 +1,103 @@ +"""Stockage privé : validation, NOFOLLOW, publication durable et lock partagé. + +CONTRACT : le namespace est possédé par l'UID courant et non accessible aux +autres utilisateurs. Les verrous restent valides via FD hérité par le guard. +""" +from contextlib import contextmanager +import fcntl +import json +import os +from pathlib import Path +import stat +import uuid +from .errors import Failure, require + +MAX_JSON = 1024 * 1024 + + +def pairs(items): + result = {} + for k, v in items: + require(k not in result, "JSON_DUPLICATE_KEY") + result[k] = v + return result + + +def loads(raw, limit=MAX_JSON): + require(len(raw) <= limit, "JSON_TOO_LARGE") + try: return json.loads(raw, object_pairs_hook=pairs, parse_constant=lambda _: (_ for _ in ()).throw(Failure("JSON_NUMBER"))) + except (ValueError, RecursionError): raise Failure("JSON_INVALID") from None + + +def private_dir(path): + path = Path(path) + require(path.is_absolute() and ".." not in path.parts, "PATH_INVALID") + # Refuser les liens dans toute la chaîne ; ne chmod jamais un parent existant. + for part in [*reversed(path.parents), path]: + if not part.exists() and not part.is_symlink(): + try: part.mkdir(mode=0o700) + except FileExistsError: pass + require(not part.is_symlink() and part.is_dir(), "PATH_SYMLINK") + st = path.stat() + require(st.st_uid == os.getuid() and stat.S_ISDIR(st.st_mode) and st.st_mode & 0o077 == 0, "DIRECTORY_PERMISSIONS") + return path + + +def read(path, validator=lambda x: x): + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) + try: + st = os.fstat(fd) + require(stat.S_ISREG(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0, "FILE_PERMISSIONS") + require(st.st_size <= MAX_JSON, "JSON_TOO_LARGE") + with os.fdopen(fd, "rb", closefd=False) as stream: data = stream.read(MAX_JSON + 1) + return validator(loads(data)) + finally: os.close(fd) + + +def atomic(path, data, validator=lambda x: x): + path = Path(path) + directory = private_dir(path.parent) + validator(data) + raw = (json.dumps(data, separators=(",", ":"), allow_nan=False) + "\n").encode() + require(len(raw) <= MAX_JSON, "JSON_TOO_LARGE") + temporary = directory / (".tmp-" + uuid.uuid4().hex) + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + require(read(temporary, validator) == data, "PUBLICATION_REREAD") + require(not path.is_symlink(), "PATH_SYMLINK") + os.replace(temporary, path) + sync_dir(directory) + finally: + if temporary.exists(): temporary.unlink() + + +def sync_dir(path): + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: os.fsync(fd) + finally: os.close(fd) + + +@contextmanager +def lock(directory, name="transaction.lock"): + directory = private_dir(directory) + fd = os.open(directory / name, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + st = os.fstat(fd) + require(stat.S_ISREG(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0, "LOCK_PERMISSIONS") + try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: raise Failure("TRANSACTION_BUSY") from None + yield fd + finally: os.close(fd) + + +def paths(): + home = Path.home() + state = Path(os.environ.get("XDG_STATE_HOME", str(home / ".local/state"))) + runtime = Path(os.environ.get("XDG_RUNTIME_DIR", "")) + require(state.is_absolute() and runtime.is_absolute(), "XDG_PATH_INVALID") + private_dir(runtime) + return private_dir(state / "labfy-sway/session-v2"), private_dir(runtime / "labfy-sway") diff --git a/quickshell/.config/quickshell/labfy-sway/sessionui/Protocol.js b/quickshell/.config/quickshell/labfy-sway/sessionui/Protocol.js new file mode 100644 index 000000000..a2c3f0fde --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/sessionui/Protocol.js @@ -0,0 +1,21 @@ +.pragma library + +// CONTRACT : aucun exit 0 seul ne vaut succès de restauration. L'UI attend +// toutes les postconditions et, au startup, l'ack durable de cette transaction. +function restoreSucceeded(v) { + return !!v && v.schema === "labfy.sway.session-v2-restore-attempt" + && v.version === 1 && v.status === "success" + && v.final_tree_verified === true && v.final_focus_verified === true + && Number.isInteger(v.slots_expected) && v.slots_expected === v.slots_filled + && Number.isInteger(v.applications_expected) && v.applications_expected === v.applications_observed + && Number.isInteger(v.anchors_created) && v.anchors_created === v.anchors_cleaned + && Number.isInteger(v.helpers_suspended) && v.helpers_suspended === v.helpers_resumed + && /^[a-f0-9]{32}$/.test(v.transaction_id) + && ["exact", "interchangeable", "best-effort"].indexOf(v.identity_level) >= 0; +} + +function details(v) { + // Aucun stdout/stderr libre dans le parcours utilisateur ou dans journald. + function code(x) { return typeof x === "string" && /^[A-Za-z_-]{1,64}$/.test(x) ? x : "UNKNOWN"; } + return "Phase : " + code(v ? v.phase : null) + "\nRaison : " + code(v ? v.reason : null); +} diff --git a/quickshell/.config/quickshell/labfy-sway/sessionui/SessionV2Service.qml b/quickshell/.config/quickshell/labfy-sway/sessionui/SessionV2Service.qml new file mode 100644 index 000000000..b3f4f23d0 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/sessionui/SessionV2Service.qml @@ -0,0 +1,62 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +QtObject { + id: service + readonly property string configHome: Quickshell.env("XDG_CONFIG_HOME") || ((Quickshell.env("HOME") || "") + "/.config") + readonly property string backend: configHome + "/quickshell/labfy-sway/session-v2.py" + property string operation: "" + property var client: null + property bool startupChooserVisible: false + readonly property bool busy: operation !== "" || backendProcess.running + signal completed(var owner, string kind, var value, bool okay) + + // Diagnostic lecture seule ; aucun endpoint IPC ne peut cliquer Restore. + property IpcHandler diagnostics: IpcHandler { + target: "sessionV2" + function status(): string { + return JSON.stringify({backend: "v2", busy: service.busy, + operation: service.operation, startupChooserCount: service.startupChooserVisible ? 1 : 0}); + } + } + + // WHY : toutes les barres, le chooser et le power menu partagent un owner. + // INVARIANT : un unique Process sérialise les commandes V2 de QuickShell. + function run(kind, name, extra, owner) { + if (busy) return false; + const allowed = ["startup-status", "ack-new", "ack-restored", "apply-last", "apply", "list", "show", "save", "delete", "plan", "restore-status", "checkpoint-last"]; + if (allowed.indexOf(kind) < 0) return false; + const argv = ["python3", "-B", backend]; + if (kind === "ack-new" || kind === "ack-restored") { + argv.push("acknowledge-startup", "--execute", "--choice", kind === "ack-new" ? "new" : "restored"); + if (kind === "ack-restored") argv.push("--transaction-id", extra); + } else { + argv.push(kind); + if (["apply", "save", "delete", "show", "plan"].indexOf(kind) >= 0) argv.push(name); + if (["startup-status", "apply-last", "apply", "save", "delete", "checkpoint-last"].indexOf(kind) >= 0) argv.push("--execute"); + if (kind === "checkpoint-last") argv.push("--reason", extra); + } + client = owner; + operation = kind; + backendProcess.command = argv; + backendProcess.running = true; + return true; + } + + property Process backendProcess: Process { + running: false + stdout: StdioCollector { id: collected; waitForEnd: true } + stderr: StdioCollector { waitForEnd: true } + onExited: (exitCode, exitStatus) => { + let value = null; + try { value = JSON.parse(collected.text); } catch (_) {} + const owner = service.client; + const kind = service.operation; + service.operation = ""; + service.client = null; + service.completed(owner, kind, value, exitCode === 0 && exitStatus === 0 && value !== null); + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/sessionui/qmldir b/quickshell/.config/quickshell/labfy-sway/sessionui/qmldir new file mode 100644 index 000000000..85b3d42f0 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/sessionui/qmldir @@ -0,0 +1 @@ +singleton SessionV2Service 1.0 SessionV2Service.qml diff --git a/quickshell/.config/quickshell/labfy-sway/shell.qml b/quickshell/.config/quickshell/labfy-sway/shell.qml index fb2dfbf5f..843ead63e 100644 --- a/quickshell/.config/quickshell/labfy-sway/shell.qml +++ b/quickshell/.config/quickshell/labfy-sway/shell.qml @@ -32,6 +32,11 @@ ShellRoot { Variants { model: Quickshell.screens - Bar { notificationService: rootNotificationService } + Bar { + notificationService: rootNotificationService + // Une seule barre possède le chooser, même en configuration multi-écran. + startupHost: Quickshell.screens.length > 0 + && modelData === Quickshell.screens[0] + } } } diff --git a/tests/qml/tst_session_exit_gate.qml b/tests/qml/tst_session_exit_gate.qml new file mode 100644 index 000000000..913785573 --- /dev/null +++ b/tests/qml/tst_session_exit_gate.qml @@ -0,0 +1,140 @@ +import QtQuick +import QtTest +import "../../quickshell/.config/quickshell/labfy-sway/controlcenter" + +TestCase { + name: "SessionExitGate" + + Component { + id: gateComponent + SessionExitGate { + actions: [ + { id: "lock", label: "Verrouiller", command: ["swaylock"] }, + { id: "suspend", label: "Veille", command: ["systemctl", "suspend"] }, + { id: "logout", label: "Déconnexion", confirmTitle: "Se déconnecter ?", + description: "La session SwayFX en cours sera fermée.", checkpointReason: "logout", + command: ["uwsm", "stop"] }, + { id: "reboot", label: "Redémarrer", checkpointReason: "reboot", + command: ["systemctl", "reboot"] }, + { id: "poweroff", label: "Éteindre", checkpointReason: "poweroff", + command: ["systemctl", "poweroff"] } + ] + } + } + + Component { + id: signalSpyComponent + SignalSpy {} + } + + function createGate() { + return createTemporaryObject(gateComponent, this); + } + + function createSpy(target, signalName) { + return createTemporaryObject(signalSpyComponent, this, { + target: target, + signalName: signalName + }); + } + + function test_logout_success_sequences_checkpoint_before_command() { + const gate = createGate(); + const checkpointSpy = createSpy(gate, "checkpointRequested"); + const commandSpy = createSpy(gate, "commandRequested"); + gate.requestAction("logout"); + compare(gate.pendingAction.id, "logout"); + compare(gate.pendingAction.label, "Déconnexion"); + compare(gate.pendingAction.command, ["uwsm", "stop"]); + compare(gate.pendingAction.confirmTitle, "Se déconnecter ?"); + compare(gate.pendingAction.description, "La session SwayFX en cours sera fermée."); + gate.confirmAction("logout"); + compare(checkpointSpy.count, 1); + compare(checkpointSpy.signalArguments[0][0], "logout"); + compare(commandSpy.count, 0); + verify(gate.busy); + compare(gate.checkpointAction.id, "logout"); + gate.confirmAction("logout"); + compare(checkpointSpy.count, 1); + gate.pendingAction = null; + gate.checkpointFinished(0, 0, '{"status":"success"}', ""); + compare(commandSpy.count, 1); + compare(commandSpy.signalArguments[0][0], ["uwsm", "stop"]); + verify(!gate.busy); + } + + function test_checkpoint_failure_blocks_command_and_preserves_escape() { + const gate = createGate(); + const failureSpy = createSpy(gate, "checkpointFailed"); + const commandSpy = createSpy(gate, "commandRequested"); + gate.requestAction("logout"); + gate.confirmAction("logout"); + gate.checkpointFinished(1, 0, "", "capture failed"); + compare(commandSpy.count, 0); + compare(failureSpy.count, 1); + compare(gate.pendingAction.id, "logout"); + compare(gate.errorMessage, "capture failed"); + gate.quitWithoutCheckpoint(); + compare(commandSpy.count, 1); + compare(commandSpy.signalArguments[0][0], ["uwsm", "stop"]); + } + + function test_cancel_after_failure_never_dispatches() { + const gate = createGate(); + const commandSpy = createSpy(gate, "commandRequested"); + gate.requestAction("logout"); + gate.confirmAction("logout"); + gate.checkpointFinished(1, 0, "", "capture failed"); + gate.cancel(); + compare(commandSpy.count, 0); + compare(gate.pendingAction, null); + } + + function test_reboot_and_poweroff_sequence() { + const expected = { + reboot: ["systemctl", "reboot"], + poweroff: ["systemctl", "poweroff"] + }; + for (const actionId in expected) { + const gate = createGate(); + const checkpointSpy = createSpy(gate, "checkpointRequested"); + const commandSpy = createSpy(gate, "commandRequested"); + gate.requestAction(actionId); + gate.confirmAction(actionId); + compare(checkpointSpy.count, 1); + compare(checkpointSpy.signalArguments[0][0], actionId); + compare(commandSpy.count, 0); + gate.checkpointFinished(0, 0, '{"status":"success"}', ""); + compare(commandSpy.count, 1); + compare(commandSpy.signalArguments[0][0], expected[actionId]); + } + } + + function test_reboot_and_poweroff_failure_never_dispatch() { + for (const actionId of ["reboot", "poweroff"]) { + const gate = createGate(); + const commandSpy = createSpy(gate, "commandRequested"); + gate.requestAction(actionId); + gate.confirmAction(actionId); + gate.checkpointFinished(1, 0, "", "capture failed"); + compare(commandSpy.count, 0); + compare(gate.pendingAction.id, actionId); + } + } + + function test_lock_and_suspend_keep_existing_contract() { + const gate = createGate(); + const checkpointSpy = createSpy(gate, "checkpointRequested"); + const commandSpy = createSpy(gate, "commandRequested"); + gate.requestAction("lock"); + compare(commandSpy.count, 1); + compare(commandSpy.signalArguments[0][0], ["swaylock"]); + compare(checkpointSpy.count, 0); + gate.requestAction("suspend"); + compare(gate.pendingAction.id, "suspend"); + gate.confirmAction("suspend"); + compare(commandSpy.count, 2); + compare(commandSpy.signalArguments[1][0], ["systemctl", "suspend"]); + compare(checkpointSpy.count, 0); + } +} diff --git a/tests/session_v2/common.py b/tests/session_v2/common.py new file mode 100644 index 000000000..2badf288c --- /dev/null +++ b/tests/session_v2/common.py @@ -0,0 +1,51 @@ +"""Fixtures isolées ; imports production réels, sockets headless possédés.""" +import copy +import os +from pathlib import Path +import sys +import time + +REPO = Path(__file__).resolve().parents[2] +BACKEND = Path(os.environ.get("LABFY_SESSION_V2_BACKEND", str(REPO / "quickshell/.config/quickshell/labfy-sway"))) +sys.path.insert(0, str(BACKEND)) +sys.path.insert(0, str(REPO / "tests/session_v2/fixtures")) +os.environ["PYTHONDONTWRITEBYTECODE"] = "1" +from session_v2.applications import Catalog, Spec +from session_v2.schema import validate + + +def fixture(shape=None, workspace="TEST_ONLY_TARGET"): + shape = shape or {"splitv": ["A", {"splith": ["B", "C"]}]} + slots, apps = [], [] + def tree(n, path=()): + if isinstance(n, str): + app = "app-" + n + slots.append({"slot_id": n, "application_id": app, "workspace": workspace, + "tree_path": list(path), "state": {"floating": False, "geometry": None, "fullscreen": False, "scratchpad": False}, + "identity_requirement": "exact", "identity_evidence": {"type": "application-singleton", "id": app}}) + apps.append({"application_id": app, "desktop_entry": app + ".desktop", "strategy": "managed-autostart", "expected_windows": 1, + "managed_by": "autostart", "identity_provider": None}) + return {"slot_id": n} + layout, children = next(iter(n.items())) + return {"layout": layout, "children": [tree(c, path + (i,)) for i, c in enumerate(children)]} + root = tree(shape) + return validate({"schema": "labfy.sway.session-v2", "version": 1, + "metadata": {"created_ns": time.time_ns(), "sway_session": "test", "reason": "manual"}, + "outputs": [{"name": "HEADLESS-1"}], "applications": apps, "window_slots": slots, + "workspaces": [{"workspace": workspace, "output": "HEADLESS-1", "root": root}], "focus": slots[0]["slot_id"]}) + + +def catalog(data): + return Catalog(tuple(Spec(a["application_id"], a["desktop_entry"], ("TEST_ONLY_" + a["application_id"],), + a["strategy"], (), a["identity_provider"]) for a in data["applications"])) + + +def build(directory): + import subprocess + flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "gtk+-3.0", "gdk-wayland-3.0"], text=True).split() + binary = Path(directory) / "labfy-v2-anchor" + subprocess.run(["cc", "-std=c17", "-Wall", "-Wextra", "-Werror", str(BACKEND / "session_v2/anchor_helper.c"), "-o", str(binary), *flags], check=True) + return binary + +# Les résultats TEST_ONLY restent locaux et ne sont jamais publiés avec les sources. +EVIDENCE = Path(os.environ.get("LABFY_SESSION_V2_EVIDENCE", str(REPO / "build/session-v2-tests"))) diff --git a/tests/session_v2/crashes.py b/tests/session_v2/crashes.py new file mode 100644 index 000000000..5457130ad --- /dev/null +++ b/tests/session_v2/crashes.py @@ -0,0 +1,79 @@ +"""SIGKILL réels de l'executor production, jamais du guard ni d'un utilisateur.""" +import json +import os +import signal +import time +from common import * +from lab import Lab, Surfaces, build_helper, until +from session_v2.storage import atomic, private_dir, read +from session_v2.executor import Executor +from session_v2.ipc import Sway + + +def worker(path): + config = read(path) + def progress(phase, count): + if [phase, count] == config["point"]: + atomic(Path(config["runtime"]) / "kill-ready.json", {"phase": phase, "count": count}) + while True: time.sleep(1) + Executor(Sway(config["socket"]), catalog(config["data"]), None, config["runtime"], config["binary"], + os.environ.copy(), progress).apply(config["data"], True) + + +def main(): + cases = [] + with Lab() as lab: + binary, helper_binary = build(lab.directory), build_helper(lab.directory) + for i, point in enumerate((("anchor", 1), ("all-anchors", 4), ("swap", 1), ("swap", 3), + ("before-cleanup", 0), ("cleanup", 1), ("after-resume", 4), ("normal", 0), ("initial-stopped", 0))): + runtime = private_dir(lab.directory / f"crash-{i}") + data = fixture({"splitv": [{"splith": ["A", "B"]}, {"splith": ["C", "D"]}]}) + apps = Surfaces(lab, helper_binary) + lab.command('workspace TEST_ONLY_SOURCE') + ids = [apps.create("TEST_ONLY_" + a["application_id"])["id"] for a in data["applications"]] + foreign = apps.create("labfy-v2-anchor-" + "f" * 32 + "-foreign")["id"] + helper = lab.spawn(["autotiling"]) + time.sleep(.15) + helper_fd = os.pidfd_open(helper.pid) + if point[0] == "initial-stopped": signal.pidfd_send_signal(helper_fd, signal.SIGSTOP) + config = {"socket": lab.socket, "runtime": str(runtime), "binary": str(binary), "data": data, "point": list(point)} + atomic(runtime / "config.json", config) + process = lab.spawn([sys.executable, "-B", str(Path(__file__).resolve()), "--worker", str(runtime / "config.json")]) + killed = None + try: + if point[0] not in ("normal", "initial-stopped"): + until(lambda: (runtime / "kill-ready.json").exists() or process.poll() is not None, 15) + assert process.poll() is None + killed = time.monotonic() + process.kill() + process.wait(timeout=25) + report = runtime / "session-v2-restore-attempt.json" + until(lambda: report.exists() and read(report)["status"] != "running", 10) + if killed: until(lambda: read(report)["phase"] == "recovered", 10) + result = read(report) + state = Path(f"/proc/{helper.pid}/status").read_text().split("State:\t", 1)[1][0] + windows = lab.windows() + row = {"point": list(point), "returncode": process.returncode, "report": result, + "kill_to_recovery_ms": (time.monotonic() - killed) * 1000 if killed else None, + "apps_preserved": set(ids).issubset(n["id"] for n in windows), + "foreign_preserved": any(n["id"] == foreign for n in windows), + "state_preserved": state == "T" if point[0] == "initial-stopped" else state not in ("T", "t"), + "anchors_remaining": sum(n.get("app_id", "").startswith("labfy-v2-anchor-") and n["id"] != foreign for n in windows)} + assert row["apps_preserved"] and row["foreign_preserved"] and row["state_preserved"] and row["anchors_remaining"] == 0, row + assert result["status"] == ("interrupted" if killed else "success"), row + if killed: assert row["kill_to_recovery_ms"] < 3000 + cases.append(row) + print(point, "PASS", flush=True) + finally: + if process.poll() is None: process.kill(); process.wait() + signal.pidfd_send_signal(helper_fd, signal.SIGCONT) + helper.terminate(); helper.wait(timeout=3) + os.close(helper_fd) + apps.close() + out = private_dir(EVIDENCE) + atomic(out / "crash.json", {"cases": cases}) + + +if __name__ == "__main__": + if "--worker" in sys.argv: worker(sys.argv[2]) + else: main() diff --git a/tests/session_v2/failures.py b/tests/session_v2/failures.py new file mode 100644 index 000000000..6e6c7ca65 --- /dev/null +++ b/tests/session_v2/failures.py @@ -0,0 +1,43 @@ +"""Échecs runtime du moteur production : conservation des apps et reprise.""" +import time +from common import * +from lab import Lab, Surfaces, build_helper +from session_v2.ipc import Sway +from session_v2.executor import Executor +from session_v2.errors import Failure +from session_v2.storage import private_dir, atomic, read + + +def main(): + rows = [] + with Lab() as lab: + binary, appbin = build(lab.directory), build_helper(lab.directory) + runtime = private_dir(lab.directory / "labfy-sway") + for mode, reason in (("missing", "APPLICATION_WINDOWS_INCOMPLETE"), ("extra", "UNEXPECTED_EXTRA_WINDOW"), ("occupied", "WORKSPACE_OCCUPIED")): + data = fixture() + apps = Surfaces(lab, appbin) + lab.command('workspace TEST_ONLY_SOURCE') + members = data["applications"][:-1] if mode == "missing" else data["applications"] + ids = [apps.create("TEST_ONLY_" + a["application_id"])["id"] for a in members] + if mode == "extra": ids.append(apps.create("TEST_ONLY_app-A")["id"]) + if mode == "occupied": + lab.command('workspace TEST_ONLY_TARGET') + ids.append(apps.create("TEST_ONLY_FOREIGN")["id"]) + helper = lab.spawn(["autotiling"]); time.sleep(.15) + try: + try: Executor(Sway(lab.socket), catalog(data), None, runtime, binary, lab.env).apply(data, True, timeout=.3) + except Failure as exc: assert exc.code == reason, (exc.code, reason) + else: raise AssertionError(mode) + report = read(runtime / "session-v2-restore-attempt.json") + assert report["status"] == "failed" and report["reason"] == reason, report + assert set(ids).issubset(n["id"] for n in lab.windows()) + assert not any(n.get("app_id", "").startswith("labfy-v2-anchor-") for n in lab.windows()) + assert Path(f"/proc/{helper.pid}/status").read_text().split("State:\t", 1)[1][0] not in ("T", "t") + rows.append({"case": mode, "report": report, "apps_preserved": True, "anchors_remaining": 0, "helper_recovered": True}) + finally: + helper.terminate(); helper.wait(timeout=3); apps.close() + atomic(private_dir(EVIDENCE) / "failures.json", {"cases": rows}) + print(len(rows), "runtime failures PASS") + + +if __name__ == "__main__": main() diff --git a/tests/session_v2/fixtures/headless.conf b/tests/session_v2/fixtures/headless.conf new file mode 100644 index 000000000..bababe3e9 --- /dev/null +++ b/tests/session_v2/fixtures/headless.conf @@ -0,0 +1,6 @@ +# TEST_ONLY : aucun include, autostart, service ou accès aux sorties physiques. +xwayland disable +output HEADLESS-1 mode 1280x720 +focus_follows_mouse no +default_border pixel 1 +seat seat0 fallback true diff --git a/tests/session_v2/fixtures/headless.py b/tests/session_v2/fixtures/headless.py new file mode 100644 index 000000000..9c819fc3e --- /dev/null +++ b/tests/session_v2/fixtures/headless.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Expériences TEST_ONLY ; jamais importé ni lancé par le code de production. + +CONTRAT : IPC exclusivement vers le socket du compositeur enfant headless. +Les seuls processus arrêtés sont des enfants détenus par cette expérience. +Les arbres conservés ne contiennent que des fenêtres synthétiques. +""" +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parent + + +def run(argv, **kwargs): + return subprocess.run(argv, text=True, capture_output=True, timeout=10, **kwargs) + + +def walk(node): + yield node + for child in node.get("nodes", []) + node.get("floating_nodes", []): + yield from walk(child) + + +class Lab: + def __init__(self): + self.directory = Path(tempfile.mkdtemp(prefix="labfy-session-v2-TEST_ONLY-")) + self.children = [] + self.logs = [] + self.socket = None + self.env = dict(os.environ) + for key in ("SWAYSOCK", "I3SOCK", "DISPLAY", "WAYLAND_DISPLAY"): + self.env.pop(key, None) + self.env.update(XDG_RUNTIME_DIR=str(self.directory), TMPDIR=str(self.directory), WLR_BACKENDS="headless", + WLR_HEADLESS_OUTPUTS="1", WLR_RENDERER="gles2", + WLR_RENDERER_ALLOW_SOFTWARE="1", LIBGL_ALWAYS_SOFTWARE="true") + + def start(self, argv): + log = (self.directory / f"child-{len(self.children)}.log").open("w") + self.logs.append(log) + proc = subprocess.Popen(argv, env=self.env, stdout=log, stderr=log, + start_new_session=True) + self.children.append(proc) + return proc + + def __enter__(self): + try: + self.sway = self.start(["sway", "-c", str(ROOT / "headless.conf")]) + end = time.monotonic() + 15 + while time.monotonic() < end: + sockets = list(self.directory.glob("sway-ipc.*.sock")) + displays = [p for p in self.directory.glob("wayland-*") if p.is_socket()] + if sockets and displays: + self.socket = str(sockets[0]) + self.env.update(SWAYSOCK=self.socket, WAYLAND_DISPLAY=displays[0].name) + self.tree() + return self + if self.sway.poll() is not None: + raise RuntimeError((self.directory / "child-0.log").read_text()[-6000:]) + time.sleep(.1) + raise RuntimeError("Délai de démarrage headless dépassé") + except BaseException: + self.__exit__(None, None, None) + raise + + def ipc(self, command=None, kind=None): + assert self.socket and self.socket.startswith(str(self.directory) + "/") + args = ["swaymsg", "-s", self.socket, "-r"] + args += ["-t", kind] if kind else [command] + result = run(args, env=self.env) + return json.loads(result.stdout) + + def command(self, text): + reply = self.ipc(text) + if not all(item.get("success") for item in reply): + raise RuntimeError((text, reply)) + return reply + + def tree(self): + return self.ipc(kind="get_tree") + + def windows(self): + return [n for n in walk(self.tree()) if n.get("app_id")] + + def kitty(self, identity): + before = {n["id"] for n in self.windows()} + self.start(["kitty", "--config", "NONE", "--class", identity, + "--title", "TEST_ONLY", "-o", "linux_display_server=wayland", + "-o", "confirm_os_window_close=0", "/usr/bin/sleep", "180"]) + end = time.monotonic() + 10 + while time.monotonic() < end: + found = [n for n in self.windows() if n["id"] not in before and n["app_id"] == identity] + if len(found) == 1: + return found[0]["id"] + time.sleep(.1) + raise RuntimeError("Fenêtre Kitty TEST_ONLY absente") + + def empty(self, workspace): + old = self.windows() + for n in old: + assert n["app_id"].startswith("TEST_ONLY") + self.command(f'[con_id={n["id"]}] kill') + self.wait_absent([n["id"] for n in old]) + self.command(f'workspace "{workspace}"') + time.sleep(.15) + + def wait_absent(self, ids): + # Une réponse IPC à kill ne prouve pas encore le retrait du top-level. + end = time.monotonic() + 5 + while time.monotonic() < end: + if not set(ids).intersection(n["id"] for n in self.windows()): + return + time.sleep(.05) + raise RuntimeError("Fenêtre TEST_ONLY encore présente après fermeture") + + def shape(self, workspace): + def node(n): + if n.get("app_id"): + return n["app_id"] + children = [node(c) for c in n.get("nodes", [])] + if len(children) == 1: + return children[0] + return {n.get("layout"): children} + found = [n for n in walk(self.tree()) if n.get("type") == "workspace" and n.get("name") == workspace] + return node(found[0]) if found else None + + def __exit__(self, *_): + # INVARIANT : même après exception, aucun helper détenu ne reste stoppé. + for proc in reversed(self.children): + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGCONT) + os.killpg(proc.pid, signal.SIGTERM) + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait(timeout=3) + for log in self.logs: + log.close() diff --git a/tests/session_v2/fixtures/lab.py b/tests/session_v2/fixtures/lab.py new file mode 100644 index 000000000..0839067ba --- /dev/null +++ b/tests/session_v2/fixtures/lab.py @@ -0,0 +1,147 @@ +"""Laboratoire headless Phase 2 ; sockets explicites, enfants possédés et bornés.""" +import json +import os +from pathlib import Path +import signal +import socket +import struct +import subprocess +import sys +import time + +ROOT = Path(__file__).resolve().parent +from headless import Lab as BaseLab, walk + + +def until(function, timeout=5): + end = time.monotonic() + timeout + while time.monotonic() < end: + value = function() + if value: + return value + time.sleep(.02) + raise TimeoutError("TEST_ONLY_TIMEOUT") + + +class IPC: + def __init__(self, path): + self.socket = path + if not path.startswith("/tmp/labfy-session-v2-TEST_ONLY-"): + raise ValueError("NON_TEST_SOCKET_FORBIDDEN") + + def ipc(self, command=None, kind=None): + message_type = {"get_tree": 4, "get_outputs": 3, "get_workspaces": 1, "get_version": 7}.get(kind, 0) + payload = (command or "").encode() + with socket.socket(socket.AF_UNIX) as connection: + connection.settimeout(3) + connection.connect(self.socket) + connection.sendall(struct.pack("<6sII", b"i3-ipc", len(payload), message_type) + payload) + def read(n): + data = bytearray() + while len(data) < n: + part = connection.recv(n - len(data)) + if not part: raise RuntimeError("IPC_CLOSED") + data.extend(part) + return bytes(data) + magic, size, _ = struct.unpack("<6sII", read(14)) + if magic != b"i3-ipc" or size > 4 * 1024 * 1024: + raise ValueError("IPC_BOUNDS") + return json.loads(read(size)) + + def command(self, command): + result = self.ipc(command) + if not result or not all(x.get("success") for x in result): + raise RuntimeError((command, result)) + return result + + def tree(self): return self.ipc(kind="get_tree") + def windows(self): return [n for n in walk(self.tree()) if n.get("app_id")] + + +class Lab(BaseLab): + def ipc(self, command=None, kind=None): + return IPC(self.socket).ipc(command, kind) + + def spawn(self, argv, stdin=None, env=None, pass_fds=()): + log = (self.directory / f"p2-{len(self.children)}.log").open("w") + self.logs.append(log) + process = subprocess.Popen(argv, stdin=stdin, stdout=log, stderr=log, + env=env or self.env, start_new_session=True, pass_fds=pass_fds) + self.children.append(process) + return process + + +class Surfaces: + def __init__(self, lab, binary): + self.lab = lab + self.proc = lab.spawn([str(binary)], stdin=subprocess.PIPE) + self.pidfd = os.pidfd_open(self.proc.pid) + self.ids = set() + + def create(self, app_id): + before = {n["id"] for n in self.lab.windows()} + self.proc.stdin.write(f"create {app_id}\n".encode()) + self.proc.stdin.flush() + found = until(lambda: [n for n in self.lab.windows() if n["id"] not in before + and n["pid"] == self.proc.pid and n["app_id"] == app_id]) + if len(found) != 1: raise ValueError("SURFACE_AMBIGUOUS") + self.ids.add(found[0]["id"]) + return found[0] + + def close(self): + if self.proc.poll() is None: + self.proc.stdin.write(b"quit\n") + self.proc.stdin.flush() + self.proc.wait(timeout=5) + until(lambda: not any(n["id"] in self.ids for n in self.lab.windows())) + self.proc.stdin.close() + os.close(self.pidfd) + + +def build_helper(directory): + flags = subprocess.run(["pkg-config", "--cflags", "--libs", "gtk+-3.0", "gdk-wayland-3.0"], + text=True, capture_output=True, check=True).stdout.split() + binary = directory / "TEST_ONLY_surfaces" + subprocess.run(["cc", "-std=c17", "-Wall", "-Wextra", "-Werror", str(ROOT / "surfaces.c"), "-o", str(binary), *flags], check=True) + return binary + + +def workspace_node(tree, name): + return next((n for n in walk(tree) if n.get("type") == "workspace" and n.get("name") == name), None) + + +def location(tree, identity): + def search(node, workspace=None, path=()): + if node.get("type") == "workspace": + workspace, path = node["name"], () + if node["id"] == identity: + return workspace, path + for i, child in enumerate(node.get("nodes", [])): + found = search(child, workspace, path + (i,)) + if found: return found + return search(tree) + + +def shape(tree, name, mapping): + node = workspace_node(tree, name) + if not node: return None + def normalize(n): + if n.get("app_id"): + return mapping.get(n["id"], "UNEXPECTED:" + n["app_id"]) + children = [normalize(c) for c in n.get("nodes", [])] + if len(children) == 1 and n["layout"] in ("splith", "splitv", "none"): + return children[0] + return {n["layout"]: children} + return normalize(node) + + +def atomic_json(path, value): + path = Path(path) + temporary = path.with_suffix(".tmp") + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as stream: + json.dump(value, stream, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) diff --git a/tests/session_v2/fixtures/surfaces.c b/tests/session_v2/fixtures/surfaces.c new file mode 100644 index 000000000..3c883aeb1 --- /dev/null +++ b/tests/session_v2/fixtures/surfaces.c @@ -0,0 +1,45 @@ +/* TEST_ONLY : un processus GTK, plusieurs top-levels Wayland adressables. + * WHY : éviter N processus terminaux pour N ancrages. + * CONTRACT : stdin privé du harness, commandes create/quit uniquement. + * INVARIANT : aucune application, configuration ou fenêtre utilisateur ouverte. + */ +#include +#include +#include +#include + +static gboolean input(GIOChannel *channel, GIOCondition condition, gpointer data) { + (void)channel; + (void)data; + if (condition & (G_IO_HUP | G_IO_ERR)) { gtk_main_quit(); return FALSE; } + char buffer[256]; + if (!fgets(buffer, sizeof buffer, stdin)) { gtk_main_quit(); return FALSE; } + if (strcmp(buffer, "quit\n") == 0) { gtk_main_quit(); return FALSE; } + char identity[181]; + if (sscanf(buffer, "create %180s", identity) != 1 || + !g_regex_match_simple("^[A-Za-z0-9_.-]{1,180}$", identity, 0, 0)) { + fprintf(stderr, "Commande TEST_ONLY invalide\n"); return TRUE; + } + GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_title(GTK_WINDOW(window), "TEST_ONLY"); + gtk_window_set_default_size(GTK_WINDOW(window), 120, 80); + gtk_widget_set_size_request(window, 1, 1); + gtk_widget_realize(window); + gtk_widget_show_all(window); + /* GTK publie son app_id lors du mapping ; l'identité de test suit ce mapping. */ + gdk_wayland_window_set_application_id(gtk_widget_get_window(window), identity); + gdk_display_flush(gtk_widget_get_display(window)); + printf("created %s\n", identity); fflush(stdout); + return TRUE; +} + +int main(int argc, char **argv) { + g_set_prgname("TEST_ONLY_SURFACES"); + gtk_init(&argc, &argv); + setvbuf(stdin, NULL, _IONBF, 0); + GIOChannel *channel = g_io_channel_unix_new(0); + g_io_add_watch(channel, G_IO_IN | G_IO_HUP | G_IO_ERR, input, NULL); + gtk_main(); + g_io_channel_unref(channel); + return 0; +} diff --git a/tests/session_v2/phase3b_runtime.py b/tests/session_v2/phase3b_runtime.py new file mode 100644 index 000000000..ab9a0d82f --- /dev/null +++ b/tests/session_v2/phase3b_runtime.py @@ -0,0 +1,33 @@ +"""Placement Limusic synthétique : vrai backend déployé, Sway TEST_ONLY.""" +from unittest.mock import patch +from common import * +from lab import Lab, Surfaces, build_helper +from session_v2.applications import Catalog +from session_v2.executor import Executor +from session_v2.ipc import Sway +from session_v2.storage import private_dir, atomic +from session_v2.snapshot import capture + + +def main(): + with Lab() as lab: + apps = Surfaces(lab, build_helper(lab.directory)) + lab.command('workspace TEST_ONLY_SOURCE') + apps.create('limusic-app') + data = fixture('A', 'TEST_ONLY_LIMUSIC') + data['applications'] = [{'application_id': 'limusic', 'desktop_entry': 'limusic.desktop', + 'strategy': 'managed-autostart', 'expected_windows': 1, 'managed_by': 'sway-autostart', 'identity_provider': None}] + data['window_slots'][0].update(application_id='limusic', identity_evidence={'type': 'application-singleton', 'id': 'limusic'}) + try: + # Toute tentative de lancement serait une régression de propriété. + with patch('session_v2.executor.launch', side_effect=lambda spec, count, env: [] if count == 0 else (_ for _ in ()).throw(AssertionError('DUPLICATE_LIMUSIC'))): + result = Executor(Sway(lab.socket), Catalog(), None, private_dir(lab.directory / 'runtime'), build(lab.directory), lab.env).apply(data, True) + snap = capture(Sway(lab.socket), Catalog(), None) + assert snap['applications'][0]['managed_by'] == 'sway-autostart' + assert result['status'] == 'success' + atomic(private_dir(EVIDENCE) / 'limusic-test-only.json', {'result': result, 'launches': 0, 'capture_owner': 'sway-autostart'}) + print('LIMUSIC_TEST_ONLY=PASS') + finally: apps.close() + + +if __name__ == '__main__': main() diff --git a/tests/session_v2/phase3b_ui.py b/tests/session_v2/phase3b_ui.py new file mode 100644 index 000000000..b8811eb8d --- /dev/null +++ b/tests/session_v2/phase3b_ui.py @@ -0,0 +1,109 @@ +"""Recette UI sans desktop utilisateur : compositor privé et CLI simulée. + +Les composants QML V2 sont les vrais candidats ; seul le backend/Theme sont +des fixtures. Aucun appel logout ou restauration utilisateur n'est possible. +""" +import json +import shutil +import subprocess +from common import * +from lab import Lab, Surfaces, build_helper +from session_v2.storage import atomic, private_dir, read +from session_v2.checkpoint import Store + +SOURCE = REPO / 'quickshell/.config/quickshell/labfy-sway' +FAKE = '''import json, os, sys +from pathlib import Path +cmd = sys.argv[1] +log = Path(os.environ['LABFY_UI_LOG']) +with log.open('a') as s: s.write(cmd + '\\n') +mode = os.environ['LABFY_UI_MODE'] +if cmd == 'startup-status': r = {'eligible': mode != 'claimed', 'claimed': mode != 'claimed', 'windows': 3, 'workspaces': 2} +elif cmd == 'apply-last': + r = {'schema':'labfy.sway.session-v2-restore-attempt', 'version':1, 'status':'success', 'transaction_id':'a'*32, 'identity_level':'exact', 'final_tree_verified':mode != 'invalid', 'final_focus_verified':True, 'slots_expected':3, 'slots_filled':3, 'applications_expected':2, 'applications_observed':2, 'anchors_created':3, 'anchors_cleaned':3, 'helpers_suspended':1, 'helpers_resumed':1} + if mode == 'failure': r = {'status':'failed', 'reason':'APPLICATION_WINDOWS_INCOMPLETE'} +elif cmd == 'acknowledge-startup': r = {'status':'acknowledged', 'choice':sys.argv[sys.argv.index('--choice')+1]} +elif cmd == 'restore-status': r = {'status':'failed', 'phase':'resolve', 'reason':'APPLICATION_WINDOWS_INCOMPLETE'} +else: raise RuntimeError('UNEXPECTED_COMMAND') +print(json.dumps(r)) +''' +HARNESS = '''//@ pragma UseQApplication +import QtQuick +import Quickshell +ShellRoot { + PanelWindow { id: bar; visible: false } + SessionStartupPromptV2 { id: prompt; barWindow: bar; startupHost: true } + Timer { interval: 600; running: true; onTriggered: { + prompt.restoreRequested(); + if (prompt.attempted) { console.error("UI_TEST_EARLY_ACTIVATION"); Qt.quit(); } + } } + Timer { interval: 1400; running: true; onTriggered: { + if (Quickshell.env("LABFY_UI_MODE") === "claimed") return; + if (!prompt.interactionArmed) { console.error("UI_TEST_NOT_ARMED"); Qt.quit(); return; } + if (Quickshell.env("LABFY_UI_MODE") === "new") prompt.newSessionRequested(); + else { prompt.restoreRequested(); prompt.restoreRequested(); } + } } + Timer { interval: 3400; running: true; onTriggered: { + const mode = Quickshell.env("LABFY_UI_MODE"); + const okay = (mode === "success" || mode === "real") ? prompt.restored && !prompt.visible + : (mode === "failure" || mode === "invalid") ? prompt.failed && prompt.visible + : !prompt.visible && !prompt.attempted; + if (prompt.failed) prompt.restoreRequested(); + console.log(okay ? "UI_TEST_PASS" : "UI_TEST_FAIL"); Qt.quit(); + } } +} +''' + + +def main(): + rows = [] + with Lab() as lab: + for mode in ('success', 'failure', 'invalid', 'new', 'claimed', 'real'): + config = lab.directory / mode / 'config' + base = config / 'quickshell/labfy-sway' + base.mkdir(parents=True) + shutil.copytree(SOURCE / 'sessionui', base / 'sessionui') + (base / 'controlcenter').mkdir() + shutil.copyfile(SOURCE / 'SessionStartupPromptV2.qml', base / 'SessionStartupPromptV2.qml') + shutil.copyfile((BACKEND / 'controlcenter/ActionButton.qml'), base / 'controlcenter/ActionButton.qml') + (base / 'theme').mkdir() + (base / 'theme/qmldir').write_text('singleton Theme 1.0 Theme.qml\n') + colors = 'popupBackground foreground secondaryForeground lavender buttonBackground emphasisBackground border danger' + (base / 'theme/Theme.qml').write_text('pragma Singleton\nimport QtQuick\nQtObject {\n' + '\n'.join('property color ' + c + ': "#333333"' for c in colors.split()) + '\n}\n') + (base / 'session-v2.py').write_text(FAKE) + (base / 'shell.qml').write_text(HARNESS) + log = lab.directory / (mode + '.calls') + env = {**lab.env, 'XDG_CONFIG_HOME': str(config), 'LABFY_UI_MODE': mode, 'LABFY_UI_LOG': str(log)} + apps = None + if mode == 'real': + # Vrai moteur avec chooser visible : tester aussi l'interaction + # entre focus layer-shell et la postcondition finale Sway. + env['XDG_STATE_HOME'] = str(private_dir(lab.directory / 'state')) + real_backend = BACKEND + anchor_binary = build(lab.directory) + (base / 'session-v2.py').write_text('import sys, os\nfrom pathlib import Path\nsys.path.insert(0, ' + repr(str(real_backend)) + ')\nfrom session_v2.cli import main\nwith Path(os.environ["LABFY_UI_LOG"]).open("a") as s: s.write(sys.argv[1] + "\\n")\nsys.exit(main(sys.argv[1:] + [\"--anchor-binary\", ' + repr(str(anchor_binary)) + ']))\n') + apps = Surfaces(lab, build_helper(lab.directory)) + lab.command('workspace TEST_ONLY_SOURCE') + apps.create('limusic-app') + data = fixture('A', 'TEST_ONLY_TARGET') + data['metadata']['sway_session'] = 'previous-session' + data['applications'] = [{'application_id': 'limusic', 'desktop_entry': 'limusic.desktop', 'strategy': 'managed-autostart', + 'managed_by': 'sway-autostart', 'expected_windows': 1, 'identity_provider': None}] + data['window_slots'][0].update(application_id='limusic', identity_evidence={'type': 'application-singleton', 'id': 'limusic'}) + Store(Path(env['XDG_STATE_HOME']) / 'labfy-sway/session-v2').checkpoint(data) + result = subprocess.run(['qs', '-p', str(base), '--no-color'], env=env, capture_output=True, text=True, timeout=15) + output = result.stdout + result.stderr + if mode == 'real': + report = read(Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway/session-v2-restore-attempt.json') + assert report['status'] == 'success', report + apps.close() + assert 'UI_TEST_PASS' in output and 'UI_TEST_FAIL' not in output, output + calls = log.read_text().splitlines() + assert calls.count('apply-last') == (1 if mode in ('success', 'failure', 'invalid', 'real') else 0), calls + assert calls.count('acknowledge-startup') == (1 if mode in ('success', 'new', 'real') else 0), calls + rows.append({'case': mode, 'calls': calls, 'result': 'PASS'}) + print(mode, 'PASS', flush=True) + atomic(private_dir(EVIDENCE) / 'ui-test-only.json', {'cases': rows}) + + +if __name__ == '__main__': main() diff --git a/tests/session_v2/qml/tst_exit_v2.qml b/tests/session_v2/qml/tst_exit_v2.qml new file mode 100644 index 000000000..03e5cd3f9 --- /dev/null +++ b/tests/session_v2/qml/tst_exit_v2.qml @@ -0,0 +1,53 @@ +import QtQuick +import QtTest + +TestCase { + name: "SessionExitGateV2" + function gate() { + const component = Qt.createComponent("../../../quickshell/.config/quickshell/labfy-sway/controlcenter/SessionExitGate.qml"); + compare(component.status, Component.Ready, component.errorString()); + return createTemporaryObject(component, this, { + checkpointSuccessStatus: "saved", + actions: [ + {id: "logout", checkpointReason: "logout", command: ["uwsm", "stop"]}, + {id: "reboot", checkpointReason: "reboot", command: ["systemctl", "reboot"]}, + {id: "poweroff", checkpointReason: "poweroff", command: ["systemctl", "poweroff"]} + ] + }); + } + Component { id: spyComponent; SignalSpy {} } + function test_saved_releases_only_confirmed_action() { + for (const action of ["logout", "reboot", "poweroff"]) { + const g = gate(); + const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"}); + g.requestAction(action); + g.confirmAction(action); + verify(g.busy); + compare(spy.count, 0); + g.checkpointFinished(0, 0, '{"status":"saved"}', ""); + compare(spy.count, 1); + compare(spy.signalArguments[0][0], action === "logout" ? ["uwsm", "stop"] : ["systemctl", action]); + } + } + function test_error_or_legacy_success_never_exits() { + for (const response of ["{}", "invalid", '{"status":"success"}', '{"status":"failed"}']) { + const g = gate(); + const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"}); + g.requestAction("logout"); g.confirmAction("logout"); + g.checkpointFinished(0, 0, response, ""); + compare(spy.count, 0); + verify(!g.busy); + 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"}); + g.requestAction("logout"); g.confirmAction("logout"); + g.quitWithoutCheckpoint(); compare(spy.count, 0); + g.checkpointFinished(2, 0, '{"status":"saved"}', "échec"); + compare(spy.count, 0); + g.quitWithoutCheckpoint(); compare(spy.count, 1); + } +} diff --git a/tests/session_v2/runtime.py b/tests/session_v2/runtime.py new file mode 100644 index 000000000..a612d7232 --- /dev/null +++ b/tests/session_v2/runtime.py @@ -0,0 +1,96 @@ +import argparse +import time +from common import * +from lab import Lab, Surfaces, build_helper, until +from session_v2.ipc import Sway +from session_v2.executor import Executor +from session_v2.storage import private_dir, atomic +from session_v2.snapshot import capture + + +def main(smoke=False): + rows = [] + shapes = ["A", {"splith": ["A", "B"]}, {"splitv": ["A", "B"]}, + {"splith": ["A", "B", "C"]}, {"splitv": ["A", "B", "C"]}, + {"splitv": ["A", {"splith": ["B", "C"]}]}, + {"splith": ["A", {"splitv": ["B", "C"]}]}, + {"splitv": [{"splith": ["A", "B"]}, {"splith": ["C", "D"]}]}, + {"splith": [{"splitv": ["A", "B"]}, {"splitv": ["C", "D"]}]}, + {"splitv": ["A", {"splith": ["B", {"splitv": ["C", "D"]}]}]}, + {"tabbed": ["A", "B", "C"]}, {"stacked": ["A", "B", "C"]}, + {"splitv": ["A", {"tabbed": ["B", "C"]}]}, {"splith": ["A", {"stacked": ["B", "C"]}]}] + with Lab() as lab: + binary = build(lab.directory) + test_binary = build_helper(lab.directory) + runtime = private_dir(lab.directory / "labfy-sway") + sway = Sway(lab.socket) + for index, shape in enumerate(shapes[:1] if smoke else shapes): + for reverse in ([False] if smoke else [False, True]): + data = fixture(shape) + apps = Surfaces(lab, test_binary) + lab.command('workspace "TEST_ONLY_SOURCE"') + order = list(reversed(data["applications"])) if reverse else data["applications"] + for app in order: apps.create("TEST_ONLY_" + app["application_id"]) + helper = lab.spawn(["autotiling"]) + time.sleep(.15) + try: + result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True) + snapped = capture(sway, catalog(data), None) + assert len(snapped["window_slots"]) == len(data["window_slots"]) + assert result["helpers_suspended"] == result["helpers_resumed"] == 1 + rows.append({"shape": shape, "reverse": reverse, "result": result, "capture": "PASS"}) + print(index, reverse, "PASS", flush=True) + finally: + helper.terminate(); helper.wait(timeout=3) + apps.close() + if not smoke: + for count in (3, 5, 10): + data = fixture({"splith": [chr(65+i) for i in range(count)]}) + apps = Surfaces(lab, test_binary) + lab.command('workspace "TEST_ONLY_SOURCE"') + for a in data["applications"]: apps.create("TEST_ONLY_" + a["application_id"]) + result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True) + rows.append({"performance_windows": count, "result": result}) + apps.close() + data = fixture() + second = fixture({"splith": ["D", "E"]}, "TEST_ONLY_SECOND") + for key in ("applications", "window_slots", "workspaces"): data[key] += second[key] + apps = Surfaces(lab, test_binary) + lab.command('workspace "TEST_ONLY_SOURCE"') + for a in data["applications"]: apps.create("TEST_ONLY_" + a["application_id"]) + result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True) + rows.append({"multi_workspace": True, "result": result}) + apps.close() + data = fixture({"splith": ["A", "B"]}) + data["workspaces"][0]["root"] = {"slot_id": "A"} + data["window_slots"][0]["tree_path"] = [] + slot = data["window_slots"][1] + slot["tree_path"] = [] + slot["state"].update(floating=True, geometry={"x": 30, "y": 40, "width": 400, "height": 240}) + apps = Surfaces(lab, test_binary) + lab.command('workspace "TEST_ONLY_SOURCE"') + for a in data["applications"]: apps.create("TEST_ONLY_" + a["application_id"]) + result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True) + rows.append({"floating": True, "result": result}) + apps.close() + # Même processus, même app_id et même titre : seule la cardinalité + # autorise ces slots explicitement interchangeables/best-effort. + for identity in ("interchangeable", "best-effort"): + data = fixture({"splith": ["A", "B", "C"]}) + data["applications"] = [data["applications"][0]] + data["applications"][0]["expected_windows"] = 3 + for slot in data["window_slots"]: + slot.update(application_id="app-A", identity_requirement=identity, identity_evidence=None) + apps = Surfaces(lab, test_binary) + lab.command('workspace "TEST_ONLY_SOURCE"') + for _ in range(3): apps.create("TEST_ONLY_app-A") + result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True) + assert result["identity_level"] == identity + rows.append({"same_app_three": True, "identity": identity, "result": result}) + apps.close() + out = EVIDENCE + private_dir(out) + atomic(out / ("smoke.json" if smoke else "runtime.json"), {"cases": rows}) + + +if __name__ == "__main__": main("--smoke" in sys.argv) diff --git a/tests/session_v2/security.py b/tests/session_v2/security.py new file mode 100644 index 000000000..4c8d49b9e --- /dev/null +++ b/tests/session_v2/security.py @@ -0,0 +1,60 @@ +"""Malformed native messages et privacy du vrai host candidat.""" +import json +import struct +import subprocess +import uuid +from common import * +from lab import Lab +from session_v2.native_host import validate as native_validate, correlate, EXTENSION_ID +from session_v2.storage import private_dir, atomic +from session_v2.errors import Failure + + +def main(): + results = [] + good = {"op": "map-window", "uuid": str(uuid.uuid4()), "runtime_token": "a" * 32} + for index, value in enumerate((None, [], {}, {**good, "op": "exec"}, {**good, "uuid": True}, + {**good, "uuid": "a" * 4096}, {**good, "runtime_token": "x" * 33}, + {**good, "argv": []}, {**good, "title": "synthetic"}, {**good, "environment": {}})): + try: native_validate(value) + except Failure: results.append({"name": f"schema-{index}", "pass": True}) + else: raise AssertionError(index) + class Reader: + def windows(self): + return [{"id": 1, "app_id": "firefox", "name": "[LABFY:" + good["runtime_token"] + "] ignored"}] + assert correlate(Reader(), good) == 1 + results.append({"name": "read-only-correlation", "pass": True}) + class Collision: + def windows(self): return Reader().windows() * 2 + try: correlate(Collision(), good) + except Failure as e: assert e.code == "EXACT_WINDOW_IDENTITY_COLLISION" + else: raise AssertionError("collision") + results.append({"name": "surface-collision", "pass": True}) + with Lab() as lab: + env = {**lab.env, "HOME": str(private_dir(lab.directory / "home")), "XDG_STATE_HOME": str(private_dir(lab.directory / "state"))} + messages = [struct.pack("=I", 0), struct.pack("=I", 4097), struct.pack("=I", 2) + b"[", struct.pack("=I", 1) + b"\xff"] + for raw in (b'{"op":1,"op":2}', b'{"op":NaN}', b'[]', b'{"command":"x"}'): + messages.append(struct.pack("=I", len(raw)) + raw) + for i, message in enumerate(messages): + proc = subprocess.run([sys.executable, "-B", str(BACKEND / "session-v2-native-host.py"), "/unused-private-manifest", EXTENSION_ID], + env=env, input=message, capture_output=True, timeout=8) + # Une troncature proprement fermée est EOF ; jamais trace ni payload. + assert not proc.stderr + if proc.stdout: + size, = struct.unpack("=I", proc.stdout[:4]); response = json.loads(proc.stdout[4:]) + assert size <= 4096 and set(response) == {"result"} and response["result"] != "OK" + results.append({"name": f"wire-{i}", "pass": True}) + denied = subprocess.run([sys.executable, "-B", str(BACKEND / "session-v2-native-host.py"), "unused", "other@invalid"], + env=env, capture_output=True, timeout=5) + assert denied.returncode == 1 and not denied.stdout and not denied.stderr + results.append({"name": "extension-id-allowlist", "pass": True}) + manifest = json.loads((REPO / "firefox-extension/session-v2/manifest.json").read_text()) + assert set(manifest["permissions"]) == {"sessions", "nativeMessaging"} and "content_scripts" not in manifest and "host_permissions" not in manifest + background = (REPO / "firefox-extension/session-v2/background.js").read_text() + assert not any(s in background for s in ("browser.tabs.", "browser.history.", "browser.cookies.", "getRecentlyClosed", "labfy_test_control", "ExecuteScript")) + results.append({"name": "minimal-permissions-no-navigation", "pass": True}) + atomic(private_dir(EVIDENCE) / "security.json", {"cases": results}) + print(len(results), "security PASS") + + +if __name__ == "__main__": main() diff --git a/tests/session_v2/test_phase3b.py b/tests/session_v2/test_phase3b.py new file mode 100644 index 000000000..52c4367b7 --- /dev/null +++ b/tests/session_v2/test_phase3b.py @@ -0,0 +1,45 @@ +"""Delta de déploiement : propriétaire autostart et acknowledgement durable.""" +import copy +from pathlib import Path +import tempfile +import unittest +from common import * +from session_v2.applications import Catalog, launch_count +from session_v2.errors import Failure +from session_v2.storage import atomic, read, private_dir +from session_v2.startup import acknowledge, successful +from session_v2.observability import Attempt + + +class DeploymentTests(unittest.TestCase): + def test_limusic_never_launched(self): + spec = Catalog().classify({'app_id': 'limusic-app'}) + app = {'application_id': 'limusic', 'desktop_entry': 'limusic.desktop', 'strategy': 'managed-autostart', + 'managed_by': 'sway-autostart', 'expected_windows': 1, 'identity_provider': None} + self.assertEqual(Catalog().validate(app), spec) + self.assertEqual(spec.argv, ()) + self.assertEqual(launch_count(app, 0), 0) + self.assertEqual(launch_count(app, 1), 0) + with self.assertRaisesRegex(Failure, 'UNEXPECTED_EXTRA_WINDOW'): launch_count(app, 2) + app['managed_by'] = 'autostart' + with self.assertRaises(Failure): Catalog().validate(app) + + def test_ack_requires_verified_current_transaction(self): + with tempfile.TemporaryDirectory() as directory: + runtime = private_dir(Path(directory)) + acknowledge(runtime, 'session', 'new') + attempt = Attempt(runtime / 'session-v2-restore-attempt.json', 'a' * 32, fixture()) + with self.assertRaises(Failure): acknowledge(runtime, 'session', 'restored', 'a' * 32) + attempt.update(status='success', phase='complete', reason='SUCCESS', final_tree_verified=True, + final_focus_verified=True, slots_filled=3, applications_observed=3) + self.assertTrue(successful(attempt.data)) + with self.assertRaises(Failure): acknowledge(runtime, 'other-session', 'restored', 'a' * 32) + with self.assertRaises(Failure): acknowledge(runtime, 'session', 'restored', 'b' * 32) + self.assertEqual(acknowledge(runtime, 'session', 'restored', 'a' * 32)['status'], 'acknowledged') + self.assertEqual(read(runtime / 'session-v2/startup.json')['choice'], 'restored') + for change in ({'final_tree_verified': False}, {'final_focus_verified': False}, + {'anchors_created': 1}, {'helpers_suspended': 1}, {'slots_filled': 2}, {'applications_observed': 2}): + self.assertFalse(successful({**attempt.data, **change})) + + +if __name__ == '__main__': unittest.main() diff --git a/tests/session_v2/test_provider.py b/tests/session_v2/test_provider.py new file mode 100644 index 000000000..810aafec9 --- /dev/null +++ b/tests/session_v2/test_provider.py @@ -0,0 +1,43 @@ +import os +import threading +import time +import unittest +import uuid +from common import * +from session_v2.storage import private_dir, atomic, read +from session_v2.firefox_identity import FirefoxProvider, process_start +from session_v2.errors import Failure +import tempfile + + +class ProviderTests(unittest.TestCase): + def test_missing_collision_and_live_publisher(self): + class SwayStub: + session = "a" * 64 + def windows(self): return [{"id": 11, "app_id": "firefox"}, {"id": 12, "app_id": "firefox"}] + with tempfile.TemporaryDirectory() as directory: + provider = FirefoxProvider(SwayStub(), directory) + a, b = str(uuid.uuid4()), str(uuid.uuid4()) + def publish(values): + end = time.monotonic() + 2 + while time.monotonic() < end: + request = provider.directory / "request.json" + if request.exists() and read(request)["expires_ns"] > time.monotonic_ns(): break + time.sleep(.005) + for ident, value in zip((11,12), values): + atomic(provider.directory / f"{ident}.json", {"schema": "firefox-session-window-uuid", "version": 1, + "session": SwayStub.session, "uuid": value, "con_id": ident, "observed_ns": time.monotonic_ns(), + "publisher_pid": os.getpid(), "publisher_start": process_start(os.getpid())}) + for values, expected_error in (((a,b), None), ((a,a), "EXACT_WINDOW_IDENTITY_COLLISION"), ((a,str(uuid.uuid4())), "EXACT_WINDOW_IDENTITY_MISSING")): + thread = threading.Thread(target=publish, args=(values,)); thread.start() + try: + if expected_error: + with self.assertRaisesRegex(Failure, expected_error): provider.resolve([a,b], SwayStub().windows(), 1) + else: self.assertEqual(provider.resolve([a,b], SwayStub().windows(), 1), {a:11,b:12}) + finally: thread.join() + self.assertTrue(provider.available()) + with self.assertRaisesRegex(Failure, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE"): + provider.capture(SwayStub().windows(), .05) + + +if __name__ == "__main__": unittest.main() diff --git a/tests/session_v2/test_unit.py b/tests/session_v2/test_unit.py new file mode 100644 index 000000000..1da346078 --- /dev/null +++ b/tests/session_v2/test_unit.py @@ -0,0 +1,234 @@ +import copy +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch +from contextlib import redirect_stdout +import uuid +from common import * +from session_v2.errors import Failure +from session_v2 import schema +from session_v2.checkpoint import Store +from session_v2.storage import atomic, read, loads, lock, private_dir +from session_v2.layout import compile_plan +from session_v2.applications import launch_count +from session_v2.slots import assign +from session_v2.native_host import validate as native_validate +from session_v2.cli import migration + + +class BackendTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="labfy-session-v2-unit-") + self.root = Path(self.temp.name) + self.data = fixture() + def tearDown(self): self.temp.cleanup() + + def test_schema_exact(self): + self.assertEqual(schema.validate(self.data), self.data) + for key in self.data: + broken = copy.deepcopy(self.data); del broken[key] + with self.assertRaises(Failure): schema.validate(broken) + self.data["pid"] = 1 + with self.assertRaises(Failure): schema.validate(self.data) + + def test_types_version_and_identity(self): + for value in (True, 2, "1", None): + broken = copy.deepcopy(self.data); broken["version"] = value + with self.assertRaises(Failure): schema.validate(broken) + self.data["applications"][0]["expected_windows"] = True + with self.assertRaises(Failure): schema.validate(self.data) + + def test_references(self): + for mutate in (lambda d: d["window_slots"][0].update(application_id="absent"), + lambda d: d.update(focus="absent"), + lambda d: d["window_slots"][0].update(tree_path=[7]), + lambda d: d["applications"][0].update(expected_windows=2)): + d = copy.deepcopy(self.data); mutate(d) + with self.assertRaises(Failure): schema.validate(d) + + def test_unsupported_states(self): + for key in ("fullscreen", "scratchpad"): + d = copy.deepcopy(self.data); d["window_slots"][0]["state"][key] = True + with self.assertRaisesRegex(Failure, "CAPABILITY_UNSUPPORTED"): schema.validate(d) + + def test_command_injection(self): + for value in ('x; exec echo bad', '../x', 'x"', 'x\n'): + d = copy.deepcopy(self.data); d["workspaces"][0]["workspace"] = value + with self.assertRaises(Failure): schema.validate(d) + + def test_plan_pure(self): + plan = compile_plan(self.data, "a" * 32) + self.assertEqual(sum(o.kind == "create" for o in plan), 3) + self.assertTrue(all(o.kind in ("workspace", "create", "focus", "layout") for o in plan)) + self.assertFalse(any("launch" in str(o) for o in plan)) + + def test_checkpoint_permissions(self): + store = Store(self.root) + store.save("example", self.data) + self.assertEqual(store.load("example"), self.data) + self.assertEqual(store.path("example").stat().st_mode & 0o777, 0o600) + self.assertEqual(store.sessions.stat().st_mode & 0o777, 0o700) + store.checkpoint(self.data) + self.assertEqual(store.last(), self.data) + self.assertEqual(store.list(), ["example"]) + store.delete("example") + self.assertEqual(store.list(), []) + + def test_intermediate_directory_permissions(self): + private_dir(self.root / "one/two/three") + for name in ("one", "one/two", "one/two/three"): + self.assertEqual((self.root / name).stat().st_mode & 0o777, 0o700) + + def test_cli_storage_checkpoint_and_startup_mock(self): + from session_v2.cli import main + state = private_dir(self.root / "state") + runtime = private_dir(self.root / "runtime") + with patch.dict(os.environ, {"XDG_STATE_HOME": str(state), "XDG_RUNTIME_DIR": str(runtime)}): + with patch("session_v2.cli.Sway") as sway, patch("session_v2.cli.FirefoxProvider"), patch("session_v2.cli.capture", return_value=self.data) as capture_mock: + sway.return_value.session = "new-session" + empty = io.StringIO() + with redirect_stdout(empty): self.assertEqual(main(["startup-status", "--socket", "/mock"]), 0) + self.assertEqual(json.loads(empty.getvalue()), {"eligible": False, "claimed": False}) + with redirect_stdout(io.StringIO()): + self.assertEqual(main(["checkpoint-last", "--execute", "--reason", "logout", "--socket", "/mock"]), 0) + self.assertEqual(main(["save", "example", "--execute", "--socket", "/mock"]), 0) + self.assertEqual(capture_mock.call_args_list[0].args[3], "logout") + def cli(args): + output = io.StringIO() + with redirect_stdout(output): code = main(args) + self.assertEqual(code, 0) + return json.loads(output.getvalue()) + self.assertEqual(cli(["list"]), {"sessions": ["example"]}) + self.assertEqual(cli(["show", "example"]), self.data) + self.assertTrue(cli(["plan", "example"])["operations"]) + self.assertTrue(cli(["startup-status", "--execute", "--socket", "/mock"])["eligible"]) + self.assertFalse(cli(["startup-status", "--socket", "/mock"])["eligible"]) + self.assertEqual(cli(["delete", "example", "--execute"]), {"status": "deleted"}) + + def test_checkpoint_failed_validation_preserves_old(self): + store = Store(self.root); store.save("a", self.data) + invalid = copy.deepcopy(self.data); invalid["version"] = 9 + with self.assertRaises(Failure): store.save("a", invalid) + self.assertEqual(store.load("a"), self.data) + self.assertFalse(list(store.sessions.glob(".tmp-*"))) + + def test_paths_links(self): + store = Store(self.root) + for name in ("../a", "a/b", "", "/tmp/a", ".", "a.json", None): + with self.assertRaises(Failure): store.path(name) + target = self.root / "outside"; atomic(target, {}) + store.path("evil").symlink_to(target) + with self.assertRaises((Failure, OSError)): store.save("evil", self.data) + self.assertEqual(read(target), {}) + + def test_json_strict(self): + for raw in (b'{"a":1,"a":2}', b'{"a":NaN}', b'{}junk', b'\xff', b'[' * 2000): + with self.assertRaises(Failure): loads(raw) + + def test_lock(self): + with lock(self.root): + with self.assertRaisesRegex(Failure, "TRANSACTION_BUSY"): + with lock(self.root): pass + + def test_launch_policy(self): + a = self.data["applications"][0].copy() + for strategy, expected, present, launches in ( + ("managed-autostart", 2, 0, 0), ("single-window", 1, 0, 1), ("single-window", 1, 1, 0), + ("browser-self-restore", 2, 0, 1), ("browser-self-restore", 2, 1, 0), + ("terminal", 3, 1, 2), ("multi-instance", 3, 1, 2)): + a.update(strategy=strategy, expected_windows=expected) + self.assertEqual(launch_count(a, present), launches) + a.update(strategy="unknown") + with self.assertRaises(Failure): launch_count(a, 0) + + def test_counts(self): + slots = self.data["window_slots"] + with self.assertRaisesRegex(Failure, "APPLICATION_WINDOWS_INCOMPLETE"): assign(slots, []) + with self.assertRaisesRegex(Failure, "UNEXPECTED_EXTRA_WINDOW"): assign(slots, [{"id": x} for x in range(4)]) + + def test_exact_inverted(self): + values = [str(uuid.uuid4()), str(uuid.uuid4())] + slots = [{"slot_id": str(i), "identity_requirement": "exact", "identity_evidence": {"type": schema.PROVIDER, "id": u}} for i,u in enumerate(values)] + self.assertEqual(assign(slots, [{"id": 99}, {"id": 12}], {values[0]: 12, values[1]: 99}), {"0": 12, "1": 99}) + with self.assertRaisesRegex(Failure, "EXACT_WINDOW_IDENTITY_MISSING"): assign(slots, [{"id": 99}, {"id": 12}], {}) + with self.assertRaisesRegex(Failure, "EXACT_WINDOW_IDENTITY_COLLISION"): assign(slots, [{"id": 99}, {"id": 12}], {u: 99 for u in values}) + + def test_interchangeable_best_effort(self): + for level in ("interchangeable", "best-effort"): + slots = [{"slot_id": s, "identity_requirement": level, "identity_evidence": None} for s in ("B", "A")] + self.assertEqual(assign(slots, [{"id": 9}, {"id": 2}]), {"A": 2, "B": 9}) + + def test_native_strict(self): + good = {"op": "map-window", "uuid": str(uuid.uuid4()), "runtime_token": "a" * 32} + self.assertEqual(native_validate(good), good) + for bad in ([], {}, {**good, "command": "x"}, {**good, "uuid": 2}, {**good, "runtime_token": "a;exec x"}, {**good, "op": "exec"}): + with self.assertRaises(Failure): native_validate(bad) + + def test_cli_execute_required(self): + for command in ("apply", "apply-last", "save", "delete", "checkpoint-last", "capture"): + p = subprocess.run([sys.executable, "-B", str(BACKEND / "session-v2.py"), command], capture_output=True, text=True) + self.assertEqual(p.returncode, 2) + self.assertEqual(json.loads(p.stdout)["reason"], "EXECUTE_REQUIRED") + + def test_coexistence_and_migration(self): + v1 = self.root / "legacy.json" + atomic(v1, {"schema": "labfy.sway.session-snapshot", "version": 1, "windows": [{"app_id": "firefox"}]}) + before = v1.read_bytes() + result = migration(v1) + self.assertEqual(result["classification"], "needs exact identity") + Store(private_dir(self.root / "session-v2")).checkpoint(self.data) + self.assertEqual(v1.read_bytes(), before) + atomic(v1, {"schema": "labfy.sway.session-snapshot", "version": 9, "windows": []}) + with self.assertRaisesRegex(Failure, "V1_FORMAT_UNRECOGNIZED"): migration(v1) + + def test_multi_output_preflight_synthetic(self): + from session_v2.executor import preflight + from unittest.mock import Mock + second = fixture("D", "TEST_ONLY_SECOND") + second["workspaces"][0]["output"] = "SYNTHETIC-2" + data = copy.deepcopy(self.data) + data["outputs"].append({"name": "SYNTHETIC-2"}) + for key in ("applications", "window_slots", "workspaces"): data[key] += second[key] + sway = Mock() + sway.outputs.return_value = [{"name": o["name"], "active": True} for o in data["outputs"]] + sway.windows.return_value = [] + sway.tree.return_value = {"nodes": []} + self.assertEqual(len(preflight(data, sway, catalog(data), None)), 4) + sway.outputs.return_value.pop() + with self.assertRaisesRegex(Failure, "OUTPUT_UNAVAILABLE"): preflight(data, sway, catalog(data), None) + sway.command.assert_not_called() + + def test_resume_attempts_all_owned_helpers(self): + from session_v2.guard import resume_helpers + helpers = [{"pid": i, "fd": i, "script": "/test/autotiling"} for i in (100, 101)] + counts = {"helpers_resumed": 0} + with patch("session_v2.guard.verify_helper", side_effect=[Failure("HELPER_SESSION_INVALID"), None]), \ + patch("session_v2.guard.signal.pidfd_send_signal") as send, \ + patch("session_v2.guard.helper_state", return_value="S"): + self.assertFalse(resume_helpers(helpers, "/test/socket", counts)) + self.assertEqual(counts["helpers_resumed"], 1) + self.assertEqual(send.call_args.args[0], 101) + self.assertTrue(helpers[1]["resumed"]) + self.assertNotIn("resumed", helpers[0]) + + def test_report_rejects_private_fields_and_bool_version(self): + from session_v2.observability import Attempt, validate as validate_report + report = Attempt(self.root / "attempt.json", "a" * 32, self.data).data + for key in ("title", "url", "argv", "environment", "cookies"): + with self.assertRaisesRegex(Failure, "REPORT_SCHEMA"): validate_report({**report, key: "forbidden"}) + with self.assertRaisesRegex(Failure, "REPORT_SCHEMA"): validate_report({**report, "version": True}) + + def test_json_schema_matches_fixture(self): + import jsonschema + contract = json.loads((BACKEND / "session_v2/schema-v1.json").read_text()) + jsonschema.Draft202012Validator.check_schema(contract) + jsonschema.validate(self.data, contract) + with self.assertRaises(jsonschema.ValidationError): jsonschema.validate({**self.data, "pid": 1}, contract) + + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_session_executor.py b/tests/test_session_executor.py deleted file mode 100644 index 60e18fb34..000000000 --- a/tests/test_session_executor.py +++ /dev/null @@ -1,604 +0,0 @@ -"""STEP19B/19C : exécuteur contrôlé, tous les effets externes sont simulés.""" - -from __future__ import annotations - -import copy -import importlib.util -import io -import json -from pathlib import Path -import tempfile -from types import SimpleNamespace -import unittest -from unittest import mock - -try: - from tests.test_session_restore import session, window -except ModuleNotFoundError: # unittest discovery ajoute directement tests/ à sys.path. - from test_session_restore import session, window - - -ROOT = Path(__file__).resolve().parents[1] -SESSION_DIR = ROOT / "quickshell/.config/quickshell/labfy-sway/session" - - -def load_module(name, filename): - spec = importlib.util.spec_from_file_location(name, SESSION_DIR / filename) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -planner = load_module("session_restore_executor_test", "session_restore.py") -executor = load_module("session_executor_test", "session_executor.py") -snapshot_backend = load_module("session_snapshot_executor_test", "session_snapshot.py") - - -DESKTOP_ENTRIES = [{ - "desktop_entry": "kitty.desktop", - "stem": "kitty", - "startup_wm_class": "kitty", - "name": "kitty", -}] - - -def kitty_saved(**kwargs): - defaults = {"workspace": "3", "con_id": 10, "pid": 1000} - defaults.update(kwargs) - return window("w1", "kitty", desktop_entry="kitty.desktop", executable="kitty", **defaults) - - -class FakeWorld: - def __init__(self, windows=(), *, launch_window=None, focused_workspace=None): - self.windows = [copy.deepcopy(item) for item in windows] - self.launch_window = copy.deepcopy(launch_window) - self.focused_workspace = focused_workspace - self.commands = [] - - def collect(self): - focused = next((item["window_id"] for item in self.windows if item.get("focused")), None) - value = session(self.windows, focused=focused) - if self.focused_workspace is not None: - if not any(item.get("name") == self.focused_workspace for item in value["workspaces"]): - value["workspaces"].append({ - "name": self.focused_workspace, - "output": "eDP-1", - "layout": "splith", - "orientation": "horizontal", - }) - value["focus"]["workspace"] = self.focused_workspace - return value - - def run(self, argv, timeout): - self.commands.append(list(argv)) - if argv[:4] == ["uwsm", "app", "--", "kitty.desktop"]: - if self.launch_window is not None: - self.windows.append(copy.deepcopy(self.launch_window)) - self.launch_window = None - return SimpleNamespace(returncode=0, stdout="", stderr="") - command = argv[-1] - prefix, operation = command.split("] ", 1) - con_id = int(prefix.removeprefix("[con_id=")) - target = next(item for item in self.windows if item["runtime"]["con_id"] == con_id) - if operation.startswith("move container to workspace number "): - target["workspace"] = operation.rsplit(" ", 1)[1] - elif operation == "floating enable": - target["floating"] = True - elif operation == "floating disable": - target["floating"] = False - elif operation == "fullscreen enable": - target["fullscreen_mode"] = 1 - elif operation == "fullscreen disable": - target["fullscreen_mode"] = 0 - elif operation == "move scratchpad": - target["scratchpad"] = {"member": True, "visibility": "hidden", "state": "fresh"} - target["workspace"] = None - target["output"] = None - elif operation == "scratchpad show": - target["scratchpad"] = {"member": True, "visibility": "visible", "state": "changed"} - target["workspace"] = self.focused_workspace or "3" - target["output"] = "eDP-1" - elif operation == "focus": - for item in self.windows: - item["focused"] = item is target - return SimpleNamespace(returncode=0, stdout='[{"success":true}]', stderr="") - - -def execute(source, world, **kwargs): - plan = planner.build_restore_plan(source, world.collect()) - return executor.execute_fresh_plan( - source, - plan, - collector=world.collect, - planner_module=planner, - desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=world.run, - timeout=0.01, - sleeper=lambda _delay: None, - **kwargs, - ), plan - - -class SessionExecutorTest(unittest.TestCase): - def test_apply_requires_explicit_execute(self): - stderr = io.StringIO() - with mock.patch.object(snapshot_backend, "apply_session") as apply, mock.patch("sys.stderr", stderr): - self.assertEqual(snapshot_backend.main(("apply", "dev")), 1) - apply.assert_not_called() - self.assertIn("REQUIRES_EXPLICIT_EXECUTE", stderr.getvalue()) - - def test_apply_execute_reaches_executor(self): - report = {"schema": executor.REPORT_SCHEMA, "status": "success"} - output = io.StringIO() - with mock.patch.object(snapshot_backend, "apply_session", return_value=report) as apply, mock.patch("sys.stdout", output): - self.assertEqual(snapshot_backend.main(("apply", "dev", "--execute")), 0) - apply.assert_called_once() - self.assertEqual(json.loads(output.getvalue()), report) - - def test_plan_blocking_happens_before_mutation(self): - saved = kitty_saved(parent="c1") - live = copy.deepcopy(saved) - live["tree_position"]["parent_container_id"] = None - container = { - "container_id": "c1", "parent_container_id": None, - "tree_position": {"branch": "tiling", "index": 0}, - "layout": "splitv", "orientation": "vertical", "percent": 1.0, - } - source = session([saved], persistent=True, containers=[container]) - world = FakeWorld([live]) - plan = planner.build_restore_plan(source, world.collect()) - with self.assertRaisesRegex(executor.RestoreExecutionError, "RESTORE_BLOCKED_UNSUPPORTED_ACTION"): - executor.execute_fresh_plan( - source, plan, collector=world.collect, planner_module=planner, - desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=world.run, - ) - self.assertEqual(world.commands, []) - - def test_command_reconstruction_ignores_arbitrary_argv(self): - saved = kitty_saved() - source = session([saved], persistent=True) - world = FakeWorld([], launch_window=kitty_saved(con_id=22, pid=2000, workspace="3")) - plan = planner.build_restore_plan(source, world.collect()) - launch = next(item for item in plan["actions"] if item["action"] == "launch-application") - launch["launch"]["argv"] = ["sh", "-c", "touch /tmp/forbidden"] - result = executor.execute_fresh_plan( - source, plan, collector=world.collect, planner_module=planner, - desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=world.run, timeout=0.01, sleeper=lambda _delay: None, - ) - self.assertEqual(result["status"], "success") - self.assertEqual(world.commands[0], ["uwsm", "app", "--", "kitty.desktop"]) - - def test_default_uwsm_launch_is_direct_and_nonblocking(self): - process = SimpleNamespace(returncode=None) - with mock.patch.object(executor.subprocess, "Popen", return_value=process) as popen, mock.patch.object( - executor.subprocess, "run" - ) as run: - self.assertIs( - executor._default_runner(["uwsm", "app", "--", "kitty.desktop"], 10.0), - process, - ) - popen.assert_called_once_with( - ["uwsm", "app", "--", "kitty.desktop"], - stdin=executor.subprocess.DEVNULL, - stdout=executor.subprocess.DEVNULL, - stderr=executor.subprocess.DEVNULL, - close_fds=True, - start_new_session=True, - ) - run.assert_not_called() - - def test_desktop_entry_is_revalidated(self): - source = session([kitty_saved()], persistent=True) - world = FakeWorld([]) - plan = planner.build_restore_plan(source, world.collect()) - with self.assertRaisesRegex(executor.RestoreExecutionError, "DesktopEntry absente"): - executor.execute_fresh_plan( - source, plan, collector=world.collect, planner_module=planner, - desktop_entries=[], resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=world.run, - ) - self.assertEqual(world.commands, []) - - def test_restore_lock_is_private_and_exclusive(self): - with tempfile.TemporaryDirectory() as temporary: - path = Path(temporary) - path.chmod(0o700) - with executor.restore_lock(path): - self.assertEqual((path / ".restore.lock").stat().st_mode & 0o777, 0o600) - with self.assertRaisesRegex(executor.RestoreExecutionError, "RESTORE_ALREADY_RUNNING"): - with executor.restore_lock(path): - self.fail("second lock unexpectedly acquired") - - def test_restore_lock_rejects_non_private_directory(self): - with tempfile.TemporaryDirectory() as temporary: - path = Path(temporary) - path.chmod(0o755) - with self.assertRaisesRegex(executor.RestoreExecutionError, "répertoire sessions non privé"): - with executor.restore_lock(path): - self.fail("unsafe directory unexpectedly accepted") - - def test_ambiguous_reuse_blocks_before_mutation(self): - saved = kitty_saved() - source = session([saved], persistent=True) - world = FakeWorld([ - kitty_saved(con_id=81, pid=8001), - kitty_saved(con_id=82, pid=8002), - ]) - plan = planner.build_restore_plan(source, world.collect()) - self.assertEqual(plan["actions"][0]["confidence"], "ambiguous") - with self.assertRaisesRegex(executor.RestoreExecutionError, "ambiguous-runtime-match"): - executor.execute_fresh_plan( - source, plan, collector=world.collect, planner_module=planner, - desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=world.run, - ) - self.assertEqual(world.commands, []) - - def test_reuse_is_validated_noop(self): - saved = kitty_saved(con_id=10) - source = session([saved], persistent=True) - world = FakeWorld([copy.deepcopy(saved)]) - result, _plan = execute(source, world) - self.assertEqual(result["actions"][0]["status"], "success") - self.assertEqual(world.commands, []) - - def test_autostart_skip_is_validated_noop(self): - saved = kitty_saved(category="autostart-managed-application", managed_by="sway-autostart") - source = session([saved], persistent=True) - world = FakeWorld([]) - result, _plan = execute(source, world) - self.assertEqual(result["actions"][0]["status"], "skipped") - self.assertEqual(world.commands, []) - - def test_launch_waits_for_new_matching_window(self): - source = session([kitty_saved()], persistent=True) - launched = kitty_saved(con_id=55, pid=5000, workspace="3") - world = FakeWorld([], launch_window=launched) - result, _plan = execute(source, world) - self.assertEqual(result["status"], "success") - self.assertEqual(result["runtime_windows"], {"w1": 55}) - - def test_launch_timeout(self): - source = session([kitty_saved()], persistent=True) - world = FakeWorld([]) - ticks = iter((0.0, 0.0, 1.0)) - result, _plan = execute(source, world, monotonic=lambda: next(ticks)) - self.assertEqual(result["status"], "failed") - self.assertIn("RESTORE_LAUNCH_TIMEOUT", result["reason"]) - - def test_launch_aborts_if_inventory_drifted_before_uwsm(self): - source = session([kitty_saved()], persistent=True) - initial = session([]) - plan = planner.build_restore_plan(source, initial) - intruder = window("w9", "other", desktop_entry="other.desktop", con_id=99, pid=9000) - commands = [] - result = executor.execute_fresh_plan( - source, plan, collector=lambda: session([intruder]), planner_module=planner, - desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=lambda argv, _timeout: commands.append(list(argv)), - ) - self.assertEqual(result["status"], "failed") - self.assertIn("inventaire con_id modifié avant lancement", result["reason"]) - self.assertEqual(commands, []) - - def test_launch_ambiguous(self): - source = session([kitty_saved()], persistent=True) - first = kitty_saved(con_id=51, pid=5001, workspace="3") - second = kitty_saved(con_id=52, pid=5002, workspace="3") - world = FakeWorld([]) - - def run(argv, timeout): - world.commands.append(list(argv)) - world.windows.extend([first, second]) - return SimpleNamespace(returncode=0, stdout="", stderr="") - - world.run = run - result, _plan = execute(source, world) - self.assertEqual(result["status"], "failed") - self.assertIn("RESTORE_LAUNCH_AMBIGUOUS", result["reason"]) - - def test_move_is_targeted_and_confirmed(self): - saved = kitty_saved(workspace="3", con_id=10) - live = kitty_saved(workspace="2", con_id=44) - source = session([saved], persistent=True) - world = FakeWorld([live]) - result, _plan = execute(source, world) - self.assertEqual(result["status"], "success") - self.assertIn("[con_id=44] move container to workspace number 3", world.commands[-1]) - - def test_floating_enable_and_disable(self): - for desired, current, expected in ((True, False, "floating enable"), (False, True, "floating disable")): - with self.subTest(desired=desired): - source = session([kitty_saved(floating=desired)], persistent=True) - world = FakeWorld([kitty_saved(floating=current, con_id=88)]) - result, _plan = execute(source, world) - self.assertEqual(result["status"], "success") - self.assertTrue(any(expected in command[-1] for command in world.commands)) - - def test_fullscreen_workspace_mode_and_disabled(self): - for desired, current, expected in ((1, 0, "fullscreen enable"), (0, 1, "fullscreen disable")): - with self.subTest(desired=desired): - source = session([kitty_saved(fullscreen=desired)], persistent=True) - world = FakeWorld([kitty_saved(fullscreen=current, con_id=89)]) - result, _plan = execute(source, world) - self.assertEqual(result["status"], "success") - self.assertTrue(any(expected in command[-1] for command in world.commands)) - - def test_unsupported_fullscreen_blocks_before_mutation(self): - source = session([kitty_saved(fullscreen=2)], persistent=True) - world = FakeWorld([kitty_saved(fullscreen=0, con_id=90)]) - plan = planner.build_restore_plan(source, world.collect()) - with self.assertRaisesRegex(executor.RestoreExecutionError, "unsupported-fullscreen-mode"): - executor.execute_fresh_plan( - source, plan, collector=world.collect, planner_module=planner, - desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=world.run, - ) - self.assertEqual(world.commands, []) - - def test_focus_is_last(self): - saved = kitty_saved(workspace="3", focused=True) - source = session([saved], persistent=True, focused="w1") - world = FakeWorld([kitty_saved(workspace="2", con_id=91, focused=False)]) - result, plan = execute(source, world) - self.assertEqual(plan["actions"][-1]["action"], "restore-focus") - self.assertTrue(world.commands[-1][-1].endswith(" focus")) - self.assertEqual(result["status"], "success") - - def test_postcondition_polling_accepts_delayed_focus_visibility(self): - saved = kitty_saved(con_id=92) - states = [kitty_saved(con_id=92, focused=False), kitty_saved(con_id=92, focused=True)] - sleeps = [] - - def collect(): - return session([states.pop(0) if len(states) > 1 else states[0]]) - - result = executor._wait_for_postcondition( - saved, - 92, - lambda item: bool(item.get("focused")), - "focus cible non atteint", - collector=collect, - planner_module=planner, - monotonic=lambda: 0.0, - sleeper=sleeps.append, - ) - self.assertTrue(result["focused"]) - self.assertEqual(sleeps, [executor.POLL_INTERVAL_SECONDS]) - - def test_runtime_con_id_disappeared(self): - saved = kitty_saved() - source = session([saved], persistent=True) - initial = session([copy.deepcopy(saved)]) - plan = planner.build_restore_plan(source, initial) - result = executor.execute_fresh_plan( - source, plan, collector=lambda: session([]), planner_module=planner, - desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=lambda *_args: self.fail("mutation must not run"), - ) - self.assertIn("con_id disparu", result["reason"]) - - def test_runtime_identity_changed(self): - saved = kitty_saved() - source = session([saved], persistent=True) - plan = planner.build_restore_plan(source, session([copy.deepcopy(saved)])) - changed = window("w9", "evil", desktop_entry="evil.desktop", con_id=10, pid=999) - result = executor.execute_fresh_plan( - source, plan, collector=lambda: session([changed]), planner_module=planner, - desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=lambda *_args: self.fail("mutation must not run"), - ) - self.assertIn("identité modifiée", result["reason"]) - - def test_snapshot_hash_is_unchanged_and_plan_is_fresh(self): - saved = kitty_saved() - source = session([saved], persistent=True, name="fresh") - world = FakeWorld([copy.deepcopy(saved)]) - loads = [] - - def loader(name): - loads.append(name) - return copy.deepcopy(source) - - with tempfile.TemporaryDirectory() as temporary: - report = executor.apply_session( - "fresh", None, sessions_path=Path(temporary), snapshot_loader=loader, - live_collector=lambda _path: world.collect(), planner_module=planner, - desktop_loader=lambda: DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=world.run, - ) - self.assertEqual(loads, ["fresh", "fresh"]) - self.assertTrue(report["snapshot_unchanged"]) - self.assertEqual(report["snapshot_hash_before"], report["snapshot_hash_after"]) - self.assertEqual(report["plan"]["live"]["windows"][0]["runtime"]["con_id"], 10) - - def test_unsupported_and_manual_actions_abort(self): - for action_type in ("restore-tree-position", "restore-scratchpad-hidden", "restore-scratchpad-visible", "manual-required", "unknown-action"): - with self.subTest(action=action_type): - source = session([kitty_saved()], persistent=True) - plan = planner.build_restore_plan(source, session([kitty_saved()])) - injected = copy.deepcopy(plan["actions"][0]) - injected.update(action_id="a999", action=action_type, phase="tree-layout", depends_on=[]) - plan["actions"] = [injected] - world = FakeWorld([kitty_saved()]) - with self.assertRaisesRegex(executor.RestoreExecutionError, "RESTORE_BLOCKED_UNSUPPORTED_ACTION"): - executor.execute_fresh_plan( - source, plan, collector=world.collect, planner_module=planner, - desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=world.run, - ) - self.assertEqual(world.commands, []) - - def test_malicious_workspace_is_never_a_command(self): - source = session([kitty_saved(workspace="3")], persistent=True) - world = FakeWorld([kitty_saved(workspace="2", con_id=77)]) - plan = planner.build_restore_plan(source, world.collect()) - move = next(item for item in plan["actions"] if item["action"] == "move-to-workspace") - move["desired_workspace"] = "3; exec touch /tmp/forbidden" - with self.assertRaisesRegex(executor.RestoreExecutionError, "unsupported-non-numeric-workspace"): - executor.execute_fresh_plan( - source, plan, collector=world.collect, planner_module=planner, - desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=world.run, - ) - self.assertEqual(world.commands, []) - - def test_malicious_desktop_entry_is_blocked_before_command(self): - saved = kitty_saved() - saved["restore_identity"]["desktop_entry"] = "kitty.desktop;touch" - source = session([saved], persistent=True) - world = FakeWorld([]) - plan = planner.build_restore_plan(source, world.collect()) - with self.assertRaisesRegex(executor.RestoreExecutionError, "identité de lancement non exacte"): - executor.execute_fresh_plan( - source, plan, collector=world.collect, planner_module=planner, - desktop_entries=[{ - "desktop_entry": "kitty.desktop;touch", "stem": "kitty", - "startup_wm_class": "kitty", "name": "hostile", - }], - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, - runner=world.run, - ) - self.assertEqual(world.commands, []) - - def test_title_and_app_id_are_not_interpolated_into_commands(self): - malicious = kitty_saved(workspace="3") - malicious["title_hint"] = "] exec touch /tmp/forbidden" - source = session([malicious], persistent=True) - world = FakeWorld([kitty_saved(workspace="2", con_id=73)]) - result, _plan = execute(source, world) - self.assertEqual(result["status"], "success") - serialized = json.dumps(world.commands) - self.assertNotIn("touch", serialized) - self.assertNotIn("title", serialized) - - def test_scratchpad_hidden_target_is_targeted_and_confirmed(self): - hidden = {"member": True, "visibility": "hidden", "state": "fresh"} - saved = kitty_saved(workspace=None, output=None, scratchpad=hidden) - live = kitty_saved(workspace="3", con_id=81) - world = FakeWorld([live], focused_workspace="3") - result, plan = execute(session([saved], persistent=True), world) - self.assertEqual(result["status"], "success") - self.assertIn("restore-scratchpad-hidden", [item["action"] for item in plan["actions"]]) - self.assertIn(["swaymsg", "-r", "[con_id=81] move scratchpad"], world.commands) - - def test_scratchpad_visible_target_uses_targeted_show(self): - visible = {"member": True, "visibility": "visible", "state": "changed"} - hidden = {"member": True, "visibility": "hidden", "state": "fresh"} - saved = kitty_saved(workspace="3", scratchpad=visible) - live = kitty_saved(workspace=None, output=None, con_id=82, scratchpad=hidden) - world = FakeWorld([live], focused_workspace="3") - result, plan = execute(session([saved], persistent=True), world) - self.assertEqual(result["status"], "success") - self.assertIn("restore-scratchpad-visible", [item["action"] for item in plan["actions"]]) - self.assertEqual(world.commands, [["swaymsg", "-r", "[con_id=82] scratchpad show"]]) - - def test_scratchpad_already_hidden_is_noop(self): - hidden = {"member": True, "visibility": "hidden", "state": "fresh"} - saved = kitty_saved(workspace=None, output=None, scratchpad=hidden) - live = kitty_saved(workspace=None, output=None, con_id=83, scratchpad=hidden) - world = FakeWorld([live], focused_workspace="3") - result, plan = execute(session([saved], persistent=True), world) - self.assertEqual(result["status"], "success") - self.assertNotIn("restore-scratchpad-hidden", [item["action"] for item in plan["actions"]]) - self.assertEqual(world.commands, []) - - def test_scratchpad_already_visible_is_noop(self): - visible = {"member": True, "visibility": "visible", "state": "changed"} - saved = kitty_saved(workspace="3", scratchpad=visible) - live = kitty_saved(workspace="3", con_id=84, scratchpad=visible) - world = FakeWorld([live], focused_workspace="3") - result, plan = execute(session([saved], persistent=True), world) - self.assertEqual(result["status"], "success") - self.assertNotIn("restore-scratchpad-visible", [item["action"] for item in plan["actions"]]) - self.assertEqual(world.commands, []) - - def test_unexpected_user_scratchpad_is_never_cycled(self): - hidden = {"member": True, "visibility": "hidden", "state": "fresh"} - saved = kitty_saved(workspace=None, output=None, scratchpad=hidden) - target = kitty_saved(workspace="3", con_id=85) - user = window( - "w9", "firefox", desktop_entry="firefox.desktop", executable="firefox", - workspace=None, output=None, con_id=99, pid=9999, scratchpad=hidden, - ) - world = FakeWorld([target, user], focused_workspace="3") - result, _plan = execute(session([saved], persistent=True), world) - self.assertEqual(result["status"], "success") - self.assertEqual(world.commands, [["swaymsg", "-r", "[con_id=85] move scratchpad"]]) - self.assertEqual(world.windows[1]["scratchpad"], hidden) - - def test_scratchpad_target_disappeared_stops(self): - hidden = {"member": True, "visibility": "hidden", "state": "fresh"} - saved = kitty_saved(workspace=None, output=None, scratchpad=hidden) - live = kitty_saved(workspace="3", con_id=86) - world = FakeWorld([live], focused_workspace="3") - plan = planner.build_restore_plan(session([saved], persistent=True), world.collect()) - world.windows.clear() - result = executor.execute_fresh_plan( - session([saved], persistent=True), plan, collector=world.collect, - planner_module=planner, desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, runner=world.run, - ) - self.assertEqual(result["status"], "failed") - self.assertIn("con_id disparu", result["reason"]) - self.assertEqual(world.commands, []) - - def test_scratchpad_identity_changed_stops(self): - hidden = {"member": True, "visibility": "hidden", "state": "fresh"} - saved = kitty_saved(workspace=None, output=None, scratchpad=hidden) - live = kitty_saved(workspace="3", con_id=87) - world = FakeWorld([live], focused_workspace="3") - plan = planner.build_restore_plan(session([saved], persistent=True), world.collect()) - world.windows[0]["app_id"] = "firefox" - world.windows[0]["restore_identity"]["app_id"] = "firefox" - world.windows[0]["restore_identity"]["desktop_entry"] = "firefox.desktop" - world.windows[0]["executable_basename"] = "firefox" - result = executor.execute_fresh_plan( - session([saved], persistent=True), plan, collector=world.collect, - planner_module=planner, desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, runner=world.run, - ) - self.assertEqual(result["status"], "failed") - self.assertIn("identité modifiée", result["reason"]) - self.assertEqual(world.commands, []) - - def test_multiple_scratchpad_identity_is_ambiguous(self): - visible = {"member": True, "visibility": "visible", "state": "changed"} - hidden = {"member": True, "visibility": "hidden", "state": "fresh"} - saved = kitty_saved(workspace="3", scratchpad=visible) - one = kitty_saved(workspace=None, output=None, con_id=88, pid=188, scratchpad=hidden) - two = kitty_saved(workspace=None, output=None, con_id=89, pid=189, scratchpad=hidden) - world = FakeWorld([one, two], focused_workspace="3") - plan = planner.build_restore_plan(session([saved], persistent=True), world.collect()) - with self.assertRaisesRegex(executor.RestoreExecutionError, "ambiguous-runtime-match"): - executor.execute_fresh_plan( - session([saved], persistent=True), plan, collector=world.collect, - planner_module=planner, desktop_entries=DESKTOP_ENTRIES, - resolve_desktop_entry=snapshot_backend.resolve_desktop_entry, runner=world.run, - ) - self.assertEqual(world.commands, []) - - def test_source_contains_no_shell_execution_primitive(self): - source = (SESSION_DIR / "session_executor.py").read_text(encoding="utf-8") - self.assertNotIn("shell=True", source) - self.assertNotIn("os.system", source) - self.assertNotIn("eval(", source) - self.assertNotIn("exec(", source) - self.assertNotIn("shell=True", source) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_session_exit_gate_qml.py b/tests/test_session_exit_gate_qml.py new file mode 100644 index 000000000..0fcb12826 --- /dev/null +++ b/tests/test_session_exit_gate_qml.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Exécuter le contrat QML de sortie de session sans commande système réelle.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +QML_TEST = ROOT / "tests/qml/tst_session_exit_gate.qml" +QML_TEST_RUNNER = Path("/usr/lib/qt6/bin/qmltestrunner") + + +class SessionExitGateQmlTest(unittest.TestCase): + def test_mocked_session_exit_contracts(self) -> None: + environment = os.environ.copy() + environment["QT_QPA_PLATFORM"] = "offscreen" + result = subprocess.run( + [str(QML_TEST_RUNNER), "-input", str(QML_TEST), "-o", "-,txt"], + cwd=ROOT, + env=environment, + text=True, + capture_output=True, + timeout=30, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_session_manager_qml.py b/tests/test_session_manager_qml.py deleted file mode 100644 index c94b21f5d..000000000 --- a/tests/test_session_manager_qml.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path -import unittest - - -ROOT = Path(__file__).resolve().parents[1] -CONTROL_CENTER = ROOT / "quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml" -SESSION_PAGE = ROOT / "quickshell/.config/quickshell/labfy-sway/controlcenter/SessionPage.qml" -SESSION_MANAGER = ROOT / "quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManager.qml" - - -class SessionManagerQmlTest(unittest.TestCase): - def setUp(self) -> None: - self.control = CONTROL_CENTER.read_text(encoding="utf-8") - self.session_page = SESSION_PAGE.read_text(encoding="utf-8") - self.manager = SESSION_MANAGER.read_text(encoding="utf-8") - - def test_navigation_preserves_power_session_and_adds_manager(self) -> None: - self.assertIn('logout", label: "Déconnexion"', self.control) - self.assertIn('command: ["uwsm", "stop"]', self.control) - self.assertIn("sessionManager: 12", self.control) - self.assertIn('onManagerRequested: popup.openPage("sessionManager")', self.control) - self.assertIn('onBackRequested: popup.openPage("session")', self.control) - self.assertIn('label: "Gestionnaire de sessions"', self.session_page) - - def test_backend_is_xdg_relative_and_every_command_is_argv(self) -> None: - self.assertIn('Quickshell.env("XDG_CONFIG_HOME")', self.manager) - self.assertIn('/quickshell/labfy-sway/session/session_snapshot.py', self.manager) - self.assertNotIn("/home/fy59", self.manager) - self.assertIn('const argv = ["python3", backend, "--compact"]', self.manager) - self.assertNotRegex(self.manager, re.compile(r"\b(?:sh|bash)\s+-c\b")) - self.assertNotIn("Quickshell.execDetached", self.manager) - - def test_user_mutations_are_explicit_and_restore_is_confirmed(self) -> None: - self.assertIn('view = exists ? "duplicate" : "create"', self.manager) - self.assertIn('page.view = "deleteConfirm"', self.manager) - self.assertIn('page.view = "restoreConfirm"', self.manager) - self.assertIn('page.run("apply", page.selectedSession.name)', self.manager) - self.assertIn('if (kind === "apply") argv.push("--execute")', self.manager) - self.assertNotIn("Timer {", self.manager) - self.assertNotIn("running: true", self.manager) - - def test_process_contract_keeps_streams_separate_and_checks_exit(self) -> None: - self.assertIn("stdout: StdioCollector", self.manager) - self.assertIn("stderr: StdioCollector", self.manager) - self.assertIn("exitCode !== 0 || exitStatus !== 0", self.manager) - self.assertIn("JSON.parse(text)", self.manager) - self.assertIn('value.read_only !== true', self.manager) - - def test_required_limitations_and_preflight_authority_are_visible(self) -> None: - self.assertIn("L’état interne des applications n’est pas garanti", self.manager) - self.assertIn("Les proportions exactes des splits ne sont pas restaurées", self.manager) - self.assertIn("Un plan frais et le preflight seront recalculés", self.manager) - self.assertIn("RESTORE_ALREADY_RUNNING", self.manager) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_session_restore.py b/tests/test_session_restore.py deleted file mode 100644 index 5ed221342..000000000 --- a/tests/test_session_restore.py +++ /dev/null @@ -1,343 +0,0 @@ -"""Contrat Restore Plan V1 : intentions déterministes et aucune exécution.""" - -import copy -import importlib.util -import io -import json -from pathlib import Path -import unittest -from unittest import mock - - -ROOT = Path(__file__).resolve().parents[1] -RESTORE_SOURCE = ROOT / "quickshell/.config/quickshell/labfy-sway/session/session_restore.py" -SNAPSHOT_SOURCE = ROOT / "quickshell/.config/quickshell/labfy-sway/session/session_snapshot.py" - - -def load_module(name, path): - spec = importlib.util.spec_from_file_location(name, path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -restore = load_module("session_restore_test", RESTORE_SOURCE) -snapshot_backend = load_module("session_snapshot_restore_test", SNAPSHOT_SOURCE) - - -def window( - window_id, - app_id, - *, - desktop_entry=None, - identity_confidence="exact", - title=None, - workspace="1", - output="eDP-1", - category="user-application", - managed_by=None, - floating=False, - fullscreen=0, - focused=False, - con_id=100, - pid=1000, - parent=None, - index=0, - xclass=None, - instance=None, - executable=None, - scratchpad=None, -): - return { - "window_id": window_id, - "snapshot_identity": f"{app_id or xclass or executable or 'unknown'}#{window_id[1:]}", - "runtime": {"con_id": con_id, "pid": pid}, - "workspace": workspace, - "output": output, - "app_id": app_id, - "xwayland": {"class": xclass, "instance": instance}, - "executable_basename": executable or app_id, - "title_hint": title, - "restore_identity": { - "app_id": app_id, - "class": xclass, - "instance": instance, - "desktop_entry": desktop_entry, - "confidence": identity_confidence, - "matched_by": "desktop-id" if desktop_entry else None, - }, - "restore_adapter": "generic-desktop" if desktop_entry else "unknown", - "classification": {"category": category, "managed_by": managed_by}, - "focused": focused, - "urgent": False, - "floating": floating, - "fullscreen_mode": fullscreen, - "rect": {"x": 0, "y": 0, "width": 800, "height": 600}, - "percent": 1.0, - "marks": [], - "scratchpad": scratchpad or {"member": False, "visibility": "not-applicable", "state": "none"}, - "tree_position": {"parent_container_id": parent, "branch": "tiling", "index": index}, - } - - -def session( - windows, - *, - persistent=False, - name="test", - outputs=("eDP-1",), - workspace_outputs=None, - containers=None, - focused=None, - captured_at="2026-10-05T00:00:00.000Z", -): - workspace_outputs = workspace_outputs or {} - workspace_names = [] - for item in windows: - workspace = item.get("workspace") - if workspace is not None and workspace not in workspace_names: - workspace_names.append(workspace) - metadata = { - "captured_at": captured_at, - "desktop": "sway:wlroots:swayfx", - "session_type": "wayland", - } - if persistent: - metadata.update({ - "session_name": name, - "created_at": captured_at, - "updated_at": captured_at, - }) - focused_id = focused - if focused_id is None: - focused_id = next((item["window_id"] for item in windows if item.get("focused")), None) - return { - "schema": "labfy.sway.session-snapshot", - "version": 1, - "metadata": metadata, - "compositor": { - "variant": "sway", - "version": "0.6", - "sway_version": "1.12.0", - "layout_engine": "sway-native", - }, - "outputs": [ - {"name": item, "active": True, "focused": index == 0} - for index, item in enumerate(outputs) - ], - "workspaces": [ - { - "name": item, - "output": workspace_outputs.get(item, outputs[0] if outputs else None), - "layout": "splith", - "orientation": "horizontal", - } - for item in workspace_names - ], - "containers": containers or [], - "windows": copy.deepcopy(windows), - "focus": { - "output": outputs[0] if outputs else None, - "workspace": next((item.get("workspace") for item in windows if item["window_id"] == focused_id), None), - "container_id": None, - "window_id": focused_id, - }, - } - - -def action_types(plan): - return [item["action"] for item in plan["actions"]] - - -class RestorePlannerTest(unittest.TestCase): - def test_exact_reuse_identical_session_has_no_mutation(self): - saved = window("w1", "firefox", desktop_entry="firefox.desktop", focused=True) - live = copy.deepcopy(saved) - live["runtime"] = {"con_id": 999, "pid": 8888} - plan = restore.build_restore_plan(session([saved], persistent=True), session([live])) - self.assertEqual(action_types(plan), ["reuse-window"]) - self.assertEqual(plan["matches"][0]["confidence"], "exact") - self.assertEqual(plan["diagnostics"]["mutating_commands_executed"], 0) - - def test_missing_exact_user_application_uses_future_uwsm_backend(self): - saved = window("w1", "firefox", desktop_entry="firefox.desktop", focused=True) - plan = restore.build_restore_plan(session([saved], persistent=True), session([])) - launch = next(item for item in plan["actions"] if item["action"] == "launch-application") - self.assertEqual(launch["launch"]["argv"], ["uwsm", "app", "--", "firefox.desktop"]) - self.assertFalse(launch["launch"]["execute_in_step19a"]) - - def test_synthetic_firefox_kitty_limusic_with_only_limusic_live(self): - saved = [ - window("w1", "firefox", desktop_entry="firefox.desktop"), - window("w2", "kitty", desktop_entry="kitty.desktop", executable="kitty"), - window("w3", "limusic-app", desktop_entry="limusic.desktop", workspace="10", category="autostart-managed-application", managed_by="sway-autostart"), - ] - live = [copy.deepcopy(saved[2])] - plan = restore.build_restore_plan(session(saved, persistent=True), session(live)) - launches = [item["desktop_entry"] for item in plan["actions"] if item["action"] == "launch-application"] - self.assertEqual(launches, ["firefox.desktop", "kitty.desktop"]) - self.assertEqual(sum(item["action"] == "reuse-window" for item in plan["actions"]), 1) - - def test_existing_application_on_wrong_workspace_is_reused_then_moved(self): - saved = window("w1", "firefox", desktop_entry="firefox.desktop", workspace="1", focused=True) - live = window("w1", "firefox", desktop_entry="firefox.desktop", workspace="2", focused=True) - plan = restore.build_restore_plan(session([saved], persistent=True), session([live])) - self.assertIn("reuse-window", action_types(plan)) - self.assertIn("move-to-workspace", action_types(plan)) - self.assertNotIn("launch-application", action_types(plan)) - move = next(item for item in plan["actions"] if item["action"] == "move-to-workspace") - self.assertEqual(move["desired_workspace"], "1") - - def test_multi_window_matches_only_best_title_and_never_reuses_twice(self): - saved_a = window("w1", "firefox", desktop_entry="firefox.desktop", title="Firefox — A") - saved_b = window("w2", "firefox", desktop_entry="firefox.desktop", title="Firefox — B") - live_b = window("w1", "firefox", desktop_entry="firefox.desktop", title="Firefox — B", pid=5000) - plan = restore.build_restore_plan(session([saved_a, saved_b], persistent=True), session([live_b])) - self.assertEqual(len(plan["matches"]), 1) - self.assertEqual(plan["matches"][0]["source_window"], "w2") - launch = next(item for item in plan["actions"] if item["action"] == "launch-application") - self.assertEqual(launch["source_window"], "w1") - - def test_title_hint_is_not_required_identity(self): - saved = window("w1", "firefox", desktop_entry="firefox.desktop", title="Ancien titre") - live = window("w9", "firefox", desktop_entry="firefox.desktop", title="Nouveau titre") - plan = restore.build_restore_plan(session([saved], persistent=True), session([live])) - self.assertEqual(plan["matches"][0]["source_window"], "w1") - - def test_app_id_without_desktop_entry_is_high_confidence(self): - saved = window("w1", "custom-app", identity_confidence="unresolved") - live = window("w7", "custom-app", identity_confidence="unresolved") - plan = restore.build_restore_plan(session([saved], persistent=True), session([live])) - self.assertEqual(plan["matches"][0]["confidence"], "high") - self.assertIn("app-id", plan["matches"][0]["matched_by"]) - - def test_xwayland_class_without_app_id_is_high_confidence(self): - saved = window("w1", None, xclass="LegacyApp", executable=None, identity_confidence="unresolved") - live = window("w8", None, xclass="LegacyApp", executable=None, identity_confidence="unresolved") - saved["executable_basename"] = None - live["executable_basename"] = None - plan = restore.build_restore_plan(session([saved], persistent=True), session([live])) - self.assertEqual(plan["matches"][0]["confidence"], "high") - self.assertEqual(plan["matches"][0]["matched_by"], ["class"]) - - def test_indistinguishable_live_candidates_are_ambiguous_but_one_to_one(self): - saved = window("w1", "firefox", desktop_entry="firefox.desktop") - first = window("w1", "firefox", desktop_entry="firefox.desktop", pid=2000) - second = window("w2", "firefox", desktop_entry="firefox.desktop", pid=3000) - plan = restore.build_restore_plan(session([saved], persistent=True), session([first, second])) - self.assertEqual(len(plan["matches"]), 1) - self.assertEqual(plan["matches"][0]["confidence"], "ambiguous") - - def test_absent_autostart_application_is_not_launched(self): - saved = window("w1", "limusic-app", desktop_entry="limusic.desktop", category="autostart-managed-application", managed_by="sway-autostart") - plan = restore.build_restore_plan(session([saved], persistent=True), session([])) - self.assertEqual(action_types(plan), ["skip-autostart-managed"]) - self.assertNotIn("launch-application", action_types(plan)) - - def test_unresolved_window_requires_manual_action(self): - saved = window("w1", None, identity_confidence="unresolved", executable=None) - saved["executable_basename"] = None - plan = restore.build_restore_plan(session([saved], persistent=True), session([])) - self.assertEqual(action_types(plan), ["manual-required"]) - self.assertEqual(plan["actions"][0]["reason"], "no-exact-launch-identity") - - def test_missing_output_is_explicit_and_has_no_fallback_launch(self): - saved = window("w1", "firefox", desktop_entry="firefox.desktop", output="HDMI-A-1") - plan = restore.build_restore_plan(session([saved], persistent=True, outputs=("HDMI-A-1",)), session([], outputs=("eDP-1",))) - self.assertEqual(action_types(plan), ["manual-required"]) - self.assertEqual(plan["actions"][0]["policy"], "deferred-no-fallback") - - def test_floating_difference_produces_intention_only(self): - saved = window("w1", "kitty", desktop_entry="kitty.desktop", floating=True) - live = window("w1", "kitty", desktop_entry="kitty.desktop", floating=False) - plan = restore.build_restore_plan(session([saved], persistent=True), session([live])) - action = next(item for item in plan["actions"] if item["action"] == "restore-floating") - self.assertTrue(action["desired_floating"]) - - def test_fullscreen_difference_produces_intention_only(self): - saved = window("w1", "firefox", desktop_entry="firefox.desktop", fullscreen=1) - live = window("w1", "firefox", desktop_entry="firefox.desktop", fullscreen=0) - plan = restore.build_restore_plan(session([saved], persistent=True), session([live])) - action = next(item for item in plan["actions"] if item["action"] == "restore-fullscreen") - self.assertEqual(action["desired_fullscreen_mode"], 1) - - def test_layout_difference_is_partially_supported(self): - saved = window("w1", "firefox", desktop_entry="firefox.desktop", parent="c1") - live = window("w1", "firefox", desktop_entry="firefox.desktop") - container = { - "container_id": "c1", "parent_container_id": None, - "tree_position": {"branch": "tiling", "index": 0}, - "layout": "splitv", "orientation": "vertical", "percent": 1.0, - } - plan = restore.build_restore_plan(session([saved], persistent=True, containers=[container]), session([live])) - action = next(item for item in plan["actions"] if item["action"] == "restore-tree-position") - self.assertEqual(action["capability"], "partially-supported") - - def test_scratchpad_hidden_intention(self): - scratch = {"member": True, "visibility": "hidden", "state": "fresh"} - saved = window("w1", "kitty", desktop_entry="kitty.desktop", workspace=None, output=None, scratchpad=scratch) - live = window("w1", "kitty", desktop_entry="kitty.desktop") - plan = restore.build_restore_plan(session([saved], persistent=True), session([live])) - self.assertIn("restore-scratchpad-hidden", action_types(plan)) - - def test_focus_is_last_after_mutating_intentions(self): - saved = window("w1", "firefox", desktop_entry="firefox.desktop", workspace="1", focused=True) - live = window("w1", "firefox", desktop_entry="firefox.desktop", workspace="2", focused=True) - plan = restore.build_restore_plan(session([saved], persistent=True, focused="w1"), session([live], focused="w1")) - self.assertEqual(plan["actions"][-1]["action"], "restore-focus") - self.assertTrue(plan["actions"][-1]["depends_on"]) - - def test_infrastructure_is_never_launched(self): - saved = window("w1", "quickshell", desktop_entry="org.quickshell.desktop", category="session-infrastructure") - plan = restore.build_restore_plan(session([saved], persistent=True), session([])) - self.assertEqual(action_types(plan), ["manual-required"]) - self.assertNotIn("launch-application", action_types(plan)) - - def test_runtime_ids_are_observations_not_matching_identity(self): - saved = window("w1", "firefox", desktop_entry="firefox.desktop", con_id=7, pid=10) - live = window("w9", "firefox", desktop_entry="firefox.desktop", con_id=900, pid=9999) - plan = restore.build_restore_plan(session([saved], persistent=True), session([live])) - self.assertEqual(plan["matches"][0]["source_window"], "w1") - self.assertEqual(plan["policy"]["runtime_identity_fields"], []) - self.assertEqual(plan["live"]["windows"][0]["runtime"], {"con_id": 900, "pid": 9999}) - - def test_same_inputs_produce_byte_equivalent_plan(self): - saved = [ - window("w1", "firefox", desktop_entry="firefox.desktop", title="A"), - window("w2", "firefox", desktop_entry="firefox.desktop", title="B"), - ] - live = [window("w7", "firefox", desktop_entry="firefox.desktop", title="B")] - source = session(saved, persistent=True) - current = session(live) - first = restore.build_restore_plan(source, current) - second = restore.build_restore_plan(copy.deepcopy(source), copy.deepcopy(current)) - self.assertEqual(json.dumps(first, sort_keys=True), json.dumps(second, sort_keys=True)) - - def test_cli_plan_uses_only_loader_collector_and_pure_planner(self): - saved = window("w1", "firefox", desktop_entry="firefox.desktop") - source = session([saved], persistent=True, name="cli") - current = session([copy.deepcopy(saved)]) - before = copy.deepcopy(source) - output = io.StringIO() - with mock.patch.object(snapshot_backend, "show_session", return_value=source) as loader, mock.patch.object( - snapshot_backend, "collect", return_value=current - ) as collector, mock.patch.object(snapshot_backend, "validate_snapshot") as validator, mock.patch("sys.stdout", output): - self.assertEqual(snapshot_backend.main(("plan", "cli")), 0) - result = json.loads(output.getvalue()) - self.assertEqual(result["schema"], "labfy.sway.restore-plan") - loader.assert_called_once_with("cli") - collector.assert_called_once() - validator.assert_called_once_with(current) - self.assertEqual(source, before) - self.assertEqual(result["diagnostics"]["applications_launched"], 0) - - def test_planner_module_has_no_process_or_filesystem_execution_api(self): - source_text = RESTORE_SOURCE.read_text(encoding="utf-8") - self.assertNotIn("import subprocess", source_text) - self.assertNotIn("subprocess.", source_text) - self.assertNotIn("swaymsg", source_text) - self.assertNotIn("os.system", source_text) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_session_snapshot.py b/tests/test_session_snapshot.py deleted file mode 100644 index 07f91a746..000000000 --- a/tests/test_session_snapshot.py +++ /dev/null @@ -1,543 +0,0 @@ -"""Contrat Session Snapshot V1 sur arbres Sway synthétiques.""" - -import copy -import concurrent.futures -import importlib.util -import io -import json -import os -from pathlib import Path -import stat -import tempfile -import unittest -from unittest import mock - - -SOURCE = ( - Path(__file__).resolve().parents[1] - / "quickshell/.config/quickshell/labfy-sway/session/session_snapshot.py" -) -SPEC = importlib.util.spec_from_file_location("session_snapshot", SOURCE) -snapshot_module = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(snapshot_module) - - -def leaf( - con_id, - app_id, - *, - focused=False, - floating="auto_off", - fullscreen=0, - properties=None, - scratchpad_state="none", -): - return { - "id": con_id, - "type": "con", - "name": f"titre {con_id}", - "app_id": app_id, - "window": con_id if properties else None, - "window_properties": properties, - "pid": 90000 + con_id, - "nodes": [], - "floating_nodes": [], - "layout": "none", - "orientation": "none", - "percent": 0.5, - "rect": {"x": con_id, "y": 2, "width": 300, "height": 200}, - "focused": focused, - "urgent": False, - "marks": [f"mark-{con_id}"], - "floating": floating, - "fullscreen_mode": fullscreen, - "scratchpad_state": scratchpad_state, - } - - -def container(con_id, layout, children, floating_nodes=None): - return { - "id": con_id, - "type": "con", - "name": None, - "nodes": children, - "floating_nodes": floating_nodes or [], - "layout": layout, - "orientation": "horizontal" if layout in {"splith", "tabbed"} else "vertical", - "percent": 1.0, - "rect": {"x": 0, "y": 0, "width": 1200, "height": 800}, - "focused": False, - "urgent": False, - "marks": [], - } - - -def fixture(): - firefox_one = leaf(11, "firefox") - firefox_two = leaf(12, "firefox", fullscreen=1) - kitty = leaf(13, "kitty", focused=True) - xwayland = leaf(14, None, properties={"class": "LegacyApp", "instance": "legacy"}) - unknown = leaf(15, "not-catalogued") - floating = leaf(16, "float-app", floating="user_on") - tabbed = container(21, "tabbed", [firefox_one, firefox_two]) - stacked = container(22, "stacked", [kitty, xwayland]) - splitv = container(23, "splitv", [tabbed, stacked]) - splith = container(24, "splith", [splitv, unknown], [floating]) - workspace = { - "id": 4, - "type": "workspace", - "name": "dev", - "nodes": [splith], - "floating_nodes": [], - "layout": "splith", - "orientation": "horizontal", - } - scratch = leaf(30, "scratch-app", floating="user_on", scratchpad_state="changed") - tree = { - "id": 1, - "type": "root", - "nodes": [ - { - "id": 2, - "type": "output", - "name": "__i3", - "nodes": [{"id": 3, "type": "workspace", "name": "__i3_scratch", "nodes": [], "floating_nodes": [scratch]}], - "floating_nodes": [], - }, - {"id": 5, "type": "output", "name": "DP-1", "nodes": [workspace], "floating_nodes": []}, - ], - } - outputs = [{ - "name": "DP-1", - "active": True, - "focused": True, - "rect": {"x": 0, "y": 0, "width": 1200, "height": 800}, - "scale": 1.25, - "transform": "normal", - "current_mode": {"width": 1920, "height": 1080, "refresh": 60000}, - }] - workspaces = [{ - "name": "dev", - "num": -1, - "output": "DP-1", - "focused": True, - "visible": True, - "urgent": False, - "rect": {"x": 0, "y": 0, "width": 1200, "height": 800}, - "layout": "splith", - "orientation": "horizontal", - }] - version = {"variant": "sway", "human_readable": "0.6", "sway_original_version": "1.12.0"} - entries = [ - {"desktop_entry": "firefox.desktop", "stem": "firefox", "startup_wm_class": "firefox", "name": "Firefox"}, - {"desktop_entry": "kitty.desktop", "stem": "kitty", "startup_wm_class": "kitty", "name": "Kitty"}, - {"desktop_entry": "legacy.desktop", "stem": "legacy-desktop", "startup_wm_class": "LegacyApp", "name": "Legacy"}, - ] - return version, outputs, workspaces, tree, entries - - -class SessionSnapshotTest(unittest.TestCase): - def build(self): - version, outputs, workspaces, tree, entries = fixture() - with mock.patch.object(snapshot_module, "_basename_from_pid", return_value=None): - return snapshot_module.build_snapshot( - version, - outputs, - workspaces, - tree, - captured_at="2026-01-01T00:00:00.000Z", - environment={"XDG_SESSION_TYPE": "wayland", "XDG_CURRENT_DESKTOP": "sway"}, - desktop_entries=entries, - managed_autostart={"float-app"}, - ) - - def test_layout_tree_order_and_simple_workspace(self): - snapshot = self.build() - self.assertEqual(snapshot["workspaces"][0]["name"], "dev") - self.assertEqual(snapshot["workspaces"][0]["roots"]["tiling"], [{"kind": "container", "id": "c1"}]) - layouts = {item["layout"] for item in snapshot["containers"]} - self.assertTrue({"splith", "splitv", "tabbed", "stacked"}.issubset(layouts)) - top = snapshot["containers"][0] - self.assertEqual([ref["kind"] for ref in top["children"]], ["container", "window"]) - - def test_simple_direct_window_and_empty_workspace(self): - version, outputs, workspaces, tree, entries = fixture() - direct = leaf(40, "firefox") - real_output = tree["nodes"][1] - real_output["nodes"][0]["nodes"] = [direct] - empty = { - "id": 41, "type": "workspace", "name": "2", "nodes": [], - "floating_nodes": [], "layout": "splith", "orientation": "horizontal", - } - real_output["nodes"].append(empty) - workspaces.append({ - "name": "2", "num": 2, "output": "DP-1", "focused": False, - "visible": False, "urgent": False, - "rect": {"x": 0, "y": 0, "width": 1200, "height": 800}, - "layout": "splith", "orientation": "horizontal", - }) - with mock.patch.object(snapshot_module, "_basename_from_pid", return_value=None): - snapshot = snapshot_module.build_snapshot(version, outputs, workspaces, tree, desktop_entries=entries) - self.assertEqual(snapshot["workspaces"][0]["roots"]["tiling"][0]["kind"], "window") - self.assertEqual(snapshot["workspaces"][1]["roots"], {"tiling": [], "floating": []}) - - def test_floating_fullscreen_focus_and_marks(self): - snapshot = self.build() - floating = next(window for window in snapshot["windows"] if window["app_id"] == "float-app") - fullscreen = next(window for window in snapshot["windows"] if window["runtime"]["con_id"] == 12) - kitty = next(window for window in snapshot["windows"] if window["app_id"] == "kitty") - self.assertTrue(floating["floating"]) - self.assertEqual(floating["classification"]["managed_by"], "sway-autostart") - self.assertEqual(fullscreen["fullscreen_mode"], 1) - self.assertEqual(snapshot["focus"]["window_id"], kitty["window_id"]) - self.assertEqual(kitty["marks"], ["mark-13"]) - - def test_duplicate_app_ids_keep_distinct_snapshot_ordinals(self): - windows = [window for window in self.build()["windows"] if window["app_id"] == "firefox"] - self.assertEqual([window["snapshot_identity"] for window in windows], ["firefox#1", "firefox#2"]) - self.assertNotEqual(windows[0]["window_id"], windows[1]["window_id"]) - - def test_desktop_identity_exact_xwayland_and_unresolved(self): - snapshot = self.build() - firefox = next(window for window in snapshot["windows"] if window["app_id"] == "firefox") - legacy = next(window for window in snapshot["windows"] if window["xwayland"]["class"] == "LegacyApp") - unknown = next(window for window in snapshot["windows"] if window["app_id"] == "not-catalogued") - self.assertEqual(firefox["restore_identity"]["desktop_entry"], "firefox.desktop") - self.assertEqual(legacy["restore_identity"]["matched_by"], "StartupWMClass") - self.assertEqual(legacy["restore_identity"]["confidence"], "exact") - self.assertEqual(unknown["restore_identity"]["confidence"], "unresolved") - - def test_hidden_scratchpad_is_not_a_user_workspace(self): - snapshot = self.build() - scratch = next(window for window in snapshot["windows"] if window["app_id"] == "scratch-app") - self.assertIsNone(scratch["workspace"]) - self.assertTrue(scratch["scratchpad"]["member"]) - self.assertEqual(scratch["scratchpad"]["visibility"], "hidden") - self.assertEqual(snapshot["restore_hints"]["scratchpad_roots"], [{"kind": "window", "id": scratch["window_id"]}]) - - def test_visible_scratchpad_member_stays_on_real_workspace(self): - version, outputs, workspaces, tree, entries = fixture() - visible = leaf(42, "scratch-visible", scratchpad_state="fresh") - tree["nodes"][1]["nodes"][0]["floating_nodes"] = [visible] - with mock.patch.object(snapshot_module, "_basename_from_pid", return_value=None): - snapshot = snapshot_module.build_snapshot(version, outputs, workspaces, tree, desktop_entries=entries) - window = next(item for item in snapshot["windows"] if item["app_id"] == "scratch-visible") - self.assertEqual(window["workspace"], "dev") - self.assertEqual(window["scratchpad"]["visibility"], "visible") - - def test_unique_executable_basename_is_only_heuristic(self): - entries = [{"desktop_entry": "odd-name.desktop", "stem": "tool", "startup_wm_class": None, "name": "Tool"}] - identity = snapshot_module.resolve_desktop_entry("unrelated", {}, "tool", entries) - self.assertEqual(identity, { - "desktop_entry": "odd-name.desktop", - "confidence": "heuristic", - "matched_by": "executable-basename", - }) - - def test_title_and_timestamp_do_not_affect_structural_projection(self): - first = self.build() - second = copy.deepcopy(first) - second["metadata"]["captured_at"] = "2027-02-02T00:00:00Z" - second["windows"][0]["title_hint"] = "titre dynamique différent" - self.assertEqual( - snapshot_module.structural_projection(first), - snapshot_module.structural_projection(second), - ) - - def test_validator_rejects_broken_tree_relation(self): - snapshot = self.build() - snapshot["workspaces"][0]["roots"]["tiling"][0]["id"] = "absent" - with self.assertRaises(snapshot_module.SnapshotError): - snapshot_module.validate_snapshot(snapshot) - - def test_validator_rejects_duplicate_reference(self): - snapshot = self.build() - root = snapshot["workspaces"][0]["roots"]["tiling"][0] - snapshot["workspaces"][0]["roots"]["tiling"].append(copy.deepcopy(root)) - with self.assertRaises(snapshot_module.SnapshotError): - snapshot_module.validate_snapshot(snapshot) - - def test_autostart_parser_excludes_shell_wrappers_and_keeps_direct_apps(self): - with tempfile.TemporaryDirectory() as folder: - path = Path(folder) / "autostart" - path.write_text( - "exec limusic-app\n" - "exec python3 ~/.config/sway/scripts/inactive-windows-transparency.py\n" - "exec sh -c 'private command'\n", - encoding="utf-8", - ) - self.assertEqual( - snapshot_module.parse_sway_autostart(path), - {"limusic-app", "inactive-windows-transparency.py"}, - ) - - def test_no_command_line_or_environment_field_exists(self): - serialized_keys = repr(self.build()).casefold() - self.assertNotIn("cmdline", serialized_keys) - self.assertNotIn("command_line", serialized_keys) - self.assertNotIn("/proc/", serialized_keys) - self.assertNotIn("environment", serialized_keys) - - -class PersistentSessionTest(unittest.TestCase): - """Contrats STEP18B dans un XDG state isolé du compte utilisateur.""" - - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - self.state = self.root / "state" - self.environment = {"XDG_STATE_HOME": str(self.state)} - version, outputs, workspaces, tree, entries = fixture() - with mock.patch.object(snapshot_module, "_basename_from_pid", return_value=None): - self.snapshot = snapshot_module.build_snapshot( - version, - outputs, - workspaces, - tree, - captured_at="2026-01-01T00:00:00.000Z", - environment={"XDG_SESSION_TYPE": "wayland", "XDG_CURRENT_DESKTOP": "sway"}, - desktop_entries=entries, - managed_autostart={"float-app"}, - ) - - def collector(self, _autostart): - return copy.deepcopy(self.snapshot) - - def save(self, name="dev", **kwargs): - return snapshot_module.save_session( - name, - environment=self.environment, - collector=self.collector, - retry_delay=0, - **kwargs, - ) - - @property - def sessions(self): - return self.state / "labfy-sway/sessions" - - def write_raw(self, name, value=None, payload=None): - snapshot_module.sessions_directory(self.environment) - path = self.sessions / f"{name}.json" - if payload is None: - payload = json.dumps(value).encode("utf-8") - path.write_bytes(payload) - path.chmod(0o600) - return path - - def test_xdg_path_fallback_and_private_directory_permissions(self): - path = snapshot_module.sessions_directory(self.environment) - self.assertEqual(path, self.sessions) - self.assertEqual(stat.S_IMODE((self.state / "labfy-sway").stat().st_mode), 0o700) - self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o700) - fallback = snapshot_module.sessions_directory({}, self.root / "home") - self.assertEqual(fallback, self.root / "home/.local/state/labfy-sway/sessions") - with self.assertRaises(snapshot_module.SnapshotError): - snapshot_module.sessions_directory({"XDG_STATE_HOME": "relative"}, self.root) - - def test_safe_and_invalid_names_prevent_path_traversal(self): - for name in ("default", "Dev_01", "osint-prod", "A" * 64): - self.assertEqual(snapshot_module.normalize_session_name(name), name) - for name in ("", "..", ".", "a/b", "../dev", "a b", "é", "A" * 65, "nul\0name"): - with self.subTest(name=repr(name)): - with self.assertRaises(snapshot_module.SnapshotError): - snapshot_module.normalize_session_name(name) - - def test_atomic_first_save_permissions_and_persistent_metadata(self): - previous_umask = os.umask(0) - try: - saved = self.save() - finally: - os.umask(previous_umask) - path = self.sessions / "dev.json" - self.assertTrue(path.is_file()) - self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) - self.assertEqual(stat.S_IMODE(self.sessions.stat().st_mode), 0o700) - self.assertEqual(saved["schema"], snapshot_module.SCHEMA) - self.assertEqual(saved["version"], 1) - self.assertEqual(saved["metadata"]["session_name"], "dev") - self.assertEqual(saved["metadata"]["created_at"], saved["metadata"]["updated_at"]) - self.assertFalse(list(self.sessions.glob("*.tmp"))) - - def test_atomic_update_preserves_created_at_and_advances_updated_at(self): - first = self.save() - first_bytes = (self.sessions / "dev.json").read_bytes() - self.snapshot["metadata"]["captured_at"] = "2026-01-01T00:00:01.000Z" - second = self.save() - self.assertEqual(second["metadata"]["created_at"], first["metadata"]["created_at"]) - self.assertGreater(second["metadata"]["updated_at"], first["metadata"]["updated_at"]) - self.assertNotEqual(second["metadata"]["captured_at"], first["metadata"]["captured_at"]) - self.assertNotEqual((self.sessions / "dev.json").read_bytes(), first_bytes) - self.assertEqual([path.name for path in self.sessions.glob("*.json")], ["dev.json"]) - - def test_list_show_delete_round_trip(self): - saved = self.save("trainlog") - listed, warnings = snapshot_module.list_sessions(environment=self.environment) - self.assertEqual(warnings, []) - self.assertEqual(listed, [{ - "name": "trainlog", - "updated_at": saved["metadata"]["updated_at"], - "windows": 7, - "workspaces": 1, - }]) - shown = snapshot_module.show_session("trainlog", environment=self.environment) - self.assertEqual(shown, saved) - snapshot_module.delete_session("trainlog", environment=self.environment) - self.assertFalse((self.sessions / "trainlog.json").exists()) - self.assertTrue(self.sessions.is_dir()) - with self.assertRaises(snapshot_module.SnapshotError): - snapshot_module.show_session("trainlog", environment=self.environment) - - def test_corrupt_invalid_relation_and_unknown_files_are_ignored_by_list(self): - self.save("valid") - self.write_raw("truncated", payload=b'{"schema":') - invalid_relation = copy.deepcopy(snapshot_module.show_session("valid", environment=self.environment)) - invalid_relation["workspaces"][0]["roots"]["tiling"][0]["id"] = "absent" - self.write_raw("relation", invalid_relation) - unknown_schema = copy.deepcopy(invalid_relation) - unknown_schema["schema"] = "unknown" - self.write_raw("schema", unknown_schema) - listed, warnings = snapshot_module.list_sessions(environment=self.environment) - self.assertEqual([item["name"] for item in listed], ["valid"]) - self.assertEqual(len(warnings), 3) - for name in ("truncated", "relation", "schema"): - with self.assertRaises(snapshot_module.SnapshotError): - snapshot_module.show_session(name, environment=self.environment) - - def test_unknown_version_has_explicit_error(self): - snapshot = self.save("future") - snapshot["version"] = 99 - self.write_raw("future", snapshot) - with self.assertRaisesRegex(snapshot_module.SnapshotError, "unsupported snapshot version"): - snapshot_module.show_session("future", environment=self.environment) - - def test_oversized_snapshot_is_rejected_before_json_loading(self): - self.write_raw("huge", payload=b"{" + b" " * snapshot_module.MAX_SNAPSHOT_BYTES) - with self.assertRaisesRegex(snapshot_module.SnapshotError, "trop volumineux"): - snapshot_module.show_session("huge", environment=self.environment) - - def test_explicit_save_repairs_corrupt_regular_target(self): - self.write_raw("repair", payload=b"not-json") - saved = self.save("repair") - self.assertEqual(snapshot_module.show_session("repair", environment=self.environment), saved) - - def test_symlink_target_is_refused_for_save_show_and_delete(self): - snapshot_module.sessions_directory(self.environment) - external = self.root / "external" - external.write_text("do not touch", encoding="utf-8") - (self.sessions / "evil.json").symlink_to(external) - with self.assertRaisesRegex(snapshot_module.SnapshotError, "lien symbolique"): - self.save("evil") - with self.assertRaises(snapshot_module.SnapshotError): - snapshot_module.show_session("evil", environment=self.environment) - with self.assertRaisesRegex(snapshot_module.SnapshotError, "lien symbolique"): - snapshot_module.delete_session("evil", environment=self.environment) - self.assertEqual(external.read_text(encoding="utf-8"), "do not touch") - - def test_managed_directory_symlink_is_refused(self): - self.state.mkdir() - external = self.root / "outside" - external.mkdir() - (self.state / "labfy-sway").symlink_to(external, target_is_directory=True) - with self.assertRaises(snapshot_module.SnapshotError): - snapshot_module.sessions_directory(self.environment) - - def test_interrupted_update_keeps_old_snapshot_and_cleans_temporary(self): - self.save() - path = self.sessions / "dev.json" - before = path.read_bytes() - - def interrupt(_temporary): - raise snapshot_module.SnapshotError("interruption simulée avant rename") - - with self.assertRaisesRegex(snapshot_module.SnapshotError, "interruption simulée"): - self.save(before_replace=interrupt) - self.assertEqual(path.read_bytes(), before) - self.assertEqual(snapshot_module.show_session("dev", environment=self.environment)["schema"], snapshot_module.SCHEMA) - self.assertFalse(list(self.sessions.glob("*.tmp"))) - - def test_concurrent_saves_are_serialized_and_valid(self): - def operation(_index): - return self.save()["metadata"]["updated_at"] - - with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: - timestamps = list(executor.map(operation, range(12))) - shown = snapshot_module.show_session("dev", environment=self.environment) - self.assertEqual(shown["metadata"]["created_at"], min(timestamps)) - self.assertEqual(shown["metadata"]["updated_at"], max(timestamps)) - self.assertFalse(list(self.sessions.glob("*.tmp"))) - - def test_retry_inconsistent_ipc_then_success(self): - calls = 0 - - def flaky(_autostart): - nonlocal calls - calls += 1 - if calls < 3: - raise snapshot_module.SnapshotError("snapshot V1 invalide: relation IPC") - return self.collector(None) - - saved = snapshot_module.save_session( - "retry", - environment=self.environment, - collector=flaky, - retry_delay=0, - ) - self.assertEqual(calls, 3) - self.assertEqual(saved["metadata"]["session_name"], "retry") - - def test_retry_exhaustion_keeps_previous_snapshot(self): - self.save("stable") - path = self.sessions / "stable.json" - before = path.read_bytes() - calls = 0 - - def inconsistent(_autostart): - nonlocal calls - calls += 1 - raise snapshot_module.SnapshotError("snapshot V1 invalide: relation IPC") - - with self.assertRaisesRegex(snapshot_module.SnapshotError, "SAVE_FAILED_INCONSISTENT_STATE"): - snapshot_module.save_session( - "stable", - environment=self.environment, - collector=inconsistent, - retry_delay=0, - ) - self.assertEqual(calls, 3) - self.assertEqual(path.read_bytes(), before) - - def test_structural_equivalence_excludes_persistence_timestamps_and_titles(self): - first = self.save() - second = copy.deepcopy(first) - second["metadata"]["created_at"] = "2030-01-01T00:00:00.000Z" - second["metadata"]["updated_at"] = "2030-01-01T00:00:01.000Z" - second["metadata"]["captured_at"] = "2030-01-01T00:00:02.000Z" - second["windows"][0]["title_hint"] = "volatile" - self.assertEqual( - snapshot_module.structural_projection(first), - snapshot_module.structural_projection(second), - ) - - def test_cli_save_list_show_delete(self): - outputs = [] - with mock.patch.dict(os.environ, self.environment, clear=False), mock.patch.object( - snapshot_module, "collect", side_effect=lambda _path: self.collector(None) - ): - for arguments in (("save", "cli"), ("list",), ("show", "cli"), ("delete", "cli")): - stdout = io.StringIO() - stderr = io.StringIO() - with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr): - self.assertEqual(snapshot_module.main(arguments), 0) - self.assertEqual(stderr.getvalue(), "") - outputs.append(json.loads(stdout.getvalue())) - self.assertEqual(outputs[0]["name"], "cli") - self.assertEqual(outputs[1][0]["name"], "cli") - self.assertEqual(outputs[2]["metadata"]["session_name"], "cli") - self.assertEqual(outputs[3], {"deleted": "cli"}) - self.assertFalse((self.sessions / "cli.json").exists()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_session_tree_restore.py b/tests/test_session_tree_restore.py deleted file mode 100644 index 42edeff8e..000000000 --- a/tests/test_session_tree_restore.py +++ /dev/null @@ -1,252 +0,0 @@ -"""STEP19C : compilation pure du sous-ensemble d'arbre Sway prouvé.""" - -from __future__ import annotations - -import copy -import importlib.util -from pathlib import Path -from types import SimpleNamespace -import unittest - - -ROOT = Path(__file__).resolve().parents[1] -MODULE = ROOT / "quickshell/.config/quickshell/labfy-sway/session/session_tree_restore.py" - - -def load_module(): - spec = importlib.util.spec_from_file_location("session_tree_restore_test", MODULE) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -tree = load_module() - - -def load_executor(): - path = ROOT / "quickshell/.config/quickshell/labfy-sway/session/session_executor.py" - spec = importlib.util.spec_from_file_location("session_tree_executor_test", path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -executor = load_executor() - - -def make_snapshot( - *, - live=False, - layout="splith", - order=(1, 2, 3), - nested=False, - workspace_layout="splith", - floating_four=False, -): - prefix = "l" if live else "w" - con_base = 100 if live else 900 - windows = [] - for number in range(1, 5 if floating_four else 4): - window_id = f"{prefix}{number}" - parent = f"{prefix}root" - branch = "tiling" - index = order.index(number) if number in order else 0 - floating = False - if number == 4: - parent = None - branch = "floating" - index = 0 - floating = True - elif nested and number in {2, 3}: - parent = f"{prefix}nested" - index = number - 2 - windows.append({ - "window_id": window_id, - "runtime": {"con_id": con_base + number, "pid": 200 + number}, - "workspace": "8", - "floating": floating, - "percent": 0.5 if nested and number in {2, 3} else 1 / 3, - "tree_position": {"parent_container_id": parent, "branch": branch, "index": index}, - }) - children = [] - for number in order: - if nested and number == 2: - children.append({"kind": "container", "id": f"{prefix}nested"}) - continue - if nested and number == 3: - continue - children.append({"kind": "window", "id": f"{prefix}{number}"}) - containers = [{ - "container_id": f"{prefix}root", - "layout": layout, - "percent": 1.0, - "children": children, - "floating_children": [], - }] - if nested: - containers.append({ - "container_id": f"{prefix}nested", - "layout": "splitv" if layout == "splith" else "splith", - "percent": 2 / 3, - "children": [ - {"kind": "window", "id": f"{prefix}2"}, - {"kind": "window", "id": f"{prefix}3"}, - ], - "floating_children": [], - }) - roots = { - "tiling": [{"kind": "container", "id": f"{prefix}root"}], - "floating": ([{"kind": "window", "id": f"{prefix}4"}] if floating_four else []), - } - return { - "workspaces": [{"name": "8", "layout": workspace_layout, "roots": roots}], - "containers": containers, - "windows": windows, - } - - -def make_plan(source, live, *, omit=()): - actions = [] - for number in range(1, len(source["windows"]) + 1): - if number in omit: - continue - live_window = live["windows"][number - 1] - actions.append({ - "action": "reuse-window", - "source_window": f"w{number}", - "live_window": copy.deepcopy(live_window), - }) - actions.append({"action": "restore-tree-position", "source_window": "w1"}) - return {"actions": actions} - - -class TreeCompilerTest(unittest.TestCase): - def compile(self, source, live, **kwargs): - return tree.compile_tree_restore(source, live, make_plan(source, live, **kwargs)) - - def test_identical_tree_has_no_operations(self): - result = self.compile(make_snapshot(), make_snapshot(live=True)) - self.assertEqual(result["operations"], []) - - def test_root_splith(self): - result = self.compile(make_snapshot(layout="splith"), make_snapshot(live=True, layout="tabbed")) - self.assertEqual(result["operations"][-1]["layout"], "splith") - - def test_root_splitv(self): - result = self.compile(make_snapshot(layout="splitv"), make_snapshot(live=True, layout="splith")) - self.assertEqual(result["operations"][-1]["layout"], "splitv") - - def test_root_tabbed(self): - result = self.compile(make_snapshot(layout="tabbed"), make_snapshot(live=True, layout="splith")) - self.assertEqual(result["operations"][-1]["layout"], "tabbed") - - def test_root_stacked(self): - result = self.compile(make_snapshot(layout="stacked"), make_snapshot(live=True, layout="splith")) - self.assertEqual(result["operations"][-1]["layout"], "stacked") - - def test_child_order_diff_uses_typed_relative_moves(self): - result = self.compile(make_snapshot(order=(1, 2, 3)), make_snapshot(live=True, order=(3, 1, 2))) - self.assertEqual([item["operation"] for item in result["operations"]], ["move-relative"]) - self.assertEqual(result["operations"][0]["relation"], "after") - - def test_nested_supported_tree(self): - result = self.compile(make_snapshot(nested=True), make_snapshot(live=True)) - self.assertEqual( - [item["operation"] for item in result["operations"]], - ["set-split-orientation", "move-relative"], - ) - - def test_nested_unsupported_tree(self): - source = make_snapshot(nested=True) - nested = source["containers"][1] - nested["children"] = [{"kind": "container", "id": "wdeep"}] - source["containers"].append({ - "container_id": "wdeep", "layout": "splith", "percent": 1.0, - "children": [{"kind": "window", "id": "w2"}, {"kind": "window", "id": "w3"}], - "floating_children": [], - }) - with self.assertRaisesRegex(tree.TreeRestoreError, "unsupported-nested-tree"): - self.compile(source, make_snapshot(live=True)) - - def test_missing_target_window(self): - with self.assertRaisesRegex(tree.TreeRestoreError, "missing-target-window"): - self.compile(make_snapshot(), make_snapshot(live=True), omit=(3,)) - - def test_unexpected_live_window(self): - source = make_snapshot(floating_four=True) - live = make_snapshot(live=True, floating_four=True) - live["workspaces"][0]["roots"]["floating"] = [] - live["workspaces"][0]["roots"]["tiling"].append({"kind": "window", "id": "l4"}) - live["windows"][3]["floating"] = False - with self.assertRaisesRegex(tree.TreeRestoreError, "unexpected-live-window"): - self.compile(source, live) - - def test_floating_branch_is_preserved_and_excluded(self): - result = self.compile(make_snapshot(floating_four=True), make_snapshot(live=True, floating_four=True)) - self.assertEqual(result["operations"], []) - self.assertNotIn("w4", repr(result["workspaces"][0]["target_tree"])) - - def test_percent_is_explicitly_unsupported(self): - source = make_snapshot() - live = make_snapshot(live=True) - live["windows"][0]["percent"] = 0.9 - result = self.compile(source, live) - self.assertEqual(result["percent_capability"], "unsupported") - self.assertEqual(result["operations"], []) - - def test_determinism(self): - source = make_snapshot(layout="stacked", order=(1, 2, 3)) - live = make_snapshot(live=True, layout="splitv", order=(3, 2, 1)) - self.assertEqual(self.compile(source, live), self.compile(copy.deepcopy(source), copy.deepcopy(live))) - - def test_hostile_values_are_rejected(self): - with self.assertRaisesRegex(tree.TreeRestoreError, "invalid-operation-layout"): - tree.validate_operation({ - "operation": "set-container-layout", "workspace": "8", "source_window": "w1", - "layout": "tabbed; exec evil", - }) - with self.assertRaisesRegex(tree.TreeRestoreError, "invalid-operation-workspace"): - tree.validate_operation({ - "operation": "move-relative", "workspace": "8; exec evil", "source_window": "w1", - "relation": "after", "reference_window": "w2", - }) - - def test_runtime_tree_drift_stops_before_second_command(self): - source = make_snapshot(layout="tabbed") - live_before = make_snapshot(live=True, layout="splith") - live_after_first = make_snapshot(live=True, layout="tabbed") - live_drifted = make_snapshot(live=True, layout="tabbed", order=(2, 1, 3)) - states = iter((live_before, live_after_first, live_drifted)) - commands = [] - - class Planner: - @staticmethod - def match_windows(saved, candidates): - return {str(saved[0]["window_id"]): {"confidence": "exact", "live_index": 0}}, [] - - compilation = { - "operations": [ - {"operation": "set-container-layout", "workspace": "8", "source_window": "w1", "layout": "tabbed"}, - {"operation": "set-container-layout", "workspace": "8", "source_window": "w1", "layout": "stacked"}, - ] - } - with self.assertRaisesRegex(executor.RestoreExecutionError, "RESTORE_TREE_DRIFTED"): - executor._execute_tree_compilation( - source, - make_plan(source, live_before), - compilation, - {"w1": 101, "w2": 102, "w3": 103}, - collector=lambda: copy.deepcopy(next(states)), - planner_module=Planner, - tree_module=tree, - runner=lambda argv, _timeout: commands.append(list(argv)) or SimpleNamespace( - returncode=0, stdout='[{"success":true}]', stderr="" - ), - monotonic=lambda: 0.0, - sleeper=lambda _delay: None, - ) - self.assertEqual(len(commands), 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/build-session-v2.py b/tools/build-session-v2.py new file mode 100755 index 000000000..7b7730474 --- /dev/null +++ b/tools/build-session-v2.py @@ -0,0 +1,42 @@ +#!/usr/bin/python3 +"""Construire les candidats locaux, sans installer ni signer ni accéder au réseau.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "quickshell/.config/quickshell/labfy-sway" + + +def build(output): + output = Path(output).resolve() + if not output.is_relative_to(ROOT): raise ValueError("OUTPUT_MUST_BE_IN_ISOLATED_WORKTREE") + output.mkdir(parents=True, exist_ok=True) + binary = output / "labfy-v2-anchor" + flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "gtk+-3.0", "gdk-wayland-3.0"], text=True).split() + subprocess.run(["cc", "-std=c17", "-O2", "-Wall", "-Wextra", "-Werror", str(SOURCE / "session_v2/anchor_helper.c"), "-o", str(binary), *flags], check=True) + xpi = output / "labfy-session-v2-unsigned.xpi" + with zipfile.ZipFile(xpi, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name in ("manifest.json", "background.js"): + info = zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)); info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, (ROOT / "firefox-extension/session-v2" / name).read_bytes()) + bundle = output / "labfy-session-v2-backend.tar.gz" + with tarfile.open(bundle, "w:gz") as archive: + 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") + 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)}} + (output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + print(json.dumps(manifest)) + + +if __name__ == "__main__": build(sys.argv[1] if len(sys.argv) > 1 else ROOT / "build/session-v2")