Compare commits

..

5 commits

62 changed files with 3937 additions and 2135 deletions

5
.gitignore vendored
View file

@ -36,3 +36,8 @@ bin/.local/bin/trainlog-bt-watch
bin/.local/bin/trainlog-btd
bin/.local/bin/trainlog-sync-once
bin/.local/bin/trainlog-syncd
# Session V2 : sorties locales reproductibles, jamais distribuées par Git.
/build/session-v2/
/build/session-v2-tests/
/quickshell/.config/quickshell/labfy-sway/session_v2/libexec/

View file

@ -116,6 +116,18 @@ inventaires et validations sont décrits dans le
| [Recovery](docs/recovery.md) | Reconstruction complète après perte du disque |
| [Security](docs/security.md) | Secrets exclus et règles de publication |
## Session Restore V2
QuickShell crée un checkpoint avant logout, reboot ou poweroff. Au login suivant,
un chooser unique restaure la session en un clic, sans autre interaction.
Firefox s’appuie sur son companion signé et un native host de corrélation ;
Limusic reste géré par l’autostart Sway. La fenêtre terminal est restaurée,
mais pas son contenu ni ses processus. Ratios exacts, fullscreen et scratchpad
ne sont pas pris en charge ; le multi-output physique n’est pas validé et le
provider Firefox actuel reste limité au profil supporté.
Voir [installation, fonctionnement et tests V2](docs/session-restore-v2.md).
## ✦ Mirrors
| Rôle | Dépôt |

View file

@ -0,0 +1,11 @@
# Référence historique V1
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).
Les anciennes sessions et snapshots utilisateur sous XDG_STATE_HOME sont
conservés. Aucune migration ni suppression de données n’a été effectuée.
Les sources et documents V1 antérieurs au nettoyage sont archivés dans la
branche locale de recherche `refactor/session-restore-v2`, sans publication.
L’historique Git public conserve les versions V1 précédemment publiées.

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

@ -0,0 +1,115 @@
# Session Restore V2
La recette réelle du 7 octobre 2026 a réussi : logout, checkpoint V2, arrêt par
UWSM, login, chooser unique et un clic **Restaurer**. Aucune interaction après
ce clic ; arbre, focus final et identité Firefox exacte vérifiés. La validation
visuelle utilisateur est PASS. Les preuves détaillées restent dans la branche
locale de recherche ; elles ne sont pas distribuées dans ce dépôt.
## Utilisation
Le menu power conserve Verrouiller, Suspendre, Déconnexion, Redémarrer et
Éteindre. Déconnexion sauvegarde un checkpoint V2 avant `uwsm stop` ; Redémarrer
et Éteindre sauvegardent avant leur action systemd. Un échec de checkpoint
permet d’annuler ou de choisir explicitement de quitter sans sauvegarder.
Au login suivant, le chooser propose Restaurer ou Nouvelle session. Un clic
Restaurer lance la transaction jusqu’au résultat final, sans étape Firefox
manuelle ni deuxième restauration. Le chooser est acquitté durablement ; un
redémarrage QuickShell dans la même session ne le réaffiche pas.
Le gestionnaire de sessions permet sauvegarde, liste, aperçu structurel,
suppression et restauration V2. L’aperçu ne modifie aucune fenêtre. Le backend
fournit aussi `show` pour inspecter une session. Les noms sont limités à 64
caractères ASCII alphanumériques, tirets et underscores, commençant par un
caractère alphanumérique. Ne pas publier les sessions ou rapports runtime.
## Installation reproductible
Prérequis : Python 3, Sway compatible, QuickShell/Qt 6, GTK3, compilateur C17,
`pkg-config`, autotiling, Kitty, Firefox et Limusic déjà configuré par l’autostart
Sway. Le catalogue est `session_v2/applications.py`. Limusic utilise
`strategy=managed-autostart`, `managed_by=sway-autostart` et aucun argv Labfy.
Depuis la racine du dépôt :
```sh
python3 -B tools/build-session-v2.py
install -Dm755 build/session-v2/labfy-v2-anchor \
quickshell/.config/quickshell/labfy-sway/session_v2/libexec/labfy-v2-anchor
```
Le build ne déploie ni extension ni préférence. Le helper compilé et les archives
restent ignorés par Git. Déployer le package Stow `quickshell` selon le guide
habituel, puis suivre [la documentation du companion Firefox](../firefox-extension/session-v2/README.md).
L’extension signée déjà installée n’a pas besoin d’être réinstallée pour ce nettoyage.
Versions de la recette : Firefox 157.0, SwayFX 0.6, QuickShell 0.3.1,
autotiling 1.9.4, Python 3.14.7, Qt Declarative 6.11.2 et GTK3 3.24.52.
## Architecture et invariants
`sessionui/SessionV2Service.qml` est l’unique propriétaire des commandes UI.
`Bar.qml` instancie `SessionStartupPromptV2` ; `shell.qml` désigne une seule barre
hôte. `ControlCenter.qml` utilise `SessionManagerV2` et `SessionExitGate` avec
le statut de checkpoint `saved`. Aucune surface active ne dépend du moteur V1.
Le backend capture applications, slots et structure ; il reconstruit par ancres
et swaps, puis vérifie arbre, absence d’ancres et focus final. Le guard possède
la suspension/reprise des helpers et leur récupération en cas d’interruption.
Les observations runtime ne sont pas des identités persistantes. Firefox garde
la propriété de ses fenêtres/onglets ; le companion fournit l’identité exacte.
Kitty restaure uniquement la fenêtre. Les échecs sont explicites ; aucune
approximation d’identité Firefox n’est substituée silencieusement.
Les checkpoints et sessions sont privés sous
`${XDG_STATE_HOME:-$HOME/.local/state}/labfy-sway/session-v2`. Les transactions,
locks, rapports et observations du provider restent sous `$XDG_RUNTIME_DIR`.
Les anciennes données utilisateur V1 sont conservées. `analyze-v1-migration`
est une analyse en lecture seule, sans conversion automatique ni moteur V1.
Les anciens documents V1 sont des références historiques non exécutables.
## Diagnostics et validation
```sh
python3 -B "$HOME/.config/quickshell/labfy-sway/session-v2.py" restore-status
qs ipc -c labfy-sway call sessionV2 status
systemctl --user is-active quickshell-labfy-sway.service
```
Le rapport de succès doit indiquer `status=success`, les deux vérifications
finales vraies, autant d’ancres nettoyées que créées et autant de helpers repris
que suspendus. Le diagnostic QuickShell doit retourner `backend=v2`, `busy=false`.
Le rapport backend contient des identités privées : ne pas le joindre brut à
un ticket public. Ne jamais relancer un restore pour simplement lire son statut.
Tests sur compositeur headless privé, sans logout ni accès au bureau utilisateur :
```sh
python3 -B -m unittest discover -s tests/session_v2 -p 'test_*.py'
python3 -B tests/session_v2/runtime.py
python3 -B tests/session_v2/failures.py
python3 -B tests/session_v2/crashes.py
python3 -B tests/session_v2/security.py
python3 -B tests/session_v2/phase3b_runtime.py
python3 -B tests/session_v2/phase3b_ui.py
QT_QPA_PLATFORM=offscreen /usr/lib/qt6/bin/qmltestrunner \
-input tests/session_v2/qml -o -,txt
```
Les tests Python nécessitent `jsonschema` ; les scénarios UI nécessitent QuickShell
et QtTest. Les résultats synthétiques restent sous `build/session-v2-tests`,
ou dans `LABFY_SESSION_V2_EVIDENCE`. Les fixtures ne lancent que leur propre
compositeur et leurs enfants TEST_ONLY. Le build helper C17 utilise
`-Wall -Wextra -Werror`. Les métadonnées QuickShell peuvent produire des
avertissements qmllint ; le chargement réel et les scénarios Qt complètent
le contrôle syntaxique, sans prétendre à zéro avertissement.
## Limites conservées
- Contenu et processus terminal non restaurés.
- Ratios exacts non restaurés.
- Fullscreen et scratchpad non pris en charge actuellement.
- Multi-output physique non validé.
- Provider Firefox candidat actuel pour le profil supporté ; plusieurs profils
simultanés ne sont pas pris en charge.

View file

@ -1,395 +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>
```
`--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.
Les anciennes sessions et snapshots utilisateur sous XDG_STATE_HOME sont
conservés. Aucune migration ni suppression de données n’a été effectuée.
Les sources et documents V1 antérieurs au nettoyage sont archivés dans la
branche locale de recherche `refactor/session-restore-v2`, sans publication.
L’historique Git public conserve les versions V1 précédemment publiées.

View file

@ -0,0 +1,57 @@
# Companion Firefox Session Restore V2
Sources de l’extension utilisée par la recette réelle validée. ID :
`session-v2@labfy.org`, version 1.0.0. Host : `org.labfy.session_v2_identity`.
Permissions limitées à `sessions` et `nativeMessaging` : aucun accès à la
navigation, aux cookies, à l’historique ou au contenu des onglets.
## Build et signature
Depuis la racine du dépôt, `python3 -B tools/build-session-v2.py` construit
`build/session-v2/labfy-session-v2-unsigned.xpi`. Le contenu du XPI est
déterministe ; le bundle backend tar.gz est seulement une sortie locale.
Aucun binaire ou XPI signé n’est versionné.
La distribution retenue est **AMO unlisted**, signée par Mozilla, puis installée
localement avec consentement explicite. Envoyer le XPI non signé via le portail
AMO de développement, choisir la distribution non répertoriée, récupérer le
package signé et l’installer depuis le gestionnaire d’extensions Firefox.
Les identifiants AMO restent hors du dépôt. Ne pas désactiver l’exigence de
signature Firefox Release. Firefox 157 est la version minimale du manifeste.
Sur le profil utilisateur validé, l’extension signée est déjà active et
`browser.startup.page=3` est déjà configuré. Le nettoyage V1 ne modifie ni
installation, ni permissions, ni préférence. Pour une nouvelle installation,
le propriétaire du profil doit choisir explicitement la réouverture des
fenêtres et onglets précédents.
## Native host
Après déploiement Stow, rendre le template `native-host.json.in` en remplaçant
`@ABSOLUTE_NATIVE_HOST_LAUNCHER@` par le chemin absolu de
`$HOME/.config/quickshell/labfy-sway/session-v2-native-host.py`.
Conserver le nom du host, le type `stdio` et l’allowlist exacte de l’extension.
Le wrapper doit rester exécutable.
Sur l’installation Linux validée, le manifeste est
`${XDG_CONFIG_HOME:-$HOME/.config}/mozilla/native-messaging-hosts/org.labfy.session_v2_identity.json`.
Le chemin historique `$HOME/.mozilla` pointe vers cette arborescence. Sur un
autre packaging Firefox, vérifier le répertoire Native Messaging utilisé avant
installation. Créer le manifeste avec permissions 0600 ; ne pas écraser un
host actif lors d’une simple mise à jour de documentation.
Le host accepte seulement le protocole `map-window` avec UUID et jeton runtime.
Les messages sont bornés à 4096 octets ; la sortie standard est réservée au
framing Native Messaging. Le host lit l’arbre Sway pour corréler le préfixe
éphémère, sans exécuter de commande Sway mutatrice. L’extension retire ce
préfixe après corrélation. Le provider exige des observations récentes et un
publisher vivant ; une identité absente ou ambiguë produit un échec explicite.
Les UUID persistants appartiennent aux fenêtres Firefox ; les observations et
identifiants de processus restent privés dans le runtime. Ne pas publier ces
fichiers. Le provider demeure le candidat validé pour le profil supporté ; le
multi-profil n’est pas pris en charge. Les fenêtres et onglets sont restaurés
par Firefox, pas par une lecture de sessionstore côté Labfy.
Voir [Session Restore V2](../../docs/session-restore-v2.md) pour le cycle complet,
les tests et les limites.

View file

@ -0,0 +1,92 @@
/* Identité locale, aucune donnée de navigation. Pas de canal de commande du
* laboratoire. Le host n'accepte que map-window et ne peut piloter Firefox.
* L'UUID appartient à la fenêtre logique, pas à son adresse runtime windowId.
*/
"use strict";
const KEY = "labfy.session.window_uuid";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
let port = null;
let pending = null;
let busy = false;
function connect() {
if (port) return port;
const current = browser.runtime.connectNative("org.labfy.session_v2_identity");
port = current;
current.onMessage.addListener(answer => {
if (port !== current) return;
if (answer.event === "capture") { cycle(); return; }
if (!pending) return;
const p = pending;
pending = null;
p.resolve(answer);
});
current.onDisconnect.addListener(() => {
if (port !== current) return;
port = null;
if (pending) { pending.reject(new Error("PROVIDER_UNAVAILABLE")); pending = null; }
});
return port;
}
async function request(message) {
const p = connect();
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (pending) { pending = null; p.disconnect(); port = null; reject(new Error("PROVIDER_TIMEOUT")); }
}, 5000);
pending = {resolve: v => { clearTimeout(timer); resolve(v); }, reject: e => { clearTimeout(timer); reject(e); }};
try { p.postMessage(message); }
catch (e) { clearTimeout(timer); pending = null; reject(e); }
});
}
async function ids() {
const windows = await browser.windows.getAll({populate: false, windowTypes: ["normal"]});
if (windows.length > 256) throw new Error("WINDOW_LIMIT");
return windows.map(w => w.id);
}
async function cycle() {
if (busy) return;
busy = true;
try {
const rows = [];
for (const id of await ids()) {
let value = await browser.sessions.getWindowValue(id, KEY);
if (value === undefined) {
// Relecture avant attribution ; une ancienne UUID absente du snapshot
// restera un échec exact, jamais une correspondance par ordre.
await sleep(200);
value = await browser.sessions.getWindowValue(id, KEY);
if (value === undefined) {
value = crypto.randomUUID();
await browser.sessions.setWindowValue(id, KEY, value);
}
}
if (!UUID.test(value)) throw new Error("INVALID_UUID");
rows.push({id, uuid: value});
}
// Les doublons sont également refusés par le provider. Les publier permet
// un diagnostic de collision au lieu d'un silence ambigu.
for (const row of rows) {
const runtime_token = crypto.randomUUID().replaceAll("-", "");
try {
await browser.windows.update(row.id, {titlePreface: `[LABFY:${runtime_token}] `});
const answer = await request({op: "map-window", uuid: row.uuid, runtime_token});
if (answer.result !== "OK" || answer.uuid !== row.uuid || !Number.isSafeInteger(answer.con_id)) throw new Error("PROVIDER_ERROR");
} finally {
await browser.windows.update(row.id, {titlePreface: ""});
}
}
} catch (_) {
// Aucune chaîne d'erreur externe ou donnée de page dans un log.
} finally { busy = false; }
}
async function start() {
for (const id of await ids()) {
try { await browser.windows.update(id, {titlePreface: ""}); } catch (_) {}
}
connect();
// Reconnexion seulement : pas de titlePreface ni requête Sway en idle.
setInterval(() => { if (!port) connect(); }, 1000);
}
start().catch(() => {});

View file

@ -0,0 +1,11 @@
{
"manifest_version": 2,
"name": "Labfy — Identité des fenêtres",
"version": "1.0.0",
"description": "Identité locale des fenêtres pour restaurer leur disposition Sway.",
"browser_specific_settings": {
"gecko": {"id": "session-v2@labfy.org", "strict_min_version": "157.0", "data_collection_permissions": {"required": ["none"]}}
},
"permissions": ["sessions", "nativeMessaging"],
"background": {"scripts": ["background.js"], "persistent": true}
}

View file

@ -0,0 +1,7 @@
{
"name": "org.labfy.session_v2_identity",
"description": "Labfy Session V2 — corrélation locale",
"path": "@ABSOLUTE_NATIVE_HOST_LAUNCHER@",
"type": "stdio",
"allowed_extensions": ["session-v2@labfy.org"]
}

View file

@ -16,6 +16,7 @@ PanelWindow {
required property var modelData
required property var notificationService
required property bool startupHost
screen: modelData
anchors {
@ -139,4 +140,8 @@ PanelWindow {
barWindow: bar
service: bar.notificationService
}
SessionStartupPromptV2 {
barWindow: bar
startupHost: bar.startupHost
}
}

View file

@ -0,0 +1,150 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import "controlcenter"
import "theme"
import "sessionui"
import "sessionui/Protocol.js" as Protocol
PanelWindow {
id: popup
required property var barWindow
required property bool startupHost
property bool interactionArmed: false
property bool failed: false
property bool restored: false
property bool detailsVisible: false
property bool attempted: false
property string technicalDetails: ""
property int windowCount: 0
property int workspaceCount: 0
readonly property bool busy: SessionV2Service.busy
function restoreRequested() {
if (!interactionArmed || busy || attempted || failed) return;
if (SessionV2Service.run("apply-last", "", "", popup)) attempted = true;
}
function newSessionRequested() {
if (interactionArmed && !busy) SessionV2Service.run("ack-new", "", "", popup);
}
function fail() {
failed = true;
restored = false;
// Détails provient uniquement de Restore Attempt V2, jamais d'un log.
SessionV2Service.run("restore-status", "", "", popup);
}
Timer {
interval: 350
running: popup.startupHost
onTriggered: SessionV2Service.run("startup-status", "", "", popup)
}
Timer { id: arm; interval: 750; onTriggered: popup.interactionArmed = popup.visible }
Timer { id: closeSuccess; interval: 600; onTriggered: popup.visible = false }
Connections {
target: SessionV2Service
function onCompleted(owner, kind, value, okay) {
if (owner !== popup) return;
if (kind === "startup-status") {
popup.visible = okay && value.eligible === true && value.claimed === true;
if (popup.visible) {
popup.windowCount = value.windows || 0;
popup.workspaceCount = value.workspaces || 0;
}
} else if (kind === "apply-last") {
if (okay && Protocol.restoreSucceeded(value))
SessionV2Service.run("ack-restored", "", value.transaction_id, popup);
else popup.fail();
} else if (kind === "ack-restored") {
if (okay && value.status === "acknowledged" && value.choice === "restored") {
popup.restored = true;
closeSuccess.start();
} else popup.fail();
} else if (kind === "ack-new") {
if (okay && value.status === "acknowledged") popup.visible = false;
else popup.fail();
} else if (kind === "restore-status") {
popup.technicalDetails = okay ? Protocol.details(value) : "Rapport de restauration indisponible.";
}
}
}
// Layer-shell, jamais un PopupWindow grabbing au nouveau login.
screen: barWindow.screen
anchors { top: true; bottom: true; left: true; right: true }
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
aboveWindows: true
focusable: true
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "labfy-session-startup-v2"
visible: false
color: "transparent"
onVisibleChanged: {
if (startupHost) SessionV2Service.startupChooserVisible = visible;
interactionArmed = false;
if (visible) arm.restart(); else arm.stop();
}
FocusScope {
id: modal
anchors.fill: parent
focus: popup.visible
// Aucun focus par défaut sur Restore. Les touches en transit n'agissent
// pas, et Escape ne vaut jamais une demande de restauration.
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Return || event.key === Qt.Key_Space) event.accepted = true;
}
Rectangle { anchors.fill: parent; color: Qt.rgba(0, 0, 0, 0.65) }
MouseArea { anchors.fill: parent }
Rectangle {
anchors.centerIn: parent
width: Math.min(460, Math.max(280, modal.width - 32))
height: content.implicitHeight + 40
color: Theme.popupBackground
radius: 8
border.color: Theme.lavender
Column {
id: content
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 20 }
spacing: 14
Text { text: "Session précédente"; color: Theme.foreground; font.pixelSize: 19; font.bold: true }
Text {
width: parent.width
text: popup.failed ? "Impossible de restaurer complètement la session."
: popup.restored ? "Session restaurée"
: popup.busy && popup.attempted ? "Restauration en cours…"
: popup.windowCount + " fenêtres • " + popup.workspaceCount + " espaces"
color: Theme.foreground
wrapMode: Text.Wrap
}
Text {
width: parent.width
visible: popup.detailsVisible
text: popup.technicalDetails
color: Theme.secondaryForeground
wrapMode: Text.Wrap
}
Row {
spacing: 10
visible: !popup.restored && (!popup.attempted || popup.failed)
ActionButton {
visible: popup.failed
label: "Détails"
enabled: !popup.busy
onClicked: popup.detailsVisible = !popup.detailsVisible
}
ActionButton {
label: "Nouvelle session"
enabled: popup.interactionArmed && !popup.busy
onClicked: popup.newSessionRequested()
}
ActionButton {
visible: !popup.failed
label: "Restaurer"
enabled: popup.interactionArmed && !popup.busy && !popup.attempted
onClicked: popup.restoreRequested()
}
}
}
}
}
}

View file

