feat(desktop): versionner les évolutions QuickShell et session V2

This commit is contained in:
fy59 2026-11-01 07:24:47 +01:00
parent 766c2ee109
commit bca26d4de2
73 changed files with 5167 additions and 487 deletions

View file

@ -140,7 +140,7 @@ show_preview = true
## Configure the maximum height of the preview to show.
## Useful when you have long scripts in your history that you want to distinguish
## by more than the first few lines.
# max_preview_height = 4
max_preview_height = 5
## Configure whether or not to show the help row, which includes the current Atuin
## version (and whether an update is available), a keymap hint, and the total
@ -291,7 +291,7 @@ records = true
## The port that should be used for TCP on non unix systems
# tcp_port = 8889
# [theme]
[theme]
## Color theme to use for rendering in the terminal.
## There are some built-in themes, including the base theme ("default"),
## "autumn" and "marine". You can add your own themes to the "./themes" subdirectory of your
@ -301,7 +301,7 @@ records = true
## https://ogeon.github.io/docs/palette/master/palette/named/index.html
## If you provide a custom theme file, it should be called "NAME.toml" and the theme below
## should be the stem, i.e. `theme = "NAME"` for your chosen NAME.
name = "catppuccin"
name = "catppuccin-mocha-lavender"
## Whether the theme manager should output normal or extra information to help fix themes.
## Boolean, true or false. If unset, left up to the theme manager.
@ -351,7 +351,7 @@ name = "catppuccin"
## column fill remaining space. Only one column should have expand = true.
##
## Default:
# columns = ["duration", "time", "command"]
columns = ["duration", "time", "command"]
##
## Examples:
##

View file

@ -0,0 +1,12 @@
[theme]
name = "catppuccin-mocha-lavender"
[colors]
AlertInfo = "#a6e3a1"
AlertWarn = "#fab387"
AlertError = "#f38ba8"
Annotation = "#b4befe"
Base = "#cdd6f4"
Guidance = "#9399b2"
Important = "#f38ba8"
Title = "#b4befe"

241
bin/.local/bin/trier-photos Executable file
View file

@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""Classement manuel de A_trier, compatible avec la photothèque Labfy.
Photos et vidéos -> Photos/AAAA/AAAA-MM-JJ/
RAW et XMP liés -> Originaux/AAAA/AAAA-MM-JJ/
Aucun fichier existant n'est remplacé. Les autres fichiers restent dans A_trier.
"""
import argparse
from collections import defaultdict
from datetime import date, datetime
import errno
import hashlib
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import unicodedata
BASE = Path.home() / "Pictures" / "Phototheque"
RAW = {".arw", ".dng", ".cr2", ".cr3", ".nef", ".nrw", ".raf",
".orf", ".rw2", ".pef", ".srw", ".3fr", ".iiq"}
PHOTO = {".jpg", ".jpeg", ".jpe", ".png", ".tif", ".tiff",
".heic", ".heif", ".avif", ".webp", ".bmp"}
VIDEO = {".mov", ".mp4", ".m4v", ".avi", ".mts", ".m2ts", ".3gp"}
MEDIA = RAW | PHOTO | VIDEO
DATE_NAME = re.compile(r"(?<!\d)((?:19|20)\d{2})[-_]?(\d{2})[-_]?(\d{2})(?!\d)")
EXIFTOOL = shutil.which("exiftool")
def valid_date(y, m, d):
try:
candidate = date(int(y), int(m), int(d))
return candidate if 1900 <= candidate.year <= 2100 else None
except ValueError:
return None
def date_of(path):
"""EXIF en priorité, ensuite le nom, puis date de modification."""
if EXIFTOOL:
try:
result = subprocess.run(
[EXIFTOOL, "-s3", "-d", "%Y-%m-%d",
"-DateTimeOriginal", "-CreateDate",
"-MediaCreateDate", "-TrackCreateDate", "--", str(path)],
capture_output=True, text=True, timeout=20, check=False,
)
for line in result.stdout.splitlines():
match = re.fullmatch(r"\s*(\d{4})-(\d{2})-(\d{2})\s*", line)
if match:
found = valid_date(*match.groups())
if found:
return found, "EXIF"
except (OSError, subprocess.TimeoutExpired):
pass
match = DATE_NAME.search(path.name)
if match:
found = valid_date(*match.groups())
if found:
return found, "nom du fichier"
return datetime.fromtimestamp(path.stat().st_mtime).date(), "modification"
def stem_for_group(path):
name = path.name
if path.suffix.lower() == ".xmp":
name = name[:-4]
pre = Path(name)
if pre.suffix.lower() in MEDIA:
name = pre.stem
else:
name = path.stem
return name.casefold()
def renamed(path, index):
if index == 0:
return path.name
if path.suffix.lower() == ".xmp":
without_xmp = path.name[:-4]
suffix = Path(without_xmp).suffix
if suffix.lower() in MEDIA:
return without_xmp[:-len(suffix)] + f"_{index}" + suffix + path.name[-4:]
return without_xmp + f"_{index}" + path.name[-4:]
return path.stem + f"_{index}" + path.suffix
def target_root(path, group, photos, originaux):
if path.suffix.lower() in RAW:
return originaux
if path.suffix.lower() != ".xmp":
return photos
associated = Path(path.name[:-4]).suffix.lower()
if associated in RAW:
return originaux
if associated in PHOTO | VIDEO:
return photos
# XMP générique : privilégier les RAW s'ils sont présents.
return originaux if any(p.suffix.lower() in RAW for p in group) else photos
def key(name):
return unicodedata.normalize("NFC", name).casefold()
def existing_names(folder, cache):
if folder not in cache:
if folder.is_symlink():
raise ValueError(f"Dossier de destination symbolique : {folder}")
cache[folder] = {key(p.name) for p in folder.iterdir()} if folder.exists() else set()
return cache[folder]
def digest(path):
hash_ = hashlib.sha256()
with path.open("rb") as file_:
for chunk in iter(lambda: file_.read(1024 * 1024), b""):
hash_.update(chunk)
return hash_.digest()
def move_without_overwrite(source, target):
"""Hardlink + unlink sur même volume, copie vérifiée sinon."""
try:
os.link(source, target, follow_symlinks=False)
except OSError as exc:
if exc.errno != errno.EXDEV:
raise
try:
with source.open("rb") as incoming, target.open("xb") as outgoing:
shutil.copyfileobj(incoming, outgoing, 1024 * 1024)
outgoing.flush()
os.fsync(outgoing.fileno())
shutil.copystat(source, target)
if digest(source) != digest(target):
raise OSError("Contrôle SHA-256 de la copie échoué")
except BaseException:
target.unlink(missing_ok=True)
raise
source.unlink()
def run(args):
source = args.source.expanduser().absolute()
photos = args.photos.expanduser().absolute()
originaux = args.originaux.expanduser().absolute()
if not source.is_dir() or source.is_symlink():
raise ValueError(f"Source absente ou symbolique : {source}")
for dest in (photos, originaux):
if dest == source or source in dest.parents:
raise ValueError("La destination ne doit pas être dans A_trier.")
if dest.is_symlink():
raise ValueError(f"Destination symbolique refusée : {dest}")
groups = defaultdict(list)
ignored = 0
for current, dirs, names in os.walk(source, followlinks=False):
dirs[:] = [d for d in dirs if not (Path(current) / d).is_symlink()]
for name in names:
file_ = Path(current) / name
if file_.is_symlink() or not file_.is_file():
ignored += 1
continue
if file_.suffix.lower() not in MEDIA | {".xmp"}:
ignored += 1
continue
groups[(file_.parent, stem_for_group(file_))].append(file_)
occupied = {}
moved = 0
errors = 0
orphans = 0
for _, group in sorted(groups.items(), key=lambda x: str(x[0])):
media = [p for p in group if p.suffix.lower() in MEDIA]
if not media:
orphans += len(group)
continue # Ne pas déplacer les XMP orphelins.
media.sort(key=lambda p: (p.suffix.lower() not in RAW,
p.suffix.lower() in VIDEO, p.name))
try:
taken, origin = date_of(media[0])
relative = Path(str(taken.year)) / taken.isoformat()
destinations = [
target_root(p, group, photos, originaux) / relative
for p in group
]
index = 0
while True:
candidates = [folder / renamed(p, index)
for p, folder in zip(group, destinations)]
identities = {(p.parent, key(p.name)) for p in candidates}
if len(identities) != len(candidates):
raise ValueError("Noms incompatibles (casse/Unicode) dans le lot")
if all(key(p.name) not in existing_names(p.parent, occupied)
for p in candidates):
break
index += 1
for file_, target in zip(group, candidates):
if args.dry_run:
print(f"[SIMULATION] {file_.relative_to(source)} -> "
f"{target.relative_to(BASE) if target.is_relative_to(BASE) else target}"
f" ({origin})")
else:
if target.parent.is_symlink():
raise ValueError(f"Dossier symbolique : {target.parent}")
target.parent.mkdir(parents=True, exist_ok=True)
move_without_overwrite(file_, target)
print(f"[OK] {file_.relative_to(source)} -> {target}")
existing_names(target.parent, occupied).add(key(target.name))
moved += 1
except (OSError, ValueError) as exc:
errors += 1
print(f"[ERREUR] {', '.join(p.name for p in group)} : {exc}",
file=sys.stderr)
label = "prévus" if args.dry_run else "déplacés"
print(f"\nFichiers {label} : {moved} ; ignorés : {ignored} ; "
f"XMP orphelins : {orphans} ; erreurs : {errors}")
return 1 if errors else 0
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("-n", "--dry-run", action="store_true",
help="simuler sans déplacer les fichiers")
parser.add_argument("--source", type=Path, default=BASE / "A_trier")
parser.add_argument("--photos", type=Path, default=BASE / "Photos")
parser.add_argument("--originaux", type=Path, default=BASE / "Originaux")
args = parser.parse_args()
try:
return run(args)
except (OSError, ValueError) as exc:
print(f"Erreur : {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,112 @@
# Nextcloud : lancement corrigé et consommation mémoire réduite
Date de validation : 27 octobre 2026, heure locale Europe/Paris.
## Résultat
Le lancement de Nextcloud depuis le menu Applications de Quickshell est corrigé et a été validé par l'ouverture d'une véritable fenêtre sur le workspace 11. Le catalogue de restauration reconnaît cette fenêtre.
La fermeture normale puis le redémarrage de l'instance de synchronisation ont réduit sa consommation immédiate : environ 4,89 Gio de mémoire résidente et de swap cumulés avant l'intervention, puis 439,4 Mio de mémoire résidente et aucun swap à 12:31:42, environ onze minutes après le redémarrage. Le service est actif et la synchronisation a repris.
**La cause durable de la forte consommation reste indéterminée.** La mémoire augmente encore pendant la synchronisation. Ces observations ne démontrent ni la disparition d'une fuite, ni l'existence d'une fuite. L'interface a également été ouverte entre les relevés. Aucune libération automatique de mémoire à la fin de la synchronisation n'est promise.
## Cause du refus de lancement
Installation observée :
- Arch : nextcloud-client 2:35.0.0-2 ;
- UWSM 0.27.1-1 ;
- entrée installée : /usr/share/applications/com.nextcloud.desktopclient.nextcloud.desktop ;
- commande de l'entrée : nextcloud %u.
Le validateur d'UWSM a été appelé sur cette entrée et a reproduit le refus. Il rejetait le groupe org.freedesktop.CloudProviders et les métadonnées Implements, BusName et ObjectPath. La validation indépendante avec desktop-file-validate a terminé avec le code 0. Le problème observé concerne donc le chemin de lancement via l'ancien validateur de métadonnées d'UWSM.
## Correction du lanceur
Fichier : quickshell/.config/quickshell/labfy-sway/applications/backend.py.
La fonction uwsm_launch_info construit une copie en mémoire de l'entrée Desktop et préfixe sa commande par « uwsm app -t service -- ». GIO conserve la gestion des arguments et des champs Desktop. Le chemin de l'entrée d'origine est conservé pour le champ %k. La copie désactive Terminal et DBusActivatable pour ce chemin de lancement. Le fichier installé et ses métadonnées CloudProviders restent intacts.
Le nouveau chemin est réservé à l'identifiant exact com.nextcloud.desktopclient.nextcloud.desktop. Les autres applications graphiques conservent leur chemin existant. L'ancien traitement des applications de terminal utilise le même helper avec son option de terminal, et le correctif antérieur de lecture des exécutables entre guillemets est préservé.
Le validateur UWSM lui-même n'a pas été modifié globalement.
## Validation du lancement
Les 16 tests de tests/applications/test_backend.py passent. Ils couvrent notamment :
- l'aiguillage du seul identifiant Nextcloud vers le nouveau chemin ;
- la conservation des métadonnées et du fichier Desktop source ;
- le lancement GIO réel vers un enregistreur UWSM de test, avec guillemets, espaces, champs Desktop et répertoire de travail ;
- les erreurs de lancement et les chemins existants de terminal, D-Bus et filtrage d'applications.
Essai réel à 12:22:26 :
- appel du backend avec « launch com.nextcloud.desktopclient.nextcloud.desktop » ;
- réponse ok, code de sortie 0, stderr vide ;
- fenêtre Nextcloud observée : con_id 95, app_id com.nextcloud.desktopclient.nextcloud ;
- processus 1061535, starttime 43226650, workspace 11 ;
- classification correcte par le catalogue de session ;
- huit fenêtres préexistantes conservées dans leur état vérifié ;
- sauvegarde automatique canonique inchangée.
La fenêtre Nextcloud reste disponible sur le workspace 11. Le retour du focus au workspace 1 n'a pas été imposé : au dernier contrôle, le focus n'était plus sur la fenêtre de test.
## Intervention sur la mémoire
L'ancienne instance était l'unique processus Nextcloud, PID 982359, lancé en arrière-plan par app-Nextcloud@autostart.service. Son activité de synchronisation progressait ; la forte consommation n'était pas associée à un arrêt complet du traitement dans les échantillons examinés.
À 12:20:36, « /usr/bin/nextcloud --quit » a été exécuté dans l'environnement graphique. Le processus s'est arrêté normalement. Le service est devenu inactif avec Result=success et ExecMainStatus=0.
À 12:20:45, le même service utilisateur app-Nextcloud@autostart.service a été démarré. Son nouveau processus est 1061535. Aucune seconde instance permanente n'a été créée.
| Relevé local | RAM résidente | Swap |
|---|---:|---:|
| 12:18:57, avant redémarrage | 2 289 668 Kio | 2 837 904 Kio |
| 12:22:04 | 332 768 Kio, soit 325,0 Mio | 0 |
| 12:24:06 | 372 024 Kio, soit 363,3 Mio | 0 |
| 12:26:27 | 403 424 Kio, soit 393,9 Mio | 0 |
| 12:31:42 | 449 960 Kio, soit 439,4 Mio | 0 |
Les valeurs avant intervention représentent environ 2,18 Gio de RAM et 2,71 Gio de swap, soit 4,89 Gio cumulés. Cette somme sert à comparer les deux composantes relevées ; elle n'est pas une mesure de RAM résidente seule.
La propriété CloudProviders Status a été lue sur le propriétaire D-Bus du nouveau processus et indiquait SYNCING. Dans l'échantillon des journaux de 12:27:55 à 12:28:13, l'activité de retraits locaux était présente, avec 301 mentions de succès et aucune des mentions d'erreur NormalError, FatalError ou SoftError recherchées. Cela prouve une progression dans cet intervalle, pas l'achèvement de toute la synchronisation.
Le dernier relevé confirme le même processus et un service active/running, Result=success.
Aucun plafond mémoire, changement de parallélisme, exclusion de synchronisation, changement de base de données ou mise à jour de paquet n'a été appliqué. La cause n'étant pas établie, ces changements ne constituent pas une correction démontrée. Aucune terminaison forcée n'a été nécessaire.
## Limite restante
Une nouvelle mesure après retour effectif de la synchronisation au repos sera nécessaire pour distinguer la mémoire de travail du lot actif d'une croissance persistante. Il n'existe pas de surveillance permanente installée par cette intervention. Le redémarrage a résorbé la surconsommation présente ; il ne remplace pas un diagnostic de récurrence.
## Liens avec la restauration de session
La restauration autorisée a réussi auparavant, à 12:10:59, transaction 335bf44840674810b4761ade767f31db : sept fenêtres restaurées, arbre et focus validés. Une capture de diagnostic indépendante a ensuite reconnu les huit fenêtres ouvertes, Proton Pass compris. La session a été acquittée comme restaurée.
La sauvegarde automatique conservée reste :
/home/fy59/.local/state/labfy-sway/session-v2/automatic/last.json
SHA-256 : 9a36bb310f48d989a8763092ae220a974a00786da6fba2c86dc25e7cfed1cd89
La fenêtre Nextcloud ouverte pour le test est postérieure à cette sauvegarde. Aucune nouvelle restauration ni déconnexion de session n'a été exécutée pendant l'intervention Nextcloud.
## Preuves et sauvegarde du code
Dossier privé des preuves :
/home/fy59/.local/state/labfy-sway/session-v2-diagnostics/20261027-120604-focus-catalog-restore
Fichiers principaux : nextcloud-launch-deployment.json, nextcloud-launch-tests.log, nextcloud-graceful-stop-result.json, nextcloud-background-restart-result.json, nextcloud-real-launch-result.json, nextcloud-sync-progress-after-restart.json, nextcloud-closeout-sample.json.
Sauvegarde préalable du code :
/home/fy59/.local/state/labfy-sway/session-v2-code-backups/20261027-121930-nextcloud-launch
Empreintes déployées :
- applications/backend.py : 9a335d9c99e8c3561f07123f6946207dfceab1aa25d0ec68b28d6e42975cb918 ;
- tests/applications/test_backend.py : 96ef887fa4757dcf6f4b06f61c1ea12b8dee95dfb4261e1c78e876c4edd349a6.
Les modifications sont présentes dans le dépôt actif et dans le worktree session-v2-desktop-apps-20261027. Aucun commit ni push n'a été effectué.

View file

@ -353,3 +353,43 @@ aucun schéma de checkpoint/Recovery et ne résout pas automatiquement un
Implémentation, audit PAM (aucun patch applicable établi), tests isolés,
installation et rollback :
[trousseau avant restauration](session-v2-keyring-before-restore-v1.md).
## ChatGPT XWayland et WhatsApp Web (27 octobre 2026)
Le catalogue ajoute deux applications précises à Firefox, Kitty et Limusic :
| Application | Identité observée | Politique |
| --- | --- | --- |
| ChatGPT | Fenêtre X11 avec WM_CLASS exactement `Chatgpt` | `single-window`, lancement `/usr/bin/chatgpt` si absent |
| WhatsApp Web | `firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded` | `managed-autostart`, propriétaire `sway-autostart` |
L'identité X11 est une clé runtime réservée `x11-class:Chatgpt`. Elle exige un
identifiant de fenêtre X11 et une classe exacte ; un app_id Wayland ne peut pas
usurper ce préfixe. Les arbres IPC restent bruts. La classification, les
observations de récupération, le préchauffage, les swaps et les contrôles finaux
emploient la même clé, toujours avec con_id, PID et starttime. Un ancien journal
X11 sans classe ne reçoit pas rétroactivement une identité prouvée.
La capture refuse plusieurs fenêtres ChatGPT avec cette politique singleton.
Les applications absentes du catalogue restent refusées explicitement ; elles
ne sont pas retirées silencieusement de la sauvegarde. Le snapshot conserve son
format version 2 et ne contient ni commande, ni chemin de document, ni contenu
de conversation.
WhatsApp est assignée au workspace 9. La ligne d’autostart corrigée par
l’utilisateur lance directement Firefox avec `-taskbar-tab`, `-new-window` et
`-container 0`, sans intermédiaire `bash -c` et sans `-profile`.
La restauration attend cette fenêtre et ne lance pas une deuxième instance.
Le provider Firefox reste responsable des fenêtres du navigateur ordinaire.
Les tests ciblés XWayland sont dans `tests/session_v2/test_xwayland.py` et les
tests existants de préchauffage/autostart restent applicables. Le scénario
`tests/session_v2/xwayland_runtime.py` restaure deux surfaces synthétiques dans
un Sway privé : Chatgpt via XWayland et WhatsApp via Wayland. Il vérifie aussi
la préservation stricte d’une fenêtre X11 inconnue hors du lot. La capture du
bureau réel a également été contrôlée avec ChatGPT puis avec WhatsApp ouverte.
Une capture réussie ne vaut pas validation d'une déconnexion/reconnexion physique.
Un incident antérieur `RECOVERY_REQUIRED` continue de demander le traitement
nominatif décrit dans `session-v2-focus-recovery-fix-v1.md`, indépendamment de
la création d'un nouveau checkpoint.

View file

@ -0,0 +1,134 @@
# Firefox webapps et restauration V2 — 27 octobre 2026
## Incident réel
À 08:34, après une nouvelle connexion Sway, la tentative
`ab783694a02749a4ad65085e9a56186e` a échoué avec
`FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE`. Le rapport affiche `cleanup-error`,
mais les preuves montrent une résolution échouée suivie d'un nettoyage réussi :
5 applications observées sur 5, 7 slots attendus, 6 ancres créées et supprimées,
0 swap, helper suspendu/repris 1/1, reçu `recovered` sans problème.
Les origines enregistrées étaient celles des applications fraîchement lancées :
Firefox, ChatGPT et Kitty sur le workspace 1, WhatsApp sur 9, LiMusic sur 10.
La récupération a remis ces origines ; elle n'a pas appliqué le layout du
checkpoint. Cela explique les colonnes sur 1 et les autostarts correctement placés.
Le checkpoint de déconnexion de 08:33:18 est conservé. Aucune nouvelle sauvegarde
n'a été créée depuis la disposition issue de cet échec. Le nouvel incident a
remplacé le journal actif du 22 octobre ; l'ancien plan de retrait ne s'applique
pas à cette nouvelle transaction, déjà récupérée.
Archive de diagnostic privée :
`/home/fy59/.local/state/labfy-sway/session-v2-diagnostics/20261027-083749-firefox-provider`.
## Cause établie
L'extension signée 1.1.1 est active et son native host est vivant, sur le bon
socket Sway. L'absence de publication n'est donc pas une extension désinstallée.
Le code de Firefox 158.0.1 installé expose les Taskbar Tabs avec le type
WebExtension `normal`, mais SessionStore exclut ces fenêtres de ses états
persistants et des fenêtres récemment fermées. Une webapp créée avant Firefox
au login n'a donc pas l'UUID qu'elle pouvait posséder en mémoire auparavant.
La 1.1.1 valide tous les UUID avant la première publication : cette absence
bloque aussi le navigateur ordinaire. Avec des UUID déjà présents, l'ordre de
publication pouvait masquer le défaut lorsque Firefox était traité avant WhatsApp.
La consultation ciblée des seules métadonnées a confirmé que l'UUID Firefox du
checkpoint existe encore dans les fenêtres fermées / l'état précédent. Aucune
URL, aucun titre ni contenu d'onglet n'a été utilisé pour attribuer une identité.
## Correction
### Companion 1.1.2 et host
Le host ajoute `classify-window` avec un jeton de corrélation, sans UUID.
La réponse fermée distingue les app_ids exacts Firefox et WhatsApp du catalogue.
Une webapp positivement reconnue est exclue avant lecture, validation ou mint
UUID. Une fenêtre normale sans UUID reste refusée. Les classes inconnues,
collisions, réponses invalides, changements de classe/con_id ou changements du
lot restent refusés.
Le cache est borné, éphémère et invalidé sur close ; les captures et les mints
revalident les classes. Il n'y a pas de marquage périodique permanent en idle.
`map-window` reste réservé au navigateur ordinaire. ID d'extension, permissions,
clé de session et format des snapshots ne changent pas.
### Moteur et interface
L'admission résout le lot Firefox complet et ses UUID exacts après lancement,
mais avant Recovery, Guard, staging et ancres. Elle revalide ensuite identités,
emplacements, ordre et focus. Une indisponibilité à cette frontière est
`DEFERRED`, sans mutation de layout ni nouvelle tentative durable. Un UUID
sauvegardé manquant reste un échec exact. Les contrôles tardifs et la récupération
après erreur restent en place.
Le dialogue différencie cette attente de Firefox d'un refus de focus. Il propose
un nouvel essai explicite sans capturer le clavier et ne réessaie pas seul.
## Validation
- 68 tests JavaScript réussis, dont 10 nouveaux sur la classification.
- 30 tests host ciblés réussis, dont 10 nouveaux ; framing réel inclus.
- Suite Python : 121 tests réussis, dont 7 nouveaux d'admission.
- Deux scénarios headless supplémentaires : `natural` réussit avec 3 swaps ;
`provider-placement` conserve son refus tardif après admission.
- Interface QML réelle en Sway privé : message Firefox, retry manuel disponible,
pas de capture du focus ni d'acquittement mensonger.
- Firefox 158.0.1 réel, profil vierge, bus et Sway privés, webapp créée en premier :
1.1.1 reproduit le refus ; 1.1.2 réussit la capture exacte. L'UUID du navigateur
est inchangé et la webapp reste sans UUID. Cela valide aussi la propagation du
préfixe de corrélation d'une vraie Taskbar Tab jusqu'à Sway.
Les tests réels sont reproductibles avec `tests/session_v2/firefox_webapp_runtime.py`
et `tests/session_v2/provider_ui.py`. Les seuls navigateurs lancés par ces tests
utilisent des profils vierges et des sockets privés. Le mode système Marionette
est réservé à ces navigateurs jetables, jamais au profil utilisateur.
Cette validation ne constitue pas un test physique de la prochaine déconnexion
et reconnexion de l'utilisateur.
## Paquet préparé et installation initialement attendue
XPI candidat 1.1.2 non signé, 6 935 octets, deux builds identiques :
`1e1b4a7a88694ff9eebda153d1ea347a2ae662ecadf40401f0a03002358fe95b`.
Il contient uniquement `manifest.json` et `background.js`.
Le host compatible peut être déployé avant l'extension et reste compatible avec
1.1.1. Le filtrage correct exige toutefois l'installation de 1.1.2 signée par
Mozilla, sous le même ID `session-v2@labfy.org`. Le paquet signé doit conserver
les mêmes deux sources ; les fichiers de signature sont ajoutés par Mozilla.
Aucune signature, publication AMO ni installation de l'extension utilisateur
n'a été effectuée pendant cette préparation.
Après signature et installation : vérifier les versions et la capture des UUID
attendus sans réécrire le checkpoint, puis effectuer une seule restauration
explicite surveillée. Ne pas inventer d'UUID, effacer les preuves ou réessayer
aveuglément une tentative échouée.
## Installation et essai réel vérifiés
L’utilisateur a installé la version **1.1.2 signée par Mozilla**. La vérification
locale confirme le même ID `session-v2@labfy.org`, active=true, signedState=2,
sans désactivation utilisateur ou applicative. Le background installé est
identique au candidat testé ; le manifest est sémantiquement identique, avec
les mêmes permissions. Les fichiers de signature ont été ajoutés par Mozilla.
SHA-256 du XPI signé installé :
`b1d9a9be10a8e0a4c865f7af106bdc27a47c9e2e206efd284111d19452b776d4`.
Après redémarrage du host natif, l’admission réelle a retrouvé l’UUID exact de
Firefox attendu dans le checkpoint, y compris avec WhatsApp présent. Le blocage
Firefox initial est donc résolu pour cette fenêtre et cette installation.
L’unique essai de restauration ensuite autorisé a révélé une autre erreur :
`RECOVERY_FOCUS_MISMATCH`, transaction
`475fac98b5234db0bddaded9cfe4198f`. Ce résultat ne constitue pas une restauration
réussie. Les deux clients déplacés ont été remis à leurs workspaces d’origine ;
le checkpoint et les preuves de l’incident restent conservés.
Voir `session-v2-focus-staging-fix-v1.md` pour la reproduction native, le
correctif et la reprise proposée. La preuve d’installation détaillée se trouve
dans `extension-installation.json`, sous le diagnostic privé
`20261027-102041-signed112-restore`.

View file

@ -0,0 +1,163 @@
# Focus pendant le staging et catalogue installé — 27 octobre 2026
## Incident réel conservé
Après installation vérifiée de l’extension Firefox signée 1.1.2, l’admission a
retrouvé l’identité exacte de la fenêtre Firefox enregistrée. Sept fenêtres
attendues étaient présentes. L’unique essai réel autorisé a ensuite échoué avec
`RECOVERY_FOCUS_MISMATCH`, transaction `475fac98b5234db0bddaded9cfe4198f`.
Deux déplacements avaient été exécutés, aucune ancre créée et aucun slot
reconstruit. Au déplacement de ChatGPT 16, le moteur attendait le conteneur
interne 32 ; Sway a focalisé le terminal 58. Le journal conserve cette divergence.
Les deux clients déplacés par cet essai, Kitty 37 et ChatGPT 16, ont été ramenés
séparément au workspace 1 après vérification de leurs identités et de leurs
origines. Le stage est vide. Cette remise en place n’est ni une restauration
réussie du checkpoint ni un rollback structurel complet. Le journal reste
`required` et son reçu `RECOVERY_REQUIRED`.
Le checkpoint de 08:33:18 est inchangé :
`9a36bb310f48d989a8763092ae220a974a00786da6fba2c86dc25e7cfed1cd89`.
Il décrit ChatGPT et deux Kitty sur 1, Firefox sur 2, WhatsApp sur 9, LiMusic
sur 10 et un troisième Kitty dans le scratchpad caché.
## Cause vérifiée sur SwayFX réel
Les listes de focus du JSON IPC donnent l’ordre des enfants directs.
La recherche interne de focus inactif examine une pile globale de descendants
de toute profondeur. Cette information globale ne peut pas toujours être
reconstruite depuis le seul arbre public.
Deux historiques synthétiques, exécutés avec le binaire installé SwayFX 0.6,
ont produit exactement le même arbre public, listes de focus comprises.
Le déplacement de la même feuille focalisée a néanmoins choisi un conteneur
interne dans un historique et une feuille dans l’autre. Le focus sur un
conteneur interne est donc légitime ; le remplacer systématiquement par une
feuille casserait l’autre cas.
Cette preuve établit le défaut de prédiction de cette transition. Elle ne
prétend pas reconstituer l’historique caché précis du bureau réel.
Sources locales vérifiées au tag 0.6 :
`sway/ipc-json.c`, `sway/input/seat.c`, `sway/commands/move.c` et
`sway/tree/workspace.c`, dans
`/home/fy59/Documents/Development/swayfx-mosaic`.
## Correction
Le moteur déplace d’abord les feuilles hors du sous-arbre focalisé, puis celles
de ce sous-arbre, en conservant l’ordre relatif de chaque groupe. Si la branche
focalisée doit disparaître, toutes les feuilles de son workspace source doivent
pouvoir être stagées. Un client étranger ou un scratchpad montré qui resterait
en place entraîne un refus préalable.
Cette admission s’applique aussi aux workspaces sources absents du checkpoint.
Elle passe avant tout journal transactionnel et est répétée après Guard. Le
contrôle du lot déjà admis garde la priorité : une nouvelle fenêtre apparue
après capture reste `APPLICATION_MEMBERSHIP_CHANGED`.
Le nouveau refus `RESTORE_FOCUS_STAGING_UNPROVEN` est différé uniquement en
phase 0. Après ouverture de la transaction, il reste une erreur transactionnelle.
L’interface fournit une explication et un nouvel essai manuel. Aucun retry
automatique ni déplacement forcé du focus n’a été ajouté.
Le prédicteur et tous les contrôles stricts de `recovery.py` restent inchangés.
Les focus sur workspace, client étranger ou conteneur interne conservant un
descendant sont préservés.
## Catalogue des applications
Le catalogue découvre maintenant les lanceurs installés avec GIO. L’inventaire
de cette machine contient 103 entrées, dont 95 cataloguées : 6 politiques
explicites et 89 entrées découvertes. Les métadonnées NoDisplay et OnlyShowIn
ne sont pas assimilées à une absence d’application.
La nouvelle stratégie V2 ouvre au plus une instance absente, puis attend toutes
les fenêtres enregistrées avant le moindre déplacement. Elle ne promet pas de
rouvrir les documents ni les commandes d’un terminal. Les identités ambiguës
restent refusées. Firefox conserve son provider exact.
Voir `session-v2-installed-app-catalog-v1.md` pour la politique complète,
les huit exclusions motivées et les limites de l’inventaire.
## Validation
- Suite Python intégrée finale : **150 tests réussis en 16,473 s**.
- 10 nouveaux tests de staging, 16 de catalogue et 3 pour Proton Pass.
- Aucun résultat attendu des tests existants n’a été assoupli. Le contrôle
intégré a permis de corriger deux régressions : racine synthétique sans ID
et priorité du refus d’un changement de lot après capture.
- Ancien moteur sur la reproduction imbriquée : échec attendu après deux
déplacements, avec `RECOVERY_FOCUS_MISMATCH`.
- Nouveau moteur sur la même reproduction : succès, quatre slots et quatre
swaps vérifiés, arbre/focus conformes, journal complete, aucune ancre ni
fenêtre restant au stage, client étranger conservé.
- Variante avec focus initial interne : succès.
- Source avec client étranger restant : refus avant toute commande et sans
journal/tentative.
- Cas existants preserve-empty-workspace et preserve-foreign : succès.
- Injection focus-helper-late : le refus strict après swaps reste présent.
- Deux historiques avec arbre IPC identique : focus natifs distincts démontrés.
Ces essais utilisent des sockets Sway privés et des clients synthétiques.
Ils ne constituent pas la validation d’une nouvelle restauration du bureau réel.
## Reprise préparée avant autorisation
Les preuves privées se trouvent dans :
`/home/fy59/.local/state/labfy-sway/session-v2-diagnostics/20261027-102041-signed112-restore`.
La dernière observation contient cinq survivants : LiMusic 7, ChatGPT 16,
Kitty 37, Proton Pass 46 et WhatsApp 62. Firefox 50 et Kitty 58/60 sont observés
absents ; leur fermeture n’est attribuée à aucune action particulière.
Le plan nominatif `retirement-current-plan.md` concerne uniquement la
transaction ci-dessus. Il propose d’archiver les trois fichiers actifs exacts,
avec copies persistantes et contrôles d’identités, de session, d’inactivité et
d’empreintes. Le reçu distinct doit porter `retired-after-manual-return`,
jamais une réussite automatique fictive.
Le helper est en dry-run par défaut. Sa fixture privée vérifie les renommages
et leur retour en cas d’échec ; son dry-run réel a réussi sans modifier les
fichiers protégés. Le retrait réel et un nouvel essai exigent l’autorisation
distincte prévue par `session-v2-focus-recovery-fix-v1.md`.
Une future restauration devra retrouver la fenêtre Firefox avec son UUID
enregistré, préparer les fenêtres manquantes, puis prouver les postconditions.
Ne pas remplacer le checkpoint par la disposition partielle actuelle.
## Validation réelle après autorisation — 12:10:59
L’utilisateur a autorisé le retrait nominatif de la tentative échouée et un
unique nouvel essai. Le helper a archivé les trois fichiers exacts sous
`/run/user/1000/labfy-sway/retired-475fac98b5234db0bddaded9cfe4198f`, en conservant
leurs copies persistantes et le statut distinct `retired-after-manual-return`.
Les deux terminaux manquants ont été ouverts. Le lancement normal de Firefox a
réouvert sa dernière fenêtre fermée, dont les métadonnées portaient l’UUID
attendu ; le provider a confirmé cette même identité sur le conteneur 79.
Aucune modification d’UUID ni réécriture de la session Firefox n’a été effectuée.
L’unique nouvel essai a réussi, transaction
`335bf44840674810b4761ade767f31db` : 7/7 slots, 6/6 ancres créées, échangées et
nettoyées, helpers suspendus/repris 1/1, arbre et focus vérifiés, recovery
`complete` sans issue. Un contrôle indépendant après l’essai a confirmé :
- workspace 1 : ChatGPT à gauche, deux Kitty empilés à droite ;
- workspace 2 : Firefox, avec l’identité exacte attendue ;
- workspace 9 : WhatsApp ; workspace 10 : LiMusic ;
- troisième Kitty caché, géométrie conforme au checkpoint ;
- Proton Pass étranger inchangé ; aucun stage ni aucune ancre restante.
Une capture réelle supplémentaire a réussi avec huit fenêtres et six
applications, dont Proton Pass. Elle est conservée uniquement dans le dossier
de diagnostic et n’a pas remplacé le checkpoint automatique d’origine.
L’acquittement `restored` a été enregistré après ces preuves ; startup-status
indique aucun essai en cours, différé ou interrompu.
Preuves privées :
`/home/fy59/.local/state/labfy-sway/session-v2-diagnostics/20261027-120604-focus-catalog-restore`.
Le checkpoint d’origine conserve son SHA-256 `9a36bb310f48d989a8763092ae220a974a00786da6fba2c86dc25e7cfed1cd89`.
Cette validation porte sur une restauration réelle dans la session active ;
aucune nouvelle déconnexion/reconnexion n’a été déclenchée pour ce contrôle.

View file

@ -0,0 +1,118 @@
# Catalogue des applications installées — session V2
## Changement demandé
Le 27 octobre 2026, l'utilisateur a demandé de prendre en charge les applications
installées, au lieu d'ajouter une entrée à chaque erreur
`APPLICATION_UNSUPPORTED`. Le catalogue explicite reste prioritaire pour
Firefox, WhatsApp, Kitty, ChatGPT, LiMusic et Proton Pass. La découverte des autres
applications complète ces politiques ; elle ne remplace aucune identité exacte
Firefox et ne modifie aucun checkpoint existant.
## Découverte et identité
`Catalog()` utilise `Gio.AppInfo.get_all()` et les objets `DesktopAppInfo`.
GIO applique la recherche XDG, y compris les installations locales et les exports
Flatpak, ainsi que la priorité des fichiers utilisateur et les overrides
`Hidden=true`. Une entrée non affichée dans le menu (`NoDisplay` ou
`OnlyShowIn`) peut toujours produire une fenêtre : elle reste admissible.
L'entrée doit être de type `Application`, avoir un ID compatible avec le
schéma, un fichier desktop régulier lisible appartenant à root ou à l'utilisateur
sans écriture groupe/autres, et un exécutable disponible ou une activation DBus.
Le catalogue ne lance rien pendant sa construction.
La correspondance des surfaces reste exacte et sensible à la casse :
- Wayland : nom du fichier desktop sans `.desktop` en priorité, puis
`StartupWMClass`, puis nom d'exécutable non partagé.
- X11 : namespace typé `x11-class:`, `StartupWMClass` explicite prioritaire,
puis noms exacts issus de l'entrée.
- Une identité de même priorité revendiquée par plusieurs entrées est retirée
de toutes ces entrées ; aucun ordre alphabétique ne tranche une ambiguïté.
- Les namespaces des politiques explicites sont réservés dans les deux
protocoles. Une entrée générique ne peut pas contourner le provider Firefox.
- Les noms d'interpréteurs/launchers communs (`env`, `flatpak`, `java`,
`python3`, `AppRun`, etc.) ne servent pas d'identité.
Aucun titre, contenu de document, argument du processus ou URL d'onglet ne sert
au classement. Une application qui ne publie aucune de ces identités reste
refusée ; un catalogue de lanceurs n'est pas une preuve que chaque application
émet l'identifiant attendu dans toutes ses versions.
## Lancement et sauvegarde
La stratégie V2 `application-self-restore` autorise au plus une demande
d'ouverture si aucune fenêtre de l'application n'est déjà présente. Elle utilise
`/usr/bin/gio launch <fichier-desktop-installé>`, avec le chemin issu du
catalogue, après revalidation de l'identité et du contenu du fichier. Aucun
exécutable, chemin de document ou argument ne provient du snapshot.
Le moteur attend le nombre complet de fenêtres sauvegardé avant le premier
déplacement. Si une application rouvre une seule fenêtre sur les deux attendues,
la restauration est reportée avec `APPLICATION_WINDOWS_INCOMPLETE`, sans
staging, sans journal de récupération nouveau, ni seconde ouverture au retry
tant que la première fenêtre existe. La présence de toutes les fenêtres permet
ensuite la restauration de leur disposition selon le contrat existant.
Une fenêtre générique unique conserve l'identité singleton. Plusieurs fenêtres
de la même application restent interchangeables sans provider dédié : leurs
documents et commandes ne sont pas sauvegardés, et leur association individuelle
à chaque contenu n'est pas garantie. Le schéma V1 et les stratégies existantes
restent inchangés ; la nouvelle stratégie n'est acceptée qu'en V2.
Les applications `Terminal=true` sont des contenus de terminal, pas des surfaces
indépendantes. Le contrat Kitty restaure les fenêtres, pas les commandes qui y
tournaient. Les nouveaux lanceurs `firefox.webapp-*` non couverts par la politique
explicite restent `provider-required` tant que leur exclusion du provider Firefox
normal n'est pas établie.
## Inventaire de la machine le 27 octobre 2026
Lecture seule dans l'environnement de la session graphique :
| Résultat | Nombre |
| --- | ---: |
| Entrées installées vues par GIO | 103 |
| Entrées du catalogue | 95 |
| Politiques explicites | 6 |
| Entrées découvertes automatiquement | 89 |
Huit entrées sont exclues du catalogue de surfaces :
| Entrée | Motif |
| --- | --- |
| `btop.desktop` | Contenu de terminal |
| `jshell-java-openjdk.desktop` | Contenu de terminal |
| `jshell-java17-openjdk.desktop` | Contenu de terminal |
| `julia.desktop` | Contenu de terminal |
| `nvim.desktop` | Contenu de terminal |
| `yazi.desktop` | Contenu de terminal |
| `org.kde.akonadi.desktop` | Aucun Exec disponible ni activation DBus |
| `org.quickshell.desktop` | Aucun Exec disponible ni activation DBus |
Les cinq surfaces présentes au moment de cette lecture étaient classées
(Proton Pass, Kitty, ChatGPT, WhatsApp et LiMusic). Firefox et les deux terminaux
supplémentaires présents au début de l'incident n'étaient plus dans l'arbre ;
aucune application n'a été ouverte ou fermée pour cet inventaire. Le checkpoint
canonique et les trois fichiers de récupération ont conservé leurs SHA-256.
## Vérifications
`tests/session_v2/test_installed_catalog.py` couvre les correspondances exactes,
les collisions et priorités, les politiques réservées, les entrées non affichées,
la résolution XDG utilisateur/système/Flatpak dans un processus isolé, les
overrides Hidden, la lecture non bloquante des fichiers non réguliers, la
revalidation du lanceur, l'absence d'arguments issus du snapshot, la sauvegarde
multi-fenêtre et le report avant mutation lorsqu'une fenêtre manque.
Les fixtures sont des fichiers temporaires et des arbres IPC inertes. Les
lancements sont interceptés. Aucun test n'ouvre les applications de l'utilisateur.
Le catalogue explicitement injecté par les tests est hermétique et ne découvre
pas les applications de la machine.
## Références
- [Desktop Entry Specification — recognized keys](https://specifications.freedesktop.org/desktop-entry/latest/recognized-keys.html)
- [GioUnix.DesktopAppInfo](https://docs.gtk.org/gio-unix/class.DesktopAppInfo.html)
- [Gio.AppInfo.launch](https://docs.gtk.org/gio/method.AppInfo.launch.html)

View file

@ -1,16 +1,89 @@
# Companion Firefox Session Restore V2
Candidat local **1.1.1 non signé**, même ID `session-v2@labfy.org`, même clé
Sources de la version **1.1.2**, même ID `session-v2@labfy.org`, même clé
`labfy.session.window_uuid`, mêmes permissions `sessions`, `nativeMessaging`.
La version signée 1.0.0 installée n'est pas modifiée par ces sources.
Le build local produit un XPI non signé. Son installation permanente nécessite
une signature Mozilla, en conservant cet ID et les identités de fenêtres existantes.
Aucun accès aux profils, au runtime utilisateur ou au socket Sway réel n'est
nécessaire pour les validations décrites ici. Aucun déploiement réalisé.
nécessaire pour les validations décrites ici. Elles ne réalisent aucun déploiement permanent.
L'expérience visée est **installation seule, puis Firefox normal, Ctrl+N compris**.
Le bouton et le panneau provisoires de 1.1.0 sont supprimés. Le background ne
crée aucune fenêtre. Une fenêtre ambiguë peut rester sans identité et faire
échouer la capture ; aucune action quotidienne de remplacement n'est imposée.
## Correctif 1.1.2 : séparer les webapps du lot UUID
Une fenêtre Firefox Taskbar Tab peut être exposée par l'API WebExtension comme
`type: normal`, alors que SessionStore l'exclut du stockage de session. Un UUID
attribué dans sa fenêtre pendant une session peut donc manquer au login suivant.
La 1.1.1 validait l'ensemble des fenêtres normales avant toute publication : une
webapp sans UUID bloquait aussi la capture du navigateur ordinaire. Si toutes
les valeurs étaient présentes, l'ordre des fenêtres pouvait masquer ce défaut :
le host ne corrélait que `firefox`/`org.mozilla.firefox`, et un navigateur publié
avant la webapp pouvait suffire au backend avant l'échec ultérieur du lot.
Sources Firefox vérifiées, y compris dans les `omni.ja` installés :
`TaskbarTabsWindowManager.sys.mjs` ouvre avec toolbar et sans dialog ;
[WindowBase.type](https://searchfox.org/firefox-main/source/toolkit/components/extensions/parent/ext-tabs-base.js)
classe alors la fenêtre comme normale ;
[windows.getAll](https://searchfox.org/firefox-main/source/browser/components/extensions/parent/ext-windows.js)
filtre ce type seulement. Dans SessionStore, `getCurrentState()` et la collecte
des fenêtres fermées excluent explicitement `isTaskbarTab`. L'API Window ne
fournit aucun attribut public `isTaskbarTab` ou `app_id` permettant ce filtrage.
La 1.1.2 ajoute une classification native avant toute lecture ou attribution
UUID. Elle conserve les permissions `sessions` et `nativeMessaging`, l'ID de
l'extension et la clé de session existants. La demande est exactement :
```json
{"op":"classify-window","runtime_token":"0123456789abcdef0123456789abcdef"}
```
Le host corrèle le préfixe éphémère, puis vérifie les app_ids exacts des entrées
Firefox et WhatsApp gérée du catalogue. Sa réponse fermée comporte `result`,
`kind`, `runtime_token` réémis et `con_id` positif. `kind` vaut exclusivement
`browser` ou `managed-webapp`. Un app_id inconnu, une autre webapp, une collision,
un timeout ou une réponse invalide refuse l'opération ; aucune erreur n'est
interprétée comme une permission d'ignorer une fenêtre. La classification ne
publie aucun enregistrement UUID. `map-window` reste réservé à Firefox ordinaire.
Le background exclut seulement une webapp positivement attestée avant
`getWindowValue`, validation, observation ou mint. Une fenêtre normale sans UUID
continue à refuser la capture exacte. Les règles de protection des anciennes
fenêtres et d'attribution aux nouveaux Ctrl+N restent identiques.
Les preuves `{kind, con_id}` sont bornées à 256 fenêtres, locales à l'instance et
supprimées sur fermeture. La première observation classe une fenêtre une fois ;
une erreur n'est pas retentée par la boucle idle. Chaque capture reclassifie
fraîchement toutes les fenêtres, y compris celles qui seront exclues, puis
revalide après l'attente d'extData. Chaque mint revalide aussi la classe avant
écriture. Un changement de classe ou de con_id interdit l'opération jusqu'à la
fermeture de cette fenêtre. L'ensemble brut des fenêtres doit rester stable
pendant l'opération. Aucun marquage ou `map-window` périodique permanent n'est
ajouté en veille.
La corrélation ajoute des préfixes brefs lors du premier classement, d'une capture
ou d'un mint ; elle utilise les mêmes garanties de nettoyage et limites API que
le protocole existant. Elle ne rend pas le sessionstore transactionnel. Un host
ancien ne comprenant pas la classification provoque un refus conservateur ; le
nouveau host reste compatible avec les messages de l'extension 1.1.1.
Les tests VM de cette révision couvrent la webapp au premier ID sans UUID, son
exclusion des lectures et écritures, la normale sans UUID toujours refusée, les
réponses non exactes, le changement de classe/con_id, l'invalidation sur close,
le changement d'ensemble et l'absence de marquage répété en idle. Le code testé
est celui du XPI. La validation Python du host reste séparée de tout profil ou
socket utilisateur. La régression a également été reproduite puis corrigée sous
Firefox réel 158.0.1 dans un profil vierge et un Sway privé (détails plus bas).
La mise à jour nécessite un nouveau XPI signé Mozilla, avec le même ID. Déployer
le host compatible avant l'installation de l'extension signée ; son redémarrage
déconnectera l'ancien port natif et ouvrira le nouveau host. Ne pas modifier le
contenu d'un XPI signé, désactiver les signatures ou réattribuer les anciens UUID.
Le build ci-dessous prépare seulement le XPI déterministe non signé : aucune
signature, publication AMO ou installation n'est réalisée par le build.
## Preuves disponibles et limite fondamentale
La 1.0.0 pouvait attribuer après `undefined` et seulement 200 ms, donc écraser
@ -54,7 +127,7 @@ ancienne arrivant entre la dernière lecture et l'écriture peut encore être
ou d'absence d'écrasement de données jamais encore observées n'est revendiquée.**
Garantir cela pour toutes les restaurations exigerait une preuve supplémentaire
fournie par Firefox ; refuser tout mint sur `onCreated` supprimerait l'auto Ctrl+N.
La 1.1.1 retient l'automatisme demandé avec refus des ambiguïtés observables.
L'automatisme de 1.1.1 est conservé avec refus des ambiguïtés observables.
## Initialisation : BOOT, READY, PROTECTED
@ -63,8 +136,9 @@ sans populate, capture les IDs de la baseline. Une fenêtre émise **avant la
résolution** de cette photographie reste ambiguë, même si `getAll` la contient.
Un événement reçu ensuite, y compris pendant BOOT, devient candidat potentiel :
`type` normal ou absent, sans `sessionId` positif. Sa présence dans
`getAll({windowTypes:["normal"]})` sera exigée avant toute écriture. Les types
explicitement différents de normal sont ignorés.
`getAll({windowTypes:["normal"]})` et une classification native fraîche `browser`
seront exigées avant toute écriture. Les types explicitement différents de normal
sont ignorés ; une webapp de type normal est exclue seulement après attestation.
**Auto-baseline sans clic, sous attestation native.** L'événement
`onInstalled(reason=install)` est mémorisé qu'il précède ou suive le premier
@ -192,7 +266,8 @@ Les titres eux-mêmes ne sont jamais lus.
Une seule opération active (initialisation/nettoyage, auto-mint ou capture) ;
captures concurrentes coalescées sans queue, sans accumuler des opérations.
Une demande native en vol ; timeout 5 secondes ; reconnexion au plus une fois par
seconde, sans map en idle. Le backend existant garde son timeout de 6 secondes :
seconde, sans map-window en idle. Les classifications de première observation
sont mises en cache, sans répétition périodique. Le backend existant garde son timeout de 6 secondes :
un checkpoint trop tôt ou durant un mint peut échouer, il ne reçoit pas de faux
succès ni d'identité inventée par la capture.
@ -203,8 +278,8 @@ annulables et un effet tardif reste possible. Les lectures sœurs sont toutes
attendues même si l'une rejette. Limite locale conservée : 256 fenêtres.
Les ensembles en mémoire sont bornés aux fenêtres suivies et nettoyés sur close.
Le provider Python exact, le format et le contenu des checkpoints et les
applications SwayFX restent inchangés. Le host ajoute uniquement l’opération
d’observation bootstrap au protocole existant. Une capture refusée ne publie pas un message d'erreur incompatible :
applications SwayFX restent inchangés. Le host ajoute les opérations
d'observation bootstrap et de classification au protocole de mapping existant. Une capture refusée ne publie pas un message d'erreur incompatible :
le provider existant échoue faute du lot demandé. Le diagnostic facultatif
`runtime.sendMessage({op: "identity-status"})`, réservé à cette extension, retourne
phase, résultat WAIT/READY/REFUSED, dernier motif et compteurs ; il n'attribue rien.
@ -254,9 +329,15 @@ erreurs et timeout, collisions, close, déplacements d'onglets, restart/update,
concurrence et reconnexion. Les 46 tests Node présents dans la copie initiale sont conservés avec leur
comportement protecteur lorsque le host n'atteste pas l'absence. Le nouveau P0
échoue contre le background pré-protocole conservé en preuve, puis passe contre
le correctif. La suite compte **58 tests Node** et **63 tests Python** (les
43 backend préexistants et 20 tests bootstrap). Les nouveaux tests Python passent par
le framing stdio réel du host avec HOME/XDG privés et un faux socket Sway
le correctif. La validation historique 1.1.1 comptait **58 tests Node** et **63 tests Python**
(les 43 backend préexistants et 20 tests bootstrap). La révision 1.1.2 conserve ces
58 scénarios Node et ajoute 10 régressions de classification : **68/68 PASS**.
Les tests ciblés du host passent **30/30** : les 20 tests bootstrap existants et
10 tests de classification. Ils vérifient l'enveloppe fermée, le jeton exact, les
classes du catalogue, les collisions même avec une surface inconnue, la réponse
sans contenu, l'absence de publication UUID et le refus de mapper une webapp.
Ces tests Python passent par le framing stdio réel du host avec HOME/XDG privés
et un faux socket Sway
possédé, y compris aller-retour bootstrap/map/bootstrap, checkpoint présent,
valide, corrompu ou inaccessible, sauvegarde nommée seule sans `last.json`,
répertoires vides, entrées inattendues dans `sessions`, liens, FIFO, permissions,
@ -267,11 +348,40 @@ la relecture avant écriture, le checkpoint apparu entre écritures et les
reconnexions sans retry baseline. Le risque de restauration indiscernable est
exposé par un test ; il ne constitue pas une preuve de classification parfaite.
Cette validation ne prouve pas la persistance physique au logout/login ni le
comportement réel de sessionstore. Aucun Firefox réel n'a été lancé.
Le minimum de manifeste reste Firefox 157. Le déploiement ultérieur de 1.1.1
avec le même ID demanderait une signature Mozilla et une installation autorisée
séparément ; aucune signature, publication AMO ni installation n'a lieu ici.
Les validations VM historiques 1.1.1 ne lançaient pas Firefox réel et ne
prouvaient pas le comportement physique de SessionStore. Le test réel 1.1.2
ci-dessous ajoute une preuve de capture ; il ne constitue pas un logout/login
de la session utilisateur. Le minimum de manifeste reste Firefox 157. Le
déploiement permanent de 1.1.2 avec le même ID nécessite une signature Mozilla
et une installation autorisée ; les tests et le build ne signent ni ne publient.
### Régression Firefox réelle 1.1.2
Le 27 octobre 2026, le scénario a été exécuté sous Firefox **158.0.1** avec un
profil vierge, HOME/XDG privés, serveur HTTP loopback et Sway headless privés.
Marionette et l'installation temporaire de l'extension sont confinés à ce
profil TEST_ONLY ; aucun profil, cookie, checkpoint ou fenêtre utilisateur
n'est utilisé. La webapp est créée avant la fenêtre normale, sans UUID ;
la normale reçoit une identité fixture stable avant l'installation temporaire.
Avec la 1.1.1, la capture reproduit
`FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE`. Avec le XPI 1.1.2 et le host de cette
révision, elle réussit : correspondance exacte UUID/con_id de la normale,
UUID normal inchangé et aucun UUID attribué à la webapp. Cela confirme aussi
que le préfixe éphémère d'une vraie Taskbar Tab atteint Sway et permet sa
classification native. Résultat final : **PASS**.
Reproduction du candidat construit dans cette copie :
```sh
python3 -B tests/session_v2/firefox_webapp_runtime.py \
--extension build/session-v2/labfy-session-v2-unsigned.xpi --expect success
```
Le script émet un résultat JSON avec seulement les verdicts et la version,
dans son répertoire temporaire TEST_ONLY. Il ferme ses processus et ressources
privés. Cette validation ne prouve pas la persistance au prochain login réel,
la signature AMO ni le déploiement permanent de l'extension.
## Native host
@ -288,8 +398,10 @@ autre packaging Firefox, vérifier le répertoire Native Messaging utilisé avan
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 `map-window` avec UUID et jeton runtime, ainsi que
`bootstrap-eligibility` sans autre champ. Les deux schémas sont fermés.
Le host accepte `map-window` avec UUID et jeton runtime, `classify-window`
avec son seul jeton runtime, ainsi que `bootstrap-eligibility` sans autre champ.
Les trois schémas sont fermés. La classification atteste seulement le type local ;
elle ne crée, ne modifie ni ne publie d'UUID.
Les messages sont bornés à 4096 octets ; la sortie standard est réservée au
framing Native Messaging. Le host lit l’arbre Sway uniquement pour corréler le préfixe
éphémère, sans exécuter de commande Sway mutatrice. L’extension retire ce
@ -305,7 +417,7 @@ 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.
## Verdict du correctif local
## Verdict historique du correctif 1.1.1
**SOURCE_TESTED — AUTO_BOOTSTRAP_CONDITIONNEL** : dans la VM, première
installation avec deux fenêtres sans UUID et absence attestée de checkpoint :
@ -314,10 +426,11 @@ présent, contrôle inaccessible ou erreur : refus conservateur. Les Ctrl+N rest
automatiques. Les preuves, empreintes, logs et patch non commité sont conservés
dans `build/bootstrap-evidence/` de cette copie privée.
**Firefox Release non installé et non testé ici.** Aucun PASS de login réel,
de persistance physique ou de déploiement n'est revendiqué. Le XPI 1.1.1 reste
non signé ; signature AMO et installation autorisée seront nécessaires
ultérieurement. La provenance d'une restauration tardive sans signal reste
**État de la validation historique 1.1.1 : Firefox Release n'avait pas été
lancé par cette validation VM.** Aucun PASS de login réel, de persistance
physique ou de déploiement n'était revendiqué. L'artefact 1.1.1 alors construit
était non signé ; il nécessitait une signature AMO et une installation
ultérieures. Le test Firefox réel ajouté pour 1.1.2 est décrit ci-dessus. La provenance d'une restauration tardive sans signal reste
inconnue ; une ancienne valeur jamais encore visible peut toujours arriver entre
la dernière lecture et l'écriture. Aucune garantie absolue de CAS ou de protection
d'une identité jamais observée n'est annoncée. Aucune réparation d'UUID perdu,

View file

@ -30,6 +30,12 @@ const candidates = new Set();
const quarantine = new Set();
const attempted = new Set();
const known = new Map();
// Preuves locales, jamais persistées ni déduites d'un titre/contenu. Le host
// distingue les app_ids exacts du catalogue avant toute lecture/mint UUID.
const classifications = new Map();
const classificationErrors = new Map();
const classificationConflicts = new Set();
let operationWindows = null;
let halted = false;
let port = null;
let pending = null;
@ -95,13 +101,69 @@ async function request(message) {
}
});
}
async function ids() {
async function rawIds() {
const windows = await api(() => browser.windows.getAll({populate: false, windowTypes: ["normal"]}));
if (windows.length > 256) throw new Error("WINDOW_LIMIT");
const result = windows.map(w => w.id).sort((a, b) => a - b);
if (result.some(id => !Number.isSafeInteger(id) || id < 0) || new Set(result).size !== result.length) throw new Error("WINDOW_IDS");
return result;
}
const sameIds = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]);
async function classifyWindow(id) {
if (classificationConflicts.has(id)) throw new Error("WINDOW_CLASS_CHANGED");
const runtime_token = crypto.randomUUID().replaceAll("-", "");
let answer;
try {
await mark(id, runtime_token);
answer = await request({op: "classify-window", runtime_token});
} finally {
await api(() => browser.windows.update(id, {titlePreface: ""}));
}
if (!answer || Object.keys(answer).sort().join(",") !== "con_id,kind,result,runtime_token"
|| answer.result !== "OK" || answer.runtime_token !== runtime_token
|| !["browser", "managed-webapp"].includes(answer.kind)
|| !Number.isSafeInteger(answer.con_id) || answer.con_id <= 0) {
throw new Error("WINDOW_CLASS_UNAVAILABLE");
}
const proof = {kind: answer.kind, con_id: answer.con_id};
const prior = classifications.get(id);
if (prior && (prior.kind !== proof.kind || prior.con_id !== proof.con_id)) {
classificationConflicts.add(id);
throw new Error("WINDOW_CLASS_CHANGED");
}
return proof;
}
async function ids(fresh = false) {
const current = await rawIds();
if (operationWindows === null) operationWindows = current;
if (!sameIds(current, operationWindows)) throw new Error("WINDOW_SET_CHANGED");
if (new Set([...classifications.keys(), ...classificationErrors.keys(), ...current]).size > 256) throw new Error("WINDOW_LIMIT");
const eligible = [];
for (const id of current) {
if (classificationConflicts.has(id)) throw new Error("WINDOW_CLASS_CHANGED");
if (!fresh && classificationErrors.has(id)) throw new Error(classificationErrors.get(id));
if (fresh || !classifications.has(id)) {
try {
const proof = await classifyWindow(id);
if (!sameIds(await rawIds(), current)) throw new Error("WINDOW_SET_CHANGED");
classifications.set(id, proof);
classificationErrors.delete(id);
} catch (error) {
// Une panne du host ne provoque pas un clignotement en boucle idle.
// Une capture explicite peut réessayer ; jamais une classe conflictuelle.
if (failureReason(error) !== "WINDOW_SET_CHANGED") classificationErrors.set(id, failureReason(error));
throw error;
}
}
if (classifications.get(id).kind === "browser") eligible.push(id);
else {
// Une webapp gérée n'entre dans aucun lot UUID, même sans extData.
candidates.delete(id); quarantine.delete(id); protectedBaseline.delete(id);
}
}
if (!sameIds(await rawIds(), current)) throw new Error("WINDOW_SET_CHANGED");
return eligible;
}
function validate(rows, complete = true) {
const seen = new Set();
for (const {uuid} of rows) {
@ -166,6 +228,9 @@ async function publish(rows) {
validate(rows);
// Valider le lot complet avant le premier message, puis chaque UUID juste
// avant usage. Ce protocole n'est pas un CAS avec le sessionstore Firefox.
// Revalider aussi les exclusions après l'attente d'extData ; une conversion
// de classe ou un con_id remplacé ne devient pas une nouvelle autorisation.
await ids(true);
const fresh = await sample(rows.map(row => row.id));
if (fresh.some((row, i) => row.uuid !== rows[i].uuid)) throw new Error("IDENTITY_CHANGED");
for (const row of rows) {
@ -174,7 +239,8 @@ async function publish(rows) {
try {
await mark(row.id, 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");
if (answer.result !== "OK" || answer.uuid !== row.uuid || !Number.isSafeInteger(answer.con_id)
|| answer.con_id !== classifications.get(row.id).con_id) throw new Error("PROVIDER_ERROR");
} finally {
await api(() => browser.windows.update(row.id, {titlePreface: ""}));
}
@ -186,14 +252,16 @@ async function cycle() {
// message natif non supporté par le provider strict existant.
if (active) return {result: "BUSY"};
active = true;
operationWindows = null;
try {
await ids(true);
await publish(await observe());
lastError = null;
return {result: "OK"};
} catch (error) {
lastError = failureReason(error);
return {result: "REFUSED", reason: lastError};
} finally { active = false; }
} finally { operationWindows = null; active = false; }
}
function changed() { changedAt = performance.now(); ++revision; }
// CONTRACT : avant la résolution du premier getAll, la provenance est ambiguë.
@ -213,6 +281,7 @@ browser.windows.onCreated.addListener(window => {
browser.windows.onRemoved.addListener(id => {
changed();
candidates.delete(id); quarantine.delete(id); attempted.delete(id); known.delete(id); protectedBaseline.delete(id);
classifications.delete(id); classificationErrors.delete(id); classificationConflicts.delete(id);
if (baseline) baseline.delete(id);
});
browser.sessions.onChanged.addListener(() => {
@ -234,6 +303,7 @@ browser.runtime.onMessage.addListener((message, sender) => {
async function autoStep() {
if (active || halted || phase === "PROTECTED") return;
active = true;
operationWindows = null;
let targets = [];
try {
const expected = await ids();
@ -284,6 +354,7 @@ async function autoStep() {
if (observed.some(row => row.id === id && row.uuid !== undefined)) throw new Error("IDENTITY_CHANGED");
// Dernière lecture intégrale avant CHAQUE écriture. Les cibles sœurs
// absentes restent isolées ; une ancienne ambiguïté ne devient pas cible.
await ids(true);
const before = await sample(expected);
if (epoch !== revision) throw new Error("WINDOW_ACTIVITY");
if (before.find(row => row.id === id).uuid !== undefined) continue;
@ -314,16 +385,16 @@ async function autoStep() {
for (const id of transient ? targets : [...candidates]) {
if (!transient || attempted.has(id)) { candidates.delete(id); quarantine.add(id); }
}
} finally { active = false; }
} finally { operationWindows = null; active = false; }
}
async function start() {
active = true;
// La baseline est capturée avant les nettoyages ; les événements déjà reçus
// restent protégés indépendamment de leur présence dans cette photographie.
try { baseline = new Set(await ids()); }
try { baseline = new Set(await rawIds()); }
catch (error) { phase = "PROTECTED"; lastError = failureReason(error); }
try {
for (const id of await ids()) {
for (const id of await rawIds()) {
try { await api(() => browser.windows.update(id, {titlePreface: ""})); } catch {}
}
} catch { /* Une erreur d'énumération ne doit pas supprimer la reconnexion. */ }

View file

@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "Labfy — Identité des fenêtres",
"version": "1.1.1",
"version": "1.1.2",
"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"]}}

View file

@ -5,3 +5,7 @@ file:///home/fy59/Documents
file:///home/fy59/Pictures/Assets
file:///home/fy59/Share
file:///home/fy59/Nextcloud/
file:///home/fy59/Pictures/Phototheque/
file:///home/fy59/Pictures/Phototheque/Photos/
file:///home/fy59/Documents/3d_models/
file:///home/fy59/Documents/807/

View file

@ -3,7 +3,8 @@ font_family JetBrainsMono Nerd Font
font_size 12.0
enable_audio_bell no
hide_window_decorations yes
allow_remote_control yes
# Aucun usage du protocole de controle distant detecte dans les dotfiles.
allow_remote_control no
# Behaviour
url_prefixes http https gemini

View file

@ -14,6 +14,7 @@ import "theme"
import "clipboard"
import "overview"
import "applications"
import "systemmonitor"
PanelWindow {
id: bar
@ -29,21 +30,55 @@ PanelWindow {
required property var keepAwakeController
required property var drawerService
required property var osdService
required property var monitorService
screen: modelData
FileView {
id: lockCaptureGuardFile
watchChanges: true
onFileChanged: reload()
path: (Quickshell.env("XDG_RUNTIME_DIR") || "") + "/labfy-lock.capture-guard"
printErrors: false
}
readonly property bool lockCaptureGuard: lockCaptureGuardFile.text().trim() === "active"
onLockCaptureGuardChanged: {
if (lockCaptureGuard) {
overviewCapture.running = false;
overviewCaptureRefresh.stop();
closeOverview();
overviewPurge.running = true;
}
}
// CONTRACT: one ShellRoot collector owns all outputs; this bar only owns
// the transient popup on its own screen.
function closeMonitor() {
if (monitorService.activeOutput === screen.name) monitorService.close();
}
function toggleMonitor() {
if (authenticationActive) return;
if (monitorService.activeOutput === screen.name) { closeMonitor(); return; }
closeApplications(); closeOverview(); closeClipboard();
controlCenter.visible = false; dateCenter.requestClose();
windowStrip.closeMenu(); rightStatusArea.closeRemovableMedia();
rightStatusArea.closePrinting(); rightStatusArea.closePhotoBackup(); closeDrawer();
monitorService.open(screen.name);
}
Connections {
target: monitorService
function onActiveOutputChanged() {
if (monitorService.activeOutput === bar.screen.name)
monitorLoader.active = true;
else monitorLoader.active = false;
}
}
LazyLoader {
id: monitorLoader
active: false
MonitorPanel {
barWindow: bar
indicator: monitorIndicator
service: bar.monitorService
onDismissed: bar.closeMonitor()
}
}
function closeDrawer() { drawerIndicator.close(false) }
function toggleDrawer() { drawerIndicator.toggle() }
@ -144,11 +179,10 @@ PanelWindow {
Connections {
target: bar.applicationCoordinator
function onAuthenticationOpening() {
bar.closeMonitor();
bar.closeApplications();
// INVARIANT: no Overview screenshot may contain the auth surface.
overviewCapture.running = false;
overviewCaptureRefresh.stop();
if (overviewLoader.item) overviewLoader.item.skipDismissCaptures = true;
if (overviewLoader.item) overviewLoader.item.cancelCaptures();
bar.closeOverview();
bar.closeClipboard();
controlCenter.visible = false;
@ -160,6 +194,7 @@ PanelWindow {
bar.closeDrawer();
}
function onApplicationsOpening(output) {
bar.closeMonitor();
bar.closeOverview();
bar.closeClipboard();
controlCenter.visible = false;
@ -207,6 +242,7 @@ PanelWindow {
}
// CONTRACT: clic et IPC suivent la même exclusion que les panneaux de barre.
function toggleClipboard() {
closeMonitor();
if (authenticationActive) return false;
if (clipboardLoader.active) {
closeClipboard();
@ -225,14 +261,15 @@ PanelWindow {
return true;
}
// CONTRACT: un seul écran reçoit l'Overview ; une capture sans overlay
// précède l'ouverture afin de montrer le workspace actif tel qu'affiché.
// CONTRACT: IPC and cached previews appear immediately. Only the loaded
// Overview owns capture work; a closed bar never schedules screenshots.
function closeOverview() {
overviewRequested = false;
if (overviewLoader.item) overviewLoader.item.dismiss();
return true;
}
function openOverview() {
closeMonitor();
if (authenticationActive || lockCaptureGuard) return false;
// INVARIANT: deux ouvertures ne créent ni une seconde capture ni une fermeture.
if (overviewRequested || overviewLoader.active) return true;
@ -246,7 +283,7 @@ PanelWindow {
rightStatusArea.closePhotoBackup();
bar.closeDrawer();
overviewRequested = true;
if (!overviewCapture.running) overviewCapture.running = true;
overviewLoader.active = true;
return true;
}
function toggleOverview() {
@ -281,42 +318,11 @@ PanelWindow {
} : null });
}
}
// WHY: the existing lock guard precedes mapping of the authentication UI.
// Purging invalidates old pixels; in-flight helpers also check this guard.
Process {
id: overviewCapture
command: ["python3", "-B", bar.overviewBackend, "capture", "--output", bar.screen.name]
onExited: (code, status) => {
if (bar.overviewRequested && !bar.authenticationActive && !bar.lockCaptureGuard) overviewLoader.active = true;
else if (code !== 0) console.warn("Capture du workspace impossible");
}
}
// CONTRACT: un workspace nouvellement visible reçoit une capture après
// que Sway a présenté sa première frame, sans attendre le cycle de 15 s.
Connections {
target: I3
function onRawEvent(event) {
if (event.type === "workspace") overviewCaptureRefresh.restart();
}
}
Timer {
id: overviewCaptureRefresh
interval: 350; repeat: false
onTriggered: {
if (!bar.authenticationActive && !bar.lockCaptureGuard && !overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
&& !applicationsLoader.active
&& !controlCenter.visible && !dateCenter.visible)
overviewCapture.running = true;
}
}
// Un seul snapshot toutes les 15 secondes pour le workspace actuellement
// visible ; aucune boucle de capture pendant l'Overview ou un autre panneau.
Timer {
interval: 15000; repeat: true; running: true; triggeredOnStart: true
onTriggered: {
if (!bar.authenticationActive && !bar.lockCaptureGuard && !overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
&& !applicationsLoader.active
&& !controlCenter.visible && !dateCenter.visible)
overviewCapture.running = true;
}
id: overviewPurge
command: ["python3", "-B", bar.overviewBackend, "purge"]
}
LazyLoader {
id: overviewLoader
@ -332,6 +338,7 @@ PanelWindow {
IpcHandler {
target: "appearanceUi-" + bar.screen.name
function openPage(page: string): bool {
bar.closeMonitor();
if (bar.authenticationActive) return false;
if (controlCenter.pages[page] === undefined) return false;
bar.closeApplications();
@ -350,6 +357,7 @@ PanelWindow {
bar.toggleDateCenter(); return true;
}
function openWindowMenu(): bool {
bar.closeMonitor();
if (bar.authenticationActive) return false;
bar.closeApplications();
bar.closeClipboard();
@ -399,6 +407,7 @@ PanelWindow {
// Le bouton de sauvegarde utilise la même exclusion que les autres popups.
function togglePhotoBackup() {
closeMonitor();
if (authenticationActive || !rightStatusArea.photoBackupVisible) return false;
const opening = !rightStatusArea.photoBackupOpen;
closeApplications();
@ -426,6 +435,7 @@ PanelWindow {
// CONTRACT: toute la capsule centrale partage le même popup et la même exclusion XOR.
function toggleDateCenter() {
closeMonitor();
if (authenticationActive) return;
const opening = !dateCenter.visible && !dateCenter.opening;
closeApplications();
@ -444,6 +454,7 @@ PanelWindow {
// CONTRACT: le tiroir prend le focus clavier sur une seule sortie et
// libère tous les autres panneaux transitoires avant son ouverture.
function prepareDrawer() {
closeMonitor();
closeApplications();
closeOverview();
closeClipboard();
@ -557,6 +568,7 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter
maxWidth: Math.max(0, leftArea.width - x - 8)
onMenuOpened: {
bar.closeMonitor();
bar.closeApplications();
bar.closeOverview();
bar.closeClipboard();
@ -588,11 +600,22 @@ PanelWindow {
height: bar.height
clip: true
Rectangle {
id: monitorIndicator
anchors.right: parent.right; anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
width: 26; height: 26; radius: 4
color: monitorService.activeOutput === bar.screen.name ? Theme.buttonPressed : "transparent"
Text { anchors.centerIn: parent; text: "󰍛"; color: Theme.lavender
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 17 }
MouseArea { anchors.fill: parent; onClicked: bar.toggleMonitor() }
}
// INVARIANT: la zone droite s'étend depuis le bord sans modifier l'ancrage central.
RightStatusArea {
id: rightStatusArea
anchors.right: parent.right
anchors.rightMargin: 8
anchors.right: monitorIndicator.left
anchors.rightMargin: 5
anchors.verticalCenter: parent.verticalCenter
barWindow: bar
wifiDevice: controlCenter.wifiDevice
@ -603,6 +626,7 @@ PanelWindow {
keepAwakeController: bar.keepAwakeController
clipboardOpen: clipboardLoader.active && !!clipboardLoader.item && !clipboardLoader.item.closing
onOpenPageRequested: page => {
bar.closeMonitor();
bar.closeApplications();
bar.closeOverview();
bar.closeClipboard();
@ -616,6 +640,7 @@ PanelWindow {
controlCenter.visible = true;
}
onToggleControlCenterRequested: {
bar.closeMonitor();
bar.closeApplications();
bar.closeOverview();
const opening = !controlCenter.visible;
@ -630,6 +655,7 @@ PanelWindow {
controlCenter.visible = opening;
}
onToggleRemovableMediaRequested: {
bar.closeMonitor();
bar.closeApplications();
bar.closeOverview();
const opening = !rightStatusArea.removableMediaOpen;
@ -643,6 +669,7 @@ PanelWindow {
bar.closeDrawer();
}
onTogglePrintingRequested: {
bar.closeMonitor();
if (bar.authenticationActive) return;
const opening = !rightStatusArea.printingOpen;
bar.closeApplications();

View file

@ -103,7 +103,13 @@ PanelWindow {
awaitingUserFocus = retryAllowed;
const prewarmDeferred = retryAllowed && (value.reason === "APPLICATION_LAUNCH_FAILED"
|| value.reason === "APPLICATION_WINDOWS_INCOMPLETE");
failureMessage = prewarmDeferred
const providerDeferred = retryAllowed && value.reason === "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE";
const stagingDeferred = retryAllowed && value.reason === "RESTORE_FOCUS_STAGING_UNPROVEN";
failureMessage = stagingDeferred
? "Restauration différée : une fenêtre qui doit rester en place empêche de préparer l’espace actif. Activez vous-même un autre espace de travail avec vos raccourcis Sway, puis cliquez Réessayer. Aucune fenêtre n’a été déplacée."
: providerDeferred
? "La restauration attend le composant Firefox. Vérifiez que Firefox et l’extension Labfy sont prêts, puis cliquez Réessayer. Les fenêtres ouvertes sont conservées sans déplacement."
: prewarmDeferred
? "Préparation incomplète. Les fenêtres ouvertes sont conservées, sans déplacement. Vérifiez les applications, puis cliquez Réessayer. Aucune application déjà présente ne sera relancée."
: retryAllowed
? "Restauration différée : aucun focus stable n’est prouvé. Ce dialogue ne capte plus le clavier. Activez vous-même une fenêtre ou un espace avec vos raccourcis Sway, puis cliquez Réessayer. Sinon, choisissez Nouvelle session."

View file

@ -110,24 +110,43 @@ def _exec_quoted(value):
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def terminal_launch_info(app):
def desktop_executable(app):
# GIO's get_executable() can retain Exec quotes or truncate a spaced path.
# Parse only for validation; UWSM/GIO still launch the installed desktop ID
# and expand its field codes, without passing Exec through a shell.
commandline = app.get_commandline()
if not commandline:
raise ValueError("Commande d'application absente")
_, argv = GLib.shell_parse_argv(commandline)
if not argv or not argv[0]:
raise ValueError("Commande d'application invalide")
return argv[0]
def uwsm_launch_info(app, *, terminal=False):
"""Let GIO expand Exec fields, then give UWSM the resulting command."""
filename = app.get_filename()
if not filename:
raise ValueError("Entrée terminal sans fichier desktop")
raise ValueError("Entrée d’application sans fichier desktop")
keyfile = GLib.KeyFile()
keyfile.load_from_file(filename, GLib.KeyFileFlags.NONE)
original = keyfile.get_string("Desktop Entry", "Exec")
# WHY: a keyfile-created DesktopAppInfo has no source filename for %k.
# Preserve that field while GLib expands the remaining desktop field codes.
# A keyfile-created DesktopAppInfo has no source filename for %k.
original = re.sub(r"(?<!%)%k", _exec_quoted(filename), original)
executable = os.path.basename(app.get_executable() or "")
prefix = "uwsm app -t service -- "
if executable != "kitty":
if terminal and os.path.basename(desktop_executable(app)) != "kitty":
prefix += "kitty -- "
keyfile.set_string("Desktop Entry", "Exec", prefix + original)
keyfile.set_boolean("Desktop Entry", "Terminal", False)
keyfile.set_boolean("Desktop Entry", "DBusActivatable", False)
return GioUnix.DesktopAppInfo.new_from_keyfile(keyfile)
result = GioUnix.DesktopAppInfo.new_from_keyfile(keyfile)
if result is None:
raise ValueError("Entrée d’application invalide")
return result
def terminal_launch_info(app):
return uwsm_launch_info(app, terminal=True)
def launch(identifier, runner=subprocess.run, loader=GioUnix.DesktopAppInfo.new):
@ -138,8 +157,8 @@ def launch(identifier, runner=subprocess.run, loader=GioUnix.DesktopAppInfo.new)
app = loader(identifier)
if app is None or app.get_is_hidden() or app.get_nodisplay() or not app.should_show():
raise ValueError("Application indisponible")
executable = app.get_executable()
if not app.get_boolean("DBusActivatable") and executable and not shutil.which(executable):
if (not app.get_boolean("DBusActivatable")
and not shutil.which(desktop_executable(app))):
raise ValueError("Exécutable introuvable")
if app.get_boolean("Terminal"):
if not shutil.which("kitty") or not shutil.which("uwsm"):
@ -151,6 +170,15 @@ def launch(identifier, runner=subprocess.run, loader=GioUnix.DesktopAppInfo.new)
if not app.launch([], None):
raise ValueError("Activation D-Bus refusée")
return
if identifier == "com.nextcloud.desktopclient.nextcloud.desktop":
# UWSM 0.27 rejects the valid CloudProviders group through pyxdg.
# Keep the installed metadata intact; only this launch descriptor
# passes GIO-expanded command arguments to UWSM instead of a desktop ID.
if not shutil.which("uwsm"):
raise ValueError("UWSM indisponible")
if not uwsm_launch_info(app).launch([], None):
raise ValueError("Demande de lancement refusée")
return
result = runner(["uwsm", "app", "-t", "service", "--", identifier],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
timeout=10, check=False)

View file

@ -21,7 +21,18 @@ L'ouverture du presse-papiers ferme Date Center, Control Center, le menu des fen
Une seule image sélectionnée peut recevoir une miniature. Elle est produite par ImageMagick dans un répertoire `0700` sous `XDG_RUNTIME_DIR`, avec limites locales de mémoire et de surface. Le panneau nettoie ses miniatures à la fermeture. Les anciens fichiers de `cliphist/thumbs` ne sont pas réutilisés : leurs permissions et leur provenance ne sont pas garanties. Le décodage utilise un fichier temporaire privé, sans placer les octets binaires dans une propriété QML ou dans les arguments d'une commande.
Les journaux ne contiennent ni aperçu ni contenu décodé. L'interface affiche les aperçus seulement quand l'utilisateur l'ouvre. Elle ne crée pas de base supplémentaire ni de nouvelle politique de rétention. Les deux collecteurs actuels ne distinguent pas de manière fiable les secrets des autres contenus ; l'exclusion automatique des mots de passe n'est donc pas annoncée. Pour éviter la collecte d'un secret, il faut agir à la source ou définir ultérieurement une politique explicite.
Les journaux ne contiennent ni aperçu ni contenu décodé. L'interface affiche les aperçus seulement quand l'utilisateur l'ouvre. Elle ne crée pas de base supplémentaire ni de nouvelle politique de rétention. Les collecteurs transmettent `CLIPBOARD_STATE=sensitive` lorsque l'application
source publie le marqueur `wl-copy --sensitive` ; `cliphist 0.7.0` ne stocke pas
ces offres. Une application qui n'émet aucun marqueur sensible n'est pas
reconnue automatiquement. Les tests STEP15A ont confirmé séparément une offre
PNG seule (collecteur image) et une offre texte+PNG (un appel par collecteur,
chaque représentation étant distincte). Le filtre `--type text` du service
texte exprime explicitement ce choix pour les prochains démarrages de service.
`UMask=0077` rend privés les nouveaux fichiers. La base existante a été
contrôlée puis mise à `0600` sans lecture de contenu. Les services actifs
n'ont pas été redémarrés par STEP15A. Le contrôle « Suspendre l'historique »
est reporté : une suspension fiable doit refléter et piloter l'état réel des
deux services sans perdre leur configuration ni leurs états initiaux. Pour éviter la collecte d'un secret, il faut agir à la source ou définir ultérieurement une politique explicite.
## Dépendances et retour arrière

View file

@ -6,6 +6,7 @@ import os
from pathlib import Path
import re
import selectors
import stat
import subprocess
import sys
import tempfile
@ -14,10 +15,36 @@ import time
IMAGE = re.compile(r"^\[\[ binary data .*?\b(png|jpeg|jpg|bmp|webp)\b", re.I)
MIMES = {"png": "image/png", "jpeg": "image/jpeg", "jpg": "image/jpeg", "bmp": "image/bmp", "webp": "image/webp"}
MAX_DECODE_BYTES = 256 * 1024 * 1024
_cache_fd = None
_cache_public = None
def command(db, action):
return ["cliphist"] + (["-db-path", db] if db else []) + [action]
# CONTRACT: validate nature/owner before tightening metadata. No database
# bytes are inspected here; symlinks and hardlinks are never repaired.
path = Path(db) if db else Path(os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")) / "cliphist/db"
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
info = path.parent.lstat()
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid()
or stat.S_IMODE(info.st_mode) != 0o700):
raise RuntimeError("Répertoire historique non privé")
parent = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
try:
fd = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
except FileNotFoundError:
fd = None
if fd is not None:
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1:
raise RuntimeError("Base historique non privée")
os.fchmod(fd, 0o600)
finally:
os.close(fd)
finally:
os.close(parent)
return ["cliphist", "-db-path", str(path), action]
def entries(db):
@ -87,15 +114,29 @@ def decode(db, ident, output):
def cache_dir():
global _cache_fd, _cache_public
# CONTRACT: le test graphique peut isoler les miniatures sans changer le
# socket Wayland fourni par XDG_RUNTIME_DIR.
runtime = Path(os.environ.get("LABFY_CLIPHIST_TEST_CACHE_DIR") or
os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()))
value = os.environ.get("LABFY_CLIPHIST_TEST_CACHE_DIR") or os.environ.get("XDG_RUNTIME_DIR")
if not value or not Path(value).is_absolute():
raise RuntimeError("Runtime privé indisponible")
runtime = Path(value)
info = runtime.lstat()
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid()
or stat.S_IMODE(info.st_mode) != 0o700):
raise RuntimeError("Runtime non privé")
path = runtime / ("labfy-cliphist-panel-" + str(os.getuid()))
path.mkdir(mode=0o700, exist_ok=True)
if path.is_symlink() or path.stat().st_uid != os.getuid() or path.stat().st_mode & 0o077:
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
info = os.fstat(fd)
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
os.close(fd)
raise RuntimeError("Répertoire temporaire non privé")
return path
# INVARIANT: all temporary/cache operations stay on the validated inode.
if _cache_fd is not None:
os.close(_cache_fd)
_cache_fd, _cache_public = fd, path
return Path(f"/proc/{os.getpid()}/fd/{fd}")
def main():
@ -110,7 +151,7 @@ def main():
elif args.action == "clean":
path = cache_dir()
for file in path.glob("*.png"):
if file.is_file() and not file.is_symlink():
if stat.S_ISREG(file.lstat().st_mode):
file.unlink()
print('{"ok":true}')
elif args.action == "wipe":
@ -139,7 +180,14 @@ def main():
print('{"ok":true}')
else:
path = cache_dir() / (item["id"] + ".png")
if not path.exists():
if path.is_symlink():
raise RuntimeError("Miniature non privée")
if path.exists():
info = path.lstat()
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1):
raise RuntimeError("Miniature non privée")
else:
with tempfile.NamedTemporaryFile(dir=path.parent, prefix="thumb-", suffix=".png", delete=False) as out:
temporary = Path(out.name)
try:
@ -152,13 +200,14 @@ def main():
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
print(json.dumps({"path": path.as_uri()}))
print(json.dumps({"path": (_cache_public / path.name).as_uri()}))
if __name__ == "__main__":
os.umask(0o077)
try:
main()
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired, RuntimeError, ValueError) as error:
# CONTRACT: aucun aperçu ni contenu décodé n'est écrit dans les journaux.
print(json.dumps({"error": str(error)}))
print(json.dumps({"error": "Opération presse-papiers indisponible"}))
sys.exit(1)

View file

@ -137,6 +137,7 @@ PopupWindow {
// fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible
// pour le mot de passe Wi-Fi et l'alias Bluetooth après navigation interne.
grabFocus: true
Keys.onEscapePressed: visible = false
color: "transparent"
Rectangle {

View file

@ -11,11 +11,16 @@ PopupWindow {
// L'horloge reste le point d'ancrage du panneau.
anchor.window: barWindow
anchor.rect.x: clockItem.x + clockItem.width / 2 - implicitWidth / 2
anchor.rect.x: Math.max(0, Math.min(barWindow.width - implicitWidth,
clockItem.x + clockItem.width / 2 - implicitWidth / 2))
anchor.rect.y: barWindow.height + 6
anchor.edges: Edges.Top | Edges.Left
anchor.gravity: Edges.Bottom | Edges.Right
implicitWidth: 450
// CONTRACT: preserve the fixed player; only the notification viewport
// yields space on fractional-scale and small outputs, below the bar.
readonly property real availableHeight: Math.max(0, Math.min(600,
barWindow.screen.height - barWindow.height - 6 - 16))
implicitWidth: Math.min(450, Math.max(0, barWindow.screen.width - 32))
// WHY: Column.implicitHeight conservait la plus grande hauteur atteinte après
// suppression d'items ; childrenRect.height reflète les zones visibles.
// INVARIANT: le Player conserve ses 116 px et seule la liste fait défiler ses items.
@ -23,11 +28,15 @@ PopupWindow {
visible: false
// CONTRACT: seule la liste effectivement visible vaut consultation.
onVisibleChanged: notificationService.setDateCenterVisible(visible)
// Laisser les boutons de barre recevoir les clics d'exclusion XOR.
grabFocus: false
// WHY: sans grab, les clics hors du PopupWindow ne lui sont jamais signalés.
// CONTRACT: le grab est actif dès l'ouverture ; QuickShell remet visible
// à false lorsque le compositeur ferme le popup sur clic extérieur.
// INVARIANT: ne pas changer le grab pendant que le panneau est visible.
grabFocus: true
color: "transparent"
function requestOpen() { opening = false; visible = true; }
function requestClose() { opening = false; visible = false; }
Keys.onEscapePressed: event => { requestClose(); event.accepted = true; }
SystemClock { id: clock; precision: SystemClock.Minutes }
Connections {
@ -47,11 +56,17 @@ PopupWindow {
anchors.margins: 16
spacing: 14
Text {
id: dateHeading
text: clock.date.toLocaleDateString(Qt.locale("fr_FR"), "dddd d MMMM")
color: Theme.accentForeground; font.pixelSize: 17; font.bold: true
}
MediaPlayer { width: parent.width }
NotificationList { width: parent.width; service: popup.notificationService }
MediaPlayer { id: mediaCard; width: parent.width }
NotificationList {
width: parent.width
service: popup.notificationService
maximumHeight: Math.max(0, popup.availableHeight - 32 - dateHeading.implicitHeight
- (mediaCard.visible ? mediaCard.height + 2 * content.spacing : content.spacing))
}
}
}
}

View file

@ -42,6 +42,9 @@ Rectangle {
anchors.fill: parent
source: card.player ? card.player.trackArtUrl : ""
fillMode: Image.PreserveAspectCrop
// WHY: displayed at 72 logical px; bound decoded pixels even
// when MPRIS provides a multi-megapixel artwork.
sourceSize: Qt.size(144, 144)
asynchronous: true
visible: status === Image.Ready
}

View file

@ -43,7 +43,7 @@ Rectangle {
}
Text { width: parent.width; text: card.notification.summary; textFormat: Text.PlainText; color: Theme.foreground; font.pixelSize: 12; font.bold: true; elide: Text.ElideRight }
Text { id: bodyText; width: parent.width; visible: text.length > 0; text: card.notification.body || ""; textFormat: Text.PlainText; color: Theme.subtext1; font.pixelSize: 11; wrapMode: Text.Wrap; maximumLineCount: 2; elide: Text.ElideRight }
Image { id: image; width: parent.width; height: status === Image.Ready ? Math.min(110, implicitHeight) : 0; visible: status === Image.Ready; source: card.notification.image || ""; fillMode: Image.PreserveAspectFit }
Image { id: image; sourceSize: Qt.size(840, 220); width: parent.width; height: status === Image.Ready ? Math.min(110, implicitHeight) : 0; visible: status === Image.Ready; source: card.notification.image || ""; fillMode: Image.PreserveAspectFit }
Flow {
id: actions
width: parent.width

View file

@ -5,11 +5,12 @@ Item {
id: list
required property var service
property bool confirmClear: false
property real maximumHeight: 335
readonly property var rows: service.records
// CONTRACT: l'état vide conserve une vraie zone sans réserver la place du player.
// La liste seule défile au-delà de 307 px de contenu.
implicitHeight: rows.length ? 28 + Math.min(307, rows.length * 94) : 80
height: implicitHeight
implicitHeight: rows.length ? 28 + Math.min(307, Math.max(0, notificationView.contentHeight)) : 80
height: Math.min(implicitHeight, maximumHeight)
Timer { interval: 5000; running: list.confirmClear; onTriggered: list.confirmClear = false }
Text { id: title; text: "Notifications"; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
Text {
@ -35,9 +36,10 @@ Item {
color: Theme.secondaryForeground; font.pixelSize: 12
}
ListView {
id: notificationView
anchors.top: title.bottom; anchors.topMargin: 8
anchors.left: parent.left; anchors.right: parent.right
height: parent.height - title.height - 8
height: Math.max(0, parent.height - title.height - 8)
visible: list.rows.length > 0
clip: true; spacing: 7; boundsBehavior: Flickable.StopAtBounds
model: list.rows

View file

@ -47,75 +47,49 @@ et les opérations IPC explicites peuvent ensuite être supprimés s'ils ne sont
plus utilisés ; les raccourcis clavier et le reste de l'Overview continuent de
fonctionner.
## Provenance des miniatures
## Politique de captures STEP15A
`grim -o <sortie> -s 0.25 -t jpeg -q 72` prend un **snapshot réel de la sortie
visible**. Une capture a lieu à l'ouverture, puis lors du changement de
workspace après 350 ms et au plus toutes les 15 s pendant que l'Overview est
fermée. Les images sont remplacées atomiquement dans
`$XDG_RUNTIME_DIR/labfy-workspace-overview/`. Le cache est éphémère et privé.
Chaque JPEG possède un manifeste JSON avec l'identité du workspace, sa sortie,
une empreinte de la hiérarchie et de la géométrie Sway, l'horodatage et le
`mtime` du fichier. `state()` ne fournit l'image que si ces données correspondent
à l'arbre courant. Les titres et le focus ne font pas partie de l'empreinte.
Une modification de taille, disposition, flottement ou composition de fenêtres
masque immédiatement l'ancien snapshot complet. Les images individuelles
disponibles restent affichées dans les nouveaux rectangles des fenêtres.
Un workspace devenu vide efface son ancienne image et son manifeste. Les
workspaces inchangés conservent leur capture.
Les cartes utilisent immédiatement l'arbre IPC et les miniatures individuelles
valides. Seule l'ouverture de l'Overview ou un changement structurel pendant
qu'elle est visible déclenche `grim -T <foreign_toplevel_identifier>`.
La barre ne lance aucune capture périodique lorsqu'elle est fermée et la
fermeture annule sa file de travail. Les fenêtres sans image gardent la
reconstruction géométrique, leurs titres et les actions IPC existantes.
Les captures complètes de sortie ont été désactivées : elles ne permettent pas
d'exclure les pixels d'une application sensible. Leurs anciennes images sont
retirées du cache lors de la prochaine lecture de l'état.
La sortie qui porte l'Overview ne peut pas être capturée tant que sa surface
overlay est visible. Après un changement structurel, elle montre donc la
reconstruction jusqu'à la fermeture du panneau ; une capture différée de
300 ms est alors tentée. Le helper vérifie avant et après `grim` la présence
de l'overlay, l'identité et l'empreinte du workspace. Une sortie visible
différente de celle du panneau peut être recapturée pendant l'ouverture,
après 350 ms, si elle ne porte pas l'overlay. Ces captures multi-écrans n'ont
pas été vérifiées sur matériel. Une capture peut contenir une autre application
superposée à la sortie entre deux vérifications ; elle reste désignée comme
dernière capture.
La politique est lue dans
`$XDG_CONFIG_HOME/labfy-sway/overview-capture.json` (ou
`~/.config/labfy-sway/overview-capture.json`). L'absence de fichier autorise
les miniatures individuelles. Un fichier invalide désactive les miniatures.
Exemple pour les désactiver :
`grim -T <identifiant>` capture réellement une fenêtre de cette session
SwayFX 0.6, même lorsque son workspace est caché. Le backend lit
`foreign_toplevel_identifier` sur la fenêtre dans `get_tree`, puis associe
l'image obtenue au vrai `con_id`. Passer le `con_id` à `grim -T` échoue : ce
sont deux identités distinctes. Chaque image individuelle a un manifeste
contenant les deux identifiants, son horodatage et le `mtime` du JPEG. Elle
reste utilisable après un déplacement ou un redimensionnement, et disparaît
du cache à la fermeture de la fenêtre. L'ouverture de l'Overview rafraîchit
la sortie visible et les fenêtres sans capture ; un changement structurel
recapture les fenêtres des workspaces concernés, par lots de 16 au plus.
Cette capture par toplevel n'inclut pas l'overlay de l'Overview.
```json
{"enabled": false, "excludeApps": []}
```
La capture d'une fenêtre cachée peut refléter sa dernière frame si
l'application cesse de dessiner hors écran. `grim -T` ne fournit pas le rendu
composé d'un workspace caché. **Aucune carte n'est présentée comme live.**
Un workspace dont la capture complète est encore compatible montre ce
snapshot. Après un changement, la carte place les dernières images des
fenêtres encore présentes selon `get_tree`. Si une fenêtre n'a pas de capture
individuelle valide, son rectangle reste une reconstruction géométrique.
Les rectangles reprennent la géométrie réelle,
le focus, la position flottante et le titre de l'application. Les onglets et
les piles sont représentés par les rectangles et titres des feuilles ; leur
ornement Sway exact n'est pas reproduit.
Exemple d'exclusion par `app_id` Wayland ou classe XWayland :
Les sorties sont obtenues par `get_outputs`, les workspaces par
`get_workspaces`, les fenêtres par `get_tree`. Les changements `window`,
`workspace` et `output` déclenchent une relecture regroupée à 220 ms lorsque
la vue est ouverte. Chaque barre capture uniquement sa propre sortie. La
sélection du panneau passe par la sortie focalisée de Sway. Une seule sortie a
été vérifiée réellement ; les déplacements et captures entre plusieurs
sorties demandent un contrôle sur matériel multi-écrans.
```json
{"enabled": true, "excludeApps": ["org.keepassxc.KeePassXC", "*password*", "proton-pass"]}
```
La commande de déplacement valide le `con_id`, attend la réponse de Sway, puis
compare les arbres avant et après. Les numéros source et destination, ainsi que
les workspaces dont l'empreinte a changé, sont renvoyés à l'interface. Les
événements IPC provoquent aussi une relecture regroupée pour les changements
externes. La sélection clavier initiale suit le workspace réellement focalisé.
Le cache éphémère garde au plus une paire JPEG/manifeste par fenêtre encore
présente dans les workspaces 1 à 10, plus les captures complètes compatibles
des sorties ; les fichiers temporaires interrompus sont retirés après une
période de grâce. Aucun service supplémentaire n'est lancé.
Le motif suit `fnmatch` et ne regarde pas le titre. Il ne détecte pas toutes
les fenêtres contenant des secrets. Les règles sont relues pendant l'ouverture
et les images devenues exclues sont purgées. L'inventaire IPC des workspaces
et les fonctions focus/déplacement restent accessibles lorsque les images
sont désactivées.
Le cache privé dans `XDG_RUNTIME_DIR` refuse les chemins douteux et garde au
plus 256 fichiers reconnus et 32 Mio. Une image est limitée à 4 Mio. Un seul
lot de capture s'exécute à la fois ; chaque lot tente au plus quatre fenêtres,
avec deux secondes par fenêtre et huit secondes pour l'ensemble du helper.
Un verrouillage ferme l'Overview, annule les programmations et purge les JPEG.
Les manifestes antérieurs au verrouillage ne sont pas réutilisés au déverrouillage.
Les miniatures sont des images ponctuelles et peuvent montrer une dernière
frame ancienne d'une fenêtre cachée ; aucune carte n'est présentée comme live.
La validation multi-écran physique reste à effectuer.
## Interactions

View file

@ -19,8 +19,7 @@ PanelWindow {
property string message: ""
property bool refreshPending: false
property string actionName: ""
property var dirtyCaptures: []
property bool skipDismissCaptures: false
property bool captureEnabled: false
property bool closing: false
property var pendingWindowNumbers: []
property var activeWindowNumbers: []
@ -53,46 +52,29 @@ PanelWindow {
closing = true;
dropTarget = 0;
visible = false;
// WHY: grim inclurait cette surface overlay. Après sa disparition,
// seules les sorties visibles dont l'arbre a changé sont recapturées.
for (const card of skipDismissCaptures ? [] : dirtyCaptures) {
Quickshell.execDetached(["python3", "-B", backend, "capture", "--output", card.output,
"--number", String(card.number), "--workspace-id", String(card.workspaceId),
"--revision", card.revision,
"--settle-ms", "300"]);
}
cancelCaptures();
overviewDismissed();
}
function cancelCaptures() {
// CONTRACT: closing destroys the helper; Python SIGTERM cleanup reaps
// its grim child. No detached capture is scheduled after dismissal.
debounce.stop(); windowCaptureDebounce.stop(); policyRefresh.stop();
pendingWindowNumbers = []; activeWindowNumbers = []; windowCaptureCursor = 0;
windowCapture.running = false;
}
function applyState(data) {
if (closing) return;
const next = data.cards || [];
const changedWindows = [];
if (!cards.length) {
for (const current of next)
if (current.windows.length && (current.visible
|| current.windows.some(window => !window.preview)))
changedWindows.push(current.number);
}
if (cards.length) {
const wasEnabled = captureEnabled;
captureEnabled = data.captureEnabled === true;
for (const current of next) {
const old = cards.find(value => value.number === current.number);
if (old && current.windows.length && (old.revision !== current.revision
if (current.windows.some(window => !window.excluded)
&& (!old || !wasEnabled || old.revision !== current.revision
|| (!old.visible && current.visible))) changedWindows.push(current.number);
if (!old || old.revision === current.revision || !current.visible
|| !current.output) continue;
if (current.windows.length === 0) {
dirtyCaptures = dirtyCaptures.filter(value => value.output !== current.output);
continue;
}
// CONTRACT: comparer les arbres avant/après repère aussi bien
// la source que la destination d'un déplacement confirmé IPC.
dirtyCaptures = dirtyCaptures.filter(value => value.output !== current.output)
.concat([{ output: current.output, number: current.number,
workspaceId: current.workspaceId, revision: current.revision }]);
if (current.output !== screen.name && !captureDebounce.running)
captureDebounce.start();
}
}
if (changedWindows.length) queueWindowCaptures(changedWindows);
if (changedWindows.length && captureEnabled) queueWindowCaptures(changedWindows);
if (JSON.stringify(cards) !== JSON.stringify(next)) cards = next;
if (!initialSelectionSet || !cards.some(value => value.number === selectedWorkspace && value.exists)) {
const focused = cards.find(value => value.focused);
@ -103,12 +85,13 @@ PanelWindow {
if (selectedWindow === 0) moveTarget = 0;
}
function queueWindowCaptures(numbers) {
if (closing || !visible || !captureEnabled) return;
for (const number of numbers)
if (!pendingWindowNumbers.includes(number)) pendingWindowNumbers.push(number);
if (!windowCapture.running && !windowCaptureDebounce.running) windowCaptureDebounce.start();
}
function startWindowCapture() {
if (windowCapture.running) return;
if (closing || !visible || !captureEnabled || windowCapture.running) return;
if (windowCaptureCursor === 0) {
if (!pendingWindowNumbers.length) return;
activeWindowNumbers = pendingWindowNumbers;
@ -119,6 +102,7 @@ PanelWindow {
windowCapture.running = true;
}
function refresh() {
if (closing) return;
if (reader.running) { refreshPending = true; return; }
reader.running = true;
}
@ -177,25 +161,10 @@ PanelWindow {
Component.onCompleted: refresh()
Timer { id: debounce; interval: 220; repeat: false; onTriggered: overview.refresh() }
Timer {
id: captureDebounce
interval: 350; repeat: false
onTriggered: overview.captureNextOtherOutput()
}
Timer {
id: windowCaptureDebounce
interval: 250; repeat: false
onTriggered: overview.startWindowCapture()
}
function captureNextOtherOutput() {
if (otherCapture.running) return;
const card = dirtyCaptures.find(value => value.output !== screen.name);
if (!card) return;
otherCapture.command = ["python3", "-B", backend, "capture", "--output", card.output,
"--number", String(card.number), "--workspace-id", String(card.workspaceId),
"--revision", card.revision];
otherCapture.running = true;
}
// WHY: rate-limit batches and coalesce structural events; no timer exists
// once this LazyLoader-owned panel closes. Policy is rechecked while open.
Timer { id: windowCaptureDebounce; interval: 1000; repeat: false; onTriggered: overview.startWindowCapture() }
Timer { id: policyRefresh; interval: 2000; repeat: true; running: !overview.closing && overview.visible; onTriggered: overview.refresh() }
Timer { interval: 120; running: true; onTriggered: keyboardRoot.forceActiveFocus() }
I3IpcListener {
subscriptions: ["window"]
@ -229,26 +198,11 @@ PanelWindow {
}
}
}
Process {
id: otherCapture
stdout: StdioCollector { id: otherCaptureOutput; waitForEnd: true }
onExited: (code, status) => {
let result = {};
try { result = JSON.parse(otherCaptureOutput.text); } catch (_) {}
const args = otherCapture.command;
const output = args[args.indexOf("--output") + 1];
if (code === 0 && result.captured)
overview.dirtyCaptures = overview.dirtyCaptures.filter(value =>
value.output !== output || value.revision !== result.revision);
overview.refresh();
if (result.captured && overview.dirtyCaptures.some(value => value.output !== overview.screen.name))
captureDebounce.restart();
}
}
Process {
id: windowCapture
stdout: StdioCollector { id: windowCaptureOutput; waitForEnd: true }
onExited: (code, status) => {
if (overview.closing) return;
let result = {};
try { result = JSON.parse(windowCaptureOutput.text); } catch (_) {}
overview.refresh();
@ -532,6 +486,9 @@ PanelWindow {
visible: !workspaceCard.modelData.snapshot
&& windowVisual.modelData.preview !== ""
fillMode: Image.PreserveAspectCrop
// WHY: bound decoded pixels to the card, not source window size.
sourceSize: Qt.size(Math.min(800, Math.ceil(width * 2)),
Math.min(600, Math.ceil(height * 2)))
asynchronous: true
cache: false
}

View file

@ -5,6 +5,11 @@ import argparse
import hashlib
import json
import os
import fnmatch
import stat
import signal
import fcntl
import resource
from pathlib import Path
import re
import subprocess
@ -13,30 +18,165 @@ import tempfile
import time
# CONTRACT: local operational bounds, never a bound on the Sway inventory.
MAX_CACHE_FILES = 256
MAX_CACHE_BYTES = 32 * 1024 * 1024
MAX_IMAGE_BYTES = 4 * 1024 * 1024
MAX_BATCH = 4
CAPTURE_SECONDS = 2
_cache_fd = None
_cache_identity = None
_cache_public = None
_deadline = None
def lock_capture_guard_active():
# CONTRACT: capture is denied while the dedicated lock may render an auth
# surface. A crash leaves the guard active until authenticated recovery.
# WHY: malformed/inaccessible guards fail closed; absence means unlocked.
runtime = os.environ.get("XDG_RUNTIME_DIR")
if not runtime:
return False
return True
try:
return (Path(runtime) / "labfy-lock.capture-guard").read_text() == "active\n"
except OSError:
fd = os.open(Path(runtime) / "labfy-lock.capture-guard", os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(fd, "rb") as stream:
return stream.read(32) != b"released\n"
except FileNotFoundError:
return False
except OSError:
return True
def lock_revision():
try:
info = (Path(os.environ["XDG_RUNTIME_DIR"]) / "labfy-lock.capture-guard").lstat()
return [info.st_ino, info.st_mtime_ns, info.st_size]
except FileNotFoundError:
return []
def sway(kind):
remaining = 5 if _deadline is None else min(5, _deadline - time.monotonic())
if remaining <= 0:
raise RuntimeError("Budget capture expiré")
result = subprocess.run(["swaymsg", "-t", kind, "-r"], capture_output=True,
text=True, timeout=5, check=True)
text=True, timeout=remaining, check=True)
return json.loads(result.stdout)
def cache_dir():
# CONTRACT: cache éphémère et privé ; aucune capture de bureau sur disque durable.
base = Path(os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()))
path = base / "labfy-workspace-overview"
path.mkdir(mode=0o700, exist_ok=True)
return path
global _cache_fd, _cache_identity, _cache_public
# CONTRACT: no /tmp fallback. Pin the directory inode so replacements and
# symlinks cannot redirect reads, grim output, or cleanup outside this cache.
runtime = os.environ.get("XDG_RUNTIME_DIR")
if not runtime or not Path(runtime).is_absolute():
raise RuntimeError("Runtime privé indisponible")
base = Path(runtime)
info = base.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
raise RuntimeError("Runtime non privé")
parent = os.open(base, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
info = os.fstat(parent)
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
raise RuntimeError("Runtime non privé")
try:
os.mkdir("labfy-workspace-overview", 0o700, dir_fd=parent)
except FileExistsError:
pass
fd = os.open("labfy-workspace-overview", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
finally:
os.close(parent)
info = os.fstat(fd)
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
os.close(fd)
raise RuntimeError("Cache non privé")
identity = (info.st_dev, info.st_ino)
if _cache_identity == identity and _cache_fd is not None:
os.close(fd)
else:
if _cache_fd is not None:
os.close(_cache_fd)
_cache_fd, _cache_identity = fd, identity
_cache_public = base / "labfy-workspace-overview"
return Path(f"/proc/{os.getpid()}/fd/{_cache_fd}")
def image_url(path):
return (_cache_public / path.name).as_uri()
def private_file(path, limit):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1 or info.st_size > limit):
os.close(fd)
raise ValueError("Fichier de cache non privé")
return os.fdopen(fd, "rb"), info
def policy():
# CONTRACT: missing policy enables per-window captures. Invalid policy
# disables them. Rules match app_id or XWayland class, never window titles.
path = Path(os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")) / "labfy-sway/overview-capture.json"
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as stream:
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
raise ValueError()
raw = stream.read(16385)
if len(raw) > 16384:
raise ValueError()
data = json.loads(raw)
rules = data.get("excludeApps", [])
if (not isinstance(data.get("enabled", True), bool) or not isinstance(rules, list)
or len(rules) > 64 or any(not isinstance(r, str) or len(r) > 200 for r in rules)):
raise ValueError()
return {"enabled": data.get("enabled", True), "excludeApps": rules}
except FileNotFoundError:
return {"enabled": True, "excludeApps": []}
except (OSError, ValueError, TypeError, AttributeError):
return {"enabled": False, "excludeApps": []}
def excluded(window, rules):
return not rules["enabled"] or any(fnmatch.fnmatchcase(window.get("app", ""), rule) for rule in rules["excludeApps"])
def purge():
# INVARIANT: unlink only cache-owned names, without following symlinks.
for path in cache_dir().iterdir():
if re.fullmatch(r"(?:window-[0-9]+|[A-Za-z0-9_.-]+-workspace-[0-9]+|tmp[A-Za-z0-9_-]+)\.(?:jpg|json)", path.name):
if not stat.S_ISDIR(path.lstat().st_mode):
path.unlink(missing_ok=True)
return {"ok": True}
def enforce_quota():
entries = []
for path in cache_dir().iterdir():
if not re.fullmatch(r"(?:window-[0-9]+|[A-Za-z0-9_.-]+-workspace-[0-9]+|tmp[A-Za-z0-9_-]+)\.(?:jpg|json)", path.name):
continue
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1:
if not stat.S_ISDIR(info.st_mode):
path.unlink(missing_ok=True)
continue
entries.append((info.st_mtime_ns, path, info.st_size))
total = sum(item[2] for item in entries)
count = len(entries)
for _, path, size in sorted(entries):
if count <= MAX_CACHE_FILES and total <= MAX_CACHE_BYTES:
break
path.unlink(missing_ok=True)
count -= 1
total -= size
def overview_open():
return any(surface.get("namespace") == "labfy-workspace-overview"
for output in sway("get_outputs") if output.get("active")
for surface in output.get("layer_shell_surfaces") or [])
def snapshot_path(output, number):
@ -64,16 +204,20 @@ def valid_window_preview(con_id, foreign_id):
path = window_preview_path(con_id)
metadata_path = window_preview_metadata_path(con_id)
try:
image_stat = path.stat()
if image_stat.st_size == 0 or metadata_path.stat().st_size > 2048:
image, image_stat = private_file(path, MAX_IMAGE_BYTES)
image.close()
if image_stat.st_size == 0:
return None
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
stream, _ = private_file(metadata_path, 2048)
with stream:
metadata = json.load(stream)
if (metadata.get("version") != 1 or metadata.get("conId") != con_id
or metadata.get("foreignId") != foreign_id
or metadata.get("imageMtimeNs") != image_stat.st_mtime_ns
or metadata.get("lockRevision") != lock_revision()
or not isinstance(metadata.get("capturedNs"), int)):
return None
return path.as_uri(), str(metadata["capturedNs"])
return image_url(path), str(metadata["capturedNs"])
except (OSError, ValueError, AttributeError, TypeError, json.JSONDecodeError):
return None
@ -90,7 +234,7 @@ def prune_window_previews(live_ids):
cutoff = time.time() - 600
for path in cache_dir().glob("tmp*.*"):
try:
if path.suffix in (".jpg", ".json") and path.stat().st_mtime < cutoff:
if path.suffix in (".jpg", ".json") and path.lstat().st_mtime < cutoff:
path.unlink(missing_ok=True)
except OSError:
pass
@ -136,18 +280,22 @@ def valid_snapshot(output, number, workspace_id, revision):
path = snapshot_path(output, number)
metadata_path = snapshot_metadata_path(output, number)
try:
image_stat = path.stat()
if image_stat.st_size == 0 or metadata_path.stat().st_size > 2048:
image, image_stat = private_file(path, MAX_IMAGE_BYTES)
image.close()
if image_stat.st_size == 0:
return None
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
stream, _ = private_file(metadata_path, 2048)
with stream:
metadata = json.load(stream)
if (metadata.get("version") != 1 or metadata.get("output") != output
or metadata.get("number") != number
or metadata.get("workspaceId") != workspace_id
or metadata.get("revision") != revision
or metadata.get("imageMtimeNs") != image_stat.st_mtime_ns
or metadata.get("lockRevision") != lock_revision()
or not isinstance(metadata.get("capturedNs"), int)):
return None
return path.as_uri(), str(metadata["capturedNs"])
return image_url(path), str(metadata["capturedNs"])
except (OSError, ValueError, AttributeError, TypeError, json.JSONDecodeError):
return None
@ -178,6 +326,16 @@ def windows_in(node):
def state():
rules = policy()
cache_ok = True
try:
if lock_capture_guard_active() or not rules["enabled"]:
purge()
enforce_quota()
except (OSError, ValueError, RuntimeError):
# CONTRACT: an unsafe/unavailable cache disables images, never IPC
# navigation. Do not repair or traverse a suspicious directory.
cache_ok = False
tree = sway("get_tree")
workspaces = sway("get_workspaces")
outputs = sway("get_outputs")
@ -202,18 +360,28 @@ def state():
workspace = by_number.get(number)
node = workspace_nodes.get(workspace["id"]) if workspace else None
output = workspace.get("output") if workspace else ""
if cache_ok:
forget_other_outputs(number, output)
windows = windows_in(node) if node else []
for window in windows:
preview = valid_window_preview(window["id"], window["foreignId"])
window["excluded"] = excluded(window, rules)
if cache_ok and window["excluded"]:
window_preview_path(window["id"]).unlink(missing_ok=True)
window_preview_metadata_path(window["id"]).unlink(missing_ok=True)
preview = (None if not cache_ok or window["excluded"] or lock_capture_guard_active()
else valid_window_preview(window["id"], window["foreignId"]))
window["preview"] = preview[0] if preview else ""
window["previewTime"] = preview[1] if preview else ""
revision = (structure_revision(node, workspace, output_info.get(output, {}).get("rect"))
if node and workspace else "")
snapshot = valid_snapshot(output, number, workspace["id"], revision) if revision else None
# WHY: composed output pixels cannot safely exclude an application.
# Retire legacy output snapshots even when their geometry is compatible.
if cache_ok and output:
forget_snapshot(output, number)
snapshot = None
if not windows:
snapshot = None
if output:
if cache_ok and output:
forget_snapshot(output, number)
cards.append({"number": number, "exists": bool(workspace),
"workspaceId": workspace["id"] if workspace else 0,
@ -228,84 +396,71 @@ def state():
# INVARIANT: l'URL QML change à chaque remplacement, même
# si deux captures surviennent pendant la même seconde.
"snapshotTime": snapshot[1] if snapshot else ""})
if cache_ok:
prune_window_previews({window["id"] for card in cards for window in card["windows"]})
return {"cards": cards, "outputs": [item["name"] for item in outputs if item.get("active")],
"focusedOutput": next((item["name"] for item in outputs if item.get("focused")), ""),
"overlayOutputs": overlay_outputs}
"overlayOutputs": overlay_outputs, "captureEnabled": cache_ok and rules["enabled"] and not lock_capture_guard_active()}
def capture(output, expected_number=None, expected_workspace_id=None,
expected_revision=None, settle_ms=0):
if lock_capture_guard_active():
# CONTRACT: retained CLI returns a fallback; whole-output capture is disabled
# because it cannot honor per-application exclusions, even before opening.
return {"captured": False}
if settle_ms < 0 or settle_ms > 1000:
raise ValueError("Délai de capture invalide")
if settle_ms:
time.sleep(settle_ms / 1000)
before = state()
current = next((card for card in before["cards"] if card["visible"] and card["output"] == output), None)
if (output in before["overlayOutputs"] or not current or not current["windows"]
or (expected_number is not None and current["number"] != expected_number)
or (expected_workspace_id is not None and current["workspaceId"] != expected_workspace_id)
or (expected_revision is not None and current["revision"] != expected_revision)):
return {"captured": False}
path = snapshot_path(output, current["number"])
metadata_path = snapshot_metadata_path(output, current["number"])
# WHY: grim capture uniquement une sortie effectivement affichée. Écriture
# atomique pour qu'un Image QML ne voie jamais un JPEG partiel.
with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".jpg", delete=False) as temp:
temporary = Path(temp.name)
try:
if lock_capture_guard_active():
return {"captured": False}
subprocess.run(["grim", "-o", output, "-s", "0.25", "-t", "jpeg", "-q", "72",
str(temporary)], check=True, timeout=8, capture_output=True)
if not temporary.is_file() or temporary.stat().st_size == 0:
raise RuntimeError("Capture vide")
after = state()
updated = next((card for card in after["cards"] if card["number"] == current["number"]), None)
# INVARIANT: un changement pendant grim ou la présence de l'Overview
# interdit de publier une image associée au mauvais arbre.
if (lock_capture_guard_active() or output in after["overlayOutputs"] or not updated or not updated["visible"]
or updated["output"] != output or updated["workspaceId"] != current["workspaceId"]
or updated["revision"] != current["revision"]):
return {"captured": False}
temporary.replace(path)
metadata = {"version": 1, "output": output, "number": current["number"],
"workspaceId": current["workspaceId"], "revision": current["revision"],
"capturedNs": time.time_ns(), "imageMtimeNs": path.stat().st_mtime_ns}
# WHY: le manifeste est publié après le JPEG ; un lecteur concurrent
# n'associe jamais l'ancienne révision aux nouveaux pixels.
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
suffix=".json", delete=False) as temp_meta:
json.dump(metadata, temp_meta, separators=(",", ":"))
temporary_meta = Path(temp_meta.name)
try:
temporary_meta.replace(metadata_path)
finally:
temporary_meta.unlink(missing_ok=True)
finally:
temporary.unlink(missing_ok=True)
return {"captured": True, "number": current["number"], "path": path.as_uri(),
"revision": current["revision"]}
def grim_limits():
# WHY: grim uses file-backed Wayland buffers before encoding the JPEG.
# RLIMIT_FSIZE also limits those buffers and can kill a valid capture with
# SIGXFSZ even when its final JPEG is small. The timeout and address-space
# limit bound the child; the encoded file is checked before publication.
resource.setrlimit(resource.RLIMIT_AS, (512 * 1024 * 1024, 512 * 1024 * 1024))
def capture_windows(numbers, cursor=0):
if lock_capture_guard_active():
global _deadline
_deadline = time.monotonic() + 8
# CONTRACT: serialize all output panels using a private advisory lock.
# A busy batch falls back without spawning another grim process.
fd = os.open(cache_dir() / "capture.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1):
raise ValueError("Verrou de cache non privé")
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return {"captured": [], "nextCursor": 0}
return _capture_windows(numbers, cursor)
finally:
_deadline = None
os.close(fd)
def _capture_windows(numbers, cursor=0):
if lock_capture_guard_active() or not policy()["enabled"]:
purge()
return {"captured": [], "nextCursor": 0}
if not overview_open():
return {"captured": [], "nextCursor": 0}
if cursor < 0:
raise ValueError("Curseur de capture invalide")
generation = lock_revision()
before = state()
candidates = [window for card in before["cards"] if card["number"] in numbers
for window in card["windows"]
if re.fullmatch(r"[0-9a-f]{32}", window["foreignId"])]
# CONTRACT: une invocation capture au plus 16 fenêtres ; l'interface peut
# reprendre au curseur suivant sans créer de service ou de pool permanent.
batch = candidates[cursor:cursor + 16]
if not excluded(window, policy()) and re.fullmatch(r"[0-9a-f]{32}", window["foreignId"])]
# CONTRACT: at most four windows, two seconds each and six seconds per batch.
# The panel retries only on a new open/structural event, never while closed.
batch = candidates[cursor:cursor + MAX_BATCH]
captures = []
deadline = min(time.monotonic() + 6, _deadline or float("inf"))
try:
for window in batch:
if lock_capture_guard_active():
if (lock_capture_guard_active() or generation != lock_revision() or not overview_open()
or time.monotonic() >= deadline or excluded(window, policy())):
break
with tempfile.NamedTemporaryFile(dir=cache_dir(), suffix=".jpg", delete=False) as temp:
temporary = Path(temp.name)
@ -313,8 +468,10 @@ def capture_windows(numbers, cursor=0):
try:
result = subprocess.run(["grim", "-T", window["foreignId"], "-s", "0.25",
"-t", "jpeg", "-q", "72", str(temporary)],
capture_output=True, timeout=5, check=False)
if result.returncode != 0 or temporary.stat().st_size == 0:
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=min(CAPTURE_SECONDS, max(.01, deadline - time.monotonic())),
preexec_fn=grim_limits, check=False)
if result.returncode != 0 or not 0 < temporary.stat().st_size <= MAX_IMAGE_BYTES:
temporary.unlink(missing_ok=True)
except (OSError, subprocess.TimeoutExpired):
temporary.unlink(missing_ok=True)
@ -322,15 +479,18 @@ def capture_windows(numbers, cursor=0):
current = {window["id"]: window for card in after["cards"] for window in card["windows"]}
published = []
for window, temporary in captures:
if lock_capture_guard_active():
if lock_capture_guard_active() or generation != lock_revision() or not overview_open():
break
if excluded(current.get(window["id"], {}), policy()):
continue
if not temporary.is_file() or current.get(window["id"], {}).get("foreignId") != window["foreignId"]:
continue
path = window_preview_path(window["id"])
metadata_path = window_preview_metadata_path(window["id"])
temporary.replace(path)
metadata = {"version": 1, "conId": window["id"], "foreignId": window["foreignId"],
"capturedNs": time.time_ns(), "imageMtimeNs": path.stat().st_mtime_ns}
"capturedNs": time.time_ns(), "lockRevision": generation,
"imageMtimeNs": path.stat().st_mtime_ns}
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=cache_dir(),
suffix=".json", delete=False) as temp_meta:
json.dump(metadata, temp_meta, separators=(",", ":"))
@ -344,6 +504,7 @@ def capture_windows(numbers, cursor=0):
for _, temporary in captures:
temporary.unlink(missing_ok=True)
next_cursor = cursor + len(batch) if cursor + len(batch) < len(candidates) else 0
enforce_quota()
return {"captured": published, "nextCursor": next_cursor}
@ -379,7 +540,7 @@ def command(action, number=None, con_id=None):
def main():
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=("state", "capture", "capture-windows", "move", "focus", "workspace"))
parser.add_argument("action", choices=("state", "capture", "capture-windows", "move", "focus", "workspace", "purge"))
parser.add_argument("--output")
parser.add_argument("--number", type=int)
parser.add_argument("--con-id", type=int)
@ -389,7 +550,9 @@ def main():
parser.add_argument("--numbers")
parser.add_argument("--cursor", type=int, default=0)
args = parser.parse_args()
if args.action == "state":
if args.action == "purge":
result = purge()
elif args.action == "state":
result = state()
elif args.action == "capture":
if not args.output or args.output not in state()["outputs"]:
@ -407,8 +570,13 @@ def main():
if __name__ == "__main__":
# CONTRACT: Quickshell cancels this helper on close/reload. Raising through
# subprocess.run kills and reaps grim instead of leaving an orphan capture.
def cancelled(signum, frame):
raise InterruptedError("Capture annulée")
signal.signal(signal.SIGTERM, cancelled)
try:
main()
except (ValueError, RuntimeError, subprocess.SubprocessError, OSError, json.JSONDecodeError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False))
print(json.dumps({"error": "Opération Overview indisponible"}))
sys.exit(1)

View file

@ -10,7 +10,7 @@ import signal
import subprocess
import time
from .errors import require
from .ipc import until, canonical, location
from .ipc import until, canonical, location, surface_identity
from .layout import compile_plan, trees
from .model import normalized
@ -82,7 +82,7 @@ class Anchors:
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,
require(live and live.get("pid") == target.get("pid") and surface_identity(live) == surface_identity(target) and surface_identity(live) 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")

View file

@ -1,11 +1,19 @@
"""Politique au niveau Application ; argv viennent du catalogue de confiance.
"""Application policy from explicit overrides and installed desktop entries.
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.
Snapshots select a catalogue ID, never an executable, argument or terminal
command. Desktop discovery uses GIO's XDG precedence; launch uses that same
installed file, verified again before one GIO launch request.
"""
from dataclasses import dataclass
from dataclasses import dataclass, replace
import hashlib
import os
from pathlib import Path
import shutil
import stat
import subprocess
from .errors import require, Failure
from .ipc import X11_CLASS_PREFIX, surface_identity
from .schema import IDENT
@dataclass(frozen=True)
@ -17,24 +25,166 @@ class Spec:
argv: tuple
provider: str | None = None
managed_by: str | None = None
desktop_signature: tuple | 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",)),
Spec("chatgpt", "chatgpt.desktop", (X11_CLASS_PREFIX + "Chatgpt",), "single-window", ("/usr/bin/chatgpt",)),
Spec("proton-pass", "proton-pass.desktop", ("proton-pass",), "single-window", ("/usr/bin/proton-pass",)),
Spec("whatsapp-web", "firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded.desktop",
("firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded",), "managed-autostart", (), managed_by="sway-autostart"),
)
# Shared interpreters/launchers provide no application identity.
WRAPPERS = frozenset(("env", "sh", "bash", "dash", "zsh", "fish", "flatpak",
"snap", "java", "javaw", "python", "python2", "python3",
"perl", "ruby", "node", "nodejs", "electron", "AppRun",
"gio", "gtk-launch", "xdg-open", "uwsm", "systemd-run"))
def _gio():
try:
import gi
gi.require_version("Gio", "2.0")
gi.require_version("GioUnix", "2.0")
from gi.repository import Gio, GioUnix, GLib
return Gio, GioUnix, GLib
except (ImportError, ValueError):
raise Failure("CATALOG_DISCOVERY_UNAVAILABLE") from None
def _desktop_signature(filename):
"""Pin an installed regular file, allowing Flatpak export symlinks."""
path = Path(filename)
require(path.is_absolute(), "APPLICATION_LAUNCH_FAILED")
resolved = path.resolve(strict=True)
# NONBLOCK + fstat before reading prevents a replaced FIFO/device from
# blocking catalogue discovery; NOFOLLOW closes the final symlink race.
descriptor = os.open(resolved, os.O_RDONLY | os.O_NONBLOCK | os.O_CLOEXEC | os.O_NOFOLLOW)
with os.fdopen(descriptor, "rb") as stream:
info = os.fstat(stream.fileno())
require(stat.S_ISREG(info.st_mode) and info.st_uid in (0, os.getuid())
and not info.st_mode & 0o022 and info.st_size <= 262144,
"APPLICATION_LAUNCH_FAILED")
raw = stream.read(262145)
final = os.fstat(stream.fileno())
require(len(raw) <= 262144 and (info.st_size, info.st_mtime_ns) ==
(final.st_size, final.st_mtime_ns), "APPLICATION_LAUNCH_FAILED")
return (str(resolved), info.st_dev, info.st_ino, info.st_mtime_ns,
info.st_size, hashlib.sha256(raw).hexdigest())
def _identity(value):
return (isinstance(value, str) and 0 < len(value) <= 240
and not value.startswith(X11_CLASS_PREFIX)
and not any(ord(c) < 32 for c in value))
def installed_specs(apps=None, reserved=DEFAULT):
"""Discover exact identities, never infer a match from title or PID args.
GIO resolves local/system/Flatpak precedence and Hidden overrides. Menu
visibility (NoDisplay/OnlyShowIn) does not determine window support. Explicit
policy remains authoritative. Declared identities outrank executable-name
fallbacks; equal-rank collisions are removed from every candidate.
"""
Gio, GioUnix, GLib = _gio()
apps = tuple(Gio.AppInfo.get_all() if apps is None else apps)
require(len(apps) <= 4096, "CATALOG_LIMIT")
reserved_entries = {s.desktop_entry for s in reserved}
reserved_names = {s.desktop_entry[:-8] for s in reserved}
reserved_names.update(identity.removeprefix(X11_CLASS_PREFIX)
for s in reserved for identity in s.app_ids)
reserved_ids = reserved_names | {X11_CLASS_PREFIX + value for value in reserved_names}
candidates, aliases, excluded = {}, {}, {}
for app in sorted(apps, key=lambda a: a.get_id() or ""):
if not isinstance(app, GioUnix.DesktopAppInfo): continue
identifier = app.get_id()
if identifier in reserved_entries:
continue
if not isinstance(identifier, str) or not IDENT.fullmatch(identifier) or not identifier.endswith(".desktop"):
continue
reason = None
if app.get_is_hidden(): reason = "hidden"
elif app.get_string("Type") != "Application": reason = "not-application"
elif app.get_boolean("Terminal"): reason = "terminal-content"
elif identifier.startswith("firefox.webapp-"): reason = "provider-required"
filename = app.get_filename()
if not reason and not filename: reason = "no-desktop-file"
executable = None
if not reason:
try:
commandline = app.get_commandline()
if commandline:
parsed, argv = GLib.shell_parse_argv(commandline)
if parsed and argv:
executable = argv[0]
if not app.get_boolean("DBusActivatable") and not (executable and shutil.which(executable)):
reason = "executable-unavailable"
signature = _desktop_signature(filename)
except (OSError, ValueError, GLib.Error, Failure):
reason = "invalid-desktop-file"
if reason:
excluded[identifier] = reason
continue
# desktop_entry itself is a stable, schema-valid ID in its own namespace.
spec = Spec(identifier, identifier, (), "application-self-restore",
("/usr/bin/gio", "launch", filename), desktop_signature=signature)
candidates[identifier] = spec
values = {}
def add(value, rank):
if _identity(value):
values[value] = max(rank, values.get(value, 0))
stem = identifier[:-8]
add(stem, 3)
# Legacy X11 fallback remains exact and case-sensitive.
if _identity(stem): values[X11_CLASS_PREFIX + stem] = 1
wm_class = app.get_startup_wm_class()
add(wm_class, 2)
if _identity(wm_class): values[X11_CLASS_PREFIX + wm_class] = 2
basename = Path(executable).name if executable else ""
if basename and basename not in WRAPPERS:
add(basename, 1)
if _identity(basename): values[X11_CLASS_PREFIX + basename] = max(1, values.get(X11_CLASS_PREFIX + basename, 0))
for identity, rank in values.items():
if identity not in reserved_ids:
aliases.setdefault(identity, []).append((rank, identifier))
accepted = {identifier: [] for identifier in candidates}
for identity, owners in aliases.items():
rank = max(r for r, _ in owners)
best = {identifier for r, identifier in owners if r == rank}
if len(best) == 1:
accepted[next(iter(best))].append(identity)
result = []
for identifier, spec in candidates.items():
if accepted[identifier]:
result.append(replace(spec, app_ids=tuple(sorted(accepted[identifier]))))
else:
excluded[identifier] = "ambiguous-identity"
return tuple(result), excluded
class Catalog:
def __init__(self, specs=DEFAULT):
def __init__(self, specs=None, *, desktop_apps=None):
# Explicit test/plugin catalogues are hermetic: no installed discovery.
self.excluded = {}
if specs is None:
discovered, self.excluded = installed_specs(desktop_apps)
specs = DEFAULT + discovered
else:
require(desktop_apps is None, "CATALOG_ARGUMENTS")
specs = tuple(specs)
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]
identity = surface_identity(node)
found = [s for s in self.specs.values() if identity in s.app_ids]
require(len(found) == 1, "APPLICATION_UNSUPPORTED")
return found[0]
@ -57,8 +207,9 @@ def launch_count(app, observed):
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.
if strategy in ("browser-self-restore", "application-self-restore"):
# Never request N launches for a generic single-instance application.
# Its full saved window set must appear before any layout mutation.
return 1 if observed == 0 else 0
if strategy in ("terminal", "multi-instance"): return n - observed
raise Failure("APPLICATION_UNSUPPORTED")
@ -66,10 +217,16 @@ def launch_count(app, observed):
def launch(spec, count, env=None):
require(type(count) is int and 0 <= count <= 256, "LAUNCH_BOUNDS")
if spec.strategy == "application-self-restore":
require(count <= 1 and spec.desktop_signature is not None
and spec.argv[:2] == ("/usr/bin/gio", "launch") and len(spec.argv) == 3,
"APPLICATION_LAUNCH_FAILED")
if count:
require(_desktop_signature(spec.argv[2]) == spec.desktop_signature,
"APPLICATION_LAUNCH_FAILED")
children = []
for _ in range(count):
# Les applications ne sont pas des ressources jetables : jamais tuées
# automatiquement lors d'un échec de restauration.
# Applications survive restoration errors; only owned helpers are killed.
children.append(subprocess.Popen(list(spec.argv), stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, start_new_session=True))
return children

View file

@ -11,7 +11,7 @@ from pathlib import Path
from .errors import Failure, Deferred, require
from .schema import validate, PROVIDER
from .storage import lock, private_dir
from .ipc import location, workspace, walk
from .ipc import location, workspace, walk, surface_identity
from .applications import launch_count, launch
from .anchors import Anchors
from .guard import Guard
@ -28,6 +28,34 @@ def process_owner(pid):
except OSError: raise Failure("TARGET_IDENTITY_CHANGED") from None
def staging_order(tree, nodes):
"""Keep a disappearing focused branch until its workspace can become empty.
Sway's IPC focus lists expose sibling order, not the global cross-depth
inactive stack. Removing a focused leaf can therefore choose an internal
ancestor or a surviving view with the same public tree. Avoid that unknown
transition; never infer a replacement focus or loosen Recovery's checks.
"""
nodes = list(nodes)
selected = next((n for n in walk(tree) if n.get("focused")), None)
if not selected or selected.get("type") == "workspace":
return nodes
descendants = {n["id"] for n in walk(selected) if n.get("app_id") or n.get("window")}
staged = {n["id"] for n in nodes}
if descendants and descendants.issubset(staged):
where = location(tree, selected["id"])
source = workspace(tree, where[0]) if where else None
require(source is not None, "RESTORE_FOCUS_STAGING_UNPROVEN")
remaining = {n["id"] for n in walk(source) if n.get("app_id") or n.get("window")} - staged
# This includes foreign clients and shown scratchpads outside the focused
# branch. Hidden scratchpads on __i3_scratch do not occupy this workspace.
require(not remaining, "RESTORE_FOCUS_STAGING_UNPROVEN")
# If a non-staged descendant remains, the focused internal node survives.
# Moving its selected leaves last still preserves that exact focus identity.
return ([n for n in nodes if n["id"] not in descendants] +
[n for n in nodes if n["id"] in descendants])
def preflight(data, sway, catalog, provider):
validate(data)
active = {o["name"] for o in sway.outputs() if o.get("active")}
@ -38,7 +66,7 @@ def preflight(data, sway, catalog, provider):
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]
members = [n for n in nodes if surface_identity(n) in specs[a["application_id"]].app_ids]
launch_count(a, len(members))
admitted.update(n["id"] for n in members)
tree = sway.tree()
@ -46,6 +74,9 @@ def preflight(data, sway, catalog, provider):
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")
# Refuse an unprovable focused-source transition before durable journals.
staging_order(tree, [n for n in walk(tree) if (n.get("app_id") or n.get("window")) and
n["id"] in admitted and scratchpad_state(n, location(tree, n["id"])[0]) == "none"])
return specs
@ -69,9 +100,12 @@ class Executor:
require(recovery_status(self.runtime)["status"] in ("idle", "complete", "recovered"), "RECOVERY_REQUIRED")
receipt = resume_pending(self.sway, self.runtime, self.process_identity)
require(not receipt or receipt["status"] != "RECOVERY_REQUIRED", "RECOVERY_REQUIRED")
try: proof, prepared = self._prewarm(data, timeout)
try:
proof, prepared = self._prewarm(data, timeout)
self._admit_providers(data, proof, prepared, min(timeout, 6))
except Failure as exc:
if exc.code in ("APPLICATION_LAUNCH_FAILED", "APPLICATION_WINDOWS_INCOMPLETE"):
if exc.code in ("APPLICATION_LAUNCH_FAILED", "APPLICATION_WINDOWS_INCOMPLETE", "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE",
"RESTORE_FOCUS_STAGING_UNPROVEN"):
raise Deferred(exc.code) from None
raise
return self._apply(data, timeout, lock_fd, proof, prepared)
@ -80,6 +114,30 @@ class Executor:
record_deferred(self.runtime, self.sway.session, exc.code)
raise
def _admit_providers(self, data, proof, prepared, timeout):
"""Prove the complete Firefox identity set before any layout mutation.
An absent publisher is deferred. A missing saved UUID remains an exact
identity failure. External waits cannot silently replace the admitted
window batch or its focus proof; later transaction checks still apply.
"""
applications = [a for a in data["applications"] if a["identity_provider"] == PROVIDER]
if not applications: return
specs = preflight(data, self.sway, self.catalog, self.provider)
for app in applications:
members = [n for n in self.sway.windows()
if surface_identity(n) in specs[app["application_id"]].app_ids]
expected = {s["identity_evidence"]["id"] for s in data["window_slots"]
if s["application_id"] == app["application_id"] and s["identity_requirement"] == "exact"}
mapping = (self.provider.resolve(expected, members, timeout=timeout) if expected
else self.provider.capture(members, timeout=timeout))
require(expected.issubset(mapping), "EXACT_WINDOW_IDENTITY_MISSING")
require(set(mapping.values()) == {n["id"] for n in members}, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE")
require(self.sway.session == proof[0], "SWAY_SESSION_CHANGED")
self._check_prepared(prepared, self._observe_prepared())
preflight(data, self.sway, self.catalog, self.provider)
require(focus_proof(self.sway) == proof, "RESTORE_FOCUS_UNSTABLE")
def _observe_prepared(self, exclude=lambda node: False):
"""Identités runtime volatiles ; aucune attribution causale du focus.
@ -217,10 +275,14 @@ class Executor:
require(n["id"] in targets, "TARGET_IDENTITY_CHANGED")
recovery.admit(n)
self.sway.command(f'[con_id={n["id"]}] move container to workspace ' + json.dumps(stage))
for n in targets.values():
# Hidden scratchpads stay detached throughout staging; neither
# workspace moves nor floating-disable may expose them.
if prepared[n["id"]]["scratchpad"] == "none": stage_node(n)
# Check the whole captured batch before interpreting focus admission:
# a newly mapped client is a membership change, not a staging deferral.
check_batch()
# Recheck after Guard, using the same exact admitted batch.
# Both hidden and shown scratchpads keep their existing treatment.
staged = [n for n in targets.values() if prepared[n["id"]]["scratchpad"] == "none"]
for n in staging_order(self.sway.tree(), staged):
stage_node(n)
def progress(phase, count):
values = {"phase": phase}
if phase == "anchor": values["anchors_created"] = count
@ -235,7 +297,7 @@ class Executor:
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())
count = sum(surface_identity(n) in spec.app_ids for n in self.sway.windows())
missing = launch_count(app, count)
require(missing == 0 and count == app["expected_windows"], "APPLICATION_WINDOWS_INCOMPLETE")
# PHASE 0 possède les lancements ; aucun nouveau mapping attendu
@ -245,7 +307,7 @@ class Executor:
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"]}
groups = {a["application_id"]: [n for n in nodes if surface_identity(n) 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")
@ -272,7 +334,7 @@ class Executor:
# lot phase0, même si un remplacement conserve la cardinalité.
check_batch()
require(set(captured) == set(targets), "APPLICATION_MEMBERSHIP_CHANGED")
live_ids = {n["id"] for n in self.sway.windows() if n.get("app_id") in all_ids}
live_ids = {n["id"] for n in self.sway.windows() if surface_identity(n) in all_ids}
require(live_ids == set(captured), "APPLICATION_MEMBERSHIP_CHANGED")
# Un intrus hors catalogue n'est pas rendu admissible par son absence
# des groupes applicatifs. Les seules cartes temporaires autorisées
@ -437,7 +499,7 @@ class Executor:
nodes = self.sway.windows()
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]
members = [n for n in nodes if surface_identity(n) 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":
@ -462,12 +524,12 @@ class Executor:
require(all(output_for.get(w["workspace"]) == w["output"] for w in data["workspaces"]), "FINAL_OUTPUT_MISMATCH")
nodes = [n for n in walk(tree) if n.get("app_id") or n.get("window")]
for a in data["applications"]:
observed = sum(n.get("app_id") in specs[a["application_id"]].app_ids for n in nodes)
observed = sum(surface_identity(n) 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")
require(n and surface_identity(n) in specs[s["application_id"]].app_ids, "TARGET_IDENTITY_CHANGED")
scratch = s["state"]["scratchpad"]
require(scratchpad_state(n, location(tree, n["id"])[0]) == ("none" if scratch is False else scratch), "FINAL_SCRATCHPAD_MISMATCH")
if s["state"]["floating"]:

View file

@ -9,6 +9,31 @@ import time
from .errors import Failure, require
X11_CLASS_PREFIX = "x11-class:"
def surface_identity(node):
"""Pure application key; the compositor's raw tree remains untouched.
Wayland app_ids cannot impersonate the reserved X11 namespace. X11 keys
require a real X window ID plus an exact WM_CLASS; missing identities stay
unknown and cannot match a catalogue entry. This key is runtime evidence,
never a command or an identity persisted in a session snapshot.
"""
app_id = node.get("app_id")
if isinstance(app_id, str) and app_id:
require(not app_id.startswith(X11_CLASS_PREFIX), "SURFACE_IDENTITY_RESERVED")
return app_id
if app_id is not None or type(node.get("window")) is not int or node["window"] <= 0:
return None
properties = node.get("window_properties")
wm_class = properties.get("class") if isinstance(properties, dict) else None
if (not isinstance(wm_class, str) or not 0 < len(wm_class) <= 256 - len(X11_CLASS_PREFIX)
or any(ord(c) < 32 for c in wm_class)):
return None
return X11_CLASS_PREFIX + wm_class
def walk(node):
yield node
for c in node.get("nodes", []) + node.get("floating_nodes", []): yield from walk(c)

View file

@ -1,4 +1,4 @@
"""Host production candidat : map-window et attestation bootstrap ; Sway GET_TREE/GET_VERSION en lecture seule.
"""Host production candidat : classification, map-window et bootstrap ; IPC Sway en lecture seule.
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.
@ -17,15 +17,22 @@ from .schema import UUID, PROVIDER
from .storage import loads, atomic, private_dir, state_path, read
from .ipc import Sway
from .firefox_identity import FirefoxProvider, process_start, validate_record
from .applications import DEFAULT
EXTENSION_ID = "session-v2@labfy.org"
APP_IDS = {"firefox", "org.mozilla.firefox"}
APP_IDS = {identity for spec in DEFAULT if spec.application_id == "firefox" for identity in spec.app_ids}
WEBAPP_IDS = {identity for spec in DEFAULT
if spec.application_id == "whatsapp-web" and spec.strategy == "managed-autostart"
and spec.managed_by == "sway-autostart" for identity in spec.app_ids}
def validate(message):
require(type(message) is dict, "MESSAGE_SCHEMA")
if message == {"op": "bootstrap-eligibility"}:
return message
if message.get("op") == "classify-window":
require(set(message) == {"op", "runtime_token"}, "MESSAGE_SCHEMA")
else:
require(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")
@ -123,6 +130,30 @@ def send(stream, value):
stream.flush()
def classify(sway, message):
"""Preuve fraîche sans UUID ni publication ; le catalogue ferme les classes.
Le jeton doit désigner exactement une surface, même si une autre surface
marquée serait inconnue. Aucun titre/suffixe n'est renvoyé ou conservé.
"""
validate(message)
require(message["op"] == "classify-window", "OP_NOT_ALLOWED")
prefix = "[LABFY:" + message["runtime_token"] + "] "
end = time.monotonic() + 2
while True:
found = [n for n in sway.windows() if (n.get("name") or "").startswith(prefix)]
require(len(found) <= 1, "EXACT_WINDOW_IDENTITY_COLLISION")
if found:
node = found[0]
app_id = node.get("app_id")
require(type(app_id) is str and app_id in APP_IDS | WEBAPP_IDS, "WINDOW_CLASS_UNSUPPORTED")
require(type(node.get("id")) is int and node["id"] > 0, "WINDOW_CLASS_UNSUPPORTED")
return {"result": "OK", "kind": "browser" if app_id in APP_IDS else "managed-webapp",
"runtime_token": message["runtime_token"], "con_id": node["id"]}
require(time.monotonic() < end, "CORRELATION_TIMEOUT")
time.sleep(.02)
def correlate(sway, message):
validate(message)
require(message["op"] == "map-window", "OP_NOT_ALLOWED")
@ -165,6 +196,9 @@ def main():
if message["op"] == "bootstrap-eligibility":
send(sys.stdout.buffer, bootstrap_eligibility(sway))
continue
if message["op"] == "classify-window":
send(sys.stdout.buffer, classify(sway, message))
continue
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(),

View file

@ -1,7 +1,9 @@
"""EXPERIMENTAL : journal local borné et reprise d'emplacement, jamais rollback ACID.
WHY : une intention durable précède l'IPC ; sa postcondition seule autorise une
reprise. CONTRACT : identité session/con_id/app_id/PID/starttime et état observé.
reprise. CONTRACT : identité session/con_id/clé de surface/PID/starttime et état observé.
Le champ runtime app_id porte la clé typée de surface, y compris WM_CLASS X11.
Une ancienne observation X11 sans classe reste distincte et ne gagne aucune preuve.
INVARIANT : aucun kill client, aucune propriété déduite du nom du workspace.
"""
import copy
@ -10,7 +12,7 @@ import re
import time
from pathlib import Path
from .errors import require
from .ipc import walk, location, workspace, until, canonical
from .ipc import walk, location, workspace, until, canonical, surface_identity
from .storage import atomic, read
JOURNAL = "session-v2-recovery.json"
@ -36,7 +38,7 @@ def observe(tree, node, start):
if any(w.get("type") == "workspace" and w.get("name") == where[0] for w in walk(o))), None)
require(output is not None, "RECOVERY_OUTPUT_MISSING")
floating = node.get("type") == "floating_con" or node.get("floating") in ("user_on", "auto_on")
return {"con_id": node["id"], "app_id": node.get("app_id"), "pid": node.get("pid"),
return {"con_id": node["id"], "app_id": surface_identity(node), "pid": node.get("pid"),
"starttime": start(node["pid"]), "workspace": where[0], "output": output,
"path": list(where[1]), "scratchpad": scratchpad_state(node, where[0]), "floating": floating, "geometry": dict(node["rect"]) if floating else None}
@ -143,7 +145,7 @@ def focus_proof(sway):
session = sway.session
def sample():
tree = sway.tree()
topology = [(n["id"], n.get("type"), n.get("app_id"), n.get("pid"),
topology = [(n["id"], n.get("type"), surface_identity(n), n.get("pid"),
n.get("focused", False), n.get("focus", []),
[c["id"] for c in n.get("nodes", [])],
[c["id"] for c in n.get("floating_nodes", [])]) for n in walk(tree)]
@ -382,7 +384,7 @@ class Recovery:
require(self.sway.session == self.data["session"], "SWAY_SESSION_CHANGED")
state = self.snapshot(node["id"])
require(self.sway.session == self.data["session"], "SWAY_SESSION_CHANGED")
require(state and state["pid"] == node.get("pid") and state["app_id"] == node.get("app_id"), "TARGET_IDENTITY_CHANGED")
require(state and state["pid"] == node.get("pid") and state["app_id"] == surface_identity(node), "TARGET_IDENTITY_CHANGED")
return state
def admit(self, node, observed=None):
@ -408,7 +410,7 @@ class Recovery:
def baseline(self, allowed):
tree = self.sway.tree()
nodes = [n for n in walk(tree) if (n.get("app_id") or n.get("window")) and n.get("app_id") not in allowed]
nodes = [n for n in walk(tree) if (n.get("app_id") or n.get("window")) and surface_identity(n) not in allowed]
require(len(nodes) <= 256, "RECOVERY_WINDOW_LIMIT")
self.data["foreign"] = [observe(tree, n, self.start) for n in nodes]
self.publish()
@ -428,7 +430,7 @@ class Recovery:
require(self.data["operations"] < 4096 and len(command) <= 2048, "RECOVERY_OPERATION_LIMIT")
tree = self.sway.tree()
if focused(tree) != self.data["focus_expected"]:
focus_node = next((n for n in walk(tree) if n.get("focused") and n.get("app_id")), None)
focus_node = next((n for n in walk(tree) if n.get("focused") and (n.get("app_id") or n.get("window"))), None)
if not focus_node or not self.anchor_owned(focus_node):
self.record_focus_conflict({"phase": "before-command", "command": command,
"before": self.data["focus_expected"], "observed": focused(tree)})

View file

@ -175,6 +175,7 @@
"multi-instance",
"terminal",
"browser-self-restore",
"application-self-restore",
"unknown"
]
},

View file

@ -8,7 +8,7 @@ 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"}
STRATEGIES = {"managed-autostart", "single-window", "multi-instance", "terminal", "browser-self-restore", "application-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")
@ -52,6 +52,7 @@ def validate(data):
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")
require(data["version"] == 2 or a["strategy"] != "application-self-restore", "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")

View file

@ -2,7 +2,7 @@
import time
from .errors import require
from .schema import validate, PROVIDER
from .ipc import walk, location
from .ipc import walk, location, surface_identity
def capture(sway, catalog, provider, reason="manual"):
@ -11,7 +11,7 @@ def capture(sway, catalog, provider, reason="manual"):
# 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("app_id"), node.get("window"), node.get("pid"), node.get("scratchpad_state", "none"), node.get("focused"), node.get("fullscreen_mode"), node.get("floating"), node.get("rect"),
surface_identity(node), node.get("window"), node.get("pid"), node.get("scratchpad_state", "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")
@ -25,6 +25,7 @@ def capture(sway, catalog, provider, reason="manual"):
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]
require(s.strategy != "single-window" or len(members) == 1, "APPLICATION_POLICY_MISMATCH")
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}

View file

@ -12,6 +12,7 @@ import "scratchpad"
import "osd"
import "applications"
import "dock"
import "systemmonitor"
ShellRoot {
id: shell
@ -26,6 +27,7 @@ ShellRoot {
// CONTRACT: le tiroir est global ; aucune barre n'installe son propre
// abonnement window ni son propre ordonnanceur de commandes.
DrawerService { id: shellDrawerService; authenticationActive: shell.authenticationActive }
MonitorService { id: shellMonitorService }
OsdService { id: shellOsdService; authenticationActive: shell.authenticationActive }
signal authenticationOpening()
function choosePolkitScreen(output) {
@ -260,6 +262,7 @@ ShellRoot {
Bar {
drawerService: shellDrawerService
osdService: shellOsdService
monitorService: shellMonitorService
applicationCoordinator: shell
authenticationActive: shell.authenticationActive
keepAwakeController: shell

View file

@ -0,0 +1,214 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import "../theme"
PopupWindow {
id: popup
required property var barWindow
required property var indicator
required property var service
signal dismissed()
anchor.window: barWindow
anchor.rect.x: Math.max(12, (barWindow.width - implicitWidth) / 2)
anchor.rect.y: barWindow.height + 6
anchor.edges: Edges.Top | Edges.Left
anchor.gravity: Edges.Bottom | Edges.Right
readonly property real usableWidth: Math.max(0, barWindow.screen.width - 24)
readonly property real usableHeight: Math.max(0, barWindow.screen.height - barWindow.height - 28)
implicitWidth: Math.min(1500, usableWidth, Math.max(600, usableWidth * 0.9))
implicitHeight: Math.min(920, usableHeight, Math.max(440, usableHeight * 0.82))
visible: false
grabFocus: true
color: "transparent"
onVisibleChanged: { service.processesVisible = visible; if (!visible) dismissed(); }
Component.onCompleted: Qt.callLater(() => { popup.visible = true; })
Component.onDestruction: service.processesVisible = false
Shortcut { sequence: "Escape"; enabled: popup.visible; onActivated: popup.visible = false }
readonly property bool narrow: width < 850
readonly property string mono: "JetBrainsMono Nerd Font"
readonly property var d: service.data || ({})
readonly property var m: d.memory || ({})
readonly property var g: d.gpu || ({})
readonly property var z: d.zram || ({})
readonly property var psi: d.psi || ({})
readonly property var t: d.temperatures || ({})
readonly property var disk: d.disk || ({})
readonly property var h: service.histories
property string sortKey: "cpu"
property int selectedPid: -1
readonly property var sortedProcesses: {
const rows = service.processRows.slice();
if (sortKey === "rss") rows.sort((a,b) => b.rss - a.rss || (b.cpu || 0) - (a.cpu || 0));
return rows;
}
function valid(v) { return v !== null && v !== undefined && isFinite(v); }
function fmt(v, n) { return valid(v) ? Number(v).toFixed(n === undefined ? 0 : n) : "—"; }
function gib(v) { return valid(v) ? (v / 1073741824).toFixed(2) + " Gio" : "indisponible"; }
function rate(v) { return valid(v) ? (v / 1048576).toFixed(2) + " Mio/s" : "indisponible"; }
function share(a,b) { return valid(a) && valid(b) && b > 0 ? Math.max(0, Math.min(100, 100 * a / b)) : 0; }
function coreKeys() { return Object.keys(service.usage.threads || {}).sort((a,b) => Number(a.slice(3)) - Number(b.slice(3))); }
component Section: Rectangle {
property string heading: ""
radius: 3; color: Theme.popupBackground; border.width: 1; border.color: Theme.outline
Text { x: 10; y: 2; text: " " + parent.heading + " "; color: Theme.lavender
font.family: popup.mono; font.pixelSize: 12; font.bold: true; font.capitalization: Font.AllUppercase
Rectangle { anchors.fill: parent; anchors.margins: -1; z: -1; color: Theme.popupBackground } }
}
component Meter: Item {
property string label: ""
property string value: ""
property real fraction: 0
property color tint: Theme.lavender
height: 21
Text { id: meterLabel; width: 48; text: parent.label; color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 11 }
Rectangle { id: track; x: 49; width: Math.max(0, parent.width - 49 - 175); height: 7; y: 5; radius: 2; color: Theme.buttonBackground
Rectangle { width: parent.width * Math.max(0, Math.min(100, fraction)) / 100; height: parent.height; radius: 2; color: tint } }
Text { x: parent.width - 171; width: 171; text: parent.value; color: Theme.foreground
horizontalAlignment: Text.AlignRight; elide: Text.ElideRight; font.family: popup.mono; font.pixelSize: 11 }
}
Rectangle {
anchors.fill: parent; radius: 4; color: Theme.panelBackground; border.color: Theme.outline
Text { id: heading; x: 14; y: 8; text: "󰍛 MONITEUR MATÉRIEL"; color: Theme.lavender
font.family: popup.mono; font.pixelSize: 14; font.bold: true }
Text { anchors.right: parent.right; anchors.rightMargin: 14; y: 10; text: "10 min · Échap pour fermer";
color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 10 }
Flickable {
id: dashboard
x: 10; y: 34; width: parent.width - 20; height: parent.height - 44
contentWidth: width; contentHeight: grid.height; clip: true
boundsBehavior: Flickable.StopAtBounds
Item {
id: grid; width: dashboard.width
readonly property real gap: 8
readonly property real topHeight: 320
readonly property real midHeight: popup.narrow ? 400 : 300
readonly property real bottomHeight: popup.narrow ? 150 : 130
height: popup.narrow ? topHeight * 2 + midHeight * 2 + bottomHeight * 2 + 5 * gap
: Math.max(dashboard.height, topHeight + midHeight + bottomHeight + 2 * gap)
Section {
id: cpuBox; heading: "CPU"; x: 0; y: 0
width: popup.narrow ? grid.width : grid.width * 0.55 - grid.gap / 2; height: grid.topHeight
Text { x: 12; y: 16; text: popup.fmt(service.usage.cpu, 1) + " %"; color: Theme.lavender
font.family: popup.mono; font.pixelSize: 27; font.bold: true; width: 115 }
Text { x: 130; y: 23; width: parent.width - 142; elide: Text.ElideRight
text: "T " + popup.fmt(popup.t.cpu, 1) + " °C " +
"f " + (popup.d.frequencies && popup.d.frequencies.length ? popup.fmt(popup.d.frequencies[0].currentMHz) : "—") +
" MHz charge " + (popup.d.load ? popup.d.load.join(" / ") : "—")
color: Theme.foreground; font.family: popup.mono; font.pixelSize: 11 }
Sparkline { x: 12; y: 57; width: parent.width - 24; height: 108
points: popup.h.cpu; ceiling: 100; stroke: Theme.lavender; gridVisible: true; fillVisible: true }
Text { x: 13; y: 170; text: "CPU LOGIQUES · % PAR CŒUR"; color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 10 }
Repeater {
model: popup.coreKeys()
Item {
required property string modelData
readonly property int indexNumber: Number(modelData.slice(3))
x: 12 + (indexNumber >= 8 ? cpuBox.width / 2 : 0)
y: 187 + (indexNumber % 8) * 16
width: (cpuBox.width - 50) / 2; height: 14
Text { x: 0; text: modelData; color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 10 }
Rectangle { x: 43; y: 4; width: Math.max(10, parent.width - 83); height: 5; color: Theme.buttonBackground; radius: 1
Rectangle { width: parent.width * popup.share(service.usage.threads[modelData], 100) / 100; height: parent.height; color: Theme.lavender; radius: 1 } }
Text { anchors.right: parent.right; width: 36; horizontalAlignment: Text.AlignRight
text: popup.fmt(service.usage.threads[modelData]); color: Theme.foreground; font.family: popup.mono; font.pixelSize: 10 }
}
}
}
Section {
id: gpuBox; heading: "GPU · AMD RADEON"; y: popup.narrow ? cpuBox.height + grid.gap : 0
x: popup.narrow ? 0 : cpuBox.width + grid.gap
width: popup.narrow ? grid.width : grid.width - x; height: grid.topHeight
Text { x: 12; y: 17; text: popup.fmt(popup.g.activity) + " %"; color: Theme.mauve; font.family: popup.mono; font.pixelSize: 26; font.bold: true }
Text { anchors.right: parent.right; anchors.rightMargin: 12; y: 26
text: "T " + popup.fmt(popup.t.gpu, 1) + " °C · " + popup.fmt(popup.g.frequencyMHz) + " MHz"
color: Theme.foreground; font.family: popup.mono; font.pixelSize: 11 }
Sparkline { x: 12; y: 57; width: parent.width - 24; height: Math.max(75, parent.height * 0.51)
points: popup.h.gpu; ceiling: 100; stroke: Theme.mauve; gridVisible: true; fillVisible: true }
Text { x: 12; y: parent.height - 63; text: "VRAM dédiée"; color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 10 }
Text { anchors.right: parent.right; anchors.rightMargin: 12; y: parent.height - 63; text: popup.gib(popup.g.vramUsed) + " / " + popup.gib(popup.g.vramTotal)
color: Theme.foreground; font.family: popup.mono; font.pixelSize: 10 }
Text { x: 12; y: parent.height - 40; text: "GTT partagée"; color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 10 }
Text { anchors.right: parent.right; anchors.rightMargin: 12; y: parent.height - 40; text: popup.gib(popup.g.gttUsed) + " / " + popup.gib(popup.g.gttTotal)
color: Theme.foreground; font.family: popup.mono; font.pixelSize: 10 }
}
Section {
id: memoryBox; heading: "MÉMOIRE · SWAP · ZRAM · PSI"
x: 0; y: (popup.narrow ? gpuBox.y + gpuBox.height : cpuBox.height) + grid.gap
width: popup.narrow ? grid.width : grid.width * 0.43 - grid.gap / 2; height: grid.midHeight
Column {
x: 12; y: 20; width: parent.width - 24; spacing: 4
Meter { width: parent.width; label: "RAM"; value: popup.gib(popup.m.used) + " / " + popup.gib(popup.m.total); fraction: popup.share(popup.m.used,popup.m.total) }
Meter { width: parent.width; label: "SWAP"; value: popup.gib(popup.m.swapUsed) + " / " + popup.gib(popup.m.swapTotal); fraction: popup.share(popup.m.swapUsed,popup.m.swapTotal); tint: Theme.mauve }
Meter { width: parent.width; label: "ZRAM"; value: popup.gib(popup.z.logical) + " logique"; fraction: popup.share(popup.z.logical,popup.m.swapTotal); tint: Theme.lavender }
Text { text: "Disponible " + popup.gib(popup.m.available) + " Cache " + popup.gib(popup.m.cache); color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 10 }
Text { text: "zram compressée " + popup.gib(popup.z.compressed) + " RAM " + popup.gib(popup.z.consumed) + " ×" + popup.fmt(popup.z.ratio,2)
color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 10 }
Rectangle { width: parent.width; height: 1; color: Theme.outline }
Text { text: "PSI · TEMPS D'ATTENTE MÉMOIRE"; color: Theme.lavender; font.family: popup.mono; font.pixelSize: 11 }
Sparkline { width: parent.width; height: Math.max(48, memoryBox.height - 197); points: popup.h.psi; ceiling: 100; stroke: Theme.mauve; gridVisible: true; fillVisible: true }
Text { text: "some " + (popup.psi.some ? popup.psi.some.map(v => popup.fmt(v,2) + "%").join(" ") : "indisponible"); color: Theme.foreground; font.family: popup.mono; font.pixelSize: 10 }
Text { text: "full " + (popup.psi.full ? popup.psi.full.map(v => popup.fmt(v,2) + "%").join(" ") : "indisponible"); color: Theme.foreground; font.family: popup.mono; font.pixelSize: 10 }
}
}
Section {
id: processBox; heading: "PROCESSUS · CPU % D'UN CŒUR"
x: popup.narrow ? 0 : memoryBox.width + grid.gap
y: popup.narrow ? memoryBox.y + memoryBox.height + grid.gap : memoryBox.y
width: popup.narrow ? grid.width : grid.width - x; height: grid.midHeight
Rectangle { x: 8; y: 18; width: parent.width - 16; height: 24; color: Theme.buttonBackground
Text { x: 6; width: 65; text: "PID"; color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 11 }
Text { x: 72; width: parent.width - 240; text: "NOM"; color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 11 }
Text { x: parent.width - 165; width: 70; text: "CPU %"; color: popup.sortKey === "cpu" ? Theme.lavender : Theme.subtext1; font.family: popup.mono; font.pixelSize: 11
MouseArea { anchors.fill: parent; onClicked: popup.sortKey = "cpu" } }
Text { x: parent.width - 90; width: 85; text: "RAM"; color: popup.sortKey === "rss" ? Theme.lavender : Theme.subtext1; font.family: popup.mono; font.pixelSize: 11
MouseArea { anchors.fill: parent; onClicked: popup.sortKey = "rss" } }
}
ListView {
id: processList; x: 8; y: 44; width: parent.width - 16; height: parent.height - 52; clip: true
model: popup.sortedProcesses; keyNavigationWraps: false; focus: true
delegate: Rectangle {
required property var modelData
required property int index
width: processList.width; height: 21
color: popup.selectedPid === modelData.pid ? Theme.buttonPressed : index % 2 ? Theme.popupBackground : "transparent"
Text { x: 6; width: 65; text: modelData.pid; color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 10 }
Text { x: 72; width: Math.max(40, parent.width - 240); text: modelData.name; elide: Text.ElideRight; color: Theme.foreground; font.family: popup.mono; font.pixelSize: 10 }
Text { x: parent.width - 165; width: 70; text: popup.fmt(modelData.cpu,1); horizontalAlignment: Text.AlignRight; color: Theme.lavender; font.family: popup.mono; font.pixelSize: 10 }
Text { x: parent.width - 90; width: 85; text: popup.gib(modelData.rss); horizontalAlignment: Text.AlignRight; color: Theme.foreground; font.family: popup.mono; font.pixelSize: 10 }
MouseArea { anchors.fill: parent; onClicked: { popup.selectedPid = modelData.pid; processList.currentIndex = index; } }
}
Keys.onUpPressed: { currentIndex = Math.max(0, currentIndex - 1); popup.selectedPid = currentItem ? currentItem.modelData.pid : -1; }
Keys.onDownPressed: { currentIndex = Math.min(count - 1, currentIndex + 1); popup.selectedPid = currentItem ? currentItem.modelData.pid : -1; }
}
Text { anchors.centerIn: processList; visible: processList.count === 0; text: "Aucun processus disponible"; color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 11 }
}
Section {
id: diskBox; heading: "STOCKAGE · / · " + (popup.disk.filesystem || "indisponible")
x: 0; y: (popup.narrow ? processBox.y + processBox.height : memoryBox.y + memoryBox.height) + grid.gap
width: popup.narrow ? grid.width : grid.width * 0.43 - grid.gap / 2; height: grid.bottomHeight
Column { x: 12; y: 23; width: parent.width - 24; spacing: 8
Meter { width: parent.width; label: "UTIL."; value: popup.gib(popup.disk.used) + " / " + popup.gib(popup.disk.total); fraction: popup.share(popup.disk.used,popup.disk.total) }
Text { text: "Espace utilisable : " + popup.gib(popup.disk.available); color: Theme.foreground; font.family: popup.mono; font.pixelSize: 11 }
Text { width: parent.width; wrapMode: Text.WordWrap; text: "Occupation logique du système de fichiers ; l'allocation interne Btrfs n'est pas affichée."
color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 10 }
}
}
Section {
id: netBox; heading: "RÉSEAU · " + (popup.d.network ? popup.d.network.interface : "déconnecté")
x: popup.narrow ? 0 : diskBox.width + grid.gap
y: popup.narrow ? diskBox.y + diskBox.height + grid.gap : diskBox.y
width: popup.narrow ? grid.width : grid.width - x; height: grid.bottomHeight
Text { x: 12; y: 17; text: "↓ " + popup.rate(service.rates.rx) + " ↑ " + popup.rate(service.rates.tx);
color: Theme.foreground; font.family: popup.mono; font.pixelSize: 11 }
Text { anchors.right: parent.right; anchors.rightMargin: 12; y: 17; text: "échelle 0–10 Mio/s";
color: Theme.subtext1; font.family: popup.mono; font.pixelSize: 10 }
Sparkline { x: 12; y: 38; width: parent.width - 24; height: 43; points: popup.h.rx; ceiling: 10485760; stroke: Theme.lavender; gridVisible: true; fillVisible: true }
Sparkline { x: 12; y: 86; width: parent.width - 24; height: 43; points: popup.h.tx; ceiling: 10485760; stroke: Theme.mauve; gridVisible: true; fillVisible: true }
}
}
}
}
}

View file

@ -0,0 +1,111 @@
import QtQuick
import Quickshell
import Quickshell.Io
Item {
id: service
property string activeOutput: ""
property string page: "Vue"
property bool processesVisible: false
readonly property bool active: activeOutput !== ""
property var data: ({})
property var usage: ({})
property var rates: ({})
property var processRows: []
property var histories: ({ cpu: [], gpu: [], ram: [], psi: [], cpuTemp: [], gpuTemp: [], rx: [], tx: [] })
property var previous: null
property var processBaseline: null
property var networkBaseline: null
property int tick: 0
readonly property string backend: (Quickshell.env("XDG_CONFIG_HOME") ||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/systemmonitor/backend.py"
// CONTRACT: ShellRoot owns this collector across every output. Closing the
// panel stops the timer and invalidates all differential baselines.
function open(output) { activeOutput = output; page = "Vue"; previous = null; processBaseline = null; networkBaseline = null; tick = 0; sample(); }
function close() { activeOutput = ""; previous = null; processBaseline = null; networkBaseline = null; sampler.running = false; }
function append(key, value, time, gap) {
const old = histories[key] || [];
const next = old.slice(Math.max(0, old.length - 599));
next.push({ value: gap ? null : value, time: time });
histories = Object.assign({}, histories, { [key]: next });
}
function percent(now, before) {
if (!now || !before || now.length !== 2 || before.length !== 2) return null;
const total = now[0] - before[0], idle = now[1] - before[1];
return total > 0 && idle >= 0 && idle <= total ? 100 * (total - idle) / total : null;
}
function sample() {
if (!active || sampler.running) return;
const args = ["python3", "-B", backend];
if (tick % 2 === 0) args.push("gpu");
if (tick % 30 === 0) args.push("disk");
if (processesVisible && tick % 4 === 0) args.push("processes");
sampler.command = args;
sampler.running = true;
tick++;
}
function accept(d) {
if (!active || d.error || !d.monotonic) return;
const prior = previous;
const gap = !!prior && (d.monotonic <= prior.monotonic || d.monotonic - prior.monotonic > 5);
const elapsed = prior && !gap ? d.monotonic - prior.monotonic : 0;
const cpu = elapsed ? percent(d.cpu.cpu, prior.cpu.cpu) : null;
let threads = {};
for (const key in d.cpu) if (key !== "cpu")
threads[key] = elapsed ? percent(d.cpu[key], prior.cpu[key]) : null;
let rx = null, tx = null;
if (!d.network || !networkBaseline || d.network.interface !== networkBaseline.interface ||
d.network.rx < networkBaseline.rx || d.network.tx < networkBaseline.tx)
networkBaseline = d.network;
if (elapsed && d.network && prior.network && d.network.interface === prior.network.interface) {
const dr = d.network.rx - prior.network.rx, dt = d.network.tx - prior.network.tx;
if (dr >= 0 && dt >= 0) { rx = dr / elapsed; tx = dt / elapsed; }
}
let rows = processRows;
if (d.processes) {
rows = d.processes.map(p => {
const old = processBaseline ? processBaseline.rows.find(q => q.pid === p.pid && q.start === p.start) : null;
const delta = old ? p.ticks - old.ticks : -1;
const processElapsed = processBaseline && !gap ? d.monotonic - processBaseline.time : 0;
return Object.assign({}, p, { cpu: processElapsed > 0 && delta >= 0 ?
100 * delta / (d.clockTicks * processElapsed) : null });
});
rows.sort((a, b) => (b.cpu || 0) - (a.cpu || 0) || b.rss - a.rss);
processRows = rows.slice(0, 80);
processBaseline = { rows: d.processes, time: d.monotonic };
}
usage = { cpu: cpu, threads: threads };
rates = { rx: rx, tx: tx,
totalRx: d.network && networkBaseline ? d.network.rx - networkBaseline.rx : null,
totalTx: d.network && networkBaseline ? d.network.tx - networkBaseline.tx : null,
swapIn: elapsed && d.swapPages.pswpin >= (prior.swapPages.pswpin || 0) ?
(d.swapPages.pswpin - prior.swapPages.pswpin) / elapsed : null,
swapOut: elapsed && d.swapPages.pswpout >= (prior.swapPages.pswpout || 0) ?
(d.swapPages.pswpout - prior.swapPages.pswpout) / elapsed : null };
data = Object.assign({}, data, d);
const t = d.monotonic;
append("cpu", cpu, t, gap);
append("ram", d.memory.total ? 100 * d.memory.used / d.memory.total : null, t, gap);
append("psi", d.psi.some ? d.psi.some[0] : null, t, gap);
append("rx", rx, t, gap); append("tx", tx, t, gap);
if (d.gpu !== undefined) append("gpu", d.gpu ? d.gpu.activity : null, t, gap);
if (d.temperatures) {
append("cpuTemp", d.temperatures.cpu, t, gap);
append("gpuTemp", d.temperatures.gpu, t, gap);
}
// INVARIANT: retain the last process reference across non-process ticks;
// process identity is PID plus start time, never PID alone.
previous = Object.assign({}, d, { processes: d.processes || (prior ? prior.processes : null),
network: d.network, gpu: d.gpu || (prior ? prior.gpu : null) });
}
Timer { interval: 1000; repeat: true; running: service.active; onTriggered: service.sample() }
Process {
id: sampler
stdout: StdioCollector { id: output; waitForEnd: true }
onExited: (code, status) => {
if (code !== 0 || !service.active) return;
try { service.accept(JSON.parse(output.text)); }
catch (error) { console.warn("Moniteur matériel : échantillon invalide", error); }
}
}
}

View file

@ -0,0 +1,44 @@
# Moniteur matériel V1
`MonitorService.qml` est créé une seule fois par `ShellRoot`. Chaque barre ne
possède qu'un indicateur et charge `MonitorPanel.qml` pendant l'ouverture.
Le collecteur lance `backend.py` directement, sans shell ni démon. Le minuteur
est arrêté lorsque `activeOutput` est vide. Une nouvelle ouverture efface les
références différentielles CPU, réseau et processus. Les courbes restent en
mémoire, avec au plus 600 points par métrique ; aucune donnée n'est persistée.
Cadence : CPU, RAM, PSI et réseau chaque seconde ; GPU et températures toutes
les deux secondes ; stockage toutes les 30 secondes ; processus toutes les
quatre secondes uniquement dans la page Processus. Le stockage est obtenu par
`statvfs('/')` : `used` est l'occupation logique signalée par le système de
fichiers et `available` l'espace utilisable pour l'utilisateur. Ce n'est pas
une mesure de l'allocation interne des chunks Btrfs.
Le CPU des processus est exprimé en pourcentage d'un cœur logique. Les PID
sont associés à l'heure de démarrage du processus avant tout calcul de delta.
La mémoire « utilisée » est `MemTotal - MemAvailable`; le cache reste une
information distincte, non additive. VRAM et GTT sont affichées séparément.
PSI décrit le temps d'attente mémoire, sans rapport direct avec le taux de RAM
occupée. Les capteurs et compteurs absents restent indisponibles.
Validation physique restante : ouverture et fermeture au clic extérieur et
Échap ; coexistence Date Center, Control Center, Overview ; dimensions à
l'échelle fractionnaire ; charge et RSS du processus QuickShell sur dix minutes
et après plusieurs ouvertures ; comportement après veille. La session SwayFX
et le service QuickShell de production ne sont pas redémarrés pour cette étape.
## STEP15B2 — tableau de bord
L'interface utilise une grille CPU/GPU, mémoire/processus et stockage/réseau.
Sur écran large, le panneau vise 90 % de la largeur utilisable et 82 % de la
hauteur utilisable, avec des plafonds de 1500 × 920 unités logiques. Le contenu
secondaire défile dans un `Flickable` ; sous 850 unités logiques de largeur,
les blocs passent en une colonne. Les processus restent visibles dans le
contenu principal et leur lecture est liée à la visibilité du panneau. Leur
tri CPU/RAM est local à l'interface ; le collecteur et ses formules restent
inchangés. Les courbes sont dessinées par `Canvas` sans créer un item par point.
La fixture `tests/systemmonitor/visual.py` utilise un Sway headless isolé et
désactive seulement son grab de popup, car un xdg_popup avec grab exige une
entrée utilisateur réelle. La fermeture extérieure et Échap doivent donc
être contrôlés sur l'écran physique.

View file

@ -0,0 +1,59 @@
import QtQuick
import "../theme"
Canvas {
id: graph
property var points: []
property real ceiling: 100
property color stroke: Theme.lavender
property bool gridVisible: false
property bool fillVisible: false
implicitWidth: 160; implicitHeight: 30
onPointsChanged: requestPaint()
onCeilingChanged: requestPaint()
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
onPaint: {
const ctx = getContext("2d");
ctx.clearRect(0, 0, width, height);
if (gridVisible) {
ctx.strokeStyle = Theme.outline; ctx.lineWidth = 1;
for (let k = 1; k <= 3; ++k) {
const y = k * height / 4;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke();
}
}
if (points.length < 2) return;
// CONTRACT: a null sample breaks the path after suspend or counter reset.
// Reuse one canvas and draw the bounded ring snapshot without QML nodes.
const color = stroke.toString();
let start = -1;
function drawSegment(first, end) {
if (end - first < 2) return;
if (fillVisible) {
ctx.beginPath();
ctx.moveTo(first * width / (points.length - 1), height);
for (let i = first; i < end; ++i) {
const x = i * width / (points.length - 1);
const y = height - 2 - Math.max(0, Math.min(1, points[i].value / Math.max(1, ceiling))) * (height - 4);
ctx.lineTo(x, y);
}
ctx.lineTo((end - 1) * width / (points.length - 1), height);
ctx.closePath(); ctx.fillStyle = Qt.rgba(stroke.r, stroke.g, stroke.b, 0.12); ctx.fill();
}
ctx.beginPath();
for (let i = first; i < end; ++i) {
const x = i * width / (points.length - 1);
const y = height - 2 - Math.max(0, Math.min(1, points[i].value / Math.max(1, ceiling))) * (height - 4);
if (i === first) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.stroke();
}
for (let i = 0; i <= points.length; ++i) {
const value = i < points.length ? points[i].value : null;
if (value !== null && value !== undefined && isFinite(value)) {
if (start < 0) start = i;
} else if (start >= 0) { drawSegment(start, i); start = -1; }
}
}
}

View file

@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Lectures matérielles bornées, en lecture seule ; un appel produit un instantané JSON."""
import json
import os
from pathlib import Path
import re
import sys
import time
PROC = Path('/proc')
SYS = Path('/sys')
def read(path):
try:
return path.read_text(encoding='ascii').strip()
except (OSError, UnicodeError):
return None
def number(path, scale=1):
value = read(path)
try:
return int(value) / scale if value is not None else None
except ValueError:
return None
def cpu_ticks(text):
"""CONTRACT: guest et guest_nice sont déjà inclus dans user et nice."""
result = {}
for line in text.splitlines():
fields = line.split()
if not fields or not re.fullmatch(r'cpu\d*', fields[0]):
continue
try:
values = [int(x) for x in fields[1:9]]
except ValueError:
continue
if len(values) < 8:
continue
result[fields[0]] = [sum(values), values[3] + values[4]]
return result
def meminfo(text):
values = {m.group(1): int(m.group(2)) * 1024 for m in
re.finditer(r'^(\w+):\s+(\d+) kB$', text, re.M)}
total = values.get('MemTotal')
available = values.get('MemAvailable')
return {'total': total, 'available': available,
'used': max(0, total - available) if total is not None and available is not None else None,
'cache': values.get('Cached'), 'buffers': values.get('Buffers'),
'swapTotal': values.get('SwapTotal'), 'swapUsed':
max(0, values['SwapTotal'] - values['SwapFree'])
if 'SwapTotal' in values and 'SwapFree' in values else None}
def psi(text):
result = {}
for kind in ('some', 'full'):
match = re.search(r'^' + kind + r'\s+avg10=([\d.]+)\s+avg60=([\d.]+)\s+avg300=([\d.]+)', text, re.M)
result[kind] = [float(x) for x in match.groups()] if match else None
return result
def zram(text):
try:
values = [int(x) for x in text.split()]
if len(values) < 3:
return None
logical, compressed, consumed = values[:3]
return {'logical': logical, 'compressed': compressed, 'consumed': consumed,
'ratio': logical / compressed if compressed else None}
except ValueError:
return None
def gpu_frequency(text):
# amdgpu exposes the selected DPM level with '*'; absent data stays absent.
for line in text.splitlines():
if '*' in line:
match = re.search(r'(\d+)Mhz', line, re.I)
if match:
return int(match.group(1))
return None
def gpu():
# INVARIANT: cardN et hwmonN sont découverts par identité, jamais par indice fixe.
for card in sorted((SYS / 'class/drm').glob('card[0-9]*')):
if not re.fullmatch(r'card\d+', card.name):
continue
dev = card / 'device'
if read(dev / 'vendor') != '0x1002':
continue
return {'device': card.name, 'activity': number(dev / 'gpu_busy_percent'),
'frequencyMHz': gpu_frequency(read(dev / 'pp_dpm_sclk') or ''),
'vramUsed': number(dev / 'mem_info_vram_used'),
'vramTotal': number(dev / 'mem_info_vram_total'),
'gttUsed': number(dev / 'mem_info_gtt_used'),
'gttTotal': number(dev / 'mem_info_gtt_total')}
return None
def temperatures():
result = {'cpu': None, 'gpu': None}
for node in sorted((SYS / 'class/hwmon').glob('hwmon[0-9]*')):
name = read(node / 'name')
if name not in ('k10temp', 'amdgpu'):
continue
target = 'cpu' if name == 'k10temp' else 'gpu'
preferred = 'Tctl' if target == 'cpu' else 'edge'
candidates = sorted(node.glob('temp*_input'))
chosen = next((p for p in candidates if read(p.with_name(p.name.replace('_input', '_label'))) == preferred), None)
if chosen is None:
chosen = next((p for p in candidates if p.name == 'temp1_input'), None)
if chosen:
result[target] = number(chosen, 1000)
return result
def network():
text = read(PROC / 'net/dev') or ''
candidates = []
for line in text.splitlines()[2:]:
if ':' not in line:
continue
name, raw = line.split(':', 1)
name = name.strip()
parts = raw.split()
if name == 'lo' or len(parts) < 16:
continue
if read(SYS / 'class/net' / name / 'operstate') != 'up':
continue
try:
candidates.append((name, int(parts[0]), int(parts[8])))
except ValueError:
pass
candidates.sort(key=lambda x: (not (SYS / 'class/net' / x[0] / 'device').exists(), x[0]))
return dict(zip(('interface', 'rx', 'tx'), candidates[0])) if candidates else None
def processes():
result = []
for node in PROC.glob('[0-9]*'):
stat = read(node / 'stat')
status = read(node / 'status')
if not stat or not status:
continue
end = stat.rfind(')')
if end < 0:
continue
fields = stat[end + 2:].split()
try:
ticks = int(fields[11]) + int(fields[12])
start = int(fields[19])
rss = int(re.search(r'^VmRSS:\s+(\d+) kB', status, re.M).group(1)) * 1024
result.append({'pid': int(node.name), 'name': stat[stat.find('(')+1:end][:64],
'ticks': ticks, 'start': start, 'rss': rss})
except (ValueError, IndexError, AttributeError):
continue
return result
def snapshot(include_gpu=False, include_disk=False, include_processes=False):
# WHY: monotonic time makes rates independent of wall-clock changes.
result = {'monotonic': time.monotonic(), 'cpu': cpu_ticks(read(PROC / 'stat') or ''),
'load': (read(PROC / 'loadavg') or '').split()[:3],
'memory': meminfo(read(PROC / 'meminfo') or ''),
'psi': psi(read(PROC / 'pressure/memory') or ''),
'zram': zram(read(SYS / 'block/zram0/mm_stat') or ''),
'network': network()}
vm = read(PROC / 'vmstat') or ''
result['swapPages'] = {k: int(v) for k, v in re.findall(r'^(pswpin|pswpout)\s+(\d+)$', vm, re.M)}
freqs = []
for cpu in sorted((SYS / 'devices/system/cpu').glob('cpu[0-9]*'), key=lambda p: int(p.name[3:])):
base = cpu / 'cpufreq'
freqs.append({'cpu': cpu.name, 'currentMHz': number(base / 'scaling_cur_freq', 1000),
'minMHz': number(base / 'cpuinfo_min_freq', 1000),
'maxMHz': number(base / 'cpuinfo_max_freq', 1000)})
result['frequencies'] = freqs
if include_gpu:
result['gpu'] = gpu()
result['temperatures'] = temperatures()
if include_disk:
st = os.statvfs('/')
total = st.f_blocks * st.f_frsize
available = st.f_bavail * st.f_frsize
result['disk'] = {'total': total, 'available': available,
'used': total - st.f_bfree * st.f_frsize,
'filesystem': next((m.split()[2] for m in (read(PROC / 'mounts') or '').splitlines()
if len(m.split()) >= 3 and m.split()[1] == '/'), None)}
if include_processes:
result['processes'] = processes()
result['clockTicks'] = os.sysconf('SC_CLK_TCK')
return result
if __name__ == '__main__':
args = set(sys.argv[1:])
try:
print(json.dumps(snapshot('gpu' in args, 'disk' in args, 'processes' in args), separators=(',', ':')))
except OSError as exc:
print(json.dumps({'error': str(exc)}))
sys.exit(1)

View file

@ -1,12 +1,15 @@
#"$schema" = 'https://starship.rs/config-schema.json'
format = """
$os$username$hostname$directory$git_branch$git_status$python$lua$rust$php$nodejs${custom.venv}
$character
"""
right_format = """
$time$battery
"""
add_newline = true
# WHY: /tmp can contain thousands of entries; the 30 ms default intermittently
# aborts project detection before the prompt's language modules are resolved.
# CONTRACT: This is a scan ceiling, not a delay or an external-command timeout.
# INVARIANT: Scanning stays bounded; this setting does not run a command.
scan_timeout = 75
# WHY: Keep identity and location stable while showing project context only when present.
# CONTRACT: The final line is always the input character; contextual modules share one compact line.
# INVARIANT: No decorative subprocess or unconditional clock is needed for this layout.
format = "$os$username$hostname$directory\n($git_branch$git_status$python$lua$rust$php$nodejs${env_var.VIRTUAL_ENV}$cmd_duration\n)$character"
right_format = "$battery"
add_newline = false
palette = "catppuccin_mocha"
@ -51,6 +54,8 @@ symbol = " "
read_only = " 󰌾"
truncation_length = 3
truncation_symbol = '…/'
style = "bold lavender"
format = '[$path]($style)[$read_only]($read_only_style)'
[docker_context]
symbol = " "
@ -82,6 +87,24 @@ symbol = " "
[git_branch]
symbol = " "
format = '[$symbol$branch]($style) '
style = "bold blue"
truncation_length = 32
truncation_symbol = "…"
[git_status]
format = '([$all_status$ahead_behind]($style) )'
style = "peach"
conflicted = "="
ahead = "⇡${count}"
behind = "⇣${count}"
diverged = "⇕"
untracked = "?"
stashed = "*"
modified = "!"
staged = "+"
renamed = "»"
deleted = "✘"
[git_commit]
tag_symbol = '  '
@ -108,7 +131,7 @@ symbol = " "
disabled = false
ssh_only = false
format = "[@$hostname]($style) "
style = "bold lavender"
style = "subtext0"
ssh_symbol = " "
[java]
@ -127,7 +150,7 @@ disabled = false
[lua]
symbol = " "
format = 'via [ $version](bold blue) '
format = '[ $version](mauve) '
[memory_usage]
symbol = "󰍛 "
@ -143,14 +166,14 @@ symbol = " "
[nodejs]
symbol = " "
format = 'via [ $version](bold green) '
format = '[ $version](mauve) '
[ocaml]
symbol = " "
[os]
disabled = false
format = "[$symbol]($style)-"
format = "[$symbol]($style)"
style = "bold lavender"
[os.symbols]
@ -212,7 +235,7 @@ symbol = " "
[php]
symbol = " "
format = 'via [ $version](147 bold) '
format = '[ $version](mauve) '
[pijul_channel]
symbol = " "
@ -222,7 +245,7 @@ symbol = "󰏗 "
[python]
symbol = " "
style = "bold yellow"
style = "mauve"
format = '[$symbol($virtualenv )]($style) '
python_binary = ["./.venv/bin/python", "python", "python3"]
@ -234,7 +257,7 @@ symbol = " "
[rust]
symbol = "󱘗 "
format = 'via [󱘗 $version](red bold)'
format = '[󱘗 $version](mauve) '
[scala]
symbol = " "
@ -253,7 +276,7 @@ symbol = " "
symbol = " "
[time]
disabled = false
disabled = true
format = "[$time]($style) "
time_format = "%H:%M" # 24h, heures:minutes
style = "blue"
@ -265,10 +288,14 @@ format = "[$user]($style)"
style_user = "bold lavender"
style_root = "bold red"
[custom.venv]
command = "echo VENV-ACTIVE"
when = '[[ -n "$VIRTUAL_ENV" ]]'
format = '[ $output ](bold red) '
# WHY: Presence of VIRTUAL_ENV is enough; the previous custom module spawned a shell.
# CONTRACT: Retain the visible VENV-ACTIVE marker without external execution.
[env_var.VIRTUAL_ENV]
format = '[VENV-ACTIVE](mauve) '
[cmd_duration]
min_time = 2_000
format = '[󰔟 $duration](subtext0) '
[xmake]
symbol = " "

View file

@ -17,10 +17,12 @@ exec python3 ~/.config/sway/scripts/inactive-windows-transparency.py --opacity $
# =================================================
assign [app_id="limusic-app"] workspace 10
assign [app_id="firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded"] workspace 9
# =================================================
# Lancement des applications
# =================================================
exec "/usr/lib/firefox/firefox" "-taskbar-tab" "fcd72ebe-94c5-4209-b3f6-bbaa7784cded" "-new-window" "https://web.whatsapp.com/" "-container" "0"
exec limusic-app
exec autotiling

View file

@ -221,12 +221,12 @@ bindsym $mod+Alt+l exec ~/.local/bin/labfy-layout-toggle
bindsym $mod+Alt+p exec proton-pass
bindsym $mod+Alt+t exec ~/.local/bin/thunderbird-proton
bindsym $mod+Alt+y exec kitty -- sh -c "yazi"
bindsym $mod+Alt+w exec gtk-launch chrome-hnpfjngllnobngcgfapefoaidbinmjnm-Default
bindsym $mod+Alt+v exec ~/.config/sway/scripts/clipboard-quickshell
bindsym $mod+Alt+c exec --no-startup-id kitty --app-id=lardon-freecad-log --title="Lardon · FreeCAD autopush" --directory=/home/fy59/Documents/Lardon /home/fy59/Documents/Lardon/tools/lardon-freecad-launcher
bindsym $mod+Alt+s exec sqlitebrowser
bindsym $mod+Alt+e exec sh -c 'cd /home/fy59/Documents/Investigations/Template/labfy-investigation && exec ./labfy-investigation'
bindsym $mod+Alt+n exec ~/.local/bin/nextcloud-sync
bindsym $mod+Alt+w exec "/usr/lib/firefox/firefox" "-taskbar-tab" "fcd72ebe-94c5-4209-b3f6-bbaa7784cded" "-new-window" "https://web.whatsapp.com/" "-container" "0"
bindsym $mod+c exec codex-desktop
###############################################

View file

@ -5,6 +5,8 @@ After=graphical-session.target
[Service]
Type=simple
# CONTRACT: newly created database files remain private; no retention change.
UMask=0077
ExecStartPre=/bin/sh -c 'for i in $(seq 1 50); do [ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ] && exit 0; sleep 0.1; done; echo "No Wayland socket" >&2; exit 1'
ExecStart=/usr/bin/wl-paste --type image/png --watch cliphist store
Restart=on-failure

View file

@ -5,8 +5,11 @@ After=graphical-session.target
[Service]
Type=simple
# CONTRACT: newly created database files remain private; no retention change.
UMask=0077
ExecStartPre=/bin/sh -c 'for i in $(seq 1 50); do [ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ] && exit 0; sleep 0.1; done; echo "No Wayland socket" >&2; exit 1'
ExecStart=/usr/bin/wl-paste --watch cliphist store
# WHY: make the intended text MIME explicit alongside the PNG collector.
ExecStart=/usr/bin/wl-paste --type text --watch cliphist store
Restart=on-failure
[Install]

View file

@ -0,0 +1,18 @@
[Unit]
Description=Lardon3D LIVE capture over private Samsung hotspot (DEBUG HTTP)
Documentation=file:///home/fy59/Documents/Development/lardon3d-live-pc-v1/tools/live_scan_pc/README.md
StartLimitIntervalSec=0
[Service]
Type=simple
WorkingDirectory=/home/fy59/Documents/Development/lardon3d-live-pc-v1
ExecStart=/home/fy59/.local/share/lardon3d/mobile-scan-v1/start-mobile.sh
Restart=on-failure
RestartSec=30s
TimeoutStopSec=15s
KillSignal=SIGTERM
UMask=0077
NoNewPrivileges=true
[Install]
WantedBy=default.target

View file

@ -5,6 +5,8 @@ from pathlib import Path
import tempfile
import time
import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch
import gi
gi.require_version("GioUnix", "2.0")
@ -60,6 +62,71 @@ class LaunchTests(unittest.TestCase):
self.assertEqual(calls[0][0][0],
["uwsm", "app", "-t", "service", "--", "fixture.desktop"])
def test_quoted_firefox_style_exec_uses_desktop_id(self):
with tempfile.TemporaryDirectory() as temporary:
app = desktop(Path(temporary) / "fixture.desktop",
command='"/usr/bin/true" "-taskbar-tab" "fixture-id" '
'"-new-window" "https://example.invalid/" %U')
self.assertIsNotNone(app)
runner = Mock(return_value=SimpleNamespace(returncode=0))
backend.launch("fixture.desktop", loader=lambda _id: app, runner=runner)
self.assertEqual(runner.call_args.args[0],
["uwsm", "app", "-t", "service", "--", "fixture.desktop"])
def test_quoted_executable_path_with_spaces(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
executable = root / "program directory" / "fixture executable"
executable.parent.mkdir()
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o700)
app = desktop(root / "fixture.desktop",
command=f'"{executable}" "argument avec espace" %F')
self.assertIsNotNone(app)
runner = Mock(return_value=SimpleNamespace(returncode=0))
backend.launch("fixture.desktop", loader=lambda _id: app, runner=runner)
self.assertEqual(runner.call_args.args[0][-1], "fixture.desktop")
def test_quoted_kitty_is_not_wrapped_twice(self):
with tempfile.TemporaryDirectory() as temporary:
app = desktop(Path(temporary) / "kitty-fixture.desktop",
command='"/usr/bin/kitty" "--hold"', terminal=True)
self.assertIsNotNone(app)
self.assertEqual(backend.terminal_launch_info(app).get_commandline().count("kitty"), 1)
def test_unavailable_executable_is_still_refused(self):
for unavailable in ("removed", "not_executable"):
with self.subTest(unavailable=unavailable), tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
executable = root / "fixture-command"
executable.write_text("#!/bin/sh\nexit 0\n")
executable.chmod(0o700)
app = desktop(root / "fixture.desktop", command=f'"{executable}"')
self.assertIsNotNone(app)
if unavailable == "removed":
executable.unlink()
else:
executable.chmod(0o600)
runner = Mock()
with self.assertRaisesRegex(ValueError, "Exécutable introuvable"):
backend.launch("fixture.desktop", loader=lambda _id: app, runner=runner)
runner.assert_not_called()
def test_dbus_activation_does_not_require_exec(self):
app = Mock()
app.get_is_hidden.return_value = False
app.get_nodisplay.return_value = False
app.should_show.return_value = True
app.get_executable.return_value = None
app.get_boolean.side_effect = lambda key: key == "DBusActivatable"
app.get_commandline.side_effect = AssertionError("D-Bus has no Exec to parse")
app.launch.return_value = True
runner = Mock()
backend.launch("org.example.Fixture.desktop", loader=lambda _id: app, runner=runner)
app.launch.assert_called_once_with([], None)
app.get_commandline.assert_not_called()
runner.assert_not_called()
def test_terminal_preserves_desktop_exec_fields_and_kitty(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@ -93,6 +160,90 @@ class LaunchTests(unittest.TestCase):
backend.launch("hidden.desktop", loader=lambda _id: app,
runner=lambda *_args, **_kwargs: self.fail("launch called"))
def test_nextcloud_uses_gio_command_descriptor(self):
with tempfile.TemporaryDirectory() as temporary:
app = desktop(Path(temporary) / "fixture.desktop", command="/usr/bin/true %u")
prepared = Mock()
prepared.launch.return_value = True
runner = Mock()
with patch.object(backend, "uwsm_launch_info", return_value=prepared) as prepare:
backend.launch("com.nextcloud.desktopclient.nextcloud.desktop",
loader=lambda _id: app, runner=runner)
prepare.assert_called_once_with(app)
prepared.launch.assert_called_once_with([], None)
runner.assert_not_called()
def test_nextcloud_cloudproviders_and_source_are_preserved(self):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "fixture.desktop"
app = desktop(path, command="/usr/bin/true %u", extra=
"SingleMainWindow=true\nImplements=org.freedesktop.CloudProviders\n"
"[org.freedesktop.CloudProviders]\n"
"BusName=org.example.Fixture\nObjectPath=/org/example/Fixture\n")
before = path.read_bytes()
prepared = backend.uwsm_launch_info(app)
self.assertEqual(prepared.get_executable(), "uwsm")
self.assertEqual(prepared.get_commandline(),
"uwsm app -t service -- /usr/bin/true %u")
self.assertEqual(prepared.get_string("Implements"),
"org.freedesktop.CloudProviders")
self.assertTrue(prepared.get_boolean("SingleMainWindow"))
self.assertFalse(prepared.get_boolean("Terminal"))
self.assertFalse(prepared.get_boolean("DBusActivatable"))
self.assertEqual(path.read_bytes(), before)
def test_graphical_wrapper_expands_fields_without_shell_or_kitty(self):
for field in ("%u", "%F"):
with self.subTest(field=field), tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
bindir = root / "bin"
bindir.mkdir()
recorder = bindir / "uwsm"
recorder.write_text("#!/usr/bin/python3\nimport json,os,sys\n"
"with open(os.environ['LABFY_LAUNCH_TEST_CAPTURE'],'w') as f:"
" json.dump({'argv':sys.argv[1:],'cwd':os.getcwd()},f)\n")
recorder.chmod(0o700)
executable = root / "program with spaces"
executable.write_text("#!/bin/sh\nexit 99\n")
executable.chmod(0o700)
output = root / "captured.json"
entry = root / "entry with spaces.desktop"
app = desktop(entry,
command=f'"{executable}" "argument avec espace" {field} %c %k %%',
extra=f"Path={root}\n")
self.assertIsNotNone(app)
env = {"PATH": str(bindir) + os.pathsep + os.environ.get("PATH", ""),
"LABFY_LAUNCH_TEST_CAPTURE": str(output)}
with patch.dict(os.environ, env):
prepared = backend.uwsm_launch_info(app)
self.assertTrue(prepared.launch([], None))
deadline = time.monotonic() + 3
while not output.exists() and time.monotonic() < deadline:
time.sleep(.01)
self.assertTrue(output.exists())
observed = json.loads(output.read_text())
self.assertEqual(observed["argv"],
["app", "-t", "service", "--", str(executable),
"argument avec espace", "Fixture", str(entry), "%"])
self.assertEqual(observed["cwd"], str(root))
def test_nextcloud_launch_failure_and_missing_uwsm_are_reported(self):
with tempfile.TemporaryDirectory() as temporary:
app = desktop(Path(temporary) / "fixture.desktop", command="/usr/bin/true %u")
prepared = Mock()
prepared.launch.return_value = False
runner = Mock()
with patch.object(backend, "uwsm_launch_info", return_value=prepared):
with self.assertRaisesRegex(ValueError, "lancement refusée"):
backend.launch("com.nextcloud.desktopclient.nextcloud.desktop",
loader=lambda _id: app, runner=runner)
with patch.object(backend.shutil, "which",
side_effect=lambda name: None if name == "uwsm" else "/usr/bin/true"):
with self.assertRaisesRegex(ValueError, "UWSM indisponible"):
backend.launch("com.nextcloud.desktopclient.nextcloud.desktop",
loader=lambda _id: app, runner=runner)
runner.assert_not_called()
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,43 @@
"""Permission and diagnostic regressions with synthetic files only."""
import importlib.util
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
SOURCE=Path(__file__).resolve().parents[2]/'quickshell/.config/quickshell/labfy-sway/clipboard/backend.py'
spec=importlib.util.spec_from_file_location('clipboard_privacy',SOURCE)
b=importlib.util.module_from_spec(spec);spec.loader.exec_module(b)
class Privacy(unittest.TestCase):
def test_database_mode_is_tightened_without_content_change(self):
with tempfile.TemporaryDirectory() as directory:
p=Path(directory)/'db';p.write_bytes(b'TEST_ONLY');p.chmod(0o644)
b.command(str(p),'list')
self.assertEqual(p.stat().st_mode&0o777,0o600)
self.assertEqual(p.read_bytes(),b'TEST_ONLY')
def test_symlink_database_rejected_without_touching_target(self):
with tempfile.TemporaryDirectory() as directory:
target=Path(directory)/'sentinel';target.write_bytes(b'TEST_ONLY');target.chmod(0o644)
db=Path(directory)/'db';db.symlink_to(target)
r=subprocess.run([sys.executable,'-B',str(SOURCE),'list','--db-path',str(db)],capture_output=True,text=True)
self.assertNotEqual(r.returncode,0)
self.assertNotIn('TEST_ONLY',r.stdout+r.stderr)
self.assertNotIn(str(target),r.stdout+r.stderr)
self.assertEqual(target.stat().st_mode&0o777,0o644)
def test_thumbnail_symlink_and_insecure_runtime_rejected(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory)
with patch.dict(os.environ,LABFY_CLIPHIST_TEST_CACHE_DIR=directory):
(root/('labfy-cliphist-panel-'+str(os.getuid()))).symlink_to(root,target_is_directory=True)
with self.assertRaises((RuntimeError,OSError)):b.cache_dir()
root.chmod(0o755)
with patch.dict(os.environ,LABFY_CLIPHIST_TEST_CACHE_DIR=directory):
with self.assertRaises((RuntimeError,OSError)):b.cache_dir()
if __name__=='__main__':unittest.main()

View file

@ -15,7 +15,7 @@ afterEach(() => {
for (const f of fixtures.splice(0)) assert.deepEqual(f.forbiddenReads, []);
});
const plain = value => JSON.parse(JSON.stringify(value));
const flush = async () => { for (let i = 0; i < 80; ++i) await Promise.resolve(); };
const flush = async () => { for (let i = 0; i < 240; ++i) await Promise.resolve(); };
function event() {
const listeners = [];
return {addListener: fn => listeners.push(fn), emit: (...args) => listeners.map(fn => fn(...args))};
@ -25,6 +25,8 @@ async function fixture(options = {}) {
const timers = new Map(), values = options.values || new Map();
const state = {ids: options.ids || [1], values, forbiddenReads: [], writes: [], maps: [], updates: [], ports: [], reads: [], mode: "ok", creates: 0, read: options.read, write: options.write, enumerate: options.enumerate};
state.bootstrap = options.bootstrap; state.bootstraps = [];
state.classifications = []; state.kinds = options.kinds || new Map();
state.conIds = options.conIds || new Map();
fixtures.push(state);
const schedule = (fn, ms, interval = 0) => { const id = ++sequence; timers.set(id, {at: now + ms, fn, interval}); return id; };
// Poisoned properties make any navigation/content inspection fail the test.
@ -45,6 +47,18 @@ async function fixture(options = {}) {
postMessage(message) {
assert.equal(p.disconnected, false);
const m = plain(message);
if (m.op === "classify-window") {
assert.deepEqual(Object.keys(m).sort(), ["op", "runtime_token"]);
const marked = state.updates.findLast(u => u.titlePreface === `[LABFY:${m.runtime_token}] `);
assert.ok(marked, "classification must correlate its own prefix");
state.classifications.push({id: marked.id, ...m});
if (state.classifyHook) return state.classifyHook(p, m, marked.id);
const kind = state.kinds.get(marked.id) || "browser";
if (!["browser", "managed-webapp"].includes(kind)) p.onMessage.emit({result: "WINDOW_CLASS_UNSUPPORTED"});
else p.onMessage.emit({result: "OK", kind, runtime_token: m.runtime_token,
con_id: state.conIds.get(marked.id) ?? 1000 + marked.id});
return;
}
if (m.op === "bootstrap-eligibility") {
assert.deepEqual(m, {op: "bootstrap-eligibility"});
state.bootstraps.push({port: state.ports.indexOf(p)});
@ -59,7 +73,12 @@ async function fixture(options = {}) {
state.maps.push({port: state.ports.indexOf(p), ...m});
if (state.mode === "throw") throw new Error("synthetic post failure");
if (state.mode === "disconnect") { p.disconnect(); return; }
if (state.mode === "ok") p.onMessage.emit({result: "OK", uuid: m.uuid, con_id: 100 + state.maps.length});
if (state.mode === "ok") {
const marked = state.updates.findLast(u => u.titlePreface === `[LABFY:${m.runtime_token}] `);
assert.ok(marked, "mapping must correlate its own prefix");
if ((state.kinds.get(marked.id) || "browser") !== "browser") p.onMessage.emit({result: "CORRELATION_TIMEOUT"});
else p.onMessage.emit({result: "OK", uuid: m.uuid, con_id: state.conIds.get(marked.id) ?? 1000 + marked.id});
}
}};
state.ports.push(p);
return p;
@ -506,7 +525,7 @@ test("capture en vol retarde bootstrap ; onInstalled ne poste rien en concurrenc
f.install(); await f.tick(3000);
assert.equal(f.bootstraps.length, 0);
assert.equal(f.maps.length, 1);
f.ports.at(-1).onMessage.emit({result: "OK", uuid: A, con_id: 42});
f.ports.at(-1).onMessage.emit({result: "OK", uuid: A, con_id: 1001});
f.mode = "ok"; await f.tick(40000);
assert.equal(f.writes.length, 0); assert.equal(f.bootstraps.length, 0);
});
@ -577,3 +596,112 @@ test("bootstrap : réponses natives non exactes refusées sans écriture baselin
f.open(2); await f.tick(6000); assert.deepEqual(f.writes.map(w => w.id), [2]);
}
});
// Régression webapp : son type WebExtension est normal, mais ses extData ne
// sont pas persistées par Firefox. Seul le host peut attester son exclusion.
test("webapp premier ID sans UUID : seule la fenêtre navigateur est publiée", async () => {
const f = await fixture({ids: [1, 2], kinds: new Map([[1, "managed-webapp"]]), values: new Map([[2, B]])});
assert.equal((await capture(f)).result, "OK");
assert.deepEqual(f.maps.map(m => m.uuid), [B]);
assert.ok(f.classifications.some(c => c.id === 1));
assert.ok(f.reads.every(r => r.id === 2));
assert.deepEqual(f.writes, []);
});
test("webapp exclue de tout mint baseline ou Ctrl+N, navigateur nouveau conservé", async () => {
const f = await fixture({ids: [1, 2], install: true, bootstrap: true,
kinds: new Map([[1, "managed-webapp"]]), values: new Map([[2, B]])});
await ready(f); f.kinds.set(3, "managed-webapp"); f.open(3); f.open(4);
await f.tick(10000);
assert.deepEqual(f.writes.map(w => w.id), [4]);
assert.ok(f.reads.every(r => ![1, 3].includes(r.id)));
assert.equal(f.values.has(1), false); assert.equal(f.values.has(3), false);
assert.equal((await capture(f)).result, "OK");
});
test("navigateur normal sans UUID reste refusé même avec une webapp exclue", async () => {
const f = await fixture({ids: [1, 2], kinds: new Map([[1, "managed-webapp"]])});
const result = await capture(f);
assert.equal(result.result, "REFUSED"); assert.equal(result.reason, "INVALID_UUID");
assert.deepEqual(f.maps, []); assert.deepEqual(f.writes, []);
await f.tick(70000);
assert.equal((await f.status()).result, "REFUSED"); assert.deepEqual(f.writes, []);
});
test("classe inconnue : refus avant lecture, aucun retry périodique de marquage", async () => {
const f = await fixture({kinds: new Map([[1, "unknown"]]), values: new Map([[1, A]])});
await f.tick(2000);
const count = f.classifications.length;
await f.tick(70000);
assert.equal(f.classifications.length, count); assert.equal(count, 1);
assert.equal((await capture(f)).reason, "WINDOW_CLASS_UNAVAILABLE");
assert.deepEqual(f.reads, []); assert.deepEqual(f.maps, []); assert.deepEqual(f.writes, []);
});
test("classe ou con_id converti : preuve fraîche refusée durablement jusqu'au close", async () => {
for (const change of ["kind", "con_id"]) {
const f = await fixture({values: new Map([[1, A]])});
assert.equal((await capture(f)).result, "OK");
const published = f.maps.length;
if (change === "kind") f.kinds.set(1, "managed-webapp"); else f.conIds.set(1, 9999);
assert.equal((await capture(f)).reason, "WINDOW_CLASS_CHANGED");
f.kinds.clear(); f.conIds.clear();
assert.equal((await capture(f)).reason, "WINDOW_CLASS_CHANGED");
assert.equal(f.maps.length, published); assert.deepEqual(f.writes, []);
f.close(1); f.open(1); f.values.set(1, A);
assert.equal((await capture(f)).result, "OK");
}
});
test("webapp devenue navigateur : ne jamais conserver une exclusion ancienne", async () => {
const f = await fixture({ids: [1, 2], kinds: new Map([[1, "managed-webapp"]]), values: new Map([[2, B]])});
assert.equal((await capture(f)).result, "OK");
f.kinds.set(1, "browser"); f.values.set(1, A);
assert.equal((await capture(f)).reason, "WINDOW_CLASS_CHANGED");
assert.deepEqual(f.maps.map(m => m.uuid), [B]); assert.deepEqual(f.writes, []);
});
test("ensemble changé pendant classification : aucun UUID lu ni publié", async () => {
const f = await fixture({values: new Map([[1, A]])});
f.classifyHook = (p, m, id) => {
f.open(2); f.classifyHook = null;
p.onMessage.emit({result: "OK", kind: "browser", runtime_token: m.runtime_token, con_id: 1000 + id});
};
await f.capture();
assert.equal((await f.captureResult).reason, "WINDOW_SET_CHANGED");
// Before the next independent autoStep, this refused capture read no UUID.
assert.deepEqual(f.reads, []); assert.deepEqual(f.maps, []); assert.deepEqual(f.writes, []);
});
test("cache borné : aucun marquage idle après le premier classement", async () => {
const f = await fixture({values: new Map([[1, A]])});
await f.tick(2000); const classified = f.classifications.length, updates = f.updates.length;
await f.tick(90000);
assert.equal(classified, 1); assert.equal(f.classifications.length, classified);
assert.equal(f.updates.length, updates); assert.deepEqual(f.maps, []);
assert.equal((await capture(f)).result, "OK");
assert.equal(f.classifications.length, classified + 2);
});
test("réponse classification fermée : jeton, classe, con_id et champs exacts", async () => {
for (const change of [{runtime_token: "0".repeat(32)}, {kind: "skip"}, {con_id: 0}, {con_id: true}, {extra: true}]) {
const f = await fixture({values: new Map([[1, A]])});
f.classifyHook = (p, m) => p.onMessage.emit({result: "OK", kind: "browser", runtime_token: m.runtime_token, con_id: 1001, ...change});
assert.equal((await capture(f)).reason, "WINDOW_CLASS_UNAVAILABLE");
assert.deepEqual(f.reads, []); assert.deepEqual(f.maps, []); assert.deepEqual(f.writes, []);
}
});
test("conversion juste avant mint : aucune écriture sous une classe périmée", async () => {
const f = await fixture({values: new Map([[1, A]])}); await ready(f);
f.classifyHook = (p, m, id) => {
const count = f.classifications.filter(c => c.id === id).length;
p.onMessage.emit({result: "OK", kind: id === 2 && count > 1 ? "managed-webapp" : "browser",
runtime_token: m.runtime_token, con_id: 1000 + id});
};
f.open(2); await f.tick(6000);
assert.deepEqual(f.writes, []); assert.equal(f.values.has(2), false);
assert.equal((await capture(f)).reason, "WINDOW_CLASS_CHANGED");
});

View file

@ -1,5 +1,6 @@
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
@ -14,6 +15,16 @@ spec.loader.exec_module(backend)
class OverviewBackendTests(unittest.TestCase):
def setUp(self):
# CONTRACT: even secondary state reads never reach the personal cache.
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
env = patch.dict(os.environ, {"XDG_RUNTIME_DIR": self.temp.name,
"XDG_CONFIG_HOME": self.temp.name})
env.start(); self.addCleanup(env.stop)
previous = os.umask(0o077)
self.addCleanup(os.umask, previous)
def test_lock_guard_blocks_all_capture_entrypoints(self):
# CONTRACT: no compositor read or grim call follows an active lock
# guard, including the window-preview path.
@ -111,10 +122,10 @@ class OverviewBackendTests(unittest.TestCase):
original = backend.structure_revision(workspace, workspaces[0], rect)
backend.snapshot_metadata_path("eDP-1", 1).write_text(json.dumps({
"version": 1, "output": "eDP-1", "number": 1, "workspaceId": 4,
"revision": original, "capturedNs": 123, "imageMtimeNs": path.stat().st_mtime_ns}))
self.assertNotEqual(backend.state()["cards"][0]["snapshot"], "")
"revision": original, "capturedNs": 123, "lockRevision": [], "imageMtimeNs": path.stat().st_mtime_ns}))
self.assertEqual(backend.state()["cards"][0]["snapshot"], "")
window["name"] = "Titre changé"
self.assertNotEqual(backend.state()["cards"][0]["snapshot"], "")
self.assertEqual(backend.state()["cards"][0]["snapshot"], "")
window["rect"] = {**rect, "width": 500}
changed = backend.state()["cards"][0]
self.assertEqual(changed["snapshot"], "")
@ -146,24 +157,10 @@ class OverviewBackendTests(unittest.TestCase):
self.assertFalse(backend.capture("eDP-1")["captured"])
self.assertFalse(backend.snapshot_path("eDP-1", 1).exists())
def test_capture_publishes_metadata_only_for_stable_visible_workspace(self):
card = {"number": 7, "workspaceId": 164, "revision": "stable", "visible": True,
"output": "eDP-1", "windows": [{"id": 42}]}
def fake_grim(args, **_kwargs):
Path(args[-1]).write_bytes(b"JPEG")
return subprocess.CompletedProcess(args, 0, "", "")
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
patch.object(backend, "state", return_value={"cards": [card], "overlayOutputs": []}), \
patch.object(backend.subprocess, "run", side_effect=fake_grim):
result = backend.capture("eDP-1", expected_number=7, expected_workspace_id=164)
self.assertTrue(result["captured"])
self.assertIsNotNone(backend.valid_snapshot("eDP-1", 7, 164, "stable"))
self.assertIsNone(backend.valid_snapshot("eDP-1", 7, 164, "changed"))
self.assertIsNone(backend.valid_snapshot("eDP-1", 7, 999, "stable"))
self.assertFalse(backend.capture("eDP-1", expected_number=8)["captured"])
self.assertFalse(backend.capture("eDP-1", expected_revision="changed")["captured"])
def test_composed_capture_disabled_even_on_visible_workspace(self):
# Whole-output capture would leak excluded applications behind a card.
with patch.object(backend.subprocess, "run", side_effect=AssertionError("grim reached")):
self.assertEqual(backend.capture("eDP-1"), {"captured": False})
def test_removed_workspace_cleans_only_its_cache_entries(self):
tree = {"type": "root", "nodes": [], "floating_nodes": []}
@ -199,14 +196,14 @@ class OverviewBackendTests(unittest.TestCase):
path.write_bytes(b"JPEG")
backend.window_preview_metadata_path(42).write_text(json.dumps({
"version": 1, "conId": 42, "foreignId": "a" * 32,
"capturedNs": 123, "imageMtimeNs": path.stat().st_mtime_ns}))
"capturedNs": 123, "lockRevision": [], "imageMtimeNs": path.stat().st_mtime_ns}))
before = backend.state()["cards"]
self.assertNotEqual(before[6]["windows"][0]["preview"], "")
source["nodes"] = []
destination["nodes"] = [window]
moved = backend.state()["cards"]
self.assertEqual(moved[6]["windows"], [])
self.assertEqual(moved[7]["windows"][0]["preview"], path.as_uri())
self.assertEqual(moved[7]["windows"][0]["preview"], backend.image_url(path))
destination["nodes"] = []
backend.state()
self.assertFalse(path.exists())
@ -222,6 +219,7 @@ class OverviewBackendTests(unittest.TestCase):
return subprocess.CompletedProcess(args, 0, b"", b"")
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
patch.object(backend, "overview_open", return_value=True), \
patch.object(backend.subprocess, "run", side_effect=fake_grim) as run:
with patch.object(backend, "state", side_effect=[before, before]):
result = backend.capture_windows({7})
@ -244,13 +242,16 @@ class OverviewBackendTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
patch.object(backend, "state", return_value=cards), \
patch.object(backend, "overview_open", return_value=True), \
patch.object(backend.subprocess, "run", side_effect=fake_grim) as run:
first = backend.capture_windows({7})
self.assertEqual(first["captured"], list(range(1, 17)))
self.assertEqual(first["nextCursor"], 16)
second = backend.capture_windows({7}, cursor=first["nextCursor"])
self.assertEqual(second["captured"], [17])
self.assertEqual(second["nextCursor"], 0)
self.assertEqual(first["captured"], list(range(1, 5)))
self.assertEqual(first["nextCursor"], 4)
captured = first["captured"]
while first["nextCursor"]:
first = backend.capture_windows({7}, cursor=first["nextCursor"])
captured += first["captured"]
self.assertEqual(captured, list(range(1, 18)))
self.assertEqual(run.call_count, 17)

View file

@ -0,0 +1,100 @@
"""Synthetic cache/IPC only: never inspect or capture personal windows."""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
spec=importlib.util.spec_from_file_location('overview_privacy',Path(__file__).resolve().parents[2]/'quickshell/.config/quickshell/labfy-sway/overview/backend.py')
b=importlib.util.module_from_spec(spec);spec.loader.exec_module(b)
class Privacy(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
self.root=Path(self.temp.name)
e=patch.dict(os.environ,XDG_RUNTIME_DIR=self.temp.name,XDG_CONFIG_HOME=self.temp.name)
e.start();self.addCleanup(e.stop)
old=os.umask(0o077);self.addCleanup(os.umask,old)
self.window={'id':42,'app':'TEST_ONLY','foreignId':'b'*32}
self.model={'cards':[{'number':1,'windows':[self.window]}]}
def write_policy(self,value):
p=self.root/'labfy-sway/overview-capture.json';p.parent.mkdir(exist_ok=True)
p.write_text(json.dumps(value))
def test_no_capture_when_closed(self):
with patch.object(b,'overview_open',return_value=False),patch.object(b.subprocess,'run',side_effect=AssertionError('capture while closed')):
self.assertEqual(b.capture_windows({1})['captured'],[])
def test_disabled_policy_and_exclusions_do_not_reach_grim(self):
for rules in [{'enabled':False},{'excludeApps':['TEST_*']}]:
self.write_policy(rules)
with patch.object(b,'overview_open',return_value=True),patch.object(b,'state',return_value=self.model),patch.object(b.subprocess,'run',side_effect=AssertionError('excluded capture')):
self.assertEqual(b.capture_windows({1})['captured'],[])
def test_invalid_policy_fails_closed(self):
self.write_policy({'enabled':'yes'})
self.assertFalse(b.policy()['enabled'])
p=self.root/'labfy-sway/overview-capture.json';p.unlink();p.symlink_to('/dev/zero')
self.assertFalse(b.policy()['enabled'])
def test_directory_symlink_and_insecure_mode_rejected(self):
outside=self.root/'outside';outside.mkdir();target=self.root/'labfy-workspace-overview'
target.symlink_to(outside,target_is_directory=True)
with self.assertRaises(OSError):b.cache_dir()
target.unlink();target.mkdir(mode=0o755);target.chmod(0o755)
with self.assertRaises(RuntimeError):b.cache_dir()
self.assertEqual(target.stat().st_mode&0o777,0o755)
def test_symlink_manifest_and_image_never_followed(self):
sentinel=self.root/'sentinel';sentinel.write_bytes(b'KEEP')
b.window_preview_path(42).symlink_to(sentinel)
self.assertIsNone(b.valid_window_preview(42,'b'*32))
b.purge()
self.assertEqual(sentinel.read_bytes(),b'KEEP')
def test_purge_does_not_touch_unrecognized_files(self):
c=b.cache_dir();(c/'unrelated.txt').write_text('KEEP');(c/'window-1.jpg').write_bytes(b'JPEG')
b.purge();self.assertTrue((c/'unrelated.txt').exists());self.assertFalse((c/'window-1.jpg').exists())
def test_count_and_byte_quotas(self):
c=b.cache_dir()
for i in range(6):(c/f'window-{i}.jpg').write_bytes(b'x'*8)
with patch.object(b,'MAX_CACHE_FILES',4),patch.object(b,'MAX_CACHE_BYTES',16):b.enforce_quota()
files=list(c.glob('*.jpg'));self.assertLessEqual(len(files),4);self.assertLessEqual(sum(p.stat().st_size for p in files),16)
def test_lock_during_capture_rejects_pixels_and_reaps_temporary(self):
def grim(args,**kwargs):
Path(args[-1]).write_bytes(b'JPEG')
(self.root/'labfy-lock.capture-guard').write_text('active\n')
return subprocess.CompletedProcess(args,0)
with patch.object(b,'overview_open',return_value=True),patch.object(b,'state',return_value=self.model),patch.object(b.subprocess,'run',side_effect=grim):
self.assertEqual(b.capture_windows({1})['captured'],[])
self.assertFalse(list(b.cache_dir().glob('*.jpg')))
def test_capture_timeout_cleans_temporary(self):
with patch.object(b,'overview_open',return_value=True),patch.object(b,'state',return_value=self.model),patch.object(b.subprocess,'run',side_effect=subprocess.TimeoutExpired('grim',2)):
self.assertEqual(b.capture_windows({1})['captured'],[])
self.assertFalse(list(b.cache_dir().glob('tmp*')))
def test_policy_change_purges_excluded_cached_window(self):
rect={'x':0,'y':0,'width':100,'height':100}
node={'type':'workspace','id':1,'num':1,'nodes':[dict(type='con',id=42,app_id='TEST_ONLY',rect=rect)],'rect':rect}
data={'get_tree':node,'get_workspaces':[dict(id=1,num=1,output='HEADLESS-1')],'get_outputs':[]}
(b.cache_dir()/'window-42.jpg').write_bytes(b'JPEG')
self.write_policy({'excludeApps':['TEST_ONLY']})
with patch.object(b,'sway',side_effect=lambda kind:data[kind]):
row=b.state()['cards'][0]['windows'][0]
self.assertTrue(row['excluded']);self.assertEqual(row['preview'],'');self.assertFalse((b.cache_dir()/'window-42.jpg').exists())
def test_lock_epoch_invalidates_previous_preview(self):
p=b.window_preview_path(42);p.write_bytes(b'JPEG')
b.window_preview_metadata_path(42).write_text(json.dumps(dict(version=1,conId=42,foreignId='b'*32,lockRevision=[],capturedNs=1,imageMtimeNs=p.stat().st_mtime_ns)))
self.assertIsNotNone(b.valid_window_preview(42,'b'*32))
(self.root/'labfy-lock.capture-guard').write_text('released\n')
self.assertIsNone(b.valid_window_preview(42,'b'*32))
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,222 @@
"""TEST_ONLY: real Firefox webapp-first capture under a private Sway and profile.
No user browser, profile, bus, socket, checkpoint, or extension is modified.
Marionette system access is enabled only for this owned disposable browser.
"""
import argparse
import http.server
import json
import os
from pathlib import Path
import shutil
import socket
import threading
import time
from common import BACKEND
from lab import Lab, until
from session_v2.ipc import Sway
from session_v2.firefox_identity import FirefoxProvider
from session_v2.errors import Failure
APP_UUID = "fcd72ebe-94c5-4209-b3f6-bbaa7784cded"
WEBAPP = "firefox.webapp-" + APP_UUID
WINDOW_UUID = "11111111-2222-4333-8444-555555555555"
KEY = "extension:session-v2@labfy.org:labfy.session.window_uuid"
class TestPage(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = b"<!doctype html><title>TEST_ONLY</title><p>TEST_ONLY</p>"
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_):
pass
class Marionette:
def __init__(self, port):
end = time.monotonic() + 20
while True:
try:
self.connection = socket.create_connection(("127.0.0.1", port), timeout=2)
break
except OSError:
if time.monotonic() > end:
raise RuntimeError("TEST_FIREFOX_NOT_READY")
time.sleep(.1)
self.connection.settimeout(25)
hello = self.receive()
assert hello == {"applicationType": "gecko", "marionetteProtocol": 3}
self.sequence = 0
self.call("WebDriver:NewSession", {"capabilities": {}})
self.call("Marionette:SetContext", {"value": "chrome"})
def receive(self):
header = bytearray()
while not header.endswith(b":"):
part = self.connection.recv(1)
if not part:
raise RuntimeError("MARIONETTE_CLOSED")
header.extend(part)
assert len(header) < 12
length = int(header[:-1])
assert 0 < length < 4 * 1024 * 1024
data = bytearray()
while len(data) < length:
part = self.connection.recv(length - len(data))
if not part:
raise RuntimeError("MARIONETTE_CLOSED")
data.extend(part)
return json.loads(data)
def call(self, name, arguments):
self.sequence += 1
raw = json.dumps([0, self.sequence, name, arguments]).encode()
self.connection.sendall(str(len(raw)).encode() + b":" + raw)
response = self.receive()
assert response[:2] == [1, self.sequence], response
if response[2] is not None:
raise RuntimeError(response[2])
return response[3]
def script(self, body, arguments=()):
script = """const done = arguments[arguments.length - 1];
const args = Array.from(arguments).slice(0, -1);
(async () => { BODY })().then(
value => done({ok: true, value}),
error => done({ok: false, error: String(error), stack: error.stack})
);""".replace("BODY", body)
result = self.call("WebDriver:ExecuteAsyncScript", {
"script": script, "args": list(arguments), "newSandbox": True,
"sandbox": "default", "line": 1, "filename": "TEST_ONLY"
})["value"]
assert result["ok"], result
return result.get("value")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--extension", type=Path, required=True)
parser.add_argument("--expect", choices=("success", "unavailable"), required=True)
args = parser.parse_args()
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), TestPage)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with Lab() as lab:
print("TEST_ONLY_DIRECTORY=" + str(lab.directory), flush=True)
profile = lab.directory / "firefox-profile"
profile.mkdir(mode=0o700)
extension = lab.directory / "companion.xpi"
shutil.copyfile(args.extension, extension)
reservation = socket.socket()
reservation.bind(("127.0.0.1", 0))
port = reservation.getsockname()[1]
reservation.close()
url = f"http://127.0.0.1:{server.server_port}/TEST_ONLY"
prefs = {
"marionette.port": port, "browser.taskbarTabs.enabled": True,
"browser.startup.page": 0, "browser.startup.homepage": "about:blank",
"browser.shell.checkDefaultBrowser": False,
"browser.aboutwelcome.enabled": False,
"startup.homepage_welcome_url": "",
"datareporting.policy.dataSubmissionEnabled": False,
"toolkit.telemetry.reportingpolicy.firstRun": False,
}
(profile / "user.js").write_text("".join(
"user_pref(" + json.dumps(k) + ", " + json.dumps(v) + ");\n"
for k, v in prefs.items()))
taskbars = profile / "taskbartabs"
taskbars.mkdir(mode=0o700)
(taskbars / "taskbartabs.json").write_text(json.dumps({
"version": 1, "taskbarTabs": [{
"id": APP_UUID, "scopes": [{"hostname": "127.0.0.1"}],
"startUrl": url, "name": "TEST_ONLY", "userContextId": 0,
}]
}))
native_dir = Path(lab.env["XDG_CONFIG_HOME"]) / "mozilla/native-messaging-hosts"
native_dir.mkdir(parents=True, mode=0o700)
(Path(lab.env["HOME"]) / ".mozilla").symlink_to(native_dir.parent)
(native_dir / "org.labfy.session_v2_identity.json").write_text(json.dumps({
"name": "org.labfy.session_v2_identity",
"description": "TEST_ONLY", "type": "stdio",
"path": str(BACKEND / "session-v2-native-host.py"),
"allowed_extensions": ["session-v2@labfy.org"],
}))
# Existing state prevents automatic baseline mint in BOTH versions.
automatic = Path(lab.env["XDG_STATE_HOME"]) / "labfy-sway/session-v2/automatic"
automatic.mkdir(parents=True, mode=0o700)
(automatic / "last.json").write_text('{"TEST_ONLY":true}\n')
env = dict(lab.env, MOZ_ENABLE_WAYLAND="1", MOZ_CRASHREPORTER_DISABLE="1")
lab.spawn([
"/usr/bin/dbus-run-session", "--", "/usr/lib/firefox/firefox",
"--no-remote", "--profile", str(profile), "--marionette",
"-remote-allow-system-access", "-taskbar-tab", APP_UUID,
"-new-window", url, "-container", "0",
], env=env)
control = Marionette(port)
sway = Sway(lab.socket)
until(lambda: any(n["app_id"] == WEBAPP for n in sway.windows()), timeout=10)
initial = [n["app_id"] for n in sway.windows()]
assert initial == [WEBAPP], initial
created = control.script("""
const {BrowserWindowTracker} = ChromeUtils.importESModule("resource:///modules/BrowserWindowTracker.sys.mjs");
const {SessionStore} = ChromeUtils.importESModule("moz-src:///browser/components/sessionstore/SessionStore.sys.mjs");
const win = await BrowserWindowTracker.promiseOpenWindow({});
await SessionStore.promiseInitialized;
SessionStore.setCustomWindowValue(win, args[0], JSON.stringify(args[1]));
return {normalID: win.docShell.browsingContext.id};
""", [KEY, WINDOW_UUID])
assert isinstance(created.get("normalID"), int), created
until(lambda: any(n["app_id"] == "firefox" for n in sway.windows()), timeout=10)
installed = control.script("""
const {AddonManager} = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");
const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
file.initWithPath(args[0]);
const addon = await AddonManager.installTemporaryAddon(file);
return {id: addon.id, version: addon.version};
""", [str(extension)])
assert installed["id"] == "session-v2@labfy.org"
print("INSTALLED_TEST_ONLY=" + json.dumps(installed), flush=True)
time.sleep(2)
nodes = [n for n in sway.windows() if n["app_id"] == "firefox"]
assert len(nodes) == 1
runtime = Path(lab.env["XDG_RUNTIME_DIR"]) / "labfy-sway"
provider = FirefoxProvider(sway, runtime)
result = {}
try:
mapping = provider.capture(nodes, timeout=6)
result = {"status": "success", "mapping_matches": mapping == {WINDOW_UUID: nodes[0]["id"]}}
assert result["mapping_matches"], result
except Failure as error:
result = {"status": "unavailable", "reason": error.code}
assert error.code == "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE"
metadata = control.script("""
const {SessionStore} = ChromeUtils.importESModule("moz-src:///browser/components/sessionstore/SessionStore.sys.mjs");
const windows = Array.from(Services.wm.getEnumerator("navigator:browser"));
return windows.map(win => ({
runtimeWindowID: win.docShell.browsingContext.id,
value: SessionStore.getCustomWindowValue(win, args[0]) || null
}));
""", [KEY])
assert len(metadata) == 2, metadata
assert [r["value"] for r in metadata if r["runtimeWindowID"] != created["normalID"]] == [None], metadata
assert [r["value"] for r in metadata if r["runtimeWindowID"] == created["normalID"]] == [json.dumps(WINDOW_UUID)], metadata
assert result["status"] == args.expect, result
result.update(extension_version=installed["version"], webapp_first=True,
webapp_never_enrolled=True, browser_uuid_unchanged=True,
isolated_profile=True, isolated_sway=True)
(lab.directory / "firefox-webapp-result.json").write_text(json.dumps(result, indent=2))
print("FIREFOX_WEBAPP_RUNTIME=" + json.dumps(result), flush=True)
control.connection.close()
finally:
server.shutdown()
server.server_close()
if __name__ == "__main__":
main()

View file

@ -167,8 +167,20 @@ def run_case(case):
thread.start()
return []
class Provider:
def resolve(self, expected, nodes):
assert expected == [UUID] and len(nodes) == 1
def __init__(self): self.resolutions = 0
def resolve(self, expected, nodes, timeout=6):
assert set(expected) == {UUID} and len(nodes) == 1
self.resolutions += 1
if self.resolutions == 1:
# Admission is read-only; these scenarios inject races
# only at the later transactional identity resolution.
assert not (runtime / JOURNAL).exists()
assert not (runtime / 'session-v2-restore-attempt.json').exists()
assert evidence['commands'] == []
observe('provider-admission')
return {UUID: nodes[0]['id']}
assert self.resolutions == 2
assert (runtime / JOURNAL).exists() and evidence['commands']
observe('provider-resolve')
if case == 'focus-third-party':
lab.command(f'[con_id={foreign_node["id"]}] focus')
@ -188,7 +200,7 @@ def run_case(case):
kitty = next(n for n in sway.windows() if n['app_id'] == 'kitty')
lab.command(f'[con_id={kitty["id"]}] move container to workspace "TEST_ONLY_MOVED"')
return {UUID: nodes[0]['id']}
def capture(self, nodes): return {UUID: nodes[0]['id']}
def capture(self, nodes, timeout=6): return {UUID: nodes[0]['id']}
runtime = private_dir(lab.directory / 'runtime')
helper = lab.spawn(['/usr/bin/python', '/usr/bin/autotiling', '--debug']) if case == 'autotiling' else None
def helpers(path):
@ -339,11 +351,162 @@ def run_case(case):
return evidence
def nested_focus_layout(lab, apps, history):
"""Synthetic equivalent of the admitted unary ancestry, plus one owned leaf.
Deleting a nonfocused unary branch can update the hidden global seat stack
without changing the direct-child focus arrays published by IPC.
"""
from session_v2.ipc import walk, workspace
lab.command('workspace "1"')
ids = {}
for name in "ABCDX":
node = apps.create("TEST_ONLY_app-" + name)
ids[name] = node["id"]
lab.command(f'[con_id={node["id"]}] mark TEST_ONLY_{name}')
lab.command(f'[con_id={ids["B"]}] splitv')
lab.command(f'[con_id={ids["C"]}] move container to mark TEST_ONLY_B')
lab.command(f'[con_id={ids["C"]}] splith')
lab.command(f'[con_id={ids["D"]}] move container to mark TEST_ONLY_C')
lab.command(f'[con_id={ids["X"]}] move container to mark TEST_ONLY_D')
for name in "ABCDX":
lab.command(f'[con_id={ids[name]}] splitv')
parent = next(n for n in walk(lab.tree()) if any(c["id"] == ids["A"] for c in n.get("nodes", [])))
lab.command(f'[con_id={parent["id"]}] layout tabbed')
lab.command(f'[con_id={ids["A"]}] splitv')
lab.command(f'[con_id={parent["id"]}] layout splitv')
for _ in range(2):
lab.command(f'[con_id={ids["B"]}] focus')
for _depth in range(20):
if focused(lab.tree())["con_id"] is None: break
lab.command("focus parent")
else: raise AssertionError("TEST_ONLY_WORKSPACE_FOCUS_DEPTH")
lab.command("splith")
for name in "ADCB":
lab.command(f'[con_id={ids[name]}] focus')
lab.command(f'[con_id={ids["C"]}] focus')
if history == "raw-reap":
lab.command(f'[con_id={ids["B"]}] focus')
lab.command(f'[con_id={ids["X"]}] move container to workspace "TEST_ONLY_OTHER"')
if history == "ordinary":
lab.command(f'[con_id={ids["B"]}] focus')
ids["outer"] = workspace(lab.tree(), "1")["nodes"][0]["id"]
return ids
def run_nested_native():
from session_v2.recovery import command_focus
from session_v2.ipc import walk
evidence = {"test_only": True, "real_login_validated": False, "real_applications": False,
"scenario": "nested-focus-stack", "histories": []}
for history in ("ordinary", "raw-reap"):
with Lab() as lab:
apps = Surfaces(lab, build_helper(lab.directory))
try:
ids = nested_focus_layout(lab, apps, history)
before = lab.tree()
row = {"history": history, "version": lab.ipc(kind="get_version"), "ids": ids,
"admission": before, "moves": []}
for name in "AB":
tree = lab.tree()
op = 'move container to workspace "TEST_ONLY_STAGE"'
expected = command_focus(tree, ids[name], op)
lab.command(f'[con_id={ids[name]}] ' + op)
row["moves"].append({"target": name, "predicted": expected, "actual": focused(lab.tree())})
expected_id = ids["outer"] if history == "ordinary" else ids["C"]
row["requirement_met"] = row["moves"][-1]["actual"] == {"con_id": expected_id, "workspace": "1"}
evidence["histories"].append(row)
finally: apps.close()
# IDs are allocated in identical order. Prove the relevant public topology
# and each direct-child focus order agree before the same moves.
def public(tree):
return [(n["id"], n.get("type"), n.get("layout"), n.get("app_id"), n.get("focused"),
n.get("focus", []), [c["id"] for c in n.get("nodes", [])],
[c["id"] for c in n.get("floating_nodes", [])]) for n in walk(tree)]
evidence["same_public_tree"] = public(evidence["histories"][0]["admission"]) == public(evidence["histories"][1]["admission"])
evidence["requirement_met"] = evidence["same_public_tree"] and all(r["requirement_met"] for r in evidence["histories"])
evidence["outcome"] = "distinct-native-focus" if evidence["requirement_met"] else "native-fixture-mismatch"
atomic(private_dir(EVIDENCE) / "login-focus-nested-focus-stack.json", evidence)
return evidence
def run_nested_staging(case="nested-staging"):
from session_v2.ipc import walk
from lab import shape
evidence = {"test_only": True, "real_login_validated": False, "real_applications": False,
"scenario": case, "commands": []}
with Lab() as lab:
apps = Surfaces(lab, build_helper(lab.directory))
try:
ids = nested_focus_layout(lab, apps, "raw-reap")
if case == "nested-staging-internal":
branch = next(n for n in walk(lab.tree()) if n.get("type") == "con" and
not n.get("app_id") and
{c["id"] for c in walk(n) if c.get("app_id")} == {ids["C"], ids["D"]})
lab.command(f'[con_id={branch["id"]}] focus')
evidence["initial_internal_focus"] = branch["id"]
if case == "nested-staging-foreign":
lab.command(f'[con_id={ids["X"]}] move container to workspace "1"')
lab.command('rename workspace "1" to "TEST_ONLY_SOURCE"')
lab.command(f'[con_id={ids["B"]}] focus')
data = fixture({"splith": ["A", {"splitv": ["B", {"splith": ["C", "D"]}]}]}, "1")
data["focus"] = "B"
sway = Sway(lab.socket)
runtime = private_dir(lab.directory / "runtime")
evidence.update(version=lab.ipc(kind="get_version"), ids=ids, before=sway.tree())
raw_command = sway.command
def command(text):
row = {"command": text, "before": focused(sway.tree())}
evidence["commands"].append(row)
try: return raw_command(text)
finally: row["after"] = focused(sway.tree())
sway.command = command
try:
with patch("session_v2.guard.discover", return_value=[]):
Executor(sway, catalog(data), None, runtime, build(lab.directory), lab.env).apply(data, True)
evidence["outcome"] = "success"
except Failure as exc:
evidence["outcome"] = exc.code
evidence.update(after=sway.tree(),
journal=read(runtime / JOURNAL) if (runtime / JOURNAL).exists() else None,
report=read(runtime / "session-v2-restore-attempt.json")
if (runtime / "session-v2-restore-attempt.json").exists() else None)
report, journal = evidence["report"], evidence["journal"]
if case == "nested-staging-foreign":
evidence["requirement_met"] = (
evidence["outcome"] == "RESTORE_FOCUS_STAGING_UNPROVEN" and
report is None and journal is None and evidence["commands"] == [] and
focused(sway.tree()) == {"con_id": ids["B"], "workspace": "TEST_ONLY_SOURCE"} and
{n["id"] for n in sway.windows()} == {ids[n] for n in "ABCDX"})
return evidence
evidence["requirement_met"] = (
evidence["outcome"] == "success" and report["final_tree_verified"] and
report["final_focus_verified"] and report["anchors_swapped"] == 4 and
report["slots_filled"] == 4 and journal["status"] == "complete" and
focused(sway.tree()) == {"con_id": ids["B"], "workspace": "1"} and
shape(sway.tree(), "1", {ids[n]: n for n in "ABCD"}) ==
{"splith": ["A", {"splitv": ["B", {"splith": ["C", "D"]}]}]})
assert not any(n.get("app_id", "").startswith("labfy-v2-anchor-") for n in sway.windows())
assert location(sway.tree(), ids["X"])[0] == "TEST_ONLY_OTHER"
if evidence["requirement_met"]:
staged = [int(r["command"].split("=", 1)[1].split("]", 1)[0])
for r in evidence["commands"] if r["command"].endswith(json.dumps(journal["stage"]))
and r["command"].startswith("[con_id=")]
assert staged[:4] == [ids[n] for n in ("ABCD" if case == "nested-staging-internal" else "ACDB")], staged
assert not any(location(sway.tree(), n["id"])[0] == journal["stage"] for n in sway.windows())
finally:
atomic(private_dir(EVIDENCE) / ("login-focus-" + case + ".json"), evidence)
apps.close()
return evidence
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--case', choices=['natural','launch-failure-retry','preserve-empty-workspace','preserve-foreign','focus-third-party','focus-same-client','unowned-map','pinned-map','map-in-ipc-gap','provider-placement','crash-mid-ipc','autotiling','focus-helper-late','focus-external-late','focus-finish-late'], default='natural')
parser.add_argument('--case', choices=['nested-focus-stack','nested-staging','nested-staging-internal','nested-staging-foreign','natural','launch-failure-retry','preserve-empty-workspace','preserve-foreign','focus-third-party','focus-same-client','unowned-map','pinned-map','map-in-ipc-gap','provider-placement','crash-mid-ipc','autotiling','focus-helper-late','focus-external-late','focus-finish-late'], default='natural')
args = parser.parse_args()
row = run_case(args.case)
row = (run_nested_native() if args.case == "nested-focus-stack" else
run_nested_staging(args.case) if args.case.startswith("nested-staging") else run_case(args.case))
print(json.dumps({'scenario': args.case, 'requirement_met': row['requirement_met'],
'outcome': row['outcome'], 'real_login_validated': False}))
raise SystemExit(0 if row['requirement_met'] else 2)

View file

@ -34,8 +34,8 @@ def run_case(case):
lab.command('layout splitv')
injected.append(True)
class Provider:
def resolve(self, expected, nodes): return {UUID: nodes[0]['id']}
def capture(self, nodes):
def resolve(self, expected, nodes, timeout=6): return {UUID: nodes[0]['id']}
def capture(self, nodes, timeout=6):
captures.append(True)
# Trois vérifications : avant focus, après resume, après finish.
if case in ('last-capture-layout', 'last-capture-swap') and len(captures) == 3:

View file

@ -46,11 +46,14 @@ def run_case(case):
engine = Executor(sway, Catalog(), None, private_dir(lab.directory / 'restore'), build(lab.directory), lab.env)
identities = {UUID: first['id'], UUID2: second['id']}
class Provider:
def resolve(self, expected, nodes):
calls = 0
def resolve(self, expected, nodes, timeout=6):
self.calls += 1
if self.calls == 1: return identities.copy()
row['commands_avant_provider'] = len(row['commands'])
if case == 'provider-unaire': inject('provider')
return identities.copy()
def capture(self, nodes): return identities.copy()
def capture(self, nodes, timeout=6): return identities.copy()
engine.provider = Provider()
raw_command = sway.command
def command(text):

View file

@ -0,0 +1,60 @@
"""TEST_ONLY: Firefox-provider deferral in the real startup QML, private Sway."""
import json
import os
from pathlib import Path
import shutil
import subprocess
from common import REPO, BACKEND
from lab import Lab
from phase3b_ui import FAKE, HARNESS
def main():
with Lab() as lab:
config = lab.directory / "config-provider"
base = config / "quickshell/labfy-sway"
base.mkdir(parents=True)
source = REPO / "quickshell/.config/quickshell/labfy-sway"
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 " + color + ': "#333333"' for color in colors.split()) + "\n}\n")
backend = FAKE.replace(
"{'status':'failed', 'reason':'APPLICATION_WINDOWS_INCOMPLETE'}",
"{'status':'DEFERRED', 'reason':'FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE'}", 1)
(base / "session-v2.py").write_text(backend)
harness = HARNESS.replace(
"if (prompt.failed) prompt.restoreRequested();",
"""if (!prompt.retryAllowed || !prompt.awaitingUserFocus || prompt.focusable
|| prompt.failureMessage.indexOf("Firefox") < 0
|| prompt.failureMessage.indexOf("focus stable") >= 0) {
console.error("UI_TEST_FAIL_PROVIDER"); Qt.quit(); return;
}""")
(base / "shell.qml").write_text(harness)
calls_path = lab.directory / "provider.calls"
env = dict(lab.env, XDG_CONFIG_HOME=str(config), LABFY_UI_MODE="failure",
LABFY_UI_LOG=str(calls_path))
binary = os.environ.get("LABFY_TEST_QUICKSHELL", "qs")
result = subprocess.run([binary, "-p", str(base), "--no-color"],
env=env, text=True, capture_output=True, timeout=15)
output = result.stdout + result.stderr
assert "UI_TEST_PASS" in output and "UI_TEST_FAIL" not in output, output
calls = calls_path.read_text().splitlines()
assert calls.count("apply-last") == 1, calls
assert "acknowledge-startup" not in calls and "restore-status" not in calls, calls
evidence = {"status": "PASS", "reason": "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE",
"correct_explanation": True, "manual_retry_available": True,
"no_focus_capture": True, "backend_calls": calls}
(lab.directory / "provider-ui-result.json").write_text(json.dumps(evidence, indent=2))
print("PROVIDER_UI=" + json.dumps(evidence), flush=True)
print("TEST_ONLY_DIRECTORY=" + str(lab.directory), flush=True)
if __name__ == "__main__":
main()

View file

@ -6,13 +6,20 @@ import subprocess
import tempfile
import threading
import unittest
from unittest.mock import patch
from unittest.mock import Mock, patch
from common import BACKEND, REPO, Path, os, sys
from session_v2.native_host import bootstrap_eligibility, EXTENSION_ID
from session_v2.native_host import bootstrap_eligibility, classify, validate, EXTENSION_ID, APP_IDS, WEBAPP_IDS
from session_v2.errors import Failure
REQUEST = {"op": "bootstrap-eligibility"}
MAP = {"op": "map-window", "uuid": "11111111-1111-4111-8111-111111111111", "runtime_token": "a" * 32}
CLASSIFY = {"op": "classify-window", "runtime_token": MAP["runtime_token"]}
WEBAPP = "firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded"
def surface(app_id="firefox", con_id=42):
return {"id": con_id, "app_id": app_id, "name": "[LABFY:" + MAP["runtime_token"] + "] private suffix"}
def frame(value):
@ -50,7 +57,7 @@ class BootstrapTests(unittest.TestCase):
self.assertIs(type(result["baseline_enrollment_allowed"]), bool)
return result["baseline_enrollment_allowed"]
def wire(self, raw, extension=EXTENSION_ID, serve=False):
def wire(self, raw, extension=EXTENSION_ID, serve=False, nodes=None):
# Le vrai host contrôle lstat/UID du socket. Le faux serveur n'accepte
# que GET_TREE/GET_VERSION ; toute commande mutatrice échoue.
server = socket.socket(socket.AF_UNIX)
@ -58,6 +65,7 @@ class BootstrapTests(unittest.TestCase):
server.listen()
server.settimeout(.1)
stop, errors = threading.Event(), []
tree = {"id": 0, "nodes": [surface()] if nodes is None else nodes}
def run():
try:
while not stop.is_set():
@ -71,7 +79,6 @@ class BootstrapTests(unittest.TestCase):
self.assertEqual((magic, size), (b"i3-ipc", 0))
self.assertIn(kind, (4, 7))
if kind == 4: self.assertTrue(serve)
tree = {"id": 0, "nodes": [{"id": 42, "app_id": "firefox", "name": "[LABFY:" + MAP["runtime_token"] + "] private suffix"}]}
payload = json.dumps(tree if kind == 4 else {"human_readable": "TEST_ONLY"}).encode()
client.sendall(struct.pack("<6sII", b"i3-ipc", len(payload), kind) + payload)
except Exception as error: errors.append(error)
@ -271,6 +278,110 @@ class BootstrapTests(unittest.TestCase):
self.assertEqual(record["publisher_pid"] > 0, True)
self.assertFalse(self.store.exists())
def assert_no_observations(self):
observations = [p for p in (self.root / "runtime").rglob("*.json") if p.stem.isdigit()]
self.assertEqual(observations, [])
self.assertFalse(self.store.exists())
def test_classify_exact_catalogue_and_no_uuid_publication(self):
self.assertEqual(APP_IDS, {"firefox", "org.mozilla.firefox"})
self.assertEqual(WEBAPP_IDS, {WEBAPP})
for app_id, kind in (("firefox", "browser"), ("org.mozilla.firefox", "browser"),
(WEBAPP, "managed-webapp")):
with self.subTest(app_id=app_id):
code, answers = self.wire(frame(CLASSIFY), serve=True, nodes=[surface(app_id)])
self.assertEqual(code, 0)
self.assertEqual(answers, [{"result": "OK", "kind": kind,
"runtime_token": CLASSIFY["runtime_token"], "con_id": 42}])
self.assert_no_observations()
def test_classify_closed_envelope_and_token(self):
invalid = [
({**CLASSIFY, "uuid": MAP["uuid"]}, "MESSAGE_SCHEMA"),
({**CLASSIFY, "kind": "managed-webapp"}, "MESSAGE_SCHEMA"),
({**CLASSIFY, "app_id": WEBAPP}, "MESSAGE_SCHEMA"),
({"op": "classify-window"}, "MESSAGE_SCHEMA"),
]
for token in (None, True, 32, "", "a" * 31, "a" * 33, "A" * 32, "g" * 32):
invalid.append(({**CLASSIFY, "runtime_token": token}, "INVALID_TOKEN"))
for message, expected in invalid:
with self.subTest(message=message):
with self.assertRaises(Failure) as caught: validate(message)
self.assertEqual(caught.exception.code, expected)
self.assertEqual(self.wire(frame(message))[1], [{"result": expected}])
self.assert_no_observations()
def test_classify_real_framing_rejects_duplicate_and_bounds(self):
duplicate = b'{"op":"classify-window","runtime_token":"' + b'a' * 32 + b'","runtime_token":"' + b'b' * 32 + b'"}'
for raw in (struct.pack("=I", len(duplicate)) + duplicate,
struct.pack("=I", 4097), struct.pack("=I", 0),
struct.pack("=I", 80) + b'{"op":"classify-window"}'):
with self.subTest(raw=raw):
_, answers = self.wire(raw)
self.assertTrue(not answers or all(a["result"] != "OK" for a in answers))
self.assert_no_observations()
def test_classify_unknown_never_becomes_managed_webapp(self):
for app_id in ("firefox.webapp-unregistered", WEBAPP + "-suffix", "Firefox", "kitty", "x11-class:firefox"):
with self.subTest(app_id=app_id):
self.assertEqual(self.wire(frame(CLASSIFY), serve=True, nodes=[surface(app_id)])[1],
[{"result": "WINDOW_CLASS_UNSUPPORTED"}])
x11 = {**surface(None), "window": 123, "window_properties": {"class": "firefox"}}
self.assertEqual(self.wire(frame(CLASSIFY), serve=True, nodes=[x11])[1],
[{"result": "WINDOW_CLASS_UNSUPPORTED"}])
self.assert_no_observations()
def test_classify_collision_including_unknown_fails_closed(self):
for second in ("org.mozilla.firefox", WEBAPP, "unregistered"):
with self.subTest(second=second):
self.assertEqual(self.wire(frame(CLASSIFY), serve=True,
nodes=[surface(), surface(second, 43)])[1],
[{"result": "EXACT_WINDOW_IDENTITY_COLLISION"}])
self.assert_no_observations()
def test_classify_requires_exact_prefix_with_bounded_timeout(self):
for name in ("private unmarked", "prefix " + surface()["name"],
"[LABFY:" + "b" * 32 + "] private suffix"):
sway = Mock()
sway.windows.return_value = [{**surface(), "name": name}]
with self.subTest(name=name), patch("session_v2.native_host.time.monotonic", side_effect=[0, 3]):
with self.assertRaises(Failure) as caught: classify(sway, CLASSIFY)
self.assertEqual(caught.exception.code, "CORRELATION_TIMEOUT")
sway.windows.assert_called_once_with()
def test_classify_requires_positive_integer_container(self):
for identity in (None, True, 0, -1, "42"):
sway = Mock()
sway.windows.return_value = [surface(con_id=identity)]
with self.subTest(identity=identity):
with self.assertRaises(Failure) as caught: classify(sway, CLASSIFY)
self.assertEqual(caught.exception.code, "WINDOW_CLASS_UNSUPPORTED")
def test_classify_does_not_extend_map_to_webapp(self):
code, answers = self.wire(frame(CLASSIFY) + frame(MAP), serve=True, nodes=[surface(WEBAPP)])
self.assertEqual(code, 0)
self.assertEqual(answers, [{"result": "OK", "kind": "managed-webapp",
"runtime_token": CLASSIFY["runtime_token"], "con_id": 42},
{"result": "CORRELATION_TIMEOUT"}])
self.assert_no_observations()
def test_classify_interleaved_browser_map_and_bootstrap_remain_compatible(self):
code, answers = self.wire(frame(REQUEST) + frame(CLASSIFY) + frame(MAP) + frame(CLASSIFY), serve=True)
self.assertEqual(code, 0)
classification = {"result": "OK", "kind": "browser",
"runtime_token": CLASSIFY["runtime_token"], "con_id": 42}
self.assertEqual(answers, [{"result": "OK", "baseline_enrollment_allowed": True}, classification,
{"result": "OK", "uuid": MAP["uuid"], "con_id": 42}, classification])
observations = [p for p in (self.root / "runtime").rglob("*.json") if p.stem.isdigit()]
self.assertEqual(len(observations), 1)
self.assertEqual(observations[0].name, "42.json")
self.assertEqual(json.loads(observations[0].read_text())["uuid"], MAP["uuid"])
self.assertFalse(self.store.exists())
def test_classify_rejects_wrong_operation(self):
with self.assertRaises(Failure) as caught: classify(Mock(), MAP)
self.assertEqual(caught.exception.code, "OP_NOT_ALLOWED")
def test_checkpoint_created_between_observations(self):
self.prepare()
self.assertTrue(self.observe())

View file

@ -0,0 +1,250 @@
"""Installed desktop discovery: inert trees/files, no real applications launched."""
import copy
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import Mock, patch
from common import BACKEND, Catalog, Spec
from test_xwayland import Sway
from session_v2.applications import DEFAULT, installed_specs, launch, launch_count, _gio
from session_v2.checkpoint import Store
from session_v2.errors import Failure, Deferred
from session_v2.executor import Executor
from session_v2.ipc import workspace, X11_CLASS_PREFIX
from session_v2.recovery import JOURNAL
from session_v2.schema import validate
from session_v2.snapshot import capture
class DesktopCatalogTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.Gio, self.GioUnix, self.GLib = _gio()
def desktop(self, identifier="org.test.Editor.desktop", extra="", executable="/usr/bin/true"):
path = self.root / identifier
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("[Desktop Entry]\nType=Application\nName=TEST_ONLY\nExec=" + executable + "\n" + extra)
path.chmod(0o600)
app = self.GioUnix.DesktopAppInfo.new_from_filename(str(path))
self.assertIsNotNone(app)
return app
def catalog(self, *apps):
return Catalog(desktop_apps=apps)
def test_explicit_catalog_is_hermetic(self):
with patch("session_v2.applications.installed_specs") as discovery:
self.assertEqual(Catalog(()).specs, {})
self.assertEqual(Catalog(DEFAULT).specs["firefox"].provider, "firefox-session-window-uuid")
discovery.assert_not_called()
def test_exact_desktop_and_declared_class_with_no_title_matching(self):
app = self.desktop(extra="StartupWMClass=TestEditor\n")
catalog = self.catalog(app)
spec = catalog.classify({"app_id": "org.test.Editor"})
self.assertEqual(spec.application_id, "org.test.Editor.desktop")
self.assertEqual(spec.strategy, "application-self-restore")
self.assertEqual(catalog.classify({"app_id": "TestEditor"}), spec)
self.assertEqual(catalog.classify({"app_id": None, "window": 12,
"window_properties": {"class": "TestEditor"}}), spec)
for identity in ("testeditor", "org.test.Unregistered"):
with self.assertRaisesRegex(Failure, "APPLICATION_UNSUPPORTED"):
catalog.classify({"app_id": identity, "name": "TestEditor"})
with self.assertRaisesRegex(Failure, "SURFACE_IDENTITY_RESERVED"):
catalog.classify({"app_id": "x11-class:TestEditor"})
def test_menu_visibility_does_not_disable_installed_window_support(self):
hidden = self.desktop("hidden.desktop", "Hidden=true\n")
helper = self.desktop("helper.desktop", "NoDisplay=true\n")
terminal = self.desktop("terminal.desktop", "Terminal=true\n")
other = self.desktop("other.desktop", "OnlyShowIn=TEST_ONLY_UNKNOWN_DESKTOP;\n")
future_webapp = self.desktop("firefox.webapp-new.desktop")
# GIO itself may reject an absent Exec; a valid wrapper path with a
# missing runtime target must not be inferred as a unique app identity.
catalog = self.catalog(hidden, helper, terminal, other, future_webapp)
self.assertEqual(catalog.excluded, {"hidden.desktop": "hidden",
"terminal.desktop": "terminal-content",
"firefox.webapp-new.desktop": "provider-required"})
self.assertEqual(len(catalog.specs), len(DEFAULT) + 2)
self.assertEqual(catalog.classify({"app_id": "helper"}).desktop_entry, "helper.desktop")
self.assertEqual(catalog.classify({"app_id": "other"}).desktop_entry, "other.desktop")
def test_shared_aliases_refuse_but_exact_declared_ids_remain(self):
first = self.desktop("org.test.First.desktop", "StartupWMClass=SharedWindow\n")
second = self.desktop("org.test.Second.desktop", "StartupWMClass=SharedWindow\n")
catalog = self.catalog(first, second)
for identity in ("SharedWindow", "true"):
with self.assertRaisesRegex(Failure, "APPLICATION_UNSUPPORTED"):
catalog.classify({"app_id": identity})
for wm_class in ("SharedWindow", "true"):
with self.assertRaisesRegex(Failure, "APPLICATION_UNSUPPORTED"):
catalog.classify({"app_id": None, "window": 1, "window_properties": {"class": wm_class}})
self.assertEqual(catalog.classify({"app_id": "org.test.First"}).desktop_entry, "org.test.First.desktop")
def test_exact_desktop_identity_outranks_an_executable_alias(self):
canonical = self.desktop("true.desktop")
other = self.desktop("org.test.Other.desktop")
self.assertEqual(self.catalog(canonical, other).classify({"app_id": "true"}).desktop_entry, "true.desktop")
def test_native_desktop_id_outranks_other_launcher_wm_class(self):
canonical = self.desktop("org.test.Native.desktop")
helper = self.desktop("org.test.Helper.desktop", "NoDisplay=true\nStartupWMClass=org.test.Native\n")
catalog = self.catalog(canonical, helper)
self.assertEqual(catalog.classify({"app_id": "org.test.Native"}).desktop_entry, "org.test.Native.desktop")
self.assertEqual(catalog.classify({"app_id": None, "window": 1, "window_properties": {"class": "org.test.Native"}}).desktop_entry, "org.test.Helper.desktop")
def test_static_policy_cannot_be_replaced_by_desktop_metadata(self):
override = self.desktop("firefox.desktop", "StartupWMClass=OtherFirefox\n")
impostor = self.desktop("org.test.Impostor.desktop", "StartupWMClass=firefox\n")
catalog = self.catalog(override, impostor)
self.assertEqual(catalog.classify({"app_id": "firefox"}), next(s for s in DEFAULT if s.application_id == "firefox"))
with self.assertRaisesRegex(Failure, "APPLICATION_UNSUPPORTED"):
catalog.classify({"app_id": "OtherFirefox"})
self.assertEqual(catalog.specs["whatsapp-web"].managed_by, "sway-autostart")
with self.assertRaisesRegex(Failure, "APPLICATION_UNSUPPORTED"):
catalog.classify({"app_id": None, "window": 1, "window_properties": {"class": "firefox"}})
def test_launch_uses_pinned_desktop_file_without_snapshot_arguments(self):
app = self.desktop(executable='"/usr/bin/true" "%U"')
spec = self.catalog(app).classify({"app_id": "org.test.Editor"})
with patch("session_v2.applications.subprocess.Popen") as popen:
launch(spec, 1, {"TEST_ONLY": "1"})
popen.assert_called_once()
self.assertEqual(popen.call_args.args[0], ["/usr/bin/gio", "launch", app.get_filename()])
self.assertNotIn("shell", popen.call_args.kwargs)
self.assertEqual(popen.call_args.kwargs["env"], {"TEST_ONLY": "1"})
with self.assertRaisesRegex(Failure, "APPLICATION_LAUNCH_FAILED"):
launch(spec, 2)
forged = {"application_id": spec.application_id, "desktop_entry": "/tmp/evil.desktop",
"strategy": spec.strategy, "managed_by": None, "identity_provider": None}
with self.assertRaisesRegex(Failure, "APPLICATION_POLICY_MISMATCH"):
self.catalog(app).validate(forged)
def test_changed_or_writable_launcher_is_not_executed(self):
app = self.desktop()
spec = self.catalog(app).classify({"app_id": "org.test.Editor"})
path = Path(app.get_filename())
path.write_text(path.read_text() + "Comment=changed\n")
with patch("session_v2.applications.subprocess.Popen") as popen:
with self.assertRaisesRegex(Failure, "APPLICATION_LAUNCH_FAILED"):
launch(spec, 1)
popen.assert_not_called()
path.chmod(0o666)
self.assertEqual(self.catalog(app).excluded[app.get_id()], "invalid-desktop-file")
def test_nonregular_launcher_is_refused_without_reading_it(self):
app = self.desktop()
spec = self.catalog(app).classify({"app_id": "org.test.Editor"})
path = Path(app.get_filename())
path.unlink()
os.mkfifo(path)
with patch("session_v2.applications.subprocess.Popen") as popen:
with self.assertRaisesRegex(Failure, "APPLICATION_LAUNCH_FAILED"):
launch(spec, 1)
popen.assert_not_called()
def test_flatpak_export_symlink_and_shared_wrapper(self):
target = self.desktop("export/org.test.Flatpak.desktop", executable="/usr/bin/env TEST_ONLY=1 /usr/bin/true")
alias = self.root / "org.test.Flatpak.desktop"
alias.symlink_to(target.get_filename())
app = self.GioUnix.DesktopAppInfo.new_from_filename(str(alias))
spec = self.catalog(app).classify({"app_id": "org.test.Flatpak"})
self.assertEqual(spec.desktop_signature[0], str(Path(target.get_filename()).resolve()))
self.assertNotIn("env", spec.app_ids)
with patch("session_v2.applications.subprocess.Popen") as popen:
launch(spec, 1)
popen.assert_called_once()
def test_xdg_user_override_hidden_and_system_flatpak_roots(self):
local, system, flatpak = (self.root / name for name in ("local", "system", "flatpak"))
for directory in (local, system, flatpak):
(directory / "applications").mkdir(parents=True)
def put(directory, identifier, extra=""):
(directory / "applications" / identifier).write_text("[Desktop Entry]\nType=Application\nName=TEST_ONLY\nExec=/usr/bin/true\n" + extra)
put(system, "org.test.Shadow.desktop", "StartupWMClass=SystemClass\n")
put(local, "org.test.Shadow.desktop", "StartupWMClass=LocalClass\n")
put(system, "org.test.Deleted.desktop")
put(local, "org.test.Deleted.desktop", "Hidden=true\n")
put(flatpak, "org.test.Flatpak.desktop")
program = "import sys,json;sys.path.insert(0,sys.argv[1]);from session_v2.applications import Catalog;c=Catalog();print(json.dumps({s.desktop_entry:s.app_ids for s in c.specs.values() if s.desktop_signature is not None}))"
env = {**os.environ, "XDG_DATA_HOME": str(local), "XDG_DATA_DIRS": str(system) + ":" + str(flatpak), "XDG_CURRENT_DESKTOP": "sway"}
data = json.loads(subprocess.check_output(["python3", "-B", "-c", program, str(BACKEND)], env=env, text=True, timeout=5))
self.assertIn("LocalClass", data["org.test.Shadow.desktop"])
self.assertNotIn("SystemClass", data["org.test.Shadow.desktop"])
self.assertNotIn("org.test.Deleted.desktop", data)
self.assertIn("org.test.Flatpak.desktop", data)
def source(self):
catalog = self.catalog(self.desktop())
sway = Sway()
sway.add("org.test.Editor", 7, focus=False)
sway.add("org.test.Editor", 8, focus=True)
data = capture(sway, catalog, None)
return catalog, sway, data
def test_multiwindow_capture_persists_layout_only_and_preserves_v1(self):
catalog, sway, data = self.source()
self.assertEqual(data["applications"][0]["expected_windows"], 2)
self.assertEqual(data["applications"][0]["strategy"], "application-self-restore")
self.assertTrue(all(slot["identity_requirement"] == "interchangeable" for slot in data["window_slots"]))
for forbidden in ("argv", "desktop_signature", str(self.root)):
self.assertNotIn(forbidden, json.dumps(data))
store = Store(self.root / "state")
store.checkpoint(data)
self.assertEqual(store.last(), data)
old = copy.deepcopy(data)
old["version"] = 1
old.pop("focus_workspace")
for slot in old["window_slots"]: slot["state"]["scratchpad"] = False
with self.assertRaisesRegex(Failure, "SCHEMA_STRATEGY"): validate(old)
schema = json.loads((BACKEND / "session_v2/schema-v2.json").read_text())
self.assertIn("application-self-restore", schema["$defs"]["application"]["properties"]["strategy"]["enum"])
schema = json.loads((BACKEND / "session_v2/schema-v1.json").read_text())
self.assertNotIn("application-self-restore", schema["$defs"]["application"]["properties"]["strategy"]["enum"])
def test_launch_count_is_once_for_absent_app_and_zero_for_partial_presence(self):
app = {"strategy": "application-self-restore", "expected_windows": 3}
self.assertEqual([launch_count(app, count) for count in (0, 1, 2, 3)], [1, 0, 0, 0])
with self.assertRaisesRegex(Failure, "UNEXPECTED_EXTRA_WINDOW"): launch_count(app, 4)
def test_prewarm_launches_once_and_requires_full_window_set(self):
catalog, sway, data = self.source()
workspace(sway.value, "1")["nodes"] = []
engine = Executor(sway, catalog, None, self.root / "runtime", "/forbidden", process_identity=lambda pid: 123)
def once(spec, count, env):
self.assertEqual(count, 1)
sway.add("org.test.Editor", 20, focus=False)
sway.add("org.test.Editor", 21, focus=True)
return []
with patch("session_v2.executor.launch", side_effect=once) as launcher, patch.object(engine, "_apply", return_value="admitted"):
self.assertEqual(engine.apply(data, True, timeout=.5), "admitted")
launcher.assert_called_once()
self.assertEqual(sway.commands, [])
self.assertFalse((self.root / "runtime" / JOURNAL).exists())
def test_partial_generic_restore_defers_without_layout_or_retry_launch(self):
catalog, sway, data = self.source()
workspace(sway.value, "1")["nodes"] = []
runtime = self.root / "runtime"
engine = Executor(sway, catalog, None, runtime, "/forbidden", process_identity=lambda pid: 123)
def only_one(spec, count, env):
sway.add("org.test.Editor", 20, focus=True)
return []
with patch("session_v2.executor.launch", side_effect=only_one) as launcher, patch.object(engine, "_apply") as apply:
for _ in range(2):
with self.assertRaisesRegex(Deferred, "APPLICATION_WINDOWS_INCOMPLETE"):
engine.apply(data, True, timeout=.08)
launcher.assert_called_once()
apply.assert_not_called()
self.assertEqual(sway.commands, [])
self.assertFalse((runtime / JOURNAL).exists())
if __name__ == "__main__":
unittest.main()

View file

@ -7,7 +7,7 @@ import threading
import unittest
from unittest.mock import patch
from common import *
from login_focus import checkpoint
from login_focus import checkpoint, UUID
from session_v2.executor import Executor
from session_v2.applications import Catalog
from session_v2.recovery import Recovery, JOURNAL, focused, focus_proof
@ -38,13 +38,23 @@ class Sway:
return node
class Provider:
def capture(self, nodes, timeout=6):
assert len(nodes) == 1
return {UUID: nodes[0]["id"]}
def resolve(self, expected, nodes, timeout=6):
mapping = self.capture(nodes, timeout)
assert set(expected).issubset(mapping)
return mapping
class PrewarmTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.runtime = Path(self.tmp.name)
self.sway = Sway()
self.engine = Executor(self.sway, Catalog(), object(), self.runtime, '/forbidden')
self.engine = Executor(self.sway, Catalog(), Provider(), self.runtime, '/forbidden')
self.data = checkpoint()
self.launches = []
self.threads = []

View file

@ -0,0 +1,69 @@
"""Password manager capture uses only the normal window placement contract."""
import json
import tempfile
import unittest
from unittest.mock import patch
from common import *
from test_scratchpad import source
from session_v2.checkpoint import Store
from session_v2.errors import Failure
from session_v2.executor import Executor
from session_v2.ipc import walk, workspace
from session_v2.recovery import JOURNAL
from session_v2.snapshot import capture
def password_manager():
sway = source()
hidden = workspace(sway.value, "__i3_scratch")["floating_nodes"][0]
hidden.update(app_id="proton-pass", name="TEST_ONLY_PRIVATE_WINDOW_TITLE")
return sway, hidden
class ProtonPassTests(unittest.TestCase):
def test_hidden_window_capture_store_round_trip(self):
sway, hidden = password_manager()
before = sway.tree()
data = capture(sway, Catalog(), None)
app = next(a for a in data["applications"] if a["application_id"] == "proton-pass")
slot = next(s for s in data["window_slots"] if s["application_id"] == "proton-pass")
self.assertEqual((app["desktop_entry"], app["strategy"], app["expected_windows"]),
("proton-pass.desktop", "single-window", 1))
self.assertEqual(slot["state"]["scratchpad"], "hidden")
self.assertEqual(slot["state"]["geometry"], hidden["rect"])
self.assertIsNone(slot["workspace"])
self.assertEqual(slot["identity_evidence"],
{"type": "application-singleton", "id": "proton-pass"})
self.assertNotIn("TEST_ONLY_PRIVATE_WINDOW_TITLE", json.dumps(data))
self.assertEqual(sway.tree(), before)
with tempfile.TemporaryDirectory() as temporary:
store = Store(Path(temporary))
store.checkpoint(data)
self.assertEqual(store.last(), data)
def test_existing_hidden_window_is_not_launched_again(self):
sway, _ = password_manager()
data = capture(sway, Catalog(), None)
with tempfile.TemporaryDirectory() as temporary:
engine = Executor(sway, Catalog(), None, Path(temporary),
"/forbidden", process_identity=lambda pid: 123)
with patch("session_v2.executor.launch") as launch, patch.object(engine, "_apply", return_value="admitted"):
self.assertEqual(engine.apply(data, True), "admitted")
launch.assert_not_called()
self.assertFalse((Path(temporary) / JOURNAL).exists())
self.assertEqual(sway.commands, [])
def test_ambiguous_or_unknown_window_still_refuses(self):
sway, _ = password_manager()
sway.add("proton-pass", 99, focus=False)
with self.assertRaisesRegex(Failure, "APPLICATION_POLICY_MISMATCH"):
capture(sway, Catalog(), None)
self.assertEqual(sway.commands, [])
sway, hidden = password_manager()
hidden["app_id"] = "proton-pass-unregistered"
with self.assertRaisesRegex(Failure, "APPLICATION_UNSUPPORTED"):
capture(sway, Catalog(), None)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,133 @@
"""Provider admission before Recovery/staging; inert windows and private journals."""
import tempfile
import unittest
from unittest.mock import patch
from common import *
from login_focus import checkpoint, UUID
from test_prewarm import Sway, Provider
from session_v2.errors import Deferred, Failure
from session_v2.executor import Executor
from session_v2.ipc import walk, workspace
from session_v2.recovery import Recovery, JOURNAL, RECEIPT, focus_proof
class ProviderAdmissionTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.runtime = Path(self.tmp.name)
self.sway = Sway()
self.sway.add("kitty", 20, focus=False)
self.sway.add("firefox", 21)
self.provider = Provider()
self.engine = Executor(self.sway, Catalog(), self.provider, self.runtime, "/forbidden")
self.data = checkpoint()
launch = patch("session_v2.executor.launch", side_effect=AssertionError("NO_LAUNCH_EXPECTED"))
launch.start()
self.addCleanup(launch.stop)
def assert_no_transaction(self):
self.assertEqual(self.sway.commands, [])
self.assertFalse((self.runtime / JOURNAL).exists())
self.assertFalse((self.runtime / "session-v2-restore-attempt.json").exists())
def test_unavailable_is_deferred_before_mutation_and_durable_attempt(self):
with patch.object(self.provider, "resolve", side_effect=Failure("FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE")), \
patch.object(self.engine, "_apply", side_effect=AssertionError("ADMISSION_REQUIRED")):
with self.assertRaisesRegex(Deferred, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE"):
self.engine.apply(self.data, True)
self.assert_no_transaction()
def test_exact_uuid_missing_is_not_relabelled_transient(self):
with patch.object(self.provider, "resolve", side_effect=Failure("EXACT_WINDOW_IDENTITY_MISSING")), \
patch.object(self.engine, "_apply", side_effect=AssertionError("ADMISSION_REQUIRED")):
with self.assertRaisesRegex(Failure, "EXACT_WINDOW_IDENTITY_MISSING") as result:
self.engine.apply(self.data, True)
self.assertNotIsInstance(result.exception, Deferred)
self.assert_no_transaction()
def test_partial_provider_mapping_never_admits_the_batch(self):
with patch.object(self.provider, "resolve", return_value={UUID: 999}), \
patch.object(self.engine, "_apply", side_effect=AssertionError("ADMISSION_REQUIRED")):
with self.assertRaisesRegex(Deferred, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE"):
self.engine.apply(self.data, True)
self.assert_no_transaction()
def test_success_rechecks_complete_batch_and_focus_before_apply(self):
resolved = []
def resolve(expected, nodes, timeout):
self.assertEqual(expected, {UUID})
self.assertEqual([n["id"] for n in nodes], [21])
self.assertGreater(timeout, 0)
self.assertLessEqual(timeout, 6)
self.assert_no_transaction()
resolved.append(True)
return {UUID: 21}
def admitted(data, timeout, lock_fd, proof, prepared):
self.assertEqual(resolved, [True])
self.assertEqual(set(prepared), {5, 20, 21})
self.assertEqual(proof, focus_proof(self.sway))
self.assert_no_transaction()
return "admitted"
with patch.object(self.provider, "resolve", side_effect=resolve), \
patch.object(self.engine, "_apply", side_effect=admitted):
self.assertEqual(self.engine.apply(self.data, True), "admitted")
def test_changes_during_provider_wait_are_refused_before_apply(self):
for change, reason in (
("identity", "TARGET_OBSERVATION_CHANGED"),
("new-window", "APPLICATION_MEMBERSHIP_CHANGED"),
("order", "TARGET_OBSERVATION_CHANGED"),
("focus", "RESTORE_FOCUS_UNSTABLE"),
("session", "SWAY_SESSION_CHANGED"),
):
with self.subTest(change=change):
self.sway = Sway()
self.sway.add("kitty", 20, focus=False)
self.sway.add("firefox", 21)
self.engine.sway = self.sway
def resolve(expected, nodes, timeout):
if change == "identity":
next(n for n in walk(self.sway.value) if n["id"] == 20)["app_id"] = "different"
elif change == "new-window":
self.sway.add("TEST_ONLY_FOREIGN", 22, focus=False)
elif change == "order":
workspace(self.sway.value, "1")["nodes"].reverse()
elif change == "focus":
for n in walk(self.sway.value):
n["focused"] = n["id"] == 20
else:
self.sway.session = "b" * 64
return {UUID: 21}
with patch.object(self.provider, "resolve", side_effect=resolve), \
patch.object(self.engine, "_apply", side_effect=AssertionError("ADMISSION_REQUIRED")):
with self.assertRaisesRegex(Failure, reason):
self.engine.apply(self.data, True)
self.assert_no_transaction()
def test_provider_failure_preserves_previous_completed_incident(self):
recovery = Recovery(self.sway, self.runtime, "b" * 32, "labfy-v2-stage-" + "b" * 32)
recovery.data["status"] = "complete"
recovery.publish()
recovery.receipt("complete", [], [])
attempt = self.runtime / "session-v2-restore-attempt.json"
attempt.write_bytes(b'{"TEST_ONLY":"prior-attempt"}\n')
paths = [self.runtime / JOURNAL, self.runtime / RECEIPT, attempt]
original = {p: p.read_bytes() for p in paths}
with patch.object(self.provider, "resolve", side_effect=Failure("FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE")), \
patch.object(self.engine, "_apply", side_effect=AssertionError("ADMISSION_REQUIRED")):
with self.assertRaises(Deferred):
self.engine.apply(self.data, True)
self.assertEqual({p: p.read_bytes() for p in paths}, original)
self.assertEqual(self.sway.commands, [])
def test_late_provider_failure_stays_a_transaction_failure(self):
with patch.object(self.engine, "_apply", side_effect=Failure("FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE")) as apply:
with self.assertRaisesRegex(Failure, "FIREFOX_IDENTITY_PROVIDER_UNAVAILABLE") as result:
self.engine.apply(self.data, True)
self.assertNotIsInstance(result.exception, Deferred)
apply.assert_called_once()
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,115 @@
"""Focus staging admission: synthetic trees; no compositor or client mutation."""
import copy
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from common import *
from session_v2.executor import Executor, staging_order
from session_v2.recovery import JOURNAL, command_focus
from session_v2.ipc import walk, workspace
from session_v2.errors import Failure, Deferred
from test_prewarm import Sway, Provider
from login_focus import checkpoint
def leaf(identity, **extra):
return dict(id=identity, type="con", app_id="TEST_ONLY", nodes=[], floating_nodes=[],
focused=False, **extra)
def branch(identity, children, focus=None):
return dict(id=identity, type="con", layout="splith", nodes=children,
floating_nodes=[], focused=False, focus=focus or [n["id"] for n in children])
def admitted_tree():
# Exact admission ancestry: 37 / (16 / (58,60)), including unary wrappers.
root = branch(32, [branch(38, [
branch(52, [branch(54, [leaf(37)])]),
branch(51, [branch(65, [leaf(16)]),
branch(59, [branch(64, [leaf(58)]), branch(61, [leaf(60)])])],
[65, 59])], [51, 52])])
ws = dict(id=29, type="workspace", name="1", layout="splith", nodes=[root],
floating_nodes=[], focused=False, focus=[32])
return dict(id=1, type="root", nodes=[dict(id=2, type="output", name="HEADLESS-1", nodes=[ws])])
class StagingFocusTests(unittest.TestCase):
def setUp(self):
self.tree = admitted_tree()
self.nodes = {n["id"]: n for n in walk(self.tree)}
self.stage = [self.nodes[i] for i in (37, 16, 58, 60)]
self.nodes[16]["focused"] = True
def order(self):
return [n["id"] for n in staging_order(self.tree, self.stage)]
def focus(self, identity):
for n in walk(self.tree): n["focused"] = n["id"] == identity
def test_focused_leaf_is_last_and_other_order_is_stable(self):
before = copy.deepcopy(self.tree)
self.assertEqual(self.order(), [37, 58, 60, 16])
self.assertEqual(self.tree, before)
self.assertEqual([n["id"] for n in self.stage], [37, 16, 58, 60])
def test_internal_focus_keeps_entire_branch_last(self):
self.focus(59)
self.stage = [self.nodes[i] for i in (58, 37, 60, 16)]
self.assertEqual(self.order(), [37, 16, 58, 60])
def test_foreign_source_leaf_refuses(self):
self.nodes[38]["nodes"].append(leaf(99))
with self.assertRaisesRegex(Failure, "RESTORE_FOCUS_STAGING_UNPROVEN"):
self.order()
def test_shown_scratchpad_source_leaf_refuses(self):
workspace(self.tree, "1")["floating_nodes"].append(leaf(99, scratchpad_state="changed"))
with self.assertRaisesRegex(Failure, "RESTORE_FOCUS_STAGING_UNPROVEN"):
self.order()
def test_internal_focus_with_nonstaged_descendant_survives(self):
self.nodes[59]["nodes"].append(leaf(99))
self.focus(59)
self.stage = [self.nodes[i] for i in (58, 37, 60, 16)]
self.assertEqual(self.order(), [37, 16, 58, 60])
def test_foreign_focus_is_preserved_without_reordering(self):
foreign = leaf(99)
self.nodes[38]["nodes"].append(foreign)
self.focus(99)
self.assertEqual(self.order(), [37, 16, 58, 60])
def test_workspace_focus_is_preserved_with_foreign_survivor(self):
self.nodes[38]["nodes"].append(leaf(99))
self.focus(29)
self.assertEqual(self.order(), [37, 16, 58, 60])
def test_hidden_scratchpad_elsewhere_does_not_block_empty_source(self):
scratch = dict(id=100, type="workspace", name="__i3_scratch", nodes=[],
floating_nodes=[leaf(99, scratchpad_state="changed")], focused=False)
self.tree["nodes"][0]["nodes"].append(scratch)
self.assertEqual(self.order(), [37, 58, 60, 16])
def test_existing_internal_ancestor_prediction_is_not_weakened(self):
self.assertEqual(command_focus(self.tree, 16, 'move container to workspace "TEST_ONLY_STAGE"'),
dict(con_id=32, workspace="1"))
def test_foreign_source_outside_checkpoint_refuses_before_journals_or_moves(self):
sway = Sway()
sway.add("kitty", 11, focus=False)
sway.add("firefox", 12)
old = workspace(sway.value, "1")
other = dict(id=90, type="workspace", name="OTHER", layout="splith",
nodes=old["nodes"], focused=False)
old["nodes"] = []
sway.value["nodes"][0]["nodes"].append(other)
sway.add("TEST_ONLY_FOREIGN", 99, "OTHER", focus=False)
with tempfile.TemporaryDirectory() as directory:
runtime = Path(directory)
with patch("subprocess.Popen", side_effect=AssertionError("NO_PROCESS_ALLOWED")):
with self.assertRaisesRegex(Deferred, "RESTORE_FOCUS_STAGING_UNPROVEN"):
Executor(sway, Catalog(), Provider(), runtime, "/forbidden").apply(checkpoint(), True)
self.assertEqual(sway.commands, [])
self.assertFalse((runtime / JOURNAL).exists())
self.assertFalse((runtime / "session-v2-restore-attempt.json").exists())
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,199 @@
"""Explicit X11 identity; inert trees and owned temporary journals only."""
import json
import tempfile
import unittest
from unittest.mock import Mock, patch
from common import *
from test_prewarm import Sway as BaseSway
from session_v2.applications import launch_count
from session_v2.anchors import Anchors
from session_v2.errors import Failure
from session_v2.executor import Executor, preflight
from session_v2.ipc import surface_identity, X11_CLASS_PREFIX, walk, workspace
from session_v2.recovery import Recovery, JOURNAL, observe
from session_v2.snapshot import capture
KEY = X11_CLASS_PREFIX + "Chatgpt"
WEBAPP = "firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded"
class Sway(BaseSway):
def __init__(self):
super().__init__()
for ws in self.value["nodes"][0]["nodes"]:
ws.update(nodes=[], layout="splith", focused=False)
def windows(self):
return [n for n in walk(self.tree()) if n.get("app_id") or n.get("window")]
def x11(self, identity=7, wm_class="Chatgpt", where="1", focus=True):
node = self.add(None, identity, where, focus)
node.update(window=0x10000 + identity, window_properties={"class": wm_class, "instance": "TEST_ONLY"})
return node
def source():
sway = Sway()
sway.x11()
sway.add("kitty", 8, focus=False)
return sway
class XwaylandTests(unittest.TestCase):
def test_typed_identity_is_exact_pure_and_requires_x11_window(self):
node = source().windows()[0]
original = copy.deepcopy(node)
self.assertEqual(surface_identity(node), KEY)
self.assertEqual(node, original)
for change in ({"window": None}, {"window": True}, {"window": 0},
{"window_properties": {}}, {"window_properties": {"class": ""}}):
self.assertIsNone(surface_identity({**node, **change}))
self.assertEqual(surface_identity({**node, "app_id": "native-chatgpt"}), "native-chatgpt")
with self.assertRaisesRegex(Failure, "SURFACE_IDENTITY_RESERVED"):
surface_identity({**node, "app_id": KEY, "window": None})
def test_catalog_rejects_unknown_class_and_native_prefix_impersonation(self):
catalog = Catalog()
node = source().windows()[0]
spec = catalog.classify(node)
self.assertEqual((spec.application_id, spec.strategy, spec.argv),
("chatgpt", "single-window", ("/usr/bin/chatgpt",)))
for wm_class in ("OtherApp", "chatgpt", None):
with self.subTest(wm_class=wm_class), self.assertRaisesRegex(Failure, "APPLICATION_UNSUPPORTED"):
catalog.classify({**node, "window_properties": {"class": wm_class}})
with self.assertRaisesRegex(Failure, "SURFACE_IDENTITY_RESERVED"):
catalog.classify({"app_id": KEY})
with self.assertRaisesRegex(Failure, "APPLICATION_UNSUPPORTED"):
catalog.classify({"app_id": None})
def test_capture_uses_application_singleton_without_runtime_identity(self):
sway = source()
original = sway.tree()
data = capture(sway, Catalog(), None)
app = next(a for a in data["applications"] if a["application_id"] == "chatgpt")
slot = next(s for s in data["window_slots"] if s["application_id"] == "chatgpt")
self.assertEqual(app["desktop_entry"], "chatgpt.desktop")
self.assertEqual(slot["identity_evidence"], {"type": "application-singleton", "id": "chatgpt"})
self.assertEqual(sway.tree(), original)
for forbidden in (KEY, "window_properties", "con_id", "starttime"):
self.assertNotIn(forbidden, json.dumps(data))
def test_multiple_chatgpt_windows_refused_before_writing_snapshot(self):
sway = source(); sway.x11(9, focus=False)
with self.assertRaisesRegex(Failure, "APPLICATION_POLICY_MISMATCH"):
capture(sway, Catalog(), None)
self.assertEqual(sway.commands, [])
def test_class_change_during_capture_is_not_a_stable_snapshot(self):
sway = source(); original_windows = sway.windows
def changed():
next(n for n in walk(sway.value) if n.get("id") == 7)["window_properties"]["class"] = "OtherApp"
return original_windows()
sway.windows = changed
with self.assertRaisesRegex(Failure, "SNAPSHOT_CHANGED"):
capture(sway, Catalog(), None)
def test_preflight_and_prewarm_admit_present_x11_without_launch(self):
sway = source(); data = capture(sway, Catalog(), None)
self.assertIn("chatgpt", preflight(data, sway, Catalog(), None))
with tempfile.TemporaryDirectory() as temporary:
engine = Executor(sway, Catalog(), None, Path(temporary), "/forbidden", process_identity=lambda pid: 123)
def admitted(data, timeout, lock_fd, proof, prepared):
self.assertEqual(prepared[7]["app_id"], KEY)
self.assertFalse((Path(temporary) / JOURNAL).exists())
return "admitted"
with patch("session_v2.executor.launch") as launch, patch.object(engine, "_apply", side_effect=admitted):
self.assertEqual(engine.apply(data, True), "admitted")
launch.assert_not_called()
self.assertEqual(sway.commands, [])
def test_prewarm_launches_one_missing_chatgpt_then_observes_x11(self):
sway = source(); data = capture(sway, Catalog(), None)
workspace(sway.value, "1")["nodes"] = [n for n in workspace(sway.value, "1")["nodes"] if n["id"] != 7]
workspace(sway.value, "1")["nodes"][0]["focused"] = True
with tempfile.TemporaryDirectory() as temporary:
engine = Executor(sway, Catalog(), None, Path(temporary), "/forbidden", process_identity=lambda pid: 123)
calls = []
def launch(spec, count, env):
calls.append((spec.application_id, count)); sway.x11(9); return []
with patch("session_v2.executor.launch", side_effect=launch), patch.object(engine, "_apply", return_value="admitted"):
self.assertEqual(engine.apply(data, True), "admitted")
self.assertEqual(calls, [("chatgpt", 1)])
self.assertFalse((Path(temporary) / JOURNAL).exists())
self.assertEqual(sway.commands, [])
def test_recovery_keeps_typed_identity_and_excludes_target_from_foreign(self):
sway = source(); other = sway.x11(9, "OtherApp", focus=False)
with tempfile.TemporaryDirectory() as temporary:
recovery = Recovery(sway, Path(temporary), "b" * 32, "labfy-v2-stage-" + "b" * 32, start=lambda pid: 123)
recovery.baseline({KEY, "kitty"})
self.assertEqual([s["con_id"] for s in recovery.data["foreign"]], [9])
node = next(n for n in sway.windows() if n["id"] == 7)
recovery.admit(node)
identity = recovery.data["windows"][0]["origin"]
self.assertEqual((identity["con_id"], identity["app_id"], identity["pid"], identity["starttime"]),
(7, KEY, os.getpid(), 123))
before = (Path(temporary) / JOURNAL).read_bytes()
next(n for n in walk(sway.value) if n.get("id") == 7)["window_properties"]["class"] = "OtherApp"
with self.assertRaisesRegex(Failure, "TARGET_IDENTITY_CHANGED"):
recovery.admit(node)
self.assertEqual((Path(temporary) / JOURNAL).read_bytes(), before)
self.assertEqual(sway.commands, [])
def test_old_untyped_x11_journal_cannot_gain_identity_silently(self):
sway = source()
with tempfile.TemporaryDirectory() as temporary:
runtime = Path(temporary)
recovery = Recovery(sway, runtime, "b" * 32, "labfy-v2-stage-" + "b" * 32, start=lambda pid: 123)
node = next(n for n in sway.windows() if n["id"] == 7)
recovery.admit(node)
for field in ("origin", "expected"):
recovery.data["windows"][0][field]["app_id"] = None
recovery.publish()
before = (runtime / JOURNAL).read_bytes()
with self.assertRaisesRegex(Failure, "TARGET_IDENTITY_CHANGED"):
recovery.admit(node)
self.assertEqual((runtime / JOURNAL).read_bytes(), before)
self.assertEqual(sway.commands, [])
def test_whatsapp_autostart_owns_launch_and_unknown_webapp_is_refused(self):
catalog = Catalog(); spec = catalog.classify({"app_id": WEBAPP})
self.assertEqual((spec.strategy, spec.managed_by, spec.argv), ("managed-autostart", "sway-autostart", ()))
app = dict(application_id=spec.application_id, desktop_entry=spec.desktop_entry,
strategy=spec.strategy, expected_windows=1, managed_by=spec.managed_by, identity_provider=None)
self.assertEqual(catalog.validate(app), spec)
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)
with self.assertRaisesRegex(Failure, "APPLICATION_UNSUPPORTED"):
catalog.classify({"app_id": "firefox.webapp-unregistered"})
def test_swap_accepts_exact_x11_target_and_rejects_identity_changes(self):
for change in (None, "class", "pid"):
with self.subTest(change=change):
sway = Sway()
output = sway.value["nodes"][0]
output["nodes"].append(dict(id=30, type="workspace", name="STAGE", layout="splith", nodes=[]))
target = copy.deepcopy(sway.x11(where="STAGE"))
anchor = sway.add("TEST_ONLY_ANCHOR", 99, focus=False)
anchors = Anchors.__new__(Anchors)
anchors.sway = sway; anchors.anchor = lambda slot: anchor
if change == "class":
workspace(sway.value, "STAGE")["nodes"][0]["window_properties"]["class"] = "OtherApp"
if change == "pid": workspace(sway.value, "STAGE")["nodes"][0]["pid"] += 1
def swap(command):
sway.commands.append(command)
a, b = workspace(sway.value, "1")["nodes"], workspace(sway.value, "STAGE")["nodes"]
a[0], b[0] = b[0], a[0]
sway.command = swap
if change:
with self.assertRaisesRegex(Failure, "TARGET_IDENTITY_CHANGED"):
anchors.swap("slot-1", target, "1", "STAGE", (KEY,))
self.assertEqual(sway.commands, [])
else:
anchors.swap("slot-1", target, "1", "STAGE", (KEY,))
self.assertEqual(sway.commands, ["[con_id=7] swap container with con_id 99"])
self.assertEqual(workspace(sway.value, "1")["nodes"][0]["id"], 7)
if __name__ == "__main__": unittest.main()

View file

@ -0,0 +1,124 @@
"""Real XWayland TEST_ONLY, with private compositor, config and children only."""
import json
import re
from unittest.mock import patch
from common import *
from lab import Lab as BaseLab, Surfaces, build_helper, until
from session_v2.errors import Failure
from session_v2.executor import Executor
from session_v2.ipc import Sway, walk, location, surface_identity
from session_v2.recovery import observe, process_start, JOURNAL, recovery_status
from session_v2.snapshot import capture
from session_v2.storage import private_dir
WEBAPP = "firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded"
class Lab(BaseLab):
def start(self, argv):
if self.socket is None and "-c" in argv:
config = self.directory / "headless-x11.conf"
original = Path(argv[argv.index("-c") + 1]).read_text()
config.write_text(original.replace("xwayland disable", "xwayland enable"))
argv = list(argv)
argv[argv.index("-c") + 1] = str(config)
return super().start(argv)
def __enter__(self):
super().__enter__()
try:
# Read only the DISPLAY from our compositor's child environment.
# No inherited desktop socket, Xauthority, bus or profile is used.
helper, marker = self.directory / "display.py", self.directory / "display"
helper.write_text("import os\nfrom pathlib import Path\nPath(" + repr(str(marker))
+ ").write_text(os.environ.get('DISPLAY', ''))\n")
self.command("exec /usr/bin/python3 " + json.dumps(str(helper)))
until(marker.exists)
display = marker.read_text()
assert re.fullmatch(r":[0-9]+(?:\.[0-9]+)?", display), "NO_PRIVATE_X_DISPLAY"
self.env.update(DISPLAY=display, GDK_BACKEND="wayland")
return self
except BaseException:
self.__exit__(None, None, None)
raise
def windows(self):
return [n for n in walk(self.tree()) if n.get("app_id") or n.get("window")]
def x11(self, wm_class):
process = self.spawn(["/usr/bin/xterm", "-class", wm_class, "-name", "TEST_ONLY",
"-title", "TEST_ONLY", "-e", "/usr/bin/sleep", "180"])
found = until(lambda: [n for n in self.windows() if n.get("pid") == process.pid
and n.get("window_properties", {}).get("class") == wm_class])
assert len(found) == 1 and found[0]["app_id"] is None
return found[0], process
def main():
with Lab() as lab:
print("TEST_ONLY_DIRECTORY=" + str(lab.directory), flush=True)
binary = build(lab.directory)
native = Surfaces(lab, build_helper(lab.directory))
sway = Sway(lab.socket)
lab.command('workspace "TEST_ONLY_TARGET"')
chat, chat_process = lab.x11("Chatgpt")
whatsapp = native.create(WEBAPP)
time.sleep(.2)
data = capture(sway, Catalog(), None)
assert {a["application_id"] for a in data["applications"]} == {"chatgpt", "whatsapp-web"}
assert all(s["identity_requirement"] == "exact" for s in data["window_slots"])
(lab.directory / "snapshot.json").write_text(json.dumps(data, indent=2))
for node in (chat, whatsapp):
lab.command(f'[con_id={node["id"]}] move container to workspace "TEST_ONLY_SOURCE"')
lab.command('workspace "TEST_ONLY_FOREIGN"')
foreign, foreign_process = lab.x11("TEST_ONLY_Unknown")
time.sleep(.2)
tree = sway.tree()
foreign_before = observe(tree, next(n for n in walk(tree) if n["id"] == foreign["id"]), process_start)
before_ids = {n["id"] for n in sway.windows()}
commands = []
raw_command = sway.command
def command(text):
commands.append(text)
return raw_command(text)
sway.command = command
runtime = private_dir(lab.directory / "runtime")
# The production catalogue is used unchanged; all real app launches are
# forbidden. Our xterm and GTK clients expose synthetic protocol IDs only.
with patch("session_v2.executor.launch", side_effect=AssertionError("REAL_APP_LAUNCH_FORBIDDEN")):
result = Executor(sway, Catalog(), None, runtime, binary, lab.env).apply(data, True)
assert result["status"] == "success", result
assert result["final_tree_verified"] and result["final_focus_verified"]
tree = sway.tree()
assert {n["id"] for n in sway.windows()} == before_ids
assert location(tree, chat["id"])[0] == location(tree, whatsapp["id"])[0] == "TEST_ONLY_TARGET"
live_foreign = next(n for n in walk(tree) if n["id"] == foreign["id"])
assert observe(tree, live_foreign, process_start) == foreign_before
assert live_foreign["focused"]
assert chat_process.poll() is None and foreign_process.poll() is None
journal = json.loads((runtime / JOURNAL).read_text())
assert recovery_status(runtime)["status"] == "complete"
assert [e["app_id"] for e in journal["foreign"]] == ["x11-class:TEST_ONLY_Unknown"]
entry = next(e for e in journal["windows"] if e["origin"]["con_id"] == chat["id"])
assert entry["origin"]["app_id"] == entry["expected"]["app_id"] == "x11-class:Chatgpt"
assert any(c.startswith(f'[con_id={chat["id"]}] swap container with con_id ') for c in commands)
# An unknown X11 class stays protected foreign evidence and is still
# refused by capture; it never becomes an admissible catalogue target.
try:
capture(sway, Catalog(), None)
except Failure as exc:
assert exc.code == "APPLICATION_UNSUPPORTED", exc.code
else:
raise AssertionError("UNKNOWN_CLASS_WAS_SAVED")
assert surface_identity(live_foreign) == "x11-class:TEST_ONLY_Unknown"
evidence = {"result": result, "unknown_x11_preserved": True,
"unknown_x11_capture_refused": True, "focus_preserved": True,
"x11_app_id_remains_null": next(n for n in sway.windows() if n["id"] == chat["id"])["app_id"] is None,
"commands": commands}
(lab.directory / "xwayland-result.json").write_text(json.dumps(evidence, indent=2))
print("XWAYLAND_RUNTIME=PASS", flush=True)
native.close()
if __name__ == "__main__":
main()

13
tests/step15a/bus.conf Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<!-- TEST_ONLY: deliberately no service directories or systemd activation. -->
<busconfig>
<type>session</type>
<listen>unix:tmpdir=/tmp</listen>
<auth>EXTERNAL</auth>
<policy context="default">
<allow user="*"/>
<allow own="*"/>
<allow send_destination="*"/>
<allow eavesdrop="true"/>
</policy>
</busconfig>

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Synthetic clipboard on a private Wayland server; never log payloads.
Sensitive sentinels exist only in memory and must not reach the test DB.
Only aggregate counts and clipboard state labels are retained as evidence.
"""
import json
import os
from pathlib import Path
import secrets
import signal
import subprocess
import sys
import time
REPO=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(REPO/'tests/session_v2/fixtures'))
from headless import Lab
ART=Path(os.environ['STEP15A_ARTIFACTS']);ART.mkdir(mode=0o700,parents=True,exist_ok=True)
with Lab() as lab:
counter=lab.directory/'counts'
spy=lab.directory/'spy.py'
spy.write_text('import os,sys\nsys.stdin.buffer.read()\nwith open(os.environ["TEST_COUNTS"],"a") as f:f.write(sys.argv[1]+" "+os.environ.get("CLIPBOARD_STATE","")+"\\n")\n')
lab.env['TEST_COUNTS']=str(counter)
def copy(payload,kind='text/plain',sensitive=False):
p=subprocess.Popen(['wl-copy','--foreground','--type',kind,*(['--sensitive'] if sensitive else [])],env=lab.env,stdin=subprocess.PIPE,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
lab.children.append(p);p.stdin.write(payload);p.stdin.close();time.sleep(.2)
def stop(procs):
for p in procs:
if p.poll() is None:
os.killpg(p.pid,signal.SIGTERM);p.wait(timeout=3)
png=subprocess.run(['magick','-size','2x2','xc:blue','png:-'],capture_output=True,env=lab.env,check=True).stdout
phases=[]
for mode in ['legacy','disjoint']:
before=len(counter.read_text().splitlines()) if counter.exists() else 0
watchers=[lab.start(['wl-paste',*(['--type','text'] if mode=='disjoint' else []),'--watch','python3',str(spy),'general']),lab.start(['wl-paste','--type','image/png','--watch','python3',str(spy),'image'])]
time.sleep(.2)
# Ignore the already-owned previous selection replayed to new watchers.
base=len(counter.read_text().splitlines()) if counter.exists() else 0
copy(png,'image/png');time.sleep(.2)
entries=counter.read_text().splitlines()[base:]
counts={name:sum(e.startswith(name+' ') for e in entries) for name in ['general','image']}
phases.append(dict(mode=mode,offer='PNG-only',callbacks=counts))
assert counts=={'general':0,'image':1},counts
# A data-control owner can offer both MIME types without focus/input.
protocol='/usr/share/qt6/wayland/protocols/wlr-data-control/wlr-data-control-unstable-v1.xml'
header=lab.directory/'data-control.h';code=lab.directory/'data-control.c'
subprocess.run(['wayland-scanner','client-header',protocol,str(header)],check=True)
subprocess.run(['wayland-scanner','private-code',protocol,str(code)],check=True)
provider=lab.directory/'mime-provider'
subprocess.run(['cc','-std=c17','-Wall','-Wextra','-I',str(lab.directory),str(REPO/'tests/step15a/mime-provider.c'),str(code),'-lwayland-client','-o',str(provider)],check=True)
base=len(counter.read_text().splitlines())
owner=lab.start([str(provider)]);time.sleep(.4)
entries=counter.read_text().splitlines()[base:]
counts={name:sum(e==name+' data' for e in entries) for name in ['general','image']}
assert counts=={'general':1,'image':1},counts
phases.append(dict(mode=mode,offer='text-and-PNG',callbacks=counts))
stop([owner])
stop(watchers)
subprocess.run(['wl-copy','--clear'],env=lab.env,check=True)
db=lab.directory/'db'
watchers=[lab.start(['wl-paste','--type','text','--watch','cliphist','-db-path',str(db),'store'])]
time.sleep(.15)
def stored_count():
p=subprocess.run(['cliphist','-db-path',str(db),'list'],env=lab.env,capture_output=True)
return len(p.stdout.splitlines()) if p.returncode==0 else 0
copy(b'TEST_ONLY ordinary');time.sleep(.2);assert stored_count()==1
# Random synthetic sensitive sentinel is never printed or written by tests.
sensitive=b'TEST_ONLY-'+secrets.token_bytes(32).hex().encode()
copy(sensitive,sensitive=True);time.sleep(.25);assert stored_count()==1
stop(watchers)
watchers=[lab.start(['wl-paste','--type','image/png','--watch','cliphist','-db-path',str(db),'store'])]
time.sleep(.15)
copy(png,'image/png');time.sleep(.2);assert stored_count()==2
copy(png,'image/png',sensitive=True);time.sleep(.2);assert stored_count()==2
stop(watchers)
# Also exercise the backend marker directly, independent of Wayland timing.
absent=lab.directory/'sensitive-only-db'
subprocess.run(['cliphist','-db-path',str(absent),'store'],input=sensitive,env=dict(lab.env,CLIPBOARD_STATE='sensitive'),check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
assert not absent.exists()
result=dict(overlap=phases,sensitive_wayland_text_retained=False,sensitive_wayland_png_retained=False,sensitive_direct_db_created=False,
real_clipboard_access=False,lab=str(lab.directory))
(ART/'clipboard-metrics.json').write_text(json.dumps(result,indent=2));print(json.dumps(result))

View file

@ -0,0 +1,45 @@
/* TEST_ONLY: offers two synthetic representations through the private
* compositor's data-control protocol. Never connects to the user's display:
* the Python harness supplies an isolated XDG_RUNTIME_DIR/WAYLAND_DISPLAY. */
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <wayland-client.h>
#include "data-control.h"
static struct wl_seat *seat;
static struct zwlr_data_control_manager_v1 *manager;
static void global(void *data, struct wl_registry *registry, uint32_t name,
const char *interface, uint32_t version) {
(void)data; (void)version;
if (!strcmp(interface,"wl_seat"))
seat=wl_registry_bind(registry,name,&wl_seat_interface,1);
if (!strcmp(interface,"zwlr_data_control_manager_v1"))
manager=wl_registry_bind(registry,name,&zwlr_data_control_manager_v1_interface,1);
}
static void removed(void *d, struct wl_registry *r, uint32_t n) {(void)d;(void)r;(void)n;}
static const struct wl_registry_listener registry_listener={global,removed};
static void send_data(void *data, struct zwlr_data_control_source_v1 *source,
const char *mime, int32_t fd) {
(void)data;(void)source;(void)mime;
/* Payload is intentionally a harmless sentinel; only MIME routing is tested. */
const char text[]="TEST_ONLY synthetic representation";
(void)write(fd,text,sizeof(text)-1); close(fd);
}
static void cancelled(void *d, struct zwlr_data_control_source_v1 *s) {(void)d;(void)s;}
static const struct zwlr_data_control_source_v1_listener source_listener={send_data,cancelled};
int main(void) {
struct wl_display *display=wl_display_connect(NULL);
if (!display) return 1;
struct wl_registry *registry=wl_display_get_registry(display);
wl_registry_add_listener(registry,&registry_listener,NULL);
if (wl_display_roundtrip(display)<0 || !seat || !manager) return 2;
struct zwlr_data_control_source_v1 *source=zwlr_data_control_manager_v1_create_data_source(manager);
zwlr_data_control_source_v1_add_listener(source,&source_listener,NULL);
zwlr_data_control_source_v1_offer(source,"text/plain");
zwlr_data_control_source_v1_offer(source,"image/png");
struct zwlr_data_control_device_v1 *device=zwlr_data_control_manager_v1_get_data_device(manager,seat);
zwlr_data_control_device_v1_set_selection(device,source);
while (wl_display_dispatch(display)>=0) {}
wl_display_disconnect(display); return 0;
}

133
tests/step15a/overview.py Normal file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Owned headless compositor, synthetic windows, aggregate capture metrics only."""
import json
import os
from pathlib import Path
import resource
import subprocess
import sys
import time
REPO=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(REPO/'tests/session_v2/fixtures'))
from headless import Lab
SRC=REPO/'quickshell/.config/quickshell/labfy-sway'
QS=str(Path.home()/'.local/opt/quickshell-qt612-popup/bin/quickshell')
ART=Path(os.environ['STEP15A_ARTIFACTS']);ART.mkdir(mode=0o700,parents=True,exist_ok=True)
def rss(pid):
for l in Path(f'/proc/{pid}/status').read_text().splitlines():
if l.startswith('VmRSS:'):return int(l.split()[1])*1024
def cpu(pid):
v=Path(f'/proc/{pid}/stat').read_text().rsplit(')',1)[1].split()
return sum(int(v[i]) for i in [11,12,13,14])/os.sysconf('SC_CLK_TCK')
with Lab() as lab:
lab.env['DBUS_SESSION_BUS_ADDRESS']=os.environ['DBUS_SESSION_BUS_ADDRESS']
config=Path(lab.env['XDG_CONFIG_HOME'])/'quickshell/labfy-sway';config.mkdir(parents=True)
for name in ['overview','theme','components']:(config/name).symlink_to(SRC/name,target_is_directory=True)
# This compositor has never seen personal windows or clipboard data.
lab.kitty('TEST_ONLY_INCLUDED');lab.kitty('TEST_ONLY_EXCLUDED')
policy=Path(lab.env['XDG_CONFIG_HOME'])/'labfy-sway/overview-capture.json';policy.parent.mkdir()
policy.write_text(json.dumps({'enabled':True,'excludeApps':['TEST_ONLY_EXCLUDED']}))
binary=lab.directory/'bin';binary.mkdir()
counter=lab.directory/'capture-count'
wrapper=binary/'grim'
wrapper.write_text('#!/usr/bin/python3\nimport os,time\nwith open(os.environ["TEST_CAPTURE_COUNT"],"a") as f:f.write(str(time.monotonic())+"\\n")\nos.execv("/usr/bin/grim",["grim",*__import__("sys").argv[1:]])\n')
wrapper.chmod(0o700);lab.env.update(PATH=str(binary)+':/usr/bin:/bin',TEST_CAPTURE_COUNT=str(counter))
# Compare one old output-capture operation on the same synthetic desktop.
# This does not run the old timer or touch any physical output.
old=lab.directory/'baseline-backend.py'
old.write_bytes(subprocess.check_output(['git','show','HEAD:quickshell/.config/quickshell/labfy-sway/overview/backend.py'],cwd=REPO))
usage=resource.getrusage(resource.RUSAGE_CHILDREN);t0=time.monotonic()
old_result=subprocess.run(['python3','-B',str(old),'capture','--output','HEADLESS-1'],env=lab.env,capture_output=True,text=True,check=True)
after_usage=resource.getrusage(resource.RUSAGE_CHILDREN)
baseline_capture=dict(wall_ms=(time.monotonic()-t0)*1000,
cpu_seconds=after_usage.ru_utime+after_usage.ru_stime-usage.ru_utime-usage.ru_stime,
captured=json.loads(old_result.stdout)['captured'])
fixture=config/'fixture.qml'
fixture.write_text('''//@ pragma UseQApplication
import QtQuick
import Quickshell
import Quickshell.Io
import "overview"
ShellRoot {
PanelWindow {id:bar;anchors {top:true;left:true;right:true} implicitHeight:34}
FileView {id:guard;path:Quickshell.env("XDG_RUNTIME_DIR")+"/labfy-lock.capture-guard";watchChanges:true;onFileChanged:reload();printErrors:false}
readonly property bool locked:guard.text().trim()==="active"
onLockedChanged: {if(locked){if(view.item)view.item.dismiss();purger.running=true;}}
Process {id:purger;command:["python3","-B","HELPER","purge"]}
LazyLoader {id:view;active:false;WorkspaceOverview {barWindow:bar;onOverviewDismissed:view.active=false}}
IpcHandler {target:"fixture"
function open():void {view.active=true}
function close():void {if(view.item)view.item.dismiss()}
function state():string {return JSON.stringify({loaded:view.active,cards:view.item?view.item.cards:[]})}
function move(id:int,number:int):void {view.item.moveWindow(id,number)}
}
}
'''.replace('HELPER',str(SRC/'overview/backend.py')))
proc=lab.start([QS,'-p',str(fixture),'--no-color'])
def call(name,*args):
r=subprocess.run([QS,'ipc','-p',str(fixture),'call','fixture',name,*map(str,args)],env=lab.env,capture_output=True,text=True,timeout=5)
if r.returncode:raise RuntimeError('Synthetic fixture IPC unavailable')
return json.loads(r.stdout) if r.stdout.strip() else None
def until(predicate,seconds=10):
end=time.monotonic()+seconds
while time.monotonic()<end:
try:
value=call('state')
if predicate(value):return value
except RuntimeError:pass
time.sleep(.05)
raise RuntimeError('Synthetic overview timeout '+str(lab.directory))
def count():return len(counter.read_text().splitlines()) if counter.exists() else 0
until(lambda v:not v['loaded'])
time.sleep(1)
before=dict(rss=rss(proc.pid),cpu=cpu(proc.pid),captures=count())
start=time.monotonic();call('open');until(lambda v:len(v['cards'])==10)
panel_ms=(time.monotonic()-start)*1000
state=until(lambda v:any(w.get('preview') for c in v['cards'] for w in c['windows']))
thumb_ms=(time.monotonic()-start)*1000
for c in state['cards']:
for w in c['windows']:
if w['app']=='TEST_ONLY_EXCLUDED':assert w['excluded'] and not w['preview']
# Measure the real move route without closing the panel; the source is synthetic.
ident=next(w['id'] for c in state['cards'] for w in c['windows'] if w['app']=='TEST_ONLY_INCLUDED')
call('move',ident,2)
until(lambda v:any(c['number']==2 and any(w['id']==ident for w in c['windows']) for c in v['cards']))
time.sleep(1.3)
subprocess.run(['/usr/bin/grim','-o','HEADLESS-1',str(ART/'overview.png')],env=lab.env,check=True)
opened=dict(rss=rss(proc.pid),cpu=cpu(proc.pid),captures=count())
cache_open=sum(p.stat().st_size for p in (lab.directory/'labfy-workspace-overview').iterdir() if p.is_file())
call('close');until(lambda v:not v['loaded']);time.sleep(.4)
closed_count=count()
# Longer than the removed 15-second timer, with native workspace changes.
lab.command('workspace number 3');time.sleep(16)
assert count()==closed_count,('closed capture',count(),closed_count)
closed=dict(rss=rss(proc.pid),cpu=cpu(proc.pid),captures=count())
cycles=[]
for _ in range(3):
call('open');until(lambda v:len(v['cards'])==10);time.sleep(1.4)
call('close');until(lambda v:not v['loaded']);time.sleep(.3)
cycles.append(rss(proc.pid))
assert max(cycles)-min(cycles)<64*1024*1024,cycles
# Real per-window capture unavailable => fallback; policy re-read while open.
disabled_start_count=count()
policy.write_text(json.dumps({'enabled':False}))
call('open');state=until(lambda v:len(v['cards'])==10);time.sleep(1.3)
state=call('state');assert all(not w['preview'] for c in state['cards'] for w in c['windows'])
assert count()==disabled_start_count
subprocess.run(['/usr/bin/grim','-o','HEADLESS-1',str(ART/'overview-disabled.png')],env=lab.env,check=True)
# Lock guard is exclusively in this test's private runtime. No session lock.
guard=lab.directory/'labfy-lock.capture-guard';guard.write_text('active\n')
until(lambda v:not v['loaded'])
time.sleep(.3)
helper=SRC/'overview/backend.py'
r=subprocess.run(['python3','-B',str(helper),'state'],env=lab.env,capture_output=True,text=True,check=True)
assert not json.loads(r.stdout)['captureEnabled']
files=list((lab.directory/'labfy-workspace-overview').glob('*.jpg'));assert not files
result=dict(closed_cycle_rss_bytes=cycles,baseline_output_capture=baseline_capture,cache_open_bytes=cache_open,panel_ms=panel_ms,thumbnail_ms=thumb_ms,before=before,opened=opened,closed=closed,
cache_bytes=sum(p.stat().st_size for p in (lab.directory/'labfy-workspace-overview').iterdir() if p.is_file()),
closed_observation_seconds=16,closed_new_captures=closed['captures']-closed_count,lab=str(lab.directory))
(ART/'overview-metrics.json').write_text(json.dumps(result,indent=2));print(json.dumps(result))

153
tests/step15a/visual.py Normal file
View file

@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""STEP15A: synthetic production components on an owned headless Sway.
CONTRACT: no inherited compositor, bus, clipboard, notifications or HOME.
Copies add measurement hooks only; the production geometry remains unchanged.
Artifacts contain only synthetic pixels and aggregate performance measurements.
"""
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import time
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / 'tests/session_v2/fixtures'))
from headless import Lab
SOURCE = REPO / 'quickshell/.config/quickshell/labfy-sway'
QS = str(Path.home() / '.local/opt/quickshell-qt612-popup/bin/quickshell')
ART = Path(os.environ['STEP15A_ARTIFACTS'])
ART.mkdir(parents=True, exist_ok=True, mode=0o700)
def call(lab, path, name, *args):
p = subprocess.run([QS, 'ipc', '-p', str(path), 'call', 'fixture', name, *map(str,args)],
env=lab.env, capture_output=True, text=True, timeout=5)
if p.returncode:
raise RuntimeError('Synthetic IPC failed: ' + p.stderr[-1000:])
return json.loads(p.stdout) if p.stdout.strip() else None
def wait(lab, path):
end = time.monotonic()+12
while time.monotonic()<end:
try:
return call(lab,path,'state')
except RuntimeError:
time.sleep(.1)
raise RuntimeError('Synthetic fixture did not load: '+str(lab.directory))
with Lab() as lab:
lab.env['DBUS_SESSION_BUS_ADDRESS'] = os.environ['DBUS_SESSION_BUS_ADDRESS']
config=Path(lab.env['XDG_CONFIG_HOME'])/'quickshell/labfy-sway'
config.mkdir(parents=True)
# Production modules are referenced by absolute import; writable generated
# appearance/session paths resolve only inside the isolated environment.
for name in ['theme','components']:
(config/name).symlink_to(SOURCE/name, target_is_directory=True)
dc=config/'datecenter'; dc.mkdir()
for p in (SOURCE/'datecenter').glob('*.qml'):
text=p.read_text()
if p.name=='NotificationList.qml':
text=text.replace(' id: list', ''' id: list
function scrollEnd() { notificationView.positionViewAtEnd(); }
function measureList() { return { y:notificationView.contentY, content:notificationView.contentHeight,
viewport:notificationView.height, count:rows.length }; }''')
if p.name=='MediaPlayer.qml':
a=text.index(' readonly property var player: {');b=text.index('\n visible:',a)
text=text[:a]+' property var player: null\n'+text[b:]
text=text.replace(' property var player: null', ''' property var player: null
function artMetrics() { return { width:artwork.sourceSize.width,height:artwork.sourceSize.height,
status:artwork.status }; }''')
if p.name=='DateCenter.qml':
text=text.replace('MediaPlayer { width:', 'MediaPlayer { id: media; width:').replace('NotificationList { width:', 'NotificationList { id: notices; width:')
text=text.replace('MediaPlayer { id: mediaCard;', 'MediaPlayer { id: media;').replace('mediaCard.', 'media.').replace('NotificationList {\n', 'NotificationList {\n id: notices\n')
text=text.replace(' property bool opening: false', ''' property bool opening: false
function setPlayer(value) { media.player = value; }
function scrollList() { notices.scrollEnd(); }
function metrics() {
return {width:width,height:height,implicitHeight:implicitHeight,
availableHeight:barWindow.screen.height-barWindow.height-22,player:media.visible,playerHeight:media.height,playerY:media.y,
listY:notices.y,listHeight:notices.height,contentHeight:content.childrenRect.height,
art:media.artMetrics(),list:notices.measureList()};
}''')
(dc/p.name).write_text(text)
art=lab.directory/'large-art.png'
subprocess.run(['magick','-size','4096x4096','xc:#b4befe',str(art)],check=True,env=lab.env)
fixture=config/'fixture.qml'
fixture.write_text('''//@ pragma UseQApplication
import QtQuick
import Quickshell
import Quickshell.Io
import "datecenter"
ShellRoot {
PanelWindow { id:bar; anchors {top:true;left:true;right:true} implicitHeight:34
Item {id:clockItem; x:parent.width/2; width:1; height:20}
}
QtObject {id:fakePlayer; property string dbusName:"org.mpris.MediaPlayer2.TEST_ONLY"
property string trackTitle:"TEST_ONLY long title "+"x".repeat(2000)
property string trackArtist:"TEST_ONLY artist"; property string trackArtUrl:"ART"
property bool isPlaying:false; property bool canGoPrevious:true
property bool canTogglePlaying:true; property bool canGoNext:true
function previous() {} function next() {} function togglePlaying() {}
}
QtObject {id:service; property var records:[]
function setDateCenterVisible(value) {} function live(id) {return null}
function dismiss(id) {records=records.filter(r=>r.internalId!==id)}
function clearAll() {records=[]}
}
DateCenter {id:popup;barWindow:bar;clockItem:clockItem;notificationService:service}
IpcHandler {target:"fixture"
function state():string {return JSON.stringify(popup.metrics())}
function setup(count:int,player:bool):void {
popup.setPlayer(player?fakePlayer:null);
service.records=Array.from({length:count},(_,i)=>({internalId:i+1,appName:"TEST_ONLY",
summary:"TEST_ONLY notification "+i,body:"synthetic long body ".repeat(40),image:""}));
popup.requestOpen();
}
function remove():void {service.dismiss(1)}
function clear():void {service.clearAll()}
function incoming():void {service.records=[{internalId:999,appName:"TEST_ONLY",summary:"Incoming",body:""}].concat(service.records)}
function hide():void {popup.requestClose()}
function scroll():void {popup.scrollList()}
}
}
'''.replace('ART',art.as_uri()))
proc=lab.start([QS,'-p',str(fixture),'--no-color'])
wait(lab,fixture)
results=[]
for scale in [1,1.4]:
lab.command(f'output HEADLESS-1 scale {scale}')
for player in [False,True]:
for n in [0,1,5,200]:
call(lab,fixture,'setup',n,str(player).lower());time.sleep(.25)
m=call(lab,fixture,'state');m.update(scale=scale,notifications=n)
results.append(m)
if player: assert m['playerHeight']==116,m
else: assert m['playerHeight']==0,m
assert m['height']>=m['contentHeight']+32-1,('clipping',m)
assert m['height']<=m['availableHeight']+1,('screen overflow',m)
if n==200:
call(lab,fixture,'scroll');time.sleep(.15)
scrolled=call(lab,fixture,'state')
assert scrolled['playerY']==m['playerY'] and scrolled['playerHeight']==m['playerHeight']
assert scrolled['list']['y']>0,scrolled
assert scrolled['list']['y']+scrolled['list']['viewport']>=scrolled['list']['content']-2,scrolled
if player:
assert scrolled['art']['width']<=144 and scrolled['art']['height']<=144,scrolled
if n in [0,200]:
subprocess.run(['grim','-o','HEADLESS-1',str(ART/f'date-{scale}-{player}-{n}.png')],env=lab.env,check=True)
call(lab,fixture,'setup',5,'true');call(lab,fixture,'remove');time.sleep(.15)
results.append(dict(action='remove',**call(lab,fixture,'state')))
call(lab,fixture,'clear');time.sleep(.15)
results.append(dict(action='clear',**call(lab,fixture,'state')))
call(lab,fixture,'incoming');time.sleep(.15)
results.append(dict(action='incoming',**call(lab,fixture,'state')))
call(lab,fixture,'setup',0,'false');time.sleep(.15)
results.append(dict(action='player-disappeared',**call(lab,fixture,'state')))
(ART/'date-metrics.json').write_text(json.dumps(results,indent=2))
call(lab,fixture,'hide')
print(json.dumps({'dateCases':len(results),'lab':str(lab.directory)}))

View file

@ -0,0 +1,34 @@
import importlib.util
from pathlib import Path
import unittest
path = Path(__file__).resolve().parents[2] / 'quickshell/.config/quickshell/labfy-sway/systemmonitor/backend.py'
spec = importlib.util.spec_from_file_location('monitor_backend', path)
backend = importlib.util.module_from_spec(spec)
spec.loader.exec_module(backend)
class BackendTests(unittest.TestCase):
def test_cpu_excludes_guest_double_count(self):
data = 'cpu 10 2 3 20 1 4 5 6 7 8\ncpu0 1 2 3 4 5 6 7 8 9 10\n'
self.assertEqual(backend.cpu_ticks(data)['cpu'], [51, 21])
def test_mem_available_and_swap(self):
d = backend.meminfo('MemTotal: 100 kB\nMemFree: 2 kB\nMemAvailable: 40 kB\nCached: 20 kB\nSwapTotal: 10 kB\nSwapFree: 3 kB')
self.assertEqual(d['used'], 60 * 1024)
self.assertEqual(d['swapUsed'], 7 * 1024)
def test_psi_and_missing(self):
d = backend.psi('some avg10=1.25 avg60=2.50 avg300=3.75 total=9\nfull avg10=0.01 avg60=0.02 avg300=0.03 total=2')
self.assertEqual(d['some'], [1.25, 2.5, 3.75])
self.assertIsNone(backend.psi('')['full'])
def test_zram_and_frequency(self):
self.assertEqual(backend.zram('100 40 48 0 0')['ratio'], 2.5)
self.assertIsNone(backend.zram(''))
self.assertEqual(backend.gpu_frequency('0: 400Mhz\n1: 1200Mhz *'), 1200)
self.assertIsNone(backend.gpu_frequency(''))
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Synthetic dashboard on an isolated headless Sway; never touches the live shell."""
import json
import resource
import os
from pathlib import Path
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'tests/session_v2/fixtures'))
from headless import Lab
SOURCE = ROOT / 'quickshell/.config/quickshell/labfy-sway'
QS = str(Path.home() / '.local/opt/quickshell-qt612-popup/bin/quickshell')
def call(lab, fixture, name, *args):
result = subprocess.run([QS, 'ipc', '-p', str(fixture), 'call', 'fixture', name, *map(str,args)],
env=lab.env, text=True, capture_output=True, timeout=5)
if result.returncode:
raise RuntimeError(result.stderr[-1500:])
if not result.stdout.strip():
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return result.stdout.strip()
with Lab() as lab:
lab.env['DBUS_SESSION_BUS_ADDRESS'] = os.environ['DBUS_SESSION_BUS_ADDRESS']
config = Path(lab.env['XDG_CONFIG_HOME']) / 'quickshell/labfy-sway'
config.mkdir(parents=True)
(config / 'theme').symlink_to(SOURCE / 'theme', target_is_directory=True)
monitor = config / 'systemmonitor'
monitor.mkdir()
for name in ('Sparkline.qml', 'MonitorPanel.qml'):
content = (SOURCE / 'systemmonitor' / name).read_text()
if name == 'MonitorPanel.qml':
content = content.replace(' grabFocus: true', ' grabFocus: false')
content = content.replace(' signal dismissed()', ''' signal dismissed()
function metrics(): string {
return JSON.stringify({width:width,height:height,screenWidth:barWindow.screen.width,
screenHeight:barWindow.screen.height,gridHeight:grid.height,viewport:dashboard.height,
cpu:[cpuBox.x,cpuBox.y,cpuBox.width,cpuBox.height],gpu:[gpuBox.x,gpuBox.y,gpuBox.width,gpuBox.height],
memory:[memoryBox.x,memoryBox.y,memoryBox.width,memoryBox.height],
processes:[processBox.x,processBox.y,processBox.width,processBox.height],
disk:[diskBox.x,diskBox.y,diskBox.width,diskBox.height],
network:[netBox.x,netBox.y,netBox.width,netBox.height],rows:processList.count,
cores:coreKeys().length,visible:visible,collect:service.processesVisible});
}''')
(monitor / name).write_text(content)
fixture = config / 'fixture.qml'
fixture.write_text('''//@ pragma UseQApplication
import QtQuick
import Quickshell
import Quickshell.Io
import "systemmonitor"
ShellRoot {
PanelWindow {id:bar; anchors {top:true;left:true;right:true} implicitHeight:34; focusable:true; color:"#1e1e2e"
Item {id:indicator; x:parent.width-30; width:26; height:26}
}
QtObject { id:service; property bool processesVisible:false
property var data: ({memory:{used:8e9,total:16e9,available:8e9,cache:1e9,swapUsed:1e9,swapTotal:6e9},gpu:null,zram:null,
psi:{some:[1,2,3],full:[0,0,0]},temperatures:{cpu:65,gpu:null},disk:{used:400e9,total:1000e9,available:500e9,filesystem:"btrfs"},
load:["1.2","1.1","1.0"],frequencies:[{currentMHz:3000}],network:null})
property var usage: ({cpu:42,threads:{cpu0:10,cpu1:20,cpu2:30,cpu3:40,cpu4:50,cpu5:60,cpu6:70,cpu7:80,
cpu8:11,cpu9:21,cpu10:31,cpu11:41,cpu12:51,cpu13:61,cpu14:71,cpu15:81}})
property var rates: ({rx:100000,tx:50000})
property var processRows: []
property var histories: ({cpu:Array.from({length:600},(_,i)=>({value:i%100})),gpu:[],ram:[],psi:[],rx:[],tx:[]})
}
MonitorPanel {id:popup;barWindow:bar;indicator:indicator;service:service;}
IpcHandler { target:"fixture"
function state(): string {return popup.metrics()}
function setRows(count:int): void { service.processRows=Array.from({length:count},(_,i)=>({pid:i+1,name:"processus très long "+i,cpu:i%100,rss:i*1000000})); }
function hide(): void {popup.visible=false}
function show(): void {popup.visible=true}
}
}
''')
proc = lab.start([QS, '-p', str(fixture), '--no-color'])
deadline = time.monotonic() + 12
while True:
try:
call(lab, fixture, 'state')
break
except RuntimeError:
if time.monotonic() > deadline:
raise RuntimeError('Fixture failed to load: ' + str(lab.directory) + '\n' + (lab.directory / 'child-1.log').read_text()[-5000:])
time.sleep(.1)
call(lab,fixture,'show')
for scale in (1, 1.4):
lab.command(f'output HEADLESS-1 scale {scale}')
for count in (0, 80):
call(lab, fixture, 'setRows', count)
time.sleep(.15)
state = call(lab, fixture, 'state')
assert state['visible'] and state['collect'], state
assert state['cores'] == 16 and state['rows'] == count, state
assert state['width'] <= state['screenWidth'] - 20, state
assert state['height'] <= state['screenHeight'] - 34, state
for key in ('cpu','gpu','memory','processes','disk','network'):
x,y,w,h = state[key]
assert x >= 0 and y >= 0 and w > 0 and h > 0 and x+w <= state['width'] and y+h <= state['gridHeight'], (key,state)
if scale == 1 and count == 80:
image = Path('/tmp/step15b2-dashboard-headless.png')
subprocess.run(['grim','-o','HEADLESS-1',str(image)], env=lab.env, check=True)
print('SCREENSHOT',image)
print('PASS',scale,count,state['width'],state['height'])
call(lab,fixture,'hide')
assert not call(lab,fixture,'state')['collect']
call(lab,fixture,'show')
print('PASS close stops process collection')
stat = Path(f'/proc/{proc.pid}/stat').read_text().split()
rss = int(Path(f'/proc/{proc.pid}/status').read_text().split('VmRSS:')[1].split()[0])
print('FIXTURE_QS_RSS_KIB', rss)
errors = [line for line in (lab.directory / 'child-1.log').read_text().splitlines() if 'ERROR:' in line]
assert not errors, errors[-20:]

View file

@ -94,31 +94,14 @@ export STARSHIP_CONFIG="$HOME/.config/starship.toml"
command -v starship >/dev/null && eval "$(starship init zsh)"
# =========================
# Atuin
# FZF + zoxide + Atuin
# =========================
eval "$(atuin init zsh)"
# =========================
# Fzf + zoxide
# =========================
command -v bat >/dev/null && export FZF_DEFAULT_OPTS="
fd
--type f
--hidden
--follow
--exclude .git
"
export FZF_CTRL_T_COMMAND="
$FZF_DEFAULT_COMMAND
"
export FZF_ALT_C_COMMAND="
fd
--type d
--hidden
--follow
--exclude .git
"
# CONTRAT: fd produit les chemins; FZF_DEFAULT_OPTS contient uniquement les options UI.
export FZF_DEFAULT_COMMAND='fd --type f --hidden --follow --exclude .git'
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"
export FZF_ALT_C_COMMAND='fd --type d --hidden --follow --exclude .git'
# Ctrl+R appartient a Atuin, Ctrl+F reste disponible pour l'historique FZF.
export FZF_CTRL_R_COMMAND=''
export FZF_DEFAULT_OPTS="
--height=30%
--layout=reverse
@ -132,11 +115,12 @@ export FZF_DEFAULT_OPTS="
--color=border:#6C7086,label:#CDD6F4
"
[ -r /usr/share/fzf/key-bindings.zsh ] && source /usr/share/fzf/key-bindings.zsh
[ -r /usr/share/fzf/completion.zsh ] && source /usr/share/fzf/completion.zsh
# Le chargeur fzf --zsh fournit deja ces deux integrations.
command -v zoxide >/dev/null && eval "$(zoxide init zsh)"
command -v fzf >/dev/null && eval "$(fzf --zsh)"
command -v zoxide >/dev/null && eval "$(zoxide init zsh)"
# Atuin installe ses bindings apres FZF : Ctrl+R reste reserve a Atuin.
command -v atuin >/dev/null && eval "$(atuin init zsh)"
# =========================
# Aliases de base
@ -150,7 +134,8 @@ alias b='yazi'
alias n='nvim'
# Git
alias g='ga && gc && gp'
# Ne jamais enchaîner automatiquement staging, commit et push.
alias g='git status --short'
alias gs='git status'
alias ga='git add .'
gc() {
@ -158,25 +143,34 @@ gc() {
read msg
git commit -m "$msg"
}
alias gp='git push'
alias gl='git log --oneline --graph --decorate'
gpall() {
local branch
gp() {
local branch target
branch="$(git branch --show-current)" || return 1
if [[ -z "$branch" ]]; then
print -u2 "Erreur : aucune branche Git active."
return 1
fi
print "Push vers Forgejo..."
git push origin "$branch" || return 1
print "Push vers GitHub..."
git push github "$branch" || return 1
print "Synchronisation terminée pour la branche : $branch"
# URL de fetch : une destination unique, même si origin déclare plusieurs pushurl.
target="$(git remote get-url origin)" || return 1
print -r -- "Push vers une seule destination : $target ($branch)"
git push "$target" "$branch"
}
alias gl='git log --oneline --graph --decorate'
gpall() {
local branch answer
branch="$(git branch --show-current)" || return 1
if [[ -z "$branch" ]]; then
print -u2 "Erreur : aucune branche Git active."
return 1
fi
print 'Destinations de publication configurees pour origin :'
git remote get-url --push --all origin || return 1
read -r 'answer?Confirmer le push sur TOUTES ces destinations (taper OUI) : ' || return 1
if [[ "$answer" != 'OUI' ]]; then
print 'Publication annulee.'
return 1
fi
git push origin "$branch"
}
# Grep / ripgrep
alias grep='grep --color=auto'