diff --git a/docs/quickshell-dock-full-width-edge-20261111.md b/docs/quickshell-dock-full-width-edge-20261111.md new file mode 100644 index 000000000..5b16916ce --- /dev/null +++ b/docs/quickshell-dock-full-width-edge-20261111.md @@ -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. diff --git a/docs/quickshell-dock-layout-and-context-v1.md b/docs/quickshell-dock-layout-and-context-v1.md new file mode 100644 index 000000000..f20f26fae --- /dev/null +++ b/docs/quickshell-dock-layout-and-context-v1.md @@ -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. diff --git a/docs/quickshell-dock-window-previews-v1.md b/docs/quickshell-dock-window-previews-v1.md new file mode 100644 index 000000000..149335ab9 --- /dev/null +++ b/docs/quickshell-dock-window-previews-v1.md @@ -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é. diff --git a/docs/session-restore-v2.md b/docs/session-restore-v2.md index 635bfc0e2..8fc17790d 100644 --- a/docs/session-restore-v2.md +++ b/docs/session-restore-v2.md @@ -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, diff --git a/docs/session-v2-installed-app-catalog-v1.md b/docs/session-v2-installed-app-catalog-v1.md index 2fbffb849..7f36c72db 100644 --- a/docs/session-v2-installed-app-catalog-v1.md +++ b/docs/session-v2-installed-app-catalog-v1.md @@ -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) diff --git a/quickshell/.config/quickshell/labfy-sway/applications/FavoritesService.qml b/quickshell/.config/quickshell/labfy-sway/applications/FavoritesService.qml index 40fb64932..97f670f3d 100644 --- a/quickshell/.config/quickshell/labfy-sway/applications/FavoritesService.qml +++ b/quickshell/.config/quickshell/labfy-sway/applications/FavoritesService.qml @@ -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; diff --git a/quickshell/.config/quickshell/labfy-sway/applications/dock_separators.py b/quickshell/.config/quickshell/labfy-sway/applications/dock_separators.py new file mode 100644 index 000000000..fe74514ca --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/applications/dock_separators.py @@ -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) diff --git a/quickshell/.config/quickshell/labfy-sway/applications/shared.py b/quickshell/.config/quickshell/labfy-sway/applications/shared.py index ee6be78c9..9f36b3de5 100644 --- a/quickshell/.config/quickshell/labfy-sway/applications/shared.py +++ b/quickshell/.config/quickshell/labfy-sway/applications/shared.py @@ -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 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) + 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)) diff --git a/quickshell/.config/quickshell/labfy-sway/bin/check-updates.py b/quickshell/.config/quickshell/labfy-sway/bin/check-updates.py index 5a0b483d9..6fa36e11b 100644 --- a/quickshell/.config/quickshell/labfy-sway/bin/check-updates.py +++ b/quickshell/.config/quickshell/labfy-sway/bin/check-updates.py @@ -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() diff --git a/quickshell/.config/quickshell/labfy-sway/bin/dock-thumbnails.py b/quickshell/.config/quickshell/labfy-sway/bin/dock-thumbnails.py new file mode 100644 index 000000000..9e5b17fc2 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/bin/dock-thumbnails.py @@ -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:])) diff --git a/quickshell/.config/quickshell/labfy-sway/dock/Dock.qml b/quickshell/.config/quickshell/labfy-sway/dock/Dock.qml index 45c7359e3..ec90f2818 100644 --- a/quickshell/.config/quickshell/labfy-sway/dock/Dock.qml +++ b/quickshell/.config/quickshell/labfy-sway/dock/Dock.qml @@ -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,17 +426,23 @@ PanelWindow { onReleased: mouse => { if (moved) { const p = mapToItem(items, mouse.x, mouse.y); - let before = ""; - for (let i = 0; i < icons.count; ++i) { - const candidate = icons.itemAt(i); - if (candidate.modelData.pinned && candidate.modelData.id !== dock.draggingId - && p.x < candidate.x + candidate.width / 2) { - before = candidate.modelData.id; break; + 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); + if (candidate.modelData.pinned && candidate.modelData.id !== dock.draggingId + && p.x < candidate.x + candidate.width / 2) { + before = candidate.modelData.id; break; + } } + FavoritesService.move(dock.draggingId, before); } - 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(); } + } + } } } diff --git a/quickshell/.config/quickshell/labfy-sway/dock/DockAction.qml b/quickshell/.config/quickshell/labfy-sway/dock/DockAction.qml index 6d5bc649c..6ee8dfff8 100644 --- a/quickshell/.config/quickshell/labfy-sway/dock/DockAction.qml +++ b/quickshell/.config/quickshell/labfy-sway/dock/DockAction.qml @@ -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() } diff --git a/quickshell/.config/quickshell/labfy-sway/dock/DockModel.js b/quickshell/.config/quickshell/labfy-sway/dock/DockModel.js new file mode 100644 index 000000000..c9ef9b5b3 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/dock/DockModel.js @@ -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; +} diff --git a/quickshell/.config/quickshell/labfy-sway/dock/DockPreviewCard.qml b/quickshell/.config/quickshell/labfy-sway/dock/DockPreviewCard.qml new file mode 100644 index 000000000..3a9f2abff --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/dock/DockPreviewCard.qml @@ -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 + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/applications.py b/quickshell/.config/quickshell/labfy-sway/session_v2/applications.py index fd4feb6c5..1207865e4 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/applications.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/applications.py @@ -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. diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py b/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py index 49b695d09..d539bc2ba 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/executor.py @@ -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() diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/native_host.py b/quickshell/.config/quickshell/labfy-sway/session_v2/native_host.py index 181e52f91..21baa61c6 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/native_host.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/native_host.py @@ -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): diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v2.json b/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v2.json index 8418dfec1..fc69a7b9d 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v2.json +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/schema-v2.json @@ -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"] } + } } } ] diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py b/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py index 7c68df357..f09ca3932 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/schema.py @@ -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") diff --git a/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py b/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py index f89e58f96..73f2ecd3d 100644 --- a/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py +++ b/quickshell/.config/quickshell/labfy-sway/session_v2/snapshot.py @@ -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", []): diff --git a/sway/.config/sway/autostart b/sway/.config/sway/autostart index c0159857d..cf9d41ddd 100644 --- a/sway/.config/sway/autostart +++ b/sway/.config/sway/autostart @@ -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 diff --git a/sway/.config/sway/bind b/sway/.config/sway/bind index 837149876..484c3ad8d 100644 --- a/sway/.config/sway/bind +++ b/sway/.config/sway/bind @@ -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 diff --git a/systemd/.config/systemd/user/trainlog-syncd.service.d/90-capacity-recovery.conf b/systemd/.config/systemd/user/trainlog-syncd.service.d/90-capacity-recovery.conf new file mode 100644 index 000000000..14192722c --- /dev/null +++ b/systemd/.config/systemd/user/trainlog-syncd.service.d/90-capacity-recovery.conf @@ -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 diff --git a/systemd/.config/systemd/user/trainlog-web.service.d/90-capacity-recovery.conf b/systemd/.config/systemd/user/trainlog-web.service.d/90-capacity-recovery.conf new file mode 100644 index 000000000..fe1bd6cf6 --- /dev/null +++ b/systemd/.config/systemd/user/trainlog-web.service.d/90-capacity-recovery.conf @@ -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 diff --git a/systemd/.config/systemd/user/trainlog-web.service.d/95-brand-android-v1.conf b/systemd/.config/systemd/user/trainlog-web.service.d/95-brand-android-v1.conf new file mode 100644 index 000000000..52f566bc3 --- /dev/null +++ b/systemd/.config/systemd/user/trainlog-web.service.d/95-brand-android-v1.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=%h/.local/share/trainlog/rollouts/brand-android-v1-20261111/desktop/libexec/trainlog -w diff --git a/tests/dock/headless.py b/tests/dock/headless.py index 1414de75f..422e646e4 100644 --- a/tests/dock/headless.py +++ b/tests/dock/headless.py @@ -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']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) diff --git a/tests/dock/test_models.js b/tests/dock/test_models.js index 19f36fbe1..4400dd8da 100644 --- a/tests/dock/test_models.js +++ b/tests/dock/test_models.js @@ -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'); diff --git a/tests/dock/test_separators.py b/tests/dock/test_separators.py new file mode 100644 index 000000000..646fc00c6 --- /dev/null +++ b/tests/dock/test_separators.py @@ -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() diff --git a/tests/dock/test_shared.py b/tests/dock/test_shared.py index 7e4a429e0..6b0805ed9 100644 --- a/tests/dock/test_shared.py +++ b/tests/dock/test_shared.py @@ -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'), diff --git a/tests/dock/test_thumbnails.py b/tests/dock/test_thumbnails.py new file mode 100644 index 000000000..e25ae93b5 --- /dev/null +++ b/tests/dock/test_thumbnails.py @@ -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() diff --git a/tests/session_v2/test_bootstrap.py b/tests/session_v2/test_bootstrap.py index f7ab2a544..7a1c40592 100644 --- a/tests/session_v2/test_bootstrap.py +++ b/tests/session_v2/test_bootstrap.py @@ -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,12 +359,14 @@ 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)]) - self.assertEqual(code, 0) - self.assertEqual(answers, [{"result": "OK", "kind": "managed-webapp", - "runtime_token": CLASSIFY["runtime_token"], "con_id": 42}, - {"result": "CORRELATION_TIMEOUT"}]) - self.assert_no_observations() + 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}, + {"result": "CORRELATION_TIMEOUT"}]) + self.assert_no_observations() def test_classify_interleaved_browser_map_and_bootstrap_remain_compatible(self): code, answers = self.wire(frame(REQUEST) + frame(CLASSIFY) + frame(MAP) + frame(CLASSIFY), serve=True) diff --git a/tests/session_v2/test_startup_x11_stability.py b/tests/session_v2/test_startup_x11_stability.py new file mode 100644 index 000000000..fae9f2228 --- /dev/null +++ b/tests/session_v2/test_startup_x11_stability.py @@ -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() diff --git a/tests/session_v2/test_unary_layout.py b/tests/session_v2/test_unary_layout.py new file mode 100644 index 000000000..74f72cd1e --- /dev/null +++ b/tests/session_v2/test_unary_layout.py @@ -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() diff --git a/tests/session_v2/test_xwayland.py b/tests/session_v2/test_xwayland.py index e83bc1d26..0ae6e82a4 100644 --- a/tests/session_v2/test_xwayland.py +++ b/tests/session_v2/test_xwayland.py @@ -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", ())) - app = dict(application_id=spec.application_id, desktop_entry=spec.desktop_entry, - strategy=spec.strategy, expected_windows=1, managed_by=spec.managed_by, identity_provider=None) - self.assertEqual(catalog.validate(app), spec) - self.assertEqual(launch_count(app, 0), 0) - self.assertEqual(launch_count(app, 1), 0) - with self.assertRaisesRegex(Failure, "UNEXPECTED_EXTRA_WINDOW"): - launch_count(app, 2) - with self.assertRaisesRegex(Failure, "APPLICATION_UNSUPPORTED"): - catalog.classify({"app_id": "firefox.webapp-unregistered"}) + def test_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) + 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": identity}) def test_swap_accepts_exact_x11_target_and_rejects_identity_changes(self): for change in (None, "class", "pid"): diff --git a/tests/session_v2/unary_layout_runtime.py b/tests/session_v2/unary_layout_runtime.py new file mode 100644 index 000000000..e25141e64 --- /dev/null +++ b/tests/session_v2/unary_layout_runtime.py @@ -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() diff --git a/tests/session_v2/xwayland_runtime.py b/tests/session_v2/xwayland_runtime.py index 8d4a9b942..2402ec346 100644 --- a/tests/session_v2/xwayland_runtime.py +++ b/tests/session_v2/xwayland_runtime.py @@ -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"] diff --git a/tests/test_quickshell_updates.py b/tests/test_quickshell_updates.py index 99e3fb2ee..a08a8fd6f 100644 --- a/tests/test_quickshell_updates.py +++ b/tests/test_quickshell_updates.py @@ -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", "")): diff --git a/tools/build-labfy-wlthumbs.sh b/tools/build-labfy-wlthumbs.sh new file mode 100755 index 000000000..5cc1a169e --- /dev/null +++ b/tools/build-labfy-wlthumbs.sh @@ -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" diff --git a/tools/wlthumbs/LICENSE b/tools/wlthumbs/LICENSE new file mode 100644 index 000000000..9bd5e1b1c --- /dev/null +++ b/tools/wlthumbs/LICENSE @@ -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. diff --git a/tools/wlthumbs/README.LABFY.md b/tools/wlthumbs/README.LABFY.md new file mode 100644 index 000000000..6a2a5d7b6 --- /dev/null +++ b/tools/wlthumbs/README.LABFY.md @@ -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. diff --git a/tools/wlthumbs/README.md b/tools/wlthumbs/README.md new file mode 100644 index 000000000..e5bac6080 --- /dev/null +++ b/tools/wlthumbs/README.md @@ -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 ID THUMBPATH APP 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 `.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` diff --git a/tools/wlthumbs/generate.sh b/tools/wlthumbs/generate.sh new file mode 100755 index 000000000..88355deca --- /dev/null +++ b/tools/wlthumbs/generate.sh @@ -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() { # + 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 diff --git a/tools/wlthumbs/go.mod b/tools/wlthumbs/go.mod new file mode 100644 index 000000000..203ffa5b7 --- /dev/null +++ b/tools/wlthumbs/go.mod @@ -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 +) diff --git a/tools/wlthumbs/go.sum b/tools/wlthumbs/go.sum new file mode 100644 index 000000000..45b20aa61 --- /dev/null +++ b/tools/wlthumbs/go.sum @@ -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= diff --git a/tools/wlthumbs/image.go b/tools/wlthumbs/image.go new file mode 100644 index 000000000..92ad09e84 --- /dev/null +++ b/tools/wlthumbs/image.go @@ -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 +} diff --git a/tools/wlthumbs/main.go b/tools/wlthumbs/main.go new file mode 100644 index 000000000..d6b57621a --- /dev/null +++ b/tools/wlthumbs/main.go @@ -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 +} diff --git a/tools/wlthumbs/proto/ext_foreign_toplevel_list.go b/tools/wlthumbs/proto/ext_foreign_toplevel_list.go new file mode 100644 index 000000000..b25110db1 --- /dev/null +++ b/tools/wlthumbs/proto/ext_foreign_toplevel_list.go @@ -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) + } +} diff --git a/tools/wlthumbs/proto/ext_image_capture_source.go b/tools/wlthumbs/proto/ext_image_capture_source.go new file mode 100644 index 000000000..4771214c8 --- /dev/null +++ b/tools/wlthumbs/proto/ext_image_capture_source.go @@ -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 +} diff --git a/tools/wlthumbs/proto/ext_image_copy_capture.go b/tools/wlthumbs/proto/ext_image_copy_capture.go new file mode 100644 index 000000000..cb59fcb2a --- /dev/null +++ b/tools/wlthumbs/proto/ext_image_copy_capture.go @@ -0,0 +1,1083 @@ +// Generated by go-wayland-scanner +// https://github.com/rajveermalviya/go-wayland/cmd/go-wayland-scanner +// XML file : protocol/ext-image-copy-capture-v1.xml +// +// ext_image_copy_capture_v1 Protocol 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. + +package proto + +import "github.com/rajveermalviya/go-wayland/wayland/client" + +// ExtImageCopyCaptureManager : manager to inform clients and begin capturing +// +// This object is a manager which offers requests to start capturing from a +// source. +type ExtImageCopyCaptureManager struct { + client.BaseProxy +} + +// NewExtImageCopyCaptureManager : manager to inform clients and begin capturing +// +// This object is a manager which offers requests to start capturing from a +// source. +func NewExtImageCopyCaptureManager(ctx *client.Context) *ExtImageCopyCaptureManager { + extImageCopyCaptureManagerV1 := &ExtImageCopyCaptureManager{} + ctx.Register(extImageCopyCaptureManagerV1) + return extImageCopyCaptureManagerV1 +} + +// CreateSession : 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. +func (i *ExtImageCopyCaptureManager) CreateSession(source *ExtImageCaptureSource, options uint32) (*ExtImageCopyCaptureSession, error) { + session := NewExtImageCopyCaptureSession(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 4 + 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], session.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], source.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(options)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return session, err +} + +// CreatePointerCursorSession : capture the pointer cursor of an image capture source +// +// Create a cursor capturing session for the pointer of an image capture +// source. +func (i *ExtImageCopyCaptureManager) CreatePointerCursorSession(source *ExtImageCaptureSource, pointer *client.Pointer) (*ExtImageCopyCaptureCursorSession, error) { + session := NewExtImageCopyCaptureCursorSession(i.Context()) + const opcode = 1 + const _reqBufLen = 8 + 4 + 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], session.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], source.ID()) + l += 4 + client.PutUint32(_reqBuf[l:l+4], pointer.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return session, err +} + +// Destroy : destroy the manager +// +// Destroy the manager object. +// +// Other objects created via this interface are unaffected. +func (i *ExtImageCopyCaptureManager) Destroy() error { + defer i.Context().Unregister(i) + const opcode = 2 + 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 +} + +type ExtImageCopyCaptureManagerError uint32 + +// ExtImageCopyCaptureManagerError : +const ( + // ExtImageCopyCaptureManagerErrorInvalidOption : invalid option flag + ExtImageCopyCaptureManagerErrorInvalidOption ExtImageCopyCaptureManagerError = 1 +) + +func (e ExtImageCopyCaptureManagerError) Name() string { + switch e { + case ExtImageCopyCaptureManagerErrorInvalidOption: + return "invalid_option" + default: + return "" + } +} + +func (e ExtImageCopyCaptureManagerError) Value() string { + switch e { + case ExtImageCopyCaptureManagerErrorInvalidOption: + return "1" + default: + return "" + } +} + +func (e ExtImageCopyCaptureManagerError) String() string { + return e.Name() + "=" + e.Value() +} + +type ExtImageCopyCaptureManagerOptions uint32 + +// ExtImageCopyCaptureManagerOptions : +const ( + // ExtImageCopyCaptureManagerOptionsPaintCursors : paint cursors onto captured frames + ExtImageCopyCaptureManagerOptionsPaintCursors ExtImageCopyCaptureManagerOptions = 1 +) + +func (e ExtImageCopyCaptureManagerOptions) Name() string { + switch e { + case ExtImageCopyCaptureManagerOptionsPaintCursors: + return "paint_cursors" + default: + return "" + } +} + +func (e ExtImageCopyCaptureManagerOptions) Value() string { + switch e { + case ExtImageCopyCaptureManagerOptionsPaintCursors: + return "1" + default: + return "" + } +} + +func (e ExtImageCopyCaptureManagerOptions) String() string { + return e.Name() + "=" + e.Value() +} + +// ExtImageCopyCaptureSession : 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. +type ExtImageCopyCaptureSession struct { + client.BaseProxy + bufferSizeHandler ExtImageCopyCaptureSessionBufferSizeHandlerFunc + shmFormatHandler ExtImageCopyCaptureSessionShmFormatHandlerFunc + dmabufDeviceHandler ExtImageCopyCaptureSessionDmabufDeviceHandlerFunc + dmabufFormatHandler ExtImageCopyCaptureSessionDmabufFormatHandlerFunc + doneHandler ExtImageCopyCaptureSessionDoneHandlerFunc + stoppedHandler ExtImageCopyCaptureSessionStoppedHandlerFunc +} + +// NewExtImageCopyCaptureSession : 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. +func NewExtImageCopyCaptureSession(ctx *client.Context) *ExtImageCopyCaptureSession { + extImageCopyCaptureSessionV1 := &ExtImageCopyCaptureSession{} + ctx.Register(extImageCopyCaptureSessionV1) + return extImageCopyCaptureSessionV1 +} + +// CreateFrame : 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. +func (i *ExtImageCopyCaptureSession) CreateFrame() (*ExtImageCopyCaptureFrame, error) { + frame := NewExtImageCopyCaptureFrame(i.Context()) + const opcode = 0 + const _reqBufLen = 8 + 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], frame.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return frame, err +} + +// Destroy : 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. +func (i *ExtImageCopyCaptureSession) 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 +} + +type ExtImageCopyCaptureSessionError uint32 + +// ExtImageCopyCaptureSessionError : +const ( + // ExtImageCopyCaptureSessionErrorDuplicateFrame : create_frame sent before destroying previous frame + ExtImageCopyCaptureSessionErrorDuplicateFrame ExtImageCopyCaptureSessionError = 1 +) + +func (e ExtImageCopyCaptureSessionError) Name() string { + switch e { + case ExtImageCopyCaptureSessionErrorDuplicateFrame: + return "duplicate_frame" + default: + return "" + } +} + +func (e ExtImageCopyCaptureSessionError) Value() string { + switch e { + case ExtImageCopyCaptureSessionErrorDuplicateFrame: + return "1" + default: + return "" + } +} + +func (e ExtImageCopyCaptureSessionError) String() string { + return e.Name() + "=" + e.Value() +} + +// ExtImageCopyCaptureSessionBufferSizeEvent : image capture source dimensions +// +// Provides the dimensions of the source image in buffer pixel coordinates. +// +// The client must attach buffers that match this size. +type ExtImageCopyCaptureSessionBufferSizeEvent struct { + Width uint32 + Height uint32 +} +type ExtImageCopyCaptureSessionBufferSizeHandlerFunc func(ExtImageCopyCaptureSessionBufferSizeEvent) + +// SetBufferSizeHandler : sets handler for ExtImageCopyCaptureSessionBufferSizeEvent +func (i *ExtImageCopyCaptureSession) SetBufferSizeHandler(f ExtImageCopyCaptureSessionBufferSizeHandlerFunc) { + i.bufferSizeHandler = f +} + +// ExtImageCopyCaptureSessionShmFormatEvent : 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. +type ExtImageCopyCaptureSessionShmFormatEvent struct { + Format uint32 +} +type ExtImageCopyCaptureSessionShmFormatHandlerFunc func(ExtImageCopyCaptureSessionShmFormatEvent) + +// SetShmFormatHandler : sets handler for ExtImageCopyCaptureSessionShmFormatEvent +func (i *ExtImageCopyCaptureSession) SetShmFormatHandler(f ExtImageCopyCaptureSessionShmFormatHandlerFunc) { + i.shmFormatHandler = f +} + +// ExtImageCopyCaptureSessionDmabufDeviceEvent : 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. +type ExtImageCopyCaptureSessionDmabufDeviceEvent struct { + Device []byte +} +type ExtImageCopyCaptureSessionDmabufDeviceHandlerFunc func(ExtImageCopyCaptureSessionDmabufDeviceEvent) + +// SetDmabufDeviceHandler : sets handler for ExtImageCopyCaptureSessionDmabufDeviceEvent +func (i *ExtImageCopyCaptureSession) SetDmabufDeviceHandler(f ExtImageCopyCaptureSessionDmabufDeviceHandlerFunc) { + i.dmabufDeviceHandler = f +} + +// ExtImageCopyCaptureSessionDmabufFormatEvent : 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. +type ExtImageCopyCaptureSessionDmabufFormatEvent struct { + Format uint32 + Modifiers []byte +} +type ExtImageCopyCaptureSessionDmabufFormatHandlerFunc func(ExtImageCopyCaptureSessionDmabufFormatEvent) + +// SetDmabufFormatHandler : sets handler for ExtImageCopyCaptureSessionDmabufFormatEvent +func (i *ExtImageCopyCaptureSession) SetDmabufFormatHandler(f ExtImageCopyCaptureSessionDmabufFormatHandlerFunc) { + i.dmabufFormatHandler = f +} + +// ExtImageCopyCaptureSessionDoneEvent : 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. +type ExtImageCopyCaptureSessionDoneEvent struct{} +type ExtImageCopyCaptureSessionDoneHandlerFunc func(ExtImageCopyCaptureSessionDoneEvent) + +// SetDoneHandler : sets handler for ExtImageCopyCaptureSessionDoneEvent +func (i *ExtImageCopyCaptureSession) SetDoneHandler(f ExtImageCopyCaptureSessionDoneHandlerFunc) { + i.doneHandler = f +} + +// ExtImageCopyCaptureSessionStoppedEvent : 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. +type ExtImageCopyCaptureSessionStoppedEvent struct{} +type ExtImageCopyCaptureSessionStoppedHandlerFunc func(ExtImageCopyCaptureSessionStoppedEvent) + +// SetStoppedHandler : sets handler for ExtImageCopyCaptureSessionStoppedEvent +func (i *ExtImageCopyCaptureSession) SetStoppedHandler(f ExtImageCopyCaptureSessionStoppedHandlerFunc) { + i.stoppedHandler = f +} + +func (i *ExtImageCopyCaptureSession) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.bufferSizeHandler == nil { + return + } + var e ExtImageCopyCaptureSessionBufferSizeEvent + l := 0 + e.Width = client.Uint32(data[l : l+4]) + l += 4 + e.Height = client.Uint32(data[l : l+4]) + l += 4 + + i.bufferSizeHandler(e) + case 1: + if i.shmFormatHandler == nil { + return + } + var e ExtImageCopyCaptureSessionShmFormatEvent + l := 0 + e.Format = client.Uint32(data[l : l+4]) + l += 4 + + i.shmFormatHandler(e) + case 2: + if i.dmabufDeviceHandler == nil { + return + } + var e ExtImageCopyCaptureSessionDmabufDeviceEvent + l := 0 + deviceLen := int(client.Uint32(data[l : l+4])) + l += 4 + e.Device = make([]byte, deviceLen) + copy(e.Device, data[l:l+deviceLen]) + l += deviceLen + + i.dmabufDeviceHandler(e) + case 3: + if i.dmabufFormatHandler == nil { + return + } + var e ExtImageCopyCaptureSessionDmabufFormatEvent + l := 0 + e.Format = client.Uint32(data[l : l+4]) + l += 4 + modifiersLen := int(client.Uint32(data[l : l+4])) + l += 4 + e.Modifiers = make([]byte, modifiersLen) + copy(e.Modifiers, data[l:l+modifiersLen]) + l += modifiersLen + + i.dmabufFormatHandler(e) + case 4: + if i.doneHandler == nil { + return + } + var e ExtImageCopyCaptureSessionDoneEvent + + i.doneHandler(e) + case 5: + if i.stoppedHandler == nil { + return + } + var e ExtImageCopyCaptureSessionStoppedEvent + + i.stoppedHandler(e) + } +} + +// ExtImageCopyCaptureFrame : 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. +type ExtImageCopyCaptureFrame struct { + client.BaseProxy + transformHandler ExtImageCopyCaptureFrameTransformHandlerFunc + damageHandler ExtImageCopyCaptureFrameDamageHandlerFunc + presentationTimeHandler ExtImageCopyCaptureFramePresentationTimeHandlerFunc + readyHandler ExtImageCopyCaptureFrameReadyHandlerFunc + failedHandler ExtImageCopyCaptureFrameFailedHandlerFunc +} + +// NewExtImageCopyCaptureFrame : 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. +func NewExtImageCopyCaptureFrame(ctx *client.Context) *ExtImageCopyCaptureFrame { + extImageCopyCaptureFrameV1 := &ExtImageCopyCaptureFrame{} + ctx.Register(extImageCopyCaptureFrameV1) + return extImageCopyCaptureFrameV1 +} + +// Destroy : destroy this object +// +// Destroys the frame. This request can be sent at any time by the +// client. +func (i *ExtImageCopyCaptureFrame) 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 +} + +// AttachBuffer : 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. +func (i *ExtImageCopyCaptureFrame) AttachBuffer(buffer *client.Buffer) error { + const opcode = 1 + const _reqBufLen = 8 + 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], buffer.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// DamageBuffer : 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. +// +// x: region x coordinate +// y: region y coordinate +// width: region width +// height: region height +func (i *ExtImageCopyCaptureFrame) DamageBuffer(x, y, width, height int32) error { + const opcode = 2 + const _reqBufLen = 8 + 4 + 4 + 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], uint32(x)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(y)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(width)) + l += 4 + client.PutUint32(_reqBuf[l:l+4], uint32(height)) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return err +} + +// Capture : 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. +func (i *ExtImageCopyCaptureFrame) Capture() error { + const opcode = 3 + 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 +} + +type ExtImageCopyCaptureFrameError uint32 + +// ExtImageCopyCaptureFrameError : +const ( + // ExtImageCopyCaptureFrameErrorNoBuffer : capture sent without attach_buffer + ExtImageCopyCaptureFrameErrorNoBuffer ExtImageCopyCaptureFrameError = 1 + // ExtImageCopyCaptureFrameErrorInvalidBufferDamage : invalid buffer damage + ExtImageCopyCaptureFrameErrorInvalidBufferDamage ExtImageCopyCaptureFrameError = 2 + // ExtImageCopyCaptureFrameErrorAlreadyCaptured : capture request has been sent + ExtImageCopyCaptureFrameErrorAlreadyCaptured ExtImageCopyCaptureFrameError = 3 +) + +func (e ExtImageCopyCaptureFrameError) Name() string { + switch e { + case ExtImageCopyCaptureFrameErrorNoBuffer: + return "no_buffer" + case ExtImageCopyCaptureFrameErrorInvalidBufferDamage: + return "invalid_buffer_damage" + case ExtImageCopyCaptureFrameErrorAlreadyCaptured: + return "already_captured" + default: + return "" + } +} + +func (e ExtImageCopyCaptureFrameError) Value() string { + switch e { + case ExtImageCopyCaptureFrameErrorNoBuffer: + return "1" + case ExtImageCopyCaptureFrameErrorInvalidBufferDamage: + return "2" + case ExtImageCopyCaptureFrameErrorAlreadyCaptured: + return "3" + default: + return "" + } +} + +func (e ExtImageCopyCaptureFrameError) String() string { + return e.Name() + "=" + e.Value() +} + +type ExtImageCopyCaptureFrameFailureReason uint32 + +// ExtImageCopyCaptureFrameFailureReason : +const ( + ExtImageCopyCaptureFrameFailureReasonUnknown ExtImageCopyCaptureFrameFailureReason = 0 + ExtImageCopyCaptureFrameFailureReasonBufferConstraints ExtImageCopyCaptureFrameFailureReason = 1 + ExtImageCopyCaptureFrameFailureReasonStopped ExtImageCopyCaptureFrameFailureReason = 2 +) + +func (e ExtImageCopyCaptureFrameFailureReason) Name() string { + switch e { + case ExtImageCopyCaptureFrameFailureReasonUnknown: + return "unknown" + case ExtImageCopyCaptureFrameFailureReasonBufferConstraints: + return "buffer_constraints" + case ExtImageCopyCaptureFrameFailureReasonStopped: + return "stopped" + default: + return "" + } +} + +func (e ExtImageCopyCaptureFrameFailureReason) Value() string { + switch e { + case ExtImageCopyCaptureFrameFailureReasonUnknown: + return "0" + case ExtImageCopyCaptureFrameFailureReasonBufferConstraints: + return "1" + case ExtImageCopyCaptureFrameFailureReasonStopped: + return "2" + default: + return "" + } +} + +func (e ExtImageCopyCaptureFrameFailureReason) String() string { + return e.Name() + "=" + e.Value() +} + +// ExtImageCopyCaptureFrameTransformEvent : buffer transform +// +// This event is sent before the ready event and holds the transform that +// the compositor has applied to the buffer contents. +type ExtImageCopyCaptureFrameTransformEvent struct { + Transform uint32 +} +type ExtImageCopyCaptureFrameTransformHandlerFunc func(ExtImageCopyCaptureFrameTransformEvent) + +// SetTransformHandler : sets handler for ExtImageCopyCaptureFrameTransformEvent +func (i *ExtImageCopyCaptureFrame) SetTransformHandler(f ExtImageCopyCaptureFrameTransformHandlerFunc) { + i.transformHandler = f +} + +// ExtImageCopyCaptureFrameDamageEvent : 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. +type ExtImageCopyCaptureFrameDamageEvent struct { + X int32 + Y int32 + Width int32 + Height int32 +} +type ExtImageCopyCaptureFrameDamageHandlerFunc func(ExtImageCopyCaptureFrameDamageEvent) + +// SetDamageHandler : sets handler for ExtImageCopyCaptureFrameDamageEvent +func (i *ExtImageCopyCaptureFrame) SetDamageHandler(f ExtImageCopyCaptureFrameDamageHandlerFunc) { + i.damageHandler = f +} + +// ExtImageCopyCaptureFramePresentationTimeEvent : 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]. +type ExtImageCopyCaptureFramePresentationTimeEvent struct { + TvSecHi uint32 + TvSecLo uint32 + TvNsec uint32 +} +type ExtImageCopyCaptureFramePresentationTimeHandlerFunc func(ExtImageCopyCaptureFramePresentationTimeEvent) + +// SetPresentationTimeHandler : sets handler for ExtImageCopyCaptureFramePresentationTimeEvent +func (i *ExtImageCopyCaptureFrame) SetPresentationTimeHandler(f ExtImageCopyCaptureFramePresentationTimeHandlerFunc) { + i.presentationTimeHandler = f +} + +// ExtImageCopyCaptureFrameReadyEvent : 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. +type ExtImageCopyCaptureFrameReadyEvent struct{} +type ExtImageCopyCaptureFrameReadyHandlerFunc func(ExtImageCopyCaptureFrameReadyEvent) + +// SetReadyHandler : sets handler for ExtImageCopyCaptureFrameReadyEvent +func (i *ExtImageCopyCaptureFrame) SetReadyHandler(f ExtImageCopyCaptureFrameReadyHandlerFunc) { + i.readyHandler = f +} + +// ExtImageCopyCaptureFrameFailedEvent : capture failed +// +// This event indicates that the attempted frame copy has failed. +// +// After receiving this event, the client must destroy the object. +type ExtImageCopyCaptureFrameFailedEvent struct { + Reason uint32 +} +type ExtImageCopyCaptureFrameFailedHandlerFunc func(ExtImageCopyCaptureFrameFailedEvent) + +// SetFailedHandler : sets handler for ExtImageCopyCaptureFrameFailedEvent +func (i *ExtImageCopyCaptureFrame) SetFailedHandler(f ExtImageCopyCaptureFrameFailedHandlerFunc) { + i.failedHandler = f +} + +func (i *ExtImageCopyCaptureFrame) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.transformHandler == nil { + return + } + var e ExtImageCopyCaptureFrameTransformEvent + l := 0 + e.Transform = client.Uint32(data[l : l+4]) + l += 4 + + i.transformHandler(e) + case 1: + if i.damageHandler == nil { + return + } + var e ExtImageCopyCaptureFrameDamageEvent + l := 0 + e.X = int32(client.Uint32(data[l : l+4])) + l += 4 + e.Y = int32(client.Uint32(data[l : l+4])) + l += 4 + e.Width = int32(client.Uint32(data[l : l+4])) + l += 4 + e.Height = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.damageHandler(e) + case 2: + if i.presentationTimeHandler == nil { + return + } + var e ExtImageCopyCaptureFramePresentationTimeEvent + l := 0 + e.TvSecHi = client.Uint32(data[l : l+4]) + l += 4 + e.TvSecLo = client.Uint32(data[l : l+4]) + l += 4 + e.TvNsec = client.Uint32(data[l : l+4]) + l += 4 + + i.presentationTimeHandler(e) + case 3: + if i.readyHandler == nil { + return + } + var e ExtImageCopyCaptureFrameReadyEvent + + i.readyHandler(e) + case 4: + if i.failedHandler == nil { + return + } + var e ExtImageCopyCaptureFrameFailedEvent + l := 0 + e.Reason = client.Uint32(data[l : l+4]) + l += 4 + + i.failedHandler(e) + } +} + +// ExtImageCopyCaptureCursorSession : cursor capture session +// +// This object represents a cursor capture session. It extends the base +// capture session with cursor-specific metadata. +type ExtImageCopyCaptureCursorSession struct { + client.BaseProxy + enterHandler ExtImageCopyCaptureCursorSessionEnterHandlerFunc + leaveHandler ExtImageCopyCaptureCursorSessionLeaveHandlerFunc + positionHandler ExtImageCopyCaptureCursorSessionPositionHandlerFunc + hotspotHandler ExtImageCopyCaptureCursorSessionHotspotHandlerFunc +} + +// NewExtImageCopyCaptureCursorSession : cursor capture session +// +// This object represents a cursor capture session. It extends the base +// capture session with cursor-specific metadata. +func NewExtImageCopyCaptureCursorSession(ctx *client.Context) *ExtImageCopyCaptureCursorSession { + extImageCopyCaptureCursorSessionV1 := &ExtImageCopyCaptureCursorSession{} + ctx.Register(extImageCopyCaptureCursorSessionV1) + return extImageCopyCaptureCursorSessionV1 +} + +// Destroy : 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. +func (i *ExtImageCopyCaptureCursorSession) 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 +} + +// GetCaptureSession : 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. +func (i *ExtImageCopyCaptureCursorSession) GetCaptureSession() (*ExtImageCopyCaptureSession, error) { + session := NewExtImageCopyCaptureSession(i.Context()) + const opcode = 1 + const _reqBufLen = 8 + 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], session.ID()) + l += 4 + err := i.Context().WriteMsg(_reqBuf[:], nil) + return session, err +} + +type ExtImageCopyCaptureCursorSessionError uint32 + +// ExtImageCopyCaptureCursorSessionError : +const ( + // ExtImageCopyCaptureCursorSessionErrorDuplicateSession : get_capture_session sent twice + ExtImageCopyCaptureCursorSessionErrorDuplicateSession ExtImageCopyCaptureCursorSessionError = 1 +) + +func (e ExtImageCopyCaptureCursorSessionError) Name() string { + switch e { + case ExtImageCopyCaptureCursorSessionErrorDuplicateSession: + return "duplicate_session" + default: + return "" + } +} + +func (e ExtImageCopyCaptureCursorSessionError) Value() string { + switch e { + case ExtImageCopyCaptureCursorSessionErrorDuplicateSession: + return "1" + default: + return "" + } +} + +func (e ExtImageCopyCaptureCursorSessionError) String() string { + return e.Name() + "=" + e.Value() +} + +// ExtImageCopyCaptureCursorSessionEnterEvent : 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. +type ExtImageCopyCaptureCursorSessionEnterEvent struct{} +type ExtImageCopyCaptureCursorSessionEnterHandlerFunc func(ExtImageCopyCaptureCursorSessionEnterEvent) + +// SetEnterHandler : sets handler for ExtImageCopyCaptureCursorSessionEnterEvent +func (i *ExtImageCopyCaptureCursorSession) SetEnterHandler(f ExtImageCopyCaptureCursorSessionEnterHandlerFunc) { + i.enterHandler = f +} + +// ExtImageCopyCaptureCursorSessionLeaveEvent : 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. +type ExtImageCopyCaptureCursorSessionLeaveEvent struct{} +type ExtImageCopyCaptureCursorSessionLeaveHandlerFunc func(ExtImageCopyCaptureCursorSessionLeaveEvent) + +// SetLeaveHandler : sets handler for ExtImageCopyCaptureCursorSessionLeaveEvent +func (i *ExtImageCopyCaptureCursorSession) SetLeaveHandler(f ExtImageCopyCaptureCursorSessionLeaveHandlerFunc) { + i.leaveHandler = f +} + +// ExtImageCopyCaptureCursorSessionPositionEvent : 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. +type ExtImageCopyCaptureCursorSessionPositionEvent struct { + X int32 + Y int32 +} +type ExtImageCopyCaptureCursorSessionPositionHandlerFunc func(ExtImageCopyCaptureCursorSessionPositionEvent) + +// SetPositionHandler : sets handler for ExtImageCopyCaptureCursorSessionPositionEvent +func (i *ExtImageCopyCaptureCursorSession) SetPositionHandler(f ExtImageCopyCaptureCursorSessionPositionHandlerFunc) { + i.positionHandler = f +} + +// ExtImageCopyCaptureCursorSessionHotspotEvent : 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. +type ExtImageCopyCaptureCursorSessionHotspotEvent struct { + X int32 + Y int32 +} +type ExtImageCopyCaptureCursorSessionHotspotHandlerFunc func(ExtImageCopyCaptureCursorSessionHotspotEvent) + +// SetHotspotHandler : sets handler for ExtImageCopyCaptureCursorSessionHotspotEvent +func (i *ExtImageCopyCaptureCursorSession) SetHotspotHandler(f ExtImageCopyCaptureCursorSessionHotspotHandlerFunc) { + i.hotspotHandler = f +} + +func (i *ExtImageCopyCaptureCursorSession) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if i.enterHandler == nil { + return + } + var e ExtImageCopyCaptureCursorSessionEnterEvent + + i.enterHandler(e) + case 1: + if i.leaveHandler == nil { + return + } + var e ExtImageCopyCaptureCursorSessionLeaveEvent + + i.leaveHandler(e) + case 2: + if i.positionHandler == nil { + return + } + var e ExtImageCopyCaptureCursorSessionPositionEvent + l := 0 + e.X = int32(client.Uint32(data[l : l+4])) + l += 4 + e.Y = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.positionHandler(e) + case 3: + if i.hotspotHandler == nil { + return + } + var e ExtImageCopyCaptureCursorSessionHotspotEvent + l := 0 + e.X = int32(client.Uint32(data[l : l+4])) + l += 4 + e.Y = int32(client.Uint32(data[l : l+4])) + l += 4 + + i.hotspotHandler(e) + } +} diff --git a/tools/wlthumbs/proto/register.go b/tools/wlthumbs/proto/register.go new file mode 100644 index 000000000..ef14c5f24 --- /dev/null +++ b/tools/wlthumbs/proto/register.go @@ -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)) +} diff --git a/tools/wlthumbs/protocol/ext-foreign-toplevel-list-v1.xml b/tools/wlthumbs/protocol/ext-foreign-toplevel-list-v1.xml new file mode 100644 index 000000000..11b0113a9 --- /dev/null +++ b/tools/wlthumbs/protocol/ext-foreign-toplevel-list-v1.xml @@ -0,0 +1,219 @@ + + + + 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. + + + + 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. + + + + + 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. + + + + + 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. + + + + + + + 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. + + + + + + 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. + + + + + + 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. + + + + + + + A ext_foreign_toplevel_handle_v1 object represents a mapped toplevel + window. A single app may have multiple mapped toplevels. + + + + + 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. + + + + + + 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. + + + + + + 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. + + + + + + The title of the toplevel has changed. + + The configured state must not be applied immediately. See + ext_foreign_toplevel_handle_v1.done for details. + + + + + + + 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. + + + + + + + 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. + + + + + diff --git a/tools/wlthumbs/protocol/ext-image-capture-source-v1.xml b/tools/wlthumbs/protocol/ext-image-capture-source-v1.xml new file mode 100644 index 000000000..b31a5da3e --- /dev/null +++ b/tools/wlthumbs/protocol/ext-image-capture-source-v1.xml @@ -0,0 +1,109 @@ + + + + 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. + + + + 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. + + + + + 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. + + + + + Destroys the image capture source. This request may be sent at any time + by the client. + + + + + + + A manager for creating image capture source objects for wl_output objects. + + + + + 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. + + + + + + + + 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. + + + + + + + A manager for creating image capture source objects for + ext_foreign_toplevel_handle_v1 objects. + + + + + Creates a source object for a foreign toplevel handle. Images captured + from this source will show the same content as the toplevel. + + + + + + + + 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. + + + + diff --git a/tools/wlthumbs/protocol/ext-image-copy-capture-v1.xml b/tools/wlthumbs/protocol/ext-image-copy-capture-v1.xml new file mode 100644 index 000000000..1ac43696a --- /dev/null +++ b/tools/wlthumbs/protocol/ext-image-copy-capture-v1.xml @@ -0,0 +1,456 @@ + + + + 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. + + + + 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. + + + + + This object is a manager which offers requests to start capturing from a + 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. + + + + + + + + + Create a cursor capturing session for the pointer of an image capture + source. + + + + + + + + + Destroy the manager object. + + Other objects created via this interface are unaffected. + + + + + + + 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. + + + + + + + + + Provides the dimensions of the source image in buffer pixel coordinates. + + The client must attach buffers that match this size. + + + + + + + + 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. + + + + + + + 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. + + + + + + + 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. + + + + + + + + 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. + + + + + + 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. + + + + + + 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. + + + + + + + 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. + + + + + + + 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. + + + + + + + + + + + Destroys the frame. This request can be sent at any time by the + client. + + + + + + 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. + + + + + + + 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. + + + + + + + + + + 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. + + + + + + This event is sent before the ready event and holds the transform that + the compositor has applied to the buffer contents. + + + + + + + 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. + + + + + + + + + + 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]. + + + + + + + + + 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. + + + + + + + An unspecified runtime error has occurred. The client may retry. + + + + + The buffer submitted by the client doesn't match the latest session + constraints. The client should re-allocate its buffers and retry. + + + + + The session has stopped. See ext_image_copy_capture_session_v1.stopped. + + + + + + + This event indicates that the attempted frame copy has failed. + + After receiving this event, the client must destroy the object. + + + + + + + + This object represents a cursor capture session. It extends the base + capture session with cursor-specific metadata. + + + + + + + + + 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. + + + + + + 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. + + + + + + + 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. + + + + + + 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. + + + + + + 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. + + + + + + + + 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. + + + + + + diff --git a/tools/wlthumbs/wayland.go b/tools/wlthumbs/wayland.go new file mode 100644 index 000000000..be7765af0 --- /dev/null +++ b/tools/wlthumbs/wayland.go @@ -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 +} diff --git a/wallpapers/.wallpapers/BookOfMormon.jpg b/wallpapers/.wallpapers/BookOfMormon.jpg new file mode 100644 index 000000000..9c78c0737 Binary files /dev/null and b/wallpapers/.wallpapers/BookOfMormon.jpg differ