Compare commits

..

No commits in common. "2939012b63991a79024d59e8d95e1d51f1680449" and "71a8ee189f791bbd6d2b4425b674d08916c4d491" have entirely different histories.

5 changed files with 4 additions and 2119 deletions

View file

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

View file

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

View file

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

View file

@ -1,543 +0,0 @@
"""Contrat Session Snapshot V1 sur arbres Sway synthétiques."""
import copy
import concurrent.futures
import importlib.util
import io
import json
import os
from pathlib import Path
import stat
import tempfile
import unittest
from unittest import mock
SOURCE = (
Path(__file__).resolve().parents[1]
/ "quickshell/.config/quickshell/labfy-sway/session/session_snapshot.py"
)
SPEC = importlib.util.spec_from_file_location("session_snapshot", SOURCE)
snapshot_module = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(snapshot_module)
def leaf(
con_id,
app_id,
*,
focused=False,
floating="auto_off",
fullscreen=0,
properties=None,
scratchpad_state="none",
):
return {
"id": con_id,
"type": "con",
"name": f"titre {con_id}",
"app_id": app_id,
"window": con_id if properties else None,
"window_properties": properties,
"pid": 90000 + con_id,
"nodes": [],
"floating_nodes": [],
"layout": "none",
"orientation": "none",
"percent": 0.5,
"rect": {"x": con_id, "y": 2, "width": 300, "height": 200},
"focused": focused,
"urgent": False,
"marks": [f"mark-{con_id}"],
"floating": floating,
"fullscreen_mode": fullscreen,
"scratchpad_state": scratchpad_state,
}
def container(con_id, layout, children, floating_nodes=None):
return {
"id": con_id,
"type": "con",
"name": None,
"nodes": children,
"floating_nodes": floating_nodes or [],
"layout": layout,
"orientation": "horizontal" if layout in {"splith", "tabbed"} else "vertical",
"percent": 1.0,
"rect": {"x": 0, "y": 0, "width": 1200, "height": 800},
"focused": False,
"urgent": False,
"marks": [],
}
def fixture():
firefox_one = leaf(11, "firefox")
firefox_two = leaf(12, "firefox", fullscreen=1)
kitty = leaf(13, "kitty", focused=True)
xwayland = leaf(14, None, properties={"class": "LegacyApp", "instance": "legacy"})
unknown = leaf(15, "not-catalogued")
floating = leaf(16, "float-app", floating="user_on")
tabbed = container(21, "tabbed", [firefox_one, firefox_two])
stacked = container(22, "stacked", [kitty, xwayland])
splitv = container(23, "splitv", [tabbed, stacked])
splith = container(24, "splith", [splitv, unknown], [floating])
workspace = {
"id": 4,
"type": "workspace",
"name": "dev",
"nodes": [splith],
"floating_nodes": [],
"layout": "splith",
"orientation": "horizontal",
}
scratch = leaf(30, "scratch-app", floating="user_on", scratchpad_state="changed")
tree = {
"id": 1,
"type": "root",
"nodes": [
{
"id": 2,
"type": "output",
"name": "__i3",
"nodes": [{"id": 3, "type": "workspace", "name": "__i3_scratch", "nodes": [], "floating_nodes": [scratch]}],
"floating_nodes": [],
},
{"id": 5, "type": "output", "name": "DP-1", "nodes": [workspace], "floating_nodes": []},
],
}
outputs = [{
"name": "DP-1",
"active": True,
"focused": True,
"rect": {"x": 0, "y": 0, "width": 1200, "height": 800},
"scale": 1.25,
"transform": "normal",
"current_mode": {"width": 1920, "height": 1080, "refresh": 60000},
}]
workspaces = [{
"name": "dev",
"num": -1,
"output": "DP-1",
"focused": True,
"visible": True,
"urgent": False,
"rect": {"x": 0, "y": 0, "width": 1200, "height": 800},
"layout": "splith",
"orientation": "horizontal",
}]
version = {"variant": "sway", "human_readable": "0.6", "sway_original_version": "1.12.0"}
entries = [
{"desktop_entry": "firefox.desktop", "stem": "firefox", "startup_wm_class": "firefox", "name": "Firefox"},
{"desktop_entry": "kitty.desktop", "stem": "kitty", "startup_wm_class": "kitty", "name": "Kitty"},
{"desktop_entry": "legacy.desktop", "stem": "legacy-desktop", "startup_wm_class": "LegacyApp", "name": "Legacy"},
]
return version, outputs, workspaces, tree, entries
class SessionSnapshotTest(unittest.TestCase):
def build(self):
version, outputs, workspaces, tree, entries = fixture()
with mock.patch.object(snapshot_module, "_basename_from_pid", return_value=None):
return snapshot_module.build_snapshot(
version,
outputs,
workspaces,
tree,
captured_at="2026-01-01T00:00:00.000Z",
environment={"XDG_SESSION_TYPE": "wayland", "XDG_CURRENT_DESKTOP": "sway"},
desktop_entries=entries,
managed_autostart={"float-app"},
)
def test_layout_tree_order_and_simple_workspace(self):
snapshot = self.build()
self.assertEqual(snapshot["workspaces"][0]["name"], "dev")
self.assertEqual(snapshot["workspaces"][0]["roots"]["tiling"], [{"kind": "container", "id": "c1"}])
layouts = {item["layout"] for item in snapshot["containers"]}
self.assertTrue({"splith", "splitv", "tabbed", "stacked"}.issubset(layouts))
top = snapshot["containers"][0]
self.assertEqual([ref["kind"] for ref in top["children"]], ["container", "window"])
def test_simple_direct_window_and_empty_workspace(self):
version, outputs, workspaces, tree, entries = fixture()
direct = leaf(40, "firefox")
real_output = tree["nodes"][1]
real_output["nodes"][0]["nodes"] = [direct]
empty = {
"id": 41, "type": "workspace", "name": "2", "nodes": [],
"floating_nodes": [], "layout": "splith", "orientation": "horizontal",
}
real_output["nodes"].append(empty)
workspaces.append({
"name": "2", "num": 2, "output": "DP-1", "focused": False,
"visible": False, "urgent": False,
"rect": {"x": 0, "y": 0, "width": 1200, "height": 800},
"layout": "splith", "orientation": "horizontal",
})
with mock.patch.object(snapshot_module, "_basename_from_pid", return_value=None):
snapshot = snapshot_module.build_snapshot(version, outputs, workspaces, tree, desktop_entries=entries)
self.assertEqual(snapshot["workspaces"][0]["roots"]["tiling"][0]["kind"], "window")
self.assertEqual(snapshot["workspaces"][1]["roots"], {"tiling": [], "floating": []})
def test_floating_fullscreen_focus_and_marks(self):
snapshot = self.build()
floating = next(window for window in snapshot["windows"] if window["app_id"] == "float-app")
fullscreen = next(window for window in snapshot["windows"] if window["runtime"]["con_id"] == 12)
kitty = next(window for window in snapshot["windows"] if window["app_id"] == "kitty")
self.assertTrue(floating["floating"])
self.assertEqual(floating["classification"]["managed_by"], "sway-autostart")
self.assertEqual(fullscreen["fullscreen_mode"], 1)
self.assertEqual(snapshot["focus"]["window_id"], kitty["window_id"])
self.assertEqual(kitty["marks"], ["mark-13"])
def test_duplicate_app_ids_keep_distinct_snapshot_ordinals(self):
windows = [window for window in self.build()["windows"] if window["app_id"] == "firefox"]
self.assertEqual([window["snapshot_identity"] for window in windows], ["firefox#1", "firefox#2"])
self.assertNotEqual(windows[0]["window_id"], windows[1]["window_id"])
def test_desktop_identity_exact_xwayland_and_unresolved(self):
snapshot = self.build()
firefox = next(window for window in snapshot["windows"] if window["app_id"] == "firefox")
legacy = next(window for window in snapshot["windows"] if window["xwayland"]["class"] == "LegacyApp")
unknown = next(window for window in snapshot["windows"] if window["app_id"] == "not-catalogued")
self.assertEqual(firefox["restore_identity"]["desktop_entry"], "firefox.desktop")
self.assertEqual(legacy["restore_identity"]["matched_by"], "StartupWMClass")
self.assertEqual(legacy["restore_identity"]["confidence"], "exact")
self.assertEqual(unknown["restore_identity"]["confidence"], "unresolved")
def test_hidden_scratchpad_is_not_a_user_workspace(self):
snapshot = self.build()
scratch = next(window for window in snapshot["windows"] if window["app_id"] == "scratch-app")
self.assertIsNone(scratch["workspace"])
self.assertTrue(scratch["scratchpad"]["member"])
self.assertEqual(scratch["scratchpad"]["visibility"], "hidden")
self.assertEqual(snapshot["restore_hints"]["scratchpad_roots"], [{"kind": "window", "id": scratch["window_id"]}])
def test_visible_scratchpad_member_stays_on_real_workspace(self):
version, outputs, workspaces, tree, entries = fixture()
visible = leaf(42, "scratch-visible", scratchpad_state="fresh")
tree["nodes"][1]["nodes"][0]["floating_nodes"] = [visible]
with mock.patch.object(snapshot_module, "_basename_from_pid", return_value=None):
snapshot = snapshot_module.build_snapshot(version, outputs, workspaces, tree, desktop_entries=entries)
window = next(item for item in snapshot["windows"] if item["app_id"] == "scratch-visible")
self.assertEqual(window["workspace"], "dev")
self.assertEqual(window["scratchpad"]["visibility"], "visible")
def test_unique_executable_basename_is_only_heuristic(self):
entries = [{"desktop_entry": "odd-name.desktop", "stem": "tool", "startup_wm_class": None, "name": "Tool"}]
identity = snapshot_module.resolve_desktop_entry("unrelated", {}, "tool", entries)
self.assertEqual(identity, {
"desktop_entry": "odd-name.desktop",
"confidence": "heuristic",
"matched_by": "executable-basename",
})
def test_title_and_timestamp_do_not_affect_structural_projection(self):
first = self.build()
second = copy.deepcopy(first)
second["metadata"]["captured_at"] = "2027-02-02T00:00:00Z"
second["windows"][0]["title_hint"] = "titre dynamique différent"
self.assertEqual(
snapshot_module.structural_projection(first),
snapshot_module.structural_projection(second),
)
def test_validator_rejects_broken_tree_relation(self):
snapshot = self.build()
snapshot["workspaces"][0]["roots"]["tiling"][0]["id"] = "absent"
with self.assertRaises(snapshot_module.SnapshotError):
snapshot_module.validate_snapshot(snapshot)
def test_validator_rejects_duplicate_reference(self):
snapshot = self.build()
root = snapshot["workspaces"][0]["roots"]["tiling"][0]
snapshot["workspaces"][0]["roots"]["tiling"].append(copy.deepcopy(root))
with self.assertRaises(snapshot_module.SnapshotError):
snapshot_module.validate_snapshot(snapshot)
def test_autostart_parser_excludes_shell_wrappers_and_keeps_direct_apps(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "autostart"
path.write_text(
"exec limusic-app\n"
"exec python3 ~/.config/sway/scripts/inactive-windows-transparency.py\n"
"exec sh -c 'private command'\n",
encoding="utf-8",
)
self.assertEqual(
snapshot_module.parse_sway_autostart(path),
{"limusic-app", "inactive-windows-transparency.py"},
)
def test_no_command_line_or_environment_field_exists(self):
serialized_keys = repr(self.build()).casefold()
self.assertNotIn("cmdline", serialized_keys)
self.assertNotIn("command_line", serialized_keys)
self.assertNotIn("/proc/", serialized_keys)
self.assertNotIn("environment", serialized_keys)
class PersistentSessionTest(unittest.TestCase):
"""Contrats STEP18B dans un XDG state isolé du compte utilisateur."""
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.state = self.root / "state"
self.environment = {"XDG_STATE_HOME": str(self.state)}
version, outputs, workspaces, tree, entries = fixture()
with mock.patch.object(snapshot_module, "_basename_from_pid", return_value=None):
self.snapshot = snapshot_module.build_snapshot(
version,
outputs,
workspaces,
tree,
captured_at="2026-01-01T00:00:00.000Z",
environment={"XDG_SESSION_TYPE": "wayland", "XDG_CURRENT_DESKTOP": "sway"},
desktop_entries=entries,
managed_autostart={"float-app"},
)
def collector(self, _autostart):
return copy.deepcopy(self.snapshot)
def save(self, name="dev", **kwargs):
return snapshot_module.save_session(
name,
environment=self.environment,
collector=self.collector,
retry_delay=0,
**kwargs,
)
@property
def sessions(self):
return self.state / "labfy-sway/sessions"
def write_raw(self, name, value=None, payload=None):
snapshot_module.sessions_directory(self.environment)
path = self.sessions / f"{name}.json"
if payload is None:
payload = json.dumps(value).encode("utf-8")
path.write_bytes(payload)
path.chmod(0o600)
return path
def test_xdg_path_fallback_and_private_directory_permissions(self):
path = snapshot_module.sessions_directory(self.environment)
self.assertEqual(path, self.sessions)
self.assertEqual(stat.S_IMODE((self.state / "labfy-sway").stat().st_mode), 0o700)
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o700)
fallback = snapshot_module.sessions_directory({}, self.root / "home")
self.assertEqual(fallback, self.root / "home/.local/state/labfy-sway/sessions")
with self.assertRaises(snapshot_module.SnapshotError):
snapshot_module.sessions_directory({"XDG_STATE_HOME": "relative"}, self.root)
def test_safe_and_invalid_names_prevent_path_traversal(self):
for name in ("default", "Dev_01", "osint-prod", "A" * 64):
self.assertEqual(snapshot_module.normalize_session_name(name), name)
for name in ("", "..", ".", "a/b", "../dev", "a b", "é", "A" * 65, "nul\0name"):
with self.subTest(name=repr(name)):
with self.assertRaises(snapshot_module.SnapshotError):
snapshot_module.normalize_session_name(name)
def test_atomic_first_save_permissions_and_persistent_metadata(self):
previous_umask = os.umask(0)
try:
saved = self.save()
finally:
os.umask(previous_umask)
path = self.sessions / "dev.json"
self.assertTrue(path.is_file())
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
self.assertEqual(stat.S_IMODE(self.sessions.stat().st_mode), 0o700)
self.assertEqual(saved["schema"], snapshot_module.SCHEMA)
self.assertEqual(saved["version"], 1)
self.assertEqual(saved["metadata"]["session_name"], "dev")
self.assertEqual(saved["metadata"]["created_at"], saved["metadata"]["updated_at"])
self.assertFalse(list(self.sessions.glob("*.tmp")))
def test_atomic_update_preserves_created_at_and_advances_updated_at(self):
first = self.save()
first_bytes = (self.sessions / "dev.json").read_bytes()
self.snapshot["metadata"]["captured_at"] = "2026-01-01T00:00:01.000Z"
second = self.save()
self.assertEqual(second["metadata"]["created_at"], first["metadata"]["created_at"])
self.assertGreater(second["metadata"]["updated_at"], first["metadata"]["updated_at"])
self.assertNotEqual(second["metadata"]["captured_at"], first["metadata"]["captured_at"])
self.assertNotEqual((self.sessions / "dev.json").read_bytes(), first_bytes)
self.assertEqual([path.name for path in self.sessions.glob("*.json")], ["dev.json"])
def test_list_show_delete_round_trip(self):
saved = self.save("trainlog")
listed, warnings = snapshot_module.list_sessions(environment=self.environment)
self.assertEqual(warnings, [])
self.assertEqual(listed, [{
"name": "trainlog",
"updated_at": saved["metadata"]["updated_at"],
"windows": 7,
"workspaces": 1,
}])
shown = snapshot_module.show_session("trainlog", environment=self.environment)
self.assertEqual(shown, saved)
snapshot_module.delete_session("trainlog", environment=self.environment)
self.assertFalse((self.sessions / "trainlog.json").exists())
self.assertTrue(self.sessions.is_dir())
with self.assertRaises(snapshot_module.SnapshotError):
snapshot_module.show_session("trainlog", environment=self.environment)
def test_corrupt_invalid_relation_and_unknown_files_are_ignored_by_list(self):
self.save("valid")
self.write_raw("truncated", payload=b'{"schema":')
invalid_relation = copy.deepcopy(snapshot_module.show_session("valid", environment=self.environment))
invalid_relation["workspaces"][0]["roots"]["tiling"][0]["id"] = "absent"
self.write_raw("relation", invalid_relation)
unknown_schema = copy.deepcopy(invalid_relation)
unknown_schema["schema"] = "unknown"
self.write_raw("schema", unknown_schema)
listed, warnings = snapshot_module.list_sessions(environment=self.environment)
self.assertEqual([item["name"] for item in listed], ["valid"])
self.assertEqual(len(warnings), 3)
for name in ("truncated", "relation", "schema"):
with self.assertRaises(snapshot_module.SnapshotError):
snapshot_module.show_session(name, environment=self.environment)
def test_unknown_version_has_explicit_error(self):
snapshot = self.save("future")
snapshot["version"] = 99
self.write_raw("future", snapshot)
with self.assertRaisesRegex(snapshot_module.SnapshotError, "unsupported snapshot version"):
snapshot_module.show_session("future", environment=self.environment)
def test_oversized_snapshot_is_rejected_before_json_loading(self):
self.write_raw("huge", payload=b"{" + b" " * snapshot_module.MAX_SNAPSHOT_BYTES)
with self.assertRaisesRegex(snapshot_module.SnapshotError, "trop volumineux"):
snapshot_module.show_session("huge", environment=self.environment)
def test_explicit_save_repairs_corrupt_regular_target(self):
self.write_raw("repair", payload=b"not-json")
saved = self.save("repair")
self.assertEqual(snapshot_module.show_session("repair", environment=self.environment), saved)
def test_symlink_target_is_refused_for_save_show_and_delete(self):
snapshot_module.sessions_directory(self.environment)
external = self.root / "external"
external.write_text("do not touch", encoding="utf-8")
(self.sessions / "evil.json").symlink_to(external)
with self.assertRaisesRegex(snapshot_module.SnapshotError, "lien symbolique"):
self.save("evil")
with self.assertRaises(snapshot_module.SnapshotError):
snapshot_module.show_session("evil", environment=self.environment)
with self.assertRaisesRegex(snapshot_module.SnapshotError, "lien symbolique"):
snapshot_module.delete_session("evil", environment=self.environment)
self.assertEqual(external.read_text(encoding="utf-8"), "do not touch")
def test_managed_directory_symlink_is_refused(self):
self.state.mkdir()
external = self.root / "outside"
external.mkdir()
(self.state / "labfy-sway").symlink_to(external, target_is_directory=True)
with self.assertRaises(snapshot_module.SnapshotError):
snapshot_module.sessions_directory(self.environment)
def test_interrupted_update_keeps_old_snapshot_and_cleans_temporary(self):
self.save()
path = self.sessions / "dev.json"
before = path.read_bytes()
def interrupt(_temporary):
raise snapshot_module.SnapshotError("interruption simulée avant rename")
with self.assertRaisesRegex(snapshot_module.SnapshotError, "interruption simulée"):
self.save(before_replace=interrupt)
self.assertEqual(path.read_bytes(), before)
self.assertEqual(snapshot_module.show_session("dev", environment=self.environment)["schema"], snapshot_module.SCHEMA)
self.assertFalse(list(self.sessions.glob("*.tmp")))
def test_concurrent_saves_are_serialized_and_valid(self):
def operation(_index):
return self.save()["metadata"]["updated_at"]
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
timestamps = list(executor.map(operation, range(12)))
shown = snapshot_module.show_session("dev", environment=self.environment)
self.assertEqual(shown["metadata"]["created_at"], min(timestamps))
self.assertEqual(shown["metadata"]["updated_at"], max(timestamps))
self.assertFalse(list(self.sessions.glob("*.tmp")))
def test_retry_inconsistent_ipc_then_success(self):
calls = 0
def flaky(_autostart):
nonlocal calls
calls += 1
if calls < 3:
raise snapshot_module.SnapshotError("snapshot V1 invalide: relation IPC")
return self.collector(None)
saved = snapshot_module.save_session(
"retry",
environment=self.environment,
collector=flaky,
retry_delay=0,
)
self.assertEqual(calls, 3)
self.assertEqual(saved["metadata"]["session_name"], "retry")
def test_retry_exhaustion_keeps_previous_snapshot(self):
self.save("stable")
path = self.sessions / "stable.json"
before = path.read_bytes()
calls = 0
def inconsistent(_autostart):
nonlocal calls
calls += 1
raise snapshot_module.SnapshotError("snapshot V1 invalide: relation IPC")
with self.assertRaisesRegex(snapshot_module.SnapshotError, "SAVE_FAILED_INCONSISTENT_STATE"):
snapshot_module.save_session(
"stable",
environment=self.environment,
collector=inconsistent,
retry_delay=0,
)
self.assertEqual(calls, 3)
self.assertEqual(path.read_bytes(), before)
def test_structural_equivalence_excludes_persistence_timestamps_and_titles(self):
first = self.save()
second = copy.deepcopy(first)
second["metadata"]["created_at"] = "2030-01-01T00:00:00.000Z"
second["metadata"]["updated_at"] = "2030-01-01T00:00:01.000Z"
second["metadata"]["captured_at"] = "2030-01-01T00:00:02.000Z"
second["windows"][0]["title_hint"] = "volatile"
self.assertEqual(
snapshot_module.structural_projection(first),
snapshot_module.structural_projection(second),
)
def test_cli_save_list_show_delete(self):
outputs = []
with mock.patch.dict(os.environ, self.environment, clear=False), mock.patch.object(
snapshot_module, "collect", side_effect=lambda _path: self.collector(None)
):
for arguments in (("save", "cli"), ("list",), ("show", "cli"), ("delete", "cli")):
stdout = io.StringIO()
stderr = io.StringIO()
with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr):
self.assertEqual(snapshot_module.main(arguments), 0)
self.assertEqual(stderr.getvalue(), "")
outputs.append(json.loads(stdout.getvalue()))
self.assertEqual(outputs[0]["name"], "cli")
self.assertEqual(outputs[1][0]["name"], "cli")
self.assertEqual(outputs[2]["metadata"]["session_name"], "cli")
self.assertEqual(outputs[3], {"deleted": "cli"})
self.assertFalse((self.sessions / "cli.json").exists())
if __name__ == "__main__":
unittest.main()