@ -1,10 +1,12 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Networking
import Quickshell.Bluetooth
import "../components"
import "../theme"
import "../sessionui"
PopupWindow {
id: popup
@ -13,11 +15,18 @@ PopupWindow {
// CONTRACT: indices externes historiques conservés ; tout nouveau routage
// 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 })
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8,
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"] },
@ -28,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
@ -44,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
@ -81,7 +116,7 @@ PopupWindow {
: currentPage === 5 ? batteryPage.implicitHeight + 32
: currentPage === pages.wallpaper ? 550
: currentPage === pages.appearance ? 520
: currentPage === pages.locationTime ? 650 : 440
: currentPage === pages.locationTime || currentPage === pages.sessionManager ? 650 : 440
visible: false
// CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après
// fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible
@ -183,6 +218,7 @@ PopupWindow {
id: sessionPage
actions: popup.sessionActions
onBackRequested: popup.openPage("main")
onManagerRequested: popup.openPage("sessionManager")
onActionRequested: id => popup.requestSessionAction(id)
}
@ -191,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);
}
}
@ -233,6 +269,52 @@ PopupWindow {
LocationTimePage {
onBackRequested: popup.openPage("appearance")
}
SessionManagerV2 {
activePage: popup.visible && popup.currentPage === popup.pages.sessionManager
onBackRequested: popup.openPage("session")
}
Column {
id: checkpointFailurePage
spacing: 16
Text {
width: parent.width
text: "La session n’a pas pu être sauvegardée."
color: Theme.warningForeground
font.pixelSize: 16
font.bold: true
wrapMode: Text.Wrap
}
Text {
width: parent.width
text: popup.checkpointError
color: Theme.secondaryForeground
font.pixelSize: 11
wrapMode: Text.Wrap
}
Row {
spacing: 8
ActionButton {
label: "Annuler"
enabled: !sessionExitGate.busy
onClicked: {
sessionExitGate.cancel();
popup.openPage("session");
}
}
ActionButton {
danger: true
enabled: !sessionExitGate.busy
label: popup.pendingAction && popup.pendingAction.id === "reboot"
? "Redémarrer sans sauvegarder"
: popup.pendingAction && popup.pendingAction.id === "poweroff"
? "Éteindre sans sauvegarder"
: "Quitter sans sauvegarder"
onClicked: popup.quitWithoutCheckpoint()
}
}
}
}
}
}

View file

@ -0,0 +1,80 @@
import QtQuick
QtObject {
id: gate
required property var actions
property string checkpointSuccessStatus: "success"
property var pendingAction: null
property var checkpointAction: null
property bool busy: false
property string errorMessage: ""
signal confirmationRequested()
signal checkpointRequested(string reason)
signal commandRequested(var command)
signal checkpointFailed()
function actionFor(id) {
return actions.find(item => item.id === id);
}
function requestAction(id) {
const selected = actionFor(id);
if (!selected || busy) return;
if (id === "lock") {
commandRequested(selected.command);
return;
}
pendingAction = selected;
confirmationRequested();
}
function confirmAction(id) {
if (!pendingAction || pendingAction.id !== id || busy) return;
if (!pendingAction.checkpointReason) {
commandRequested(pendingAction.command);
return;
}
// WHY: le popup peut perdre sa visibilité pendant le Process asynchrone.
// CONTRACT: l'action confirmée reste l'autorité jusqu'au résultat du checkpoint.
// INVARIANT: aucune commande destructive n'est émise avant un succès JSON exit 0.
checkpointAction = pendingAction;
errorMessage = "";
busy = true;
checkpointRequested(checkpointAction.checkpointReason);
}
function checkpointFinished(exitCode, exitStatus, stdoutText, stderrText) {
if (!busy || !checkpointAction) return;
let response = undefined;
try { response = JSON.parse(stdoutText); } catch (_) {}
const completedAction = checkpointAction;
busy = false;
checkpointAction = null;
if (exitCode === 0 && exitStatus === 0 && response
&& response.status === checkpointSuccessStatus) {
pendingAction = null;
commandRequested(completedAction.command);
return;
}
pendingAction = completedAction;
errorMessage = stderrText.trim() || "Le backend de sauvegarde a échoué.";
checkpointFailed();
}
function cancel() {
if (busy) return;
pendingAction = null;
checkpointAction = null;
errorMessage = "";
}
function quitWithoutCheckpoint() {
if (!pendingAction || busy) return;
const command = pendingAction.command;
pendingAction = null;
checkpointAction = null;
commandRequested(command);
}
}

View file

@ -0,0 +1,106 @@
import QtQuick
import QtQuick.Controls
import "../theme"
import "../sessionui"
import "../sessionui/Protocol.js" as Protocol
Item {
id: page
required property bool activePage
signal backRequested()
property var sessions: []
property string selected: ""
property string feedback: ""
property string details: ""
property bool restoreFailed: false
property bool detailsVisible: false
readonly property bool busy: SessionV2Service.busy
implicitHeight: content.implicitHeight
onActivePageChanged: if (activePage && !busy) SessionV2Service.run("list", "", "", page)
Connections {
target: SessionV2Service
function onCompleted(owner, kind, value, okay) {
if (owner !== page) return;
if (kind === "list") { if (okay) page.sessions = value.sessions; return; }
if (kind === "apply") {
if (okay && Protocol.restoreSucceeded(value)) page.feedback = "Session restaurée";
else {
page.restoreFailed = true;
page.feedback = "Impossible de restaurer complètement la session.";
SessionV2Service.run("restore-status", "", "", page);
}
} else if (kind === "restore-status") page.details = Protocol.details(value);
else if (!okay) page.feedback = "L’opération n’a pas pu aboutir.";
else if (kind === "plan") page.feedback = "Plan structurel : " + value.operations.length + " opérations. Aucune fenêtre modifiée.";
else {
page.feedback = kind === "save" ? "Session sauvegardée" : "Session supprimée";
SessionV2Service.run("list", "", "", page);
}
}
}
Column {
id: content
width: parent.width
spacing: 12
Text { text: "Gestionnaire de sessions V2"; color: Theme.foreground; font.pixelSize: 17; font.bold: true }
Text { width: parent.width; text: page.feedback; visible: text.length > 0; color: Theme.foreground; wrapMode: Text.Wrap }
Text { width: parent.width; text: page.details; visible: page.detailsVisible; color: Theme.secondaryForeground; wrapMode: Text.Wrap }
Column {
width: parent.width
spacing: 10
visible: !page.restoreFailed
TextField {
id: nameField
width: parent.width
placeholderText: "Nom de la session"
enabled: !page.busy
maximumLength: 64
}
ActionButton {
label: "Sauvegarder"
enabled: !page.busy && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(nameField.text)
&& page.sessions.indexOf(nameField.text) < 0
onClicked: SessionV2Service.run("save", nameField.text, "", page)
}
ComboBox {
id: selection
width: parent.width
model: page.sessions
enabled: !page.busy
onCurrentTextChanged: page.selected = currentText
}
Row {
spacing: 8
ActionButton {
label: "Aperçu"
enabled: !page.busy && page.selected.length > 0
onClicked: SessionV2Service.run("plan", page.selected, "", page)
}
ActionButton {
label: "Restaurer"
enabled: !page.busy && page.selected.length > 0
onClicked: { page.feedback = "Restauration en cours…"; SessionV2Service.run("apply", page.selected, "", page); }
}
ActionButton {
label: "Supprimer"
enabled: !page.busy && page.selected.length > 0
onClicked: SessionV2Service.run("delete", page.selected, "", page)
}
}
}
Row {
spacing: 8
ActionButton {
visible: page.restoreFailed
label: "Détails"
enabled: !page.busy
onClicked: page.detailsVisible = !page.detailsVisible
}
ActionButton {
label: page.restoreFailed ? "Nouvelle session" : "Retour"
enabled: !page.busy
onClicked: { page.restoreFailed = false; page.feedback = ""; page.detailsVisible = false; page.backRequested(); }
}
}
}
}

View file

@ -8,10 +8,11 @@ Item {
required property var actions
signal backRequested()
signal managerRequested()
signal actionRequested(string actionId)
// La taille doit être connue avant la création des delegates pour éviter un popup de hauteur nulle.
implicitHeight: 28 + actions.length * 48 + actions.length * 10
implicitHeight: 28 + (actions.length + 1) * 48 + (actions.length + 1) * 10
Column {
id: content
@ -49,6 +50,17 @@ Item {
}
}
SessionAction {
width: content.width
actionInfo: ({
id: "session-manager",
label: "Gestionnaire de sessions",
icon: "󰆓",
accent: Theme.accent
})
onSelected: page.managerRequested()
}
Repeater {
model: page.actions
delegate: SessionAction {

View file

@ -0,0 +1,8 @@
#!/usr/bin/python3
"""Entrée native messaging ; jamais lancée via une commande fournie par message."""
import sys
sys.dont_write_bytecode = True
from session_v2.native_host import main
from session_v2.errors import Failure
try: main()
except (Failure, OSError): sys.exit(1)

View file

@ -0,0 +1,6 @@
#!/usr/bin/python3
"""Entrée V2 indépendante ; aucun import QuickShell/V1."""
import sys
sys.dont_write_bytecode = True
from session_v2.cli import main
sys.exit(main())

View file

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

View file

@ -0,0 +1,44 @@
/* WHY : une seule application possédée fournit les feuilles temporaires.
* CONTRACT : stdin privé, create <identité> / quit, 256 fenêtres maximum.
* INVARIANT : aucun lancement applicatif ni accès aux fenêtres d'autrui.
*/
#include <gtk/gtk.h>
#include <gdk/gdkwayland.h>
#include <stdio.h>
#include <string.h>
static unsigned created;
static gboolean input(GIOChannel *channel, GIOCondition condition, gpointer data) {
(void)channel; (void)data;
if (condition & (G_IO_HUP | G_IO_ERR)) { gtk_main_quit(); return FALSE; }
char buffer[256];
if (!fgets(buffer, sizeof buffer, stdin)) { gtk_main_quit(); return FALSE; }
if (!strcmp(buffer, "quit\n")) { gtk_main_quit(); return FALSE; }
if (created >= 256 || !g_regex_match_simple(
"^create labfy-v2-anchor-[a-f0-9]{32}-[A-Za-z0-9_.-]{1,96}\\n$", buffer, 0, 0)) {
gtk_main_quit(); return FALSE;
}
buffer[strcspn(buffer, "\n")] = 0;
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(window), "Restauration de session…");
gtk_window_set_default_size(GTK_WINDOW(window), 120, 80);
gtk_widget_set_size_request(window, 1, 1);
gtk_widget_show_all(window);
/* GTK remplace app_id au mapping : publier notre identité ensuite. */
gdk_wayland_window_set_application_id(gtk_widget_get_window(window), buffer + 7);
gdk_display_flush(gtk_widget_get_display(window));
++created;
return TRUE;
}
int main(int argc, char **argv) {
g_set_prgname("labfy-v2-anchor");
gtk_init(&argc, &argv);
if (!GDK_IS_WAYLAND_DISPLAY(gdk_display_get_default())) return 2;
setvbuf(stdin, NULL, _IONBF, 0);
GIOChannel *channel = g_io_channel_unix_new(0);
g_io_add_watch(channel, G_IO_IN | G_IO_HUP | G_IO_ERR, input, NULL);
gtk_main();
g_io_channel_unref(channel);
return 0;
}

View file

@ -0,0 +1,109 @@
"""Ressources temporaires avec PID runtime épinglé et identité exacte.
La propriété ne découle jamais d'un préfixe. Tout kill est précédé d'une nouvelle
lecture et d'un contrôle pidfd + PID du helper + app_id autorisé de la transaction.
"""
import json
import os
from pathlib import Path
import signal
import subprocess
import time
from .errors import require
from .ipc import until, canonical, location
from .layout import compile_plan, trees
from .model import normalized
class Anchors:
def __init__(self, sway, data, tx, executable, env=None):
require(Path(executable).is_absolute() and Path(executable).is_file(), "ANCHOR_HELPER_UNAVAILABLE")
self.sway, self.data, self.tx = sway, data, tx
self.plan = compile_plan(data, tx)
self.ids = {}
self.active = True
self.process = subprocess.Popen([str(executable)], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, env=env, start_new_session=True)
self.pidfd = os.pidfd_open(self.process.pid)
self.allowed = {self.identity(s["slot_id"]) for s in data["window_slots"] if not s["state"]["floating"]}
def identity(self, slot): return "labfy-v2-anchor-" + self.tx + "-" + slot
def owned(self, node):
signal.pidfd_send_signal(self.pidfd, 0)
return node.get("pid") == self.process.pid and node.get("app_id") in self.allowed
def anchor(self, slot):
require(self.active, "TRANSACTION_INACTIVE")
found = [n for n in self.sway.windows() if n.get("app_id") == self.identity(slot)]
require(len(found) == 1 and self.owned(found[0]), "ANCHOR_OWNERSHIP_INVALID")
return found[0]
def mutate(self, slot, operation):
node = self.anchor(slot)
self.sway.command(f'[con_id={node["id"]}] {operation}')
def build(self, progress=lambda *_: None):
outputs = {w["workspace"]: w["output"] for w in self.data["workspaces"]}
for op in self.plan:
if op.kind == "workspace":
self.sway.command("workspace " + json.dumps(op.workspace))
self.sway.command("move workspace to output " + json.dumps(outputs[op.workspace]))
elif op.kind == "create":
self.process.stdin.write(("create " + self.identity(op.slot_id) + "\n").encode())
self.process.stdin.flush()
until(lambda: any(n.get("app_id") == self.identity(op.slot_id) for n in self.sway.windows()))
self.ids[op.slot_id] = self.anchor(op.slot_id)["id"]
progress("anchor", len(self.ids))
elif op.kind == "focus": self.mutate(op.slot_id, "focus")
elif op.kind == "layout":
self.mutate(op.slot_id, "focus")
layout = "stacking" if op.layout == "stacked" else op.layout
if op.root: self.mutate(op.slot_id, "layout " + layout)
else:
self.mutate(op.slot_id, "split " + ("v" if op.layout == "splitv" else "h"))
if op.layout in ("tabbed", "stacked"): self.mutate(op.slot_id, "layout " + layout)
else: require(False, "ANCHOR_PLAN_INVALID")
self.verify(self.ids)
progress("all-anchors", len(self.ids))
def verify(self, mapping):
tree = self.sway.tree()
reverse = {v: k for k, v in mapping.items()}
for t in trees(self.data):
require(canonical(tree, t.workspace, reverse) == normalized(t.root), "FINAL_TREE_MISMATCH")
def swap(self, slot, target, target_workspace, stage, allowed_app_ids):
anchor = self.anchor(slot)
before = self.sway.tree()
live = next((n for n in self.sway.windows() if n["id"] == target["id"]), None)
require(live and live.get("pid") == target.get("pid") and live.get("app_id") == target.get("app_id") and live.get("app_id") in allowed_app_ids,
"TARGET_IDENTITY_CHANGED")
prior_a, prior_t = location(before, anchor["id"]), location(before, target["id"])
require(prior_a and prior_a[0] == target_workspace and prior_t and prior_t[0] == stage, "SWAP_WORKSPACE_CHANGED")
self.sway.command(f'[con_id={live["id"]}] swap container with con_id {anchor["id"]}')
after = self.sway.tree()
require(location(after, live["id"]) == prior_a and location(after, anchor["id"]) == prior_t, "SWAP_POSTCONDITION_FAILED")
self.anchor(slot)
def cleanup(self, progress=lambda *_: None):
progress("before-cleanup", 0)
count = 0
for old in self.sway.windows():
if not self.owned(old): continue
fresh = next((n for n in self.sway.windows() if n["id"] == old["id"]), None)
if fresh and self.owned(fresh):
self.sway.command(f'[con_id={fresh["id"]}] kill')
count += 1
progress("cleanup", count)
until(lambda: not any(self.owned(n) for n in self.sway.windows()))
return count
def close(self):
self.active = False
if not self.process.stdin.closed: self.process.stdin.close()
try: self.process.wait(timeout=3)
except subprocess.TimeoutExpired:
signal.pidfd_send_signal(self.pidfd, signal.SIGTERM)
self.process.wait(timeout=3)
os.close(self.pidfd)

View file

@ -0,0 +1,75 @@
"""Politique au niveau Application ; argv viennent du catalogue de confiance.
Le snapshot sélectionne seulement une entrée exacte du catalogue. Il ne peut
introduire aucun exécutable, argument, commande shell ni commande de terminal.
"""
from dataclasses import dataclass
import subprocess
from .errors import require, Failure
@dataclass(frozen=True)
class Spec:
application_id: str
desktop_entry: str
app_ids: tuple
strategy: str
argv: tuple
provider: str | None = None
managed_by: str | None = None
DEFAULT = (
Spec("limusic", "limusic.desktop", ("limusic-app",), "managed-autostart", (), managed_by="sway-autostart"),
Spec("firefox", "firefox.desktop", ("firefox", "org.mozilla.firefox"), "browser-self-restore", ("/usr/bin/firefox",), "firefox-session-window-uuid"),
Spec("kitty", "kitty.desktop", ("kitty",), "terminal", ("/usr/bin/kitty",)),
)
class Catalog:
def __init__(self, specs=DEFAULT):
self.specs = {s.application_id: s for s in specs}
require(len(self.specs) == len(specs), "CATALOG_DUPLICATE")
ids = [v for s in specs for v in s.app_ids]
require(len(ids) == len(set(ids)), "CATALOG_AMBIGUOUS")
def classify(self, node):
found = [s for s in self.specs.values() if node.get("app_id") in s.app_ids]
require(len(found) == 1, "APPLICATION_UNSUPPORTED")
return found[0]
def validate(self, app):
require(app["application_id"] in self.specs, "APPLICATION_UNSUPPORTED")
s = self.specs[app["application_id"]]
require(app["desktop_entry"] == s.desktop_entry and app["strategy"] == s.strategy and app["identity_provider"] == s.provider,
"APPLICATION_POLICY_MISMATCH")
require(s.strategy != "unknown", "APPLICATION_UNSUPPORTED")
owner = (s.managed_by or "autostart") if s.strategy == "managed-autostart" else None
require(app["managed_by"] == owner, "APPLICATION_POLICY_MISMATCH")
return s
def launch_count(app, observed):
n = app["expected_windows"]
require(0 <= observed <= n, "UNEXPECTED_EXTRA_WINDOW")
strategy = app["strategy"]
if strategy == "managed-autostart": return 0
if strategy == "single-window":
require(n == 1, "APPLICATION_POLICY_MISMATCH")
return 1 - observed
if strategy == "browser-self-restore":
# Une instance déjà présente n'autorise jamais une deuxième ouverture.
return 1 if observed == 0 else 0
if strategy in ("terminal", "multi-instance"): return n - observed
raise Failure("APPLICATION_UNSUPPORTED")
def launch(spec, count, env=None):
require(type(count) is int and 0 <= count <= 256, "LAUNCH_BOUNDS")
children = []
for _ in range(count):
# Les applications ne sont pas des ressources jetables : jamais tuées
# automatiquement lors d'un échec de restauration.
children.append(subprocess.Popen(list(spec.argv), stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, start_new_session=True))
return children

View file

@ -0,0 +1,34 @@
"""Namespace distinct de V1 ; noms non interprétés comme chemins."""
import re
from . import schema
from .storage import atomic, private_dir, read, sync_dir, lock
from .errors import require
class Store:
def __init__(self, root):
self.root = private_dir(root)
self.sessions = private_dir(self.root / "sessions")
self.automatic = private_dir(self.root / "automatic")
def path(self, name):
require(type(name) is str and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", name), "SESSION_NAME_INVALID")
return self.sessions / (name + ".json")
def save(self, name, data):
with lock(self.root, "store.lock"): atomic(self.path(name), data, schema.validate)
def load(self, name): return read(self.path(name), schema.validate)
def list(self): return sorted(p.stem for p in self.sessions.glob("*.json") if not p.is_symlink())
def delete(self, name):
with lock(self.root, "store.lock"):
path = self.path(name)
read(path, schema.validate)
path.unlink()
sync_dir(self.sessions)
def last(self): return read(self.automatic / "last.json", schema.validate)
def checkpoint(self, data):
with lock(self.root, "store.lock"): atomic(self.automatic / "last.json", data, schema.validate)

View file

@ -0,0 +1,103 @@
"""CLI backend ; pas d'appel logout/reboot, pas d'intégration QuickShell V1."""
import argparse
from dataclasses import asdict
import json
import os
from pathlib import Path
import sys
import uuid
from .errors import Failure, require
from .storage import paths, read, atomic, lock, private_dir
from .schema import validate
from .checkpoint import Store
from .ipc import Sway
from .applications import Catalog
from .firefox_identity import FirefoxProvider
from .snapshot import capture
from .layout import compile_plan
from .executor import Executor
from .observability import validate as validate_report
from .startup import acknowledge
def migration(path):
value = read(path)
require(type(value) is dict, "V1_FORMAT_UNRECOGNIZED")
require(value.get("schema") == "labfy.sway.session-snapshot" and type(value.get("version")) is int and value["version"] == 1, "V1_FORMAT_UNRECOGNIZED")
# Analyse conservatrice, jamais de conversion ni copie des identités runtime.
windows = value.get("windows", [])
require(type(windows) is list and len(windows) <= 256, "V1_FORMAT_UNRECOGNIZED")
require(all(type(w) is dict for w in windows), "V1_FORMAT_UNRECOGNIZED")
require(all(type(w.get("scratchpad", {})) is dict for w in windows), "V1_FORMAT_UNRECOGNIZED")
known = all(w.get("app_id") in ("kitty", "firefox", "org.mozilla.firefox") for w in windows)
unsupported = any(w.get("fullscreen_mode") or w.get("scratchpad", {}).get("member") for w in windows)
classification = "unsupported" if not known or unsupported or not windows else "needs exact identity" if any(w.get("app_id") in ("firefox", "org.mozilla.firefox") for w in windows) else "migratable"
return {"classification": classification, "windows": len(windows), "migratable": classification == "migratable",
"reason": "READ_ONLY_ANALYSIS_NO_CONVERSION"}
def main(argv=None):
parser = argparse.ArgumentParser(description="Backend de restauration de session V2")
parser.add_argument("command", choices=("capture", "save", "list", "show", "delete", "plan", "apply", "checkpoint-last", "startup-status", "acknowledge-startup", "apply-last", "restore-status", "analyze-v1-migration"))
parser.add_argument("name", nargs="?")
parser.add_argument("--execute", action="store_true")
parser.add_argument("--stdout", action="store_true")
parser.add_argument("--choice", choices=("new", "restored"))
parser.add_argument("--transaction-id")
parser.add_argument("--reason", choices=("logout", "reboot", "poweroff"), default="logout")
parser.add_argument("--socket", default=os.environ.get("SWAYSOCK", ""))
parser.add_argument("--anchor-binary", default=str(Path(__file__).parent / "libexec/labfy-v2-anchor"))
args = parser.parse_args(argv)
try:
if args.command in ("capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"):
require(args.execute, "EXECUTE_REQUIRED")
state, runtime = paths()
store = Store(state)
command = args.command
if command == "list": result = {"sessions": store.list()}
elif command == "show": result = store.load(args.name)
elif command == "delete": store.delete(args.name); result = {"status": "deleted"}
elif command == "restore-status":
try: result = read(runtime / "session-v2-restore-attempt.json", validate_report)
except FileNotFoundError: result = {"status": "idle", "reason": "NO_RESTORE_ATTEMPT"}
elif command == "analyze-v1-migration": result = migration(args.name)
elif command == "plan": result = {"operations": [asdict(x) for x in compile_plan(store.load(args.name), "0" * 32)]}
else:
sway = Sway(args.socket)
catalog = Catalog()
provider = FirefoxProvider(sway, runtime)
if command == "acknowledge-startup":
result = acknowledge(runtime, sway.session, args.choice, args.transaction_id)
elif command == "startup-status":
try: last = store.last()
except FileNotFoundError: last = None
if last is None:
result = {"eligible": False, "claimed": False}
else:
marker = private_dir(runtime / "session-v2") / "startup.json"
with lock(marker.parent):
seen = read(marker) if marker.exists() else {}
eligible = last["metadata"]["sway_session"] != sway.session and seen.get("session") != sway.session
if args.execute and eligible: atomic(marker, {"session": sway.session})
result = {"eligible": eligible, "claimed": bool(args.execute and eligible)}
result.update(windows=len(last['window_slots']), workspaces=len(last['workspaces']))
elif command in ("capture", "save", "checkpoint-last"):
with lock(private_dir(runtime / "session-v2")):
data = capture(sway, catalog, provider, args.reason if command == "checkpoint-last" else "manual")
if command == "save": store.save(args.name, data)
elif command == "checkpoint-last": store.checkpoint(data)
result = data if command == "capture" else {"status": "saved"}
else:
data = store.last() if command == "apply-last" else store.load(args.name)
result = Executor(sway, catalog, provider, runtime, args.anchor_binary).apply(data, execute=True)
print(json.dumps(result, ensure_ascii=False))
return 0
except Failure as exc:
print(json.dumps({"status": "failed", "reason": exc.code}))
return 2
except (OSError, TypeError, ValueError):
print(json.dumps({"status": "failed", "reason": "BACKEND_IO_OR_FORMAT_ERROR"}))
return 2
if __name__ == "__main__": sys.exit(main())

View file

@ -0,0 +1,9 @@
class Failure(Exception):
"""Codes stables uniquement ; aucune exception externe ni contenu utilisateur."""
def __init__(self, code):
self.code = code
super().__init__(code)
def require(condition, code):
if not condition: raise Failure(code)

View file

@ -0,0 +1,197 @@
"""Transaction de restauration fail-safe, sans promesse de rollback ACID.
Les applications sont conservées après erreur. Seul le helper possédé peut être
supprimé. La vérification de l'arbre n'est jamais remplacée par la cardinalité.
"""
import json
import time
import uuid
from pathlib import Path
from .errors import Failure, require
from .schema import validate, PROVIDER
from .storage import lock, private_dir
from .ipc import location, workspace, walk, until
from .applications import launch_count, launch
from .anchors import Anchors
from .guard import Guard
from .observability import Attempt
from .slots import assign
def preflight(data, sway, catalog, provider):
validate(data)
active = {o["name"] for o in sway.outputs() if o.get("active")}
require({o["name"] for o in data["outputs"]}.issubset(active), "OUTPUT_UNAVAILABLE")
specs = {a["application_id"]: catalog.validate(a) for a in data["applications"]}
if any(a["identity_provider"] == PROVIDER for a in data["applications"]):
require(provider is not None, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE")
nodes = sway.windows()
admitted = set()
for a in data["applications"]:
members = [n for n in nodes if n.get("app_id") in specs[a["application_id"]].app_ids]
launch_count(a, len(members))
admitted.update(n["id"] for n in members)
tree = sway.tree()
for w in data["workspaces"]:
ws = workspace(tree, w["workspace"])
if ws:
require(all(n["id"] in admitted for n in walk(ws) if n.get("app_id") or n.get("window")), "WORKSPACE_OCCUPIED")
return specs
class Executor:
def __init__(self, sway, catalog, provider, runtime, anchor_binary, env=None, progress=None):
self.sway, self.catalog, self.provider = sway, catalog, provider
self.runtime = private_dir(runtime)
self.binary, self.env = anchor_binary, env
self.progress = progress or (lambda *_: None)
def apply(self, data, execute=False, timeout=30):
require(execute is True, "EXECUTE_REQUIRED")
require(type(timeout) in (int, float) and 0 < timeout <= 60, "TIMEOUT_INVALID")
with lock(private_dir(self.runtime / "session-v2")) as lock_fd:
return self._apply(data, timeout, lock_fd)
def _apply(self, data, timeout, lock_fd):
validate(data)
tx = uuid.uuid4().hex
attempt = Attempt(self.runtime / "session-v2-restore-attempt.json", tx, data)
anchors, guard = None, None
mapping = {}
stage = "labfy-v2-stage-" + tx
try:
specs = preflight(data, self.sway, self.catalog, self.provider)
attempt.update(phase="guard")
anchors = Anchors(self.sway, data, tx, self.binary, self.env)
guard = Guard(self.sway, anchors, self.runtime, attempt.path, lock_fd)
attempt.update(helpers_suspended=guard.ready["helpers_suspended"])
all_ids = {a for s in specs.values() for a in s.app_ids}
def stage_node(n):
fresh = next((x for x in self.sway.windows() if x["id"] == n["id"]), None)
require(fresh and fresh.get("pid") == n.get("pid") and fresh.get("app_id") == n.get("app_id") and fresh.get("app_id") in all_ids,
"TARGET_IDENTITY_CHANGED")
self.sway.command(f'[con_id={n["id"]}] move container to workspace ' + json.dumps(stage))
for n in self.sway.windows():
if n.get("app_id") in all_ids: stage_node(n)
def progress(phase, count):
values = {"phase": phase}
if phase == "anchor": values["anchors_created"] = count
elif phase == "cleanup": values["anchors_cleaned"] = count
attempt.update(**values)
self.progress(phase, count)
start = time.monotonic()
anchors.build(progress)
attempt.data["timings_ms"]["anchor_build"] = (time.monotonic() - start) * 1000
attempt.update(phase="launch")
self.sway.command("workspace " + json.dumps(stage))
children = []
start = time.monotonic()
for app in data["applications"]:
spec = specs[app["application_id"]]
count = sum(n.get("app_id") in spec.app_ids for n in self.sway.windows())
children.extend(launch(spec, launch_count(app, count), self.env))
end = time.monotonic() + timeout
groups = {}
while True:
nodes = self.sway.windows()
groups = {a["application_id"]: [n for n in nodes if n.get("app_id") in specs[a["application_id"]].app_ids] for a in data["applications"]}
require(all(len(groups[a["application_id"]]) <= a["expected_windows"] for a in data["applications"]), "UNEXPECTED_EXTRA_WINDOW")
if all(len(groups[a["application_id"]]) == a["expected_windows"] for a in data["applications"]): break
require(time.monotonic() < end, "APPLICATION_WINDOWS_INCOMPLETE")
time.sleep(.025)
attempt.data["timings_ms"]["application_wait"] = (time.monotonic() - start) * 1000
attempt.update(phase="resolve", applications_observed=len(groups))
for app in data["applications"]:
members = [s for s in data["window_slots"] if s["application_id"] == app["application_id"]]
identity = None
if app["identity_provider"] == PROVIDER:
expected = [s["identity_evidence"]["id"] for s in members if s["identity_requirement"] == "exact"]
identity = self.provider.resolve(expected, groups[app["application_id"]]) if expected else self.provider.capture(groups[app["application_id"]])
attempt.data["providers"] = [PROVIDER]
attempt.data["identity_resolutions"] = [{"uuid": k, "con_id": v} for k, v in identity.items()]
mapping.update(assign(members, groups[app["application_id"]], identity))
captured = {n["id"]: n for group in groups.values() for n in group}
for n in captured.values():
if location(self.sway.tree(), n["id"])[0] != stage: stage_node(n)
if n.get("type") == "floating_con" or n.get("floating") in ("user_on", "auto_on"):
self.sway.command(f'[con_id={n["id"]}] floating disable')
start = time.monotonic()
for slot in data["window_slots"]:
if slot["state"]["floating"]: continue
target = captured[mapping[slot["slot_id"]]]
anchors.swap(slot["slot_id"], target, slot["workspace"], stage, specs[slot["application_id"]].app_ids)
attempt.update(phase="swap", anchors_swapped=attempt.data["anchors_swapped"] + 1)
self.progress("swap", attempt.data["anchors_swapped"])
attempt.data["timings_ms"]["swap"] = (time.monotonic() - start) * 1000
start = time.monotonic()
anchors.cleanup(progress)
attempt.data["timings_ms"]["cleanup"] = (time.monotonic() - start) * 1000
start = time.monotonic()
anchors.verify(mapping)
self._floating(data, mapping, captured)
self._verify(data, mapping, specs, anchors)
attempt.data["timings_ms"]["verification"] = (time.monotonic() - start) * 1000
attempt.update(final_tree_verified=True, slots_filled=len(mapping), phase="focus")
if data["focus"]:
self.sway.command(f'[con_id={mapping[data["focus"]]}] focus')
answer = guard.resume()
attempt.update(helpers_resumed=answer["helpers_resumed"], phase="final-verification")
self.progress("after-resume", len(mapping))
self._verify(data, mapping, specs, anchors)
require(data["focus"] is None or any(n["id"] == mapping[data["focus"]] and n.get("focused") for n in self.sway.windows()), "FINAL_FOCUS_MISMATCH")
attempt.update(final_focus_verified=True, status="success", reason="SUCCESS", phase="complete")
guard.finish()
guard = None
return attempt.data
except Exception as exc:
code = exc.code if isinstance(exc, Failure) else "RESTORE_INTERNAL_ERROR"
attempt.update(status="failed", reason=code, phase="cleanup-error")
raise Failure(code) from None
finally:
if guard is not None:
try:
answer = guard.finish()
attempt.update(helpers_resumed=answer["helpers_resumed"], anchors_cleaned=attempt.data["anchors_cleaned"] + answer["anchors_cleaned"])
except Exception: attempt.update(status="failed", reason="GUARD_RECOVERY_FAILED")
if anchors is not None: anchors.close()
def _floating(self, data, mapping, captured):
for s in data["window_slots"]:
if not s["state"]["floating"]: continue
target = mapping[s["slot_id"]]
n = next((n for n in self.sway.windows() if n["id"] == target), None)
require(n and n.get("pid") == captured[target].get("pid"), "TARGET_IDENTITY_CHANGED")
self.sway.command(f'[con_id={target}] move container to workspace ' + json.dumps(s["workspace"]))
# Sway rattache une fenêtre déplacée en coordonnées à l'espace
# visible de l'output : rendre la cible visible avant sa géométrie.
self.sway.command("workspace " + json.dumps(s["workspace"]))
output = next(w["output"] for w in data["workspaces"] if w["workspace"] == s["workspace"])
self.sway.command("move workspace to output " + json.dumps(output))
self.sway.command(f'[con_id={target}] floating enable')
g = s["state"]["geometry"]
self.sway.command(f'[con_id={target}] resize set width {g["width"]} px height {g["height"]} px')
self.sway.command(f'[con_id={target}] move absolute position {g["x"]} px {g["y"]} px')
def _verify(self, data, mapping, specs, anchors):
anchors.verify(mapping)
output_for = {w["name"]: o["name"] for o in self.sway.tree().get("nodes", [])
for w in walk(o) if w.get("type") == "workspace"}
require(all(output_for.get(w["workspace"]) == w["output"] for w in data["workspaces"]), "FINAL_OUTPUT_MISMATCH")
nodes = self.sway.windows()
for a in data["applications"]:
observed = sum(n.get("app_id") in specs[a["application_id"]].app_ids for n in nodes)
require(observed >= a["expected_windows"], "APPLICATION_WINDOWS_INCOMPLETE")
require(observed <= a["expected_windows"], "UNEXPECTED_EXTRA_WINDOW")
for s in data["window_slots"]:
n = next((n for n in nodes if n["id"] == mapping[s["slot_id"]]), None)
require(n and n.get("app_id") in specs[s["application_id"]].app_ids, "TARGET_IDENTITY_CHANGED")
if s["state"]["floating"]:
require(location(self.sway.tree(), n["id"])[0] == s["workspace"] and n.get("floating") in ("auto_on", "user_on") and
all(n["rect"][k] == v for k, v in s["state"]["geometry"].items()), "FLOATING_GEOMETRY_MISMATCH")
for a in data["applications"]:
if a["identity_provider"] != PROVIDER: continue
members = [n for n in nodes if n.get("app_id") in specs[a["application_id"]].app_ids]
identities = self.provider.capture(members)
for s in data["window_slots"]:
if s["application_id"] == a["application_id"] and s["identity_requirement"] == "exact":
require(identities.get(s["identity_evidence"]["id"]) == mapping[s["slot_id"]], "EXACT_WINDOW_IDENTITY_MISSING")

View file

@ -0,0 +1,89 @@
"""Interface provider ; observations runtime privées, vivantes et périssables.
Le moteur ne connaît ni WebExtension ni titrePreface. Le provider exige un mapping
complet du jeu courant de surfaces, avec publisher vivant, puis un arbre stable.
Les fichiers runtime ne sont jamais des identités persistantes du snapshot.
"""
from abc import ABC, abstractmethod
import os
from pathlib import Path
import signal
import time
from .errors import Failure, require
from .schema import UUID, PROVIDER, fields, integer
from .storage import private_dir, read, atomic
from .ipc import until
class IdentityProvider(ABC):
@abstractmethod
def available(self): pass
@abstractmethod
def capture(self, nodes, timeout=6): pass
@abstractmethod
def resolve(self, expected, nodes, timeout=6): pass
def verify(self, mapping, nodes): return self.capture(nodes) == mapping
def process_start(pid): return Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()[19]
def validate_record(r):
fields(r, "schema version session uuid con_id observed_ns publisher_pid publisher_start")
require(r["schema"] == PROVIDER and type(r["version"]) is int and r["version"] == 1, "PROVIDER_RECORD_INVALID")
require(type(r["uuid"]) is str and UUID.fullmatch(r["uuid"]), "INVALID_UUID")
for key in ("con_id", "observed_ns", "publisher_pid"): integer(r[key], 1, 2**63 - 1)
require(type(r["publisher_start"]) is str and r["publisher_start"].isdigit(), "PROVIDER_RECORD_INVALID")
return r
class FirefoxProvider(IdentityProvider):
def __init__(self, sway, runtime):
self.sway = sway
self.directory = private_dir(Path(runtime) / "session-v2/firefox" / sway.session)
def available(self):
for p in list(self.directory.glob("*.json"))[:257]:
if not p.stem.isdigit(): continue
try:
if self._valid(p): return True
except (OSError, Failure): continue
return False
def _valid(self, path):
r = read(path, validate_record)
require(r["session"] == self.sway.session and 0 <= time.monotonic_ns() - r["observed_ns"] < 3_000_000_000, "PROVIDER_STALE")
require(Path(f'/proc/{r["publisher_pid"]}').stat().st_uid == os.getuid(), "PROVIDER_STALE")
fd = os.pidfd_open(r["publisher_pid"])
try:
require(process_start(r["publisher_pid"]) == r["publisher_start"], "PROVIDER_STALE")
signal.pidfd_send_signal(fd, 0)
finally: os.close(fd)
return r
def capture(self, nodes, timeout=6):
expected = {n["id"] for n in nodes}
if not expected: return {}
requested = time.monotonic_ns()
atomic(self.directory / "request.json", {"expires_ns": requested + int(timeout * 1e9)})
def collect():
try:
rows = [self._valid(self.directory / f"{i}.json") for i in sorted(expected)]
if any(r["observed_ns"] < requested for r in rows): return None
except (OSError, Failure): return None
require(len({r["publisher_pid"] for r in rows}) == 1, "PROVIDER_MULTIPLE_PROFILES_UNSUPPORTED")
require(len({r["uuid"] for r in rows}) == len(rows), "EXACT_WINDOW_IDENTITY_COLLISION")
live = {n["id"]: n for n in self.sway.windows()}
require(expected.issubset(live), "APPLICATION_WINDOWS_INCOMPLETE")
# Ne livrer qu'après retrait du préfixe, sans conserver les titres.
if any((live[i].get("name") or "").startswith("[LABFY:") for i in expected): return None
return {r["uuid"]: r["con_id"] for r in rows}
try: return until(collect, timeout, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE")
finally: atomic(self.directory / "request.json", {"expires_ns": 0})
def resolve(self, expected, nodes, timeout=6):
result = self.capture(nodes, timeout)
require(set(expected).issubset(result), "EXACT_WINDOW_IDENTITY_MISSING")
# Les autres fenêtres peuvent correspondre à des slots explicitement
# interchangeables ; la cardinalité applicative est vérifiée par l'executor.
return result

View file

@ -0,0 +1,212 @@
"""Watchdog externe : reprise ne dépendant jamais du finally de l'executor.
CONTRACT : PIDFD hérités, UID/executable/script/SWAYSOCK vérifiés avant signal.
Le lock de transaction est hérité : pas de tentative concurrente pendant recovery.
INVARIANT : un helper initialement stoppé n'est jamais repris par Labfy.
Limite : la mort simultanée du watchdog/compositeur n'est pas une transaction ACID.
"""
import json
import os
from pathlib import Path
import select
import shutil
import signal
import socket
import subprocess
import sys
import time
from .errors import require
from .ipc import Sway, until
from .storage import atomic, read, loads
def helper_state(pid): return Path(f"/proc/{pid}/status").read_text().split("State:\t", 1)[1][0]
def verify_helper(pid, sway_socket, script):
root = Path(f"/proc/{pid}")
require(root.stat().st_uid == os.getuid(), "HELPER_UID_INVALID")
argv = (root / "cmdline").read_bytes().split(b"\0")
expected_exe = str(Path(sys.executable).resolve())
require(str((root / "exe").resolve()) == expected_exe, "HELPER_EXECUTABLE_INVALID")
require(len(argv) > 1 and os.fsdecode(argv[1]) == script, "HELPER_SCRIPT_INVALID")
with (root / "environ").open("rb") as stream: env = stream.read(65537)
require(len(env) <= 65536 and (b"SWAYSOCK=" + os.fsencode(sway_socket)) in env.split(b"\0"), "HELPER_SESSION_INVALID")
def discover(sway_socket):
script = shutil.which("autotiling")
if not script: return []
script = os.path.abspath(script)
helpers = []
for root in Path("/proc").iterdir():
if not root.name.isdigit(): continue
fd = None
try:
verify_helper(int(root.name), sway_socket, script)
fd = os.pidfd_open(int(root.name))
verify_helper(int(root.name), sway_socket, script)
helpers.append({"pid": int(root.name), "fd": fd, "script": script})
fd = None
except Exception as exc:
# Les autres processus ne sont ni des erreurs de restore, ni ciblés.
from .errors import Failure
if not isinstance(exc, (OSError, ValueError, Failure)): raise
finally:
if fd is not None: os.close(fd)
if len(helpers) > 8:
for h in helpers: os.close(h["fd"])
require(False, "HELPER_COUNT_LIMIT")
return helpers
class Guard:
def __init__(self, sway, anchors, runtime, report_path, lock_fd, deadline=120):
self.helpers = discover(sway.path)
self.parent, child = socket.socketpair(type=socket.SOCK_SEQPACKET)
engine_fd = os.pidfd_open(os.getpid())
read_fd, write_fd = os.pipe()
config = {"socket": sway.path, "engine_fd": engine_fd, "anchor_pid": anchors.process.pid,
"anchor_fd": anchors.pidfd, "writer_fd": anchors.process.stdin.fileno(), "allowed": sorted(anchors.allowed),
"helpers": self.helpers, "control": child.fileno(), "report": str(report_path), "deadline": deadline}
fds = (read_fd, engine_fd, anchors.pidfd, anchors.process.stdin.fileno(), child.fileno(), lock_fd,
*(h["fd"] for h in self.helpers))
try:
self.process = subprocess.Popen([sys.executable, "-B", "-m", "session_v2.guard", str(read_fd)],
pass_fds=fds, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parent.parent)})
except Exception:
os.close(write_fd)
self.parent.close()
for h in self.helpers: os.close(h["fd"])
raise
finally:
os.close(read_fd)
os.close(engine_fd)
child.close()
with os.fdopen(write_fd, "wb") as stream: stream.write(json.dumps(config).encode())
self.parent.settimeout(10)
try:
self.ready = loads(self.parent.recv(4096), 4096)
require(self.ready.get("status") == "ready", "GUARD_START_FAILED")
except Exception:
# EOF réveille le guard même si l'executor n'a pas reçu l'ACK.
self.parent.close()
for h in self.helpers: os.close(h["fd"])
self.process.wait(timeout=15)
raise
def resume(self):
self.parent.sendall(b"resume")
self.parent.settimeout(15)
answer = loads(self.parent.recv(4096), 4096)
require(answer.get("status") == "resumed", "GUARD_RECOVERY_FAILED")
return answer
def finish(self):
self.parent.sendall(b"finish")
self.parent.settimeout(15)
answer = loads(self.parent.recv(4096), 4096)
self.process.wait(timeout=5)
self.parent.close()
for h in self.helpers: os.close(h["fd"])
require(answer.get("status") == "finished", "GUARD_RECOVERY_FAILED")
return answer
def resume_helpers(stopped, sway_socket, counts):
"""Une identité devenue invalide ne doit pas empêcher les autres reprises.
CONTRACT : ne jamais signaler un processus non vérifié ; tenter tous les
helpers encore dus, puis signaler un échec global sans perdre leurs états.
"""
okay = True
for h in stopped:
if h.get("resumed"): continue
try:
verify_helper(h["pid"], sway_socket, h["script"])
signal.pidfd_send_signal(h["fd"], signal.SIGCONT)
until(lambda: helper_state(h["pid"]) not in ("T", "t"), 2)
h["resumed"] = True
counts["helpers_resumed"] += 1
except (ProcessLookupError, FileNotFoundError): h["resumed"] = True
except Exception: okay = False
return okay
def run(config):
control = socket.socket(fileno=config["control"])
stopped = []
counts = {"helpers_suspended": 0, "helpers_resumed": 0}
normal = False
sway = Sway(config["socket"])
def resume():
return resume_helpers(stopped, config["socket"], counts)
try:
for h in config["helpers"]:
verify_helper(h["pid"], config["socket"], h["script"])
if helper_state(h["pid"]) in ("T", "t"): continue
# Enregistrer l'obligation avant SIGSTOP : une exception après signal
# ne doit pas perdre la responsabilité de reprise.
stopped.append(h)
signal.pidfd_send_signal(h["fd"], signal.SIGSTOP)
until(lambda: helper_state(h["pid"]) in ("T", "t"), 2)
counts["helpers_suspended"] += 1
control.sendall(json.dumps({"status": "ready", **counts}).encode())
end = time.monotonic() + config["deadline"]
while True:
readable, _, _ = select.select([config["engine_fd"], control], [], [], max(0, end - time.monotonic()))
if config["engine_fd"] in readable: break
if control in readable:
message = control.recv(16)
if message == b"resume":
require(resume(), "GUARD_RECOVERY_FAILED")
control.sendall(json.dumps({"status": "resumed", **counts}).encode())
continue
normal = message == b"finish"
break
if not readable:
signal.pidfd_send_signal(config["engine_fd"], signal.SIGKILL)
until(lambda: bool(select.select([config["engine_fd"]], [], [], 0)[0]), 3)
break
require(resume(), "GUARD_RECOVERY_FAILED")
def owned(n):
signal.pidfd_send_signal(config["anchor_fd"], 0)
return n.get("pid") == config["anchor_pid"] and n.get("app_id") in config["allowed"]
cleaned = 0
for old in sway.windows():
if not owned(old): continue
fresh = next((n for n in sway.windows() if n["id"] == old["id"]), None)
if fresh and owned(fresh):
sway.command(f'[con_id={fresh["id"]}] kill')
cleaned += 1
until(lambda: not any(owned(n) for n in sway.windows()))
os.write(config["writer_fd"], b"quit\n")
if not normal:
data = read(config["report"])
if data["status"] != "success":
data.update(status="interrupted", phase="recovered", reason="EXECUTOR_INTERRUPTED")
data.update(**counts)
data["anchors_cleaned"] += cleaned
atomic(config["report"], data)
else: control.sendall(json.dumps({"status": "finished", "anchors_cleaned": cleaned, **counts}).encode())
except Exception:
resume()
# Arrêter seulement le helper possédé retire aussi ses anchors orphelins.
try: signal.pidfd_send_signal(config["anchor_fd"], signal.SIGTERM)
except ProcessLookupError: pass
try:
data = read(config["report"])
data.update(status="interrupted", phase="guard-error", reason="GUARD_RECOVERY_FAILED", **counts)
atomic(config["report"], data)
control.sendall(b'{"status":"failed"}')
except (OSError, BrokenPipeError): pass
finally:
resume()
control.close()
os.close(config["writer_fd"])
if __name__ == "__main__":
with os.fdopen(int(sys.argv[1]), "rb") as stream: config = loads(stream.read(65537), 65536)
run(config)

View file

@ -0,0 +1,91 @@
"""Sway IPC explicite et borné. Le socket identifie la session, pas un PID persisté."""
import hashlib
import json
import os
import socket
import stat
import struct
import time
from .errors import Failure, require
def walk(node):
yield node
for c in node.get("nodes", []) + node.get("floating_nodes", []): yield from walk(c)
def until(fn, timeout=5, code="RUNTIME_TIMEOUT"):
end = time.monotonic() + timeout
while True:
value = fn()
if value: return value
if time.monotonic() >= end: raise Failure(code)
time.sleep(.025)
class Sway:
def __init__(self, path):
require(type(path) is str and os.path.isabs(path), "SWAY_SOCKET_INVALID")
st = os.lstat(path)
require(stat.S_ISSOCK(st.st_mode) and st.st_uid == os.getuid(), "SWAY_SOCKET_INVALID")
self.path = path
self.identity = (st.st_dev, st.st_ino)
self.session = hashlib.sha256(f"{path}:{st.st_dev}:{st.st_ino}:{st.st_ctime_ns}".encode()).hexdigest()
def request(self, kind, payload=b""):
st = os.lstat(self.path)
require((st.st_dev, st.st_ino) == self.identity and st.st_uid == os.getuid(), "SWAY_SESSION_CHANGED")
with socket.socket(socket.AF_UNIX) as connection:
end = time.monotonic() + 3
connection.settimeout(3)
connection.connect(self.path)
_, uid, _ = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
require(uid == os.getuid(), "SWAY_PEER_INVALID")
connection.sendall(struct.pack("<6sII", b"i3-ipc", len(payload), kind) + payload)
def take(n):
result = bytearray()
while len(result) < n:
require(time.monotonic() < end, "IPC_TIMEOUT")
connection.settimeout(max(.001, end - time.monotonic()))
part = connection.recv(n - len(result))
require(part, "IPC_CLOSED")
result.extend(part)
return result
magic, size, answer = struct.unpack("<6sII", take(14))
require(magic == b"i3-ipc" and answer == kind and size <= 4 * 1024 * 1024, "IPC_BOUNDS")
return json.loads(take(size))
def tree(self): return self.request(4)
def outputs(self): return self.request(3)
def windows(self): return [n for n in walk(self.tree()) if n.get("app_id") or n.get("window")]
def command(self, text):
result = self.request(0, text.encode())
require(result and all(r.get("success") for r in result), "SWAY_COMMAND_FAILED")
def location(tree, identity):
def find(node, ws=None, path=()):
if node.get("type") == "workspace": ws, path = node["name"], ()
if node["id"] == identity: return ws, path
for i, c in enumerate(node.get("nodes", [])):
found = find(c, ws, path + (i,))
if found: return found
for c in node.get("floating_nodes", []):
found = find(c, ws, ())
if found: return found
return find(tree)
def workspace(tree, name):
return next((n for n in walk(tree) if n.get("type") == "workspace" and n.get("name") == name), None)
def canonical(tree, name, mapping):
ws = workspace(tree, name)
if not ws or not ws["nodes"]: return None
def norm(node):
if node.get("app_id") or node.get("window"): return mapping.get(node["id"], "UNEXPECTED")
children = [norm(c) for c in node.get("nodes", [])]
if len(children) == 1 and node["layout"] in ("splith", "splitv", "none"): return children[0]
return {node["layout"]: children}
return norm(ws)

View file

@ -0,0 +1,46 @@
"""Compilation pure, top-down, arité conservée ; aucune opération de lancement."""
from dataclasses import dataclass
from .model import SplitNode, leaves, WorkspaceTree, decode_tree
@dataclass(frozen=True)
class Operation:
kind: str
transaction_id: str
workspace: str
slot_id: str = ""
layout: str = ""
parent: tuple = ()
child_order: int = 0
root: bool = False
def trees(data): return [WorkspaceTree(w["workspace"], w["output"], decode_tree(w["root"])) for w in data["workspaces"]]
def compile_plan(data, tx):
from .schema import validate
import re
from .errors import require
validate(data)
require(re.fullmatch(r"[0-9a-f]{32}", tx), "TRANSACTION_ID_INVALID")
plan = []
for t in trees(data):
def emit(kind, slot="", layout="", parent=(), order=0, root=False):
plan.append(Operation(kind, tx, t.workspace, slot, layout, parent, order, root))
def build(node, path=(), root=False):
if not isinstance(node, SplitNode): return
first = leaves(node.children[0])[0]
emit("layout", first, node.layout, path, root=root)
previous = first
for i, child in enumerate(node.children[1:], 1):
emit("focus", previous)
seed = leaves(child)[0]
emit("create", seed, parent=path, order=i)
previous = seed
for i, child in enumerate(node.children): build(child, path + (i,))
if t.root is not None:
emit("workspace")
emit("create", leaves(t.root)[0])
build(t.root, root=True)
return tuple(plan)

View file

@ -0,0 +1,59 @@
"""Identités persistantes pures. Aucun PID, con_id ni commande exécutable."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Application:
application_id: str
desktop_entry: str
strategy: str
expected_windows: int
managed_by: str | None
identity_provider: str | None
@dataclass(frozen=True)
class WindowSlot:
slot_id: str
application_id: str
workspace: str
tree_path: tuple
state: dict
identity_requirement: str
identity_evidence: dict | None
@dataclass(frozen=True)
class WindowSlotRef:
slot_id: str
@dataclass(frozen=True)
class SplitNode:
layout: str
children: tuple
@dataclass(frozen=True)
class WorkspaceTree:
workspace: str
output: str
root: object
def decode_tree(value):
if value is None: return None
if "slot_id" in value: return WindowSlotRef(value["slot_id"])
return SplitNode(value["layout"], tuple(decode_tree(c) for c in value["children"]))
def leaves(node):
if node is None: return []
if isinstance(node, WindowSlotRef): return [node.slot_id]
return [s for c in node.children for s in leaves(c)]
def normalized(node):
if node is None: return None
if isinstance(node, WindowSlotRef): return node.slot_id
return {node.layout: [normalized(c) for c in node.children]}

View file

@ -0,0 +1,111 @@
"""Host production candidat : seul map-window ; Sway GET_TREE exclusivement.
WHY : corrélation par jeton sans lire le suffixe sémantique ni l'exporter.
CONTRACT : message natif <=4096, clés exactes, aucun shell/argv reçu.
INVARIANT : publication privée runtime seulement, sans URL/titre/environnement.
"""
import json
import os
import re
import select
import struct
import sys
import time
from .errors import require, Failure
from .schema import UUID, PROVIDER
from .storage import loads, atomic, paths, read
from .ipc import Sway
from .firefox_identity import FirefoxProvider, process_start, validate_record
EXTENSION_ID = "session-v2@labfy.org"
APP_IDS = {"firefox", "org.mozilla.firefox"}
def validate(message):
require(type(message) is dict and set(message) == {"op", "uuid", "runtime_token"}, "MESSAGE_SCHEMA")
require(message["op"] == "map-window", "OP_NOT_ALLOWED")
require(type(message["uuid"]) is str and UUID.fullmatch(message["uuid"]), "INVALID_UUID")
require(type(message["runtime_token"]) is str and re.fullmatch(r"[0-9a-f]{32}", message["runtime_token"]), "INVALID_TOKEN")
return message
def take(stream, n, timeout=5):
end, result = time.monotonic() + timeout, bytearray()
while len(result) < n:
require(time.monotonic() < end and select.select([stream], [], [], max(0, end - time.monotonic()))[0], "MESSAGE_TIMEOUT")
data = stream.read(n - len(result))
if not data: raise EOFError()
result.extend(data)
return bytes(result)
def receive(stream):
size, = struct.unpack("=I", take(stream, 4))
require(0 < size <= 4096, "MESSAGE_BOUNDS")
return validate(loads(take(stream, size), 4096))
def send(stream, value):
raw = json.dumps(value, separators=(",", ":")).encode()
require(len(raw) <= 4096, "MESSAGE_BOUNDS")
stream.write(struct.pack("=I", len(raw)) + raw)
stream.flush()
def correlate(sway, message):
validate(message)
prefix = "[LABFY:" + message["runtime_token"] + "] "
end = time.monotonic() + 2
while True:
found = [n["id"] for n in sway.windows() if n.get("app_id") in APP_IDS and (n.get("name") or "").startswith(prefix)]
require(len(found) <= 1, "EXACT_WINDOW_IDENTITY_COLLISION")
if found: return found[0]
require(time.monotonic() < end, "CORRELATION_TIMEOUT")
time.sleep(.02)
def main():
# Firefox transmet manifest + ID. Le manifest allowlist est l'autorité ;
# l'ID est aussi contrôlé ici, sans accepter de paramètres de configuration.
require(len(sys.argv) == 3 and sys.argv[2] == EXTENSION_ID, "EXTENSION_NOT_ALLOWED")
sway = Sway(os.environ.get("SWAYSOCK", ""))
_, runtime = paths()
provider = FirefoxProvider(sway, runtime)
last_request = 0
while True:
try:
# Le host ne demande une corrélation que pendant une requête locale
# bornée du moteur ; aucun clignotement de préfixe en idle.
if not select.select([sys.stdin.buffer.raw], [], [], .1)[0]:
try:
request = read(provider.directory / "request.json")
now = time.monotonic_ns()
if type(request) is dict and set(request) == {"expires_ns"} and type(request["expires_ns"]) is int and now < request["expires_ns"] <= now + 10_000_000_000 and now - last_request > 500_000_000:
send(sys.stdout.buffer, {"event": "capture"})
last_request = now
except FileNotFoundError: pass
continue
message = receive(sys.stdin.buffer.raw)
identity = correlate(sway, message)
record = {"schema": PROVIDER, "version": 1, "session": sway.session, "uuid": message["uuid"],
"con_id": identity, "observed_ns": time.monotonic_ns(), "publisher_pid": os.getpid(),
"publisher_start": process_start(os.getpid())}
atomic(provider.directory / f"{identity}.json", record, validate_record)
# Collecte des seules observations périmées de ce namespace possédé.
for p in list(provider.directory.glob("*.json"))[:512]:
if not p.stem.isdigit() or p.name == f"{identity}.json": continue
if time.time() - p.stat().st_mtime > 10: p.unlink()
send(sys.stdout.buffer, {"result": "OK", "uuid": message["uuid"], "con_id": identity})
except EOFError: return
except Failure as exc:
send(sys.stdout.buffer, {"result": exc.code})
# Après un framing invalide il n'existe aucun point de resynchronisation.
if exc.code.startswith(("MESSAGE_", "JSON_")): return
except (OSError, ValueError, RecursionError):
send(sys.stdout.buffer, {"result": "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE"})
return
if __name__ == "__main__":
try: main()
except (Failure, OSError): sys.exit(1)

View file

@ -0,0 +1,50 @@
"""Journal runtime borné, sans texte externe ni contenu de navigation."""
from .storage import atomic
from .errors import require
import math
import re
from .schema import UUID, PROVIDER
FIELDS = "schema version transaction_id phase status reason applications_expected applications_observed slots_expected slots_filled anchors_created anchors_swapped anchors_cleaned helpers_suspended helpers_resumed providers identity_resolutions identity_level final_tree_verified final_focus_verified timings_ms".split()
def validate(value):
require(type(value) is dict and set(value) == set(FIELDS), "REPORT_SCHEMA")
require(value["schema"] == "labfy.sway.session-v2-restore-attempt" and type(value["version"]) is int and value["version"] == 1, "REPORT_SCHEMA")
require(type(value["transaction_id"]) is str and re.fullmatch(r"[a-f0-9]{32}", value["transaction_id"]), "REPORT_SCHEMA")
require(value["status"] in ("running", "success", "failed", "interrupted"), "REPORT_SCHEMA")
for key in ("phase", "reason"):
require(type(value[key]) is str and re.fullmatch(r"[A-Za-z_-]{1,64}", value[key]), "REPORT_SCHEMA")
for key in ("applications_expected", "applications_observed", "slots_expected", "slots_filled", "anchors_created", "anchors_swapped", "anchors_cleaned", "helpers_suspended", "helpers_resumed"):
require(type(value[key]) is int and 0 <= value[key] <= 256, "REPORT_SCHEMA")
require(value["providers"] in ([], [PROVIDER]), "REPORT_SCHEMA")
require(value["identity_level"] in ("exact", "interchangeable", "best-effort"), "REPORT_SCHEMA")
for key in ("final_tree_verified", "final_focus_verified"): require(type(value[key]) is bool, "REPORT_SCHEMA")
require(type(value["identity_resolutions"]) is list and len(value["identity_resolutions"]) <= 256, "REPORT_SCHEMA")
for r in value["identity_resolutions"]:
require(type(r) is dict and set(r) == {"uuid", "con_id"} and type(r["uuid"]) is str and UUID.fullmatch(r["uuid"]) and
type(r["con_id"]) is int and 0 < r["con_id"] < 2**63, "REPORT_SCHEMA")
require(type(value["timings_ms"]) is dict and set(value["timings_ms"]).issubset({"anchor_build", "swap", "cleanup", "verification", "application_wait"}), "REPORT_SCHEMA")
for v in value["timings_ms"].values(): require(type(v) in (float, int) and math.isfinite(v) and 0 <= v <= 1e6, "REPORT_SCHEMA")
return value
class Attempt:
def __init__(self, path, tx, data):
self.path = path
self.data = {"schema": "labfy.sway.session-v2-restore-attempt", "version": 1,
"transaction_id": tx, "phase": "validate", "status": "running", "reason": "NONE",
"applications_expected": len(data["applications"]), "applications_observed": 0,
"slots_expected": len(data["window_slots"]), "slots_filled": 0,
"anchors_created": 0, "anchors_swapped": 0, "anchors_cleaned": 0,
"helpers_suspended": 0, "helpers_resumed": 0, "providers": [], "identity_resolutions": [],
"identity_level": "best-effort" if any(s["identity_requirement"] == "best-effort" for s in data["window_slots"]) else
"interchangeable" if any(s["identity_requirement"] == "interchangeable" for s in data["window_slots"]) else "exact",
"final_tree_verified": False, "final_focus_verified": False, "timings_ms": {}}
self.publish()
def publish(self): atomic(self.path, self.data, validate)
def update(self, **values):
require(set(values).issubset(FIELDS), "REPORT_SCHEMA")
self.data.update(values)
self.publish()

View file

@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:labfy:sway:session-v2:1",
"type": "object", "additionalProperties": false,
"required": ["schema", "version", "metadata", "outputs", "applications", "window_slots", "workspaces", "focus"],
"properties": {
"schema": {"const": "labfy.sway.session-v2"}, "version": {"const": 1, "type": "integer"},
"metadata": {
"type": "object", "additionalProperties": false, "required": ["created_ns", "sway_session", "reason"],
"properties": {"created_ns": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807}, "sway_session": {"$ref": "#/$defs/id"}, "reason": {"enum": ["manual", "logout", "reboot", "poweroff"]}}
},
"outputs": {"type": "array", "maxItems": 256, "items": {"type": "object", "additionalProperties": false, "required": ["name"], "properties": {"name": {"$ref": "#/$defs/place"}}}},
"applications": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/application"}},
"window_slots": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/slot"}},
"workspaces": {"type": "array", "maxItems": 256, "items": {"type": "object", "additionalProperties": false, "required": ["workspace", "output", "root"], "properties": {"workspace": {"$ref": "#/$defs/place"}, "output": {"$ref": "#/$defs/place"}, "root": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/tree"}]}}}},
"focus": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/id"}]}
},
"$defs": {
"id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$"},
"place": {"type": "string", "pattern": "^[A-Za-z0-9 _:-]{1,64}$"},
"application": {
"type": "object", "additionalProperties": false,
"required": ["application_id", "desktop_entry", "strategy", "expected_windows", "managed_by", "identity_provider"],
"properties": {
"application_id": {"$ref": "#/$defs/id"}, "desktop_entry": {"allOf": [{"$ref": "#/$defs/id"}, {"pattern": "\\.desktop$"}]},
"strategy": {"enum": ["managed-autostart", "single-window", "multi-instance", "terminal", "browser-self-restore", "unknown"]},
"expected_windows": {"type": "integer", "minimum": 1, "maximum": 256},
"managed_by": {"enum": [null, "autostart", "sway-autostart"]}, "identity_provider": {"enum": [null, "firefox-session-window-uuid"]}
}
},
"geometry": {
"type": "object", "additionalProperties": false, "required": ["x", "y", "width", "height"],
"properties": {"x": {"type": "integer", "minimum": -100000, "maximum": 100000}, "y": {"type": "integer", "minimum": -100000, "maximum": 100000}, "width": {"type": "integer", "minimum": 1, "maximum": 32768}, "height": {"type": "integer", "minimum": 1, "maximum": 32768}}
},
"evidence": {
"oneOf": [
{"type": "null"},
{"type": "object", "additionalProperties": false, "required": ["type", "id"], "properties": {"type": {"const": "firefox-session-window-uuid"}, "id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}}},
{"type": "object", "additionalProperties": false, "required": ["type", "id"], "properties": {"type": {"const": "application-singleton"}, "id": {"$ref": "#/$defs/id"}}}
]
},
"slot": {
"type": "object", "additionalProperties": false,
"required": ["slot_id", "application_id", "workspace", "tree_path", "state", "identity_requirement", "identity_evidence"],
"properties": {
"slot_id": {"$ref": "#/$defs/id"}, "application_id": {"$ref": "#/$defs/id"}, "workspace": {"$ref": "#/$defs/place"},
"tree_path": {"type": "array", "maxItems": 32, "items": {"type": "integer", "minimum": 0, "maximum": 31}},
"identity_requirement": {"enum": ["exact", "best-effort", "interchangeable"]}, "identity_evidence": {"$ref": "#/$defs/evidence"},
"state": {"type": "object", "additionalProperties": false, "required": ["floating", "geometry", "fullscreen", "scratchpad"], "properties": {"floating": {"type": "boolean"}, "geometry": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/geometry"}]}, "fullscreen": {"const": false}, "scratchpad": {"const": false}}}
}
},
"tree": {
"oneOf": [
{"type": "object", "additionalProperties": false, "required": ["slot_id"], "properties": {"slot_id": {"$ref": "#/$defs/id"}}},
{"type": "object", "additionalProperties": false, "required": ["layout", "children"], "properties": {"layout": {"enum": ["splith", "splitv", "tabbed", "stacked"]}, "children": {"type": "array", "minItems": 2, "maxItems": 32, "items": {"$ref": "#/$defs/tree"}}}}
]
}
}
}

View file

@ -0,0 +1,125 @@
"""Validation versionnée, exacte et référentielle avant toute mutation.
Les bornes contrôlent le travail local ; aucun ancien identifiant runtime n'est
accepté. Un état non implémenté est refusé, jamais silencieusement oublié.
"""
import re
from .errors import require
SCHEMA = "labfy.sway.session-v2"
LAYOUTS = {"splith", "splitv", "tabbed", "stacked"}
STRATEGIES = {"managed-autostart", "single-window", "multi-instance", "terminal", "browser-self-restore", "unknown"}
PROVIDER = "firefox-session-window-uuid"
UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\Z")
IDENT = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,95}\Z")
PLACE = re.compile(r"[A-Za-z0-9 _:-]{1,64}\Z")
def fields(value, names):
require(type(value) is dict and set(value) == set(names.split()), "SCHEMA_FIELDS")
def integer(value, low, high):
require(type(value) is int and low <= value <= high, "SCHEMA_INTEGER")
def text(value, pattern=IDENT):
require(type(value) is str and pattern.fullmatch(value), "SCHEMA_STRING")
def array(value, maximum=256):
require(type(value) is list and len(value) <= maximum, "SCHEMA_ARRAY")
def validate(data):
fields(data, "schema version metadata outputs applications window_slots workspaces focus")
require(data["schema"] == SCHEMA and type(data["version"]) is int and data["version"] == 1, "SCHEMA_VERSION")
fields(data["metadata"], "created_ns sway_session reason")
integer(data["metadata"]["created_ns"], 0, 2**63 - 1)
text(data["metadata"]["sway_session"])
require(data["metadata"]["reason"] in ("manual", "logout", "reboot", "poweroff"), "SCHEMA_REASON")
for key in ("outputs", "applications", "window_slots", "workspaces"): array(data[key])
outputs, apps, slots, workspaces = {}, {}, {}, {}
for o in data["outputs"]:
fields(o, "name")
text(o["name"], PLACE)
require(o["name"] not in outputs, "DUPLICATE_OUTPUT")
outputs[o["name"]] = o
for a in data["applications"]:
fields(a, "application_id desktop_entry strategy expected_windows managed_by identity_provider")
text(a["application_id"])
text(a["desktop_entry"])
require(a["desktop_entry"].endswith(".desktop"), "SCHEMA_DESKTOP_ENTRY")
require(type(a["strategy"]) is str and a["strategy"] in STRATEGIES, "SCHEMA_STRATEGY")
integer(a["expected_windows"], 1, 256)
require(a["managed_by"] in (None, "autostart", "sway-autostart"), "SCHEMA_MANAGED_BY")
require(a["identity_provider"] in (None, PROVIDER), "SCHEMA_PROVIDER")
require(a["application_id"] not in apps, "DUPLICATE_APPLICATION")
apps[a["application_id"]] = a
for s in data["window_slots"]:
fields(s, "slot_id application_id workspace tree_path state identity_requirement identity_evidence")
text(s["slot_id"])
text(s["application_id"])
text(s["workspace"], PLACE)
array(s["tree_path"], 32)
for i in s["tree_path"]: integer(i, 0, 31)
require(s["application_id"] in apps and s["slot_id"] not in slots, "SLOT_REFERENCE")
require(s["identity_requirement"] in ("exact", "best-effort", "interchangeable"), "SCHEMA_IDENTITY")
state = s["state"]
fields(state, "floating geometry fullscreen scratchpad")
require(type(state["floating"]) is bool, "SCHEMA_BOOLEAN")
require(type(state["fullscreen"]) is bool and state["fullscreen"] is False and state["scratchpad"] is False,
"CAPABILITY_UNSUPPORTED")
if state["floating"]:
fields(state["geometry"], "x y width height")
for k in ("x", "y"): integer(state["geometry"][k], -100000, 100000)
for k in ("width", "height"): integer(state["geometry"][k], 1, 32768)
require(s["tree_path"] == [], "FLOATING_TREE_PATH")
else: require(state["geometry"] is None, "TILING_GEOMETRY")
evidence = s["identity_evidence"]
if s["identity_requirement"] == "exact":
fields(evidence, "type id")
if evidence["type"] == PROVIDER:
require(type(evidence["id"]) is str and UUID.fullmatch(evidence["id"]), "INVALID_UUID")
require(apps[s["application_id"]]["identity_provider"] == PROVIDER, "PROVIDER_REFERENCE")
else:
require(evidence["type"] == "application-singleton" and evidence["id"] == s["application_id"] and
apps[s["application_id"]]["expected_windows"] == 1, "EXACT_IDENTITY_UNSUPPORTED")
else: require(evidence is None, "SCHEMA_IDENTITY")
slots[s["slot_id"]] = s
seen = set()
def tree(node, workspace, path=()):
require(len(path) <= 32, "TREE_DEPTH")
if node is None: return
require(type(node) is dict, "TREE_NODE")
if "slot_id" in node:
fields(node, "slot_id")
text(node["slot_id"])
require(node["slot_id"] in slots and node["slot_id"] not in seen, "TREE_SLOT_REFERENCE")
s = slots[node["slot_id"]]
require(not s["state"]["floating"] and s["workspace"] == workspace and s["tree_path"] == list(path), "TREE_SLOT_POSITION")
seen.add(node["slot_id"])
else:
fields(node, "layout children")
require(type(node["layout"]) is str and node["layout"] in LAYOUTS, "TREE_LAYOUT")
array(node["children"], 32)
require(len(node["children"]) >= 2 and all(c is not None for c in node["children"]), "TREE_ARITY")
for i, c in enumerate(node["children"]): tree(c, workspace, path + (i,))
for w in data["workspaces"]:
fields(w, "workspace output root")
text(w["workspace"], PLACE)
text(w["output"], PLACE)
require(w["workspace"] not in workspaces and w["output"] in outputs, "WORKSPACE_REFERENCE")
workspaces[w["workspace"]] = w
tree(w["root"], w["workspace"])
for s in slots.values():
require(s["workspace"] in workspaces and (s["state"]["floating"] or s["slot_id"] in seen), "UNPLACED_SLOT")
for name in workspaces:
require(any(s["workspace"] == name for s in slots.values()), "EMPTY_WORKSPACE_UNSUPPORTED")
for a in apps.values():
members = [s for s in slots.values() if s["application_id"] == a["application_id"]]
require(len(members) == a["expected_windows"], "APPLICATION_CARDINALITY")
uuids = [s["identity_evidence"]["id"] for s in members if s["identity_evidence"] and s["identity_evidence"]["type"] == PROVIDER]
require(len(uuids) == len(set(uuids)), "EXACT_WINDOW_IDENTITY_COLLISION")
require(data["focus"] is None or type(data["focus"]) is str and data["focus"] in slots, "FOCUS_REFERENCE")
return data

View file

@ -0,0 +1,27 @@
"""Affectation : exact reste exact ; best-effort est toujours signalé."""
from .errors import require
from .schema import PROVIDER
def assign(slots, nodes, identities=None):
require(len(nodes) >= len(slots), "APPLICATION_WINDOWS_INCOMPLETE")
require(len(nodes) <= len(slots), "UNEXPECTED_EXTRA_WINDOW")
result, remaining = {}, {n["id"] for n in nodes}
identities = identities or {}
require(len(set(identities.values())) == len(identities), "EXACT_WINDOW_IDENTITY_COLLISION")
for s in slots:
if s["identity_requirement"] != "exact": continue
evidence = s["identity_evidence"]
if evidence["type"] == PROVIDER:
require(evidence["id"] in identities, "EXACT_WINDOW_IDENTITY_MISSING")
target = identities[evidence["id"]]
else:
require(len(nodes) == 1, "EXACT_IDENTITY_UNSUPPORTED")
target = nodes[0]["id"]
require(target in remaining, "EXACT_WINDOW_IDENTITY_COLLISION")
result[s["slot_id"]] = target
remaining.remove(target)
# Ordre numérique runtime déterministe, jamais une preuve sémantique.
for s, target in zip(sorted((s for s in slots if s["slot_id"] not in result), key=lambda x: x["slot_id"]), sorted(remaining)):
result[s["slot_id"]] = target
return result

View file

@ -0,0 +1,78 @@
"""Capture minimale, sans nom de fenêtre, chemin de document ou argv."""
import time
from .errors import require
from .schema import validate, PROVIDER
from .ipc import walk
def capture(sway, catalog, provider, reason="manual"):
tree = sway.tree()
def topology(node):
# Les titres, tokens et métadonnées applicatives ne participent pas à
# cette empreinte éphémère. Détecter aussi déplacement/focus durant capture.
return (node.get("id"), node.get("layout"), node.get("name") if node.get("type") in ("workspace", "output") else None,
node.get("focused"), node.get("fullscreen_mode"), node.get("floating"), node.get("rect"),
tuple(topology(c) for c in node.get("nodes", [])), tuple(topology(c) for c in node.get("floating_nodes", [])))
nodes = [n for n in walk(tree) if n.get("app_id") or n.get("window")]
require(len(nodes) <= 256, "WINDOW_LIMIT")
specs = {}
for n in nodes:
require(not n.get("fullscreen_mode") and n.get("scratchpad_state", "none") == "none", "CAPABILITY_UNSUPPORTED")
specs[n["id"]] = catalog.classify(n)
apps = {}
identities = {}
for s in specs.values():
if s.application_id in apps: continue
members = [n for n in nodes if specs[n["id"]].application_id == s.application_id]
apps[s.application_id] = {"application_id": s.application_id, "desktop_entry": s.desktop_entry,
"strategy": s.strategy, "expected_windows": len(members),
"managed_by": (s.managed_by or "autostart") if s.strategy == "managed-autostart" else None, "identity_provider": s.provider}
if s.provider == PROVIDER:
require(provider is not None, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE")
identities.update({v: k for k, v in provider.capture(members).items()})
slots, workspaces = [], []
focused = None
def leaf(n, workspace, path, floating):
nonlocal focused
app = specs[n["id"]]
slot_id = "slot-" + str(len(slots) + 1)
if app.provider == PROVIDER:
require(n["id"] in identities, "EXACT_WINDOW_IDENTITY_MISSING")
requirement, evidence = "exact", {"type": PROVIDER, "id": identities[n["id"]]}
elif apps[app.application_id]["expected_windows"] == 1:
requirement, evidence = "exact", {"type": "application-singleton", "id": app.application_id}
else: requirement, evidence = "interchangeable", None
if n.get("focused"): focused = slot_id
geometry = {k: n["rect"][k] for k in ("x", "y", "width", "height")} if floating else None
slots.append({"slot_id": slot_id, "application_id": app.application_id, "workspace": workspace,
"tree_path": list(path), "state": {"floating": floating, "geometry": geometry, "fullscreen": False, "scratchpad": False},
"identity_requirement": requirement, "identity_evidence": evidence})
return {"slot_id": slot_id}
def convert(n, workspace, path=()):
if n.get("app_id") or n.get("window"): return leaf(n, workspace, path, False)
children = n.get("nodes", [])
if not children: return None
if len(children) == 1:
require(n["layout"] in ("splith", "splitv", "none"), "SINGLE_CHILD_LAYOUT_UNSUPPORTED")
return convert(children[0], workspace, path)
return {"layout": n["layout"], "children": [convert(c, workspace, path + (i,)) for i, c in enumerate(children)]}
outputs = []
for output in tree.get("nodes", []):
for ws in output.get("nodes", []):
if ws.get("type") != "workspace" or not (ws.get("nodes") or ws.get("floating_nodes")): continue
require(ws["name"] != "__i3_scratch", "CAPABILITY_UNSUPPORTED")
root = convert(ws, ws["name"])
for f in ws.get("floating_nodes", []):
children = [n for n in walk(f) if n.get("app_id") or n.get("window")]
require(len(children) == 1, "FLOATING_CONTAINER_UNSUPPORTED")
leaf(children[0], ws["name"], (), True)
workspaces.append({"workspace": ws["name"], "output": output["name"], "root": root})
if output["name"] not in outputs: outputs.append(output["name"])
# Relecture : aucun checkpoint hybride si une fenêtre apparaît/disparaît.
require({n["id"] for n in sway.windows()} == set(specs), "SNAPSHOT_CHANGED")
require(topology(sway.tree()) == topology(tree), "SNAPSHOT_CHANGED")
data = {"schema": "labfy.sway.session-v2", "version": 1,
"metadata": {"created_ns": time.time_ns(), "sway_session": sway.session, "reason": reason},
"outputs": [{"name": n} for n in outputs], "applications": list(apps.values()),
"window_slots": slots, "workspaces": workspaces, "focus": focused}
return validate(data)

View file

@ -0,0 +1,31 @@
"""Décision persistante par instance Sway, distincte du booléen de l'UI.
CONTRACT : restored exige le rapport final de la transaction reçue par l'UI.
Aucune décision ne ferme de fenêtre ni ne déclenche de restauration.
"""
from .errors import require
from .storage import private_dir, lock, read, atomic
from .observability import validate
def successful(report):
validate(report)
return (report['status'] == 'success' and report['final_tree_verified'] and report['final_focus_verified']
and report['slots_expected'] == report['slots_filled']
and report['applications_expected'] == report['applications_observed']
and report['anchors_created'] == report['anchors_cleaned']
and report['helpers_suspended'] == report['helpers_resumed'])
def acknowledge(runtime, session, choice, transaction_id=None):
require(choice in ('new', 'restored'), 'STARTUP_CHOICE_INVALID')
directory = private_dir(runtime / 'session-v2')
marker = directory / 'startup.json'
with lock(directory):
if choice == 'restored':
claimed = read(marker)
report = read(runtime / 'session-v2-restore-attempt.json', validate)
require(claimed.get('session') == session and transaction_id == report['transaction_id']
and successful(report), 'RESTORE_ACKNOWLEDGEMENT_FAILED')
atomic(marker, {'session': session, 'choice': choice, 'transaction_id': transaction_id if choice == 'restored' else None})
return {'status': 'acknowledged', 'choice': choice}

View file

@ -0,0 +1,103 @@
"""Stockage privé : validation, NOFOLLOW, publication durable et lock partagé.
CONTRACT : le namespace est possédé par l'UID courant et non accessible aux
autres utilisateurs. Les verrous restent valides via FD hérité par le guard.
"""
from contextlib import contextmanager
import fcntl
import json
import os
from pathlib import Path
import stat
import uuid
from .errors import Failure, require
MAX_JSON = 1024 * 1024
def pairs(items):
result = {}
for k, v in items:
require(k not in result, "JSON_DUPLICATE_KEY")
result[k] = v
return result
def loads(raw, limit=MAX_JSON):
require(len(raw) <= limit, "JSON_TOO_LARGE")
try: return json.loads(raw, object_pairs_hook=pairs, parse_constant=lambda _: (_ for _ in ()).throw(Failure("JSON_NUMBER")))
except (ValueError, RecursionError): raise Failure("JSON_INVALID") from None
def private_dir(path):
path = Path(path)
require(path.is_absolute() and ".." not in path.parts, "PATH_INVALID")
# Refuser les liens dans toute la chaîne ; ne chmod jamais un parent existant.
for part in [*reversed(path.parents), path]:
if not part.exists() and not part.is_symlink():
try: part.mkdir(mode=0o700)
except FileExistsError: pass
require(not part.is_symlink() and part.is_dir(), "PATH_SYMLINK")
st = path.stat()
require(st.st_uid == os.getuid() and stat.S_ISDIR(st.st_mode) and st.st_mode & 0o077 == 0, "DIRECTORY_PERMISSIONS")
return path
def read(path, validator=lambda x: x):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
st = os.fstat(fd)
require(stat.S_ISREG(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0, "FILE_PERMISSIONS")
require(st.st_size <= MAX_JSON, "JSON_TOO_LARGE")
with os.fdopen(fd, "rb", closefd=False) as stream: data = stream.read(MAX_JSON + 1)
return validator(loads(data))
finally: os.close(fd)
def atomic(path, data, validator=lambda x: x):
path = Path(path)
directory = private_dir(path.parent)
validator(data)
raw = (json.dumps(data, separators=(",", ":"), allow_nan=False) + "\n").encode()
require(len(raw) <= MAX_JSON, "JSON_TOO_LARGE")
temporary = directory / (".tmp-" + uuid.uuid4().hex)
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
require(read(temporary, validator) == data, "PUBLICATION_REREAD")
require(not path.is_symlink(), "PATH_SYMLINK")
os.replace(temporary, path)
sync_dir(directory)
finally:
if temporary.exists(): temporary.unlink()
def sync_dir(path):
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try: os.fsync(fd)
finally: os.close(fd)
@contextmanager
def lock(directory, name="transaction.lock"):
directory = private_dir(directory)
fd = os.open(directory / name, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
st = os.fstat(fd)
require(stat.S_ISREG(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0, "LOCK_PERMISSIONS")
try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: raise Failure("TRANSACTION_BUSY") from None
yield fd
finally: os.close(fd)
def paths():
home = Path.home()
state = Path(os.environ.get("XDG_STATE_HOME", str(home / ".local/state")))
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", ""))
require(state.is_absolute() and runtime.is_absolute(), "XDG_PATH_INVALID")
private_dir(runtime)
return private_dir(state / "labfy-sway/session-v2"), private_dir(runtime / "labfy-sway")

View file

@ -0,0 +1,21 @@
.pragma library
// CONTRACT : aucun exit 0 seul ne vaut succès de restauration. L'UI attend
// toutes les postconditions et, au startup, l'ack durable de cette transaction.
function restoreSucceeded(v) {
return !!v && v.schema === "labfy.sway.session-v2-restore-attempt"
&& v.version === 1 && v.status === "success"
&& v.final_tree_verified === true && v.final_focus_verified === true
&& Number.isInteger(v.slots_expected) && v.slots_expected === v.slots_filled
&& Number.isInteger(v.applications_expected) && v.applications_expected === v.applications_observed
&& Number.isInteger(v.anchors_created) && v.anchors_created === v.anchors_cleaned
&& Number.isInteger(v.helpers_suspended) && v.helpers_suspended === v.helpers_resumed
&& /^[a-f0-9]{32}$/.test(v.transaction_id)
&& ["exact", "interchangeable", "best-effort"].indexOf(v.identity_level) >= 0;
}
function details(v) {
// Aucun stdout/stderr libre dans le parcours utilisateur ou dans journald.
function code(x) { return typeof x === "string" && /^[A-Za-z_-]{1,64}$/.test(x) ? x : "UNKNOWN"; }
return "Phase : " + code(v ? v.phase : null) + "\nRaison : " + code(v ? v.reason : null);
}

View file

@ -0,0 +1,62 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
QtObject {
id: service
readonly property string configHome: Quickshell.env("XDG_CONFIG_HOME") || ((Quickshell.env("HOME") || "") + "/.config")
readonly property string backend: configHome + "/quickshell/labfy-sway/session-v2.py"
property string operation: ""
property var client: null
property bool startupChooserVisible: false
readonly property bool busy: operation !== "" || backendProcess.running
signal completed(var owner, string kind, var value, bool okay)
// Diagnostic lecture seule ; aucun endpoint IPC ne peut cliquer Restore.
property IpcHandler diagnostics: IpcHandler {
target: "sessionV2"
function status(): string {
return JSON.stringify({backend: "v2", busy: service.busy,
operation: service.operation, startupChooserCount: service.startupChooserVisible ? 1 : 0});
}
}
// WHY : toutes les barres, le chooser et le power menu partagent un owner.
// INVARIANT : un unique Process sérialise les commandes V2 de QuickShell.
function run(kind, name, extra, owner) {
if (busy) return false;
const allowed = ["startup-status", "ack-new", "ack-restored", "apply-last", "apply", "list", "show", "save", "delete", "plan", "restore-status", "checkpoint-last"];
if (allowed.indexOf(kind) < 0) return false;
const argv = ["python3", "-B", backend];
if (kind === "ack-new" || kind === "ack-restored") {
argv.push("acknowledge-startup", "--execute", "--choice", kind === "ack-new" ? "new" : "restored");
if (kind === "ack-restored") argv.push("--transaction-id", extra);
} else {
argv.push(kind);
if (["apply", "save", "delete", "show", "plan"].indexOf(kind) >= 0) argv.push(name);
if (["startup-status", "apply-last", "apply", "save", "delete", "checkpoint-last"].indexOf(kind) >= 0) argv.push("--execute");
if (kind === "checkpoint-last") argv.push("--reason", extra);
}
client = owner;
operation = kind;
backendProcess.command = argv;
backendProcess.running = true;
return true;
}
property Process backendProcess: Process {
running: false
stdout: StdioCollector { id: collected; waitForEnd: true }
stderr: StdioCollector { waitForEnd: true }
onExited: (exitCode, exitStatus) => {
let value = null;
try { value = JSON.parse(collected.text); } catch (_) {}
const owner = service.client;
const kind = service.operation;
service.operation = "";
service.client = null;
service.completed(owner, kind, value, exitCode === 0 && exitStatus === 0 && value !== null);
}
}
}

View file

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

View file

@ -32,6 +32,11 @@ ShellRoot {
Variants {
model: Quickshell.screens
Bar { notificationService: rootNotificationService }
Bar {
notificationService: rootNotificationService
// Une seule barre possède le chooser, même en configuration multi-écran.
startupHost: Quickshell.screens.length > 0
&& modelData === Quickshell.screens[0]
}
}
}

View file

@ -0,0 +1,140 @@
import QtQuick
import QtTest
import "../../quickshell/.config/quickshell/labfy-sway/controlcenter"
TestCase {
name: "SessionExitGate"
Component {
id: gateComponent
SessionExitGate {
actions: [
{ id: "lock", label: "Verrouiller", command: ["swaylock"] },
{ id: "suspend", label: "Veille", command: ["systemctl", "suspend"] },
{ id: "logout", label: "Déconnexion", confirmTitle: "Se déconnecter ?",
description: "La session SwayFX en cours sera fermée.", checkpointReason: "logout",
command: ["uwsm", "stop"] },
{ id: "reboot", label: "Redémarrer", checkpointReason: "reboot",
command: ["systemctl", "reboot"] },
{ id: "poweroff", label: "Éteindre", checkpointReason: "poweroff",
command: ["systemctl", "poweroff"] }
]
}
}
Component {
id: signalSpyComponent
SignalSpy {}
}
function createGate() {
return createTemporaryObject(gateComponent, this);
}
function createSpy(target, signalName) {
return createTemporaryObject(signalSpyComponent, this, {
target: target,
signalName: signalName
});
}
function test_logout_success_sequences_checkpoint_before_command() {
const gate = createGate();
const checkpointSpy = createSpy(gate, "checkpointRequested");
const commandSpy = createSpy(gate, "commandRequested");
gate.requestAction("logout");
compare(gate.pendingAction.id, "logout");
compare(gate.pendingAction.label, "Déconnexion");
compare(gate.pendingAction.command, ["uwsm", "stop"]);
compare(gate.pendingAction.confirmTitle, "Se déconnecter ?");
compare(gate.pendingAction.description, "La session SwayFX en cours sera fermée.");
gate.confirmAction("logout");
compare(checkpointSpy.count, 1);
compare(checkpointSpy.signalArguments[0][0], "logout");
compare(commandSpy.count, 0);
verify(gate.busy);
compare(gate.checkpointAction.id, "logout");
gate.confirmAction("logout");
compare(checkpointSpy.count, 1);
gate.pendingAction = null;
gate.checkpointFinished(0, 0, '{"status":"success"}', "");
compare(commandSpy.count, 1);
compare(commandSpy.signalArguments[0][0], ["uwsm", "stop"]);
verify(!gate.busy);
}
function test_checkpoint_failure_blocks_command_and_preserves_escape() {
const gate = createGate();
const failureSpy = createSpy(gate, "checkpointFailed");
const commandSpy = createSpy(gate, "commandRequested");
gate.requestAction("logout");
gate.confirmAction("logout");
gate.checkpointFinished(1, 0, "", "capture failed");
compare(commandSpy.count, 0);
compare(failureSpy.count, 1);
compare(gate.pendingAction.id, "logout");
compare(gate.errorMessage, "capture failed");
gate.quitWithoutCheckpoint();
compare(commandSpy.count, 1);
compare(commandSpy.signalArguments[0][0], ["uwsm", "stop"]);
}
function test_cancel_after_failure_never_dispatches() {
const gate = createGate();
const commandSpy = createSpy(gate, "commandRequested");
gate.requestAction("logout");
gate.confirmAction("logout");
gate.checkpointFinished(1, 0, "", "capture failed");
gate.cancel();
compare(commandSpy.count, 0);
compare(gate.pendingAction, null);
}
function test_reboot_and_poweroff_sequence() {
const expected = {
reboot: ["systemctl", "reboot"],
poweroff: ["systemctl", "poweroff"]
};
for (const actionId in expected) {
const gate = createGate();
const checkpointSpy = createSpy(gate, "checkpointRequested");
const commandSpy = createSpy(gate, "commandRequested");
gate.requestAction(actionId);
gate.confirmAction(actionId);
compare(checkpointSpy.count, 1);
compare(checkpointSpy.signalArguments[0][0], actionId);
compare(commandSpy.count, 0);
gate.checkpointFinished(0, 0, '{"status":"success"}', "");
compare(commandSpy.count, 1);
compare(commandSpy.signalArguments[0][0], expected[actionId]);
}
}
function test_reboot_and_poweroff_failure_never_dispatch() {
for (const actionId of ["reboot", "poweroff"]) {
const gate = createGate();
const commandSpy = createSpy(gate, "commandRequested");
gate.requestAction(actionId);
gate.confirmAction(actionId);
gate.checkpointFinished(1, 0, "", "capture failed");
compare(commandSpy.count, 0);
compare(gate.pendingAction.id, actionId);
}
}
function test_lock_and_suspend_keep_existing_contract() {
const gate = createGate();
const checkpointSpy = createSpy(gate, "checkpointRequested");
const commandSpy = createSpy(gate, "commandRequested");
gate.requestAction("lock");
compare(commandSpy.count, 1);
compare(commandSpy.signalArguments[0][0], ["swaylock"]);
compare(checkpointSpy.count, 0);
gate.requestAction("suspend");
compare(gate.pendingAction.id, "suspend");
gate.confirmAction("suspend");
compare(commandSpy.count, 2);
compare(commandSpy.signalArguments[1][0], ["systemctl", "suspend"]);
compare(checkpointSpy.count, 0);
}
}

View file

@ -0,0 +1,51 @@
"""Fixtures isolées ; imports production réels, sockets headless possédés."""
import copy
import os
from pathlib import Path
import sys
import time
REPO = Path(__file__).resolve().parents[2]
BACKEND = Path(os.environ.get("LABFY_SESSION_V2_BACKEND", str(REPO / "quickshell/.config/quickshell/labfy-sway")))
sys.path.insert(0, str(BACKEND))
sys.path.insert(0, str(REPO / "tests/session_v2/fixtures"))
os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
from session_v2.applications import Catalog, Spec
from session_v2.schema import validate
def fixture(shape=None, workspace="TEST_ONLY_TARGET"):
shape = shape or {"splitv": ["A", {"splith": ["B", "C"]}]}
slots, apps = [], []
def tree(n, path=()):
if isinstance(n, str):
app = "app-" + n
slots.append({"slot_id": n, "application_id": app, "workspace": workspace,
"tree_path": list(path), "state": {"floating": False, "geometry": None, "fullscreen": False, "scratchpad": False},
"identity_requirement": "exact", "identity_evidence": {"type": "application-singleton", "id": app}})
apps.append({"application_id": app, "desktop_entry": app + ".desktop", "strategy": "managed-autostart", "expected_windows": 1,
"managed_by": "autostart", "identity_provider": None})
return {"slot_id": n}
layout, children = next(iter(n.items()))
return {"layout": layout, "children": [tree(c, path + (i,)) for i, c in enumerate(children)]}
root = tree(shape)
return validate({"schema": "labfy.sway.session-v2", "version": 1,
"metadata": {"created_ns": time.time_ns(), "sway_session": "test", "reason": "manual"},
"outputs": [{"name": "HEADLESS-1"}], "applications": apps, "window_slots": slots,
"workspaces": [{"workspace": workspace, "output": "HEADLESS-1", "root": root}], "focus": slots[0]["slot_id"]})
def catalog(data):
return Catalog(tuple(Spec(a["application_id"], a["desktop_entry"], ("TEST_ONLY_" + a["application_id"],),
a["strategy"], (), a["identity_provider"]) for a in data["applications"]))
def build(directory):
import subprocess
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "gtk+-3.0", "gdk-wayland-3.0"], text=True).split()
binary = Path(directory) / "labfy-v2-anchor"
subprocess.run(["cc", "-std=c17", "-Wall", "-Wextra", "-Werror", str(BACKEND / "session_v2/anchor_helper.c"), "-o", str(binary), *flags], check=True)
return binary
# Les résultats TEST_ONLY restent locaux et ne sont jamais publiés avec les sources.
EVIDENCE = Path(os.environ.get("LABFY_SESSION_V2_EVIDENCE", str(REPO / "build/session-v2-tests")))

View file

@ -0,0 +1,79 @@
"""SIGKILL réels de l'executor production, jamais du guard ni d'un utilisateur."""
import json
import os
import signal
import time
from common import *
from lab import Lab, Surfaces, build_helper, until
from session_v2.storage import atomic, private_dir, read
from session_v2.executor import Executor
from session_v2.ipc import Sway
def worker(path):
config = read(path)
def progress(phase, count):
if [phase, count] == config["point"]:
atomic(Path(config["runtime"]) / "kill-ready.json", {"phase": phase, "count": count})
while True: time.sleep(1)
Executor(Sway(config["socket"]), catalog(config["data"]), None, config["runtime"], config["binary"],
os.environ.copy(), progress).apply(config["data"], True)
def main():
cases = []
with Lab() as lab:
binary, helper_binary = build(lab.directory), build_helper(lab.directory)
for i, point in enumerate((("anchor", 1), ("all-anchors", 4), ("swap", 1), ("swap", 3),
("before-cleanup", 0), ("cleanup", 1), ("after-resume", 4), ("normal", 0), ("initial-stopped", 0))):
runtime = private_dir(lab.directory / f"crash-{i}")
data = fixture({"splitv": [{"splith": ["A", "B"]}, {"splith": ["C", "D"]}]})
apps = Surfaces(lab, helper_binary)
lab.command('workspace TEST_ONLY_SOURCE')
ids = [apps.create("TEST_ONLY_" + a["application_id"])["id"] for a in data["applications"]]
foreign = apps.create("labfy-v2-anchor-" + "f" * 32 + "-foreign")["id"]
helper = lab.spawn(["autotiling"])
time.sleep(.15)
helper_fd = os.pidfd_open(helper.pid)
if point[0] == "initial-stopped": signal.pidfd_send_signal(helper_fd, signal.SIGSTOP)
config = {"socket": lab.socket, "runtime": str(runtime), "binary": str(binary), "data": data, "point": list(point)}
atomic(runtime / "config.json", config)
process = lab.spawn([sys.executable, "-B", str(Path(__file__).resolve()), "--worker", str(runtime / "config.json")])
killed = None
try:
if point[0] not in ("normal", "initial-stopped"):
until(lambda: (runtime / "kill-ready.json").exists() or process.poll() is not None, 15)
assert process.poll() is None
killed = time.monotonic()
process.kill()
process.wait(timeout=25)
report = runtime / "session-v2-restore-attempt.json"
until(lambda: report.exists() and read(report)["status"] != "running", 10)
if killed: until(lambda: read(report)["phase"] == "recovered", 10)
result = read(report)
state = Path(f"/proc/{helper.pid}/status").read_text().split("State:\t", 1)[1][0]
windows = lab.windows()
row = {"point": list(point), "returncode": process.returncode, "report": result,
"kill_to_recovery_ms": (time.monotonic() - killed) * 1000 if killed else None,
"apps_preserved": set(ids).issubset(n["id"] for n in windows),
"foreign_preserved": any(n["id"] == foreign for n in windows),
"state_preserved": state == "T" if point[0] == "initial-stopped" else state not in ("T", "t"),
"anchors_remaining": sum(n.get("app_id", "").startswith("labfy-v2-anchor-") and n["id"] != foreign for n in windows)}
assert row["apps_preserved"] and row["foreign_preserved"] and row["state_preserved"] and row["anchors_remaining"] == 0, row
assert result["status"] == ("interrupted" if killed else "success"), row
if killed: assert row["kill_to_recovery_ms"] < 3000
cases.append(row)
print(point, "PASS", flush=True)
finally:
if process.poll() is None: process.kill(); process.wait()
signal.pidfd_send_signal(helper_fd, signal.SIGCONT)
helper.terminate(); helper.wait(timeout=3)
os.close(helper_fd)
apps.close()
out = private_dir(EVIDENCE)
atomic(out / "crash.json", {"cases": cases})
if __name__ == "__main__":
if "--worker" in sys.argv: worker(sys.argv[2])
else: main()

View file

@ -0,0 +1,43 @@
"""Échecs runtime du moteur production : conservation des apps et reprise."""
import time
from common import *
from lab import Lab, Surfaces, build_helper
from session_v2.ipc import Sway
from session_v2.executor import Executor
from session_v2.errors import Failure
from session_v2.storage import private_dir, atomic, read
def main():
rows = []
with Lab() as lab:
binary, appbin = build(lab.directory), build_helper(lab.directory)
runtime = private_dir(lab.directory / "labfy-sway")
for mode, reason in (("missing", "APPLICATION_WINDOWS_INCOMPLETE"), ("extra", "UNEXPECTED_EXTRA_WINDOW"), ("occupied", "WORKSPACE_OCCUPIED")):
data = fixture()
apps = Surfaces(lab, appbin)
lab.command('workspace TEST_ONLY_SOURCE')
members = data["applications"][:-1] if mode == "missing" else data["applications"]
ids = [apps.create("TEST_ONLY_" + a["application_id"])["id"] for a in members]
if mode == "extra": ids.append(apps.create("TEST_ONLY_app-A")["id"])
if mode == "occupied":
lab.command('workspace TEST_ONLY_TARGET')
ids.append(apps.create("TEST_ONLY_FOREIGN")["id"])
helper = lab.spawn(["autotiling"]); time.sleep(.15)
try:
try: Executor(Sway(lab.socket), catalog(data), None, runtime, binary, lab.env).apply(data, True, timeout=.3)
except Failure as exc: assert exc.code == reason, (exc.code, reason)
else: raise AssertionError(mode)
report = read(runtime / "session-v2-restore-attempt.json")
assert report["status"] == "failed" and report["reason"] == reason, report
assert set(ids).issubset(n["id"] for n in lab.windows())
assert not any(n.get("app_id", "").startswith("labfy-v2-anchor-") for n in lab.windows())
assert Path(f"/proc/{helper.pid}/status").read_text().split("State:\t", 1)[1][0] not in ("T", "t")
rows.append({"case": mode, "report": report, "apps_preserved": True, "anchors_remaining": 0, "helper_recovered": True})
finally:
helper.terminate(); helper.wait(timeout=3); apps.close()
atomic(private_dir(EVIDENCE) / "failures.json", {"cases": rows})
print(len(rows), "runtime failures PASS")
if __name__ == "__main__": main()

View file

@ -0,0 +1,6 @@
# TEST_ONLY : aucun include, autostart, service ou accès aux sorties physiques.
xwayland disable
output HEADLESS-1 mode 1280x720
focus_follows_mouse no
default_border pixel 1
seat seat0 fallback true

View file

@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Expériences TEST_ONLY ; jamais importé ni lancé par le code de production.
CONTRAT : IPC exclusivement vers le socket du compositeur enfant headless.
Les seuls processus arrêtés sont des enfants détenus par cette expérience.
Les arbres conservés ne contiennent que des fenêtres synthétiques.
"""
import json
import os
from pathlib import Path
import signal
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parent
def run(argv, **kwargs):
return subprocess.run(argv, text=True, capture_output=True, timeout=10, **kwargs)
def walk(node):
yield node
for child in node.get("nodes", []) + node.get("floating_nodes", []):
yield from walk(child)
class Lab:
def __init__(self):
self.directory = Path(tempfile.mkdtemp(prefix="labfy-session-v2-TEST_ONLY-"))
self.children = []
self.logs = []
self.socket = None
self.env = dict(os.environ)
for key in ("SWAYSOCK", "I3SOCK", "DISPLAY", "WAYLAND_DISPLAY"):
self.env.pop(key, None)
self.env.update(XDG_RUNTIME_DIR=str(self.directory), TMPDIR=str(self.directory), WLR_BACKENDS="headless",
WLR_HEADLESS_OUTPUTS="1", WLR_RENDERER="gles2",
WLR_RENDERER_ALLOW_SOFTWARE="1", LIBGL_ALWAYS_SOFTWARE="true")
def start(self, argv):
log = (self.directory / f"child-{len(self.children)}.log").open("w")
self.logs.append(log)
proc = subprocess.Popen(argv, env=self.env, stdout=log, stderr=log,
start_new_session=True)
self.children.append(proc)
return proc
def __enter__(self):
try:
self.sway = self.start(["sway", "-c", str(ROOT / "headless.conf")])
end = time.monotonic() + 15
while time.monotonic() < end:
sockets = list(self.directory.glob("sway-ipc.*.sock"))
displays = [p for p in self.directory.glob("wayland-*") if p.is_socket()]
if sockets and displays:
self.socket = str(sockets[0])
self.env.update(SWAYSOCK=self.socket, WAYLAND_DISPLAY=displays[0].name)
self.tree()
return self
if self.sway.poll() is not None:
raise RuntimeError((self.directory / "child-0.log").read_text()[-6000:])
time.sleep(.1)
raise RuntimeError("Délai de démarrage headless dépassé")
except BaseException:
self.__exit__(None, None, None)
raise
def ipc(self, command=None, kind=None):
assert self.socket and self.socket.startswith(str(self.directory) + "/")
args = ["swaymsg", "-s", self.socket, "-r"]
args += ["-t", kind] if kind else [command]
result = run(args, env=self.env)
return json.loads(result.stdout)
def command(self, text):
reply = self.ipc(text)
if not all(item.get("success") for item in reply):
raise RuntimeError((text, reply))
return reply
def tree(self):
return self.ipc(kind="get_tree")
def windows(self):
return [n for n in walk(self.tree()) if n.get("app_id")]
def kitty(self, identity):
before = {n["id"] for n in self.windows()}
self.start(["kitty", "--config", "NONE", "--class", identity,
"--title", "TEST_ONLY", "-o", "linux_display_server=wayland",
"-o", "confirm_os_window_close=0", "/usr/bin/sleep", "180"])
end = time.monotonic() + 10
while time.monotonic() < end:
found = [n for n in self.windows() if n["id"] not in before and n["app_id"] == identity]
if len(found) == 1:
return found[0]["id"]
time.sleep(.1)
raise RuntimeError("Fenêtre Kitty TEST_ONLY absente")
def empty(self, workspace):
old = self.windows()
for n in old:
assert n["app_id"].startswith("TEST_ONLY")
self.command(f'[con_id={n["id"]}] kill')
self.wait_absent([n["id"] for n in old])
self.command(f'workspace "{workspace}"')
time.sleep(.15)
def wait_absent(self, ids):
# Une réponse IPC à kill ne prouve pas encore le retrait du top-level.
end = time.monotonic() + 5
while time.monotonic() < end:
if not set(ids).intersection(n["id"] for n in self.windows()):
return
time.sleep(.05)
raise RuntimeError("Fenêtre TEST_ONLY encore présente après fermeture")
def shape(self, workspace):
def node(n):
if n.get("app_id"):
return n["app_id"]
children = [node(c) for c in n.get("nodes", [])]
if len(children) == 1:
return children[0]
return {n.get("layout"): children}
found = [n for n in walk(self.tree()) if n.get("type") == "workspace" and n.get("name") == workspace]
return node(found[0]) if found else None
def __exit__(self, *_):
# INVARIANT : même après exception, aucun helper détenu ne reste stoppé.
for proc in reversed(self.children):
if proc.poll() is None:
os.killpg(proc.pid, signal.SIGCONT)
os.killpg(proc.pid, signal.SIGTERM)
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait(timeout=3)
for log in self.logs:
log.close()

View file

@ -0,0 +1,147 @@
"""Laboratoire headless Phase 2 ; sockets explicites, enfants possédés et bornés."""
import json
import os
from pathlib import Path
import signal
import socket
import struct
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parent
from headless import Lab as BaseLab, walk
def until(function, timeout=5):
end = time.monotonic() + timeout
while time.monotonic() < end:
value = function()
if value:
return value
time.sleep(.02)
raise TimeoutError("TEST_ONLY_TIMEOUT")
class IPC:
def __init__(self, path):
self.socket = path
if not path.startswith("/tmp/labfy-session-v2-TEST_ONLY-"):
raise ValueError("NON_TEST_SOCKET_FORBIDDEN")
def ipc(self, command=None, kind=None):
message_type = {"get_tree": 4, "get_outputs": 3, "get_workspaces": 1, "get_version": 7}.get(kind, 0)
payload = (command or "").encode()
with socket.socket(socket.AF_UNIX) as connection:
connection.settimeout(3)
connection.connect(self.socket)
connection.sendall(struct.pack("<6sII", b"i3-ipc", len(payload), message_type) + payload)
def read(n):
data = bytearray()
while len(data) < n:
part = connection.recv(n - len(data))
if not part: raise RuntimeError("IPC_CLOSED")
data.extend(part)
return bytes(data)
magic, size, _ = struct.unpack("<6sII", read(14))
if magic != b"i3-ipc" or size > 4 * 1024 * 1024:
raise ValueError("IPC_BOUNDS")
return json.loads(read(size))
def command(self, command):
result = self.ipc(command)
if not result or not all(x.get("success") for x in result):
raise RuntimeError((command, result))
return result
def tree(self): return self.ipc(kind="get_tree")
def windows(self): return [n for n in walk(self.tree()) if n.get("app_id")]
class Lab(BaseLab):
def ipc(self, command=None, kind=None):
return IPC(self.socket).ipc(command, kind)
def spawn(self, argv, stdin=None, env=None, pass_fds=()):
log = (self.directory / f"p2-{len(self.children)}.log").open("w")
self.logs.append(log)
process = subprocess.Popen(argv, stdin=stdin, stdout=log, stderr=log,
env=env or self.env, start_new_session=True, pass_fds=pass_fds)
self.children.append(process)
return process
class Surfaces:
def __init__(self, lab, binary):
self.lab = lab
self.proc = lab.spawn([str(binary)], stdin=subprocess.PIPE)
self.pidfd = os.pidfd_open(self.proc.pid)
self.ids = set()
def create(self, app_id):
before = {n["id"] for n in self.lab.windows()}
self.proc.stdin.write(f"create {app_id}\n".encode())
self.proc.stdin.flush()
found = until(lambda: [n for n in self.lab.windows() if n["id"] not in before
and n["pid"] == self.proc.pid and n["app_id"] == app_id])
if len(found) != 1: raise ValueError("SURFACE_AMBIGUOUS")
self.ids.add(found[0]["id"])
return found[0]
def close(self):
if self.proc.poll() is None:
self.proc.stdin.write(b"quit\n")
self.proc.stdin.flush()
self.proc.wait(timeout=5)
until(lambda: not any(n["id"] in self.ids for n in self.lab.windows()))
self.proc.stdin.close()
os.close(self.pidfd)
def build_helper(directory):
flags = subprocess.run(["pkg-config", "--cflags", "--libs", "gtk+-3.0", "gdk-wayland-3.0"],
text=True, capture_output=True, check=True).stdout.split()
binary = directory / "TEST_ONLY_surfaces"
subprocess.run(["cc", "-std=c17", "-Wall", "-Wextra", "-Werror", str(ROOT / "surfaces.c"), "-o", str(binary), *flags], check=True)
return binary
def workspace_node(tree, name):
return next((n for n in walk(tree) if n.get("type") == "workspace" and n.get("name") == name), None)
def location(tree, identity):
def search(node, workspace=None, path=()):
if node.get("type") == "workspace":
workspace, path = node["name"], ()
if node["id"] == identity:
return workspace, path
for i, child in enumerate(node.get("nodes", [])):
found = search(child, workspace, path + (i,))
if found: return found
return search(tree)
def shape(tree, name, mapping):
node = workspace_node(tree, name)
if not node: return None
def normalize(n):
if n.get("app_id"):
return mapping.get(n["id"], "UNEXPECTED:" + n["app_id"])
children = [normalize(c) for c in n.get("nodes", [])]
if len(children) == 1 and n["layout"] in ("splith", "splitv", "none"):
return children[0]
return {n["layout"]: children}
return normalize(node)
def atomic_json(path, value):
path = Path(path)
temporary = path.with_suffix(".tmp")
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as stream:
json.dump(value, stream, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)

View file

@ -0,0 +1,45 @@
/* TEST_ONLY : un processus GTK, plusieurs top-levels Wayland adressables.
* WHY : éviter N processus terminaux pour N ancrages.
* CONTRACT : stdin privé du harness, commandes create/quit uniquement.
* INVARIANT : aucune application, configuration ou fenêtre utilisateur ouverte.
*/
#include <gtk/gtk.h>
#include <gdk/gdkwayland.h>
#include <stdio.h>
#include <string.h>
static gboolean input(GIOChannel *channel, GIOCondition condition, gpointer data) {
(void)channel;
(void)data;
if (condition & (G_IO_HUP | G_IO_ERR)) { gtk_main_quit(); return FALSE; }
char buffer[256];
if (!fgets(buffer, sizeof buffer, stdin)) { gtk_main_quit(); return FALSE; }
if (strcmp(buffer, "quit\n") == 0) { gtk_main_quit(); return FALSE; }
char identity[181];
if (sscanf(buffer, "create %180s", identity) != 1 ||
!g_regex_match_simple("^[A-Za-z0-9_.-]{1,180}$", identity, 0, 0)) {
fprintf(stderr, "Commande TEST_ONLY invalide\n"); return TRUE;
}
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(window), "TEST_ONLY");
gtk_window_set_default_size(GTK_WINDOW(window), 120, 80);
gtk_widget_set_size_request(window, 1, 1);
gtk_widget_realize(window);
gtk_widget_show_all(window);
/* GTK publie son app_id lors du mapping ; l'identité de test suit ce mapping. */
gdk_wayland_window_set_application_id(gtk_widget_get_window(window), identity);
gdk_display_flush(gtk_widget_get_display(window));
printf("created %s\n", identity); fflush(stdout);
return TRUE;
}
int main(int argc, char **argv) {
g_set_prgname("TEST_ONLY_SURFACES");
gtk_init(&argc, &argv);
setvbuf(stdin, NULL, _IONBF, 0);
GIOChannel *channel = g_io_channel_unix_new(0);
g_io_add_watch(channel, G_IO_IN | G_IO_HUP | G_IO_ERR, input, NULL);
gtk_main();
g_io_channel_unref(channel);
return 0;
}

View file

@ -0,0 +1,33 @@
"""Placement Limusic synthétique : vrai backend déployé, Sway TEST_ONLY."""
from unittest.mock import patch
from common import *
from lab import Lab, Surfaces, build_helper
from session_v2.applications import Catalog
from session_v2.executor import Executor
from session_v2.ipc import Sway
from session_v2.storage import private_dir, atomic
from session_v2.snapshot import capture
def main():
with Lab() as lab:
apps = Surfaces(lab, build_helper(lab.directory))
lab.command('workspace TEST_ONLY_SOURCE')
apps.create('limusic-app')
data = fixture('A', 'TEST_ONLY_LIMUSIC')
data['applications'] = [{'application_id': 'limusic', 'desktop_entry': 'limusic.desktop',
'strategy': 'managed-autostart', 'expected_windows': 1, 'managed_by': 'sway-autostart', 'identity_provider': None}]
data['window_slots'][0].update(application_id='limusic', identity_evidence={'type': 'application-singleton', 'id': 'limusic'})
try:
# Toute tentative de lancement serait une régression de propriété.
with patch('session_v2.executor.launch', side_effect=lambda spec, count, env: [] if count == 0 else (_ for _ in ()).throw(AssertionError('DUPLICATE_LIMUSIC'))):
result = Executor(Sway(lab.socket), Catalog(), None, private_dir(lab.directory / 'runtime'), build(lab.directory), lab.env).apply(data, True)
snap = capture(Sway(lab.socket), Catalog(), None)
assert snap['applications'][0]['managed_by'] == 'sway-autostart'
assert result['status'] == 'success'
atomic(private_dir(EVIDENCE) / 'limusic-test-only.json', {'result': result, 'launches': 0, 'capture_owner': 'sway-autostart'})
print('LIMUSIC_TEST_ONLY=PASS')
finally: apps.close()
if __name__ == '__main__': main()

View file

@ -0,0 +1,109 @@
"""Recette UI sans desktop utilisateur : compositor privé et CLI simulée.
Les composants QML V2 sont les vrais candidats ; seul le backend/Theme sont
des fixtures. Aucun appel logout ou restauration utilisateur n'est possible.
"""
import json
import shutil
import subprocess
from common import *
from lab import Lab, Surfaces, build_helper
from session_v2.storage import atomic, private_dir, read
from session_v2.checkpoint import Store
SOURCE = REPO / 'quickshell/.config/quickshell/labfy-sway'
FAKE = '''import json, os, sys
from pathlib import Path
cmd = sys.argv[1]
log = Path(os.environ['LABFY_UI_LOG'])
with log.open('a') as s: s.write(cmd + '\\n')
mode = os.environ['LABFY_UI_MODE']
if cmd == 'startup-status': r = {'eligible': mode != 'claimed', 'claimed': mode != 'claimed', 'windows': 3, 'workspaces': 2}
elif cmd == 'apply-last':
r = {'schema':'labfy.sway.session-v2-restore-attempt', 'version':1, 'status':'success', 'transaction_id':'a'*32, 'identity_level':'exact', 'final_tree_verified':mode != 'invalid', 'final_focus_verified':True, 'slots_expected':3, 'slots_filled':3, 'applications_expected':2, 'applications_observed':2, 'anchors_created':3, 'anchors_cleaned':3, 'helpers_suspended':1, 'helpers_resumed':1}
if mode == 'failure': r = {'status':'failed', 'reason':'APPLICATION_WINDOWS_INCOMPLETE'}
elif cmd == 'acknowledge-startup': r = {'status':'acknowledged', 'choice':sys.argv[sys.argv.index('--choice')+1]}
elif cmd == 'restore-status': r = {'status':'failed', 'phase':'resolve', 'reason':'APPLICATION_WINDOWS_INCOMPLETE'}
else: raise RuntimeError('UNEXPECTED_COMMAND')
print(json.dumps(r))
'''
HARNESS = '''//@ pragma UseQApplication
import QtQuick
import Quickshell
ShellRoot {
PanelWindow { id: bar; visible: false }
SessionStartupPromptV2 { id: prompt; barWindow: bar; startupHost: true }
Timer { interval: 600; running: true; onTriggered: {
prompt.restoreRequested();
if (prompt.attempted) { console.error("UI_TEST_EARLY_ACTIVATION"); Qt.quit(); }
} }
Timer { interval: 1400; running: true; onTriggered: {
if (Quickshell.env("LABFY_UI_MODE") === "claimed") return;
if (!prompt.interactionArmed) { console.error("UI_TEST_NOT_ARMED"); Qt.quit(); return; }
if (Quickshell.env("LABFY_UI_MODE") === "new") prompt.newSessionRequested();
else { prompt.restoreRequested(); prompt.restoreRequested(); }
} }
Timer { interval: 3400; running: true; onTriggered: {
const mode = Quickshell.env("LABFY_UI_MODE");
const okay = (mode === "success" || mode === "real") ? prompt.restored && !prompt.visible
: (mode === "failure" || mode === "invalid") ? prompt.failed && prompt.visible
: !prompt.visible && !prompt.attempted;
if (prompt.failed) prompt.restoreRequested();
console.log(okay ? "UI_TEST_PASS" : "UI_TEST_FAIL"); Qt.quit();
} }
}
'''
def main():
rows = []
with Lab() as lab:
for mode in ('success', 'failure', 'invalid', 'new', 'claimed', 'real'):
config = lab.directory / mode / 'config'
base = config / 'quickshell/labfy-sway'
base.mkdir(parents=True)
shutil.copytree(SOURCE / 'sessionui', base / 'sessionui')
(base / 'controlcenter').mkdir()
shutil.copyfile(SOURCE / 'SessionStartupPromptV2.qml', base / 'SessionStartupPromptV2.qml')
shutil.copyfile((BACKEND / 'controlcenter/ActionButton.qml'), base / 'controlcenter/ActionButton.qml')
(base / 'theme').mkdir()
(base / 'theme/qmldir').write_text('singleton Theme 1.0 Theme.qml\n')
colors = 'popupBackground foreground secondaryForeground lavender buttonBackground emphasisBackground border danger'
(base / 'theme/Theme.qml').write_text('pragma Singleton\nimport QtQuick\nQtObject {\n' + '\n'.join('property color ' + c + ': "#333333"' for c in colors.split()) + '\n}\n')
(base / 'session-v2.py').write_text(FAKE)
(base / 'shell.qml').write_text(HARNESS)
log = lab.directory / (mode + '.calls')
env = {**lab.env, 'XDG_CONFIG_HOME': str(config), 'LABFY_UI_MODE': mode, 'LABFY_UI_LOG': str(log)}
apps = None
if mode == 'real':
# Vrai moteur avec chooser visible : tester aussi l'interaction
# entre focus layer-shell et la postcondition finale Sway.
env['XDG_STATE_HOME'] = str(private_dir(lab.directory / 'state'))
real_backend = BACKEND
anchor_binary = build(lab.directory)
(base / 'session-v2.py').write_text('import sys, os\nfrom pathlib import Path\nsys.path.insert(0, ' + repr(str(real_backend)) + ')\nfrom session_v2.cli import main\nwith Path(os.environ["LABFY_UI_LOG"]).open("a") as s: s.write(sys.argv[1] + "\\n")\nsys.exit(main(sys.argv[1:] + [\"--anchor-binary\", ' + repr(str(anchor_binary)) + ']))\n')
apps = Surfaces(lab, build_helper(lab.directory))
lab.command('workspace TEST_ONLY_SOURCE')
apps.create('limusic-app')
data = fixture('A', 'TEST_ONLY_TARGET')
data['metadata']['sway_session'] = 'previous-session'
data['applications'] = [{'application_id': 'limusic', 'desktop_entry': 'limusic.desktop', 'strategy': 'managed-autostart',
'managed_by': 'sway-autostart', 'expected_windows': 1, 'identity_provider': None}]
data['window_slots'][0].update(application_id='limusic', identity_evidence={'type': 'application-singleton', 'id': 'limusic'})
Store(Path(env['XDG_STATE_HOME']) / 'labfy-sway/session-v2').checkpoint(data)
result = subprocess.run(['qs', '-p', str(base), '--no-color'], env=env, capture_output=True, text=True, timeout=15)
output = result.stdout + result.stderr
if mode == 'real':
report = read(Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway/session-v2-restore-attempt.json')
assert report['status'] == 'success', report
apps.close()
assert 'UI_TEST_PASS' in output and 'UI_TEST_FAIL' not in output, output
calls = log.read_text().splitlines()
assert calls.count('apply-last') == (1 if mode in ('success', 'failure', 'invalid', 'real') else 0), calls
assert calls.count('acknowledge-startup') == (1 if mode in ('success', 'new', 'real') else 0), calls
rows.append({'case': mode, 'calls': calls, 'result': 'PASS'})
print(mode, 'PASS', flush=True)
atomic(private_dir(EVIDENCE) / 'ui-test-only.json', {'cases': rows})
if __name__ == '__main__': main()

View file

@ -0,0 +1,53 @@
import QtQuick
import QtTest
TestCase {
name: "SessionExitGateV2"
function gate() {
const component = Qt.createComponent("../../../quickshell/.config/quickshell/labfy-sway/controlcenter/SessionExitGate.qml");
compare(component.status, Component.Ready, component.errorString());
return createTemporaryObject(component, this, {
checkpointSuccessStatus: "saved",
actions: [
{id: "logout", checkpointReason: "logout", command: ["uwsm", "stop"]},
{id: "reboot", checkpointReason: "reboot", command: ["systemctl", "reboot"]},
{id: "poweroff", checkpointReason: "poweroff", command: ["systemctl", "poweroff"]}
]
});
}
Component { id: spyComponent; SignalSpy {} }
function test_saved_releases_only_confirmed_action() {
for (const action of ["logout", "reboot", "poweroff"]) {
const g = gate();
const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"});
g.requestAction(action);
g.confirmAction(action);
verify(g.busy);
compare(spy.count, 0);
g.checkpointFinished(0, 0, '{"status":"saved"}', "");
compare(spy.count, 1);
compare(spy.signalArguments[0][0], action === "logout" ? ["uwsm", "stop"] : ["systemctl", action]);
}
}
function test_error_or_legacy_success_never_exits() {
for (const response of ["{}", "invalid", '{"status":"success"}', '{"status":"failed"}']) {
const g = gate();
const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"});
g.requestAction("logout"); g.confirmAction("logout");
g.checkpointFinished(0, 0, response, "");
compare(spy.count, 0);
verify(!g.busy);
g.cancel();
compare(spy.count, 0);
}
}
function test_quit_without_saving_requires_explicit_call() {
const g = gate();
const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"});
g.requestAction("logout"); g.confirmAction("logout");
g.quitWithoutCheckpoint(); compare(spy.count, 0);
g.checkpointFinished(2, 0, '{"status":"saved"}', "échec");
compare(spy.count, 0);
g.quitWithoutCheckpoint(); compare(spy.count, 1);
}
}

View file

@ -0,0 +1,96 @@
import argparse
import time
from common import *
from lab import Lab, Surfaces, build_helper, until
from session_v2.ipc import Sway
from session_v2.executor import Executor
from session_v2.storage import private_dir, atomic
from session_v2.snapshot import capture
def main(smoke=False):
rows = []
shapes = ["A", {"splith": ["A", "B"]}, {"splitv": ["A", "B"]},
{"splith": ["A", "B", "C"]}, {"splitv": ["A", "B", "C"]},
{"splitv": ["A", {"splith": ["B", "C"]}]},
{"splith": ["A", {"splitv": ["B", "C"]}]},
{"splitv": [{"splith": ["A", "B"]}, {"splith": ["C", "D"]}]},
{"splith": [{"splitv": ["A", "B"]}, {"splitv": ["C", "D"]}]},
{"splitv": ["A", {"splith": ["B", {"splitv": ["C", "D"]}]}]},
{"tabbed": ["A", "B", "C"]}, {"stacked": ["A", "B", "C"]},
{"splitv": ["A", {"tabbed": ["B", "C"]}]}, {"splith": ["A", {"stacked": ["B", "C"]}]}]
with Lab() as lab:
binary = build(lab.directory)
test_binary = build_helper(lab.directory)
runtime = private_dir(lab.directory / "labfy-sway")
sway = Sway(lab.socket)
for index, shape in enumerate(shapes[:1] if smoke else shapes):
for reverse in ([False] if smoke else [False, True]):
data = fixture(shape)
apps = Surfaces(lab, test_binary)
lab.command('workspace "TEST_ONLY_SOURCE"')
order = list(reversed(data["applications"])) if reverse else data["applications"]
for app in order: apps.create("TEST_ONLY_" + app["application_id"])
helper = lab.spawn(["autotiling"])
time.sleep(.15)
try:
result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True)
snapped = capture(sway, catalog(data), None)
assert len(snapped["window_slots"]) == len(data["window_slots"])
assert result["helpers_suspended"] == result["helpers_resumed"] == 1
rows.append({"shape": shape, "reverse": reverse, "result": result, "capture": "PASS"})
print(index, reverse, "PASS", flush=True)
finally:
helper.terminate(); helper.wait(timeout=3)
apps.close()
if not smoke:
for count in (3, 5, 10):
data = fixture({"splith": [chr(65+i) for i in range(count)]})
apps = Surfaces(lab, test_binary)
lab.command('workspace "TEST_ONLY_SOURCE"')
for a in data["applications"]: apps.create("TEST_ONLY_" + a["application_id"])
result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True)
rows.append({"performance_windows": count, "result": result})
apps.close()
data = fixture()
second = fixture({"splith": ["D", "E"]}, "TEST_ONLY_SECOND")
for key in ("applications", "window_slots", "workspaces"): data[key] += second[key]
apps = Surfaces(lab, test_binary)
lab.command('workspace "TEST_ONLY_SOURCE"')
for a in data["applications"]: apps.create("TEST_ONLY_" + a["application_id"])
result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True)
rows.append({"multi_workspace": True, "result": result})
apps.close()
data = fixture({"splith": ["A", "B"]})
data["workspaces"][0]["root"] = {"slot_id": "A"}
data["window_slots"][0]["tree_path"] = []
slot = data["window_slots"][1]
slot["tree_path"] = []
slot["state"].update(floating=True, geometry={"x": 30, "y": 40, "width": 400, "height": 240})
apps = Surfaces(lab, test_binary)
lab.command('workspace "TEST_ONLY_SOURCE"')
for a in data["applications"]: apps.create("TEST_ONLY_" + a["application_id"])
result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True)
rows.append({"floating": True, "result": result})
apps.close()
# Même processus, même app_id et même titre : seule la cardinalité
# autorise ces slots explicitement interchangeables/best-effort.
for identity in ("interchangeable", "best-effort"):
data = fixture({"splith": ["A", "B", "C"]})
data["applications"] = [data["applications"][0]]
data["applications"][0]["expected_windows"] = 3
for slot in data["window_slots"]:
slot.update(application_id="app-A", identity_requirement=identity, identity_evidence=None)
apps = Surfaces(lab, test_binary)
lab.command('workspace "TEST_ONLY_SOURCE"')
for _ in range(3): apps.create("TEST_ONLY_app-A")
result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True)
assert result["identity_level"] == identity
rows.append({"same_app_three": True, "identity": identity, "result": result})
apps.close()
out = EVIDENCE
private_dir(out)
atomic(out / ("smoke.json" if smoke else "runtime.json"), {"cases": rows})
if __name__ == "__main__": main("--smoke" in sys.argv)

