feat(quickshell): enhance dock with window previews and customization

This commit is contained in:
fy59 2026-11-11 10:10:21 +01:00
parent be5b257190
commit c6b9b4f47f
55 changed files with 5145 additions and 98 deletions

View file

@ -0,0 +1,38 @@
# QuickShell Dock — full-width bottom edge activation (2026-11-11)
## Report and root cause
The centered QuickShell Dock used a layer-shell `PanelWindow` whose width
matched its visible row of icons. Its hidden activation sensor was limited to
`min(360, dock.width)`. Outside that narrow central region, moving the
pointer against the bottom edge of an output could not enter the surface.
## Change
`dock/Dock.qml` now anchors its input surface to the bottom, left and right
edges of **each output**. Only the **4px bottom sensor** is interactive while
hidden; the panel retains `exclusionMode: Ignore`, `exclusiveZone: 0`,
`WlrKeyboardFocus.None` and the existing input `Region` mask.
The visible `body` is centered within this full-width surface and preserves
its former calculated width, icon geometry, hover scale, 180ms activation dwell,
210ms slide, 450ms hide grace and scroll behavior. Context popup coordinates
include the visual body's horizontal offset. This does not affect the top bar,
Sway bindings, system shortcuts, favorites persistence or Session Restore V2.
## Validation
`qmllint quickshell/.config/quickshell/labfy-sway/dock/Dock.qml` passed;
`node tests/dock/test_models.js` and the six unit tests in `tests/dock/`
passed. Full `tests/dock/headless.py` ran with a private SwayFX and a
separate artifacts directory in `~/.cache/labfy-dock-edge-test`:
- `PASS` for activation from the left and right screen corners;
- sensor width equals output width while visual body remains centered;
- all existing favorites, auto-hide, context, scrolling, focus, drag,
multi-window, framebuffer, animation and resolution scenarios pass;
- no visual geometry or SVG raster reload during the frame sweep.
No user desktop windows or active Sway compositor were touched by the
headless tests. The production QuickShell service must reload the amended
QML to receive the new input region; Sway itself does not need a reload.

View file

