feat(session): deploy validated one-click restore v2 and retire v1
This commit is contained in:
parent
d41131037f
commit
fbb5740f9e
69 changed files with 3916 additions and 6841 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -116,6 +116,18 @@ inventaires et validations sont décrits dans le
|
|||
| [Recovery](docs/recovery.md) | Reconstruction complète après perte du disque |
|
||||
| [Security](docs/security.md) | Secrets exclus et règles de publication |
|
||||
|
||||
## Session Restore V2
|
||||
|
||||
QuickShell crée un checkpoint avant logout, reboot ou poweroff. Au login suivant,
|
||||
un chooser unique restaure la session en un clic, sans autre interaction.
|
||||
Firefox s’appuie sur son companion signé et un native host de corrélation ;
|
||||
Limusic reste géré par l’autostart Sway. La fenêtre terminal est restaurée,
|
||||
mais pas son contenu ni ses processus. Ratios exacts, fullscreen et scratchpad
|
||||
ne sont pas pris en charge ; le multi-output physique n’est pas validé et le
|
||||
provider Firefox actuel reste limité au profil supporté.
|
||||
|
||||
Voir [installation, fonctionnement et tests V2](docs/session-restore-v2.md).
|
||||
|
||||
## ✦ Mirrors
|
||||
|
||||
| Rôle | Dépôt |
|
||||
|
|
|
|||
|
|
@ -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
115
docs/session-restore-v2.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
57
firefox-extension/session-v2/README.md
Normal file
57
firefox-extension/session-v2/README.md
Normal 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.
|
||||
92
firefox-extension/session-v2/background.js
Normal file
92
firefox-extension/session-v2/background.js
Normal 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(() => {});
|
||||
11
firefox-extension/session-v2/manifest.json
Normal file
11
firefox-extension/session-v2/manifest.json
Normal 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}
|
||||
}
|
||||
7
firefox-extension/session-v2/native-host.json.in
Normal file
7
firefox-extension/session-v2/native-host.json.in
Normal 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"]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
quickshell/.config/quickshell/labfy-sway/session-v2-native-host.py
Executable file
8
quickshell/.config/quickshell/labfy-sway/session-v2-native-host.py
Executable 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)
|
||||
6
quickshell/.config/quickshell/labfy-sway/session-v2.py
Executable file
6
quickshell/.config/quickshell/labfy-sway/session-v2.py
Executable 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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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()]
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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))
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Session V2 : backend indépendant, jamais importé par la V1."""
|
||||
|
|
@ -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;
|
||||
}
|
||||
109
quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py
Normal file
109
quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
103
quickshell/.config/quickshell/labfy-sway/session_v2/cli.py
Normal file
103
quickshell/.config/quickshell/labfy-sway/session_v2/cli.py
Normal 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())
|
||||
|
|
@ -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)
|
||||
197
quickshell/.config/quickshell/labfy-sway/session_v2/executor.py
Normal file
197
quickshell/.config/quickshell/labfy-sway/session_v2/executor.py
Normal 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")
|
||||
|
|
@ -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
|
||||
212
quickshell/.config/quickshell/labfy-sway/session_v2/guard.py
Normal file
212
quickshell/.config/quickshell/labfy-sway/session_v2/guard.py
Normal 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)
|
||||
91
quickshell/.config/quickshell/labfy-sway/session_v2/ipc.py
Normal file
91
quickshell/.config/quickshell/labfy-sway/session_v2/ipc.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
59
quickshell/.config/quickshell/labfy-sway/session_v2/model.py
Normal file
59
quickshell/.config/quickshell/labfy-sway/session_v2/model.py
Normal 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]}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"}}}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
125
quickshell/.config/quickshell/labfy-sway/session_v2/schema.py
Normal file
125
quickshell/.config/quickshell/labfy-sway/session_v2/schema.py
Normal 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
|
||||
27
quickshell/.config/quickshell/labfy-sway/session_v2/slots.py
Normal file
27
quickshell/.config/quickshell/labfy-sway/session_v2/slots.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}
|
||||
103
quickshell/.config/quickshell/labfy-sway/session_v2/storage.py
Normal file
103
quickshell/.config/quickshell/labfy-sway/session_v2/storage.py
Normal 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")
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
singleton SessionV2Service 1.0 SessionV2Service.qml
|
||||
|
|
@ -32,6 +32,11 @@ ShellRoot {
|
|||
Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
Bar { notificationService: rootNotificationService }
|
||||
Bar {
|
||||
notificationService: rootNotificationService
|
||||
// Une seule barre possède le chooser, même en configuration multi-écran.
|
||||
startupHost: Quickshell.screens.length > 0
|
||||
&& modelData === Quickshell.screens[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
tests/qml/tst_session_exit_gate.qml
Normal file
140
tests/qml/tst_session_exit_gate.qml
Normal 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);
|
||||
}
|
||||
}
|
||||
51
tests/session_v2/common.py
Normal file
51
tests/session_v2/common.py
Normal 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")))
|
||||
79
tests/session_v2/crashes.py
Normal file
79
tests/session_v2/crashes.py
Normal 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()
|
||||
43
tests/session_v2/failures.py
Normal file
43
tests/session_v2/failures.py
Normal 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()
|
||||
6
tests/session_v2/fixtures/headless.conf
Normal file
6
tests/session_v2/fixtures/headless.conf
Normal 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
|
||||
143
tests/session_v2/fixtures/headless.py
Normal file
143
tests/session_v2/fixtures/headless.py
Normal 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()
|
||||
147
tests/session_v2/fixtures/lab.py
Normal file
147
tests/session_v2/fixtures/lab.py
Normal 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)
|
||||
45
tests/session_v2/fixtures/surfaces.c
Normal file
45
tests/session_v2/fixtures/surfaces.c
Normal 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;
|
||||
}
|
||||
33
tests/session_v2/phase3b_runtime.py
Normal file
33
tests/session_v2/phase3b_runtime.py
Normal 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()
|
||||
109
tests/session_v2/phase3b_ui.py
Normal file
109
tests/session_v2/phase3b_ui.py
Normal 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()
|
||||
53
tests/session_v2/qml/tst_exit_v2.qml
Normal file
53
tests/session_v2/qml/tst_exit_v2.qml
Normal 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);
|
||||
}
|
||||
}
|
||||
96
tests/session_v2/runtime.py
Normal file
96
tests/session_v2/runtime.py
Normal 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)
|
||||
60
tests/session_v2/security.py
Normal file
60
tests/session_v2/security.py
Normal 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()
|
||||
45
tests/session_v2/test_phase3b.py
Normal file
45
tests/session_v2/test_phase3b.py
Normal 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()
|
||||
43
tests/session_v2/test_provider.py
Normal file
43
tests/session_v2/test_provider.py
Normal 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()
|
||||
234
tests/session_v2/test_unit.py
Normal file
234
tests/session_v2/test_unit.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
34
tests/test_session_exit_gate_qml.py
Normal file
34
tests/test_session_exit_gate_qml.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
42
tools/build-session-v2.py
Executable 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")
|
||||
Loading…
Reference in a new issue