Compare commits

...

2 commits

58 changed files with 3931 additions and 63 deletions

View file

@ -0,0 +1,137 @@
# QuickShell Dock Tela — Polish V2
Livraison isolée du 22 octobre 2026, base `d4e2a001`, branche
`quickshell-dock-tela-polish-v2`, worktree
`/home/fy59/.dotfiles-worktrees/quickshell-dock-tela-polish-v2`.
La configuration du bureau actif n’a pas été modifiée ou rechargée.
## Cause et correction du hover
La V1 animait la largeur de chaque case de la `Row` et celle de chaque
`ApplicationIcon`. Chaque déplacement du pointeur modifiait donc les positions
suivantes, la largeur de la rangée et potentiellement celle de la surface.
De plus, `ApplicationIcon` calcule le `sourceSize` de son image depuis sa largeur :
la variation de taille demandait de nouvelles résolutions de rasterisation pour
les SVG Tela asynchrones. Ces deux chemins constituent les causes structurelles
identifiées des saccades et du risque de clignotement. Aucun profilage GPU du
bureau physique n’est revendiqué.
Les cases gardent désormais une largeur de 54 px (plus le séparateur éventuel),
un pas de 57 px et des cibles souris fixes. Les images restent à 46 × 46 px,
avec une résolution demandée fixe de 92 × 92 px. Seul leur `scale` varie de
1 à 1,39, ancré en bas, avec une interpolation OutCubic de 80 ms. La taille
visuelle maximale est ainsi d’environ 64 px. Les deux marges de 12 px à
l’intérieur du viewport protègent aussi les icônes agrandies aux extrémités,
y compris après défilement sur un petit écran.
L’influence du pointeur est une courbe smoothstep, symétrique, de rayon 76 px.
Ce rayon est inférieur à un pas et demi : seules la case la plus proche et ses
voisines immédiates peuvent recevoir une cible de grossissement non nulle.
Les positions de référence restent indépendantes de la transformation.
Les transitions peuvent brièvement finir leur interpolation après la sortie
du rayon. Aucun IPC, blur, chargement d’image ni relayout n’est déclenché par
l’animation elle-même. Les petits calculs d’influence restent liés au pointeur ;
aucun timer de polling ou délai de regroupement supplémentaire n’est nécessaire.
## Fond flottant
Le voile principal passe de 89 % à **16 % d’opacité**, sa hauteur de 70 à 58 px,
avec une bordure de 1 px à 9 % d’opacité. Une `RectangularShadow` QtQuick.Effects,
de géométrie fixe, produit l’ombre diffuse (blur 24, spread 2, noir à 32 %).
Elle ne capture ni les icônes ni le bureau. Le mode de contraste élevé conserve
un fond plus soutenu à 94 % et une bordure à 50 % pour la lisibilité.
La règle SwayFX du seul namespace `labfy-sway-dock` désactive le flou du fond.
Le compromis retenu est un verre teinté très transparent, sans réfraction ni
flou des fenêtres derrière. Le rendu ne dépend donc plus d’un flou compositeur
recalculé pendant le hover. Les effets de la barre restent inchangés.
`RectangularShadow` nécessite Qt ≥ 6.9 ; l’hôte testé utilise Qt 6.12.0,
QuickShell 0.3.2 et SwayFX 0.6 (Sway 1.12.0).
## Contrats préservés
La machine auto-hide conserve sa bande de 4 px, son armement de 180 ms,
sa grâce de sortie de 450 ms et son animation verticale de 210 ms.
`WlrKeyboardFocus.None`, couche Top, `exclusiveZone: 0` et le masque d’entrée
restent en place. Aucune place permanente n’est réservée à l’écran.
Le menu, `FavoritesService`, la résolution Tela et le format des favoris
partagés sont inchangés. Aucun fichier Session V2, Keyring, PAM, greetd ou
logique de session n’a été modifié.
## Validation
Les tests s’exécutent dans un Sway privé headless, avec HOME, XDG,
sockets Wayland/Sway et profil Firefox privés, sans bus de session hérité. Les enfants sont arrêtés par
le laboratoire. Les journaux et résultats sont conservés dans
[validation/quickshell-dock-tela-polish-v2](validation/quickshell-dock-tela-polish-v2/).
Commandes reproductibles depuis le worktree :
```sh
python -m unittest discover -s tests/applications -v
node tests/applications/test_model.js
python -m unittest discover -s tests/dock -v
node tests/dock/test_models.js
python -m unittest discover -s tests/session_v2 -v
python tests/dock/headless.py
python tests/dock/shell_load.py
python tests/dock/session_compat.py
python tests/session_v2/validate_keyring.py --sway /usr/bin/sway
git diff --check
```
Résultats : 7 tests applications, 6 tests dock, 93 tests Session V2 backend,
les deux suites JavaScript, le chargement du shell complet, le scénario dock
et les scénarios Session V2/Keyring avec dock passent. Le validateur Keyring
complet termine avec ses onze contrôles à zéro (succès).
Le test dock vérifie notamment :
- 73 positions latérales sans changement de géométrie, sans changement d’état
des images et avec au plus trois influences non nulles ;
- 4,5 secondes de balayage continu, un mouvement souris par frame Qt,
sans changement de géométrie ni d’état de chargement des images ;
- extrémités non rognées après défilement sur une sortie de 400 px ;
- auto-hide, maintien pendant le menu contextuel, drag réel et redémarrage vide ;
- synchronisation menu/dock/client externe et réordonnancement durable ;
- absence de vol de focus vers le dock, fenêtres Kitty/Firefox privées,
reconnexion IPC et changements de résolution ;
- navigation clavier du menu et présence de la règle SwayFX sans blur du dock.
Le dernier passage mesure 281 intervalles : moyenne 16.00 ms,
95e percentile 16.46 ms, maximum 16.62 ms.
Les intervalles d’animation sont conservés dans `result.json` ; ce sont des
mesures de cadence Qt avec rendu logiciel, pas des timestamps de présentation
matérielle. Ils établissent la stabilité du laboratoire privé. La perception
sur le GPU, le fond d’écran et les sorties physiques de la session active reste
à confirmer après intégration, sans prétendre l’avoir observée ici.
Captures issues de `grim`, sans retouche :
- [Dock fermé](validation/quickshell-dock-tela-polish-v2/closed.png).
- [Dock ouvert](validation/quickshell-dock-tela-polish-v2/open.png).
- [Dock survolé](validation/quickshell-dock-tela-polish-v2/zoom.png).
- [Survol sur fond clair : ombre et transparence](validation/quickshell-dock-tela-polish-v2/zoom-light.png).
## Intégration
Le patch complet, fichiers nouveaux et PNG compris, se trouve à côté du worktree :
`/home/fy59/.dotfiles-worktrees/quickshell-dock-tela-polish-v2.patch`.
Les changements restent non commités et aucun fichier de favoris n’est livré.
Après revue, depuis le dépôt principal à la base compatible :
```sh
cd /home/fy59/.dotfiles
git apply --check /home/fy59/.dotfiles-worktrees/quickshell-dock-tela-polish-v2.patch
git apply /home/fy59/.dotfiles-worktrees/quickshell-dock-tela-polish-v2.patch
swaymsg reload
systemctl --user restart quickshell-labfy-sway.service
```
La vérification `git apply --check` est effectuée à la livraison. Les trois
commandes d’application/rechargement restent à exécuter lors de l’intégration.
Le redémarrage QuickShell recharge aussi les autres composants du shell.
Après intégration, vérifier visuellement un balayage latéral, l’auto-hide et
le menu sur les sorties physiques. Il n’est pas nécessaire de réécrire les
favoris ou de redémarrer la session utilisateur.

View file

