diff --git a/atuin/.config/atuin/config.toml b/atuin/.config/atuin/config.toml index df85821e9..a3d581e83 100644 --- a/atuin/.config/atuin/config.toml +++ b/atuin/.config/atuin/config.toml @@ -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: ## diff --git a/atuin/.config/atuin/themes/catppuccin-mocha-lavender.toml b/atuin/.config/atuin/themes/catppuccin-mocha-lavender.toml new file mode 100644 index 000000000..ade651915 --- /dev/null +++ b/atuin/.config/atuin/themes/catppuccin-mocha-lavender.toml @@ -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" diff --git a/bin/.local/bin/trier-photos b/bin/.local/bin/trier-photos new file mode 100755 index 000000000..871076e5b --- /dev/null +++ b/bin/.local/bin/trier-photos @@ -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"(? " + 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()) diff --git a/docs/nextcloud-launch-memory-fix-v1.md b/docs/nextcloud-launch-memory-fix-v1.md new file mode 100644 index 000000000..4d3de1418 --- /dev/null +++ b/docs/nextcloud-launch-memory-fix-v1.md @@ -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é. diff --git a/docs/session-restore-v2.md b/docs/session-restore-v2.md index de6469fbc..635bfc0e2 100644 --- a/docs/session-restore-v2.md +++ b/docs/session-restore-v2.md @@ -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. diff --git a/docs/session-v2-firefox-webapps-fix-v1.md b/docs/session-v2-firefox-webapps-fix-v1.md new file mode 100644 index 000000000..75bd10dc3 --- /dev/null +++ b/docs/session-v2-firefox-webapps-fix-v1.md @@ -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`. diff --git a/docs/session-v2-focus-staging-fix-v1.md b/docs/session-v2-focus-staging-fix-v1.md new file mode 100644 index 000000000..44928780e --- /dev/null +++ b/docs/session-v2-focus-staging-fix-v1.md @@ -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. diff --git a/docs/session-v2-installed-app-catalog-v1.md b/docs/session-v2-installed-app-catalog-v1.md new file mode 100644 index 000000000..2fbffb849 --- /dev/null +++ b/docs/session-v2-installed-app-catalog-v1.md @@ -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 `, 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) diff --git a/firefox-extension/session-v2/README.md b/firefox-extension/session-v2/README.md index 61dc66bb3..f5eec3446 100644 --- a/firefox-extension/session-v2/README.md +++ b/firefox-extension/session-v2/README.md @@ -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, diff --git a/firefox-extension/session-v2/background.js b/firefox-extension/session-v2/background.js index 886444d43..0c70cd844 100644 --- a/firefox-extension/session-v2/background.js +++ b/firefox-extension/session-v2/background.js @@ -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. */ } diff --git a/firefox-extension/session-v2/manifest.json b/firefox-extension/session-v2/manifest.json index 680c2faa9..c60d10288 100644 --- a/firefox-extension/session-v2/manifest.json +++ b/firefox-extension/session-v2/manifest.json @@ -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"]}} diff --git a/gtk-3.0/.config/gtk-3.0/bookmarks b/gtk-3.0/.config/gtk-3.0/bookmarks index 3651371af..c7398427e 100644 --- a/gtk-3.0/.config/gtk-3.0/bookmarks +++ b/gtk-3.0/.config/gtk-3.0/bookmarks @@ -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/ diff --git a/kitty/.config/kitty/kitty.conf b/kitty/.config/kitty/kitty.conf index a5f347f60..42ae5844b 100644 --- a/kitty/.config/kitty/kitty.conf +++ b/kitty/.config/kitty/kitty.conf @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/Bar.qml b/quickshell/.config/quickshell/labfy-sway/Bar.qml index 1eda66316..091c3fcab 100644 --- a/quickshell/.config/quickshell/labfy-sway/Bar.qml +++ b/quickshell/.config/quickshell/labfy-sway/Bar.qml @@ -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(); diff --git a/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml b/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml index e145ec3f5..f8903b4bf 100644 --- a/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml +++ b/quickshell/.config/quickshell/labfy-sway/SessionStartupPromptV2.qml @@ -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." diff --git a/quickshell/.config/quickshell/labfy-sway/applications/backend.py b/quickshell/.config/quickshell/labfy-sway/applications/backend.py index 331a5f6c3..fd5ca4ace 100644 --- a/quickshell/.config/quickshell/labfy-sway/applications/backend.py +++ b/quickshell/.config/quickshell/labfy-sway/applications/backend.py @@ -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"(? { 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)) + } } } } diff --git a/quickshell/.config/quickshell/labfy-sway/datecenter/MediaPlayer.qml b/quickshell/.config/quickshell/labfy-sway/datecenter/MediaPlayer.qml index 0264500a5..3f9110d19 100644 --- a/quickshell/.config/quickshell/labfy-sway/datecenter/MediaPlayer.qml +++ b/quickshell/.config/quickshell/labfy-sway/datecenter/MediaPlayer.qml @@ -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 } diff --git a/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationItem.qml b/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationItem.qml index 12cfdc1e0..6a813c6bb 100644 --- a/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationItem.qml +++ b/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationItem.qml @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationList.qml b/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationList.qml index a23649398..28e8bc087 100644 --- a/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationList.qml +++ b/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationList.qml @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/overview/README.md b/quickshell/.config/quickshell/labfy-sway/overview/README.md index d8fd5de51..f0eb7df3a 100644 --- a/quickshell/.config/quickshell/labfy-sway/overview/README.md +++ b/quickshell/.config/quickshell/labfy-sway/overview/README.md @@ -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 -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 `. +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 ` 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 diff --git a/quickshell/.config/quickshell/labfy-sway/overview/WorkspaceOverview.qml b/quickshell/.config/quickshell/labfy-sway/overview/WorkspaceOverview.qml index 47f3963f0..6b531ad11 100644 --- a/quickshell/.config/quickshell/labfy-sway/overview/WorkspaceOverview.qml +++ b/quickshell/.config/quickshell/labfy-sway/overview/WorkspaceOverview.qml @@ -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) { - for (const current of next) { - const old = cards.find(value => value.number === current.number); - if (old && current.windows.length && (old.revision !== current.revision + const wasEnabled = captureEnabled; + captureEnabled = data.captureEnabled === true; + for (const current of next) { + const old = cards.find(value => value.number === current.number); + 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 } diff --git a/quickshell/.config/quickshell/labfy-sway/overview/backend.py b/quickshell/.config/quickshell/labfy-sway/overview/backend.py index 66c292699..682d2d46b 100644 --- a/quickshell/.config/quickshell/labfy-sway/overview/backend.py +++ b/quickshell/.config/quickshell/labfy-sway/overview/backend.py @@ -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 "" - forget_other_outputs(number, output) + 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 ""}) - prune_window_previews({window["id"] for card in cards for window in card["windows"]}) + 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(): - 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"]} + # CONTRACT: retained CLI returns a fallback; whole-output capture is disabled + # because it cannot honor per-application exclusions, even before opening. + return {"captured": False} + + +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) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py b/quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py index a33c205b1..d546a038f 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/anchors.py @@ -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") diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/applications.py b/quickshell/.config/quickshell/labfy-sway/session_v2/applications.py index 255f4521b..fd4feb6c5 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/applications.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/applications.py @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py b/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py index 10c2d17a0..49b695d09 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py @@ -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"]: diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/ipc.py b/quickshell/.config/quickshell/labfy-sway/session_v2/ipc.py index c818f741d..cce24d61f 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/ipc.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/ipc.py @@ -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) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/native_host.py b/quickshell/.config/quickshell/labfy-sway/session_v2/native_host.py index d4ccb9082..181e52f91 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/native_host.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/native_host.py @@ -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,18 +17,25 @@ 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 - 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") + 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") require(type(message["runtime_token"]) is str and re.fullmatch(r"[0-9a-f]{32}", message["runtime_token"]), "INVALID_TOKEN") return message @@ -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(), diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/recovery.py b/quickshell/.config/quickshell/labfy-sway/session_v2/recovery.py index ce9571aaf..a354bef73 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/recovery.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/recovery.py @@ -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)}) diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v2.json b/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v2.json index 021c17eeb..8418dfec1 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v2.json +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v2.json @@ -175,6 +175,7 @@ "multi-instance", "terminal", "browser-self-restore", + "application-self-restore", "unknown" ] }, diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py b/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py index 1f43d6eae..7c68df357 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py @@ -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") diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py b/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py index 3f6780b6c..f89e58f96 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py @@ -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} diff --git a/quickshell/.config/quickshell/labfy-sway/shell.qml b/quickshell/.config/quickshell/labfy-sway/shell.qml index 712bbfbbd..140d40d65 100644 --- a/quickshell/.config/quickshell/labfy-sway/shell.qml +++ b/quickshell/.config/quickshell/labfy-sway/shell.qml @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/systemmonitor/MonitorPanel.qml b/quickshell/.config/quickshell/labfy-sway/systemmonitor/MonitorPanel.qml new file mode 100644 index 000000000..8cb1db159 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/systemmonitor/MonitorPanel.qml @@ -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 } + } + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/systemmonitor/MonitorService.qml b/quickshell/.config/quickshell/labfy-sway/systemmonitor/MonitorService.qml new file mode 100644 index 000000000..b0862b3e4 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/systemmonitor/MonitorService.qml @@ -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); } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/systemmonitor/README.md b/quickshell/.config/quickshell/labfy-sway/systemmonitor/README.md new file mode 100644 index 000000000..bb37d2292 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/systemmonitor/README.md @@ -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. diff --git a/quickshell/.config/quickshell/labfy-sway/systemmonitor/Sparkline.qml b/quickshell/.config/quickshell/labfy-sway/systemmonitor/Sparkline.qml new file mode 100644 index 000000000..f5b19bc17 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/systemmonitor/Sparkline.qml @@ -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; } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/systemmonitor/backend.py b/quickshell/.config/quickshell/labfy-sway/systemmonitor/backend.py new file mode 100644 index 000000000..f04a3d01b --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/systemmonitor/backend.py @@ -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) diff --git a/starship/.config/starship.toml b/starship/.config/starship.toml index ab8fd6324..c4936f3c8 100644 --- a/starship/.config/starship.toml +++ b/starship/.config/starship.toml @@ -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 = " " diff --git a/sway/.config/sway/autostart b/sway/.config/sway/autostart index 6fb4cd62c..c0159857d 100644 --- a/sway/.config/sway/autostart +++ b/sway/.config/sway/autostart @@ -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 diff --git a/sway/.config/sway/bind b/sway/.config/sway/bind index 074ef4c9d..837149876 100644 --- a/sway/.config/sway/bind +++ b/sway/.config/sway/bind @@ -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 ############################################### diff --git a/systemd/.config/systemd/user/cliphist-image.service b/systemd/.config/systemd/user/cliphist-image.service index dc9107a28..1a2c3cb88 100644 --- a/systemd/.config/systemd/user/cliphist-image.service +++ b/systemd/.config/systemd/user/cliphist-image.service @@ -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 diff --git a/systemd/.config/systemd/user/cliphist-text.service b/systemd/.config/systemd/user/cliphist-text.service index 645d7b83f..6a15a7150 100644 --- a/systemd/.config/systemd/user/cliphist-text.service +++ b/systemd/.config/systemd/user/cliphist-text.service @@ -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] diff --git a/systemd/.config/systemd/user/lardon3d-mobile.service b/systemd/.config/systemd/user/lardon3d-mobile.service new file mode 100644 index 000000000..fd57b2aac --- /dev/null +++ b/systemd/.config/systemd/user/lardon3d-mobile.service @@ -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 diff --git a/tests/applications/test_backend.py b/tests/applications/test_backend.py index 61c0b03dd..fffb8ccf6 100644 --- a/tests/applications/test_backend.py +++ b/tests/applications/test_backend.py @@ -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() diff --git a/tests/clipboard/test_privacy.py b/tests/clipboard/test_privacy.py new file mode 100644 index 000000000..a38489017 --- /dev/null +++ b/tests/clipboard/test_privacy.py @@ -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() diff --git a/tests/firefox_session_v2/background.test.js b/tests/firefox_session_v2/background.test.js index d3d88ca06..b60dd9523 100644 --- a/tests/firefox_session_v2/background.test.js +++ b/tests/firefox_session_v2/background.test.js @@ -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"); +}); diff --git a/tests/overview/test_backend.py b/tests/overview/test_backend.py index ee0dac741..713e53a1c 100644 --- a/tests/overview/test_backend.py +++ b/tests/overview/test_backend.py @@ -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) diff --git a/tests/overview/test_privacy.py b/tests/overview/test_privacy.py new file mode 100644 index 000000000..76dab9ee2 --- /dev/null +++ b/tests/overview/test_privacy.py @@ -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() diff --git a/tests/session_v2/firefox_webapp_runtime.py b/tests/session_v2/firefox_webapp_runtime.py new file mode 100644 index 000000000..369b53bc6 --- /dev/null +++ b/tests/session_v2/firefox_webapp_runtime.py @@ -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"TEST_ONLY