@ -0,0 +1,79 @@
# QuickShell Dock — organisation et menu contextuel (11 novembre 2026)
## Objectif
Conserver le dock QuickShell SwayFX existant (zoom fixe, centrage, bande
d'activation de 4 px sur toute la largeur, couche Top sans focus clavier),
mais permettre de réorganiser les icônes épinglées et de gérer des séparateurs.
## Organisation du dock
- Glisser une icône épinglée pour la replacer par rapport aux autres favoris.
- Clic droit sur une icône épinglée : « Déplacer à gauche » et
« Déplacer à droite » lorsqu'un déplacement est possible.
- Clic droit sur une icône épinglée : « Ajouter séparateur avant/après ».
- Cliquer ou faire clic droit sur un séparateur : déplacer à gauche/droite
ou le supprimer. Le glisser-déposer d'un séparateur est également pris en charge.
- Les fenêtres ouvertes non épinglées demeurent après les favoris ; le
séparateur automatique à cette frontière est conservé.
La liste partagée des favoris conserve son contrat et son fichier **v1** :
`~/.local/state/labfy-sway/applications-favorites.json`. Les séparateurs
sont enregistrés séparément dans
`~/.local/state/labfy-sway/dock-separators.json`. Le format est strict,
limité à 16 séparateurs, avec écriture atomique, verrou de répertoire et
rejet sans écrasement d'un fichier invalide. Aucun identifiant de séparateur
n'est accepté comme application exécutable.
## Fermeture et transparence du menu
Le menu contextuel n'a plus de ligne « Fermer le menu ».
- Un clic dans la marge autour du menu le ferme immédiatement, sans lancer
l'application située derrière cette marge.
- Un éloignement du pointeur au-delà de cette zone déclenche une fermeture
avec **480 ms de tolérance** ; survoler de nouveau le menu annule l'échéance.
- La surface Wayland d'entrée est limitée à une petite zone autour du menu et
au dock existant. Le reste du bureau demeure interactif ; en dehors de la
zone de capture, la fermeture provient de l'éloignement du pointeur, pas
d'une interception globale des clics.
- Le fond de menu a **85 % d'opacité** (soit 15 % de transparence), avec les
textes entièrement opaques. Le mode de contraste élevé conserve un fond
presque opaque (94 %). Les survols de ses actions restent translucides.
L'auto-hide, les menus, l'animation et le focus des fenêtres ne sont pas
transformés en commandes Sway. Aucun redémarrage de Sway n'est nécessaire.
## Vérifications non destructives
- `tests/dock/test_separators.py` valide stockage, suppression, déplacements,
corruption, liens symboliques, opérations concurrentes et invariants v1.
- `tests/dock/test_models.js` valide les séparateurs et déplacements.
- `tests/dock/headless.py` utilise un **SwayFX privé** ; les actions réelles
du menu sont exercées, y compris clic à côté, délai d'éloignement, survol
prolongé, couleur et gestion réelle d'un séparateur.
- Les tests applications/dock, le shell complet, la compatibilité Session V2
et le lint QML sont exécutés avant l'activation sur ArchASP.
Les traces de test sont conservées dans
`~/.cache/labfy-dock-context-dismiss-v1`. Aucune modification volontaire
de la liste réelle de favoris ni de l'état Session Restore V2.
## Correction de la zone morte centrale (11 novembre 2026)
La fermeture automatique introduisait une régression dans la zone de survol :
les régions `dismissZone` et `context` étaient **soustraites** du masque
Wayland lorsqu'elles étaient invisibles. Leur géométrie continuait pourtant
d'exister, ce qui creusait des trous dans la bande sensible de 4 px, notamment
au centre de l'écran.
Les régions secondaires du masque utilisent désormais uniquement l'opération
`Combine`, avec largeur et hauteur égales à zéro tant que leurs composants
ne doivent pas recevoir la souris. Le capteur pleine largeur n'est donc
jamais soustrait, ni au démarrage ni après fermeture d'un menu. La zone
cliquable du menu n'est ajoutée qu'à son ouverture.
Le test SwayFX privé `tests/dock/headless.py` vérifie les trois régions
vides lorsque le dock est masqué, leur activation lorsque le menu apparaît,
leur désactivation après fermeture et un nouveau survol au centre, en plus
des deux coins. Il n'agit pas sur le pointeur physique de l'utilisateur.

View file

@ -0,0 +1,81 @@
# QuickShell Dock — points de fenêtres et vignettes Wayland (11 novembre 2026)
## Comportement
Chaque application de la rangée du dock affiche autant de points sous son icône
qu'elle possède de fenêtres **réellement détectées par Sway**. Les icônes des
séparateurs n'affichent pas de points. Un survol intentionnel (> 330 ms) sur
une application ouverte affiche des cartes de fenêtres, avec leur **aperçu
visuel ponctuel**, leur titre et leur workspace. Cliquer une carte transmet
l'identifiant exact au mécanisme existant `FavoritesService.focusWindow()`.
Le survol seul ne déplace aucune fenêtre, ne change aucun workspace et ne
modifie jamais le focus clavier.
Au-delà de quatre cartes, le panneau défile horizontalement. L'interface
ne prend jamais le focus clavier. Après éloignement du pointeur (620 ms), le
panneau disparaît. Les menus contextuels et le déplacement des favoris ont
priorité sur les vignettes.
## Capture native, sans surveillance permanente
SwayFX sur ArchASP publie les protocoles `ext-foreign-toplevel-list-v1`,
`ext-image-copy-capture-v1` et
`ext-foreign-toplevel-image-capture-source-manager-v1`. Les fenêtres de son
arbre IPC possèdent un `foreign_toplevel_identifier` permettant une
correspondance stricte entre le clic sur une vignette et la fenêtre Sway.
QuickShell 0.3.2 ne fournit pas un `ScreencopyView` utilisable pour cette
capture individuelle sur Sway (son chemin toplevel dépend de Hyprland).
Le collecteur ponctuel en Go est fondé sur le projet MIT
`mil-ad/wlthumbs`, commit amont
`516603c3144078b4d974108b80f7e9622ee4f3c9`, avec le code et la
licence conservés sous `tools/wlthumbs/`. Le correctif local ajoute
`-ids` : la capture est refusée sans une liste explicite de 1 à 8
identifiants hexadécimaux (32 caractères), et ne capture **que** ces
fenêtres. La capture de sorties complètes est interdite dans ce mode.
Le build reproductible utilisateur, sans sudo :
`tools/build-labfy-wlthumbs.sh`, installe le binaire dans
`~/.local/libexec/labfy-wlthumbs`.
Le wrapper `bin/dock-thumbnails.py` valide une seconde fois tous les
identifiants Sway et étrangers, limite le nombre de captures à 8 et la
taille des PNG, impose un dossier de session `0700` dans
`$XDG_RUNTIME_DIR/labfy-dock-window-thumbnails` et restitue seulement
les URI des images validées. Le nettoyage est déclenché après fermeture
du panneau, ainsi qu'à chaque nouvelle capture. Aucune capture
permanente, périodique, en arrière-plan ou à la déconnexion n'est créée.
En cas d'échec d'un aperçu, la carte affiche l'icône de l'application,
le titre et le workspace ; la sélection reste fonctionnelle.
## Compatibilité et sécurité
- Les favoris partagés, les séparateurs, le modèle d'applications et le menu
QuickShell existant conservent leur format.
- La récupération des fenêtres réutilise le snapshot IPC Sway existant,
sans nouvelle boucle de polling.
- La capture est limitée à la demande de l'utilisateur, et les noms des
fenêtres ne sont pas utilisés pour décider de leur identité.
- La couche du dock conserve `WlrKeyboardFocus.None`,
`exclusionMode.Ignore`, `exclusiveZone: 0` et la bande d'activation
de 4 px sur toute la largeur.
- Les PNG locaux constituent des données sensibles ; ils résident seulement
dans le runtime privé et sont supprimés à la fermeture du panneau.
- Les vignettes ne modifient pas Session Restore V2, l'autostart ni les
raccourcis Sway.
## Validation
- Tests unitaires `tests/dock/test_thumbnails.py` : entrées bornées,
captures sélectives, permissions, nettoyage et refus des chemins suspects.
- `tests/dock/headless.py` : vrai SwayFX et vrai QuickShell isolés,
trois fenêtres Kitty pour trois points et trois cartes sélectionnables,
non-vol de focus, captures réelles de la session de test, nettoyage, et
anciens scénarios de dock.
- Test non destructif sur ArchASP : sélection de trois fenêtres Firefox,
production de trois PNG temporaires puis nettoyage, sans changement
de focus ; ne conserve pas leur contenu.
- `qmllint`, suites applications/dock, chargement du shell complet et
compatibilité Session Restore V2.
Les artefacts de test générés sont dans le cache privé de développement,
pas dans le dépôt. Aucun média de la session de travail n'est publié.

View file

@ -66,6 +66,12 @@ modifié, focus pending/conflict et toute fenêtre pending. Executor lui fournit
le contrôle structurel sur sa dernière observation avant publication. Une
normalisation de parent unaire par Sway reste permise si placement, ordre et
layout canonique sont conservés ; les ratios exacts restent hors contrat.
Depuis le correctif du 11 novembre 2026, un conteneur `tabbed` ou `stacked`
à enfant unique est conservé dans le checkpoint V2, avec son chemin et son
layout. Seuls les parents unaires `splith`, `splitv` ou `none` sont aplatis.
Le schéma V1 reste inchangé. La capture et la reconstruction ont été validées
sur SwayFX headless isolé (`tests/session_v2/test_unary_layout.py` et
`tests/session_v2/unary_layout_runtime.py`), sans déconnexion utilisateur.
Ces contrôles ne sont **pas un CAS** : ils ne garantissent rien contre une
modification postérieure à la dernière observation du compositeur.
@ -83,6 +89,38 @@ fournit aussi `show` pour inspecter une session. Les noms sont limités à 64
caractères ASCII alphanumériques, tirets et underscores, commençant par un
caractère alphanumérique. Ne pas publier les sessions ou rapports runtime.
## Incident du 11 novembre 2026 — fenêtre ChatGPT recréée au login
La transaction `a88c486662da487aae136dce01f6ea92` a reconstruit 7/7
slots et vérifié la structure finale, avant de refuser la vérification du
focus : `RECOVERY_FOCUS_MISMATCH`, `final-observation` (con_id 17 attendu,
con_id 32 observé). Les frontières antérieures au retour final du provider
étaient conformes. Le premier conteneur XWayland ChatGPT 17 a ensuite disparu ;
le conteneur 32 correspond au même `x11-class:Chatgpt` et au même PID 753170.
Le processus ChatGPT venait de démarrer pendant le prewarm (vers 07:41:22),
et la tentative s'est terminée vers 07:41:29. Cette chronologie indique un
remapping de fenêtre concurrent, sans prouver la cause interne du remapping.
Le journal a refusé de récupérer six emplacements sur structure changée et
reste `RECOVERY_REQUIRED` : aucun retour arrière global n'est prétendu.
Le patch V2 attend désormais **avant tout Recovery/Attempt** au moins 12 secondes
à partir du lancement d'une nouvelle instance ChatGPT XWayland, et deux
secondes de stabilité de son con_id/PID/starttime/placement. Les fenêtres déjà
présentes ne sont pas concernées ; elles sont toujours contrôlées strictement.
Si le délai de phase0 expire, le moteur diffère la restauration sans lancer de
transaction. Aucun relâchement des contrôles après admission, aucun nouveau
`focus` automatique et aucun remplacement d'identité dans Recovery.
Les tests `test_startup_x11_stability.py` couvrent le changement 17 → 32,
l'identité des fenêtres préexistantes, le client étranger et le timeout ;
`test_xwayland.py` vérifie l'appel du gate depuis le prewarm réel du moteur.
Résultats : 157 tests Python, reconstruction headless `tabbed`/`stacked` et
scénario XWayland privé PASS. La reprise de session physique n'a pas été
réexécutée. Les trois journaux et le checkpoint ont été archivés dans
`~/.local/state/labfy-sway/session-v2-diagnostics/20261111-0741-chatgpt-window-remap`
avec empreintes vérifiées. La transaction active exige un traitement séparé
avec approbation : ne pas l'effacer ni retenter `apply-last` directement.
## Installation reproductible
Prérequis : Python 3, Sway compatible, QuickShell/Qt 6, GTK3, compilateur C17,

View file

@ -111,6 +111,28 @@ lancements sont interceptés. Aucun test n'ouvre les applications de l'utilisate
Le catalogue explicitement injecté par les tests est hermétique et ne découvre
pas les applications de la machine.
## Correctif ciblé Immich — 11 novembre 2026
Après la correction des parents `tabbed`/`stacked` unaires, la déconnexion a
renvoyé `APPLICATION_UNSUPPORTED`. L'inventaire Sway et GIO a identifié la
webapp Immich `firefox.webapp-40f22022-70af-4784-81ee-100e6a56dc50`,
présente dans le lanceur utilisateur et démarrée par l'autostart Sway sur
le workspace 8. Le catalogue excluait cette webapp avec `provider-required`.
La politique explicite `immich-web` adopte `managed-autostart` et
`managed_by=sway-autostart`, comme WhatsApp. Le host natif Firefox reconnaît
uniquement les webapps explicites gérées par Sway, jamais tout le préfixe
`firefox.webapp-*` ; il ne leur attribue pas d'UUID de fenêtre navigateur.
Les webapps inconnues restent refusées.
Validation : 153 tests Python PASS, reconstruction SwayFX/XWayland privé avec
ChatGPT, WhatsApp et Immich PASS ; les agencements unaires `tabbed` et `stacked`
restent PASS. Le host Firefox a été reconnecté sans fermer le navigateur.
Une capture réelle sur le bureau utilisateur (`capture --execute`, sans
sauvegarde ni déconnexion) a réussi : six applications, sept fenêtres ;
le checkpoint automatique antérieur est inchangé. La déconnexion et la
restauration physiques restent à valider par l'utilisateur.
## Références
- [Desktop Entry Specification — recognized keys](https://specifications.freedesktop.org/desktop-entry/latest/recognized-keys.html)

View file

@ -12,6 +12,7 @@ Singleton {
property var ids: []
property var apps: []
property var windows: []
property var separators: []
property bool ready: false
property bool available: false
property string error: ""
@ -24,6 +25,9 @@ Singleton {
}
function toggle(id) { mutate("toggle", [id]); }
function move(id, before) { mutate("move", [id, before]); }
function addSeparator(position) { mutate("dock-separator-add", [position]); }
function removeSeparator(id) { mutate("dock-separator-remove", [id]); }
function moveSeparator(id, position) { mutate("dock-separator-move", [id, position]); }
function launch(id) { act("launch", id); }
function focusWindow(id) { act("focus", id); }
function act(kind, id) {
@ -45,6 +49,7 @@ Singleton {
service.error = state.error || "";
if (!state.ids) return;
service.ids = state.ids;
service.separators = state.separators || [];
service.apps = Model.catalog(state.apps);
service.iconTheme = state.theme;
service.ready = true;

View file

@ -0,0 +1,112 @@
"""Strict, independent dock separators persisted alongside shared favorites.
Separators are *not* desktop applications; the favorites v1 schema remains
unchanged. Positions denote gaps in the current ordered pinned-app list.
"""
import fcntl
import json
import os
from pathlib import Path
import re
import secrets
import tempfile
MAX_SEPARATORS = 16
MAX_BYTES = 4096
SEPARATOR_ID = re.compile(r"^sep-[a-f0-9]{12}$")
def path_for(favorites_path):
return Path(favorites_path).with_name("dock-separators.json")
def _position(value):
return type(value) is int and 0 <= value <= 64
def read(path):
try:
if path.is_symlink():
raise ValueError("État des séparateurs invalide")
with path.open("rb") as stream:
raw = stream.read(MAX_BYTES + 1)
except FileNotFoundError:
return []
if len(raw) > MAX_BYTES:
raise ValueError("État des séparateurs trop volumineux")
try:
data = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("État des séparateurs invalide") from exc
entries = data.get("separators") if isinstance(data, dict) and data.get("version") == 1 else None
if (not isinstance(entries, list) or len(entries) > MAX_SEPARATORS
or any(not isinstance(e, dict) or set(e) != {"id", "position"}
or not isinstance(e["id"], str)
or SEPARATOR_ID.fullmatch(e["id"]) is None
or not _position(e["position"]) for e in entries)):
raise ValueError("État des séparateurs invalide")
if (len({e["id"] for e in entries}) != len(entries)
or len({e["position"] for e in entries}) != len(entries)):
raise ValueError("Séparateurs dupliqués")
return entries
def write(path, action, values):
"""One atomic move/add/remove; never replace a possibly stale full list."""
if action not in ("add", "remove", "move") or not isinstance(values, list):
raise ValueError("Action de séparateur invalide")
if action == "add" and (len(values) != 1 or not _position(values[0])):
raise ValueError("Position de séparateur invalide")
if action == "remove" and (len(values) != 1 or not isinstance(values[0], str)
or SEPARATOR_ID.fullmatch(values[0]) is None):
raise ValueError("Identifiant de séparateur invalide")
if action == "move" and (len(values) != 2 or not isinstance(values[0], str)
or SEPARATOR_ID.fullmatch(values[0]) is None
or not _position(values[1])):
raise ValueError("Déplacement de séparateur invalide")
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
fcntl.flock(directory, fcntl.LOCK_EX)
entries = read(path)
if action == "add":
pos = values[0]
if len(entries) >= MAX_SEPARATORS:
raise ValueError("Trop de séparateurs")
if any(e["position"] == pos for e in entries):
raise ValueError("Un séparateur existe déjà ici")
used = {e["id"] for e in entries}
identifier = "sep-" + secrets.token_hex(6)
while identifier in used:
identifier = "sep-" + secrets.token_hex(6)
entries.append({"id": identifier, "position": pos})
elif action == "remove":
if not any(e["id"] == values[0] for e in entries):
raise ValueError("Séparateur indisponible")
entries = [e for e in entries if e["id"] != values[0]]
else:
identifier, pos = values
entry = next((e for e in entries if e["id"] == identifier), None)
if entry is None:
raise ValueError("Séparateur indisponible")
if any(e["id"] != identifier and e["position"] == pos for e in entries):
raise ValueError("Un séparateur existe déjà ici")
entry["position"] = pos
entries.sort(key=lambda e: (e["position"], e["id"]))
payload = (json.dumps({"version": 1, "separators": entries},
ensure_ascii=False) + "\n").encode("utf-8")
fd, temporary = tempfile.mkstemp(prefix=".dock-separators-", dir=path.parent)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
os.fsync(directory)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
return entries
finally:
os.close(directory)

View file

@ -5,6 +5,7 @@ remains backend.write_favorites, under its existing directory transaction lock.
"""
import json
import os
import re
from pathlib import Path
import socket
import struct
@ -15,6 +16,7 @@ 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
from dock_separators import path_for as separators_path_for, read as read_separators, write as write_separators
# CONTRACT: private GtkIconTheme instance, never Gtk settings or desktop-wide
# theme changes. GTK implements theme inheritance and absolute GIcon lookup.
@ -105,10 +107,13 @@ def windows(tree):
props = node.get('window_properties') or {}
if not (node.get('app_id') or node.get('window')):
continue
foreign = node.get('foreign_toplevel_identifier') or ''
if not isinstance(foreign, str) or re.fullmatch(r'[a-f0-9]{32}', foreign) is None:
foreign = ''
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'))))
foreignId=foreign, focused=bool(node.get('focused'))))
return sorted(result, key=lambda w: int(w['id']))
@ -135,13 +140,16 @@ def serve():
list. File monitoring survives atomic replacement by watching the directory.
"""
path = state_path()
separators_path = separators_path_for(path)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
apps = catalog()
def publish(error=''):
try:
exists, ids = read_favorites(path)
separators = read_separators(separators_path)
print(json.dumps(dict(ids=ids, exists=exists, apps=apps,
theme=ICON_THEME, error=error)), flush=True)
separators=separators, theme=ICON_THEME,
error=error)), flush=True)
except (OSError, ValueError) as exc:
print(json.dumps(dict(error=str(exc))), flush=True)
return False
@ -180,11 +188,16 @@ def serve():
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 not isinstance(ids, list):
raise ValueError('Commande invalide')
if action in ('dock-separator-add', 'dock-separator-remove', 'dock-separator-move'):
write_separators(separators_path, action.removeprefix('dock-separator-'), ids)
elif action in ('add', 'remove', 'toggle', 'move', 'seed'):
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)
else:
raise ValueError('Commande invalide')
publish()
except (OSError, ValueError, TypeError) as exc:
publish(str(exc))

View file

@ -82,8 +82,11 @@ def packages(command, origin):
if result.returncode:
raise RuntimeError((result.stderr or result.stdout or f"Code {result.returncode}").strip()[:500])
rows = []
# yay -Qua may append relative age [12h36m] or [2d3h]; it is not
# part of the available package version. Permit bounded AUR metadata only.
age = r"(?: \[(?:[0-9]{1,4}[a-zA-Z]{1,3}){1,8}\])?" if origin == "AUR" else ""
for line in result.stdout.splitlines():
match = re.fullmatch(r"([a-zA-Z0-9@._+:-]+) (\S+) -> (\S+)", line.strip())
match = re.fullmatch(r"([a-zA-Z0-9@._+:-]+) (\S+) -> (\S+)" + age, line.strip())
if not match:
raise RuntimeError("Sortie du collecteur inattendue : " + line[:160])
name, installed, available = match.groups()

View file

@ -0,0 +1,129 @@
#!/usr/bin/python3
"""Capture only requested Sway toplevels, on demand for the dock hover panel.
Privacy: 0700 XDG_RUNTIME_DIR cache; no full-desktop capture, no permanent
storage, bounded captures, and no Wayland focus/activation commands.
"""
import json
import os
from pathlib import Path
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import time
WINDOW_ID = re.compile(r"^[1-9][0-9]{0,18}$")
FOREIGN_ID = re.compile(r"^[a-f0-9]{32}$")
ROOT_NAME = "labfy-dock-window-thumbnails"
MAX_IMAGES = 8
MAX_PNG_BYTES = 2_000_000
MAX_ARGUMENT = 2048
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
def cache_root():
runtime = Path(os.environ["XDG_RUNTIME_DIR"])
if not runtime.is_dir() or runtime.is_symlink() or runtime.stat().st_uid != os.getuid():
raise ValueError("XDG_RUNTIME_DIR invalide")
root = runtime / ROOT_NAME
if root.is_symlink():
raise ValueError("Cache de vignettes invalide")
root.mkdir(mode=0o700, exist_ok=True)
info = root.stat()
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid()
or info.st_mode & 0o077):
raise ValueError("Permissions du cache de vignettes invalides")
return root
def purge(root, *, all_dirs=False):
for entry in root.iterdir():
if not entry.name.startswith("shot-") or entry.is_symlink() or not entry.is_dir():
continue
try:
info = entry.stat()
if info.st_uid == os.getuid() and (all_dirs or time.time() - info.st_mtime > 120):
shutil.rmtree(entry)
except OSError:
pass
def validate(raw):
if not isinstance(raw, str) or len(raw) > MAX_ARGUMENT:
raise ValueError("Demande de vignettes invalide")
rows = json.loads(raw)
if not isinstance(rows, list) or not 1 <= len(rows) <= MAX_IMAGES:
raise ValueError("Nombre de vignettes invalide")
result = []
ids, foreign = set(), set()
for row in rows:
if not isinstance(row, dict) or set(row) != {"id", "foreignId"}:
raise ValueError("Donnée de fenêtre invalide")
wid, fid = row["id"], row["foreignId"]
if (not isinstance(wid, str) or WINDOW_ID.fullmatch(wid) is None
or not isinstance(fid, str) or FOREIGN_ID.fullmatch(fid) is None
or wid in ids or fid in foreign):
raise ValueError("Identifiant de fenêtre invalide")
ids.add(wid)
foreign.add(fid)
result.append((wid, fid))
return result
def capture(raw, *, tool=None):
selected = validate(raw)
root = cache_root()
purge(root)
folder = Path(tempfile.mkdtemp(prefix="shot-", dir=root))
output = {}
tool = tool or str(Path.home() / ".local/libexec/labfy-wlthumbs")
try:
completed = subprocess.run(
[tool, "-ids", ",".join(fid for _, fid in selected),
"-size", "224", "-dir", str(folder)],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, timeout=6, check=False)
if completed.returncode != 0:
return {"images": {}, "error": "Capture indisponible"}
for wid, fid in selected:
path = folder / (fid + ".png")
try:
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
continue
if not 8 < info.st_size < MAX_PNG_BYTES:
continue
with path.open("rb") as stream:
if stream.read(8) != PNG_MAGIC:
continue
output[wid] = path.as_uri()
except OSError:
continue
return {"images": output, "error": ""}
except (OSError, subprocess.TimeoutExpired):
return {"images": {}, "error": "Capture indisponible"}
finally:
if not output:
shutil.rmtree(folder, ignore_errors=True)
def main(argv):
try:
if len(argv) == 1 and argv[0] == "clear":
purge(cache_root(), all_dirs=True)
return 0
if len(argv) != 2 or argv[0] != "capture":
raise ValueError("Commande invalide")
result = capture(argv[1])
print(json.dumps(result, ensure_ascii=False), flush=True)
return 0
except (OSError, ValueError, TypeError, json.JSONDecodeError):
print(json.dumps({"images": {}, "error": "Capture indisponible"}), flush=True)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -3,15 +3,21 @@ import QtQuick.Controls
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import Quickshell.Io
import "../applications"
import "../theme"
import "AutoHide.js" as AutoHide
import "DockModel.js" as DockModel
PanelWindow {
id: dock
required property var modelData
property bool suppressed: false
screen: modelData
// The transparent layer spans the output so the bottom edge is reachable
// at every horizontal position. The visible dock remains centered below.
anchors.left: true
anchors.right: true
anchors.bottom: true
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
@ -22,8 +28,9 @@ PanelWindow {
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.namespace: "labfy-sway-dock"
color: "transparent"
implicitWidth: Math.max(96, Math.min(screen.width - 24, items.width + 52))
implicitWidth: screen.width
implicitHeight: 360
readonly property real visualWidth: Math.max(96, Math.min(screen.width - 24, items.width + 52))
property string phase: "hidden"
readonly property bool revealed: phase === "shown" || phase === "waiting" || phase === "held"
property bool pressed: false
@ -31,20 +38,71 @@ PanelWindow {
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
// The preview is a transient, focusless popup opened only on pointer dwell.
property string previewId: ""
property string pendingPreviewId: ""
property real previewAnchorX: 0
property var previewImages: ({})
property int previewGeneration: 0
property int captureGeneration: -1
property string captureAppId: ""
readonly property var previewEntry: entries.find(e => e.id === previewId) || null
readonly property bool previewVisible: previewId !== "" && previewEntry !== null
&& previewEntry.windows.length > 0 && contextEntry === null
readonly property string captureScript: decodeURIComponent(
Qt.resolvedUrl("../bin/dock-thumbnails.py").toString().replace(/^file:\/\//, ""))
readonly property bool held: pressed || draggingId !== "" || contextEntry !== null || previewVisible
readonly property var entries: DockModel.entries(FavoritesService.dockEntries, FavoritesService.separators)
readonly property int pinnedCount: FavoritesService.ids.length
property alias contentItemRow: items
property alias surfaceBody: body
property alias contextCard: context
property alias contextDismissArea: dismissZone
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.
// INVARIANT: only the four-pixel, full-output bottom strip receives input
// while hidden. When revealed, the centered visual body also receives input.
// The edge strip must never acquire holes when the popup is closed.
// Empty conditional regions, rather than Subtract, avoid carving the
// (still geometrically present) hidden context/dismiss zone out of it.
mask: Region {
item: sensor
Region { item: body; intersection: dock.revealed ? Intersection.Combine : Intersection.Subtract }
Region { item: context; intersection: context.visible ? Intersection.Combine : Intersection.Subtract }
Region {
id: bodyInputMask
x: body.x; y: body.y
width: dock.revealed ? body.width : 0
height: dock.revealed ? body.height : 0
intersection: Intersection.Combine
}
Region {
id: dismissInputMask
x: dismissZone.x; y: dismissZone.y
width: context.visible ? dismissZone.width : 0
height: context.visible ? dismissZone.height : 0
intersection: Intersection.Combine
}
Region {
id: contextInputMask
x: context.x; y: context.y
width: context.visible ? context.width : 0
height: context.visible ? context.height : 0
intersection: Intersection.Combine
}
Region {
id: previewInputMask
x: previewPopup.x; y: previewPopup.y
width: dock.previewVisible ? previewPopup.width : 0
height: dock.previewVisible ? previewPopup.height : 0
intersection: Intersection.Combine
}
}
// Exposed for the private compositor's input-mask regression assertions.
property alias bodyMaskRegion: bodyInputMask
property alias dismissMaskRegion: dismissInputMask
property alias contextMaskRegion: contextInputMask
property alias previewPopupItem: previewPopup
property alias previewCardsRow: cardRow
property alias previewMaskRegion: previewInputMask
property alias previewArmRunning: previewArm.running
function reconcile(event) {
phase = AutoHide.next(phase, event || "input", {
suppressed: suppressed, held: held,
@ -53,11 +111,16 @@ PanelWindow {
});
}
onHeldChanged: reconcile()
onSuppressedChanged: { contextEntry = null; draggingId = ""; pressed = false; reconcile(); }
onSuppressedChanged: {
contextEntry = null; draggingId = ""; pressed = false;
closePreview(); reconcile();
}
onEntriesChanged: {
if (contextEntry) {
contextEntry = entries.find(entry => entry.id === contextEntry.id) || null;
}
if (previewId && (!previewEntry || previewEntry.windows.length === 0))
closePreview();
}
Timer {
id: deadline
@ -67,19 +130,157 @@ PanelWindow {
}
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.windows.length > 1) openContext(entry, body.width / 2);
else if (!entry.missing) FavoritesService.launch(entry.id);
}
function openContext(entry, x) {
closePreview();
contextX = x;
contextEntry = entry;
contextDismiss.restart();
}
function closeContext() {
contextDismiss.stop();
contextEntry = null;
}
// Hover state and the same 4px trigger keep the dock alive; the menu
// owns only a small temporary outside-click region, not the whole screen.
Timer {
id: contextDismiss
interval: 480
onTriggered: {
if (dock.contextEntry && !contextHover.hovered && !dismissZone.containsMouse)
dock.closeContext();
}
}
// Capture is one-shot on hover, never continuous and never on a timer
// while idle. A stale response cannot replace a newer hover selection.
function queuePreview(entry, x) {
if (suppressed || contextEntry || !entry || !entry.windows
|| entry.windows.length === 0 || entry.kind || pressed) return;
if (previewId === entry.id || pendingPreviewId === entry.id) {
previewClose.stop();
return;
}
previewAnchorX = x;
pendingPreviewId = entry.id;
previewClose.stop();
previewArm.restart();
}
function leavePreviewTile(id) {
// Child MouseAreas can briefly lose hover without leaving the
// dock surface (scaled icon and tooltip). The body owns the stable
// pointer position, and changes between adjacent icons are tracked
// there instead of closing the popup spuriously.
if (!bodyHover.hovered && pendingPreviewId === id) previewArm.stop();
if (!bodyHover.hovered && !previewHover.hovered) previewClose.restart();
}
function trackPreview(x, y) {
if (y < 32 || y > 100 || !revealed || contextEntry || pressed
|| draggingId !== "" || suppressed) return;
const rowX = x - viewport.x - items.x + viewport.contentX;
for (let i = 0; i < icons.count; ++i) {
const tile = icons.itemAt(i);
if (!tile || tile.divider || tile.modelData.windows.length === 0) continue;
if (rowX >= tile.x && rowX <= tile.x + tile.width) {
queuePreview(tile.modelData, tile.center);
return;
}
}
previewArm.stop();
if (previewVisible && !previewHover.hovered) previewClose.restart();
}
function closePreview() {
previewArm.stop();
previewClose.stop();
pendingPreviewId = "";
previewId = "";
previewImages = ({});
++previewGeneration;
thumbsCleanup.restart();
}
function showPreview() {
if (!pendingPreviewId || suppressed || contextEntry || pressed) return;
const next = entries.find(e => e.id === pendingPreviewId);
if (!next || next.windows.length === 0) return;
previewId = next.id;
previewImages = ({});
++previewGeneration;
requestThumbnails();
}
function requestThumbnails() {
if (!previewVisible || thumbnailJob.running) return;
const candidates = previewEntry.windows.filter(w => w.foreignId)
.slice(0, 8).map(w => ({id: String(w.id), foreignId: w.foreignId}));
if (!candidates.length) return;
captureAppId = previewId;
captureGeneration = previewGeneration;
thumbnailJob.command = ["python3", "-B", captureScript,
"capture", JSON.stringify(candidates)];
thumbnailJob.running = true;
}
Timer {
id: previewArm
interval: 330
onTriggered: dock.showPreview()
}
Timer {
id: previewClose
interval: 620
onTriggered: { if (!previewHover.hovered) dock.closePreview(); }
}
Timer {
id: thumbsCleanup
interval: 1250
onTriggered: {
if (!dock.previewVisible && !thumbnailJob.running && !cleanupJob.running)
cleanupJob.running = true;
else if (!dock.previewVisible) restart();
}
}
Process {
id: thumbnailJob
stdout: StdioCollector { id: thumbnailReply; waitForEnd: true }
onExited: {
if (captureGeneration === dock.previewGeneration
&& captureAppId === dock.previewId && dock.previewVisible) {
try {
const result = JSON.parse(thumbnailReply.text);
dock.previewImages = result.images || ({});
} catch (_) {
dock.previewImages = ({});
}
} else if (dock.previewVisible) {
Qt.callLater(dock.requestThumbnails);
} else {
thumbsCleanup.restart();
}
}
}
Process {
id: cleanupJob
command: ["python3", "-B", dock.captureScript, "clear"]
running: false
}
function movePinned(id, direction) {
const target = DockModel.moveTarget(FavoritesService.ids, id, direction);
if (target) FavoritesService.move(target.id, target.before);
}
function separatorPositionAt(localX) {
let position = 0;
for (let i = 0; i < icons.count; ++i) {
const candidate = icons.itemAt(i);
if (candidate && candidate.modelData.pinned
&& localX > candidate.x + candidate.width / 2)
++position;
}
return position;
}
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)
anchors.left: parent.left
anchors.right: parent.right
height: 4
hoverEnabled: true
onEntered: dock.reconcile()
@ -88,14 +289,26 @@ PanelWindow {
}
Item {
id: body
width: parent.width
width: dock.visualWidth
x: (dock.width - width) / 2
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
onHoveredChanged: {
dock.reconcile();
if (!hovered) {
dock.pointerX = -1000;
previewArm.stop();
if (dock.previewVisible && !previewHover.hovered)
previewClose.restart();
}
}
onPointChanged: {
dock.pointerX = point.position.x;
dock.trackPreview(point.position.x, point.position.y);
}
}
// WHY: an analytic shadow needs neither a captured backdrop nor a
// moving blur source. Its geometry stays fixed throughout hover.
@ -134,27 +347,29 @@ PanelWindow {
id: tile
required property var modelData
required property int index
readonly property bool separator: index === dock.pinnedCount && dock.pinnedCount > 0
readonly property bool customDivider: modelData.kind === "separator"
readonly property bool automaticDivider: modelData.kind === "automatic-separator"
readonly property bool divider: customDivider || automaticDivider
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
// Slots stay fixed while icon scale changes; separators
// have no zoom and cannot launch an application.
readonly property real baseCenter: center
readonly property real growth: !divider && dock.revealed && bodyHover.hovered
? AutoHide.influence(baseCenter - dock.pointerX) : 0
width: 54 + (separator ? 13 : 0)
width: customDivider ? 20 : automaticDivider ? 13 : 54
height: 105
Rectangle {
visible: tile.separator
x: 2; y: 45; width: 1; height: 32; color: Theme.strongBorder
visible: tile.divider
x: (parent.width - width) / 2; y: 45; width: 1; height: 32
color: tile.customDivider ? Theme.lavender : Theme.strongBorder
}
ApplicationIcon {
id: icon
// WHY: ApplicationIcon derives sourceSize from width.
// Fixed dimensions avoid async SVG reload/fallback flashes.
visible: !tile.divider
width: 46; height: 46
x: (parent.width - width + (tile.separator ? 12 : 0)) / 2
x: (parent.width - width) / 2
y: 37
transformOrigin: Item.Bottom
scale: 1 + tile.growth * 0.39
@ -162,25 +377,48 @@ PanelWindow {
iconSource: tile.modelData.iconSource || ""
opacity: dock.draggingId === tile.modelData.id ? 0.5 : 1
}
Rectangle {
// One lavender dot per real Sway toplevel. Compress
// spacing instead of turning multiple windows into one.
Row {
id: windowDots
property int count: tile.divider ? 0 : tile.modelData.windows.length
spacing: count <= 8 ? 2 : 0.5
anchors.horizontalCenter: icon.horizontalCenter
y: 90; width: 4; height: 4; radius: 2
color: Theme.lavender; visible: tile.modelData.windows.length > 0
y: 91
Repeater {
model: windowDots.count
delegate: Rectangle {
width: Math.max(1, Math.min(4,
(46 - windowDots.spacing * (windowDots.count - 1))
/ windowDots.count))
height: width
radius: width / 2
color: Theme.lavender
}
}
}
readonly property int dotCount: windowDots.count
MouseArea {
id: pointer
anchors.fill: parent
visible: !tile.automaticDivider
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: true
preventStealing: tile.modelData.pinned
preventStealing: tile.modelData.pinned || tile.customDivider
property real startX: 0
property real startY: 0
property bool moved: false
onEntered: {
if (!tile.divider) dock.queuePreview(tile.modelData, tile.center);
}
onExited: dock.leavePreviewTile(tile.modelData.id)
onPressed: mouse => {
dock.closePreview();
dock.pressed = true; startX = mouse.x; startY = mouse.y; moved = false;
}
onPositionChanged: mouse => {
if (pressed && pressedButtons === Qt.LeftButton && tile.modelData.pinned
if (pressed && pressedButtons === Qt.LeftButton
&& (tile.modelData.pinned || tile.customDivider)
&& Math.hypot(mouse.x - startX, mouse.y - startY) > 8) {
moved = true; dock.draggingId = tile.modelData.id;
}
@ -188,6 +426,9 @@ PanelWindow {
onReleased: mouse => {
if (moved) {
const p = mapToItem(items, mouse.x, mouse.y);
if (tile.customDivider) {
FavoritesService.moveSeparator(dock.draggingId, dock.separatorPositionAt(p.x));
} else {
let before = "";
for (let i = 0; i < icons.count; ++i) {
const candidate = icons.itemAt(i);
@ -197,8 +438,11 @@ PanelWindow {
}
}
FavoritesService.move(dock.draggingId, before);
}
} else if (mouse.button === Qt.RightButton) {
dock.openContext(tile.modelData, tile.center);
} else if (tile.customDivider) {
dock.openContext(tile.modelData, tile.center);
} else if (mouse.button === Qt.MiddleButton) {
if (!tile.modelData.missing && tile.modelData.newInstance)
FavoritesService.launch(tile.modelData.id);
@ -208,8 +452,8 @@ PanelWindow {
dock.draggingId = ""; dock.pressed = false;
}
onCanceled: { dock.draggingId = ""; dock.pressed = false; }
ToolTip.visible: containsMouse && !dock.held
ToolTip.text: tile.modelData.name
ToolTip.visible: containsMouse && !dock.held && !dock.previewVisible
ToolTip.text: tile.customDivider ? "Séparateur — clic pour gérer" : tile.modelData.name
ToolTip.delay: 600
}
}
@ -217,16 +461,98 @@ PanelWindow {
}
}
}
// On-demand window chooser above the existing dock. The main panel stays
// input-transparent outside the chooser, body and full-width bottom sensor.
Rectangle {
id: previewPopup
objectName: "dock-window-preview"
visible: dock.previewVisible
width: Math.min(dock.width - 24,
Math.min(4, dock.previewEntry ? dock.previewEntry.windows.length : 0) * 198 + 20)
height: 170
x: Math.max(8, Math.min(dock.width - width - 8,
body.x + dock.previewAnchorX - width / 2))
y: body.y - height + 18
radius: 12
color: Qt.rgba(Theme.popupBackground.r, Theme.popupBackground.g,
Theme.popupBackground.b, Theme.highContrast ? 0.94 : 0.85)
border.color: Theme.strongBorder
z: 5
HoverHandler {
id: previewHover
onHoveredChanged: {
if (hovered) previewClose.stop();
else if (dock.previewVisible) previewClose.restart();
dock.reconcile();
}
}
Flickable {
id: previewScroll
anchors.fill: parent
anchors.margins: 10
contentWidth: cardRow.width
contentHeight: height
clip: true
interactive: contentWidth > width
boundsBehavior: Flickable.StopAtBounds
Row {
id: cardRow
height: previewScroll.height
spacing: 8
Repeater {
model: dock.previewEntry ? dock.previewEntry.windows : []
delegate: DockPreviewCard {
required property var modelData
windowData: modelData
iconSource: dock.previewEntry ? dock.previewEntry.iconSource || "" : ""
imageSource: dock.previewImages[String(modelData.id)] || ""
onActivated: {
FavoritesService.focusWindow(modelData.id);
dock.closePreview();
}
}
}
}
}
}
// An outside click near the menu dismisses it without launching another
// app. Moving beyond this margin starts a short close grace instead.
MouseArea {
id: dismissZone
objectName: "dock-context-dismiss-zone"
visible: context.visible
x: Math.max(0, context.x - 68)
y: Math.max(0, context.y - 52)
width: Math.min(dock.width - x, context.width + 136)
height: dock.height - y
hoverEnabled: true
onEntered: contextDismiss.stop()
onExited: contextDismiss.restart()
onClicked: dock.closeContext()
}
Rectangle {
id: context
visible: dock.contextEntry !== null
width: Math.min(330, dock.width)
width: Math.min(330, body.width)
height: Math.min(230, contextContent.height + 16)
x: Math.max(0, Math.min(dock.width - width, dock.contextX - width / 2))
// contextX is local to the centered visual dock, not to the
// full-width input surface.
x: Math.max(0, Math.min(dock.width - width, body.x + dock.contextX - width / 2))
y: dock.height - 116 - height
radius: 12; color: Theme.popupBackground
radius: 12
// 15% transparent (85% opaque), except accessibility mode.
// Keep menu text fully opaque for readability.
color: Qt.rgba(Theme.popupBackground.r, Theme.popupBackground.g,
Theme.popupBackground.b, Theme.highContrast ? 0.94 : 0.85)
border.color: Theme.strongBorder
HoverHandler { id: contextHover; onHoveredChanged: dock.reconcile() }
HoverHandler {
id: contextHover
onHoveredChanged: {
if (hovered) contextDismiss.stop();
else if (!dismissZone.containsMouse) contextDismiss.restart();
dock.reconcile();
}
}
Flickable {
anchors.fill: parent; anchors.margins: 8
contentHeight: contextContent.height; clip: true
@ -249,18 +575,80 @@ PanelWindow {
}
DockAction {
width: parent.width
visible: !!dock.contextEntry && !dock.contextEntry.missing
visible: !!dock.contextEntry && !dock.contextEntry.kind
&& !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)
visible: !!dock.contextEntry && !dock.contextEntry.kind
&& (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() }
DockAction {
width: parent.width
visible: !!dock.contextEntry && !!dock.contextEntry.pinned
&& DockModel.moveTarget(FavoritesService.ids, dock.contextEntry.id, -1) !== null
text: "Déplacer à gauche"
onTriggered: { dock.movePinned(dock.contextEntry.id, -1); dock.closeContext(); }
}
DockAction {
width: parent.width
visible: !!dock.contextEntry && !!dock.contextEntry.pinned
&& DockModel.moveTarget(FavoritesService.ids, dock.contextEntry.id, 1) !== null
text: "Déplacer à droite"
onTriggered: { dock.movePinned(dock.contextEntry.id, 1); dock.closeContext(); }
}
DockAction {
width: parent.width
readonly property int position: dock.contextEntry
? FavoritesService.ids.indexOf(dock.contextEntry.id) : -1
visible: position >= 0 && !DockModel.hasSeparator(FavoritesService.separators, position)
text: "Ajouter séparateur avant"
onTriggered: { FavoritesService.addSeparator(position); dock.closeContext(); }
}
DockAction {
width: parent.width
readonly property int position: dock.contextEntry
? FavoritesService.ids.indexOf(dock.contextEntry.id) + 1 : -1
visible: !!dock.contextEntry && !!dock.contextEntry.pinned
&& !DockModel.hasSeparator(FavoritesService.separators, position)
text: "Ajouter séparateur après"
onTriggered: { FavoritesService.addSeparator(position); dock.closeContext(); }
}
DockAction {
width: parent.width
visible: !!dock.contextEntry && dock.contextEntry.kind === "separator"
&& dock.contextEntry.position > 0
&& !DockModel.hasSeparator(FavoritesService.separators, dock.contextEntry.position - 1)
text: "Déplacer séparateur à gauche"
onTriggered: {
FavoritesService.moveSeparator(dock.contextEntry.id, dock.contextEntry.position - 1);
dock.closeContext();
}
}
DockAction {
width: parent.width
visible: !!dock.contextEntry && dock.contextEntry.kind === "separator"
&& dock.contextEntry.position < FavoritesService.ids.length
&& !DockModel.hasSeparator(FavoritesService.separators, dock.contextEntry.position + 1)
text: "Déplacer séparateur à droite"
onTriggered: {
FavoritesService.moveSeparator(dock.contextEntry.id, dock.contextEntry.position + 1);
dock.closeContext();
}
}
DockAction {
width: parent.width
objectName: "dock-remove-separator"
visible: !!dock.contextEntry && dock.contextEntry.kind === "separator"
text: "Supprimer le séparateur"
onTriggered: { FavoritesService.removeSeparator(dock.contextEntry.id); dock.closeContext(); }
}
}
}
}

View file

@ -6,7 +6,9 @@ Rectangle {
signal triggered()
height: visible ? 30 : 0
radius: 5
color: pointer.containsMouse ? Theme.buttonHover : "transparent"
// Keep action hover translucent over the 65%-transparent context menu.
color: pointer.containsMouse ? Qt.rgba(Theme.buttonHover.r, Theme.buttonHover.g,
Theme.buttonHover.b, 0.5) : "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

@ -0,0 +1,42 @@
.pragma library
// The favorites file remains a pure ordered list of desktop IDs. User separators
// belong to their own document and live at *gaps* between pinned applications.
function entries(applications, separators) {
const pinned = applications.filter(entry => entry.pinned);
const running = applications.filter(entry => !entry.pinned);
const result = [];
const at = new Map();
for (const separator of (separators || [])) {
if (!separator || !/^sep-[0-9a-f]{12}$/.test(separator.id)
|| !Number.isInteger(separator.position) || separator.position < 0)
continue;
const index = Math.min(pinned.length, separator.position);
if (!at.has(index)) at.set(index, []);
at.get(index).push({id: separator.id, kind: "separator",
position: separator.position,
name: "Séparateur", windows: [], pinned: false});
}
for (let i = 0; i <= pinned.length; ++i) {
for (const sep of (at.get(i) || [])) result.push(sep);
if (i < pinned.length) result.push(pinned[i]);
}
if (pinned.length && running.length)
result.push({id: "dock-running-boundary", kind: "automatic-separator",
name: "Applications ouvertes", windows: [], pinned: false});
return result.concat(running);
}
function hasSeparator(separators, position) {
return (separators || []).some(sep => sep.position === position);
}
// A pinned icon can move relative to other pinned icons, never into an
// unpinned process or an arbitrary desktop ID.
function moveTarget(ids, id, direction) {
const index = ids.indexOf(id);
if (direction === -1 && index > 0) return {id: id, before: ids[index - 1]};
if (direction === 1 && index >= 0 && index < ids.length - 1)
return {id: id, before: ids[index + 2] || ""};
return null;
}

View file

@ -0,0 +1,82 @@
import QtQuick
import QtQuick.Controls
import "../applications"
import "../theme"
// A selectable, keyboard-focusless preview of one existing Sway toplevel.
// A missing/failed screencopy falls back to the corresponding app icon.
Rectangle {
id: card
required property var windowData
property string iconSource: ""
property string imageSource: ""
signal activated()
width: 190
height: 148
radius: 8
color: Qt.rgba(Theme.surface0.r, Theme.surface0.g, Theme.surface0.b, 0.8)
border.color: pointer.containsMouse ? Theme.lavender : Theme.strongBorder
border.width: pointer.containsMouse ? 2 : 1
Item {
id: shotArea
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 7
height: 99
clip: true
Rectangle { anchors.fill: parent; radius: 5; color: Theme.mantle }
Image {
id: shot
anchors.fill: parent
source: card.imageSource
cache: false
asynchronous: true
fillMode: Image.PreserveAspectFit
visible: status === Image.Ready
}
ApplicationIcon {
anchors.centerIn: parent
width: 46
height: 46
visible: shot.status !== Image.Ready
iconSource: card.iconSource
}
}
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: shotArea.bottom
anchors.topMargin: 3
anchors.leftMargin: 7
anchors.rightMargin: 7
text: card.windowData.title || "Fenêtre"
textFormat: Text.PlainText
elide: Text.ElideRight
color: Theme.foreground
font.pixelSize: 11
}
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: 7
anchors.rightMargin: 7
anchors.bottomMargin: 3
text: "Workspace " + (card.windowData.workspace || "?")
elide: Text.ElideRight
color: Theme.secondaryForeground
font.pixelSize: 10
}
MouseArea {
id: pointer
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton
onClicked: card.activated()
ToolTip.visible: containsMouse
ToolTip.text: card.windowData.title || "Fenêtre"
ToolTip.delay: 650
}
}

View file

@ -36,6 +36,8 @@ DEFAULT = (
Spec("proton-pass", "proton-pass.desktop", ("proton-pass",), "single-window", ("/usr/bin/proton-pass",)),
Spec("whatsapp-web", "firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded.desktop",
("firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded",), "managed-autostart", (), managed_by="sway-autostart"),
Spec("immich-web", "firefox.webapp-40f22022-70af-4784-81ee-100e6a56dc50.desktop",
("firefox.webapp-40f22022-70af-4784-81ee-100e6a56dc50",), "managed-autostart", (), managed_by="sway-autostart"),
)
# Shared interpreters/launchers provide no application identity.

View file

@ -154,6 +154,39 @@ class Executor:
except OSError: raise Failure("TARGET_IDENTITY_CHANGED") from None
return states
def _settle_fresh_chatgpt(self, baseline, spec, session, allowed, timeout,
minimum_age=12.0, stable_for=2.0):
"""Wait for a newly launched ChatGPT XWayland window to stop remapping.
This is PRE-TRANSACTION only. A freshly spawned ChatGPT process may
replace its first X11 window/con_id several seconds after mapping.
Never pin that first window as a durable Recovery origin. Pre-existing
clients remain immutable; only the newly launched application's
pre-admission identity may change. There is no retry after admission.
"""
started = time.monotonic()
deadline = started + timeout
last, stable_since = None, None
while True:
now = time.monotonic()
require(now < deadline, "APPLICATION_WINDOWS_INCOMPLETE")
require(self.sway.session == session, "SWAY_SESSION_CHANGED")
current = self._observe_prepared()
self._check_prepared(baseline, current, exact=False)
require(all(s["app_id"] in allowed for i, s in current.items() if i not in baseline),
"APPLICATION_MEMBERSHIP_CHANGED")
candidates = [s for s in current.values() if s["app_id"] in spec.app_ids]
signature = None
if len(candidates) == 1:
s = candidates[0]
signature = (s["con_id"], s["pid"], s["starttime"], s["app_id"], placement(s))
if signature != last:
last, stable_since = signature, now if signature is not None else None
if (signature is not None and now - started >= minimum_age
and stable_since is not None and now - stable_since >= stable_for):
return current
time.sleep(.05)
def _prewarm(self, data, timeout):
"""PHASE 0 sous le verrou V2, avant Recovery/Attempt et tout IPC mutant.
@ -189,8 +222,14 @@ class Executor:
missing = launch_count(app, count) if count == 0 else 0
if missing:
require(time.monotonic() < end, "APPLICATION_WINDOWS_INCOMPLETE")
try: launch(spec, missing, self.env)
try: children = launch(spec, missing, self.env)
except OSError: raise Failure("APPLICATION_LAUNCH_FAILED") from None
# ChatGPT's freshly launched X11 shell may replace its first
# window ~7s after startup. Wait before observing a durable
# origin; an existing window never gets this exception.
if spec.application_id == "chatgpt" and children:
observed = self._settle_fresh_chatgpt(observed, spec, session,
all_ids, max(.001, end - time.monotonic()))
while True:
require(self.sway.session == session, "SWAY_SESSION_CHANGED")
current = self._observe_prepared()

View file

@ -21,9 +21,11 @@ from .applications import DEFAULT
EXTENSION_ID = "session-v2@labfy.org"
APP_IDS = {identity for spec in DEFAULT if spec.application_id == "firefox" for identity in spec.app_ids}
# Seules les webapps explicitement recensées et gérées par l’autostart Sway
# sont exclues du provider UUID Firefox. Aucune découverte dynamique ici.
WEBAPP_IDS = {identity for spec in DEFAULT
if spec.application_id == "whatsapp-web" and spec.strategy == "managed-autostart"
and spec.managed_by == "sway-autostart" for identity in spec.app_ids}
if spec.strategy == "managed-autostart" and spec.managed_by == "sway-autostart"
for identity in spec.app_ids if identity.startswith("firefox.webapp-")}
def validate(message):

View file

@ -482,12 +482,22 @@
},
"children": {
"type": "array",
"minItems": 2,
"minItems": 1,
"maxItems": 32,
"items": {
"$ref": "#/$defs/tree"
}
}
},
"if": {
"properties": {
"children": { "maxItems": 1 }
}
},
"then": {
"properties": {
"layout": { "enum": ["tabbed", "stacked"] }
}
}
}
]

View file

@ -115,7 +115,9 @@ def validate(data):
fields(node, "layout children")
require(type(node["layout"]) is str and node["layout"] in LAYOUTS, "TREE_LAYOUT")
array(node["children"], 32)
require(len(node["children"]) >= 2 and all(c is not None for c in node["children"]), "TREE_ARITY")
count = len(node["children"])
unary_group = (data["version"] == 2 and count == 1 and node["layout"] in ("tabbed", "stacked"))
require((count >= 2 or unary_group) and all(c is not None for c in node["children"]), "TREE_ARITY")
for i, c in enumerate(node["children"]): tree(c, workspace, path + (i,))
for w in data["workspaces"]:
fields(w, "workspace output root")

View file

@ -69,9 +69,12 @@ def capture(sway, catalog, provider, reason="manual"):
if n.get("app_id") or n.get("window"): return leaf(n, workspace, path, False)
children = n.get("nodes", [])
if not children: return None
if len(children) == 1:
require(n["layout"] in ("splith", "splitv", "none"), "SINGLE_CHILD_LAYOUT_UNSUPPORTED")
# Sway peut conserver un onglet ou une pile avec une seule fenêtre.
# Ne normaliser que les parents splith/splitv/none : les conteneurs
# tabbed/stacked unaires portent un choix de présentation à restaurer.
if len(children) == 1 and n["layout"] in ("splith", "splitv", "none"):
return convert(children[0], workspace, path)
require(n["layout"] in ("splith", "splitv", "tabbed", "stacked"), "TREE_LAYOUT")
return {"layout": n["layout"], "children": [convert(c, workspace, path + (i,)) for i, c in enumerate(children)]}
outputs = []
for output in tree.get("nodes", []):

View file

@ -18,11 +18,13 @@ exec python3 ~/.config/sway/scripts/inactive-windows-transparency.py --opacity $
assign [app_id="limusic-app"] workspace 10
assign [app_id="firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded"] workspace 9
assign [app_id="firefox.webapp-40f22022-70af-4784-81ee-100e6a56dc50"] workspace 8
# =================================================
# Lancement des applications
# =================================================
exec "/usr/lib/firefox/firefox" "-taskbar-tab" "fcd72ebe-94c5-4209-b3f6-bbaa7784cded" "-new-window" "https://web.whatsapp.com/" "-container" "0"
exec "/usr/lib/firefox/firefox" "-taskbar-tab" "40f22022-70af-4784-81ee-100e6a56dc50" "-new-window" "https://pic.labfytools.com/" "-container" "0"
exec limusic-app
exec autotiling

View file

@ -213,7 +213,7 @@ bindsym $mod+Alt+i exec ~/.local/bin/vmware-on-demand
bindsym $mod+Alt+f exec firefox
bindsym $mod+Alt+a exec AppFlowy
bindsym $mod+Alt+g exec gimp
bindsym Mod1+m exec ~/.config/sway/scripts/applications-quickshell
bindsym $mod+m exec ~/.config/sway/scripts/applications-quickshell
bindsym $mod+Alt+m exec limusic-app
bindsym $mod+Alt+b exec blueman-manager
bindsym $mod+Alt+o exec /home/fy59/.local/bin/labfy-lock

View file

@ -0,0 +1,7 @@
[Service]
Environment=TRAINLOG_TOOLS_DIR=/home/fy59/.local/share/trainlog/rollouts/sync-capacity-recovery-v1-292bcc8/tools
Environment=TRAINLOG_SYNC_TOOLS_DIR=/home/fy59/.local/share/trainlog/rollouts/sync-capacity-recovery-v1-292bcc8/tools
Environment=TRAINLOG_SYNC_BT_ADAPTER=/home/fy59/.local/share/trainlog/rollouts/program-recalage-v1-3a4ec34/desktop/libexec/trainlog-generation-bt-adapter
Environment=TRAINLOG_SYNC_MTP_ADAPTER=/home/fy59/.local/share/trainlog/rollouts/program-recalage-v1-3a4ec34/desktop/libexec/trainlog-generation-mtp-adapter
ExecStart=
ExecStart=/usr/bin/python3 /home/fy59/.local/share/trainlog/rollouts/sync-capacity-recovery-v1-292bcc8/tools/trainlog_syncd.py --sync-once /home/fy59/.local/share/trainlog/rollouts/program-recalage-v1-3a4ec34/desktop/bin/trainlog-sync-once --orchestrator /home/fy59/.local/share/trainlog/rollouts/sync-capacity-recovery-v1-292bcc8/tools/sync_orchestrator.py --interval 3

View file

@ -0,0 +1,7 @@
[Service]
Environment=TRAINLOG_TOOLS_DIR=/home/fy59/.local/share/trainlog/rollouts/sync-capacity-recovery-v1-292bcc8/tools
Environment=TRAINLOG_SYNC_TOOLS_DIR=/home/fy59/.local/share/trainlog/rollouts/sync-capacity-recovery-v1-292bcc8/tools
Environment=TRAINLOG_SYNC_BT_ADAPTER=/home/fy59/.local/share/trainlog/rollouts/sleep-pdf-polish-v1-e454c86/desktop/libexec/trainlog-generation-bt-adapter
Environment=TRAINLOG_SYNC_MTP_ADAPTER=/home/fy59/.local/share/trainlog/rollouts/sleep-pdf-polish-v1-e454c86/desktop/libexec/trainlog-generation-mtp-adapter
ExecStart=
ExecStart=/home/fy59/.local/share/trainlog/rollouts/sleep-pdf-polish-v1-e454c86/desktop/libexec/trainlog -w

View file

@ -0,0 +1,3 @@
[Service]
ExecStart=
ExecStart=%h/.local/share/trainlog/rollouts/brand-android-v1-20261111/desktop/libexec/trainlog -w

View file

@ -2,6 +2,7 @@
"""Private Sway only; production Dock/Menu, real Python bridge and input paths."""
import json
import os
import shutil
from pathlib import Path
import subprocess
import sys
@ -13,7 +14,8 @@ 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'
ARTIFACTS = Path(os.environ.get('LABFY_DOCK_TEST_ARTIFACTS',
str(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']
@ -34,6 +36,10 @@ class DockLab(Lab):
with DockLab() as lab:
proxy=IpcProxy(lab.directory/'dock-ipc.sock',lab.socket)
lab.env['SWAYSOCK']=str(lab.directory/'dock-ipc.sock')
# Capture helper in private HOME, bound only to the private Wayland socket.
capture_tool=Path(lab.env['HOME'])/'.local/libexec/labfy-wlthumbs'
capture_tool.parent.mkdir(parents=True,exist_ok=True)
shutil.copy2(Path.home()/'.local/libexec/labfy-wlthumbs',capture_tool)
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)))
@ -80,6 +86,8 @@ ShellRoot {
function state(): string {
return JSON.stringify({ready:A.FavoritesService.ready, ids:A.FavoritesService.ids,
error:A.FavoritesService.error, phase:dock.phase, revealed:dock.revealed,
separators:A.FavoritesService.separators,
dividerIds:Array.from(dock.contentItemRow.children).filter(t=>t.modelData && t.modelData.kind).map(t=>t.modelData.id),
entries:dock.entries, theme:A.FavoritesService.iconTheme,
menu:menu.active ? menu.item.favoriteIds : null,
searchFocused:menu.active && menu.item.searchFocused,
@ -87,6 +95,26 @@ ShellRoot {
gridFocused:menu.active && menu.item.gridFocused,
screenWidth:dock.screen.width,
bodyY:dock.surfaceBody.y, width:dock.width, pointerX:dock.pointerX,
bodyX:dock.surfaceBody.x, bodyWidth:dock.surfaceBody.width,
sensorWidth:dock.edgeArea.width,
bodyMaskWidth:dock.bodyMaskRegion.width,
dismissMaskWidth:dock.dismissMaskRegion.width,
contextMaskWidth:dock.contextMaskRegion.width,
previewMaskWidth:dock.previewMaskRegion.width,
previewVisible:dock.previewVisible,
pendingPreviewId:dock.pendingPreviewId,
previewArmRunning:dock.previewArmRunning,
previewGeneration:dock.previewGeneration,
previewWindowIds:dock.previewEntry ? dock.previewEntry.windows.map(w=>w.id) : [],
previewCardCount:Array.from(dock.previewCardsRow.children).filter(c=>c.windowData).length,
previewImageCount:Object.keys(dock.previewImages).length,
hoveredTiles:Array.from(dock.contentItemRow.children)
.filter(t=>t.modelData && t.children[3] && t.children[3].containsMouse)
.map(t=>t.modelData.id),
dockPressed:dock.pressed,
dotCounts:Array.from(dock.contentItemRow.children)
.filter(t=>t.modelData && !t.modelData.kind)
.map(t=>({id:t.modelData.id,dots:t.dotCount})),
tileWidth:dock.contentItemRow.children[0] ? dock.contentItemRow.children[0].width : 0,
tiles:Array.from(dock.contentItemRow.children).filter(t => t.modelData).map(t => ({
@ -94,7 +122,9 @@ ShellRoot {
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});
context:!!dock.contextEntry,
contextAlpha:dock.contextCard.color.a,
dismissVisible:dock.contextDismissArea.visible});
}
function startFrames(): void {
sweepFrames.intervals=[]; sweepFrames.origin=dock.width;
@ -107,7 +137,20 @@ ShellRoot {
geometryChanged:sweepFrames.geometryChanged,imageChanged:sweepFrames.imageChanged});
}
function edge(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width/2, 2); }
function edgeLeft(): void { input.mouseMove(dock.edgeArea, 2, 2); }
function edgeRight(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width-3, 2); }
function body(): void { input.mouseMove(dock.surfaceBody, 35, 70); }
function hoverPinned(id:string): void {
const tile=Array.from(dock.contentItemRow.children).find(t=>t.modelData && t.modelData.id===id);
if(!tile) throw new Error('No tile '+id);
input.mouseMove(tile.children[3],tile.width/2,65);
}
function hoverPreview(): void { input.mouseMove(dock.previewPopupItem,25,30); }
function clickPreview(index:int): void {
const cards=Array.from(dock.previewCardsRow.children).filter(c=>c.windowData);
if(index<0 || index>=cards.length) throw new Error('No preview card');
input.mouseClick(cards[index],cards[index].width/2,cards[index].height/2);
}
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);
@ -116,6 +159,18 @@ ShellRoot {
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 addSeparatorAt(position:int): void { A.FavoritesService.addSeparator(position); }
function moveSeparatorTo(id:string,position:int): void { A.FavoritesService.moveSeparator(id,position); }
function removeSeparator(id:string): void { A.FavoritesService.removeSeparator(id); }
function openSeparatorMenu(): void {
const tile=Array.from(dock.contentItemRow.children).find(t=>t.modelData && t.modelData.kind==="separator");
if (!tile) throw new Error("missing separator tile");
input.mouseClick(tile,tile.width/2,65,Qt.RightButton);
}
function removeSeparatorFromMenu(): void {
const action=input.findChild(dock.contextCard,"dock-remove-separator");
input.mouseClick(action,action.width/2,action.height/2);
}
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); }
@ -126,6 +181,11 @@ ShellRoot {
function rightClick(): void {
input.mouseClick(dock.contentItemRow.children[0],27,65,Qt.RightButton);
}
function hoverContext(): void { input.mouseMove(dock.contextCard,15,15); }
function clickAside(): void {
const area=dock.contextDismissArea;
input.mouseClick(area,5,Math.min(95,area.height-8));
}
function dragFirstAfterSecond(): void {
const first=dock.contentItemRow.children[0];
input.mousePress(first,27,65,Qt.LeftButton);
@ -133,7 +193,7 @@ ShellRoot {
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 context(): void { dock.openContext(dock.entries[0],dock.surfaceBody.width/2); }
function closeContext(): void { dock.closeContext(); }
function suppress(value:bool): void { dock.suppressed=value; }
function focus(id:string): void { A.FavoritesService.focusWindow(id); }
@ -159,10 +219,69 @@ ShellRoot {
time.sleep(1.2)
initial=wait(lambda s:s['ready'])
assert initial['ids']==NINE and initial['phase']=='hidden', initial
# Regression: hidden popup areas must NOT subtract holes from the
# four-pixel sensor (especially at the centered dock position).
assert initial['sensorWidth']==initial['screenWidth'], initial
assert initial['previewMaskWidth']==0 and not initial['previewVisible'],initial
assert all(initial[key]==0 for key in
('bodyMaskWidth', 'dismissMaskWidth', 'contextMaskWidth')), 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')
# Separator mutations must be durable and visible to the same live model.
call('edge'); wait(lambda s:s['revealed'])
call('addSeparatorAt',2)
state=wait(lambda s:len(s['separators'])==1 and len(s['dividerIds'])==1)
separator_id=state['separators'][0]['id']
assert state['separators'][0]['position']==2,state
assert state['dividerIds']==[separator_id],state
separator_file=Path(lab.env['XDG_STATE_HOME'])/'labfy-sway/dock-separators.json'
assert json.loads(separator_file.read_text())['separators'][0]['id']==separator_id
call('moveSeparatorTo',separator_id,3)
wait(lambda s:s['separators'][0]['position']==3)
call('openSeparatorMenu')
wait(lambda s:s['context'] and s['phase']=='held')
call('removeSeparatorFromMenu')
wait(lambda s:not s['separators'] and not s['dividerIds'] and not s['context'])
assert json.loads(separator_file.read_text())['separators']==[]
call('leave'); wait(lambda s:s['phase']=='hidden')
# Translucent menu; hover keeps it open, clicking beside closes at once;
# moving away allows a short grace without a visible close button.
call('edge'); wait(lambda s:s['revealed'])
call('rightClick')
opened=wait(lambda s:s['context'])
assert opened['dismissVisible'],opened
assert opened['dismissMaskWidth'] > 0 and opened['contextMaskWidth'] > 0, opened
assert abs(opened['contextAlpha'] - .85) < .01,opened
call('hoverContext'); time.sleep(.72)
assert call('state')['context'],call('state')
call('clickAside')
after_close=wait(lambda s:not s['context'])
assert after_close['dismissMaskWidth']==0 and after_close['contextMaskWidth']==0, after_close
call('edge'); wait(lambda s:s['revealed'])
call('rightClick'); wait(lambda s:s['context'])
call('leave'); wait(lambda s:not s['context'],2)
hidden_after_popup=wait(lambda s:s['phase']=='hidden')
assert all(hidden_after_popup[key]==0 for key in
('bodyMaskWidth', 'dismissMaskWidth', 'contextMaskWidth')), hidden_after_popup
# A fresh center-edge hover must work after dismissing a popup.
call('edge')
center=wait(lambda s:s['revealed'])
assert center['sensorWidth']==center['screenWidth'], center
call('leave'); wait(lambda s:s['phase']=='hidden')
# Every horizontal position of the four-pixel strip must arm the same
# centered dock, including both physical screen corners.
assert initial['width']==initial['screenWidth'], initial
assert initial['sensorWidth']==initial['screenWidth'], initial
assert initial['bodyWidth']<initial['width'], initial
assert abs(initial['bodyX']*2 + initial['bodyWidth']-initial['width'])<1.1, initial
for corner in ('edgeLeft', 'edgeRight'):
call(corner)
revealed=wait(lambda s:s['revealed'])
assert revealed['sensorWidth']==revealed['screenWidth'], revealed
assert abs(revealed['bodyX']*2+revealed['bodyWidth']-revealed['width'])<1.1, revealed
call('leave'); wait(lambda s:s['phase']=='hidden')
call('edge'); wait(lambda s:s['revealed']); time.sleep(.35)
capture('open')
resting=call('state')
@ -264,18 +383,49 @@ ShellRoot {
assert str(focused())==firewin
for action in ('edge','body','leave'):
call(action); time.sleep(.25); assert str(focused())==firewin
# Three real test windows must show three dots and three selectable cards.
extra1=lab.kitty('kitty')
extra2=lab.kitty('kitty')
grouped=wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==3 for e in s['entries']))
dots=next(t for t in grouped['dotCounts'] if t['id']=='kitty.desktop')
assert dots['dots']==3,grouped
focus_before=focused()
call('edge'); wait(lambda s:s['revealed'])
call('hoverPinned','kitty.desktop')
peek=wait(lambda s:s['previewVisible'] and s['previewCardCount']==3,12)
assert peek['previewMaskWidth']>0 and len(peek['previewWindowIds'])==3,peek
assert focused()==focus_before,'Hover changed focus'
call('hoverPreview'); time.sleep(.8)
active_preview=call('state')
assert active_preview['previewVisible'],'Preview must stay open on hover'
captured=wait(lambda s:s['previewImageCount']>=1,5)
assert captured['previewImageCount'] <= 3,captured
print('PRIVATE_CAPTURED_PREVIEWS=',captured['previewImageCount'])
target=int(peek['previewWindowIds'][0])
call('clickPreview',0)
wait(lambda s:not s['previewVisible'])
for _ in range(20):
if focused()==target:break
time.sleep(.1)
assert focused()==target,(focused(),target)
call('leave'); wait(lambda s:s['phase']=='hidden')
cache=Path(lab.env['XDG_RUNTIME_DIR'])/'labfy-dock-window-thumbnails'
for _ in range(30):
if not cache.exists() or not list(cache.iterdir()):break
time.sleep(.15)
assert not cache.exists() or not list(cache.iterdir()),cache
# 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
assert resized['width']==800 and resized['bodyWidth']<=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
assert end['tiles'][-1]['scale']>1 and end['tiles'][-1]['visualRight']<=end['bodyWidth']-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
@ -290,7 +440,8 @@ ShellRoot {
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',
'empty restart','autohide','full-width left/right edge activation','centered visual dock',
'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)

View file

@ -50,4 +50,21 @@ 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');
const dockModel=load('dock/DockModel.js');
const pins=[{id:'a.desktop',pinned:true},{id:'b.desktop',pinned:true},{id:'c.desktop',pinned:true}];
const live=[{id:'running.desktop',pinned:false}];
let laid=dockModel.entries(pins.concat(live),[{id:'sep-abcdefabcdef',position:1}]);
assert.equal(Array.from(laid, x=>x.id).join('|'),
'a.desktop|sep-abcdefabcdef|b.desktop|c.desktop|dock-running-boundary|running.desktop');
assert.equal(laid[1].kind,'separator');
assert.equal(laid[4].kind,'automatic-separator');
assert.equal(dockModel.entries(pins,[]).length,3);
laid=dockModel.entries(pins,[{id:'sep-abcdefabcdef',position:9}]);
assert.equal(laid.at(-1).kind,'separator');
assert.equal(dockModel.hasSeparator([{position:1}],1),true);
assert.equal(dockModel.hasSeparator([],1),false);
assert.equal(dockModel.moveTarget(['a.desktop','b.desktop','c.desktop'],'b.desktop',-1).before,'a.desktop');
assert.equal(dockModel.moveTarget(['a.desktop','b.desktop','c.desktop'],'b.desktop',1).before,'');
assert.equal(dockModel.moveTarget(['a.desktop','b.desktop','c.desktop'],'a.desktop',-1),null);
assert.equal(dockModel.moveTarget(['a.desktop','b.desktop','c.desktop'],'c.desktop',1),null);
console.log('Dock state, separators and window models: PASS');

View file

@ -0,0 +1,82 @@
"""Separators persist independently of the v1 favorites file."""
import concurrent.futures
import json
from pathlib import Path
import sys
import tempfile
import unittest
APP = Path(__file__).resolve().parents[2] / "quickshell/.config/quickshell/labfy-sway/applications"
sys.path.insert(0, str(APP))
import backend
import dock_separators as sep
class SeparatorPersistence(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.favorites = Path(self.temp.name) / "labfy-sway/applications-favorites.json"
backend.write_favorites(self.favorites, "seed",
["a.desktop", "b.desktop", "c.desktop"])
self.original = self.favorites.read_bytes()
self.path = sep.path_for(self.favorites)
def test_add_move_delete_durable(self):
self.assertEqual(sep.read(self.path), [])
first = sep.write(self.path, "add", [1])
self.assertEqual(len(first), 1)
self.assertEqual(first[0]["position"], 1)
identifier = first[0]["id"]
self.assertRegex(identifier, r"^sep-[0-9a-f]{12}$")
self.assertEqual(sep.read(self.path), first)
self.assertEqual(sep.write(self.path, "move", [identifier, 2])[0]["position"], 2)
second = sep.write(self.path, "add", [0])
self.assertEqual([x["position"] for x in second], [0, 2])
self.assertEqual(sep.write(self.path, "remove", [identifier])[0]["position"], 0)
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
self.assertEqual(self.favorites.read_bytes(), self.original)
def test_invalid_actions_never_replace_previous_state(self):
entry = sep.write(self.path, "add", [1])[0]
before = self.path.read_bytes()
invalid = [("add", [1]), ("add", [-1]), ("add", [True]),
("add", ["1"]), ("add", [65]), ("move", [entry["id"], 65]),
("move", ["sep-000000000000", 3]), ("move", [entry["id"], False]),
("remove", ["../escape"]), ("remove", ["sep-000000000000"]),
("invalid", [])]
for action, value in invalid:
with self.subTest(action=action, value=value), self.assertRaises(ValueError):
sep.write(self.path, action, value)
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(self.favorites.read_bytes(), self.original)
def test_corrupt_and_symlink_state_refuse_overwrite(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text('{"version":1,"separators":[{"id":"invalid","position":2}]}')
before = self.path.read_bytes()
with self.assertRaises(ValueError):
sep.write(self.path, "add", [1])
self.assertEqual(self.path.read_bytes(), before)
self.path.unlink()
self.path.symlink_to(self.favorites)
with self.assertRaises(ValueError):
sep.write(self.path, "add", [1])
self.assertEqual(self.favorites.read_bytes(), self.original)
def test_concurrent_changes_stay_atomic_and_bounded(self):
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
items = list(pool.map(lambda i: sep.write(self.path, "add", [i]),
range(10)))
self.assertEqual(len(items), 10)
self.assertEqual(len(sep.read(self.path)), 10)
for index in range(10, 16):
sep.write(self.path, "add", [index])
with self.assertRaisesRegex(ValueError, "Trop de séparateurs"):
sep.write(self.path, "add", [16])
self.assertEqual(len(sep.read(self.path)), 16)
self.assertEqual(self.favorites.read_bytes(), self.original)
if __name__ == "__main__":
unittest.main()

View file

@ -92,12 +92,16 @@ class Icons(unittest.TestCase):
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')])])
dict(id=42,window=3,window_properties={'class':'Fixture','instance':'fixture'},
foreign_toplevel_identifier='a'*32,name='Arbitrary'),
dict(id=43,app_id='kitty',foreign_toplevel_identifier='../invalid',
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')
self.assertEqual(windows[0]['foreignId'],'a'*32)
self.assertEqual(windows[1]['foreignId'],'')
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'),

View file

@ -0,0 +1,76 @@
"""Private, one-shot thumbnail contract; never grabs a desktop during tests."""
import importlib.util
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest import mock
SOURCE = (Path(__file__).resolve().parents[2] /
"quickshell/.config/quickshell/labfy-sway/bin/dock-thumbnails.py")
spec = importlib.util.spec_from_file_location("labfy_dock_thumbnails", SOURCE)
thumbs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(thumbs)
class ThumbnailContract(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
env = mock.patch.dict(os.environ, XDG_RUNTIME_DIR=self.temp.name)
env.start()
self.addCleanup(env.stop)
self.a = {"id": "51", "foreignId": "a" * 32}
self.b = {"id": "52", "foreignId": "b" * 32}
def test_strict_window_ids_and_limit(self):
valid = thumbs.validate(json.dumps([self.a, self.b]))
self.assertEqual(valid, [("51", "a" * 32), ("52", "b" * 32)])
for values in ([], [self.a] * 2, [self.a] * 9,
[{"id": "../../x", "foreignId": "a" * 32}],
[{"id": "51", "foreignId": "../target"}],
[{"id": "51", "foreignId": "A" * 32}],
[{"id": 51, "foreignId": "a" * 32}],
[{"id": "51", "foreignId": "a" * 32, "title": "private"}]):
with self.subTest(rows=values), self.assertRaises(ValueError):
thumbs.validate(json.dumps(values))
def test_approved_ids_only_private_files_and_cleanup(self):
calls = []
def runner(argv, **kwargs):
calls.append((argv, kwargs))
folder = Path(argv[argv.index("-dir") + 1])
self.assertTrue(folder.is_dir())
self.assertEqual(folder.parent, thumbs.cache_root())
self.assertEqual(folder.parent.stat().st_mode & 0o777, 0o700)
self.assertEqual(argv[argv.index("-ids")+1], "a" * 32 + "," + "b" * 32)
self.assertEqual(argv[argv.index("-size")+1], "224")
for ident in ("a" * 32, "b" * 32):
(folder / (ident + ".png")).write_bytes(thumbs.PNG_MAGIC + b"specimen")
return mock.Mock(returncode=0)
with mock.patch.object(thumbs.subprocess, "run", side_effect=runner):
result = thumbs.capture(json.dumps([self.a, self.b]), tool="/mock/trusted-capture")
self.assertEqual(set(result["images"]), {"51", "52"})
self.assertEqual(result["error"], "")
self.assertEqual(len(calls), 1)
self.assertTrue(all(uri.startswith(thumbs.cache_root().as_uri())
for uri in result["images"].values()))
thumbs.purge(thumbs.cache_root(), all_dirs=True)
self.assertEqual(list(thumbs.cache_root().iterdir()), [])
def test_failure_does_not_leak_files_or_capture_other_windows(self):
with mock.patch.object(thumbs.subprocess, "run", return_value=mock.Mock(returncode=3)):
self.assertEqual(thumbs.capture(json.dumps([self.a]), tool="/mock/fail")["images"], {})
self.assertEqual(list(thumbs.cache_root().iterdir()), [])
def test_invalid_and_symlink_cache_refused(self):
root = thumbs.cache_root()
root.rmdir()
root.symlink_to(Path(self.temp.name))
with self.assertRaises(ValueError):
thumbs.cache_root()
if __name__ == "__main__":
unittest.main()

View file

@ -16,6 +16,7 @@ REQUEST = {"op": "bootstrap-eligibility"}
MAP = {"op": "map-window", "uuid": "11111111-1111-4111-8111-111111111111", "runtime_token": "a" * 32}
CLASSIFY = {"op": "classify-window", "runtime_token": MAP["runtime_token"]}
WEBAPP = "firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded"
IMMICH_WEBAPP = "firefox.webapp-40f22022-70af-4784-81ee-100e6a56dc50"
def surface(app_id="firefox", con_id=42):
@ -285,9 +286,9 @@ class BootstrapTests(unittest.TestCase):
def test_classify_exact_catalogue_and_no_uuid_publication(self):
self.assertEqual(APP_IDS, {"firefox", "org.mozilla.firefox"})
self.assertEqual(WEBAPP_IDS, {WEBAPP})
self.assertEqual(WEBAPP_IDS, {WEBAPP, IMMICH_WEBAPP})
for app_id, kind in (("firefox", "browser"), ("org.mozilla.firefox", "browser"),
(WEBAPP, "managed-webapp")):
(WEBAPP, "managed-webapp"), (IMMICH_WEBAPP, "managed-webapp")):
with self.subTest(app_id=app_id):
code, answers = self.wire(frame(CLASSIFY), serve=True, nodes=[surface(app_id)])
self.assertEqual(code, 0)
@ -358,7 +359,9 @@ class BootstrapTests(unittest.TestCase):
self.assertEqual(caught.exception.code, "WINDOW_CLASS_UNSUPPORTED")
def test_classify_does_not_extend_map_to_webapp(self):
code, answers = self.wire(frame(CLASSIFY) + frame(MAP), serve=True, nodes=[surface(WEBAPP)])
for webapp in (WEBAPP, IMMICH_WEBAPP):
with self.subTest(webapp=webapp):
code, answers = self.wire(frame(CLASSIFY) + frame(MAP), serve=True, nodes=[surface(webapp)])
self.assertEqual(code, 0)
self.assertEqual(answers, [{"result": "OK", "kind": "managed-webapp",
"runtime_token": CLASSIFY["runtime_token"], "con_id": 42},

View file

@ -0,0 +1,87 @@
"""TEST_ONLY: late ChatGPT X11 window remapping during prewarm.
An old con_id may be replaced before the transaction. Never admit the
transient leaf, and never relax identities after Recovery starts.
"""
import copy
import time
import unittest
from unittest.mock import Mock
from common import *
from session_v2.executor import Executor
from session_v2.errors import Failure
class StartupX11StabilityTests(unittest.TestCase):
@staticmethod
def state(ident, app, ws, pid):
return {
"con_id": ident, "app_id": app, "pid": pid,
"starttime": "123", "workspace": ws, "output": "HEADLESS-1",
"path": [0], "floating": False, "geometry": None,
"scratchpad": "none",
}
def setUp(self):
self.sway = Mock(session="a" * 64)
self.engine = Executor.__new__(Executor)
self.engine.sway = self.sway
self.base = {5: self.state(5, "limusic-app", "10", 500)}
self.spec = Spec("chatgpt", "chatgpt.desktop",
("x11-class:Chatgpt",), "single-window",
("/usr/bin/chatgpt",))
self.allowed = {"limusic-app", "x11-class:Chatgpt"}
def test_late_remap_is_reobserved_before_pinning(self):
started = time.monotonic()
def sample():
elapsed = time.monotonic() - started
con_id = 17 if elapsed < .07 else 32
return {**copy.deepcopy(self.base),
con_id: self.state(con_id, "x11-class:Chatgpt", "1", 753170)}
self.engine._observe_prepared = sample
result = self.engine._settle_fresh_chatgpt(
self.base, self.spec, self.sway.session, self.allowed,
.7, minimum_age=.22, stable_for=.09)
self.assertIn(32, result)
self.assertNotIn(17, result)
self.sway.command.assert_not_called()
def test_preexisting_client_change_is_never_accepted(self):
def sample():
d = copy.deepcopy(self.base)
d[5]["pid"] = 501
d[17] = self.state(17, "x11-class:Chatgpt", "1", 753170)
return d
self.engine._observe_prepared = sample
with self.assertRaisesRegex(Failure, "TARGET_OBSERVATION_CHANGED"):
self.engine._settle_fresh_chatgpt(
self.base, self.spec, self.sway.session, self.allowed,
.4, minimum_age=.1, stable_for=.05)
def test_unknown_new_application_is_not_admitted(self):
self.engine._observe_prepared = lambda: {
**copy.deepcopy(self.base),
17: self.state(17, "x11-class:Chatgpt", "1", 753170),
99: self.state(99, "intruder", "2", 999),
}
with self.assertRaisesRegex(Failure, "APPLICATION_MEMBERSHIP_CHANGED"):
self.engine._settle_fresh_chatgpt(
self.base, self.spec, self.sway.session, self.allowed,
.3, minimum_age=.1, stable_for=.05)
def test_incomplete_settle_deferred_before_transaction(self):
self.engine._observe_prepared = lambda: {
**copy.deepcopy(self.base),
17: self.state(17, "x11-class:Chatgpt", "1", 753170),
}
with self.assertRaisesRegex(Failure, "APPLICATION_WINDOWS_INCOMPLETE"):
self.engine._settle_fresh_chatgpt(
self.base, self.spec, self.sway.session, self.allowed,
.08, minimum_age=.3, stable_for=.05)
self.sway.command.assert_not_called()
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,95 @@
"""Regression: unary tabbed/stacked Sway containers are restorable V2 nodes.
No commands are sent to the user's active compositor.
"""
import copy
import json
import tempfile
import unittest
from common import *
from test_prewarm import Sway
from session_v2.snapshot import capture
from session_v2.ipc import workspace, canonical
from session_v2.schema import validate
from session_v2.layout import compile_plan
from session_v2.checkpoint import Store
from session_v2.errors import Failure
class UnaryLayoutTests(unittest.TestCase):
def sample(self, layout):
sway = Sway()
ws = workspace(sway.value, "1")
workspace(sway.value, "10")["layout"] = "splith"
ws["layout"] = "splith"
first = sway.add("kitty", 7, focus=True)
second = sway.add("proton-pass", 8, focus=False)
ws["nodes"] = [
first,
{"id": 70, "type": "con", "layout": layout, "nodes": [second]},
]
return sway
def test_unary_tabbed_and_stacked_capture_schema_and_persistence(self):
for layout in ("tabbed", "stacked"):
with self.subTest(layout=layout):
sway = self.sample(layout)
before = sway.tree()
data = capture(sway, Catalog(), None)
root = next(w["root"] for w in data["workspaces"] if w["workspace"] == "1")
self.assertEqual(root["layout"], "splith")
group = root["children"][1]
self.assertEqual(group["layout"], layout)
self.assertEqual(len(group["children"]), 1)
slot = group["children"][0]["slot_id"]
self.assertEqual(next(s["tree_path"] for s in data["window_slots"]
if s["slot_id"] == slot), [1, 0])
self.assertEqual(validate(data), data)
self.assertEqual(sway.tree(), before)
plan = compile_plan(data, "a" * 32)
self.assertTrue(any(op.kind == "layout" and op.layout == layout and
op.parent == (1,) for op in plan))
mapping = {7: root["children"][0]["slot_id"], 8: slot}
self.assertEqual(canonical(before, "1", mapping),
{"splith": [root["children"][0]["slot_id"],
{layout: [slot]}]})
with tempfile.TemporaryDirectory() as path:
store = Store(Path(path))
store.save("unary", data)
self.assertEqual(store.load("unary"), data)
try:
import jsonschema
except ImportError:
continue
schema = json.loads((BACKEND / "session_v2/schema-v2.json").read_text())
jsonschema.Draft202012Validator.check_schema(schema)
jsonschema.Draft202012Validator(schema).validate(data)
def test_unary_split_and_none_remain_normalized(self):
for layout in ("splith", "splitv", "none"):
with self.subTest(layout=layout):
sway = self.sample(layout)
data = capture(sway, Catalog(), None)
root = next(w["root"] for w in data["workspaces"] if w["workspace"] == "1")
self.assertIn("slot_id", root["children"][1])
self.assertEqual(len(data["window_slots"]), 3)
def test_invalid_unary_split_stays_rejected(self):
data = capture(self.sample("tabbed"), Catalog(), None)
root = next(w["root"] for w in data["workspaces"] if w["workspace"] == "1")
root["children"][1]["layout"] = "splitv"
with self.assertRaisesRegex(Failure, "TREE_ARITY"):
validate(data)
try:
import jsonschema
except ImportError:
return
from jsonschema import ValidationError
contract = json.loads((BACKEND / "session_v2/schema-v2.json").read_text())
with self.assertRaises(ValidationError):
jsonschema.validate(data, contract)
if __name__ == "__main__":
unittest.main()

View file

@ -16,6 +16,7 @@ from session_v2.snapshot import capture
KEY = X11_CLASS_PREFIX + "Chatgpt"
WEBAPP = "firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded"
IMMICH_WEBAPP = "firefox.webapp-40f22022-70af-4784-81ee-100e6a56dc50"
class Sway(BaseSway):
@ -114,9 +115,18 @@ class XwaylandTests(unittest.TestCase):
engine = Executor(sway, Catalog(), None, Path(temporary), "/forbidden", process_identity=lambda pid: 123)
calls = []
def launch(spec, count, env):
calls.append((spec.application_id, count)); sway.x11(9); return []
with patch("session_v2.executor.launch", side_effect=launch), patch.object(engine, "_apply", return_value="admitted"):
calls.append((spec.application_id, count)); sway.x11(9); return [object()]
def settled(baseline, spec, session, allowed, timeout):
self.assertEqual(spec.application_id, "chatgpt")
self.assertEqual(session, sway.session)
self.assertNotIn(9, baseline)
self.assertIn(KEY, allowed)
return engine._observe_prepared()
with patch("session_v2.executor.launch", side_effect=launch), \
patch.object(engine, "_settle_fresh_chatgpt", side_effect=settled) as gate, \
patch.object(engine, "_apply", return_value="admitted"):
self.assertEqual(engine.apply(data, True), "admitted")
gate.assert_called_once()
self.assertEqual(calls, [("chatgpt", 1)])
self.assertFalse((Path(temporary) / JOURNAL).exists())
self.assertEqual(sway.commands, [])
@ -155,18 +165,25 @@ class XwaylandTests(unittest.TestCase):
self.assertEqual((runtime / JOURNAL).read_bytes(), before)
self.assertEqual(sway.commands, [])
def test_whatsapp_autostart_owns_launch_and_unknown_webapp_is_refused(self):
catalog = Catalog(); spec = catalog.classify({"app_id": WEBAPP})
self.assertEqual((spec.strategy, spec.managed_by, spec.argv), ("managed-autostart", "sway-autostart", ()))
def test_explicit_webapps_autostart_owns_launch_and_unknown_is_refused(self):
catalog = Catalog()
for identity, expected_id in ((WEBAPP, "whatsapp-web"), (IMMICH_WEBAPP, "immich-web")):
with self.subTest(identity=identity):
spec = catalog.classify({"app_id": identity})
self.assertEqual(spec.application_id, expected_id)
self.assertEqual((spec.strategy, spec.managed_by, spec.argv),
("managed-autostart", "sway-autostart", ()))
app = dict(application_id=spec.application_id, desktop_entry=spec.desktop_entry,
strategy=spec.strategy, expected_windows=1, managed_by=spec.managed_by, identity_provider=None)
strategy=spec.strategy, expected_windows=1,
managed_by=spec.managed_by, identity_provider=None)
self.assertEqual(catalog.validate(app), spec)
self.assertEqual(launch_count(app, 0), 0)
self.assertEqual(launch_count(app, 1), 0)
with self.assertRaisesRegex(Failure, "UNEXPECTED_EXTRA_WINDOW"):
launch_count(app, 2)
for identity in ("firefox.webapp-unregistered", IMMICH_WEBAPP + "-suffix"):
with self.assertRaisesRegex(Failure, "APPLICATION_UNSUPPORTED"):
catalog.classify({"app_id": "firefox.webapp-unregistered"})
catalog.classify({"app_id": identity})
def test_swap_accepts_exact_x11_target_and_rejects_identity_changes(self):
for change in (None, "class", "pid"):

View file

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""TEST_ONLY: reconstruct nested unary tabbed/stacked under isolated SwayFX.
No IPC goes to the user's Sway socket; all processes are lab-owned children.
"""
from common import *
from lab import Lab, Surfaces, build_helper
from session_v2.ipc import Sway
from session_v2.schema import validate
from session_v2.snapshot import capture
from session_v2.executor import Executor
from session_v2.storage import private_dir
def target(layout):
data = fixture({"splith": ["A", "B"]})
data["version"] = 2
data["focus_workspace"] = "TEST_ONLY_TARGET"
for slot in data["window_slots"]:
slot["state"]["scratchpad"] = "none"
data["workspaces"][0]["root"]["children"][1] = {
"layout": layout, "children": [{"slot_id": "B"}]}
next(s for s in data["window_slots"] if s["slot_id"] == "B")["tree_path"] = [1, 0]
return validate(data)
def main():
with Lab() as lab:
helper = build(lab.directory)
surface_binary = build_helper(lab.directory)
runtime = private_dir(lab.directory / "runtime-v2")
sway = Sway(lab.socket)
for layout in ("tabbed", "stacked"):
data = target(layout)
apps = Surfaces(lab, surface_binary)
lab.command('workspace "TEST_ONLY_SOURCE"')
try:
apps.create("TEST_ONLY_app-A")
apps.create("TEST_ONLY_app-B")
result = Executor(sway, catalog(data), None, runtime,
helper, lab.env).apply(data, True)
saved = capture(sway, catalog(data), None)
root = next(w["root"] for w in saved["workspaces"]
if w["workspace"] == "TEST_ONLY_TARGET")
assert root["layout"] == "splith", root
child = root["children"][1]
assert child["layout"] == layout and len(child["children"]) == 1, root
assert result["status"] == "success", result
print(f"HEADLESS_{layout.upper()}_RECONSTRUCTION=PASS", flush=True)
finally:
apps.close()
if __name__ == "__main__":
main()

View file

@ -12,6 +12,7 @@ from session_v2.snapshot import capture
from session_v2.storage import private_dir
WEBAPP = "firefox.webapp-fcd72ebe-94c5-4209-b3f6-bbaa7784cded"
IMMICH_WEBAPP = "firefox.webapp-40f22022-70af-4784-81ee-100e6a56dc50"
class Lab(BaseLab):
@ -63,12 +64,13 @@ def main():
lab.command('workspace "TEST_ONLY_TARGET"')
chat, chat_process = lab.x11("Chatgpt")
whatsapp = native.create(WEBAPP)
immich = native.create(IMMICH_WEBAPP)
time.sleep(.2)
data = capture(sway, Catalog(), None)
assert {a["application_id"] for a in data["applications"]} == {"chatgpt", "whatsapp-web"}
assert {a["application_id"] for a in data["applications"]} == {"chatgpt", "whatsapp-web", "immich-web"}
assert all(s["identity_requirement"] == "exact" for s in data["window_slots"])
(lab.directory / "snapshot.json").write_text(json.dumps(data, indent=2))
for node in (chat, whatsapp):
for node in (chat, whatsapp, immich):
lab.command(f'[con_id={node["id"]}] move container to workspace "TEST_ONLY_SOURCE"')
lab.command('workspace "TEST_ONLY_FOREIGN"')
foreign, foreign_process = lab.x11("TEST_ONLY_Unknown")
@ -91,7 +93,7 @@ def main():
assert result["final_tree_verified"] and result["final_focus_verified"]
tree = sway.tree()
assert {n["id"] for n in sway.windows()} == before_ids
assert location(tree, chat["id"])[0] == location(tree, whatsapp["id"])[0] == "TEST_ONLY_TARGET"
assert all(location(tree, n["id"])[0] == "TEST_ONLY_TARGET" for n in (chat, whatsapp, immich))
live_foreign = next(n for n in walk(tree) if n["id"] == foreign["id"])
assert observe(tree, live_foreign, process_start) == foreign_before
assert live_foreign["focused"]

View file

@ -42,6 +42,26 @@ class CollectorTests(unittest.TestCase):
self.assertEqual(rows[0]["origin"], "Arch")
self.assertEqual(self.collect(["yay", "-Qua"], 0, "pkg-git 1 -> 2\n")[0]["origin"], "AUR")
def test_aur_relative_age_suffix_keeps_versions_and_count(self):
output = ("proton-pass-bin 1.43.0-1 -> 1.43.1-1 [12h36m]\n"
"example-bin 2:2.0-1 -> 2:2.1-1 [2d3h]\n"
"plain-git 1 -> 2\n")
rows = self.collect(["yay", "-Qua"], 0, output)
self.assertEqual(len(rows), 3)
self.assertEqual(rows[0]["name"], "proton-pass-bin")
self.assertEqual(rows[0]["available"], "1.43.1-1")
self.assertEqual(rows[1]["available"], "2:2.1-1")
self.assertEqual([row["origin"] for row in rows], ["AUR"] * 3)
def test_age_suffix_is_bounded_and_aur_only(self):
for output in ("pkg 1 -> 2 [something went wrong]\n",
"pkg 1 -> 2 [12h36m] extra\n",
"pkg 1 -> 2 [123456789h]\n" * 100):
with self.subTest(output=output[:40]), self.assertRaises(RuntimeError):
self.collect(["yay", "-Qua"], 0, output)
with self.assertRaises(RuntimeError):
self.collect(["checkupdates"], 0, "pkg 1 -> 2 [12h36m]\n")
def test_errors_not_zero(self):
for command, code, stdout, stderr in ((["checkupdates"], 1, "", ""), (["checkupdates"], 2, "", "failure"),
(["yay"], 1, "", "network"), (["yay"], 0, "bad output", "")):

20
tools/build-labfy-wlthumbs.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Build the audited, MIT-licensed, ID-filtered Wayland window snapshot helper.
# User scope only. No sudo, no package installation and no session restart.
set -euo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
src="$root/wlthumbs"
dest="$HOME/.local/libexec"
mkdir -p -- "$dest"
tmp="$(mktemp -- "$dest/.labfy-wlthumbs.XXXXXXXX")"
trap 'rm -f -- "$tmp"' EXIT
(
cd -- "$src"
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o "$tmp" .
)
chmod 755 "$tmp"
mv -f -- "$tmp" "$dest/labfy-wlthumbs"
printf 'Binary installed: %s/labfy-wlthumbs\n' "$dest"
sha256sum "$dest/labfy-wlthumbs"

21
tools/wlthumbs/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Milad Alizadeh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,29 @@
# wlthumbs — copie spécifique au dock Labfy
Source amont MIT : https://github.com/mil-ad/wlthumbs
Commit de référence : `516603c3144078b4d974108b80f7e9622ee4f3c9`
La licence MIT d'origine est conservée dans `LICENSE`.
**Différence intentionnelle :** un argument `-ids` obligatoire (1 à 8
identifiants `foreign_toplevel_identifier` hexadécimaux de 32 caractères),
avec liste blanche appliquée **avant** toute création de source de capture.
La capture des sorties complètes par `-outputs` est refusée dans notre version.
C'est une précaution essentielle : le dock ne doit jamais capturer toutes les
fenêtres quand l'utilisateur survole une seule application.
Ce programme ne surveille ni le bureau ni le pointeur. Il est lancé uniquement
à la demande par `bin/dock-thumbnails.py`, effectue une seule capture par fenêtre
autorisée, puis se termine.
Pour construire et installer dans le répertoire utilisateur :
```sh
~/.dotfiles/tools/build-labfy-wlthumbs.sh
```
Dépendances de compilation : Go (module déclaré dans `go.mod`, versions
figées dans `go.sum`). Pas de `sudo`, aucune modification de SwayFX, ni
de connexion réseau à l'exécution du programme compilé.
Voir `docs/quickshell-dock-window-previews-v1.md` pour les restrictions de
confidentialité, le cache temporaire et les tests.

85
tools/wlthumbs/README.md Normal file
View file

@ -0,0 +1,85 @@
# wlthumbs
A tiny Wayland client that captures thumbnails of every open window (and,
optionally, every monitor) over a **single** Wayland connection, using the
wlroots capture protocols:
- `ext-foreign-toplevel-list-v1` — enumerate windows
- `ext-image-capture-source-v1` — a capture source from a toplevel handle or output
- `ext-image-copy-capture-v1` — copy a frame into a `wl_shm` buffer
It scales each frame in-process and writes a small PNG, printing one
tab-separated row per source:
```
TYPE <TAB> ID <TAB> THUMBPATH <TAB> APP <TAB> TITLE
```
- `TYPE` — `window` or `output`
- `ID` — the window's `foreign_toplevel_identifier` (matches sway's
`foreign_toplevel_identifier`) or the output name (e.g. `DP-1`)
- `THUMBPATH` — path to the PNG, or empty if that source failed to capture
- `APP` — `app_id` (window) or `make model` (output)
- `TITLE` — window title, or output name
It does one thing: dump window/monitor thumbnails for a shell front-end (e.g. a
rofi grid) to consume. It does **not** draw any UI.
## Usage
```
wlthumbs [-size N] [-dir DIR] [-windows] [-outputs]
```
- `-size N` max thumbnail dimension in px (default 256)
- `-dir DIR` output directory (default: a fresh temp dir under `$XDG_RUNTIME_DIR`)
- `-windows` capture windows (default true)
- `-outputs` also capture monitors (default false)
Each thumbnail is named `<ID>.png` (the ID column), so a caller that already
knows the id can find the file directly — `"$dir/$id.png"` — without parsing
stdout; a missing file means that source failed to capture.
The caller owns `-dir`'s lifetime — wlthumbs never deletes thumbnails (a front-end
typically reads them after wlthumbs exits). With no `-dir`, PNGs land in a temp dir
under `$XDG_RUNTIME_DIR` that you should clean up yourself.
Set `WLTHUMBS_TIME=1` to print phase timings to stderr.
Example — thumbnail every window, then feed a rofi grid:
```sh
wlthumbs -size 220 | while IFS=$'\t' read -r type id thumb app title; do
printf '%s\x00icon\x1f%s\n' "$title" "$thumb"
done | rofi -dmenu -show-icons
```
## Building
```
go build -o wlthumbs .
```
Requires a wlroots-based compositor (sway, etc.) advertising the three `ext-*`
protocols above. Pure Go — no cgo.
## Notes
- Captures run sequentially on the one connection (wlroots does not parallelize
them); each frame is scaled/encoded on a worker goroutine while the next window
is captured, so encode cost hides under capture time.
- The protocol bindings in `proto/` are generated from the XML in `protocol/`
and committed. See `generate.sh` to regenerate (and the manual patch it
documents).
- Two go-wayland quirks are worked around in the code: `Registry.Bind` writing a
padded string length (rejected by newer libwayland — see `bind` in
`wayland.go`), and no support for server-allocated objects (see
`proto/register.go`).
## Source layout
- `main.go` — CLI flags, orchestration, phase timing (`WLTHUMBS_TIME`)
- `wayland.go` — the Wayland client: connect/bind globals, enumerate toplevels
and outputs, and capture one frame per source
- `image.go` — shm pixel-format handling, output-transform, scaling, PNG output
- `proto/` — generated protocol bindings (+ `register.go`); see `generate.sh`

39
tools/wlthumbs/generate.sh Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Regenerate the Wayland protocol bindings in proto/ from the XML in protocol/.
#
# The bindings are committed so normal builds need no network or codegen; run
# this only when bumping a protocol. After running, re-apply the manual patch
# noted at the bottom.
set -euo pipefail
cd "$(dirname "$0")"
SCANNER="github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner@latest"
gen() { # <xml> <out> <pkg-suffix-stripped-name>
go run "$SCANNER" -pkg proto -suffix v1 -o "proto/$2" -i "protocol/$1"
}
gen ext-foreign-toplevel-list-v1.xml ext_foreign_toplevel_list.go
gen ext-image-capture-source-v1.xml ext_image_capture_source.go
gen ext-image-copy-capture-v1.xml ext_image_copy_capture.go
cat <<'NOTE'
Bindings regenerated.
MANUAL PATCH REQUIRED (see proto/register.go for the why):
In proto/ext_foreign_toplevel_list.go, the generated Dispatch for the
`toplevel` event does:
e.Toplevel = i.Context().GetProxy(client.Uint32(data[l : l+4])).(*ExtForeignToplevelHandle)
That panics because the toplevel arg is a *server*-allocated new_id that was
never registered client-side. Replace it with:
toplevelID := client.Uint32(data[l : l+4])
handle := &ExtForeignToplevelHandle{}
registerServerObject(i.Context(), toplevelID, handle)
e.Toplevel = handle
Then: gofmt -w proto/*.go && go build ./...
NOTE

9
tools/wlthumbs/go.mod Normal file
View file

@ -0,0 +1,9 @@
module github.com/mil-ad/wlthumbs
go 1.26.5
require (
github.com/rajveermalviya/go-wayland/wayland v0.0.0-20230130181619-0ad78d1310b2
golang.org/x/image v0.44.0
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1
)

6
tools/wlthumbs/go.sum Normal file
View file

@ -0,0 +1,6 @@
github.com/rajveermalviya/go-wayland/wayland v0.0.0-20230130181619-0ad78d1310b2 h1:tRhbehjSCwQSZL7A2AoZlKrDYhZzaPIAcpnhfaUc0Tw=
github.com/rajveermalviya/go-wayland/wayland v0.0.0-20230130181619-0ad78d1310b2/go.mod h1:PXhW/GoWcMBeiZ39ZdgoMs/xduJEEUE+kxUBB2Kwd+M=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1 h1:kwrAHlwJ0DUBZwQ238v+Uod/3eZ8B2K5rYsUHBQvzmI=
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

128
tools/wlthumbs/image.go Normal file
View file

@ -0,0 +1,128 @@
package main
import (
"image"
"image/png"
"os"
"golang.org/x/image/draw"
)
// shmFormat is one 32-bit packed format we know how to read. wlroots advertises
// formats as DRM fourccs (plus the two historical wl_shm enum values 0/1 for
// ARGB/XRGB8888). swapRB is true when the memory byte order is B,G,R,x (so we
// must swap R and B to get RGBA); false when it is already R,G,B,x.
type shmFormat struct {
value uint32
swapRB bool
}
// knownFormats is the preference order for capture buffers. We force opaque
// output, so alpha vs. no-alpha doesn't matter; any entry present is accepted.
var knownFormats = []shmFormat{
{1, true}, // wl_shm XRGB8888 -> B,G,R,x
{0, true}, // wl_shm ARGB8888 -> B,G,R,A
{0x34325258, true}, // DRM XR24 XRGB8888 -> B,G,R,x
{0x34325241, true}, // DRM AR24 ARGB8888 -> B,G,R,A
{0x34324258, false}, // DRM XB24 XBGR8888 -> R,G,B,x
{0x34324241, false}, // DRM AB24 ABGR8888 -> R,G,B,A
}
// chooseFormat picks the first format the compositor offered that we recognize.
func chooseFormat(offered []uint32) (value uint32, swapRB, ok bool) {
for _, kf := range knownFormats {
for _, v := range offered {
if v == kf.value {
return kf.value, kf.swapRB, true
}
}
}
return 0, false, false
}
// writeThumb converts raw pixels to RGBA, applies the output transform, scales
// to fit within `size`, and writes a PNG.
func writeThumb(raw []byte, w, h int, transform uint32, swapRB bool, size int, path string) error {
img := toRGBA(raw, w, h, swapRB)
img = applyTransform(img, transform)
b := img.Bounds()
sw, sh := b.Dx(), b.Dy()
dw, dh := sw, sh
if sw > size || sh > size {
if sw >= sh {
dw, dh = size, size*sh/sw
} else {
dw, dh = size*sw/sh, size
}
}
dw, dh = max(dw, 1), max(dh, 1)
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
draw.BiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
enc := png.Encoder{CompressionLevel: png.BestSpeed}
return enc.Encode(f, dst)
}
// toRGBA converts a packed 32-bit buffer into an opaque image.RGBA. If swapRB is
// set the source byte order is B,G,R,x; otherwise it is already R,G,B,x.
func toRGBA(raw []byte, w, h int, swapRB bool) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
dst := img.Pix
for i := 0; i < w*h; i++ {
j := i * 4
if swapRB {
dst[j+0], dst[j+1], dst[j+2] = raw[j+2], raw[j+1], raw[j+0]
} else {
dst[j+0], dst[j+1], dst[j+2] = raw[j+0], raw[j+1], raw[j+2]
}
dst[j+3] = 255 // force opaque
}
return img
}
// applyTransform re-orients pixels per the wl_output transform enum (0-7). It is
// a no-op for the common "normal" transform.
func applyTransform(src *image.RGBA, t uint32) *image.RGBA {
if t == 0 {
return src
}
b := src.Bounds()
w, h := b.Dx(), b.Dy()
dw, dh := w, h
if t == 1 || t == 3 || t == 5 || t == 7 { // 90/270 rotations swap dimensions
dw, dh = h, w
}
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
for sy := 0; sy < h; sy++ {
for sx := 0; sx < w; sx++ {
var dx, dy int
switch t {
case 1: // 90
dx, dy = sy, w-1-sx
case 2: // 180
dx, dy = w-1-sx, h-1-sy
case 3: // 270
dx, dy = h-1-sy, sx
case 4: // flipped
dx, dy = w-1-sx, sy
case 5: // flipped-90
dx, dy = sy, sx
case 6: // flipped-180
dx, dy = sx, h-1-sy
case 7: // flipped-270
dx, dy = h-1-sy, w-1-sx
}
si := src.PixOffset(sx, sy)
di := dst.PixOffset(dx, dy)
copy(dst.Pix[di:di+4], src.Pix[si:si+4])
}
}
return dst
}

171
tools/wlthumbs/main.go Normal file
View file

@ -0,0 +1,171 @@
// wlthumbs captures thumbnails of every open window (and optionally every
// monitor) over a single Wayland connection using the wlroots capture protocols
// (ext-foreign-toplevel-list-v1, ext-image-capture-source-v1, and
// ext-image-copy-capture-v1). It scales each frame in-process and writes a small
// PNG, printing one TSV row per source:
//
// TYPE \t ID \t THUMBPATH \t APP \t TITLE
//
// where TYPE is "window" or "output". A failed capture still prints its row with
// an empty THUMBPATH so callers can show a label without an icon. The line-
// oriented output is meant to be joined/filtered by a shell caller (e.g. a rofi
// grid front-end).
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
func main() {
log.SetFlags(0)
log.SetPrefix("wlthumbs: ")
size := flag.Int("size", 256, "max thumbnail dimension in px")
dir := flag.String("dir", "", "output directory for thumbnails (default: a temp dir under $XDG_RUNTIME_DIR)")
windows := flag.Bool("windows", true, "capture windows (toplevels)")
outputs := flag.Bool("outputs", false, "capture monitors (outputs)")
ids := flag.String("ids", "", "comma-separated foreign toplevel IDs to capture; required for dock")
flag.Parse()
allowed := make(map[string]bool)
for _, id := range strings.Split(*ids, ",") {
if len(id) != 32 || strings.Trim(id, "0123456789abcdef") != "" {
log.Fatal("invalid foreign toplevel identifier")
}
allowed[id] = true
}
if len(allowed) == 0 || len(allowed) > 8 || *outputs {
log.Fatal("dock capture requires 1-8 window IDs and no output capture")
}
outDir, err := ensureDir(*dir)
if err != nil {
log.Fatal(err)
}
t := newTimer()
a := &app{}
if err := a.connect(); err != nil {
log.Fatal(err)
}
defer a.display.Context().Close()
t.mark("connect")
a.enumerate()
t.mark("enumerate")
sources := a.buildSources(*windows, *outputs, allowed)
t.mark("sources")
a.capture(sources, outDir, *size)
t.mark("capture+encode")
for _, s := range sources {
fmt.Printf("%s\t%s\t%s\t%s\t%s\n", s.typ, s.id, s.thumb, s.app, s.title)
}
}
// buildSources creates a capture source for each requested window/monitor, in a
// stable order (windows then outputs).
func (a *app) buildSources(windows, outputs bool, allowed map[string]bool) []*source {
var sources []*source
if windows {
for _, t := range a.toplevels {
if t.closed || !allowed[t.id] {
continue
}
src, err := a.toplevelSrcMgr.CreateSource(t.handle)
if err != nil {
log.Printf("create window source %q: %v", t.id, err)
continue
}
sources = append(sources, &source{typ: "window", id: t.id, app: t.appID, title: t.title, cap: src})
}
}
if outputs {
for _, o := range a.outputs {
src, err := a.outputSrcMgr.CreateSource(o.wl)
if err != nil {
log.Printf("create output source %q: %v", o.name, err)
continue
}
model := strings.TrimSpace(o.make_ + " " + o.model)
sources = append(sources, &source{typ: "output", id: o.name, app: model, title: o.name, cap: src})
}
}
return sources
}
// capture grabs each source one at a time on the single connection (wlroots does
// not parallelize captures anyway) and hands the frame to a worker pool to scale
// + encode while the next window is captured — so encode cost hides under the
// compositor's capture time, and only one capture session is ever live at once.
func (a *app) capture(sources []*source, outDir string, size int) {
var wg sync.WaitGroup
sem := make(chan struct{}, max(2, runtime.NumCPU()))
for _, s := range sources {
if !a.captureOne(s) {
continue
}
path := filepath.Join(outDir, safeName(s.id)+".png")
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
if err := writeThumb(s.raw, int(s.w), int(s.h), s.transform, s.swapRB, size, path); err != nil {
log.Printf("thumb %s: %v", path, err)
return
}
s.thumb = path
}()
}
wg.Wait()
}
// safeName turns a source id into a filename. Foreign-toplevel ids and output
// names are filename-safe on sway; we only guard against path separators and
// control characters so a caller can build "$dir/$id.png" from the id alone.
func safeName(id string) string {
return strings.Map(func(r rune) rune {
if r == '/' || r < 0x20 {
return '_'
}
return r
}, id)
}
func ensureDir(dir string) (string, error) {
if dir != "" {
return dir, os.MkdirAll(dir, 0o700)
}
base := os.Getenv("XDG_RUNTIME_DIR")
if base == "" {
base = os.TempDir()
}
return os.MkdirTemp(base, "wlthumbs-")
}
// timer prints phase durations to stderr when WLTHUMBS_TIME is set.
type timer struct {
on bool
last time.Time
}
func newTimer() *timer { return &timer{on: os.Getenv("WLTHUMBS_TIME") != "", last: time.Now()} }
func (t *timer) mark(label string) {
if !t.on {
return
}
now := time.Now()
fmt.Fprintf(os.Stderr, "%-12s %6.1fms\n", label, float64(now.Sub(t.last).Microseconds())/1000)
t.last = now
}

View file

@ -0,0 +1,399 @@
// Generated by go-wayland-scanner
// https://github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner
// XML file : protocol/ext-foreign-toplevel-list-v1.xml
//
// ext_foreign_toplevel_list_v1 Protocol Copyright:
//
// Copyright © 2018 Ilia Bozhinov
// Copyright © 2020 Isaac Freund
// Copyright © 2022 wb9688
// Copyright © 2023 i509VCB
//
// Permission to use, copy, modify, distribute, and sell this
// software and its documentation for any purpose is hereby granted
// without fee, provided that the above copyright notice appear in
// all copies and that both that copyright notice and this permission
// notice appear in supporting documentation, and that the name of
// the copyright holders not be used in advertising or publicity
// pertaining to distribution of the software without specific,
// written prior permission. The copyright holders make no
// representations about the suitability of this software for any
// purpose. It is provided "as is" without express or implied
// warranty.
//
// THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
// SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
// FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
// SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
// AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
// ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
// THIS SOFTWARE.
package proto
import "github.com/rajveermalviya/go-wayland/wayland/client"
// ExtForeignToplevelList : list toplevels
//
// A toplevel is defined as a surface with a role similar to xdg_toplevel.
// XWayland surfaces may be treated like toplevels in this protocol.
//
// After a client binds the ext_foreign_toplevel_list_v1, each mapped
// toplevel window will be sent using the ext_foreign_toplevel_list_v1.toplevel
// event.
//
// Clients which only care about the current state can perform a roundtrip after
// binding this global.
//
// For each instance of ext_foreign_toplevel_list_v1, the compositor must
// create a new ext_foreign_toplevel_handle_v1 object for each mapped toplevel.
//
// If a compositor implementation sends the ext_foreign_toplevel_list_v1.finished
// event after the global is bound, the compositor must not send any
// ext_foreign_toplevel_list_v1.toplevel events.
type ExtForeignToplevelList struct {
client.BaseProxy
toplevelHandler ExtForeignToplevelListToplevelHandlerFunc
finishedHandler ExtForeignToplevelListFinishedHandlerFunc
}
// NewExtForeignToplevelList : list toplevels
//
// A toplevel is defined as a surface with a role similar to xdg_toplevel.
// XWayland surfaces may be treated like toplevels in this protocol.
//
// After a client binds the ext_foreign_toplevel_list_v1, each mapped
// toplevel window will be sent using the ext_foreign_toplevel_list_v1.toplevel
// event.
//
// Clients which only care about the current state can perform a roundtrip after
// binding this global.
//
// For each instance of ext_foreign_toplevel_list_v1, the compositor must
// create a new ext_foreign_toplevel_handle_v1 object for each mapped toplevel.
//
// If a compositor implementation sends the ext_foreign_toplevel_list_v1.finished
// event after the global is bound, the compositor must not send any
// ext_foreign_toplevel_list_v1.toplevel events.
func NewExtForeignToplevelList(ctx *client.Context) *ExtForeignToplevelList {
extForeignToplevelListV1 := &ExtForeignToplevelList{}
ctx.Register(extForeignToplevelListV1)
return extForeignToplevelListV1
}
// Stop : stop sending events
//
// This request indicates that the client no longer wishes to receive
// events for new toplevels.
//
// The Wayland protocol is asynchronous, meaning the compositor may send
// further toplevel events until the stop request is processed.
// The client should wait for a ext_foreign_toplevel_list_v1.finished
// event before destroying this object.
func (i *ExtForeignToplevelList) Stop() error {
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// Destroy : destroy the ext_foreign_toplevel_list_v1 object
//
// This request should be called either when the client will no longer
// use the ext_foreign_toplevel_list_v1 or after the finished event
// has been received to allow destruction of the object.
//
// If a client wishes to destroy this object it should send a
// ext_foreign_toplevel_list_v1.stop request and wait for a ext_foreign_toplevel_list_v1.finished
// event, then destroy the handles and then this object.
func (i *ExtForeignToplevelList) Destroy() error {
defer i.Context().Unregister(i)
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// ExtForeignToplevelListToplevelEvent : a toplevel has been created
//
// This event is emitted whenever a new toplevel window is created. It is
// emitted for all toplevels, regardless of the app that has created them.
//
// All initial properties of the toplevel (identifier, title, app_id) will be sent
// immediately after this event using the corresponding events for
// ext_foreign_toplevel_handle_v1. The compositor will use the
// ext_foreign_toplevel_handle_v1.done event to indicate when all data has
// been sent.
type ExtForeignToplevelListToplevelEvent struct {
Toplevel *ExtForeignToplevelHandle
}
type ExtForeignToplevelListToplevelHandlerFunc func(ExtForeignToplevelListToplevelEvent)
// SetToplevelHandler : sets handler for ExtForeignToplevelListToplevelEvent
func (i *ExtForeignToplevelList) SetToplevelHandler(f ExtForeignToplevelListToplevelHandlerFunc) {
i.toplevelHandler = f
}
// ExtForeignToplevelListFinishedEvent : the compositor has finished with the toplevel manager
//
// This event indicates that the compositor is done sending events
// to this object. The client should destroy the object.
// See ext_foreign_toplevel_list_v1.destroy for more information.
//
// The compositor must not send any more toplevel events after this event.
type ExtForeignToplevelListFinishedEvent struct{}
type ExtForeignToplevelListFinishedHandlerFunc func(ExtForeignToplevelListFinishedEvent)
// SetFinishedHandler : sets handler for ExtForeignToplevelListFinishedEvent
func (i *ExtForeignToplevelList) SetFinishedHandler(f ExtForeignToplevelListFinishedHandlerFunc) {
i.finishedHandler = f
}
func (i *ExtForeignToplevelList) Dispatch(opcode uint32, fd int, data []byte) {
switch opcode {
case 0:
if i.toplevelHandler == nil {
return
}
var e ExtForeignToplevelListToplevelEvent
l := 0
// PATCHED (see register.go): the toplevel arg is a server-allocated
// new_id; the stock scanner emits GetProxy(...).(*T) which panics because
// the object was never registered. Create and register it under the
// server id instead.
toplevelID := client.Uint32(data[l : l+4])
handle := &ExtForeignToplevelHandle{}
registerServerObject(i.Context(), toplevelID, handle)
e.Toplevel = handle
l += 4
i.toplevelHandler(e)
case 1:
if i.finishedHandler == nil {
return
}
var e ExtForeignToplevelListFinishedEvent
i.finishedHandler(e)
}
}
// ExtForeignToplevelHandle : a mapped toplevel
//
// A ext_foreign_toplevel_handle_v1 object represents a mapped toplevel
// window. A single app may have multiple mapped toplevels.
type ExtForeignToplevelHandle struct {
client.BaseProxy
closedHandler ExtForeignToplevelHandleClosedHandlerFunc
doneHandler ExtForeignToplevelHandleDoneHandlerFunc
titleHandler ExtForeignToplevelHandleTitleHandlerFunc
appIdHandler ExtForeignToplevelHandleAppIdHandlerFunc
identifierHandler ExtForeignToplevelHandleIdentifierHandlerFunc
}
// NewExtForeignToplevelHandle : a mapped toplevel
//
// A ext_foreign_toplevel_handle_v1 object represents a mapped toplevel
// window. A single app may have multiple mapped toplevels.
func NewExtForeignToplevelHandle(ctx *client.Context) *ExtForeignToplevelHandle {
extForeignToplevelHandleV1 := &ExtForeignToplevelHandle{}
ctx.Register(extForeignToplevelHandleV1)
return extForeignToplevelHandleV1
}
// Destroy : destroy the ext_foreign_toplevel_handle_v1 object
//
// This request should be used when the client will no longer use the handle
// or after the closed event has been received to allow destruction of the
// object.
//
// When a handle is destroyed, a new handle may not be created by the server
// until the toplevel is unmapped and then remapped. Destroying a toplevel handle
// is not recommended unless the client is cleaning up child objects
// before destroying the ext_foreign_toplevel_list_v1 object, the toplevel
// was closed or the toplevel handle will not be used in the future.
//
// Other protocols which extend the ext_foreign_toplevel_handle_v1
// interface should require destructors for extension interfaces be
// called before allowing the toplevel handle to be destroyed.
func (i *ExtForeignToplevelHandle) Destroy() error {
defer i.Context().Unregister(i)
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// ExtForeignToplevelHandleClosedEvent : the toplevel has been closed
//
// The server will emit no further events on the ext_foreign_toplevel_handle_v1
// after this event. Any requests received aside from the destroy request must
// be ignored. Upon receiving this event, the client should destroy the handle.
//
// Other protocols which extend the ext_foreign_toplevel_handle_v1
// interface must also ignore requests other than destructors.
type ExtForeignToplevelHandleClosedEvent struct{}
type ExtForeignToplevelHandleClosedHandlerFunc func(ExtForeignToplevelHandleClosedEvent)
// SetClosedHandler : sets handler for ExtForeignToplevelHandleClosedEvent
func (i *ExtForeignToplevelHandle) SetClosedHandler(f ExtForeignToplevelHandleClosedHandlerFunc) {
i.closedHandler = f
}
// ExtForeignToplevelHandleDoneEvent : all information about the toplevel has been sent
//
// This event is sent after all changes in the toplevel state have
// been sent.
//
// This allows changes to the ext_foreign_toplevel_handle_v1 properties
// to be atomically applied. Other protocols which extend the
// ext_foreign_toplevel_handle_v1 interface may use this event to also
// atomically apply any pending state.
//
// This event must not be sent after the ext_foreign_toplevel_handle_v1.closed
// event.
type ExtForeignToplevelHandleDoneEvent struct{}
type ExtForeignToplevelHandleDoneHandlerFunc func(ExtForeignToplevelHandleDoneEvent)
// SetDoneHandler : sets handler for ExtForeignToplevelHandleDoneEvent
func (i *ExtForeignToplevelHandle) SetDoneHandler(f ExtForeignToplevelHandleDoneHandlerFunc) {
i.doneHandler = f
}
// ExtForeignToplevelHandleTitleEvent : title change
//
// The title of the toplevel has changed.
//
// The configured state must not be applied immediately. See
// ext_foreign_toplevel_handle_v1.done for details.
type ExtForeignToplevelHandleTitleEvent struct {
Title string
}
type ExtForeignToplevelHandleTitleHandlerFunc func(ExtForeignToplevelHandleTitleEvent)
// SetTitleHandler : sets handler for ExtForeignToplevelHandleTitleEvent
func (i *ExtForeignToplevelHandle) SetTitleHandler(f ExtForeignToplevelHandleTitleHandlerFunc) {
i.titleHandler = f
}
// ExtForeignToplevelHandleAppIdEvent : app_id change
//
// The app id of the toplevel has changed.
//
// The configured state must not be applied immediately. See
// ext_foreign_toplevel_handle_v1.done for details.
type ExtForeignToplevelHandleAppIdEvent struct {
AppId string
}
type ExtForeignToplevelHandleAppIdHandlerFunc func(ExtForeignToplevelHandleAppIdEvent)
// SetAppIdHandler : sets handler for ExtForeignToplevelHandleAppIdEvent
func (i *ExtForeignToplevelHandle) SetAppIdHandler(f ExtForeignToplevelHandleAppIdHandlerFunc) {
i.appIdHandler = f
}
// ExtForeignToplevelHandleIdentifierEvent : a stable identifier for a toplevel
//
// This identifier is used to check if two or more toplevel handles belong
// to the same toplevel.
//
// The identifier is useful for command line tools or privileged clients
// which may need to reference an exact toplevel across processes or
// instances of the ext_foreign_toplevel_list_v1 global.
//
// The compositor must only send this event when the handle is created.
//
// The identifier must be unique per toplevel and it's handles. Two different
// toplevels must not have the same identifier. The identifier is only valid
// as long as the toplevel is mapped. If the toplevel is unmapped the identifier
// must not be reused. An identifier must not be reused by the compositor to
// ensure there are no races when sharing identifiers between processes.
//
// An identifier is a string that contains up to 32 printable ASCII bytes.
// An identifier must not be an empty string. It is recommended that a
// compositor includes an opaque generation value in identifiers. How the
// generation value is used when generating the identifier is implementation
// dependent.
type ExtForeignToplevelHandleIdentifierEvent struct {
Identifier string
}
type ExtForeignToplevelHandleIdentifierHandlerFunc func(ExtForeignToplevelHandleIdentifierEvent)
// SetIdentifierHandler : sets handler for ExtForeignToplevelHandleIdentifierEvent
func (i *ExtForeignToplevelHandle) SetIdentifierHandler(f ExtForeignToplevelHandleIdentifierHandlerFunc) {
i.identifierHandler = f
}
func (i *ExtForeignToplevelHandle) Dispatch(opcode uint32, fd int, data []byte) {
switch opcode {
case 0:
if i.closedHandler == nil {
return
}
var e ExtForeignToplevelHandleClosedEvent
i.closedHandler(e)
case 1:
if i.doneHandler == nil {
return
}
var e ExtForeignToplevelHandleDoneEvent
i.doneHandler(e)
case 2:
if i.titleHandler == nil {
return
}
var e ExtForeignToplevelHandleTitleEvent
l := 0
titleLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.Title = client.String(data[l : l+titleLen])
l += titleLen
i.titleHandler(e)
case 3:
if i.appIdHandler == nil {
return
}
var e ExtForeignToplevelHandleAppIdEvent
l := 0
appIdLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.AppId = client.String(data[l : l+appIdLen])
l += appIdLen
i.appIdHandler(e)
case 4:
if i.identifierHandler == nil {
return
}
var e ExtForeignToplevelHandleIdentifierEvent
l := 0
identifierLen := client.PaddedLen(int(client.Uint32(data[l : l+4])))
l += 4
e.Identifier = client.String(data[l : l+identifierLen])
l += identifierLen
i.identifierHandler(e)
}
}

View file

@ -0,0 +1,195 @@
// Generated by go-wayland-scanner
// https://github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner
// XML file : protocol/ext-image-capture-source-v1.xml
//
// ext_image_capture_source_v1 Protocol Copyright:
//
// Copyright © 2022 Andri Yngvason
// Copyright © 2024 Simon Ser
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice (including the next
// paragraph) shall be included in all copies or substantial portions of the
// Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
package proto
import "github.com/rajveermalviya/go-wayland/wayland/client"
// ExtImageCaptureSource : opaque image capture source object
//
// The image capture source object is an opaque descriptor for a capturable
// resource. This resource may be any sort of entity from which an image
// may be derived.
//
// Note, because ext_image_capture_source_v1 objects are created from multiple
// independent factory interfaces, the ext_image_capture_source_v1 interface is
// frozen at version 1.
type ExtImageCaptureSource struct {
client.BaseProxy
}
// NewExtImageCaptureSource : opaque image capture source object
//
// The image capture source object is an opaque descriptor for a capturable
// resource. This resource may be any sort of entity from which an image
// may be derived.
//
// Note, because ext_image_capture_source_v1 objects are created from multiple
// independent factory interfaces, the ext_image_capture_source_v1 interface is
// frozen at version 1.
func NewExtImageCaptureSource(ctx *client.Context) *ExtImageCaptureSource {
extImageCaptureSourceV1 := &ExtImageCaptureSource{}
ctx.Register(extImageCaptureSourceV1)
return extImageCaptureSourceV1
}
// Destroy : delete this object
//
// Destroys the image capture source. This request may be sent at any time
// by the client.
func (i *ExtImageCaptureSource) Destroy() error {
defer i.Context().Unregister(i)
const opcode = 0
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// ExtOutputImageCaptureSourceManager : image capture source manager for outputs
//
// A manager for creating image capture source objects for wl_output objects.
type ExtOutputImageCaptureSourceManager struct {
client.BaseProxy
}
// NewExtOutputImageCaptureSourceManager : image capture source manager for outputs
//
// A manager for creating image capture source objects for wl_output objects.
func NewExtOutputImageCaptureSourceManager(ctx *client.Context) *ExtOutputImageCaptureSourceManager {
extOutputImageCaptureSourceManagerV1 := &ExtOutputImageCaptureSourceManager{}
ctx.Register(extOutputImageCaptureSourceManagerV1)
return extOutputImageCaptureSourceManagerV1
}
// CreateSource : create source object for output
//
// Creates a source object for an output. Images captured from this source
// will show the same content as the output. Some elements may be omitted,
// such as cursors and overlays that have been marked as transparent to
// capturing.
func (i *ExtOutputImageCaptureSourceManager) CreateSource(output *client.Output) (*ExtImageCaptureSource, error) {
source := NewExtImageCaptureSource(i.Context())
const opcode = 0
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], source.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], output.ID())
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return source, err
}
// Destroy : delete this object
//
// Destroys the manager. This request may be sent at any time by the client
// and objects created by the manager will remain valid after its
// destruction.
func (i *ExtOutputImageCaptureSourceManager) Destroy() error {
defer i.Context().Unregister(i)
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}
// ExtForeignToplevelImageCaptureSourceManager : image capture source manager for foreign toplevels
//
// A manager for creating image capture source objects for
// ext_foreign_toplevel_handle_v1 objects.
type ExtForeignToplevelImageCaptureSourceManager struct {
client.BaseProxy
}
// NewExtForeignToplevelImageCaptureSourceManager : image capture source manager for foreign toplevels
//
// A manager for creating image capture source objects for
// ext_foreign_toplevel_handle_v1 objects.
func NewExtForeignToplevelImageCaptureSourceManager(ctx *client.Context) *ExtForeignToplevelImageCaptureSourceManager {
extForeignToplevelImageCaptureSourceManagerV1 := &ExtForeignToplevelImageCaptureSourceManager{}
ctx.Register(extForeignToplevelImageCaptureSourceManagerV1)
return extForeignToplevelImageCaptureSourceManagerV1
}
// CreateSource : create source object for foreign toplevel
//
// Creates a source object for a foreign toplevel handle. Images captured
// from this source will show the same content as the toplevel.
func (i *ExtForeignToplevelImageCaptureSourceManager) CreateSource(toplevelHandle *ExtForeignToplevelHandle) (*ExtImageCaptureSource, error) {
source := NewExtImageCaptureSource(i.Context())
const opcode = 0
const _reqBufLen = 8 + 4 + 4
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
client.PutUint32(_reqBuf[l:l+4], source.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], toplevelHandle.ID())
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return source, err
}
// Destroy : delete this object
//
// Destroys the manager. This request may be sent at any time by the client
// and objects created by the manager will remain valid after its
// destruction.
func (i *ExtForeignToplevelImageCaptureSourceManager) Destroy() error {
defer i.Context().Unregister(i)
const opcode = 1
const _reqBufLen = 8
var _reqBuf [_reqBufLen]byte
l := 0
client.PutUint32(_reqBuf[l:4], i.ID())
l += 4
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
l += 4
err := i.Context().WriteMsg(_reqBuf[:], nil)
return err
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
package proto
import (
"reflect"
"unsafe"
"github.com/rajveermalviya/go-wayland/wayland/client"
)
// registerServerObject registers a proxy under a server-allocated object id.
//
// go-wayland only knows how to create client-allocated objects (its Context has
// no public way to register an object under an id chosen by the compositor). But
// the ext_foreign_toplevel_list_v1.toplevel event delivers a new_id the *server*
// allocates, and the object must be registered under that id for its subsequent
// events (identifier/title/app_id/closed/done) to be dispatched to it. We reach
// into the Context's private objects map to do so. This is safe because the
// go-wayland dependency is version-pinned, so its internal layout is fixed.
func registerServerObject(ctx *client.Context, id uint32, p client.Proxy) {
p.SetContext(ctx)
p.SetID(id)
f := reflect.ValueOf(ctx).Elem().FieldByName("objects")
f = reflect.NewAt(f.Type(), unsafe.Pointer(f.UnsafeAddr())).Elem()
f.SetMapIndex(reflect.ValueOf(id), reflect.ValueOf(p))
}

View file

@ -0,0 +1,219 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="ext_foreign_toplevel_list_v1">
<copyright>
Copyright © 2018 Ilia Bozhinov
Copyright © 2020 Isaac Freund
Copyright © 2022 wb9688
Copyright © 2023 i509VCB
Permission to use, copy, modify, distribute, and sell this
software and its documentation for any purpose is hereby granted
without fee, provided that the above copyright notice appear in
all copies and that both that copyright notice and this permission
notice appear in supporting documentation, and that the name of
the copyright holders not be used in advertising or publicity
pertaining to distribution of the software without specific,
written prior permission. The copyright holders make no
representations about the suitability of this software for any
purpose. It is provided "as is" without express or implied
warranty.
THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
</copyright>
<description summary="list toplevels">
The purpose of this protocol is to provide protocol object handles for
toplevels, possibly originating from another client.
This protocol is intentionally minimalistic and expects additional
functionality (e.g. creating a screencopy source from a toplevel handle,
getting information about the state of the toplevel) to be implemented
in extension protocols.
The compositor may choose to restrict this protocol to a special client
launched by the compositor itself or expose it to all clients,
this is compositor policy.
The key words "must", "must not", "required", "shall", "shall not",
"should", "should not", "recommended", "may", and "optional" in this
document are to be interpreted as described in IETF RFC 2119.
Warning! The protocol described in this file is currently in the testing
phase. Backward compatible changes may be added together with the
corresponding interface version bump. Backward incompatible changes can
only be done by creating a new major version of the extension.
</description>
<interface name="ext_foreign_toplevel_list_v1" version="1">
<description summary="list toplevels">
A toplevel is defined as a surface with a role similar to xdg_toplevel.
XWayland surfaces may be treated like toplevels in this protocol.
After a client binds the ext_foreign_toplevel_list_v1, each mapped
toplevel window will be sent using the ext_foreign_toplevel_list_v1.toplevel
event.
Clients which only care about the current state can perform a roundtrip after
binding this global.
For each instance of ext_foreign_toplevel_list_v1, the compositor must
create a new ext_foreign_toplevel_handle_v1 object for each mapped toplevel.
If a compositor implementation sends the ext_foreign_toplevel_list_v1.finished
event after the global is bound, the compositor must not send any
ext_foreign_toplevel_list_v1.toplevel events.
</description>
<event name="toplevel">
<description summary="a toplevel has been created">
This event is emitted whenever a new toplevel window is created. It is
emitted for all toplevels, regardless of the app that has created them.
All initial properties of the toplevel (identifier, title, app_id) will be sent
immediately after this event using the corresponding events for
ext_foreign_toplevel_handle_v1. The compositor will use the
ext_foreign_toplevel_handle_v1.done event to indicate when all data has
been sent.
</description>
<arg name="toplevel" type="new_id" interface="ext_foreign_toplevel_handle_v1"/>
</event>
<event name="finished">
<description summary="the compositor has finished with the toplevel manager">
This event indicates that the compositor is done sending events
to this object. The client should destroy the object.
See ext_foreign_toplevel_list_v1.destroy for more information.
The compositor must not send any more toplevel events after this event.
</description>
</event>
<request name="stop">
<description summary="stop sending events">
This request indicates that the client no longer wishes to receive
events for new toplevels.
The Wayland protocol is asynchronous, meaning the compositor may send
further toplevel events until the stop request is processed.
The client should wait for a ext_foreign_toplevel_list_v1.finished
event before destroying this object.
</description>
</request>
<request name="destroy" type="destructor">
<description summary="destroy the ext_foreign_toplevel_list_v1 object">
This request should be called either when the client will no longer
use the ext_foreign_toplevel_list_v1 or after the finished event
has been received to allow destruction of the object.
If a client wishes to destroy this object it should send a
ext_foreign_toplevel_list_v1.stop request and wait for a ext_foreign_toplevel_list_v1.finished
event, then destroy the handles and then this object.
</description>
</request>
</interface>
<interface name="ext_foreign_toplevel_handle_v1" version="1">
<description summary="a mapped toplevel">
A ext_foreign_toplevel_handle_v1 object represents a mapped toplevel
window. A single app may have multiple mapped toplevels.
</description>
<request name="destroy" type="destructor">
<description summary="destroy the ext_foreign_toplevel_handle_v1 object">
This request should be used when the client will no longer use the handle
or after the closed event has been received to allow destruction of the
object.
When a handle is destroyed, a new handle may not be created by the server
until the toplevel is unmapped and then remapped. Destroying a toplevel handle
is not recommended unless the client is cleaning up child objects
before destroying the ext_foreign_toplevel_list_v1 object, the toplevel
was closed or the toplevel handle will not be used in the future.
Other protocols which extend the ext_foreign_toplevel_handle_v1
interface should require destructors for extension interfaces be
called before allowing the toplevel handle to be destroyed.
</description>
</request>
<event name="closed">
<description summary="the toplevel has been closed">
The server will emit no further events on the ext_foreign_toplevel_handle_v1
after this event. Any requests received aside from the destroy request must
be ignored. Upon receiving this event, the client should destroy the handle.
Other protocols which extend the ext_foreign_toplevel_handle_v1
interface must also ignore requests other than destructors.
</description>
</event>
<event name="done">
<description summary="all information about the toplevel has been sent">
This event is sent after all changes in the toplevel state have
been sent.
This allows changes to the ext_foreign_toplevel_handle_v1 properties
to be atomically applied. Other protocols which extend the
ext_foreign_toplevel_handle_v1 interface may use this event to also
atomically apply any pending state.
This event must not be sent after the ext_foreign_toplevel_handle_v1.closed
event.
</description>
</event>
<event name="title">
<description summary="title change">
The title of the toplevel has changed.
The configured state must not be applied immediately. See
ext_foreign_toplevel_handle_v1.done for details.
</description>
<arg name="title" type="string"/>
</event>
<event name="app_id">
<description summary="app_id change">
The app id of the toplevel has changed.
The configured state must not be applied immediately. See
ext_foreign_toplevel_handle_v1.done for details.
</description>
<arg name="app_id" type="string"/>
</event>
<event name="identifier">
<description summary="a stable identifier for a toplevel">
This identifier is used to check if two or more toplevel handles belong
to the same toplevel.
The identifier is useful for command line tools or privileged clients
which may need to reference an exact toplevel across processes or
instances of the ext_foreign_toplevel_list_v1 global.
The compositor must only send this event when the handle is created.
The identifier must be unique per toplevel and it's handles. Two different
toplevels must not have the same identifier. The identifier is only valid
as long as the toplevel is mapped. If the toplevel is unmapped the identifier
must not be reused. An identifier must not be reused by the compositor to
ensure there are no races when sharing identifiers between processes.
An identifier is a string that contains up to 32 printable ASCII bytes.
An identifier must not be an empty string. It is recommended that a
compositor includes an opaque generation value in identifiers. How the
generation value is used when generating the identifier is implementation
dependent.
</description>
<arg name="identifier" type="string"/>
</event>
</interface>
</protocol>

View file

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="ext_image_capture_source_v1">
<copyright>
Copyright © 2022 Andri Yngvason
Copyright © 2024 Simon Ser
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</copyright>
<description summary="opaque image capture source objects">
This protocol serves as an intermediary between capturing protocols and
potential image capture sources such as outputs and toplevels.
This protocol may be extended to support more image capture sources in the
future, thereby adding those image capture sources to other protocols that
use the image capture source object without having to modify those
protocols.
Warning! The protocol described in this file is currently in the testing
phase. Backward compatible changes may be added together with the
corresponding interface version bump. Backward incompatible changes can
only be done by creating a new major version of the extension.
</description>
<interface name="ext_image_capture_source_v1" version="1">
<description summary="opaque image capture source object">
The image capture source object is an opaque descriptor for a capturable
resource. This resource may be any sort of entity from which an image
may be derived.
Note, because ext_image_capture_source_v1 objects are created from multiple
independent factory interfaces, the ext_image_capture_source_v1 interface is
frozen at version 1.
</description>
<request name="destroy" type="destructor">
<description summary="delete this object">
Destroys the image capture source. This request may be sent at any time
by the client.
</description>
</request>
</interface>
<interface name="ext_output_image_capture_source_manager_v1" version="1">
<description summary="image capture source manager for outputs">
A manager for creating image capture source objects for wl_output objects.
</description>
<request name="create_source">
<description summary="create source object for output">
Creates a source object for an output. Images captured from this source
will show the same content as the output. Some elements may be omitted,
such as cursors and overlays that have been marked as transparent to
capturing.
</description>
<arg name="source" type="new_id" interface="ext_image_capture_source_v1"/>
<arg name="output" type="object" interface="wl_output"/>
</request>
<request name="destroy" type="destructor">
<description summary="delete this object">
Destroys the manager. This request may be sent at any time by the client
and objects created by the manager will remain valid after its
destruction.
</description>
</request>
</interface>
<interface name="ext_foreign_toplevel_image_capture_source_manager_v1" version="1">
<description summary="image capture source manager for foreign toplevels">
A manager for creating image capture source objects for
ext_foreign_toplevel_handle_v1 objects.
</description>
<request name="create_source">
<description summary="create source object for foreign toplevel">
Creates a source object for a foreign toplevel handle. Images captured
from this source will show the same content as the toplevel.
</description>
<arg name="source" type="new_id" interface="ext_image_capture_source_v1"/>
<arg name="toplevel_handle" type="object" interface="ext_foreign_toplevel_handle_v1"/>
</request>
<request name="destroy" type="destructor">
<description summary="delete this object">
Destroys the manager. This request may be sent at any time by the client
and objects created by the manager will remain valid after its
destruction.
</description>
</request>
</interface>
</protocol>

View file

@ -0,0 +1,456 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="ext_image_copy_capture_v1">
<copyright>
Copyright © 2021-2023 Andri Yngvason
Copyright © 2024 Simon Ser
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</copyright>
<description summary="image capturing into client buffers">
This protocol allows clients to ask the compositor to capture image sources
such as outputs and toplevels into user submitted buffers.
Warning! The protocol described in this file is currently in the testing
phase. Backward compatible changes may be added together with the
corresponding interface version bump. Backward incompatible changes can
only be done by creating a new major version of the extension.
</description>
<interface name="ext_image_copy_capture_manager_v1" version="1">
<description summary="manager to inform clients and begin capturing">
This object is a manager which offers requests to start capturing from a
source.
</description>
<enum name="error">
<entry name="invalid_option" value="1" summary="invalid option flag"/>
</enum>
<enum name="options" bitfield="true">
<entry name="paint_cursors" value="1" summary="paint cursors onto captured frames"/>
</enum>
<request name="create_session">
<description summary="capture an image capture source">
Create a capturing session for an image capture source.
If the paint_cursors option is set, cursors shall be composited onto
the captured frame. The cursor must not be composited onto the frame
if this flag is not set.
If the options bitfield is invalid, the invalid_option protocol error
is sent.
</description>
<arg name="session" type="new_id" interface="ext_image_copy_capture_session_v1"/>
<arg name="source" type="object" interface="ext_image_capture_source_v1"/>
<arg name="options" type="uint" enum="options"/>
</request>
<request name="create_pointer_cursor_session">
<description summary="capture the pointer cursor of an image capture source">
Create a cursor capturing session for the pointer of an image capture
source.
</description>
<arg name="session" type="new_id" interface="ext_image_copy_capture_cursor_session_v1"/>
<arg name="source" type="object" interface="ext_image_capture_source_v1"/>
<arg name="pointer" type="object" interface="wl_pointer"/>
</request>
<request name="destroy" type="destructor">
<description summary="destroy the manager">
Destroy the manager object.
Other objects created via this interface are unaffected.
</description>
</request>
</interface>
<interface name="ext_image_copy_capture_session_v1" version="1">
<description summary="image copy capture session">
This object represents an active image copy capture session.
After a capture session is created, buffer constraint events will be
emitted from the compositor to tell the client which buffer types and
formats are supported for reading from the session. The compositor may
re-send buffer constraint events whenever they change.
To advertise buffer constraints, the compositor must send in no
particular order: zero or more shm_format and dmabuf_format events, zero
or one dmabuf_device event, and exactly one buffer_size event. Then the
compositor must send a done event.
When the client has received all the buffer constraints, it can create a
buffer accordingly, attach it to the capture session using the
attach_buffer request, set the buffer damage using the damage_buffer
request and then send the capture request.
</description>
<enum name="error">
<entry name="duplicate_frame" value="1"
summary="create_frame sent before destroying previous frame"/>
</enum>
<event name="buffer_size">
<description summary="image capture source dimensions">
Provides the dimensions of the source image in buffer pixel coordinates.
The client must attach buffers that match this size.
</description>
<arg name="width" type="uint" summary="buffer width"/>
<arg name="height" type="uint" summary="buffer height"/>
</event>
<event name="shm_format">
<description summary="shm buffer format">
Provides the format that must be used for shared-memory buffers.
This event may be emitted multiple times, in which case the client may
choose any given format.
</description>
<arg name="format" type="uint" enum="wl_shm.format" summary="shm format"/>
</event>
<event name="dmabuf_device">
<description summary="dma-buf device">
This event advertises the device buffers must be allocated on for
dma-buf buffers.
In general the device is a DRM node. The DRM node type (primary vs.
render) is unspecified. Clients must not rely on the compositor sending
a particular node type. Clients cannot check two devices for equality
by comparing the dev_t value.
</description>
<arg name="device" type="array" summary="device dev_t value"/>
</event>
<event name="dmabuf_format">
<description summary="dma-buf format">
Provides the format that must be used for dma-buf buffers.
The client may choose any of the modifiers advertised in the array of
64-bit unsigned integers.
This event may be emitted multiple times, in which case the client may
choose any given format.
</description>
<arg name="format" type="uint" summary="drm format code"/>
<arg name="modifiers" type="array" summary="drm format modifiers"/>
</event>
<event name="done">
<description summary="all constraints have been sent">
This event is sent once when all buffer constraint events have been
sent.
The compositor must always end a batch of buffer constraint events with
this event, regardless of whether it sends the initial constraints or
an update.
</description>
</event>
<event name="stopped">
<description summary="session is no longer available">
This event indicates that the capture session has stopped and is no
longer available. This can happen in a number of cases, e.g. when the
underlying source is destroyed, if the user decides to end the image
capture, or if an unrecoverable runtime error has occurred.
The client should destroy the session after receiving this event.
</description>
</event>
<request name="create_frame">
<description summary="create a frame">
Create a capture frame for this session.
At most one frame object can exist for a given session at any time. If
a client sends a create_frame request before a previous frame object
has been destroyed, the duplicate_frame protocol error is raised.
</description>
<arg name="frame" type="new_id" interface="ext_image_copy_capture_frame_v1"/>
</request>
<request name="destroy" type="destructor">
<description summary="delete this object">
Destroys the session. This request can be sent at any time by the
client.
This request doesn't affect ext_image_copy_capture_frame_v1 objects created by
this object.
</description>
</request>
</interface>
<interface name="ext_image_copy_capture_frame_v1" version="1">
<description summary="image capture frame">
This object represents an image capture frame.
The client should attach a buffer, damage the buffer, and then send a
capture request.
If the capture is successful, the compositor must send the frame metadata
(transform, damage, presentation_time in any order) followed by the ready
event.
If the capture fails, the compositor must send the failed event.
</description>
<enum name="error">
<entry name="no_buffer" value="1" summary="capture sent without attach_buffer"/>
<entry name="invalid_buffer_damage" value="2" summary="invalid buffer damage"/>
<entry name="already_captured" value="3" summary="capture request has been sent"/>
</enum>
<request name="destroy" type="destructor">
<description summary="destroy this object">
Destroys the frame. This request can be sent at any time by the
client.
</description>
</request>
<request name="attach_buffer">
<description summary="attach buffer to session">
Attach a buffer to the session.
The wl_buffer.release request is unused.
The new buffer replaces any previously attached buffer.
This request must not be sent after capture, or else the
already_captured protocol error is raised.
</description>
<arg name="buffer" type="object" interface="wl_buffer"/>
</request>
<request name="damage_buffer">
<description summary="damage buffer">
Apply damage to the buffer which is to be captured next. This request
may be sent multiple times to describe a region.
The client indicates the accumulated damage since this wl_buffer was
last captured. During capture, the compositor will update the buffer
with at least the union of the region passed by the client and the
region advertised by ext_image_copy_capture_frame_v1.damage.
When a wl_buffer is captured for the first time, or when the client
doesn't track damage, the client must damage the whole buffer.
This is for optimisation purposes. The compositor may use this
information to reduce copying.
These coordinates originate from the upper left corner of the buffer.
If x or y are strictly negative, or if width or height are negative or
zero, the invalid_buffer_damage protocol error is raised.
This request must not be sent after capture, or else the
already_captured protocol error is raised.
</description>
<arg name="x" type="int" summary="region x coordinate"/>
<arg name="y" type="int" summary="region y coordinate"/>
<arg name="width" type="int" summary="region width"/>
<arg name="height" type="int" summary="region height"/>
</request>
<request name="capture">
<description summary="capture a frame">
Capture a frame.
Unless this is the first successful captured frame performed in this
session, the compositor may wait an indefinite amount of time for the
source content to change before performing the copy.
This request may only be sent once, or else the already_captured
protocol error is raised. A buffer must be attached before this request
is sent, or else the no_buffer protocol error is raised.
</description>
</request>
<event name="transform">
<description summary="buffer transform">
This event is sent before the ready event and holds the transform that
the compositor has applied to the buffer contents.
</description>
<arg name="transform" type="uint" enum="wl_output.transform"/>
</event>
<event name="damage">
<description summary="buffer damaged region">
This event is sent before the ready event. It may be generated multiple
times to describe a region.
The first captured frame in a session will always carry full damage.
Subsequent frames' damaged regions describe which parts of the buffer
have changed since the last ready event.
These coordinates originate in the upper left corner of the buffer.
</description>
<arg name="x" type="int" summary="damage x coordinate"/>
<arg name="y" type="int" summary="damage y coordinate"/>
<arg name="width" type="int" summary="damage width"/>
<arg name="height" type="int" summary="damage height"/>
</event>
<event name="presentation_time">
<description summary="presentation time of the frame">
This event indicates the time at which the frame is presented to the
output in system monotonic time. This event is sent before the ready
event.
The timestamp is expressed as tv_sec_hi, tv_sec_lo, tv_nsec triples,
each component being an unsigned 32-bit value. Whole seconds are in
tv_sec which is a 64-bit value combined from tv_sec_hi and tv_sec_lo,
and the additional fractional part in tv_nsec as nanoseconds. Hence,
for valid timestamps tv_nsec must be in [0, 999999999].
</description>
<arg name="tv_sec_hi" type="uint"
summary="high 32 bits of the seconds part of the timestamp"/>
<arg name="tv_sec_lo" type="uint"
summary="low 32 bits of the seconds part of the timestamp"/>
<arg name="tv_nsec" type="uint"
summary="nanoseconds part of the timestamp"/>
</event>
<event name="ready">
<description summary="frame is available for reading">
Called as soon as the frame is copied, indicating it is available
for reading.
The buffer may be re-used by the client after this event.
After receiving this event, the client must destroy the object.
</description>
</event>
<enum name="failure_reason">
<entry name="unknown" value="0">
<description summary="unknown runtime error">
An unspecified runtime error has occurred. The client may retry.
</description>
</entry>
<entry name="buffer_constraints" value="1">
<description summary="buffer constraints mismatch">
The buffer submitted by the client doesn't match the latest session
constraints. The client should re-allocate its buffers and retry.
</description>
</entry>
<entry name="stopped" value="2">
<description summary="session is no longer available">
The session has stopped. See ext_image_copy_capture_session_v1.stopped.
</description>
</entry>
</enum>
<event name="failed">
<description summary="capture failed">
This event indicates that the attempted frame copy has failed.
After receiving this event, the client must destroy the object.
</description>
<arg name="reason" type="uint" enum="failure_reason"/>
</event>
</interface>
<interface name="ext_image_copy_capture_cursor_session_v1" version="1">
<description summary="cursor capture session">
This object represents a cursor capture session. It extends the base
capture session with cursor-specific metadata.
</description>
<enum name="error">
<entry name="duplicate_session" value="1" summary="get_capture_session sent twice"/>
</enum>
<request name="destroy" type="destructor">
<description summary="delete this object">
Destroys the session. This request can be sent at any time by the
client.
This request doesn't affect ext_image_copy_capture_frame_v1 objects created by
this object.
</description>
</request>
<request name="get_capture_session">
<description summary="get image copy capturer session">
Gets the image copy capture session for this cursor session.
The session will produce frames of the cursor image. The compositor may
pause the session when the cursor leaves the captured area.
This request must not be sent more than once, or else the
duplicate_session protocol error is raised.
</description>
<arg name="session" type="new_id" interface="ext_image_copy_capture_session_v1"/>
</request>
<event name="enter">
<description summary="cursor entered captured area">
Sent when a cursor enters the captured area. It shall be generated
before the "position" and "hotspot" events when and only when a cursor
enters the area.
The cursor enters the captured area when the cursor image intersects
with the captured area. Note, this is different from e.g.
wl_pointer.enter.
</description>
</event>
<event name="leave">
<description summary="cursor left captured area">
Sent when a cursor leaves the captured area. No "position" or "hotspot"
event is generated for the cursor until the cursor enters the captured
area again.
</description>
</event>
<event name="position">
<description summary="position changed">
Cursors outside the image capture source do not get captured and no
event will be generated for them.
The given position is the position of the cursor's hotspot and it is
relative to the main buffer's top left corner in transformed buffer
pixel coordinates. The coordinates may be negative or greater than the
main buffer size.
</description>
<arg name="x" type="int" summary="position x coordinates"/>
<arg name="y" type="int" summary="position y coordinates"/>
</event>
<event name="hotspot">
<description summary="hotspot changed">
The hotspot describes the offset between the cursor image and the
position of the input device.
The given coordinates are the hotspot's offset from the origin in
buffer coordinates.
Clients should not apply the hotspot immediately: the hotspot becomes
effective when the next ext_image_copy_capture_frame_v1.ready event is received.
Compositors may delay this event until the client captures a new frame.
</description>
<arg name="x" type="int" summary="hotspot x coordinates"/>
<arg name="y" type="int" summary="hotspot y coordinates"/>
</event>
</interface>
</protocol>

333
tools/wlthumbs/wayland.go Normal file
View file

@ -0,0 +1,333 @@
package main
import (
"fmt"
"log"
"strings"
"github.com/mil-ad/wlthumbs/proto"
"github.com/rajveermalviya/go-wayland/wayland/client"
"golang.org/x/sys/unix"
)
type toplevel struct {
handle *proto.ExtForeignToplevelHandle
id, title, appID string
closed, done bool
}
type output struct {
wl *client.Output
name string
make_, model string
done bool
}
// source is a normalized capture target (window or monitor). captureOne fills
// the trailing fields with the captured frame.
type source struct {
typ string // "window" | "output"
id string
app string
title string
cap *proto.ExtImageCaptureSource
raw []byte // captured pixels (nil until/unless capture succeeds)
w, h uint32
transform uint32
swapRB bool
thumb string // path to the written PNG (empty if capture/encode failed)
}
// app holds the Wayland connection and the globals/objects we bind on it.
type app struct {
display *client.Display
registry *client.Registry
shm *client.Shm
copyMgr *proto.ExtImageCopyCaptureManager
toplevelSrcMgr *proto.ExtForeignToplevelImageCaptureSourceManager
outputSrcMgr *proto.ExtOutputImageCaptureSourceManager
toplevelList *proto.ExtForeignToplevelList
toplevels []*toplevel
outputs []*output
}
// connect opens the Wayland connection, binds the globals we need, and verifies
// the compositor supports the required capture protocols.
func (a *app) connect() error {
display, err := client.Connect("")
if err != nil {
return fmt.Errorf("connect to wayland: %w", err)
}
a.display = display
display.SetErrorHandler(func(e client.DisplayErrorEvent) {
log.Printf("display error: object=%d code=%d %q", e.ObjectId, e.Code, e.Message)
})
registry, err := display.GetRegistry()
if err != nil {
return fmt.Errorf("get registry: %w", err)
}
a.registry = registry
registry.SetGlobalHandler(a.handleGlobal)
a.roundtrip() // register globals
a.roundtrip() // deliver their initial events
var missing []string
if a.shm == nil {
missing = append(missing, "wl_shm")
}
if a.copyMgr == nil {
missing = append(missing, "ext_image_copy_capture_manager_v1")
}
if a.toplevelSrcMgr == nil {
missing = append(missing, "ext_foreign_toplevel_image_capture_source_manager_v1")
}
if len(missing) > 0 {
return fmt.Errorf("compositor is missing required protocols: %s", strings.Join(missing, ", "))
}
return nil
}
func (a *app) ctx() *client.Context { return a.display.Context() }
// bind is a spec-correct wl_registry.bind. go-wayland's Registry.Bind writes the
// string-length prefix as the *padded* length instead of len+1; older libwayland
// tolerated it, but newer libwayland rejects it with "invalid arguments". The
// proxy is already registered by its New* constructor, so we only marshal and
// send the request here.
func (a *app) bind(e client.RegistryGlobalEvent, p client.Proxy) {
iface := e.Interface
strLen := len(iface) + 1 // includes the null terminator
pad := strLen
if pad%4 != 0 {
pad += 4 - pad%4
}
const opcode = 0
bufLen := 8 + 4 + (4 + pad) + 4 + 4
buf := make([]byte, bufLen)
client.PutUint32(buf[0:4], a.registry.ID())
client.PutUint32(buf[4:8], uint32(bufLen<<16|opcode))
client.PutUint32(buf[8:12], e.Name)
client.PutString(buf[12:12+4+pad], iface, strLen)
o := 12 + 4 + pad
client.PutUint32(buf[o:o+4], e.Version)
client.PutUint32(buf[o+4:o+8], p.ID())
if err := a.ctx().WriteMsg(buf, nil); err != nil {
log.Fatalf("bind %s: %v", iface, err)
}
}
func (a *app) handleGlobal(e client.RegistryGlobalEvent) {
switch e.Interface {
case "wl_shm":
a.shm = client.NewShm(a.ctx())
a.bind(e, a.shm)
case "ext_image_copy_capture_manager_v1":
a.copyMgr = proto.NewExtImageCopyCaptureManager(a.ctx())
a.bind(e, a.copyMgr)
case "ext_foreign_toplevel_image_capture_source_manager_v1":
a.toplevelSrcMgr = proto.NewExtForeignToplevelImageCaptureSourceManager(a.ctx())
a.bind(e, a.toplevelSrcMgr)
case "ext_output_image_capture_source_manager_v1":
a.outputSrcMgr = proto.NewExtOutputImageCaptureSourceManager(a.ctx())
a.bind(e, a.outputSrcMgr)
case "ext_foreign_toplevel_list_v1":
a.toplevelList = proto.NewExtForeignToplevelList(a.ctx())
a.bind(e, a.toplevelList)
a.toplevelList.SetToplevelHandler(a.handleToplevel)
case "wl_output":
o := &output{wl: client.NewOutput(a.ctx())}
a.bind(e, o.wl)
a.outputs = append(a.outputs, o)
o.wl.SetGeometryHandler(func(e client.OutputGeometryEvent) { o.make_, o.model = e.Make, e.Model })
o.wl.SetNameHandler(func(e client.OutputNameEvent) { o.name = e.Name })
o.wl.SetDoneHandler(func(client.OutputDoneEvent) { o.done = true })
}
}
func (a *app) handleToplevel(e proto.ExtForeignToplevelListToplevelEvent) {
t := &toplevel{handle: e.Toplevel}
a.toplevels = append(a.toplevels, t)
t.handle.SetIdentifierHandler(func(e proto.ExtForeignToplevelHandleIdentifierEvent) { t.id = e.Identifier })
t.handle.SetTitleHandler(func(e proto.ExtForeignToplevelHandleTitleEvent) { t.title = e.Title })
t.handle.SetAppIdHandler(func(e proto.ExtForeignToplevelHandleAppIdEvent) { t.appID = e.AppId })
t.handle.SetClosedHandler(func(proto.ExtForeignToplevelHandleClosedEvent) { t.closed = true })
t.handle.SetDoneHandler(func(proto.ExtForeignToplevelHandleDoneEvent) { t.done = true })
}
// enumerate collects toplevels and outputs, round-tripping until each has
// delivered its full initial state (a "done" event) and no new ones appear.
func (a *app) enumerate() {
const maxRounds = 20 // safety bound; converges in 1-2 in practice
for range maxRounds {
before := len(a.toplevels)
a.roundtrip()
stable := len(a.toplevels) == before
allDone := true
for _, t := range a.toplevels {
if !t.done && !t.closed {
allDone = false
}
}
for _, o := range a.outputs {
if !o.done {
allDone = false
}
}
if stable && allDone {
return
}
}
}
// roundtrip flushes the connection and dispatches events until the compositor
// answers a sync callback, i.e. until everything queued so far is processed.
func (a *app) roundtrip() {
cb, err := a.display.Sync()
if err != nil {
log.Fatalf("sync: %v", err)
}
defer cb.Destroy()
done := false
cb.SetDoneHandler(func(client.CallbackDoneEvent) { done = true })
for !done {
if err := a.dispatch(); err != nil {
log.Fatalf("dispatch: %v", err)
}
}
}
// dispatch processes one event, tolerating events addressed to objects we have
// already destroyed. The compositor may have queued events for a session/frame
// before it saw our destroy request; go-wayland reports those as "unable find
// sender" but has still consumed the message, so it is safe to continue. A real
// connection failure (EOF, broken pipe) is returned so callers can abort.
func (a *app) dispatch() error {
err := a.ctx().Dispatch()
if err != nil && strings.Contains(err.Error(), "unable find sender") {
return nil
}
return err
}
// captureOne grabs a single frame for one source into an shm buffer and stores
// the raw pixels (and dimensions/transform/format) on the source. It returns
// false if the compositor could not satisfy the capture. All Wayland and mmap
// resources for this source are released before returning.
func (a *app) captureOne(s *source) bool {
session, err := a.copyMgr.CreateSession(s.cap, 0)
if err != nil {
log.Printf("create session %q: %v", s.id, err)
return false
}
defer session.Destroy()
defer s.cap.Destroy()
var w, h uint32
var formats []uint32
sessionDone := false
session.SetBufferSizeHandler(func(e proto.ExtImageCopyCaptureSessionBufferSizeEvent) { w, h = e.Width, e.Height })
session.SetShmFormatHandler(func(e proto.ExtImageCopyCaptureSessionShmFormatEvent) { formats = append(formats, e.Format) })
session.SetDoneHandler(func(proto.ExtImageCopyCaptureSessionDoneEvent) { sessionDone = true })
session.SetStoppedHandler(func(proto.ExtImageCopyCaptureSessionStoppedEvent) { sessionDone = true })
a.roundtrip()
if !sessionDone || w == 0 || h == 0 {
log.Printf("no buffer constraints for %q", s.id)
return false
}
format, swapRB, ok := chooseFormat(formats)
if !ok {
log.Printf("no supported shm format for %q (got %v)", s.id, formats)
return false
}
stride := int(w) * 4
size := stride * int(h)
fd, err := shmFile(size)
if err != nil {
log.Printf("shm file for %q: %v", s.id, err)
return false
}
defer unix.Close(fd)
data, err := unix.Mmap(fd, 0, size, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil {
log.Printf("mmap for %q: %v", s.id, err)
return false
}
defer unix.Munmap(data)
pool, err := a.shm.CreatePool(fd, int32(size))
if err != nil {
log.Printf("create pool for %q: %v", s.id, err)
return false
}
defer pool.Destroy()
buf, err := pool.CreateBuffer(0, int32(w), int32(h), int32(stride), uint32(format))
if err != nil {
log.Printf("create buffer for %q: %v", s.id, err)
return false
}
defer buf.Destroy()
frame, err := session.CreateFrame()
if err != nil {
log.Printf("create frame for %q: %v", s.id, err)
return false
}
defer frame.Destroy()
var tf uint32
ready, failed := false, false
frame.SetTransformHandler(func(e proto.ExtImageCopyCaptureFrameTransformEvent) { tf = e.Transform })
frame.SetReadyHandler(func(proto.ExtImageCopyCaptureFrameReadyEvent) { ready = true })
frame.SetFailedHandler(func(e proto.ExtImageCopyCaptureFrameFailedEvent) {
failed = true
log.Printf("capture failed for %q (reason=%d)", s.id, e.Reason)
})
if err := frame.AttachBuffer(buf); err != nil {
log.Printf("attach buffer for %q: %v", s.id, err)
return false
}
frame.DamageBuffer(0, 0, int32(w), int32(h))
if err := frame.Capture(); err != nil {
log.Printf("capture for %q: %v", s.id, err)
return false
}
for !ready && !failed {
if err := a.dispatch(); err != nil {
log.Printf("dispatch during capture %q: %v", s.id, err)
return false
}
}
if failed {
return false
}
// Copy the pixels out of the mapping before the deferred Munmap runs.
s.raw = make([]byte, size)
copy(s.raw, data)
s.w, s.h, s.transform, s.swapRB = w, h, tf, swapRB
return true
}
// shmFile returns an fd backed by an anonymous in-memory file of the given size.
func shmFile(size int) (int, error) {
fd, err := unix.MemfdCreate("wlthumbs", unix.MFD_CLOEXEC)
if err != nil {
return -1, fmt.Errorf("memfd_create: %w", err)
}
if err := unix.Ftruncate(fd, int64(size)); err != nil {
unix.Close(fd)
return -1, fmt.Errorf("ftruncate: %w", err)
}
return fd, nil
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB