feat(session): deploy validated one-click restore v2 and retire v1

This commit is contained in:
fy59 2026-10-07 05:54:56 +02:00
parent d41131037f
commit fbb5740f9e
69 changed files with 3916 additions and 6841 deletions

5
.gitignore vendored
View file

@ -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/

View file

@ -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 |

View file

@ -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 <name>
```
`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 <name> --execute
```
`apply <name>` 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=<entier validé>] move container to workspace number <entier validé>
```
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=<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>
mark --add|--replace [--toggle] <identifier>
unmark [<identifier>]
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=<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_<token_hex_aléatoire>
[source] move container to mark __labfy_restore_<token_hex_aléatoire>
[reference] unmark __labfy_restore_<token_hex_aléatoire>
```
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=<numérique>, 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=<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 <name> --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.

115
docs/session-restore-v2.md Normal file
View file

@ -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.

View file

@ -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/<pid>/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 `<name>.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 <name>
session_snapshot.py list
session_snapshot.py show <name>
session_snapshot.py delete <name>
session_snapshot.py plan <name>
```
`--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.

View file

@ -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.

View file

@ -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(() => {});

View file

@ -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}
}

View file

@ -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"]
}

View file

@ -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
}
}

View file

@ -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()
}
}
}
}
}
}

View file

@ -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()
}
}
}
}
}
}

View file

@ -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);
}
}

View file

@ -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(); } }
}
}
}
}

View file

@ -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(); }
}
}
}
}

View file

@ -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)

View file

@ -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())

View file

@ -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

View file

@ -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()]

View file

@ -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))

View file

@ -0,0 +1 @@
"""Session V2 : backend indépendant, jamais importé par la V1."""

View file

@ -0,0 +1,44 @@
/* WHY : une seule application possédée fournit les feuilles temporaires.
* CONTRACT : stdin privé, create <identité> / quit, 256 fenêtres maximum.
* INVARIANT : aucun lancement applicatif ni accès aux fenêtres d'autrui.
*/
#include <gtk/gtk.h>
#include <gdk/gdkwayland.h>
#include <stdio.h>
#include <string.h>
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;
}

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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())

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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]}

View file

@ -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)

View file

@ -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()

View file

@ -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"}}}}
]
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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}

View file

@ -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")

View file

@ -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);
}

View file

@ -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);
}
}
}

View file

@ -0,0 +1 @@
singleton SessionV2Service 1.0 SessionV2Service.qml

View file

@ -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]
}
}
}

View file

@ -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);
}
}

View file

@ -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")))

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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 <gtk/gtk.h>
#include <gdk/gdkwayland.h>
#include <stdio.h>
#include <string.h>
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;
}

View file

@ -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()

View file

@ -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()

View file

@ -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);
}
}

View file

@ -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)

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

42
tools/build-session-v2.py Executable file
View file

@ -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")