TEST_ONLY

" + 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() diff --git a/tests/session_v2/login_focus.py b/tests/session_v2/login_focus.py index 23303d22f..2c7dcb95b 100644 --- a/tests/session_v2/login_focus.py +++ b/tests/session_v2/login_focus.py @@ -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) diff --git a/tests/session_v2/p0_runtime.py b/tests/session_v2/p0_runtime.py index fd4230db0..7a2ec50c2 100644 --- a/tests/session_v2/p0_runtime.py +++ b/tests/session_v2/p0_runtime.py @@ -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: diff --git a/tests/session_v2/path_stability_runtime.py b/tests/session_v2/path_stability_runtime.py index 08fd13599..5e5a44821 100644 --- a/tests/session_v2/path_stability_runtime.py +++ b/tests/session_v2/path_stability_runtime.py @@ -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): diff --git a/tests/session_v2/provider_ui.py b/tests/session_v2/provider_ui.py new file mode 100644 index 000000000..dab0bd48c --- /dev/null +++ b/tests/session_v2/provider_ui.py @@ -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() diff --git a/tests/session_v2/test_bootstrap.py b/tests/session_v2/test_bootstrap.py index c5159dc1b..f7ab2a544 100644 --- a/tests/session_v2/test_bootstrap.py +++ b/tests/session_v2/test_bootstrap.py @@ -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()) diff --git a/tests/session_v2/test_installed_catalog.py b/tests/session_v2/test_installed_catalog.py new file mode 100644 index 000000000..9a5d50b30 --- /dev/null +++ b/tests/session_v2/test_installed_catalog.py @@ -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() diff --git a/tests/session_v2/test_prewarm.py b/tests/session_v2/test_prewarm.py index df4e9ecf3..a5dcbabba 100644 --- a/tests/session_v2/test_prewarm.py +++ b/tests/session_v2/test_prewarm.py @@ -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 = [] diff --git a/tests/session_v2/test_proton_pass.py b/tests/session_v2/test_proton_pass.py new file mode 100644 index 000000000..5be73fec7 --- /dev/null +++ b/tests/session_v2/test_proton_pass.py @@ -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() diff --git a/tests/session_v2/test_provider_admission.py b/tests/session_v2/test_provider_admission.py new file mode 100644 index 000000000..7fd83a26b --- /dev/null +++ b/tests/session_v2/test_provider_admission.py @@ -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() diff --git a/tests/session_v2/test_staging_focus.py b/tests/session_v2/test_staging_focus.py new file mode 100644 index 000000000..d017b681f --- /dev/null +++ b/tests/session_v2/test_staging_focus.py @@ -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() diff --git a/tests/session_v2/test_xwayland.py b/tests/session_v2/test_xwayland.py new file mode 100644 index 000000000..e83bc1d26 --- /dev/null +++ b/tests/session_v2/test_xwayland.py @@ -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() diff --git a/tests/session_v2/xwayland_runtime.py b/tests/session_v2/xwayland_runtime.py new file mode 100644 index 000000000..8d4a9b942 --- /dev/null +++ b/tests/session_v2/xwayland_runtime.py @@ -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() diff --git a/tests/step15a/bus.conf b/tests/step15a/bus.conf new file mode 100644 index 000000000..1eb5f2dc9 --- /dev/null +++ b/tests/step15a/bus.conf @@ -0,0 +1,13 @@ + + + + session + unix:tmpdir=/tmp + EXTERNAL + + + + + + + diff --git a/tests/step15a/clipboard.py b/tests/step15a/clipboard.py new file mode 100644 index 000000000..7dbbc4d2e --- /dev/null +++ b/tests/step15a/clipboard.py @@ -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)) diff --git a/tests/step15a/mime-provider.c b/tests/step15a/mime-provider.c new file mode 100644 index 000000000..624bd0b37 --- /dev/null +++ b/tests/step15a/mime-provider.c @@ -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 +#include +#include +#include +#include +#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,®istry_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; +} diff --git a/tests/step15a/overview.py b/tests/step15a/overview.py new file mode 100644 index 000000000..053c0ea16 --- /dev/null +++ b/tests/step15a/overview.py @@ -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() 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)) diff --git a/tests/step15a/visual.py b/tests/step15a/visual.py new file mode 100644 index 000000000..a1211d7ce --- /dev/null +++ b/tests/step15a/visual.py @@ -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()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)})) diff --git a/tests/systemmonitor/test_backend.py b/tests/systemmonitor/test_backend.py new file mode 100644 index 000000000..a27af450a --- /dev/null +++ b/tests/systemmonitor/test_backend.py @@ -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() diff --git a/tests/systemmonitor/visual.py b/tests/systemmonitor/visual.py new file mode 100644 index 000000000..9e9b9f9f6 --- /dev/null +++ b/tests/systemmonitor/visual.py @@ -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:] diff --git a/zsh/.zshrc b/zsh/.zshrc index 2625b7dce..e60744168 100644 --- a/zsh/.zshrc +++ b/zsh/.zshrc @@ -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'