@ -0,0 +1,178 @@
# Dock QuickShell, Tela et favoris partagés V1
Implémentation dans le worktree `/home/fy59/.dotfiles-worktrees/quickshell-dock-tela`,
branche `codex/quickshell-dock-tela`, base `a556cd56`. Aucun commit ni déploiement.
## Architecture et persistance
`applications/FavoritesService.qml` est le singleton du processus QuickShell.
Le menu et chaque dock lisent exactement ses `ids`, son catalogue et ses chemins
d’icônes. `ApplicationIcon.qml` est leur composant visuel commun.
Le menu conserve sa navigation clavier, sa recherche, ses catégories et son
chemin de lancement UWSM/GIO. Les handlers clavier passent explicitement avant
le traitement interne des contrôles (`Keys.BeforeItem`) pour respecter le cycle
Tab recherche → catégories → grille → recherche. Le chemin du backend est désormais relatif au module,
ce qui permet aussi de tester un worktree sans modifier la configuration active.
`~/.local/state/labfy-sway/applications-favorites.json` reste le seul stockage,
avec le même format version 1. Aucun changement de schéma. Les neuf favoris
existants ne sont ni migrés ni réordonnés au démarrage. Une liste vide existante
reste vide ; seul un fichier absent autorise le seed historique du menu.
Un favori absent du catalogue reste enregistré et apparaît dans le dock comme
indisponible, avec possibilité de le désépingler. Une réinstallation le résout
à nouveau sans changer son ordre.
Le backend Python conserve le verrou de répertoire `flock`, le fichier temporaire
privé, le remplacement atomique et les `fsync`. Les opérations ajoutées sont :
- `favorites toggle ID` : bascule atomique, y compris pour deux clics rapides
avant réception de la première réponse.
- `favorites move ID AVANT_ID` : déplacement avant une cible encore présente.
Une chaîne vide comme cible signifie la fin de la liste. Source ou cible
disparue : refus sans écriture. Aucun remplacement global d’une liste périmée.
- `serve` : protocole JSON par lignes sur stdin/stdout, lecture initiale,
mutations sérialisées et surveillance Gio du répertoire. Les remplacements
atomiques et les écritures des anciens clients CLI sont observés.
La réponse publiée relit le stockage. Le QML ne modifie jamais les favoris par
anticipation ; un refus publie une erreur et laisse les deux interfaces sur le
même état durable. Les autres processus restent protégés par le même verrou.
Entrées bornées à 16 Kio, limite existante de 64 favoris conservée.
## Icônes
Le réglage unique est `ICON_THEME` dans `applications/shared.py`, actuellement
`Tela-circle-purple-dark`. Une instance **privée** de `Gtk.IconTheme` effectue
la résolution du `GIcon` fourni par Gio pour chaque `.desktop`, avec l’héritage
natif du thème. Les icônes absolues sont traitées comme `GFileIcon` et les URI
échappent correctement espaces et caractères spéciaux. Les icônes installées
par l’application dans hicolor/pixmaps servent de repli ; ensuite vient
`application-x-executable`. Le composant QML dessine un secours local si un
fichier est absent ou illisible, sans dépendre d’une police d’icônes.
Aucun réglage GTK/Qt global n’est écrit. Le processus utilise PyGObject/Gio déjà
employé par le backend et le typelib GTK 3 déjà présent sur cet hôte ; aucun
paquet n’a été installé. Le résolveur natif GTK évite de réimplémenter les règles
d’héritage, là où `Quickshell.iconPath` ne permet pas de choisir un thème privé.
Yazi, OrcaSlicer et AppFlowy n’ont pas d’icône correspondante dans Tela installé :
leurs icônes hicolor sont volontairement conservées. Aucun nom d’application
n’est transformé en chemin d’icône codé en dur.
## Dock et focus
Un `dock/Dock.qml` par sortie, centré et sans zone exclusive. Surface layer-shell
`Top`, namespace `labfy-sway-dock`, `WlrKeyboardFocus.None`. Les menus contextuels
sont dessinés dans cette même surface : ils n’installent aucun grab clavier.
Palette et couleurs viennent du thème Catppuccin existant. Icônes au repos à
46 px, agrandissement jusqu’à 67 px avec influence décroissante sur les voisines.
Les positions au repos servent au calcul pour éviter une boucle taille/position.
Les mouvements et le glissement vertical sont animés. Le contenu dépassement
reste défilable horizontalement avec une barre de défilement.
Machine à états pure dans `dock/AutoHide.js` : `hidden`, `arming`, `shown`,
`waiting`, `held`. Au démarrage : `hidden`. Bande sensible de 4 px, centrée,
limitée à 360 px au repos ; attente de 180 ms avant apparition, grâce de 450 ms
avant fermeture. **Un seul Timer** porte l’échéance de la machine. Pression,
glisser-déposer et menu contextuel bloquent la fermeture. Le masque d’entrée
laisse passer le pointeur hors du corps, du menu et de la bande. Les changements
de résolution recalculent la largeur disponible. Polkit et le menu d’applications
suppriment temporairement le dock via son propriétaire dans `shell.qml`.
Le bloc ajouté à `sway/.config/sway/swayfx` limite le flou au namespace du dock
et reprend `$bar_blur`. Aucun effet global supplémentaire.
## Fenêtres et actions
Un seul `swaymsg -m -t subscribe` pour les événements window/workspace/output,
possédé par le singleton. Après coupure, relance à 1,5 s et réconciliation de
l’arbre. Les rafales sont regroupées à 60 ms, et un indicateur dirty préserve
un événement survenu pendant une requête. Aucune interrogation périodique en
fonctionnement normal. Les requêtes Python utilisent directement le socket IPC,
un encodage little-endian explicite, une réponse limitée à 16 Mio et un timeout.
Correspondances par app_id, classe/instance XWayland, StartupWMClass, identifiant
desktop et exécutable. Les alias ambigus ne sont pas attribués ; aucun titre
n’est utilisé pour reconnaître une application. Les titres servent seulement
à afficher le choix entre fenêtres.
Clic gauche : lancement d’un favori installé sans fenêtre, focus explicite d’une
fenêtre, ou choix si plusieurs fenêtres. Les applications ouvertes non épinglées
suivent un séparateur. Clic droit : fenêtres, ouvrir, épingler/désépingler, fermer
le menu. Glisser un favori puis relâcher sur sa nouvelle position enregistre son
ordre. Clic molette : demande de nouvelle instance pour les terminaux dont la
politique est connue (`NEW_INSTANCE_IDS`), sans promesse pour une application
singleton inconnue.
`focus` valide le con_id dans l’arbre courant ; un scratchpad caché est montré
avant focus. Cette commande n’est appelée que par les handlers de clic. Ni la
machine à états, ni le watcher, ni les animations ne l’appellent. Les surfaces
layer-shell n’entrent pas dans l’arbre des toplevels et les snapshots Session V2.
Aucun fichier du greeter, PAM, Keyring ou moteur Session V2 n’est modifié.
## Validation reproductible
Depuis le worktree, sans arrêter la session graphique active :
```sh
python -m unittest discover -s tests/applications -v
node tests/applications/test_model.js
python -m unittest discover -s tests/dock -v
node tests/dock/test_models.js
python tests/dock/headless.py
python tests/dock/shell_load.py
python tests/dock/session_compat.py
python tests/session_v2/validate_keyring.py --sway /usr/bin/sway
git diff --check
```
Les tests graphiques créent un compositeur SwayFX headless enfant avec HOME,
XDG, socket Wayland et socket Sway privés. Le test Firefox utilise son propre
profil. `wtype` envoie Tab uniquement au compositeur privé. Le proxy de test coupe
les connexions IPC pour exercer la reconnexion réelle. Les enfants du laboratoire
sont arrêtés à la sortie. Aucun environnement ni journal Session V2 utilisateur
n’est réutilisé.
Les captures `validation/quickshell-dock-tela/{closed,open,zoom}.png` et le JSON
`result.json` proviennent de ce laboratoire. Les scénarios incluent clic droit,
choix/pinning, drag souris réel, agrandissement mesuré, entrée/sortie, ordre durable,
refus de déplacement, zéro favori après redémarrage, installation/désinstallation
d’une entrée de catalogue, client CLI externe, navigation clavier du menu,
Firefox, deux Kitty, flottant, scratchpad, fermeture et résolution.
La suite de non-régression Session V2/Keyring couvre 93 tests backend et ses
scénarios graphiques. `session_compat.py` exécute aussi les scénarios chooser et
Keyring existants avec le dock présent : prêt, acceptation, annulation, timeout,
absence, verrouillage persistant, collection différente, redémarrage pendant
l’unlock, succès/échec de restauration, nouvelle session et focus différé réel.
## Limites et intégration après revue
- Une application sans identité desktop correspondante reste visible sous son
identité fenêtre avec une icône générique, sans épinglage inventé. Les alias
réellement nécessaires peuvent être ajoutés après observation de l’app_id ou
de StartupWMClass ; le titre ne doit pas devenir une heuristique.
- Yazi/Neovim hébergés dans une fenêtre Kitty ordinaire ont l’identité Kitty.
Le dock ne déduit pas leur identité du titre ou du processus enfant. Le
comportement de lancement terminal existant est conservé.
- XWayland est couvert par des arbres de test ; la recette graphique privée
désactive XWayland. Une vérification avec une vraie application X11 reste utile.
- Le greeter/PAM réels ne sont pas exécutés dans la recette. La compatibilité
Keyring et restauration est exercée avec leurs fixtures privées et les vrais
composants de production. Un cycle de connexion matériel reste une recette
manuelle après revue, jamais déclenchée ici.
- Les PNG ne prouvent pas à eux seuls la qualité du flou sur une fenêtre réelle ;
la règle SwayFX est chargée dans le laboratoire. Aucun test tactile ou écran
à facteur d’échelle fractionnaire n’est revendiqué.
La livraison reste dans le worktree. Examiner les sources, les tests, les PNG et
le patch complet adjacent au worktree. Après validation humaine, appliquer le
patch sur la base compatible dans le dépôt principal, sans copier le fichier de
favoris. Vérifier d’abord avec `git apply --check CHEMIN_DU_PATCH`, puis appliquer
selon votre procédure de revue. Recharger ensuite QuickShell et Sway pour le
nouveau module et la règle de flou. Le rechargement n’a pas été exécuté ici.
## Références des contrats de plateforme
- [QuickShell WlrLayershell](https://quickshell.org/docs/v0.3.1/types/Quickshell.Wayland/WlrLayershell/) : modes de focus layer-shell, recoupés avec les types installés de 0.3.2.
- [Spécification des thèmes d’icônes](https://specifications.freedesktop.org/icon-theme/) : résolution et héritage délégués au toolkit.
- [Protocole IPC i3/Sway](https://i3wm.org/docs/ipc.html) : framing et événements, vérifiés avec le SwayFX privé installé.

View file

@ -0,0 +1,12 @@
test_bad_state_does_not_get_overwritten (test_backend.FavoritesTests.test_bad_state_does_not_get_overwritten) ... ok
test_seed_order_atomic_update_and_missing_entry (test_backend.FavoritesTests.test_seed_order_atomic_update_and_missing_entry) ... ok
test_graphical_uses_desktop_id_and_uwsm_service (test_backend.LaunchTests.test_graphical_uses_desktop_id_and_uwsm_service) ... ok
test_invalid_id_and_missing_entry (test_backend.LaunchTests.test_invalid_id_and_missing_entry) ... ok
test_nodisplay_entry_is_refused_before_launch (test_backend.LaunchTests.test_nodisplay_entry_is_refused_before_launch) ... ok
test_terminal_entry_already_running_kitty_is_not_wrapped_twice (test_backend.LaunchTests.test_terminal_entry_already_running_kitty_is_not_wrapped_twice) ... ok
test_terminal_preserves_desktop_exec_fields_and_kitty (test_backend.LaunchTests.test_terminal_preserves_desktop_exec_fields_and_kitty) ... ok
----------------------------------------------------------------------
Ran 7 tests in 0.004s
OK

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,18 @@
/tmp/labfy-session-v2-TEST_ONLY-s23v41ci
ready PASS
accept PASS
cancel PASS
timeout PASS
absent PASS
still-locked PASS
different PASS
quickshell-restart-during-unlock PASS
keyring_ui WITH_DOCK PASS
success PASS
failure PASS
invalid PASS
new PASS
claimed PASS
real PASS
deferred-real PASS
phase3b_ui WITH_DOCK PASS

View file

@ -0,0 +1 @@
Dock headless: PASS /tmp/labfy-session-v2-TEST_ONLY-dlggtemr

View file

@ -0,0 +1,2 @@
Dock state and window models: PASS
ApplicationsModel: PASS

View file

@ -0,0 +1,59 @@
............................/usr/lib/python3.14/site-packages/gi/events.py:803: DeprecationWarning: 'asyncio.AbstractEventLoopPolicy' is deprecated and slated for removal in Python 3.16
AbstractEventLoopPolicy = getattr(asyncio, "AbstractEventLoopPolicy", object)
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
../usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
./usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
../usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
../usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
..........................................................
----------------------------------------------------------------------
Ran 93 tests in 8.443s
OK

View file

@ -0,0 +1,5 @@
.............................
----------------------------------------------------------------------
Ran 29 tests in 0.129s
OK

View file

@ -0,0 +1,4 @@
/tmp/labfy-session-v2-TEST_ONLY-0p63m0if
stable-final-focus PASS
/tmp/labfy-session-v2-TEST_ONLY-vn3vcup_
late-refused PASS

View file

@ -0,0 +1,9 @@
/tmp/labfy-session-v2-TEST_ONLY-p4jqsmpt
ready PASS
accept PASS
cancel PASS
timeout PASS
absent PASS
still-locked PASS
different PASS
quickshell-restart-during-unlock PASS

View file

@ -0,0 +1,5 @@
guard-replacement PASS APPLICATION_MEMBERSHIP_CHANGED
guard-extra PASS APPLICATION_MEMBERSHIP_CHANGED
last-capture-layout PASS FINAL_TREE_MISMATCH
last-capture-swap PASS RECOVERY_ORDER_CHANGED
guard-finish-layout PASS FINAL_TREE_MISMATCH

View file

@ -0,0 +1,5 @@
pendant-guard SUCCÈS : quatre swaps, quatre slots, arbre exact, focus vérifié
apres-phase0 SUCCÈS : quatre swaps, quatre slots, arbre exact, focus vérifié
deux-frontieres SUCCÈS : quatre swaps, quatre slots, arbre exact, focus vérifié
autotiling-guard SUCCÈS : quatre swaps, quatre slots, arbre exact, focus vérifié
provider-unaire SUCCÈS : changement externe refusé avant IPC suivant

View file

@ -0,0 +1,7 @@
success PASS
failure PASS
invalid PASS
new PASS
claimed PASS
real PASS
deferred-real PASS

View file

@ -0,0 +1,10 @@
********* Start testing of qmltestrunner *********
Config: Using QtTest library 6.12.0, Qt 6.12.0 (x86_64-little_endian-lp64 shared (dynamic) release build; by GCC 16.2.1 20260810), arch unknown
PASS : qmltestrunner::SessionExitGateV2::initTestCase()
PASS : qmltestrunner::SessionExitGateV2::test_backend_reason_is_visible_and_logout_stays_gated()
PASS : qmltestrunner::SessionExitGateV2::test_error_or_legacy_success_never_exits()
PASS : qmltestrunner::SessionExitGateV2::test_quit_without_saving_requires_explicit_call()
PASS : qmltestrunner::SessionExitGateV2::test_saved_releases_only_confirmed_action()
PASS : qmltestrunner::SessionExitGateV2::cleanupTestCase()
Totals: 6 passed, 0 failed, 0 skipped, 0 blacklisted, 5ms
********* Finished testing of qmltestrunner *********

View file

@ -0,0 +1,5 @@
...............
----------------------------------------------------------------------
Ran 15 tests in 20.330s
OK

View file

@ -0,0 +1,13 @@
{
"backend": 0,
"focus": 0,
"qml-unit": 0,
"qml-qt": 0,
"keyring-ui": 0,
"keyring-runtime": 0,
"scratchpad-runtime": 0,
"phase3b-ui": 0,
"p0-runtime": 0,
"path-runtime": 0,
"diff-check": 0
}

View file

@ -0,0 +1,2 @@
/tmp/labfy-session-v2-TEST_ONLY-1lfda8ji
PASS save/store/restore/show/hide/new-membership/all-hidden/cold-kitty/no-duplicate

View file

@ -0,0 +1,12 @@
/tmp/labfy-keyring-validation-ibq2bukh
backend 0
focus 0
qml-unit 0
qml-qt 0
keyring-ui 0
keyring-runtime 0
scratchpad-runtime 0
phase3b-ui 0
p0-runtime 0
path-runtime 0
diff-check 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,152 @@
test_absent_last_existing_directory (test_bootstrap.BootstrapTests.test_absent_last_existing_directory) ... ok
test_absent_no_creation (test_bootstrap.BootstrapTests.test_absent_no_creation) ... ok
test_allowlist_denied (test_bootstrap.BootstrapTests.test_allowlist_denied) ... ok
test_checkpoint_created_between_observations (test_bootstrap.BootstrapTests.test_checkpoint_created_between_observations) ... ok
test_empty_sessions_with_or_without_automatic_allows_without_creation (test_bootstrap.BootstrapTests.test_empty_sessions_with_or_without_automatic_allows_without_creation) ... ok
test_envelope_and_framing_rejected (test_bootstrap.BootstrapTests.test_envelope_and_framing_rejected) ... ok
test_home_fallback_matches_canonical_location (test_bootstrap.BootstrapTests.test_home_fallback_matches_canonical_location) ... ok
test_interleaved_operations_map_unchanged (test_bootstrap.BootstrapTests.test_interleaved_operations_map_unchanged) ... ok
test_last_with_sessions_absent_refuses (test_bootstrap.BootstrapTests.test_last_with_sessions_absent_refuses) ... ok
test_named_only_without_automatic_refuses (test_bootstrap.BootstrapTests.test_named_only_without_automatic_refuses) ... ok
test_os_error_is_not_absence (test_bootstrap.BootstrapTests.test_os_error_is_not_absence) ... ok
test_present_and_corrupt_always_refuse (test_bootstrap.BootstrapTests.test_present_and_corrupt_always_refuse) ... ok
test_sessions_any_entry_refuses_without_content_read (test_bootstrap.BootstrapTests.test_sessions_any_entry_refuses_without_content_read) ... ok
test_sessions_list_error_refuses (test_bootstrap.BootstrapTests.test_sessions_list_error_refuses) ... ok
test_sessions_unsafe_directory_refuses (test_bootstrap.BootstrapTests.test_sessions_unsafe_directory_refuses) ... ok
test_socket_authority_failure_refuses_absence (test_bootstrap.BootstrapTests.test_socket_authority_failure_refuses_absence) ... ok
test_state_symlink_and_relative_state_refuse (test_bootstrap.BootstrapTests.test_state_symlink_and_relative_state_refuse) ... ok
test_symlinks_and_special_last_refuse_without_open (test_bootstrap.BootstrapTests.test_symlinks_and_special_last_refuse_without_open) ... ok
test_unreadable_and_nonprivate_directory_refuse (test_bootstrap.BootstrapTests.test_unreadable_and_nonprivate_directory_refuse) ... ok
test_valid_store_checkpoint_and_unreadable_file_refuse (test_bootstrap.BootstrapTests.test_valid_store_checkpoint_and_unreadable_file_refuse) ... ok
test_equivalent_unary_paths_are_not_identity_changes (test_focus_recovery.FocusRecoveryTests.test_equivalent_unary_paths_are_not_identity_changes) ... ok
test_firefox_command_proven_then_kitty_late_conflict_is_preserved (test_focus_recovery.FocusRecoveryTests.test_firefox_command_proven_then_kitty_late_conflict_is_preserved) ... ok
test_interrupted_intention_preserves_all_evidence (test_focus_recovery.FocusRecoveryTests.test_interrupted_intention_preserves_all_evidence) ... ok
test_layout_change_with_identical_raw_paths_still_refuses (test_focus_recovery.FocusRecoveryTests.test_layout_change_with_identical_raw_paths_still_refuses) ... ok
test_path_evidence_limit_refuses_without_overwriting_history (test_focus_recovery.FocusRecoveryTests.test_path_evidence_limit_refuses_without_overwriting_history) ... ok
test_real_identity_order_workspace_and_branch_changes_still_refuse (test_focus_recovery.FocusRecoveryTests.test_real_identity_order_workspace_and_branch_changes_still_refuse) ... ok
test_prepare_quits_before_resume_and_finish_has_no_delayed_exit (test_guard_protocol.GuardProtocolTests.test_prepare_quits_before_resume_and_finish_has_no_delayed_exit) ... ok
test_resume_without_prepare_refuses_and_still_cleans_owned_helper (test_guard_protocol.GuardProtocolTests.test_resume_without_prepare_refuses_and_still_cleans_owned_helper) ... ok
test_actual_dbus_states (test_keyring.KeyringTests.test_actual_dbus_states) ... /usr/lib/python3.14/site-packages/gi/events.py:803: DeprecationWarning: 'asyncio.AbstractEventLoopPolicy' is deprecated and slated for removal in Python 3.16
AbstractEventLoopPolicy = getattr(asyncio, "AbstractEventLoopPolicy", object)
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
ok
test_apply_is_excluded_during_unlock (test_keyring.KeyringTests.test_apply_is_excluded_during_unlock) ... ok
test_apply_last_cannot_bypass_gate (test_keyring.KeyringTests.test_apply_last_cannot_bypass_gate) ... /usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
ok
test_ready_preserves_executor_result_and_recovery_required (test_keyring.KeyringTests.test_ready_preserves_executor_result_and_recovery_required) ... /usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
ok
test_remote_error_text_is_never_logged_or_persisted (test_keyring.KeyringTests.test_remote_error_text_is_never_logged_or_persisted) ... ok
test_transaction_prevents_prompt (test_keyring.KeyringTests.test_transaction_prevents_prompt) ... /usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
ok
test_two_processes_and_restart (test_keyring.KeyringTests.test_two_processes_and_restart) ... ok
test_unlock_never_restores (test_keyring.KeyringTests.test_unlock_never_restores) ... /usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
ok
test_complete_refuses_full_identity_placement_and_pending (test_p0_boundaries.BoundaryTests.test_complete_refuses_full_identity_placement_and_pending) ... ok
test_extra_after_first_move_refuses_before_second_staging (test_p0_boundaries.BoundaryTests.test_extra_after_first_move_refuses_before_second_staging) ... ok
test_guard_replacement_and_extra_refuse_before_any_mutation (test_p0_boundaries.BoundaryTests.test_guard_replacement_and_extra_refuse_before_any_mutation) ... ok
test_last_provider_capture_cannot_hide_structure_or_identity_change (test_p0_boundaries.BoundaryTests.test_last_provider_capture_cannot_hide_structure_or_identity_change) ... ok
test_layout_change_in_second_split_focus_observation_refuses (test_p0_boundaries.BoundaryTests.test_layout_change_in_second_split_focus_observation_refuses) ... ok
test_unary_parent_normalization_preserves_canonical_layout (test_p0_boundaries.BoundaryTests.test_unary_parent_normalization_preserves_canonical_layout) ... ok
test_unchanged_final_observation_completes (test_p0_boundaries.BoundaryTests.test_unchanged_final_observation_completes) ... ok
test_changement_apres_capture_canonique_refuse_avant_deplacement (test_path_stability.PathTests.test_changement_apres_capture_canonique_refuse_avant_deplacement) ... ok
test_guard_refuse_les_divergences_avant_tout_ipc (test_path_stability.PathTests.test_guard_refuse_les_divergences_avant_tout_ipc) ... ok
test_ack_requires_verified_current_transaction (test_phase3b.DeploymentTests.test_ack_requires_verified_current_transaction) ... ok
test_limusic_never_launched (test_phase3b.DeploymentTests.test_limusic_never_launched) ... ok
test_async_mapping_completes_before_apply_and_focus_proof (test_prewarm.PrewarmTests.test_async_mapping_completes_before_apply_and_focus_proof) ... ok
test_deferred_marker_and_cli_preserve_cause_for_explicit_retry (test_prewarm.PrewarmTests.test_deferred_marker_and_cli_preserve_cause_for_explicit_retry) ... ok
test_existing_required_journal_refuses_before_launch (test_prewarm.PrewarmTests.test_existing_required_journal_refuses_before_launch) ... ok
test_focus_change_after_proof_refuses_without_publication (test_prewarm.PrewarmTests.test_focus_change_after_proof_refuses_without_publication) ... ok
test_intruder_extra_identity_session_and_owner_refuse_without_publication (test_prewarm.PrewarmTests.test_intruder_extra_identity_session_and_owner_refuse_without_publication) ... ok
test_launch_failure_preserves_clients_and_retry_does_not_duplicate (test_prewarm.PrewarmTests.test_launch_failure_preserves_clients_and_retry_does_not_duplicate) ... ok
test_missing_managed_autostart_refuses_before_launch (test_prewarm.PrewarmTests.test_missing_managed_autostart_refuses_before_launch) ... ok
test_missing_collision_and_live_publisher (test_provider.ProviderTests.test_missing_collision_and_live_publisher) ... ok
test_capture_is_deterministic_for_a_stable_tree (test_scratchpad.ScratchpadTests.test_capture_is_deterministic_for_a_stable_tree) ... ok
test_capture_store_reload_and_no_internal_workspace (test_scratchpad.ScratchpadTests.test_capture_store_reload_and_no_internal_workspace) ... ok
test_change_between_reads_and_ambiguous_focus_refuse (test_scratchpad.ScratchpadTests.test_change_between_reads_and_ambiguous_focus_refuse) ... ok
test_empty_focus_workspace_is_persisted (test_scratchpad.ScratchpadTests.test_empty_focus_workspace_is_persisted) ... ok
test_group_ancestor_is_not_admitted_by_leaf_identity (test_scratchpad.ScratchpadTests.test_group_ancestor_is_not_admitted_by_leaf_identity) ... ok
test_hidden_focus_is_rejected_before_command (test_scratchpad.ScratchpadTests.test_hidden_focus_is_rejected_before_command) ... ok
test_initial_scratchpad_add_predicts_source_workspace_focus (test_scratchpad.ScratchpadTests.test_initial_scratchpad_add_predicts_source_workspace_focus) ... ok
test_journal_detects_identity_output_scratchpad_and_interruption (test_scratchpad.ScratchpadTests.test_journal_detects_identity_output_scratchpad_and_interruption) ... ok
test_layer_shell_can_leave_no_sway_focus (test_scratchpad.ScratchpadTests.test_layer_shell_can_leave_no_sway_focus) ... ok
test_legacy_journal_not_reinterpreted (test_scratchpad.ScratchpadTests.test_legacy_journal_not_reinterpreted) ... ok
test_multiple_focused_nodes_are_still_refused (test_scratchpad.ScratchpadTests.test_multiple_focused_nodes_are_still_refused) ... ok
test_runtime_visibility_assignment_never_exposes_hidden_peer (test_scratchpad.ScratchpadTests.test_runtime_visibility_assignment_never_exposes_hidden_peer) ... ok
test_shown_and_multiple_hidden (test_scratchpad.ScratchpadTests.test_shown_and_multiple_hidden) ... ok
test_strict_versions_and_scratchpad_references (test_scratchpad.ScratchpadTests.test_strict_versions_and_scratchpad_references) ... ok
test_checkpoint_failed_validation_preserves_old (test_unit.BackendTests.test_checkpoint_failed_validation_preserves_old) ... ok
test_checkpoint_permissions (test_unit.BackendTests.test_checkpoint_permissions) ... ok
test_cli_execute_required (test_unit.BackendTests.test_cli_execute_required) ... ok
test_cli_storage_checkpoint_and_startup_mock (test_unit.BackendTests.test_cli_storage_checkpoint_and_startup_mock) ... ok
test_coexistence_and_migration (test_unit.BackendTests.test_coexistence_and_migration) ... ok
test_command_injection (test_unit.BackendTests.test_command_injection) ... ok
test_counts (test_unit.BackendTests.test_counts) ... ok
test_exact_inverted (test_unit.BackendTests.test_exact_inverted) ... ok
test_interchangeable_best_effort (test_unit.BackendTests.test_interchangeable_best_effort) ... ok
test_intermediate_directory_permissions (test_unit.BackendTests.test_intermediate_directory_permissions) ... ok
test_json_schema_matches_fixture (test_unit.BackendTests.test_json_schema_matches_fixture) ... ok
test_json_strict (test_unit.BackendTests.test_json_strict) ... ok
test_launch_policy (test_unit.BackendTests.test_launch_policy) ... ok
test_lock (test_unit.BackendTests.test_lock) ... ok
test_multi_output_preflight_synthetic (test_unit.BackendTests.test_multi_output_preflight_synthetic) ... ok
test_native_strict (test_unit.BackendTests.test_native_strict) ... ok
test_paths_links (test_unit.BackendTests.test_paths_links) ... ok
test_plan_pure (test_unit.BackendTests.test_plan_pure) ... ok
test_references (test_unit.BackendTests.test_references) ... ok
test_report_rejects_private_fields_and_bool_version (test_unit.BackendTests.test_report_rejects_private_fields_and_bool_version) ... ok
test_resume_attempts_all_owned_helpers (test_unit.BackendTests.test_resume_attempts_all_owned_helpers) ... ok
test_schema_exact (test_unit.BackendTests.test_schema_exact) ... ok
test_types_version_and_identity (test_unit.BackendTests.test_types_version_and_identity) ... ok
test_unsupported_states (test_unit.BackendTests.test_unsupported_states) ... ok
----------------------------------------------------------------------
Ran 93 tests in 8.405s
OK

View file

@ -0,0 +1 @@
Full shell private-compositor load: PASS /tmp/labfy-session-v2-TEST_ONLY-vx5tlv5i

View file

@ -0,0 +1,11 @@
test_inheritance_from_a_private_test_theme (test_shared.Icons.test_inheritance_from_a_private_test_theme) ... ok
test_tela_desktop_icon_and_absolute (test_shared.Icons.test_tela_desktop_icon_and_absolute) ... ok
test_nine_move_missing_anchor_and_empty (test_shared.Persistence.test_nine_move_missing_anchor_and_empty) ... ok
test_real_concurrent_processes_keep_all_additions_during_moves (test_shared.Persistence.test_real_concurrent_processes_keep_all_additions_during_moves) ... ok
test_toggle_is_atomic_and_does_not_depend_on_a_stale_ui_list (test_shared.Persistence.test_toggle_is_atomic_and_does_not_depend_on_a_stale_ui_list) ... ok
test_wayland_xwayland_scratchpad_and_explicit_focus (test_shared.Sway.test_wayland_xwayland_scratchpad_and_explicit_focus) ... ok
----------------------------------------------------------------------
Ran 6 tests in 0.735s
OK

View file

@ -0,0 +1,39 @@
{
"status": "PASS",
"base": "d4e2a0013e7a9452add80dddc110e036ebe4ee96",
"backendTests": {
"applications": 7,
"dock": 6,
"sessionV2": 93
},
"javascript": [
"ApplicationsModel: PASS",
"Dock state and window models: PASS"
],
"graphical": [
"private Sway dock scenario",
"complete shell load",
"Session V2 and Keyring with dock"
],
"keyring": {
"backend": 0,
"focus": 0,
"qml-unit": 0,
"qml-qt": 0,
"keyring-ui": 0,
"keyring-runtime": 0,
"scratchpad-runtime": 0,
"phase3b-ui": 0,
"p0-runtime": 0,
"path-runtime": 0,
"diff-check": 0
},
"finalChecks": [
"git diff --check: PASS",
"git apply --check against unchanged main checkout: PASS",
"no Binding loop, assignment, ReferenceError or TypeError in final dock lab logs",
"main checkout git status --short: empty"
],
"finalShellLab": "/tmp/labfy-session-v2-TEST_ONLY-ywdppzn5",
"activeSessionModified": false
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1 @@
Dock headless: PASS /tmp/labfy-session-v2-TEST_ONLY-88x0lspm

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -0,0 +1,225 @@
{
"status": "PASS",
"initial": {
"ready": true,
"ids": [
"firefox.desktop",
"kitty.desktop",
"yazi.desktop",
"nvim.desktop",
"org.darktable.darktable.desktop",
"orca-slicer.desktop",
"org.freecad.FreeCAD.desktop",
"org.kde.digikam.desktop",
"AppFlowy.desktop"
],
"error": "",
"phase": "hidden",
"revealed": false,
"entries": [
{
"id": "firefox.desktop",
"name": "Mozilla Firefox",
"genericName": "Web Browser",
"keywords": [
"Internet",
"WWW",
"Browser",
"Web",
"Explorer"
],
"icon": "firefox",
"iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/firefox.svg",
"aliases": [
"firefox"
],
"newInstance": false,
"category": "Internet",
"pinned": true,
"windows": []
},
{
"id": "kitty.desktop",
"name": "kitty",
"genericName": "Terminal emulator",
"keywords": [],
"icon": "kitty",
"iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/kitty.svg",
"aliases": [
"kitty"
],
"newInstance": true,
"category": "Syst\u00e8me",
"pinned": true,
"windows": []
},
{
"id": "yazi.desktop",
"name": "Yazi File Manager",
"genericName": "",
"keywords": [
"File",
"Manager",
"Explorer",
"Browser",
"Launcher"
],
"icon": "yazi",
"iconSource": "file:///usr/share/icons/hicolor/48x48/apps/yazi.png",
"aliases": [
"yazi"
],
"newInstance": false,
"category": "Syst\u00e8me",
"pinned": true,
"windows": []
},
{
"id": "nvim.desktop",
"name": "Neovim",
"genericName": "Text Editor",
"keywords": [
"Text",
"editor"
],
"icon": "nvim",
"iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/nvim.svg",
"aliases": [
"nvim"
],
"newInstance": false,
"category": "D\u00e9veloppement",
"pinned": true,
"windows": []
},
{
"id": "org.darktable.darktable.desktop",
"name": "Darktable Photo Workflow Software",
"genericName": "Virtual Lighttable and Darkroom",
"keywords": [
"graphics",
"photography",
"raw"
],
"icon": "darktable",
"iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/darktable.svg",
"aliases": [
"darktable",
"org.darktable.darktable"
],
"newInstance": false,
"category": "Graphisme",
"pinned": true,
"windows": []
},
{
"id": "orca-slicer.desktop",
"name": "OrcaSlicer",
"genericName": "",
"keywords": [],
"icon": "OrcaSlicer",
"iconSource": "file:///usr/share/icons/hicolor/192x192/apps/OrcaSlicer.png",
"aliases": [
"apprun",
"orca-slicer"
],
"newInstance": false,
"category": "Utilitaires",
"pinned": true,
"windows": []
},
{
"id": "org.freecad.FreeCAD.desktop",
"name": "FreeCAD",
"genericName": "CAD Application",
"keywords": [],
"icon": "org.freecad.FreeCAD",
"iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/org.freecad.FreeCAD.svg",
"aliases": [
"freecad",
"org.freecad.freecad"
],
"newInstance": false,
"category": "Graphisme",
"pinned": true,
"windows": []
},
{
"id": "org.kde.digikam.desktop",
"name": "digiKam",
"genericName": "Photo Management Program",
"keywords": [],
"icon": "digikam",
"iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/digikam.svg",
"aliases": [
"digikam",
"org.kde.digikam"
],
"newInstance": false,
"category": "Graphisme",
"pinned": true,
"windows": []
},
{
"id": "AppFlowy.desktop",
"name": "AppFlowy",
"genericName": "",
"keywords": [
"Notes"
],
"icon": "appflowy",
"iconSource": "file:///usr/share/icons/hicolor/scalable/apps/appflowy.svg",
"aliases": [
"appflowy"
],
"newInstance": false,
"category": "Internet",
"pinned": true,
"windows": []
}
],
"theme": "Tela-circle-purple-dark",
"menu": null,
"searchFocused": false,
"navFocused": false,
"gridFocused": false,
"screenWidth": 1280,
"bodyY": 364,
"width": 538,
"pointerX": -1000,
"tileWidth": 54,
"context": false
},
"reordered": [
"kitty.desktop",
"firefox.desktop",
"yazi.desktop",
"nvim.desktop",
"org.darktable.darktable.desktop",
"orca-slicer.desktop",
"org.freecad.FreeCAD.desktop",
"org.kde.digikam.desktop",
"AppFlowy.desktop"
],
"checks": [
"shared favorites",
"menu and dock mutations",
"durable reorder",
"empty restart",
"autohide",
"context hold",
"kitty multi-window",
"floating scratchpad explicit focus",
"window close",
"no hover focus",
"real pointer drag",
"zoom growth",
"IPC reconnect",
"Firefox private profile",
"desktop install/remove monitoring",
"external writer",
"keyboard menu navigation",
"resolution change",
"SwayFX dock blur rule"
]
}

View file

@ -0,0 +1,18 @@
/tmp/labfy-session-v2-TEST_ONLY-43bkifog
ready PASS
accept PASS
cancel PASS
timeout PASS
absent PASS
still-locked PASS
different PASS
quickshell-restart-during-unlock PASS
keyring_ui WITH_DOCK PASS
success PASS
failure PASS
invalid PASS
new PASS
claimed PASS
real PASS
deferred-real PASS
phase3b_ui WITH_DOCK PASS

View file

@ -0,0 +1,13 @@
{
"backend": 0,
"focus": 0,
"qml-unit": 0,
"qml-qt": 0,
"keyring-ui": 0,
"keyring-runtime": 0,
"scratchpad-runtime": 0,
"phase3b-ui": 0,
"p0-runtime": 0,
"path-runtime": 0,
"diff-check": 0
}

View file

@ -0,0 +1 @@
Full shell private-compositor load: PASS /tmp/labfy-session-v2-TEST_ONLY-rfgtrd4k

View file

@ -0,0 +1,31 @@
{
"status": "PASS",
"baseline": "a556cd56",
"quickshell": "0.3.2",
"sway": "SwayFX 0.6 / Sway 1.12.0",
"tests": {
"existingApplicationsPython": 7,
"dockPython": 6,
"existingApplicationsJS": "PASS",
"dockModelsJS": "PASS",
"sessionV2BackendPython": 93,
"sessionV2ValidationGroups": 11,
"keyringWithDock": "PASS (8 cases)",
"restoreUIWithDock": "PASS (7 cases)",
"headless": "PASS",
"fullShellRelativeImports": "PASS",
"diffCheck": "PASS"
},
"activeSession": "not modified; original nine favorites preserved",
"limitations": [
"XWayland fixture coverage only",
"physical greeter/PAM login not exercised",
"terminal-host identity is preserved",
"fractional scale and touch not exercised"
],
"notes": [
"Absolute-import fixture emits QuickShell scanner warnings; actual relative-import shell loads.",
"Virtual keyboard teardown emits Qt Wayland leave/text-input warnings; keyboard cycle passes.",
"Full shell intentionally has no system services in the private compositor."
]
}

View file

@ -0,0 +1,11 @@
test_inheritance_from_a_private_test_theme (test_shared.Icons.test_inheritance_from_a_private_test_theme) ... ok
test_tela_desktop_icon_and_absolute (test_shared.Icons.test_tela_desktop_icon_and_absolute) ... ok
test_nine_move_missing_anchor_and_empty (test_shared.Persistence.test_nine_move_missing_anchor_and_empty) ... ok
test_real_concurrent_processes_keep_all_additions_during_moves (test_shared.Persistence.test_real_concurrent_processes_keep_all_additions_during_moves) ... ok
test_toggle_is_atomic_and_does_not_depend_on_a_stale_ui_list (test_shared.Persistence.test_toggle_is_atomic_and_does_not_depend_on_a_stale_ui_list) ... ok
test_wayland_xwayland_scratchpad_and_explicit_focus (test_shared.Sway.test_wayland_xwayland_scratchpad_and_explicit_focus) ... ok
----------------------------------------------------------------------
Ran 6 tests in 0.705s
OK

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -103,8 +103,7 @@ PanelWindow {
property string applicationsError: "" property string applicationsError: ""
property string queuedApplicationId: "" property string queuedApplicationId: ""
readonly property string applicationsBackend: (Quickshell.env("XDG_CONFIG_HOME") || readonly property string applicationsBackend: FavoritesService.backend
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/applications/backend.py"
function closeApplications() { applicationCoordinator.applicationsOutput = ""; } function closeApplications() { applicationCoordinator.applicationsOutput = ""; }
function toggleApplications() { function toggleApplications() {
if (authenticationActive) return false; if (authenticationActive) return false;

View file

@ -389,3 +389,10 @@ relance n’est automatique, y compris après redémarrage QuickShell.
Voir [Session Restore V2](../../../../docs/session-restore-v2.md) pour les Voir [Session Restore V2](../../../../docs/session-restore-v2.md) pour les
commandes, les preuves et les limites de l’environnement. La recette réussie commandes, les preuves et les limites de l’environnement. La recette réussie
du 7 octobre est historique ; elle ne valide pas cette révision au login réel. du 7 octobre est historique ; elle ne valide pas cette révision au login réel.
## Dock et favoris partagés
Le dock à masquage automatique et le menu utilisent `applications/FavoritesService.qml`
et `ApplicationIcon.qml`. Le stockage existant reste inchangé. Architecture,
interactions, tests headless, limites et procédure d’intégration :
[Dock Tela / favoris V1](../../../../docs/quickshell-dock-tela-shared-favorites-v1.md).

View file

@ -0,0 +1,24 @@
import QtQuick
import "../theme"
Item {
property string iconSource: ""
Image {
id: image
anchors.fill: parent
source: parent.iconSource
sourceSize.width: Math.ceil(width * 2)
sourceSize.height: Math.ceil(height * 2)
fillMode: Image.PreserveAspectFit
asynchronous: true
}
// CONTRACT: a corrupt, removed or unresolvable icon always has a visible
// fallback independent of any installed font or desktop icon theme.
Rectangle {
anchors.fill: parent
visible: image.status !== Image.Ready
radius: width / 2; color: Theme.surface1
border.color: Theme.lavender
Text { anchors.centerIn: parent; text: "◇"; color: Theme.lavender; font.pixelSize: parent.width * 0.6 }
}
}

View file

@ -12,8 +12,8 @@ PanelWindow {
signal menuDismissed() signal menuDismissed()
signal launchRequested(string desktopId) signal launchRequested(string desktopId)
property string errorMessage: "" property string errorMessage: ""
property var allApps: [] readonly property var allApps: FavoritesService.apps
property var favoriteIds: [] readonly property var favoriteIds: FavoritesService.ids
property string selectedCategory: "Toutes" property string selectedCategory: "Toutes"
property int selectedIndex: 0 property int selectedIndex: 0
property int navIndex: 0 property int navIndex: 0
@ -44,10 +44,6 @@ PanelWindow {
WlrLayershell.namespace: "labfy-applications-menu" WlrLayershell.namespace: "labfy-applications-menu"
color: "transparent" color: "transparent"
function refreshCatalog() {
allApps = ApplicationsModel.catalog(DesktopEntries.applications.values);
if (!favoriteIds.length && selectedCategory === "Favoris") selectedCategory = "Toutes";
}
function chooseCategory(key) { function chooseCategory(key) {
selectedCategory = key; selectedCategory = key;
navIndex = navigation.findIndex(item => item.key === key); navIndex = navigation.findIndex(item => item.key === key);
@ -63,10 +59,7 @@ PanelWindow {
if (app) launchRequested(app.id); if (app) launchRequested(app.id);
} }
function toggleFavorite(id) { function toggleFavorite(id) {
const had = favoriteIds.includes(id); FavoritesService.toggle(id);
favoriteIds = had ? favoriteIds.filter(value => value !== id) : favoriteIds.concat([id]);
Quickshell.execDetached(["python3", "-B", backend, "favorites", had ? "remove" : "add", id]);
if (selectedCategory === "Favoris" && favoriteIds.length === 0) chooseCategory("Toutes");
} }
function dismiss() { function dismiss() {
if (closing) return; if (closing) return;
@ -80,39 +73,10 @@ PanelWindow {
interval: 150; repeat: false interval: 150; repeat: false
onTriggered: { menu.visible = false; menu.menuDismissed(); } onTriggered: { menu.visible = false; menu.menuDismissed(); }
} }
readonly property string backend: (Quickshell.env("XDG_CONFIG_HOME") ||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/applications/backend.py"
onVisibleAppsChanged: selectedIndex = Math.min(selectedIndex, Math.max(0, visibleApps.length - 1)) onVisibleAppsChanged: selectedIndex = Math.min(selectedIndex, Math.max(0, visibleApps.length - 1))
Connections {
target: DesktopEntries
function onApplicationsChanged() { menu.refreshCatalog(); }
}
Process {
id: favoritesReader
command: ["python3", "-B", menu.backend, "favorites", "read"]
stdout: StdioCollector { id: favoritesOutput; waitForEnd: true }
onExited: (code, status) => {
if (code !== 0) { menu.errorMessage = "Favoris indisponibles"; return; }
try {
const data = JSON.parse(favoritesOutput.text);
if (data.exists) {
menu.favoriteIds = data.ids;
} else {
const suggested = ApplicationsModel.initialFavorites(menu.allApps);
menu.favoriteIds = suggested;
Quickshell.execDetached(["python3", "-B", menu.backend, "favorites", "seed"]
.concat(suggested));
}
menu.selectedCategory = menu.favoriteIds.some(id => menu.allApps.some(app => app.id === id))
? "Favoris" : "Toutes";
menu.navIndex = menu.selectedCategory === "Favoris" ? 0 : 1;
} catch (_) { menu.errorMessage = "Favoris invalides"; }
}
}
Component.onCompleted: { Component.onCompleted: {
refreshCatalog(); selectedCategory = favoriteIds.some(id => allApps.some(app => app.id === id)) ? "Favoris" : "Toutes";
favoritesReader.running = true; navIndex = selectedCategory === "Favoris" ? 0 : 1;
} }
// The Wayland surface exists after completion; focus at the next frame. // The Wayland surface exists after completion; focus at the next frame.
Timer { interval: 120; running: true; repeat: false; onTriggered: search.forceActiveFocus() } Timer { interval: 120; running: true; repeat: false; onTriggered: search.forceActiveFocus() }
@ -166,6 +130,10 @@ PanelWindow {
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 13 font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 13
background: Item {} background: Item {}
onTextChanged: { menu.selectedIndex = 0; grid.positionViewAtBeginning(); } onTextChanged: { menu.selectedIndex = 0; grid.positionViewAtBeginning(); }
// WHY: TextField's built-in Tab navigation consumes the
// event before AfterItem handlers. Preserve this menu's
// explicit search/sidebar/grid keyboard cycle.
Keys.priority: Keys.BeforeItem
Keys.onPressed: event => { Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) { menu.dismiss(); event.accepted = true; } if (event.key === Qt.Key_Escape) { menu.dismiss(); event.accepted = true; }
else if (event.key === Qt.Key_Backtab || else if (event.key === Qt.Key_Backtab ||
@ -182,8 +150,8 @@ PanelWindow {
} }
} }
Text { Text {
visible: menu.errorMessage !== "" visible: menu.errorMessage !== "" || FavoritesService.error !== ""
width: parent.width; text: menu.errorMessage width: parent.width; text: menu.errorMessage || FavoritesService.error
color: Theme.danger; font.pixelSize: 12; elide: Text.ElideRight color: Theme.danger; font.pixelSize: 12; elide: Text.ElideRight
} }
Item { Item {
@ -199,6 +167,7 @@ PanelWindow {
anchors.fill: parent; anchors.margins: 5 anchors.fill: parent; anchors.margins: 5
clip: true; spacing: 3 clip: true; spacing: 3
model: menu.navigation model: menu.navigation
Keys.priority: Keys.BeforeItem
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Keys.onPressed: event => { Keys.onPressed: event => {
if (event.key === Qt.Key_Up || event.key === Qt.Key_Down) { if (event.key === Qt.Key_Up || event.key === Qt.Key_Down) {
@ -255,6 +224,7 @@ PanelWindow {
visible: menu.visibleApps.length > 0 visible: menu.visibleApps.length > 0
clip: true clip: true
model: menu.visibleApps model: menu.visibleApps
Keys.priority: Keys.BeforeItem
cellWidth: Math.max(80, Math.floor(width / (width >= 420 ? 4 : 3))) cellWidth: Math.max(80, Math.floor(width / (width >= 420 ? 4 : 3)))
cellHeight: 104 cellHeight: 104
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
@ -293,23 +263,12 @@ PanelWindow {
border.color: menu.selectedIndex === index && grid.activeFocus border.color: menu.selectedIndex === index && grid.activeFocus
? Theme.lavender : "transparent" ? Theme.lavender : "transparent"
} }
Image { ApplicationIcon {
id: appIcon id: appIcon
anchors.top: parent.top; anchors.topMargin: 12 anchors.top: parent.top; anchors.topMargin: 12
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
width: 46; height: 46 width: 46; height: 46
source: modelData.icon.startsWith("/") ? "file://" + modelData.icon iconSource: modelData.iconSource
: Quickshell.iconPath(modelData.icon || "application-x-executable",
"application-x-executable")
sourceSize.width: 46; sourceSize.height: 46
fillMode: Image.PreserveAspectFit
asynchronous: true
}
Text {
visible: appIcon.status === Image.Error
anchors.centerIn: appIcon
text: "󰀻"; color: Theme.lavender
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 32
} }
Text { Text {
anchors.top: appIcon.bottom; anchors.topMargin: 5 anchors.top: appIcon.bottom; anchors.topMargin: 5

View file

@ -31,7 +31,8 @@ function catalog(entries) {
.map(entry => ({ id: entry.id.endsWith(".desktop") ? entry.id : entry.id + ".desktop", .map(entry => ({ id: entry.id.endsWith(".desktop") ? entry.id : entry.id + ".desktop",
name: entry.name || entry.id, name: entry.name || entry.id,
genericName: entry.genericName || "", keywords: entry.keywords || [], genericName: entry.genericName || "", keywords: entry.keywords || [],
icon: entry.icon || "", category: classify(entry) })) icon: entry.icon || "", iconSource: entry.iconSource || "",
aliases: entry.aliases || [], newInstance: !!entry.newInstance, category: classify(entry) }))
.sort((a, b) => a.name.localeCompare(b.name, "fr", { sensitivity: "base" }) || a.id.localeCompare(b.id)); .sort((a, b) => a.name.localeCompare(b.name, "fr", { sensitivity: "base" }) || a.id.localeCompare(b.id));
} }

View file

@ -0,0 +1,102 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import "ApplicationsModel.js" as Model
import "WindowModel.js" as Windows
Singleton {
id: service
// CONTRACT: both interfaces bind these backend-authoritative properties.
// No UI writes ids; a failed command never leaves an optimistic favorite.
property var ids: []
property var apps: []
property var windows: []
property bool ready: false
property bool available: false
property string error: ""
property string iconTheme: ""
readonly property string backend: decodeURIComponent(Qt.resolvedUrl("backend.py").toString().replace(/^file:\/\//, ""))
readonly property var dockEntries: Windows.entries(apps, ids, windows)
function mutate(action, values) {
if (!available) { error = "Service de favoris indisponible"; return; }
bridge.write(JSON.stringify({action: action, ids: values}) + "\n");
}
function toggle(id) { mutate("toggle", [id]); }
function move(id, before) { mutate("move", [id, before]); }
function launch(id) { act("launch", id); }
function focusWindow(id) { act("focus", id); }
function act(kind, id) {
// INVARIANT: callers are user-click handlers only. Neither snapshots,
// state transitions nor animations may call this action boundary.
if (action.running) { error = "Action en cours"; return; }
action.command = ["python3", "-B", backend, kind, id];
action.running = true;
}
Process {
id: bridge
command: ["python3", "-B", service.backend, "serve"]
stdinEnabled: true
running: true
stdout: SplitParser {
onRead: data => {
try {
const state = JSON.parse(data);
service.error = state.error || "";
if (!state.ids) return;
service.ids = state.ids;
service.apps = Model.catalog(state.apps);
service.iconTheme = state.theme;
service.ready = true;
service.available = true;
// WHY: an existing empty file means intentionally empty.
// Seed only an absent file; backend repeats that check under lock.
if (!state.exists) service.mutate("seed", Model.initialFavorites(service.apps));
} catch (_) { service.error = "Réponse des favoris invalide"; }
}
}
onExited: { service.available = false; reconnect.start(); }
}
Timer { id: reconnect; interval: 1500; onTriggered: bridge.running = true }
Process {
id: action
stdout: StdioCollector { id: actionReply; waitForEnd: true }
onExited: (code, status) => {
if (code !== 0) {
try { service.error = JSON.parse(actionReply.text).error || "Action refusée"; }
catch (_) { service.error = "Action refusée"; }
}
}
}
// CONTRACT: one native event subscription for all outputs. Reconcile on
// connect and after bursts; dirty preserves events received during a query.
property bool dirty: false
function refreshWindows() { dirty = true; if (!snapshot.running) settle.restart(); }
Process {
id: subscription
command: ["swaymsg", "-m", "-t", "subscribe", '["window","workspace","output"]']
running: true
stdout: SplitParser { onRead: data => service.refreshWindows() }
onStarted: service.refreshWindows()
onExited: { service.windows = []; reconnectEvents.restart(); }
}
// WHY: the native I3 listener in 0.3.2 did not resubscribe after a forced
// socket loss in the private-compositor test. This one owned process has
// a single restart deadline; there is no steady-state window polling.
Timer { id: reconnectEvents; interval: 1500; onTriggered: subscription.running = true }
Timer { id: settle; interval: 60; onTriggered: { service.dirty = false; snapshot.running = true; } }
Timer { id: retrySnapshot; interval: 1500; onTriggered: service.refreshWindows() }
Process {
id: snapshot
command: ["python3", "-B", service.backend, "windows"]
stdout: StdioCollector { id: snapshotReply; waitForEnd: true }
onExited: (code, status) => {
if (code === 0) {
try { service.windows = JSON.parse(snapshotReply.text).windows; }
catch (_) { service.windows = []; }
} else { service.windows = []; retrySnapshot.restart(); }
if (service.dirty) settle.restart();
}
}
Component.onCompleted: refreshWindows()
}

View file

@ -0,0 +1,35 @@
.pragma library
function match(apps, window) {
// CONTRACT: declared identities only, never titles. Exact desktop stem
// wins; an ambiguous executable/WM_CLASS alias must remain unknown.
const keys = [window.appId, window.wmClass, window.instance]
.filter(Boolean).map(value => value.toLowerCase());
for (const key of keys) {
const exact = apps.filter(app => app.id.slice(0, -8).toLowerCase() === key);
if (exact.length === 1) return exact[0];
const aliases = apps.filter(app => (app.aliases || []).includes(key));
if (aliases.length === 1) return aliases[0];
}
return null;
}
function entries(apps, ids, windows) {
const byId = new Map(apps.map(app => [app.id, app]));
const result = ids.map(id => Object.assign({}, byId.get(id) || {
id: id, name: id.replace(/\.desktop$/, "") + " (indisponible)",
iconSource: "", newInstance: false, missing: true
}, {pinned: true, windows: []}));
const groups = new Map(result.map(entry => [entry.id, entry]));
for (const window of windows) {
const app = match(apps, window);
const id = app ? app.id : "window:" + (window.appId || window.wmClass || window.id);
if (!groups.has(id)) {
const entry = Object.assign({}, app || {id: id, name: window.appId || window.wmClass || "Application",
iconSource: "", missing: true, newInstance: false}, {pinned: false, windows: []});
groups.set(id, entry); result.push(entry);
}
groups.get(id).windows.push(window);
}
return result;
}

View file

@ -57,18 +57,31 @@ def write_favorites(path, action, ids):
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try: try:
fcntl.flock(directory, fcntl.LOCK_EX) fcntl.flock(directory, fcntl.LOCK_EX)
if (action in ("add", "remove", "toggle") and (len(ids) != 1 or not valid_id(ids[0]))) or (
action == "move" and (len(ids) != 2 or not valid_id(ids[0])
or (ids[1] and not valid_id(ids[1])))):
raise ValueError("Identifiant de favori invalide")
exists, current = read_favorites(path) exists, current = read_favorites(path)
if action == "seed": if action == "seed":
if exists: if exists:
return current return current
current = list(dict.fromkeys(ids))[:MAX_FAVORITES] current = list(dict.fromkeys(ids))[:MAX_FAVORITES]
elif action == "add": elif action == "add" or (action == "toggle" and ids[0] not in current):
if ids[0] not in current: if ids[0] not in current:
if len(current) >= MAX_FAVORITES: if len(current) >= MAX_FAVORITES:
raise ValueError("Trop de favoris") raise ValueError("Trop de favoris")
current.append(ids[0]) current.append(ids[0])
elif action == "remove": elif action in ("remove", "toggle"):
current = [item for item in current if item != ids[0]] current = [item for item in current if item != ids[0]]
elif action == "move":
# CONTRACT: move one existing ID before a live anchor (or to the
# end). Never replace a client's stale whole list: concurrent adds
# and removes survive. A vanished source/anchor rejects atomically.
if len(ids) != 2 or ids[0] not in current or (ids[1] and ids[1] not in current):
raise ValueError("Favori déplacé ou cible indisponible")
if ids[0] != ids[1]:
current.remove(ids[0])
current.insert(current.index(ids[1]) if ids[1] else len(current), ids[0])
else: else:
raise ValueError("Action de favoris invalide") raise ValueError("Action de favoris invalide")
if any(not valid_id(item) for item in current): if any(not valid_id(item) for item in current):
@ -155,10 +168,22 @@ def main(argv):
if len(ids) > 4 or any(not valid_id(item) for item in ids): if len(ids) > 4 or any(not valid_id(item) for item in ids):
raise ValueError("Favoris initiaux invalides") raise ValueError("Favoris initiaux invalides")
result = {"ids": write_favorites(state_path(), "seed", ids)} result = {"ids": write_favorites(state_path(), "seed", ids)}
elif len(argv) == 3 and argv[:2] in (["favorites", "add"], ["favorites", "remove"]): elif len(argv) == 3 and argv[:2] in (["favorites", "add"], ["favorites", "remove"], ["favorites", "toggle"]):
if not valid_id(argv[2]): if not valid_id(argv[2]):
raise ValueError("Identifiant de favori invalide") raise ValueError("Identifiant de favori invalide")
result = {"ids": write_favorites(state_path(), argv[1], [argv[2]])} result = {"ids": write_favorites(state_path(), argv[1], [argv[2]])}
elif len(argv) == 4 and argv[:2] == ["favorites", "move"]:
result = {"ids": write_favorites(state_path(), "move", argv[2:])}
elif argv == ["serve"]:
from shared import serve
return serve()
elif argv == ["windows"]:
from shared import windows, sway_request
result = {"windows": windows(sway_request(4, ""))}
elif len(argv) == 2 and argv[0] == "focus":
from shared import focus
focus(argv[1])
result = {"ok": True}
elif len(argv) == 2 and argv[0] == "launch": elif len(argv) == 2 and argv[0] == "launch":
launch(argv[1]) launch(argv[1])
result = {"ok": True} result = {"ok": True}
@ -166,7 +191,7 @@ def main(argv):
raise ValueError("Commande invalide") raise ValueError("Commande invalide")
print(json.dumps(result, ensure_ascii=False)) print(json.dumps(result, ensure_ascii=False))
return 0 return 0
except (OSError, ValueError, GLib.Error, json.JSONDecodeError, subprocess.TimeoutExpired) as error: except (OSError, ValueError, KeyError, GLib.Error, json.JSONDecodeError, subprocess.TimeoutExpired) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False)) print(json.dumps({"error": str(error)}, ensure_ascii=False))
return 1 return 1

View file

@ -0,0 +1,3 @@
singleton FavoritesService 1.0 FavoritesService.qml
ApplicationsMenu 1.0 ApplicationsMenu.qml
ApplicationIcon 1.0 ApplicationIcon.qml

View file

@ -0,0 +1,196 @@
"""Shared application catalog, private icon theme and bounded Sway requests.
No session/restore state is consulted or changed. The only persistent write
remains backend.write_favorites, under its existing directory transaction lock.
"""
import json
import os
from pathlib import Path
import socket
import struct
import sys
import time
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gio, GioUnix, GLib, Gtk
from backend import state_path, read_favorites, write_favorites, valid_id
# CONTRACT: private GtkIconTheme instance, never Gtk settings or desktop-wide
# theme changes. GTK implements theme inheritance and absolute GIcon lookup.
ICON_THEME = 'Tela-circle-purple-dark'
# CONTRACT: middle-click is conservative. These terminal launchers create a
# new toplevel per invocation. Unknown and single-instance GUI applications
# retain their ordinary Open action without promising another instance.
NEW_INSTANCE_IDS = frozenset(('kitty.desktop', 'foot.desktop', 'Alacritty.desktop',
'org.wezfurlong.wezterm.desktop'))
class IconResolver:
def __init__(self, theme=ICON_THEME):
self.theme = Gtk.IconTheme.new()
self.theme.set_custom_theme(theme)
def resolve(self, icon):
if icon:
info = self.theme.lookup_by_gicon(icon, 48, Gtk.IconLookupFlags.FORCE_SIZE)
if info and Path(info.get_filename()).is_file():
return Path(info.get_filename()).as_uri()
info = self.theme.lookup_icon('application-x-executable', 48, 0)
return Path(info.get_filename()).as_uri() if info and Path(info.get_filename()).is_file() else ''
def catalog():
resolver = IconResolver()
result = []
for app in Gio.AppInfo.get_all():
identifier = app.get_id()
if not valid_id(identifier) or not app.should_show() or not isinstance(app, GioUnix.DesktopAppInfo):
continue
icon = app.get_icon()
# WHY: StartupWMClass is the desktop author's declared window identity.
# Filename stem and executable basename cover standard Wayland app_ids;
# ambiguous aliases are deliberately left unmatched by the UI model.
aliases = [identifier[:-8], app.get_startup_wm_class(),
os.path.basename(app.get_executable() or '')]
result.append(dict(id=identifier, name=app.get_display_name(),
genericName=app.get_generic_name() or '',
keywords=list(app.get_keywords() or []),
categories=(app.get_categories() or '').split(';'),
icon=icon.to_string() if icon else '', iconSource=resolver.resolve(icon),
aliases=sorted({v.casefold() for v in aliases if v}),
# D-Bus activation may only activate an existing window;
# do not promise a new instance for these applications.
newInstance=identifier in NEW_INSTANCE_IDS and not app.get_boolean('DBusActivatable')))
return sorted(result, key=lambda a: a['id'])
def sway_request(kind, payload):
# CONTRACT: direct IPC, explicit little-endian widths, bounded reply and
# timeout. No shell, no inherited process, no background focus operations.
data = payload.encode()
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
deadline = time.monotonic() + 2
sock.settimeout(2)
sock.connect(os.environ['SWAYSOCK'])
sock.sendall(b'i3-ipc' + struct.pack('<II', len(data), kind) + data)
def receive(size):
parts = bytearray()
while len(parts) < size:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError('Délai IPC Sway dépassé')
sock.settimeout(remaining)
block = sock.recv(size - len(parts))
if not block:
raise ValueError('IPC Sway interrompu')
parts.extend(block)
return parts
header = receive(14)
length, reply_kind = struct.unpack('<II', header[6:])
if header[:6] != b'i3-ipc' or reply_kind != kind or length > 16 * 1024 * 1024:
raise ValueError('Réponse IPC Sway invalide')
return json.loads(receive(length))
def windows(tree):
result = []
pending = [(tree, '')]
while pending:
node, workspace = pending.pop()
if node.get('type') == 'workspace':
workspace = node.get('name', '')
pending.extend((child, workspace) for child in
node.get('nodes', []) + node.get('floating_nodes', []))
props = node.get('window_properties') or {}
if not (node.get('app_id') or node.get('window')):
continue
result.append(dict(id=str(node['id']), title=node.get('name') or 'Fenêtre',
appId=node.get('app_id') or '', wmClass=props.get('class') or '',
instance=props.get('instance') or '', workspace=workspace,
focused=bool(node.get('focused'))))
return sorted(result, key=lambda w: int(w['id']))
def focus(identifier):
if not isinstance(identifier, str) or not identifier.isascii() or not identifier.isdigit():
raise ValueError('Identifiant de fenêtre invalide')
target = next((w for w in windows(sway_request(4, '')) if w['id'] == identifier), None)
if not target:
raise ValueError('Fenêtre fermée')
command = f'[con_id={int(identifier)}] '
if target['workspace'] == '__i3_scratch':
replies = sway_request(0, command + 'scratchpad show')
if not all(reply.get('success') for reply in replies):
raise ValueError('Scratchpad indisponible')
if not all(reply.get('success') for reply in sway_request(0, command + 'focus')):
raise ValueError('Focus refusé')
def serve():
"""Single stream serializes UI mutations and disk notifications.
INVARIANT: every published list is re-read from disk. A rejected mutation
publishes the unchanged durable state plus an error, never an optimistic
list. File monitoring survives atomic replacement by watching the directory.
"""
path = state_path()
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
apps = catalog()
def publish(error=''):
try:
exists, ids = read_favorites(path)
print(json.dumps(dict(ids=ids, exists=exists, apps=apps,
theme=ICON_THEME, error=error)), flush=True)
except (OSError, ValueError) as exc:
print(json.dumps(dict(error=str(exc))), flush=True)
return False
monitor = Gio.File.new_for_path(str(path.parent)).monitor_directory(Gio.FileMonitorFlags.NONE, None)
pending = [0]
def changed(*_args):
if not pending[0]:
pending[0] = GLib.timeout_add(35, refresh)
def refresh():
pending[0] = 0
return publish()
monitor.connect('changed', changed)
app_monitor = Gio.AppInfoMonitor.get()
def apps_changed(*_args):
nonlocal apps
apps = catalog()
publish()
app_monitor.connect('changed', apps_changed)
loop = GLib.MainLoop()
# CONTRACT: newline JSON input is bounded before parsing. GLib owns the
# single main thread; EOF ends this helper with its QuickShell owner.
buffer = bytearray()
def request(fd, condition):
chunk = os.read(fd, 4096)
if not chunk:
loop.quit()
return False
buffer.extend(chunk)
if len(buffer) > 16384:
loop.quit()
return False
while b'\n' in buffer:
line, _, rest = buffer.partition(b'\n')
buffer[:] = rest
try:
data = json.loads(line)
action = data.get('action')
ids = data.get('ids')
if action not in ('add', 'remove', 'toggle', 'move', 'seed') or not isinstance(ids, list):
raise ValueError('Commande de favoris invalide')
if action == 'seed' and (len(ids) > 4 or any(not valid_id(i) for i in ids)):
raise ValueError('Favoris initiaux invalides')
write_favorites(path, action, ids)
publish()
except (OSError, ValueError, TypeError) as exc:
publish(str(exc))
return True
GLib.io_add_watch(sys.stdin.fileno(), GLib.IO_IN | GLib.IO_HUP, request)
publish()
loop.run()
monitor.cancel()
return 0

View file

@ -0,0 +1,25 @@
.pragma library
// CONTRACT: the sole deadline belongs to arming (edge dwell) or waiting
// (exit grace). Holding any interaction cancels it. No animation sends IPC.
function next(state, event, input) {
if (input.suppressed) return "hidden";
if (input.held) return "held";
if (input.body) return "shown";
if (input.edge) {
if (state === "hidden") return "arming";
if (state === "arming") return event === "deadline" ? "shown" : "arming";
return "shown";
}
if (state === "hidden" || state === "arming") return "hidden";
if (state === "waiting") return event === "deadline" ? "hidden" : "waiting";
return "waiting";
}
function influence(distance) {
// CONTRACT: 57 px resting pitch; a radius below 1.5 pitches affects
// at most the nearest slot and its immediate neighbours. Smoothstep has
// zero slope at both ends, avoiding a kink as an icon enters the halo.
const t = Math.max(0, 1 - Math.abs(distance) / 76);
return t * t * (3 - 2 * t);
}

View file

@ -0,0 +1,273 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import "../applications"
import "../theme"
import "AutoHide.js" as AutoHide
PanelWindow {
id: dock
required property var modelData
property bool suppressed: false
screen: modelData
anchors.bottom: true
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
aboveWindows: true
WlrLayershell.layer: WlrLayer.Top
// CONTRACT: the dock and its in-surface menus never accept keyboard focus.
// Layer surfaces are absent from get_tree, hence Session V2 snapshots.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.namespace: "labfy-sway-dock"
color: "transparent"
implicitWidth: Math.max(96, Math.min(screen.width - 24, items.width + 52))
implicitHeight: 360
property string phase: "hidden"
readonly property bool revealed: phase === "shown" || phase === "waiting" || phase === "held"
property bool pressed: false
property string draggingId: ""
property var contextEntry: null
property real contextX: 0
property real pointerX: -1000
readonly property bool held: pressed || draggingId !== "" || contextEntry !== null
readonly property var entries: FavoritesService.dockEntries
readonly property int pinnedCount: FavoritesService.ids.length
property alias contentItemRow: items
property alias surfaceBody: body
property alias contextCard: context
property alias edgeArea: sensor
// INVARIANT: only the bottom activation strip receives input while hidden.
// Visible input follows the animated body, with a bridge to the edge.
mask: Region {
item: sensor
Region { item: body; intersection: dock.revealed ? Intersection.Combine : Intersection.Subtract }
Region { item: context; intersection: context.visible ? Intersection.Combine : Intersection.Subtract }
}
function reconcile(event) {
phase = AutoHide.next(phase, event || "input", {
suppressed: suppressed, held: held,
body: bodyHover.hovered || contextHover.hovered,
edge: sensor.containsMouse
});
}
onHeldChanged: reconcile()
onSuppressedChanged: { contextEntry = null; draggingId = ""; pressed = false; reconcile(); }
onEntriesChanged: {
if (contextEntry) {
contextEntry = entries.find(entry => entry.id === contextEntry.id) || null;
}
}
Timer {
id: deadline
interval: dock.phase === "arming" ? 180 : 450
running: dock.phase === "arming" || dock.phase === "waiting"
onTriggered: dock.reconcile("deadline")
}
function activate(entry) {
if (entry.windows.length === 1) FavoritesService.focusWindow(entry.windows[0].id);
else if (entry.windows.length > 1) openContext(entry, width / 2);
else if (!entry.missing) FavoritesService.launch(entry.id);
}
function openContext(entry, x) {
contextX = x;
contextEntry = entry;
}
function closeContext() { contextEntry = null; }
MouseArea {
id: sensor
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
width: dock.revealed ? dock.width : Math.min(360, dock.width)
height: 4
hoverEnabled: true
onEntered: dock.reconcile()
onExited: dock.reconcile()
onClicked: dock.closeContext()
}
Item {
id: body
width: parent.width
height: 110
y: dock.revealed ? dock.height - height : dock.height + 4
Behavior on y { NumberAnimation { duration: 210; easing.type: Easing.OutCubic } }
HoverHandler {
id: bodyHover
onHoveredChanged: { dock.reconcile(); if (!hovered) dock.pointerX = -1000; }
onPointChanged: dock.pointerX = point.position.x
}
// WHY: an analytic shadow needs neither a captured backdrop nor a
// moving blur source. Its geometry stays fixed throughout hover.
RectangularShadow {
x: 16; y: 43; width: parent.width - 32; height: 48
radius: 20; blur: 24; spread: 2
color: Qt.rgba(0, 0, 0, 0.32)
}
Rectangle {
x: 14; y: 36; width: parent.width - 28; height: 58
radius: 20
color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, Theme.highContrast ? 0.94 : 0.16)
border.color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, Theme.highContrast ? 0.5 : 0.09)
border.width: 1
}
Flickable {
id: viewport
x: 12; y: 0; width: parent.width - 24; height: 105
// CONTRACT: reserve visual overflow inside the clipped viewport,
// including at both scroll limits, for the 1.39x end icons.
contentWidth: items.width + 24; contentHeight: height
clip: true
interactive: dock.draggingId === ""
boundsBehavior: Flickable.StopAtBounds
ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded }
Row {
id: items
x: 12
height: viewport.height
spacing: 3
move: Transition { NumberAnimation { properties: "x"; duration: 110; easing.type: Easing.OutCubic } }
Repeater {
id: icons
model: dock.entries
delegate: Item {
id: tile
required property var modelData
required property int index
readonly property bool separator: index === dock.pinnedCount && dock.pinnedCount > 0
readonly property real center: viewport.x + items.x + x + width / 2 - viewport.contentX
// INVARIANT: pointer influence uses fixed layout slots.
// Only the visual transform changes: Row, hit targets,
// window width and SVG raster size never follow hover.
readonly property real baseCenter: viewport.x + items.x + index * 57 + 27
+ (index >= dock.pinnedCount && dock.pinnedCount > 0 ? 13 : 0) - viewport.contentX
readonly property real growth: dock.revealed && bodyHover.hovered
? AutoHide.influence(baseCenter - dock.pointerX) : 0
width: 54 + (separator ? 13 : 0)
height: 105
Rectangle {
visible: tile.separator
x: 2; y: 45; width: 1; height: 32; color: Theme.strongBorder
}
ApplicationIcon {
id: icon
// WHY: ApplicationIcon derives sourceSize from width.
// Fixed dimensions avoid async SVG reload/fallback flashes.
width: 46; height: 46
x: (parent.width - width + (tile.separator ? 12 : 0)) / 2
y: 37
transformOrigin: Item.Bottom
scale: 1 + tile.growth * 0.39
Behavior on scale { NumberAnimation { duration: 80; easing.type: Easing.OutCubic } }
iconSource: tile.modelData.iconSource || ""
opacity: dock.draggingId === tile.modelData.id ? 0.5 : 1
}
Rectangle {
anchors.horizontalCenter: icon.horizontalCenter
y: 90; width: 4; height: 4; radius: 2
color: Theme.lavender; visible: tile.modelData.windows.length > 0
}
MouseArea {
id: pointer
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: true
preventStealing: tile.modelData.pinned
property real startX: 0
property real startY: 0
property bool moved: false
onPressed: mouse => {
dock.pressed = true; startX = mouse.x; startY = mouse.y; moved = false;
}
onPositionChanged: mouse => {
if (pressed && pressedButtons === Qt.LeftButton && tile.modelData.pinned
&& Math.hypot(mouse.x - startX, mouse.y - startY) > 8) {
moved = true; dock.draggingId = tile.modelData.id;
}
}
onReleased: mouse => {
if (moved) {
const p = mapToItem(items, mouse.x, mouse.y);
let before = "";
for (let i = 0; i < icons.count; ++i) {
const candidate = icons.itemAt(i);
if (candidate.modelData.pinned && candidate.modelData.id !== dock.draggingId
&& p.x < candidate.x + candidate.width / 2) {
before = candidate.modelData.id; break;
}
}
FavoritesService.move(dock.draggingId, before);
} else if (mouse.button === Qt.RightButton) {
dock.openContext(tile.modelData, tile.center);
} else if (mouse.button === Qt.MiddleButton) {
if (!tile.modelData.missing && tile.modelData.newInstance)
FavoritesService.launch(tile.modelData.id);
} else {
dock.closeContext(); dock.activate(tile.modelData);
}
dock.draggingId = ""; dock.pressed = false;
}
onCanceled: { dock.draggingId = ""; dock.pressed = false; }
ToolTip.visible: containsMouse && !dock.held
ToolTip.text: tile.modelData.name
ToolTip.delay: 600
}
}
}
}
}
}
Rectangle {
id: context
visible: dock.contextEntry !== null
width: Math.min(330, dock.width)
height: Math.min(230, contextContent.height + 16)
x: Math.max(0, Math.min(dock.width - width, dock.contextX - width / 2))
y: dock.height - 116 - height
radius: 12; color: Theme.popupBackground
border.color: Theme.strongBorder
HoverHandler { id: contextHover; onHoveredChanged: dock.reconcile() }
Flickable {
anchors.fill: parent; anchors.margins: 8
contentHeight: contextContent.height; clip: true
Column {
id: contextContent
width: parent.width
Text {
width: parent.width; height: 28
text: dock.contextEntry ? dock.contextEntry.name : ""
color: Theme.lavender; elide: Text.ElideRight; font.bold: true
}
Repeater {
model: dock.contextEntry ? dock.contextEntry.windows : []
delegate: DockAction {
required property var modelData
width: contextContent.width
text: modelData.title + " · " + modelData.workspace
onTriggered: { FavoritesService.focusWindow(modelData.id); dock.closeContext(); }
}
}
DockAction {
width: parent.width
visible: !!dock.contextEntry && !dock.contextEntry.missing
text: "Ouvrir"
onTriggered: { FavoritesService.launch(dock.contextEntry.id); dock.closeContext(); }
}
DockAction {
width: parent.width
visible: !!dock.contextEntry && (dock.contextEntry.pinned || !dock.contextEntry.missing)
objectName: "dock-pin-action"
text: dock.contextEntry && dock.contextEntry.pinned ? "Désépingler" : "Épingler"
onTriggered: { FavoritesService.toggle(dock.contextEntry.id); dock.closeContext(); }
}
DockAction { width: parent.width; text: "Fermer le menu"; onTriggered: dock.closeContext() }
}
}
}
Text {
visible: dock.revealed && FavoritesService.error !== "" && !context.visible
anchors.horizontalCenter: parent.horizontalCenter
y: dock.height - 140
text: FavoritesService.error; color: Theme.danger; font.pixelSize: 12
}
}

View file

@ -0,0 +1,13 @@
import QtQuick
import "../theme"
Rectangle {
id: action
property string text: ""
signal triggered()
height: visible ? 30 : 0
radius: 5
color: pointer.containsMouse ? Theme.buttonHover : "transparent"
Text { anchors.fill: parent; anchors.margins: 5; text: action.text; color: Theme.foreground
elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter; font.pixelSize: 12 }
MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: action.triggered() }
}

View file

@ -10,6 +10,8 @@ import "theme"
import "polkit" import "polkit"
import "scratchpad" import "scratchpad"
import "osd" import "osd"
import "applications"
import "dock"
ShellRoot { ShellRoot {
id: shell id: shell
@ -242,6 +244,12 @@ ShellRoot {
return AppearanceController.acceptPublishedNightLight(payload); return AppearanceController.acceptPublishedNightLight(payload);
} }
} }
// CONTRACT: shell-owned singleton remains alive while the menu is closed.
readonly property bool applicationsReady: FavoritesService.ready
Variants {
model: Quickshell.screens
Dock { suppressed: shell.authenticationActive || shell.applicationsOutput !== "" }
}
// Une barre par écran, y compris si les sorties changent pendant la session. // Une barre par écran, y compris si les sorties changent pendant la session.
// CONTRACT: un seul serveur D-Bus pour toutes les sorties. // CONTRACT: un seul serveur D-Bus pour toutes les sorties.
NotificationService { id: rootNotificationService } NotificationService { id: rootNotificationService }

View file

@ -26,3 +26,14 @@ layer_effects "labfy-sway-bar" {
shadows enable shadows enable
corner_radius 4 corner_radius 4
} }
# CONTRACT: dock glass uses a light tint and analytic QML shadow. Disabling
# backdrop blur avoids compositor blur work on every magnification frame.
# No window rule or focus/restore policy is changed.
layer_effects "labfy-sway-dock" {
blur disable
blur_xray disable
blur_ignore_transparent enable
shadows disable
corner_radius 25
}

296
tests/dock/headless.py Normal file
View file

@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Private Sway only; production Dock/Menu, real Python bridge and input paths."""
import json
import os
from pathlib import Path
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
from ipc_proxy import IpcProxy
SOURCE = REPO / 'quickshell/.config/quickshell/labfy-sway'
ARTIFACTS = REPO / 'docs/validation/quickshell-dock-tela-polish-v2'
NINE = ['firefox.desktop', 'kitty.desktop', 'yazi.desktop', 'nvim.desktop',
'org.darktable.darktable.desktop', 'orca-slicer.desktop', 'org.freecad.FreeCAD.desktop',
'org.kde.digikam.desktop', 'AppFlowy.desktop']
class DockLab(Lab):
def __init__(self):
super().__init__()
self.config=self.directory/'dock-sway.conf'
base=(REPO/'tests/session_v2/fixtures/headless.conf').read_text()
effects=(REPO/'sway/.config/sway/swayfx').read_text().split('layer_effects "labfy-sway-dock"')[1]
effects='layer_effects "labfy-sway-dock"'+effects
self.config.write_text(base+'\noutput HEADLESS-1 bg #11111b solid_color\nset $bar_blur enable\n'+effects)
def start(self, argv):
if argv[0]==os.environ.get('LABFY_TEST_SWAY','sway'):
argv=[*argv[:-1],str(self.config)]
return super().start(argv)
with DockLab() as lab:
proxy=IpcProxy(lab.directory/'dock-ipc.sock',lab.socket)
lab.env['SWAYSOCK']=str(lab.directory/'dock-ipc.sock')
path = Path(lab.env['XDG_STATE_HOME']) / 'labfy-sway/applications-favorites.json'
path.parent.mkdir(parents=True)
path.write_text(json.dumps(dict(version=1, ids=NINE)))
qml = lab.directory / 'fixture.qml'
qml.write_text('''//@ pragma UseQApplication
import QtQuick
import QtTest
import Quickshell
import Quickshell.Io
import "SOURCE/applications" as A
import "SOURCE/dock" as D
ShellRoot {
D.Dock { id: dock; modelData: Quickshell.screens[0] }
PanelWindow { id: bar; implicitWidth: 100; implicitHeight: 30; anchors.top: true; color: "transparent" }
LazyLoader { id: menu; active: false; A.ApplicationsMenu { barWindow: bar } }
// TEST_ONLY: one pointer move per animation frame avoids measuring IPC
// process startup instead of continuous production hover. These are Qt
// animation intervals, not hardware presentation timestamps.
FrameAnimation {
id: sweepFrames
property var intervals: []
property real origin: 0
property bool geometryChanged: false
property bool imageChanged: false
property var baseline: []
running: false
onTriggered: {
if (currentFrame > 1) intervals.push(frameTime * 1000);
const cycle = (elapsedTime * 0.7) % 2;
input.mouseMove(dock.surfaceBody, 51 + 420 * (cycle <= 1 ? cycle : 2 - cycle), 70);
const tiles=Array.from(dock.contentItemRow.children).filter(t => t.modelData);
for (let i=0; i<tiles.length; ++i) {
const t=tiles[i], b=baseline[i];
if (t.x!==b.x || t.width!==b.width || t.children[1].width!==46 || dock.width!==origin)
geometryChanged=true;
if (t.children[1].children[0].status!==b.status) imageChanged=true;
}
if (elapsedTime >= 4.5) running=false;
}
}
TestCase { id: input; name: "DockInput"; when: false }
IpcHandler {
target: "dockTest"
function state(): string {
return JSON.stringify({ready:A.FavoritesService.ready, ids:A.FavoritesService.ids,
error:A.FavoritesService.error, phase:dock.phase, revealed:dock.revealed,
entries:dock.entries, theme:A.FavoritesService.iconTheme,
menu:menu.active ? menu.item.favoriteIds : null,
searchFocused:menu.active && menu.item.searchFocused,
navFocused:menu.active && menu.item.navigationFocused,
gridFocused:menu.active && menu.item.gridFocused,
screenWidth:dock.screen.width,
bodyY:dock.surfaceBody.y, width:dock.width, pointerX:dock.pointerX,
tileWidth:dock.contentItemRow.children[0] ? dock.contentItemRow.children[0].width : 0,
tiles:Array.from(dock.contentItemRow.children).filter(t => t.modelData).map(t => ({
x:t.x, width:t.width, growth:t.growth, scale:t.children[1].scale,
visualLeft:t.children[1].mapToItem(dock.contentItemRow.parent.parent,0,0).x,
visualRight:t.children[1].mapToItem(dock.contentItemRow.parent.parent,46,0).x,
iconWidth:t.children[1].width, imageStatus:t.children[1].children[0].status})),
context:!!dock.contextEntry});
}
function startFrames(): void {
sweepFrames.intervals=[]; sweepFrames.origin=dock.width;
sweepFrames.baseline=Array.from(dock.contentItemRow.children).filter(t => t.modelData)
.map(t => ({x:t.x,width:t.width,status:t.children[1].children[0].status}));
sweepFrames.start();
}
function frames(): string {
return JSON.stringify({running:sweepFrames.running, intervals:sweepFrames.intervals,
geometryChanged:sweepFrames.geometryChanged,imageChanged:sweepFrames.imageChanged});
}
function edge(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width/2, 2); }
function body(): void { input.mouseMove(dock.surfaceBody, 35, 70); }
function sweep(x:real): void { input.mouseMove(dock.surfaceBody, x, 70); }
function scrollEnd(): void {
const v=dock.contentItemRow.parent.parent; v.contentX=Math.max(0,v.contentWidth-v.width);
}
function scrollStart(): void { dock.contentItemRow.parent.parent.contentX=0; }
function leave(): void { input.mouseMove(dock.contentItem, 1, 1); }
function menuToggle(id:string): void { if (!menu.active) menu.active=true; menu.item.toggleFavorite(id); }
function dockToggle(id:string): void { A.FavoritesService.toggle(id); }
function move(id:string, before:string): void { A.FavoritesService.move(id,before); }
function closeMenu(): void { menu.active=false; }
function tab(): void { input.keyClick(Qt.Key_Tab); }
function clickPin(): void {
const action=input.findChild(dock.contextCard,"dock-pin-action");
input.mouseClick(action,action.width/2,action.height/2);
}
function rightClick(): void {
input.mouseClick(dock.contentItemRow.children[0],27,65,Qt.RightButton);
}
function dragFirstAfterSecond(): void {
const first=dock.contentItemRow.children[0];
input.mousePress(first,27,65,Qt.LeftButton);
input.mouseMove(first,125,65,100);
input.mouseRelease(first,125,65,Qt.LeftButton);
}
function activateFirst(): void { input.mouseClick(dock.contentItemRow.children[0],27,65); }
function context(): void { dock.openContext(dock.entries[0],dock.width/2); }
function closeContext(): void { dock.closeContext(); }
function suppress(value:bool): void { dock.suppressed=value; }
function focus(id:string): void { A.FavoritesService.focusWindow(id); }
}
}
'''.replace('SOURCE', SOURCE.as_uri()))
lab.env['QT_QPA_PLATFORM'] = 'wayland'
process = lab.start(['quickshell', '-p', str(qml)])
def call(name, *args):
r = subprocess.run(['quickshell','ipc','-p',str(qml),'call','dockTest',name,*map(str,args)],
env=lab.env, text=True, capture_output=True, timeout=8)
if r.returncode:
logs = '\n'.join(p.read_text()[-9000:] for p in lab.directory.glob('child-*.log'))
raise AssertionError((name,r.stdout,r.stderr,logs))
return json.loads(r.stdout) if r.stdout.strip() else None
def wait(predicate, seconds=8):
end = time.monotonic()+seconds
while time.monotonic()<end:
state=call('state')
if predicate(state): return state
time.sleep(.08)
raise AssertionError(call('state'))
time.sleep(1.2)
initial=wait(lambda s:s['ready'])
assert initial['ids']==NINE and initial['phase']=='hidden', initial
ARTIFACTS.mkdir(parents=True,exist_ok=True)
def capture(name):
subprocess.run(['grim',str(ARTIFACTS / (name+'.png'))],env=lab.env,check=True)
capture('closed')
call('edge'); wait(lambda s:s['revealed']); time.sleep(.35)
capture('open')
resting=call('state')
call('body'); time.sleep(.3); zoom=call('state'); capture('zoom')
lab.command('output HEADLESS-1 bg #aab4c8 solid_color')
time.sleep(.2); capture('zoom-light')
lab.command('output HEADLESS-1 bg #11111b solid_color')
assert zoom['tileWidth']==54 and zoom['tiles'][0]['scale']>1, zoom
assert zoom['tiles'][0]['visualLeft']>=0,zoom
# Exercise production hover across slot boundaries. Layout and image
# readiness must remain stable while visual transforms change.
samples=[]
for x in range(45, 480, 6):
call('sweep',x)
state=call('state')
assert state['phase']=='shown',state
assert state['width']==resting['width'],state
assert [(t['x'],t['width'],t['iconWidth']) for t in state['tiles']]==[(t['x'],t['width'],t['iconWidth']) for t in resting['tiles']],state
assert sum(t['growth']>0 for t in state['tiles'])<=3,state
assert [t['imageStatus'] for t in state['tiles']]==[t['imageStatus'] for t in resting['tiles']],state
samples.append({'pointerX':state['pointerX'],'scales':[t['scale'] for t in state['tiles']]})
call('startFrames')
time.sleep(4.8)
frames=call('frames')
assert not frames['running'] and len(frames['intervals'])>30,frames
assert not frames['geometryChanged'] and not frames['imageChanged'],frames
intervals=sorted(frames['intervals'])
timing={'frames':len(intervals),'meanMs':sum(intervals)/len(intervals),
'p95Ms':intervals[int((len(intervals)-1)*.95)],'maxMs':max(intervals)}
call('leave'); wait(lambda s:s['phase']=='hidden')
call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
call('rightClick'); wait(lambda s:s['phase']=='held'); time.sleep(.7)
assert call('state')['context']
call('clickPin'); wait(lambda s:'firefox.desktop' not in s['ids'])
call('dockToggle','firefox.desktop'); wait(lambda s:'firefox.desktop' in s['ids'])
call('move','firefox.desktop','kitty.desktop'); wait(lambda s:s['ids']==NINE)
call('leave'); wait(lambda s:s['phase']=='hidden')
# Refused move leaves both interfaces at the same durable list.
call('move','kitty.desktop','removed.desktop')
wait(lambda s:bool(s['error']))
assert call('state')['ids']==NINE
call('menuToggle','fixture.desktop'); wait(lambda s:'fixture.desktop' in s['ids'] and s['menu']==s['ids'])
# Gio catalog monitoring replaces the placeholder without dropping its ID.
desktop=Path(lab.env['HOME'])/'.local/share/applications/fixture.desktop'
desktop.parent.mkdir(parents=True,exist_ok=True)
desktop.write_text('[Desktop Entry]\nType=Application\nName=Installed Fixture\nExec=/usr/bin/true\nIcon=firefox\n')
wait(lambda s:any(e['id']=='fixture.desktop' and not e.get('missing') for e in s['entries']))
desktop.unlink()
wait(lambda s:any(e['id']=='fixture.desktop' and e.get('missing') for e in s['entries']))
# Keep the virtual keyboard alive: the headless seat has no physical
# keyboard, so destroying wtype would remove keyboard capabilities and
# deactivate the QML focus window before we could sample its focus item.
keyboard=lab.start(['wtype','-s','300','-k','Tab','-s','1500','-k','Tab',
'-s','1500','-k','Tab','-s','1500'])
wait(lambda s:s['navFocused'])
wait(lambda s:s['gridFocused'])
wait(lambda s:s['searchFocused'])
keyboard.wait(timeout=8)
# An independent legacy CLI writer is observed by menu and dock live.
subprocess.run(['python','-B',str(SOURCE/'applications/backend.py'),'favorites','add','external.desktop'],
env=lab.env,check=True,capture_output=True)
wait(lambda s:'external.desktop' in s['ids'] and s['menu']==s['ids'])
call('dockToggle','external.desktop'); wait(lambda s:'external.desktop' not in s['ids'])
call('dockToggle','fixture.desktop'); wait(lambda s:'fixture.desktop' not in s['ids'] and s['menu']==s['ids'])
call('move','kitty.desktop','firefox.desktop'); reordered=wait(lambda s:s['ids'][0]=='kitty.desktop')
assert json.loads(path.read_text())['ids']==reordered['ids']
call('closeMenu')
call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
call('dragFirstAfterSecond')
dragged=wait(lambda s:s['ids'][0]=='firefox.desktop')
assert dragged['ids'][1]=='kitty.desktop',dragged
call('leave'); wait(lambda s:s['phase']=='hidden')
# No focus changes while the pointer reveals, zooms, hides, or opens menus.
win=lab.kitty('kitty')
time.sleep(.5)
focused=lambda: next(n['id'] for n in lab.windows() if n.get('focused'))
assert focused()==win
for action in ('edge','body','context','leave','closeContext'):
call(action); time.sleep(.25); assert focused()==win, action
call('suppress','true'); assert call('state')['phase']=='hidden'
call('suppress','false')
wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==1 for e in s['entries']))
proxy.disconnect()
time.sleep(2)
second=lab.kitty('kitty')
wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==2 for e in s['entries']))
lab.command(f'[con_id={win}] floating enable')
lab.command(f'[con_id={win}] move scratchpad')
call('focus',str(win)); time.sleep(.3); assert focused()==win
lab.command(f'[con_id={second}] kill')
wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==1 for e in s['entries']))
# Firefox runs with the private HOME/profile and no inherited session bus.
profile=lab.directory/'firefox-profile'; profile.mkdir()
firefox=lab.start(['firefox','--no-remote','--profile',str(profile),'--new-window','about:blank'])
firefox_state=wait(lambda s:any(e['id']=='firefox.desktop' and e['windows'] for e in s['entries']),20)
firewin=next(e for e in firefox_state['entries'] if e['id']=='firefox.desktop')['windows'][0]['id']
call('closeContext'); call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
call('activateFirst'); time.sleep(.3)
assert str(focused())==firewin
for action in ('edge','body','leave'):
call(action); time.sleep(.25); assert str(focused())==firewin
# Layer-shell surfaces cannot enter the Session V2 toplevel inventory.
assert not any('labfy-sway-dock' in str(n.get('app_id','')) for n in lab.windows())
lab.command('output HEADLESS-1 mode 800x600')
resized=wait(lambda s:s['screenWidth']==800)
assert resized['width']<=776,resized
# Magnified end icons keep their visual margin at both scroll limits.
lab.command('output HEADLESS-1 mode 400x600')
wait(lambda s:s['screenWidth']==400)
call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
call('scrollEnd'); call('sweep',325); time.sleep(.15)
end=call('state')
assert end['tiles'][-1]['scale']>1 and end['tiles'][-1]['visualRight']<=end['width']-24,end
call('scrollStart'); call('sweep',51); time.sleep(.15)
start=call('state')
assert start['tiles'][0]['scale']>1 and start['tiles'][0]['visualLeft']>=0,start
lab.command('output HEADLESS-1 mode 1280x720')
wait(lambda s:s['screenWidth']==1280)
# Existing empty state must stay empty, including a bridge restart.
for identifier in list(call('state')['ids']): call('dockToggle',identifier)
wait(lambda s:not s['ids'])
process.terminate(); process.wait(timeout=5)
process=lab.start(['quickshell','-p',str(qml)])
time.sleep(1)
assert wait(lambda s:s['ready'])['ids']==[]
(ARTIFACTS/'result.json').write_text(json.dumps(dict(status='PASS',initial=initial,hoverSamples=samples,animationTiming=timing,frameSweep=frames,
reordered=reordered['ids'],checks=['shared favorites','menu and dock mutations','durable reorder',
'empty restart','autohide','context hold','kitty multi-window','floating scratchpad explicit focus',
'window close','no hover focus','real pointer drag','fixed layout transform hover', 'stable SVG readiness', 'lateral sweep 73 samples','IPC reconnect','Firefox private profile','desktop install/remove monitoring','external writer','keyboard menu navigation','resolution change','SwayFX dock blur disabled']),indent=2)+'\n')
proxy.close()
print('Dock headless: PASS',lab.directory)

54
tests/dock/ipc_proxy.py Normal file
View file

@ -0,0 +1,54 @@
"""Test-only disconnectable proxy; targets only a fixture-owned Sway socket."""
import select
import socket
import threading
class IpcProxy:
def __init__(self, path, target):
self.target = target
self.socket = socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
self.socket.bind(str(path)); self.socket.listen(8); self.socket.settimeout(.2)
self.stopped = threading.Event()
self.lock = threading.Lock()
self.connections = set()
self.thread = threading.Thread(target=self.accept,daemon=True)
self.thread.start()
def accept(self):
while not self.stopped.is_set():
try: client,_ = self.socket.accept()
except socket.timeout: continue
except OSError: break
upstream=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
upstream.settimeout(2)
try: upstream.connect(self.target)
except OSError: client.close(); upstream.close(); continue
with self.lock:
if len(self.connections)>=16:
client.close(); upstream.close(); continue
self.connections.add(client); self.connections.add(upstream)
threading.Thread(target=self.forward,args=(client,upstream),daemon=True).start()
def forward(self, client, upstream):
try:
while not self.stopped.is_set():
ready,_,_=select.select([client,upstream],[],[],.2)
for source in ready:
data=source.recv(65536)
if not data: return
(upstream if source is client else client).sendall(data)
except (OSError,ValueError): pass
finally:
with self.lock:
self.connections.discard(client); self.connections.discard(upstream)
client.close(); upstream.close()
def disconnect(self):
with self.lock:
for connection in self.connections:
try: connection.shutdown(socket.SHUT_RDWR)
except OSError: pass
def close(self):
self.stopped.set(); self.disconnect(); self.socket.close(); self.thread.join(2)

View file

@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Run existing production chooser/restore scenarios with the dock present.
Only the test harness is extended in memory. No Session V2 source, journal,
keyring/PAM configuration or active graphical environment is changed.
"""
from pathlib import Path
import sys
ROOT=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT/'tests/session_v2'))
import keyring_ui
import phase3b_ui
source=(ROOT/'quickshell/.config/quickshell/labfy-sway/dock').as_uri()
for module in (keyring_ui,phase3b_ui):
module.HARNESS=module.HARNESS.replace('import Quickshell\n',
'import Quickshell\nimport "'+source+'" as D\n')
module.HARNESS=module.HARNESS.replace('ShellRoot {','''ShellRoot {
D.Dock { id: compatibilityDock; modelData: Quickshell.screens[0] }
Timer { interval: 3500; running: true; onTriggered: {
if (compatibilityDock.phase !== "hidden") {
console.error("DOCK_COMPAT_FAIL_NOT_HIDDEN"); Qt.quit();
}
} }
''')
module.main()
print(module.__name__+' WITH_DOCK PASS',flush=True)

23
tests/dock/shell_load.py Normal file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""Load the actual shell through its relative imports in a private Sway.
The session bus is private; the system bus address deliberately points to a
missing fixture socket so no real Polkit agent or system service is contacted.
"""
from pathlib import Path
import sys
import time
ROOT=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT/'tests/session_v2/fixtures'))
from headless import Lab
with Lab() as lab:
lab.env.update(DBUS_SYSTEM_BUS_ADDRESS='unix:path='+str(lab.directory/'no-system-bus'),QT_QPA_PLATFORM='wayland')
shell=ROOT/'quickshell/.config/quickshell/labfy-sway/shell.qml'
process=lab.start(['dbus-run-session','--','quickshell','-p',str(shell)])
time.sleep(3)
log=(lab.directory/'child-1.log').read_text()
assert 'Configuration Loaded' in log and process.poll() is None,log
assert 'Binding loop' not in log,log
print('Full shell private-compositor load: PASS',lab.directory)

53
tests/dock/test_models.js Normal file
View file

@ -0,0 +1,53 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
function load(file) {
const context=vm.createContext({});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../quickshell/.config/quickshell/labfy-sway',file),'utf8')
.replace(/^\.pragma library\s*\n/,''),context);
return context;
}
const hide=load('dock/AutoHide.js');
let phase='hidden';
const input={suppressed:false,held:false,body:false,edge:false};
const event=(name='input') => phase=hide.next(phase,name,input);
assert.equal(event(),'hidden');
input.edge=true; assert.equal(event(),'arming');
input.edge=false; assert.equal(event(),'hidden');
input.edge=true; event(); assert.equal(event('deadline'),'shown');
input.edge=false; assert.equal(event(),'waiting');
input.body=true; assert.equal(event(),'shown');
input.body=false; event(); input.held=true; assert.equal(event(),'held');
assert.equal(event('deadline'),'held');
input.held=false; assert.equal(event(),'waiting');
assert.equal(event('deadline'),'hidden');
input.held=true; event(); input.suppressed=true; assert.equal(event(),'hidden');
assert.equal(hide.influence(0),1);
assert.ok(hide.influence(25)>hide.influence(55));
assert.equal(hide.influence(76),0);
assert.equal(hide.influence(-76),0);
assert.equal(hide.influence(-35),hide.influence(35));
for (let cursor=0; cursor<570; ++cursor) {
const affected=Array.from({length:10},(_,i)=>i).filter(i=>hide.influence(i*57-cursor)>0);
assert.ok(affected.length<=3);
assert.ok(affected.length===0 || affected.at(-1)-affected[0]<=2);
}
const model=load('applications/WindowModel.js');
const apps=[{id:'kitty.desktop',aliases:['kitty'],iconSource:'file:///tela/kitty.svg'},
{id:'org.app.desktop',aliases:['startup-class','executable']},
{id:'org.other.desktop',aliases:['executable']}];
assert.equal(model.match(apps,{appId:'kitty'}).id,'kitty.desktop');
assert.equal(model.match(apps,{wmClass:'Startup-Class'}).id,'org.app.desktop');
assert.equal(model.match(apps,{appId:'executable'}),null);
assert.equal(model.match(apps,{title:'kitty'}),null);
let entries=model.entries(apps,['absent.desktop','kitty.desktop'],[{id:'10',appId:'kitty'},{id:'11',appId:'kitty'},
{id:'12',appId:'unknown'}]);
assert.equal(entries[0].missing,true);
assert.equal(entries[1].windows.length,2);
assert.equal(entries[1].iconSource,apps[0].iconSource);
assert.equal(entries[2].pinned,false);
assert.equal(model.entries(apps,[],[]).length,0);
assert.equal(model.entries(apps,[],[{id:'11',appId:'kitty'}])[0].pinned,false);
assert.equal(model.entries(apps,['kitty.desktop'],[])[0].windows.length,0);
console.log('Dock state and window models: PASS');

109
tests/dock/test_shared.py Normal file
View file

@ -0,0 +1,109 @@
"""Durable real backend operations and private toolkit icon resolution."""
import concurrent.futures
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[2]
APP = ROOT/'quickshell/.config/quickshell/labfy-sway/applications'
sys.path.insert(0, str(APP))
import backend
import shared
from gi.repository import Gio
NINE = ['firefox.desktop','kitty.desktop','yazi.desktop','nvim.desktop',
'org.darktable.darktable.desktop','orca-slicer.desktop','org.freecad.FreeCAD.desktop',
'org.kde.digikam.desktop','AppFlowy.desktop']
class Persistence(unittest.TestCase):
def test_nine_move_missing_anchor_and_empty(self):
with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'labfy-sway/applications-favorites.json'
path.parent.mkdir()
path.write_text(json.dumps(dict(version=1,ids=NINE)))
self.assertEqual(backend.read_favorites(path)[1],NINE)
expected=[NINE[1],NINE[0]]+NINE[2:]
self.assertEqual(backend.write_favorites(path,'move',[NINE[1],NINE[0]]),expected)
before=path.read_bytes()
for pair in ([NINE[0],'absent.desktop'], ['absent.desktop',NINE[0]], [NINE[0],'../bad']):
with self.assertRaises(ValueError): backend.write_favorites(path,'move',pair)
self.assertEqual(before,path.read_bytes())
for identifier in NINE: backend.write_favorites(path,'remove',[identifier])
self.assertEqual(backend.write_favorites(path,'seed',NINE[:4]),[])
self.assertEqual(backend.read_favorites(path),(True,[]))
def test_toggle_is_atomic_and_does_not_depend_on_a_stale_ui_list(self):
with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'favorites.json'
backend.write_favorites(path,'toggle',['kitty.desktop'])
self.assertEqual(backend.write_favorites(path,'toggle',['kitty.desktop']),[])
def test_real_concurrent_processes_keep_all_additions_during_moves(self):
with tempfile.TemporaryDirectory() as temp:
env=dict(os.environ,XDG_STATE_HOME=temp)
path=Path(temp)/'labfy-sway/applications-favorites.json'
backend.write_favorites(path,'seed',['a.desktop','b.desktop'])
def run(args):
result=subprocess.run([sys.executable,'-B',str(APP/'backend.py'),'favorites',*args],
env=env,capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
jobs=[['add',f'added{i}.desktop'] for i in range(24)]
jobs += [['move','b.desktop','a.desktop'] for _ in range(10)]
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
list(executor.map(run,jobs))
ids=backend.read_favorites(path)[1]
self.assertEqual(ids[:2],['b.desktop','a.desktop'])
self.assertEqual(len(ids),26)
self.assertEqual(set(ids[2:]),{f'added{i}.desktop' for i in range(24)})
class Icons(unittest.TestCase):
def test_tela_desktop_icon_and_absolute(self):
resolver=shared.IconResolver()
self.assertIn('/Tela-circle-purple-dark/',resolver.resolve(Gio.ThemedIcon.new('firefox')))
with tempfile.TemporaryDirectory() as temp:
path=Path(temp)/'custom icon.svg'
path.write_text('<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"/>')
self.assertEqual(resolver.resolve(Gio.FileIcon.new(Gio.File.new_for_path(str(path)))),path.as_uri())
self.assertTrue(resolver.resolve(Gio.ThemedIcon.new('labfy-icon-does-not-exist')))
self.assertTrue(resolver.resolve(Gio.FileIcon.new(Gio.File.new_for_path('/missing/icon.svg'))))
def test_inheritance_from_a_private_test_theme(self):
with tempfile.TemporaryDirectory() as temp:
root=Path(temp)
for name,parent in [('Child','Parent'),('Parent','hicolor')]:
directory=root/name; (directory/'48x48/apps').mkdir(parents=True)
(directory/'index.theme').write_text('[Icon Theme]\nName='+name+'\nInherits='+parent+
'\nDirectories=48x48/apps\n[48x48/apps]\nSize=48\nType=Fixed\nContext=Applications\n')
icon=root/'Parent/48x48/apps/inherited.svg'
icon.write_text('<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"/>')
resolver=shared.IconResolver('Child')
resolver.theme.set_search_path([temp])
self.assertEqual(resolver.resolve(Gio.ThemedIcon.new('inherited')),icon.as_uri())
class Sway(unittest.TestCase):
def test_wayland_xwayland_scratchpad_and_explicit_focus(self):
tree=dict(nodes=[dict(type='workspace',name='__i3_scratch',floating_nodes=[
dict(id=42,window=3,window_properties={'class':'Fixture','instance':'fixture'},name='Arbitrary'),
dict(id=43,app_id='kitty',name='Completely different title')])])
windows=shared.windows(tree)
self.assertEqual(windows[0]['wmClass'],'Fixture')
self.assertEqual(windows[1]['appId'],'kitty')
self.assertEqual(windows[0]['workspace'],'__i3_scratch')
with patch.object(shared,'sway_request',side_effect=[tree,[{'success':True}],[{'success':True}]]) as ipc:
shared.focus('42')
self.assertEqual([c.args for c in ipc.call_args_list],[(4,''),(0,'[con_id=42] scratchpad show'),
(0,'[con_id=42] focus')])
with patch.object(shared,'sway_request') as ipc:
with self.assertRaises(ValueError): shared.focus('42] focus; exec bad')
ipc.assert_not_called()
if __name__=='__main__': unittest.main()