Compare commits
5 commits
2939012b63
...
fbb5740f9e
| Author | SHA1 | Date | |
|---|---|---|---|
| fbb5740f9e | |||
| d41131037f | |||
| 5567a76738 | |||
| 5a14abe323 | |||
| 46b29e0f22 |
62 changed files with 3937 additions and 2135 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -36,3 +36,8 @@ bin/.local/bin/trainlog-bt-watch
|
|||
bin/.local/bin/trainlog-btd
|
||||
bin/.local/bin/trainlog-sync-once
|
||||
bin/.local/bin/trainlog-syncd
|
||||
|
||||
# Session V2 : sorties locales reproductibles, jamais distribuées par Git.
|
||||
/build/session-v2/
|
||||
/build/session-v2-tests/
|
||||
/quickshell/.config/quickshell/labfy-sway/session_v2/libexec/
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -116,6 +116,18 @@ inventaires et validations sont décrits dans le
|
|||
| [Recovery](docs/recovery.md) | Reconstruction complète après perte du disque |
|
||||
| [Security](docs/security.md) | Secrets exclus et règles de publication |
|
||||
|
||||
## Session Restore V2
|
||||
|
||||
QuickShell crée un checkpoint avant logout, reboot ou poweroff. Au login suivant,
|
||||
un chooser unique restaure la session en un clic, sans autre interaction.
|
||||
Firefox s’appuie sur son companion signé et un native host de corrélation ;
|
||||
Limusic reste géré par l’autostart Sway. La fenêtre terminal est restaurée,
|
||||
mais pas son contenu ni ses processus. Ratios exacts, fullscreen et scratchpad
|
||||
ne sont pas pris en charge ; le multi-output physique n’est pas validé et le
|
||||
provider Firefox actuel reste limité au profil supporté.
|
||||
|
||||
Voir [installation, fonctionnement et tests V2](docs/session-restore-v2.md).
|
||||
|
||||
## ✦ Mirrors
|
||||
|
||||
| Rôle | Dépôt |
|
||||
|
|
|
|||
11
docs/session-restore-v1.md
Normal file
11
docs/session-restore-v1.md
Normal 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
115
docs/session-restore-v2.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Session Restore V2
|
||||
|
||||
La recette réelle du 7 octobre 2026 a réussi : logout, checkpoint V2, arrêt par
|
||||
UWSM, login, chooser unique et un clic **Restaurer**. Aucune interaction après
|
||||
ce clic ; arbre, focus final et identité Firefox exacte vérifiés. La validation
|
||||
visuelle utilisateur est PASS. Les preuves détaillées restent dans la branche
|
||||
locale de recherche ; elles ne sont pas distribuées dans ce dépôt.
|
||||
|
||||
## Utilisation
|
||||
|
||||
Le menu power conserve Verrouiller, Suspendre, Déconnexion, Redémarrer et
|
||||
Éteindre. Déconnexion sauvegarde un checkpoint V2 avant `uwsm stop` ; Redémarrer
|
||||
et Éteindre sauvegardent avant leur action systemd. Un échec de checkpoint
|
||||
permet d’annuler ou de choisir explicitement de quitter sans sauvegarder.
|
||||
|
||||
Au login suivant, le chooser propose Restaurer ou Nouvelle session. Un clic
|
||||
Restaurer lance la transaction jusqu’au résultat final, sans étape Firefox
|
||||
manuelle ni deuxième restauration. Le chooser est acquitté durablement ; un
|
||||
redémarrage QuickShell dans la même session ne le réaffiche pas.
|
||||
|
||||
Le gestionnaire de sessions permet sauvegarde, liste, aperçu structurel,
|
||||
suppression et restauration V2. L’aperçu ne modifie aucune fenêtre. Le backend
|
||||
fournit aussi `show` pour inspecter une session. Les noms sont limités à 64
|
||||
caractères ASCII alphanumériques, tirets et underscores, commençant par un
|
||||
caractère alphanumérique. Ne pas publier les sessions ou rapports runtime.
|
||||
|
||||
## Installation reproductible
|
||||
|
||||
Prérequis : Python 3, Sway compatible, QuickShell/Qt 6, GTK3, compilateur C17,
|
||||
`pkg-config`, autotiling, Kitty, Firefox et Limusic déjà configuré par l’autostart
|
||||
Sway. Le catalogue est `session_v2/applications.py`. Limusic utilise
|
||||
`strategy=managed-autostart`, `managed_by=sway-autostart` et aucun argv Labfy.
|
||||
|
||||
Depuis la racine du dépôt :
|
||||
|
||||
```sh
|
||||
python3 -B tools/build-session-v2.py
|
||||
install -Dm755 build/session-v2/labfy-v2-anchor \
|
||||
quickshell/.config/quickshell/labfy-sway/session_v2/libexec/labfy-v2-anchor
|
||||
```
|
||||
|
||||
Le build ne déploie ni extension ni préférence. Le helper compilé et les archives
|
||||
restent ignorés par Git. Déployer le package Stow `quickshell` selon le guide
|
||||
habituel, puis suivre [la documentation du companion Firefox](../firefox-extension/session-v2/README.md).
|
||||
L’extension signée déjà installée n’a pas besoin d’être réinstallée pour ce nettoyage.
|
||||
|
||||
Versions de la recette : Firefox 157.0, SwayFX 0.6, QuickShell 0.3.1,
|
||||
autotiling 1.9.4, Python 3.14.7, Qt Declarative 6.11.2 et GTK3 3.24.52.
|
||||
|
||||
## Architecture et invariants
|
||||
|
||||
`sessionui/SessionV2Service.qml` est l’unique propriétaire des commandes UI.
|
||||
`Bar.qml` instancie `SessionStartupPromptV2` ; `shell.qml` désigne une seule barre
|
||||
hôte. `ControlCenter.qml` utilise `SessionManagerV2` et `SessionExitGate` avec
|
||||
le statut de checkpoint `saved`. Aucune surface active ne dépend du moteur V1.
|
||||
|
||||
Le backend capture applications, slots et structure ; il reconstruit par ancres
|
||||
et swaps, puis vérifie arbre, absence d’ancres et focus final. Le guard possède
|
||||
la suspension/reprise des helpers et leur récupération en cas d’interruption.
|
||||
Les observations runtime ne sont pas des identités persistantes. Firefox garde
|
||||
la propriété de ses fenêtres/onglets ; le companion fournit l’identité exacte.
|
||||
Kitty restaure uniquement la fenêtre. Les échecs sont explicites ; aucune
|
||||
approximation d’identité Firefox n’est substituée silencieusement.
|
||||
|
||||
Les checkpoints et sessions sont privés sous
|
||||
`${XDG_STATE_HOME:-$HOME/.local/state}/labfy-sway/session-v2`. Les transactions,
|
||||
locks, rapports et observations du provider restent sous `$XDG_RUNTIME_DIR`.
|
||||
Les anciennes données utilisateur V1 sont conservées. `analyze-v1-migration`
|
||||
est une analyse en lecture seule, sans conversion automatique ni moteur V1.
|
||||
Les anciens documents V1 sont des références historiques non exécutables.
|
||||
|
||||
## Diagnostics et validation
|
||||
|
||||
```sh
|
||||
python3 -B "$HOME/.config/quickshell/labfy-sway/session-v2.py" restore-status
|
||||
qs ipc -c labfy-sway call sessionV2 status
|
||||
systemctl --user is-active quickshell-labfy-sway.service
|
||||
```
|
||||
|
||||
Le rapport de succès doit indiquer `status=success`, les deux vérifications
|
||||
finales vraies, autant d’ancres nettoyées que créées et autant de helpers repris
|
||||
que suspendus. Le diagnostic QuickShell doit retourner `backend=v2`, `busy=false`.
|
||||
Le rapport backend contient des identités privées : ne pas le joindre brut à
|
||||
un ticket public. Ne jamais relancer un restore pour simplement lire son statut.
|
||||
|
||||
Tests sur compositeur headless privé, sans logout ni accès au bureau utilisateur :
|
||||
|
||||
```sh
|
||||
python3 -B -m unittest discover -s tests/session_v2 -p 'test_*.py'
|
||||
python3 -B tests/session_v2/runtime.py
|
||||
python3 -B tests/session_v2/failures.py
|
||||
python3 -B tests/session_v2/crashes.py
|
||||
python3 -B tests/session_v2/security.py
|
||||
python3 -B tests/session_v2/phase3b_runtime.py
|
||||
python3 -B tests/session_v2/phase3b_ui.py
|
||||
QT_QPA_PLATFORM=offscreen /usr/lib/qt6/bin/qmltestrunner \
|
||||
-input tests/session_v2/qml -o -,txt
|
||||
```
|
||||
|
||||
Les tests Python nécessitent `jsonschema` ; les scénarios UI nécessitent QuickShell
|
||||
et QtTest. Les résultats synthétiques restent sous `build/session-v2-tests`,
|
||||
ou dans `LABFY_SESSION_V2_EVIDENCE`. Les fixtures ne lancent que leur propre
|
||||
compositeur et leurs enfants TEST_ONLY. Le build helper C17 utilise
|
||||
`-Wall -Wextra -Werror`. Les métadonnées QuickShell peuvent produire des
|
||||
avertissements qmllint ; le chargement réel et les scénarios Qt complètent
|
||||
le contrôle syntaxique, sans prétendre à zéro avertissement.
|
||||
|
||||
## Limites conservées
|
||||
|
||||
- Contenu et processus terminal non restaurés.
|
||||
- Ratios exacts non restaurés.
|
||||
- Fullscreen et scratchpad non pris en charge actuellement.
|
||||
- Multi-output physique non validé.
|
||||
- Provider Firefox candidat actuel pour le profil supporté ; plusieurs profils
|
||||
simultanés ne sont pas pris en charge.
|
||||
|
|
@ -1,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.
|
||||
|
|
|
|||
57
firefox-extension/session-v2/README.md
Normal file
57
firefox-extension/session-v2/README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Companion Firefox Session Restore V2
|
||||
|
||||
Sources de l’extension utilisée par la recette réelle validée. ID :
|
||||
`session-v2@labfy.org`, version 1.0.0. Host : `org.labfy.session_v2_identity`.
|
||||
Permissions limitées à `sessions` et `nativeMessaging` : aucun accès à la
|
||||
navigation, aux cookies, à l’historique ou au contenu des onglets.
|
||||
|
||||
## Build et signature
|
||||
|
||||
Depuis la racine du dépôt, `python3 -B tools/build-session-v2.py` construit
|
||||
`build/session-v2/labfy-session-v2-unsigned.xpi`. Le contenu du XPI est
|
||||
déterministe ; le bundle backend tar.gz est seulement une sortie locale.
|
||||
Aucun binaire ou XPI signé n’est versionné.
|
||||
|
||||
La distribution retenue est **AMO unlisted**, signée par Mozilla, puis installée
|
||||
localement avec consentement explicite. Envoyer le XPI non signé via le portail
|
||||
AMO de développement, choisir la distribution non répertoriée, récupérer le
|
||||
package signé et l’installer depuis le gestionnaire d’extensions Firefox.
|
||||
Les identifiants AMO restent hors du dépôt. Ne pas désactiver l’exigence de
|
||||
signature Firefox Release. Firefox 157 est la version minimale du manifeste.
|
||||
|
||||
Sur le profil utilisateur validé, l’extension signée est déjà active et
|
||||
`browser.startup.page=3` est déjà configuré. Le nettoyage V1 ne modifie ni
|
||||
installation, ni permissions, ni préférence. Pour une nouvelle installation,
|
||||
le propriétaire du profil doit choisir explicitement la réouverture des
|
||||
fenêtres et onglets précédents.
|
||||
|
||||
## Native host
|
||||
|
||||
Après déploiement Stow, rendre le template `native-host.json.in` en remplaçant
|
||||
`@ABSOLUTE_NATIVE_HOST_LAUNCHER@` par le chemin absolu de
|
||||
`$HOME/.config/quickshell/labfy-sway/session-v2-native-host.py`.
|
||||
Conserver le nom du host, le type `stdio` et l’allowlist exacte de l’extension.
|
||||
Le wrapper doit rester exécutable.
|
||||
|
||||
Sur l’installation Linux validée, le manifeste est
|
||||
`${XDG_CONFIG_HOME:-$HOME/.config}/mozilla/native-messaging-hosts/org.labfy.session_v2_identity.json`.
|
||||
Le chemin historique `$HOME/.mozilla` pointe vers cette arborescence. Sur un
|
||||
autre packaging Firefox, vérifier le répertoire Native Messaging utilisé avant
|
||||
installation. Créer le manifeste avec permissions 0600 ; ne pas écraser un
|
||||
host actif lors d’une simple mise à jour de documentation.
|
||||
|
||||
Le host accepte seulement le protocole `map-window` avec UUID et jeton runtime.
|
||||
Les messages sont bornés à 4096 octets ; la sortie standard est réservée au
|
||||
framing Native Messaging. Le host lit l’arbre Sway pour corréler le préfixe
|
||||
éphémère, sans exécuter de commande Sway mutatrice. L’extension retire ce
|
||||
préfixe après corrélation. Le provider exige des observations récentes et un
|
||||
publisher vivant ; une identité absente ou ambiguë produit un échec explicite.
|
||||
|
||||
Les UUID persistants appartiennent aux fenêtres Firefox ; les observations et
|
||||
identifiants de processus restent privés dans le runtime. Ne pas publier ces
|
||||
fichiers. Le provider demeure le candidat validé pour le profil supporté ; le
|
||||
multi-profil n’est pas pris en charge. Les fenêtres et onglets sont restaurés
|
||||
par Firefox, pas par une lecture de sessionstore côté Labfy.
|
||||
|
||||
Voir [Session Restore V2](../../docs/session-restore-v2.md) pour le cycle complet,
|
||||
les tests et les limites.
|
||||
92
firefox-extension/session-v2/background.js
Normal file
92
firefox-extension/session-v2/background.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/* Identité locale, aucune donnée de navigation. Pas de canal de commande du
|
||||
* laboratoire. Le host n'accepte que map-window et ne peut piloter Firefox.
|
||||
* L'UUID appartient à la fenêtre logique, pas à son adresse runtime windowId.
|
||||
*/
|
||||
"use strict";
|
||||
const KEY = "labfy.session.window_uuid";
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
let port = null;
|
||||
let pending = null;
|
||||
let busy = false;
|
||||
|
||||
function connect() {
|
||||
if (port) return port;
|
||||
const current = browser.runtime.connectNative("org.labfy.session_v2_identity");
|
||||
port = current;
|
||||
current.onMessage.addListener(answer => {
|
||||
if (port !== current) return;
|
||||
if (answer.event === "capture") { cycle(); return; }
|
||||
if (!pending) return;
|
||||
const p = pending;
|
||||
pending = null;
|
||||
p.resolve(answer);
|
||||
});
|
||||
current.onDisconnect.addListener(() => {
|
||||
if (port !== current) return;
|
||||
port = null;
|
||||
if (pending) { pending.reject(new Error("PROVIDER_UNAVAILABLE")); pending = null; }
|
||||
});
|
||||
return port;
|
||||
}
|
||||
async function request(message) {
|
||||
const p = connect();
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (pending) { pending = null; p.disconnect(); port = null; reject(new Error("PROVIDER_TIMEOUT")); }
|
||||
}, 5000);
|
||||
pending = {resolve: v => { clearTimeout(timer); resolve(v); }, reject: e => { clearTimeout(timer); reject(e); }};
|
||||
try { p.postMessage(message); }
|
||||
catch (e) { clearTimeout(timer); pending = null; reject(e); }
|
||||
});
|
||||
}
|
||||
async function ids() {
|
||||
const windows = await browser.windows.getAll({populate: false, windowTypes: ["normal"]});
|
||||
if (windows.length > 256) throw new Error("WINDOW_LIMIT");
|
||||
return windows.map(w => w.id);
|
||||
}
|
||||
async function cycle() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const rows = [];
|
||||
for (const id of await ids()) {
|
||||
let value = await browser.sessions.getWindowValue(id, KEY);
|
||||
if (value === undefined) {
|
||||
// Relecture avant attribution ; une ancienne UUID absente du snapshot
|
||||
// restera un échec exact, jamais une correspondance par ordre.
|
||||
await sleep(200);
|
||||
value = await browser.sessions.getWindowValue(id, KEY);
|
||||
if (value === undefined) {
|
||||
value = crypto.randomUUID();
|
||||
await browser.sessions.setWindowValue(id, KEY, value);
|
||||
}
|
||||
}
|
||||
if (!UUID.test(value)) throw new Error("INVALID_UUID");
|
||||
rows.push({id, uuid: value});
|
||||
}
|
||||
// Les doublons sont également refusés par le provider. Les publier permet
|
||||
// un diagnostic de collision au lieu d'un silence ambigu.
|
||||
for (const row of rows) {
|
||||
const runtime_token = crypto.randomUUID().replaceAll("-", "");
|
||||
try {
|
||||
await browser.windows.update(row.id, {titlePreface: `[LABFY:${runtime_token}] `});
|
||||
const answer = await request({op: "map-window", uuid: row.uuid, runtime_token});
|
||||
if (answer.result !== "OK" || answer.uuid !== row.uuid || !Number.isSafeInteger(answer.con_id)) throw new Error("PROVIDER_ERROR");
|
||||
} finally {
|
||||
await browser.windows.update(row.id, {titlePreface: ""});
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Aucune chaîne d'erreur externe ou donnée de page dans un log.
|
||||
} finally { busy = false; }
|
||||
}
|
||||
async function start() {
|
||||
for (const id of await ids()) {
|
||||
try { await browser.windows.update(id, {titlePreface: ""}); } catch (_) {}
|
||||
}
|
||||
connect();
|
||||
// Reconnexion seulement : pas de titlePreface ni requête Sway en idle.
|
||||
setInterval(() => { if (!port) connect(); }, 1000);
|
||||
}
|
||||
start().catch(() => {});
|
||||
11
firefox-extension/session-v2/manifest.json
Normal file
11
firefox-extension/session-v2/manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Labfy — Identité des fenêtres",
|
||||
"version": "1.0.0",
|
||||
"description": "Identité locale des fenêtres pour restaurer leur disposition Sway.",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {"id": "session-v2@labfy.org", "strict_min_version": "157.0", "data_collection_permissions": {"required": ["none"]}}
|
||||
},
|
||||
"permissions": ["sessions", "nativeMessaging"],
|
||||
"background": {"scripts": ["background.js"], "persistent": true}
|
||||
}
|
||||
7
firefox-extension/session-v2/native-host.json.in
Normal file
7
firefox-extension/session-v2/native-host.json.in
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "org.labfy.session_v2_identity",
|
||||
"description": "Labfy Session V2 — corrélation locale",
|
||||
"path": "@ABSOLUTE_NATIVE_HOST_LAUNCHER@",
|
||||
"type": "stdio",
|
||||
"allowed_extensions": ["session-v2@labfy.org"]
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ PanelWindow {
|
|||
|
||||
required property var modelData
|
||||
required property var notificationService
|
||||
required property bool startupHost
|
||||
screen: modelData
|
||||
|
||||
anchors {
|
||||
|
|
@ -139,4 +140,8 @@ PanelWindow {
|
|||
barWindow: bar
|
||||
service: bar.notificationService
|
||||
}
|
||||
SessionStartupPromptV2 {
|
||||
barWindow: bar
|
||||
startupHost: bar.startupHost
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import "controlcenter"
|
||||
import "theme"
|
||||
import "sessionui"
|
||||
import "sessionui/Protocol.js" as Protocol
|
||||
|
||||
PanelWindow {
|
||||
id: popup
|
||||
required property var barWindow
|
||||
required property bool startupHost
|
||||
property bool interactionArmed: false
|
||||
property bool failed: false
|
||||
property bool restored: false
|
||||
property bool detailsVisible: false
|
||||
property bool attempted: false
|
||||
property string technicalDetails: ""
|
||||
property int windowCount: 0
|
||||
property int workspaceCount: 0
|
||||
readonly property bool busy: SessionV2Service.busy
|
||||
function restoreRequested() {
|
||||
if (!interactionArmed || busy || attempted || failed) return;
|
||||
if (SessionV2Service.run("apply-last", "", "", popup)) attempted = true;
|
||||
}
|
||||
function newSessionRequested() {
|
||||
if (interactionArmed && !busy) SessionV2Service.run("ack-new", "", "", popup);
|
||||
}
|
||||
function fail() {
|
||||
failed = true;
|
||||
restored = false;
|
||||
// Détails provient uniquement de Restore Attempt V2, jamais d'un log.
|
||||
SessionV2Service.run("restore-status", "", "", popup);
|
||||
}
|
||||
Timer {
|
||||
interval: 350
|
||||
running: popup.startupHost
|
||||
onTriggered: SessionV2Service.run("startup-status", "", "", popup)
|
||||
}
|
||||
Timer { id: arm; interval: 750; onTriggered: popup.interactionArmed = popup.visible }
|
||||
Timer { id: closeSuccess; interval: 600; onTriggered: popup.visible = false }
|
||||
Connections {
|
||||
target: SessionV2Service
|
||||
function onCompleted(owner, kind, value, okay) {
|
||||
if (owner !== popup) return;
|
||||
if (kind === "startup-status") {
|
||||
popup.visible = okay && value.eligible === true && value.claimed === true;
|
||||
if (popup.visible) {
|
||||
popup.windowCount = value.windows || 0;
|
||||
popup.workspaceCount = value.workspaces || 0;
|
||||
}
|
||||
} else if (kind === "apply-last") {
|
||||
if (okay && Protocol.restoreSucceeded(value))
|
||||
SessionV2Service.run("ack-restored", "", value.transaction_id, popup);
|
||||
else popup.fail();
|
||||
} else if (kind === "ack-restored") {
|
||||
if (okay && value.status === "acknowledged" && value.choice === "restored") {
|
||||
popup.restored = true;
|
||||
closeSuccess.start();
|
||||
} else popup.fail();
|
||||
} else if (kind === "ack-new") {
|
||||
if (okay && value.status === "acknowledged") popup.visible = false;
|
||||
else popup.fail();
|
||||
} else if (kind === "restore-status") {
|
||||
popup.technicalDetails = okay ? Protocol.details(value) : "Rapport de restauration indisponible.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layer-shell, jamais un PopupWindow grabbing au nouveau login.
|
||||
screen: barWindow.screen
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
exclusiveZone: 0
|
||||
aboveWindows: true
|
||||
focusable: true
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.namespace: "labfy-session-startup-v2"
|
||||
visible: false
|
||||
color: "transparent"
|
||||
onVisibleChanged: {
|
||||
if (startupHost) SessionV2Service.startupChooserVisible = visible;
|
||||
interactionArmed = false;
|
||||
if (visible) arm.restart(); else arm.stop();
|
||||
}
|
||||
FocusScope {
|
||||
id: modal
|
||||
anchors.fill: parent
|
||||
focus: popup.visible
|
||||
// Aucun focus par défaut sur Restore. Les touches en transit n'agissent
|
||||
// pas, et Escape ne vaut jamais une demande de restauration.
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Escape || event.key === Qt.Key_Enter
|
||||
|| event.key === Qt.Key_Return || event.key === Qt.Key_Space) event.accepted = true;
|
||||
}
|
||||
Rectangle { anchors.fill: parent; color: Qt.rgba(0, 0, 0, 0.65) }
|
||||
MouseArea { anchors.fill: parent }
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(460, Math.max(280, modal.width - 32))
|
||||
height: content.implicitHeight + 40
|
||||
color: Theme.popupBackground
|
||||
radius: 8
|
||||
border.color: Theme.lavender
|
||||
Column {
|
||||
id: content
|
||||
anchors { left: parent.left; right: parent.right; top: parent.top; margins: 20 }
|
||||
spacing: 14
|
||||
Text { text: "Session précédente"; color: Theme.foreground; font.pixelSize: 19; font.bold: true }
|
||||
Text {
|
||||
width: parent.width
|
||||
text: popup.failed ? "Impossible de restaurer complètement la session."
|
||||
: popup.restored ? "Session restaurée"
|
||||
: popup.busy && popup.attempted ? "Restauration en cours…"
|
||||
: popup.windowCount + " fenêtres • " + popup.workspaceCount + " espaces"
|
||||
color: Theme.foreground
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: popup.detailsVisible
|
||||
text: popup.technicalDetails
|
||||
color: Theme.secondaryForeground
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
Row {
|
||||
spacing: 10
|
||||
visible: !popup.restored && (!popup.attempted || popup.failed)
|
||||
ActionButton {
|
||||
visible: popup.failed
|
||||
label: "Détails"
|
||||
enabled: !popup.busy
|
||||
onClicked: popup.detailsVisible = !popup.detailsVisible
|
||||
}
|
||||
ActionButton {
|
||||
label: "Nouvelle session"
|
||||
enabled: popup.interactionArmed && !popup.busy
|
||||
onClicked: popup.newSessionRequested()
|
||||
}
|
||||
ActionButton {
|
||||
visible: !popup.failed
|
||||
label: "Restaurer"
|
||||
enabled: popup.interactionArmed && !popup.busy && !popup.attempted
|
||||
onClicked: popup.restoreRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Networking
|
||||
import Quickshell.Bluetooth
|
||||
import "../components"
|
||||
import "../theme"
|
||||
import "../sessionui"
|
||||
|
||||
PopupWindow {
|
||||
id: popup
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import "../theme"
|
||||
import "../sessionui"
|
||||
import "../sessionui/Protocol.js" as Protocol
|
||||
|
||||
Item {
|
||||
id: page
|
||||
required property bool activePage
|
||||
signal backRequested()
|
||||
property var sessions: []
|
||||
property string selected: ""
|
||||
property string feedback: ""
|
||||
property string details: ""
|
||||
property bool restoreFailed: false
|
||||
property bool detailsVisible: false
|
||||
readonly property bool busy: SessionV2Service.busy
|
||||
implicitHeight: content.implicitHeight
|
||||
onActivePageChanged: if (activePage && !busy) SessionV2Service.run("list", "", "", page)
|
||||
Connections {
|
||||
target: SessionV2Service
|
||||
function onCompleted(owner, kind, value, okay) {
|
||||
if (owner !== page) return;
|
||||
if (kind === "list") { if (okay) page.sessions = value.sessions; return; }
|
||||
if (kind === "apply") {
|
||||
if (okay && Protocol.restoreSucceeded(value)) page.feedback = "Session restaurée";
|
||||
else {
|
||||
page.restoreFailed = true;
|
||||
page.feedback = "Impossible de restaurer complètement la session.";
|
||||
SessionV2Service.run("restore-status", "", "", page);
|
||||
}
|
||||
} else if (kind === "restore-status") page.details = Protocol.details(value);
|
||||
else if (!okay) page.feedback = "L’opération n’a pas pu aboutir.";
|
||||
else if (kind === "plan") page.feedback = "Plan structurel : " + value.operations.length + " opérations. Aucune fenêtre modifiée.";
|
||||
else {
|
||||
page.feedback = kind === "save" ? "Session sauvegardée" : "Session supprimée";
|
||||
SessionV2Service.run("list", "", "", page);
|
||||
}
|
||||
}
|
||||
}
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width
|
||||
spacing: 12
|
||||
Text { text: "Gestionnaire de sessions V2"; color: Theme.foreground; font.pixelSize: 17; font.bold: true }
|
||||
Text { width: parent.width; text: page.feedback; visible: text.length > 0; color: Theme.foreground; wrapMode: Text.Wrap }
|
||||
Text { width: parent.width; text: page.details; visible: page.detailsVisible; color: Theme.secondaryForeground; wrapMode: Text.Wrap }
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
visible: !page.restoreFailed
|
||||
TextField {
|
||||
id: nameField
|
||||
width: parent.width
|
||||
placeholderText: "Nom de la session"
|
||||
enabled: !page.busy
|
||||
maximumLength: 64
|
||||
}
|
||||
ActionButton {
|
||||
label: "Sauvegarder"
|
||||
enabled: !page.busy && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(nameField.text)
|
||||
&& page.sessions.indexOf(nameField.text) < 0
|
||||
onClicked: SessionV2Service.run("save", nameField.text, "", page)
|
||||
}
|
||||
ComboBox {
|
||||
id: selection
|
||||
width: parent.width
|
||||
model: page.sessions
|
||||
enabled: !page.busy
|
||||
onCurrentTextChanged: page.selected = currentText
|
||||
}
|
||||
Row {
|
||||
spacing: 8
|
||||
ActionButton {
|
||||
label: "Aperçu"
|
||||
enabled: !page.busy && page.selected.length > 0
|
||||
onClicked: SessionV2Service.run("plan", page.selected, "", page)
|
||||
}
|
||||
ActionButton {
|
||||
label: "Restaurer"
|
||||
enabled: !page.busy && page.selected.length > 0
|
||||
onClicked: { page.feedback = "Restauration en cours…"; SessionV2Service.run("apply", page.selected, "", page); }
|
||||
}
|
||||
ActionButton {
|
||||
label: "Supprimer"
|
||||
enabled: !page.busy && page.selected.length > 0
|
||||
onClicked: SessionV2Service.run("delete", page.selected, "", page)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
spacing: 8
|
||||
ActionButton {
|
||||
visible: page.restoreFailed
|
||||
label: "Détails"
|
||||
enabled: !page.busy
|
||||
onClicked: page.detailsVisible = !page.detailsVisible
|
||||
}
|
||||
ActionButton {
|
||||
label: page.restoreFailed ? "Nouvelle session" : "Retour"
|
||||
enabled: !page.busy
|
||||
onClicked: { page.restoreFailed = false; page.feedback = ""; page.detailsVisible = false; page.backRequested(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,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 {
|
||||
|
|
|
|||
8
quickshell/.config/quickshell/labfy-sway/session-v2-native-host.py
Executable file
8
quickshell/.config/quickshell/labfy-sway/session-v2-native-host.py
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/python3
|
||||
"""Entrée native messaging ; jamais lancée via une commande fournie par message."""
|
||||
import sys
|
||||
sys.dont_write_bytecode = True
|
||||
from session_v2.native_host import main
|
||||
from session_v2.errors import Failure
|
||||
try: main()
|
||||
except (Failure, OSError): sys.exit(1)
|
||||
6
quickshell/.config/quickshell/labfy-sway/session-v2.py
Executable file
6
quickshell/.config/quickshell/labfy-sway/session-v2.py
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/python3
|
||||
"""Entrée V2 indépendante ; aucun import QuickShell/V1."""
|
||||
import sys
|
||||
sys.dont_write_bytecode = True
|
||||
from session_v2.cli import main
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1 @@
|
|||
"""Session V2 : backend indépendant, jamais importé par la V1."""
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/* WHY : une seule application possédée fournit les feuilles temporaires.
|
||||
* CONTRACT : stdin privé, create <identité> / quit, 256 fenêtres maximum.
|
||||
* INVARIANT : aucun lancement applicatif ni accès aux fenêtres d'autrui.
|
||||
*/
|
||||
#include <gtk/gtk.h>
|
||||
#include <gdk/gdkwayland.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static unsigned created;
|
||||
static gboolean input(GIOChannel *channel, GIOCondition condition, gpointer data) {
|
||||
(void)channel; (void)data;
|
||||
if (condition & (G_IO_HUP | G_IO_ERR)) { gtk_main_quit(); return FALSE; }
|
||||
char buffer[256];
|
||||
if (!fgets(buffer, sizeof buffer, stdin)) { gtk_main_quit(); return FALSE; }
|
||||
if (!strcmp(buffer, "quit\n")) { gtk_main_quit(); return FALSE; }
|
||||
if (created >= 256 || !g_regex_match_simple(
|
||||
"^create labfy-v2-anchor-[a-f0-9]{32}-[A-Za-z0-9_.-]{1,96}\\n$", buffer, 0, 0)) {
|
||||
gtk_main_quit(); return FALSE;
|
||||
}
|
||||
buffer[strcspn(buffer, "\n")] = 0;
|
||||
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
gtk_window_set_title(GTK_WINDOW(window), "Restauration de session…");
|
||||
gtk_window_set_default_size(GTK_WINDOW(window), 120, 80);
|
||||
gtk_widget_set_size_request(window, 1, 1);
|
||||
gtk_widget_show_all(window);
|
||||
/* GTK remplace app_id au mapping : publier notre identité ensuite. */
|
||||
gdk_wayland_window_set_application_id(gtk_widget_get_window(window), buffer + 7);
|
||||
gdk_display_flush(gtk_widget_get_display(window));
|
||||
++created;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
g_set_prgname("labfy-v2-anchor");
|
||||
gtk_init(&argc, &argv);
|
||||
if (!GDK_IS_WAYLAND_DISPLAY(gdk_display_get_default())) return 2;
|
||||
setvbuf(stdin, NULL, _IONBF, 0);
|
||||
GIOChannel *channel = g_io_channel_unix_new(0);
|
||||
g_io_add_watch(channel, G_IO_IN | G_IO_HUP | G_IO_ERR, input, NULL);
|
||||
gtk_main();
|
||||
g_io_channel_unref(channel);
|
||||
return 0;
|
||||
}
|
||||
109
quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py
Normal file
109
quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Ressources temporaires avec PID runtime épinglé et identité exacte.
|
||||
|
||||
La propriété ne découle jamais d'un préfixe. Tout kill est précédé d'une nouvelle
|
||||
lecture et d'un contrôle pidfd + PID du helper + app_id autorisé de la transaction.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
from .errors import require
|
||||
from .ipc import until, canonical, location
|
||||
from .layout import compile_plan, trees
|
||||
from .model import normalized
|
||||
|
||||
|
||||
class Anchors:
|
||||
def __init__(self, sway, data, tx, executable, env=None):
|
||||
require(Path(executable).is_absolute() and Path(executable).is_file(), "ANCHOR_HELPER_UNAVAILABLE")
|
||||
self.sway, self.data, self.tx = sway, data, tx
|
||||
self.plan = compile_plan(data, tx)
|
||||
self.ids = {}
|
||||
self.active = True
|
||||
self.process = subprocess.Popen([str(executable)], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, env=env, start_new_session=True)
|
||||
self.pidfd = os.pidfd_open(self.process.pid)
|
||||
self.allowed = {self.identity(s["slot_id"]) for s in data["window_slots"] if not s["state"]["floating"]}
|
||||
|
||||
def identity(self, slot): return "labfy-v2-anchor-" + self.tx + "-" + slot
|
||||
|
||||
def owned(self, node):
|
||||
signal.pidfd_send_signal(self.pidfd, 0)
|
||||
return node.get("pid") == self.process.pid and node.get("app_id") in self.allowed
|
||||
|
||||
def anchor(self, slot):
|
||||
require(self.active, "TRANSACTION_INACTIVE")
|
||||
found = [n for n in self.sway.windows() if n.get("app_id") == self.identity(slot)]
|
||||
require(len(found) == 1 and self.owned(found[0]), "ANCHOR_OWNERSHIP_INVALID")
|
||||
return found[0]
|
||||
|
||||
def mutate(self, slot, operation):
|
||||
node = self.anchor(slot)
|
||||
self.sway.command(f'[con_id={node["id"]}] {operation}')
|
||||
|
||||
def build(self, progress=lambda *_: None):
|
||||
outputs = {w["workspace"]: w["output"] for w in self.data["workspaces"]}
|
||||
for op in self.plan:
|
||||
if op.kind == "workspace":
|
||||
self.sway.command("workspace " + json.dumps(op.workspace))
|
||||
self.sway.command("move workspace to output " + json.dumps(outputs[op.workspace]))
|
||||
elif op.kind == "create":
|
||||
self.process.stdin.write(("create " + self.identity(op.slot_id) + "\n").encode())
|
||||
self.process.stdin.flush()
|
||||
until(lambda: any(n.get("app_id") == self.identity(op.slot_id) for n in self.sway.windows()))
|
||||
self.ids[op.slot_id] = self.anchor(op.slot_id)["id"]
|
||||
progress("anchor", len(self.ids))
|
||||
elif op.kind == "focus": self.mutate(op.slot_id, "focus")
|
||||
elif op.kind == "layout":
|
||||
self.mutate(op.slot_id, "focus")
|
||||
layout = "stacking" if op.layout == "stacked" else op.layout
|
||||
if op.root: self.mutate(op.slot_id, "layout " + layout)
|
||||
else:
|
||||
self.mutate(op.slot_id, "split " + ("v" if op.layout == "splitv" else "h"))
|
||||
if op.layout in ("tabbed", "stacked"): self.mutate(op.slot_id, "layout " + layout)
|
||||
else: require(False, "ANCHOR_PLAN_INVALID")
|
||||
self.verify(self.ids)
|
||||
progress("all-anchors", len(self.ids))
|
||||
|
||||
def verify(self, mapping):
|
||||
tree = self.sway.tree()
|
||||
reverse = {v: k for k, v in mapping.items()}
|
||||
for t in trees(self.data):
|
||||
require(canonical(tree, t.workspace, reverse) == normalized(t.root), "FINAL_TREE_MISMATCH")
|
||||
|
||||
def swap(self, slot, target, target_workspace, stage, allowed_app_ids):
|
||||
anchor = self.anchor(slot)
|
||||
before = self.sway.tree()
|
||||
live = next((n for n in self.sway.windows() if n["id"] == target["id"]), None)
|
||||
require(live and live.get("pid") == target.get("pid") and live.get("app_id") == target.get("app_id") and live.get("app_id") in allowed_app_ids,
|
||||
"TARGET_IDENTITY_CHANGED")
|
||||
prior_a, prior_t = location(before, anchor["id"]), location(before, target["id"])
|
||||
require(prior_a and prior_a[0] == target_workspace and prior_t and prior_t[0] == stage, "SWAP_WORKSPACE_CHANGED")
|
||||
self.sway.command(f'[con_id={live["id"]}] swap container with con_id {anchor["id"]}')
|
||||
after = self.sway.tree()
|
||||
require(location(after, live["id"]) == prior_a and location(after, anchor["id"]) == prior_t, "SWAP_POSTCONDITION_FAILED")
|
||||
self.anchor(slot)
|
||||
|
||||
def cleanup(self, progress=lambda *_: None):
|
||||
progress("before-cleanup", 0)
|
||||
count = 0
|
||||
for old in self.sway.windows():
|
||||
if not self.owned(old): continue
|
||||
fresh = next((n for n in self.sway.windows() if n["id"] == old["id"]), None)
|
||||
if fresh and self.owned(fresh):
|
||||
self.sway.command(f'[con_id={fresh["id"]}] kill')
|
||||
count += 1
|
||||
progress("cleanup", count)
|
||||
until(lambda: not any(self.owned(n) for n in self.sway.windows()))
|
||||
return count
|
||||
|
||||
def close(self):
|
||||
self.active = False
|
||||
if not self.process.stdin.closed: self.process.stdin.close()
|
||||
try: self.process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
signal.pidfd_send_signal(self.pidfd, signal.SIGTERM)
|
||||
self.process.wait(timeout=3)
|
||||
os.close(self.pidfd)
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
"""Politique au niveau Application ; argv viennent du catalogue de confiance.
|
||||
|
||||
Le snapshot sélectionne seulement une entrée exacte du catalogue. Il ne peut
|
||||
introduire aucun exécutable, argument, commande shell ni commande de terminal.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
import subprocess
|
||||
from .errors import require, Failure
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Spec:
|
||||
application_id: str
|
||||
desktop_entry: str
|
||||
app_ids: tuple
|
||||
strategy: str
|
||||
argv: tuple
|
||||
provider: str | None = None
|
||||
managed_by: str | None = None
|
||||
|
||||
|
||||
DEFAULT = (
|
||||
Spec("limusic", "limusic.desktop", ("limusic-app",), "managed-autostart", (), managed_by="sway-autostart"),
|
||||
Spec("firefox", "firefox.desktop", ("firefox", "org.mozilla.firefox"), "browser-self-restore", ("/usr/bin/firefox",), "firefox-session-window-uuid"),
|
||||
Spec("kitty", "kitty.desktop", ("kitty",), "terminal", ("/usr/bin/kitty",)),
|
||||
)
|
||||
|
||||
|
||||
class Catalog:
|
||||
def __init__(self, specs=DEFAULT):
|
||||
self.specs = {s.application_id: s for s in specs}
|
||||
require(len(self.specs) == len(specs), "CATALOG_DUPLICATE")
|
||||
ids = [v for s in specs for v in s.app_ids]
|
||||
require(len(ids) == len(set(ids)), "CATALOG_AMBIGUOUS")
|
||||
|
||||
def classify(self, node):
|
||||
found = [s for s in self.specs.values() if node.get("app_id") in s.app_ids]
|
||||
require(len(found) == 1, "APPLICATION_UNSUPPORTED")
|
||||
return found[0]
|
||||
|
||||
def validate(self, app):
|
||||
require(app["application_id"] in self.specs, "APPLICATION_UNSUPPORTED")
|
||||
s = self.specs[app["application_id"]]
|
||||
require(app["desktop_entry"] == s.desktop_entry and app["strategy"] == s.strategy and app["identity_provider"] == s.provider,
|
||||
"APPLICATION_POLICY_MISMATCH")
|
||||
require(s.strategy != "unknown", "APPLICATION_UNSUPPORTED")
|
||||
owner = (s.managed_by or "autostart") if s.strategy == "managed-autostart" else None
|
||||
require(app["managed_by"] == owner, "APPLICATION_POLICY_MISMATCH")
|
||||
return s
|
||||
|
||||
|
||||
def launch_count(app, observed):
|
||||
n = app["expected_windows"]
|
||||
require(0 <= observed <= n, "UNEXPECTED_EXTRA_WINDOW")
|
||||
strategy = app["strategy"]
|
||||
if strategy == "managed-autostart": return 0
|
||||
if strategy == "single-window":
|
||||
require(n == 1, "APPLICATION_POLICY_MISMATCH")
|
||||
return 1 - observed
|
||||
if strategy == "browser-self-restore":
|
||||
# Une instance déjà présente n'autorise jamais une deuxième ouverture.
|
||||
return 1 if observed == 0 else 0
|
||||
if strategy in ("terminal", "multi-instance"): return n - observed
|
||||
raise Failure("APPLICATION_UNSUPPORTED")
|
||||
|
||||
|
||||
def launch(spec, count, env=None):
|
||||
require(type(count) is int and 0 <= count <= 256, "LAUNCH_BOUNDS")
|
||||
children = []
|
||||
for _ in range(count):
|
||||
# Les applications ne sont pas des ressources jetables : jamais tuées
|
||||
# automatiquement lors d'un échec de restauration.
|
||||
children.append(subprocess.Popen(list(spec.argv), stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, start_new_session=True))
|
||||
return children
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"""Namespace distinct de V1 ; noms non interprétés comme chemins."""
|
||||
import re
|
||||
from . import schema
|
||||
from .storage import atomic, private_dir, read, sync_dir, lock
|
||||
from .errors import require
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, root):
|
||||
self.root = private_dir(root)
|
||||
self.sessions = private_dir(self.root / "sessions")
|
||||
self.automatic = private_dir(self.root / "automatic")
|
||||
|
||||
def path(self, name):
|
||||
require(type(name) is str and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", name), "SESSION_NAME_INVALID")
|
||||
return self.sessions / (name + ".json")
|
||||
|
||||
def save(self, name, data):
|
||||
with lock(self.root, "store.lock"): atomic(self.path(name), data, schema.validate)
|
||||
|
||||
def load(self, name): return read(self.path(name), schema.validate)
|
||||
def list(self): return sorted(p.stem for p in self.sessions.glob("*.json") if not p.is_symlink())
|
||||
|
||||
def delete(self, name):
|
||||
with lock(self.root, "store.lock"):
|
||||
path = self.path(name)
|
||||
read(path, schema.validate)
|
||||
path.unlink()
|
||||
sync_dir(self.sessions)
|
||||
|
||||
def last(self): return read(self.automatic / "last.json", schema.validate)
|
||||
|
||||
def checkpoint(self, data):
|
||||
with lock(self.root, "store.lock"): atomic(self.automatic / "last.json", data, schema.validate)
|
||||
103
quickshell/.config/quickshell/labfy-sway/session_v2/cli.py
Normal file
103
quickshell/.config/quickshell/labfy-sway/session_v2/cli.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""CLI backend ; pas d'appel logout/reboot, pas d'intégration QuickShell V1."""
|
||||
import argparse
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import uuid
|
||||
from .errors import Failure, require
|
||||
from .storage import paths, read, atomic, lock, private_dir
|
||||
from .schema import validate
|
||||
from .checkpoint import Store
|
||||
from .ipc import Sway
|
||||
from .applications import Catalog
|
||||
from .firefox_identity import FirefoxProvider
|
||||
from .snapshot import capture
|
||||
from .layout import compile_plan
|
||||
from .executor import Executor
|
||||
from .observability import validate as validate_report
|
||||
from .startup import acknowledge
|
||||
|
||||
|
||||
def migration(path):
|
||||
value = read(path)
|
||||
require(type(value) is dict, "V1_FORMAT_UNRECOGNIZED")
|
||||
require(value.get("schema") == "labfy.sway.session-snapshot" and type(value.get("version")) is int and value["version"] == 1, "V1_FORMAT_UNRECOGNIZED")
|
||||
# Analyse conservatrice, jamais de conversion ni copie des identités runtime.
|
||||
windows = value.get("windows", [])
|
||||
require(type(windows) is list and len(windows) <= 256, "V1_FORMAT_UNRECOGNIZED")
|
||||
require(all(type(w) is dict for w in windows), "V1_FORMAT_UNRECOGNIZED")
|
||||
require(all(type(w.get("scratchpad", {})) is dict for w in windows), "V1_FORMAT_UNRECOGNIZED")
|
||||
known = all(w.get("app_id") in ("kitty", "firefox", "org.mozilla.firefox") for w in windows)
|
||||
unsupported = any(w.get("fullscreen_mode") or w.get("scratchpad", {}).get("member") for w in windows)
|
||||
classification = "unsupported" if not known or unsupported or not windows else "needs exact identity" if any(w.get("app_id") in ("firefox", "org.mozilla.firefox") for w in windows) else "migratable"
|
||||
return {"classification": classification, "windows": len(windows), "migratable": classification == "migratable",
|
||||
"reason": "READ_ONLY_ANALYSIS_NO_CONVERSION"}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="Backend de restauration de session V2")
|
||||
parser.add_argument("command", choices=("capture", "save", "list", "show", "delete", "plan", "apply", "checkpoint-last", "startup-status", "acknowledge-startup", "apply-last", "restore-status", "analyze-v1-migration"))
|
||||
parser.add_argument("name", nargs="?")
|
||||
parser.add_argument("--execute", action="store_true")
|
||||
parser.add_argument("--stdout", action="store_true")
|
||||
parser.add_argument("--choice", choices=("new", "restored"))
|
||||
parser.add_argument("--transaction-id")
|
||||
parser.add_argument("--reason", choices=("logout", "reboot", "poweroff"), default="logout")
|
||||
parser.add_argument("--socket", default=os.environ.get("SWAYSOCK", ""))
|
||||
parser.add_argument("--anchor-binary", default=str(Path(__file__).parent / "libexec/labfy-v2-anchor"))
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if args.command in ("capture", "save", "delete", "apply", "checkpoint-last", "apply-last", "acknowledge-startup"):
|
||||
require(args.execute, "EXECUTE_REQUIRED")
|
||||
state, runtime = paths()
|
||||
store = Store(state)
|
||||
command = args.command
|
||||
if command == "list": result = {"sessions": store.list()}
|
||||
elif command == "show": result = store.load(args.name)
|
||||
elif command == "delete": store.delete(args.name); result = {"status": "deleted"}
|
||||
elif command == "restore-status":
|
||||
try: result = read(runtime / "session-v2-restore-attempt.json", validate_report)
|
||||
except FileNotFoundError: result = {"status": "idle", "reason": "NO_RESTORE_ATTEMPT"}
|
||||
elif command == "analyze-v1-migration": result = migration(args.name)
|
||||
elif command == "plan": result = {"operations": [asdict(x) for x in compile_plan(store.load(args.name), "0" * 32)]}
|
||||
else:
|
||||
sway = Sway(args.socket)
|
||||
catalog = Catalog()
|
||||
provider = FirefoxProvider(sway, runtime)
|
||||
if command == "acknowledge-startup":
|
||||
result = acknowledge(runtime, sway.session, args.choice, args.transaction_id)
|
||||
elif command == "startup-status":
|
||||
try: last = store.last()
|
||||
except FileNotFoundError: last = None
|
||||
if last is None:
|
||||
result = {"eligible": False, "claimed": False}
|
||||
else:
|
||||
marker = private_dir(runtime / "session-v2") / "startup.json"
|
||||
with lock(marker.parent):
|
||||
seen = read(marker) if marker.exists() else {}
|
||||
eligible = last["metadata"]["sway_session"] != sway.session and seen.get("session") != sway.session
|
||||
if args.execute and eligible: atomic(marker, {"session": sway.session})
|
||||
result = {"eligible": eligible, "claimed": bool(args.execute and eligible)}
|
||||
result.update(windows=len(last['window_slots']), workspaces=len(last['workspaces']))
|
||||
elif command in ("capture", "save", "checkpoint-last"):
|
||||
with lock(private_dir(runtime / "session-v2")):
|
||||
data = capture(sway, catalog, provider, args.reason if command == "checkpoint-last" else "manual")
|
||||
if command == "save": store.save(args.name, data)
|
||||
elif command == "checkpoint-last": store.checkpoint(data)
|
||||
result = data if command == "capture" else {"status": "saved"}
|
||||
else:
|
||||
data = store.last() if command == "apply-last" else store.load(args.name)
|
||||
result = Executor(sway, catalog, provider, runtime, args.anchor_binary).apply(data, execute=True)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
except Failure as exc:
|
||||
print(json.dumps({"status": "failed", "reason": exc.code}))
|
||||
return 2
|
||||
except (OSError, TypeError, ValueError):
|
||||
print(json.dumps({"status": "failed", "reason": "BACKEND_IO_OR_FORMAT_ERROR"}))
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__": sys.exit(main())
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
class Failure(Exception):
|
||||
"""Codes stables uniquement ; aucune exception externe ni contenu utilisateur."""
|
||||
def __init__(self, code):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def require(condition, code):
|
||||
if not condition: raise Failure(code)
|
||||
197
quickshell/.config/quickshell/labfy-sway/session_v2/executor.py
Normal file
197
quickshell/.config/quickshell/labfy-sway/session_v2/executor.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Transaction de restauration fail-safe, sans promesse de rollback ACID.
|
||||
|
||||
Les applications sont conservées après erreur. Seul le helper possédé peut être
|
||||
supprimé. La vérification de l'arbre n'est jamais remplacée par la cardinalité.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from .errors import Failure, require
|
||||
from .schema import validate, PROVIDER
|
||||
from .storage import lock, private_dir
|
||||
from .ipc import location, workspace, walk, until
|
||||
from .applications import launch_count, launch
|
||||
from .anchors import Anchors
|
||||
from .guard import Guard
|
||||
from .observability import Attempt
|
||||
from .slots import assign
|
||||
|
||||
|
||||
def preflight(data, sway, catalog, provider):
|
||||
validate(data)
|
||||
active = {o["name"] for o in sway.outputs() if o.get("active")}
|
||||
require({o["name"] for o in data["outputs"]}.issubset(active), "OUTPUT_UNAVAILABLE")
|
||||
specs = {a["application_id"]: catalog.validate(a) for a in data["applications"]}
|
||||
if any(a["identity_provider"] == PROVIDER for a in data["applications"]):
|
||||
require(provider is not None, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE")
|
||||
nodes = sway.windows()
|
||||
admitted = set()
|
||||
for a in data["applications"]:
|
||||
members = [n for n in nodes if n.get("app_id") in specs[a["application_id"]].app_ids]
|
||||
launch_count(a, len(members))
|
||||
admitted.update(n["id"] for n in members)
|
||||
tree = sway.tree()
|
||||
for w in data["workspaces"]:
|
||||
ws = workspace(tree, w["workspace"])
|
||||
if ws:
|
||||
require(all(n["id"] in admitted for n in walk(ws) if n.get("app_id") or n.get("window")), "WORKSPACE_OCCUPIED")
|
||||
return specs
|
||||
|
||||
|
||||
class Executor:
|
||||
def __init__(self, sway, catalog, provider, runtime, anchor_binary, env=None, progress=None):
|
||||
self.sway, self.catalog, self.provider = sway, catalog, provider
|
||||
self.runtime = private_dir(runtime)
|
||||
self.binary, self.env = anchor_binary, env
|
||||
self.progress = progress or (lambda *_: None)
|
||||
|
||||
def apply(self, data, execute=False, timeout=30):
|
||||
require(execute is True, "EXECUTE_REQUIRED")
|
||||
require(type(timeout) in (int, float) and 0 < timeout <= 60, "TIMEOUT_INVALID")
|
||||
with lock(private_dir(self.runtime / "session-v2")) as lock_fd:
|
||||
return self._apply(data, timeout, lock_fd)
|
||||
|
||||
def _apply(self, data, timeout, lock_fd):
|
||||
validate(data)
|
||||
tx = uuid.uuid4().hex
|
||||
attempt = Attempt(self.runtime / "session-v2-restore-attempt.json", tx, data)
|
||||
anchors, guard = None, None
|
||||
mapping = {}
|
||||
stage = "labfy-v2-stage-" + tx
|
||||
try:
|
||||
specs = preflight(data, self.sway, self.catalog, self.provider)
|
||||
attempt.update(phase="guard")
|
||||
anchors = Anchors(self.sway, data, tx, self.binary, self.env)
|
||||
guard = Guard(self.sway, anchors, self.runtime, attempt.path, lock_fd)
|
||||
attempt.update(helpers_suspended=guard.ready["helpers_suspended"])
|
||||
all_ids = {a for s in specs.values() for a in s.app_ids}
|
||||
def stage_node(n):
|
||||
fresh = next((x for x in self.sway.windows() if x["id"] == n["id"]), None)
|
||||
require(fresh and fresh.get("pid") == n.get("pid") and fresh.get("app_id") == n.get("app_id") and fresh.get("app_id") in all_ids,
|
||||
"TARGET_IDENTITY_CHANGED")
|
||||
self.sway.command(f'[con_id={n["id"]}] move container to workspace ' + json.dumps(stage))
|
||||
for n in self.sway.windows():
|
||||
if n.get("app_id") in all_ids: stage_node(n)
|
||||
def progress(phase, count):
|
||||
values = {"phase": phase}
|
||||
if phase == "anchor": values["anchors_created"] = count
|
||||
elif phase == "cleanup": values["anchors_cleaned"] = count
|
||||
attempt.update(**values)
|
||||
self.progress(phase, count)
|
||||
start = time.monotonic()
|
||||
anchors.build(progress)
|
||||
attempt.data["timings_ms"]["anchor_build"] = (time.monotonic() - start) * 1000
|
||||
attempt.update(phase="launch")
|
||||
self.sway.command("workspace " + json.dumps(stage))
|
||||
children = []
|
||||
start = time.monotonic()
|
||||
for app in data["applications"]:
|
||||
spec = specs[app["application_id"]]
|
||||
count = sum(n.get("app_id") in spec.app_ids for n in self.sway.windows())
|
||||
children.extend(launch(spec, launch_count(app, count), self.env))
|
||||
end = time.monotonic() + timeout
|
||||
groups = {}
|
||||
while True:
|
||||
nodes = self.sway.windows()
|
||||
groups = {a["application_id"]: [n for n in nodes if n.get("app_id") in specs[a["application_id"]].app_ids] for a in data["applications"]}
|
||||
require(all(len(groups[a["application_id"]]) <= a["expected_windows"] for a in data["applications"]), "UNEXPECTED_EXTRA_WINDOW")
|
||||
if all(len(groups[a["application_id"]]) == a["expected_windows"] for a in data["applications"]): break
|
||||
require(time.monotonic() < end, "APPLICATION_WINDOWS_INCOMPLETE")
|
||||
time.sleep(.025)
|
||||
attempt.data["timings_ms"]["application_wait"] = (time.monotonic() - start) * 1000
|
||||
attempt.update(phase="resolve", applications_observed=len(groups))
|
||||
for app in data["applications"]:
|
||||
members = [s for s in data["window_slots"] if s["application_id"] == app["application_id"]]
|
||||
identity = None
|
||||
if app["identity_provider"] == PROVIDER:
|
||||
expected = [s["identity_evidence"]["id"] for s in members if s["identity_requirement"] == "exact"]
|
||||
identity = self.provider.resolve(expected, groups[app["application_id"]]) if expected else self.provider.capture(groups[app["application_id"]])
|
||||
attempt.data["providers"] = [PROVIDER]
|
||||
attempt.data["identity_resolutions"] = [{"uuid": k, "con_id": v} for k, v in identity.items()]
|
||||
mapping.update(assign(members, groups[app["application_id"]], identity))
|
||||
captured = {n["id"]: n for group in groups.values() for n in group}
|
||||
for n in captured.values():
|
||||
if location(self.sway.tree(), n["id"])[0] != stage: stage_node(n)
|
||||
if n.get("type") == "floating_con" or n.get("floating") in ("user_on", "auto_on"):
|
||||
self.sway.command(f'[con_id={n["id"]}] floating disable')
|
||||
start = time.monotonic()
|
||||
for slot in data["window_slots"]:
|
||||
if slot["state"]["floating"]: continue
|
||||
target = captured[mapping[slot["slot_id"]]]
|
||||
anchors.swap(slot["slot_id"], target, slot["workspace"], stage, specs[slot["application_id"]].app_ids)
|
||||
attempt.update(phase="swap", anchors_swapped=attempt.data["anchors_swapped"] + 1)
|
||||
self.progress("swap", attempt.data["anchors_swapped"])
|
||||
attempt.data["timings_ms"]["swap"] = (time.monotonic() - start) * 1000
|
||||
start = time.monotonic()
|
||||
anchors.cleanup(progress)
|
||||
attempt.data["timings_ms"]["cleanup"] = (time.monotonic() - start) * 1000
|
||||
start = time.monotonic()
|
||||
anchors.verify(mapping)
|
||||
self._floating(data, mapping, captured)
|
||||
self._verify(data, mapping, specs, anchors)
|
||||
attempt.data["timings_ms"]["verification"] = (time.monotonic() - start) * 1000
|
||||
attempt.update(final_tree_verified=True, slots_filled=len(mapping), phase="focus")
|
||||
if data["focus"]:
|
||||
self.sway.command(f'[con_id={mapping[data["focus"]]}] focus')
|
||||
answer = guard.resume()
|
||||
attempt.update(helpers_resumed=answer["helpers_resumed"], phase="final-verification")
|
||||
self.progress("after-resume", len(mapping))
|
||||
self._verify(data, mapping, specs, anchors)
|
||||
require(data["focus"] is None or any(n["id"] == mapping[data["focus"]] and n.get("focused") for n in self.sway.windows()), "FINAL_FOCUS_MISMATCH")
|
||||
attempt.update(final_focus_verified=True, status="success", reason="SUCCESS", phase="complete")
|
||||
guard.finish()
|
||||
guard = None
|
||||
return attempt.data
|
||||
except Exception as exc:
|
||||
code = exc.code if isinstance(exc, Failure) else "RESTORE_INTERNAL_ERROR"
|
||||
attempt.update(status="failed", reason=code, phase="cleanup-error")
|
||||
raise Failure(code) from None
|
||||
finally:
|
||||
if guard is not None:
|
||||
try:
|
||||
answer = guard.finish()
|
||||
attempt.update(helpers_resumed=answer["helpers_resumed"], anchors_cleaned=attempt.data["anchors_cleaned"] + answer["anchors_cleaned"])
|
||||
except Exception: attempt.update(status="failed", reason="GUARD_RECOVERY_FAILED")
|
||||
if anchors is not None: anchors.close()
|
||||
|
||||
def _floating(self, data, mapping, captured):
|
||||
for s in data["window_slots"]:
|
||||
if not s["state"]["floating"]: continue
|
||||
target = mapping[s["slot_id"]]
|
||||
n = next((n for n in self.sway.windows() if n["id"] == target), None)
|
||||
require(n and n.get("pid") == captured[target].get("pid"), "TARGET_IDENTITY_CHANGED")
|
||||
self.sway.command(f'[con_id={target}] move container to workspace ' + json.dumps(s["workspace"]))
|
||||
# Sway rattache une fenêtre déplacée en coordonnées à l'espace
|
||||
# visible de l'output : rendre la cible visible avant sa géométrie.
|
||||
self.sway.command("workspace " + json.dumps(s["workspace"]))
|
||||
output = next(w["output"] for w in data["workspaces"] if w["workspace"] == s["workspace"])
|
||||
self.sway.command("move workspace to output " + json.dumps(output))
|
||||
self.sway.command(f'[con_id={target}] floating enable')
|
||||
g = s["state"]["geometry"]
|
||||
self.sway.command(f'[con_id={target}] resize set width {g["width"]} px height {g["height"]} px')
|
||||
self.sway.command(f'[con_id={target}] move absolute position {g["x"]} px {g["y"]} px')
|
||||
|
||||
def _verify(self, data, mapping, specs, anchors):
|
||||
anchors.verify(mapping)
|
||||
output_for = {w["name"]: o["name"] for o in self.sway.tree().get("nodes", [])
|
||||
for w in walk(o) if w.get("type") == "workspace"}
|
||||
require(all(output_for.get(w["workspace"]) == w["output"] for w in data["workspaces"]), "FINAL_OUTPUT_MISMATCH")
|
||||
nodes = self.sway.windows()
|
||||
for a in data["applications"]:
|
||||
observed = sum(n.get("app_id") in specs[a["application_id"]].app_ids for n in nodes)
|
||||
require(observed >= a["expected_windows"], "APPLICATION_WINDOWS_INCOMPLETE")
|
||||
require(observed <= a["expected_windows"], "UNEXPECTED_EXTRA_WINDOW")
|
||||
for s in data["window_slots"]:
|
||||
n = next((n for n in nodes if n["id"] == mapping[s["slot_id"]]), None)
|
||||
require(n and n.get("app_id") in specs[s["application_id"]].app_ids, "TARGET_IDENTITY_CHANGED")
|
||||
if s["state"]["floating"]:
|
||||
require(location(self.sway.tree(), n["id"])[0] == s["workspace"] and n.get("floating") in ("auto_on", "user_on") and
|
||||
all(n["rect"][k] == v for k, v in s["state"]["geometry"].items()), "FLOATING_GEOMETRY_MISMATCH")
|
||||
for a in data["applications"]:
|
||||
if a["identity_provider"] != PROVIDER: continue
|
||||
members = [n for n in nodes if n.get("app_id") in specs[a["application_id"]].app_ids]
|
||||
identities = self.provider.capture(members)
|
||||
for s in data["window_slots"]:
|
||||
if s["application_id"] == a["application_id"] and s["identity_requirement"] == "exact":
|
||||
require(identities.get(s["identity_evidence"]["id"]) == mapping[s["slot_id"]], "EXACT_WINDOW_IDENTITY_MISSING")
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"""Interface provider ; observations runtime privées, vivantes et périssables.
|
||||
|
||||
Le moteur ne connaît ni WebExtension ni titrePreface. Le provider exige un mapping
|
||||
complet du jeu courant de surfaces, avec publisher vivant, puis un arbre stable.
|
||||
Les fichiers runtime ne sont jamais des identités persistantes du snapshot.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import time
|
||||
from .errors import Failure, require
|
||||
from .schema import UUID, PROVIDER, fields, integer
|
||||
from .storage import private_dir, read, atomic
|
||||
from .ipc import until
|
||||
|
||||
|
||||
class IdentityProvider(ABC):
|
||||
@abstractmethod
|
||||
def available(self): pass
|
||||
@abstractmethod
|
||||
def capture(self, nodes, timeout=6): pass
|
||||
@abstractmethod
|
||||
def resolve(self, expected, nodes, timeout=6): pass
|
||||
def verify(self, mapping, nodes): return self.capture(nodes) == mapping
|
||||
|
||||
|
||||
def process_start(pid): return Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()[19]
|
||||
|
||||
|
||||
def validate_record(r):
|
||||
fields(r, "schema version session uuid con_id observed_ns publisher_pid publisher_start")
|
||||
require(r["schema"] == PROVIDER and type(r["version"]) is int and r["version"] == 1, "PROVIDER_RECORD_INVALID")
|
||||
require(type(r["uuid"]) is str and UUID.fullmatch(r["uuid"]), "INVALID_UUID")
|
||||
for key in ("con_id", "observed_ns", "publisher_pid"): integer(r[key], 1, 2**63 - 1)
|
||||
require(type(r["publisher_start"]) is str and r["publisher_start"].isdigit(), "PROVIDER_RECORD_INVALID")
|
||||
return r
|
||||
|
||||
|
||||
class FirefoxProvider(IdentityProvider):
|
||||
def __init__(self, sway, runtime):
|
||||
self.sway = sway
|
||||
self.directory = private_dir(Path(runtime) / "session-v2/firefox" / sway.session)
|
||||
|
||||
def available(self):
|
||||
for p in list(self.directory.glob("*.json"))[:257]:
|
||||
if not p.stem.isdigit(): continue
|
||||
try:
|
||||
if self._valid(p): return True
|
||||
except (OSError, Failure): continue
|
||||
return False
|
||||
|
||||
def _valid(self, path):
|
||||
r = read(path, validate_record)
|
||||
require(r["session"] == self.sway.session and 0 <= time.monotonic_ns() - r["observed_ns"] < 3_000_000_000, "PROVIDER_STALE")
|
||||
require(Path(f'/proc/{r["publisher_pid"]}').stat().st_uid == os.getuid(), "PROVIDER_STALE")
|
||||
fd = os.pidfd_open(r["publisher_pid"])
|
||||
try:
|
||||
require(process_start(r["publisher_pid"]) == r["publisher_start"], "PROVIDER_STALE")
|
||||
signal.pidfd_send_signal(fd, 0)
|
||||
finally: os.close(fd)
|
||||
return r
|
||||
|
||||
def capture(self, nodes, timeout=6):
|
||||
expected = {n["id"] for n in nodes}
|
||||
if not expected: return {}
|
||||
requested = time.monotonic_ns()
|
||||
atomic(self.directory / "request.json", {"expires_ns": requested + int(timeout * 1e9)})
|
||||
def collect():
|
||||
try:
|
||||
rows = [self._valid(self.directory / f"{i}.json") for i in sorted(expected)]
|
||||
if any(r["observed_ns"] < requested for r in rows): return None
|
||||
except (OSError, Failure): return None
|
||||
require(len({r["publisher_pid"] for r in rows}) == 1, "PROVIDER_MULTIPLE_PROFILES_UNSUPPORTED")
|
||||
require(len({r["uuid"] for r in rows}) == len(rows), "EXACT_WINDOW_IDENTITY_COLLISION")
|
||||
live = {n["id"]: n for n in self.sway.windows()}
|
||||
require(expected.issubset(live), "APPLICATION_WINDOWS_INCOMPLETE")
|
||||
# Ne livrer qu'après retrait du préfixe, sans conserver les titres.
|
||||
if any((live[i].get("name") or "").startswith("[LABFY:") for i in expected): return None
|
||||
return {r["uuid"]: r["con_id"] for r in rows}
|
||||
try: return until(collect, timeout, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE")
|
||||
finally: atomic(self.directory / "request.json", {"expires_ns": 0})
|
||||
|
||||
def resolve(self, expected, nodes, timeout=6):
|
||||
result = self.capture(nodes, timeout)
|
||||
require(set(expected).issubset(result), "EXACT_WINDOW_IDENTITY_MISSING")
|
||||
# Les autres fenêtres peuvent correspondre à des slots explicitement
|
||||
# interchangeables ; la cardinalité applicative est vérifiée par l'executor.
|
||||
return result
|
||||
212
quickshell/.config/quickshell/labfy-sway/session_v2/guard.py
Normal file
212
quickshell/.config/quickshell/labfy-sway/session_v2/guard.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
"""Watchdog externe : reprise ne dépendant jamais du finally de l'executor.
|
||||
|
||||
CONTRACT : PIDFD hérités, UID/executable/script/SWAYSOCK vérifiés avant signal.
|
||||
Le lock de transaction est hérité : pas de tentative concurrente pendant recovery.
|
||||
INVARIANT : un helper initialement stoppé n'est jamais repris par Labfy.
|
||||
Limite : la mort simultanée du watchdog/compositeur n'est pas une transaction ACID.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from .errors import require
|
||||
from .ipc import Sway, until
|
||||
from .storage import atomic, read, loads
|
||||
|
||||
|
||||
def helper_state(pid): return Path(f"/proc/{pid}/status").read_text().split("State:\t", 1)[1][0]
|
||||
|
||||
|
||||
def verify_helper(pid, sway_socket, script):
|
||||
root = Path(f"/proc/{pid}")
|
||||
require(root.stat().st_uid == os.getuid(), "HELPER_UID_INVALID")
|
||||
argv = (root / "cmdline").read_bytes().split(b"\0")
|
||||
expected_exe = str(Path(sys.executable).resolve())
|
||||
require(str((root / "exe").resolve()) == expected_exe, "HELPER_EXECUTABLE_INVALID")
|
||||
require(len(argv) > 1 and os.fsdecode(argv[1]) == script, "HELPER_SCRIPT_INVALID")
|
||||
with (root / "environ").open("rb") as stream: env = stream.read(65537)
|
||||
require(len(env) <= 65536 and (b"SWAYSOCK=" + os.fsencode(sway_socket)) in env.split(b"\0"), "HELPER_SESSION_INVALID")
|
||||
|
||||
|
||||
def discover(sway_socket):
|
||||
script = shutil.which("autotiling")
|
||||
if not script: return []
|
||||
script = os.path.abspath(script)
|
||||
helpers = []
|
||||
for root in Path("/proc").iterdir():
|
||||
if not root.name.isdigit(): continue
|
||||
fd = None
|
||||
try:
|
||||
verify_helper(int(root.name), sway_socket, script)
|
||||
fd = os.pidfd_open(int(root.name))
|
||||
verify_helper(int(root.name), sway_socket, script)
|
||||
helpers.append({"pid": int(root.name), "fd": fd, "script": script})
|
||||
fd = None
|
||||
except Exception as exc:
|
||||
# Les autres processus ne sont ni des erreurs de restore, ni ciblés.
|
||||
from .errors import Failure
|
||||
if not isinstance(exc, (OSError, ValueError, Failure)): raise
|
||||
finally:
|
||||
if fd is not None: os.close(fd)
|
||||
if len(helpers) > 8:
|
||||
for h in helpers: os.close(h["fd"])
|
||||
require(False, "HELPER_COUNT_LIMIT")
|
||||
return helpers
|
||||
|
||||
|
||||
class Guard:
|
||||
def __init__(self, sway, anchors, runtime, report_path, lock_fd, deadline=120):
|
||||
self.helpers = discover(sway.path)
|
||||
self.parent, child = socket.socketpair(type=socket.SOCK_SEQPACKET)
|
||||
engine_fd = os.pidfd_open(os.getpid())
|
||||
read_fd, write_fd = os.pipe()
|
||||
config = {"socket": sway.path, "engine_fd": engine_fd, "anchor_pid": anchors.process.pid,
|
||||
"anchor_fd": anchors.pidfd, "writer_fd": anchors.process.stdin.fileno(), "allowed": sorted(anchors.allowed),
|
||||
"helpers": self.helpers, "control": child.fileno(), "report": str(report_path), "deadline": deadline}
|
||||
fds = (read_fd, engine_fd, anchors.pidfd, anchors.process.stdin.fileno(), child.fileno(), lock_fd,
|
||||
*(h["fd"] for h in self.helpers))
|
||||
try:
|
||||
self.process = subprocess.Popen([sys.executable, "-B", "-m", "session_v2.guard", str(read_fd)],
|
||||
pass_fds=fds, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
|
||||
env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parent.parent)})
|
||||
except Exception:
|
||||
os.close(write_fd)
|
||||
self.parent.close()
|
||||
for h in self.helpers: os.close(h["fd"])
|
||||
raise
|
||||
finally:
|
||||
os.close(read_fd)
|
||||
os.close(engine_fd)
|
||||
child.close()
|
||||
with os.fdopen(write_fd, "wb") as stream: stream.write(json.dumps(config).encode())
|
||||
self.parent.settimeout(10)
|
||||
try:
|
||||
self.ready = loads(self.parent.recv(4096), 4096)
|
||||
require(self.ready.get("status") == "ready", "GUARD_START_FAILED")
|
||||
except Exception:
|
||||
# EOF réveille le guard même si l'executor n'a pas reçu l'ACK.
|
||||
self.parent.close()
|
||||
for h in self.helpers: os.close(h["fd"])
|
||||
self.process.wait(timeout=15)
|
||||
raise
|
||||
|
||||
def resume(self):
|
||||
self.parent.sendall(b"resume")
|
||||
self.parent.settimeout(15)
|
||||
answer = loads(self.parent.recv(4096), 4096)
|
||||
require(answer.get("status") == "resumed", "GUARD_RECOVERY_FAILED")
|
||||
return answer
|
||||
|
||||
def finish(self):
|
||||
self.parent.sendall(b"finish")
|
||||
self.parent.settimeout(15)
|
||||
answer = loads(self.parent.recv(4096), 4096)
|
||||
self.process.wait(timeout=5)
|
||||
self.parent.close()
|
||||
for h in self.helpers: os.close(h["fd"])
|
||||
require(answer.get("status") == "finished", "GUARD_RECOVERY_FAILED")
|
||||
return answer
|
||||
|
||||
|
||||
def resume_helpers(stopped, sway_socket, counts):
|
||||
"""Une identité devenue invalide ne doit pas empêcher les autres reprises.
|
||||
|
||||
CONTRACT : ne jamais signaler un processus non vérifié ; tenter tous les
|
||||
helpers encore dus, puis signaler un échec global sans perdre leurs états.
|
||||
"""
|
||||
okay = True
|
||||
for h in stopped:
|
||||
if h.get("resumed"): continue
|
||||
try:
|
||||
verify_helper(h["pid"], sway_socket, h["script"])
|
||||
signal.pidfd_send_signal(h["fd"], signal.SIGCONT)
|
||||
until(lambda: helper_state(h["pid"]) not in ("T", "t"), 2)
|
||||
h["resumed"] = True
|
||||
counts["helpers_resumed"] += 1
|
||||
except (ProcessLookupError, FileNotFoundError): h["resumed"] = True
|
||||
except Exception: okay = False
|
||||
return okay
|
||||
|
||||
|
||||
def run(config):
|
||||
control = socket.socket(fileno=config["control"])
|
||||
stopped = []
|
||||
counts = {"helpers_suspended": 0, "helpers_resumed": 0}
|
||||
normal = False
|
||||
sway = Sway(config["socket"])
|
||||
def resume():
|
||||
return resume_helpers(stopped, config["socket"], counts)
|
||||
try:
|
||||
for h in config["helpers"]:
|
||||
verify_helper(h["pid"], config["socket"], h["script"])
|
||||
if helper_state(h["pid"]) in ("T", "t"): continue
|
||||
# Enregistrer l'obligation avant SIGSTOP : une exception après signal
|
||||
# ne doit pas perdre la responsabilité de reprise.
|
||||
stopped.append(h)
|
||||
signal.pidfd_send_signal(h["fd"], signal.SIGSTOP)
|
||||
until(lambda: helper_state(h["pid"]) in ("T", "t"), 2)
|
||||
counts["helpers_suspended"] += 1
|
||||
control.sendall(json.dumps({"status": "ready", **counts}).encode())
|
||||
end = time.monotonic() + config["deadline"]
|
||||
while True:
|
||||
readable, _, _ = select.select([config["engine_fd"], control], [], [], max(0, end - time.monotonic()))
|
||||
if config["engine_fd"] in readable: break
|
||||
if control in readable:
|
||||
message = control.recv(16)
|
||||
if message == b"resume":
|
||||
require(resume(), "GUARD_RECOVERY_FAILED")
|
||||
control.sendall(json.dumps({"status": "resumed", **counts}).encode())
|
||||
continue
|
||||
normal = message == b"finish"
|
||||
break
|
||||
if not readable:
|
||||
signal.pidfd_send_signal(config["engine_fd"], signal.SIGKILL)
|
||||
until(lambda: bool(select.select([config["engine_fd"]], [], [], 0)[0]), 3)
|
||||
break
|
||||
require(resume(), "GUARD_RECOVERY_FAILED")
|
||||
def owned(n):
|
||||
signal.pidfd_send_signal(config["anchor_fd"], 0)
|
||||
return n.get("pid") == config["anchor_pid"] and n.get("app_id") in config["allowed"]
|
||||
cleaned = 0
|
||||
for old in sway.windows():
|
||||
if not owned(old): continue
|
||||
fresh = next((n for n in sway.windows() if n["id"] == old["id"]), None)
|
||||
if fresh and owned(fresh):
|
||||
sway.command(f'[con_id={fresh["id"]}] kill')
|
||||
cleaned += 1
|
||||
until(lambda: not any(owned(n) for n in sway.windows()))
|
||||
os.write(config["writer_fd"], b"quit\n")
|
||||
if not normal:
|
||||
data = read(config["report"])
|
||||
if data["status"] != "success":
|
||||
data.update(status="interrupted", phase="recovered", reason="EXECUTOR_INTERRUPTED")
|
||||
data.update(**counts)
|
||||
data["anchors_cleaned"] += cleaned
|
||||
atomic(config["report"], data)
|
||||
else: control.sendall(json.dumps({"status": "finished", "anchors_cleaned": cleaned, **counts}).encode())
|
||||
except Exception:
|
||||
resume()
|
||||
# Arrêter seulement le helper possédé retire aussi ses anchors orphelins.
|
||||
try: signal.pidfd_send_signal(config["anchor_fd"], signal.SIGTERM)
|
||||
except ProcessLookupError: pass
|
||||
try:
|
||||
data = read(config["report"])
|
||||
data.update(status="interrupted", phase="guard-error", reason="GUARD_RECOVERY_FAILED", **counts)
|
||||
atomic(config["report"], data)
|
||||
control.sendall(b'{"status":"failed"}')
|
||||
except (OSError, BrokenPipeError): pass
|
||||
finally:
|
||||
resume()
|
||||
control.close()
|
||||
os.close(config["writer_fd"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with os.fdopen(int(sys.argv[1]), "rb") as stream: config = loads(stream.read(65537), 65536)
|
||||
run(config)
|
||||
91
quickshell/.config/quickshell/labfy-sway/session_v2/ipc.py
Normal file
91
quickshell/.config/quickshell/labfy-sway/session_v2/ipc.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Sway IPC explicite et borné. Le socket identifie la session, pas un PID persisté."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import time
|
||||
from .errors import Failure, require
|
||||
|
||||
|
||||
def walk(node):
|
||||
yield node
|
||||
for c in node.get("nodes", []) + node.get("floating_nodes", []): yield from walk(c)
|
||||
|
||||
|
||||
def until(fn, timeout=5, code="RUNTIME_TIMEOUT"):
|
||||
end = time.monotonic() + timeout
|
||||
while True:
|
||||
value = fn()
|
||||
if value: return value
|
||||
if time.monotonic() >= end: raise Failure(code)
|
||||
time.sleep(.025)
|
||||
|
||||
|
||||
class Sway:
|
||||
def __init__(self, path):
|
||||
require(type(path) is str and os.path.isabs(path), "SWAY_SOCKET_INVALID")
|
||||
st = os.lstat(path)
|
||||
require(stat.S_ISSOCK(st.st_mode) and st.st_uid == os.getuid(), "SWAY_SOCKET_INVALID")
|
||||
self.path = path
|
||||
self.identity = (st.st_dev, st.st_ino)
|
||||
self.session = hashlib.sha256(f"{path}:{st.st_dev}:{st.st_ino}:{st.st_ctime_ns}".encode()).hexdigest()
|
||||
|
||||
def request(self, kind, payload=b""):
|
||||
st = os.lstat(self.path)
|
||||
require((st.st_dev, st.st_ino) == self.identity and st.st_uid == os.getuid(), "SWAY_SESSION_CHANGED")
|
||||
with socket.socket(socket.AF_UNIX) as connection:
|
||||
end = time.monotonic() + 3
|
||||
connection.settimeout(3)
|
||||
connection.connect(self.path)
|
||||
_, uid, _ = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
|
||||
require(uid == os.getuid(), "SWAY_PEER_INVALID")
|
||||
connection.sendall(struct.pack("<6sII", b"i3-ipc", len(payload), kind) + payload)
|
||||
def take(n):
|
||||
result = bytearray()
|
||||
while len(result) < n:
|
||||
require(time.monotonic() < end, "IPC_TIMEOUT")
|
||||
connection.settimeout(max(.001, end - time.monotonic()))
|
||||
part = connection.recv(n - len(result))
|
||||
require(part, "IPC_CLOSED")
|
||||
result.extend(part)
|
||||
return result
|
||||
magic, size, answer = struct.unpack("<6sII", take(14))
|
||||
require(magic == b"i3-ipc" and answer == kind and size <= 4 * 1024 * 1024, "IPC_BOUNDS")
|
||||
return json.loads(take(size))
|
||||
|
||||
def tree(self): return self.request(4)
|
||||
def outputs(self): return self.request(3)
|
||||
def windows(self): return [n for n in walk(self.tree()) if n.get("app_id") or n.get("window")]
|
||||
def command(self, text):
|
||||
result = self.request(0, text.encode())
|
||||
require(result and all(r.get("success") for r in result), "SWAY_COMMAND_FAILED")
|
||||
|
||||
|
||||
def location(tree, identity):
|
||||
def find(node, ws=None, path=()):
|
||||
if node.get("type") == "workspace": ws, path = node["name"], ()
|
||||
if node["id"] == identity: return ws, path
|
||||
for i, c in enumerate(node.get("nodes", [])):
|
||||
found = find(c, ws, path + (i,))
|
||||
if found: return found
|
||||
for c in node.get("floating_nodes", []):
|
||||
found = find(c, ws, ())
|
||||
if found: return found
|
||||
return find(tree)
|
||||
|
||||
|
||||
def workspace(tree, name):
|
||||
return next((n for n in walk(tree) if n.get("type") == "workspace" and n.get("name") == name), None)
|
||||
|
||||
|
||||
def canonical(tree, name, mapping):
|
||||
ws = workspace(tree, name)
|
||||
if not ws or not ws["nodes"]: return None
|
||||
def norm(node):
|
||||
if node.get("app_id") or node.get("window"): return mapping.get(node["id"], "UNEXPECTED")
|
||||
children = [norm(c) for c in node.get("nodes", [])]
|
||||
if len(children) == 1 and node["layout"] in ("splith", "splitv", "none"): return children[0]
|
||||
return {node["layout"]: children}
|
||||
return norm(ws)
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
"""Compilation pure, top-down, arité conservée ; aucune opération de lancement."""
|
||||
from dataclasses import dataclass
|
||||
from .model import SplitNode, leaves, WorkspaceTree, decode_tree
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Operation:
|
||||
kind: str
|
||||
transaction_id: str
|
||||
workspace: str
|
||||
slot_id: str = ""
|
||||
layout: str = ""
|
||||
parent: tuple = ()
|
||||
child_order: int = 0
|
||||
root: bool = False
|
||||
|
||||
|
||||
def trees(data): return [WorkspaceTree(w["workspace"], w["output"], decode_tree(w["root"])) for w in data["workspaces"]]
|
||||
|
||||
|
||||
def compile_plan(data, tx):
|
||||
from .schema import validate
|
||||
import re
|
||||
from .errors import require
|
||||
validate(data)
|
||||
require(re.fullmatch(r"[0-9a-f]{32}", tx), "TRANSACTION_ID_INVALID")
|
||||
plan = []
|
||||
for t in trees(data):
|
||||
def emit(kind, slot="", layout="", parent=(), order=0, root=False):
|
||||
plan.append(Operation(kind, tx, t.workspace, slot, layout, parent, order, root))
|
||||
def build(node, path=(), root=False):
|
||||
if not isinstance(node, SplitNode): return
|
||||
first = leaves(node.children[0])[0]
|
||||
emit("layout", first, node.layout, path, root=root)
|
||||
previous = first
|
||||
for i, child in enumerate(node.children[1:], 1):
|
||||
emit("focus", previous)
|
||||
seed = leaves(child)[0]
|
||||
emit("create", seed, parent=path, order=i)
|
||||
previous = seed
|
||||
for i, child in enumerate(node.children): build(child, path + (i,))
|
||||
if t.root is not None:
|
||||
emit("workspace")
|
||||
emit("create", leaves(t.root)[0])
|
||||
build(t.root, root=True)
|
||||
return tuple(plan)
|
||||
59
quickshell/.config/quickshell/labfy-sway/session_v2/model.py
Normal file
59
quickshell/.config/quickshell/labfy-sway/session_v2/model.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Identités persistantes pures. Aucun PID, con_id ni commande exécutable."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Application:
|
||||
application_id: str
|
||||
desktop_entry: str
|
||||
strategy: str
|
||||
expected_windows: int
|
||||
managed_by: str | None
|
||||
identity_provider: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowSlot:
|
||||
slot_id: str
|
||||
application_id: str
|
||||
workspace: str
|
||||
tree_path: tuple
|
||||
state: dict
|
||||
identity_requirement: str
|
||||
identity_evidence: dict | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowSlotRef:
|
||||
slot_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SplitNode:
|
||||
layout: str
|
||||
children: tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceTree:
|
||||
workspace: str
|
||||
output: str
|
||||
root: object
|
||||
|
||||
|
||||
def decode_tree(value):
|
||||
if value is None: return None
|
||||
if "slot_id" in value: return WindowSlotRef(value["slot_id"])
|
||||
return SplitNode(value["layout"], tuple(decode_tree(c) for c in value["children"]))
|
||||
|
||||
|
||||
def leaves(node):
|
||||
if node is None: return []
|
||||
if isinstance(node, WindowSlotRef): return [node.slot_id]
|
||||
return [s for c in node.children for s in leaves(c)]
|
||||
|
||||
|
||||
def normalized(node):
|
||||
if node is None: return None
|
||||
if isinstance(node, WindowSlotRef): return node.slot_id
|
||||
return {node.layout: [normalized(c) for c in node.children]}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
"""Host production candidat : seul map-window ; Sway GET_TREE exclusivement.
|
||||
|
||||
WHY : corrélation par jeton sans lire le suffixe sémantique ni l'exporter.
|
||||
CONTRACT : message natif <=4096, clés exactes, aucun shell/argv reçu.
|
||||
INVARIANT : publication privée runtime seulement, sans URL/titre/environnement.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from .errors import require, Failure
|
||||
from .schema import UUID, PROVIDER
|
||||
from .storage import loads, atomic, paths, read
|
||||
from .ipc import Sway
|
||||
from .firefox_identity import FirefoxProvider, process_start, validate_record
|
||||
|
||||
EXTENSION_ID = "session-v2@labfy.org"
|
||||
APP_IDS = {"firefox", "org.mozilla.firefox"}
|
||||
|
||||
|
||||
def validate(message):
|
||||
require(type(message) is dict and set(message) == {"op", "uuid", "runtime_token"}, "MESSAGE_SCHEMA")
|
||||
require(message["op"] == "map-window", "OP_NOT_ALLOWED")
|
||||
require(type(message["uuid"]) is str and UUID.fullmatch(message["uuid"]), "INVALID_UUID")
|
||||
require(type(message["runtime_token"]) is str and re.fullmatch(r"[0-9a-f]{32}", message["runtime_token"]), "INVALID_TOKEN")
|
||||
return message
|
||||
|
||||
|
||||
def take(stream, n, timeout=5):
|
||||
end, result = time.monotonic() + timeout, bytearray()
|
||||
while len(result) < n:
|
||||
require(time.monotonic() < end and select.select([stream], [], [], max(0, end - time.monotonic()))[0], "MESSAGE_TIMEOUT")
|
||||
data = stream.read(n - len(result))
|
||||
if not data: raise EOFError()
|
||||
result.extend(data)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def receive(stream):
|
||||
size, = struct.unpack("=I", take(stream, 4))
|
||||
require(0 < size <= 4096, "MESSAGE_BOUNDS")
|
||||
return validate(loads(take(stream, size), 4096))
|
||||
|
||||
|
||||
def send(stream, value):
|
||||
raw = json.dumps(value, separators=(",", ":")).encode()
|
||||
require(len(raw) <= 4096, "MESSAGE_BOUNDS")
|
||||
stream.write(struct.pack("=I", len(raw)) + raw)
|
||||
stream.flush()
|
||||
|
||||
|
||||
def correlate(sway, message):
|
||||
validate(message)
|
||||
prefix = "[LABFY:" + message["runtime_token"] + "] "
|
||||
end = time.monotonic() + 2
|
||||
while True:
|
||||
found = [n["id"] for n in sway.windows() if n.get("app_id") in APP_IDS and (n.get("name") or "").startswith(prefix)]
|
||||
require(len(found) <= 1, "EXACT_WINDOW_IDENTITY_COLLISION")
|
||||
if found: return found[0]
|
||||
require(time.monotonic() < end, "CORRELATION_TIMEOUT")
|
||||
time.sleep(.02)
|
||||
|
||||
|
||||
def main():
|
||||
# Firefox transmet manifest + ID. Le manifest allowlist est l'autorité ;
|
||||
# l'ID est aussi contrôlé ici, sans accepter de paramètres de configuration.
|
||||
require(len(sys.argv) == 3 and sys.argv[2] == EXTENSION_ID, "EXTENSION_NOT_ALLOWED")
|
||||
sway = Sway(os.environ.get("SWAYSOCK", ""))
|
||||
_, runtime = paths()
|
||||
provider = FirefoxProvider(sway, runtime)
|
||||
last_request = 0
|
||||
while True:
|
||||
try:
|
||||
# Le host ne demande une corrélation que pendant une requête locale
|
||||
# bornée du moteur ; aucun clignotement de préfixe en idle.
|
||||
if not select.select([sys.stdin.buffer.raw], [], [], .1)[0]:
|
||||
try:
|
||||
request = read(provider.directory / "request.json")
|
||||
now = time.monotonic_ns()
|
||||
if type(request) is dict and set(request) == {"expires_ns"} and type(request["expires_ns"]) is int and now < request["expires_ns"] <= now + 10_000_000_000 and now - last_request > 500_000_000:
|
||||
send(sys.stdout.buffer, {"event": "capture"})
|
||||
last_request = now
|
||||
except FileNotFoundError: pass
|
||||
continue
|
||||
message = receive(sys.stdin.buffer.raw)
|
||||
identity = correlate(sway, message)
|
||||
record = {"schema": PROVIDER, "version": 1, "session": sway.session, "uuid": message["uuid"],
|
||||
"con_id": identity, "observed_ns": time.monotonic_ns(), "publisher_pid": os.getpid(),
|
||||
"publisher_start": process_start(os.getpid())}
|
||||
atomic(provider.directory / f"{identity}.json", record, validate_record)
|
||||
# Collecte des seules observations périmées de ce namespace possédé.
|
||||
for p in list(provider.directory.glob("*.json"))[:512]:
|
||||
if not p.stem.isdigit() or p.name == f"{identity}.json": continue
|
||||
if time.time() - p.stat().st_mtime > 10: p.unlink()
|
||||
send(sys.stdout.buffer, {"result": "OK", "uuid": message["uuid"], "con_id": identity})
|
||||
except EOFError: return
|
||||
except Failure as exc:
|
||||
send(sys.stdout.buffer, {"result": exc.code})
|
||||
# Après un framing invalide il n'existe aucun point de resynchronisation.
|
||||
if exc.code.startswith(("MESSAGE_", "JSON_")): return
|
||||
except (OSError, ValueError, RecursionError):
|
||||
send(sys.stdout.buffer, {"result": "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE"})
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try: main()
|
||||
except (Failure, OSError): sys.exit(1)
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
"""Journal runtime borné, sans texte externe ni contenu de navigation."""
|
||||
from .storage import atomic
|
||||
from .errors import require
|
||||
import math
|
||||
import re
|
||||
from .schema import UUID, PROVIDER
|
||||
|
||||
FIELDS = "schema version transaction_id phase status reason applications_expected applications_observed slots_expected slots_filled anchors_created anchors_swapped anchors_cleaned helpers_suspended helpers_resumed providers identity_resolutions identity_level final_tree_verified final_focus_verified timings_ms".split()
|
||||
|
||||
|
||||
def validate(value):
|
||||
require(type(value) is dict and set(value) == set(FIELDS), "REPORT_SCHEMA")
|
||||
require(value["schema"] == "labfy.sway.session-v2-restore-attempt" and type(value["version"]) is int and value["version"] == 1, "REPORT_SCHEMA")
|
||||
require(type(value["transaction_id"]) is str and re.fullmatch(r"[a-f0-9]{32}", value["transaction_id"]), "REPORT_SCHEMA")
|
||||
require(value["status"] in ("running", "success", "failed", "interrupted"), "REPORT_SCHEMA")
|
||||
for key in ("phase", "reason"):
|
||||
require(type(value[key]) is str and re.fullmatch(r"[A-Za-z_-]{1,64}", value[key]), "REPORT_SCHEMA")
|
||||
for key in ("applications_expected", "applications_observed", "slots_expected", "slots_filled", "anchors_created", "anchors_swapped", "anchors_cleaned", "helpers_suspended", "helpers_resumed"):
|
||||
require(type(value[key]) is int and 0 <= value[key] <= 256, "REPORT_SCHEMA")
|
||||
require(value["providers"] in ([], [PROVIDER]), "REPORT_SCHEMA")
|
||||
require(value["identity_level"] in ("exact", "interchangeable", "best-effort"), "REPORT_SCHEMA")
|
||||
for key in ("final_tree_verified", "final_focus_verified"): require(type(value[key]) is bool, "REPORT_SCHEMA")
|
||||
require(type(value["identity_resolutions"]) is list and len(value["identity_resolutions"]) <= 256, "REPORT_SCHEMA")
|
||||
for r in value["identity_resolutions"]:
|
||||
require(type(r) is dict and set(r) == {"uuid", "con_id"} and type(r["uuid"]) is str and UUID.fullmatch(r["uuid"]) and
|
||||
type(r["con_id"]) is int and 0 < r["con_id"] < 2**63, "REPORT_SCHEMA")
|
||||
require(type(value["timings_ms"]) is dict and set(value["timings_ms"]).issubset({"anchor_build", "swap", "cleanup", "verification", "application_wait"}), "REPORT_SCHEMA")
|
||||
for v in value["timings_ms"].values(): require(type(v) in (float, int) and math.isfinite(v) and 0 <= v <= 1e6, "REPORT_SCHEMA")
|
||||
return value
|
||||
|
||||
|
||||
class Attempt:
|
||||
def __init__(self, path, tx, data):
|
||||
self.path = path
|
||||
self.data = {"schema": "labfy.sway.session-v2-restore-attempt", "version": 1,
|
||||
"transaction_id": tx, "phase": "validate", "status": "running", "reason": "NONE",
|
||||
"applications_expected": len(data["applications"]), "applications_observed": 0,
|
||||
"slots_expected": len(data["window_slots"]), "slots_filled": 0,
|
||||
"anchors_created": 0, "anchors_swapped": 0, "anchors_cleaned": 0,
|
||||
"helpers_suspended": 0, "helpers_resumed": 0, "providers": [], "identity_resolutions": [],
|
||||
"identity_level": "best-effort" if any(s["identity_requirement"] == "best-effort" for s in data["window_slots"]) else
|
||||
"interchangeable" if any(s["identity_requirement"] == "interchangeable" for s in data["window_slots"]) else "exact",
|
||||
"final_tree_verified": False, "final_focus_verified": False, "timings_ms": {}}
|
||||
self.publish()
|
||||
|
||||
def publish(self): atomic(self.path, self.data, validate)
|
||||
def update(self, **values):
|
||||
require(set(values).issubset(FIELDS), "REPORT_SCHEMA")
|
||||
self.data.update(values)
|
||||
self.publish()
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:labfy:sway:session-v2:1",
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["schema", "version", "metadata", "outputs", "applications", "window_slots", "workspaces", "focus"],
|
||||
"properties": {
|
||||
"schema": {"const": "labfy.sway.session-v2"}, "version": {"const": 1, "type": "integer"},
|
||||
"metadata": {
|
||||
"type": "object", "additionalProperties": false, "required": ["created_ns", "sway_session", "reason"],
|
||||
"properties": {"created_ns": {"type": "integer", "minimum": 0, "maximum": 9223372036854775807}, "sway_session": {"$ref": "#/$defs/id"}, "reason": {"enum": ["manual", "logout", "reboot", "poweroff"]}}
|
||||
},
|
||||
"outputs": {"type": "array", "maxItems": 256, "items": {"type": "object", "additionalProperties": false, "required": ["name"], "properties": {"name": {"$ref": "#/$defs/place"}}}},
|
||||
"applications": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/application"}},
|
||||
"window_slots": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/slot"}},
|
||||
"workspaces": {"type": "array", "maxItems": 256, "items": {"type": "object", "additionalProperties": false, "required": ["workspace", "output", "root"], "properties": {"workspace": {"$ref": "#/$defs/place"}, "output": {"$ref": "#/$defs/place"}, "root": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/tree"}]}}}},
|
||||
"focus": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/id"}]}
|
||||
},
|
||||
"$defs": {
|
||||
"id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,95}$"},
|
||||
"place": {"type": "string", "pattern": "^[A-Za-z0-9 _:-]{1,64}$"},
|
||||
"application": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["application_id", "desktop_entry", "strategy", "expected_windows", "managed_by", "identity_provider"],
|
||||
"properties": {
|
||||
"application_id": {"$ref": "#/$defs/id"}, "desktop_entry": {"allOf": [{"$ref": "#/$defs/id"}, {"pattern": "\\.desktop$"}]},
|
||||
"strategy": {"enum": ["managed-autostart", "single-window", "multi-instance", "terminal", "browser-self-restore", "unknown"]},
|
||||
"expected_windows": {"type": "integer", "minimum": 1, "maximum": 256},
|
||||
"managed_by": {"enum": [null, "autostart", "sway-autostart"]}, "identity_provider": {"enum": [null, "firefox-session-window-uuid"]}
|
||||
}
|
||||
},
|
||||
"geometry": {
|
||||
"type": "object", "additionalProperties": false, "required": ["x", "y", "width", "height"],
|
||||
"properties": {"x": {"type": "integer", "minimum": -100000, "maximum": 100000}, "y": {"type": "integer", "minimum": -100000, "maximum": 100000}, "width": {"type": "integer", "minimum": 1, "maximum": 32768}, "height": {"type": "integer", "minimum": 1, "maximum": 32768}}
|
||||
},
|
||||
"evidence": {
|
||||
"oneOf": [
|
||||
{"type": "null"},
|
||||
{"type": "object", "additionalProperties": false, "required": ["type", "id"], "properties": {"type": {"const": "firefox-session-window-uuid"}, "id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}}},
|
||||
{"type": "object", "additionalProperties": false, "required": ["type", "id"], "properties": {"type": {"const": "application-singleton"}, "id": {"$ref": "#/$defs/id"}}}
|
||||
]
|
||||
},
|
||||
"slot": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["slot_id", "application_id", "workspace", "tree_path", "state", "identity_requirement", "identity_evidence"],
|
||||
"properties": {
|
||||
"slot_id": {"$ref": "#/$defs/id"}, "application_id": {"$ref": "#/$defs/id"}, "workspace": {"$ref": "#/$defs/place"},
|
||||
"tree_path": {"type": "array", "maxItems": 32, "items": {"type": "integer", "minimum": 0, "maximum": 31}},
|
||||
"identity_requirement": {"enum": ["exact", "best-effort", "interchangeable"]}, "identity_evidence": {"$ref": "#/$defs/evidence"},
|
||||
"state": {"type": "object", "additionalProperties": false, "required": ["floating", "geometry", "fullscreen", "scratchpad"], "properties": {"floating": {"type": "boolean"}, "geometry": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/geometry"}]}, "fullscreen": {"const": false}, "scratchpad": {"const": false}}}
|
||||
}
|
||||
},
|
||||
"tree": {
|
||||
"oneOf": [
|
||||
{"type": "object", "additionalProperties": false, "required": ["slot_id"], "properties": {"slot_id": {"$ref": "#/$defs/id"}}},
|
||||
{"type": "object", "additionalProperties": false, "required": ["layout", "children"], "properties": {"layout": {"enum": ["splith", "splitv", "tabbed", "stacked"]}, "children": {"type": "array", "minItems": 2, "maxItems": 32, "items": {"$ref": "#/$defs/tree"}}}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
125
quickshell/.config/quickshell/labfy-sway/session_v2/schema.py
Normal file
125
quickshell/.config/quickshell/labfy-sway/session_v2/schema.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Validation versionnée, exacte et référentielle avant toute mutation.
|
||||
|
||||
Les bornes contrôlent le travail local ; aucun ancien identifiant runtime n'est
|
||||
accepté. Un état non implémenté est refusé, jamais silencieusement oublié.
|
||||
"""
|
||||
import re
|
||||
from .errors import require
|
||||
|
||||
SCHEMA = "labfy.sway.session-v2"
|
||||
LAYOUTS = {"splith", "splitv", "tabbed", "stacked"}
|
||||
STRATEGIES = {"managed-autostart", "single-window", "multi-instance", "terminal", "browser-self-restore", "unknown"}
|
||||
PROVIDER = "firefox-session-window-uuid"
|
||||
UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\Z")
|
||||
IDENT = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,95}\Z")
|
||||
PLACE = re.compile(r"[A-Za-z0-9 _:-]{1,64}\Z")
|
||||
|
||||
|
||||
def fields(value, names):
|
||||
require(type(value) is dict and set(value) == set(names.split()), "SCHEMA_FIELDS")
|
||||
|
||||
|
||||
def integer(value, low, high):
|
||||
require(type(value) is int and low <= value <= high, "SCHEMA_INTEGER")
|
||||
|
||||
|
||||
def text(value, pattern=IDENT):
|
||||
require(type(value) is str and pattern.fullmatch(value), "SCHEMA_STRING")
|
||||
|
||||
|
||||
def array(value, maximum=256):
|
||||
require(type(value) is list and len(value) <= maximum, "SCHEMA_ARRAY")
|
||||
|
||||
|
||||
def validate(data):
|
||||
fields(data, "schema version metadata outputs applications window_slots workspaces focus")
|
||||
require(data["schema"] == SCHEMA and type(data["version"]) is int and data["version"] == 1, "SCHEMA_VERSION")
|
||||
fields(data["metadata"], "created_ns sway_session reason")
|
||||
integer(data["metadata"]["created_ns"], 0, 2**63 - 1)
|
||||
text(data["metadata"]["sway_session"])
|
||||
require(data["metadata"]["reason"] in ("manual", "logout", "reboot", "poweroff"), "SCHEMA_REASON")
|
||||
for key in ("outputs", "applications", "window_slots", "workspaces"): array(data[key])
|
||||
outputs, apps, slots, workspaces = {}, {}, {}, {}
|
||||
for o in data["outputs"]:
|
||||
fields(o, "name")
|
||||
text(o["name"], PLACE)
|
||||
require(o["name"] not in outputs, "DUPLICATE_OUTPUT")
|
||||
outputs[o["name"]] = o
|
||||
for a in data["applications"]:
|
||||
fields(a, "application_id desktop_entry strategy expected_windows managed_by identity_provider")
|
||||
text(a["application_id"])
|
||||
text(a["desktop_entry"])
|
||||
require(a["desktop_entry"].endswith(".desktop"), "SCHEMA_DESKTOP_ENTRY")
|
||||
require(type(a["strategy"]) is str and a["strategy"] in STRATEGIES, "SCHEMA_STRATEGY")
|
||||
integer(a["expected_windows"], 1, 256)
|
||||
require(a["managed_by"] in (None, "autostart", "sway-autostart"), "SCHEMA_MANAGED_BY")
|
||||
require(a["identity_provider"] in (None, PROVIDER), "SCHEMA_PROVIDER")
|
||||
require(a["application_id"] not in apps, "DUPLICATE_APPLICATION")
|
||||
apps[a["application_id"]] = a
|
||||
for s in data["window_slots"]:
|
||||
fields(s, "slot_id application_id workspace tree_path state identity_requirement identity_evidence")
|
||||
text(s["slot_id"])
|
||||
text(s["application_id"])
|
||||
text(s["workspace"], PLACE)
|
||||
array(s["tree_path"], 32)
|
||||
for i in s["tree_path"]: integer(i, 0, 31)
|
||||
require(s["application_id"] in apps and s["slot_id"] not in slots, "SLOT_REFERENCE")
|
||||
require(s["identity_requirement"] in ("exact", "best-effort", "interchangeable"), "SCHEMA_IDENTITY")
|
||||
state = s["state"]
|
||||
fields(state, "floating geometry fullscreen scratchpad")
|
||||
require(type(state["floating"]) is bool, "SCHEMA_BOOLEAN")
|
||||
require(type(state["fullscreen"]) is bool and state["fullscreen"] is False and state["scratchpad"] is False,
|
||||
"CAPABILITY_UNSUPPORTED")
|
||||
if state["floating"]:
|
||||
fields(state["geometry"], "x y width height")
|
||||
for k in ("x", "y"): integer(state["geometry"][k], -100000, 100000)
|
||||
for k in ("width", "height"): integer(state["geometry"][k], 1, 32768)
|
||||
require(s["tree_path"] == [], "FLOATING_TREE_PATH")
|
||||
else: require(state["geometry"] is None, "TILING_GEOMETRY")
|
||||
evidence = s["identity_evidence"]
|
||||
if s["identity_requirement"] == "exact":
|
||||
fields(evidence, "type id")
|
||||
if evidence["type"] == PROVIDER:
|
||||
require(type(evidence["id"]) is str and UUID.fullmatch(evidence["id"]), "INVALID_UUID")
|
||||
require(apps[s["application_id"]]["identity_provider"] == PROVIDER, "PROVIDER_REFERENCE")
|
||||
else:
|
||||
require(evidence["type"] == "application-singleton" and evidence["id"] == s["application_id"] and
|
||||
apps[s["application_id"]]["expected_windows"] == 1, "EXACT_IDENTITY_UNSUPPORTED")
|
||||
else: require(evidence is None, "SCHEMA_IDENTITY")
|
||||
slots[s["slot_id"]] = s
|
||||
seen = set()
|
||||
def tree(node, workspace, path=()):
|
||||
require(len(path) <= 32, "TREE_DEPTH")
|
||||
if node is None: return
|
||||
require(type(node) is dict, "TREE_NODE")
|
||||
if "slot_id" in node:
|
||||
fields(node, "slot_id")
|
||||
text(node["slot_id"])
|
||||
require(node["slot_id"] in slots and node["slot_id"] not in seen, "TREE_SLOT_REFERENCE")
|
||||
s = slots[node["slot_id"]]
|
||||
require(not s["state"]["floating"] and s["workspace"] == workspace and s["tree_path"] == list(path), "TREE_SLOT_POSITION")
|
||||
seen.add(node["slot_id"])
|
||||
else:
|
||||
fields(node, "layout children")
|
||||
require(type(node["layout"]) is str and node["layout"] in LAYOUTS, "TREE_LAYOUT")
|
||||
array(node["children"], 32)
|
||||
require(len(node["children"]) >= 2 and all(c is not None for c in node["children"]), "TREE_ARITY")
|
||||
for i, c in enumerate(node["children"]): tree(c, workspace, path + (i,))
|
||||
for w in data["workspaces"]:
|
||||
fields(w, "workspace output root")
|
||||
text(w["workspace"], PLACE)
|
||||
text(w["output"], PLACE)
|
||||
require(w["workspace"] not in workspaces and w["output"] in outputs, "WORKSPACE_REFERENCE")
|
||||
workspaces[w["workspace"]] = w
|
||||
tree(w["root"], w["workspace"])
|
||||
for s in slots.values():
|
||||
require(s["workspace"] in workspaces and (s["state"]["floating"] or s["slot_id"] in seen), "UNPLACED_SLOT")
|
||||
for name in workspaces:
|
||||
require(any(s["workspace"] == name for s in slots.values()), "EMPTY_WORKSPACE_UNSUPPORTED")
|
||||
for a in apps.values():
|
||||
members = [s for s in slots.values() if s["application_id"] == a["application_id"]]
|
||||
require(len(members) == a["expected_windows"], "APPLICATION_CARDINALITY")
|
||||
uuids = [s["identity_evidence"]["id"] for s in members if s["identity_evidence"] and s["identity_evidence"]["type"] == PROVIDER]
|
||||
require(len(uuids) == len(set(uuids)), "EXACT_WINDOW_IDENTITY_COLLISION")
|
||||
require(data["focus"] is None or type(data["focus"]) is str and data["focus"] in slots, "FOCUS_REFERENCE")
|
||||
return data
|
||||
27
quickshell/.config/quickshell/labfy-sway/session_v2/slots.py
Normal file
27
quickshell/.config/quickshell/labfy-sway/session_v2/slots.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Affectation : exact reste exact ; best-effort est toujours signalé."""
|
||||
from .errors import require
|
||||
from .schema import PROVIDER
|
||||
|
||||
|
||||
def assign(slots, nodes, identities=None):
|
||||
require(len(nodes) >= len(slots), "APPLICATION_WINDOWS_INCOMPLETE")
|
||||
require(len(nodes) <= len(slots), "UNEXPECTED_EXTRA_WINDOW")
|
||||
result, remaining = {}, {n["id"] for n in nodes}
|
||||
identities = identities or {}
|
||||
require(len(set(identities.values())) == len(identities), "EXACT_WINDOW_IDENTITY_COLLISION")
|
||||
for s in slots:
|
||||
if s["identity_requirement"] != "exact": continue
|
||||
evidence = s["identity_evidence"]
|
||||
if evidence["type"] == PROVIDER:
|
||||
require(evidence["id"] in identities, "EXACT_WINDOW_IDENTITY_MISSING")
|
||||
target = identities[evidence["id"]]
|
||||
else:
|
||||
require(len(nodes) == 1, "EXACT_IDENTITY_UNSUPPORTED")
|
||||
target = nodes[0]["id"]
|
||||
require(target in remaining, "EXACT_WINDOW_IDENTITY_COLLISION")
|
||||
result[s["slot_id"]] = target
|
||||
remaining.remove(target)
|
||||
# Ordre numérique runtime déterministe, jamais une preuve sémantique.
|
||||
for s, target in zip(sorted((s for s in slots if s["slot_id"] not in result), key=lambda x: x["slot_id"]), sorted(remaining)):
|
||||
result[s["slot_id"]] = target
|
||||
return result
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
"""Capture minimale, sans nom de fenêtre, chemin de document ou argv."""
|
||||
import time
|
||||
from .errors import require
|
||||
from .schema import validate, PROVIDER
|
||||
from .ipc import walk
|
||||
|
||||
|
||||
def capture(sway, catalog, provider, reason="manual"):
|
||||
tree = sway.tree()
|
||||
def topology(node):
|
||||
# Les titres, tokens et métadonnées applicatives ne participent pas à
|
||||
# cette empreinte éphémère. Détecter aussi déplacement/focus durant capture.
|
||||
return (node.get("id"), node.get("layout"), node.get("name") if node.get("type") in ("workspace", "output") else None,
|
||||
node.get("focused"), node.get("fullscreen_mode"), node.get("floating"), node.get("rect"),
|
||||
tuple(topology(c) for c in node.get("nodes", [])), tuple(topology(c) for c in node.get("floating_nodes", [])))
|
||||
nodes = [n for n in walk(tree) if n.get("app_id") or n.get("window")]
|
||||
require(len(nodes) <= 256, "WINDOW_LIMIT")
|
||||
specs = {}
|
||||
for n in nodes:
|
||||
require(not n.get("fullscreen_mode") and n.get("scratchpad_state", "none") == "none", "CAPABILITY_UNSUPPORTED")
|
||||
specs[n["id"]] = catalog.classify(n)
|
||||
apps = {}
|
||||
identities = {}
|
||||
for s in specs.values():
|
||||
if s.application_id in apps: continue
|
||||
members = [n for n in nodes if specs[n["id"]].application_id == s.application_id]
|
||||
apps[s.application_id] = {"application_id": s.application_id, "desktop_entry": s.desktop_entry,
|
||||
"strategy": s.strategy, "expected_windows": len(members),
|
||||
"managed_by": (s.managed_by or "autostart") if s.strategy == "managed-autostart" else None, "identity_provider": s.provider}
|
||||
if s.provider == PROVIDER:
|
||||
require(provider is not None, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE")
|
||||
identities.update({v: k for k, v in provider.capture(members).items()})
|
||||
slots, workspaces = [], []
|
||||
focused = None
|
||||
def leaf(n, workspace, path, floating):
|
||||
nonlocal focused
|
||||
app = specs[n["id"]]
|
||||
slot_id = "slot-" + str(len(slots) + 1)
|
||||
if app.provider == PROVIDER:
|
||||
require(n["id"] in identities, "EXACT_WINDOW_IDENTITY_MISSING")
|
||||
requirement, evidence = "exact", {"type": PROVIDER, "id": identities[n["id"]]}
|
||||
elif apps[app.application_id]["expected_windows"] == 1:
|
||||
requirement, evidence = "exact", {"type": "application-singleton", "id": app.application_id}
|
||||
else: requirement, evidence = "interchangeable", None
|
||||
if n.get("focused"): focused = slot_id
|
||||
geometry = {k: n["rect"][k] for k in ("x", "y", "width", "height")} if floating else None
|
||||
slots.append({"slot_id": slot_id, "application_id": app.application_id, "workspace": workspace,
|
||||
"tree_path": list(path), "state": {"floating": floating, "geometry": geometry, "fullscreen": False, "scratchpad": False},
|
||||
"identity_requirement": requirement, "identity_evidence": evidence})
|
||||
return {"slot_id": slot_id}
|
||||
def convert(n, workspace, path=()):
|
||||
if n.get("app_id") or n.get("window"): return leaf(n, workspace, path, False)
|
||||
children = n.get("nodes", [])
|
||||
if not children: return None
|
||||
if len(children) == 1:
|
||||
require(n["layout"] in ("splith", "splitv", "none"), "SINGLE_CHILD_LAYOUT_UNSUPPORTED")
|
||||
return convert(children[0], workspace, path)
|
||||
return {"layout": n["layout"], "children": [convert(c, workspace, path + (i,)) for i, c in enumerate(children)]}
|
||||
outputs = []
|
||||
for output in tree.get("nodes", []):
|
||||
for ws in output.get("nodes", []):
|
||||
if ws.get("type") != "workspace" or not (ws.get("nodes") or ws.get("floating_nodes")): continue
|
||||
require(ws["name"] != "__i3_scratch", "CAPABILITY_UNSUPPORTED")
|
||||
root = convert(ws, ws["name"])
|
||||
for f in ws.get("floating_nodes", []):
|
||||
children = [n for n in walk(f) if n.get("app_id") or n.get("window")]
|
||||
require(len(children) == 1, "FLOATING_CONTAINER_UNSUPPORTED")
|
||||
leaf(children[0], ws["name"], (), True)
|
||||
workspaces.append({"workspace": ws["name"], "output": output["name"], "root": root})
|
||||
if output["name"] not in outputs: outputs.append(output["name"])
|
||||
# Relecture : aucun checkpoint hybride si une fenêtre apparaît/disparaît.
|
||||
require({n["id"] for n in sway.windows()} == set(specs), "SNAPSHOT_CHANGED")
|
||||
require(topology(sway.tree()) == topology(tree), "SNAPSHOT_CHANGED")
|
||||
data = {"schema": "labfy.sway.session-v2", "version": 1,
|
||||
"metadata": {"created_ns": time.time_ns(), "sway_session": sway.session, "reason": reason},
|
||||
"outputs": [{"name": n} for n in outputs], "applications": list(apps.values()),
|
||||
"window_slots": slots, "workspaces": workspaces, "focus": focused}
|
||||
return validate(data)
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"""Décision persistante par instance Sway, distincte du booléen de l'UI.
|
||||
|
||||
CONTRACT : restored exige le rapport final de la transaction reçue par l'UI.
|
||||
Aucune décision ne ferme de fenêtre ni ne déclenche de restauration.
|
||||
"""
|
||||
from .errors import require
|
||||
from .storage import private_dir, lock, read, atomic
|
||||
from .observability import validate
|
||||
|
||||
|
||||
def successful(report):
|
||||
validate(report)
|
||||
return (report['status'] == 'success' and report['final_tree_verified'] and report['final_focus_verified']
|
||||
and report['slots_expected'] == report['slots_filled']
|
||||
and report['applications_expected'] == report['applications_observed']
|
||||
and report['anchors_created'] == report['anchors_cleaned']
|
||||
and report['helpers_suspended'] == report['helpers_resumed'])
|
||||
|
||||
|
||||
def acknowledge(runtime, session, choice, transaction_id=None):
|
||||
require(choice in ('new', 'restored'), 'STARTUP_CHOICE_INVALID')
|
||||
directory = private_dir(runtime / 'session-v2')
|
||||
marker = directory / 'startup.json'
|
||||
with lock(directory):
|
||||
if choice == 'restored':
|
||||
claimed = read(marker)
|
||||
report = read(runtime / 'session-v2-restore-attempt.json', validate)
|
||||
require(claimed.get('session') == session and transaction_id == report['transaction_id']
|
||||
and successful(report), 'RESTORE_ACKNOWLEDGEMENT_FAILED')
|
||||
atomic(marker, {'session': session, 'choice': choice, 'transaction_id': transaction_id if choice == 'restored' else None})
|
||||
return {'status': 'acknowledged', 'choice': choice}
|
||||
103
quickshell/.config/quickshell/labfy-sway/session_v2/storage.py
Normal file
103
quickshell/.config/quickshell/labfy-sway/session_v2/storage.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Stockage privé : validation, NOFOLLOW, publication durable et lock partagé.
|
||||
|
||||
CONTRACT : le namespace est possédé par l'UID courant et non accessible aux
|
||||
autres utilisateurs. Les verrous restent valides via FD hérité par le guard.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import uuid
|
||||
from .errors import Failure, require
|
||||
|
||||
MAX_JSON = 1024 * 1024
|
||||
|
||||
|
||||
def pairs(items):
|
||||
result = {}
|
||||
for k, v in items:
|
||||
require(k not in result, "JSON_DUPLICATE_KEY")
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
|
||||
def loads(raw, limit=MAX_JSON):
|
||||
require(len(raw) <= limit, "JSON_TOO_LARGE")
|
||||
try: return json.loads(raw, object_pairs_hook=pairs, parse_constant=lambda _: (_ for _ in ()).throw(Failure("JSON_NUMBER")))
|
||||
except (ValueError, RecursionError): raise Failure("JSON_INVALID") from None
|
||||
|
||||
|
||||
def private_dir(path):
|
||||
path = Path(path)
|
||||
require(path.is_absolute() and ".." not in path.parts, "PATH_INVALID")
|
||||
# Refuser les liens dans toute la chaîne ; ne chmod jamais un parent existant.
|
||||
for part in [*reversed(path.parents), path]:
|
||||
if not part.exists() and not part.is_symlink():
|
||||
try: part.mkdir(mode=0o700)
|
||||
except FileExistsError: pass
|
||||
require(not part.is_symlink() and part.is_dir(), "PATH_SYMLINK")
|
||||
st = path.stat()
|
||||
require(st.st_uid == os.getuid() and stat.S_ISDIR(st.st_mode) and st.st_mode & 0o077 == 0, "DIRECTORY_PERMISSIONS")
|
||||
return path
|
||||
|
||||
|
||||
def read(path, validator=lambda x: x):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
require(stat.S_ISREG(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0, "FILE_PERMISSIONS")
|
||||
require(st.st_size <= MAX_JSON, "JSON_TOO_LARGE")
|
||||
with os.fdopen(fd, "rb", closefd=False) as stream: data = stream.read(MAX_JSON + 1)
|
||||
return validator(loads(data))
|
||||
finally: os.close(fd)
|
||||
|
||||
|
||||
def atomic(path, data, validator=lambda x: x):
|
||||
path = Path(path)
|
||||
directory = private_dir(path.parent)
|
||||
validator(data)
|
||||
raw = (json.dumps(data, separators=(",", ":"), allow_nan=False) + "\n").encode()
|
||||
require(len(raw) <= MAX_JSON, "JSON_TOO_LARGE")
|
||||
temporary = directory / (".tmp-" + uuid.uuid4().hex)
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(raw)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
require(read(temporary, validator) == data, "PUBLICATION_REREAD")
|
||||
require(not path.is_symlink(), "PATH_SYMLINK")
|
||||
os.replace(temporary, path)
|
||||
sync_dir(directory)
|
||||
finally:
|
||||
if temporary.exists(): temporary.unlink()
|
||||
|
||||
|
||||
def sync_dir(path):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try: os.fsync(fd)
|
||||
finally: os.close(fd)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def lock(directory, name="transaction.lock"):
|
||||
directory = private_dir(directory)
|
||||
fd = os.open(directory / name, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
require(stat.S_ISREG(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0, "LOCK_PERMISSIONS")
|
||||
try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError: raise Failure("TRANSACTION_BUSY") from None
|
||||
yield fd
|
||||
finally: os.close(fd)
|
||||
|
||||
|
||||
def paths():
|
||||
home = Path.home()
|
||||
state = Path(os.environ.get("XDG_STATE_HOME", str(home / ".local/state")))
|
||||
runtime = Path(os.environ.get("XDG_RUNTIME_DIR", ""))
|
||||
require(state.is_absolute() and runtime.is_absolute(), "XDG_PATH_INVALID")
|
||||
private_dir(runtime)
|
||||
return private_dir(state / "labfy-sway/session-v2"), private_dir(runtime / "labfy-sway")
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
.pragma library
|
||||
|
||||
// CONTRACT : aucun exit 0 seul ne vaut succès de restauration. L'UI attend
|
||||
// toutes les postconditions et, au startup, l'ack durable de cette transaction.
|
||||
function restoreSucceeded(v) {
|
||||
return !!v && v.schema === "labfy.sway.session-v2-restore-attempt"
|
||||
&& v.version === 1 && v.status === "success"
|
||||
&& v.final_tree_verified === true && v.final_focus_verified === true
|
||||
&& Number.isInteger(v.slots_expected) && v.slots_expected === v.slots_filled
|
||||
&& Number.isInteger(v.applications_expected) && v.applications_expected === v.applications_observed
|
||||
&& Number.isInteger(v.anchors_created) && v.anchors_created === v.anchors_cleaned
|
||||
&& Number.isInteger(v.helpers_suspended) && v.helpers_suspended === v.helpers_resumed
|
||||
&& /^[a-f0-9]{32}$/.test(v.transaction_id)
|
||||
&& ["exact", "interchangeable", "best-effort"].indexOf(v.identity_level) >= 0;
|
||||
}
|
||||
|
||||
function details(v) {
|
||||
// Aucun stdout/stderr libre dans le parcours utilisateur ou dans journald.
|
||||
function code(x) { return typeof x === "string" && /^[A-Za-z_-]{1,64}$/.test(x) ? x : "UNKNOWN"; }
|
||||
return "Phase : " + code(v ? v.phase : null) + "\nRaison : " + code(v ? v.reason : null);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
QtObject {
|
||||
id: service
|
||||
readonly property string configHome: Quickshell.env("XDG_CONFIG_HOME") || ((Quickshell.env("HOME") || "") + "/.config")
|
||||
readonly property string backend: configHome + "/quickshell/labfy-sway/session-v2.py"
|
||||
property string operation: ""
|
||||
property var client: null
|
||||
property bool startupChooserVisible: false
|
||||
readonly property bool busy: operation !== "" || backendProcess.running
|
||||
signal completed(var owner, string kind, var value, bool okay)
|
||||
|
||||
// Diagnostic lecture seule ; aucun endpoint IPC ne peut cliquer Restore.
|
||||
property IpcHandler diagnostics: IpcHandler {
|
||||
target: "sessionV2"
|
||||
function status(): string {
|
||||
return JSON.stringify({backend: "v2", busy: service.busy,
|
||||
operation: service.operation, startupChooserCount: service.startupChooserVisible ? 1 : 0});
|
||||
}
|
||||
}
|
||||
|
||||
// WHY : toutes les barres, le chooser et le power menu partagent un owner.
|
||||
// INVARIANT : un unique Process sérialise les commandes V2 de QuickShell.
|
||||
function run(kind, name, extra, owner) {
|
||||
if (busy) return false;
|
||||
const allowed = ["startup-status", "ack-new", "ack-restored", "apply-last", "apply", "list", "show", "save", "delete", "plan", "restore-status", "checkpoint-last"];
|
||||
if (allowed.indexOf(kind) < 0) return false;
|
||||
const argv = ["python3", "-B", backend];
|
||||
if (kind === "ack-new" || kind === "ack-restored") {
|
||||
argv.push("acknowledge-startup", "--execute", "--choice", kind === "ack-new" ? "new" : "restored");
|
||||
if (kind === "ack-restored") argv.push("--transaction-id", extra);
|
||||
} else {
|
||||
argv.push(kind);
|
||||
if (["apply", "save", "delete", "show", "plan"].indexOf(kind) >= 0) argv.push(name);
|
||||
if (["startup-status", "apply-last", "apply", "save", "delete", "checkpoint-last"].indexOf(kind) >= 0) argv.push("--execute");
|
||||
if (kind === "checkpoint-last") argv.push("--reason", extra);
|
||||
}
|
||||
client = owner;
|
||||
operation = kind;
|
||||
backendProcess.command = argv;
|
||||
backendProcess.running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
property Process backendProcess: Process {
|
||||
running: false
|
||||
stdout: StdioCollector { id: collected; waitForEnd: true }
|
||||
stderr: StdioCollector { waitForEnd: true }
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
let value = null;
|
||||
try { value = JSON.parse(collected.text); } catch (_) {}
|
||||
const owner = service.client;
|
||||
const kind = service.operation;
|
||||
service.operation = "";
|
||||
service.client = null;
|
||||
service.completed(owner, kind, value, exitCode === 0 && exitStatus === 0 && value !== null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
singleton SessionV2Service 1.0 SessionV2Service.qml
|
||||
|
|
@ -32,6 +32,11 @@ ShellRoot {
|
|||
Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
Bar { notificationService: rootNotificationService }
|
||||
Bar {
|
||||
notificationService: rootNotificationService
|
||||
// Une seule barre possède le chooser, même en configuration multi-écran.
|
||||
startupHost: Quickshell.screens.length > 0
|
||||
&& modelData === Quickshell.screens[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
tests/qml/tst_session_exit_gate.qml
Normal file
140
tests/qml/tst_session_exit_gate.qml
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import QtQuick
|
||||
import QtTest
|
||||
import "../../quickshell/.config/quickshell/labfy-sway/controlcenter"
|
||||
|
||||
TestCase {
|
||||
name: "SessionExitGate"
|
||||
|
||||
Component {
|
||||
id: gateComponent
|
||||
SessionExitGate {
|
||||
actions: [
|
||||
{ id: "lock", label: "Verrouiller", command: ["swaylock"] },
|
||||
{ id: "suspend", label: "Veille", command: ["systemctl", "suspend"] },
|
||||
{ id: "logout", label: "Déconnexion", confirmTitle: "Se déconnecter ?",
|
||||
description: "La session SwayFX en cours sera fermée.", checkpointReason: "logout",
|
||||
command: ["uwsm", "stop"] },
|
||||
{ id: "reboot", label: "Redémarrer", checkpointReason: "reboot",
|
||||
command: ["systemctl", "reboot"] },
|
||||
{ id: "poweroff", label: "Éteindre", checkpointReason: "poweroff",
|
||||
command: ["systemctl", "poweroff"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: signalSpyComponent
|
||||
SignalSpy {}
|
||||
}
|
||||
|
||||
function createGate() {
|
||||
return createTemporaryObject(gateComponent, this);
|
||||
}
|
||||
|
||||
function createSpy(target, signalName) {
|
||||
return createTemporaryObject(signalSpyComponent, this, {
|
||||
target: target,
|
||||
signalName: signalName
|
||||
});
|
||||
}
|
||||
|
||||
function test_logout_success_sequences_checkpoint_before_command() {
|
||||
const gate = createGate();
|
||||
const checkpointSpy = createSpy(gate, "checkpointRequested");
|
||||
const commandSpy = createSpy(gate, "commandRequested");
|
||||
gate.requestAction("logout");
|
||||
compare(gate.pendingAction.id, "logout");
|
||||
compare(gate.pendingAction.label, "Déconnexion");
|
||||
compare(gate.pendingAction.command, ["uwsm", "stop"]);
|
||||
compare(gate.pendingAction.confirmTitle, "Se déconnecter ?");
|
||||
compare(gate.pendingAction.description, "La session SwayFX en cours sera fermée.");
|
||||
gate.confirmAction("logout");
|
||||
compare(checkpointSpy.count, 1);
|
||||
compare(checkpointSpy.signalArguments[0][0], "logout");
|
||||
compare(commandSpy.count, 0);
|
||||
verify(gate.busy);
|
||||
compare(gate.checkpointAction.id, "logout");
|
||||
gate.confirmAction("logout");
|
||||
compare(checkpointSpy.count, 1);
|
||||
gate.pendingAction = null;
|
||||
gate.checkpointFinished(0, 0, '{"status":"success"}', "");
|
||||
compare(commandSpy.count, 1);
|
||||
compare(commandSpy.signalArguments[0][0], ["uwsm", "stop"]);
|
||||
verify(!gate.busy);
|
||||
}
|
||||
|
||||
function test_checkpoint_failure_blocks_command_and_preserves_escape() {
|
||||
const gate = createGate();
|
||||
const failureSpy = createSpy(gate, "checkpointFailed");
|
||||
const commandSpy = createSpy(gate, "commandRequested");
|
||||
gate.requestAction("logout");
|
||||
gate.confirmAction("logout");
|
||||
gate.checkpointFinished(1, 0, "", "capture failed");
|
||||
compare(commandSpy.count, 0);
|
||||
compare(failureSpy.count, 1);
|
||||
compare(gate.pendingAction.id, "logout");
|
||||
compare(gate.errorMessage, "capture failed");
|
||||
gate.quitWithoutCheckpoint();
|
||||
compare(commandSpy.count, 1);
|
||||
compare(commandSpy.signalArguments[0][0], ["uwsm", "stop"]);
|
||||
}
|
||||
|
||||
function test_cancel_after_failure_never_dispatches() {
|
||||
const gate = createGate();
|
||||
const commandSpy = createSpy(gate, "commandRequested");
|
||||
gate.requestAction("logout");
|
||||
gate.confirmAction("logout");
|
||||
gate.checkpointFinished(1, 0, "", "capture failed");
|
||||
gate.cancel();
|
||||
compare(commandSpy.count, 0);
|
||||
compare(gate.pendingAction, null);
|
||||
}
|
||||
|
||||
function test_reboot_and_poweroff_sequence() {
|
||||
const expected = {
|
||||
reboot: ["systemctl", "reboot"],
|
||||
poweroff: ["systemctl", "poweroff"]
|
||||
};
|
||||
for (const actionId in expected) {
|
||||
const gate = createGate();
|
||||
const checkpointSpy = createSpy(gate, "checkpointRequested");
|
||||
const commandSpy = createSpy(gate, "commandRequested");
|
||||
gate.requestAction(actionId);
|
||||
gate.confirmAction(actionId);
|
||||
compare(checkpointSpy.count, 1);
|
||||
compare(checkpointSpy.signalArguments[0][0], actionId);
|
||||
compare(commandSpy.count, 0);
|
||||
gate.checkpointFinished(0, 0, '{"status":"success"}', "");
|
||||
compare(commandSpy.count, 1);
|
||||
compare(commandSpy.signalArguments[0][0], expected[actionId]);
|
||||
}
|
||||
}
|
||||
|
||||
function test_reboot_and_poweroff_failure_never_dispatch() {
|
||||
for (const actionId of ["reboot", "poweroff"]) {
|
||||
const gate = createGate();
|
||||
const commandSpy = createSpy(gate, "commandRequested");
|
||||
gate.requestAction(actionId);
|
||||
gate.confirmAction(actionId);
|
||||
gate.checkpointFinished(1, 0, "", "capture failed");
|
||||
compare(commandSpy.count, 0);
|
||||
compare(gate.pendingAction.id, actionId);
|
||||
}
|
||||
}
|
||||
|
||||
function test_lock_and_suspend_keep_existing_contract() {
|
||||
const gate = createGate();
|
||||
const checkpointSpy = createSpy(gate, "checkpointRequested");
|
||||
const commandSpy = createSpy(gate, "commandRequested");
|
||||
gate.requestAction("lock");
|
||||
compare(commandSpy.count, 1);
|
||||
compare(commandSpy.signalArguments[0][0], ["swaylock"]);
|
||||
compare(checkpointSpy.count, 0);
|
||||
gate.requestAction("suspend");
|
||||
compare(gate.pendingAction.id, "suspend");
|
||||
gate.confirmAction("suspend");
|
||||
compare(commandSpy.count, 2);
|
||||
compare(commandSpy.signalArguments[1][0], ["systemctl", "suspend"]);
|
||||
compare(checkpointSpy.count, 0);
|
||||
}
|
||||
}
|
||||
51
tests/session_v2/common.py
Normal file
51
tests/session_v2/common.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Fixtures isolées ; imports production réels, sockets headless possédés."""
|
||||
import copy
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
BACKEND = Path(os.environ.get("LABFY_SESSION_V2_BACKEND", str(REPO / "quickshell/.config/quickshell/labfy-sway")))
|
||||
sys.path.insert(0, str(BACKEND))
|
||||
sys.path.insert(0, str(REPO / "tests/session_v2/fixtures"))
|
||||
os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
from session_v2.applications import Catalog, Spec
|
||||
from session_v2.schema import validate
|
||||
|
||||
|
||||
def fixture(shape=None, workspace="TEST_ONLY_TARGET"):
|
||||
shape = shape or {"splitv": ["A", {"splith": ["B", "C"]}]}
|
||||
slots, apps = [], []
|
||||
def tree(n, path=()):
|
||||
if isinstance(n, str):
|
||||
app = "app-" + n
|
||||
slots.append({"slot_id": n, "application_id": app, "workspace": workspace,
|
||||
"tree_path": list(path), "state": {"floating": False, "geometry": None, "fullscreen": False, "scratchpad": False},
|
||||
"identity_requirement": "exact", "identity_evidence": {"type": "application-singleton", "id": app}})
|
||||
apps.append({"application_id": app, "desktop_entry": app + ".desktop", "strategy": "managed-autostart", "expected_windows": 1,
|
||||
"managed_by": "autostart", "identity_provider": None})
|
||||
return {"slot_id": n}
|
||||
layout, children = next(iter(n.items()))
|
||||
return {"layout": layout, "children": [tree(c, path + (i,)) for i, c in enumerate(children)]}
|
||||
root = tree(shape)
|
||||
return validate({"schema": "labfy.sway.session-v2", "version": 1,
|
||||
"metadata": {"created_ns": time.time_ns(), "sway_session": "test", "reason": "manual"},
|
||||
"outputs": [{"name": "HEADLESS-1"}], "applications": apps, "window_slots": slots,
|
||||
"workspaces": [{"workspace": workspace, "output": "HEADLESS-1", "root": root}], "focus": slots[0]["slot_id"]})
|
||||
|
||||
|
||||
def catalog(data):
|
||||
return Catalog(tuple(Spec(a["application_id"], a["desktop_entry"], ("TEST_ONLY_" + a["application_id"],),
|
||||
a["strategy"], (), a["identity_provider"]) for a in data["applications"]))
|
||||
|
||||
|
||||
def build(directory):
|
||||
import subprocess
|
||||
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "gtk+-3.0", "gdk-wayland-3.0"], text=True).split()
|
||||
binary = Path(directory) / "labfy-v2-anchor"
|
||||
subprocess.run(["cc", "-std=c17", "-Wall", "-Wextra", "-Werror", str(BACKEND / "session_v2/anchor_helper.c"), "-o", str(binary), *flags], check=True)
|
||||
return binary
|
||||
|
||||
# Les résultats TEST_ONLY restent locaux et ne sont jamais publiés avec les sources.
|
||||
EVIDENCE = Path(os.environ.get("LABFY_SESSION_V2_EVIDENCE", str(REPO / "build/session-v2-tests")))
|
||||
79
tests/session_v2/crashes.py
Normal file
79
tests/session_v2/crashes.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""SIGKILL réels de l'executor production, jamais du guard ni d'un utilisateur."""
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from common import *
|
||||
from lab import Lab, Surfaces, build_helper, until
|
||||
from session_v2.storage import atomic, private_dir, read
|
||||
from session_v2.executor import Executor
|
||||
from session_v2.ipc import Sway
|
||||
|
||||
|
||||
def worker(path):
|
||||
config = read(path)
|
||||
def progress(phase, count):
|
||||
if [phase, count] == config["point"]:
|
||||
atomic(Path(config["runtime"]) / "kill-ready.json", {"phase": phase, "count": count})
|
||||
while True: time.sleep(1)
|
||||
Executor(Sway(config["socket"]), catalog(config["data"]), None, config["runtime"], config["binary"],
|
||||
os.environ.copy(), progress).apply(config["data"], True)
|
||||
|
||||
|
||||
def main():
|
||||
cases = []
|
||||
with Lab() as lab:
|
||||
binary, helper_binary = build(lab.directory), build_helper(lab.directory)
|
||||
for i, point in enumerate((("anchor", 1), ("all-anchors", 4), ("swap", 1), ("swap", 3),
|
||||
("before-cleanup", 0), ("cleanup", 1), ("after-resume", 4), ("normal", 0), ("initial-stopped", 0))):
|
||||
runtime = private_dir(lab.directory / f"crash-{i}")
|
||||
data = fixture({"splitv": [{"splith": ["A", "B"]}, {"splith": ["C", "D"]}]})
|
||||
apps = Surfaces(lab, helper_binary)
|
||||
lab.command('workspace TEST_ONLY_SOURCE')
|
||||
ids = [apps.create("TEST_ONLY_" + a["application_id"])["id"] for a in data["applications"]]
|
||||
foreign = apps.create("labfy-v2-anchor-" + "f" * 32 + "-foreign")["id"]
|
||||
helper = lab.spawn(["autotiling"])
|
||||
time.sleep(.15)
|
||||
helper_fd = os.pidfd_open(helper.pid)
|
||||
if point[0] == "initial-stopped": signal.pidfd_send_signal(helper_fd, signal.SIGSTOP)
|
||||
config = {"socket": lab.socket, "runtime": str(runtime), "binary": str(binary), "data": data, "point": list(point)}
|
||||
atomic(runtime / "config.json", config)
|
||||
process = lab.spawn([sys.executable, "-B", str(Path(__file__).resolve()), "--worker", str(runtime / "config.json")])
|
||||
killed = None
|
||||
try:
|
||||
if point[0] not in ("normal", "initial-stopped"):
|
||||
until(lambda: (runtime / "kill-ready.json").exists() or process.poll() is not None, 15)
|
||||
assert process.poll() is None
|
||||
killed = time.monotonic()
|
||||
process.kill()
|
||||
process.wait(timeout=25)
|
||||
report = runtime / "session-v2-restore-attempt.json"
|
||||
until(lambda: report.exists() and read(report)["status"] != "running", 10)
|
||||
if killed: until(lambda: read(report)["phase"] == "recovered", 10)
|
||||
result = read(report)
|
||||
state = Path(f"/proc/{helper.pid}/status").read_text().split("State:\t", 1)[1][0]
|
||||
windows = lab.windows()
|
||||
row = {"point": list(point), "returncode": process.returncode, "report": result,
|
||||
"kill_to_recovery_ms": (time.monotonic() - killed) * 1000 if killed else None,
|
||||
"apps_preserved": set(ids).issubset(n["id"] for n in windows),
|
||||
"foreign_preserved": any(n["id"] == foreign for n in windows),
|
||||
"state_preserved": state == "T" if point[0] == "initial-stopped" else state not in ("T", "t"),
|
||||
"anchors_remaining": sum(n.get("app_id", "").startswith("labfy-v2-anchor-") and n["id"] != foreign for n in windows)}
|
||||
assert row["apps_preserved"] and row["foreign_preserved"] and row["state_preserved"] and row["anchors_remaining"] == 0, row
|
||||
assert result["status"] == ("interrupted" if killed else "success"), row
|
||||
if killed: assert row["kill_to_recovery_ms"] < 3000
|
||||
cases.append(row)
|
||||
print(point, "PASS", flush=True)
|
||||
finally:
|
||||
if process.poll() is None: process.kill(); process.wait()
|
||||
signal.pidfd_send_signal(helper_fd, signal.SIGCONT)
|
||||
helper.terminate(); helper.wait(timeout=3)
|
||||
os.close(helper_fd)
|
||||
apps.close()
|
||||
out = private_dir(EVIDENCE)
|
||||
atomic(out / "crash.json", {"cases": cases})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--worker" in sys.argv: worker(sys.argv[2])
|
||||
else: main()
|
||||
43
tests/session_v2/failures.py
Normal file
43
tests/session_v2/failures.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Échecs runtime du moteur production : conservation des apps et reprise."""
|
||||
import time
|
||||
from common import *
|
||||
from lab import Lab, Surfaces, build_helper
|
||||
from session_v2.ipc import Sway
|
||||
from session_v2.executor import Executor
|
||||
from session_v2.errors import Failure
|
||||
from session_v2.storage import private_dir, atomic, read
|
||||
|
||||
|
||||
def main():
|
||||
rows = []
|
||||
with Lab() as lab:
|
||||
binary, appbin = build(lab.directory), build_helper(lab.directory)
|
||||
runtime = private_dir(lab.directory / "labfy-sway")
|
||||
for mode, reason in (("missing", "APPLICATION_WINDOWS_INCOMPLETE"), ("extra", "UNEXPECTED_EXTRA_WINDOW"), ("occupied", "WORKSPACE_OCCUPIED")):
|
||||
data = fixture()
|
||||
apps = Surfaces(lab, appbin)
|
||||
lab.command('workspace TEST_ONLY_SOURCE')
|
||||
members = data["applications"][:-1] if mode == "missing" else data["applications"]
|
||||
ids = [apps.create("TEST_ONLY_" + a["application_id"])["id"] for a in members]
|
||||
if mode == "extra": ids.append(apps.create("TEST_ONLY_app-A")["id"])
|
||||
if mode == "occupied":
|
||||
lab.command('workspace TEST_ONLY_TARGET')
|
||||
ids.append(apps.create("TEST_ONLY_FOREIGN")["id"])
|
||||
helper = lab.spawn(["autotiling"]); time.sleep(.15)
|
||||
try:
|
||||
try: Executor(Sway(lab.socket), catalog(data), None, runtime, binary, lab.env).apply(data, True, timeout=.3)
|
||||
except Failure as exc: assert exc.code == reason, (exc.code, reason)
|
||||
else: raise AssertionError(mode)
|
||||
report = read(runtime / "session-v2-restore-attempt.json")
|
||||
assert report["status"] == "failed" and report["reason"] == reason, report
|
||||
assert set(ids).issubset(n["id"] for n in lab.windows())
|
||||
assert not any(n.get("app_id", "").startswith("labfy-v2-anchor-") for n in lab.windows())
|
||||
assert Path(f"/proc/{helper.pid}/status").read_text().split("State:\t", 1)[1][0] not in ("T", "t")
|
||||
rows.append({"case": mode, "report": report, "apps_preserved": True, "anchors_remaining": 0, "helper_recovered": True})
|
||||
finally:
|
||||
helper.terminate(); helper.wait(timeout=3); apps.close()
|
||||
atomic(private_dir(EVIDENCE) / "failures.json", {"cases": rows})
|
||||
print(len(rows), "runtime failures PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__": main()
|
||||
6
tests/session_v2/fixtures/headless.conf
Normal file
6
tests/session_v2/fixtures/headless.conf
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# TEST_ONLY : aucun include, autostart, service ou accès aux sorties physiques.
|
||||
xwayland disable
|
||||
output HEADLESS-1 mode 1280x720
|
||||
focus_follows_mouse no
|
||||
default_border pixel 1
|
||||
seat seat0 fallback true
|
||||
143
tests/session_v2/fixtures/headless.py
Normal file
143
tests/session_v2/fixtures/headless.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Expériences TEST_ONLY ; jamais importé ni lancé par le code de production.
|
||||
|
||||
CONTRAT : IPC exclusivement vers le socket du compositeur enfant headless.
|
||||
Les seuls processus arrêtés sont des enfants détenus par cette expérience.
|
||||
Les arbres conservés ne contiennent que des fenêtres synthétiques.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def run(argv, **kwargs):
|
||||
return subprocess.run(argv, text=True, capture_output=True, timeout=10, **kwargs)
|
||||
|
||||
|
||||
def walk(node):
|
||||
yield node
|
||||
for child in node.get("nodes", []) + node.get("floating_nodes", []):
|
||||
yield from walk(child)
|
||||
|
||||
|
||||
class Lab:
|
||||
def __init__(self):
|
||||
self.directory = Path(tempfile.mkdtemp(prefix="labfy-session-v2-TEST_ONLY-"))
|
||||
self.children = []
|
||||
self.logs = []
|
||||
self.socket = None
|
||||
self.env = dict(os.environ)
|
||||
for key in ("SWAYSOCK", "I3SOCK", "DISPLAY", "WAYLAND_DISPLAY"):
|
||||
self.env.pop(key, None)
|
||||
self.env.update(XDG_RUNTIME_DIR=str(self.directory), TMPDIR=str(self.directory), WLR_BACKENDS="headless",
|
||||
WLR_HEADLESS_OUTPUTS="1", WLR_RENDERER="gles2",
|
||||
WLR_RENDERER_ALLOW_SOFTWARE="1", LIBGL_ALWAYS_SOFTWARE="true")
|
||||
|
||||
def start(self, argv):
|
||||
log = (self.directory / f"child-{len(self.children)}.log").open("w")
|
||||
self.logs.append(log)
|
||||
proc = subprocess.Popen(argv, env=self.env, stdout=log, stderr=log,
|
||||
start_new_session=True)
|
||||
self.children.append(proc)
|
||||
return proc
|
||||
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.sway = self.start(["sway", "-c", str(ROOT / "headless.conf")])
|
||||
end = time.monotonic() + 15
|
||||
while time.monotonic() < end:
|
||||
sockets = list(self.directory.glob("sway-ipc.*.sock"))
|
||||
displays = [p for p in self.directory.glob("wayland-*") if p.is_socket()]
|
||||
if sockets and displays:
|
||||
self.socket = str(sockets[0])
|
||||
self.env.update(SWAYSOCK=self.socket, WAYLAND_DISPLAY=displays[0].name)
|
||||
self.tree()
|
||||
return self
|
||||
if self.sway.poll() is not None:
|
||||
raise RuntimeError((self.directory / "child-0.log").read_text()[-6000:])
|
||||
time.sleep(.1)
|
||||
raise RuntimeError("Délai de démarrage headless dépassé")
|
||||
except BaseException:
|
||||
self.__exit__(None, None, None)
|
||||
raise
|
||||
|
||||
def ipc(self, command=None, kind=None):
|
||||
assert self.socket and self.socket.startswith(str(self.directory) + "/")
|
||||
args = ["swaymsg", "-s", self.socket, "-r"]
|
||||
args += ["-t", kind] if kind else [command]
|
||||
result = run(args, env=self.env)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def command(self, text):
|
||||
reply = self.ipc(text)
|
||||
if not all(item.get("success") for item in reply):
|
||||
raise RuntimeError((text, reply))
|
||||
return reply
|
||||
|
||||
def tree(self):
|
||||
return self.ipc(kind="get_tree")
|
||||
|
||||
def windows(self):
|
||||
return [n for n in walk(self.tree()) if n.get("app_id")]
|
||||
|
||||
def kitty(self, identity):
|
||||
before = {n["id"] for n in self.windows()}
|
||||
self.start(["kitty", "--config", "NONE", "--class", identity,
|
||||
"--title", "TEST_ONLY", "-o", "linux_display_server=wayland",
|
||||
"-o", "confirm_os_window_close=0", "/usr/bin/sleep", "180"])
|
||||
end = time.monotonic() + 10
|
||||
while time.monotonic() < end:
|
||||
found = [n for n in self.windows() if n["id"] not in before and n["app_id"] == identity]
|
||||
if len(found) == 1:
|
||||
return found[0]["id"]
|
||||
time.sleep(.1)
|
||||
raise RuntimeError("Fenêtre Kitty TEST_ONLY absente")
|
||||
|
||||
def empty(self, workspace):
|
||||
old = self.windows()
|
||||
for n in old:
|
||||
assert n["app_id"].startswith("TEST_ONLY")
|
||||
self.command(f'[con_id={n["id"]}] kill')
|
||||
self.wait_absent([n["id"] for n in old])
|
||||
self.command(f'workspace "{workspace}"')
|
||||
time.sleep(.15)
|
||||
|
||||
def wait_absent(self, ids):
|
||||
# Une réponse IPC à kill ne prouve pas encore le retrait du top-level.
|
||||
end = time.monotonic() + 5
|
||||
while time.monotonic() < end:
|
||||
if not set(ids).intersection(n["id"] for n in self.windows()):
|
||||
return
|
||||
time.sleep(.05)
|
||||
raise RuntimeError("Fenêtre TEST_ONLY encore présente après fermeture")
|
||||
|
||||
def shape(self, workspace):
|
||||
def node(n):
|
||||
if n.get("app_id"):
|
||||
return n["app_id"]
|
||||
children = [node(c) for c in n.get("nodes", [])]
|
||||
if len(children) == 1:
|
||||
return children[0]
|
||||
return {n.get("layout"): children}
|
||||
found = [n for n in walk(self.tree()) if n.get("type") == "workspace" and n.get("name") == workspace]
|
||||
return node(found[0]) if found else None
|
||||
|
||||
def __exit__(self, *_):
|
||||
# INVARIANT : même après exception, aucun helper détenu ne reste stoppé.
|
||||
for proc in reversed(self.children):
|
||||
if proc.poll() is None:
|
||||
os.killpg(proc.pid, signal.SIGCONT)
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.wait(timeout=3)
|
||||
for log in self.logs:
|
||||
log.close()
|
||||
147
tests/session_v2/fixtures/lab.py
Normal file
147
tests/session_v2/fixtures/lab.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""Laboratoire headless Phase 2 ; sockets explicites, enfants possédés et bornés."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
from headless import Lab as BaseLab, walk
|
||||
|
||||
|
||||
def until(function, timeout=5):
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
value = function()
|
||||
if value:
|
||||
return value
|
||||
time.sleep(.02)
|
||||
raise TimeoutError("TEST_ONLY_TIMEOUT")
|
||||
|
||||
|
||||
class IPC:
|
||||
def __init__(self, path):
|
||||
self.socket = path
|
||||
if not path.startswith("/tmp/labfy-session-v2-TEST_ONLY-"):
|
||||
raise ValueError("NON_TEST_SOCKET_FORBIDDEN")
|
||||
|
||||
def ipc(self, command=None, kind=None):
|
||||
message_type = {"get_tree": 4, "get_outputs": 3, "get_workspaces": 1, "get_version": 7}.get(kind, 0)
|
||||
payload = (command or "").encode()
|
||||
with socket.socket(socket.AF_UNIX) as connection:
|
||||
connection.settimeout(3)
|
||||
connection.connect(self.socket)
|
||||
connection.sendall(struct.pack("<6sII", b"i3-ipc", len(payload), message_type) + payload)
|
||||
def read(n):
|
||||
data = bytearray()
|
||||
while len(data) < n:
|
||||
part = connection.recv(n - len(data))
|
||||
if not part: raise RuntimeError("IPC_CLOSED")
|
||||
data.extend(part)
|
||||
return bytes(data)
|
||||
magic, size, _ = struct.unpack("<6sII", read(14))
|
||||
if magic != b"i3-ipc" or size > 4 * 1024 * 1024:
|
||||
raise ValueError("IPC_BOUNDS")
|
||||
return json.loads(read(size))
|
||||
|
||||
def command(self, command):
|
||||
result = self.ipc(command)
|
||||
if not result or not all(x.get("success") for x in result):
|
||||
raise RuntimeError((command, result))
|
||||
return result
|
||||
|
||||
def tree(self): return self.ipc(kind="get_tree")
|
||||
def windows(self): return [n for n in walk(self.tree()) if n.get("app_id")]
|
||||
|
||||
|
||||
class Lab(BaseLab):
|
||||
def ipc(self, command=None, kind=None):
|
||||
return IPC(self.socket).ipc(command, kind)
|
||||
|
||||
def spawn(self, argv, stdin=None, env=None, pass_fds=()):
|
||||
log = (self.directory / f"p2-{len(self.children)}.log").open("w")
|
||||
self.logs.append(log)
|
||||
process = subprocess.Popen(argv, stdin=stdin, stdout=log, stderr=log,
|
||||
env=env or self.env, start_new_session=True, pass_fds=pass_fds)
|
||||
self.children.append(process)
|
||||
return process
|
||||
|
||||
|
||||
class Surfaces:
|
||||
def __init__(self, lab, binary):
|
||||
self.lab = lab
|
||||
self.proc = lab.spawn([str(binary)], stdin=subprocess.PIPE)
|
||||
self.pidfd = os.pidfd_open(self.proc.pid)
|
||||
self.ids = set()
|
||||
|
||||
def create(self, app_id):
|
||||
before = {n["id"] for n in self.lab.windows()}
|
||||
self.proc.stdin.write(f"create {app_id}\n".encode())
|
||||
self.proc.stdin.flush()
|
||||
found = until(lambda: [n for n in self.lab.windows() if n["id"] not in before
|
||||
and n["pid"] == self.proc.pid and n["app_id"] == app_id])
|
||||
if len(found) != 1: raise ValueError("SURFACE_AMBIGUOUS")
|
||||
self.ids.add(found[0]["id"])
|
||||
return found[0]
|
||||
|
||||
def close(self):
|
||||
if self.proc.poll() is None:
|
||||
self.proc.stdin.write(b"quit\n")
|
||||
self.proc.stdin.flush()
|
||||
self.proc.wait(timeout=5)
|
||||
until(lambda: not any(n["id"] in self.ids for n in self.lab.windows()))
|
||||
self.proc.stdin.close()
|
||||
os.close(self.pidfd)
|
||||
|
||||
|
||||
def build_helper(directory):
|
||||
flags = subprocess.run(["pkg-config", "--cflags", "--libs", "gtk+-3.0", "gdk-wayland-3.0"],
|
||||
text=True, capture_output=True, check=True).stdout.split()
|
||||
binary = directory / "TEST_ONLY_surfaces"
|
||||
subprocess.run(["cc", "-std=c17", "-Wall", "-Wextra", "-Werror", str(ROOT / "surfaces.c"), "-o", str(binary), *flags], check=True)
|
||||
return binary
|
||||
|
||||
|
||||
def workspace_node(tree, name):
|
||||
return next((n for n in walk(tree) if n.get("type") == "workspace" and n.get("name") == name), None)
|
||||
|
||||
|
||||
def location(tree, identity):
|
||||
def search(node, workspace=None, path=()):
|
||||
if node.get("type") == "workspace":
|
||||
workspace, path = node["name"], ()
|
||||
if node["id"] == identity:
|
||||
return workspace, path
|
||||
for i, child in enumerate(node.get("nodes", [])):
|
||||
found = search(child, workspace, path + (i,))
|
||||
if found: return found
|
||||
return search(tree)
|
||||
|
||||
|
||||
def shape(tree, name, mapping):
|
||||
node = workspace_node(tree, name)
|
||||
if not node: return None
|
||||
def normalize(n):
|
||||
if n.get("app_id"):
|
||||
return mapping.get(n["id"], "UNEXPECTED:" + n["app_id"])
|
||||
children = [normalize(c) for c in n.get("nodes", [])]
|
||||
if len(children) == 1 and n["layout"] in ("splith", "splitv", "none"):
|
||||
return children[0]
|
||||
return {n["layout"]: children}
|
||||
return normalize(node)
|
||||
|
||||
|
||||
def atomic_json(path, value):
|
||||
path = Path(path)
|
||||
temporary = path.with_suffix(".tmp")
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as stream:
|
||||
json.dump(value, stream, indent=2)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
45
tests/session_v2/fixtures/surfaces.c
Normal file
45
tests/session_v2/fixtures/surfaces.c
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/* TEST_ONLY : un processus GTK, plusieurs top-levels Wayland adressables.
|
||||
* WHY : éviter N processus terminaux pour N ancrages.
|
||||
* CONTRACT : stdin privé du harness, commandes create/quit uniquement.
|
||||
* INVARIANT : aucune application, configuration ou fenêtre utilisateur ouverte.
|
||||
*/
|
||||
#include <gtk/gtk.h>
|
||||
#include <gdk/gdkwayland.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static gboolean input(GIOChannel *channel, GIOCondition condition, gpointer data) {
|
||||
(void)channel;
|
||||
(void)data;
|
||||
if (condition & (G_IO_HUP | G_IO_ERR)) { gtk_main_quit(); return FALSE; }
|
||||
char buffer[256];
|
||||
if (!fgets(buffer, sizeof buffer, stdin)) { gtk_main_quit(); return FALSE; }
|
||||
if (strcmp(buffer, "quit\n") == 0) { gtk_main_quit(); return FALSE; }
|
||||
char identity[181];
|
||||
if (sscanf(buffer, "create %180s", identity) != 1 ||
|
||||
!g_regex_match_simple("^[A-Za-z0-9_.-]{1,180}$", identity, 0, 0)) {
|
||||
fprintf(stderr, "Commande TEST_ONLY invalide\n"); return TRUE;
|
||||
}
|
||||
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
gtk_window_set_title(GTK_WINDOW(window), "TEST_ONLY");
|
||||
gtk_window_set_default_size(GTK_WINDOW(window), 120, 80);
|
||||
gtk_widget_set_size_request(window, 1, 1);
|
||||
gtk_widget_realize(window);
|
||||
gtk_widget_show_all(window);
|
||||
/* GTK publie son app_id lors du mapping ; l'identité de test suit ce mapping. */
|
||||
gdk_wayland_window_set_application_id(gtk_widget_get_window(window), identity);
|
||||
gdk_display_flush(gtk_widget_get_display(window));
|
||||
printf("created %s\n", identity); fflush(stdout);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
g_set_prgname("TEST_ONLY_SURFACES");
|
||||
gtk_init(&argc, &argv);
|
||||
setvbuf(stdin, NULL, _IONBF, 0);
|
||||
GIOChannel *channel = g_io_channel_unix_new(0);
|
||||
g_io_add_watch(channel, G_IO_IN | G_IO_HUP | G_IO_ERR, input, NULL);
|
||||
gtk_main();
|
||||
g_io_channel_unref(channel);
|
||||
return 0;
|
||||
}
|
||||
33
tests/session_v2/phase3b_runtime.py
Normal file
33
tests/session_v2/phase3b_runtime.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Placement Limusic synthétique : vrai backend déployé, Sway TEST_ONLY."""
|
||||
from unittest.mock import patch
|
||||
from common import *
|
||||
from lab import Lab, Surfaces, build_helper
|
||||
from session_v2.applications import Catalog
|
||||
from session_v2.executor import Executor
|
||||
from session_v2.ipc import Sway
|
||||
from session_v2.storage import private_dir, atomic
|
||||
from session_v2.snapshot import capture
|
||||
|
||||
|
||||
def main():
|
||||
with Lab() as lab:
|
||||
apps = Surfaces(lab, build_helper(lab.directory))
|
||||
lab.command('workspace TEST_ONLY_SOURCE')
|
||||
apps.create('limusic-app')
|
||||
data = fixture('A', 'TEST_ONLY_LIMUSIC')
|
||||
data['applications'] = [{'application_id': 'limusic', 'desktop_entry': 'limusic.desktop',
|
||||
'strategy': 'managed-autostart', 'expected_windows': 1, 'managed_by': 'sway-autostart', 'identity_provider': None}]
|
||||
data['window_slots'][0].update(application_id='limusic', identity_evidence={'type': 'application-singleton', 'id': 'limusic'})
|
||||
try:
|
||||
# Toute tentative de lancement serait une régression de propriété.
|
||||
with patch('session_v2.executor.launch', side_effect=lambda spec, count, env: [] if count == 0 else (_ for _ in ()).throw(AssertionError('DUPLICATE_LIMUSIC'))):
|
||||
result = Executor(Sway(lab.socket), Catalog(), None, private_dir(lab.directory / 'runtime'), build(lab.directory), lab.env).apply(data, True)
|
||||
snap = capture(Sway(lab.socket), Catalog(), None)
|
||||
assert snap['applications'][0]['managed_by'] == 'sway-autostart'
|
||||
assert result['status'] == 'success'
|
||||
atomic(private_dir(EVIDENCE) / 'limusic-test-only.json', {'result': result, 'launches': 0, 'capture_owner': 'sway-autostart'})
|
||||
print('LIMUSIC_TEST_ONLY=PASS')
|
||||
finally: apps.close()
|
||||
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
109
tests/session_v2/phase3b_ui.py
Normal file
109
tests/session_v2/phase3b_ui.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Recette UI sans desktop utilisateur : compositor privé et CLI simulée.
|
||||
|
||||
Les composants QML V2 sont les vrais candidats ; seul le backend/Theme sont
|
||||
des fixtures. Aucun appel logout ou restauration utilisateur n'est possible.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from common import *
|
||||
from lab import Lab, Surfaces, build_helper
|
||||
from session_v2.storage import atomic, private_dir, read
|
||||
from session_v2.checkpoint import Store
|
||||
|
||||
SOURCE = REPO / 'quickshell/.config/quickshell/labfy-sway'
|
||||
FAKE = '''import json, os, sys
|
||||
from pathlib import Path
|
||||
cmd = sys.argv[1]
|
||||
log = Path(os.environ['LABFY_UI_LOG'])
|
||||
with log.open('a') as s: s.write(cmd + '\\n')
|
||||
mode = os.environ['LABFY_UI_MODE']
|
||||
if cmd == 'startup-status': r = {'eligible': mode != 'claimed', 'claimed': mode != 'claimed', 'windows': 3, 'workspaces': 2}
|
||||
elif cmd == 'apply-last':
|
||||
r = {'schema':'labfy.sway.session-v2-restore-attempt', 'version':1, 'status':'success', 'transaction_id':'a'*32, 'identity_level':'exact', 'final_tree_verified':mode != 'invalid', 'final_focus_verified':True, 'slots_expected':3, 'slots_filled':3, 'applications_expected':2, 'applications_observed':2, 'anchors_created':3, 'anchors_cleaned':3, 'helpers_suspended':1, 'helpers_resumed':1}
|
||||
if mode == 'failure': r = {'status':'failed', 'reason':'APPLICATION_WINDOWS_INCOMPLETE'}
|
||||
elif cmd == 'acknowledge-startup': r = {'status':'acknowledged', 'choice':sys.argv[sys.argv.index('--choice')+1]}
|
||||
elif cmd == 'restore-status': r = {'status':'failed', 'phase':'resolve', 'reason':'APPLICATION_WINDOWS_INCOMPLETE'}
|
||||
else: raise RuntimeError('UNEXPECTED_COMMAND')
|
||||
print(json.dumps(r))
|
||||
'''
|
||||
HARNESS = '''//@ pragma UseQApplication
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
ShellRoot {
|
||||
PanelWindow { id: bar; visible: false }
|
||||
SessionStartupPromptV2 { id: prompt; barWindow: bar; startupHost: true }
|
||||
Timer { interval: 600; running: true; onTriggered: {
|
||||
prompt.restoreRequested();
|
||||
if (prompt.attempted) { console.error("UI_TEST_EARLY_ACTIVATION"); Qt.quit(); }
|
||||
} }
|
||||
Timer { interval: 1400; running: true; onTriggered: {
|
||||
if (Quickshell.env("LABFY_UI_MODE") === "claimed") return;
|
||||
if (!prompt.interactionArmed) { console.error("UI_TEST_NOT_ARMED"); Qt.quit(); return; }
|
||||
if (Quickshell.env("LABFY_UI_MODE") === "new") prompt.newSessionRequested();
|
||||
else { prompt.restoreRequested(); prompt.restoreRequested(); }
|
||||
} }
|
||||
Timer { interval: 3400; running: true; onTriggered: {
|
||||
const mode = Quickshell.env("LABFY_UI_MODE");
|
||||
const okay = (mode === "success" || mode === "real") ? prompt.restored && !prompt.visible
|
||||
: (mode === "failure" || mode === "invalid") ? prompt.failed && prompt.visible
|
||||
: !prompt.visible && !prompt.attempted;
|
||||
if (prompt.failed) prompt.restoreRequested();
|
||||
console.log(okay ? "UI_TEST_PASS" : "UI_TEST_FAIL"); Qt.quit();
|
||||
} }
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def main():
|
||||
rows = []
|
||||
with Lab() as lab:
|
||||
for mode in ('success', 'failure', 'invalid', 'new', 'claimed', 'real'):
|
||||
config = lab.directory / mode / 'config'
|
||||
base = config / 'quickshell/labfy-sway'
|
||||
base.mkdir(parents=True)
|
||||
shutil.copytree(SOURCE / 'sessionui', base / 'sessionui')
|
||||
(base / 'controlcenter').mkdir()
|
||||
shutil.copyfile(SOURCE / 'SessionStartupPromptV2.qml', base / 'SessionStartupPromptV2.qml')
|
||||
shutil.copyfile((BACKEND / 'controlcenter/ActionButton.qml'), base / 'controlcenter/ActionButton.qml')
|
||||
(base / 'theme').mkdir()
|
||||
(base / 'theme/qmldir').write_text('singleton Theme 1.0 Theme.qml\n')
|
||||
colors = 'popupBackground foreground secondaryForeground lavender buttonBackground emphasisBackground border danger'
|
||||
(base / 'theme/Theme.qml').write_text('pragma Singleton\nimport QtQuick\nQtObject {\n' + '\n'.join('property color ' + c + ': "#333333"' for c in colors.split()) + '\n}\n')
|
||||
(base / 'session-v2.py').write_text(FAKE)
|
||||
(base / 'shell.qml').write_text(HARNESS)
|
||||
log = lab.directory / (mode + '.calls')
|
||||
env = {**lab.env, 'XDG_CONFIG_HOME': str(config), 'LABFY_UI_MODE': mode, 'LABFY_UI_LOG': str(log)}
|
||||
apps = None
|
||||
if mode == 'real':
|
||||
# Vrai moteur avec chooser visible : tester aussi l'interaction
|
||||
# entre focus layer-shell et la postcondition finale Sway.
|
||||
env['XDG_STATE_HOME'] = str(private_dir(lab.directory / 'state'))
|
||||
real_backend = BACKEND
|
||||
anchor_binary = build(lab.directory)
|
||||
(base / 'session-v2.py').write_text('import sys, os\nfrom pathlib import Path\nsys.path.insert(0, ' + repr(str(real_backend)) + ')\nfrom session_v2.cli import main\nwith Path(os.environ["LABFY_UI_LOG"]).open("a") as s: s.write(sys.argv[1] + "\\n")\nsys.exit(main(sys.argv[1:] + [\"--anchor-binary\", ' + repr(str(anchor_binary)) + ']))\n')
|
||||
apps = Surfaces(lab, build_helper(lab.directory))
|
||||
lab.command('workspace TEST_ONLY_SOURCE')
|
||||
apps.create('limusic-app')
|
||||
data = fixture('A', 'TEST_ONLY_TARGET')
|
||||
data['metadata']['sway_session'] = 'previous-session'
|
||||
data['applications'] = [{'application_id': 'limusic', 'desktop_entry': 'limusic.desktop', 'strategy': 'managed-autostart',
|
||||
'managed_by': 'sway-autostart', 'expected_windows': 1, 'identity_provider': None}]
|
||||
data['window_slots'][0].update(application_id='limusic', identity_evidence={'type': 'application-singleton', 'id': 'limusic'})
|
||||
Store(Path(env['XDG_STATE_HOME']) / 'labfy-sway/session-v2').checkpoint(data)
|
||||
result = subprocess.run(['qs', '-p', str(base), '--no-color'], env=env, capture_output=True, text=True, timeout=15)
|
||||
output = result.stdout + result.stderr
|
||||
if mode == 'real':
|
||||
report = read(Path(env['XDG_RUNTIME_DIR']) / 'labfy-sway/session-v2-restore-attempt.json')
|
||||
assert report['status'] == 'success', report
|
||||
apps.close()
|
||||
assert 'UI_TEST_PASS' in output and 'UI_TEST_FAIL' not in output, output
|
||||
calls = log.read_text().splitlines()
|
||||
assert calls.count('apply-last') == (1 if mode in ('success', 'failure', 'invalid', 'real') else 0), calls
|
||||
assert calls.count('acknowledge-startup') == (1 if mode in ('success', 'new', 'real') else 0), calls
|
||||
rows.append({'case': mode, 'calls': calls, 'result': 'PASS'})
|
||||
print(mode, 'PASS', flush=True)
|
||||
atomic(private_dir(EVIDENCE) / 'ui-test-only.json', {'cases': rows})
|
||||
|
||||
|
||||
if __name__ == '__main__': main()
|
||||
53
tests/session_v2/qml/tst_exit_v2.qml
Normal file
53
tests/session_v2/qml/tst_exit_v2.qml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import QtQuick
|
||||
import QtTest
|
||||
|
||||
TestCase {
|
||||
name: "SessionExitGateV2"
|
||||
function gate() {
|
||||
const component = Qt.createComponent("../../../quickshell/.config/quickshell/labfy-sway/controlcenter/SessionExitGate.qml");
|
||||
compare(component.status, Component.Ready, component.errorString());
|
||||
return createTemporaryObject(component, this, {
|
||||
checkpointSuccessStatus: "saved",
|
||||
actions: [
|
||||
{id: "logout", checkpointReason: "logout", command: ["uwsm", "stop"]},
|
||||
{id: "reboot", checkpointReason: "reboot", command: ["systemctl", "reboot"]},
|
||||
{id: "poweroff", checkpointReason: "poweroff", command: ["systemctl", "poweroff"]}
|
||||
]
|
||||
});
|
||||
}
|
||||
Component { id: spyComponent; SignalSpy {} }
|
||||
function test_saved_releases_only_confirmed_action() {
|
||||
for (const action of ["logout", "reboot", "poweroff"]) {
|
||||
const g = gate();
|
||||
const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"});
|
||||
g.requestAction(action);
|
||||
g.confirmAction(action);
|
||||
verify(g.busy);
|
||||
compare(spy.count, 0);
|
||||
g.checkpointFinished(0, 0, '{"status":"saved"}', "");
|
||||
compare(spy.count, 1);
|
||||
compare(spy.signalArguments[0][0], action === "logout" ? ["uwsm", "stop"] : ["systemctl", action]);
|
||||
}
|
||||
}
|
||||
function test_error_or_legacy_success_never_exits() {
|
||||
for (const response of ["{}", "invalid", '{"status":"success"}', '{"status":"failed"}']) {
|
||||
const g = gate();
|
||||
const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"});
|
||||
g.requestAction("logout"); g.confirmAction("logout");
|
||||
g.checkpointFinished(0, 0, response, "");
|
||||
compare(spy.count, 0);
|
||||
verify(!g.busy);
|
||||
g.cancel();
|
||||
compare(spy.count, 0);
|
||||
}
|
||||
}
|
||||
function test_quit_without_saving_requires_explicit_call() {
|
||||
const g = gate();
|
||||
const spy = createTemporaryObject(spyComponent, this, {target: g, signalName: "commandRequested"});
|
||||
g.requestAction("logout"); g.confirmAction("logout");
|
||||
g.quitWithoutCheckpoint(); compare(spy.count, 0);
|
||||
g.checkpointFinished(2, 0, '{"status":"saved"}', "échec");
|
||||
compare(spy.count, 0);
|
||||
g.quitWithoutCheckpoint(); compare(spy.count, 1);
|
||||
}
|
||||
}
|
||||
96
tests/session_v2/runtime.py
Normal file
96
tests/session_v2/runtime.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import argparse
|
||||
import time
|
||||
from common import *
|
||||
from lab import Lab, Surfaces, build_helper, until
|
||||
from session_v2.ipc import Sway
|
||||
from session_v2.executor import Executor
|
||||
from session_v2.storage import private_dir, atomic
|
||||
from session_v2.snapshot import capture
|
||||
|
||||
|
||||
def main(smoke=False):
|
||||
rows = []
|
||||
shapes = ["A", {"splith": ["A", "B"]}, {"splitv": ["A", "B"]},
|
||||
{"splith": ["A", "B", "C"]}, {"splitv": ["A", "B", "C"]},
|
||||
{"splitv": ["A", {"splith": ["B", "C"]}]},
|
||||
{"splith": ["A", {"splitv": ["B", "C"]}]},
|
||||
{"splitv": [{"splith": ["A", "B"]}, {"splith": ["C", "D"]}]},
|
||||
{"splith": [{"splitv": ["A", "B"]}, {"splitv": ["C", "D"]}]},
|
||||
{"splitv": ["A", {"splith": ["B", {"splitv": ["C", "D"]}]}]},
|
||||
{"tabbed": ["A", "B", "C"]}, {"stacked": ["A", "B", "C"]},
|
||||
{"splitv": ["A", {"tabbed": ["B", "C"]}]}, {"splith": ["A", {"stacked": ["B", "C"]}]}]
|
||||
with Lab() as lab:
|
||||
binary = build(lab.directory)
|
||||
test_binary = build_helper(lab.directory)
|
||||
runtime = private_dir(lab.directory / "labfy-sway")
|
||||
sway = Sway(lab.socket)
|
||||
for index, shape in enumerate(shapes[:1] if smoke else shapes):
|
||||
for reverse in ([False] if smoke else [False, True]):
|
||||
data = fixture(shape)
|
||||
apps = Surfaces(lab, test_binary)
|
||||
lab.command('workspace "TEST_ONLY_SOURCE"')
|
||||
order = list(reversed(data["applications"])) if reverse else data["applications"]
|
||||
for app in order: apps.create("TEST_ONLY_" + app["application_id"])
|
||||
helper = lab.spawn(["autotiling"])
|
||||
time.sleep(.15)
|
||||
try:
|
||||
result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True)
|
||||
snapped = capture(sway, catalog(data), None)
|
||||
assert len(snapped["window_slots"]) == len(data["window_slots"])
|
||||
assert result["helpers_suspended"] == result["helpers_resumed"] == 1
|
||||
rows.append({"shape": shape, "reverse": reverse, "result": result, "capture": "PASS"})
|
||||
print(index, reverse, "PASS", flush=True)
|
||||
finally:
|
||||
helper.terminate(); helper.wait(timeout=3)
|
||||
apps.close()
|
||||
if not smoke:
|
||||
for count in (3, 5, 10):
|
||||
data = fixture({"splith": [chr(65+i) for i in range(count)]})
|
||||
apps = Surfaces(lab, test_binary)
|
||||
lab.command('workspace "TEST_ONLY_SOURCE"')
|
||||
for a in data["applications"]: apps.create("TEST_ONLY_" + a["application_id"])
|
||||
result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True)
|
||||
rows.append({"performance_windows": count, "result": result})
|
||||
apps.close()
|
||||
data = fixture()
|
||||
second = fixture({"splith": ["D", "E"]}, "TEST_ONLY_SECOND")
|
||||
for key in ("applications", "window_slots", "workspaces"): data[key] += second[key]
|
||||
apps = Surfaces(lab, test_binary)
|
||||
lab.command('workspace "TEST_ONLY_SOURCE"')
|
||||
for a in data["applications"]: apps.create("TEST_ONLY_" + a["application_id"])
|
||||
result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True)
|
||||
rows.append({"multi_workspace": True, "result": result})
|
||||
apps.close()
|
||||
data = fixture({"splith": ["A", "B"]})
|
||||
data["workspaces"][0]["root"] = {"slot_id": "A"}
|
||||
data["window_slots"][0]["tree_path"] = []
|
||||
slot = data["window_slots"][1]
|
||||
slot["tree_path"] = []
|
||||
slot["state"].update(floating=True, geometry={"x": 30, "y": 40, "width": 400, "height": 240})
|
||||
apps = Surfaces(lab, test_binary)
|
||||
lab.command('workspace "TEST_ONLY_SOURCE"')
|
||||
for a in data["applications"]: apps.create("TEST_ONLY_" + a["application_id"])
|
||||
result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True)
|
||||
rows.append({"floating": True, "result": result})
|
||||
apps.close()
|
||||
# Même processus, même app_id et même titre : seule la cardinalité
|
||||
# autorise ces slots explicitement interchangeables/best-effort.
|
||||
for identity in ("interchangeable", "best-effort"):
|
||||
data = fixture({"splith": ["A", "B", "C"]})
|
||||
data["applications"] = [data["applications"][0]]
|
||||
data["applications"][0]["expected_windows"] = 3
|
||||
for slot in data["window_slots"]:
|
||||
slot.update(application_id="app-A", identity_requirement=identity, identity_evidence=None)
|
||||
apps = Surfaces(lab, test_binary)
|
||||
lab.command('workspace "TEST_ONLY_SOURCE"')
|
||||
for _ in range(3): apps.create("TEST_ONLY_app-A")
|
||||
result = Executor(sway, catalog(data), None, runtime, binary, lab.env).apply(data, True)
|
||||
assert result["identity_level"] == identity
|
||||
rows.append({"same_app_three": True, "identity": identity, "result": result})
|
||||
apps.close()
|
||||
out = EVIDENCE
|
||||
private_dir(out)
|
||||
atomic(out / ("smoke.json" if smoke else "runtime.json"), {"cases": rows})
|
||||
|
||||
|
||||
if __name__ == "__main__": main("--smoke" in sys.argv)
|
||||
60
tests/session_v2/security.py
Normal file
60
tests/session_v2/security.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Malformed native messages et privacy du vrai host candidat."""
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
import uuid
|
||||
from common import *
|
||||
from lab import Lab
|
||||
from session_v2.native_host import validate as native_validate, correlate, EXTENSION_ID
|
||||
from session_v2.storage import private_dir, atomic
|
||||
from session_v2.errors import Failure
|
||||
|
||||
|
||||
def main():
|
||||
results = []
|
||||
good = {"op": "map-window", "uuid": str(uuid.uuid4()), "runtime_token": "a" * 32}
|
||||
for index, value in enumerate((None, [], {}, {**good, "op": "exec"}, {**good, "uuid": True},
|
||||
{**good, "uuid": "a" * 4096}, {**good, "runtime_token": "x" * 33},
|
||||
{**good, "argv": []}, {**good, "title": "synthetic"}, {**good, "environment": {}})):
|
||||
try: native_validate(value)
|
||||
except Failure: results.append({"name": f"schema-{index}", "pass": True})
|
||||
else: raise AssertionError(index)
|
||||
class Reader:
|
||||
def windows(self):
|
||||
return [{"id": 1, "app_id": "firefox", "name": "[LABFY:" + good["runtime_token"] + "] ignored"}]
|
||||
assert correlate(Reader(), good) == 1
|
||||
results.append({"name": "read-only-correlation", "pass": True})
|
||||
class Collision:
|
||||
def windows(self): return Reader().windows() * 2
|
||||
try: correlate(Collision(), good)
|
||||
except Failure as e: assert e.code == "EXACT_WINDOW_IDENTITY_COLLISION"
|
||||
else: raise AssertionError("collision")
|
||||
results.append({"name": "surface-collision", "pass": True})
|
||||
with Lab() as lab:
|
||||
env = {**lab.env, "HOME": str(private_dir(lab.directory / "home")), "XDG_STATE_HOME": str(private_dir(lab.directory / "state"))}
|
||||
messages = [struct.pack("=I", 0), struct.pack("=I", 4097), struct.pack("=I", 2) + b"[", struct.pack("=I", 1) + b"\xff"]
|
||||
for raw in (b'{"op":1,"op":2}', b'{"op":NaN}', b'[]', b'{"command":"x"}'):
|
||||
messages.append(struct.pack("=I", len(raw)) + raw)
|
||||
for i, message in enumerate(messages):
|
||||
proc = subprocess.run([sys.executable, "-B", str(BACKEND / "session-v2-native-host.py"), "/unused-private-manifest", EXTENSION_ID],
|
||||
env=env, input=message, capture_output=True, timeout=8)
|
||||
# Une troncature proprement fermée est EOF ; jamais trace ni payload.
|
||||
assert not proc.stderr
|
||||
if proc.stdout:
|
||||
size, = struct.unpack("=I", proc.stdout[:4]); response = json.loads(proc.stdout[4:])
|
||||
assert size <= 4096 and set(response) == {"result"} and response["result"] != "OK"
|
||||
results.append({"name": f"wire-{i}", "pass": True})
|
||||
denied = subprocess.run([sys.executable, "-B", str(BACKEND / "session-v2-native-host.py"), "unused", "other@invalid"],
|
||||
env=env, capture_output=True, timeout=5)
|
||||
assert denied.returncode == 1 and not denied.stdout and not denied.stderr
|
||||
results.append({"name": "extension-id-allowlist", "pass": True})
|
||||
manifest = json.loads((REPO / "firefox-extension/session-v2/manifest.json").read_text())
|
||||
assert set(manifest["permissions"]) == {"sessions", "nativeMessaging"} and "content_scripts" not in manifest and "host_permissions" not in manifest
|
||||
background = (REPO / "firefox-extension/session-v2/background.js").read_text()
|
||||
assert not any(s in background for s in ("browser.tabs.", "browser.history.", "browser.cookies.", "getRecentlyClosed", "labfy_test_control", "ExecuteScript"))
|
||||
results.append({"name": "minimal-permissions-no-navigation", "pass": True})
|
||||
atomic(private_dir(EVIDENCE) / "security.json", {"cases": results})
|
||||
print(len(results), "security PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__": main()
|
||||
45
tests/session_v2/test_phase3b.py
Normal file
45
tests/session_v2/test_phase3b.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Delta de déploiement : propriétaire autostart et acknowledgement durable."""
|
||||
import copy
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from common import *
|
||||
from session_v2.applications import Catalog, launch_count
|
||||
from session_v2.errors import Failure
|
||||
from session_v2.storage import atomic, read, private_dir
|
||||
from session_v2.startup import acknowledge, successful
|
||||
from session_v2.observability import Attempt
|
||||
|
||||
|
||||
class DeploymentTests(unittest.TestCase):
|
||||
def test_limusic_never_launched(self):
|
||||
spec = Catalog().classify({'app_id': 'limusic-app'})
|
||||
app = {'application_id': 'limusic', 'desktop_entry': 'limusic.desktop', 'strategy': 'managed-autostart',
|
||||
'managed_by': 'sway-autostart', 'expected_windows': 1, 'identity_provider': None}
|
||||
self.assertEqual(Catalog().validate(app), spec)
|
||||
self.assertEqual(spec.argv, ())
|
||||
self.assertEqual(launch_count(app, 0), 0)
|
||||
self.assertEqual(launch_count(app, 1), 0)
|
||||
with self.assertRaisesRegex(Failure, 'UNEXPECTED_EXTRA_WINDOW'): launch_count(app, 2)
|
||||
app['managed_by'] = 'autostart'
|
||||
with self.assertRaises(Failure): Catalog().validate(app)
|
||||
|
||||
def test_ack_requires_verified_current_transaction(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
runtime = private_dir(Path(directory))
|
||||
acknowledge(runtime, 'session', 'new')
|
||||
attempt = Attempt(runtime / 'session-v2-restore-attempt.json', 'a' * 32, fixture())
|
||||
with self.assertRaises(Failure): acknowledge(runtime, 'session', 'restored', 'a' * 32)
|
||||
attempt.update(status='success', phase='complete', reason='SUCCESS', final_tree_verified=True,
|
||||
final_focus_verified=True, slots_filled=3, applications_observed=3)
|
||||
self.assertTrue(successful(attempt.data))
|
||||
with self.assertRaises(Failure): acknowledge(runtime, 'other-session', 'restored', 'a' * 32)
|
||||
with self.assertRaises(Failure): acknowledge(runtime, 'session', 'restored', 'b' * 32)
|
||||
self.assertEqual(acknowledge(runtime, 'session', 'restored', 'a' * 32)['status'], 'acknowledged')
|
||||
self.assertEqual(read(runtime / 'session-v2/startup.json')['choice'], 'restored')
|
||||
for change in ({'final_tree_verified': False}, {'final_focus_verified': False},
|
||||
{'anchors_created': 1}, {'helpers_suspended': 1}, {'slots_filled': 2}, {'applications_observed': 2}):
|
||||
self.assertFalse(successful({**attempt.data, **change}))
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
43
tests/session_v2/test_provider.py
Normal file
43
tests/session_v2/test_provider.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import os
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import uuid
|
||||
from common import *
|
||||
from session_v2.storage import private_dir, atomic, read
|
||||
from session_v2.firefox_identity import FirefoxProvider, process_start
|
||||
from session_v2.errors import Failure
|
||||
import tempfile
|
||||
|
||||
|
||||
class ProviderTests(unittest.TestCase):
|
||||
def test_missing_collision_and_live_publisher(self):
|
||||
class SwayStub:
|
||||
session = "a" * 64
|
||||
def windows(self): return [{"id": 11, "app_id": "firefox"}, {"id": 12, "app_id": "firefox"}]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
provider = FirefoxProvider(SwayStub(), directory)
|
||||
a, b = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
def publish(values):
|
||||
end = time.monotonic() + 2
|
||||
while time.monotonic() < end:
|
||||
request = provider.directory / "request.json"
|
||||
if request.exists() and read(request)["expires_ns"] > time.monotonic_ns(): break
|
||||
time.sleep(.005)
|
||||
for ident, value in zip((11,12), values):
|
||||
atomic(provider.directory / f"{ident}.json", {"schema": "firefox-session-window-uuid", "version": 1,
|
||||
"session": SwayStub.session, "uuid": value, "con_id": ident, "observed_ns": time.monotonic_ns(),
|
||||
"publisher_pid": os.getpid(), "publisher_start": process_start(os.getpid())})
|
||||
for values, expected_error in (((a,b), None), ((a,a), "EXACT_WINDOW_IDENTITY_COLLISION"), ((a,str(uuid.uuid4())), "EXACT_WINDOW_IDENTITY_MISSING")):
|
||||
thread = threading.Thread(target=publish, args=(values,)); thread.start()
|
||||
try:
|
||||
if expected_error:
|
||||
with self.assertRaisesRegex(Failure, expected_error): provider.resolve([a,b], SwayStub().windows(), 1)
|
||||
else: self.assertEqual(provider.resolve([a,b], SwayStub().windows(), 1), {a:11,b:12})
|
||||
finally: thread.join()
|
||||
self.assertTrue(provider.available())
|
||||
with self.assertRaisesRegex(Failure, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE"):
|
||||
provider.capture(SwayStub().windows(), .05)
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
234
tests/session_v2/test_unit.py
Normal file
234
tests/session_v2/test_unit.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import copy
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from contextlib import redirect_stdout
|
||||
import uuid
|
||||
from common import *
|
||||
from session_v2.errors import Failure
|
||||
from session_v2 import schema
|
||||
from session_v2.checkpoint import Store
|
||||
from session_v2.storage import atomic, read, loads, lock, private_dir
|
||||
from session_v2.layout import compile_plan
|
||||
from session_v2.applications import launch_count
|
||||
from session_v2.slots import assign
|
||||
from session_v2.native_host import validate as native_validate
|
||||
from session_v2.cli import migration
|
||||
|
||||
|
||||
class BackendTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="labfy-session-v2-unit-")
|
||||
self.root = Path(self.temp.name)
|
||||
self.data = fixture()
|
||||
def tearDown(self): self.temp.cleanup()
|
||||
|
||||
def test_schema_exact(self):
|
||||
self.assertEqual(schema.validate(self.data), self.data)
|
||||
for key in self.data:
|
||||
broken = copy.deepcopy(self.data); del broken[key]
|
||||
with self.assertRaises(Failure): schema.validate(broken)
|
||||
self.data["pid"] = 1
|
||||
with self.assertRaises(Failure): schema.validate(self.data)
|
||||
|
||||
def test_types_version_and_identity(self):
|
||||
for value in (True, 2, "1", None):
|
||||
broken = copy.deepcopy(self.data); broken["version"] = value
|
||||
with self.assertRaises(Failure): schema.validate(broken)
|
||||
self.data["applications"][0]["expected_windows"] = True
|
||||
with self.assertRaises(Failure): schema.validate(self.data)
|
||||
|
||||
def test_references(self):
|
||||
for mutate in (lambda d: d["window_slots"][0].update(application_id="absent"),
|
||||
lambda d: d.update(focus="absent"),
|
||||
lambda d: d["window_slots"][0].update(tree_path=[7]),
|
||||
lambda d: d["applications"][0].update(expected_windows=2)):
|
||||
d = copy.deepcopy(self.data); mutate(d)
|
||||
with self.assertRaises(Failure): schema.validate(d)
|
||||
|
||||
def test_unsupported_states(self):
|
||||
for key in ("fullscreen", "scratchpad"):
|
||||
d = copy.deepcopy(self.data); d["window_slots"][0]["state"][key] = True
|
||||
with self.assertRaisesRegex(Failure, "CAPABILITY_UNSUPPORTED"): schema.validate(d)
|
||||
|
||||
def test_command_injection(self):
|
||||
for value in ('x; exec echo bad', '../x', 'x"', 'x\n'):
|
||||
d = copy.deepcopy(self.data); d["workspaces"][0]["workspace"] = value
|
||||
with self.assertRaises(Failure): schema.validate(d)
|
||||
|
||||
def test_plan_pure(self):
|
||||
plan = compile_plan(self.data, "a" * 32)
|
||||
self.assertEqual(sum(o.kind == "create" for o in plan), 3)
|
||||
self.assertTrue(all(o.kind in ("workspace", "create", "focus", "layout") for o in plan))
|
||||
self.assertFalse(any("launch" in str(o) for o in plan))
|
||||
|
||||
def test_checkpoint_permissions(self):
|
||||
store = Store(self.root)
|
||||
store.save("example", self.data)
|
||||
self.assertEqual(store.load("example"), self.data)
|
||||
self.assertEqual(store.path("example").stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(store.sessions.stat().st_mode & 0o777, 0o700)
|
||||
store.checkpoint(self.data)
|
||||
self.assertEqual(store.last(), self.data)
|
||||
self.assertEqual(store.list(), ["example"])
|
||||
store.delete("example")
|
||||
self.assertEqual(store.list(), [])
|
||||
|
||||
def test_intermediate_directory_permissions(self):
|
||||
private_dir(self.root / "one/two/three")
|
||||
for name in ("one", "one/two", "one/two/three"):
|
||||
self.assertEqual((self.root / name).stat().st_mode & 0o777, 0o700)
|
||||
|
||||
def test_cli_storage_checkpoint_and_startup_mock(self):
|
||||
from session_v2.cli import main
|
||||
state = private_dir(self.root / "state")
|
||||
runtime = private_dir(self.root / "runtime")
|
||||
with patch.dict(os.environ, {"XDG_STATE_HOME": str(state), "XDG_RUNTIME_DIR": str(runtime)}):
|
||||
with patch("session_v2.cli.Sway") as sway, patch("session_v2.cli.FirefoxProvider"), patch("session_v2.cli.capture", return_value=self.data) as capture_mock:
|
||||
sway.return_value.session = "new-session"
|
||||
empty = io.StringIO()
|
||||
with redirect_stdout(empty): self.assertEqual(main(["startup-status", "--socket", "/mock"]), 0)
|
||||
self.assertEqual(json.loads(empty.getvalue()), {"eligible": False, "claimed": False})
|
||||
with redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(main(["checkpoint-last", "--execute", "--reason", "logout", "--socket", "/mock"]), 0)
|
||||
self.assertEqual(main(["save", "example", "--execute", "--socket", "/mock"]), 0)
|
||||
self.assertEqual(capture_mock.call_args_list[0].args[3], "logout")
|
||||
def cli(args):
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): code = main(args)
|
||||
self.assertEqual(code, 0)
|
||||
return json.loads(output.getvalue())
|
||||
self.assertEqual(cli(["list"]), {"sessions": ["example"]})
|
||||
self.assertEqual(cli(["show", "example"]), self.data)
|
||||
self.assertTrue(cli(["plan", "example"])["operations"])
|
||||
self.assertTrue(cli(["startup-status", "--execute", "--socket", "/mock"])["eligible"])
|
||||
self.assertFalse(cli(["startup-status", "--socket", "/mock"])["eligible"])
|
||||
self.assertEqual(cli(["delete", "example", "--execute"]), {"status": "deleted"})
|
||||
|
||||
def test_checkpoint_failed_validation_preserves_old(self):
|
||||
store = Store(self.root); store.save("a", self.data)
|
||||
invalid = copy.deepcopy(self.data); invalid["version"] = 9
|
||||
with self.assertRaises(Failure): store.save("a", invalid)
|
||||
self.assertEqual(store.load("a"), self.data)
|
||||
self.assertFalse(list(store.sessions.glob(".tmp-*")))
|
||||
|
||||
def test_paths_links(self):
|
||||
store = Store(self.root)
|
||||
for name in ("../a", "a/b", "", "/tmp/a", ".", "a.json", None):
|
||||
with self.assertRaises(Failure): store.path(name)
|
||||
target = self.root / "outside"; atomic(target, {})
|
||||
store.path("evil").symlink_to(target)
|
||||
with self.assertRaises((Failure, OSError)): store.save("evil", self.data)
|
||||
self.assertEqual(read(target), {})
|
||||
|
||||
def test_json_strict(self):
|
||||
for raw in (b'{"a":1,"a":2}', b'{"a":NaN}', b'{}junk', b'\xff', b'[' * 2000):
|
||||
with self.assertRaises(Failure): loads(raw)
|
||||
|
||||
def test_lock(self):
|
||||
with lock(self.root):
|
||||
with self.assertRaisesRegex(Failure, "TRANSACTION_BUSY"):
|
||||
with lock(self.root): pass
|
||||
|
||||
def test_launch_policy(self):
|
||||
a = self.data["applications"][0].copy()
|
||||
for strategy, expected, present, launches in (
|
||||
("managed-autostart", 2, 0, 0), ("single-window", 1, 0, 1), ("single-window", 1, 1, 0),
|
||||
("browser-self-restore", 2, 0, 1), ("browser-self-restore", 2, 1, 0),
|
||||
("terminal", 3, 1, 2), ("multi-instance", 3, 1, 2)):
|
||||
a.update(strategy=strategy, expected_windows=expected)
|
||||
self.assertEqual(launch_count(a, present), launches)
|
||||
a.update(strategy="unknown")
|
||||
with self.assertRaises(Failure): launch_count(a, 0)
|
||||
|
||||
def test_counts(self):
|
||||
slots = self.data["window_slots"]
|
||||
with self.assertRaisesRegex(Failure, "APPLICATION_WINDOWS_INCOMPLETE"): assign(slots, [])
|
||||
with self.assertRaisesRegex(Failure, "UNEXPECTED_EXTRA_WINDOW"): assign(slots, [{"id": x} for x in range(4)])
|
||||
|
||||
def test_exact_inverted(self):
|
||||
values = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
slots = [{"slot_id": str(i), "identity_requirement": "exact", "identity_evidence": {"type": schema.PROVIDER, "id": u}} for i,u in enumerate(values)]
|
||||
self.assertEqual(assign(slots, [{"id": 99}, {"id": 12}], {values[0]: 12, values[1]: 99}), {"0": 12, "1": 99})
|
||||
with self.assertRaisesRegex(Failure, "EXACT_WINDOW_IDENTITY_MISSING"): assign(slots, [{"id": 99}, {"id": 12}], {})
|
||||
with self.assertRaisesRegex(Failure, "EXACT_WINDOW_IDENTITY_COLLISION"): assign(slots, [{"id": 99}, {"id": 12}], {u: 99 for u in values})
|
||||
|
||||
def test_interchangeable_best_effort(self):
|
||||
for level in ("interchangeable", "best-effort"):
|
||||
slots = [{"slot_id": s, "identity_requirement": level, "identity_evidence": None} for s in ("B", "A")]
|
||||
self.assertEqual(assign(slots, [{"id": 9}, {"id": 2}]), {"A": 2, "B": 9})
|
||||
|
||||
def test_native_strict(self):
|
||||
good = {"op": "map-window", "uuid": str(uuid.uuid4()), "runtime_token": "a" * 32}
|
||||
self.assertEqual(native_validate(good), good)
|
||||
for bad in ([], {}, {**good, "command": "x"}, {**good, "uuid": 2}, {**good, "runtime_token": "a;exec x"}, {**good, "op": "exec"}):
|
||||
with self.assertRaises(Failure): native_validate(bad)
|
||||
|
||||
def test_cli_execute_required(self):
|
||||
for command in ("apply", "apply-last", "save", "delete", "checkpoint-last", "capture"):
|
||||
p = subprocess.run([sys.executable, "-B", str(BACKEND / "session-v2.py"), command], capture_output=True, text=True)
|
||||
self.assertEqual(p.returncode, 2)
|
||||
self.assertEqual(json.loads(p.stdout)["reason"], "EXECUTE_REQUIRED")
|
||||
|
||||
def test_coexistence_and_migration(self):
|
||||
v1 = self.root / "legacy.json"
|
||||
atomic(v1, {"schema": "labfy.sway.session-snapshot", "version": 1, "windows": [{"app_id": "firefox"}]})
|
||||
before = v1.read_bytes()
|
||||
result = migration(v1)
|
||||
self.assertEqual(result["classification"], "needs exact identity")
|
||||
Store(private_dir(self.root / "session-v2")).checkpoint(self.data)
|
||||
self.assertEqual(v1.read_bytes(), before)
|
||||
atomic(v1, {"schema": "labfy.sway.session-snapshot", "version": 9, "windows": []})
|
||||
with self.assertRaisesRegex(Failure, "V1_FORMAT_UNRECOGNIZED"): migration(v1)
|
||||
|
||||
def test_multi_output_preflight_synthetic(self):
|
||||
from session_v2.executor import preflight
|
||||
from unittest.mock import Mock
|
||||
second = fixture("D", "TEST_ONLY_SECOND")
|
||||
second["workspaces"][0]["output"] = "SYNTHETIC-2"
|
||||
data = copy.deepcopy(self.data)
|
||||
data["outputs"].append({"name": "SYNTHETIC-2"})
|
||||
for key in ("applications", "window_slots", "workspaces"): data[key] += second[key]
|
||||
sway = Mock()
|
||||
sway.outputs.return_value = [{"name": o["name"], "active": True} for o in data["outputs"]]
|
||||
sway.windows.return_value = []
|
||||
sway.tree.return_value = {"nodes": []}
|
||||
self.assertEqual(len(preflight(data, sway, catalog(data), None)), 4)
|
||||
sway.outputs.return_value.pop()
|
||||
with self.assertRaisesRegex(Failure, "OUTPUT_UNAVAILABLE"): preflight(data, sway, catalog(data), None)
|
||||
sway.command.assert_not_called()
|
||||
|
||||
def test_resume_attempts_all_owned_helpers(self):
|
||||
from session_v2.guard import resume_helpers
|
||||
helpers = [{"pid": i, "fd": i, "script": "/test/autotiling"} for i in (100, 101)]
|
||||
counts = {"helpers_resumed": 0}
|
||||
with patch("session_v2.guard.verify_helper", side_effect=[Failure("HELPER_SESSION_INVALID"), None]), \
|
||||
patch("session_v2.guard.signal.pidfd_send_signal") as send, \
|
||||
patch("session_v2.guard.helper_state", return_value="S"):
|
||||
self.assertFalse(resume_helpers(helpers, "/test/socket", counts))
|
||||
self.assertEqual(counts["helpers_resumed"], 1)
|
||||
self.assertEqual(send.call_args.args[0], 101)
|
||||
self.assertTrue(helpers[1]["resumed"])
|
||||
self.assertNotIn("resumed", helpers[0])
|
||||
|
||||
def test_report_rejects_private_fields_and_bool_version(self):
|
||||
from session_v2.observability import Attempt, validate as validate_report
|
||||
report = Attempt(self.root / "attempt.json", "a" * 32, self.data).data
|
||||
for key in ("title", "url", "argv", "environment", "cookies"):
|
||||
with self.assertRaisesRegex(Failure, "REPORT_SCHEMA"): validate_report({**report, key: "forbidden"})
|
||||
with self.assertRaisesRegex(Failure, "REPORT_SCHEMA"): validate_report({**report, "version": True})
|
||||
|
||||
def test_json_schema_matches_fixture(self):
|
||||
import jsonschema
|
||||
contract = json.loads((BACKEND / "session_v2/schema-v1.json").read_text())
|
||||
jsonschema.Draft202012Validator.check_schema(contract)
|
||||
jsonschema.validate(self.data, contract)
|
||||
with self.assertRaises(jsonschema.ValidationError): jsonschema.validate({**self.data, "pid": 1}, contract)
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
34
tests/test_session_exit_gate_qml.py
Normal file
34
tests/test_session_exit_gate_qml.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Exécuter le contrat QML de sortie de session sans commande système réelle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
QML_TEST = ROOT / "tests/qml/tst_session_exit_gate.qml"
|
||||
QML_TEST_RUNNER = Path("/usr/lib/qt6/bin/qmltestrunner")
|
||||
|
||||
|
||||
class SessionExitGateQmlTest(unittest.TestCase):
|
||||
def test_mocked_session_exit_contracts(self) -> None:
|
||||
environment = os.environ.copy()
|
||||
environment["QT_QPA_PLATFORM"] = "offscreen"
|
||||
result = subprocess.run(
|
||||
[str(QML_TEST_RUNNER), "-input", str(QML_TEST), "-o", "-,txt"],
|
||||
cwd=ROOT,
|
||||
env=environment,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,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
42
tools/build-session-v2.py
Executable file
|
|
@ -0,0 +1,42 @@
|
|||
#!/usr/bin/python3
|
||||
"""Construire les candidats locaux, sans installer ni signer ni accéder au réseau."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / "quickshell/.config/quickshell/labfy-sway"
|
||||
|
||||
|
||||
def build(output):
|
||||
output = Path(output).resolve()
|
||||
if not output.is_relative_to(ROOT): raise ValueError("OUTPUT_MUST_BE_IN_ISOLATED_WORKTREE")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
binary = output / "labfy-v2-anchor"
|
||||
flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "gtk+-3.0", "gdk-wayland-3.0"], text=True).split()
|
||||
subprocess.run(["cc", "-std=c17", "-O2", "-Wall", "-Wextra", "-Werror", str(SOURCE / "session_v2/anchor_helper.c"), "-o", str(binary), *flags], check=True)
|
||||
xpi = output / "labfy-session-v2-unsigned.xpi"
|
||||
with zipfile.ZipFile(xpi, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for name in ("manifest.json", "background.js"):
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)); info.compress_type = zipfile.ZIP_DEFLATED
|
||||
archive.writestr(info, (ROOT / "firefox-extension/session-v2" / name).read_bytes())
|
||||
bundle = output / "labfy-session-v2-backend.tar.gz"
|
||||
with tarfile.open(bundle, "w:gz") as archive:
|
||||
for name in ("session-v2.py", "session-v2-native-host.py"):
|
||||
archive.add(SOURCE / name, arcname=name)
|
||||
for p in sorted((SOURCE / "session_v2").glob("*.py")): archive.add(p, arcname="session_v2/" + p.name)
|
||||
archive.add(SOURCE / "session_v2/schema-v1.json", arcname="session_v2/schema-v1.json")
|
||||
archive.add(binary, arcname="session_v2/libexec/labfy-v2-anchor")
|
||||
archive.add(ROOT / "firefox-extension/session-v2/native-host.json.in", arcname="native-host.json.in")
|
||||
manifest = {"signed": False, "installed": False, "files": {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in (binary, xpi, bundle)}}
|
||||
(output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
print(json.dumps(manifest))
|
||||
|
||||
|
||||
if __name__ == "__main__": build(sys.argv[1] if len(sys.argv) > 1 else ROOT / "build/session-v2")
|
||||
Loading…
Reference in a new issue