View file

@ -0,0 +1,60 @@
"""Malformed native messages et privacy du vrai host candidat."""
import json
import struct
import subprocess
import uuid
from common import *
from lab import Lab
from session_v2.native_host import validate as native_validate, correlate, EXTENSION_ID
from session_v2.storage import private_dir, atomic
from session_v2.errors import Failure
def main():
results = []
good = {"op": "map-window", "uuid": str(uuid.uuid4()), "runtime_token": "a" * 32}
for index, value in enumerate((None, [], {}, {**good, "op": "exec"}, {**good, "uuid": True},
{**good, "uuid": "a" * 4096}, {**good, "runtime_token": "x" * 33},
{**good, "argv": []}, {**good, "title": "synthetic"}, {**good, "environment": {}})):
try: native_validate(value)
except Failure: results.append({"name": f"schema-{index}", "pass": True})
else: raise AssertionError(index)
class Reader:
def windows(self):
return [{"id": 1, "app_id": "firefox", "name": "[LABFY:" + good["runtime_token"] + "] ignored"}]
assert correlate(Reader(), good) == 1
results.append({"name": "read-only-correlation", "pass": True})
class Collision:
def windows(self): return Reader().windows() * 2
try: correlate(Collision(), good)
except Failure as e: assert e.code == "EXACT_WINDOW_IDENTITY_COLLISION"
else: raise AssertionError("collision")
results.append({"name": "surface-collision", "pass": True})
with Lab() as lab:
env = {**lab.env, "HOME": str(private_dir(lab.directory / "home")), "XDG_STATE_HOME": str(private_dir(lab.directory / "state"))}
messages = [struct.pack("=I", 0), struct.pack("=I", 4097), struct.pack("=I", 2) + b"[", struct.pack("=I", 1) + b"\xff"]
for raw in (b'{"op":1,"op":2}', b'{"op":NaN}', b'[]', b'{"command":"x"}'):
messages.append(struct.pack("=I", len(raw)) + raw)
for i, message in enumerate(messages):
proc = subprocess.run([sys.executable, "-B", str(BACKEND / "session-v2-native-host.py"), "/unused-private-manifest", EXTENSION_ID],
env=env, input=message, capture_output=True, timeout=8)
# Une troncature proprement fermée est EOF ; jamais trace ni payload.
assert not proc.stderr
if proc.stdout:
size, = struct.unpack("=I", proc.stdout[:4]); response = json.loads(proc.stdout[4:])
assert size <= 4096 and set(response) == {"result"} and response["result"] != "OK"
results.append({"name": f"wire-{i}", "pass": True})
denied = subprocess.run([sys.executable, "-B", str(BACKEND / "session-v2-native-host.py"), "unused", "other@invalid"],
env=env, capture_output=True, timeout=5)
assert denied.returncode == 1 and not denied.stdout and not denied.stderr
results.append({"name": "extension-id-allowlist", "pass": True})
manifest = json.loads((REPO / "firefox-extension/session-v2/manifest.json").read_text())
assert set(manifest["permissions"]) == {"sessions", "nativeMessaging"} and "content_scripts" not in manifest and "host_permissions" not in manifest
background = (REPO / "firefox-extension/session-v2/background.js").read_text()
assert not any(s in background for s in ("browser.tabs.", "browser.history.", "browser.cookies.", "getRecentlyClosed", "labfy_test_control", "ExecuteScript"))
results.append({"name": "minimal-permissions-no-navigation", "pass": True})
atomic(private_dir(EVIDENCE) / "security.json", {"cases": results})
print(len(results), "security PASS")
if __name__ == "__main__": main()

View file

@ -0,0 +1,45 @@
"""Delta de déploiement : propriétaire autostart et acknowledgement durable."""
import copy
from pathlib import Path
import tempfile
import unittest
from common import *
from session_v2.applications import Catalog, launch_count
from session_v2.errors import Failure
from session_v2.storage import atomic, read, private_dir
from session_v2.startup import acknowledge, successful
from session_v2.observability import Attempt
class DeploymentTests(unittest.TestCase):
def test_limusic_never_launched(self):
spec = Catalog().classify({'app_id': 'limusic-app'})
app = {'application_id': 'limusic', 'desktop_entry': 'limusic.desktop', 'strategy': 'managed-autostart',
'managed_by': 'sway-autostart', 'expected_windows': 1, 'identity_provider': None}
self.assertEqual(Catalog().validate(app), spec)
self.assertEqual(spec.argv, ())
self.assertEqual(launch_count(app, 0), 0)
self.assertEqual(launch_count(app, 1), 0)
with self.assertRaisesRegex(Failure, 'UNEXPECTED_EXTRA_WINDOW'): launch_count(app, 2)
app['managed_by'] = 'autostart'
with self.assertRaises(Failure): Catalog().validate(app)
def test_ack_requires_verified_current_transaction(self):
with tempfile.TemporaryDirectory() as directory:
runtime = private_dir(Path(directory))
acknowledge(runtime, 'session', 'new')
attempt = Attempt(runtime / 'session-v2-restore-attempt.json', 'a' * 32, fixture())
with self.assertRaises(Failure): acknowledge(runtime, 'session', 'restored', 'a' * 32)
attempt.update(status='success', phase='complete', reason='SUCCESS', final_tree_verified=True,
final_focus_verified=True, slots_filled=3, applications_observed=3)
self.assertTrue(successful(attempt.data))
with self.assertRaises(Failure): acknowledge(runtime, 'other-session', 'restored', 'a' * 32)
with self.assertRaises(Failure): acknowledge(runtime, 'session', 'restored', 'b' * 32)
self.assertEqual(acknowledge(runtime, 'session', 'restored', 'a' * 32)['status'], 'acknowledged')
self.assertEqual(read(runtime / 'session-v2/startup.json')['choice'], 'restored')
for change in ({'final_tree_verified': False}, {'final_focus_verified': False},
{'anchors_created': 1}, {'helpers_suspended': 1}, {'slots_filled': 2}, {'applications_observed': 2}):
self.assertFalse(successful({**attempt.data, **change}))
if __name__ == '__main__': unittest.main()

View file

@ -0,0 +1,43 @@
import os
import threading
import time
import unittest
import uuid
from common import *
from session_v2.storage import private_dir, atomic, read
from session_v2.firefox_identity import FirefoxProvider, process_start
from session_v2.errors import Failure
import tempfile
class ProviderTests(unittest.TestCase):
def test_missing_collision_and_live_publisher(self):
class SwayStub:
session = "a" * 64
def windows(self): return [{"id": 11, "app_id": "firefox"}, {"id": 12, "app_id": "firefox"}]
with tempfile.TemporaryDirectory() as directory:
provider = FirefoxProvider(SwayStub(), directory)
a, b = str(uuid.uuid4()), str(uuid.uuid4())
def publish(values):
end = time.monotonic() + 2
while time.monotonic() < end:
request = provider.directory / "request.json"
if request.exists() and read(request)["expires_ns"] > time.monotonic_ns(): break
time.sleep(.005)
for ident, value in zip((11,12), values):
atomic(provider.directory / f"{ident}.json", {"schema": "firefox-session-window-uuid", "version": 1,
"session": SwayStub.session, "uuid": value, "con_id": ident, "observed_ns": time.monotonic_ns(),
"publisher_pid": os.getpid(), "publisher_start": process_start(os.getpid())})
for values, expected_error in (((a,b), None), ((a,a), "EXACT_WINDOW_IDENTITY_COLLISION"), ((a,str(uuid.uuid4())), "EXACT_WINDOW_IDENTITY_MISSING")):
thread = threading.Thread(target=publish, args=(values,)); thread.start()
try:
if expected_error:
with self.assertRaisesRegex(Failure, expected_error): provider.resolve([a,b], SwayStub().windows(), 1)
else: self.assertEqual(provider.resolve([a,b], SwayStub().windows(), 1), {a:11,b:12})
finally: thread.join()
self.assertTrue(provider.available())
with self.assertRaisesRegex(Failure, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE"):
provider.capture(SwayStub().windows(), .05)
if __name__ == "__main__": unittest.main()

View file

@ -0,0 +1,234 @@
import copy
import io
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from contextlib import redirect_stdout
import uuid
from common import *
from session_v2.errors import Failure
from session_v2 import schema
from session_v2.checkpoint import Store
from session_v2.storage import atomic, read, loads, lock, private_dir
from session_v2.layout import compile_plan
from session_v2.applications import launch_count
from session_v2.slots import assign
from session_v2.native_host import validate as native_validate
from session_v2.cli import migration
class BackendTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="labfy-session-v2-unit-")
self.root = Path(self.temp.name)
self.data = fixture()
def tearDown(self): self.temp.cleanup()
def test_schema_exact(self):
self.assertEqual(schema.validate(self.data), self.data)
for key in self.data:
broken = copy.deepcopy(self.data); del broken[key]
with self.assertRaises(Failure): schema.validate(broken)
self.data["pid"] = 1
with self.assertRaises(Failure): schema.validate(self.data)
def test_types_version_and_identity(self):
for value in (True, 2, "1", None):
broken = copy.deepcopy(self.data); broken["version"] = value
with self.assertRaises(Failure): schema.validate(broken)
self.data["applications"][0]["expected_windows"] = True
with self.assertRaises(Failure): schema.validate(self.data)
def test_references(self):
for mutate in (lambda d: d["window_slots"][0].update(application_id="absent"),
lambda d: d.update(focus="absent"),
lambda d: d["window_slots"][0].update(tree_path=[7]),
lambda d: d["applications"][0].update(expected_windows=2)):
d = copy.deepcopy(self.data); mutate(d)
with self.assertRaises(Failure): schema.validate(d)
def test_unsupported_states(self):
for key in ("fullscreen", "scratchpad"):
d = copy.deepcopy(self.data); d["window_slots"][0]["state"][key] = True
with self.assertRaisesRegex(Failure, "CAPABILITY_UNSUPPORTED"): schema.validate(d)
def test_command_injection(self):
for value in ('x; exec echo bad', '../x', 'x"', 'x\n'):
d = copy.deepcopy(self.data); d["workspaces"][0]["workspace"] = value
with self.assertRaises(Failure): schema.validate(d)
def test_plan_pure(self):
plan = compile_plan(self.data, "a" * 32)
self.assertEqual(sum(o.kind == "create" for o in plan), 3)
self.assertTrue(all(o.kind in ("workspace", "create", "focus", "layout") for o in plan))
self.assertFalse(any("launch" in str(o) for o in plan))
def test_checkpoint_permissions(self):
store = Store(self.root)
store.save("example", self.data)
self.assertEqual(store.load("example"), self.data)
self.assertEqual(store.path("example").stat().st_mode & 0o777, 0o600)
self.assertEqual(store.sessions.stat().st_mode & 0o777, 0o700)
store.checkpoint(self.data)
self.assertEqual(store.last(), self.data)
self.assertEqual(store.list(), ["example"])
store.delete("example")
self.assertEqual(store.list(), [])
def test_intermediate_directory_permissions(self):
private_dir(self.root / "one/two/three")
for name in ("one", "one/two", "one/two/three"):
self.assertEqual((self.root / name).stat().st_mode & 0o777, 0o700)
def test_cli_storage_checkpoint_and_startup_mock(self):
from session_v2.cli import main
state = private_dir(self.root / "state")
runtime = private_dir(self.root / "runtime")
with patch.dict(os.environ, {"XDG_STATE_HOME": str(state), "XDG_RUNTIME_DIR": str(runtime)}):
with patch("session_v2.cli.Sway") as sway, patch("session_v2.cli.FirefoxProvider"), patch("session_v2.cli.capture", return_value=self.data) as capture_mock:
sway.return_value.session = "new-session"
empty = io.StringIO()
with redirect_stdout(empty): self.assertEqual(main(["startup-status", "--socket", "/mock"]), 0)
self.assertEqual(json.loads(empty.getvalue()), {"eligible": False, "claimed": False})
with redirect_stdout(io.StringIO()):
self.assertEqual(main(["checkpoint-last", "--execute", "--reason", "logout", "--socket", "/mock"]), 0)
self.assertEqual(main(["save", "example", "--execute", "--socket", "/mock"]), 0)
self.assertEqual(capture_mock.call_args_list[0].args[3], "logout")
def cli(args):
output = io.StringIO()
with redirect_stdout(output): code = main(args)
self.assertEqual(code, 0)
return json.loads(output.getvalue())
self.assertEqual(cli(["list"]), {"sessions": ["example"]})
self.assertEqual(cli(["show", "example"]), self.data)
self.assertTrue(cli(["plan", "example"])["operations"])
self.assertTrue(cli(["startup-status", "--execute", "--socket", "/mock"])["eligible"])
self.assertFalse(cli(["startup-status", "--socket", "/mock"])["eligible"])
self.assertEqual(cli(["delete", "example", "--execute"]), {"status": "deleted"})
def test_checkpoint_failed_validation_preserves_old(self):
store = Store(self.root); store.save("a", self.data)
invalid = copy.deepcopy(self.data); invalid["version"] = 9
with self.assertRaises(Failure): store.save("a", invalid)
self.assertEqual(store.load("a"), self.data)
self.assertFalse(list(store.sessions.glob(".tmp-*")))
def test_paths_links(self):
store = Store(self.root)
for name in ("../a", "a/b", "", "/tmp/a", ".", "a.json", None):
with self.assertRaises(Failure): store.path(name)
target = self.root / "outside"; atomic(target, {})
store.path("evil").symlink_to(target)
with self.assertRaises((Failure, OSError)): store.save("evil", self.data)
self.assertEqual(read(target), {})
def test_json_strict(self):
for raw in (b'{"a":1,"a":2}', b'{"a":NaN}', b'{}junk', b'\xff', b'[' * 2000):
with self.assertRaises(Failure): loads(raw)
def test_lock(self):
with lock(self.root):
with self.assertRaisesRegex(Failure, "TRANSACTION_BUSY"):
with lock(self.root): pass
def test_launch_policy(self):
a = self.data["applications"][0].copy()
for strategy, expected, present, launches in (
("managed-autostart", 2, 0, 0), ("single-window", 1, 0, 1), ("single-window", 1, 1, 0),
("browser-self-restore", 2, 0, 1), ("browser-self-restore", 2, 1, 0),
("terminal", 3, 1, 2), ("multi-instance", 3, 1, 2)):
a.update(strategy=strategy, expected_windows=expected)
self.assertEqual(launch_count(a, present), launches)
a.update(strategy="unknown")
with self.assertRaises(Failure): launch_count(a, 0)
def test_counts(self):
slots = self.data["window_slots"]
with self.assertRaisesRegex(Failure, "APPLICATION_WINDOWS_INCOMPLETE"): assign(slots, [])
with self.assertRaisesRegex(Failure, "UNEXPECTED_EXTRA_WINDOW"): assign(slots, [{"id": x} for x in range(4)])
def test_exact_inverted(self):
values = [str(uuid.uuid4()), str(uuid.uuid4())]
slots = [{"slot_id": str(i), "identity_requirement": "exact", "identity_evidence": {"type": schema.PROVIDER, "id": u}} for i,u in enumerate(values)]
self.assertEqual(assign(slots, [{"id": 99}, {"id": 12}], {values[0]: 12, values[1]: 99}), {"0": 12, "1": 99})
with self.assertRaisesRegex(Failure, "EXACT_WINDOW_IDENTITY_MISSING"): assign(slots, [{"id": 99}, {"id": 12}], {})
with self.assertRaisesRegex(Failure, "EXACT_WINDOW_IDENTITY_COLLISION"): assign(slots, [{"id": 99}, {"id": 12}], {u: 99 for u in values})
def test_interchangeable_best_effort(self):
for level in ("interchangeable", "best-effort"):
slots = [{"slot_id": s, "identity_requirement": level, "identity_evidence": None} for s in ("B", "A")]
self.assertEqual(assign(slots, [{"id": 9}, {"id": 2}]), {"A": 2, "B": 9})
def test_native_strict(self):
good = {"op": "map-window", "uuid": str(uuid.uuid4()), "runtime_token": "a" * 32}
self.assertEqual(native_validate(good), good)
for bad in ([], {}, {**good, "command": "x"}, {**good, "uuid": 2}, {**good, "runtime_token": "a;exec x"}, {**good, "op": "exec"}):
with self.assertRaises(Failure): native_validate(bad)
def test_cli_execute_required(self):
for command in ("apply", "apply-last", "save", "delete", "checkpoint-last", "capture"):
p = subprocess.run([sys.executable, "-B", str(BACKEND / "session-v2.py"), command], capture_output=True, text=True)
self.assertEqual(p.returncode, 2)
self.assertEqual(json.loads(p.stdout)["reason"], "EXECUTE_REQUIRED")
def test_coexistence_and_migration(self):
v1 = self.root / "legacy.json"
atomic(v1, {"schema": "labfy.sway.session-snapshot", "version": 1, "windows": [{"app_id": "firefox"}]})
before = v1.read_bytes()
result = migration(v1)
self.assertEqual(result["classification"], "needs exact identity")
Store(private_dir(self.root / "session-v2")).checkpoint(self.data)
self.assertEqual(v1.read_bytes(), before)
atomic(v1, {"schema": "labfy.sway.session-snapshot", "version": 9, "windows": []})
with self.assertRaisesRegex(Failure, "V1_FORMAT_UNRECOGNIZED"): migration(v1)
def test_multi_output_preflight_synthetic(self):
from session_v2.executor import preflight
from unittest.mock import Mock
second = fixture("D", "TEST_ONLY_SECOND")
second["workspaces"][0]["output"] = "SYNTHETIC-2"
data = copy.deepcopy(self.data)
data["outputs"].append({"name": "SYNTHETIC-2"})
for key in ("applications", "window_slots", "workspaces"): data[key] += second[key]
sway = Mock()
sway.outputs.return_value = [{"name": o["name"], "active": True} for o in data["outputs"]]
sway.windows.return_value = []
sway.tree.return_value = {"nodes": []}
self.assertEqual(len(preflight(data, sway, catalog(data), None)), 4)
sway.outputs.return_value.pop()
with self.assertRaisesRegex(Failure, "OUTPUT_UNAVAILABLE"): preflight(data, sway, catalog(data), None)
sway.command.assert_not_called()
def test_resume_attempts_all_owned_helpers(self):
from session_v2.guard import resume_helpers
helpers = [{"pid": i, "fd": i, "script": "/test/autotiling"} for i in (100, 101)]
counts = {"helpers_resumed": 0}
with patch("session_v2.guard.verify_helper", side_effect=[Failure("HELPER_SESSION_INVALID"), None]), \
patch("session_v2.guard.signal.pidfd_send_signal") as send, \
patch("session_v2.guard.helper_state", return_value="S"):
self.assertFalse(resume_helpers(helpers, "/test/socket", counts))
self.assertEqual(counts["helpers_resumed"], 1)
self.assertEqual(send.call_args.args[0], 101)
self.assertTrue(helpers[1]["resumed"])
self.assertNotIn("resumed", helpers[0])
def test_report_rejects_private_fields_and_bool_version(self):
from session_v2.observability import Attempt, validate as validate_report
report = Attempt(self.root / "attempt.json", "a" * 32, self.data).data
for key in ("title", "url", "argv", "environment", "cookies"):
with self.assertRaisesRegex(Failure, "REPORT_SCHEMA"): validate_report({**report, key: "forbidden"})
with self.assertRaisesRegex(Failure, "REPORT_SCHEMA"): validate_report({**report, "version": True})
def test_json_schema_matches_fixture(self):
import jsonschema
contract = json.loads((BACKEND / "session_v2/schema-v1.json").read_text())
jsonschema.Draft202012Validator.check_schema(contract)
jsonschema.validate(self.data, contract)
with self.assertRaises(jsonschema.ValidationError): jsonschema.validate({**self.data, "pid": 1}, contract)
if __name__ == "__main__": unittest.main()

