Compare commits
No commits in common. "2939012b63991a79024d59e8d95e1d51f1680449" and "71a8ee189f791bbd6d2b4425b674d08916c4d491" have entirely different histories.
2939012b63
...
71a8ee189f
5 changed files with 4 additions and 2119 deletions
|
|
@ -17,7 +17,6 @@ limites détaillées.
|
|||
| Document | Responsabilité |
|
||||
| --- | --- |
|
||||
| [Desktop](desktop.md) | Session greetd/UWSM/Sway et composants Wayland canoniques |
|
||||
| [Session Snapshot V1](session-snapshot-v1.md) | Capture Sway STEP18A et sauvegardes persistantes STEP18B, sans restauration |
|
||||
| [Shell](shell.md) | Zsh, outils interactifs, plugins et scripts utilisateur |
|
||||
| [systemd](systemd.md) | Limite système/user, unités et activations utilisateur |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,395 +0,0 @@
|
|||
# Session Snapshot V1 — STEP18A / STEP18B
|
||||
|
||||
## Objet et périmètre
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
`--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é. Ces commandes ne contiennent et n'appellent aucune
|
||||
opération `restore`, `launch` ou `apply`.
|
||||
|
||||
### 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.
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.I3
|
||||
import Quickshell.Networking
|
||||
import Quickshell.Bluetooth
|
||||
import "../components"
|
||||
|
|
@ -27,8 +28,7 @@ PopupWindow {
|
|||
command: ["systemctl", "suspend"] },
|
||||
{ id: "logout", label: "Déconnexion", icon: "", accent: Theme.urgent,
|
||||
confirmTitle: "Se déconnecter ?",
|
||||
description: "La session SwayFX en cours sera fermée.",
|
||||
command: ["uwsm", "stop"] },
|
||||
description: "La session SwayFX en cours sera fermée.", command: null },
|
||||
{ id: "reboot", label: "Redémarrer", icon: "", accent: Theme.warning,
|
||||
confirmTitle: "Redémarrer l'ordinateur ?",
|
||||
description: "La session en cours sera fermée.",
|
||||
|
|
@ -63,9 +63,8 @@ PopupWindow {
|
|||
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);
|
||||
if (id === "logout") I3.dispatch("exit");
|
||||
else Quickshell.execDetached(selected.command);
|
||||
}
|
||||
|
||||
anchor.window: barWindow
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
Loading…
Reference in a new issue