View file

@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Exécuter le contrat QML de sortie de session sans commande système réelle."""
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import unittest
ROOT = Path(__file__).resolve().parents[1]
QML_TEST = ROOT / "tests/qml/tst_session_exit_gate.qml"
QML_TEST_RUNNER = Path("/usr/lib/qt6/bin/qmltestrunner")
class SessionExitGateQmlTest(unittest.TestCase):
def test_mocked_session_exit_contracts(self) -> None:
environment = os.environ.copy()
environment["QT_QPA_PLATFORM"] = "offscreen"
result = subprocess.run(
[str(QML_TEST_RUNNER), "-input", str(QML_TEST), "-o", "-,txt"],
cwd=ROOT,
env=environment,
text=True,
capture_output=True,
timeout=30,
check=False,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()

View file

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

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

@ -0,0 +1,42 @@
#!/usr/bin/python3
"""Construire les candidats locaux, sans installer ni signer ni accéder au réseau."""
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import tarfile
import tempfile
import zipfile
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "quickshell/.config/quickshell/labfy-sway"
def build(output):
output = Path(output).resolve()
if not output.is_relative_to(ROOT): raise ValueError("OUTPUT_MUST_BE_IN_ISOLATED_WORKTREE")
output.mkdir(parents=True, exist_ok=True)
binary = output / "labfy-v2-anchor"
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "gtk+-3.0", "gdk-wayland-3.0"], text=True).split()
subprocess.run(["cc", "-std=c17", "-O2", "-Wall", "-Wextra", "-Werror", str(SOURCE / "session_v2/anchor_helper.c"), "-o", str(binary), *flags], check=True)
xpi = output / "labfy-session-v2-unsigned.xpi"
with zipfile.ZipFile(xpi, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for name in ("manifest.json", "background.js"):
info = zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)); info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(info, (ROOT / "firefox-extension/session-v2" / name).read_bytes())
bundle = output / "labfy-session-v2-backend.tar.gz"
with tarfile.open(bundle, "w:gz") as archive:
for name in ("session-v2.py", "session-v2-native-host.py"):
archive.add(SOURCE / name, arcname=name)
for p in sorted((SOURCE / "session_v2").glob("*.py")): archive.add(p, arcname="session_v2/" + p.name)
archive.add(SOURCE / "session_v2/schema-v1.json", arcname="session_v2/schema-v1.json")
archive.add(binary, arcname="session_v2/libexec/labfy-v2-anchor")
archive.add(ROOT / "firefox-extension/session-v2/native-host.json.in", arcname="native-host.json.in")
manifest = {"signed": False, "installed": False, "files": {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in (binary, xpi, bundle)}}
(output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
print(json.dumps(manifest))
if __name__ == "__main__": build(sys.argv[1] if len(sys.argv) > 1 else ROOT / "build/session-v2")